| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152 |
- // 账单与仪表盘端点处理器(端点 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}, details[], total, page, pageSize, pages, from, to}`。
- // (`details` 是顶层数组、不嵌套;`pages` 由 Service 的 `billPages` 一次算定,handler 不重复取整。)
- // 出参(失败):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` 手工拼分页元信息(total/page/pageSize/pages)+ `details` 数组,
- // 保证前端 `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)
- }
- // 分页五字段**由 Service 一次算定**(`res.Pages`),handler 不再自己取整:
- // 同一份分页元信息若有两处算法,迟早出现"某处四舍五入、某处向上取整"的偏差。
- // 注意:明细数组键名是 **`details`**(不是 `list`,也不是嵌套对象)—— 以 `BillResult` 的
- // json tag 为准(契约 v1.4 rev.2 已按代码定稿:`data` 平铺)。
- return response.OK(c, fiber.Map{
- "summary": res.Summary,
- "details": res.Details,
- "total": res.Total,
- "page": res.Page,
- "pageSize": res.PageSize,
- "pages": res.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)
- }
|