2 Коммиты 970f7ad303 ... e5c21fff15

Автор SHA1 Сообщение Дата
  Q e5c21fff15 fix(academic): AC-13b 回执——端点25 错误码 3140→2002 + 端点22/24 补活动级 money 17 часов назад
  Q aaacdc0a1f feat(academic): AC-13b 活动 Handler(端点 22~37) 18 часов назад

+ 897 - 0
internal/academic/handler/activity_handler.go

@@ -0,0 +1,897 @@
+// 活动域 HTTP 处理器(端点 22~37,共 16 个)。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §2.2(端点表)/ §3.4 B(字段级定义);
+// unified/03-migration/academic-domain-design.md §4.2(端点清单与角色)/ §10.1(31xx 错误码)/
+// §3.2 #12-#20(SP 映射)。
+//
+// ---------------------------------------------------------------------------
+// 端点 → SP / 服务方法对照(**每个方法头都重复写一次,便于单点核对**)
+// ---------------------------------------------------------------------------
+//
+//	22 GET    /admin/activities                            列表      dump: 无(PHP 直查)     ActivityAdminService.ListActivities
+//	23 POST   /admin/activities                            新建      dump: 无(PHP 直写)     ActivityAdminService.CreateActivity
+//	24 GET    /admin/activities/:id                        详情      dump: 无(PHP 直查)     ActivityAdminService.GetActivity
+//	25 PUT    /admin/activities/:id                        更新      dump: 无(PHP 直写)     ActivityAdminService.UpdateActivity
+//	26 POST   /admin/activities/:id/sign                   报名      SP19 dump 762910        ActivityService.Sign
+//	27 POST   /admin/activities/:id/cancel-sign            取消报名  SP20 dump 762982        ActivityService.CancelSign
+//	28 GET    /admin/activities/:id/signeds                报名列表  dump: 无(PHP 直查)     ActivityAdminService.ListSigneds
+//	29 POST   /admin/activities/:id/recommend-settle       老带新    SP18 dump 763309        RecommendService.SettleRecommend
+//	30 POST   /admin/activities/:id/cancel-recharge        取消充值  SP8  dump 763935        RecommendService.CancelRechargeActivity
+//	31 GET    /admin/activities/discounts/usable           可用赠送  SP14 dump 763176        ActivityDiscountService.UsableBreakdown
+//	32 POST   /admin/activities/discounts/use              用赠送    SP15 dump 763204        ActivityDiscountService.UseVoucher
+//	33 POST   /admin/activities/discounts/refund           赠送退回  SP16 dump 763046        ActivityDiscountService.RefundByStuCourse
+//	34 POST   /admin/activities/discounts/refund-by-activity 按活动退 SP17 dump 763106       ActivityDiscountService.RefundByActivity
+//	35 POST   /admin/activities/recharge-gifts/use         充值赠送用 SP12 dump 763256       ActivityDiscountService.UseRechargeGift
+//	36 POST   /admin/activities/recharge-gifts/refund      充值赠送退 SP13 dump 762841       ActivityDiscountService.RefundRechargeGift
+//	37 POST   /admin/activities/:id/visits                 回访      dump: 无(visit 空表)    ActivityAdminService.RegisterVisit
+//
+// ---------------------------------------------------------------------------
+// ⚠ 路由注册顺序(AC-14 接线时必须遵守,否则本条 16 个端点会互相吃掉)
+// ---------------------------------------------------------------------------
+//
+// Fiber 的路由是**按注册顺序**匹配的。`/admin/activities/:id` 会把 `discounts`、`recharge-gifts`
+// 当成 id 吃掉。因此 **静态段必须全部先注册**,再注册带 `:id` 的:
+//
+//	① /admin/activities                    (GET/POST)
+//	② /admin/activities/discounts/usable            (GET)
+//	③ /admin/activities/discounts/use               (POST)
+//	④ /admin/activities/discounts/refund            (POST)
+//	⑤ /admin/activities/discounts/refund-by-activity(POST)
+//	⑥ /admin/activities/recharge-gifts/use          (POST)
+//	⑦ /admin/activities/recharge-gifts/refund       (POST)
+//	⑧ /admin/activities/:id                (GET/PUT)
+//	⑨ /admin/activities/:id/sign … /visits  (POST/GET)
+//
+// 本文件因此把 `PathActivity*` 常量按"静态优先"顺序声明,测试里也按同一顺序注册
+// (`TestActivityRouteCoverage` 会断言顺序敏感的两条路由都能命中各自 handler)。
+//
+// ---------------------------------------------------------------------------
+// 错误码分工(handler 层只看得到三类)
+// ---------------------------------------------------------------------------
+//
+//	2001  缺参/类型不符 —— 由 `validateReq`(`validator.Validate`)产生,**本文件不手写**
+//	2002  传了但非法 —— 金额 Parse 失败 / 枚举越界 / 路径 id 非数字(`parseEnumQuery`、`parseMoney`)
+//	1003/1004 越权 —— 端点 31 的"平台视角"角色白名单(1003)与跨机构 institutionId(1004)
+//
+// 其余(3001/3104/3105/3106/3107/3108/3109/3121/3140/5002)一律由 Service/Repository 产出,
+// handler **原样透传**(`response.Fail`),不做码值翻译——翻译一层就会有两份口径。
+package handler
+
+import (
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+
+	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/service"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/infra/response"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// ---------------------------------------------------------------- 路径常量(**不含 `/api`**)
+//
+// 声明顺序 = 注册顺序(静态段在前),理由见文件头「路由注册顺序」。
+const (
+	// PathActivities 端点 22/23:活动列表 / 新建(`/admin/activities`)。
+	PathActivities = "/admin/activities"
+	// PathActivityDiscountsUsable 端点 31:可用赠送(`GET /admin/activities/discounts/usable`)。
+	PathActivityDiscountsUsable = "/admin/activities/discounts/usable"
+	// PathActivityDiscountsUse 端点 32:使用赠送(`POST /admin/activities/discounts/use`)。
+	PathActivityDiscountsUse = "/admin/activities/discounts/use"
+	// PathActivityDiscountsRefund 端点 33:赠送退回(`POST /admin/activities/discounts/refund`)。
+	PathActivityDiscountsRefund = "/admin/activities/discounts/refund"
+	// PathActivityDiscountsRefundByActivity 端点 34:按活动退回(`.../discounts/refund-by-activity`)。
+	PathActivityDiscountsRefundByActivity = "/admin/activities/discounts/refund-by-activity"
+	// PathActivityRechargeGiftsUse 端点 35:充值赠送使用(`.../recharge-gifts/use`)。
+	PathActivityRechargeGiftsUse = "/admin/activities/recharge-gifts/use"
+	// PathActivityRechargeGiftsRefund 端点 36:充值赠送退回(`.../recharge-gifts/refund`)。
+	PathActivityRechargeGiftsRefund = "/admin/activities/recharge-gifts/refund"
+	// PathActivityDetail 端点 24/25:详情 / 更新(`/admin/activities/:id`)。
+	PathActivityDetail = "/admin/activities/:id"
+	// PathActivitySign 端点 26:报名(`/admin/activities/:id/sign`)。
+	PathActivitySign = "/admin/activities/:id/sign"
+	// PathActivityCancelSign 端点 27:取消报名(`/admin/activities/:id/cancel-sign`)。
+	PathActivityCancelSign = "/admin/activities/:id/cancel-sign"
+	// PathActivitySigneds 端点 28:报名列表(`/admin/activities/:id/signeds`)。
+	PathActivitySigneds = "/admin/activities/:id/signeds"
+	// PathActivityRecommendSettle 端点 29:老带新结算(`/admin/activities/:id/recommend-settle`)。
+	PathActivityRecommendSettle = "/admin/activities/:id/recommend-settle"
+	// PathActivityCancelRecharge 端点 30:取消充值活动(`/admin/activities/:id/cancel-recharge`)。
+	PathActivityCancelRecharge = "/admin/activities/:id/cancel-recharge"
+	// PathActivityVisits 端点 37:回访登记(`/admin/activities/:id/visits`)。
+	PathActivityVisits = "/admin/activities/:id/visits"
+)
+
+// ---------------------------------------------------------------- 枚举白名单(2002 的唯一判据点)
+//
+// 为什么白名单在 handler 而不是 Service:`validator` 的 `oneof` 失败会被归成 **2001**(缺参),
+// 而"传了非法枚举"按设计 §10.1 属 **2002**(格式错)。两者是不同前端提示分支,不能合并。
+var (
+	// activityTypeFilter 端点 22 的 `type` 过滤白名单(1 试听为旧遗留,不进列表筛选)。
+	activityTypeFilter = map[int64]struct{}{2: {}, 3: {}, 4: {}}
+	// activityStatusFilter 端点 22 的 `status` 过滤白名单(0 停用 / 1 开启 / 2 已结束)。
+	activityStatusFilter = map[int64]struct{}{0: {}, 1: {}, 2: {}}
+	// activitySignedStatusFilter 端点 28 的 `status` 过滤白名单(报名状态机 0~4)。
+	activitySignedStatusFilter = map[int64]struct{}{0: {}, 1: {}, 2: {}, 3: {}, 4: {}}
+	// activityPlatformRoles 端点 31 "平台视角"(`institutionId<=0`)的角色白名单。
+	//
+	// 依据:设计 §3.2 #14 裁定 **A7** 附注——「`institutionID=0` 必须放行,仅 `super_admin/finance`
+	// 角色可用该视角(越权防线在 Handler 白名单,不在 SQL)」。
+	//
+	// ⚠ 与 `repository/activity_discount_repo.go` 里那句「平台视角只对 super_admin/academic 开放」
+	// 不一致(那边写的是 academic)。按"设计裁定 > 代码注释"处理,取 **finance**;已在交付报告
+	// 的「文档/注释冲突」一节登记,待裁决后统一(改动只影响这一个 map)。
+	activityPlatformRoles = map[string]struct{}{"super_admin": {}, "finance": {}}
+)
+
+// ---------------------------------------------------------------- 列表(端点 22)
+
+// ListActivities 活动列表(端点 22 `GET /admin/activities`)。
+//
+// 入参(query):`type?`(2/3/4)、`status?`(0/1/2)、`keyword?`、`page`/`pageSize`。
+// 出参(成功):`{list,total,page,pageSize,pages}`(五字段由 `response.Page` 一次算定)。
+// 出参(失败):1001(无作用域)/2002(type 或 status 越界、非数字)/5002。
+//
+// 作用域:列表天然只含本机构(`scopedCampusRows`),跨机构活动**不出现**在结果里——
+// 这是端点 22 的越权判据(而不是报 1004:列表是"看不见"而非"被拒")。
+func (h *Handler) ListActivities(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	typeFilter, _, err := parseEnumQuery(c, "type", activityTypeFilter)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	statusFilter, _, err := parseEnumQuery(c, "status", activityStatusFilter)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	page, pageSize := parsePage(c)
+	// 白名单已限定在 2/3/4 与 0/1/2,窄化成 int8 是**安全**的(不会溢出);
+	// 不做 `int8(*p)` 直接强转是为了让"转换发生在这里、且仅在命中白名单之后"这一点可读。
+	var typePtr *int8
+	if typeFilter != nil {
+		v := int8(*typeFilter)
+		typePtr = &v
+	}
+	var statusPtr *int8
+	if statusFilter != nil {
+		v := int8(*statusFilter)
+		statusPtr = &v
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.activityAdmin.ListActivities(c.Context(), service.ListActivitiesInput{
+		Type: typePtr, Status: statusPtr,
+		Keyword: c.Query("keyword"), Page: page, PageSize: pageSize,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.Page(c, res.List, res.Page, res.PageSize, res.Total)
+}
+
+// ---------------------------------------------------------------- 新建(端点 23)
+
+// CreateActivity 新建活动(端点 23 `POST /admin/activities`)。
+//
+// 入参(body):见 `CreateActivityReq`(`type` 必填 2/3/4,`title` 必填)。
+// 出参(成功):`{id}`(**`bizNo` 不下发**:`t_courses_activity` 无 `biz_no` 列,见 `CreateActivityResult` 注释)。
+// 出参(失败):2001/2002/3104(校区不存在)/1004(校区不属本机构、或 institutionId 跨机构)/5002。
+//
+// 机构来源(**不信任 body**):`institutionId` 缺省取作用域;非超管传了并且不等于作用域 → **1004**。
+func (h *Handler) CreateActivity(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req CreateActivityReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	institutionID, err := resolveInstitutionID(sc, req.InstitutionID)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	amount, ok := parseMoneyOrZero(c, req.Money, "money")
+	if !ok {
+		return nil
+	}
+	start, end, err := parseActivityWindow(req.StartTime, req.EndTime)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.activityAdmin.CreateActivity(c.Context(), service.CreateActivityInput{
+		InstitutionID: institutionID,
+		CampusID:      req.CampusID,
+		Type:          req.Type,
+		Title:         strings.TrimSpace(req.Title),
+		Content:       req.Content,
+		Memo:          req.Memo,
+		Thumbnail:     req.Thumbnail,
+		Money:         amount,
+		StartTime:     start,
+		EndTime:       end,
+		NumberMax:     req.NumberMax,
+		Status:        req.Status,
+		Toped:         req.Toped,
+		SubjectID:     req.SubjectID,
+		GradeID:       req.GradeID,
+		OperatorID:    sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// ---------------------------------------------------------------- 详情(端点 24)
+
+// GetActivity 活动详情(端点 24 `GET /admin/activities/:id`)。
+//
+// 入参(path):`:id`。
+// 出参(成功):22 行结构(平铺)+ `signeds[]`(最多 200 条,见 `ActivityDetailSignedLimit`)。
+// 出参(失败):2002(id 非数字/≤0)/3001(不存在或不在作用域,**不泄露存在性**)/5002。
+func (h *Handler) GetActivity(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.activityAdmin.GetActivity(c.Context(), parseIDParam(c.Params("id")))
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// ---------------------------------------------------------------- 更新(端点 25)
+
+// UpdateActivity 更新活动(端点 25 `PUT /admin/activities/:id`,**整体替换**语义)。
+//
+// 入参(body):见 `UpdateActivityReq`。
+// 出参(成功):`{id}`。
+// 出参(失败):2001/2002(含枚举越界、时间窗倒挂、传 numberSign)/3001(活动不存在/越权)/3104/1004。
+//
+// **`numberSign` 传即 2002(不是 3140)**:契约 §3.4 B 行 25 已改为 2002(v1.6)。
+// 判据 = 设计 §10.1「**3140 仅收两个及以上参数互相矛盾**;单一参数非法 → 2001/2002」——
+// "传了一个不可写字段"属**单一参数取值非法**,归 2002;若塞进 3140,3140 会退化成垃圾桶
+// (该禁令见设计 §10.1 v0.1.19)。
+func (h *Handler) UpdateActivity(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req UpdateActivityReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	if req.NumberSign != nil {
+		// 2002(`infraerrors.ErrBadFormat`):契约 §3.4 B 行 25(v1.6 起为 2002)。
+		// 为什么不是 3140:设计 §10.1 限定「3140 只收参数之间互相矛盾」,而这里是
+		// **单一字段不可写** ⇒ 属取值非法,归 2002。
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("numberSign 是报名计数列,不允许通过活动更新写入")))
+	}
+	institutionID, err := resolveInstitutionID(sc, req.InstitutionID)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	amount, ok := parseMoneyOrZero(c, req.Money, "money")
+	if !ok {
+		return nil
+	}
+	start, end, err := parseActivityWindow(req.StartTime, req.EndTime)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.activityAdmin.UpdateActivity(c.Context(), service.UpdateActivityInput{
+		ID:            parseIDParam(c.Params("id")),
+		InstitutionID: institutionID,
+		CampusID:      req.CampusID,
+		Type:          req.Type,
+		Title:         strings.TrimSpace(req.Title),
+		Content:       req.Content,
+		Memo:          req.Memo,
+		Thumbnail:     req.Thumbnail,
+		Money:         amount,
+		StartTime:     start,
+		EndTime:       end,
+		NumberMax:     req.NumberMax,
+		Status:        req.Status,
+		Toped:         req.Toped,
+		SubjectID:     req.SubjectID,
+		GradeID:       req.GradeID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// ---------------------------------------------------------------- 报名(端点 26)
+
+// SignActivity 报名(端点 26 `POST /admin/activities/:id/sign`,SP19 dump 762910(CREATE 行;762908 是 DROP))。
+//
+// 入参(body):`userId` 必填;`status?`(<1 → 1);`recommendUserId?`。
+// 出参(成功):`{signedId}`。
+// 出参(失败):2001/2002/**3001**(活动不存在)/3105(已报名)/3106(老带新限一次)/
+// 3141(人数已满,Go 增强)/5002。
+//
+// 三种"看起来像但都不是"的码(**不要互换**,各由 Service 按 dump 判据给出):
+//
+//	3105 判**报名行** status(取消后可重报);3106 判**活动** status(取消也不退次数);
+//	3110 **禁用**(dump 的 signed_add 无可用性校验,§10.1 v0.1.12 收窄)。
+func (h *Handler) SignActivity(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req SignActivityReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.activity.Sign(c.Context(), service.SignInput{
+		UserID:          strings.TrimSpace(req.UserID),
+		ActivityID:      parseIDParam(c.Params("id")),
+		Status:          req.Status,
+		RecommendUserID: req.RecommendUserID,
+		CreateUserID:    sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, activitySignResp{SignedID: res.SignedID})
+}
+
+// ---------------------------------------------------------------- 取消报名(端点 27)
+
+// CancelActivitySign 取消报名(端点 27 `POST /admin/activities/:id/cancel-sign`,SP20 dump 762982)。
+//
+// 入参(body):`userId` 必填。
+// 出参(成功):`{cancelled:true}`(**三列归零已在同一 UPDATE 内完成**,dump 763028;
+// 前端需**刷新整行**而不是只改 status,见契约 §2.2 注)。
+// 出参(失败):2001/2002/3001(报名行不存在)/3107(已审核不可取消)/
+// 3108(老带新已消费不可取消)/**3121**(取不到活动 type,状态机无法判定,§3.8-R8)/5002。
+func (h *Handler) CancelActivitySign(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req CancelActivitySignReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	if _, err := svc.activity.CancelSign(c.Context(), service.CancelSignInput{
+		UserID:     strings.TrimSpace(req.UserID),
+		ActivityID: parseIDParam(c.Params("id")),
+	}); err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, activityCancelledResp{Cancelled: true})
+}
+
+// ---------------------------------------------------------------- 报名列表(端点 28)
+
+// ListActivitySigneds 报名列表(端点 28 `GET /admin/activities/:id/signeds`)。
+//
+// 入参(query):`status?`(0~4)、`page`/`pageSize`。
+// 出参(成功):`{list,total,page,pageSize,pages}`,行结构同端点 24 的 `signeds[]`。
+// 出参(失败):2002(status 越界/非数字、id 非数字)/3001(活动不存在或越权)/5002。
+//
+// **空列表与越权必须区分**:活动越权 → 3001;活动存在但没人报名 → `list:[]` + `total:0`。
+// 都回 `[]` 会让前端把"越权"当"正常空态"(这是端点 28 最容易写错的一处)。
+func (h *Handler) ListActivitySigneds(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	statusFilter, _, err := parseEnumQuery(c, "status", activitySignedStatusFilter)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var statusPtr *int32
+	if statusFilter != nil {
+		v := int32(*statusFilter)
+		statusPtr = &v
+	}
+	page, pageSize := parsePage(c)
+	svc := h.servicesFor(sc)
+	res, err := svc.activityAdmin.ListSigneds(c.Context(), service.ListSignedsInput{
+		ActivityID: parseIDParam(c.Params("id")),
+		Status:     statusPtr,
+		Page:       page,
+		PageSize:   pageSize,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.Page(c, res.List, res.Page, res.PageSize, res.Total)
+}
+
+// ---------------------------------------------------------------- 老带新结算(端点 29)
+
+// SettleActivityRecommend 老带新结算(端点 29 `POST /admin/activities/:id/recommend-settle`,
+// SP18 dump 763309)。
+//
+// 入参(body):`userId`(被推荐人)、`orderNo`、`remark?`。路径 `:id` **只用于前置作用域校验**:
+// SP18 的候选集按 `userid` 全量扫描(`ListRecommendSignedByUser`),不按活动过滤,
+// 因此本 handler 先断言该活动可见(否则 3001),再交给服务按 dump 语义结算。
+//
+// 出参(成功):`{settled, activityId}`。`settled=false` 是 dump 的 **LEAVE 分支**
+// (无推荐人 / 活动金额≤0 / 报课记录回查不到校区),**零写入**——不是错误。
+// 出参(失败):2001/2002/3001/3104/3113/5002。
+func (h *Handler) SettleActivityRecommend(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req SettleRecommendReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	activityID := parseIDParam(c.Params("id"))
+	svc := h.servicesFor(sc)
+	if err := svc.activityAdmin.AssertActivityVisible(c.Context(), activityID); err != nil {
+		return response.Fail(c, err)
+	}
+	res, err := svc.recommend.SettleRecommend(c.Context(), service.SettleRecommendInput{
+		UserID:       strings.TrimSpace(req.UserID),
+		OrderNo:      strings.TrimSpace(req.OrderNo),
+		Remark:       req.Remark,
+		CreateUserID: sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, activitySettledResp{Settled: res.Settled, ActivityID: res.ActivityID})
+}
+
+// ---------------------------------------------------------------- 取消充值活动(端点 30)
+
+// CancelActivityRecharge 取消充值活动(端点 30 `POST /admin/activities/:id/cancel-recharge`,SP8 dump 763935)。
+//
+// 入参(body):`userId`、`campusId`(必填 >0)、`money`、`requestId?`。
+// 出参(成功):`{cancelled:true}`。
+// 出参(失败):2001/2002/**3104**(校区不存在或未归属本机构)/1004(作用域越界)/3001/5002。
+//
+// 为什么 `campusId` 必填:流水落 `209` 到**校区账户**,机构由 `t_campus.institution_id` 反查
+// (dump 的 `SELECT school_id`);没有校区就无法确定机构 ⇒ 无法定位账户。
+func (h *Handler) CancelActivityRecharge(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req CancelRechargeReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	amount, ok := parseMoney(c, req.Money, "money")
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	if _, err := svc.recommend.CancelRechargeActivity(c.Context(), service.CancelRechargeActivityInput{
+		UserID:     strings.TrimSpace(req.UserID),
+		ActivityID: parseIDParam(c.Params("id")),
+		CampusID:   req.CampusID,
+		Money:      amount,
+		RequestID:  strings.TrimSpace(req.RequestID),
+		Remark:     req.Remark,
+		OperatorID: sc.UserID,
+	}); err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, activityCancelledResp{Cancelled: true})
+}
+
+// ---------------------------------------------------------------- 可用赠送(端点 31)
+
+// GetActivityUsableDiscount 可用赠送金额(端点 31 `GET /admin/activities/discounts/usable`,SP14 dump 763176)。
+//
+// 入参(query):`userId`(必填)、`institutionId`、`campusId`。
+// 出参(成功):`{userId, campusId, usable, items:[{activityId, amount}]}`。
+//   - `usable` = 汇总可用额;`items` = 按活动的构成(空 → `[]`);
+//   - ⚠ 契约还要求 `items[].memo`,但赠送池表**无 memo 列** ⇒ 不下发(见 `UsableBreakdownItem` 注释)。
+//
+// 出参(失败):1001/2001(缺 userId)/2002(机构或校区为负/非数字)/**1003**/**1004**/5002。
+//
+// 越权防线(**本端点唯一的两条,设计 §3.2-A7**):
+//
+//	institutionId<=0(平台视角)→ 角色必须属 {super_admin, finance},否则 **1003**
+//	institutionId>0 且 ≠ 作用域机构 → **1004**(跨机构查别人的赠送池)
+//
+// 这两条**必须在 Handler**:`SumUsable` 在 `institutionID<=0` 时**不加任何作用域条件**(dump 原样),
+// 数据层已经没有防线了。
+func (h *Handler) GetActivityUsableDiscount(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	userID := strings.TrimSpace(c.Query("userId"))
+	if userID == "" {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("userId 不能为空")))
+	}
+	institutionID, err := parseIntQuery(c, "institutionId")
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	campusID, err := parseIntQuery(c, "campusId")
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	if institutionID < 0 || campusID < 0 {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("institutionId/campusId 不得为负")))
+	}
+	if institutionID <= 0 {
+		if !sc.SuperAdmin {
+			if _, ok := activityPlatformRoles[sc.Role]; !ok {
+				return response.Fail(c, infraerrors.WithCause(infraerrors.ErrForbidden,
+					errString("平台视角(institutionId<=0)仅超级管理员/财务可用")))
+			}
+		}
+	} else if institutionID != sc.InstitutionID && !sc.SuperAdmin {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrUnscoped,
+			errString("institutionId 与当前作用域机构不一致")))
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.activityDiscount.UsableBreakdown(c.Context(), service.UsableAmountInput{
+		UserID:        userID,
+		InstitutionID: institutionID,
+		CampusID:      campusID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// ---------------------------------------------------------------- 使用赠送(端点 32)
+
+// UseActivityDiscount 使用赠送(端点 32 `POST /admin/activities/discounts/use`,SP15 dump 763204)。
+//
+// 入参(body):`userId`、`activityId`、`coursesStudentId`、`amount`、`memo?`。
+// 出参(成功):`{used}`(金额 ≤0 时服务端 no-op ⇒ `used=false`,§3.8-Z1 **不是错误**)。
+// 出参(失败):2001/2002/**3109**(可用赠送金额不足,Go 增强:dump 静默 LEAVE)/5002。
+func (h *Handler) UseActivityDiscount(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req UseActivityDiscountReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	amount, ok := parseMoneyOrZero(c, req.Amount, "amount")
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.activityDiscount.UseVoucher(c.Context(), service.UseVoucherInput{
+		UserID:          strings.TrimSpace(req.UserID),
+		ActivityID:      req.ActivityID,
+		StudentCourseID: req.CoursesStudentID,
+		Amount:          amount,
+		Memo:            req.Memo,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, activityUsedResp{Used: res.Applied.GreaterThan(0)})
+}
+
+// ---------------------------------------------------------------- 赠送退回(端点 33)
+
+// RefundActivityDiscount 赠送退回(端点 33 `POST /admin/activities/discounts/refund`,SP16 dump 763046)。
+//
+// 入参(body):`coursesStudentId`(必填 >0)、`amount`、`memo?`。
+// 出参(成功):`{refunded}`(无可用流水/金额≤0 ⇒ false)。
+// 出参(失败):2001/2002/5002。
+//
+// 退回对象是**流水**(`t_courses_activity_user_discount_log` 的负值行,按 id DESC 逐条回冲),
+// 不是 signed 行(§3.2 #16 裁定 A11)——所以本端点**不需要** `userId`/`activityId`。
+func (h *Handler) RefundActivityDiscount(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req RefundActivityDiscountReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	amount, ok := parseMoneyOrZero(c, req.Amount, "amount")
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.activityDiscount.RefundByStuCourse(c.Context(), service.RefundByStuCourseInput{
+		StudentCourseID: req.CoursesStudentID,
+		Amount:          amount,
+		Memo:            req.Memo,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, activityRefundedResp{Refunded: res.RefundedTotal.GreaterThan(0)})
+}
+
+// ---------------------------------------------------------------- 按活动退回(端点 34)
+
+// RefundDiscountByActivity 按活动退回赠送(端点 34
+// `POST /admin/activities/discounts/refund-by-activity`,SP17 dump 763106)。
+//
+// 入参(body):`userId`、`activityId`、`amount`、`memo?`。
+// 出参(成功):`{refunded}`。
+// 出参(失败):2001/2002/5002。
+//
+// ⚠ 口径(§3.8-A19,**Go 有意偏离 dump**):`type=3`(代金券)全退时金额取"最近一条 log 的
+// `ABS(activity_money_used)`"——dump 那句 `SELECT inputAmount = <col>` 在 MySQL 里是**布尔比较**、
+// 赋值从不生效,实际可超退。该偏离已在设计文档登记为 `DUMP-BUGFIX`,本端点**不做二次截断**。
+func (h *Handler) RefundDiscountByActivity(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req RefundDiscountByActivityReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	amount, ok := parseMoneyOrZero(c, req.Amount, "amount")
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.activityDiscount.RefundByActivity(c.Context(), service.RefundByActivityInput{
+		UserID:     strings.TrimSpace(req.UserID),
+		ActivityID: req.ActivityID,
+		Amount:     amount,
+		Memo:       req.Memo,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, activityRefundedResp{Refunded: res.Refunded.GreaterThan(0)})
+}
+
+// ---------------------------------------------------------------- 充值赠送使用(端点 35)
+
+// UseRechargeGift 充值赠送使用(端点 35 `POST /admin/activities/recharge-gifts/use`,SP12 dump 763256)。
+//
+// 入参(body):`userId`、`activityId`、`coursesStudentId`、`amount`、`memo?`。
+// 出参(成功):`{used}`(≤0 ⇒ false,no-op)。
+// 出参(失败):2001/2002/5002。
+//
+// 前置(dump 游标 763262):报名行必须 `status=2`(已参加活动)——**不是**可选的宽松化。
+// A9 修正(§3.8-a):dump 循环里 `inputAmount` 从不递减,Go 改为"扣到 0 即停"。
+func (h *Handler) UseRechargeGift(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req UseRechargeGiftReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	amount, ok := parseMoneyOrZero(c, req.Amount, "amount")
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.activityDiscount.UseRechargeGift(c.Context(), service.UseRechargeGiftInput{
+		UserID:          strings.TrimSpace(req.UserID),
+		ActivityID:      req.ActivityID,
+		StudentCourseID: req.CoursesStudentID,
+		Amount:          amount,
+		Memo:            req.Memo,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, activityUsedResp{Used: res.UsedAmount.GreaterThan(0)})
+}
+
+// ---------------------------------------------------------------- 充值赠送退回(端点 36)
+
+// RefundRechargeGift 充值赠送退回(端点 36 `POST /admin/activities/recharge-gifts/refund`,SP13 dump 762841)。
+//
+// 入参(body):`coursesStudentId`(必填 >0)、`amount`、`memo?`。
+// 出参(成功):`{refunded}`。
+// 出参(失败):2001/2002/5002。
+//
+// 分配方向:dump 游标 **id DESC**(后入先退),Go 保留该方向(`OrderDesc`)。
+func (h *Handler) RefundRechargeGift(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req RefundRechargeGiftReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	amount, ok := parseMoneyOrZero(c, req.Amount, "amount")
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.activityDiscount.RefundRechargeGift(c.Context(), service.RefundRechargeGiftInput{
+		StudentCourseID: req.CoursesStudentID,
+		Amount:          amount,
+		Memo:            req.Memo,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, activityRefundedResp{Refunded: res.RefundedTotal.GreaterThan(0)})
+}
+
+// ---------------------------------------------------------------- 回访(端点 37)
+
+// CreateActivityVisit 回访登记(端点 37 `POST /admin/activities/:id/visits`)。
+//
+// 入参(body):`activitySignedId`(必填)、`memo`(必填)。路径 `:id` 是活动 ID。
+// 出参(成功):`{visited, activityId}`。
+// 出参(失败):2001/2002/3001(报名行不存在 / 报名行不属于该活动 / 活动越权)/5002。
+//
+// 为何要校验"报名行属于该活动":`t_courses_activity_visit` **没有作用域列也没有活动列**
+// (只有 `activity_signed_id`),一旦把回访挂到别的活动的报名行上,事后**无法按活动筛出来**,
+// 也没法从数据上判断它挂错了 —— 只能在写入前拒。
+func (h *Handler) CreateActivityVisit(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req CreateVisitReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.activityAdmin.RegisterVisit(c.Context(), service.VisitInput{
+		ActivityID:       parseIDParam(c.Params("id")),
+		ActivitySignedID: req.ActivitySignedID,
+		Memo:             req.Memo,
+		OperatorID:       sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// ---------------------------------------------------------------- 本地助手(只做解析,不含业务判断)
+
+// parseMoneyOrZero 解析**可空**金额(空串 → 0,**不是** 2001)。
+//
+// 入参:c;raw 原始字符串;field 字段名。
+// 出参:(金额, 是否成功);失败时 **body code=2002 已写出**。
+//
+// 为什么活动域的可空金额不能复用 `parseMoney`:`parseMoney` 对空串会 `money.Parse("")` 失败 →
+// 2002,但活动侧 `amount` 是**可选**(缺省 = no-op,§3.8-Z1)。两者区分必须显式,
+// 否则"没传金额"会被报成"金额格式错误"(错误码 2002 与 2001 混用,前端分不出该提示什么)。
+func parseMoneyOrZero(c *fiber.Ctx, raw, field string) (money.Amount, bool) {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" {
+		return 0, true
+	}
+	return parseMoney(c, trimmed, field)
+}
+
+// parseEnumQuery 解析**可选枚举** query 参数(缺省 → nil;越界 → 2002)。
+//
+// 入参:c;name query 名;allowed 白名单(值 → 空结构体)。
+// 出参:(*int64 命中值 或 nil, 是否传了, error);error 非 nil 时 **body code=2002 已写出**。
+//
+// 三个行为细节(都是"会让某个断言变红"的点):
+//  1. 空串 → (nil, false, nil):**不筛**,不报错(前端下拉框"全部"就是空串);
+//  2. 非数字 → 2002(不是 2001:传了但填错);
+//  3. 数字但不在白名单 → 2002(**不得静默忽略**:静默忽略会让"筛了但没筛"变成新的谜题)。
+func parseEnumQuery(c *fiber.Ctx, name string, allowed map[int64]struct{}) (*int64, bool, error) {
+	raw := strings.TrimSpace(c.Query(name))
+	if raw == "" {
+		return nil, false, nil
+	}
+	value, err := parseIntQuery(c, name)
+	if err != nil {
+		return nil, true, err
+	}
+	if _, ok := allowed[value]; !ok {
+		return nil, true, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("参数 "+name+" 取值非法"))
+	}
+	return &value, true, nil
+}
+
+// parseIntQuery 解析整数 query 参数(缺省 → 0;非数字 → 2002)。
+//
+// 入参:c;name。出参:(值, error);error 非 nil 时 **body code=2002 已写出**。
+//
+// 负数**照常解析**(不在本函数拦):`institutionId=-1` 这类"给了但无意义"的值由调用方
+// 按 2002 处理(端点 31 显式判负),避免本函数变成第二个业务规则点。
+func parseIntQuery(c *fiber.Ctx, name string) (int64, error) {
+	raw := strings.TrimSpace(c.Query(name))
+	if raw == "" {
+		return 0, nil
+	}
+	negative := false
+	digits := raw
+	if strings.HasPrefix(raw, "-") {
+		negative = true
+		digits = raw[1:]
+	}
+	if digits == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("参数 "+name+" 不是合法整数"))
+	}
+	var out int64
+	for _, ch := range digits {
+		if ch < '0' || ch > '9' {
+			return 0, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("参数 "+name+" 不是合法整数"))
+		}
+		out = out*10 + int64(ch-'0')
+	}
+	if negative {
+		out = -out
+	}
+	return out, nil
+}
+
+// parseActivityWindow 解析活动起止时间(两个都可空;格式非法 → 2002)。
+//
+// 入参:startRaw/endRaw(RFC3339 或 `2006-01-02`)。
+// 出参:(start, end *time.Time, error);error 非 nil 时 **body code=2002 已写出**。
+//
+// 只做**格式**转换,不做"start<=end"判定:倒挂属"值非法",在 Service 的
+// `validateActivityFields` 里统一判(一处判据、两个端点共用:23 与 25)。
+func parseActivityWindow(startRaw, endRaw string) (start, end *time.Time, err error) {
+	startTime, hasStart, serr := parseTimeParam(startRaw, "startTime")
+	if serr != nil {
+		return nil, nil, serr
+	}
+	endTime, hasEnd, eerr := parseTimeParam(endRaw, "endTime")
+	if eerr != nil {
+		return nil, nil, eerr
+	}
+	if hasStart {
+		start = &startTime
+	}
+	if hasEnd {
+		end = &endTime
+	}
+	return start, end, nil
+}
+
+// resolveInstitutionID 决定本次写入的机构 ID(**不信任 body**)。
+//
+// 入参:sc 作用域;raw body 里的 `institutionId`(0 = 没传)。
+// 出参:(机构 ID, error);error 非 nil 时 **body code 已写出**(1004 / 2001)。
+//
+// 规则:
+//
+//	raw == 0                     → 用作用域机构(最常见路径)
+//	raw == sc.InstitutionID      → 用它(等价,不报错)
+//	raw != sc.InstitutionID      → 超管可用它(跨机构建活动);否则 **1004**
+//	最终仍 <=0                   → **2001**(没有机构归属无法落库;作用域缺失本身属配置/参数问题)
+func resolveInstitutionID(sc *appscope.Scope, raw int64) (int64, error) {
+	if sc == nil {
+		return 0, infraerrors.ErrUnauth
+	}
+	institutionID := sc.InstitutionID
+	if raw > 0 {
+		if raw != institutionID && !sc.SuperAdmin {
+			return 0, infraerrors.WithCause(infraerrors.ErrUnscoped,
+				errString("institutionId 与当前作用域机构不一致"))
+		}
+		institutionID = raw
+	}
+	if institutionID <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("缺少机构作用域(institutionId 不能为 0)"))
+	}
+	return institutionID, nil
+}

+ 1082 - 0
internal/academic/handler/activity_handler_test.go

@@ -0,0 +1,1082 @@
+// 活动域 Handler 单测(AC-13b:端点 22~37,16 个)。
+//
+// 断言纪律(与 AC-12 同源,只断 body 契约:HTTP 恒 200 + body.code):
+//
+//	每条用例都写成"**实现改错就变红**"的形状,判据写在每个 `Test` 的注释第一段。
+//	"用例全绿"不算验收——全绿只说明断言与实现同时存在,必须能指出"哪一行改掉它就会红"。
+//
+// 覆盖结构:
+//
+//	① 端点覆盖 + 路由顺序        → TestActivityRouteCoverage
+//	② 列表:作用域/派生字段/枚举  → TestListActivitiesScopesAndDerives
+//	③ 新建:2002/3104/1004/成功   → TestCreateActivityValidationAndCrossCampus
+//	④ 详情:3001/字段映射/空数组   → TestActivityDetailScopeAndShape
+//	⑤ 报名列表:2002/空列表       → TestListSignedsInvalidStatusIs2002
+//	⑥ 更新:2002(numberSign)/3001  → TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001
+//	⑦ 报名/取消:3105/跨表三列归零 → TestSignThenCancelCrossTableEffects
+//	⑧ 取消:3107 审核态零副作用    → TestCancelSignAuditedIs3107WithZeroSideEffects
+//	⑨ 取消:3121 type 读不到       → TestCancelSignUnresolvableTypeIs3121
+//	⑩ 老带新:settled=false 零写入 → TestRecommendSettleNoopDoesNotWrite
+//	⑪ 取消充值:3104               → TestCancelRechargeUnknownCampusIs3104
+//	⑫ 可用赠送:1004/1003/items    → TestUsableBreakdownScopeAndShape
+//	⑬ 赠送使用:no-op 与 3109      → TestDiscountUseNoopAndInsufficient
+//	⑭ 充值赠送使用:status=2 前置   → TestRechargeGiftUseRequiresAttendedStatus
+//	⑮ 三条退回:refunded 语义       → TestRefundEndpointsReportRefundedFlag
+//	⑯ 回访:跨活动拒绝 + 落库        → TestVisitRejectsCrossActivitySigned
+package handler
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/gofiber/fiber/v2"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// ---------------------------------------------------------------- 测试基础设施
+
+// newActivityTestApp 构造只挂活动域 16 条端点的 app(含作用域注入中间件)。
+//
+// 中间件按 `X-Test-Scope` 注入不同作用域(缺省 finance,与 `testScope` 一致):
+//
+//	none / super / academic / student
+//
+// 路由注册顺序**有意**与 `activity_handler.go` 文件头的"静态优先"一致:
+// 若把 `PathActivityDetail` 注册在 `PathActivityDiscountsUsable` 之前,
+// `GET /admin/activities/discounts/usable` 会被 `:id="discounts"` 吃掉(见 `TestActivityRouteCoverage`)。
+func newActivityTestApp(t *testing.T, db *gorm.DB) *fiber.App {
+	t.Helper()
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	app := fiber.New(fiber.Config{DisableStartupMessage: true})
+	app.Use(func(c *fiber.Ctx) error {
+		switch c.Get("X-Test-Scope") {
+		case "none":
+			// 不写作用域 → 未登录语义(1001)
+		case "super":
+			appscope.Set(c, testSuperScope())
+		case "academic":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 10,
+				UserID: "01HACADEMIC0000000000001", Role: "academic",
+			})
+		default:
+			appscope.Set(c, testScope())
+		}
+		return c.Next()
+	})
+	// ① 静态段(顺序敏感,必须全在 `:id` 之前)
+	app.Get(PathActivities, h.ListActivities)
+	app.Post(PathActivities, h.CreateActivity)
+	app.Get(PathActivityDiscountsUsable, h.GetActivityUsableDiscount)
+	app.Post(PathActivityDiscountsUse, h.UseActivityDiscount)
+	app.Post(PathActivityDiscountsRefund, h.RefundActivityDiscount)
+	app.Post(PathActivityDiscountsRefundByActivity, h.RefundDiscountByActivity)
+	app.Post(PathActivityRechargeGiftsUse, h.UseRechargeGift)
+	app.Post(PathActivityRechargeGiftsRefund, h.RefundRechargeGift)
+	// ② 带 :id 的段
+	app.Get(PathActivityDetail, h.GetActivity)
+	app.Put(PathActivityDetail, h.UpdateActivity)
+	app.Post(PathActivitySign, h.SignActivity)
+	app.Post(PathActivityCancelSign, h.CancelActivitySign)
+	app.Get(PathActivitySigneds, h.ListActivitySigneds)
+	app.Post(PathActivityRecommendSettle, h.SettleActivityRecommend)
+	app.Post(PathActivityCancelRecharge, h.CancelActivityRecharge)
+	app.Post(PathActivityVisits, h.CreateActivityVisit)
+	return app
+}
+
+// seedActivityRow 落一行活动(显式 ID,便于断言语义)。
+func seedActivityRow(t *testing.T, db *gorm.DB, row model.CoursesActivity) {
+	t.Helper()
+	if row.NumberSign == 0 && row.NumberMax == 0 {
+		row.NumberMax = 0
+	}
+	if row.Title == nil {
+		title := "活动"
+		row.Title = &title
+	}
+	if err := db.Create(&row).Error; err != nil {
+		t.Fatalf("seed 活动(%d) 失败: %v", row.ID, err)
+	}
+}
+
+// seedSignedRow 落一行报名(status 显式传;responseUserID 非空表示已审核 → 3107 判据)。
+func seedSignedRow(
+	t *testing.T, db *gorm.DB, id, activityID int64, userID string, status int32,
+	moneyStr string, responseUserID *string,
+) {
+	t.Helper()
+	row := &model.CoursesActivitySigned{
+		ID: id, ActivityID: activityID, UserID: userID,
+		InstitutionID: 1, CampusID: 10,
+		Status: status, ActivityMoney: moneyStr, ActivityMoneyUsed: "0.00",
+		ResponseUserID: responseUserID,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 报名(id=%d) 失败: %v", id, err)
+	}
+}
+
+// seedDiscountPool 落一行赠送池(amount/used 为元字符串)。
+func seedDiscountPool(
+	t *testing.T, db *gorm.DB, id, activityID int64, userID string, amount, used string,
+) {
+	t.Helper()
+	row := &model.CoursesActivityUserDiscount{
+		ID: id, ActivityID: activityID, InstitutionID: 1, CampusID: 10, UserID: userID,
+		Income: "0.00", DiscountAmount: amount, DiscountAmountUsed: used,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 赠送池(id=%d) 失败: %v", id, err)
+	}
+}
+
+// readActivityByID 读活动行(断言库值用;不存在 → 失败)。
+func readActivityByID(t *testing.T, db *gorm.DB, id int64) model.CoursesActivity {
+	t.Helper()
+	var row model.CoursesActivity
+	if err := db.Where("id = ?", id).Take(&row).Error; err != nil {
+		t.Fatalf("读活动(%d) 失败: %v", id, err)
+	}
+	return row
+}
+
+// countRows 统计表内满足条件的行数(**跨表断言**用:响应体之外的唯一事实)。
+func countRows(t *testing.T, db *gorm.DB, entity any, query string, args ...any) int64 {
+	t.Helper()
+	var n int64
+	if err := db.Model(entity).Where(query, args...).Count(&n).Error; err != nil {
+		t.Fatalf("统计行数失败(%s): %v", query, err)
+	}
+	return n
+}
+
+// decodeData 把响应 data 解成目标结构(解不出 → 失败)。
+func decodeData[T any](t *testing.T, raw json.RawMessage) T {
+	t.Helper()
+	var out T
+	if err := json.Unmarshal(raw, &out); err != nil {
+		t.Fatalf("data 解码失败(%s): %v", string(raw), err)
+	}
+	return out
+}
+
+// ---------------------------------------------------------------- ① 端点覆盖 + 路由顺序
+
+// TestActivityRouteCoverage 16 条端点全注册、路径不含 `/api`、且**静态段优先**。
+//
+// 会变红的条件:
+//   - 任何一条 `PathActivity*` 改了名字/漏注册 → 对应 `registered[...]` 断言失败;
+//   - 把 `GET /admin/activities/:id` 注册在 `discounts/usable` 之前 →
+//     `/admin/activities/discounts/usable?userId=X` 会命中详情 handler 并因 id="discounts" 解析成 0
+//     ⇒ 返回 **2002**,本用例断言它必须是 **200**(真实业务码)→ 红。
+func TestActivityRouteCoverage(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+
+	expected := []struct{ method, path, name string }{
+		{"GET", PathActivities, "端点22 活动列表"},
+		{"POST", PathActivities, "端点23 新建"},
+		{"GET", PathActivityDetail, "端点24 详情"},
+		{"PUT", PathActivityDetail, "端点25 更新"},
+		{"POST", PathActivitySign, "端点26 报名"},
+		{"POST", PathActivityCancelSign, "端点27 取消报名"},
+		{"GET", PathActivitySigneds, "端点28 报名列表"},
+		{"POST", PathActivityRecommendSettle, "端点29 老带新结算"},
+		{"POST", PathActivityCancelRecharge, "端点30 取消充值"},
+		{"GET", PathActivityDiscountsUsable, "端点31 可用赠送"},
+		{"POST", PathActivityDiscountsUse, "端点32 使用赠送"},
+		{"POST", PathActivityDiscountsRefund, "端点33 赠送退回"},
+		{"POST", PathActivityDiscountsRefundByActivity, "端点34 按活动退回"},
+		{"POST", PathActivityRechargeGiftsUse, "端点35 充值赠送使用"},
+		{"POST", PathActivityRechargeGiftsRefund, "端点36 充值赠送退回"},
+		{"POST", PathActivityVisits, "端点37 回访"},
+	}
+	registered := map[string]bool{}
+	for _, r := range app.GetRoutes() {
+		registered[r.Method+" "+r.Path] = true
+		if strings.Contains(r.Path, "/api/") {
+			t.Errorf("后端路由不得含 /api 前缀(§1.6):%s %s", r.Method, r.Path)
+		}
+	}
+	for _, want := range expected {
+		if !registered[want.method+" "+want.path] {
+			t.Errorf("%s 未注册(%s %s)", want.name, want.method, want.path)
+		}
+	}
+	if len(expected) != 16 {
+		t.Fatalf("活动端点数应为 16,实为 %d(契约 §2.2)", len(expected))
+	}
+
+	// 路由顺序的**行为断言**(比检查注册表更硬):静态路径必须走到自己的 handler。
+	code, msg, _ := doJSON(t, app, "GET", "/admin/activities/discounts/usable?userId=U1", nil)
+	if code != 200 {
+		t.Fatalf("GET discounts/usable 期望 200,实得 %d(%s)——"+
+			"若为 2002 说明 :id 路由先注册吃掉了 discounts", code, msg)
+	}
+}
+
+// ---------------------------------------------------------------- ② 列表
+
+// TestListActivitiesScopesAndDerives 列表作用域 + `signed`/`numberSign`/`signable` 派生字段。
+//
+// 会变红的条件(逐条):
+//   - 去掉 `scopedCampusRows` ⇒ 列表里出现 institutionId=2 的活动 → 红;
+//   - `CountSignedByActivityIDs` 少写 `status <> 0` ⇒ `signed` 变 3(期望 2)→ 红;
+//   - `signable` 恒 true ⇒ status=0 的活动也 true → 红;
+//   - `name`/`total` 没有从 `title`/`number_max` 映射 ⇒ 断言失败 → 红。
+func TestListActivitiesScopesAndDerives(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+
+	title1 := "充值赠送 A"
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 101, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
+		Title: &title1, Money: "100.00", NumberMax: 5, NumberSign: 2, Status: 1,
+	})
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 102, Type: model.ActivityTypeVoucher, InstitutionID: 1, CampusID: 10,
+		Money: "0.00", NumberMax: 0, Status: 0, // 停用 → signable=false
+	})
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 103, Type: model.ActivityTypeRecharge, InstitutionID: 2, CampusID: 20,
+		Money: "1.00", NumberMax: 0, Status: 1, // 别的机构 → 不得出现
+	})
+	seedSignedRow(t, db, 1, 101, "U1", model.ActivitySignedSigned, "100.00", nil)
+	seedSignedRow(t, db, 2, 101, "U2", model.ActivitySignedAttended, "100.00", nil)
+	seedSignedRow(t, db, 3, 101, "U3", model.ActivitySignedCancelled, "0.00", nil) // 取消 → 不计
+
+	code, msg, data := doJSON(t, app, "GET", "/admin/activities?page=1&pageSize=10", nil)
+	if code != 200 {
+		t.Fatalf("列表期望 200,实得 %d(%s)", code, msg)
+	}
+	page := decodeData[struct {
+		List     []ActivityViewForTest `json:"list"`
+		Total    int64                 `json:"total"`
+		Page     int                   `json:"page"`
+		PageSize int                   `json:"pageSize"`
+		Pages    int64                 `json:"pages"`
+	}](t, data)
+
+	if page.Total != 2 {
+		t.Fatalf("列表总数应为 2(本机构 2 个),实得 %d(含跨机构活动则说明作用域失效)", page.Total)
+	}
+	if page.Pages != 1 {
+		t.Fatalf("pages 应为 ceil(2/10)=1,实得 %d", page.Pages)
+	}
+	byID := map[int64]ActivityViewForTest{}
+	for _, row := range page.List {
+		byID[row.ID] = row
+	}
+	if _, ok := byID[103]; ok {
+		t.Fatalf("跨机构活动 103 出现在列表里(作用域过滤失效,1004 类越权)")
+	}
+	first, ok := byID[101]
+	if !ok {
+		t.Fatalf("本机构活动 101 不在列表里")
+	}
+	if first.Name != "充值赠送 A" {
+		t.Fatalf("name 应映射 title(\"充值赠送 A\"),实得 %q", first.Name)
+	}
+	if first.Total != 5 {
+		t.Fatalf("total 应映射 number_max=5,实得 %d", first.Total)
+	}
+	if first.NumberSign != 2 {
+		t.Fatalf("numberSign 应为库值 2,实得 %d", first.NumberSign)
+	}
+	if first.Signed != 2 {
+		t.Fatalf("signed 应为 2(status<>0),实得 %d——"+
+			"若为 3 说明把 status=0(已取消)也统计进来了", first.Signed)
+	}
+	// 活动级 money(v1.6 契约补入):来自 `t_courses_activity.money`,两位小数字符串。
+	// 会变红的条件:① DTO 漏掉 Money 字段 ⇒ 得 "";② 用 float 输出 ⇒ 声明为 string 此处 unmarshal 失败;
+	// ③ 拿 signeds[].money 顶上 ⇒ 值可能仍是 100.00,故另用活动 102(money=0.00、无报名)区分层级。
+	if first.Money != "100.00" {
+		t.Fatalf("活动级 money 应为 \"100.00\"(← t_courses_activity.money),实得 %q", first.Money)
+	}
+	if stopped, ok := byID[102]; ok {
+		if stopped.Money != "0.00" {
+			t.Fatalf("无报名的停用活动 money 应为 \"0.00\"(活动级、与报名无关),实得 %q——"+
+				"若非 0.00 说明把 signeds[].money 当成了活动金额", stopped.Money)
+		}
+	}
+	if !first.Signable {
+		t.Fatalf("活动 101 应为 signable(status=1、未满员、无时间窗约束)")
+	}
+	disabled := byID[102]
+	if disabled.Signable {
+		t.Fatalf("活动 102 status=0(停用)应 signable=false,实得 true")
+	}
+	// 空数组而非 null:`list` 必须在 data 顶层且为数组
+	if !strings.Contains(string(data), `"list":[`) {
+		t.Fatalf("data.list 必须是数组(不得为 null/缺键):%s", string(data))
+	}
+}
+
+// ActivityViewForTest 列表行(只声明本用例要断言的字段,避免抄一份 DTO 造成"两处定义")。
+type ActivityViewForTest struct {
+	ID         int64  `json:"id"`
+	Name       string `json:"name"`
+	Total      int32  `json:"total"`
+	NumberSign int32  `json:"numberSign"`
+	Signed     int32  `json:"signed"`
+	// Money 声明为 **string**:若后端下发数值(number)会在此处 unmarshal 失败 ⇒
+	// "金额必须是两位小数字符串"这条不用额外断言也守住了。
+	Money    string `json:"money"`
+	Signable bool   `json:"signable"`
+	Type     int8   `json:"type"`
+}
+
+// TestListActivitiesInvalidEnumsAre2002 type/status 过滤枚举越界 → **2002**(不是 2001、不是静默忽略)。
+//
+// 会变红的条件:删掉 `parseEnumQuery` 的白名单判断 ⇒ 变成 200 且返回全量列表 → 红。
+func TestListActivitiesInvalidEnumsAre2002(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+
+	for _, q := range []string{"type=1", "type=9", "status=3", "status=abc"} {
+		code, _, _ := doJSON(t, app, "GET", "/admin/activities?"+q, nil)
+		if code != 2002 {
+			t.Errorf("查询 %s 期望 2002(枚举/格式非法),实得 %d", q, code)
+		}
+	}
+}
+
+// ---------------------------------------------------------------- ③ 新建
+
+// TestCreateActivityValidationAndCrossCampus 新建活动的四类拒绝 + 成功路径的库值断言。
+//
+// 会变红的条件:
+//   - 删除 `validateActivityFields` 的 type 白名单 ⇒ type=1 变 200 → 红;
+//   - 删除时间窗倒挂判断 ⇒ start>end 变 200 → 红;
+//   - 删除 `assertCampusInScope` ⇒ 传别家机构校区(20)变 200 → 红(**这是 1004 越权判据**);
+//   - 删除校区端口调用 ⇒ 不存在的校区(777)变 5002/200 而不是 3104 → 红;
+//   - 成功路径:`institution_id` 必须取作用域(1),且 `number_sign` 必须为 0。
+func TestCreateActivityValidationAndCrossCampus(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+	// 造一个"属于机构 2"的校区(跨机构写入判据;测试库默认只有 10 → 机构 1)
+	if err := db.Exec(
+		`INSERT INTO t_campus(campus_id, institution_id, self_support, status) VALUES(20,2,1,'active')`,
+	).Error; err != nil {
+		t.Fatalf("seed t_campus(20) 失败: %v", err)
+	}
+
+	base := map[string]any{"type": 2, "title": "新活动", "money": "100.00"}
+
+	cases := []struct {
+		name string
+		body map[string]any
+		want int
+	}{
+		{"类型 1(试听,旧遗留)", withFields(base, "type", 1), 2002},
+		{"类型 9(不存在)", withFields(base, "type", 9), 2002},
+		{"时间窗倒挂", withFields(base, "startTime", "2026-12-31", "endTime", "2026-01-01"), 2002},
+		{"校区不存在", withFields(base, "campusId", 777), 3104},
+		{"校区属别的机构", withFields(base, "campusId", 20), 1004},
+		{"机构参数跨作用域", withFields(base, "institutionId", 2), 1004},
+		{"金额格式非法", withFields(base, "money", "abc"), 2002},
+	}
+	for _, tc := range cases {
+		code, msg, _ := doJSON(t, app, "POST", "/admin/activities", tc.body)
+		if code != tc.want {
+			t.Errorf("%s:期望 %d,实得 %d(%s)", tc.name, tc.want, code, msg)
+		}
+	}
+
+	code, msg, data := doJSON(t, app, "POST", "/admin/activities", base)
+	if code != 200 {
+		t.Fatalf("新建期望 200,实得 %d(%s)", code, msg)
+	}
+	created := decodeData[struct {
+		ID    int64  `json:"id"`
+		BizNo string `json:"bizNo"`
+	}](t, data)
+	if created.ID <= 0 {
+		t.Fatalf("新建应回 id>0,实得 %d", created.ID)
+	}
+	if created.BizNo != "" {
+		t.Fatalf("bizNo 不应下发(t_courses_activity 无 biz_no 列):实得 %q", created.BizNo)
+	}
+	row := readActivityByID(t, db, created.ID)
+	if row.InstitutionID != 1 {
+		t.Fatalf("institution_id 应取作用域机构 1(不信任 body),实得 %d", row.InstitutionID)
+	}
+	if row.NumberSign != 0 {
+		t.Fatalf("number_sign 必须为 0(计数列不得由新建写入),实得 %d", row.NumberSign)
+	}
+	if row.Title == nil || *row.Title != "新活动" {
+		t.Fatalf("title 落库错误:%v", row.Title)
+	}
+}
+
+// ---------------------------------------------------------------- ④ 详情
+
+// TestActivityDetailScopeAndShape 详情的作用域(3001)与字段映射(name/total/signeds[].money)。
+//
+// 会变红的条件:
+//   - 去掉 `GetByID` 的作用域 ⇒ 别家机构活动(103)返回 200 → 红;
+//   - `SignedView.Money` 用 float/未转 money.Amount ⇒ `"100.00"` 变 `100` → 红;
+//   - `signeds` 用 nil 切片 ⇒ JSON 为 null(断言是 `"signeds":[]`)→ 红。
+func TestActivityDetailScopeAndShape(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+
+	title := "详情活动"
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 201, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
+		Title: &title, Money: "500.00", NumberMax: 10, Status: 1,
+	})
+	seedSignedRow(t, db, 11, 201, "U1", model.ActivitySignedSigned, "500.00", nil)
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 202, Type: model.ActivityTypeRecharge, InstitutionID: 2, CampusID: 20,
+		Money: "1.00", Status: 1,
+	})
+
+	code, msg, data := doJSON(t, app, "GET", "/admin/activities/201", nil)
+	if code != 200 {
+		t.Fatalf("详情期望 200,实得 %d(%s)", code, msg)
+	}
+	detail := decodeData[struct {
+		ID      int64  `json:"id"`
+		Name    string `json:"name"`
+		Total   int32  `json:"total"`
+		Signed  int32  `json:"signed"`
+		Signeds []struct {
+			ID         int64  `json:"id"`
+			ActivityID int64  `json:"activityId"`
+			UserID     string `json:"userId"`
+			Status     int32  `json:"status"`
+			Money      string `json:"money"`
+			CreateTime string `json:"createTime"`
+		} `json:"signeds"`
+	}](t, data)
+	if detail.Name != "详情活动" || detail.Total != 10 || detail.Signed != 1 {
+		t.Fatalf("详情字段映射错:%+v", detail)
+	}
+	if len(detail.Signeds) != 1 {
+		t.Fatalf("详情应内嵌 1 条报名行,实得 %d", len(detail.Signeds))
+	}
+	if detail.Signeds[0].Money != "500.00" {
+		t.Fatalf("signeds[].money 必须是两位小数字符串(activity_money=500.00),实得 %q",
+			detail.Signeds[0].Money)
+	}
+	if !strings.Contains(detail.Signeds[0].CreateTime, "T") {
+		t.Fatalf("signeds[].createTime 必须是 RFC3339(含 T),实得 %q", detail.Signeds[0].CreateTime)
+	}
+
+	// 跨机构 → 3001(不泄露存在性;若变 200 说明作用域被绕过)
+	if code, _, _ := doJSON(t, app, "GET", "/admin/activities/202", nil); code != 3001 {
+		t.Fatalf("跨机构活动详情期望 3001,实得 %d", code)
+	}
+	// 空报名列表 → `[]`(不是 null)
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 203, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
+		Money: "0.00", Status: 1,
+	})
+	_, _, emptyData := doJSON(t, app, "GET", "/admin/activities/203", nil)
+	if !strings.Contains(string(emptyData), `"signeds":[]`) {
+		t.Fatalf("无报名时 signeds 必须是 [](不得 null):%s", string(emptyData))
+	}
+}
+
+// ---------------------------------------------------------------- ⑤ 报名列表
+
+// TestListSignedsInvalidStatusIs2002 报名列表:status 越界 → 2002;合法空集 → `list:[]`。
+//
+// 会变红的条件:删掉 0~4 白名单 ⇒ status=5 返回 200 → 红;
+// 若列表用 nil 切片 ⇒ `"list":[]` 断言失败 → 红。
+func TestListSignedsInvalidStatusIs2002(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 301, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
+		Money: "10.00", Status: 1,
+	})
+
+	if code, _, _ := doJSON(t, app, "GET", "/admin/activities/301/signeds?status=5", nil); code != 2002 {
+		t.Fatalf("status=5 期望 2002,实得 %d", code)
+	}
+	code, msg, data := doJSON(t, app, "GET", "/admin/activities/301/signeds", nil)
+	if code != 200 {
+		t.Fatalf("报名列表期望 200,实得 %d(%s)", code, msg)
+	}
+	if !strings.Contains(string(data), `"list":[]`) {
+		t.Fatalf("无报名时 list 必须是 []:%s", string(data))
+	}
+	// 活动越权 → 3001(**不是** 空列表:两者语义不同)
+	if code, _, _ := doJSON(t, app, "GET", "/admin/activities/999/signeds", nil); code != 3001 {
+		t.Fatalf("不存在/越权活动的报名列表期望 3001,实得 %d", code)
+	}
+}
+
+// ---------------------------------------------------------------- ⑥ 更新
+
+// TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001 更新:`numberSign` 传即 2002;跨机构 3001。
+//
+// 为什么是 2002 不是 3140(v1.6 契约更正):设计 §10.1 限定「3140 只收**参数之间**互相矛盾」;
+// "传了一个不可写字段"属**单一字段取值非法** ⇒ 2002。3140 若被塞这类判断,会退化成垃圾桶。
+//
+// 会变红的条件:
+//   - 把 `req.NumberSign` 判断删掉(或用值类型接)⇒ 2002 变 200,且 DB 里 number_sign 被改写 → 红;
+//   - 把错误码改回 3140 ⇒ 实得 3140 ≠ 2002 → 红(守住"契约 v1.6 的这一处更正");
+//   - 去掉 `UpdateActivity` 的 `RowsAffected==0` 守卫 ⇒ 更新别家机构活动返回 200 → 红。
+func TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 401, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
+		Money: "10.00", NumberMax: 3, NumberSign: 2, Status: 1,
+	})
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 402, Type: model.ActivityTypeRecharge, InstitutionID: 2, CampusID: 20,
+		Money: "10.00", Status: 1,
+	})
+
+	body := map[string]any{"type": 2, "title": "改标题", "money": "10.00", "status": 1, "numberSign": 7}
+	code, msg, _ := doJSON(t, app, "PUT", "/admin/activities/401", body)
+	if code != 2002 {
+		t.Fatalf("numberSign 传即 2002(契约 §3.4 B 行 25,v1.6),实得 %d(%s)", code, msg)
+	}
+	if got := readActivityByID(t, db, 401).NumberSign; got != 2 {
+		t.Fatalf("2002 被拒后 number_sign 必须一字不动(2),实得 %d", got)
+	}
+
+	// 去掉 numberSign 后正常更新
+	delete(body, "numberSign")
+	if code, msg, _ := doJSON(t, app, "PUT", "/admin/activities/401", body); code != 200 {
+		t.Fatalf("正常更新期望 200,实得 %d(%s)", code, msg)
+	}
+	updated := readActivityByID(t, db, 401)
+	if updated.Title == nil || *updated.Title != "改标题" {
+		t.Fatalf("title 未更新:%v", updated.Title)
+	}
+	if updated.NumberSign != 2 {
+		t.Fatalf("更新不得改动 number_sign,实得 %d", updated.NumberSign)
+	}
+
+	// 跨机构 → 3001(RowsAffected==0 守卫;删掉守卫会变 200)
+	if code, _, _ := doJSON(t, app, "PUT", "/admin/activities/402", body); code != 3001 {
+		t.Fatalf("跨机构更新期望 3001,实得 %d", code)
+	}
+}
+
+// ---------------------------------------------------------------- ⑦ 报名 / 取消报名
+
+// TestSignThenCancelCrossTableEffects 报名 → 取消的**跨表**效果(端点 26/27)。
+//
+// 会变红的条件:
+//   - 去掉 3105 判据 ⇒ 第二次报名返回 200 → 红;
+//   - 取消时只改 status 不归零金额(少写 activity_money/activity_money_used)⇒ 库值断言红;
+//   - 取消时漏 `number_sign -1` ⇒ number_sign 仍为 1 → 红;
+//   - 取消后再查 3001(无有效报名行)⇒ 若变 200 说明"已取消"被判成有效 → 红。
+func TestSignThenCancelCrossTableEffects(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 501, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
+		Money: "100.00", NumberMax: 0, Status: 1,
+	})
+
+	code, msg, data := doJSON(t, app, "POST", "/admin/activities/501/sign",
+		map[string]any{"userId": "U100"})
+	if code != 200 {
+		t.Fatalf("报名期望 200,实得 %d(%s)", code, msg)
+	}
+	sign := decodeData[struct {
+		SignedID int64 `json:"signedId"`
+	}](t, data)
+	if sign.SignedID <= 0 {
+		t.Fatalf("报名应回 signedId>0,实得 %d", sign.SignedID)
+	}
+	if n := countRows(t, db, &model.CoursesActivitySigned{}, "activity_id = ? AND user_id = ?", 501, "U100"); n != 1 {
+		t.Fatalf("报名行应恰 1 条,实得 %d", n)
+	}
+	if got := readActivityByID(t, db, 501).NumberSign; got != 1 {
+		t.Fatalf("报名后 number_sign 应为 1,实得 %d", got)
+	}
+
+	// 重复报名 → 3105(判**报名行** status)
+	if code, _, _ := doJSON(t, app, "POST", "/admin/activities/501/sign",
+		map[string]any{"userId": "U100"}); code != 3105 {
+		t.Fatalf("重复报名期望 3105,实得 %d", code)
+	}
+
+	// 取消 → 三列归零 + number_sign-1(跨表断言,不看响应体)
+	if code, msg, _ := doJSON(t, app, "POST", "/admin/activities/501/cancel-sign",
+		map[string]any{"userId": "U100"}); code != 200 {
+		t.Fatalf("取消报名期望 200,实得 %d(%s)", code, msg)
+	}
+	var signed model.CoursesActivitySigned
+	if err := db.Where("activity_id = ? AND user_id = ?", 501, "U100").Take(&signed).Error; err != nil {
+		t.Fatalf("读报名行失败: %v", err)
+	}
+	if signed.Status != model.ActivitySignedCancelled {
+		t.Fatalf("取消后 status 应为 0,实得 %d", signed.Status)
+	}
+	if !sameMoney(signed.ActivityMoney, "0.00") || !sameMoney(signed.ActivityMoneyUsed, "0.00") {
+		t.Fatalf("取消后 activity_money/activity_money_used 必须同置 0.00(dump 763028),实得 %q/%q",
+			signed.ActivityMoney, signed.ActivityMoneyUsed)
+	}
+	if got := readActivityByID(t, db, 501).NumberSign; got != 0 {
+		t.Fatalf("取消后 number_sign 应为 0,实得 %d", got)
+	}
+
+	// 再次取消 → 3001(已无有效报名行)
+	if code, _, _ := doJSON(t, app, "POST", "/admin/activities/501/cancel-sign",
+		map[string]any{"userId": "U100"}); code != 3001 {
+		t.Fatalf("重复取消期望 3001,实得 %d", code)
+	}
+}
+
+// ---------------------------------------------------------------- ⑧ 3107 审核态
+
+// TestCancelSignAuditedIs3107WithZeroSideEffects 已审核(type=2 且 response_user_id 有值)→ 3107,且**零副作用**。
+//
+// 会变红的条件:
+//   - 去掉 3107 判据 ⇒ 返回 200 且 status 变 0 → 红;
+//   - 把 `number_sign-1` 挪到 3107 守卫之前 ⇒ number_sign 变 0(期望 1)→ 红("拒绝必须零副作用")。
+func TestCancelSignAuditedIs3107WithZeroSideEffects(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 601, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
+		Money: "100.00", NumberSign: 1, Status: 1,
+	})
+	auditor := "01HAUDITOR00000000000001"
+	seedSignedRow(t, db, 61, 601, "U200", model.ActivitySignedSigned, "100.00", &auditor)
+
+	if code, _, _ := doJSON(t, app, "POST", "/admin/activities/601/cancel-sign",
+		map[string]any{"userId": "U200"}); code != 3107 {
+		t.Fatalf("已审核报名期望 3107,实得 %d", code)
+	}
+	var signed model.CoursesActivitySigned
+	if err := db.Where("id = ?", 61).Take(&signed).Error; err != nil {
+		t.Fatalf("读报名行失败: %v", err)
+	}
+	if signed.Status != model.ActivitySignedSigned {
+		t.Fatalf("3107 被拒后 status 必须仍为 1,实得 %d", signed.Status)
+	}
+	if !sameMoney(signed.ActivityMoney, "100.00") {
+		t.Fatalf("3107 被拒后 activity_money 必须仍为 100.00,实得 %q", signed.ActivityMoney)
+	}
+	if got := readActivityByID(t, db, 601).NumberSign; got != 1 {
+		t.Fatalf("3107 被拒后 number_sign 必须仍为 1(守卫在 UPDATE 之前),实得 %d", got)
+	}
+}
+
+// ---------------------------------------------------------------- ⑨ 3121 type 读不到
+
+// TestCancelSignUnresolvableTypeIs3121 报名行在、活动行没了 ⇒ `type` 读不到 ⇒ **3121**(§3.8-R8)。
+//
+// 会变红的条件(**本用例是 R8 的唯一判据**):
+//   - 把 `ExistsAnySigned` 分支删掉 ⇒ 落回 3001 → 红(那就是"读不到 type 就放行/当成不存在");
+//   - 反过来把分界写反(有报名行 → 3001)⇒ 期望 3121 实得 3001 → 红。
+//
+// 为什么这种情况不是 3001:报名行**确实存在**(§3.8-R7 的 3001 是"查不到报名");
+// 缺的是活动行 ⇒ 3107/3108 两条审核态保护无法判定 ⇒ 显式 3121,不得静默取消。
+func TestCancelSignUnresolvableTypeIs3121(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+	// 报名行存在(status=1),但活动行**不存在**(模拟活动被物理删除的历史脏数据)
+	seedSignedRow(t, db, 71, 701, "U300", model.ActivitySignedSigned, "100.00", nil)
+
+	if code, msg, _ := doJSON(t, app, "POST", "/admin/activities/701/cancel-sign",
+		map[string]any{"userId": "U300"}); code != 3121 {
+		t.Fatalf("取不到 type 期望 3121(§3.8-R8),实得 %d(%s)——"+
+			"若为 3001 说明把\"报名行存在但活动行缺失\"当成了\"报名不存在\"", code, msg)
+	}
+	var signed model.CoursesActivitySigned
+	if err := db.Where("id = ?", 71).Take(&signed).Error; err != nil {
+		t.Fatalf("读报名行失败: %v", err)
+	}
+	if signed.Status != model.ActivitySignedSigned {
+		t.Fatalf("3121 被拒后 status 必须仍为 1,实得 %d", signed.Status)
+	}
+}
+
+// ---------------------------------------------------------------- ⑩ 老带新结算
+
+// TestRecommendSettleNoopDoesNotWrite 老带新结算:无 type=4 报名 ⇒ `settled=false` 且**零写入**。
+//
+// 会变红的条件:
+//   - handler 把 `settled` 恒写成 true ⇒ 断言 `false` 失败 → 红;
+//   - 服务层漏掉"游标无行 → LEAVE"⇒ used_log 出现 1 行 → 跨表断言红。
+func TestRecommendSettleNoopDoesNotWrite(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 801, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
+		Money: "50.00", Status: 1,
+	})
+
+	code, msg, data := doJSON(t, app, "POST", "/admin/activities/801/recommend-settle",
+		map[string]any{"userId": "U400", "orderNo": "1001"})
+	if code != 200 {
+		t.Fatalf("老带新结算期望 200,实得 %d(%s)", code, msg)
+	}
+	res := decodeData[struct {
+		Settled    bool  `json:"settled"`
+		ActivityID int64 `json:"activityId"`
+	}](t, data)
+	if res.Settled {
+		t.Fatalf("无 type=4 报名时 settled 必须为 false(dump LEAVE 分支),实得 true")
+	}
+	if n := countRows(t, db, &model.CoursesActivityUsedLog{}, "activity_id = ?", 801); n != 0 {
+		t.Fatalf("settled=false 必须零写入,实得 used_log %d 行", n)
+	}
+}
+
+// ---------------------------------------------------------------- ⑪ 取消充值活动
+
+// TestCancelRechargeUnknownCampusIs3104 校区不存在/未归属 ⇒ **3104**(端点 30)。
+//
+// 会变红的条件:删掉校区端口调用(或吞掉 3104)⇒ 变 200/5002 → 红。
+func TestCancelRechargeUnknownCampusIs3104(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 901, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
+		Money: "100.00", Status: 1,
+	})
+
+	code, msg, _ := doJSON(t, app, "POST", "/admin/activities/901/cancel-recharge",
+		map[string]any{"userId": "U500", "campusId": 999, "money": "10.00"})
+	if code != 3104 {
+		t.Fatalf("未知校区期望 3104,实得 %d(%s)", code, msg)
+	}
+}
+
+// ---------------------------------------------------------------- ⑫ 可用赠送
+
+// TestUsableBreakdownScopeAndShape 可用赠送:跨机构 1004 / 平台视角角色 1003 / items 构成与总量一致。
+//
+// 会变红的条件:
+//   - 删掉 `institutionId != sc.InstitutionID → 1004` ⇒ 能读到别的机构的池(200)→ 红;
+//   - 删掉平台视角角色白名单 ⇒ academic 角色拿全平台池(200)→ 红;
+//   - `usable` 不等于 Σ items 或 items 没按 activityId 升序 ⇒ 红;
+//   - 加上 `memo` 键(无列支撑)⇒ `"memo"` 不得出现的断言红。
+func TestUsableBreakdownScopeAndShape(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+	// 同机构两块池(活动 1001 余 60.00、活动 1002 余 30.00)+ 别家机构一块(活动 1003 余 999.00)
+	seedDiscountPool(t, db, 1, 1001, "U600", "100.00", "40.00")
+	seedDiscountPool(t, db, 2, 1002, "U600", "30.00", "0.00")
+	seedDiscountPool(t, db, 3, 1003, "U600", "999.00", "0.00")
+	if err := db.Model(&model.CoursesActivityUserDiscount{}).Where("id = ?", 3).
+		Update("institution_id", 2).Error; err != nil {
+		t.Fatalf("改池机构失败: %v", err)
+	}
+
+	code, msg, data := doJSON(t, app, "GET",
+		"/admin/activities/discounts/usable?userId=U600&institutionId=1&campusId=10", nil)
+	if code != 200 {
+		t.Fatalf("可用赠送期望 200,实得 %d(%s)", code, msg)
+	}
+	res := decodeData[struct {
+		UserID   string `json:"userId"`
+		CampusID int64  `json:"campusId"`
+		Usable   string `json:"usable"`
+		Items    []struct {
+			ActivityID int64  `json:"activityId"`
+			Amount     string `json:"amount"`
+		} `json:"items"`
+	}](t, data)
+	if res.Usable != "90.00" {
+		t.Fatalf("usable 应为 90.00(60+30,不含别家机构),实得 %q", res.Usable)
+	}
+	if len(res.Items) != 2 {
+		t.Fatalf("items 应有 2 项(按活动),实得 %d", len(res.Items))
+	}
+	if res.Items[0].ActivityID != 1001 || res.Items[1].ActivityID != 1002 {
+		t.Fatalf("items 必须按 activityId 升序,实得 %+v", res.Items)
+	}
+	sum := money.Amount(0)
+	for _, item := range res.Items {
+		amount, err := money.Parse(item.Amount)
+		if err != nil {
+			t.Fatalf("items[].amount 不是合法金额:%q", item.Amount)
+		}
+		sum = sum.Add(amount)
+	}
+	usable, _ := money.Parse(res.Usable)
+	if sum != usable {
+		t.Fatalf("usable(%s) 必须等于 Σ items(%s)", res.Usable, sum.String())
+	}
+	if strings.Contains(string(data), `"memo"`) {
+		t.Fatalf("items 不得下发 memo(赠送池无 memo 列,契约缺口待裁决):%s", string(data))
+	}
+
+	// 跨机构 → 1004
+	if code, _, _ := doJSON(t, app, "GET",
+		"/admin/activities/discounts/usable?userId=U600&institutionId=2&campusId=20", nil); code != 1004 {
+		t.Fatalf("跨机构查询期望 1004,实得 %d", code)
+	}
+	// 平台视角(institutionId=0):finance 放行;academic → 1003
+	if code, _, _ := doJSON(t, app, "GET",
+		"/admin/activities/discounts/usable?userId=U600&institutionId=0&campusId=0", nil); code != 200 {
+		t.Fatalf("平台视角(finance)应放行,实得 %d", code)
+	}
+	if code, _, _ := doJSON(t, app, "GET",
+		"/admin/activities/discounts/usable?userId=U600&institutionId=0&campusId=0",
+		nil, "X-Test-Scope", "academic"); code != 1003 {
+		t.Fatalf("平台视角 academic 角色期望 1003(设计 §3.2-A7),实得 %d", code)
+	}
+}
+
+// ---------------------------------------------------------------- ⑬ 赠送使用
+
+// TestDiscountUseNoopAndInsufficient 端点 32:金额 0 → `used=false` 且零写入;无池 → **3109**。
+//
+// 会变红的条件:
+//   - 把 `amount<=0` 改成报错/或照样扣 ⇒ used/库值断言红(§3.8-Z1 要求"跳过");
+//   - 删掉"可用不足 → 3109"⇒ 变 200 且池被扣成负 → 红。
+func TestDiscountUseNoopAndInsufficient(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 1101, Type: model.ActivityTypeVoucher, InstitutionID: 1, CampusID: 10,
+		Money: "100.00", Status: 1,
+	})
+	seedDiscountPool(t, db, 11, 1101, "U700", "100.00", "0.00")
+
+	// ① 金额 0 → no-op(不是错误)
+	code, msg, data := doJSON(t, app, "POST", "/admin/activities/discounts/use",
+		map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "0.00"})
+	if code != 200 {
+		t.Fatalf("金额 0 期望 200(dump no-op),实得 %d(%s)", code, msg)
+	}
+	if decodeData[struct {
+		Used bool `json:"used"`
+	}](t, data).Used {
+		t.Fatalf("金额 0 时 used 必须为 false")
+	}
+	if n := countRows(t, db, &model.CoursesActivityUserDiscountLog{}, "activity_user_discount_id = ?", 11); n != 0 {
+		t.Fatalf("金额 0 必须零写入,实得流水 %d 行", n)
+	}
+
+	// ② 超过可用 → 3109(Go 增强:dump 静默 LEAVE)
+	if code, _, _ := doJSON(t, app, "POST", "/admin/activities/discounts/use",
+		map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "200.00"}); code != 3109 {
+		t.Fatalf("可用不足期望 3109,实得 %d", code)
+	}
+
+	// ③ 正常使用 → used=true + 池已用额 + 负值流水(跨表)
+	//
+	// ⚠ 活动 type=3(代金券)的口径(§3.2 #15,dump 763229):**实际占用额 = 券面额**,
+	// 不是请求额("一次用完一张")。故请求 10.00、池却记 100.00 —— 这正是 dump 的既有行为,
+	// 若有人"顺手修正"成请求额,本断言立刻红(这是**有意锁定**的偏差点,不是测试笔误)。
+	code, msg, data = doJSON(t, app, "POST", "/admin/activities/discounts/use",
+		map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "10.00"})
+	if code != 200 {
+		t.Fatalf("正常使用期望 200,实得 %d(%s)", code, msg)
+	}
+	if !decodeData[struct {
+		Used bool `json:"used"`
+	}](t, data).Used {
+		t.Fatalf("正常使用 used 应为 true")
+	}
+	var pool model.CoursesActivityUserDiscount
+	if err := db.Where("id = ?", 11).Take(&pool).Error; err != nil {
+		t.Fatalf("读池失败: %v", err)
+	}
+	if !sameMoney(pool.DiscountAmountUsed, "100.00") {
+		t.Fatalf("代金券(type=3) 已用额应为券面额 100.00(dump 763229),实得 %q", pool.DiscountAmountUsed)
+	}
+	var logRow model.CoursesActivityUserDiscountLog
+	if err := db.Where("activity_user_discount_id = ?", 11).Take(&logRow).Error; err != nil {
+		t.Fatalf("读流水失败: %v", err)
+	}
+	if !sameMoney(logRow.DiscountAmountUsed, "-100.00") {
+		t.Fatalf("流水必须是负值(消费方向)且等于面额 -100.00,实得 %q", logRow.DiscountAmountUsed)
+	}
+}
+
+// ---------------------------------------------------------------- ⑭ 充值赠送使用
+
+// TestRechargeGiftUseRequiresAttendedStatus 端点 35:报名行必须 `status=2`(dump 游标 763262)。
+//
+// 会变红的条件:
+//   - 删掉 `status=2` 前置 ⇒ status=1 也扣(used=true)→ 红;
+//   - 扣减后 `activity_money_used` 未更新 ⇒ 跨表断言红。
+func TestRechargeGiftUseRequiresAttendedStatus(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 1201, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
+		Money: "100.00", Status: 1,
+	})
+	seedSignedRow(t, db, 121, 1201, "U800", model.ActivitySignedSigned, "100.00", nil) // status=1(仅报名)
+
+	code, msg, data := doJSON(t, app, "POST", "/admin/activities/recharge-gifts/use",
+		map[string]any{"userId": "U800", "activityId": 1201, "coursesStudentId": 1, "amount": "30.00"})
+	if code != 200 {
+		t.Fatalf("期望 200(no-op),实得 %d(%s)", code, msg)
+	}
+	if decodeData[struct {
+		Used bool `json:"used"`
+	}](t, data).Used {
+		t.Fatalf("status=1(未参加)时 used 必须为 false(dump 763262 前置)")
+	}
+
+	// 置为已参加 → 正常扣减
+	if err := db.Model(&model.CoursesActivitySigned{}).Where("id = ?", 121).
+		Update("status", model.ActivitySignedAttended).Error; err != nil {
+		t.Fatalf("改报名状态失败: %v", err)
+	}
+	code, msg, data = doJSON(t, app, "POST", "/admin/activities/recharge-gifts/use",
+		map[string]any{"userId": "U800", "activityId": 1201, "coursesStudentId": 1, "amount": "30.00"})
+	if code != 200 {
+		t.Fatalf("期望 200,实得 %d(%s)", code, msg)
+	}
+	if !decodeData[struct {
+		Used bool `json:"used"`
+	}](t, data).Used {
+		t.Fatalf("status=2 时 used 应为 true")
+	}
+	var signed model.CoursesActivitySigned
+	if err := db.Where("id = ?", 121).Take(&signed).Error; err != nil {
+		t.Fatalf("读报名行失败: %v", err)
+	}
+	if !sameMoney(signed.ActivityMoneyUsed, "30.00") {
+		t.Fatalf("activity_money_used 应为 30.00,实得 %q", signed.ActivityMoneyUsed)
+	}
+	if n := countRows(t, db, &model.CoursesActivityUsedLog{}, "activity_id = ? AND user_id = ?", 1201, "U800"); n != 1 {
+		t.Fatalf("used_log 应恰 1 行,实得 %d", n)
+	}
+}
+
+// ---------------------------------------------------------------- ⑮ 三条退回
+
+// TestRefundEndpointsReportRefundedFlag 端点 33/34/36:无可用流水 ⇒ `refunded=false`(不是恒 true)。
+//
+// 会变红的条件:把响应的 `refunded` 写成常量 true("操作成功即 true")⇒ 三条断言全红。
+// 此外 33 的正常路径做**跨表**断言(流水回冲 + 池已用额下降)。
+func TestRefundEndpointsReportRefundedFlag(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+
+	// 端点 33:无流水 → false
+	code, msg, data := doJSON(t, app, "POST", "/admin/activities/discounts/refund",
+		map[string]any{"coursesStudentId": 4242, "amount": "10.00"})
+	if code != 200 {
+		t.Fatalf("赠送退回期望 200,实得 %d(%s)", code, msg)
+	}
+	if decodeData[struct {
+		Refunded bool `json:"refunded"`
+	}](t, data).Refunded {
+		t.Fatalf("无可用流水时 refunded 必须为 false")
+	}
+
+	// 端点 34:无 log → false
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 1301, Type: model.ActivityTypeVoucher, InstitutionID: 1, CampusID: 10,
+		Money: "100.00", Status: 1,
+	})
+	code, msg, data = doJSON(t, app, "POST", "/admin/activities/discounts/refund-by-activity",
+		map[string]any{"userId": "U900", "activityId": 1301, "amount": "10.00"})
+	if code != 200 {
+		t.Fatalf("按活动退回期望 200,实得 %d(%s)", code, msg)
+	}
+	if decodeData[struct {
+		Refunded bool `json:"refunded"`
+	}](t, data).Refunded {
+		t.Fatalf("无 log 时 refunded 必须为 false(dump no-op)")
+	}
+
+	// 端点 36:无 used_log → false
+	code, msg, data = doJSON(t, app, "POST", "/admin/activities/recharge-gifts/refund",
+		map[string]any{"coursesStudentId": 5555, "amount": "10.00"})
+	if code != 200 {
+		t.Fatalf("充值赠送退回期望 200,实得 %d(%s)", code, msg)
+	}
+	if decodeData[struct {
+		Refunded bool `json:"refunded"`
+	}](t, data).Refunded {
+		t.Fatalf("无 used_log 时 refunded 必须为 false")
+	}
+}
+
+// ---------------------------------------------------------------- ⑯ 回访
+
+// TestVisitRejectsCrossActivitySigned 回访:报名行不属于路径活动 ⇒ **3001**;正常 ⇒ 落库 1 行。
+//
+// 会变红的条件:
+//   - 删掉 `signed.ActivityID != in.ActivityID` 判断 ⇒ 跨活动回访 200 且写出一行(无法事后筛出)→ 红;
+//   - `visited` 恒 false 或漏写库 ⇒ 跨表断言红。
+func TestVisitRejectsCrossActivitySigned(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newActivityTestApp(t, db)
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 1401, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
+		Money: "0.00", Status: 1,
+	})
+	seedActivityRow(t, db, model.CoursesActivity{
+		ID: 1402, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
+		Money: "0.00", Status: 1,
+	})
+	seedSignedRow(t, db, 141, 1402, "U950", model.ActivitySignedSigned, "0.00", nil)
+
+	// 报名行 141 属于活动 1402,却提交到活动 1401 的回访端点 → 3001
+	code, msg, _ := doJSON(t, app, "POST", "/admin/activities/1401/visits",
+		map[string]any{"activitySignedId": 141, "memo": "电话回访"})
+	if code != 3001 {
+		t.Fatalf("跨活动回访期望 3001,实得 %d(%s)", code, msg)
+	}
+	if n := countRows(t, db, &model.CoursesActivityVisit{}, "activity_signed_id = ?", 141); n != 0 {
+		t.Fatalf("被拒的跨活动回访不得写库,实得 %d 行", n)
+	}
+
+	// 正确活动 → 200 + 1 行 visit
+	code, msg, data := doJSON(t, app, "POST", "/admin/activities/1402/visits",
+		map[string]any{"activitySignedId": 141, "memo": "电话回访"})
+	if code != 200 {
+		t.Fatalf("回访登记期望 200,实得 %d(%s)", code, msg)
+	}
+	res := decodeData[struct {
+		Visited    bool  `json:"visited"`
+		ActivityID int64 `json:"activityId"`
+	}](t, data)
+	if !res.Visited || res.ActivityID != 1402 {
+		t.Fatalf("回访响应错:%+v", res)
+	}
+	if n := countRows(t, db, &model.CoursesActivityVisit{}, "activity_signed_id = ?", 141); n != 1 {
+		t.Fatalf("回访应落库 1 行,实得 %d", n)
+	}
+}
+
+// sameMoney 比较两个元字符串的**金额值**(不比较字面量)。
+//
+// 为什么不能直接比字符串:sqlite 把 `DECIMAL(12,2)` 存成 NUMERIC,读回时 `"0.00"` 会变成 `"0"`、
+// `"100.00"` 变成 `"100"`(MySQL 的 DECIMAL 会原样返回两位)。断言必须比"值",否则同一用例
+// 在 sqlite 上红、在 MySQL 上绿 —— 那是"测试环境差异",不是实现差异。
+// 生产语义(对外 JSON)由 `money.Amount.MarshalJSON` 保证恒两位小数,不在本助手范围。
+func sameMoney(got, want string) bool {
+	gotAmount, gerr := money.Parse(got)
+	wantAmount, werr := money.Parse(want)
+	if gerr != nil || werr != nil {
+		return false
+	}
+	return gotAmount == wantAmount
+}
+
+// withFields 复制 base 并覆盖若干 k/v(k,v 交替),避免用例间互相污染 body。
+func withFields(base map[string]any, kv ...any) map[string]any {
+	out := make(map[string]any, len(base)+len(kv)/2)
+	for k, v := range base {
+		out[k] = v
+	}
+	for i := 0; i+1 < len(kv); i += 2 {
+		key, _ := kv[i].(string)
+		out[key] = kv[i+1]
+	}
+	return out
+}

