// 余额账户仓储(t_user_expense / t_user_expense_school / t_user_expense_log)——AC-03 资金主账。 // // 依据:unified/03-migration/academic-domain-design.md §1.5(幂等键 uk_request_type)/ §1.7(两条例外) // / §10.2(type 编码与方向口径)/ §10.3(事务边界只在 Service)/ §10.6(分页钳制)。 // // 三张表的性质差异(**决定了作用域处理方式,读代码前必看**): // // t_user_expense 全局钱包,**无** institution_id/campus_id(§1.7 例外一) // → 本仓储的"全局"方法**不吃** Scoped(叠作用域会 Unknown column); // 越权断言由 Service 经 port.MembershipChecker 完成,仓储只按 user_id 精确取数。 // t_user_expense_school 校区账户,带作用域列 + UK(user_id, institution_id, campus_id) // → "校区"方法**一律经 Scoped**(普通角色强制本机构,超管放行)。 // t_user_expense_log 流水,只增不改,**无**作用域列(同 t_user_expense) // → 按 user_id 取数;幂等由 UNIQUE(request_id, type) 兜底。 // // 金额纪律(红线,代码评审逐处检查): // - 入参出参一律 `money.Amount`(分,int64),**禁止 float64**; // - 写库一律 `col = col + ?` **原子表达式**(禁读-改-写,防丢更新),金额以 decimal // **字符串字面量**入场(`money.Amount.String()`,精确无浮点); // - 汇总(SumLogMoney)在 **Go 侧定点累加**,禁止 `SQL SUM(decimal)` 回 REAL 再转(精度漂移)。 // // 事务纪律:本文件**不开事务**,所有写方法必须接收调用方的 `tx *gorm.DB`(设计 §10.3); // 全局 + 校区双写必须由**同一个 tx** 串起来,且固定顺序「全局 → 校区」(model 红线:防死锁)。 package repository import ( "context" "errors" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // ErrDuplicateLog 幂等重复哨兵:t_user_expense_log 撞 `uk_request_type`(request_id+type)。 // // 语义(设计 §1.5):同一资金请求号 + 同一 type 的第二次写入**不是错误**,而是幂等命中—— // Service 捕获本 sentinel 后返回**首次结果**(code 仍 200,Repeated=true),不得抛 5002。 // // 判定方式:`stderrors.Is(err, repository.ErrDuplicateLog)`。 var ErrDuplicateLog = infraerrors.New(infraerrors.CodeUnique, "重复的余额变更请求(request_id+type 已存在)") // ErrAccountNotFound 账户行缺失哨兵:原子增减命中 0 行(账户未创建)。 // // 语义:资金方法要求账户**先存在**(Service 在事务开头 UpsertGlobal/UpsertCampus 建户), // 命中 0 行说明调用顺序错误或账户被误删 → 必须报错回滚,禁止静默跳过(否则出现"流水有、余额没变")。 var ErrAccountNotFound = infraerrors.New(infraerrors.CodeNotFound, "余额账户不存在(须先 Upsert 建户)") // 余额账户列名白名单(原子增减的目标列,禁止调用方透传字符串 → 防列名注入)。 const ( // expenseColBalance 余额列(可正可负,负数=欠费)。 expenseColBalance = "balance" // expenseColSpend 上课消费课费单价之和(只增)。 expenseColSpend = "spend" // expenseColSpecialExpenses 特殊费用(坏账/清零/提前退费累加,只增)。 expenseColSpecialExpenses = "special_expenses" // expenseColTotalIncome 总充值。 expenseColTotalIncome = "total_income" // expenseColTotalExpenditure 总消费。 expenseColTotalExpenditure = "total_expenditure" ) // 列名常量的**导出别名**(AC-04/AC-05 的 Service 层构造 `ColumnDelta` 时使用)。 // // 为什么导出:批量增减 primitive(AddColumnsGlobalThenCampus / AddCampusColumns)需要调用方 // 声明"改哪几个列",而列名必须来自白名单(禁止字符串透传,防列名注入)。 // 白名单校验仍由 `isExpenseColumn` 单点收口,导出别名只是让 service 不必复制字面量。 const ( // ExpenseColumnBalance 余额列。 ExpenseColumnBalance = expenseColBalance // ExpenseColumnSpend 上课消费课费单价之和列(只增)。 ExpenseColumnSpend = expenseColSpend // ExpenseColumnSpecialExpenses 特殊费用列(坏账/清零累加)。 ExpenseColumnSpecialExpenses = expenseColSpecialExpenses // ExpenseColumnTotalIncome 总充值列。 ExpenseColumnTotalIncome = expenseColTotalIncome // ExpenseColumnTotalExpenditure 总消费列。 ExpenseColumnTotalExpenditure = expenseColTotalExpenditure ) // ColumnDelta 单列增量(原子表达式 `col = col + ?` 的目标列 + 增量)。 // // 用途:一次 UPDATE 同时改多列(dump 的 SP 每条 UPDATE 都同时改 2~3 列), // 避免"多列分多条语句"产生的中间态(对账/双跑会读到半成品)。 // // 字段: // - Column string:列名(**必须**取自 ExpenseColumn* 常量,白名单校验); // - Delta money.Amount:增量(分;正=加,负=减,0=该列不参与本次更新)。 type ColumnDelta struct { // Column 目标列名(ExpenseColumn* 常量)。 Column string // Delta 增量(分)。 Delta money.Amount } // buildColumnExprs 把 ColumnDelta 列表编译成 GORM 的"列 → 原子表达式"映射。 // // 入参:op 操作名(错误文案定位);deltas 列增量列表(空 → ErrInternal,属装配错误)。 // 出参: // - map[string]any:可直接交给 `UpdateColumns` 的更新映射(值为 gorm.Expr); // 所有 Delta 均为 0 时返回 **空 map**(调用方据此判定 no-op); // - ErrBadParam(非法列名/金额越界)/ ErrInternal。 // // 红线:金额以 decimal **字符串字面量**入场(`Amount.String()`),全程不经 float64(仓库包注释)。 func buildColumnExprs(op string, deltas []ColumnDelta) (map[string]any, error) { if len(deltas) == 0 { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: "+op+" 未指定任何列(属装配错误)")) } updates := make(map[string]any, len(deltas)) for _, d := range deltas { if !isExpenseColumn(d.Column) { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: "+op+" 非法列名 "+d.Column)) } if d.Delta.IsZero() { continue } lit, err := amountLiteral(d.Delta) if err != nil { return nil, err } updates[d.Column] = gorm.Expr(d.Column+" + ?", lit) } return updates, nil } // institutionScoped 只叠「机构」作用域(**故意不叠 `Sc.CampusID` 等值过滤**)。 // // 为什么不能用 Scoped:多校区写操作(课费转结 = 两个校区一进一出)一旦叠加 // `campus_id = Sc.CampusID`,第二个校区必然查不到 → 转结永远失败; // 而 `t_courses_activity` 系列还存在 `campus_id = 0`(全机构池)语义,等值过滤同样会漏行。 // // 安全边界(不放松):机构过滤仍强制(超管除外),且**调用方必须显式传 campus_id**, // 二者一起把写入范围钉死在「本机构的指定校区」(比 Scoped 的等值更精确,不是更宽)。 func (r *ExpenseRepo) institutionScoped(db *gorm.DB) *gorm.DB { if db == nil { return nil } if r == nil || r.Sc == nil { // 纵深防御:无作用域上下文禁止进入数据访问层(与 BaseRepo.Scoped 同口径 fail-fast) panic(errNoScope.Error()) } if r.Sc.SuperAdmin { return db } return db.Where("institution_id = ?", r.Sc.InstitutionID) } // ExpenseRepo 余额账户仓储(全局钱包 + 校区账户 + 流水)。 // // 继承 BaseRepo 的目的:**校区账户**(t_user_expense_school)强制机构/校区作用域; // 全局钱包与流水(无作用域列)的方法显式标注「平台级数据,不吃 Scoped」(§1.7 例外一)。 type ExpenseRepo struct { *BaseRepo } // NewExpenseRepo 构造余额账户仓储。 // // 入参: // - db *gorm.DB:连接池句柄(必填); // - sc *appscope.Scope:请求作用域(校区账户读写必需;系统任务传 nil 时仅全局/流水方法可用)。 // // 出参:*ExpenseRepo(CampusScoped=true:t_user_expense_school 带 campus_id)。 func NewExpenseRepo(db *gorm.DB, sc *appscope.Scope) *ExpenseRepo { r := &ExpenseRepo{BaseRepo: NewBaseRepo(db, sc)} r.CampusScoped = true return r } // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。 // // 入参:ctx context.Context(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。 func (r *ExpenseRepo) db(ctx context.Context) (*gorm.DB, error) { if r == nil || r.DB == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ExpenseRepo.DB 为 nil")) } if tx := TxFromContext(ctx); tx != nil { return tx, nil } if ctx == nil { return r.DB, nil } return r.DB.WithContext(ctx), nil } // amountLiteral 把金额压成 SQL 用的 decimal 字符串字面量(越界 → ErrBadParam)。 // // 入参:delta money.Amount(分)。出参:string("12.34"/"-0.01")+ error。 // // 为什么用字符串而不是 int64:列类型是 decimal(12,2),直接传 int64 会被当成"元"的整数写入; // 传两位小数字符串由 DB 精确解析,全程不经过 float64(红线)。 func amountLiteral(delta money.Amount) (string, error) { if err := delta.Validate(); err != nil { return "", infraerrors.WithCause(infraerrors.ErrBadParam, err) } return delta.String(), nil } // UpsertGlobal 建全局钱包账户(**不动已有余额**,UPSERT 语义)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**,nil → ErrInternal); // - row 账户行(UserID 非空;金额列留空则补 "0.00")。 // // 出参:nil 成功;ErrBadParam(缺 user_id/金额非法)/ ErrDBError。 // // 语义(关键):`ON CONFLICT(user_id) DO NOTHING` —— 冲突时**绝不**覆盖 balance/spend 等列 // (UPSERT 只负责"建户",资金变动一律走 Add* 原子表达式,否则并发充值会丢更新)。 // // 平台级数据(t_user_expense 无作用域列,§1.7 例外一):越权断言在 Service 层。 func (r *ExpenseRepo) UpsertGlobal(ctx context.Context, tx *gorm.DB, row *model.UserExpense) error { if err := requireTx(tx, "UpsertGlobal"); err != nil { return err } if row == nil || strings.TrimSpace(row.UserID) == "" { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpsertGlobal 需要 user_id")) } if err := normalizeExpenseAmounts(&row.Balance, &row.Spend, &row.TotalIncome, &row.TotalExpenditure, &row.TotalRecommendedAmount, &row.SpecialExpenses); err != nil { return err } err := tx.WithContext(ctxOrBackground(ctx)). Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}}, DoNothing: true}). Create(row).Error if err != nil { return infraerrors.WithCause(infraerrors.ErrDBError, err) } return nil } // UpsertCampus 建校区账户(**不动已有余额**,UPSERT 语义;服务端补齐作用域机构)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**); // - row 账户行(UserID 非空;InstitutionID=0 时用请求作用域补齐;CampusID 允许 0=机构级)。 // // 出参:nil 成功;ErrBadParam / ErrUnscoped(作用域缺失无法补齐机构)/ ErrDBError。 // // SQL:`INSERT ... ON CONFLICT(user_id, institution_id, campus_id) DO NOTHING` // (UK uk_user_inst_campus 兜底并发建户;冲突不覆盖余额,理由同 UpsertGlobal)。 func (r *ExpenseRepo) UpsertCampus(ctx context.Context, tx *gorm.DB, row *model.UserExpenseSchool) error { if err := requireTx(tx, "UpsertCampus"); err != nil { return err } if row == nil || strings.TrimSpace(row.UserID) == "" { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpsertCampus 需要 user_id")) } if row.InstitutionID == 0 { inst, _ := r.GetScopedID() if inst <= 0 { return infraerrors.WithCause(infraerrors.ErrUnscoped, errString("repository: UpsertCampus 缺少 institution_id 且作用域无机构")) } row.InstitutionID = inst } if row.CampusID < 0 { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: campus_id 不得为负(0=机构级)")) } if err := normalizeExpenseAmounts(&row.Balance, &row.Spend, &row.TotalIncome, &row.TotalExpenditure, &row.TotalRecommendedAmount, &row.SpecialExpenses); err != nil { return err } err := tx.WithContext(ctxOrBackground(ctx)). Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "user_id"}, {Name: "institution_id"}, {Name: "campus_id"}}, DoNothing: true, }). Create(row).Error if err != nil { return infraerrors.WithCause(infraerrors.ErrDBError, err) } return nil } // normalizeExpenseAmounts 把空金额列补成 "0.00",非法金额 → ErrBadParam。 // // 入参:待写入行的 6 个金额列地址(全局/校区两张表列名一致,复用同一归一路径)。 // 出参:nil 合法;ErrBadParam(格式非法)。 // // 说明:仅做**格式校验 + 空值补齐**,不做四舍五入/截断(金额语义由 Service 负责)。 func normalizeExpenseAmounts(fields ...*string) error { for _, f := range fields { if f == nil { continue } if strings.TrimSpace(*f) == "" { *f = "0.00" continue } if _, err := money.Parse(*f); err != nil { return infraerrors.WithCause(infraerrors.ErrBadParam, err) } } return nil } // AddBalance 余额原子增减(`balance = balance + ?`)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**:全局 + 校区双写必须同事务); // - userID 用户(ULID;空 → ErrBadParam); // - campusID 校区 ID:**>0 → 全局账户 + 该校区账户双写**(固定顺序「全局→校区」防死锁); // **==0 → 只更新全局账户**(全局钱包主口径); // - delta 增量(正=入账,负=出账;0 → 空操作返回 0)。 // // 出参: // - int64:**全局账户**受影响行数(1=成功;0 且无错=delta 为 0); // - ErrAccountNotFound(账户未建户)/ ErrBadParam / ErrDBError。 // // 红线: // - 一律 `balance = balance + ?` 原子表达式,**禁止**先 SELECT 再 UPDATE(并发丢更新); // - 与流水写入(InsertLog)必须同事务,否则出现"流水有、余额没变"; // - 机构级校区账户(campus_id=0 行)不在本方法范围内(与全局账户的 0 值语义冲突, // 需要时由 Service 走独立的校区账户增减接口,已在 AC-03 交接中标注)。 func (r *ExpenseRepo) AddBalance(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) { return r.addColumnAtomic(ctx, tx, "AddBalance", expenseColBalance, userID, campusID, delta) } // AddSpend 上课消费课费单价之和原子累加(`spend = spend + ?`,只增)。 // // 入参/出参/事务语义同 AddBalance。红线:**本列不动 balance**(dump 的 spend SP 只统计口径), // 真正扣费走 AddBalance,混用会造成余额与统计双错。 func (r *ExpenseRepo) AddSpend(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) { return r.addColumnAtomic(ctx, tx, "AddSpend", expenseColSpend, userID, campusID, delta) } // AddSpecialExpense 特殊费用原子累加(`special_expenses = special_expenses + ?`)。 // // 使用场景:坏账处理 / 余额清零 / 充值活动提前退费差额(只增不减,对账口径列)。 // 入参/出参/事务语义同 AddBalance。 func (r *ExpenseRepo) AddSpecialExpense(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) { return r.addColumnAtomic(ctx, tx, "AddSpecialExpense", expenseColSpecialExpenses, userID, campusID, delta) } // AddTotalIncome 总充值原子累加(`total_income = total_income + ?`)。 // // 使用场景:充值 +、退费 −、清零不动、转结双向。入参/出参/事务语义同 AddBalance。 func (r *ExpenseRepo) AddTotalIncome(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) { return r.addColumnAtomic(ctx, tx, "AddTotalIncome", expenseColTotalIncome, userID, campusID, delta) } // AddTotalExpenditure 总消费原子累加(`total_expenditure = total_expenditure + ?`)。 // // 使用场景:消费 +、退费 −、转结双向调整(与 t_user_expense_school 同名列须同事务,本方法已双写)。 // 入参/出参/事务语义同 AddBalance。 func (r *ExpenseRepo) AddTotalExpenditure(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) { return r.addColumnAtomic(ctx, tx, "AddTotalExpenditure", expenseColTotalExpenditure, userID, campusID, delta) } // addColumnAtomic 单列原子增减收口(5 个 Add* 方法的唯一实现,column 来自白名单常量)。 // // 入参: // - ctx/tx:请求上下文 + **必传**事务句柄; // - op:调用方方法名(仅用于错误文案定位); // - column:列名(**只接受本文件 expenseCol* 常量**,内部白名单校验,防列名注入); // - userID/campusID/delta:同 AddBalance。 // // 出参:(全局受影响行数, error)。campusID>0 时:先全局后校区,任一 0 行 → ErrAccountNotFound。 // // SQL 形状(必须保持"表达式 + 参数",不得退化为读-改-写): // // UPDATE t_user_expense SET = + ? WHERE user_id = ? // UPDATE t_user_expense_school SET = + ? WHERE user_id = ? AND campus_id = ? // [AND institution_id = ?] -- Scoped 注入(普通角色) func (r *ExpenseRepo) addColumnAtomic(ctx context.Context, tx *gorm.DB, op, column, userID string, campusID int64, delta money.Amount) (int64, error) { if err := requireTx(tx, op); err != nil { return 0, err } if !isExpenseColumn(column) { return 0, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: 非法列名 "+column)) } if strings.TrimSpace(userID) == "" { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: "+op+" 需要 user_id")) } if delta.IsZero() { return 0, nil } lit, err := amountLiteral(delta) if err != nil { return 0, err } db := tx.WithContext(ctxOrBackground(ctx)) // ① 全局钱包(固定先全局,后校区:两事务并发时锁顺序一致,避免死锁) global := db.Model(&model.UserExpense{}). Where("user_id = ?", userID). UpdateColumn(column, gorm.Expr(column+" + ?", lit)) if global.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, global.Error) } if global.RowsAffected == 0 { return 0, infraerrors.WithCause(ErrAccountNotFound, errString("repository: "+op+" 全局账户未建户")) } // ② 校区账户(仅 campusID>0;普通角色的机构/校区过滤由 Scoped 强制) if campusID > 0 { campus := r.Scoped(db.Model(&model.UserExpenseSchool{})). Where("user_id = ? AND campus_id = ?", userID, campusID). UpdateColumn(column, gorm.Expr(column+" + ?", lit)) if campus.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, campus.Error) } if campus.RowsAffected == 0 { return 0, infraerrors.WithCause(ErrAccountNotFound, errString("repository: "+op+" 校区账户未建户(user_id/campus_id 不匹配)")) } } return global.RowsAffected, nil } // isExpenseColumn 列名白名单校验(只接受本文件 expenseCol* 常量 + `total_recommended_amount`)。 // // `total_recommended_amount` 由 AC-08 老带新结算引入(常量定义见 expense_repo_recommend.go), // 必须与 `balance` 同一语句双增(dump 763344/763351)。 func isExpenseColumn(column string) bool { switch column { case expenseColBalance, expenseColSpend, expenseColSpecialExpenses, expenseColTotalIncome, expenseColTotalExpenditure, ExpenseColumnTotalRecommendedAmount: return true default: return false } } // GetForUpdate 取全局钱包账户并加行锁(**退费/清零前必调**,防超退)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**:行锁只在事务内有效); // - userID 用户(ULID;空 → ErrBadParam)。 // // 出参: // - *model.UserExpense:账户行(金额为字符串原文,调用方用 money.Parse 转 Amount); // - ErrNotFound(账户不存在)/ ErrBadParam / ErrDBError。 // // SQL:`SELECT * FROM t_user_expense WHERE user_id = ? LIMIT 1 FOR UPDATE`(MySQL 渲染; // sqlite 单测栈不渲染行锁,但 clause 已挂载,见单测断言口径)。 // // 平台级数据(无作用域列),越权断言在 Service。 func (r *ExpenseRepo) GetForUpdate(ctx context.Context, tx *gorm.DB, userID string) (*model.UserExpense, error) { if err := requireTx(tx, "GetForUpdate"); err != nil { return nil, err } if strings.TrimSpace(userID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetForUpdate 需要 user_id")) } var out model.UserExpense err := tx.WithContext(ctxOrBackground(ctx)). Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}). Where("user_id = ?", userID). Take(&out).Error if err != nil { return nil, wrapQueryErr(err, "GetForUpdate(t_user_expense)") } return &out, nil } // GetCampusForUpdate 取校区账户并加行锁(校区退费/清零前必调)。 // // 入参:ctx / tx(**必传**)/ userID / campusID(>=0;0=机构级账户)。 // 出参:*model.UserExpenseSchool + ErrNotFound/ErrBadParam/ErrDBError; // 普通角色跨机构 → ErrNotFound(Scoped 过滤,不泄露存在性)。 // // SQL:`SELECT * FROM t_user_expense_school WHERE user_id=? AND campus_id=? [AND institution_id=?] // LIMIT 1 FOR UPDATE`。 func (r *ExpenseRepo) GetCampusForUpdate(ctx context.Context, tx *gorm.DB, userID string, campusID int64) (*model.UserExpenseSchool, error) { if err := requireTx(tx, "GetCampusForUpdate"); err != nil { return nil, err } if strings.TrimSpace(userID) == "" || campusID < 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetCampusForUpdate 需要 user_id 且 campus_id >= 0")) } var out model.UserExpenseSchool q := r.Scoped(tx.WithContext(ctxOrBackground(ctx))) err := q.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}). Where("user_id = ? AND campus_id = ?", userID, campusID). Take(&out).Error if err != nil { return nil, wrapQueryErr(err, "GetCampusForUpdate(t_user_expense_school)") } return &out, nil } // GetBalance 只读查询余额(**无行锁**,列表/详情展示用)。 // // 入参:ctx 请求上下文;userID(空 → ErrBadParam);campusID(**0 = 查全局钱包**,>0 = 查该校区账户)。 // 出参: // - money.Amount:余额(分;账户不存在返回 0 且 err = ErrNotFound); // - ErrNotFound / ErrBadParam / ErrDBError。 // // 红线:**不得**用本方法的结果做"够不够扣"的判断再扣费(读-改-写竞态), // 资金判定必须在事务内用 GetForUpdate / GetCampusForUpdate 的加锁读。 func (r *ExpenseRepo) GetBalance(ctx context.Context, userID string, campusID int64) (money.Amount, error) { if strings.TrimSpace(userID) == "" { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetBalance 需要 user_id")) } db, err := r.db(ctx) if err != nil { return 0, err } var raw string if campusID > 0 { var row model.UserExpenseSchool q := r.Scoped(db.Model(&model.UserExpenseSchool{})) if err := q.Where("user_id = ? AND campus_id = ?", userID, campusID). Select("balance").Take(&row).Error; err != nil { return 0, wrapQueryErr(err, "GetBalance(t_user_expense_school)") } raw = row.Balance } else { var row model.UserExpense if err := db.Model(&model.UserExpense{}).Where("user_id = ?", userID). Select("balance").Take(&row).Error; err != nil { return 0, wrapQueryErr(err, "GetBalance(t_user_expense)") } raw = row.Balance } amount, perr := money.Parse(raw) if perr != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr) } return amount, nil } // InsertLog 写入余额变更流水(**只增不改**;幂等键 request_id+type)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**:必须与余额增减同事务); // - log 流水行(UserID 非空、Type != 0、Money 合法;RequestID 为幂等键,历史数据可 NULL)。 // // 出参: // - nil:首次写入成功; // - ErrDuplicateLog(CodeUnique 3003):撞 uk_request_type,**属幂等命中**, // Service 捕获 `stderrors.Is(err, repository.ErrDuplicateLog)` 后返回首次结果(不得抛 5002); // - ErrBadParam / ErrDBError。 // // 平台级数据(流水无作用域列),越权断言在 Service。 func (r *ExpenseRepo) InsertLog(ctx context.Context, tx *gorm.DB, log *model.UserExpenseLog) error { if err := requireTx(tx, "InsertLog"); err != nil { return err } if log == nil || strings.TrimSpace(log.UserID) == "" { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: InsertLog 需要 user_id")) } if log.Type == 0 { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: InsertLog 需要 type")) } if err := normalizeExpenseAmounts(&log.Money); err != nil { return err } if log.BalanceAfter != nil { if err := normalizeExpenseAmounts(log.BalanceAfter); err != nil { return err } } err := tx.WithContext(ctxOrBackground(ctx)).Create(log).Error if err != nil { if IsDupKey(err) { // 幂等命中:同一 request_id + type 重复提交 → 返回 sentinel,由 Service 决定"返回首次结果" return infraerrors.WithCause(ErrDuplicateLog, err) } return infraerrors.WithCause(infraerrors.ErrDBError, err) } return nil } // AddColumnsGlobalThenCampus 多列原子增减(**一张表一条 UPDATE**,固定顺序「全局 → 校区」)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**:两张账户表必须同事务,否则出现"全局扣了、校区没扣"); // - op 操作名(错误文案定位,如 "Recharge"); // - userID 用户(ULID;空 → ErrBadParam); // - institutionID 机构 ID(**必填 >0**:校区账户必须按机构精确过滤,防跨机构同 campus_id 误更新 // ——设计 §3.1 #4 BUG#2 的同类缺陷,本方法把机构过滤做成硬参数); // - campusID 校区 ID(>0 时同时更新校区账户;==0 只更新全局钱包); // - deltas 列增量(如 `{balance: -refunded}, {total_income: -refunded}`)。 // // 出参: // - int64:全局账户受影响行数(>0 正常;0 且无错 = 全部增量为 0 的 no-op); // - ErrAccountNotFound(账户未建户,调用顺序错误)/ ErrBadParam / ErrInternal / ErrDBError。 // // SQL 形状(**必须保持"表达式 + 参数"**,禁读-改-写): // // UPDATE t_user_expense SET balance = balance + ?, total_income = total_income + ? // WHERE user_id = ? // UPDATE t_user_expense_school SET balance = balance + ?, total_income = total_income + ? // WHERE user_id = ? AND institution_id = ? AND campus_id = ? // // 为什么两列合成一条 UPDATE:dump 的 SP 每条 UPDATE 都同时改 2~3 列, // 拆成多条会让"余额已减、总充值未减"的中间态被并发对账/双跑读到。 func (r *ExpenseRepo) AddColumnsGlobalThenCampus( ctx context.Context, tx *gorm.DB, op, userID string, institutionID, campusID int64, deltas ...ColumnDelta, ) (int64, error) { if err := requireTx(tx, op); err != nil { return 0, err } if strings.TrimSpace(userID) == "" { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: "+op+" 需要 user_id")) } if institutionID <= 0 || campusID < 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: "+op+" 需要 institution_id>0 且 campus_id>=0")) } updates, err := buildColumnExprs(op, deltas) if err != nil { return 0, err } if len(updates) == 0 { return 0, nil } db := tx.WithContext(ctxOrBackground(ctx)) // ① 全局钱包(固定先全局,后校区:并发时锁顺序一致,避免死循环等待) global := db.Model(&model.UserExpense{}). Where("user_id = ?", userID). UpdateColumns(updates) if global.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, global.Error) } if global.RowsAffected == 0 { return 0, infraerrors.WithCause(ErrAccountNotFound, errString("repository: "+op+" 全局账户未建户")) } // ② 校区账户(campusID>0 才写;机构显式过滤,不叠 Sc.CampusID 等值——见 institutionScoped) if campusID > 0 { campus := r.institutionScoped(db.Model(&model.UserExpenseSchool{})). Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID). UpdateColumns(updates) if campus.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, campus.Error) } if campus.RowsAffected == 0 { return 0, infraerrors.WithCause(ErrAccountNotFound, errString("repository: "+op+" 校区账户未建户(user_id/institution_id/campus_id 不匹配)")) } } return global.RowsAffected, nil } // AddCampusColumns **只**更新校区账户的多列原子增减(不动全局钱包)。 // // 使用场景(唯一):`sp_user_expense_school_transfer_expenditure` 课费转结—— // dump 明确**不动 balance**,只在两个校区账户上调整 `total_expenditure` / `total_income` // (转出方各减、转入方各加);全局钱包不参与,故不能用 AddColumnsGlobalThenCampus。 // // 入参/出参:同 AddColumnsGlobalThenCampus(institutionID 必填 >0,campusID 必填 >0)。 // // SQL:`UPDATE t_user_expense_school SET WHERE user_id=? AND institution_id=? AND campus_id=?` func (r *ExpenseRepo) AddCampusColumns( ctx context.Context, tx *gorm.DB, op, userID string, institutionID, campusID int64, deltas ...ColumnDelta, ) (int64, error) { if err := requireTx(tx, op); err != nil { return 0, err } if strings.TrimSpace(userID) == "" { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: "+op+" 需要 user_id")) } if institutionID <= 0 || campusID <= 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: "+op+" 需要 institution_id>0 且 campus_id>0")) } updates, err := buildColumnExprs(op, deltas) if err != nil { return 0, err } if len(updates) == 0 { return 0, nil } db := tx.WithContext(ctxOrBackground(ctx)) res := r.institutionScoped(db.Model(&model.UserExpenseSchool{})). Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID). UpdateColumns(updates) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } if res.RowsAffected == 0 { return 0, infraerrors.WithCause(ErrAccountNotFound, errString("repository: "+op+" 校区账户未建户(user_id/institution_id/campus_id 不匹配)")) } return res.RowsAffected, nil } // GetGlobalAccount 读全局钱包账户整行(**无行锁**,只用于写后快照/结果回读)。 // // 入参:ctx / tx(可 nil:nil 时用连接池或 ctx 内事务)/ userID(空 → ErrBadParam)。 // 出参:*model.UserExpense(金额为 decimal 字符串原文)+ ErrNotFound / ErrBadParam / ErrDBError。 // // 红线:**不得**用本方法的余额做"够不够扣"的判断(读-改-写竞态), // 资金判定必须用 GetForUpdate(加锁读)。 func (r *ExpenseRepo) GetGlobalAccount(ctx context.Context, tx *gorm.DB, userID string) (*model.UserExpense, error) { if strings.TrimSpace(userID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetGlobalAccount 需要 user_id")) } db, err := r.queryDB(ctx, tx) if err != nil { return nil, err } var out model.UserExpense if err := db.Model(&model.UserExpense{}).Where("user_id = ?", userID).Take(&out).Error; err != nil { return nil, wrapQueryErr(err, "GetGlobalAccount(t_user_expense)") } return &out, nil } // GetCampusAccount 读校区账户整行(**无行锁**,写后快照/结果回读用)。 // // 入参:ctx / tx(可 nil)/ userID / institutionID(必填 >0)/ campusID(>=0;0=机构级账户)。 // 出参:*model.UserExpenseSchool + ErrNotFound / ErrBadParam / ErrDBError。 // // 作用域:机构过滤强制(institutionScoped,超管除外);**不叠 Sc.CampusID 等值**, // 因为本方法用于"结果快照",入参已精确指定机构 + 校区(比叠加更严格,见 institutionScoped)。 func (r *ExpenseRepo) GetCampusAccount( ctx context.Context, tx *gorm.DB, userID string, institutionID, campusID int64, ) (*model.UserExpenseSchool, error) { if strings.TrimSpace(userID) == "" || institutionID <= 0 || campusID < 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetCampusAccount 需要 user_id 且 institution_id>0 且 campus_id>=0")) } db, err := r.queryDB(ctx, tx) if err != nil { return nil, err } var out model.UserExpenseSchool q := r.institutionScoped(db.Model(&model.UserExpenseSchool{})) if err := q.Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID). Take(&out).Error; err != nil { return nil, wrapQueryErr(err, "GetCampusAccount(t_user_expense_school)") } return &out, nil } // GetCampusForUpdateExact 取指定机构+校区账户并加行锁(**跨校区操作专用**)。 // // 与 GetCampusForUpdate 的唯一区别:**不叠 `Sc.CampusID` 等值过滤**(只叠机构)。 // // 为什么需要它:课费转结必然同时访问两个校区账户(源/目标)。若用 GetCampusForUpdate, // 操作者作用域里的 `Sc.CampusID` 会把另一个校区的行过滤掉 → 转结永远失败; // 而"转结必须在两个校区之间发生"是业务前提,不能用等值过滤把它排除。 // // 安全边界:机构过滤强制 + 入参显式 campus_id 精确命中(跨机构转结需超管作用域)。 // // 出参:*model.UserExpenseSchool + ErrNotFound / ErrBadParam / ErrDBError。 func (r *ExpenseRepo) GetCampusForUpdateExact( ctx context.Context, tx *gorm.DB, userID string, institutionID, campusID int64, ) (*model.UserExpenseSchool, error) { if err := requireTx(tx, "GetCampusForUpdateExact"); err != nil { return nil, err } if strings.TrimSpace(userID) == "" || institutionID <= 0 || campusID < 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetCampusForUpdateExact 需要 user_id 且 institution_id>0 且 campus_id>=0")) } db := tx.WithContext(ctxOrBackground(ctx)) var out model.UserExpenseSchool q := r.institutionScoped(db.Model(&model.UserExpenseSchool{})) err := q.Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID). Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}). Take(&out).Error if err != nil { return nil, wrapQueryErr(err, "GetCampusForUpdateExact(t_user_expense_school)") } return &out, nil } // queryDB 选择查询句柄(tx 优先 → ctx 内事务 → 连接池)。 // // 入参:ctx 请求上下文(可 nil);tx 事务句柄(可 nil)。 // 出参:(*gorm.DB, error);未装配 DB → ErrInternal。 func (r *ExpenseRepo) queryDB(ctx context.Context, tx *gorm.DB) (*gorm.DB, error) { if tx != nil { return tx.WithContext(ctxOrBackground(ctx)), nil } return r.db(ctx) } // FindLogByRequest 按幂等键 `(request_id, type)` 查既有流水(幂等预检/命中回放)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**可 nil**:读方法,nil 时走 ctx 内事务或连接池); // - userID 用户(ULID;空 → ErrBadParam); // - requestID 幂等请求号(**空串 → 返回 (nil, nil),表示该请求不享幂等**,设计 §1.5「缺省」); // - logType 流水类型(与 request_id 组成唯一键 uk_request_type)。 // // 出参: // - *model.UserExpenseLog:命中行;未命中返回 **(nil, nil)**(不是 ErrNotFound——预检语义是"有没有"); // - ErrBadParam / ErrDBError。 // // 唯一键口径:`uk_request_type(request_id, type)`;MySQL 唯一键对 NULL 放行, // 因此历史行(request_id IS NULL)永不命中本方法(符合设计 §1.5:NULL 不享幂等)。 func (r *ExpenseRepo) FindLogByRequest( ctx context.Context, tx *gorm.DB, userID, requestID string, logType int32, ) (*model.UserExpenseLog, error) { if strings.TrimSpace(userID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindLogByRequest 需要 user_id")) } if strings.TrimSpace(requestID) == "" { return nil, nil } db, err := r.queryDB(ctx, tx) if err != nil { return nil, err } var out model.UserExpenseLog takeErr := db.Model(&model.UserExpenseLog{}). Where("user_id = ? AND request_id = ? AND type = ?", userID, requestID, logType). Take(&out).Error if takeErr != nil { if errors.Is(takeErr, gorm.ErrRecordNotFound) { return nil, nil } return nil, infraerrors.WithCause(infraerrors.ErrDBError, takeErr) } return &out, nil } // ListLogs 分页查流水(按创建时间倒序,时间窗可空)。 // // 入参: // - ctx 请求上下文; // - userID 用户(ULID;空 → ErrBadParam;流水表无作用域列 → 越权断言在 Service); // - campusIDs **必须为空**:t_user_expense_log 无 campus_id 列(DDL 第二部分「3.」), // 传非空值一律 ErrBadParam —— 明确拒绝优于"看起来筛了其实没筛"的静默越权; // 校区维度流水视图需 Service 侧 join t_user_expense_school 后叠加(不在本方法职责内); // - start/end 创建时间窗(零值 = 不限;两端**闭区间**); // - page 分页(PageQuery;内部 ClampPage:pageSize ∈ [1,200])。 // // 出参: // - []model.UserExpenseLog:**空结果返回长度 0 的非 nil 切片**(JSON 序列化为 [] 而非 null); // - ErrBadParam / ErrDBError。 func (r *ExpenseRepo) ListLogs(ctx context.Context, userID string, campusIDs []int64, start, end time.Time, page PageQuery) ([]model.UserExpenseLog, error) { if strings.TrimSpace(userID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListLogs 需要 user_id")) } if len(campusIDs) > 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListLogs 不支持 campus_id 过滤(t_user_expense_log 无该列)")) } db, err := r.db(ctx) if err != nil { return nil, err } q := db.Model(&model.UserExpenseLog{}).Where("user_id = ?", userID) if !start.IsZero() { q = q.Where("create_time >= ?", start) } if !end.IsZero() { q = q.Where("create_time <= ?", end) } pageSize := page.Limit() rows := make([]model.UserExpenseLog, 0, pageSize) err = q.Order("create_time DESC").Order("id DESC"). Limit(pageSize).Offset(page.Offset()). Find(&rows).Error if err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } if rows == nil { rows = make([]model.UserExpenseLog, 0) } return rows, nil } // SumLogMoney 汇总流水金额(对账用;**Go 侧定点累加**)。 // // 入参: // - ctx 请求上下文; // - userID 用户(ULID;空 → ErrBadParam); // - campusIDs **必须为空**(理由同 ListLogs:流水表无 campus_id 列); // - types 类型白名单(空 = 全部类型;非空时 `type IN (...)`)。 // // 出参: // - money.Amount:金额合计(分;正负按流水方向自然累加;无数据为 0); // - ErrBadParam / ErrDBError。 // // 红线:**禁止** `SELECT SUM(money)` —— MySQL 会把 decimal 求和结果转 REAL(浮点), // 与 money.Amount 的定点口径冲突;本方法 Sql 只取原始字符串,累加在 Go 侧 `money.Parse` 后完成。 func (r *ExpenseRepo) SumLogMoney(ctx context.Context, userID string, campusIDs []int64, types []int) (money.Amount, error) { if strings.TrimSpace(userID) == "" { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SumLogMoney 需要 user_id")) } if len(campusIDs) > 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SumLogMoney 不支持 campus_id 过滤(t_user_expense_log 无该列)")) } db, err := r.db(ctx) if err != nil { return 0, err } q := db.Model(&model.UserExpenseLog{}).Where("user_id = ?", userID) if len(types) > 0 { q = q.Where("type IN ?", types) } var rows []model.UserExpenseLog if err := q.Select("money").Find(&rows).Error; err != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } total := money.Amount(0) for i := range rows { amount, perr := money.Parse(rows[i].Money) if perr != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr) } total = total.Add(amount) } return total, nil } // ---------------------------------------------------------------- AC-12 查询原语(端点 1 / 11 / 21) // LogFilter 流水筛选条件(端点 11 `GET /admin/finance/logs`)。 // // 字段语义(nil/空 = 不筛,与"探针为零值即不过滤"的既有 filter 惯例一致): // - UserID string:为空表示**跨用户**(机构内全量;超管跨机构)——这是财务查账的主场景; // - Type *int32:流水类型(model.ExpenseLogType*);用指针区分"未传"与"传了 0"; // - From/To *time.Time:`create_time` 闭区间(§3.8-E2:`create_time` 是**业务时间**,参与筛选)。 // // 红线:`t_user_expense_log` **无** institution_id / campus_id 列(§3.8-f 约束 2), // 因此"跨用户"查询**无法**在本方法里做机构过滤 —— 越权由 Service 层断言 // (财务角色只见本机构用户;实现见 service.FinanceQueryService.ListLogs)。 type LogFilter struct { // UserID 用户(ULID;空 = 跨用户)。 UserID string // Type 流水类型(nil = 不筛)。 Type *int32 // From 起始时间(闭区间下界;nil = 不筛)。 From *time.Time // To 结束时间(闭区间上界;nil = 不筛)。 To *time.Time } // ListLogsFiltered 分页查流水(带类型/时间/用户筛选,附总条数)。 // // 入参:ctx;f LogFilter;page PageQuery(内部钳制 pageSize ∈ [1,200])。 // 出参: // - []model.UserExpenseLog:`create_time DESC, id DESC`(倒序,只增表);空结果非 nil 切片; // - int64:满足筛选条件的总条数(分页器用); // - ErrDBError。 // // 为什么新开一个方法而不是改 ListLogs:`ListLogs` 是 AC-03 的既有契约(无 type 过滤、 // 不返回 total、且对 campusIDs 非空显式 ErrBadParam),改签名会波及既有调用与用例。 // 本方法是"财务查账"专用视图,两者共存、互不复用。 func (r *ExpenseRepo) ListLogsFiltered( ctx context.Context, f LogFilter, page PageQuery, ) ([]model.UserExpenseLog, int64, error) { if r == nil || r.DB == nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ListLogsFiltered 未装配 DB")) } db, err := r.queryDB(ctx, nil) if err != nil { return nil, 0, err } base := db.Model(&model.UserExpenseLog{}) if strings.TrimSpace(f.UserID) != "" { base = base.Where("user_id = ?", strings.TrimSpace(f.UserID)) } if f.Type != nil { base = base.Where("type = ?", *f.Type) } if f.From != nil { base = base.Where("create_time >= ?", *f.From) } if f.To != nil { base = base.Where("create_time <= ?", *f.To) } var total int64 if err := base.Count(&total).Error; err != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } pageSize := page.Limit() rows := make([]model.UserExpenseLog, 0, pageSize) qErr := base.Order("create_time DESC").Order("id DESC"). Limit(pageSize).Offset(page.Offset()). Find(&rows).Error if qErr != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, qErr) } if rows == nil { rows = make([]model.UserExpenseLog, 0) } return rows, total, nil } // CampusAccountAgg 校区账户聚合结果(端点 21 仪表盘的"余额分布")。 // // 全部金额为 `money.Amount`(**Go 侧定点累加**,禁止 SQL SUM(decimal),见本文件头金额纪律)。 type CampusAccountAgg struct { // Accounts 校区账户行数。 Accounts int64 // Balance 校区余额合计(元;含负数欠费账户,故可能为负)。 Balance money.Amount // TotalIncome 累计充值合计(元)。 TotalIncome money.Amount // TotalExpenditure 累计消费合计(元)。 TotalExpenditure money.Amount // SpecialExpenses 特殊费用合计(元)。 SpecialExpenses money.Amount } // AggregateCampusAccounts 聚合本机构(可选指定校区)的校区账户累计列(端点 21)。 // // 入参: // - ctx 请求上下文; // - institutionID 机构 ID(>0;**普通角色必须与作用域一致**,此处只做机构过滤 + 越权断言交由 Service); // - campusID 校区 ID(>0 时只聚合该校区;0 = 全机构所有校区)。 // // 出参:CampusAccountAgg;ErrBadParam(institutionID<=0)/ ErrDBError。 // // 作用域:`institutionScoped`(机构强制,超管除外;**不叠** `Sc.CampusID` 等值, // 由入参 campusID 显式指定,理由同转结/跨校区场景)。 // // ⚠ 口径边界(必须知道):`t_user_expense_log` 无 campus 列 ⇒ **期间流水无法按校区聚合**(§3.8-f 约束 2)。 // 本方法返回的是"账户累计列"(total_income / total_expenditure / balance), // 不是"区间发生额";from/to 的区间口径在 Service 层以"累计列快照"呈现(端点 21 为 P2,已登记)。 func (r *ExpenseRepo) AggregateCampusAccounts( ctx context.Context, institutionID, campusID int64, ) (CampusAccountAgg, error) { if institutionID <= 0 { return CampusAccountAgg{}, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: AggregateCampusAccounts 需要 institution_id(>0)")) } db, err := r.queryDB(ctx, nil) if err != nil { return CampusAccountAgg{}, err } q := r.institutionScoped(db.Model(&model.UserExpenseSchool{})). Where("institution_id = ?", institutionID) if campusID > 0 { q = q.Where("campus_id = ?", campusID) } var rows []model.UserExpenseSchool if err := q.Select("balance", "total_income", "total_expenditure", "special_expenses").Find(&rows).Error; err != nil { return CampusAccountAgg{}, infraerrors.WithCause(infraerrors.ErrDBError, err) } agg := CampusAccountAgg{Accounts: int64(len(rows))} for i := range rows { balance, perr := money.Parse(rows[i].Balance) if perr != nil { return CampusAccountAgg{}, infraerrors.WithCause(infraerrors.ErrDBError, perr) } income, perr := money.Parse(rows[i].TotalIncome) if perr != nil { return CampusAccountAgg{}, infraerrors.WithCause(infraerrors.ErrDBError, perr) } expenditure, perr := money.Parse(rows[i].TotalExpenditure) if perr != nil { return CampusAccountAgg{}, infraerrors.WithCause(infraerrors.ErrDBError, perr) } special, perr := money.Parse(rows[i].SpecialExpenses) if perr != nil { return CampusAccountAgg{}, infraerrors.WithCause(infraerrors.ErrDBError, perr) } agg.Balance = agg.Balance.Add(balance) agg.TotalIncome = agg.TotalIncome.Add(income) agg.TotalExpenditure = agg.TotalExpenditure.Add(expenditure) agg.SpecialExpenses = agg.SpecialExpenses.Add(special) } return agg, nil } // ListCampusBalances 取本机构(可选指定校区)各校区账户的余额列表(端点 21 `balanceDistribution`)。 // // 入参:ctx;institutionID(>0);campusID(>0 只取该校区;0 = 全部校区)。 // 出参:[]CampusBalanceRow(按 campus_id ASC);ErrBadParam / ErrDBError。 // // 为什么这个能算而"期间收支"不能算:`t_user_expense_school` **有** institution_id/campus_id // (可聚合),而 `t_user_expense_log` **没有**(§3.8-f 约束 2)—— // 这正是契约 v1.5 删掉 `periodIncome/periodExpense/net` 却保留 `balanceDistribution` 的原因。 type CampusBalanceRow struct { // CampusID 校区 ID。 CampusID int64 // Balance 余额(分;Go 侧定点解析原始 decimal 字符串)。 Balance money.Amount } // ListCampusBalances 见类型注释;金额在 Go 侧定点解析(禁止 SQL SUM(decimal))。 func (r *ExpenseRepo) ListCampusBalances( ctx context.Context, institutionID, campusID int64, ) ([]CampusBalanceRow, error) { if institutionID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListCampusBalances 需要 institution_id(>0)")) } db, err := r.queryDB(ctx, nil) if err != nil { return nil, err } q := r.institutionScoped(db.Model(&model.UserExpenseSchool{})). Where("institution_id = ?", institutionID) if campusID > 0 { q = q.Where("campus_id = ?", campusID) } var rows []model.UserExpenseSchool if qErr := q.Select("campus_id", "balance").Order("campus_id ASC").Find(&rows).Error; qErr != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr) } out := make([]CampusBalanceRow, 0, len(rows)) for i := range rows { balance, perr := money.Parse(rows[i].Balance) if perr != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr) } out = append(out, CampusBalanceRow{CampusID: rows[i].CampusID, Balance: balance}) } return out, nil }