Răsfoiți Sursa

feat(academic): AC-03 余额账户/工单/支付单仓储层(原子增减 + 行锁 + 幂等 + NULL 行三通道)

## 新增仓储(internal/academic/repository/)
- `expense_repo.go`(ExpenseRepo,**继承 BaseRepo** —— 校区账户强制机构作用域):
  · `UpsertGlobal`/`UpsertCampus`:UPSERT 只建户(`ON CONFLICT ... DO NOTHING`),**绝不覆盖余额**
    (否则并发充值丢更新);校区冲突键 = UK(user_id, institution_id, campus_id);
  · `AddBalance`/`AddSpend`/`AddSpecialExpense`/`AddTotalIncome`/`AddTotalExpenditure`:
    一律 `col = col + ?` **原子表达式**(金额 decimal 字符串字面量入场,无 float64),
    campusID>0 时「全局 → 校区」**固定顺序双写**(防死锁,model 红线),任一命中 0 行 →
    `ErrAccountNotFound` 回滚(禁止"流水有、余额没变");
  · `GetForUpdate`(全局)/ `GetCampusForUpdate`(校区):`SELECT ... FOR UPDATE` 行锁,退费防超退;
  · `GetBalance(ctx,userID,campusID)`:只读无锁(文档标注不得用于扣费判定);
  · `InsertLog`:写 t_user_expense_log,撞 `uk_request_type` → `ErrDuplicateLog`(CodeUnique)sentinel,
    Service 捕获后返回首次结果(幂等命中不是 5002);
  · `ListLogs`:分页(`PageQuery` 钳制 200)、`create_time DESC, id DESC`、**空结果非 nil 切片**;
  · `SumLogMoney`:**Go 侧定点累加**(禁 SQL SUM 回 decimal→REAL)。
- `balance_request_repo.go`(BalanceRequestRepo,继承 BaseRepo):
  `GetForUpdate`(行锁 + 机构作用域)/ `Insert`(biz_no 撞键 → `ErrDuplicateBizNo`)/
  `UpdateApproved`(**CAS `WHERE approved = 0`**,重复审批 affected=0,防双人同时通过重复入账)/
  `ListByUser`(分页、非 nil)。
- `order_pay_repo.go`(OrderPayRepo,**不继承 BaseRepo** —— 架构裁定 L3):
  `institution_id`/`campus_id` 可空,`Scoped` 的三值逻辑会静默丢 NULL 行,故改为**三显式通道**:
  ① `ListByUser`(付款人主口径,含本人 NULL 行)② `ListByScope`(显式机构/校区,NULL 行天然不返回,
  非超管越权 → ErrUnscoped)③ `GetByOrderNo`(系统回调通道:biz_no 命中,pay_order 兜底);
  `Create`(幂等 biz_no)、`UpdateStatusCAS`(`WHERE status = from`,重复回调 affected=0)。
  **严禁** `OR institution_id IS NULL` 兜底(§2.5)。
  兼容别名 `GetByUserID`/`GetByInstitution` 直接委托 `ListByUser`/`ListByScope`(团队任务书命名 ↔ 设计 §2.5 命名对齐,实现只有一份)。
- `base.go`:新增共享分页入参 `PageQuery`(`Normalize`/`Limit`/`Offset`,全部经 `ClampPage`)。

## 索引补强(t_order_pay)
- model tag:`InstitutionID`/`CampusID`/`CreateTime` 组 `idx_order_inst_campus`
  `(institution_id, campus_id, create_time)`(支撑通道②);
- DDL fixture 同步新增该 KEY,并刷新 `testdata/academic-model-ddl.sha256`(`sha256-lf-v1` 口径,
  三者同提交:文档副本不在本仓,已在报告中标注需跨仓同步)。

## 单测(13 个用例,全部 PASS)
UPSERT 双表形状与不覆盖余额 / 5 个列的原子表达式 SQL(真实生成的 SQL,经 gorm logger 抓取)/
全局→校区双写顺序 / MySQL 方言 `FOR UPDATE` / 幂等键第二次写入识别重复 /
分页空列表非 nil 与 pageSize 钳制 200 / 跨机构读不到、超管可见 / 跨机构记账被 scoping 拦截并回滚 /
工单 CAS 重复 affected=0 / 支付单三通道 + NULL 行可见性 + CAS 幂等。

## 口径偏差(已在代码注释登记,待架构/主理人确认)
1. 索引名 `idx_order_inst_campus`(非 `idx_inst_campus`):SQLite 索引名是**库级全局唯一**,
   而 AC-01 已有 6 张表共用 `idx_inst_campus` → sqlite 单库里同时 AutoMigrate 会报
   `index idx_inst_campus already exists`(已实测)。MySQL 侧语义不变。
2. `ListLogs`/`SumLogMoney` 的 `campusIDs` 参数:t_user_expense_log **无 campus_id 列**(DDL 第二部分「3.」),
   故传非空值一律 `ErrBadParam`(明确拒绝 > 静默不过滤的假安全);校区维度流水视图需 Service join 校区账户。
3. `GetForUpdate` 拆成全局/校区两个方法(返回类型不同,无法合并到单签名)。

## 已知遗留(建议登记技术债)
AC-01 的 27 表**无法在同一个 sqlite 库**全量 AutoMigrate:`uk_biz_no`(7 表)与 `idx_inst_campus`(6 表)
跨表重名。当前各测试按"无冲突子集"分库建表;建议在 CI 规范化专项里统一处理(或 CI 走 MySQL 容器)。

`go build ./... && go vet ./... && go test ./... -short` 全绿。
Q 21 ore în urmă
părinte
comite
92b8b1a0f7

+ 3 - 3
internal/academic/model/models_fund.go