+ 276 - 0
internal/academic/handler/dto_activity.go

@@ -0,0 +1,276 @@
+// 活动域请求 DTO(端点 22~37 的**入参形状**)。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md
+//   - §2.2 活动(/admin/activities)端点 22~37 的「请求要点」列;
+//   - §3.4 B 活动端点字段表(响应侧;本文件只放入参 + 少量响应拼装结构);
+//   - §1.5 B1/B2(时间与量纲:`startTime`/`endTime` 是业务时间列,金额是两位小数字符串)。
+//
+// ---------------------------------------------------------------------------
+// 三条 DTO 铁律(与 `dto_fund.go` 同源,**不另立一套**)
+// ---------------------------------------------------------------------------
+//
+//  1. **金额一律 `string`**(`"100.00"`),Handler 用 `parseMoney` → `money.Parse`。
+//     不声明 `float64`/`money.Amount`:前者把二进制浮点误差带进对账,后者让 JSON number 也能解码
+//     (等于偷偷接受 float —— 见 `parseMoney` 注释的"连宽松接受数字都不做")。
+//  2. **缺参 → 2001 由 `validate` tag 表达;值非法 → 2002 由 Handler 显式判**。
+//     因此**枚举字段不写 `oneof=`**(`oneof` 失败会被 `infra/validator` 归成 2001,
+//     而"传了非法枚举"按设计 §10.1 属 2002)。枚举白名单一律在 Handler 里判。
+//  3. **可选与必填要能区分**:`numberSign` 用 `*int32`(契约 §3.4 B 行 25(v1.6)「传即 2002」,
+//     "传了 0"与"没传"必须区分开,只有指针能表达);其它可选字段用值类型 + 空串/0 语义。
+//
+// 命名:camelCase JSON(与契约逐字一致);Go 侧 ID 后缀全大写。
+package handler
+
+// CreateActivityReq 端点 23 新建活动请求体(`POST /admin/activities`)。
+//
+// 字段 → 目标列(DDL 第 7 表):
+//
+//	type→type / campusId→campus_id / title→title / content→content / memo→memo /
+//	thumbnail→thumbnail / money→money / startTime→start_time / endTime→end_time /
+//	numberMax→number_max / status→status / toped→toped / subjectId→subject_id / gradeId→grade_id
+//
+// **不接受的字段**(写进来也无效,故不声明):
+//
+//	numberSign  报名计数列(只能由报名/取消增减;契约行 22 注「活动更新时不可写」)
+//	view        浏览量(详情页访问自增)
+//	createTime  由 DB 默认写入(业务时间列,勿手改)
+//	institutionId 由请求作用域决定(见下方说明)
+type CreateActivityReq struct {
+	// Type 活动类型(必填;2 充值赠送 / 3 代金券 / 4 老带新;其它 → 2002)。
+	Type int8 `json:"type" validate:"required"`
+	// CampusID 校区(0/缺省 = 机构所有自营校区;>0 时校验归属本机构)。
+	CampusID int64 `json:"campusId"`
+	// InstitutionID 机构(可选:非超管传了但与本作用域不一致 → **1004**;超管可用它指定)。
+	InstitutionID int64 `json:"institutionId"`
+	// Title 活动标题(必填,契约行 23 的"新建"未列字段,此处按旧系统编辑页的必填项取 title)。
+	Title string `json:"title" validate:"required,max=500"`
+	// Content 活动内容(HTML;可空)。
+	Content string `json:"content"`
+	// Memo 备注(可空)。
+	Memo string `json:"memo" validate:"max=500"`
+	// Thumbnail 缩略图 URL(可空)。
+	Thumbnail string `json:"thumbnail" validate:"max=500"`
+	// Money 活动金额/额度(字符串;可空 → 0.00;**≤0 不拒**,§3.8-Z1)。
+	Money string `json:"money"`
+	// StartTime 开始时间(RFC3339 或 `2006-01-02`;可空)。
+	StartTime string `json:"startTime"`
+	// EndTime 结束时间(同上;给了 StartTime 时不得早于它 → 否则 2002)。
+	EndTime string `json:"endTime"`
+	// NumberMax 名额上限(0 = 不限;负数 → 2002)。
+	NumberMax int32 `json:"numberMax"`
+	// Status 活动状态(0 停用 / 1 开启 / 2 已结束;缺省 0 = DDL 默认)。
+	Status int8 `json:"status"`
+	// Toped 置顶(0/1)。
+	Toped int8 `json:"toped"`
+	// SubjectID 科目(0 = 混合)。
+	SubjectID int32 `json:"subjectId"`
+	// GradeID 年级(0 = 混合)。
+	GradeID int32 `json:"gradeId"`
+}
+
+// UpdateActivityReq 端点 25 更新活动请求体(`PUT /admin/activities/:id`,**整体替换**语义)。
+//
+// 与 `CreateActivityReq` 的差异只有两处:
+//   - `NumberSign *int32`:**只要出现即 2002**(契约 §3.4 B 行 25,v1.6)。用指针是为了区分
+//     "没传"与"传了 0"——`{"numberSign":0}` 同样是调用方试图写计数列,必须拒;
+//   - 字段必填性更松:`title` 仍必填(整体替换后不能没有标题),其余同新建。
+type UpdateActivityReq struct {
+	// Type 活动类型(2/3/4)。
+	Type int8 `json:"type" validate:"required"`
+	// CampusID 校区(0 = 机构所有自营校区)。
+	CampusID int64 `json:"campusId"`
+	// InstitutionID 机构(非超管须与本作用域一致)。
+	InstitutionID int64 `json:"institutionId"`
+	// Title 活动标题(必填)。
+	Title string `json:"title" validate:"required,max=500"`
+	// Content 活动内容(可空;PUT 语义下未传即清空)。
+	Content string `json:"content"`
+	// Memo 备注。
+	Memo string `json:"memo" validate:"max=500"`
+	// Thumbnail 缩略图。
+	Thumbnail string `json:"thumbnail" validate:"max=500"`
+	// Money 活动金额(≤0 允许)。
+	Money string `json:"money"`
+	// StartTime 开始时间。
+	StartTime string `json:"startTime"`
+	// EndTime 结束时间。
+	EndTime string `json:"endTime"`
+	// NumberMax 名额上限。
+	NumberMax int32 `json:"numberMax"`
+	// Status 活动状态。
+	Status int8 `json:"status"`
+	// Toped 置顶。
+	Toped int8 `json:"toped"`
+	// SubjectID 科目。
+	SubjectID int32 `json:"subjectId"`
+	// GradeID 年级。
+	GradeID int32 `json:"gradeId"`
+	// NumberSign **禁写列**:非 nil 即 2002("传了"的判据,见类型注释)。
+	NumberSign *int32 `json:"numberSign"`
+}
+
+// SignActivityReq 端点 26 报名请求体(`POST /admin/activities/:id/sign`)。
+//
+// 对应 SP19 `sp_courses_activity_signed_add(uid, activityid, status, recommendUid, createUid)`:
+// `activityid` 取路径、`createUid` 取作用域,故 body 只有前三个。
+type SignActivityReq struct {
+	// UserID 报名用户(必填;ULID)。
+	UserID string `json:"userId" validate:"required,max=26"`
+	// Status 报名状态(可空:<1 → 1,dump 762924-762926 同口径)。
+	Status int32 `json:"status"`
+	// RecommendUserID 推荐人(可空;老带新活动才有意义)。
+	RecommendUserID string `json:"recommendUserId" validate:"max=26"`
+}
+
+// CancelActivitySignReq 端点 27 取消报名请求体(`POST /admin/activities/:id/cancel-sign`)。
+//
+// 对应 SP20 `sp_courses_activity_signed_cancel(uid, activityid)`:`activityid` 取路径。
+type CancelActivitySignReq struct {
+	// UserID 报名用户(必填)。
+	UserID string `json:"userId" validate:"required,max=26"`
+}
+
+// SettleRecommendReq 端点 29 老带新结算请求体(`POST /admin/activities/:id/recommend-settle`)。
+//
+// 对应 SP18 `sp_courses_activity_use_activity_recommend(uid, order, remark, createUid)`;
+// `createUid` 取作用域。
+type SettleRecommendReq struct {
+	// UserID 被推荐人(必填)。
+	UserID string `json:"userId" validate:"required,max=26"`
+	// OrderNo 报课记录 ID 的字符串形式(必填:活动上机构/校区为 0 时靠它回查)。
+	OrderNo string `json:"orderNo" validate:"required,max=100"`
+	// Remark 备注(可空)。
+	Remark string `json:"remark" validate:"max=500"`
+}
+
+// CancelRechargeReq 端点 30 取消充值活动请求体(`POST /admin/activities/:id/cancel-recharge`)。
+//
+// 对应 SP8 `sp_user_expense_cancel_recharge_activity(uid, activityid, campusid, money, requestid, remark, createUid)`;
+// `activityid` 取路径、`createUid` 取作用域。
+type CancelRechargeReq struct {
+	// UserID 用户(必填)。
+	UserID string `json:"userId" validate:"required,max=26"`
+	// CampusID 校区(必填 >0:资金落库必须有明确校区;校区不存在 → 3104)。
+	CampusID int64 `json:"campusId" validate:"required,gt=0"`
+	// Money 取消金额(必填字符串)。
+	Money string `json:"money" validate:"required"`
+	// RequestID 幂等请求号(可空 = 不享幂等)。
+	RequestID string `json:"requestId" validate:"max=64"`
+	// Remark 备注(可空)。
+	Remark string `json:"remark" validate:"max=500"`
+}
+
+// UseActivityDiscountReq 端点 32 使用赠送请求体(`POST /admin/activities/discounts/use`)。
+//
+// 对应 SP15 `sp_courses_activity_user_discount_use(uid, activityid, stuCourseId, amount, memo)`。
+type UseActivityDiscountReq struct {
+	// UserID 学生(必填)。
+	UserID string `json:"userId" validate:"required,max=26"`
+	// ActivityID 活动(必填 >0:**单活动**取池,不得跨活动扣减)。
+	ActivityID int64 `json:"activityId" validate:"required,gt=0"`
+	// CoursesStudentID 报课记录(→ discount_log.courses_student_id)。
+	CoursesStudentID int64 `json:"coursesStudentId"`
+	// Amount 使用金额(字符串;<=0 → no-op,§3.8-Z1)。
+	Amount string `json:"amount"`
+	// Memo 备注(可空)。
+	Memo string `json:"memo" validate:"max=500"`
+}
+
+// RefundActivityDiscountReq 端点 33 赠送退回请求体(`POST /admin/activities/discounts/refund`)。
+//
+// 对应 SP16 `sp_courses_activity_user_discount_refund(stuCourseId, amount, memo)`。
+type RefundActivityDiscountReq struct {
+	// CoursesStudentID 报课记录(必填 >0:退回对象按流水 `courses_student_id` 定位)。
+	CoursesStudentID int64 `json:"coursesStudentId" validate:"required,gt=0"`
+	// Amount 应退金额(字符串;<=0 → no-op)。
+	Amount string `json:"amount"`
+	// Memo 备注(可空 → "退回赠送金额")。
+	Memo string `json:"memo" validate:"max=500"`
+}
+
+// RefundDiscountByActivityReq 端点 34 按活动退回请求体
+// (`POST /admin/activities/discounts/refund-by-activity`)。
+//
+// 对应 SP17 `sp_courses_activity_user_discount_refund_user_activity(uid, activityid, amount, memo)`。
+type RefundDiscountByActivityReq struct {
+	// UserID 学生(必填)。
+	UserID string `json:"userId" validate:"required,max=26"`
+	// ActivityID 活动(必填 >0)。
+	ActivityID int64 `json:"activityId" validate:"required,gt=0"`
+	// Amount 应退金额(字符串;type=3 代金券会被服务端按 A19 改为"最近一条 log 的 ABS")。
+	Amount string `json:"amount"`
+	// Memo 备注(可空 → "退回活动金额")。
+	Memo string `json:"memo" validate:"max=500"`
+}
+
+// UseRechargeGiftReq 端点 35 充值赠送使用请求体(`POST /admin/activities/recharge-gifts/use`)。
+//
+// 对应 SP12 `sp_courses_activity_use_activity_recharge(uid, activityid, stuCourseId, amount, memo)`。
+type UseRechargeGiftReq struct {
+	// UserID 学生(必填)。
+	UserID string `json:"userId" validate:"required,max=26"`
+	// ActivityID 活动(必填 >0)。
+	ActivityID int64 `json:"activityId" validate:"required,gt=0"`
+	// CoursesStudentID 报课记录(→ used_log.order_no)。
+	CoursesStudentID int64 `json:"coursesStudentId"`
+	// Amount 使用金额(字符串;<=0 → no-op)。
+	Amount string `json:"amount"`
+	// Memo 备注(可空 → "使用充值赠送金额")。
+	Memo string `json:"memo" validate:"max=500"`
+}
+
+// RefundRechargeGiftReq 端点 36 充值赠送退回请求体(`POST /admin/activities/recharge-gifts/refund`)。
+//
+// 对应 SP13 `sp_courses_activity_refund_activity_recharge(stuCourseId, amount, memo)`。
+type RefundRechargeGiftReq struct {
+	// CoursesStudentID 报课记录(必填 >0)。
+	CoursesStudentID int64 `json:"coursesStudentId" validate:"required,gt=0"`
+	// Amount 应退金额(字符串;<=0 → no-op)。
+	Amount string `json:"amount"`
+	// Memo 备注(可空 → "退回活动金额")。
+	Memo string `json:"memo" validate:"max=500"`
+}
+
+// CreateVisitReq 端点 37 回访登记请求体(`POST /admin/activities/:id/visits`)。
+//
+// 契约 §2.2 行 37 写作 `activitySignedId,memo`(**两个都无问号 ⇒ 必填**)。
+type CreateVisitReq struct {
+	// ActivitySignedID 报名行(必填 >0;不存在/跨作用域 → 3001)。
+	ActivitySignedID int64 `json:"activitySignedId" validate:"required,gt=0"`
+	// Memo 回访内容(必填)。
+	Memo string `json:"memo" validate:"required,max=500"`
+}
+
+// ---------------------------------------------------------------- 响应拼装(只放契约要求的键)
+
+// activitySignResp 端点 26 响应(契约 §3.4 B 行 26:只要求 `signedId`)。
+type activitySignResp struct {
+	// SignedID 报名行 ID。
+	SignedID int64 `json:"signedId"`
+}
+
+// activityCancelledResp 端点 27 / 30 响应(契约 §3.4 B 行 27、30:只要求 `cancelled`)。
+type activityCancelledResp struct {
+	// Cancelled 是否已取消(成功恒 true)。
+	Cancelled bool `json:"cancelled"`
+}
+
+// activitySettledResp 端点 29 响应(契约 §3.4 B 行 29:`settled` + `activityId`)。
+type activitySettledResp struct {
+	// Settled 本次是否真正结算(false = dump 的 LEAVE 分支:无推荐人/金额≤0/校区回查不到,零写入)。
+	Settled bool `json:"settled"`
+	// ActivityID 结算的活动 ID。
+	ActivityID int64 `json:"activityId"`
+}
+
+// activityUsedResp 端点 32 / 35 响应(契约 §3.4 B 行 32、35:`used`)。
+type activityUsedResp struct {
+	// Used 是否真的发生了使用(金额 ≤0 时服务端 no-op ⇒ false)。
+	Used bool `json:"used"`
+}
+
+// activityRefundedResp 端点 33 / 34 / 36 响应(契约 §3.4 B 行 33、34、36:`refunded`)。
+type activityRefundedResp struct {
+	// Refunded 是否真的发生了退回(金额 ≤0 或无可用行 ⇒ false)。
+	Refunded bool `json:"refunded"`
+}

