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