// 账单与仪表盘端点处理器(端点 17 `GET /student/bill`、端点 21 `GET /admin/finance/dashboard`)。 // // 依据:unified/03-migration/academic-domain-design.md // - §4.1 端点 17(`/student/bill`,SP25 get_bill)与端点 21(`/admin/finance/dashboard`,P2) // - §3.4 #25:账单 = **汇总对象 + 明细分页** 两段返回 // - §3.8-f 约束 2:`t_user_expense_log` 无 campus 列 ⇒ 仪表盘不给"按校区拆流水" // // 为什么端点 17 挂在 `/student` 段却写在 finance handler 文件组里:它复用 AC-10 的 BillService // (同一 SP),且与财务对账是同一份数字口径;段位只影响**角色白名单与挂载点**(AC-14 接), // 不影响本文件的方法归属。 package handler import ( "strings" "github.com/gofiber/fiber/v2" "yuansiwei/unified-backend/internal/academic/service" infraerrors "yuansiwei/unified-backend/internal/infra/errors" "yuansiwei/unified-backend/internal/infra/response" ) // billSelfRoles 允许"查他人账单"的角色(`/student/bill?userId=` 的越权判定)。 // // 语义: // - 家长代查(parent)、平台超管(super_admin)、财务对账(finance)、教务(academic)可指定 userId; // - 学生(student)**只能查自己**(userId 与作用域不一致 → 1003)。 // // 为什么家长放行:移动端 `/student/*` 的角色白名单本就是 `student,parent`(§4.1 端点 17), // 家长 token 的 canonical_user_id 是家长本人,不可能是学生 id,若不放行则家长端永远查不到孩子账单。 // 孩子的归属校验由 `BillService` 注入的 MembershipChecker 兜底(不在本表内做"亲子关系"判定—— // 那需要 org 域的家庭关系表,属跨域建模)。 var billSelfRoles = map[string]struct{}{ "super_admin": {}, "parent": {}, "finance": {}, "academic": {}, } // GetBill 学生账单(端点 17 `GET /student/bill`,SP25 `sp_user_expense_get_bill`)。 // // 入参(query): // - `userId` 学生(可空:空 = 作用域本人;非空且角色不在 billSelfRoles → 1003); // - `campusIds` 校区逗号串(dump 第二入参;**只回显、不过滤**——dump 里它是死参数); // - `from`/`to` 时间窗(**`to` 不含上界**:要含当日必须传次日或 23:59:59); // - `page`/`pageSize` 分页(钳制上限 200)。 // // 出参(成功):`{summary{9 列 + 2 个对账辅助列 + campusIds}, list[], total, page, pageSize, pages, from, to}`。 // 出参(失败):1001(无作用域)/2001(缺 from/to、from>to)/2002(campusIds 或时间格式非法)/1003/1004/5002。 // // **两个对账字段名由前端契约 v1.3 端点 17 注 ① 定死**(契约定名、后端照做,不是实现产出后回填): // - `flowExclusiveEnd`:与「期末 − 期初」对齐的当期发生额(右开口径,**不含** `to` 当天); // - `flowInclusiveEnd`:**含 `to` 当天**的当期发生额(dump 的 `BETWEEN` 两端闭口径)。 // 二者之差 == 落在 `to` 当天的流水金额。**两个都必须下发**——只留一个就没法让前端"看见" // 这个不对称(契约 v1.2 把两件事写成同一句 `< to`,根因正是"看不见")。 // // 字段名的落地位置与守护方式:JSON tag 在 `service.BillSummary`(本 handler 直接透传该对象), // 由 `TestBillEndpointIdentity` 断言——用例刻意造一笔落在 `to` 当天的流水,检查两个键都能解出、 // 且`inclusive − exclusive` 恰好等于该笔金额。谁把 tag 改名,该用例立刻红(解出零值 ⇒ 差值断言失败)。 // // 响应形状说明:账单是"汇总 + 明细"两段,**不能**用 `response.Page`(那会把 summary 挤掉), // 因此这里用 `response.OK` 手工拼 `PageData` 的 5 个键(list/total/page/pageSize/pages), // 保证前端 `useListPage` 的读法一致(分页元信息仍在 data 内,不散落顶层)。 func (h *Handler) GetBill(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } target := strings.TrimSpace(c.Query("userId")) if target == "" { target = sc.UserID } if target != sc.UserID && !sc.SuperAdmin { if _, ok := billSelfRoles[sc.Role]; !ok { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrForbidden, errString("该角色只能查询本人账单"))) } } from, hasFrom, ferr := parseTimeParam(c.Query("from"), "from") if ferr != nil { return response.Fail(c, ferr) } to, hasTo, terr := parseTimeParam(c.Query("to"), "to") if terr != nil { return response.Fail(c, terr) } if !hasFrom || !hasTo { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("账单查询需要 from 与 to(账户区间对账的两个边界都是必填)"))) } page, pageSize := parsePage(c) svc := h.servicesFor(sc) res, err := svc.bill.GetBill(c.Context(), service.GetBillInput{ UserID: target, CampusIDs: c.Query("campusIds"), From: from, To: to, Page: page, PageSize: pageSize, }) if err != nil { return response.Fail(c, err) } pages := int64(0) if res.PageSize > 0 { pages = (res.Total + int64(res.PageSize) - 1) / int64(res.PageSize) } return response.OK(c, fiber.Map{ "summary": res.Summary, "list": res.Details, "total": res.Total, "page": res.Page, "pageSize": res.PageSize, "pages": pages, "from": res.From, "to": res.To, }) } // GetDashboard 财务仪表盘(端点 21 `GET /admin/finance/dashboard`,**P2**)。 // // 入参(query):`from`/`to`(必填)、`campusId`(可选,>0 只统计该校区)。 // 出参(成功):`DashboardResult`(余额分布 + 累计收支 + `flowsByCampusSupported=false`)。 // 出参(失败):1001/2001(缺时间窗 / from>to)/5002。 // // ⚠ 口径(与 service.DashboardResult 注释同源):本端点是**账户累计列横截面**, // 不是 `[from,to]` 区间发生额——因为 `t_user_expense_log` 没有 campus/机构列, // 期间流水在数据层**无法**按校区聚合(§3.8-f 约束 2)。响应里显式给 // `flowsByCampusSupported=false` 告知前端这个边界,避免被当成"字段漏了"。 func (h *Handler) GetDashboard(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } from, hasFrom, ferr := parseTimeParam(c.Query("from"), "from") if ferr != nil { return response.Fail(c, ferr) } to, hasTo, terr := parseTimeParam(c.Query("to"), "to") if terr != nil { return response.Fail(c, terr) } if !hasFrom || !hasTo { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("dashboard 需要 from 与 to"))) } svc := h.servicesFor(sc) res, err := svc.query.Dashboard(c.Context(), service.DashboardInput{ From: from, To: to, CampusID: parseIDParam(c.Query("campusId")), }) if err != nil { return response.Fail(c, err) } return response.OK(c, res) }