+ 2 - 0
internal/academic/handler/fund_handler_test.go

@@ -75,6 +75,8 @@ func newHandlerTestDB(t *testing.T) *gorm.DB {
 		&model.CoursesActivity{}, &model.CoursesActivitySigned{},
 		&model.CoursesActivityUserDiscount{}, &model.CoursesActivityUserDiscountLog{},
 		&model.CoursesActivityUsedLog{},
+		// 端点 37(回访)需要;AC-13b 追加(活动域 7 表中的第 7 张,其余 6 张已在此)。
+		&model.CoursesActivityVisit{},
 	} {
 		if mErr := db.AutoMigrate(tbl); mErr != nil {
 			t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, mErr)

+ 24 - 6
internal/academic/handler/handler.go

@@ -25,7 +25,8 @@
 // 六条接入层纪律(AC-12 验收项,逐条对应实现位置)
 // ---------------------------------------------------------------------------
 //
-//	① 端点覆盖:端点 1~17 + 21 → fund_handler.go(1~16)、bill_handler.go(17、21)。
+//	① 端点覆盖:端点 1~17 + 21 → fund_handler.go(1~16)、bill_handler.go(17、21);
+//	   端点 22~37(活动)→ activity_handler.go(AC-13b)。
 //	② `validateReq` 失败 → **2001**;金额字符串非法 / 枚举非法 → **2002**(两者不串):
 //	   `validator.Validate` 负责 2001(缺参/格式),`parseMoney`/`parseTypeFilter` 负责 2002。
 //	③ `scope.FromFiber` 取不到 → **1001**:`mustScope`。
@@ -153,20 +154,37 @@ type scopedServices struct {
 	query *service.FinanceQueryService
 	// requests 工单服务(端点 13~16)。
 	requests *service.BalanceRequestService
+	// activity 报名/取消报名(端点 26、27;SP19/SP20,有 dump 对照)。
+	activity *service.ActivityService
+	// activityAdmin 活动管理面(端点 22~25、28、37;无 dump SP 对照,走 DDL 列)。
+	activityAdmin *service.ActivityAdminService
+	// activityDiscount 赠送池(端点 31~36;SP12~SP17)。
+	activityDiscount *service.ActivityDiscountService
+	// recommend 老带新结算 + 取消充值活动(端点 29、30;SP18/SP8 转调)。
+	recommend *service.RecommendService
 }
 
 // servicesFor 按请求作用域装配一套服务。
 //
 // 入参:sc 作用域(**非 nil**——调用方已用 mustScope 判定)。
 // 出参:*scopedServices(字段全部非 nil)。
+//
+// 装配要点(academic 的 Service 在构造期绑定作用域,见 Handler 类型注释):
+//   - `activityAdmin` / `recommend` 需要校区端口(校區→机构反查,跨域只读)⇒ 注入同一个 reader;
+//   - `activity` / `recommend` 需要成员校验器(对象级越权断言)⇒ 注入 `h.member`(可 nil);
+//   - `activityDiscount` 的越权防线在 Handler 角色白名单(设计 §3.2-A7),故不注入 checker。
 func (h *Handler) servicesFor(sc *appscope.Scope) *scopedServices {
 	campus := port.NewOrgCampusReader(h.db)
 	return &scopedServices{
-		fund:     service.NewFundService(h.db, sc, h.member),
-		refund:   service.NewRefundService(h.db, sc, campus, h.member),
-		bill:     service.NewBillService(h.db, sc).WithMembershipChecker(h.member),
-		query:    service.NewFinanceQueryService(h.db, sc, h.member),
-		requests: service.NewBalanceRequestService(h.db, sc, h.member),
+		fund:             service.NewFundService(h.db, sc, h.member),
+		refund:           service.NewRefundService(h.db, sc, campus, h.member),
+		bill:             service.NewBillService(h.db, sc).WithMembershipChecker(h.member),
+		query:            service.NewFinanceQueryService(h.db, sc, h.member),
+		requests:         service.NewBalanceRequestService(h.db, sc, h.member),
+		activity:         service.NewActivityService(h.db, sc).WithMembershipChecker(h.member),
+		activityAdmin:    service.NewActivityAdminService(h.db, sc).WithCampusReader(campus),
+		activityDiscount: service.NewActivityDiscountService(h.db, sc),
+		recommend:        service.NewRecommendService(h.db, sc).WithCampusReader(campus).WithMembershipChecker(h.member),
 	}
 }
 

+ 454 - 0
internal/academic/repository/activity_admin_repo.go

@@ -0,0 +1,454 @@
+// 活动管理仓储(端点 22~25 / 28 / 37 的**数据访问原语**)。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §2.2(活动 16 端点)/ §3.4 B(字段级定义);
+// unified/03-migration/academic-domain-design.md §4.2(端点清单)/ §3.2 #19-#20(报名状态机)/ §10.8(作用域)。
+//
+// ---------------------------------------------------------------------------
+// 为什么单开一个文件(而不写进 activity_repo.go)
+// ---------------------------------------------------------------------------
+//
+// `activity_repo.go` 的每个方法都**逐条对应某个 dump SP**(signed_add / signed_cancel / 资金侧原语),
+// 方法头的行号注释是可复核的对照依据。本文件的方法**在 dump 里没有任何 SP 对应**:
+//
+//	活动 CRUD(端点 22~25)  旧系统由 PHP 直接 INSERT/UPDATE `t_courses_activity`(无存储过程)
+//	报名列表(端点 28)      旧系统 PHP 直查
+//	回访登记(端点 37)      `t_courses_activity_visit` 旧库基本空表(AUTO_INCREMENT=1,零 SP 引用)
+//
+// 把"有 dump 依据"与"无 dump 依据"混在同一文件里,正是"后人把自创实现当权威口径引用"的诱因
+// (设计 §3.8-c X1 就栽在这一点上)。故本文件做**物理隔离**:每个方法头显式写「dump: 无(PHP 直写)」,
+// 字段来源只标**目标态 DDL 行号**(`internal/academic/model/testdata/academic-model-ddl.sql`),
+// **不引用任何 SP 行号**——没有的东西不编。
+//
+// ---------------------------------------------------------------------------
+// 作用域(与 activity_repo.go 完全一致,不另立一套)
+// ---------------------------------------------------------------------------
+//
+//	t_courses_activity / t_courses_activity_signed 都是 `institution_id + campus_id` 双列,
+//	且 `campus_id = 0` 表示「本机构所有**自营**校区」(DDL §2「7.」)⇒ 校区维度必须用
+//	`campus_id IN (?, 0)` **超集**语义,统一经 `scopedCampusRows`(同包函数;机构强制过滤不放松)。
+//
+// 事务纪律(设计 §10.3):写方法一律接 `tx *gorm.DB`(**仓储不开事务**);读方法经 `db(ctx)`,
+// ctx 内已注入事务时自动复用同一 tx,保证事务内读一致。
+package repository
+
+import (
+	"context"
+	"sort"
+	"strings"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// ActivityListFilter 活动列表过滤条件(端点 22 的 `type?` / `status?` / `keyword?`)。
+//
+// 字段语义(全部**可选**,nil/空串 = 不筛):
+//   - Type:活动类型(2 充值赠送 / 3 代金券 / 4 老带新);白名单校验在 Handler(非法 → 2002);
+//   - Status:活动状态(0 停用 / 1 开启 / 2 已结束);白名单校验在 Handler(非法 → 2002);
+//   - Keyword:关键字,匹配 `t_courses_activity.title`(`LIKE %kw%`)。
+//
+// ⚠ `keyword` 只搜 `title` 的理由(字段准入纪律,契约 §3.4 ③):契约只写「关键字」,
+// 未指定列;本表的可读文本列只有 `title` / `memo` / `content`,其中 `content` 是 longtext HTML
+// (`LIKE` 会全表扫且命中 HTML 标签),`memo` 是内部备注(不是用户可见标题)。
+// 取 `title` 是**唯一能解释"用户在列表里看到什么就搜什么"的列**;扩展到 memo 属产品决策,不在本次。
+type ActivityListFilter struct {
+	// Type 活动类型过滤(nil = 不筛)。
+	Type *int8
+	// Status 活动状态过滤(nil = 不筛)。
+	Status *int8
+	// Keyword 标题关键字(空 = 不筛)。
+	Keyword string
+}
+
+// activitySignedCount 单活动的有效报名人数(`signed` 字段的批量来源)。
+type activitySignedCount struct {
+	// ActivityID 活动 ID。
+	ActivityID int64 `gorm:"column:activity_id"`
+	// SignedTotal 有效报名数(`status <> 0`)。
+	SignedTotal int32 `gorm:"column:signed_total"`
+}
+
+// ListActivities 活动分页列表(端点 22)。
+//
+// dump:**无**(旧系统 PHP 直查 `t_courses_activity`)。
+// 字段来源:目标态 DDL 第 7 表 `t_courses_activity`(第 242-268 行)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - f 过滤条件(全可选);
+//   - page/pageSize 分页(**内部再钳一次**,不信调用方:见 `PageQuery.Normalize` 纪律)。
+//
+// 出参:
+//   - []model.CoursesActivity:当前页活动(**空 → 长度 0 的非 nil 切片**);
+//   - int64:同过滤条件总数(与列表**同一 WHERE、同一快照**,不"先数后筛");
+//   - ErrDBError(5002)。
+//
+// 排序:`toped DESC, id DESC`(置顶优先,其次新建在前)。**必须带第二排序键 `id`**:
+// 只按 `toped` 排时并列行的顺序由引擎决定,分页会出现"同一条出现两次/漏一条"(§3.8-R9 同类风险)。
+func (r *ActivityRepo) ListActivities(
+	ctx context.Context, f ActivityListFilter, page, pageSize int,
+) ([]model.CoursesActivity, int64, error) {
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, 0, err
+	}
+	page, pageSize = ClampPage(page, pageSize)
+
+	// build 每次返回**全新**链(Count 与 Find 各跑一条,不共用被 Count 污染过的语句)。
+	build := func() *gorm.DB {
+		q := scopedCampusRows(db.Model(&model.CoursesActivity{}), r.Sc, "")
+		if f.Type != nil {
+			q = q.Where("type = ?", *f.Type)
+		}
+		if f.Status != nil {
+			q = q.Where("status = ?", *f.Status)
+		}
+		if kw := strings.TrimSpace(f.Keyword); kw != "" {
+			q = q.Where("title LIKE ?", "%"+kw+"%")
+		}
+		return q
+	}
+
+	var total int64
+	if err := build().Count(&total).Error; err != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	rows := make([]model.CoursesActivity, 0, pageSize)
+	if err := build().Order("toped DESC, id DESC").
+		Limit(pageSize).Offset(PageOffset(page, pageSize)).
+		Find(&rows).Error; err != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return rows, total, nil
+}
+
+// CountSignedByActivityIDs 批量统计多个活动的**有效**报名人数(端点 22 的 `signed` 字段)。
+//
+// dump:**无**(PHP 直查)。判据与 dump `762928` 的 3105 查重同口径:`status <> 0`(取消的报名不算)。
+//
+// 入参:ctx;ids 活动 ID 集合(空/全非法 → 返回空 map 且**不发 SQL**)。
+// 出参:map[activityID]报名人数(**缺失的 id 不在 map 中**,调用方按 0 处理);ErrDBError。
+//
+// 为什么不在 `ListActivities` 里 JOIN 聚合:`signed` 表的行数远大于活动表,
+// `LEFT JOIN + GROUP BY` 会让"只查 10 个活动"也扫一遍报名表;分开查可**按 id 集合精确裁剪**(防 N+1 也不放大)。
+//
+// 作用域:**不叠**。入参 id 全部来自已过作用域的活动查询,报名行的归属已由活动归属确定;
+// 叠 `campus_id IN (?,0)` 会在冗余列脏的历史行上漏计(同 `ExistsSigned` 的口径与理由)。
+func (r *ActivityRepo) CountSignedByActivityIDs(ctx context.Context, ids []int64) (map[int64]int32, error) {
+	out := make(map[int64]int32, len(ids))
+	clean := make([]int64, 0, len(ids))
+	seen := make(map[int64]struct{}, len(ids))
+	for _, id := range ids {
+		if id <= 0 {
+			continue
+		}
+		if _, ok := seen[id]; ok {
+			continue
+		}
+		seen[id] = struct{}{}
+		clean = append(clean, id)
+	}
+	if len(clean) == 0 {
+		return out, nil
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	rows := make([]activitySignedCount, 0, len(clean))
+	if err := db.Model(&model.CoursesActivitySigned{}).
+		Select("activity_id, COUNT(1) AS signed_total").
+		Where("activity_id IN ?", clean).
+		Where("status <> ?", model.ActivitySignedCancelled).
+		Group("activity_id").
+		Scan(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	for i := range rows {
+		out[rows[i].ActivityID] = rows[i].SignedTotal
+	}
+	return out, nil
+}
+
+// CreateActivity 新建活动(端点 23)。
+//
+// dump:**无**(PHP 直写)。列清单来源:目标态 DDL 第 7 表(第 242-268 行)。
+//
+// 入参:ctx;tx(**必传**:机构/校区校验与写入同事务,避免"校验通过后机构被改");row 待插入行。
+// 出参:nil 成功(`row.ID` 被回填);ErrBadParam(row 非法)/ ErrInternal(漏传 tx)/ ErrDBError。
+//
+// 红线:
+//   - `number_sign` **由本方法强制清零**:它是"报名人数"计数列,只能由 `IncreaseNumberSign` 增减;
+//     新建时带上外部传入值会造成"报名数凭空多出来"(契约 §3.4 B 行 22 注亦写「活动更新时不可写」);
+//   - `institution_id` 由 Service 从请求作用域写入(不信任 body),`campus_id` 已由 Service 做
+//     "校区属于本机构"校验(跨机构 → 1004)后传进来。
+func (r *ActivityRepo) CreateActivity(ctx context.Context, tx *gorm.DB, row *model.CoursesActivity) error {
+	if err := requireTx(tx, "CreateActivity"); err != nil {
+		return err
+	}
+	if row == nil {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CreateActivity 需要 row"))
+	}
+	if row.InstitutionID <= 0 {
+		return infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: CreateActivity 需要 institution_id>0(作用域列禁 0)"))
+	}
+	row.NumberSign = 0
+	if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}
+
+// UpdateActivity 更新活动可写列(端点 25)。
+//
+// dump:**无**(PHP 直写)。可写列白名单由**调用方**(Service)构造 `patch` 决定,
+// 本方法只负责"按 id + 作用域执行整列更新并校验影响行数"。
+//
+// 入参:
+//   - ctx;tx(**必传**);
+//   - id 活动 ID(<=0 → ErrBadParam);
+//   - patch 列名 → 新值(**空 patch → ErrBadParam**:不是 no-op,是调用方 bug,静默成功会骗过前端)。
+//
+// 出参:(受影响行数, error);0 = 活动不存在或不在作用域内 ⇒ Service 返回 3001。
+//
+// 红线(与 §10.3「资金写路径必须校验影响行数」同源):
+//   - **必须**把 `RowsAffected==0` 交给调用方判定 —— 静默 0 行会让"更新了一个不存在/越权的活动"
+//     表现成"成功",前端刷新后字段没变、用户只会反复点。
+//   - `number_sign` **FREQ** 不在白名单:它在 Service 侧被显式拒绝(3140,契约 §3.4 B 行 25)。
+func (r *ActivityRepo) UpdateActivity(
+	ctx context.Context, tx *gorm.DB, id int64, patch map[string]any,
+) (int64, error) {
+	if err := requireTx(tx, "UpdateActivity"); err != nil {
+		return 0, err
+	}
+	if id <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateActivity 需要 id>0"))
+	}
+	if len(patch) == 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: UpdateActivity 的 patch 为空(无列可更新)"))
+	}
+	q := scopedCampusRows(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivity{}), r.Sc, "")
+	res := q.Where("id = ?", id).UpdateColumns(patch)
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// ListSignedByActivity 活动报名列表(端点 28,可按报名状态过滤)。
+//
+// dump:**无**(PHP 直查)。行结构来源:DDL 第 8 表 `t_courses_activity_signed`(第 277-300 行)。
+//
+// 入参:
+//   - ctx;activityID 活动 ID(<=0 → ErrBadParam);
+//   - status 报名状态过滤(nil = 不筛;**不做白名单校验**——状态枚举由 Handler 判 2002);
+//   - page/pageSize 分页(内部再钳)。
+//
+// 出参:([]报名行, 总数, error);空 → 长度 0 的非 nil 切片。
+//
+// 排序:`id ASC`(报名先后,与"推荐人先来后到"的业务读法一致),第二排序键即主键、天然确定。
+//
+// 作用域:`scopedCampusRows` 叠在 **signed 行**上(报名行自带冗余机构/校区列)。
+// 注意活动归属**不由本方法校验**:Service 先 `GetByID`(带作用域)确认活动可见,
+// 再调本方法;否则"活动越权"会表现为"列表为空"(静默),比报 3001 更难排查。
+func (r *ActivityRepo) ListSignedByActivity(
+	ctx context.Context, activityID int64, status *int32, page, pageSize int,
+) ([]model.CoursesActivitySigned, int64, error) {
+	if activityID <= 0 {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: ListSignedByActivity 需要 activity_id>0"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, 0, err
+	}
+	page, pageSize = ClampPage(page, pageSize)
+	build := func() *gorm.DB {
+		q := scopedCampusRows(db.Model(&model.CoursesActivitySigned{}), r.Sc, "")
+		q = q.Where("activity_id = ?", activityID)
+		if status != nil {
+			q = q.Where("status = ?", *status)
+		}
+		return q
+	}
+	var total int64
+	if err := build().Count(&total).Error; err != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	rows := make([]model.CoursesActivitySigned, 0, pageSize)
+	if err := build().Order("id ASC").Limit(pageSize).Offset(PageOffset(page, pageSize)).
+		Find(&rows).Error; err != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return rows, total, nil
+}
+
+// GetSignedByID 按报名行主键取单行(端点 37 回访登记的**前置存在性校验**)。
+//
+// dump:**无**(`t_courses_activity_visit` 旧库为零引用的空表)。
+//
+// 入参:ctx;signedID 报名行 ID(<=0 → ErrBadParam)。
+// 出参:*model.CoursesActivitySigned;ErrNotFound(3001:不存在或不在作用域内,不泄露存在性)/ ErrDBError。
+//
+// 为什么回访必须先取这一行:请求只给 `activitySignedId`,而响应要回 `activityId`
+// (契约 §3.4 B 行 37),且必须证明"这条报名行属于路径里的 `:id` 活动且在本机构"——
+// 否则跨机构/跨活动的回访会被静默写入(visit 表没有作用域列,写进去就再也筛不出来)。
+func (r *ActivityRepo) GetSignedByID(ctx context.Context, signedID int64) (*model.CoursesActivitySigned, error) {
+	if signedID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: GetSignedByID 需要 signed_id>0"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.CoursesActivitySigned
+	q := scopedCampusRows(db.Model(&model.CoursesActivitySigned{}), r.Sc, "")
+	if err := q.Where("id = ?", signedID).Take(&out).Error; err != nil {
+		return nil, wrapQueryErr(err, "GetSignedByID(t_courses_activity_signed)")
+	}
+	return &out, nil
+}
+
+// ExistsAnySigned 判断"该用户在该活动上是否留有**任意有效报名行**"(**不做活动 JOIN**)。
+//
+// dump:**无**(本方法只服务 §3.8-R8 的 3121 分支,dump 该分支是"读不到 type 就静默放行",Go 显式化)。
+//
+// 用途(**唯一用途**,不要拿它替代 `ExistsSigned`):
+// 取消报名取 `type` 失败时,用它区分两种"取不到":
+//
+//	无报名行  → 3001(§3.8-R7:查不到报名)
+//	有报名行但 JOIN 不到活动行 ⇒ type 读不到 ⇒ **3121**(§3.8-R8:状态机无法判定,
+//	                           不得"读不到就放行",否则跳过 3107/3108 的审核态保护)
+//
+// 入参:ctx;userID(空 → ErrBadParam);activityID(<=0 → ErrBadParam)。
+// 出参:(是否存在, error);ErrBadParam / ErrDBError。
+//
+// 判据与 `ExistsSigned`(3105)**逐字相同**(`user_id/activity_id/status<>0`)——两者只是
+// "有没有活动行"的处理不同;**不得**在这里改判据(改了就等于把 3105 与 3121 的边界挪了)。
+func (r *ActivityRepo) ExistsAnySigned(ctx context.Context, userID string, activityID int64) (bool, error) {
+	if strings.TrimSpace(userID) == "" || activityID <= 0 {
+		return false, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: ExistsAnySigned 需要 user_id 且 activity_id>0"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return false, err
+	}
+	var count int64
+	if err := db.Model(&model.CoursesActivitySigned{}).
+		Where("user_id = ? AND activity_id = ? AND status <> ?",
+			userID, activityID, model.ActivitySignedCancelled).
+		Count(&count).Error; err != nil {
+		return false, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return count > 0, nil
+}
+
+// InsertVisit 写入一条活动回访记录(端点 37)。
+//
+// dump:**无**(`t_courses_activity_visit` 在 dump 中只有 DDL,无任何 SP/PHP 引用;
+// 来源见 dump 第 18413-18420 行)。列来源:目标态 DDL 第 13 表(第 404-416 行)。
+//
+// 入参:ctx;tx(**必传**:与报名行存在性校验同事务);row 待插入行(`activity_signed_id` 必 >0)。
+// 出参:nil 成功;ErrBadParam / ErrInternal / ErrDBError。
+//
+// 红线:`activity_signed_id` 为 NULL 的行(旧库遗留"无报名记录的回访")**不新造**——
+// 目标态该列可为空,但新写入一律要求有报名行(R8/契约 §3.4 都建立在"回访针对某条报名"之上)。
+func (r *ActivityRepo) InsertVisit(ctx context.Context, tx *gorm.DB, row *model.CoursesActivityVisit) error {
+	if err := requireTx(tx, "InsertVisit"); err != nil {
+		return err
+	}
+	if row == nil || row.ActivitySignedID == nil || *row.ActivitySignedID <= 0 {
+		return infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: InsertVisit 需要 activity_signed_id>0"))
+	}
+	if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}
+
+// ActivityUsableItem 按活动拆分的可用赠送金额构成项(端点 31 的 `items[]` 单元)。
+//
+// 字段:
+//   - ActivityID:活动 ID(`t_courses_activity_user_discount.activity_id`);
+//   - Amount:该活动下可用额度合计(分)= Σ(`discount_amount − discount_amount_used`)。
+type ActivityUsableItem struct {
+	// ActivityID 活动 ID。
+	ActivityID int64
+	// Amount 该活动可用额(分,严格 > 0)。
+	Amount money.Amount
+}
+
+// SumUsableByActivity 按活动拆分用户可用赠送金额(端点 31 的 `items`,SP14 的**分活动视图**)。
+//
+// dump 对照(**只复刻过滤条件,不改口径**):SP14 `sp_courses_activity_user_discount_usable`
+// (dump 763176)只给出**总额**,没有"按活动拆分"的分支;本方法 = 同一 WHERE + `GROUP BY activity_id`,
+// 因此它**不引入新的筛选语义**,只是把已有行的构成显式化(契约 §3.4 B 行 31 要求 `items` 是"按活动的构成")。
+//
+// 过滤条件与 `SumUsable` **逐字一致**(同一 dump 段 763181-763185):
+//
+//	WHERE user_id = ?
+//	  AND discount_amount > discount_amount_used          -- 可用额 > 0 才算
+//	  [AND institution_id = ? AND campus_id = ?]          -- 仅 institutionID>0 时两列一起写(等值)
+//
+// 金额精度:**不用 SQL SUM()**——逐行在 Go 侧用 `money.Amount`(int64 分)累加,理由同 `SumUsable`
+// (sqlite 的 `SUM(DECIMAL)` 返回 REAL,会把分位带进浮点;§10.7 禁 `SELECT SUM(decimal)`)。
+//
+// 入参:ctx;userID(空 → ErrBadParam);institutionID/campusID(负数 → ErrBadParam;0 的语义见 `SumUsable`)。
+// 出参:[]ActivityUsableItem(**按 activityID 升序**、**空 → 长度 0 的非 nil 切片**);ErrBadParam / ErrDBError。
+//
+// 为什么排序写死:map 遍历顺序随机,直接输出会让"同一请求两次不同顺序"(前端列表抖动、双跑噪声)。
+func (r *ActivityDiscountRepo) SumUsableByActivity(
+	ctx context.Context, userID string, institutionID, campusID int64,
+) ([]ActivityUsableItem, error) {
+	if strings.TrimSpace(userID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: SumUsableByActivity 需要 user_id"))
+	}
+	if institutionID < 0 || campusID < 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: SumUsableByActivity 机构/校区参数不得为负(0 的语义见 SumUsable)"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	q := db.Model(&model.CoursesActivityUserDiscount{}).
+		Where("user_id = ?", userID).
+		Where("discount_amount > discount_amount_used")
+	if institutionID > 0 {
+		q = q.Where("institution_id = ? AND campus_id = ?", institutionID, campusID)
+	}
+	var rows []model.CoursesActivityUserDiscount
+	if err := q.Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	perActivity := make(map[int64]money.Amount, len(rows))
+	for i := range rows {
+		totalAmount, perr := money.Parse(rows[i].DiscountAmount)
+		if perr != nil {
+			return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
+		}
+		usedAmount, perr := money.Parse(rows[i].DiscountAmountUsed)
+		if perr != nil {
+			return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
+		}
+		perActivity[rows[i].ActivityID] = perActivity[rows[i].ActivityID].Add(totalAmount.Sub(usedAmount))
+	}
+	out := make([]ActivityUsableItem, 0, len(perActivity))
+	for activityID, amount := range perActivity {
+		if amount <= 0 {
+			continue // 多行相抵后仍不可用(如退回把已用额冲正)→ 不进 items(`usable` 合计也不含)
+		}
+		out = append(out, ActivityUsableItem{ActivityID: activityID, Amount: amount})
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].ActivityID < out[j].ActivityID })
+	return out, nil
+}

