divide_handler.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. // 分账端点处理器(端点 38 分账树 / 43 流水列表)——AC-13 第 1 批(读路径优先)。
  2. //
  3. // 依据:unified/03-migration/academic-frontend-contract-v1.md §3.4 B/C 表 + 设计 §3.3 #22~#25。
  4. //
  5. // 本批**只做两条读端点**,理由见文件末"本批范围";41/42 是写路径(要新增 `DeleteLogByID`
  6. // 等 Service 方法并配套事务用例),放在第 2 批,避免"半实现的写端点"混进首次提交。
  7. //
  8. // ---------------------------------------------------------------------------
  9. // 量纲红线(mock 已在此错过一次,后端不得重犯)
  10. // ---------------------------------------------------------------------------
  11. //
  12. // `quota` = **金额**(元/次分成,`DECIMAL(12,2)`,两位小数字符串),**不是** 0~1 比率。
  13. //
  14. // 本文件金额出参一律 `money.Amount`(JSON 两位小数字符串),DTO 里**不出现 float64**。
  15. //
  16. // ---------------------------------------------------------------------------
  17. // 作用域口径(`t_user_divide_into*` 无 institution_id/campus_id,§1.7 例外二)
  18. // ---------------------------------------------------------------------------
  19. //
  20. // 两张表都是**全局推广体系**(推广关系不归属某个机构),数据层无法按机构过滤 ⇒ 防线只有两条:
  21. // 1. `scope.FromFiber` 必须存在(取不到 → 1001,避免匿名读全平台分账数据);
  22. // 2. 端点角色白名单(AC-14 落 RBAC):预期 `super_admin / finance / market`。
  23. //
  24. // 这也是它与其他端点的本质差异:**分账树是跨机构的,不存在"本机构分账树"**。
  25. package handler
  26. import (
  27. "github.com/gofiber/fiber/v2"
  28. "yuansiwei/unified-backend/internal/academic/service"
  29. "yuansiwei/unified-backend/internal/infra/response"
  30. appscope "yuansiwei/unified-backend/internal/infra/scope"
  31. )
  32. // 分账域路径常量(**不含 `/api`**,§4 口径表)。
  33. const (
  34. // PathDivideTree 端点 38:分账树(`GET /admin/divide-into/tree`)。
  35. PathDivideTree = "/admin/divide-into/tree"
  36. // PathDivideLogs 端点 43:分账流水列表(`GET /admin/divide-into/logs`)。
  37. // 端点 41(POST 同路径)与 42(`DELETE /admin/divide-into/logs/:id`)在第 2 批接入。
  38. PathDivideLogs = "/admin/divide-into/logs"
  39. )
  40. // GetDivideTree 分账树(端点 38 `GET /admin/divide-into/tree`)。
  41. //
  42. // 出参(成功):`{tree:[…], nodeCount, rootCount}` —— **包一层 `tree`**
  43. // (契约 §3.4 结构约定 2 的显式例外:树不是分页对象)。
  44. // 节点:`userId / realName / parentUserId|null / quota:金额 / cumulativeIncome:金额 / layer / children[]`。
  45. // 出参(失败):1001(无作用域)/ 5001(**Nested Set 不变式被破坏**,数据损坏)/ 5002(读库或金额异常)。
  46. //
  47. // ⚠ 5001 是**有意的**:树坏了就**不返回**一棵"看起来对"的树。位移错位无法从当前数据推断原值,
  48. // 猜测式修复会把"确定性错误"变成"随机正确"(不变式 I1~I4 见 `service.divide_tree_service.go` 文件头)。
  49. func (h *Handler) GetDivideTree(c *fiber.Ctx) error {
  50. sc, err := mustScope(c)
  51. if err != nil {
  52. return response.Fail(c, err)
  53. }
  54. tree, err := h.divideServiceFor(sc).GetTree(c.Context())
  55. if err != nil {
  56. return response.Fail(c, err)
  57. }
  58. return response.OK(c, tree)
  59. }
  60. // ListDivideLogs 分账流水列表(端点 43 `GET /admin/divide-into/logs`)。
  61. //
  62. // 入参(query):
  63. // - `findUserId`(分成获得者;**表列是 `user_id`**,契约响应字段才叫 `findUserId`);
  64. // - `studentId`;
  65. // - `from`/`to`(**右开**:`from <= createTime < to`,与契约 §1.3 一致);
  66. // - `page`/`pageSize`(钳制上限 200)。
  67. //
  68. // 出参(成功):`分页`——`{list,total,page,pageSize,pages}`,行字段见 `service.DivideLogView`。
  69. //
  70. // ⚠ 列表端点用通用分页形状(键名 `list`),**不是**端点 17 的 `details`
  71. // (那是"汇总 + 明细"的复合 data,两者不要互相抄)。
  72. //
  73. // 出参(失败):1001 / 2001(`from > to`)/ 2002(时间格式非法)/ 5002。
  74. func (h *Handler) ListDivideLogs(c *fiber.Ctx) error {
  75. sc, err := mustScope(c)
  76. if err != nil {
  77. return response.Fail(c, err)
  78. }
  79. from, _, ferr := parseTimeParam(c.Query("from"), "from")
  80. if ferr != nil {
  81. return response.Fail(c, ferr)
  82. }
  83. to, _, terr := parseTimeParam(c.Query("to"), "to")
  84. if terr != nil {
  85. return response.Fail(c, terr)
  86. }
  87. page, pageSize := parsePage(c)
  88. q := service.DivideLogQuery{
  89. FindUserID: c.Query("findUserId"), StudentID: c.Query("studentId"),
  90. Page: page, PageSize: pageSize,
  91. }
  92. if !from.IsZero() {
  93. q.From = &from
  94. }
  95. if !to.IsZero() {
  96. q.To = &to
  97. }
  98. rows, total, err := h.divideServiceFor(sc).ListLogs(c.Context(), q)
  99. if err != nil {
  100. return response.Fail(c, err)
  101. }
  102. return response.Page(c, rows, page, pageSize, total)
  103. }
  104. // divideServiceFor 按作用域装配分账查询服务(分账域**不**做机构 scoping,见文件头"作用域口径")。
  105. //
  106. // 姓名解析器本批未注入(`realName` 返回空串):姓名在 identity/org 域,academic 未持有
  107. // `t_user` 结构(§1.3 跨域只读要经 port);AC-14 装配时注入真实实现即可,不影响树结构与金额。
  108. // **不编造**姓名 —— 空串是显式的"未解析",前端按"未填"展示。
  109. func (h *Handler) divideServiceFor(sc *appscope.Scope) *service.DivideTreeService {
  110. return service.NewDivideTreeService(h.db, sc, nil)
  111. }
  112. // 本批范围(AC-13 增量交付,明确边界防"以为做完了")
  113. //
  114. // ✅ 38 分账树(含 Nested Set 不变式 I1~I4 校验)
  115. // ✅ 43 分账流水列表(右开时间窗 + 同快照 total)
  116. // ⏳ 41 记流水 / 42 删流水 —— 需要新增 Service 写方法(`AddLog` 已存在、`DeleteLogByID` 需新增)
  117. // 并配套"撤销回滚 total_income 同事务"的跨表断言,放第 2 批
  118. // ⏳ 22~37 活动 / 39~40 分账额度 / 44~57 课程排班 / 58~63 课酬维护 —— 后续批次