@@ -182,8 +182,8 @@ type OrderPay struct {
 	BizNo         string     `gorm:"uniqueIndex:uk_biz_no;column:biz_no;size:32;not null" json:"bizNo"`               // 业务单号(旧 UUID 原样保留;UNIQUE;对外暴露给前端/支付网关)|枚举:无|写入:下单时|FK:幂等/回滚锚点|红线:不可变(支付网关侧已登记)
 	Status        int8       `gorm:"index:idx_status;column:status;not null;default:0" json:"status"`                 // 状态(-2 失败/-1 取消/0 未支付/1 成功)|枚举:OrderPayStatus*|写入:下单/取消/回调|FK:无|红线:状态机单向(成功不可回退),回调重复须幂等
 	Type          *string    `gorm:"column:type;size:100" json:"type,omitempty"`                                      // 订单类型(充值/报课…,旧字符串原样)|枚举:旧字符串枚举(不收敛)|写入:下单时|FK:无|红线:仅展示/分流,不参与金额计算
-	InstitutionID *int64     `gorm:"column:institution_id" json:"institutionId,omitempty"`                            // 机构 ID(原 schoolid;作用域列;可空)|枚举:无|写入:下单时|FK:→ t_institution|红线:NULL ≠ 0,勿混判
-	CampusID      *int64     `gorm:"column:campus_id" json:"campusId,omitempty"`                                      // 校区 ID(原 campusid;作用域列;可空)|枚举:无|写入:下单时|FK:→ t_campus|红线:NULL ≠ 0
+	InstitutionID *int64     `gorm:"index:idx_order_inst_campus,priority:1;column:institution_id" json:"institutionId,omitempty"`                            // 机构 ID(原 schoolid;作用域列;可空)|枚举:无|写入:下单时|FK:→ t_institution|红线:NULL ≠ 0,勿混判
+	CampusID      *int64     `gorm:"index:idx_order_inst_campus,priority:2;column:campus_id" json:"campusId,omitempty"`                                      // 校区 ID(原 campusid;作用域列;可空)|枚举:无|写入:下单时|FK:→ t_campus|红线:NULL ≠ 0
 	UserID        *string    `gorm:"index:idx_user_create,priority:1;column:user_id;size:26" json:"userId,omitempty"` // 付款用户(ULID;原 userid)|枚举:无|写入:下单时|FK:→ t_user(逻辑)|红线:家长代付场景 ≠ create_user_id
 	PayOrder      *string    `gorm:"index:idx_pay_order;column:pay_order;size:50" json:"payOrder,omitempty"`          // 交易单号(第三方流水号)|枚举:无|写入:支付回调|FK:弱关联 t_payment_log|红线:非唯一,索引仅用于排障
 	PayType       *string    `gorm:"column:pay_type;size:10" json:"payType,omitempty"`                                // 支付方式(JSAPI 微信 / W02 银联 AllinPay)|枚举:JSAPI/W02|写入:下单时|FK:无|红线:新增通道须评审(对账口径)
@@ -192,7 +192,7 @@ type OrderPay struct {
 	PayTime       *time.Time `gorm:"column:pay_time" json:"payTime,omitempty"`                                        // 支付时间(datetime(3))|枚举:无|写入:回调成功|FK:无|红线:成功态必须非 NULL
 	PayReturnData *string    `gorm:"column:pay_return_data;type:longtext" json:"-"`                                   // 支付返回数据(JSON 直存;含敏感信息)|枚举:无|写入:回调|FK:无|红线:**API 与日志均不得回显**(json:"-")
 	CreateUserID  *string    `gorm:"column:create_user_id;size:26" json:"createUserId,omitempty"`                     // 下单人(ULID)|枚举:无|写入:下单时|FK:→ t_user(逻辑)|红线:家长代付场景与 user_id 不同
-	CreateTime    time.Time  `gorm:"index:idx_user_create,priority:2;column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`  // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	CreateTime    time.Time  `gorm:"index:idx_user_create,priority:2;index:idx_order_inst_campus,priority:3;column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`  // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
 	Remark        *string    `gorm:"column:remark;size:500" json:"remark,omitempty"`                                  // 用户备注|枚举:无|写入:下单时|FK:无|红线:用户输入,展示需转义
 }
 

+ 1 - 1
internal/academic/model/testdata/academic-model-ddl.sha256

@@ -2,4 +2,4 @@
 # target=testdata/academic-model-ddl.sql(academic 27 表目标态 DDL fixture)
 # 归一规则:仅 \r\n -> \n(单 CR / 末尾换行 / BOM / 行尾空白一律不归一)
 # 纪律:DDL 变更须同一提交内同步「文档副本 / fixture / 本文件」三处
-7a25c9183840e566a4022ec1c207a711d86d1dc9bdeef0ea00486e06ed975935
+cf0638f81bfb96076c911219691a98d6dc98db61696f0058d681052f9ed2b259

+ 2 - 1
internal/academic/model/testdata/academic-model-ddl.sql

@@ -197,7 +197,8 @@ CREATE TABLE `t_order_pay` (
   UNIQUE KEY `uk_biz_no` (`biz_no`),
   KEY `idx_user_create` (`user_id`, `create_time`),
   KEY `idx_status` (`status`),
-  KEY `idx_pay_order` (`pay_order`)
+  KEY `idx_pay_order` (`pay_order`),
+  KEY `idx_order_inst_campus` (`institution_id`, `campus_id`, `create_time`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='支付订单表(旧 UUID→int64+biz_no·充值/报课支付)' ROW_FORMAT=Dynamic;
 
 -- ----------------------------------------------------------------------------

+ 217 - 0
internal/academic/repository/balance_request_repo.go

@@ -0,0 +1,217 @@
+// 余额变更工单仓储(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"
+	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
+}
+
+// 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
+}

+ 244 - 0
internal/academic/repository/balance_request_repo_test.go

@@ -0,0 +1,244 @@
+// BalanceRequestRepo 单测(AC-03):biz_no 幂等 / 审批 CAS / FOR UPDATE / 分页 / scoping。
+//
+// 测试栈:glebarez/sqlite 文件库(**只迁 t_user_balance_request 单表**——sqlite 索引名是库级全局,
+// `uk_biz_no` 与其它单号表重名,混迁会撞名,原因见 expense_repo_test.go 注释)。
+package repository
+
+import (
+	"context"
+	"errors"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// newBalanceRequestRepoDB 工单单表文件库。
+func newBalanceRequestRepoDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_balance_request_repo_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(工单文件库) 失败: %v", err)
+	}
+	t.Cleanup(func() {
+		if sqlDB, e := db.DB(); e == nil {
+			_ = sqlDB.Close()
+		}
+	})
+	if err := db.AutoMigrate(&model.UserBalanceRequest{}); err != nil {
+		t.Fatalf("AutoMigrate(t_user_balance_request) 失败: %v", err)
+	}
+	return db
+}
+
+// TestBalanceRequestInsertAndDuplicate biz_no 幂等:撞 uk_biz_no 识别为重复单号(3003),不抛 5002。
+func TestBalanceRequestInsertAndDuplicate(t *testing.T) {
+	db := newBalanceRequestRepoDB(t)
+	repo := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	row := &model.UserBalanceRequest{
+		BizNo: "BIZ-0001", Type: model.BalanceRequestTypeRecharge,
+		UserID: "01HUSER000000000000000201", Money: "100.00",
+		RequestUserID: "01HUSER000000000000000201",
+	}
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.Insert(ctx, tx, row) }); err != nil {
+		t.Fatalf("创建工单失败: %v", err)
+	}
+	if row.InstitutionID != 1 || row.CampusID != 2 {
+		t.Fatalf("作用域列未补齐:inst=%d campus=%d", row.InstitutionID, row.CampusID)
+	}
+	if row.Approved != model.BalanceRequestApprovedPending {
+		t.Fatalf("新建工单 approved = %d,期望 0(未处理)", row.Approved)
+	}
+
+	dup := &model.UserBalanceRequest{
+		BizNo: "BIZ-0001", Type: model.BalanceRequestTypeRecharge,
+		UserID: "01HUSER000000000000000201", Money: "100.00",
+		RequestUserID: "01HUSER000000000000000201",
+	}
+	err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.Insert(ctx, tx, dup) })
+	if !errors.Is(err, ErrDuplicateBizNo) {
+		t.Fatalf("重复单号错误 = %v,期望 ErrDuplicateBizNo", err)
+	}
+	var appErr *infraerrors.AppError
+	if !errors.As(err, &appErr) || appErr.Code != infraerrors.CodeUnique {
+		t.Fatalf("重复单号错误码不符:%v", err)
+	}
+}
+
+// TestBalanceRequestUpdateApprovedCAS 审批 CAS:0→终态只成功一次,重复调用 affected=0。
+func TestBalanceRequestUpdateApprovedCAS(t *testing.T) {
+	db := newBalanceRequestRepoDB(t)
+	repo := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	row := &model.UserBalanceRequest{
+		BizNo: "BIZ-0002", Type: model.BalanceRequestTypeRefund,
+		UserID: "01HUSER000000000000000202", Money: "50.00",
+		RequestUserID: "01HUSER000000000000000202",
+	}
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.Insert(ctx, tx, row) }); err != nil {
+		t.Fatalf("创建工单失败: %v", err)
+	}
+	approver := "01HUSER000000000000000299"
+	now := time.Date(2026, 2, 1, 10, 0, 0, 0, time.UTC)
+
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		affected, e := repo.UpdateApproved(ctx, tx, row.ID, model.BalanceRequestApprovedPassed, &approver, &now)
+		if e != nil {
+			return e
+		}
+		if affected != 1 {
+			t.Fatalf("首次审批 affected = %d,期望 1", affected)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("首次审批失败: %v", err)
+	}
+
+	// 重复审批(第二次点"通过"):affected=0(幂等,不得二次入账)
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		affected, e := repo.UpdateApproved(ctx, tx, row.ID, model.BalanceRequestApprovedPassed, &approver, &now)
+		if e != nil {
+			return e
+		}
+		if affected != 0 {
+			t.Fatalf("重复审批 affected = %d,期望 0(CAS 防重复入账)", affected)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("重复审批失败: %v", err)
+	}
+
+	var stored model.UserBalanceRequest
+	if err := db.Where("id = ?", row.ID).Take(&stored).Error; err != nil {
+		t.Fatalf("回读工单失败: %v", err)
+	}
+	if stored.Approved != model.BalanceRequestApprovedPassed {
+		t.Fatalf("approved = %d,期望 1", stored.Approved)
+	}
+	if stored.ResponseUserID == nil || *stored.ResponseUserID != approver {
+		t.Fatalf("审批人未落库:%v", stored.ResponseUserID)
+	}
+
+	// 非法目标状态 → ErrBadParam
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.UpdateApproved(ctx, tx, row.ID, model.BalanceRequestApprovedPending, nil, nil)
+		if !errors.Is(e, infraerrors.ErrBadParam) {
+			t.Fatalf("非法目标状态错误 = %v,期望 ErrBadParam", e)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("非法目标状态用例失败: %v", err)
+	}
+}
+
+// TestBalanceRequestForUpdateSQL MySQL 方言:FOR UPDATE + 机构作用域同时出现在审批加锁读里。
+func TestBalanceRequestForUpdateSQL(t *testing.T) {
+	mdb, capture := newMySQLDryRunDB(t)
+	repo := NewBalanceRequestRepo(mdb, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	ctx := context.Background()
+
+	capture.Reset()
+	_, _ = repo.GetForUpdate(ctx, mdb, 7) // DryRun 必然 ErrRecordNotFound,只断言 SQL 形状
+	sql := capture.Last()
+	if !strings.Contains(sql, "FOR UPDATE") {
+		t.Fatalf("缺少 FOR UPDATE:%q", sql)
+	}
+	if !strings.Contains(sql, "institution_id") {
+		t.Fatalf("缺少机构作用域:%q", sql)
+	}
+}
+
+// TestBalanceRequestScoping 作用域:跨机构读不到(ErrNotFound)、超管可见。
+func TestBalanceRequestScoping(t *testing.T) {
+	db := newBalanceRequestRepoDB(t)
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+	owner := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+
+	row := &model.UserBalanceRequest{
+		BizNo: "BIZ-0003", Type: model.BalanceRequestTypeBadDebt,
+		UserID: "01HUSER000000000000000203", Money: "10.00",
+		RequestUserID: "01HUSER000000000000000203",
+	}
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return owner.Insert(ctx, tx, row) }); err != nil {
+		t.Fatalf("创建工单失败: %v", err)
+	}
+
+	other := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 2, CampusID: 5})
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := other.GetForUpdate(ctx, tx, row.ID)
+		if !errors.Is(e, infraerrors.ErrNotFound) {
+			t.Fatalf("跨机构读取错误 = %v,期望 ErrNotFound", e)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("跨机构用例失败: %v", err)
+	}
+	if list, err := other.ListByUser(ctx, row.UserID, PageQuery{}); err != nil || len(list) != 0 {
+		t.Fatalf("跨机构列表应查不到:len=%d err=%v", len(list), err)
+	}
+
+	admin := NewBalanceRequestRepo(db, &appscope.Scope{SuperAdmin: true, InstitutionID: 9})
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := admin.GetForUpdate(ctx, tx, row.ID)
+		return e
+	}); err != nil {
+		t.Fatalf("超管读取失败: %v", err)
+	}
+	if list, err := admin.ListByUser(ctx, row.UserID, PageQuery{}); err != nil || len(list) != 1 {
+		t.Fatalf("超管列表应命中 1 条:len=%d err=%v", len(list), err)
+	}
+}
+
+// TestBalanceRequestListByUserPaging 分页:空结果非 nil、倒序、pageSize 钳制。
+func TestBalanceRequestListByUserPaging(t *testing.T) {
+	db := newBalanceRequestRepoDB(t)
+	repo := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	ctx := context.Background()
+	const user = "01HUSER000000000000000204"
+
+	empty, err := repo.ListByUser(ctx, user, PageQuery{})
+	if err != nil {
+		t.Fatalf("空结果查询失败: %v", err)
+	}
+	if empty == nil || len(empty) != 0 {
+		t.Fatalf("空结果必须是非 nil 空切片:%v", empty)
+	}
+
+	for i := 0; i < 3; i++ {
+		row := &model.UserBalanceRequest{
+			BizNo: "BIZ-PAGE-" + string(rune('A'+i)), Type: model.BalanceRequestTypeRecharge,
+			UserID: user, Money: "1.00", RequestUserID: user,
+			InstitutionID: 1, CampusID: 2,
+		}
+		if err := db.Create(row).Error; err != nil {
+			t.Fatalf("seed 工单失败: %v", err)
+		}
+	}
+	page, err := repo.ListByUser(ctx, user, PageQuery{Page: 1, PageSize: 2})
+	if err != nil {
+		t.Fatalf("分页查询失败: %v", err)
+	}
+	if len(page) != 2 {
+		t.Fatalf("第 1 页长度 = %d,期望 2", len(page))
+	}
+	if page[0].ID < page[1].ID {
+		t.Fatalf("未按 id 倒序:%d / %d", page[0].ID, page[1].ID)
+	}
+	if got := (PageQuery{PageSize: 5000}).Limit(); got != MaxPageSize {
+		t.Fatalf("pageSize 钳制 = %d,期望 %d", got, MaxPageSize)
+	}
+}

