bill_handler.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  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}, list[], total, page, pageSize, pages, from, to}`。
  44. // 出参(失败):1001(无作用域)/2001(缺 from/to、from>to)/2002(campusIds 或时间格式非法)/1003/1004/5002。
  45. //
  46. // 响应形状说明:账单是"汇总 + 明细"两段,**不能**用 `response.Page`(那会把 summary 挤掉),
  47. // 因此这里用 `response.OK` 手工拼 `PageData` 的 5 个键(list/total/page/pageSize/pages),
  48. // 保证前端 `useListPage` 的读法一致(分页元信息仍在 data 内,不散落顶层)。
  49. func (h *Handler) GetBill(c *fiber.Ctx) error {
  50. sc, err := mustScope(c)
  51. if err != nil {
  52. return response.Fail(c, err)
  53. }
  54. target := strings.TrimSpace(c.Query("userId"))
  55. if target == "" {
  56. target = sc.UserID
  57. }
  58. if target != sc.UserID && !sc.SuperAdmin {
  59. if _, ok := billSelfRoles[sc.Role]; !ok {
  60. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrForbidden,
  61. errString("该角色只能查询本人账单")))
  62. }
  63. }
  64. from, hasFrom, ferr := parseTimeParam(c.Query("from"), "from")
  65. if ferr != nil {
  66. return response.Fail(c, ferr)
  67. }
  68. to, hasTo, terr := parseTimeParam(c.Query("to"), "to")
  69. if terr != nil {
  70. return response.Fail(c, terr)
  71. }
  72. if !hasFrom || !hasTo {
  73. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
  74. errString("账单查询需要 from 与 to(账户区间对账的两个边界都是必填)")))
  75. }
  76. page, pageSize := parsePage(c)
  77. svc := h.servicesFor(sc)
  78. res, err := svc.bill.GetBill(c.Context(), service.GetBillInput{
  79. UserID: target, CampusIDs: c.Query("campusIds"),
  80. From: from, To: to, Page: page, PageSize: pageSize,
  81. })
  82. if err != nil {
  83. return response.Fail(c, err)
  84. }
  85. pages := int64(0)
  86. if res.PageSize > 0 {
  87. pages = (res.Total + int64(res.PageSize) - 1) / int64(res.PageSize)
  88. }
  89. return response.OK(c, fiber.Map{
  90. "summary": res.Summary,
  91. "list": res.Details,
  92. "total": res.Total,
  93. "page": res.Page,
  94. "pageSize": res.PageSize,
  95. "pages": pages,
  96. "from": res.From,
  97. "to": res.To,
  98. })
  99. }
  100. // GetDashboard 财务仪表盘(端点 21 `GET /admin/finance/dashboard`,**P2**)。
  101. //
  102. // 入参(query):`from`/`to`(必填)、`campusId`(可选,>0 只统计该校区)。
  103. // 出参(成功):`DashboardResult`(余额分布 + 累计收支 + `flowsByCampusSupported=false`)。
  104. // 出参(失败):1001/2001(缺时间窗 / from>to)/5002。
  105. //
  106. // ⚠ 口径(与 service.DashboardResult 注释同源):本端点是**账户累计列横截面**,
  107. // 不是 `[from,to]` 区间发生额——因为 `t_user_expense_log` 没有 campus/机构列,
  108. // 期间流水在数据层**无法**按校区聚合(§3.8-f 约束 2)。响应里显式给
  109. // `flowsByCampusSupported=false` 告知前端这个边界,避免被当成"字段漏了"。
  110. func (h *Handler) GetDashboard(c *fiber.Ctx) error {
  111. sc, err := mustScope(c)
  112. if err != nil {
  113. return response.Fail(c, err)
  114. }
  115. from, hasFrom, ferr := parseTimeParam(c.Query("from"), "from")
  116. if ferr != nil {
  117. return response.Fail(c, ferr)
  118. }
  119. to, hasTo, terr := parseTimeParam(c.Query("to"), "to")
  120. if terr != nil {
  121. return response.Fail(c, terr)
  122. }
  123. if !hasFrom || !hasTo {
  124. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
  125. errString("dashboard 需要 from 与 to")))
  126. }
  127. svc := h.servicesFor(sc)
  128. res, err := svc.query.Dashboard(c.Context(), service.DashboardInput{
  129. From: from, To: to, CampusID: parseIDParam(c.Query("campusId")),
  130. })
  131. if err != nil {
  132. return response.Fail(c, err)
  133. }
  134. return response.OK(c, res)
  135. }