// 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 }