+ 34 - 0
internal/academic/repository/base.go

@@ -304,6 +304,40 @@ func PageOffset(page, pageSize int) int {
 	return (page - 1) * pageSize
 }
 
+// PageQuery 分页入参(academic 域统一形状,设计 §10.6)。
+//
+// 字段:
+//   - Page int:页码(1 基;<1 由 ClampPage 归一为 1);
+//   - PageSize int:每页条数(<1 → DefaultPageSize=10;>200 → MaxPageSize=200)。
+//
+// 用法:仓储方法内部**必须**先 `page.Normalize()` 再拼 SQL(禁止直接使用调用方原始值,
+// 防大 pageSize 打爆内存/慢查询)。
+type PageQuery struct {
+	// Page 页码(1 基)。
+	Page int
+	// PageSize 每页条数(钳制区间 [1, 200])。
+	PageSize int
+}
+
+// Normalize 返回钳制后的 (page, pageSize)(语义等同 ClampPage,便于链式调用点少写一次转换)。
+//
+// 出参:page ∈ [1, +∞);pageSize ∈ [1, MaxPageSize]。
+func (p PageQuery) Normalize() (page, pageSize int) {
+	return ClampPage(p.Page, p.PageSize)
+}
+
+// Limit 返回本次查询应使用的 LIMIT 值(已钳制)。
+func (p PageQuery) Limit() int {
+	_, pageSize := p.Normalize()
+	return pageSize
+}
+
+// Offset 返回本次查询应使用的 OFFSET 值(已钳制)。
+func (p PageQuery) Offset() int {
+	page, pageSize := p.Normalize()
+	return PageOffset(page, pageSize)
+}
+
 // dupKeyHints 唯一键撞车的文案特征(不引第三方驱动错误类型,兼容 MySQL 与 sqlite)。
 var dupKeyHints = []string{
 	"Duplicate entry",          // MySQL 1062

+ 578 - 0
internal/academic/repository/expense_repo.go

@@ -0,0 +1,578 @@
+// 余额账户仓储(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 <col> = <col> + ? WHERE user_id = ?
+//	UPDATE t_user_expense_school SET <col> = <col> + ? 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* 常量)。
+func isExpenseColumn(column string) bool {
+	switch column {
+	case expenseColBalance, expenseColSpend, expenseColSpecialExpenses,
+		expenseColTotalIncome, expenseColTotalExpenditure:
+		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
+}
+
+// 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
+}

+ 712 - 0
internal/academic/repository/expense_repo_test.go

@@ -0,0 +1,712 @@
+// ExpenseRepo 单测(AC-03):UPSERT 双表 / 原子表达式 / FOR UPDATE / 幂等键 / 分页 / scoping。
+//
+// 测试栈(与 AC-06 活动仓储同一套手法):
+//   - **行为用例**:glebarez/sqlite 临时文件库(纯 Go,CGO_ENABLED=0 可跑);
+//   - **SQL 形状用例**:给 GORM 挂"抓 SQL"的 logger(`sqlCapture`)——
+//     DryRun/真实库都会经 `Logger.Trace` 暴露 Explain 后的完整 SQL,因此断言的是**仓储真实生成的语句**
+//     (不是测试里手抄一份),能真正校验「原子表达式 / 全局→校区顺序 / 不含 SELECT / FOR UPDATE」;
+//   - **MySQL 方言用例**:`mysql.New(...SkipInitializeWithVersion)` + `DisableAutomaticPing` 的 DryRun 句柄,
+//     不连库即可拿到 MySQL 方言 SQL(sqlite 不渲染行锁 clause,只有 MySQL 方言能证明 FOR UPDATE 真进了 SQL)。
+package repository
+
+import (
+	"context"
+	"errors"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/driver/mysql"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+
+	"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"
+)
+
+// sqlCapture 抓取 GORM 执行的 SQL(线程安全;`stmts` 为最近一次 Reset 之后的全部语句)。
+//
+// 为什么这样做:仓储方法内部不暴露 *gorm.DB,测试又要断言"真实生成的 SQL 形状",
+// 唯一无侵入的通道就是 GORM 的 logger。
+type sqlCapture struct {
+	mu    sync.Mutex
+	stmts []string
+}
+
+// LogMode 实现 logger.Interface(测试恒为 Info 级别,不做分级)。
+func (c *sqlCapture) LogMode(logger.LogLevel) logger.Interface { return c }
+
+// Info 实现 logger.Interface(测试不关心日志正文)。
+func (c *sqlCapture) Info(context.Context, string, ...interface{}) {}
+
+// Warn 实现 logger.Interface。
+func (c *sqlCapture) Warn(context.Context, string, ...interface{}) {}
+
+// Error 实现 logger.Interface。
+func (c *sqlCapture) Error(context.Context, string, ...interface{}) {}
+
+// Trace 实现 logger.Interface:记录 Explain 后的 SQL 文本。
+func (c *sqlCapture) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
+	sql, _ := fc()
+	if strings.TrimSpace(sql) == "" {
+		return
+	}
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.stmts = append(c.stmts, sql)
+}
+
+// Reset 清空已捕获语句(每个断言段开始前调用)。
+func (c *sqlCapture) Reset() {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.stmts = nil
+}
+
+// All 返回已捕获语句的快照。
+func (c *sqlCapture) All() []string {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	out := make([]string, len(c.stmts))
+	copy(out, c.stmts)
+	return out
+}
+
+// Last 返回最后一条语句(无语句时返回空串)。
+func (c *sqlCapture) Last() string {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	if len(c.stmts) == 0 {
+		return ""
+	}
+	return c.stmts[len(c.stmts)-1]
+}
+
+// compactSQL 去掉空白,便于跨方言断言 SQL 片段(sqlite/MySQL 的 Explain 空格风格不同)。
+func compactSQL(sql string) string {
+	return strings.Join(strings.Fields(sql), "")
+}
+
+// newMySQLDryRunDB MySQL 方言 DryRun 句柄 + SQL 抓取器(**不建立连接**,仅渲染方言级 SQL)。
+func newMySQLDryRunDB(t *testing.T) (*gorm.DB, *sqlCapture) {
+	t.Helper()
+	capture := &sqlCapture{}
+	db, err := gorm.Open(mysql.New(mysql.Config{
+		DSN:                       "academic:academic@tcp(127.0.0.1:3306)/academic?charset=utf8mb4&parseTime=True",
+		SkipInitializeWithVersion: true,
+	}), &gorm.Config{DryRun: true, DisableAutomaticPing: true, Logger: capture})
+	if err != nil {
+		t.Fatalf("gorm.Open(MySQL DryRun) 失败: %v", err)
+	}
+	return db, capture
+}
+
+// newExpenseRepoDB 余额账户域真实库(文件库;账户/校区账户/流水 **3 表** AutoMigrate)。
+//
+// ⚠ 为什么只迁 3 表(探索结论,登记为跨表索引重名技术债):
+// SQLite 的索引名是**数据库级全局唯一**(MySQL 是表级),而 AC-01 的 27 表里
+// `uk_biz_no`(7 张单号表)与 `idx_inst_campus`(6 张作用域表)跨表重名 ——
+// 因此**不能在同一个 sqlite 库**里 AutoMigrate 这些表(会报 `index ... already exists`)。
+// 本测试的全部用例只需余额 3 表(其余表各自在对应测试文件里单独建库),故按"无冲突子集"建库。
+// 真库(MySQL)侧不受影响,由 `TestDevDBAutoMigrate` 全量 27 表覆盖。
+func newExpenseRepoDB(t *testing.T) *gorm.DB {
+	db, _ := newExpenseRepoDBWithCapture(t)
+	return db
+}
+
+// newExpenseRepoDBWithCapture 同上,额外返回 SQL 抓取器(AutoMigrate 期间产生的语句会先被 Reset)。
+func newExpenseRepoDBWithCapture(t *testing.T) (*gorm.DB, *sqlCapture) {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_expense_repo_test.db")
+	capture := &sqlCapture{}
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{Logger: capture})
+	if err != nil {
+		t.Fatalf("gorm.Open(余额文件库) 失败: %v", err)
+	}
+	t.Cleanup(func() {
+		if sqlDB, e := db.DB(); e == nil {
+			_ = sqlDB.Close()
+		}
+	})
+	if err := db.AutoMigrate(
+		&model.UserExpense{},
+		&model.UserExpenseSchool{},
+		&model.UserExpenseLog{},
+	); err != nil {
+		t.Fatalf("AutoMigrate(余额 3 表) 失败: %v", err)
+	}
+	capture.Reset()
+	return db, capture
+}
+
+// seedExpenseAccounts 建全局账户 + 两个校区账户(inst=1:campus 2 / campus 3)。
+func seedExpenseAccounts(t *testing.T, db *gorm.DB, userID string) {
+	t.Helper()
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		if e := repo.UpsertGlobal(ctx, tx, &model.UserExpense{UserID: userID}); e != nil {
+			return e
+		}
+		if e := repo.UpsertCampus(ctx, tx, &model.UserExpenseSchool{UserID: userID, InstitutionID: 1, CampusID: 2}); e != nil {
+			return e
+		}
+		return repo.UpsertCampus(ctx, tx, &model.UserExpenseSchool{UserID: userID, InstitutionID: 1, CampusID: 3})
+	}); err != nil {
+		t.Fatalf("建账户失败: %v", err)
+	}
+}
+
+// TestExpenseUpsertNoClobber UPSERT 双表:重复建户不覆盖已有余额/统计列(只建户,不改钱)。
+func TestExpenseUpsertNoClobber(t *testing.T) {
+	db := newExpenseRepoDB(t)
+	const user = "01HUSER000000000000000101"
+	seedExpenseAccounts(t, db, user)
+
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.AddBalance(ctx, tx, user, 2, mustAmount(t, "10.50"))
+		return e
+	}); err != nil {
+		t.Fatalf("AddBalance 失败: %v", err)
+	}
+
+	// 再次 UPSERT(模拟并发重复建户 / 重放):余额必须保持不变
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		if e := repo.UpsertGlobal(ctx, tx, &model.UserExpense{UserID: user, Balance: "999.00"}); e != nil {
+			return e
+		}
+		return repo.UpsertCampus(ctx, tx, &model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2, Balance: "999.00"})
+	}); err != nil {
+		t.Fatalf("重复 UPSERT 失败: %v", err)
+	}
+
+	assertBalance(t, repo, ctx, user, 0, "10.50")
+	assertBalance(t, repo, ctx, user, 2, "10.50")
+}
+
+// TestExpenseUpsertSQLShape UPSERT 语句形状:带冲突目标 + DO NOTHING(不覆盖余额列)。
+func TestExpenseUpsertSQLShape(t *testing.T) {
+	db, capture := newExpenseRepoDBWithCapture(t)
+	const user = "01HUSER000000000000000111"
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	capture.Reset()
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		if e := repo.UpsertGlobal(ctx, tx, &model.UserExpense{UserID: user}); e != nil {
+			return e
+		}
+		return repo.UpsertCampus(ctx, tx, &model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2})
+	}); err != nil {
+		t.Fatalf("UPSERT 失败: %v", err)
+	}
+
+	var globalSQL, campusSQL string
+	for _, stmt := range capture.All() {
+		switch {
+		case strings.Contains(stmt, "t_user_expense_school"):
+			campusSQL = compactSQL(stmt)
+		case strings.Contains(stmt, "t_user_expense`"):
+			globalSQL = compactSQL(stmt)
+		}
+	}
+	if !strings.Contains(globalSQL, "ONCONFLICT") || !strings.Contains(globalSQL, "DONOTHING") {
+		t.Fatalf("全局 UPSERT 缺少 ON CONFLICT DO NOTHING:%s", globalSQL)
+	}
+	if !strings.Contains(globalSQL, "user_id") {
+		t.Fatalf("全局 UPSERT 冲突目标缺少 user_id:%s", globalSQL)
+	}
+	if !strings.Contains(campusSQL, "ONCONFLICT") ||
+		!strings.Contains(campusSQL, "user_id") ||
+		!strings.Contains(campusSQL, "institution_id") ||
+		!strings.Contains(campusSQL, "campus_id") {
+		t.Fatalf("校区 UPSERT 冲突键不完整(应含 user_id+institution_id+campus_id):%s", campusSQL)
+	}
+	// 关键红线:冲突分支不得 SET balance(否则并发充值丢更新)
+	if strings.Contains(campusSQL, "DOUPDATE") || strings.Contains(globalSQL, "DOUPDATE") {
+		t.Fatalf("UPSERT 不得覆盖已有余额列:global=%s campus=%s", globalSQL, campusSQL)
+	}
+}
+
+// TestExpenseAddBalanceSQLShape 原子表达式形状 + 双写顺序(全局 → 校区)+ 无读-改-写。
+func TestExpenseAddBalanceSQLShape(t *testing.T) {
+	db, capture := newExpenseRepoDBWithCapture(t)
+	const user = "01HUSER000000000000000112"
+	seedExpenseAccounts(t, db, user)
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	capture.Reset()
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.AddBalance(ctx, tx, user, 2, mustAmount(t, "10.50"))
+		return e
+	}); err != nil {
+		t.Fatalf("AddBalance 失败: %v", err)
+	}
+
+	stmts := capture.All()
+	var updates []string
+	for _, s := range stmts {
+		if strings.Contains(strings.ToUpper(s), "SELECT") {
+			t.Fatalf("资金增减路径出现 SELECT(读-改-写反例):%s", s)
+		}
+		if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(s)), "UPDATE") {
+			updates = append(updates, compactSQL(s))
+		}
+	}
+	if len(updates) != 2 {
+		t.Fatalf("期望 2 条 UPDATE(全局+校区),实得 %d:%v", len(updates), stmts)
+	}
+	if !strings.Contains(updates[0], "`balance`=balance+") || !strings.Contains(updates[0], "t_user_expense`") {
+		t.Fatalf("第 1 条 UPDATE 应作用于全局账户且为原子表达式:%s", updates[0])
+	}
+	if !strings.Contains(updates[1], "`balance`=balance+") || !strings.Contains(updates[1], "t_user_expense_school") {
+		t.Fatalf("第 2 条 UPDATE 应作用于校区账户:%s", updates[1])
+	}
+	// 固定顺序「全局 → 校区」是防死锁红线
+	if strings.Contains(updates[0], "school") {
+		t.Fatalf("双写顺序错误:必须先全局后校区,实得第 1 条 = %s", updates[0])
+	}
+}
+
+// TestExpenseAddColumnFamily 四个统计列各自原子累加,且都不动 balance(红线)。
+func TestExpenseAddColumnFamily(t *testing.T) {
+	db, capture := newExpenseRepoDBWithCapture(t)
+	const user = "01HUSER000000000000000113"
+	seedExpenseAccounts(t, db, user)
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	cases := []struct {
+		name   string
+		column string
+		call   func(tx *gorm.DB) error
+	}{
+		{"AddSpend", expenseColSpend, func(tx *gorm.DB) error {
+			_, e := repo.AddSpend(ctx, tx, user, 0, mustAmount(t, "8.00"))
+			return e
+		}},
+		{"AddSpecialExpense", expenseColSpecialExpenses, func(tx *gorm.DB) error {
+			_, e := repo.AddSpecialExpense(ctx, tx, user, 0, mustAmount(t, "2.50"))
+			return e
+		}},
+		{"AddTotalIncome", expenseColTotalIncome, func(tx *gorm.DB) error {
+			_, e := repo.AddTotalIncome(ctx, tx, user, 0, mustAmount(t, "100.00"))
+			return e
+		}},
+		{"AddTotalExpenditure", expenseColTotalExpenditure, func(tx *gorm.DB) error {
+			_, e := repo.AddTotalExpenditure(ctx, tx, user, 0, mustAmount(t, "30.00"))
+			return e
+		}},
+	}
+	for _, tc := range cases {
+		capture.Reset()
+		if err := runner.WithinTx(ctx, tc.call); err != nil {
+			t.Fatalf("[%s] 失败: %v", tc.name, err)
+		}
+		sql := compactSQL(capture.Last())
+		if !strings.Contains(sql, "`"+tc.column+"`="+tc.column+"+") {
+			t.Fatalf("[%s] 缺少原子表达式 `%s = %s + ?`:%s", tc.name, tc.column, tc.column, sql)
+		}
+		if strings.Contains(sql, "`balance`") {
+			t.Fatalf("[%s] 统计列累加不得动 balance:%s", tc.name, sql)
+		}
+	}
+
+	var global model.UserExpense
+	if err := db.Where("user_id = ?", user).Take(&global).Error; err != nil {
+		t.Fatalf("读取全局账户失败: %v", err)
+	}
+	// 注:sqlite 的 decimal 走 NUMERIC 亲和性,回读可能是 "8"(省略尾零),故一律按分值比较
+	wants := map[string]string{
+		expenseColSpend:            "8.00",
+		expenseColSpecialExpenses:  "2.50",
+		expenseColTotalIncome:      "100.00",
+		expenseColTotalExpenditure: "30.00",
+	}
+	got := map[string]string{
+		expenseColSpend:            global.Spend,
+		expenseColSpecialExpenses:  global.SpecialExpenses,
+		expenseColTotalIncome:      global.TotalIncome,
+		expenseColTotalExpenditure: global.TotalExpenditure,
+	}
+	for column, want := range wants {
+		if parseAmount(t, got[column]) != parseAmount(t, want) {
+			t.Fatalf("列 %s = %s,期望 %s", column, got[column], want)
+		}
+	}
+	if parseAmount(t, global.Balance) != 0 {
+		t.Fatalf("balance = %s,统计列累加不得动余额(红线)", global.Balance)
+	}
+}
+
+// TestExpenseAddBalanceBehavior 双写 / 负数 / 零增量 / 未建户报错。
+func TestExpenseAddBalanceBehavior(t *testing.T) {
+	db := newExpenseRepoDB(t)
+	const user = "01HUSER000000000000000102"
+	seedExpenseAccounts(t, db, user)
+
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		affected, e := repo.AddBalance(ctx, tx, user, 2, mustAmount(t, "100.00"))
+		if e != nil {
+			return e
+		}
+		if affected != 1 {
+			t.Fatalf("AddBalance 全局受影响行数 = %d,期望 1", affected)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("AddBalance 失败: %v", err)
+	}
+	assertBalance(t, repo, ctx, user, 0, "100.00")
+	assertBalance(t, repo, ctx, user, 2, "100.00")
+
+	// 消费 -30.25(全局 + 校区同步)
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.AddBalance(ctx, tx, user, 2, mustAmount(t, "-30.25"))
+		return e
+	}); err != nil {
+		t.Fatalf("AddBalance(负数) 失败: %v", err)
+	}
+	assertBalance(t, repo, ctx, user, 0, "69.75")
+	assertBalance(t, repo, ctx, user, 2, "69.75")
+
+	// 零增量:空操作(0 行,不报错)
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		affected, e := repo.AddBalance(ctx, tx, user, 2, money.Amount(0))
+		if e != nil {
+			return e
+		}
+		if affected != 0 {
+			t.Fatalf("零增量受影响行数 = %d,期望 0", affected)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("零增量失败: %v", err)
+	}
+
+	// 未建户 → ErrAccountNotFound(禁止静默跳过)
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.AddBalance(ctx, tx, "01HUSER000000000000000999", 2, mustAmount(t, "1.00"))
+		if !errors.Is(e, ErrAccountNotFound) {
+			t.Fatalf("未建户错误 = %v,期望 ErrAccountNotFound", e)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("未建户用例失败: %v", err)
+	}
+}
+
+// TestExpenseForUpdateSQL MySQL 方言下必须渲染 `FOR UPDATE`(行锁防超退),校区查询还须叠机构作用域。
+func TestExpenseForUpdateSQL(t *testing.T) {
+	mdb, capture := newMySQLDryRunDB(t)
+	repo := NewExpenseRepo(mdb, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	ctx := context.Background()
+
+	capture.Reset()
+	// DryRun 下 Take 必然返回 ErrRecordNotFound;本条用例只断言 SQL 形状,忽略返回值
+	_, _ = repo.GetForUpdate(ctx, mdb, "01HUSER000000000000000114")
+	if sql := capture.Last(); !strings.Contains(sql, "FOR UPDATE") {
+		t.Fatalf("GetForUpdate 全局账户 SQL 缺少 FOR UPDATE:%q", sql)
+	}
+
+	capture.Reset()
+	_, _ = repo.GetCampusForUpdate(ctx, mdb, "01HUSER000000000000000114", 2)
+	sql := capture.Last()
+	if !strings.Contains(sql, "FOR UPDATE") {
+		t.Fatalf("GetCampusForUpdate SQL 缺少 FOR UPDATE:%q", sql)
+	}
+	if !strings.Contains(sql, "institution_id") {
+		t.Fatalf("校区账户查询未叠加机构作用域:%q", sql)
+	}
+}
+
+// TestExpenseForUpdateBehavior 行为:命中返回行;跨机构/不存在 → ErrNotFound(不泄露存在性)。
+func TestExpenseForUpdateBehavior(t *testing.T) {
+	db := newExpenseRepoDB(t)
+	const user = "01HUSER000000000000000105"
+	seedExpenseAccounts(t, db, user)
+	ctx := context.Background()
+	runner := NewTxRunner(db)
+
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		row, e := repo.GetForUpdate(ctx, tx, user)
+		if e != nil {
+			return e
+		}
+		if row.UserID != user {
+			t.Fatalf("GetForUpdate 返回 user_id = %s", row.UserID)
+		}
+		campusRow, e := repo.GetCampusForUpdate(ctx, tx, user, 2)
+		if e != nil {
+			return e
+		}
+		if campusRow.CampusID != 2 {
+			t.Fatalf("GetCampusForUpdate 返回 campus_id = %d", campusRow.CampusID)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("GetForUpdate 失败: %v", err)
+	}
+
+	// 跨机构(scope=2)读 inst=1 的校区账户 → ErrNotFound
+	other := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 2, CampusID: 2})
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := other.GetCampusForUpdate(ctx, tx, user, 2)
+		if !errors.Is(e, infraerrors.ErrNotFound) {
+			t.Fatalf("跨机构读取错误 = %v,期望 ErrNotFound", e)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("跨机构用例失败: %v", err)
+	}
+
+	// 不存在 → ErrNotFound
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.GetForUpdate(ctx, tx, "01HUSER000000000000000998")
+		if !errors.Is(e, infraerrors.ErrNotFound) {
+			t.Fatalf("不存在账户错误 = %v,期望 ErrNotFound", e)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("不存在用例失败: %v", err)
+	}
+
+	// 超管跨机构可见(作用域放行)
+	admin := NewExpenseRepo(db, &appscope.Scope{SuperAdmin: true, InstitutionID: 9})
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := admin.GetCampusForUpdate(ctx, tx, user, 2)
+		return e
+	}); err != nil {
+		t.Fatalf("超管读取失败: %v", err)
+	}
+}
+
+// TestExpenseCampusScopingWrite 普通角色不得给别的机构账户记账(Scoped 命中 0 行 → 事务回滚)。
+func TestExpenseCampusScopingWrite(t *testing.T) {
+	db := newExpenseRepoDB(t)
+	const user = "01HUSER000000000000000106"
+	seedExpenseAccounts(t, db, user)
+	ctx := context.Background()
+	runner := NewTxRunner(db)
+
+	other := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 2, CampusID: 2})
+	err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := other.AddBalance(ctx, tx, user, 2, mustAmount(t, "1.00"))
+		if !errors.Is(e, ErrAccountNotFound) {
+			t.Fatalf("跨机构记账错误 = %v,期望 ErrAccountNotFound(scoping 拦截)", e)
+		}
+		return e
+	})
+	if err == nil {
+		t.Fatal("跨机构记账本应失败(事务应回滚)")
+	}
+
+	// 事务回滚后余额未变(全局更新被一并撤销)
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	assertBalance(t, repo, ctx, user, 0, "0.00")
+	assertBalance(t, repo, ctx, user, 2, "0.00")
+}
+
+// TestExpenseInsertLogIdempotent 幂等键 uk_request_type:同一 request_id+type 第二次写入识别为重复。
+func TestExpenseInsertLogIdempotent(t *testing.T) {
+	db := newExpenseRepoDB(t)
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+	reqID := "REQ-0001"
+	const user = "01HUSER000000000000000107"
+
+	write := func(log *model.UserExpenseLog) error {
+		return runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.InsertLog(ctx, tx, log) })
+	}
+	if err := write(&model.UserExpenseLog{UserID: user, Type: model.ExpenseLogTypeRecharge,
+		RequestID: &reqID, Money: "100.00"}); err != nil {
+		t.Fatalf("首次写流水失败: %v", err)
+	}
+	err := write(&model.UserExpenseLog{UserID: user, Type: model.ExpenseLogTypeRecharge,
+		RequestID: &reqID, Money: "100.00"})
+	if !errors.Is(err, ErrDuplicateLog) {
+		t.Fatalf("重复请求错误 = %v,期望 ErrDuplicateLog(幂等命中,非 5002)", err)
+	}
+	var appErr *infraerrors.AppError
+	if !errors.As(err, &appErr) || appErr.Code != infraerrors.CodeUnique {
+		t.Fatalf("重复请求错误码不符:%v", err)
+	}
+	// 不同 type 不算重复(type 参与幂等键)
+	if err := write(&model.UserExpenseLog{UserID: user, Type: model.ExpenseLogTypeRefund,
+		RequestID: &reqID, Money: "-100.00"}); err != nil {
+		t.Fatalf("不同 type 应视为新请求:%v", err)
+	}
+}
+
+// TestExpenseListLogsPagingAndFilters 分页(空列表非 nil、钳制 200、倒序)+ campusIDs 明确拒绝。
+func TestExpenseListLogsPagingAndFilters(t *testing.T) {
+	db := newExpenseRepoDB(t)
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	ctx := context.Background()
+
+	empty, err := repo.ListLogs(ctx, "01HUSER000000000000000108", nil, time.Time{}, time.Time{}, PageQuery{})
+	if err != nil {
+		t.Fatalf("空结果查询失败: %v", err)
+	}
+	if empty == nil {
+		t.Fatal("空结果必须是非 nil 切片")
+	}
+	if len(empty) != 0 {
+		t.Fatalf("空结果长度 = %d", len(empty))
+	}
+
+	const user = "01HUSER000000000000000109"
+	base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+	for i := 0; i < 5; i++ {
+		reqID := "REQ-PAGE-" + string(rune('A'+i))
+		row := &model.UserExpenseLog{
+			UserID: user, Type: model.ExpenseLogTypeRecharge, RequestID: &reqID,
+			Money: "1.00", CreateTime: base.Add(time.Duration(i) * time.Minute),
+		}
+		if err := db.Create(row).Error; err != nil {
+			t.Fatalf("seed 流水失败: %v", err)
+		}
+	}
+
+	page1, err := repo.ListLogs(ctx, user, nil, time.Time{}, time.Time{}, PageQuery{Page: 1, PageSize: 2})
+	if err != nil {
+		t.Fatalf("分页查询失败: %v", err)
+	}
+	if len(page1) != 2 {
+		t.Fatalf("第 1 页长度 = %d,期望 2", len(page1))
+	}
+	if !page1[0].CreateTime.After(page1[1].CreateTime) {
+		t.Fatalf("未按 create_time 倒序:%v / %v", page1[0].CreateTime, page1[1].CreateTime)
+	}
+	page3, err := repo.ListLogs(ctx, user, nil, time.Time{}, time.Time{}, PageQuery{Page: 3, PageSize: 2})
+	if err != nil {
+		t.Fatalf("第 3 页查询失败: %v", err)
+	}
+	if len(page3) != 1 {
+		t.Fatalf("第 3 页长度 = %d,期望 1", len(page3))
+	}
+
+	// pageSize 钳制:999 → 200
+	if got := (PageQuery{PageSize: 999}).Limit(); got != MaxPageSize {
+		t.Fatalf("pageSize 钳制 = %d,期望 %d", got, MaxPageSize)
+	}
+	if got := (PageQuery{}).Limit(); got != DefaultPageSize {
+		t.Fatalf("默认 pageSize = %d,期望 %d", got, DefaultPageSize)
+	}
+	if got := (PageQuery{Page: 3, PageSize: 10}).Offset(); got != 20 {
+		t.Fatalf("offset = %d,期望 20", got)
+	}
+
+	// 时间窗过滤(闭区间)
+	inWindow, err := repo.ListLogs(ctx, user, nil, base.Add(time.Minute), base.Add(2*time.Minute), PageQuery{PageSize: 10})
+	if err != nil {
+		t.Fatalf("时间窗查询失败: %v", err)
+	}
+	if len(inWindow) != 2 {
+		t.Fatalf("时间窗命中 %d 条,期望 2", len(inWindow))
+	}
+
+	if _, err := repo.ListLogs(ctx, user, []int64{2}, time.Time{}, time.Time{}, PageQuery{}); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("campusIDs 非空错误 = %v,期望 ErrBadParam", err)
+	}
+}
+
+// TestExpenseSumLogMoneyGoSide 对账汇总在 Go 侧定点累加(禁 SQL SUM 回 REAL)。
+func TestExpenseSumLogMoneyGoSide(t *testing.T) {
+	db, capture := newExpenseRepoDBWithCapture(t)
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	ctx := context.Background()
+	const user = "01HUSER000000000000000110"
+
+	rows := []*model.UserExpenseLog{
+		{UserID: user, Type: model.ExpenseLogTypeRecharge, Money: "100.00"},
+		{UserID: user, Type: model.ExpenseLogTypeCourseCampus, Money: "-30.25"},
+		{UserID: user, Type: model.ExpenseLogTypeRefund, Money: "-69.75"},
+		{UserID: user, Type: model.ExpenseLogTypeRecharge, Money: "0.01"},
+	}
+	for i := range rows {
+		if err := db.Create(rows[i]).Error; err != nil {
+			t.Fatalf("seed 流水失败: %v", err)
+		}
+	}
+
+	capture.Reset()
+	total, err := repo.SumLogMoney(ctx, user, nil, nil)
+	if err != nil {
+		t.Fatalf("汇总失败: %v", err)
+	}
+	if got := total.String(); got != "0.01" {
+		t.Fatalf("全类型合计 = %s,期望 0.01", got)
+	}
+	for _, stmt := range capture.All() {
+		if strings.Contains(strings.ToUpper(stmt), "SUM(") {
+			t.Fatalf("汇总不得下推到 SQL(禁 SUM 回 REAL):%s", stmt)
+		}
+	}
+
+	rechargeOnly, err := repo.SumLogMoney(ctx, user, nil, []int{int(model.ExpenseLogTypeRecharge)})
+	if err != nil {
+		t.Fatalf("按类型汇总失败: %v", err)
+	}
+	if got := rechargeOnly.String(); got != "100.01" {
+		t.Fatalf("充值合计 = %s,期望 100.01", got)
+	}
+
+	if _, err := repo.SumLogMoney(ctx, user, []int64{2}, nil); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("campusIDs 非空错误 = %v,期望 ErrBadParam", err)
+	}
+}
+
+// assertBalance 断言账户余额(campusID=0 查全局)。
+func assertBalance(t *testing.T, repo *ExpenseRepo, ctx context.Context, user string, campusID int64, want string) {
+	t.Helper()
+	got, err := repo.GetBalance(ctx, user, campusID)
+	if err != nil {
+		t.Fatalf("读取余额失败(campus=%d): %v", campusID, err)
+	}
+	if got.String() != want {
+		t.Fatalf("余额(campus=%d) = %s,期望 %s", campusID, got.String(), want)
+	}
+}
+
+// mustAmount 断言式解析金额(测试内固定字面量,解析失败直接判红)。
+func mustAmount(t *testing.T, s string) money.Amount {
+	t.Helper()
+	return parseAmount(t, s)
+}
+
+// parseAmount 把 decimal 字符串解析成 money.Amount(空串按 0 处理)。
+//
+// 为什么不直接比字符串:sqlite 的 decimal 列是 NUMERIC 亲和性,回读可能省略尾零("8" vs "8.00"),
+// 字符串相等会假红;按"分"比较才是金额语义。
+func parseAmount(t *testing.T, s string) money.Amount {
+	t.Helper()
+	if strings.TrimSpace(s) == "" {
+		return 0
+	}
+	a, err := money.Parse(s)
+	if err != nil {
+		t.Fatalf("解析金额 %q 失败: %v", s, err)
+	}
+	return a
+}

