activity_handler.go 38 KB

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