+ 833 - 0
internal/academic/service/activity_admin_service.go

@@ -0,0 +1,833 @@
+// 活动管理服务(端点 22~25 / 28 / 37 的编排层)。
+//
+// 依据:
+//   - unified/03-migration/academic-frontend-contract-v1.md §2.2(活动 16 端点)/ §3.4 B(字段级定义 + 量纲);
+//   - unified/03-migration/academic-domain-design.md §4.2(端点清单与角色)/ §1.7(作用域)/
+//     §10.1(错误码:2001 缺参 / 2002 格式 / 3001 不存在 / 1004 越权 / 3140 参数矛盾)/
+//     §10.3(事务边界只在 Service)/ §3.8-Z1(活动类金额 ≤0 **跳过**,不升级 2002)/ §3.8-R7(查不到 → 3001)。
+//
+// ---------------------------------------------------------------------------
+// 与 `activity_service.go` 的分工(两者**都属活动域**,但职责不同)
+// ---------------------------------------------------------------------------
+//
+//	activity_service.go        有 dump SP 对照的**资金/状态机**写路径(signed_add / signed_cancel)
+//	activity_admin_service.go  无 dump SP 对照的**管理面**读写(活动 CRUD / 报名列表 / 回访)
+//
+// 分文件而不是塞一起:前者的每个分支都能指到 dump 行号,后者只能指到 DDL 列。混在一起后,
+// "这段逻辑对不对"就会变成"文档说对就对"——本文件因此坚持"每个字段写来源列、写不出来就不写"。
+//
+// ---------------------------------------------------------------------------
+// 三条错误码纪律(本文件最容易写错的地方)
+// ---------------------------------------------------------------------------
+//
+//	① 2001(缺参)只在 Handler 由 `validate` tag 产生;Service 这里只处理**值**的问题。
+//	② 2002 只用于「传了但非法」:枚举越界(type∉{2,3,4} / status∉{0,1,2})、金额不可表示、
+//	   时间窗倒挂。**不得**把"业务规则不满足"塞进来(那是专用 31xx 或 3140)。
+//	③ 活动类金额 **≤0 一律不拒**(§3.8-Z1:dump 对活动侧金额只做 `max(x,0)` 与"≤0 跳过",
+//	   从不报错;把 ≤0 升级成 2002 会在活动服务里造出 dump 不存在的拒绝分支)。因此
+//	   `CreateActivity` 允许 `money<=0`(`Sign` 时由 `max(money,0)` 归一),只在超出
+//	   `money.Amount` 可表示范围时才 2002。
+package service
+
+import (
+	"context"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/port"
+	"yuansiwei/unified-backend/internal/academic/repository"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+const (
+	// ActivityDetailSignedLimit 活动详情内嵌报名列表的条数上限(端点 24 的 `signeds[]`)。
+	//
+	// 为什么设上限而不是"全量":详情是高频页面,热门活动报名可能上万行;全量下发会让
+	// 单次响应到 MB 级。取 200 与**分页上限同值**(`repository.MaxPageSize`),语义是
+	// "详情页看最近 200 条,要翻页用端点 28"——这条不是契约要求,属实现选择,已在交付报告登记。
+	ActivityDetailSignedLimit = 200
+)
+
+// activityEditableTypes 活动可写类型白名单(契约 §2.2 端点 23:「新建(type 2/3/4)」)。
+//
+// 1(试听)是旧遗留(`model.ActivityTypeTrial` 注释:不参与 SP 逻辑)⇒ 新建不接受。
+var activityEditableTypes = map[int8]struct{}{
+	model.ActivityTypeRecharge:  {},
+	model.ActivityTypeVoucher:   {},
+	model.ActivityTypeRecommend: {},
+}
+
+// activityStatuses 活动状态白名单(`model.ActivityStatus*`:0 停用 / 1 开启 / 2 已结束)。
+var activityStatuses = map[int8]struct{}{
+	model.ActivityStatusDisabled: {},
+	model.ActivityStatusEnabled:  {},
+	model.ActivityStatusEnded:    {},
+}
+
+// ActivityAdminService 活动管理服务(端点 22~25 / 28 / 37)。
+//
+// 字段:
+//   - repo 活动/报名仓储(作用域在构造期绑定);
+//   - runner 事务运行器(新建/更新/回访各一个事务);
+//   - campus 校区只读端口(校验 `campusId` 是否归属本机构;可 nil:nil 时跳过错机构校验,
+//     仅限系统任务/割接脚本,生产装配**必须**注入);
+//   - sc 请求作用域(机构/校区过滤的唯一来源,`CreateActivity` 的 `institution_id` 取自这里);
+//   - now 时钟(`signable` 的时间窗判定;单测注入固定时刻,避免用例随日期变红)。
+type ActivityAdminService struct {
+	repo   *repository.ActivityRepo
+	runner *repository.TxRunner
+	campus port.CampusReader
+	sc     *appscope.Scope
+	now    func() time.Time
+}
+
+// NewActivityAdminService 构造活动管理服务。
+//
+// 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域)。
+// 出参:*ActivityAdminService(校区端口用 `WithCampusReader` 注入)。
+func NewActivityAdminService(db *gorm.DB, sc *appscope.Scope) *ActivityAdminService {
+	return &ActivityAdminService{
+		repo:   repository.NewActivityRepo(db, sc),
+		runner: repository.NewTxRunner(db),
+		sc:     sc,
+		now:    time.Now,
+	}
+}
+
+// WithCampusReader 注入校区只读端口(返自身,链式装配)。
+//
+// 入参:reader port.CampusReader(可 nil)。
+// 出参:*ActivityAdminService(同一个实例)。
+func (s *ActivityAdminService) WithCampusReader(reader port.CampusReader) *ActivityAdminService {
+	s.campus = reader
+	return s
+}
+
+// WithClock 注入时钟(单测用;生产用 `time.Now`)。
+//
+// 入参:now func() time.Time(nil → 忽略)。
+// 出参:*ActivityAdminService(同一个实例)。
+func (s *ActivityAdminService) WithClock(now func() time.Time) *ActivityAdminService {
+	if now != nil {
+		s.now = now
+	}
+	return s
+}
+
+// ---------------------------------------------------------------- 响应视图(字段名 = 契约 §3.4 B 逐字)
+
+// ActivityView 活动列表/详情行(契约 §3.4 B 行 22 / 24,**字段名与量纲逐字照抄**)。
+//
+// 字段 → 来源列(右侧为契约要求的准入溯源,缺一列就不该有该字段):
+//
+//	id            ← t_courses_activity.id
+//	institutionId ← t_courses_activity.institution_id
+//	campusId      ← t_courses_activity.campus_id
+//	type          ← t_courses_activity.type
+//	name          ← t_courses_activity.title        (**纯改名**:契约定名 name,含义仍是"活动标题")
+//	status        ← t_courses_activity.status
+//	numberSign    ← t_courses_activity.number_sign
+//	total         ← t_courses_activity.number_max   (**纯改名**:契约定名 total=名额上限)
+//	signed        ← COUNT(t_courses_activity_signed WHERE status<>0)  (派生,判据同 dump 762928)
+//	money         ← t_courses_activity.money        (**活动金额**:充值赠送额度 / 老带新奖励 / 特殊费用额)
+//	signable      ← status/number_max/number_sign/start_time/end_time 合成(派生,**仅展示位**)
+//	startTime     ← t_courses_activity.start_time
+//	endTime       ← t_courses_activity.end_time
+//
+// ⚠ **`money` 与 `signeds[].money` 不是一回事**:本字段是**活动级**(配置的活动金额,
+// 契约 §3.4 B 行 22 注),报名行里的是**报名级**(实际用掉的金额)。两者层级不同、勿混用。
+// 归属依据:v1.6 契约补入本字段(表有 `money` 列且属活动主干属性)。
+type ActivityView struct {
+	// ID 活动 ID。
+	ID int64 `json:"id"`
+	// InstitutionID 机构 ID。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区 ID(0 = 机构所有自营校区)。
+	CampusID int64 `json:"campusId"`
+	// Type 活动类型(2 充值赠送 / 3 代金券 / 4 老带新)。
+	Type int8 `json:"type"`
+	// Name 活动标题(← title)。
+	Name string `json:"name"`
+	// Status 活动状态(0 停用 / 1 开启 / 2 已结束)。
+	Status int8 `json:"status"`
+	// NumberSign 计数列 number_sign(报名 +1 / 取消 -1;**活动更新时不可写**)。
+	NumberSign int32 `json:"numberSign"`
+	// Total 名额上限(← number_max;0 = 不限)。
+	Total int32 `json:"total"`
+	// Signed 有效报名人数(`signed.status <> 0` 的行数)。
+	Signed int32 `json:"signed"`
+	// Money 活动金额(← `money`;充值赠送额度 / 老带新奖励 / 特殊费用额)。
+	// **不是** `signeds[].money`(那是报名级实扣金额)。
+	Money money.Amount `json:"money"`
+	// Signable 前端展示位:当前是否"看起来可以报名"(**不代表后端会拒绝**,见契约 §3.4 B 行 22 注)。
+	Signable bool `json:"signable"`
+	// StartTime 活动开始时间(可空)。
+	StartTime *time.Time `json:"startTime"`
+	// EndTime 活动结束时间(可空)。
+	EndTime *time.Time `json:"endTime"`
+}
+
+// SignedView 报名行视图(契约 §3.4 B 行 24 / 28 的 `signeds[]` 行结构)。
+//
+// 字段 → 来源列:
+//
+//	id              ← t_courses_activity_signed.id
+//	activityId      ← t_courses_activity_signed.activity_id
+//	userId          ← t_courses_activity_signed.user_id
+//	status          ← t_courses_activity_signed.status(0 取消/1 报名/2 参加/3 报课/4 过期)
+//	recommendUserId ← t_courses_activity_signed.recommend_user_id(NULL → null)
+//	money           ← t_courses_activity_signed.activity_money(**纯改名**:契约定名 money)
+//	createTime      ← t_courses_activity_signed.create_time(**业务时间**:报名时间,§1.5 B1 ①类)
+//
+// ⚠ `createTime` 在这里是**真实业务时间**(legacy 原地迁移列),与端点 12 的
+// `createdAt`(迁移执行时刻的新加列,禁下发)不是一回事——两者源列不同,别类推。
+type SignedView struct {
+	// ID 报名行 ID。
+	ID int64 `json:"id"`
+	// ActivityID 活动 ID。
+	ActivityID int64 `json:"activityId"`
+	// UserID 报名用户(ULID)。
+	UserID string `json:"userId"`
+	// Status 报名状态(0/1/2/3/4)。
+	Status int32 `json:"status"`
+	// RecommendUserID 推荐人(可空 → null)。
+	RecommendUserID *string `json:"recommendUserId"`
+	// Money 活动金额快照(← activity_money,分定点)。
+	Money money.Amount `json:"money"`
+	// CreateTime 报名时间(业务时间)。
+	CreateTime time.Time `json:"createTime"`
+}
+
+// ListActivitiesResult 活动列表响应(`response.Page` 直接消费,分页五字段由它算定)。
+type ListActivitiesResult struct {
+	// List 当前页活动(**空 → `[]`,不是 nil**)。
+	List []ActivityView
+	// Total 同过滤条件总数。
+	Total int64
+	// Page 当前页码(回显)。
+	Page int
+	// PageSize 每页条数(回显,已钳制 ≤200)。
+	PageSize int
+}
+
+// ActivityDetailResult 活动详情响应(契约 §3.4 B 行 24:22 行结构 + `signeds[]`)。
+//
+// 结构:内嵌 `ActivityView`(JSON 平铺,**不套 `activity` 层**)+ `signeds` 数组。
+type ActivityDetailResult struct {
+	// ActivityView 活动行字段(平铺)。
+	ActivityView
+	// Signeds 报名行(最多 `ActivityDetailSignedLimit` 条,按 id 升序;空 → `[]`)。
+	Signeds []SignedView `json:"signeds"`
+}
+
+// ListSignedsResult 报名列表响应(契约 §3.4 B 行 28)。
+type ListSignedsResult struct {
+	// List 当前页报名行(空 → `[]`)。
+	List []SignedView
+	// Total 同过滤条件总数。
+	Total int64
+	// Page 当前页码。
+	Page int
+	// PageSize 每页条数(已钳制)。
+	PageSize int
+}
+
+// CreateActivityResult 新建活动响应(契约 §3.4 B 行 23)。
+//
+// ⚠ 契约该行还要求 `bizNo:string`,但 `t_courses_activity` **没有 `biz_no` 列**
+// (目标态 DDL 第 242-268 行逐列核对:id/type/toped/thumbnail/subject_id/grade_id/institution_id/
+// campus_id/start_time/end_time/number_max/number_sign/title/content/money/status/view/memo/
+// create_user_id/create_time/updated_at)。按 §3.4 ③「字段准入条件」,**无列即不下发**:
+// 本结构体只回 `id`,`bizNo` 作为契约缺陷上报(「先报、不擅自造字段」),
+// 不在后端编一个"活动-<id>"之类的伪单号——伪单号会立刻被当作对账/幂等键使用。
+type CreateActivityResult struct {
+	// ID 新活动 ID。
+	ID int64 `json:"id"`
+}
+
+// UpdateActivityResult 更新活动响应(契约 §3.4 B 行 25:`{id}`)。
+type UpdateActivityResult struct {
+	// ID 活动 ID。
+	ID int64 `json:"id"`
+}
+
+// VisitResult 回访登记响应(契约 §3.4 B 行 37:`{visited, activityId}`)。
+type VisitResult struct {
+	// Visited 是否已登记(成功恒 true)。
+	Visited bool `json:"visited"`
+	// ActivityID 报名行所属活动 ID。
+	ActivityID int64 `json:"activityId"`
+}
+
+// ---------------------------------------------------------------- 入参
+
+// ListActivitiesInput 活动列表入参(端点 22)。
+//
+// 字段:Type/Status 已由 Handler 做白名单校验(非法 → 2002),此处只做"给了就筛"。
+type ListActivitiesInput struct {
+	// Type 活动类型(nil = 不筛)。
+	Type *int8
+	// Status 活动状态(nil = 不筛)。
+	Status *int8
+	// Keyword 标题关键字(空 = 不筛)。
+	Keyword string
+	// Page 页码(<1 → 1)。
+	Page int
+	// PageSize 每页条数(<1 → 10;>200 → 200)。
+	PageSize int
+}
+
+// CreateActivityInput 新建活动入参(端点 23)。
+//
+// 字段来源:目标态 DDL 第 7 表的**可写列**(计数列 number_sign / view / create_time / updated_at
+// 不在此列——它们分别由报名、访问、DB 默认维护)。
+type CreateActivityInput struct {
+	// InstitutionID 机构 ID(Handler 从请求作用域填入;**不取 body**)。
+	InstitutionID int64
+	// CampusID 校区 ID(0 = 机构所有自营校区);>0 时校验归属本机构。
+	CampusID int64
+	// Type 活动类型(2/3/4;其它 → 2002)。
+	Type int8
+	// Title 活动标题(→ title)。
+	Title string
+	// Content 活动内容(HTML;可空)。
+	Content string
+	// Memo 备注(可空)。
+	Memo string
+	// Thumbnail 缩略图 URL(可空)。
+	Thumbnail string
+	// Money 活动金额/额度(分;**允许 ≤0**,见文件头纪律 ③)。
+	Money money.Amount
+	// StartTime 开始时间(可空)。
+	StartTime *time.Time
+	// EndTime 结束时间(可空;给了 StartTime 时不得早于它)。
+	EndTime *time.Time
+	// NumberMax 名额上限(0 = 不限;负数 → 2002)。
+	NumberMax int32
+	// Status 活动状态(0/1/2;其它 → 2002)。
+	Status int8
+	// Toped 置顶(0/1)。
+	Toped int8
+	// SubjectID 科目(0 = 混合)。
+	SubjectID int32
+	// GradeID 年级(0 = 混合)。
+	GradeID int32
+	// OperatorID 操作人(→ create_user_id;空 → NULL)。
+	OperatorID string
+}
+
+// UpdateActivityInput 更新活动入参(端点 25,**PUT = 整体替换**)。
+//
+// 语义(重要,避免"漏传字段被保留"的错觉):PUT 语义是**整体替换可编辑字段**,
+// 未传的可选字段(Content/Memo/Thumbnail)会被置空 —— 与 PATCH 的"只改传了的"不同。
+// 这样写有两个理由:① 旧系统编辑页是一次提交全表单;② "漏传即保留"会让前端无法把
+// 活动备注清空(清空 = 传空串,而空串与"没传"在 Go 结构体里不可区分,除非上指针,
+// 而指针化会让 20 个字段全部变成 `*T` 判空,读起来比 PUT 语义复杂得多)。
+//
+// `NumberSign` **不在入参里**:契约 §3.4 B 行 25(v1.6)「`numberSign` 传即 2002」——
+// 该判定在 Handler 侧完成(需要知道"传了没有",只有原始 JSON 层知道),
+// 到 Service 时已是"允许写"的状态。
+type UpdateActivityInput struct {
+	// ID 活动 ID(>0)。
+	ID int64
+	// InstitutionID 请求作用域机构(用于校验,**不参与更新**:机构归属不可变)。
+	InstitutionID int64
+	// CampusID 校区 ID(0 = 机构所有自营校区);>0 时校验归属本机构。
+	CampusID int64
+	// Type 活动类型(2/3/4;运行期改 type 属高危,这里**允许**改——旧系统允许,不做额外限制)。
+	Type int8
+	// Title 活动标题。
+	Title string
+	// Content 活动内容。
+	Content string
+	// Memo 备注。
+	Memo string
+	// Thumbnail 缩略图。
+	Thumbnail string
+	// Money 活动金额(分;≤0 允许)。
+	Money money.Amount
+	// StartTime 开始时间(可空 → NULL)。
+	StartTime *time.Time
+	// EndTime 结束时间(可空 → NULL)。
+	EndTime *time.Time
+	// NumberMax 名额上限(0 = 不限)。
+	NumberMax int32
+	// Status 活动状态(0/1/2)。
+	Status int8
+	// Toped 置顶。
+	Toped int8
+	// SubjectID 科目。
+	SubjectID int32
+	// GradeID 年级。
+	GradeID int32
+}
+
+// ListSignedsInput 报名列表入参(端点 28)。
+type ListSignedsInput struct {
+	// ActivityID 活动 ID(>0)。
+	ActivityID int64
+	// Status 报名状态过滤(nil = 不筛;白名单由 Handler 校验)。
+	Status *int32
+	// Page 页码。
+	Page int
+	// PageSize 每页条数。
+	PageSize int
+}
+
+// VisitInput 回访登记入参(端点 37)。
+type VisitInput struct {
+	// ActivityID 路径里的活动 ID(>0)。
+	ActivityID int64
+	// ActivitySignedID 报名行 ID(>0)。
+	ActivitySignedID int64
+	// Memo 回访内容(必填,契约 §2.2 行 37 写的是 `activitySignedId,memo` 无问号)。
+	Memo string
+	// OperatorID 回访人(→ create_user_id;来自作用域,不取 body)。
+	OperatorID string
+}
+
+// ---------------------------------------------------------------- 列表(端点 22)
+
+// ListActivities 活动分页列表(端点 22 `GET /admin/activities`)。
+//
+// dump:**无**(PHP 直查)。派生字段 `signed` 的判据与 dump `762928` 的 3105 查重同口径
+// (`status <> 0`),这样"列表显示的报名数"与"能否报名"不会互相打脸。
+//
+// 入参:ctx / in。出参:*ListActivitiesResult;错误码 5002。
+func (s *ActivityAdminService) ListActivities(
+	ctx context.Context, in ListActivitiesInput,
+) (*ListActivitiesResult, error) {
+	if s == nil || s.repo == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配"))
+	}
+	rows, total, err := s.repo.ListActivities(ctx, repository.ActivityListFilter{
+		Type: in.Type, Status: in.Status, Keyword: in.Keyword,
+	}, in.Page, in.PageSize)
+	if err != nil {
+		return nil, err
+	}
+	ids := make([]int64, 0, len(rows))
+	for i := range rows {
+		ids = append(ids, rows[i].ID)
+	}
+	counts, err := s.repo.CountSignedByActivityIDs(ctx, ids)
+	if err != nil {
+		return nil, err
+	}
+	list := make([]ActivityView, 0, len(rows))
+	for i := range rows {
+		list = append(list, s.view(&rows[i], counts[rows[i].ID]))
+	}
+	page, pageSize := repository.ClampPage(in.Page, in.PageSize)
+	return &ListActivitiesResult{List: list, Total: total, Page: page, PageSize: pageSize}, nil
+}
+
+// ---------------------------------------------------------------- 详情(端点 24)
+
+// GetActivity 活动详情(端点 24 `GET /admin/activities/:id`)。
+//
+// 入参:ctx;id 活动 ID(<=0 → 2002,由 Handler 的路径解析给出)。
+// 出参:*ActivityDetailResult;错误码 3001(不存在或不在作用域内)/ 5002。
+//
+// 作用域:`GetByID` 带机构+校区过滤 ⇒ 跨机构活动一律 **3001**(不泄露存在性)。
+// 报名行同理(`ListSignedByActivity` 叠 signed 的冗余作用域列)。
+func (s *ActivityAdminService) GetActivity(ctx context.Context, id int64) (*ActivityDetailResult, error) {
+	if id <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: GetActivity 需要 id>0"))
+	}
+	if s == nil || s.repo == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配"))
+	}
+	act, err := s.repo.GetByID(ctx, id)
+	if err != nil {
+		return nil, err
+	}
+	counts, err := s.repo.CountSignedByActivityIDs(ctx, []int64{act.ID})
+	if err != nil {
+		return nil, err
+	}
+	signedRows, _, err := s.repo.ListSignedByActivity(ctx, act.ID, nil, 1, ActivityDetailSignedLimit)
+	if err != nil {
+		return nil, err
+	}
+	return &ActivityDetailResult{
+		ActivityView: s.view(act, counts[act.ID]),
+		Signeds:      s.signedViews(signedRows),
+	}, nil
+}
+
+// ---------------------------------------------------------------- 新建(端点 23)
+
+// CreateActivity 新建活动(端点 23 `POST /admin/activities`)。
+//
+// 校验顺序(**先参数、后越权、再落库**):
+//
+//	① type ∈ {2,3,4}        → 否则 2002(契约 §2.2 行 23)
+//	② status ∈ {0,1,2}      → 否则 2002
+//	③ numberMax >= 0        → 否则 2002(计数列不接受负值)
+//	④ money 可表示          → 否则 2002(≤0 **不拒**,§3.8-Z1)
+//	⑤ startTime <= endTime  → 否则 2002(时间窗倒挂)
+//	⑥ campusId 归属本机构   → 校区不存在 3104 / 跨机构 **1004**
+//	⑦ 落库(number_sign 由仓储强制清零)
+//
+// 入参:ctx / in(`InstitutionID` 来自作用域)。出参:*CreateActivityResult;错误码 2002/3104/1004/5002。
+func (s *ActivityAdminService) CreateActivity(
+	ctx context.Context, in CreateActivityInput,
+) (*CreateActivityResult, error) {
+	if s == nil || s.repo == nil || s.runner == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配"))
+	}
+	if in.InstitutionID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: CreateActivity 缺少机构作用域(institution_id 不能为 0)"))
+	}
+	if err := validateActivityFields(in.Type, in.Status, in.NumberMax, in.Money, in.StartTime, in.EndTime); err != nil {
+		return nil, err
+	}
+	if err := s.assertCampusInScope(ctx, in.CampusID, in.InstitutionID); err != nil {
+		return nil, err
+	}
+	row := &model.CoursesActivity{
+		Type:          in.Type,
+		Toped:         in.Toped,
+		Thumbnail:     optString(in.Thumbnail),
+		SubjectID:     in.SubjectID,
+		GradeID:       in.GradeID,
+		InstitutionID: in.InstitutionID,
+		CampusID:      in.CampusID,
+		StartTime:     in.StartTime,
+		EndTime:       in.EndTime,
+		NumberMax:     in.NumberMax,
+		NumberSign:    0, // 计数列:由仓储再强制清零一次(双保险)
+		Title:         optString(in.Title),
+		Content:       optString(in.Content),
+		Money:         in.Money.String(),
+		Status:        in.Status,
+		View:          0,
+		Memo:          optString(in.Memo),
+		CreateUserID:  optString(in.OperatorID),
+	}
+	if err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		return s.repo.CreateActivity(repository.WithTxContext(ctx, tx), tx, row)
+	}); err != nil {
+		return nil, err
+	}
+	return &CreateActivityResult{ID: row.ID}, nil
+}
+
+// ---------------------------------------------------------------- 更新(端点 25)
+
+// UpdateActivity 更新活动(端点 25 `PUT /admin/activities/:id`)。
+//
+// 校验:与新建同(①~⑥),额外一条 —— 活动必须存在且在本作用域(否则 **3001**,
+// 由 `RowsAffected==0` 判定:`UpdateActivity` 的 WHERE 已带作用域)。
+//
+// `number_sign` **不写**:契约 §3.4 B 行 25(v1.6)「`numberSign` 传即 2002」,该判定在 Handler
+// (只有原始 JSON 层能区分"没传"与"传了 0")。到本方法时 patch 里绝不含该列。
+//
+// 入参:ctx / in。出参:*UpdateActivityResult;错误码 2002/3104/1004/3001/5002。
+func (s *ActivityAdminService) UpdateActivity(
+	ctx context.Context, in UpdateActivityInput,
+) (*UpdateActivityResult, error) {
+	if in.ID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: UpdateActivity 需要 id>0"))
+	}
+	if s == nil || s.repo == nil || s.runner == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配"))
+	}
+	if in.InstitutionID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: UpdateActivity 缺少机构作用域"))
+	}
+	if err := validateActivityFields(in.Type, in.Status, in.NumberMax, in.Money, in.StartTime, in.EndTime); err != nil {
+		return nil, err
+	}
+	if err := s.assertCampusInScope(ctx, in.CampusID, in.InstitutionID); err != nil {
+		return nil, err
+	}
+	patch := map[string]any{
+		"type":       in.Type,
+		"toped":      in.Toped,
+		"thumbnail":  in.Thumbnail,
+		"subject_id": in.SubjectID,
+		"grade_id":   in.GradeID,
+		"campus_id":  in.CampusID,
+		"start_time": in.StartTime,
+		"end_time":   in.EndTime,
+		"number_max": in.NumberMax,
+		"title":      in.Title,
+		"content":    in.Content,
+		"money":      in.Money.String(),
+		"status":     in.Status,
+		"memo":       in.Memo,
+	}
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		affected, e := s.repo.UpdateActivity(repository.WithTxContext(ctx, tx), tx, in.ID, patch)
+		if e != nil {
+			return e
+		}
+		if affected == 0 {
+			return infraerrors.WithCause(infraerrors.ErrNotFound,
+				errString("service: UpdateActivity 活动不存在或不在作用域内"))
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return &UpdateActivityResult{ID: in.ID}, nil
+}
+
+// ---------------------------------------------------------------- 报名列表(端点 28)
+
+// ListSigneds 活动报名列表(端点 28 `GET /admin/activities/:id/signeds`)。
+//
+// 前置:活动必须在本作用域内(`GetByID`)——否则返回 **3001** 而不是空列表:
+// "活动越权"与"活动真的没人报名"是两件事,都回 `[]` 会让前端把越权当正常空态。
+//
+// 入参:ctx / in。出参:*ListSignedsResult;错误码 3001/5002。
+func (s *ActivityAdminService) ListSigneds(
+	ctx context.Context, in ListSignedsInput,
+) (*ListSignedsResult, error) {
+	if in.ActivityID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: ListSigneds 需要 activity_id>0"))
+	}
+	if s == nil || s.repo == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配"))
+	}
+	if _, err := s.repo.GetByID(ctx, in.ActivityID); err != nil {
+		return nil, err
+	}
+	rows, total, err := s.repo.ListSignedByActivity(ctx, in.ActivityID, in.Status, in.Page, in.PageSize)
+	if err != nil {
+		return nil, err
+	}
+	page, pageSize := repository.ClampPage(in.Page, in.PageSize)
+	return &ListSignedsResult{List: s.signedViews(rows), Total: total, Page: page, PageSize: pageSize}, nil
+}
+
+// ---------------------------------------------------------------- 回访(端点 37)
+
+// RegisterVisit 登记活动回访(端点 37 `POST /admin/activities/:id/visits`)。
+//
+// dump:**无**(`t_courses_activity_visit` 旧库零引用)。语义按契约 §2.2 行 37:给某条报名行记一条回访。
+//
+// 校验(顺序):
+//
+//	① activitySignedId > 0         → 2001(Handler 的 validate tag)
+//	② 报名行存在且在本作用域        → 3001(`GetSignedByID` 带作用域)
+//	③ 报名行属于路径里的活动         → 3001(**跨活动回访必须拒**:否则 visit 会挂到错误的报名行上,
+//	                                 且 visit 表没有作用域列,写错后无法筛出)
+//	④ 活动本身在本作用域            → 3001(`GetByID`)
+//	⑤ 同事务写 visit
+//
+// 幂等:**不做**。旧库对回访无幂等键(表上只有 id/activity_signed_id/create_user_id/create_time/memo),
+// 目标态也不加:回访是"多次沟通记录",重复登记是合法业务(同一学员可以打三次电话)。
+//
+// 入参:ctx / in。出参:*VisitResult;错误码 3001/5002。
+func (s *ActivityAdminService) RegisterVisit(ctx context.Context, in VisitInput) (*VisitResult, error) {
+	if in.ActivityID <= 0 || in.ActivitySignedID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: RegisterVisit 需要 activity_id>0 且 activity_signed_id>0"))
+	}
+	if s == nil || s.repo == nil || s.runner == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配"))
+	}
+	signed, err := s.repo.GetSignedByID(ctx, in.ActivitySignedID)
+	if err != nil {
+		return nil, err
+	}
+	if signed.ActivityID != in.ActivityID {
+		return nil, infraerrors.WithCause(infraerrors.ErrNotFound,
+			errString("service: RegisterVisit 报名行不属于该活动"))
+	}
+	if _, err := s.repo.GetByID(ctx, in.ActivityID); err != nil {
+		return nil, err
+	}
+	signedID := signed.ID
+	row := &model.CoursesActivityVisit{
+		ActivitySignedID: &signedID,
+		CreateUserID:     optString(in.OperatorID),
+		Memo:             optString(in.Memo),
+	}
+	if err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		return s.repo.InsertVisit(repository.WithTxContext(ctx, tx), tx, row)
+	}); err != nil {
+		return nil, err
+	}
+	return &VisitResult{Visited: true, ActivityID: signed.ActivityID}, nil
+}
+
+// ---------------------------------------------------------------- 内部助手
+
+// ---------------------------------------------------------------- 存在性/作用域断言
+
+// AssertActivityVisible 断言活动存在且在本请求作用域内(端点 29 等的**前置守卫**)。
+//
+// 为什么单独一个方法而不是复用 `GetActivity`:`GetActivity` 会连带查报名人数与 200 条报名行
+// (3 条 SQL),而端点 29(老带新结算)只需要"活动可见"这一个事实——
+// 用重查询做守卫会在热点写路径上白付两次扫描,且返回的字段一个都不用。
+//
+// 入参:ctx;activityID(<=0 → 2002)。出参:nil 表示可见;3001(不存在/越权,不泄露存在性)/ 5002。
+func (s *ActivityAdminService) AssertActivityVisible(ctx context.Context, activityID int64) error {
+	if activityID <= 0 {
+		return infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: AssertActivityVisible 需要 activity_id>0"))
+	}
+	if s == nil || s.repo == nil {
+		return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配"))
+	}
+	_, err := s.repo.GetByID(ctx, activityID)
+	return err
+}
+
+// view 把活动行 + 有效报名数转成对外视图。
+//
+// 入参:act 活动行(非 nil);signed 有效报名数(缺失 → 0)。
+// 出参:ActivityView(字段来源见类型注释)。
+func (s *ActivityAdminService) view(act *model.CoursesActivity, signed int32) ActivityView {
+	name := ""
+	if act.Title != nil {
+		name = *act.Title
+	}
+	// 活动级金额(`t_courses_activity.money`):与 signedViews 同策略——脏值不静默改写,
+	// 按 0 输出(列受 DECIMAL(12,2) 约束,正常库不会走到这里)。
+	activityMoney, aerr := money.Parse(act.Money)
+	if aerr != nil {
+		activityMoney = 0
+	}
+	return ActivityView{
+		ID:            act.ID,
+		InstitutionID: act.InstitutionID,
+		CampusID:      act.CampusID,
+		Type:          act.Type,
+		Name:          name,
+		Status:        act.Status,
+		NumberSign:    act.NumberSign,
+		Total:         act.NumberMax,
+		Signed:        signed,
+		Money:         activityMoney,
+		Signable:      s.signable(act),
+		StartTime:     act.StartTime,
+		EndTime:       act.EndTime,
+	}
+}
+
+// signedViews 批量转报名行视图(空入参 → **非 nil 空切片**,保证 JSON 是 `[]` 不是 `null`)。
+func (s *ActivityAdminService) signedViews(rows []model.CoursesActivitySigned) []SignedView {
+	out := make([]SignedView, 0, len(rows))
+	for i := range rows {
+		amount, err := money.Parse(rows[i].ActivityMoney)
+		if err != nil {
+			// 金额列脏值:**不静默置 0**(那会让前端把脏数据当"0 元活动",比报错更难发现)。
+			// 该行金额按 0 输出会让"总计对不上"变成新的谜题,故这里保留 0 但**不改变**其他字段,
+			// 由交付报告的"脏值可见性"一条说明(金额列本身受 DECIMAL 约束,正常库不会走到这里)。
+			amount = 0
+		}
+		out = append(out, SignedView{
+			ID:              rows[i].ID,
+			ActivityID:      rows[i].ActivityID,
+			UserID:          rows[i].UserID,
+			Status:          rows[i].Status,
+			RecommendUserID: rows[i].RecommendUserID,
+			Money:           amount,
+			CreateTime:      rows[i].CreateTime,
+		})
+	}
+	return out
+}
+
+// signable 计算"看起来可以报名"(**仅展示位**,契约 §3.4 B 行 22 注明确不具强制力)。
+//
+// 判据(全部可追溯到具体列,且都是"旧系统 PHP 展示逻辑"的同义表达):
+//
+//	status = 1(开启)
+//	AND (number_max = 0 OR number_sign < number_max)   -- 名额未满
+//	AND (start_time IS NULL OR now >= start_time)
+//	AND (end_time   IS NULL OR now <= end_time)
+//
+// ⚠ 不要据此在 `signed_add` 加校验(§10.1-3110 已收窄:dump 的 signed_add **无**任何可用性校验,
+// 活动 status=0 时 dump 照样放行并 `number_sign+1`)。本函数只喂前端展示,不参与写路径。
+func (s *ActivityAdminService) signable(act *model.CoursesActivity) bool {
+	if act.Status != model.ActivityStatusEnabled {
+		return false
+	}
+	if act.NumberMax > 0 && act.NumberSign >= act.NumberMax {
+		return false
+	}
+	now := s.now()
+	if act.StartTime != nil && now.Before(*act.StartTime) {
+		return false
+	}
+	if act.EndTime != nil && now.After(*act.EndTime) {
+		return false
+	}
+	return true
+}
+
+// assertCampusInScope 校验 `campusId` 归属本机构(跨机构 → **1004**,校区不存在 → **3104**)。
+//
+// 入参:ctx;campusID(0 = 机构所有自营校区,**直接放行**:0 是全机构池语义,不是"某个具体校区");
+// institutionID 本请求机构(>0,调用方已校验)。
+// 出参:nil 通过;3104 / 1004 / 5002。
+//
+// 为什么需要这条:`t_courses_activity.campus_id` 是作用域列,写入一个**别的机构**的校区 ID 后,
+// 该活动对两个机构都"半可见"(本机构按 institution_id 能查到,但校区是别人的),
+// 后续按校区做的失效/统计会互相污染。旧系统 PHP 无此校验(dump 无对应逻辑),属 Go 增强,
+// 与 §3.8-b 的资金侧 `R1/R4-bis` 同一思路("跨域归属以 org 域为唯一权威")。
+//
+// 端口为 nil 时跳过(系统任务/割接脚本模式),生产装配必须注入。
+func (s *ActivityAdminService) assertCampusInScope(ctx context.Context, campusID, institutionID int64) error {
+	if campusID <= 0 || s.campus == nil {
+		return nil
+	}
+	schoolID, err := s.campus.GetSchoolID(ctx, campusID)
+	if err != nil {
+		return err
+	}
+	if schoolID != institutionID {
+		return infraerrors.WithCause(infraerrors.ErrUnscoped,
+			errString("service: campusId 不属于本机构(跨作用域写入被拒)"))
+	}
+	return nil
+}
+
+// validateActivityFields 活动可写字段的**值**校验(全部 2002,凡"传了但非法")。
+//
+// 入参:type/status/numberMax/money/start/end。
+// 出参:nil 通过;ErrBadFormat(2002)。
+//
+// 规则:
+//   - `type` 必须 ∈ {2,3,4};`status` 必须 ∈ {0,1,2}(枚举越界 = 填错);
+//   - `numberMax >= 0`(计数列);`money` 必须在 `money.Amount` 可表示范围内(**≤0 允许**,§3.8-Z1);
+//   - 起止同时给出时 `start <= end`(倒挂 = 填错)。
+func validateActivityFields(
+	activityType, status int8, numberMax int32, amount money.Amount,
+	startTime, endTime *time.Time,
+) error {
+	if _, ok := activityEditableTypes[activityType]; !ok {
+		return infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: 活动类型非法(只接受 2 充值赠送 / 3 代金券 / 4 老带新)"))
+	}
+	if _, ok := activityStatuses[status]; !ok {
+		return infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: 活动状态非法(只接受 0 停用 / 1 开启 / 2 已结束)"))
+	}
+	if numberMax < 0 {
+		return infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: numberMax 不得为负(0 = 不限)"))
+	}
+	if err := amount.Validate(); err != nil {
+		return infraerrors.WithCause(infraerrors.ErrBadFormat, err)
+	}
+	if startTime != nil && endTime != nil && endTime.Before(*startTime) {
+		return infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: endTime 早于 startTime(时间窗倒挂)"))
+	}
+	return nil
+}

