// 收支端点处理器(端点 60:3 条路由)——AC-13d 第三批。 // // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 行 60(角色 `super_admin, finance`)/ // §3.4 D 表 60(v1.9 重写:JOIN 字典、补 `expenditureId`、删 `direction`、`userId`→`createUserId`); // 设计 `academic-domain-design.md` §4.5(端点清单)。 // // --------------------------------------------------------------------------- // 端点 → 服务方法对照(3 条,**没有 `POST /types`**) // --------------------------------------------------------------------------- // // 60a GET /admin/expenditures ListExpenditures → ExpenditureService.ListLogs // 60b POST /admin/expenditures CreateExpenditure → ExpenditureService.CreateLog // 60c GET /admin/expenditures/types ListExpenditureTypes → ExpenditureService.ListTypes // // 契约原写法 `GET/POST /admin/expenditures[/types]` 字面像 4 条,实为 3 条(v1.9 明确不做 `POST /types`)。 // `POST /types` 若将来要加,**必须先解决"平台通用字典能否由机构改"**——让任一机构新建类型并不难, // 难的是删/改时的引用完整性(`expenditure_log.expenditure_id` 指向它,删一条字典会让历史记录失去类型名)。 // // --------------------------------------------------------------------------- // 静态段 vs `:id` 段(本批**无真冲突**,如实说明) // --------------------------------------------------------------------------- // // `/admin/expenditures/types` 与未来可能的 `/admin/expenditures/:id` 属**同 depth**, // 但本批**没有** `:id` 路由 ⇒ 当前不存在"谁先注册"的问题。仍把 `types` 排在前面注册: // 将来若加 `GET /:id`,只要保持"静态段在 `:id` 之前"这条纪律(route.go 表顺序即注册顺序), // 就不会出现 `:id` 吃掉 "types"(解析失败 → 2002)这种"改一行、整页报错"的故障。 // **不编造一条假冲突来凑"变红用例"**(AC-13c 已按此口径如实登记过)。 // // --------------------------------------------------------------------------- // 三条纪律 // --------------------------------------------------------------------------- // // 1. **Handler 不碰 DB**:只做"取作用域 → 解析参数 → 调 Service → 写响应"(本文件无一条 SQL)。 // 2. **金额一律走 `money.Amount`**:入参 `money` 是**字符串**(`parseMoney`),出参也序列化成字符串 // (2 位小数)。**任何一处出现 float64**,对账迟早差几分钱,而且查不出来。 // 3. **机构/校区越权统一 1004**:查询侧由 Service `assertInstitution` 报, // 写入侧由 `assertScopeMatch` 报;SQL 层的 `Scoped` 是第二道,不替代 1004(空列表 ≠ 越权)。 package handler import ( "strings" "time" "github.com/gofiber/fiber/v2" "yuansiwei/unified-backend/internal/academic/service" infraerrors "yuansiwei/unified-backend/internal/infra/errors" "yuansiwei/unified-backend/internal/infra/response" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // ---------------------------------------------------------------- 路径常量(**不含 `/api`**) const ( // PathExpenditures 端点 60:收支记录列表 / 记账(`/admin/expenditures`)。 PathExpenditures = "/admin/expenditures" // PathExpenditureTypes 端点 60:收支类型字典(`/admin/expenditures/types`;**静态段先于 `:id`**)。 PathExpenditureTypes = "/admin/expenditures/types" ) // ListExpenditureLogs 收支记录列表(端点 60 `GET /admin/expenditures`)。 // // 入参(query):`institutionId?`(超管可借此收窄;传了则非超管必须等于作用域机构,否则 1004)、 // `campusId?`、`expenditureId?`、`type?`(1 支出 / 2 收入)、`from?`/`to?`、分页。 // // `from`/`to` 口径(**两句话,别记反**): // // `from` 恒为**闭**下界(`>=`); // `to` 恒为**开**上界(`<`)——但纯日期形态会自动补到次日 00:00, // 所以 `to=2026-04-10` 的实际含义是"**含 4 月 10 日一整天**"。 // // 为什么不做成"闭区间":`create_time` 是 `datetime(3)`,`<= 2026-04-10 00:00:00` // 会把 4 月 10 日白天记的账全部漏掉(用户以为"今天没记账")。 // 为什么保留两种形态:前端"日期选择器"只给 `2006-01-02`,而报表导出会带时分秒; // 若一律当"当天零点",后者的语义会静默变化。 // 出参(成功):`{list,total,page,pageSize,pages}`,行见 `expenditureLogView`(9 字段,含字典 `type`/`name`)。 // 出参(失败):1001/2001/2002(`type`/`campusId`/时间格式非法)/**1004**(跨机构/跨校区)/5002。 // // ⚠ **`type` 与 `name` 是字典列**(记录表没有):服务层按 `expenditure_id` 批量查字典补齐, // 且**包含平台通用字典 `institution_id = 0`**(那不是未归属数据,是"房租/水电/学费收入"这类全平台类型)。 func (h *Handler) ListExpenditureLogs(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } institutionID, err := optionalInstitutionQuery(c, sc) if err != nil { return response.Fail(c, err) } campusID, err := parseIntQuery(c, "campusId") if err != nil { return response.Fail(c, err) } if campusID < 0 { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("campusId 不得为负"))) } var campusPtr *int64 if strings.TrimSpace(c.Query("campusId")) != "" { // 校区维度越权:非超管给了别的校区 = 1004(不是"空列表") if serr := assertScopeMatches(sc, effectiveInstitution(sc, institutionID), campusID, "收支记录列表"); serr != nil { return response.Fail(c, serr) } v := campusID campusPtr = &v } expenditureID, err := parseIntQuery(c, "expenditureId") if err != nil { return response.Fail(c, err) } if expenditureID < 0 { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("expenditureId 不得为负"))) } var expenditurePtr *int64 if strings.TrimSpace(c.Query("expenditureId")) != "" { v := expenditureID expenditurePtr = &v } direction, _, err := parseEnumQuery(c, "type", map[int64]struct{}{ int64(service.ExpenditureDirectionExpense): {}, int64(service.ExpenditureDirectionIncome): {}, }) if err != nil { return response.Fail(c, err) } var directionPtr *int32 if direction != nil { v := int32(*direction) directionPtr = &v } from, hasFrom, err := parseTimeParam(c.Query("from"), "from") if err != nil { return response.Fail(c, err) } to, hasTo, err := parseTimeParam(c.Query("to"), "to") if err != nil { return response.Fail(c, err) } if hasTo { to = normalizeToUpperBound(c.Query("to"), to) } if hasFrom && hasTo && to.Before(from) { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("to 不能早于 from"))) } page, pageSize := parsePage(c) svc := h.servicesFor(sc) rows, total, err := svc.expenditure.ListLogs(c.Context(), service.ExpenditureLogQuery{ InstitutionID: institutionID, CampusID: campusPtr, ExpenditureID: expenditurePtr, Type: directionPtr, From: timePtr(from, hasFrom), To: timePtr(to, hasTo), Page: page, PageSize: pageSize, }) if err != nil { return response.Fail(c, err) } views := make([]expenditureLogView, 0, len(rows)) for i := range rows { row := &rows[i] views = append(views, expenditureLogView{ ID: row.ID, BizNo: row.BizNo, ExpenditureID: row.ExpenditureID, Type: row.Type, Name: row.Name, Money: row.Money.String(), // 恒 2 位小数(定点,无 float 路径) CreateUserID: row.CreateUserID, // 可空指针:NULL → null(不是 "") CampusID: row.CampusID, CreateTime: row.CreateTime, }) } return response.Page(c, views, page, pageSize, total) } // CreateExpenditureLog 记一笔收支(端点 60 `POST /admin/expenditures`)。 // // 入参(body):见 `CreateExpenditureLogReq`(`expenditureId` + `money` 必填)。 // 出参(成功):`{id, bizNo}`。 // 出参(失败):1001/2001(缺机构/缺类型/缺金额)/2002(金额 ≤0 或格式非法、校区为负)/ // **3001**(收支类型不存在或对本机构不可见)/1004(跨机构/跨校区)/5002。 // // 事务:字典可见性校验 + 插记录 + 回写报表分桶(`year`/`month`)**同一事务**。 func (h *Handler) CreateExpenditureLog(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req CreateExpenditureLogReq if !validateReq(c, &req) { return nil // body code=2001 已写出 } institutionID, err := resolveInstitutionID(sc, req.InstitutionID) if err != nil { return response.Fail(c, err) // 2001 缺机构 / 1004 跨机构 } if req.CampusID < 0 { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("campusId 不得为负"))) } campusID := req.CampusID if campusID == 0 && sc.CampusID > 0 { // 未传校区且有校区作用域 → 取作用域校区。**只会收窄,不会放宽**: // 反过来"补 0"会让一条本该记在某校区的账落到机构级,校区报表直接少一条。 campusID = sc.CampusID } if serr := assertScopeMatches(sc, institutionID, campusID, "记账"); serr != nil { return response.Fail(c, serr) } amount, ok := parseMoney(c, req.Money, "money") if !ok { return nil // body code=2002 已写出 } svc := h.servicesFor(sc) created, err := svc.expenditure.CreateLog(c.Context(), service.CreateExpenditureLogInput{ InstitutionID: institutionID, CampusID: campusID, ExpenditureID: req.ExpenditureID, Money: amount, Remark: req.Remark, CreateUserID: sc.UserID, }) if err != nil { return response.Fail(c, err) // 2002/3001/1004/5002 } return response.OK(c, createdExpenditureLogView{ID: created.ID, BizNo: created.BizNo}) } // ListExpenditureTypes 收支类型字典(端点 60 `GET /admin/expenditures/types`)。 // // 入参(query):`institutionId?`(传了则非超管必须等于作用域机构,否则 1004;不传 = 作用域机构)。 // 出参(成功):`{types:[{id,type,name,parentId}]}`(按 `type,parentId,id` 递增)。 // 出参(失败):1001/2002(`institutionId` 非法)/**1004**/5002。 // // ⚠ **平台通用字典(`institution_id = 0`)恒在结果里**。若被作用域过滤掉,机构视角下 // "新增收支"的下拉框会只剩自己建的几条(通常 0 条)⇒ 功能看起来是坏的,而数据其实好好的。 // 超管未指定机构时只返回平台字典(否则多机构的同名类型会混在一起,选项重复、父子关系错乱)。 func (h *Handler) ListExpenditureTypes(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } institutionID, err := optionalInstitutionQuery(c, sc) if err != nil { return response.Fail(c, err) } svc := h.servicesFor(sc) types, err := svc.expenditure.ListTypes(c.Context(), institutionID) if err != nil { return response.Fail(c, err) } views := make([]expenditureTypeView, 0, len(types)) for i := range types { views = append(views, expenditureTypeView{ ID: types[i].ID, Type: types[i].Type, Name: types[i].Name, ParentID: types[i].ParentID, }) } return response.OK(c, expenditureTypesResp{Types: views}) } // optionalInstitutionQuery 解析可选的 `institutionId` query(**super 可借此收窄,其余必须一致**)。 // // 入参:c;sc 作用域。出参:(institutionID, error): // - 未传 → (0, nil):交给作用域(普通用户由 SQL 层 `Scoped` 强制;超管 = 跨机构视角); // - 传了 >0 → 走 `resolveInstitutionID`(非超管不等于作用域机构 → **1004**); // - 传了 ≤0 → **2002**("给了但无意义",不静默当没传——否则前端拼错参数名会毫无反馈)。 func optionalInstitutionQuery(c *fiber.Ctx, sc *appscope.Scope) (int64, error) { raw := strings.TrimSpace(c.Query("institutionId")) if raw == "" { return 0, nil } value, err := parseIntQuery(c, "institutionId") if err != nil { return 0, err } if value <= 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("institutionId 必须 > 0(不筛机构请不要传该参数)")) } return resolveInstitutionID(sc, value) } // timePtr 把 (时间, 是否传了) 归一为可选指针(未传 → nil,交给仓储"不筛")。 // // 入参:t 解析结果;has 是否传了。出参:*time.Time。 func timePtr(t time.Time, has bool) *time.Time { if !has { return nil } return &t } // normalizeToUpperBound 归一 `to` 上界:纯日期(`2006-01-02`)→ 次日 00:00(配合仓储的开区间 `<`)。 // // 入参:raw 原始 query;parsed 已解析的时间。出参:用于 `create_time < ?` 的上界。 // // 判定"纯日期"用**原始串长度**(10),而不是猜时间是否为 0 点:前端真传 // `2026-04-10T00:00:00Z` 时语义是"到该时刻为止"(开区间),不该被自动扩成整天—— // 两种情况字节不同、意图不同,用长度判定最诚实。 func normalizeToUpperBound(raw string, parsed time.Time) time.Time { if len(strings.TrimSpace(raw)) == len("2006-01-02") { return parsed.AddDate(0, 0, 1) } return parsed } // effectiveInstitution 折算"本次请求实际作用于哪个机构"(供 `assertScopeMatches` 使用)。 // // 入参:sc 作用域(可 nil);institutionID 显式传的机构(0 = 未传)。 // 出参:机构 ID(显式优先;否则作用域机构;都取不到 → 0)。 // // 用途:把"可选的 institutionId query"与"作用域机构"合成一个确定值,再交给统一的 // `assertScopeMatches` 做越权判断——**避免每个 Handler 各写一遍"显式 or 作用域"的分支**, // 那种重复分支最容易出现"查询侧校验了、写入侧忘了"的不对称。 func effectiveInstitution(sc *appscope.Scope, institutionID int64) int64 { if institutionID > 0 { return institutionID } if sc == nil { return 0 } return sc.InstitutionID }