// 余额账户仓储(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" "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" ) // 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