// 余额变更工单仓储(t_user_balance_request)——AC-03 资金工单(充值/退费/坏账/取消活动/转结)。 // // 依据:unified/03-migration/academic-domain-design.md §2「4. t_user_balance_request」/ §3(审批驱动) // / §10.3(事务边界只在 Service)。 // // 工单是**资金变动唯一入口**:`approved` 由 0(未处理)→ 终态的审批动作才触发余额变动; // 因此本仓储的三个写方法都是"资金事务的参与者",一律接收调用方 tx(本文件不开事务)。 // // 并发防线: // - `UpdateApproved` 是 **CAS**(`WHERE approved = 0`)——重复审批/双人同时点通过时, // 第二次 affected=0,Service 据此返回幂等结果而不是重复入账; // - `GetForUpdate` 提供 `SELECT ... FOR UPDATE` 加锁读(审批前读旧值/校验金额用)。 // // 作用域:表带 institution_id + campus_id(作用域列)→ 继承 BaseRepo,普通角色强制本机构。 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" ) // ErrDuplicateBizNo 工单业务单号重复哨兵(撞 uk_biz_no)——属幂等命中,不是 5002。 // // 判定方式:`stderrors.Is(err, repository.ErrDuplicateBizNo)`。 var ErrDuplicateBizNo = infraerrors.New(infraerrors.CodeUnique, "重复的业务单号(biz_no 已存在)") // BalanceRequestRepo 余额变更工单仓储(多租户表,继承 BaseRepo 强制机构作用域)。 type BalanceRequestRepo struct { *BaseRepo } // NewBalanceRequestRepo 构造工单仓储。 // // 入参:db *gorm.DB(连接池句柄)/ sc *appscope.Scope(请求作用域;系统任务可 nil,一查询即 panic)。 // 出参:*BalanceRequestRepo(CampusScoped=true:表带 campus_id)。 func NewBalanceRequestRepo(db *gorm.DB, sc *appscope.Scope) *BalanceRequestRepo { r := &BalanceRequestRepo{BaseRepo: NewBaseRepo(db, sc)} r.CampusScoped = true return r } // db 取查询句柄(ctx 内含 tx 时复用同一事务)。 func (r *BalanceRequestRepo) db(ctx context.Context) (*gorm.DB, error) { if r == nil || r.DB == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: BalanceRequestRepo.DB 为 nil")) } if tx := TxFromContext(ctx); tx != nil { return tx, nil } if ctx == nil { return r.DB, nil } return r.DB.WithContext(ctx), nil } // GetForUpdate 按主键取工单并加行锁(审批前读旧值)。 // // 入参:ctx 请求上下文;tx 事务句柄(**必传**);id 工单 ID(<=0 → ErrBadParam)。 // 出参:*model.UserBalanceRequest + ErrNotFound(不存在或跨机构)/ ErrBadParam / ErrDBError。 // // SQL:`SELECT * FROM t_user_balance_request WHERE id=? [AND institution_id=?] LIMIT 1 FOR UPDATE`。 func (r *BalanceRequestRepo) GetForUpdate(ctx context.Context, tx *gorm.DB, id int64) (*model.UserBalanceRequest, error) { if err := requireTx(tx, "GetForUpdate(t_user_balance_request)"); err != nil { return nil, err } if id <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: balance request id 必须 > 0")) } var out model.UserBalanceRequest q := r.Scoped(tx.WithContext(ctxOrBackground(ctx))) err := q.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}). Where("id = ?", id).Take(&out).Error if err != nil { return nil, wrapQueryErr(err, "GetForUpdate(t_user_balance_request)") } return &out, nil } // Insert 创建工单(幂等键 biz_no)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**); // - row 工单行(BizNo 非空、Type != 0、UserID 非空;InstitutionID=0 时用作用域补齐)。 // // 出参: // - nil 成功; // - ErrDuplicateBizNo(biz_no 已存在 → 幂等命中); // - ErrBadParam / ErrUnscoped / ErrDBError。 // // 红线:`approved` 一律由 DB 默认 0(未处理)落库,**禁止**创建即通过(资金变动必须走审批链)。 func (r *BalanceRequestRepo) Insert(ctx context.Context, tx *gorm.DB, row *model.UserBalanceRequest) error { if err := requireTx(tx, "Insert(t_user_balance_request)"); err != nil { return err } if row == nil { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: balance request 为 nil")) } if strings.TrimSpace(row.BizNo) == "" || strings.TrimSpace(row.UserID) == "" || row.Type == 0 { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: balance request 需要 biz_no/user_id/type")) } if row.InstitutionID == 0 { inst, campus := r.GetScopedID() if inst <= 0 { return infraerrors.WithCause(infraerrors.ErrUnscoped, errString("repository: balance request 缺少 institution_id 且作用域无机构")) } row.InstitutionID = inst row.CampusID = campus } if err := normalizeExpenseAmounts(&row.Money); err != nil { return err } // approved 由调用方显式置 0(零值即未处理),此处不做覆盖以便"迁移搬入历史终态工单" err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error if err != nil { if IsDupKey(err) { return infraerrors.WithCause(ErrDuplicateBizNo, err) } return infraerrors.WithCause(infraerrors.ErrDBError, err) } return nil } // UpdateApproved 审批状态 CAS 推进(`WHERE approved = 0`,0→终态唯一入口)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**:资金变动 + 工单回写必须同事务); // - id 工单 ID(<=0 → ErrBadParam); // - to 目标状态(model.BalanceRequestApproved*:-2 取消 / -1 不通过 / 1 已通过); // - responseUserID 审批人(ULID;nil = 系统自动审批,落 NULL); // - responseTime 审批时间(nil = 不写,由调用方决定;资金口径要求非 nil 见 Service 校验)。 // // 出参: // - int64:受影响行数(**1=本次抢到审批权**;0=已被处理过/不存在/跨机构 → Service 做幂等返回); // - ErrBadParam / ErrDBError。 // // SQL(单语句 CAS):`UPDATE t_user_balance_request SET approved=?, response_user_id=?, response_time=? // WHERE id=? AND approved=0 [AND institution_id=?]`。 // // 红线:CAS 的 `approved = 0` 条件**不可省**(否则双人同时点"通过"会重复入账); // 金额/类型**不允许**在本方法内变更(改金额须新建工单)。 func (r *BalanceRequestRepo) UpdateApproved(ctx context.Context, tx *gorm.DB, id int64, to int8, responseUserID *string, responseTime *time.Time) (int64, error) { if err := requireTx(tx, "UpdateApproved"); err != nil { return 0, err } if id <= 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateApproved 需要 id")) } switch to { case model.BalanceRequestApprovedCancelled, model.BalanceRequestApprovedRejected, model.BalanceRequestApprovedPassed: default: return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateApproved 目标状态非法(只允许 -2/-1/1)")) } updates := map[string]any{ "approved": to, "response_user_id": responseUserID, // nil → 写 NULL } if responseTime != nil { updates["response_time"] = *responseTime } q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.UserBalanceRequest{})) res := q.Where("id = ? AND approved = ?", id, model.BalanceRequestApprovedPending). Updates(updates) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } return res.RowsAffected, nil } // SumRechargeActivityUsedGift 汇总用户在某机构某校区「已使用的充值赠送金额」(SP5 第二步修正)。 // // 用途:`sp_user_expense_school_get_refundable_amount` 的可退额计算**第二步**—— // `refundable = max(min(校区余额, 全局余额) − 已用赠送额, 0)`(第一步的截断在 Service 层)。 // // dump 原文(yuansiwei_finance.sql:764421 起,游标段被 `/* */` 注释掉,实际只生效这条 SUM): // // SELECT SUM(t2.activity_money_used) // FROM t_user_balance_request t1 // LEFT JOIN t_courses_activity_signed t2 ON t1.id = t2.requestid // WHERE t1.type = 100 AND t1.userid = ? AND t1.schoolid = ? AND t1.campusid = ? // AND t1.aproved = 1 // AND t2.activity_money > 0 AND t2.activity_money_used < t2.activity_money; // // 目标态列映射(**必须按映射改写,不能照抄 dump 的列名**): // // t1.id (旧库 UUID 主键) → t_user_balance_request.biz_no // (设计 §2.2 / §11-10:旧 UUID 原样搬进 biz_no,新 id 是自增 int64) // t2.requestid (旧库字符串申请单号) → t_courses_activity_signed.request_no // (DDL 注释:「申请单号(原 requestid,关联 t_user_balance_request.biz_no)」) // t1.userid / t1.schoolid / t1.campusid / t1.aproved → user_id / institution_id / campus_id / approved // // → JOIN 条件因此是 **`br.biz_no = cas.request_no`**,不是 `br.id = cas.request_no`: // 新 `id` 是自增整数,与字符串单号**永不等值**(照抄 dump 会静默恒不命中 → 可退额永远不少扣,属资金口径 BUG); // `t_courses_activity_signed` 的 model 红线亦已写明「字符串保留(非 int64 FK),勿 JOIN id」。 // // 两个过滤条件的语义(照抄 dump,**不得"顺手简化"**): // - `cas.activity_money > 0`:只统计赠送额为正的活动行(负数=抵扣,不进赠送池); // - `cas.activity_money_used < cas.activity_money`:只统计赠送池**尚未用尽**的行—— // 用尽的行其赠送额已全部转为可用余额且不可再退,故不参与本次扣减。 // // 作用域:**显式**按 `br.institution_id` / `br.campus_id` 过滤,**不**用 `BaseRepo.Scoped`—— // Scoped 生成的是裸列名 `institution_id = ?`,在 JOIN 下两表同名列会二义、SQL 直接报错; // 这里把等价过滤写成别名限定列,安全边界不变(普通角色同样被钉在本机构 + 本校区,超管由调用方 // 的 campusID 入参钉住,与 Scoped 的语义差异仅"不因超管而放宽",属于更严而非更宽)。 // // 精度:**Go 侧定点累加**(本仓红线:禁止 `SQL SUM(decimal)` 回 REAL 再转 Amount)。 // // 入参: // - ctx 请求上下文; // - userID 用户(ULID;空 → ErrBadParam); // - institutionID 机构 ID(**必须 > 0**:与 dump 的 `schoolid` 等值); // - campusID 校区 ID(**必须 > 0**:与 dump 的 `campusid` 等值)。 // // 出参: // - money.Amount:已用赠送额合计(分;无匹配行 → 0,**不返回 ErrNotFound**); // - ErrBadParam / ErrDBError。 func (r *BalanceRequestRepo) SumRechargeActivityUsedGift( ctx context.Context, userID string, institutionID, campusID int64, ) (money.Amount, error) { if strings.TrimSpace(userID) == "" { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SumRechargeActivityUsedGift 需要 user_id")) } if institutionID <= 0 || campusID <= 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SumRechargeActivityUsedGift 需要 institution_id>0 且 campus_id>0")) } db, err := r.db(ctx) if err != nil { return 0, err } // 扫描目标用 *string:LEFT JOIN 未命中时该列为 NULL,string 直接接会扫描报错。 type usedGiftRow struct { ActivityMoneyUsed *string `gorm:"column:activity_money_used"` } rows := make([]usedGiftRow, 0, 8) scanErr := db.Table("t_user_balance_request AS br"). Select("cas.activity_money_used AS activity_money_used"). Joins("LEFT JOIN t_courses_activity_signed AS cas ON br.biz_no = cas.request_no"). Where("br.type = ? AND br.user_id = ? AND br.institution_id = ? AND br.campus_id = ? AND br.approved = ?", model.BalanceRequestTypeRecharge, userID, institutionID, campusID, model.BalanceRequestApprovedPassed). Where("cas.activity_money > 0 AND cas.activity_money_used < cas.activity_money"). Scan(&rows).Error if scanErr != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, scanErr) } total := money.Amount(0) for i := range rows { if rows[i].ActivityMoneyUsed == nil { continue // LEFT JOIN 未命中的占位行(不可能进入 where,防御性跳过) } amount, perr := money.Parse(*rows[i].ActivityMoneyUsed) if perr != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr) } total = total.Add(amount) } return total, nil } // ListByUser 分页列出某用户的工单(按 id 倒序,最新在前)。 // // 入参: // - ctx 请求上下文; // - userID 用户(ULID;空 → ErrBadParam); // - page 分页(PageQuery;内部 ClampPage:pageSize ∈ [1,200])。 // // 出参: // - []model.UserBalanceRequest:**空结果返回长度 0 的非 nil 切片**; // - ErrBadParam / ErrDBError。 // // 作用域:经 Scoped(普通角色只见本机构工单;超管全量)。 func (r *BalanceRequestRepo) ListByUser(ctx context.Context, userID string, page PageQuery) ([]model.UserBalanceRequest, error) { if strings.TrimSpace(userID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListByUser 需要 user_id")) } db, err := r.db(ctx) if err != nil { return nil, err } pageSize := page.Limit() rows := make([]model.UserBalanceRequest, 0, pageSize) q := r.Scoped(db.Model(&model.UserBalanceRequest{})) err = q.Where("user_id = ?", userID). 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.UserBalanceRequest, 0) } return rows, nil } // ---------------------------------------------------------------- AC-12 查询原语(端点 12 / 14~16) // RequestFilter 工单列表筛选(端点 12 `GET /admin/finance/requests`)。 // // 字段语义(nil/空 = 不筛): // - Type *int32:工单类型(100 普通充值/101 坏账/202 退费/203 取消充值活动/300 转结等); // - Approved *int8:审批状态(-2 取消/-1 不通过/0 未处理/1 已通过);指针区分"未传"与"传 0"; // - UserID string:限定某个用户(空 = 本机构全部)。 type RequestFilter struct { // Type 工单类型(nil = 不筛)。 Type *int32 // Approved 审批状态(nil = 不筛)。 Approved *int8 // UserID 用户(ULID;空 = 不筛)。 UserID string } // ListFiltered 分页查工单(带类型/状态/用户筛选,附总条数)。 // // 入参:ctx;f RequestFilter;page PageQuery(内部钳制 pageSize ∈ [1,200])。 // 出参: // - []model.UserBalanceRequest:`id DESC`(新单在前);空结果非 nil 切片; // - int64:总条数; // - ErrDBError。 // // 作用域:经 `Scoped`(普通角色只见本机构;超管全量)。**不用** `institutionScoped`: // 工单是纯机构内资源,`campus_id` 等值过滤也合理(财务按自己校区看单)。 func (r *BalanceRequestRepo) ListFiltered( ctx context.Context, f RequestFilter, page PageQuery, ) ([]model.UserBalanceRequest, int64, error) { db, err := r.db(ctx) if err != nil { return nil, 0, err } base := r.Scoped(db.Model(&model.UserBalanceRequest{})) if f.Type != nil { base = base.Where("type = ?", *f.Type) } if f.Approved != nil { base = base.Where("approved = ?", *f.Approved) } if strings.TrimSpace(f.UserID) != "" { base = base.Where("user_id = ?", strings.TrimSpace(f.UserID)) } var total int64 if cErr := base.Count(&total).Error; cErr != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, cErr) } pageSize := page.Limit() rows := make([]model.UserBalanceRequest, 0, pageSize) if qErr := base.Order("id DESC").Limit(pageSize).Offset(page.Offset()).Find(&rows).Error; qErr != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, qErr) } if rows == nil { rows = make([]model.UserBalanceRequest, 0) } return rows, total, nil } // GetByID 按主键读工单(**不加锁**,审批前置校验/详情回读用)。 // // 入参:ctx;id 工单 ID(<=0 → ErrBadParam)。 // 出参:*model.UserBalanceRequest + ErrNotFound(不存在或跨机构)/ ErrBadParam / ErrDBError。 // // 与 GetForUpdate 的分工:写路径(审批 CAS)**必须**用 `GetForUpdate`(tx 内加行锁); // 本方法只用于"读一眼",**禁止**在事务写路径里用它替代加锁读(会退化成无锁读-改-写)。 func (r *BalanceRequestRepo) GetByID(ctx context.Context, id int64) (*model.UserBalanceRequest, error) { if id <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: balance request id 必须 > 0")) } db, err := r.db(ctx) if err != nil { return nil, err } var out model.UserBalanceRequest if qErr := r.Scoped(db.Model(&model.UserBalanceRequest{})).Where("id = ?", id).Take(&out).Error; qErr != nil { return nil, wrapQueryErr(qErr, "GetByID(t_user_balance_request)") } return &out, nil } // UpdateResponseMsg 回写审批意见(`response_msg`),审批动作的**附属写**(不改状态、不做 CAS)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**:与审批状态推进同事务,避免"状态变了、意见没写进去"); // - id 工单 ID(<=0 → ErrBadParam); // - msg 审批意见(空串 → 写 NULL,保持"无意见"语义)。 // // 出参:受影响行数(0 = 工单不存在/跨机构);ErrBadParam / ErrDBError。 // // 为什么单独一条语句而不并进 UpdateApproved:`UpdateApproved` 是**状态机 CAS**(`WHERE approved=0`), // 而意见回写发生在 CAS **成功之后**(此时 approved 已非 0,CAS 条件必然不成立)。 // 把两者塞进一条 SQL 会让 CAS 语义变模糊(评审时无法一眼看出"状态推进只发生一次")。 func (r *BalanceRequestRepo) UpdateResponseMsg( ctx context.Context, tx *gorm.DB, id int64, msg string, ) (int64, error) { if err := requireTx(tx, "UpdateResponseMsg"); err != nil { return 0, err } if id <= 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateResponseMsg 需要 id")) } var value any if strings.TrimSpace(msg) != "" { value = msg } res := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.UserBalanceRequest{})). Where("id = ?", id). UpdateColumn("response_msg", value) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } return res.RowsAffected, nil }