activity_handler.go 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896
  1. // 活动域 HTTP 处理器(端点 22~37,共 16 个)。
  2. //
  3. // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.2(端点表)/ §3.4 B(字段级定义);
  4. // unified/03-migration/academic-domain-design.md §4.2(端点清单与角色)/ §10.1(31xx 错误码)/
  5. // §3.2 #12-#20(SP 映射)。
  6. //
  7. // ---------------------------------------------------------------------------
  8. // 端点 → SP / 服务方法对照(**每个方法头都重复写一次,便于单点核对**)
  9. // ---------------------------------------------------------------------------
  10. //
  11. // 22 GET /admin/activities 列表 dump: 无(PHP 直查) ActivityAdminService.ListActivities
  12. // 23 POST /admin/activities 新建 dump: 无(PHP 直写) ActivityAdminService.CreateActivity
  13. // 24 GET /admin/activities/:id 详情 dump: 无(PHP 直查) ActivityAdminService.GetActivity
  14. // 25 PUT /admin/activities/:id 更新 dump: 无(PHP 直写) ActivityAdminService.UpdateActivity
  15. // 26 POST /admin/activities/:id/sign 报名 SP19 dump 762910 ActivityService.Sign
  16. // 27 POST /admin/activities/:id/cancel-sign 取消报名 SP20 dump 762982 ActivityService.CancelSign
  17. // 28 GET /admin/activities/:id/signeds 报名列表 dump: 无(PHP 直查) ActivityAdminService.ListSigneds
  18. // 29 POST /admin/activities/:id/recommend-settle 老带新 SP18 dump 763309 RecommendService.SettleRecommend
  19. // 30 POST /admin/activities/:id/cancel-recharge 取消充值 SP8 dump 763935 RecommendService.CancelRechargeActivity
  20. // 31 GET /admin/activities/discounts/usable 可用赠送 SP14 dump 763176 ActivityDiscountService.UsableBreakdown
  21. // 32 POST /admin/activities/discounts/use 用赠送 SP15 dump 763204 ActivityDiscountService.UseVoucher
  22. // 33 POST /admin/activities/discounts/refund 赠送退回 SP16 dump 763046 ActivityDiscountService.RefundByStuCourse
  23. // 34 POST /admin/activities/discounts/refund-by-activity 按活动退 SP17 dump 763106 ActivityDiscountService.RefundByActivity
  24. // 35 POST /admin/activities/recharge-gifts/use 充值赠送用 SP12 dump 763256 ActivityDiscountService.UseRechargeGift
  25. // 36 POST /admin/activities/recharge-gifts/refund 充值赠送退 SP13 dump 762841 ActivityDiscountService.RefundRechargeGift
  26. // 37 POST /admin/activities/:id/visits 回访 dump: 无(visit 空表) ActivityAdminService.RegisterVisit
  27. //
  28. // ---------------------------------------------------------------------------
  29. // ⚠ 路由注册顺序(AC-14 接线时必须遵守,否则本条 16 个端点会互相吃掉)
  30. // ---------------------------------------------------------------------------
  31. //
  32. // Fiber 的路由是**按注册顺序**匹配的。`/admin/activities/:id` 会把 `discounts`、`recharge-gifts`
  33. // 当成 id 吃掉。因此 **静态段必须全部先注册**,再注册带 `:id` 的:
  34. //
  35. // ① /admin/activities (GET/POST)
  36. // ② /admin/activities/discounts/usable (GET)
  37. // ③ /admin/activities/discounts/use (POST)
  38. // ④ /admin/activities/discounts/refund (POST)
  39. // ⑤ /admin/activities/discounts/refund-by-activity(POST)
  40. // ⑥ /admin/activities/recharge-gifts/use (POST)
  41. // ⑦ /admin/activities/recharge-gifts/refund (POST)
  42. // ⑧ /admin/activities/:id (GET/PUT)
  43. // ⑨ /admin/activities/:id/sign … /visits (POST/GET)
  44. //
  45. // 本文件因此把 `PathActivity*` 常量按"静态优先"顺序声明,测试里也按同一顺序注册
  46. // (`TestActivityRouteCoverage` 会断言顺序敏感的两条路由都能命中各自 handler)。
  47. //
  48. // ---------------------------------------------------------------------------
  49. // 错误码分工(handler 层只看得到三类)
  50. // ---------------------------------------------------------------------------
  51. //
  52. // 2001 缺参/类型不符 —— 由 `validateReq`(`validator.Validate`)产生,**本文件不手写**
  53. // 2002 传了但非法 —— 金额 Parse 失败 / 枚举越界 / 路径 id 非数字(`parseEnumQuery`、`parseMoney`)
  54. // 1003/1004 越权 —— 端点 31 的"平台视角"角色白名单(1003)与跨机构 institutionId(1004)
  55. //
  56. // 其余(3001/3104/3105/3106/3107/3108/3109/3121/3140/5002)一律由 Service/Repository 产出,
  57. // handler **原样透传**(`response.Fail`),不做码值翻译——翻译一层就会有两份口径。
  58. package handler
  59. import (
  60. "strings"
  61. "time"
  62. "github.com/gofiber/fiber/v2"
  63. "yuansiwei/unified-backend/internal/academic/apperr"
  64. "yuansiwei/unified-backend/internal/academic/money"
  65. "yuansiwei/unified-backend/internal/academic/service"
  66. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  67. "yuansiwei/unified-backend/internal/infra/response"
  68. appscope "yuansiwei/unified-backend/internal/infra/scope"
  69. )
  70. // ---------------------------------------------------------------- 路径常量(**不含 `/api`**)
  71. //
  72. // 声明顺序 = 注册顺序(静态段在前),理由见文件头「路由注册顺序」。
  73. const (
  74. // PathActivities 端点 22/23:活动列表 / 新建(`/admin/activities`)。
  75. PathActivities = "/admin/activities"
  76. // PathActivityDiscountsUsable 端点 31:可用赠送(`GET /admin/activities/discounts/usable`)。
  77. PathActivityDiscountsUsable = "/admin/activities/discounts/usable"
  78. // PathActivityDiscountsUse 端点 32:使用赠送(`POST /admin/activities/discounts/use`)。
  79. PathActivityDiscountsUse = "/admin/activities/discounts/use"
  80. // PathActivityDiscountsRefund 端点 33:赠送退回(`POST /admin/activities/discounts/refund`)。
  81. PathActivityDiscountsRefund = "/admin/activities/discounts/refund"
  82. // PathActivityDiscountsRefundByActivity 端点 34:按活动退回(`.../discounts/refund-by-activity`)。
  83. PathActivityDiscountsRefundByActivity = "/admin/activities/discounts/refund-by-activity"
  84. // PathActivityRechargeGiftsUse 端点 35:充值赠送使用(`.../recharge-gifts/use`)。
  85. PathActivityRechargeGiftsUse = "/admin/activities/recharge-gifts/use"
  86. // PathActivityRechargeGiftsRefund 端点 36:充值赠送退回(`.../recharge-gifts/refund`)。
  87. PathActivityRechargeGiftsRefund = "/admin/activities/recharge-gifts/refund"
  88. // PathActivityDetail 端点 24/25:详情 / 更新(`/admin/activities/:id`)。
  89. PathActivityDetail = "/admin/activities/:id"
  90. // PathActivitySign 端点 26:报名(`/admin/activities/:id/sign`)。
  91. PathActivitySign = "/admin/activities/:id/sign"
  92. // PathActivityCancelSign 端点 27:取消报名(`/admin/activities/:id/cancel-sign`)。
  93. PathActivityCancelSign = "/admin/activities/:id/cancel-sign"
  94. // PathActivitySigneds 端点 28:报名列表(`/admin/activities/:id/signeds`)。
  95. PathActivitySigneds = "/admin/activities/:id/signeds"
  96. // PathActivityRecommendSettle 端点 29:老带新结算(`/admin/activities/:id/recommend-settle`)。
  97. PathActivityRecommendSettle = "/admin/activities/:id/recommend-settle"
  98. // PathActivityCancelRecharge 端点 30:取消充值活动(`/admin/activities/:id/cancel-recharge`)。
  99. PathActivityCancelRecharge = "/admin/activities/:id/cancel-recharge"
  100. // PathActivityVisits 端点 37:回访登记(`/admin/activities/:id/visits`)。
  101. PathActivityVisits = "/admin/activities/:id/visits"
  102. )
  103. // ---------------------------------------------------------------- 枚举白名单(2002 的唯一判据点)
  104. //
  105. // 为什么白名单在 handler 而不是 Service:`validator` 的 `oneof` 失败会被归成 **2001**(缺参),
  106. // 而"传了非法枚举"按设计 §10.1 属 **2002**(格式错)。两者是不同前端提示分支,不能合并。
  107. var (
  108. // activityTypeFilter 端点 22 的 `type` 过滤白名单(1 试听为旧遗留,不进列表筛选)。
  109. activityTypeFilter = map[int64]struct{}{2: {}, 3: {}, 4: {}}
  110. // activityStatusFilter 端点 22 的 `status` 过滤白名单(0 停用 / 1 开启 / 2 已结束)。
  111. activityStatusFilter = map[int64]struct{}{0: {}, 1: {}, 2: {}}
  112. // activitySignedStatusFilter 端点 28 的 `status` 过滤白名单(报名状态机 0~4)。
  113. activitySignedStatusFilter = map[int64]struct{}{0: {}, 1: {}, 2: {}, 3: {}, 4: {}}
  114. // activityPlatformRoles 端点 31 "平台视角"(`institutionId<=0`)的角色白名单。
  115. //
  116. // 依据:设计 §3.2 #14 裁定 **A7** 附注——「`institutionID=0` 必须放行,仅 `super_admin/finance`
  117. // 角色可用该视角(越权防线在 Handler 白名单,不在 SQL)」。
  118. //
  119. // ⚠ 与 `repository/activity_discount_repo.go` 里那句「平台视角只对 super_admin/academic 开放」
  120. // 不一致(那边写的是 academic)。按"设计裁定 > 代码注释"处理,取 **finance**;已在交付报告
  121. // 的「文档/注释冲突」一节登记,待裁决后统一(改动只影响这一个 map)。
  122. activityPlatformRoles = map[string]struct{}{"super_admin": {}, "finance": {}}
  123. )
  124. // ---------------------------------------------------------------- 列表(端点 22)
  125. // ListActivities 活动列表(端点 22 `GET /admin/activities`)。
  126. //
  127. // 入参(query):`type?`(2/3/4)、`status?`(0/1/2)、`keyword?`、`page`/`pageSize`。
  128. // 出参(成功):`{list,total,page,pageSize,pages}`(五字段由 `response.Page` 一次算定)。
  129. // 出参(失败):1001(无作用域)/2002(type 或 status 越界、非数字)/5002。
  130. //
  131. // 作用域:列表天然只含本机构(`scopedCampusRows`),跨机构活动**不出现**在结果里——
  132. // 这是端点 22 的越权判据(而不是报 1004:列表是"看不见"而非"被拒")。
  133. func (h *Handler) ListActivities(c *fiber.Ctx) error {
  134. sc, err := mustScope(c)
  135. if err != nil {
  136. return response.Fail(c, err)
  137. }
  138. typeFilter, _, err := parseEnumQuery(c, "type", activityTypeFilter)
  139. if err != nil {
  140. return response.Fail(c, err)
  141. }
  142. statusFilter, _, err := parseEnumQuery(c, "status", activityStatusFilter)
  143. if err != nil {
  144. return response.Fail(c, err)
  145. }
  146. page, pageSize := parsePage(c)
  147. // 白名单已限定在 2/3/4 与 0/1/2,窄化成 int8 是**安全**的(不会溢出);
  148. // 不做 `int8(*p)` 直接强转是为了让"转换发生在这里、且仅在命中白名单之后"这一点可读。
  149. var typePtr *int8
  150. if typeFilter != nil {
  151. v := int8(*typeFilter)
  152. typePtr = &v
  153. }
  154. var statusPtr *int8
  155. if statusFilter != nil {
  156. v := int8(*statusFilter)
  157. statusPtr = &v
  158. }
  159. svc := h.servicesFor(sc)
  160. res, err := svc.activityAdmin.ListActivities(c.Context(), service.ListActivitiesInput{
  161. Type: typePtr, Status: statusPtr,
  162. Keyword: c.Query("keyword"), Page: page, PageSize: pageSize,
  163. })
  164. if err != nil {
  165. return response.Fail(c, err)
  166. }
  167. return response.Page(c, res.List, res.Page, res.PageSize, res.Total)
  168. }
  169. // ---------------------------------------------------------------- 新建(端点 23)
  170. // CreateActivity 新建活动(端点 23 `POST /admin/activities`)。
  171. //
  172. // 入参(body):见 `CreateActivityReq`(`type` 必填 2/3/4,`title` 必填)。
  173. // 出参(成功):`{id}`(**`bizNo` 不下发**:`t_courses_activity` 无 `biz_no` 列,见 `CreateActivityResult` 注释)。
  174. // 出参(失败):2001/2002/3104(校区不存在)/1004(校区不属本机构、或 institutionId 跨机构)/5002。
  175. //
  176. // 机构来源(**不信任 body**):`institutionId` 缺省取作用域;非超管传了并且不等于作用域 → **1004**。
  177. func (h *Handler) CreateActivity(c *fiber.Ctx) error {
  178. sc, err := mustScope(c)
  179. if err != nil {
  180. return response.Fail(c, err)
  181. }
  182. var req CreateActivityReq
  183. if !validateReq(c, &req) {
  184. return nil
  185. }
  186. institutionID, err := resolveInstitutionID(sc, req.InstitutionID)
  187. if err != nil {
  188. return response.Fail(c, err)
  189. }
  190. amount, ok := parseMoneyOrZero(c, req.Money, "money")
  191. if !ok {
  192. return nil
  193. }
  194. start, end, err := parseActivityWindow(req.StartTime, req.EndTime)
  195. if err != nil {
  196. return response.Fail(c, err)
  197. }
  198. svc := h.servicesFor(sc)
  199. res, err := svc.activityAdmin.CreateActivity(c.Context(), service.CreateActivityInput{
  200. InstitutionID: institutionID,
  201. CampusID: req.CampusID,
  202. Type: req.Type,
  203. Title: strings.TrimSpace(req.Title),
  204. Content: req.Content,
  205. Memo: req.Memo,
  206. Thumbnail: req.Thumbnail,
  207. Money: amount,
  208. StartTime: start,
  209. EndTime: end,
  210. NumberMax: req.NumberMax,
  211. Status: req.Status,
  212. Toped: req.Toped,
  213. SubjectID: req.SubjectID,
  214. GradeID: req.GradeID,
  215. OperatorID: sc.UserID,
  216. })
  217. if err != nil {
  218. return response.Fail(c, err)
  219. }
  220. return response.OK(c, res)
  221. }
  222. // ---------------------------------------------------------------- 详情(端点 24)
  223. // GetActivity 活动详情(端点 24 `GET /admin/activities/:id`)。
  224. //
  225. // 入参(path):`:id`。
  226. // 出参(成功):22 行结构(平铺)+ `signeds[]`(最多 200 条,见 `ActivityDetailSignedLimit`)。
  227. // 出参(失败):2002(id 非数字/≤0)/3001(不存在或不在作用域,**不泄露存在性**)/5002。
  228. func (h *Handler) GetActivity(c *fiber.Ctx) error {
  229. sc, err := mustScope(c)
  230. if err != nil {
  231. return response.Fail(c, err)
  232. }
  233. svc := h.servicesFor(sc)
  234. res, err := svc.activityAdmin.GetActivity(c.Context(), parseIDParam(c.Params("id")))
  235. if err != nil {
  236. return response.Fail(c, err)
  237. }
  238. return response.OK(c, res)
  239. }
  240. // ---------------------------------------------------------------- 更新(端点 25)
  241. // UpdateActivity 更新活动(端点 25 `PUT /admin/activities/:id`,**整体替换**语义)。
  242. //
  243. // 入参(body):见 `UpdateActivityReq`。
  244. // 出参(成功):`{id}`。
  245. // 出参(失败):2001/2002(含枚举越界、时间窗倒挂)/3001(活动不存在/越权)/3104/1004/**3140**。
  246. //
  247. // **3140 的唯一触发点**:body 里出现 `numberSign`(契约 §3.4 B 行 25「`numberSign` 传即 3140」)。
  248. // 注意这里是**契约原话**,与设计 §10.1「3140 仅收两参数互相矛盾」的口径**不完全一致**——
  249. // 按"契约是前端唯一对接依据"取契约,已在交付报告登记该口径冲突(不是实现自由裁量)。
  250. func (h *Handler) UpdateActivity(c *fiber.Ctx) error {
  251. sc, err := mustScope(c)
  252. if err != nil {
  253. return response.Fail(c, err)
  254. }
  255. var req UpdateActivityReq
  256. if !validateReq(c, &req) {
  257. return nil
  258. }
  259. if req.NumberSign != nil {
  260. // 3140(`apperr.ErrBusinessParam`):契约 §3.4 B 行 25 原文「`numberSign` 传即 3140」。
  261. // 引用常量而非数字,理由见 `internal/academic/apperr/codes.go` 包注释(31xx 唯一定义点)。
  262. return response.Fail(c, infraerrors.WithCause(apperr.ErrBusinessParam,
  263. errString("numberSign 是报名计数列,不允许通过活动更新写入")))
  264. }
  265. institutionID, err := resolveInstitutionID(sc, req.InstitutionID)
  266. if err != nil {
  267. return response.Fail(c, err)
  268. }
  269. amount, ok := parseMoneyOrZero(c, req.Money, "money")
  270. if !ok {
  271. return nil
  272. }
  273. start, end, err := parseActivityWindow(req.StartTime, req.EndTime)
  274. if err != nil {
  275. return response.Fail(c, err)
  276. }
  277. svc := h.servicesFor(sc)
  278. res, err := svc.activityAdmin.UpdateActivity(c.Context(), service.UpdateActivityInput{
  279. ID: parseIDParam(c.Params("id")),
  280. InstitutionID: institutionID,
  281. CampusID: req.CampusID,
  282. Type: req.Type,
  283. Title: strings.TrimSpace(req.Title),
  284. Content: req.Content,
  285. Memo: req.Memo,
  286. Thumbnail: req.Thumbnail,
  287. Money: amount,
  288. StartTime: start,
  289. EndTime: end,
  290. NumberMax: req.NumberMax,
  291. Status: req.Status,
  292. Toped: req.Toped,
  293. SubjectID: req.SubjectID,
  294. GradeID: req.GradeID,
  295. })
  296. if err != nil {
  297. return response.Fail(c, err)
  298. }
  299. return response.OK(c, res)
  300. }
  301. // ---------------------------------------------------------------- 报名(端点 26)
  302. // SignActivity 报名(端点 26 `POST /admin/activities/:id/sign`,SP19 dump 762910(CREATE 行;762908 是 DROP))。
  303. //
  304. // 入参(body):`userId` 必填;`status?`(<1 → 1);`recommendUserId?`。
  305. // 出参(成功):`{signedId}`。
  306. // 出参(失败):2001/2002/**3001**(活动不存在)/3105(已报名)/3106(老带新限一次)/
  307. // 3141(人数已满,Go 增强)/5002。
  308. //
  309. // 三种"看起来像但都不是"的码(**不要互换**,各由 Service 按 dump 判据给出):
  310. //
  311. // 3105 判**报名行** status(取消后可重报);3106 判**活动** status(取消也不退次数);
  312. // 3110 **禁用**(dump 的 signed_add 无可用性校验,§10.1 v0.1.12 收窄)。
  313. func (h *Handler) SignActivity(c *fiber.Ctx) error {
  314. sc, err := mustScope(c)
  315. if err != nil {
  316. return response.Fail(c, err)
  317. }
  318. var req SignActivityReq
  319. if !validateReq(c, &req) {
  320. return nil
  321. }
  322. svc := h.servicesFor(sc)
  323. res, err := svc.activity.Sign(c.Context(), service.SignInput{
  324. UserID: strings.TrimSpace(req.UserID),
  325. ActivityID: parseIDParam(c.Params("id")),
  326. Status: req.Status,
  327. RecommendUserID: req.RecommendUserID,
  328. CreateUserID: sc.UserID,
  329. })
  330. if err != nil {
  331. return response.Fail(c, err)
  332. }
  333. return response.OK(c, activitySignResp{SignedID: res.SignedID})
  334. }
  335. // ---------------------------------------------------------------- 取消报名(端点 27)
  336. // CancelActivitySign 取消报名(端点 27 `POST /admin/activities/:id/cancel-sign`,SP20 dump 762982)。
  337. //
  338. // 入参(body):`userId` 必填。
  339. // 出参(成功):`{cancelled:true}`(**三列归零已在同一 UPDATE 内完成**,dump 763028;
  340. // 前端需**刷新整行**而不是只改 status,见契约 §2.2 注)。
  341. // 出参(失败):2001/2002/3001(报名行不存在)/3107(已审核不可取消)/
  342. // 3108(老带新已消费不可取消)/**3121**(取不到活动 type,状态机无法判定,§3.8-R8)/5002。
  343. func (h *Handler) CancelActivitySign(c *fiber.Ctx) error {
  344. sc, err := mustScope(c)
  345. if err != nil {
  346. return response.Fail(c, err)
  347. }
  348. var req CancelActivitySignReq
  349. if !validateReq(c, &req) {
  350. return nil
  351. }
  352. svc := h.servicesFor(sc)
  353. if _, err := svc.activity.CancelSign(c.Context(), service.CancelSignInput{
  354. UserID: strings.TrimSpace(req.UserID),
  355. ActivityID: parseIDParam(c.Params("id")),
  356. }); err != nil {
  357. return response.Fail(c, err)
  358. }
  359. return response.OK(c, activityCancelledResp{Cancelled: true})
  360. }
  361. // ---------------------------------------------------------------- 报名列表(端点 28)
  362. // ListActivitySigneds 报名列表(端点 28 `GET /admin/activities/:id/signeds`)。
  363. //
  364. // 入参(query):`status?`(0~4)、`page`/`pageSize`。
  365. // 出参(成功):`{list,total,page,pageSize,pages}`,行结构同端点 24 的 `signeds[]`。
  366. // 出参(失败):2002(status 越界/非数字、id 非数字)/3001(活动不存在或越权)/5002。
  367. //
  368. // **空列表与越权必须区分**:活动越权 → 3001;活动存在但没人报名 → `list:[]` + `total:0`。
  369. // 都回 `[]` 会让前端把"越权"当"正常空态"(这是端点 28 最容易写错的一处)。
  370. func (h *Handler) ListActivitySigneds(c *fiber.Ctx) error {
  371. sc, err := mustScope(c)
  372. if err != nil {
  373. return response.Fail(c, err)
  374. }
  375. statusFilter, _, err := parseEnumQuery(c, "status", activitySignedStatusFilter)
  376. if err != nil {
  377. return response.Fail(c, err)
  378. }
  379. var statusPtr *int32
  380. if statusFilter != nil {
  381. v := int32(*statusFilter)
  382. statusPtr = &v
  383. }
  384. page, pageSize := parsePage(c)
  385. svc := h.servicesFor(sc)
  386. res, err := svc.activityAdmin.ListSigneds(c.Context(), service.ListSignedsInput{
  387. ActivityID: parseIDParam(c.Params("id")),
  388. Status: statusPtr,
  389. Page: page,
  390. PageSize: pageSize,
  391. })
  392. if err != nil {
  393. return response.Fail(c, err)
  394. }
  395. return response.Page(c, res.List, res.Page, res.PageSize, res.Total)
  396. }
  397. // ---------------------------------------------------------------- 老带新结算(端点 29)
  398. // SettleActivityRecommend 老带新结算(端点 29 `POST /admin/activities/:id/recommend-settle`,
  399. // SP18 dump 763309)。
  400. //
  401. // 入参(body):`userId`(被推荐人)、`orderNo`、`remark?`。路径 `:id` **只用于前置作用域校验**:
  402. // SP18 的候选集按 `userid` 全量扫描(`ListRecommendSignedByUser`),不按活动过滤,
  403. // 因此本 handler 先断言该活动可见(否则 3001),再交给服务按 dump 语义结算。
  404. //
  405. // 出参(成功):`{settled, activityId}`。`settled=false` 是 dump 的 **LEAVE 分支**
  406. // (无推荐人 / 活动金额≤0 / 报课记录回查不到校区),**零写入**——不是错误。
  407. // 出参(失败):2001/2002/3001/3104/3113/5002。
  408. func (h *Handler) SettleActivityRecommend(c *fiber.Ctx) error {
  409. sc, err := mustScope(c)
  410. if err != nil {
  411. return response.Fail(c, err)
  412. }
  413. var req SettleRecommendReq
  414. if !validateReq(c, &req) {
  415. return nil
  416. }
  417. activityID := parseIDParam(c.Params("id"))
  418. svc := h.servicesFor(sc)
  419. if err := svc.activityAdmin.AssertActivityVisible(c.Context(), activityID); err != nil {
  420. return response.Fail(c, err)
  421. }
  422. res, err := svc.recommend.SettleRecommend(c.Context(), service.SettleRecommendInput{
  423. UserID: strings.TrimSpace(req.UserID),
  424. OrderNo: strings.TrimSpace(req.OrderNo),
  425. Remark: req.Remark,
  426. CreateUserID: sc.UserID,
  427. })
  428. if err != nil {
  429. return response.Fail(c, err)
  430. }
  431. return response.OK(c, activitySettledResp{Settled: res.Settled, ActivityID: res.ActivityID})
  432. }
  433. // ---------------------------------------------------------------- 取消充值活动(端点 30)
  434. // CancelActivityRecharge 取消充值活动(端点 30 `POST /admin/activities/:id/cancel-recharge`,SP8 dump 763935)。
  435. //
  436. // 入参(body):`userId`、`campusId`(必填 >0)、`money`、`requestId?`。
  437. // 出参(成功):`{cancelled:true}`。
  438. // 出参(失败):2001/2002/**3104**(校区不存在或未归属本机构)/1004(作用域越界)/3001/5002。
  439. //
  440. // 为什么 `campusId` 必填:流水落 `209` 到**校区账户**,机构由 `t_campus.institution_id` 反查
  441. // (dump 的 `SELECT school_id`);没有校区就无法确定机构 ⇒ 无法定位账户。
  442. func (h *Handler) CancelActivityRecharge(c *fiber.Ctx) error {
  443. sc, err := mustScope(c)
  444. if err != nil {
  445. return response.Fail(c, err)
  446. }
  447. var req CancelRechargeReq
  448. if !validateReq(c, &req) {
  449. return nil
  450. }
  451. amount, ok := parseMoney(c, req.Money, "money")
  452. if !ok {
  453. return nil
  454. }
  455. svc := h.servicesFor(sc)
  456. if _, err := svc.recommend.CancelRechargeActivity(c.Context(), service.CancelRechargeActivityInput{
  457. UserID: strings.TrimSpace(req.UserID),
  458. ActivityID: parseIDParam(c.Params("id")),
  459. CampusID: req.CampusID,
  460. Money: amount,
  461. RequestID: strings.TrimSpace(req.RequestID),
  462. Remark: req.Remark,
  463. OperatorID: sc.UserID,
  464. }); err != nil {
  465. return response.Fail(c, err)
  466. }
  467. return response.OK(c, activityCancelledResp{Cancelled: true})
  468. }
  469. // ---------------------------------------------------------------- 可用赠送(端点 31)
  470. // GetActivityUsableDiscount 可用赠送金额(端点 31 `GET /admin/activities/discounts/usable`,SP14 dump 763176)。
  471. //
  472. // 入参(query):`userId`(必填)、`institutionId`、`campusId`。
  473. // 出参(成功):`{userId, campusId, usable, items:[{activityId, amount}]}`。
  474. // - `usable` = 汇总可用额;`items` = 按活动的构成(空 → `[]`);
  475. // - ⚠ 契约还要求 `items[].memo`,但赠送池表**无 memo 列** ⇒ 不下发(见 `UsableBreakdownItem` 注释)。
  476. //
  477. // 出参(失败):1001/2001(缺 userId)/2002(机构或校区为负/非数字)/**1003**/**1004**/5002。
  478. //
  479. // 越权防线(**本端点唯一的两条,设计 §3.2-A7**):
  480. //
  481. // institutionId<=0(平台视角)→ 角色必须属 {super_admin, finance},否则 **1003**
  482. // institutionId>0 且 ≠ 作用域机构 → **1004**(跨机构查别人的赠送池)
  483. //
  484. // 这两条**必须在 Handler**:`SumUsable` 在 `institutionID<=0` 时**不加任何作用域条件**(dump 原样),
  485. // 数据层已经没有防线了。
  486. func (h *Handler) GetActivityUsableDiscount(c *fiber.Ctx) error {
  487. sc, err := mustScope(c)
  488. if err != nil {
  489. return response.Fail(c, err)
  490. }
  491. userID := strings.TrimSpace(c.Query("userId"))
  492. if userID == "" {
  493. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
  494. errString("userId 不能为空")))
  495. }
  496. institutionID, err := parseIntQuery(c, "institutionId")
  497. if err != nil {
  498. return response.Fail(c, err)
  499. }
  500. campusID, err := parseIntQuery(c, "campusId")
  501. if err != nil {
  502. return response.Fail(c, err)
  503. }
  504. if institutionID < 0 || campusID < 0 {
  505. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  506. errString("institutionId/campusId 不得为负")))
  507. }
  508. if institutionID <= 0 {
  509. if !sc.SuperAdmin {
  510. if _, ok := activityPlatformRoles[sc.Role]; !ok {
  511. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrForbidden,
  512. errString("平台视角(institutionId<=0)仅超级管理员/财务可用")))
  513. }
  514. }
  515. } else if institutionID != sc.InstitutionID && !sc.SuperAdmin {
  516. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrUnscoped,
  517. errString("institutionId 与当前作用域机构不一致")))
  518. }
  519. svc := h.servicesFor(sc)
  520. res, err := svc.activityDiscount.UsableBreakdown(c.Context(), service.UsableAmountInput{
  521. UserID: userID,
  522. InstitutionID: institutionID,
  523. CampusID: campusID,
  524. })
  525. if err != nil {
  526. return response.Fail(c, err)
  527. }
  528. return response.OK(c, res)
  529. }
  530. // ---------------------------------------------------------------- 使用赠送(端点 32)
  531. // UseActivityDiscount 使用赠送(端点 32 `POST /admin/activities/discounts/use`,SP15 dump 763204)。
  532. //
  533. // 入参(body):`userId`、`activityId`、`coursesStudentId`、`amount`、`memo?`。
  534. // 出参(成功):`{used}`(金额 ≤0 时服务端 no-op ⇒ `used=false`,§3.8-Z1 **不是错误**)。
  535. // 出参(失败):2001/2002/**3109**(可用赠送金额不足,Go 增强:dump 静默 LEAVE)/5002。
  536. func (h *Handler) UseActivityDiscount(c *fiber.Ctx) error {
  537. sc, err := mustScope(c)
  538. if err != nil {
  539. return response.Fail(c, err)
  540. }
  541. var req UseActivityDiscountReq
  542. if !validateReq(c, &req) {
  543. return nil
  544. }
  545. amount, ok := parseMoneyOrZero(c, req.Amount, "amount")
  546. if !ok {
  547. return nil
  548. }
  549. svc := h.servicesFor(sc)
  550. res, err := svc.activityDiscount.UseVoucher(c.Context(), service.UseVoucherInput{
  551. UserID: strings.TrimSpace(req.UserID),
  552. ActivityID: req.ActivityID,
  553. StudentCourseID: req.CoursesStudentID,
  554. Amount: amount,
  555. Memo: req.Memo,
  556. })
  557. if err != nil {
  558. return response.Fail(c, err)
  559. }
  560. return response.OK(c, activityUsedResp{Used: res.Applied.GreaterThan(0)})
  561. }
  562. // ---------------------------------------------------------------- 赠送退回(端点 33)
  563. // RefundActivityDiscount 赠送退回(端点 33 `POST /admin/activities/discounts/refund`,SP16 dump 763046)。
  564. //
  565. // 入参(body):`coursesStudentId`(必填 >0)、`amount`、`memo?`。
  566. // 出参(成功):`{refunded}`(无可用流水/金额≤0 ⇒ false)。
  567. // 出参(失败):2001/2002/5002。
  568. //
  569. // 退回对象是**流水**(`t_courses_activity_user_discount_log` 的负值行,按 id DESC 逐条回冲),
  570. // 不是 signed 行(§3.2 #16 裁定 A11)——所以本端点**不需要** `userId`/`activityId`。
  571. func (h *Handler) RefundActivityDiscount(c *fiber.Ctx) error {
  572. sc, err := mustScope(c)
  573. if err != nil {
  574. return response.Fail(c, err)
  575. }
  576. var req RefundActivityDiscountReq
  577. if !validateReq(c, &req) {
  578. return nil
  579. }
  580. amount, ok := parseMoneyOrZero(c, req.Amount, "amount")
  581. if !ok {
  582. return nil
  583. }
  584. svc := h.servicesFor(sc)
  585. res, err := svc.activityDiscount.RefundByStuCourse(c.Context(), service.RefundByStuCourseInput{
  586. StudentCourseID: req.CoursesStudentID,
  587. Amount: amount,
  588. Memo: req.Memo,
  589. })
  590. if err != nil {
  591. return response.Fail(c, err)
  592. }
  593. return response.OK(c, activityRefundedResp{Refunded: res.RefundedTotal.GreaterThan(0)})
  594. }
  595. // ---------------------------------------------------------------- 按活动退回(端点 34)
  596. // RefundDiscountByActivity 按活动退回赠送(端点 34
  597. // `POST /admin/activities/discounts/refund-by-activity`,SP17 dump 763106)。
  598. //
  599. // 入参(body):`userId`、`activityId`、`amount`、`memo?`。
  600. // 出参(成功):`{refunded}`。
  601. // 出参(失败):2001/2002/5002。
  602. //
  603. // ⚠ 口径(§3.8-A19,**Go 有意偏离 dump**):`type=3`(代金券)全退时金额取"最近一条 log 的
  604. // `ABS(activity_money_used)`"——dump 那句 `SELECT inputAmount = <col>` 在 MySQL 里是**布尔比较**、
  605. // 赋值从不生效,实际可超退。该偏离已在设计文档登记为 `DUMP-BUGFIX`,本端点**不做二次截断**。
  606. func (h *Handler) RefundDiscountByActivity(c *fiber.Ctx) error {
  607. sc, err := mustScope(c)
  608. if err != nil {
  609. return response.Fail(c, err)
  610. }
  611. var req RefundDiscountByActivityReq
  612. if !validateReq(c, &req) {
  613. return nil
  614. }
  615. amount, ok := parseMoneyOrZero(c, req.Amount, "amount")
  616. if !ok {
  617. return nil
  618. }
  619. svc := h.servicesFor(sc)
  620. res, err := svc.activityDiscount.RefundByActivity(c.Context(), service.RefundByActivityInput{
  621. UserID: strings.TrimSpace(req.UserID),
  622. ActivityID: req.ActivityID,
  623. Amount: amount,
  624. Memo: req.Memo,
  625. })
  626. if err != nil {
  627. return response.Fail(c, err)
  628. }
  629. return response.OK(c, activityRefundedResp{Refunded: res.Refunded.GreaterThan(0)})
  630. }
  631. // ---------------------------------------------------------------- 充值赠送使用(端点 35)
  632. // UseRechargeGift 充值赠送使用(端点 35 `POST /admin/activities/recharge-gifts/use`,SP12 dump 763256)。
  633. //
  634. // 入参(body):`userId`、`activityId`、`coursesStudentId`、`amount`、`memo?`。
  635. // 出参(成功):`{used}`(≤0 ⇒ false,no-op)。
  636. // 出参(失败):2001/2002/5002。
  637. //
  638. // 前置(dump 游标 763262):报名行必须 `status=2`(已参加活动)——**不是**可选的宽松化。
  639. // A9 修正(§3.8-a):dump 循环里 `inputAmount` 从不递减,Go 改为"扣到 0 即停"。
  640. func (h *Handler) UseRechargeGift(c *fiber.Ctx) error {
  641. sc, err := mustScope(c)
  642. if err != nil {
  643. return response.Fail(c, err)
  644. }
  645. var req UseRechargeGiftReq
  646. if !validateReq(c, &req) {
  647. return nil
  648. }
  649. amount, ok := parseMoneyOrZero(c, req.Amount, "amount")
  650. if !ok {
  651. return nil
  652. }
  653. svc := h.servicesFor(sc)
  654. res, err := svc.activityDiscount.UseRechargeGift(c.Context(), service.UseRechargeGiftInput{
  655. UserID: strings.TrimSpace(req.UserID),
  656. ActivityID: req.ActivityID,
  657. StudentCourseID: req.CoursesStudentID,
  658. Amount: amount,
  659. Memo: req.Memo,
  660. })
  661. if err != nil {
  662. return response.Fail(c, err)
  663. }
  664. return response.OK(c, activityUsedResp{Used: res.UsedAmount.GreaterThan(0)})
  665. }
  666. // ---------------------------------------------------------------- 充值赠送退回(端点 36)
  667. // RefundRechargeGift 充值赠送退回(端点 36 `POST /admin/activities/recharge-gifts/refund`,SP13 dump 762841)。
  668. //
  669. // 入参(body):`coursesStudentId`(必填 >0)、`amount`、`memo?`。
  670. // 出参(成功):`{refunded}`。
  671. // 出参(失败):2001/2002/5002。
  672. //
  673. // 分配方向:dump 游标 **id DESC**(后入先退),Go 保留该方向(`OrderDesc`)。
  674. func (h *Handler) RefundRechargeGift(c *fiber.Ctx) error {
  675. sc, err := mustScope(c)
  676. if err != nil {
  677. return response.Fail(c, err)
  678. }
  679. var req RefundRechargeGiftReq
  680. if !validateReq(c, &req) {
  681. return nil
  682. }
  683. amount, ok := parseMoneyOrZero(c, req.Amount, "amount")
  684. if !ok {
  685. return nil
  686. }
  687. svc := h.servicesFor(sc)
  688. res, err := svc.activityDiscount.RefundRechargeGift(c.Context(), service.RefundRechargeGiftInput{
  689. StudentCourseID: req.CoursesStudentID,
  690. Amount: amount,
  691. Memo: req.Memo,
  692. })
  693. if err != nil {
  694. return response.Fail(c, err)
  695. }
  696. return response.OK(c, activityRefundedResp{Refunded: res.RefundedTotal.GreaterThan(0)})
  697. }
  698. // ---------------------------------------------------------------- 回访(端点 37)
  699. // CreateActivityVisit 回访登记(端点 37 `POST /admin/activities/:id/visits`)。
  700. //
  701. // 入参(body):`activitySignedId`(必填)、`memo`(必填)。路径 `:id` 是活动 ID。
  702. // 出参(成功):`{visited, activityId}`。
  703. // 出参(失败):2001/2002/3001(报名行不存在 / 报名行不属于该活动 / 活动越权)/5002。
  704. //
  705. // 为何要校验"报名行属于该活动":`t_courses_activity_visit` **没有作用域列也没有活动列**
  706. // (只有 `activity_signed_id`),一旦把回访挂到别的活动的报名行上,事后**无法按活动筛出来**,
  707. // 也没法从数据上判断它挂错了 —— 只能在写入前拒。
  708. func (h *Handler) CreateActivityVisit(c *fiber.Ctx) error {
  709. sc, err := mustScope(c)
  710. if err != nil {
  711. return response.Fail(c, err)
  712. }
  713. var req CreateVisitReq
  714. if !validateReq(c, &req) {
  715. return nil
  716. }
  717. svc := h.servicesFor(sc)
  718. res, err := svc.activityAdmin.RegisterVisit(c.Context(), service.VisitInput{
  719. ActivityID: parseIDParam(c.Params("id")),
  720. ActivitySignedID: req.ActivitySignedID,
  721. Memo: req.Memo,
  722. OperatorID: sc.UserID,
  723. })
  724. if err != nil {
  725. return response.Fail(c, err)
  726. }
  727. return response.OK(c, res)
  728. }
  729. // ---------------------------------------------------------------- 本地助手(只做解析,不含业务判断)
  730. // parseMoneyOrZero 解析**可空**金额(空串 → 0,**不是** 2001)。
  731. //
  732. // 入参:c;raw 原始字符串;field 字段名。
  733. // 出参:(金额, 是否成功);失败时 **body code=2002 已写出**。
  734. //
  735. // 为什么活动域的可空金额不能复用 `parseMoney`:`parseMoney` 对空串会 `money.Parse("")` 失败 →
  736. // 2002,但活动侧 `amount` 是**可选**(缺省 = no-op,§3.8-Z1)。两者区分必须显式,
  737. // 否则"没传金额"会被报成"金额格式错误"(错误码 2002 与 2001 混用,前端分不出该提示什么)。
  738. func parseMoneyOrZero(c *fiber.Ctx, raw, field string) (money.Amount, bool) {
  739. trimmed := strings.TrimSpace(raw)
  740. if trimmed == "" {
  741. return 0, true
  742. }
  743. return parseMoney(c, trimmed, field)
  744. }
  745. // parseEnumQuery 解析**可选枚举** query 参数(缺省 → nil;越界 → 2002)。
  746. //
  747. // 入参:c;name query 名;allowed 白名单(值 → 空结构体)。
  748. // 出参:(*int64 命中值 或 nil, 是否传了, error);error 非 nil 时 **body code=2002 已写出**。
  749. //
  750. // 三个行为细节(都是"会让某个断言变红"的点):
  751. // 1. 空串 → (nil, false, nil):**不筛**,不报错(前端下拉框"全部"就是空串);
  752. // 2. 非数字 → 2002(不是 2001:传了但填错);
  753. // 3. 数字但不在白名单 → 2002(**不得静默忽略**:静默忽略会让"筛了但没筛"变成新的谜题)。
  754. func parseEnumQuery(c *fiber.Ctx, name string, allowed map[int64]struct{}) (*int64, bool, error) {
  755. raw := strings.TrimSpace(c.Query(name))
  756. if raw == "" {
  757. return nil, false, nil
  758. }
  759. value, err := parseIntQuery(c, name)
  760. if err != nil {
  761. return nil, true, err
  762. }
  763. if _, ok := allowed[value]; !ok {
  764. return nil, true, infraerrors.WithCause(infraerrors.ErrBadFormat,
  765. errString("参数 "+name+" 取值非法"))
  766. }
  767. return &value, true, nil
  768. }
  769. // parseIntQuery 解析整数 query 参数(缺省 → 0;非数字 → 2002)。
  770. //
  771. // 入参:c;name。出参:(值, error);error 非 nil 时 **body code=2002 已写出**。
  772. //
  773. // 负数**照常解析**(不在本函数拦):`institutionId=-1` 这类"给了但无意义"的值由调用方
  774. // 按 2002 处理(端点 31 显式判负),避免本函数变成第二个业务规则点。
  775. func parseIntQuery(c *fiber.Ctx, name string) (int64, error) {
  776. raw := strings.TrimSpace(c.Query(name))
  777. if raw == "" {
  778. return 0, nil
  779. }
  780. negative := false
  781. digits := raw
  782. if strings.HasPrefix(raw, "-") {
  783. negative = true
  784. digits = raw[1:]
  785. }
  786. if digits == "" {
  787. return 0, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("参数 "+name+" 不是合法整数"))
  788. }
  789. var out int64
  790. for _, ch := range digits {
  791. if ch < '0' || ch > '9' {
  792. return 0, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("参数 "+name+" 不是合法整数"))
  793. }
  794. out = out*10 + int64(ch-'0')
  795. }
  796. if negative {
  797. out = -out
  798. }
  799. return out, nil
  800. }
  801. // parseActivityWindow 解析活动起止时间(两个都可空;格式非法 → 2002)。
  802. //
  803. // 入参:startRaw/endRaw(RFC3339 或 `2006-01-02`)。
  804. // 出参:(start, end *time.Time, error);error 非 nil 时 **body code=2002 已写出**。
  805. //
  806. // 只做**格式**转换,不做"start<=end"判定:倒挂属"值非法",在 Service 的
  807. // `validateActivityFields` 里统一判(一处判据、两个端点共用:23 与 25)。
  808. func parseActivityWindow(startRaw, endRaw string) (start, end *time.Time, err error) {
  809. startTime, hasStart, serr := parseTimeParam(startRaw, "startTime")
  810. if serr != nil {
  811. return nil, nil, serr
  812. }
  813. endTime, hasEnd, eerr := parseTimeParam(endRaw, "endTime")
  814. if eerr != nil {
  815. return nil, nil, eerr
  816. }
  817. if hasStart {
  818. start = &startTime
  819. }
  820. if hasEnd {
  821. end = &endTime
  822. }
  823. return start, end, nil
  824. }
  825. // resolveInstitutionID 决定本次写入的机构 ID(**不信任 body**)。
  826. //
  827. // 入参:sc 作用域;raw body 里的 `institutionId`(0 = 没传)。
  828. // 出参:(机构 ID, error);error 非 nil 时 **body code 已写出**(1004 / 2001)。
  829. //
  830. // 规则:
  831. //
  832. // raw == 0 → 用作用域机构(最常见路径)
  833. // raw == sc.InstitutionID → 用它(等价,不报错)
  834. // raw != sc.InstitutionID → 超管可用它(跨机构建活动);否则 **1004**
  835. // 最终仍 <=0 → **2001**(没有机构归属无法落库;作用域缺失本身属配置/参数问题)
  836. func resolveInstitutionID(sc *appscope.Scope, raw int64) (int64, error) {
  837. if sc == nil {
  838. return 0, infraerrors.ErrUnauth
  839. }
  840. institutionID := sc.InstitutionID
  841. if raw > 0 {
  842. if raw != institutionID && !sc.SuperAdmin {
  843. return 0, infraerrors.WithCause(infraerrors.ErrUnscoped,
  844. errString("institutionId 与当前作用域机构不一致"))
  845. }
  846. institutionID = raw
  847. }
  848. if institutionID <= 0 {
  849. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  850. errString("缺少机构作用域(institutionId 不能为 0)"))
  851. }
  852. return institutionID, nil
  853. }