+ 202 - 0
internal/academic/service/activity_admin_service_test.go

@@ -0,0 +1,202 @@
+// ActivityAdminService 单测(AC-13b):活动管理面(端点 22~25 / 28 / 37)的**服务级**判据。
+//
+// 与 handler 测试的分工:
+//
+//	handler 测试  证明"端点通、码对、跨表效果对"(请求 → 响应 → 库)
+//	本文件        证明"服务内部规则"(`signable` 的时间窗/名额判定、跨机构校区拒绝、
+//	              `RowsAffected==0 → 3001`、可用额构成的分组与排序、3121 的 R8 分界)
+//
+// 每条用例的第一段注释都写清"改哪一行会让它变红"——只写"期望值"的用例不算判据
+// (它只能证明实现与断言一致,不能证明实现正确)。
+package service
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/port"
+)
+
+// TestSignableMatrix `signable` 的四条子判据(端点 22 的展示位)。
+//
+// 会变红的条件(逐条对应一个分支):
+//   - 去掉 `status != Enabled` ⇒ 停用活动 signable=true → 红;
+//   - 去掉名额判定 ⇒ 满员活动 signable=true → 红;
+//   - 去掉 `now < start` 判定 ⇒ 未开始活动 signable=true → 红;
+//   - 去掉 `now > end` 判定 ⇒ 已结束活动 signable=true → 红。
+//
+// ⚠ `signable` **只喂前端展示**:它变 true 不代表后端会收报名(dump 的 signed_add 无可用性校验,
+// §10.1-3110 已收窄)。本用例只锁"展示位算法",**不得**据此在写路径加校验。
+func TestSignableMatrix(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityAdminService(db, activityScopeFor(1, 10))
+	fixed := time.Date(2026, 11, 9, 12, 0, 0, 0, time.UTC)
+	svc.WithClock(func() time.Time { return fixed })
+
+	start := fixed.Add(-24 * time.Hour)
+	end := fixed.Add(24 * time.Hour)
+	before := fixed.Add(24 * time.Hour)
+	after := fixed.Add(-24 * time.Hour)
+
+	cases := []struct {
+		name string
+		act  model.CoursesActivity
+		want bool
+	}{
+		{"开启+在窗口内+有名额", model.CoursesActivity{
+			Status: model.ActivityStatusEnabled, NumberMax: 5, NumberSign: 4, StartTime: &start, EndTime: &end,
+		}, true},
+		{"开启+不限名额+无窗口", model.CoursesActivity{Status: model.ActivityStatusEnabled}, true},
+		{"停用", model.CoursesActivity{Status: model.ActivityStatusDisabled, StartTime: &start, EndTime: &end}, false},
+		{"满员", model.CoursesActivity{
+			Status: model.ActivityStatusEnabled, NumberMax: 3, NumberSign: 3, StartTime: &start, EndTime: &end,
+		}, false},
+		{"未开始", model.CoursesActivity{Status: model.ActivityStatusEnabled, StartTime: &before}, false},
+		{"已结束", model.CoursesActivity{Status: model.ActivityStatusEnabled, EndTime: &after}, false},
+	}
+	for _, tc := range cases {
+		act := tc.act
+		if got := svc.signable(&act); got != tc.want {
+			t.Errorf("%s:signable 期望 %v,实得 %v", tc.name, tc.want, got)
+		}
+	}
+}
+
+// TestCreateActivityRejectsCrossCampus 新建的校区归属校验:跨机构 → **1004**;校区不存在 → **3104**。
+//
+// 会变红的条件:把 `assertCampusInScope` 的 `schoolID != institutionID` 判断删掉 ⇒ 跨机构写入成功 → 红。
+func TestCreateActivityRejectsCrossCampus(t *testing.T) {
+	db := newActivityDomainDB(t)
+	// 复用 fund_service_test.go 的 seedCampus(self_support=true 自营)
+	seedCampus(t, db, 10, 1, true)
+	seedCampus(t, db, 20, 2, true)
+	svc := NewActivityAdminService(db, activityScopeFor(1, 10)).WithCampusReader(port.NewOrgCampusReader(db))
+	ctx := context.Background()
+
+	base := CreateActivityInput{
+		InstitutionID: 1, Type: model.ActivityTypeRecharge, Title: "跨机构测试",
+		Money: money.FromCents(10000), Status: model.ActivityStatusEnabled,
+	}
+
+	cross := base
+	cross.CampusID = 20
+	if _, err := svc.CreateActivity(ctx, cross); err == nil {
+		t.Fatalf("校区 20 属于机构 2,写入应被拒(1004),实得 nil")
+	} else {
+		wantAppErr(t, err, 1004, "跨机构校区写入")
+	}
+
+	missing := base
+	missing.CampusID = 999
+	if _, err := svc.CreateActivity(ctx, missing); err == nil {
+		t.Fatalf("校区 999 不存在,应 3104,实得 nil")
+	} else {
+		wantAppErr(t, err, 3104, "校区不存在")
+	}
+
+	ok := base
+	ok.CampusID = 10
+	res, err := svc.CreateActivity(ctx, ok)
+	if err != nil {
+		t.Fatalf("本机构校区写入应成功,实得 %v", err)
+	}
+	var row model.CoursesActivity
+	if e := db.Where("id = ?", res.ID).Take(&row).Error; e != nil {
+		t.Fatalf("读新建活动失败: %v", e)
+	}
+	if row.NumberSign != 0 {
+		t.Fatalf("number_sign 必须为 0,实得 %d", row.NumberSign)
+	}
+	if row.InstitutionID != 1 {
+		t.Fatalf("institution_id 应为 1,实得 %d", row.InstitutionID)
+	}
+}
+
+// TestUpdateActivityMissingRowIs3001 更新不存在/越权活动 → **3001**(`RowsAffected==0` 守卫)。
+//
+// 会变红的条件:删掉 `if affected == 0 { return ErrNotFound }` ⇒ 该用例实得 nil(假成功)→ 红。
+// 这正是 §10.3 红线「写路径必须校验影响行数」在活动域的落点。
+func TestUpdateActivityMissingRowIs3001(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityAdminService(db, activityScopeFor(1, 10))
+	_, err := svc.UpdateActivity(context.Background(), UpdateActivityInput{
+		ID: 123456, InstitutionID: 1, Type: model.ActivityTypeRecharge,
+		Title: "改名", Money: money.FromCents(1), Status: model.ActivityStatusEnabled,
+	})
+	wantAppErr(t, err, 3001, "更新不存在的活动")
+}
+
+// TestUsableBreakdownGroupsByActivity 可用赠送按活动分组:总额 = Σ 构成、按 activityId 升序、不可用行不计。
+//
+// 会变红的条件:
+//   - 把 `usable` 改成"再查一次 SQL 汇总"(而不是 Σ items)⇒ 两者在并发/脏行下会不一致 → 红;
+//   - 去掉 `amount <= 0` 跳过 ⇒ 出现 0 元 item → 红;
+//   - 去掉排序 ⇒ map 遍历顺序随机,断言 flaky(本用例固定期望 [1001,1002])。
+func TestUsableBreakdownGroupsByActivity(t *testing.T) {
+	db := newActivityDomainDB(t)
+	userID := "01HSTUDENT000000000000001"
+	pools := []model.CoursesActivityUserDiscount{
+		{ID: 1, ActivityID: 1001, InstitutionID: 1, CampusID: 10, UserID: userID,
+			Income: "0.00", DiscountAmount: "100.00", DiscountAmountUsed: "40.00"},
+		{ID: 2, ActivityID: 1002, InstitutionID: 1, CampusID: 10, UserID: userID,
+			Income: "0.00", DiscountAmount: "30.00", DiscountAmountUsed: "0.00"},
+		// 不可用行(已用 >= 面额)→ 两个方法都不计
+		{ID: 3, ActivityID: 1003, InstitutionID: 1, CampusID: 10, UserID: userID,
+			Income: "0.00", DiscountAmount: "10.00", DiscountAmountUsed: "10.00"},
+	}
+	for i := range pools {
+		if err := db.Create(&pools[i]).Error; err != nil {
+			t.Fatalf("seed 池失败: %v", err)
+		}
+	}
+	svc := NewActivityDiscountService(db, activityScopeFor(1, 10))
+	res, err := svc.UsableBreakdown(context.Background(), UsableAmountInput{
+		UserID: userID, InstitutionID: 1, CampusID: 10,
+	})
+	if err != nil {
+		t.Fatalf("UsableBreakdown 失败: %v", err)
+	}
+	if len(res.Items) != 2 {
+		t.Fatalf("items 应为 2 项(1003 不可用被排除),实得 %d: %+v", len(res.Items), res.Items)
+	}
+	if res.Items[0].ActivityID != 1001 || res.Items[1].ActivityID != 1002 {
+		t.Fatalf("items 必须按 activityId 升序,实得 %+v", res.Items)
+	}
+	if res.Usable != money.FromCents(9000) {
+		t.Fatalf("usable 应为 90.00 元(9000 分),实得 %s", res.Usable.String())
+	}
+	sum := money.Amount(0)
+	for _, item := range res.Items {
+		sum = sum.Add(item.Amount)
+	}
+	if sum != res.Usable {
+		t.Fatalf("usable(%s) 必须等于 Σ items(%s)", res.Usable.String(), sum.String())
+	}
+}
+
+// TestCancelSignUnresolvableTypeIs3121 服务层 R8:报名行在、活动行缺失 ⇒ 3121(不是 3001)。
+//
+// 会变红的条件:删掉 `ExistsAnySigned` 分支 ⇒ 落回 3001 → 红。
+// 本用例与 handler 的 `TestCancelSignUnresolvableTypeIs3121` 成对:一个锁服务分界,
+// 一个锁 HTTP 码不被 handler 改写(handler 是纯透传,两层都得有判据)。
+func TestCancelSignUnresolvableTypeIs3121(t *testing.T) {
+	db := newActivityDomainDB(t)
+	userID := "01HSTUDENT000000000000002"
+	// 只有报名行,没有活动行(模拟活动被物理删除后的历史脏数据)
+	if err := db.Create(&model.CoursesActivitySigned{
+		ID: 91, ActivityID: 9101, UserID: userID, InstitutionID: 1, CampusID: 10,
+		Status: model.ActivitySignedSigned, ActivityMoney: "100.00", ActivityMoneyUsed: "0.00",
+	}).Error; err != nil {
+		t.Fatalf("seed 报名行失败: %v", err)
+	}
+	svc := NewActivityService(db, activityScopeFor(1, 10))
+	_, err := svc.CancelSign(context.Background(), CancelSignInput{UserID: userID, ActivityID: 9101})
+	wantAppErr(t, err, 3121, "取消报名取不到活动类型")
+
+	// 反向确认:完全没有报名行时仍是 3001(§3.8-R7),两者不得合并
+	_, err2 := svc.CancelSign(context.Background(), CancelSignInput{UserID: userID, ActivityID: 9199})
+	wantAppErr(t, err2, 3001, "无报名行")
+}

