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