+ 312 - 0
internal/academic/repository/order_pay_repo.go

@@ -0,0 +1,312 @@
+// 支付订单仓储(t_order_pay)——AC-03 三通道读取 + CAS 状态推进。
+//
+// 依据:unified/03-migration/academic-domain-design.md §2「5. t_order_pay」/ §2.5(NULL 行可见性裁定)
+// / §10.3(事务边界只在 Service)。
+//
+// **本仓储不继承 BaseRepo(架构裁定 L3,§2.5)**,原因是这张表的作用域列**可空**:
+//
+//	t_order_pay.institution_id / campus_id 都是 `BIGINT DEFAULT NULL`(旧库历史行+家长代付行可为 NULL)。
+//	BaseRepo.Scoped 注入的是 `institution_id = ?`,在 SQL 三值逻辑下 `NULL = 1` 结果是 **UNKNOWN**
+//	(不成立)→ 会把 NULL 行**静默丢掉**;写成 `institution_id = ? OR institution_id IS NULL` 又等于
+//	给"任何机构"开了读全量 NULL 行的口子(**严禁**,§2.5 明文禁止)。
+//
+// 因此本仓储用**三条显式通道**,可见性边界写在方法签名里,不靠隐藏的作用域注入:
+//
+//	① ListByUser(ctx, userID, page)                付款人主口径:`user_id = 本人`(**含** NULL 机构行——
+//	                                               钱是本人付的,本人当然可读);
+//	② ListByScope(ctx, institutionID, campusID, page) 显式机构/校区:`institution_id = ?`(NULL 行天然不返回);
+//	                                                非超管传别的机构 → ErrUnscoped;
+//	③ GetByOrderNo(ctx, orderNo)                   系统通道(支付回调/网关对账):按 biz_no 精确命中,
+//	                                                **不受作用域限制**,仅 Service 内部回调路径可调。
+//
+// 事务纪律:写方法一律接收 `tx *gorm.DB`(本文件不开事务)。
+package repository
+
+import (
+	"context"
+	stderrors "errors"
+	"strings"
+
+	"gorm.io/gorm"
+
+	"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"
+)
+
+// OrderPayRepo 支付订单仓储(**不继承 BaseRepo**,见文件头 §2.5 裁定)。
+type OrderPayRepo struct {
+	// DB 数据库句柄(连接池由 infra/db 统一建立;nil 时任何查询返回 ErrInternal)。
+	DB *gorm.DB
+	// Sc 请求作用域(仅用于"非超管不得借参数跨机构"的断言;**不参与 SQL 过滤**)。
+	Sc *appscope.Scope
+}
+
+// NewOrderPayRepo 构造支付订单仓储。
+//
+// 入参:
+//   - db *gorm.DB:连接池句柄(必填);
+//   - sc *appscope.Scope:请求作用域(可 nil:系统回调/对账任务无作用域;
+//     为 nil 时 ListByScope 返回 ErrUnscoped —— 系统任务请走 GetByOrderNo / ListByUser)。
+//
+// 出参:*OrderPayRepo。
+func NewOrderPayRepo(db *gorm.DB, sc *appscope.Scope) *OrderPayRepo {
+	return &OrderPayRepo{DB: db, Sc: sc}
+}
+
+// db 取查询句柄(ctx 内含 tx 时复用同一事务)。
+func (r *OrderPayRepo) db(ctx context.Context) (*gorm.DB, error) {
+	if r == nil || r.DB == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: OrderPayRepo.DB 为 nil"))
+	}
+	if tx := TxFromContext(ctx); tx != nil {
+		return tx, nil
+	}
+	if ctx == nil {
+		return r.DB, nil
+	}
+	return r.DB.WithContext(ctx), nil
+}
+
+// ListByUser 通道①:按付款人列出订单(**含机构/校区为 NULL 的行**)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - userID 付款用户(ULID;空 → ErrBadParam);
+//   - page 分页(PageQuery;ClampPage:pageSize ∈ [1,200])。
+//
+// 出参:[]model.OrderPay(**空结果返回非 nil 切片**)+ ErrBadParam / ErrDBError。
+//
+// SQL:`SELECT * FROM t_order_pay WHERE user_id = ? ORDER BY create_time DESC, id DESC LIMIT ? OFFSET ?`
+//
+// 可见性(§2.5):本人付款的行(含 NULL 机构)可见;**不**做机构过滤(付款人主口径)。
+// 红线:不得改写成 `user_id = ? AND institution_id = ?`(会把 NULL 行与跨机构付款行一起丢掉)。
+func (r *OrderPayRepo) ListByUser(ctx context.Context, userID string, page PageQuery) ([]model.OrderPay, 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.OrderPay, 0, pageSize)
+	if err := db.Model(&model.OrderPay{}).
+		Where("user_id = ?", userID).
+		Order("create_time DESC").Order("id DESC").
+		Limit(pageSize).Offset(page.Offset()).
+		Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	if rows == nil {
+		rows = make([]model.OrderPay, 0)
+	}
+	return rows, nil
+}
+
+// GetByUserID 通道①的兼容别名(团队任务书命名;语义完全等同 ListByUser)。
+//
+// 保留双名只为跨文档对齐(设计 §2.5 叫 ListByUser,AC-03 任务书叫 GetByUserID),
+// 实现只有一份:本方法**直接委托** ListByUser,不复制 SQL。
+func (r *OrderPayRepo) GetByUserID(ctx context.Context, userID string, page PageQuery) ([]model.OrderPay, error) {
+	return r.ListByUser(ctx, userID, page)
+}
+
+// ListByScope 通道②:按**显式机构/校区参数**列出订单(NULL 机构行**不返回**)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - institutionID 机构 ID(<=0 → ErrBadParam;NULL 行不在本通道范围内);
+//   - campusID 校区 ID(nil = 不限定校区;非 nil → `campus_id = *campusID`,NULL 行天然不返回);
+//   - page 分页(PageQuery)。
+//
+// 出参:[]model.OrderPay(非 nil)+ ErrBadParam / ErrUnscoped(**非超管传了别的机构**)/ ErrDBError。
+//
+// SQL:
+//
+//	SELECT * FROM t_order_pay WHERE institution_id = ? [AND campus_id = ?]
+//	 ORDER BY create_time DESC, id DESC LIMIT ? OFFSET ?
+//
+// 红线(§2.5):**严禁** `OR institution_id IS NULL` 兜底;NULL 行只能经通道①(本人)或
+// 通道③(系统回调)读取,超管全景视图请显式传 institutionID(逐机构查询)。
+func (r *OrderPayRepo) ListByScope(ctx context.Context, institutionID int64, campusID *int64, page PageQuery) ([]model.OrderPay, error) {
+	if institutionID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: ListByScope 需要 institution_id > 0"))
+	}
+	if r.Sc == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrUnscoped,
+			errString("repository: ListByScope 需要请求作用域(系统任务请走 GetByOrderNo)"))
+	}
+	if !r.Sc.SuperAdmin && r.Sc.InstitutionID != institutionID {
+		return nil, infraerrors.WithCause(infraerrors.ErrUnscoped,
+			errString("repository: ListByScope 机构参数越权(作用域机构与入参不一致)"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	q := db.Model(&model.OrderPay{}).Where("institution_id = ?", institutionID)
+	if campusID != nil {
+		if *campusID < 0 {
+			return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+				errString("repository: ListByScope campus_id 不得为负"))
+		}
+		q = q.Where("campus_id = ?", *campusID)
+	}
+	pageSize := page.Limit()
+	rows := make([]model.OrderPay, 0, pageSize)
+	if err := q.Order("create_time DESC").Order("id DESC").
+		Limit(pageSize).Offset(page.Offset()).
+		Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	if rows == nil {
+		rows = make([]model.OrderPay, 0)
+	}
+	return rows, nil
+}
+
+// GetByInstitution 通道②的兼容别名(团队任务书命名;语义完全等同 ListByScope,含越权断言)。
+func (r *OrderPayRepo) GetByInstitution(ctx context.Context, institutionID int64, campusID *int64, page PageQuery) ([]model.OrderPay, error) {
+	return r.ListByScope(ctx, institutionID, campusID, page)
+}
+
+// GetByOrderNo 通道③:系统通道(支付回调 / 网关对账),按 `biz_no` 精确命中。
+//
+// 入参:ctx 请求上下文;orderNo 对外业务单号(biz_no;空 → ErrBadParam)。
+// 出参:*model.OrderPay + ErrNotFound / ErrBadParam / ErrDBError。
+//
+// 命中顺序(回调幂等的双重锚点):
+//  1. `biz_no = ?`(我们下发给网关的单号,主口径);
+//  2. 未命中则退到 `pay_order = ?`(第三方流水号,排障/对账场景;`idx_pay_order` 支撑)。
+//
+// 红线:本通道**不受作用域限制**(回调时无用户上下文),只允许 Service 的支付回调/对账路径调用;
+// 任何面向用户的接口都不得用它取单后直接回显(NULL 机构行会绕过机构边界)。
+func (r *OrderPayRepo) GetByOrderNo(ctx context.Context, orderNo string) (*model.OrderPay, error) {
+	if strings.TrimSpace(orderNo) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetByOrderNo 需要单号"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.OrderPay
+	if err := db.Model(&model.OrderPay{}).Where("biz_no = ?", orderNo).Take(&out).Error; err == nil {
+		return &out, nil
+	} else if !isNotFound(err) {
+		return nil, wrapQueryErr(err, "GetByOrderNo(biz_no)")
+	}
+	out = model.OrderPay{}
+	if err := db.Model(&model.OrderPay{}).Where("pay_order = ?", orderNo).
+		Order("id DESC").Take(&out).Error; err != nil {
+		return nil, wrapQueryErr(err, "GetByOrderNo(pay_order)")
+	}
+	return &out, nil
+}
+
+// Create 创建支付订单(下单)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**:下单 + 后续业务单号回写需同事务);
+//   - order 订单行(BizNo 非空、PayMoney 合法;Status 零值 = 未支付;机构/校区允许 nil)。
+//
+// 出参:nil 成功;ErrDuplicateBizNo(biz_no 撞键 → 幂等命中)/ ErrBadParam / ErrDBError。
+//
+// 红线:`pay_money` 一旦落库不得再改(回调金额与订单金额不一致必须拒绝入账,判定在 Service)。
+func (r *OrderPayRepo) Create(ctx context.Context, tx *gorm.DB, order *model.OrderPay) error {
+	if err := requireTx(tx, "OrderPay.Create"); err != nil {
+		return err
+	}
+	if order == nil || strings.TrimSpace(order.BizNo) == "" {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: Create(t_order_pay) 需要 biz_no"))
+	}
+	if err := normalizeExpenseAmounts(&order.PayMoney); err != nil {
+		return err
+	}
+	if err := tx.WithContext(ctxOrBackground(ctx)).Create(order).Error; err != nil {
+		if IsDupKey(err) {
+			return infraerrors.WithCause(ErrDuplicateBizNo, err)
+		}
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}
+
+// UpdateStatusCAS 状态机 CAS 推进(`WHERE status = from`)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**:状态推进 + 资金入账必须同事务);
+//   - orderNo 对外业务单号(biz_no;空 → ErrBadParam);
+//   - fromStatus 期望当前状态(model.OrderPayStatus*);toStatus 目标状态。
+//
+// 出参:
+//   - int64:受影响行数(**1=本次推进成功**;0=当前状态不等于 fromStatus 或单号不存在 → 幂等返回);
+//   - ErrBadParam(状态组合非法)/ ErrDBError。
+//
+// SQL:`UPDATE t_order_pay SET status = ? WHERE biz_no = ? AND status = ?`。
+//
+// 红线:回调重复投递必须 affected=0(**不得**无条件 UPDATE,否则重复回调会把"已退款/已取消"
+// 的订单重新推成成功);状态机单向(成功不可回退)由 Service 校验 from/to 组合,本方法只做条件更新。
+func (r *OrderPayRepo) UpdateStatusCAS(ctx context.Context, tx *gorm.DB, orderNo string, fromStatus, toStatus int8) (int64, error) {
+	if err := requireTx(tx, "OrderPay.UpdateStatusCAS"); err != nil {
+		return 0, err
+	}
+	if strings.TrimSpace(orderNo) == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: UpdateStatusCAS 需要单号"))
+	}
+	if !validOrderPayStatus(fromStatus) || !validOrderPayStatus(toStatus) {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: UpdateStatusCAS 状态值非法"))
+	}
+	res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.OrderPay{}).
+		Where("biz_no = ? AND status = ?", orderNo, fromStatus).
+		UpdateColumn("status", toStatus)
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// validOrderPayStatus 状态值白名单(-2 失败 / -1 取消 / 0 未支付 / 1 成功)。
+func validOrderPayStatus(status int8) bool {
+	switch status {
+	case model.OrderPayStatusFailed, model.OrderPayStatusCancelled,
+		model.OrderPayStatusUnpaid, model.OrderPayStatusSucceeded:
+		return true
+	default:
+		return false
+	}
+}
+
+// isNotFound 判断错误是否为"记录不存在"(供 GetByOrderNo 的两段式命中使用)。
+func isNotFound(err error) bool {
+	if err == nil {
+		return false
+	}
+	if stderrors.Is(err, gorm.ErrRecordNotFound) {
+		return true
+	}
+	var appErr *infraerrors.AppError
+	if stderrors.As(err, &appErr) {
+		return appErr.Code == infraerrors.CodeNotFound
+	}
+	return false
+}
+
+// MoneyFromString 把 model 层的金额字符串转 money.Amount(调用方高频使用的薄封装)。
+//
+// 入参:raw string(decimal(12,2) 原文,可空串 → 0)。出参:money.Amount + error(格式非法)。
+//
+// 说明:model 层金额列一律 `string`(结构稳定),金额运算集中在 money 包;本函数避免
+// 调用方在每个字段上重复写 `if raw == "" {...}` 的样板判断。
+func MoneyFromString(raw string) (money.Amount, error) {
+	if strings.TrimSpace(raw) == "" {
+		return 0, nil
+	}
+	return money.Parse(raw)
+}

+ 307 - 0
internal/academic/repository/order_pay_repo_test.go

@@ -0,0 +1,307 @@
+// OrderPayRepo 单测(AC-03):三通道可见性(含 NULL 行裁定 §2.5)/ CAS 幂等 / 分页 / 幂等单号。
+//
+// 测试栈:glebarez/sqlite 文件库(**只迁 t_order_pay 单表**——sqlite 索引名库级全局,
+// `uk_biz_no` 与工单/支付日志表重名,混迁会撞名,原因见 expense_repo_test.go 注释)。
+package repository
+
+import (
+	"context"
+	"errors"
+	"path/filepath"
+	"testing"
+	"time"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// newOrderPayRepoDB 支付单表文件库。
+func newOrderPayRepoDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_order_pay_repo_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(支付单文件库) 失败: %v", err)
+	}
+	t.Cleanup(func() {
+		if sqlDB, e := db.DB(); e == nil {
+			_ = sqlDB.Close()
+		}
+	})
+	if err := db.AutoMigrate(&model.OrderPay{}); err != nil {
+		t.Fatalf("AutoMigrate(t_order_pay) 失败: %v", err)
+	}
+	return db
+}
+
+// seedOrderPay 插入一条支付单(机构/校区可为 nil,用于覆盖 NULL 行裁定)。
+//
+// createTime 传零值即由 DB 默认值兜底(model 侧 `default:CURRENT_TIMESTAMP`)。
+func seedOrderPay(t *testing.T, db *gorm.DB, bizNo string, inst, campus *int64, userID *string, createTime time.Time) {
+	t.Helper()
+	row := &model.OrderPay{
+		BizNo: bizNo, InstitutionID: inst, CampusID: campus, UserID: userID,
+		PayMoney: "100.00", Status: model.OrderPayStatusUnpaid, CreateTime: createTime,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 支付单 %s 失败: %v", bizNo, err)
+	}
+}
+
+// ptrI64 int64 指针助手(NULL 与 0 语义不同,显式构造便于阅读)。
+func ptrI64(v int64) *int64 { return &v }
+
+// ptrStr 字符串指针助手。
+func ptrStr(v string) *string { return &v }
+
+// TestOrderPayListByUserIncludesNullScope 通道①:付款人主口径——本人付的 NULL 机构行**必须**可见。
+func TestOrderPayListByUserIncludesNullScope(t *testing.T) {
+	db := newOrderPayRepoDB(t)
+	const owner = "01HUSER000000000000000301"
+	const otherUser = "01HUSER000000000000000302"
+	base := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
+
+	seedOrderPay(t, db, "PAY-A", ptrI64(1), ptrI64(2), ptrStr(owner), base)
+	seedOrderPay(t, db, "PAY-B", nil, nil, ptrStr(owner), base.Add(time.Minute)) // 家长代付/历史行:NULL 机构
+	seedOrderPay(t, db, "PAY-C", ptrI64(2), ptrI64(5), ptrStr(otherUser), base)
+
+	repo := NewOrderPayRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	rows, err := repo.ListByUser(context.Background(), owner, PageQuery{PageSize: 10})
+	if err != nil {
+		t.Fatalf("ListByUser 失败: %v", err)
+	}
+	if len(rows) != 2 {
+		t.Fatalf("本人订单数 = %d,期望 2(含 NULL 机构行,且不含他人订单)", len(rows))
+	}
+	if rows[0].BizNo != "PAY-B" || rows[1].BizNo != "PAY-A" {
+		t.Fatalf("排序应为 create_time DESC:%s / %s", rows[0].BizNo, rows[1].BizNo)
+	}
+	nullSeen := false
+	for _, r := range rows {
+		if r.InstitutionID == nil {
+			nullSeen = true
+		}
+	}
+	if !nullSeen {
+		t.Fatal("付款人通道必须返回 NULL 机构行(§2.5:钱是本人付的)")
+	}
+}
+
+// TestOrderPayListByScopeExcludesNullRows 通道②:显式机构参数——NULL 行**不得**出现;非超管越权拒绝。
+func TestOrderPayListByScopeExcludesNullRows(t *testing.T) {
+	db := newOrderPayRepoDB(t)
+	const user = "01HUSER000000000000000303"
+	seedOrderPay(t, db, "PAY-D", ptrI64(1), ptrI64(2), ptrStr(user), time.Time{})
+	seedOrderPay(t, db, "PAY-E", nil, nil, ptrStr(user), time.Time{}) // NULL 行:institution/campus 都空
+	seedOrderPay(t, db, "PAY-F", ptrI64(2), ptrI64(5), ptrStr(user), time.Time{})
+
+	ctx := context.Background()
+	repo := NewOrderPayRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+
+	all, err := repo.ListByScope(ctx, 1, nil, PageQuery{PageSize: 10})
+	if err != nil {
+		t.Fatalf("ListByScope(inst=1) 失败: %v", err)
+	}
+	if len(all) != 1 || all[0].BizNo != "PAY-D" {
+		t.Fatalf("机构 1 只应有 1 条非 NULL 行,实得 %d:%v", len(all), all)
+	}
+
+	withCampus, err := repo.ListByScope(ctx, 1, ptrI64(2), PageQuery{PageSize: 10})
+	if err != nil {
+		t.Fatalf("ListByScope(inst=1,campus=2) 失败: %v", err)
+	}
+	if len(withCampus) != 1 || withCampus[0].BizNo != "PAY-D" {
+		t.Fatalf("机构 1 校区 2 只应有 1 条,实得 %d", len(withCampus))
+	}
+
+	emptyCampus, err := repo.ListByScope(ctx, 1, ptrI64(9), PageQuery{PageSize: 10})
+	if err != nil {
+		t.Fatalf("ListByScope(inst=1,campus=9) 失败: %v", err)
+	}
+	if emptyCampus == nil || len(emptyCampus) != 0 {
+		t.Fatalf("空结果必须是非 nil 空切片:%v", emptyCampus)
+	}
+
+	// 非超管借参数跨机构 → ErrUnscoped
+	if _, err := repo.ListByScope(ctx, 2, nil, PageQuery{}); !errors.Is(err, infraerrors.ErrUnscoped) {
+		t.Fatalf("跨机构参数错误 = %v,期望 ErrUnscoped", err)
+	}
+	// 机构参数非法
+	if _, err := repo.ListByScope(ctx, 0, nil, PageQuery{}); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("institution_id=0 错误 = %v,期望 ErrBadParam", err)
+	}
+	// nil 作用域(系统任务)不得走参数通道
+	sys := NewOrderPayRepo(db, nil)
+	if _, err := sys.ListByScope(ctx, 1, nil, PageQuery{}); !errors.Is(err, infraerrors.ErrUnscoped) {
+		t.Fatalf("nil 作用域错误 = %v,期望 ErrUnscoped", err)
+	}
+	// 超管可看任意机构
+	admin := NewOrderPayRepo(db, &appscope.Scope{SuperAdmin: true, InstitutionID: 9})
+	adminRows, err := admin.ListByScope(ctx, 2, nil, PageQuery{PageSize: 10})
+	if err != nil {
+		t.Fatalf("超管查询失败: %v", err)
+	}
+	if len(adminRows) != 1 || adminRows[0].BizNo != "PAY-F" {
+		t.Fatalf("超管应看到机构 2 的 1 条,实得 %d", len(adminRows))
+	}
+}
+
+// TestOrderPayGetByOrderNo 通道③:系统通道按 biz_no 命中;pay_order 兜底;不存在 → ErrNotFound。
+func TestOrderPayGetByOrderNo(t *testing.T) {
+	db := newOrderPayRepoDB(t)
+	const user = "01HUSER000000000000000304"
+	seedOrderPay(t, db, "PAY-G", nil, nil, ptrStr(user), time.Time{})
+	if err := db.Model(&model.OrderPay{}).Where("biz_no = ?", "PAY-G").
+		UpdateColumn("pay_order", "4200001234").Error; err != nil {
+		t.Fatalf("回填 pay_order 失败: %v", err)
+	}
+
+	sys := NewOrderPayRepo(db, nil) // 回调路径:无请求作用域
+	ctx := context.Background()
+
+	byBiz, err := sys.GetByOrderNo(ctx, "PAY-G")
+	if err != nil {
+		t.Fatalf("按 biz_no 查询失败: %v", err)
+	}
+	if byBiz.BizNo != "PAY-G" || byBiz.InstitutionID != nil {
+		t.Fatalf("命中结果不符:%s / %v", byBiz.BizNo, byBiz.InstitutionID)
+	}
+
+	byPayOrder, err := sys.GetByOrderNo(ctx, "4200001234")
+	if err != nil {
+		t.Fatalf("按第三方流水号兜底查询失败: %v", err)
+	}
+	if byPayOrder.BizNo != "PAY-G" {
+		t.Fatalf("兜底命中结果不符:%s", byPayOrder.BizNo)
+	}
+
+	if _, err := sys.GetByOrderNo(ctx, "PAY-NOT-EXIST"); !errors.Is(err, infraerrors.ErrNotFound) {
+		t.Fatalf("不存在单号错误 = %v,期望 ErrNotFound", err)
+	}
+	if _, err := sys.GetByOrderNo(ctx, "  "); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("空单号错误 = %v,期望 ErrBadParam", err)
+	}
+}
+
+// TestOrderPayUpdateStatusCASIdempotent CAS:首次成功、重复调用 affected=0、非法状态拒绝。
+func TestOrderPayUpdateStatusCASIdempotent(t *testing.T) {
+	db := newOrderPayRepoDB(t)
+	repo := NewOrderPayRepo(db, nil)
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+	const user = "01HUSER000000000000000305"
+
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		return repo.Create(ctx, tx, &model.OrderPay{
+			BizNo: "PAY-H", UserID: ptrStr(user), InstitutionID: ptrI64(1), CampusID: ptrI64(2),
+			PayMoney: "88.00", Status: model.OrderPayStatusUnpaid,
+		})
+	}); err != nil {
+		t.Fatalf("创建支付单失败: %v", err)
+	}
+
+	// 首次回调:0 → 1
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		affected, e := repo.UpdateStatusCAS(ctx, tx, "PAY-H",
+			model.OrderPayStatusUnpaid, model.OrderPayStatusSucceeded)
+		if e != nil {
+			return e
+		}
+		if affected != 1 {
+			t.Fatalf("首次 CAS affected = %d,期望 1", affected)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("首次 CAS 失败: %v", err)
+	}
+
+	// 重复回调:affected=0(幂等,禁止重复入账)
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		affected, e := repo.UpdateStatusCAS(ctx, tx, "PAY-H",
+			model.OrderPayStatusUnpaid, model.OrderPayStatusSucceeded)
+		if e != nil {
+			return e
+		}
+		if affected != 0 {
+			t.Fatalf("重复 CAS affected = %d,期望 0", affected)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("重复 CAS 失败: %v", err)
+	}
+
+	// 不存在的单号:affected=0(不报错,由 Service 判 404/幂等)
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		affected, e := repo.UpdateStatusCAS(ctx, tx, "PAY-NONE",
+			model.OrderPayStatusUnpaid, model.OrderPayStatusSucceeded)
+		if e != nil {
+			return e
+		}
+		if affected != 0 {
+			t.Fatalf("不存在单号 affected = %d,期望 0", affected)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("不存在单号用例失败: %v", err)
+	}
+
+	// 非法状态值 → ErrBadParam
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.UpdateStatusCAS(ctx, tx, "PAY-H", model.OrderPayStatusSucceeded, 9)
+		if !errors.Is(e, infraerrors.ErrBadParam) {
+			t.Fatalf("非法状态错误 = %v,期望 ErrBadParam", e)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("非法状态用例失败: %v", err)
+	}
+}
+
+// TestOrderPayCreateDuplicateBizNo 下单幂等:biz_no 撞键 → ErrDuplicateBizNo(3003)。
+func TestOrderPayCreateDuplicateBizNo(t *testing.T) {
+	db := newOrderPayRepoDB(t)
+	repo := NewOrderPayRepo(db, nil)
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	order := &model.OrderPay{BizNo: "PAY-I", PayMoney: "1.00", Status: model.OrderPayStatusUnpaid}
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.Create(ctx, tx, order) }); err != nil {
+		t.Fatalf("创建支付单失败: %v", err)
+	}
+	dup := &model.OrderPay{BizNo: "PAY-I", PayMoney: "1.00", Status: model.OrderPayStatusUnpaid}
+	err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.Create(ctx, tx, dup) })
+	if !errors.Is(err, ErrDuplicateBizNo) {
+		t.Fatalf("重复单号错误 = %v,期望 ErrDuplicateBizNo", err)
+	}
+	// 缺 biz_no → ErrBadParam
+	bad := &model.OrderPay{PayMoney: "1.00"}
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.Create(ctx, tx, bad) }); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("缺 biz_no 错误 = %v,期望 ErrBadParam", err)
+	}
+}
+
+// TestOrderPayListPagingEmpty 分页:空结果非 nil;pageSize 钳制 200。
+func TestOrderPayListPagingEmpty(t *testing.T) {
+	db := newOrderPayRepoDB(t)
+	repo := NewOrderPayRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	ctx := context.Background()
+
+	empty, err := repo.ListByUser(ctx, "01HUSER000000000000000399", PageQuery{})
+	if err != nil {
+		t.Fatalf("空结果查询失败: %v", err)
+	}
+	if empty == nil || len(empty) != 0 {
+		t.Fatalf("空结果必须是非 nil 空切片:%v", empty)
+	}
+	if got := (PageQuery{PageSize: 1000}).Limit(); got != MaxPageSize {
+		t.Fatalf("pageSize 钳制 = %d,期望 %d", got, MaxPageSize)
+	}
+	// 空 user_id → ErrBadParam
+	if _, err := repo.ListByUser(ctx, " ", PageQuery{}); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("空 user_id 错误 = %v,期望 ErrBadParam", err)
+	}
+}