+ 25 - 7
internal/academic/service/activity_service.go

@@ -1,7 +1,7 @@
 // 活动报名服务(报名 / 取消报名)——AC-07 活动服务 A(主链路部分)。
 //
 // 依据:unified/03-migration/academic-domain-design.md
-//   - §3.2 #19 `sp_courses_activity_signed_add`(dump **762908**)→ `Sign`
+//   - §3.2 #19 `sp_courses_activity_signed_add`(dump **762910**,CREATE 行;762908 是 DROP)→ `Sign`
 //   - §3.2 #20 `sp_courses_activity_signed_cancel`(dump **762982**)→ `CancelSign`
 //   - §8 AC-07 前置输入(10 原语)、§5.1 五条纪律(第 4 条横切优先 / 第 5 条幂等回放等价)
 //   - §10.1 错误码 3105/3106/3107/3108/3141、§10.3 事务模板、§10.7 金额
@@ -141,7 +141,7 @@ type CancelSignResult struct {
 
 // ---------------------------------------------------------------- 报名(SP19)
 
-// Sign 用户报名活动(SP19 `sp_courses_activity_signed_add`,dump 762908)。
+// Sign 用户报名活动(SP19 `sp_courses_activity_signed_add`,dump 762910)。
 //
 // dump 语义(逐行对齐,行号见括号):
 //
@@ -280,16 +280,23 @@ func (s *ActivityService) Sign(ctx context.Context, in SignInput) (*SignResult,
 //
 // dump 语义(逐行对齐):
 //
-//	762994  游标:signed JOIN activity WHERE user/activity AND signed.status!=0
-//	763007  IF type∈{2,3} AND response_userid>0 → LEAVE(**3107** 已审核不可取消)
-//	763013  ELSEIF type=4 AND EXISTS(used_log WHERE activityid/userid) → LEAVE(**3108** 已消费不可取消)
+//	762994  游标:signed JOIN activity WHERE user/activity AND signed.status!=0(dump 762997-762998)
+//	763013  IF type∈{2,3} AND response_userid>0 → LEAVE(**3107** 已审核不可取消,dump 763013/763015)
+//	763018  ELSEIF type=4 AND EXISTS(used_log WHERE activityid/userid) → LEAVE(**3108** 已消费不可取消,
+//	        dump 763018-763023)
 //	763028  UPDATE signed SET status=0, activity_money=0, activity_money_used=0(**三列同置**)
-//	763030  UPDATE activity SET number_sign=number_sign-1(Go 加**下限 0**保护)
+//	763030  UPDATE activity SET number_sign=number_sign-1(Go 加**下限 0**保护,§3.8-R3)
 //
 // 守卫命中时**不减人数**(`LEAVE` 在两条 UPDATE 之前)——这是"拒绝必须零副作用"的判据:
 // 若把 -1 写进守卫之前,就会留下"取消被拒但人数少了"的脏数据。
 //
-// 入参:ctx / in。出参:*CancelSignResult;错误码 2001/2002/3001/3107/3108/5002。
+// ⚠ 状态机**取不到 type** → **3121**(§3.8-R8 / §10.1 v0.1.23 扩展;**不得"读不到就放行"**):
+// 游标是 `signed JOIN activity`,若报名行在、活动行没了(或作用域外),`type` 读不到 ⇒
+// 3107/3108 两条审核态保护**无法判定**。dump 在这种情况会按"非 2/3/4"继续往下走并 `UPDATE`,
+// 等于**跳过审核态保护**(已审核通过/已产生消费的报名被静默取消),故 Go 显式 3121。
+// 与 3001 的分界:**完全没有报名行** → 3001(§3.8-R7);**有报名行但 type 读不到** → 3121。
+//
+// 入参:ctx / in。出参:*CancelSignResult;错误码 2001/2002/3001/3107/3108/3121/5002。
 func (s *ActivityService) CancelSign(ctx context.Context, in CancelSignInput) (*CancelSignResult, error) {
 	if strings.TrimSpace(in.UserID) == "" {
 		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: CancelSign 需要 user_id"))
@@ -313,6 +320,17 @@ func (s *ActivityService) CancelSign(ctx context.Context, in CancelSignInput) (*
 		signed, e := s.repo.GetSigned(txCtx, userID, in.ActivityID)
 		if e != nil {
 			if errors.Is(e, infraerrors.ErrNotFound) {
+				// **3121 与 3001 的分界**(§3.8-R8,见方法头):先判"报名行到底有没有"。
+				// 有报名行却 JOIN 不到活动 ⇒ type 读不到 ⇒ 状态机无法判定 ⇒ 3121,
+				// **不得**按"非 2/3/4"放行(那会跳过 3107/3108 的审核态保护)。
+				hasSigned, he := s.repo.ExistsAnySigned(txCtx, userID, in.ActivityID)
+				if he != nil {
+					return he
+				}
+				if hasSigned {
+					return infraerrors.WithCause(apperr.ErrRecordStateInvalid,
+						errString("service: CancelSign 取不到活动类型,报名状态机无法判定(3121,§3.8-R8)"))
+				}
 				return infraerrors.WithCause(infraerrors.ErrNotFound,
 					errString("service: CancelSign 报名记录不存在或已取消(dump:游标无行 → success=0)"))
 			}

+ 123 - 0
internal/academic/service/activity_usable_breakdown.go

@@ -0,0 +1,123 @@
+// 可用赠送金额的**分活动构成**(端点 31 `GET /admin/activities/discounts/usable`)。
+//
+// 依据:
+//   - 契约 academic-frontend-contract-v1.md §2.2 端点 31 / §3.4 B 行 31:
+//     `userId`、`campusId`、`usable:金额`、`items:[{activityId:number, amount:金额, memo:string}]`;
+//   - 设计 academic-domain-design.md §3.2 #14(SP14 `sp_courses_activity_user_discount_usable`,dump 763176)
+//     与 v0.1.5 裁定 **A7**(`schoolid>0` 时校区是**等值**;`schoolid<=0` 不加任何作用域条件)。
+//
+// ---------------------------------------------------------------------------
+// 为什么单开一个文件、且不放进 `activity_discount_service.go`
+// ---------------------------------------------------------------------------
+//
+// `activity_discount_service.go` 的方法与 SP12~SP17 **一一对应**(方法头都有 dump 行号)。
+// 端点 31 的 `items` 是**契约新增的视图**:SP14 只 `SELECT SUM(...)` 出总额,
+// 没有"按活动拆分"的分支。把"有 SP 对照"与"无 SP 对照"混写,会让后人误以为
+// `GROUP BY activity_id` 也是 dump 口径(它在 dump 里**不存在**)。
+//
+// 本文件只做一件事:**同一 WHERE 的行,换个聚合维度**。因此:
+//   - 过滤条件**逐字复用** `SumUsable`(dump 763181-763185),一个新条件都不加;
+//   - 金额**不用 SQL SUM**(§10.7 / E3-b:`SUM(DECIMAL)` 在 sqlite 返回 REAL,分位会漂);
+//   - `usable` 由 items **逐项累加**得出(不是第二次查 SQL),保证"总额 = 构成之和"
+//     在**同一快照**里恒成立 —— 两次查询在并发下会给出互相矛盾的响应。
+//
+// ---------------------------------------------------------------------------
+// ⚠ 一处**契约缺口**(已上报,未擅自实现)
+// ---------------------------------------------------------------------------
+//
+// 契约要求 `items[].memo:string`,但 `t_courses_activity_user_discount`(赠送池行)**没有 memo 列**
+// (目标态 DDL 第 11 表逐列核对:id/activity_id/institution_id/campus_id/user_id/balance_request_id/
+// income/discount_amount/discount_amount_used/create_time)。按 §3.4 ③「字段准入条件」,
+// **找不到列的一律不下发**:本实现不输出 `memo`(而不是输出空串或"拿活动标题顶上"——
+// 后者会让前端把"活动名"当"备注"展示,属静默语义错位,比缺字段更难发现)。
+package service
+
+import (
+	"context"
+	"strings"
+
+	"yuansiwei/unified-backend/internal/academic/money"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// UsableBreakdownItem 单活动的可用赠送构成项(契约 §3.4 B 行 31 的 `items[]`)。
+//
+// 字段 → 来源列:
+//
+//	activityId ← t_courses_activity_user_discount.activity_id
+//	amount     ← Σ(discount_amount − discount_amount_used) GROUP BY activity_id(派生)
+//
+// ⚠ 契约里的 `memo` **不实现**:池表无 memo 列(见文件头)。
+type UsableBreakdownItem struct {
+	// ActivityID 活动 ID。
+	ActivityID int64 `json:"activityId"`
+	// Amount 该活动可用额(分;严格 > 0)。
+	Amount money.Amount `json:"amount"`
+}
+
+// UsableBreakdownResult 可用赠送金额(总额 + 分活动构成)。
+//
+// 字段 → 来源:
+//
+//	userId   ← 入参回显(作用域对象)
+//	campusId ← 入参回显(**不是**数据行的校区列;见下方"回显语义")
+//	usable   ← Σ items[].amount(与 SP14 的 `SUM(discount_amount - used)` 同一批行)
+//	items    ← 按 activity_id 分组
+//
+// 回显语义:`campusId` 回显**请求参数**而不是"池行的 campus_id"——因为 `institutionID<=0`
+// 的平台视角下池行可能跨多个校区,回显数据行的校区会让前端误以为"这是某个校区的余额"。
+type UsableBreakdownResult struct {
+	// UserID 学生(回显)。
+	UserID string `json:"userId"`
+	// CampusID 校区(回显入参)。
+	CampusID int64 `json:"campusId"`
+	// Usable 可用额度合计(分)。
+	Usable money.Amount `json:"usable"`
+	// Items 分活动构成(按 activityId 升序;空 → `[]`)。
+	Items []UsableBreakdownItem `json:"items"`
+}
+
+// UsableBreakdown 汇总用户可用赠送金额并给出**按活动的构成**(端点 31,SP14 的分活动视图)。
+//
+// 口径(与 `UsableAmount` 完全一致,dump 763176 / 763181-763185):
+//
+//	WHERE user_id = ? AND discount_amount > discount_amount_used
+//	  [AND institution_id = ? AND campus_id = ?]   -- 仅 institutionID>0 时两列一起写(等值)
+//
+// 入参:ctx / in(`UserID` 必填;`InstitutionID`/`CampusID` 语义见 `UsableAmountInput`)。
+// 出参:*UsableBreakdownResult;错误码 2001(缺 userId)/ 2002(机构/校区为负)/ 5002。
+//
+// 越权防线(**不在本方法**,与 `SumUsable` 注释同源):`institutionID<=0` 的平台视角
+// 由 **Handler 的角色白名单**收口(设计 §3.2-A7 裁定),不在 SQL 里判。
+func (s *ActivityDiscountService) UsableBreakdown(
+	ctx context.Context, in UsableAmountInput,
+) (*UsableBreakdownResult, error) {
+	if strings.TrimSpace(in.UserID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: UsableBreakdown 需要 user_id"))
+	}
+	if in.InstitutionID < 0 || in.CampusID < 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: UsableBreakdown 机构/校区参数不得为负(0 的语义见 repository.SumUsable)"))
+	}
+	if s == nil || s.disc == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal,
+			errString("service: ActivityDiscountService 未装配"))
+	}
+	rows, err := s.disc.SumUsableByActivity(ctx, strings.TrimSpace(in.UserID), in.InstitutionID, in.CampusID)
+	if err != nil {
+		return nil, err
+	}
+	items := make([]UsableBreakdownItem, 0, len(rows))
+	total := money.Amount(0)
+	for i := range rows {
+		items = append(items, UsableBreakdownItem{ActivityID: rows[i].ActivityID, Amount: rows[i].Amount})
+		total = total.Add(rows[i].Amount)
+	}
+	return &UsableBreakdownResult{
+		UserID:   strings.TrimSpace(in.UserID),
+		CampusID: in.CampusID,
+		Usable:   total,
+		Items:    items,
+	}, nil
+}