// 收支记录 + 收支字典服务(端点 60)——AC-13d 第三批。 // // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 行 60 / §3.4 D 表 60(v1.9 重写); // 设计 `academic-domain-design.md` §4.5 端点清单(角色 `super_admin, finance`)。 // // --------------------------------------------------------------------------- // 为什么新增(AC-10 / AC-11 / AC-13c 都没覆盖过这张表) // --------------------------------------------------------------------------- // // 能力 现状(此前批次) 冲突 // ListLogs `t_expenditure_log` 从未被读取 无(新只读查询) // ListTypes `t_expenditure` 从未被读取 无(新只读查询) // CreateLog 从未有过写入面(资金域的钱走 无(新事务;**不碰** // `t_user_expense*` 与学生钱包, `t_user_expense*`, // 与"机构收支台账"是两套账) 两套账不得混) // // 三条硬约束(团队批准):不改既有方法签名与行为、新增放新文件、逐条登记(上表)。 // // --------------------------------------------------------------------------- // ⚠ 两套账不能混(这是本端点最容易犯的错) // --------------------------------------------------------------------------- // // 学生钱包 / 消费 → `t_user_expense` / `t_user_expense_school` / `t_user_expense_log`(端点 2~9) // 机构收支台账 → `t_expenditure_log`(本端点) // // 二者**没有任何列级联动**:记账不会改学生余额,学生退费也不会自动生成一条机构支出。 // 把两者混起来算"利润"是产品口径问题,不是后端能替它决定的;本服务只做台账 CRUD。 // // --------------------------------------------------------------------------- // 三处口径(都写在这里,避免下一批改的时候靠猜) // --------------------------------------------------------------------------- // // 1. **`cost` 恒存正数**,方向由字典 `type`(1 支出 / 2 收入)决定(model 列红线)。 // ⇒ 入参 `money` 必须 > 0:负数会让"支出 + 负数"与"收入 + 正数"变成同一条记录的两种写法, // 报表口径立刻分叉。传 0 也拒(零元记账没有业务意义,只会污染对账)。 // 2. **`bizNo` 由后端生成**(26 位 ULID):前端不能指定单号,否则可枚举/可撞号; // `biz_no` 是唯一键,撞了就是 5002(不该等到那时候才发现)。 // 3. **`year` / `month` 分桶列按"库认定的落库时刻"算**(`create_time` 由 DB 默认值填, // 应用插入后回读再回写分桶)。用应用时钟会在跨月那一秒把记录记到错月份, // 而报表上只表现为"某月少一条"。 package service import ( "context" "strings" "time" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" "yuansiwei/unified-backend/internal/academic/repository" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // 收支方向枚举(与 `t_expenditure.type` 列一致;契约 D 表:1 支出 / 2 收入)。 const ( // ExpenditureDirectionExpense 支出(1)。 ExpenditureDirectionExpense int32 = 1 // ExpenditureDirectionIncome 收入(2)。 ExpenditureDirectionIncome int32 = 2 ) // ExpenditureService 机构收支台账服务(端点 60 的三条路由)。 type ExpenditureService struct { // repo 收支仓储(记录走 scoping,字典按"0 = 平台通用"特判)。 repo *repository.ExpenditureRepo // runner 事务运行器(记账 = 校验字典 + 插记录 + 回写分桶,必须同一事务)。 runner *repository.TxRunner // sc 请求作用域(机构/校区越权断言)。 sc *appscope.Scope // now 时钟(仅用于判断"入参是否给出日期"等非落库时间场景;落库时间一律取 DB)。 now func() time.Time } // NewExpenditureService 构造收支台账服务。 // // 入参:db 连接池;sc 请求作用域(nil = 系统任务视角,此时记录查询会 fail-fast)。 // 出参:*ExpenditureService。 func NewExpenditureService(db *gorm.DB, sc *appscope.Scope) *ExpenditureService { return &ExpenditureService{ repo: repository.NewExpenditureRepo(db, sc), runner: repository.NewTxRunner(db), sc: sc, now: time.Now, } } // ExpenditureLogQuery 端点 60 GET 记录的查询入参。 // // 字段:InstitutionID 机构(0 = 依赖作用域;超管可借此收窄);CampusID 校区(nil 不筛); // ExpenditureID 收支类型(nil 不筛);Type 方向(nil 不筛);From/To 创建时间区间(含两端); // Page/PageSize 分页。 type ExpenditureLogQuery struct { InstitutionID int64 CampusID *int64 ExpenditureID *int64 Type *int32 From *time.Time To *time.Time Page int PageSize int } // ExpenditureLogView 收支记录对外视图(契约 D 表 60 的 9 个字段,**逐列溯源**)。 // // 字段来源: // // ID ← t_expenditure_log.id // BizNo ← biz_no(业务单号,对外唯一标识;不可变) // ExpenditureID ← expenditure_id(**补这个字段**,否则前端无法回填编辑表单) // Type ← t_expenditure.type(**字典**:1 支出 / 2 收入;记录表无此列) // Name ← t_expenditure.name(**字典**;记录表无此列) // Money ← cost(金额 2 位小数;**列名是 cost、字段名是 money**,契约口径) // CreateUserID ← create_user_id(**记账人**,可空 → `null`;不是"收付对象",记录表没有那列) // CampusID ← campus_id // CreateTime ← create_time // // 契约里原有的 `direction` / `userId` **不下发**:前者与 `type` 同义(两表都没有该列), // 后者语义是"收付对象"而记录表只有"记账人"(改名 `createUserId` 才不误导)。 type ExpenditureLogView struct { ID int64 BizNo string ExpenditureID int64 Type int32 Name string Money money.Amount CreateUserID *string CampusID int64 CreateTime time.Time } // ExpenditureTypeView 收支字典项(契约:`{id, type, name, parentId}`)。 // // 字段来源:ID ← id;Type ← type;Name ← name;ParentID ← parent_id(0 = 顶级,自引用树)。 type ExpenditureTypeView struct { ID int64 Type int32 Name string ParentID int64 } // CreateExpenditureLogInput 端点 60 POST 记账入参。 // // 字段:InstitutionID(必填 >0,非超管须等于作用域机构);CampusID(0 = 机构级); // ExpenditureID(必填 >0,须在字典中且对本机构可见);Money(必填 >0,元字符串解析后传入); // Remark(可空);CreateUserID(记账人 = 当前登录用户,由 Handler 从作用域取)。 type CreateExpenditureLogInput struct { InstitutionID int64 CampusID int64 ExpenditureID int64 Money money.Amount Remark string CreateUserID string } // CreatedExpenditureLog 记账结果(契约:POST → `{id, bizNo}`)。 type CreatedExpenditureLog struct { // ID 记录 ID(自增)。 ID int64 // BizNo 业务单号(后端生成的 26 位 ULID)。 BizNo string } // ListLogs 分页查询收支记录(字典名/方向由批量查字典补齐)。 // // 入参:ctx;q 查询入参。 // 出参:([]ExpenditureLogView, total, error);1004(跨机构)/5002。 // // 为什么"分页取记录 + 批量取字典"而不是 SQL JOIN:记录表走 `Scoped` 生成的是**裸列名**, // JOIN 后 `institution_id` 会 `ambiguous column name`(本项目已踩过); // 且两步走能让"平台通用字典必须放行"这条规则只出现在 `ListTypesByIDs` 一处。 func (s *ExpenditureService) ListLogs(ctx context.Context, q ExpenditureLogQuery) ([]ExpenditureLogView, int64, error) { if err := s.assertInstitution(q.InstitutionID); err != nil { return nil, 0, err } rows, total, err := s.repo.ListLogs(ctx, repository.ExpenditureLogFilter{ InstitutionID: q.InstitutionID, CampusID: q.CampusID, ExpenditureID: q.ExpenditureID, Type: q.Type, From: q.From, To: q.To, }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize}) if err != nil { return nil, 0, err } if len(rows) == 0 { return make([]ExpenditureLogView, 0), total, nil } // 批量取字典(**含平台通用字典**),避免 N+1;`expenditure_id = 0` 不查(不存在该 id)。 ids := make([]int64, 0, len(rows)) seen := make(map[int64]struct{}, len(rows)) for i := range rows { id := rows[i].ExpenditureID if id <= 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} ids = append(ids, id) } types, err := s.repo.ListTypesByIDs(ctx, ids, s.dictionaryInstitution(q.InstitutionID)) if err != nil { return nil, 0, err } dict := make(map[int64]*model.Expenditure, len(types)) for i := range types { dict[types[i].ID] = &types[i] } views := make([]ExpenditureLogView, 0, len(rows)) for i := range rows { row := &rows[i] cost, perr := parseAmount(row.Cost) if perr != nil { return nil, 0, perr } view := ExpenditureLogView{ ID: row.ID, BizNo: row.BizNo, ExpenditureID: row.ExpenditureID, Money: cost, CreateUserID: optionalTrimmedPtr(row.CreateUserID), CampusID: row.CampusID, CreateTime: row.CreateTime, } // 字典命中才有 type/name;未命中保持 0 / ""(见下)。 if t, ok := dict[row.ExpenditureID]; ok { view.Type = t.Type view.Name = strings.TrimSpace(t.Name) } // ⚠ 字典未命中(类型被删 / `expenditure_id = 0`)时 **type=0、name=""**,不编造"未知类型": // 编一个字符串等于凭空造业务值(契约 §3.4 ③:每个字段必须能追到列,造的值追不到), // 而且前端再想按 type 分组时会被这个假值带偏。空值至少诚实。 views = append(views, view) } return views, total, nil } // ListTypes 取收支字典(契约:`GET /admin/expenditures/types` → `types:[{id,type,name,parentId}]`)。 // // 入参:ctx;institutionID 机构(<=0 = 只取平台通用字典,见 `dictionaryInstitution`)。 // 出参:([]ExpenditureTypeView, error);1004(跨机构)/5002。 // // **平台通用字典(`institution_id = 0`)恒在结果里**:它不是"未归属数据", // 而是全平台共用的类型(如"房租/水电/学费收入")。若被作用域过滤掉, // 机构视角下"新增收支"的下拉框会只剩自己建的几条(通常为 0 条)⇒ 功能像是坏了。 func (s *ExpenditureService) ListTypes(ctx context.Context, institutionID int64) ([]ExpenditureTypeView, error) { if err := s.assertInstitution(institutionID); err != nil { return nil, err } rows, err := s.repo.ListTypes(ctx, s.dictionaryInstitution(institutionID)) if err != nil { return nil, err } views := make([]ExpenditureTypeView, 0, len(rows)) for i := range rows { views = append(views, ExpenditureTypeView{ ID: rows[i].ID, Type: rows[i].Type, Name: strings.TrimSpace(rows[i].Name), ParentID: rows[i].ParentID, }) } return views, nil } // CreateLog 记一笔收支(校验字典可见性 → 插记录 → 按库时间回写分桶,**同一事务**)。 // // 入参:ctx;in CreateExpenditureLogInput。 // 出参:*CreatedExpenditureLog(id + bizNo);2001(缺机构/类型)/2002(金额≤0、校区为负)/ // 3001(收支类型不存在或不可见)/1004(跨机构/跨校区)/5002。 func (s *ExpenditureService) CreateLog(ctx context.Context, in CreateExpenditureLogInput) (*CreatedExpenditureLog, error) { if in.InstitutionID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 记账需要 institutionId(>0)")) } if in.ExpenditureID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 记账需要 expenditureId(>0)")) } if in.CampusID < 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: campusId 不得为负")) } if !(in.Money.GreaterThan(0)) { // 零/负都拒:`cost` 恒存正数,方向由字典 type 决定(model 列红线)。 // 放行负数会让"支出 -100"与"收入 +100"成为同一条记录的两种写法,报表口径立刻分叉。 return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: 记账金额必须大于 0(收支方向由 expenditureId 对应的 type 决定,"+ "cost 恒存正数;传负数会与反方向的正数记账重复)")) } if err := in.Money.Validate(); err != nil { return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, err) } if err := s.assertScopeMatch(in.InstitutionID, in.CampusID); err != nil { return nil, err } bizNo, err := newBizNo() if err != nil { return nil, err } row := &model.ExpenditureLog{ BizNo: bizNo, InstitutionID: in.InstitutionID, CampusID: in.CampusID, ExpenditureID: in.ExpenditureID, // 金额列是 `decimal(12,2)` 字符串:走 `money.Amount.String()`(定点,恒 2 位小数)。 Cost: in.Money.String(), CreateUserID: stringPtr(in.CreateUserID), Remark: stringPtr(in.Remark), } err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) // 字典可见性校验放在事务内:并发删字典时不会出现"校验通过但字典已没了"的窗口。 if _, terr := s.repo.GetTypeByID(txCtx, in.ExpenditureID, s.dictionaryInstitution(in.InstitutionID)); terr != nil { return terr // 2002(id 非法)/ 3001(不存在或不可见)/ 5002 } if cerr := s.repo.CreateLog(txCtx, tx, row); cerr != nil { return cerr } // 分桶按**库认定的落库时刻**算(`create_time` 由 DB 默认值填,应用侧时钟会跨月错桶)。 year := int16(row.CreateTime.Year()) month := int8(row.CreateTime.Month()) return s.repo.SetLogBuckets(txCtx, tx, row.ID, year, month) }) if err != nil { return nil, err } return &CreatedExpenditureLog{ID: row.ID, BizNo: row.BizNo}, nil } // assertInstitution 机构维度越权断言(非超管必须与作用域机构一致)。 // // 入参:institutionID(0 = 未指定,依赖作用域,放行)。出参:1004 或 nil。 // // 为什么要单独一道(`Scoped` 已经在 SQL 层过滤):SQL 层过滤会把"跨机构查询"变成**空列表**, // 症状是"这家机构一条收支都没有",而不是"你越权了"。查询类端点必须显式报 1004, // 否则前端会以为数据丢了(本项目在 AC-13c 已按此口径统一)。 func (s *ExpenditureService) assertInstitution(institutionID int64) error { if s.sc == nil { return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 收支服务缺少作用域")) } if s.sc.SuperAdmin || institutionID <= 0 { return nil } if institutionID != s.sc.InstitutionID { return infraerrors.WithCause(infraerrors.ErrUnscoped, errString("service: 收支记录机构与当前作用域机构不一致")) } return nil } // assertScopeMatch 写入时的机构 + 校区双维度断言(非超管)。 // // 入参:institutionID;campusID。出参:1004 或 nil。 // // 校区规则与课程域一致:作用域带校区(>0)时,写入的 `campus_id` 必须等于它, // 否则就是"往别的校区记账"——而收支记录是**按校区出报表**的,越权写入会直接污染他校区报表。 func (s *ExpenditureService) assertScopeMatch(institutionID, campusID int64) error { if s.sc == nil { return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 收支服务缺少作用域")) } if s.sc.SuperAdmin { return nil } if institutionID != s.sc.InstitutionID { return infraerrors.WithCause(infraerrors.ErrUnscoped, errString("service: 记账机构与当前作用域机构不一致")) } if s.sc.CampusID > 0 && campusID != s.sc.CampusID { return infraerrors.WithCause(infraerrors.ErrUnscoped, errString("service: 记账校区与当前作用域校区不一致")) } return nil } // dictionaryInstitution 折算"查字典时该用哪个机构"。 // // 入参:institutionID 查询/写入的机构(可 0)。出参:用于字典过滤的机构 ID(0 = 只取平台通用字典)。 // // 规则:调用方给了机构就用它;没给(超管未指定)→ 用作用域机构;仍为 0 → 只取平台字典。 // **任何一支都包含 `institution_id = 0`**(见 repo.dictionaryScope):平台通用字典永不缺席。 func (s *ExpenditureService) dictionaryInstitution(institutionID int64) int64 { if institutionID > 0 { return institutionID } if s.sc != nil { return s.sc.InstitutionID } return 0 }