| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330 |
- // 收支端点处理器(端点 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
- }
|