// 活动域 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 =