bill_handler.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. // 账单与仪表盘端点处理器(端点 17 `GET /student/bill`、端点 21 `GET /admin/finance/dashboard`)。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md
  4. // - §4.1 端点 17(`/student/bill`,SP25 get_bill)与端点 21(`/admin/finance/dashboard`,P2)
  5. // - §3.4 #25:账单 = **汇总对象 + 明细分页** 两段返回
  6. // - §3.8-f 约束 2:`t_user_expense_log` 无 campus 列 ⇒ 仪表盘不给"按校区拆流水"
  7. //
  8. // 为什么端点 17 挂在 `/student` 段却写在 finance handler 文件组里:它复用 AC-10 的 BillService
  9. // (同一 SP),且与财务对账是同一份数字口径;段位只影响**角色白名单与挂载点**(AC-14 接),
  10. // 不影响本文件的方法归属。
  11. package handler
  12. import (
  13. "strings"
  14. "github.com/gofiber/fiber/v2"
  15. "yuansiwei/unified-backend/internal/academic/service"
  16. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  17. "yuansiwei/unified-backend/internal/infra/response"
  18. )
  19. // billSelfRoles 允许"查他人账单"的角色(`/student/bill?userId=` 的越权判定)。
  20. //
  21. // 语义:
  22. // - 家长代查(parent)、平台超管(super_admin)、财务对账(finance)、教务(academic)可指定 userId;
  23. // - 学生(student)**只能查自己**(userId 与作用域不一致 → 1003)。
  24. //
  25. // 为什么家长放行:移动端 `/student/*` 的角色白名单本就是 `student,parent`(§4.1 端点 17),
  26. // 家长 token 的 canonical_user_id 是家长本人,不可能是学生 id,若不放行则家长端永远查不到孩子账单。
  27. // 孩子的归属校验由 `BillService` 注入的 MembershipChecker 兜底(不在本表内做"亲子关系"判定——
  28. // 那需要 org 域的家庭关系表,属跨域建模)。
  29. var billSelfRoles = map[string]struct{}{
  30. "super_admin": {},
  31. "parent": {},
  32. "finance": {},
  33. "academic": {},
  34. }
  35. // GetBill 学生账单(端点 17 `GET /student/bill`,SP25 `sp_user_expense_get_bill`)。
  36. //
  37. // 入参(query):
  38. // - `userId` 学生(可空:空 = 作用域本人;非空且角色不在 billSelfRoles → 1003);
  39. // - `campusIds` 校区逗号串(dump 第二入参;**只回显、不过滤**——dump 里它是死参数);
  40. // - `from`/`to` 时间窗(**`to` 不含上界**:要含当日必须传次日或 23:59:59);
  41. // - `page`/`pageSize` 分页(钳制上限 200)。
  42. //
  43. // 出参(成功):`{summary{9 列 + 2 个对账辅助列 + campusIds}, details[], total, page, pageSize, pages, from, to}`。
  44. // (`details` 是顶层数组、不嵌套;`pages` 由 Service 的 `billPages` 一次算定,handler 不重复取整。)
  45. // 出参(失败):1001(无作用域)/2001(缺 from/to、from>to)/2002(campusIds 或时间格式非法)/1003/1004/5002。
  46. //
  47. // **两个对账字段名由前端契约 v1.3 端点 17 注 ① 定死**(契约定名、后端照做,不是实现产出后回填):
  48. // - `flowExclusiveEnd`:与「期末 − 期初」对齐的当期发生额(右开口径,**不含** `to` 当天);
  49. // - `flowInclusiveEnd`:**含 `to` 当天**的当期发生额(dump 的 `BETWEEN` 两端闭口径)。
  50. // 二者之差 == 落在 `to` 当天的流水金额。**两个都必须下发**——只留一个就没法让前端"看见"
  51. // 这个不对称(契约 v1.2 把两件事写成同一句 `< to`,根因正是"看不见")。
  52. //
  53. // 字段名的落地位置与守护方式:JSON tag 在 `service.BillSummary`(本 handler 直接透传该对象),
  54. // 由 `TestBillEndpointIdentity` 断言——用例刻意造一笔落在 `to` 当天的流水,检查两个键都能解出、
  55. // 且`inclusive − exclusive` 恰好等于该笔金额。谁把 tag 改名,该用例立刻红(解出零值 ⇒ 差值断言失败)。
  56. //
  57. // 响应形状说明:账单是"汇总 + 明细"两段,**不能**用 `response.Page`(那会把 summary 挤掉),
  58. // 因此这里用 `response.OK` 手工拼分页元信息(total/page/pageSize/pages)+ `details` 数组,
  59. // 保证前端 `useListPage` 的读法一致(分页元信息仍在 data 内,不散落顶层)。
  60. func (h *Handler) GetBill(c *fiber.Ctx) error {
  61. sc, err := mustScope(c)
  62. if err != nil {
  63. return response.Fail(c, err)
  64. }
  65. target := strings.TrimSpace(c.Query("userId"))
  66. if target == "" {
  67. target = sc.UserID
  68. }
  69. if target != sc.UserID && !sc.SuperAdmin {
  70. if _, ok := billSelfRoles[sc.Role]; !ok {
  71. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrForbidden,
  72. errString("该角色只能查询本人账单")))
  73. }
  74. }
  75. from, hasFrom, ferr := parseTimeParam(c.Query("from"), "from")
  76. if ferr != nil {
  77. return response.Fail(c, ferr)
  78. }
  79. to, hasTo, terr := parseTimeParam(c.Query("to"), "to")
  80. if terr != nil {
  81. return response.Fail(c, terr)
  82. }
  83. if !hasFrom || !hasTo {
  84. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
  85. errString("账单查询需要 from 与 to(账户区间对账的两个边界都是必填)")))
  86. }
  87. page, pageSize := parsePage(c)
  88. svc := h.servicesFor(sc)
  89. res, err := svc.bill.GetBill(c.Context(), service.GetBillInput{
  90. UserID: target, CampusIDs: c.Query("campusIds"),
  91. From: from, To: to, Page: page, PageSize: pageSize,
  92. })
  93. if err != nil {
  94. return response.Fail(c, err)
  95. }
  96. // 分页五字段**由 Service 一次算定**(`res.Pages`),handler 不再自己取整:
  97. // 同一份分页元信息若有两处算法,迟早出现"某处四舍五入、某处向上取整"的偏差。
  98. // 注意:明细数组键名是 **`details`**(不是 `list`,也不是嵌套对象)—— 以 `BillResult` 的
  99. // json tag 为准(契约 v1.4 rev.2 已按代码定稿:`data` 平铺)。
  100. return response.OK(c, fiber.Map{
  101. "summary": res.Summary,
  102. "details": res.Details,
  103. "total": res.Total,
  104. "page": res.Page,
  105. "pageSize": res.PageSize,
  106. "pages": res.Pages,
  107. "from": res.From,
  108. "to": res.To,
  109. })
  110. }
  111. // GetDashboard 财务仪表盘(端点 21 `GET /admin/finance/dashboard`,**P2**)。
  112. //
  113. // 入参(query):`from`/`to`(必填)、`campusId`(可选,>0 只统计该校区)。
  114. // 出参(成功):`DashboardResult`(余额分布 + 累计收支 + `flowsByCampusSupported=false`)。
  115. // 出参(失败):1001/2001(缺时间窗 / from>to)/5002。
  116. //
  117. // ⚠ 口径(与 service.DashboardResult 注释同源):本端点是**账户累计列横截面**,
  118. // 不是 `[from,to]` 区间发生额——因为 `t_user_expense_log` 没有 campus/机构列,
  119. // 期间流水在数据层**无法**按校区聚合(§3.8-f 约束 2)。响应里显式给
  120. // `flowsByCampusSupported=false` 告知前端这个边界,避免被当成"字段漏了"。
  121. func (h *Handler) GetDashboard(c *fiber.Ctx) error {
  122. sc, err := mustScope(c)
  123. if err != nil {
  124. return response.Fail(c, err)
  125. }
  126. from, hasFrom, ferr := parseTimeParam(c.Query("from"), "from")
  127. if ferr != nil {
  128. return response.Fail(c, ferr)
  129. }
  130. to, hasTo, terr := parseTimeParam(c.Query("to"), "to")
  131. if terr != nil {
  132. return response.Fail(c, terr)
  133. }
  134. if !hasFrom || !hasTo {
  135. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
  136. errString("dashboard 需要 from 与 to")))
  137. }
  138. svc := h.servicesFor(sc)
  139. res, err := svc.query.Dashboard(c.Context(), service.DashboardInput{
  140. From: from, To: to, CampusID: parseIDParam(c.Query("campusId")),
  141. })
  142. if err != nil {
  143. return response.Fail(c, err)
  144. }
  145. return response.OK(c, res)
  146. }