// FundService 单测(AC-04):充值双表 / 幂等(含 20 并发)/ 消费三列组合 / 退费 BUG#2 / // 可退金额 / 坏账 / 累计消费 / 落库金额越界负例。 // // 测试栈(与 AC-03 仓储测试同一套手法): // - 行为用例:glebarez/sqlite 文件库(真实事务、真实唯一键 uk_request_type); // - SQL 顺序用例:给 GORM 挂"抓 SQL"的 logger(sqlRecorder),断言仓储**真实生成**的语句顺序; // - 索引纪律:academic model 多表共用 `idx_inst_campus` 等索引名,SQLite 的索引名是**库级**唯一 // (MySQL 表级),故 migrateFundTables 逐表迁移后**只删非唯一索引**—— // `uk_request_type` / `uk_user_activity` / `uk_user_inst_campus` 必须保留, // 否则幂等用例会静默变成"永远不撞键"的假绿。 package service import ( "context" "errors" "fmt" "path/filepath" "strings" "sync" "testing" "time" gormsqlite "github.com/glebarez/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "yuansiwei/unified-backend/internal/academic/apperr" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" "yuansiwei/unified-backend/internal/academic/port" "yuansiwei/unified-backend/internal/academic/repository" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // ---------------------------------------------------------------- 测试基础设施 // sqlRecorder GORM logger:按执行顺序收集 SQL(不改写、不过滤)。 // // 用途:断言"全局账户锁先于校区账户锁"这类**顺序性**红约束(顺序错 = 与并发充值交叉即成环)。 // 只实现 logger.Interface 的最小集合(Trace 收到 Explain 后的完整 SQL)。 type sqlRecorder struct { mu sync.Mutex stmts []string } // Printf 收口(logger.Writer 兼容方法)。 func (r *sqlRecorder) Printf(format string, args ...any) { msg := strings.TrimSpace(fmt.Sprintf(format, args...)) if msg == "" { return } r.mu.Lock() defer r.mu.Unlock() r.stmts = append(r.stmts, msg) } // LogMode 实现 logger.Interface(不做级别过滤)。 func (r *sqlRecorder) LogMode(logger.LogLevel) logger.Interface { return r } // Info 实现 logger.Interface(单测不需要普通日志)。 func (r *sqlRecorder) Info(context.Context, string, ...any) {} // Warn 实现 logger.Interface。 func (r *sqlRecorder) Warn(context.Context, string, ...any) {} // Error 实现 logger.Interface。 func (r *sqlRecorder) Error(context.Context, string, ...any) {} // Trace 实现 logger.Interface:把本次语句追加进 stmts。 func (r *sqlRecorder) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) { sql, _ := fc() r.Printf("%s", sql) } // Snapshot 返回当前收集到的语句快照(调用方不得修改返回切片内部元素语义)。 func (r *sqlRecorder) Snapshot() []string { r.mu.Lock() defer r.mu.Unlock() out := make([]string, len(r.stmts)) copy(out, r.stmts) return out } // Reset 清空收集。 func (r *sqlRecorder) Reset() { r.mu.Lock() defer r.mu.Unlock() r.stmts = nil } // migrateFundTables 逐表 AutoMigrate,并**只删除非唯一索引**。 // // 为什么只删非唯一:SQLite 索引名库级唯一(`idx_inst_campus` 同时出现在 // t_user_expense_school 与 t_courses_activity_signed),一次性迁移会报 already exists; // 但 `uk_request_type` / `uk_user_activity` / `uk_user_inst_campus` 是**幂等与唯一性的实现载体**, // 删掉会让"并发重复提交只落 1 条流水"的用例变成永远通过的假绿。 func migrateFundTables(t *testing.T, db *gorm.DB, tables ...any) { t.Helper() for _, tbl := range tables { if err := db.AutoMigrate(tbl); err != nil { t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err) } stmt := &gorm.Statement{DB: db} if err := stmt.Parse(tbl); err != nil { t.Fatalf("解析表名(%T) 失败: %v", tbl, err) } type idxRow struct { Name string SQL *string } var rows []idxRow if err := db.Raw( "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'", stmt.Schema.Table).Scan(&rows).Error; err != nil { t.Fatalf("读取索引清单(%s) 失败: %v", stmt.Schema.Table, err) } for _, row := range rows { sqlText := "" if row.SQL != nil { sqlText = strings.ToUpper(*row.SQL) } if strings.Contains(sqlText, "UNIQUE") { continue // 唯一约束必须保留(幂等键/业务 UK) } if err := db.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; err != nil { t.Fatalf("删除索引(%s) 失败: %v", row.Name, err) } } } } // newFundServiceDB 余额域测试库(余额 3 表 + 活动 2 表 + 简化 t_campus)。 // // t_campus 用原生 DDL 建(academic 侧只有 port 的只读 row struct,不参与 AutoMigrate, // 这正是设计 §1.3「跨域只读」的落地形态:academic 不建 org 的表,只读)。 func newFundServiceDB(t *testing.T) *gorm.DB { t.Helper() db, _ := newFundServiceDBWithRecorder(t) return db } // newFundServiceDBWithRecorder 同 newFundServiceDB,并返回 SQL 记录器。 func newFundServiceDBWithRecorder(t *testing.T) (*gorm.DB, *sqlRecorder) { t.Helper() dsn := filepath.Join(t.TempDir(), "academic_fund_service_test.db") rec := &sqlRecorder{} db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{Logger: rec}) if err != nil { t.Fatalf("gorm.Open(余额域文件库) 失败: %v", err) } sqlDB, err := db.DB() if err != nil { t.Fatalf("取 sql.DB 失败: %v", err) } // 并发用例需要"多 goroutine 抢同一库":SQLite 写事务必须串行, // 单连接池即可让 20 个 goroutine 依次推进(生产 MySQL 是行锁串行,此处用连接池串行等价模拟)。 sqlDB.SetMaxOpenConns(1) t.Cleanup(func() { _ = sqlDB.Close() }) migrateFundTables(t, db, &model.UserExpense{}, &model.UserExpenseSchool{}, &model.UserExpenseLog{}, &model.CoursesActivity{}, &model.CoursesActivitySigned{}, // t_user_balance_request:SP5 可退额第二步(已用赠送额)要按「充值工单 × 活动报名」汇总, // 缺表会让 SP5 用例退化成"永远查不到赠送额"的假绿。 &model.UserBalanceRequest{}, ) // 跨域只读表(org 域 t_campus 的最小列集;self_support 为 1=自营 / 0=加盟) if err := db.Exec(`CREATE TABLE t_campus ( campus_id INTEGER PRIMARY KEY, institution_id INTEGER NOT NULL DEFAULT 0, self_support BOOLEAN NOT NULL DEFAULT 1, status VARCHAR(32) NOT NULL DEFAULT 'active' )`).Error; err != nil { t.Fatalf("建 t_campus 失败: %v", err) } return db, rec } // fundUserID 生成 26 位类 ULID 的测试用户 ID(列宽 varchar(26))。 func fundUserID(i int) string { return fmt.Sprintf("01HUSER%019d", i) } // fundScope 构造普通校区作用域(机构 1 / 校区 10)。 func fundScope() *appscope.Scope { return &appscope.Scope{InstitutionID: 1, CampusID: 10, UserID: "01HOPERATOR0000000000001", Role: "finance"} } // fundScopeFor 构造指定机构/校区的作用域(跨机构用例)。 func fundScopeFor(institutionID, campusID int64) *appscope.Scope { return &appscope.Scope{InstitutionID: institutionID, CampusID: campusID, UserID: "01HOPERATOR0000000000001", Role: "finance"} } // fundSuperScope 平台超管作用域(跨机构视角,用于"必须能同时看到两个机构"的用例)。 func fundSuperScope() *appscope.Scope { return &appscope.Scope{InstitutionID: 1, CampusID: 0, UserID: "01HSUPERADMIN0000000001", Role: "super_admin", SuperAdmin: true} } // mustAmount 解析金额(测试内固定字面量)。 func mustAmount(t *testing.T, s string) money.Amount { t.Helper() a, err := money.Parse(s) if err != nil { t.Fatalf("金额解析失败 %q: %v", s, err) } return a } // seedCampus 落一行 t_campus(self_support: true=自营 / false=加盟)。 func seedCampus(t *testing.T, db *gorm.DB, campusID, institutionID int64, selfSupport bool) { t.Helper() if err := db.Exec( "INSERT INTO t_campus(campus_id, institution_id, self_support, status) VALUES(?,?,?,?)", campusID, institutionID, selfSupport, "active").Error; err != nil { t.Fatalf("seed t_campus(%d) 失败: %v", campusID, err) } } // readGlobal 读全局钱包。 func readGlobal(t *testing.T, db *gorm.DB, userID string) model.UserExpense { t.Helper() var row model.UserExpense if err := db.Where("user_id = ?", userID).Take(&row).Error; err != nil { t.Fatalf("读全局账户(%s) 失败: %v", userID, err) } return row } // readSchool 读校区账户。 func readSchool(t *testing.T, db *gorm.DB, userID string, institutionID, campusID int64) model.UserExpenseSchool { t.Helper() var row model.UserExpenseSchool if err := db.Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID). Take(&row).Error; err != nil { t.Fatalf("读校区账户(%s/%d/%d) 失败: %v", userID, institutionID, campusID, err) } return row } // logsByType 取某用户的指定 type 流水(按 id ASC)。 func logsByType(t *testing.T, db *gorm.DB, userID string, typ int32) []model.UserExpenseLog { t.Helper() var rows []model.UserExpenseLog if err := db.Where("user_id = ? AND type = ?", userID, typ).Order("id ASC").Find(&rows).Error; err != nil { t.Fatalf("读流水(type=%d) 失败: %v", typ, err) } return rows } // countLogs 统计某用户指定 type 的流水条数。 func countLogsByType(t *testing.T, db *gorm.DB, userID string, typ int32) int { t.Helper() return len(logsByType(t, db, userID, typ)) } // ---------------------------------------------------------------- 充值(SP1 / SP2) // TestRechargeUpdatesBothTables 充值双表同事务:全局 + 校区 balance/total_income 同步,流水含 balance_after。 func TestRechargeUpdatesBothTables(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) ctx := context.Background() user := fundUserID(1) res, err := svc.Recharge(ctx, RechargeInput{ UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00"), RequestID: "REQ-RECHARGE-1", }) if err != nil { t.Fatalf("Recharge 失败: %v", err) } if res.Repeated { t.Fatalf("首次充值不应 repeated=true") } if res.LogID <= 0 { t.Fatalf("logId 应 > 0,实得 %d", res.LogID) } if got := cents(t, res.Balance.String()); got != 10000 { t.Fatalf("返回余额=%d 分,期望 10000", got) } global := readGlobal(t, db, user) if got := cents(t, global.Balance); got != 10000 { t.Fatalf("全局 balance=%d 分,期望 10000", got) } if got := cents(t, global.TotalIncome); got != 10000 { t.Fatalf("全局 total_income=%d 分,期望 10000", got) } school := readSchool(t, db, user, 1, 10) if got := cents(t, school.Balance); got != 10000 { t.Fatalf("校区 balance=%d 分,期望 10000", got) } if got := cents(t, school.TotalIncome); got != 10000 { t.Fatalf("校区 total_income=%d 分,期望 10000", got) } logs := logsByType(t, db, user, model.ExpenseLogTypeRecharge) if len(logs) != 1 { t.Fatalf("充值流水应为 1 条,实得 %d", len(logs)) } if got := cents(t, logs[0].Money); got != 10000 { t.Fatalf("流水 money=%d 分,期望 10000(方向为正)", got) } if logs[0].RequestID == nil || *logs[0].RequestID != "REQ-RECHARGE-1" { t.Fatalf("流水 request_id 未落:%+v", logs[0].RequestID) } if logs[0].BalanceAfter == nil || cents(t, *logs[0].BalanceAfter) != 10000 { t.Fatalf("流水 balance_after 快照错误:%+v", logs[0].BalanceAfter) } if logs[0].Remark == nil || *logs[0].Remark != defaultRechargeRemark { t.Fatalf("流水 remark 默认文案错误:%+v", logs[0].Remark) } } // TestRechargeKindSetsSchoolRemark SP1 / SP2 的唯一差异:默认备注文案。 func TestRechargeKindSetsSchoolRemark(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) ctx := context.Background() user := fundUserID(2) if _, err := svc.Recharge(ctx, RechargeInput{ UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "10.00"), RequestID: "REQ-KIND-1", Kind: RechargeKindSchool, }); err != nil { t.Fatalf("Recharge(Kind=School) 失败: %v", err) } logs := logsByType(t, db, user, model.ExpenseLogTypeRecharge) if len(logs) != 1 || logs[0].Remark == nil || *logs[0].Remark != defaultSchoolRechargeRemark { t.Fatalf("SP2 默认备注应为 %q,实得 %+v", defaultSchoolRechargeRemark, logs) } } // TestRechargeIdempotentSameRequestID 同 requestId 第二次提交 → repeated=true、不重复入账。 func TestRechargeIdempotentSameRequestID(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) ctx := context.Background() user := fundUserID(3) in := RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "50.00"), RequestID: "REQ-IDEMPOTENT-1"} first, err := svc.Recharge(ctx, in) if err != nil { t.Fatalf("首次充值失败: %v", err) } second, err := svc.Recharge(ctx, in) if err != nil { t.Fatalf("重复充值应返回成功(幂等),实得错误: %v", err) } if !second.Repeated { t.Fatalf("重复提交应 repeated=true") } if second.LogID != first.LogID { t.Fatalf("重复提交应回放首次 logId=%d,实得 %d", first.LogID, second.LogID) } if count := countLogsByType(t, db, user, model.ExpenseLogTypeRecharge); count != 1 { t.Fatalf("流水应只 1 条,实得 %d", count) } if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 5000 { t.Fatalf("余额应只变一次(5000 分),实得 %d", got) } } // TestRechargeConcurrentSameRequestID 20 goroutine 同 requestId → 1 条流水、余额只变一次。 // // SQLite 无行锁,用单连接池把写事务串行化(等价于 MySQL 的行锁串行语义); // 断言点与生产一致:**流水条数**与**余额增量**(不是"是否报错")。 func TestRechargeConcurrentSameRequestID(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) ctx := context.Background() user := fundUserID(4) in := RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "30.00"), RequestID: "REQ-CONCURRENT-1"} const goroutines = 20 var wg sync.WaitGroup results := make([]*RechargeResult, goroutines) errs := make([]error, goroutines) for i := 0; i < goroutines; i++ { wg.Add(1) go func(idx int) { defer wg.Done() results[idx], errs[idx] = svc.Recharge(ctx, in) }(i) } wg.Wait() for i, err := range errs { if err != nil { t.Fatalf("并发第 %d 次充值失败: %v", i, err) } } if count := countLogsByType(t, db, user, model.ExpenseLogTypeRecharge); count != 1 { t.Fatalf("并发同 requestId 应只 1 条流水,实得 %d", count) } if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 3000 { t.Fatalf("余额应只入账一次(3000 分),实得 %d", got) } repeated := 0 for _, r := range results { if r != nil && r.Repeated { repeated++ } } if repeated != goroutines-1 { t.Fatalf("应有 %d 次 repeated=true,实得 %d", goroutines-1, repeated) } } // TestRechargeValidation 入参校验:空用户 2001 / 校区缺失 3140 / 金额<=0 与超上限 2002。 func TestRechargeValidation(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) ctx := context.Background() cases := []struct { name string in RechargeInput want int }{ {"空用户", RechargeInput{UserID: "", InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "1.00")}, 2001}, {"校区缺失", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 0, Money: mustAmount(t, "1.00")}, 3140}, {"机构缺失", RechargeInput{UserID: fundUserID(5), InstitutionID: 0, CampusID: 10, Money: mustAmount(t, "1.00")}, 3140}, {"金额为 0", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10, Money: 0}, 2002}, {"金额为负", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "-1.00")}, 2002}, {"超单笔上限", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "1000000.01")}, 2002}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := svc.Recharge(ctx, tc.in) requireAppCode(t, err, tc.want) }) } } // ---------------------------------------------------------------- 消费(SP3) // TestExpenditureTypeBranches dump 的两个列组合分支 + 流水方向为负。 func TestExpenditureTypeBranches(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) ctx := context.Background() user := fundUserID(6) if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00")}); err != nil { t.Fatalf("准备余额失败: %v", err) } // type=200(历史扣费):balance−、total_expenditure+、spend+ if _, err := svc.Expenditure(ctx, ExpenditureInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "30.00"), Type: model.ExpenseLogTypeHistoryDeduct, RequestID: "REQ-EXP-200"}); err != nil { t.Fatalf("Expenditure(200) 失败: %v", err) } school := readSchool(t, db, user, 1, 10) if got := cents(t, school.Balance); got != 7000 { t.Fatalf("200 后 balance 应 7000 分,实得 %d", got) } if got := cents(t, school.TotalExpenditure); got != 3000 { t.Fatalf("200 后 total_expenditure 应 3000 分,实得 %d", got) } if got := cents(t, school.Spend); got != 3000 { t.Fatalf("200 后 spend 应 3000 分,实得 %d", got) } // type=201(校区报课消费):balance−、total_expenditure+,**spend 不动** if _, err := svc.Expenditure(ctx, ExpenditureInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "20.00"), Type: model.ExpenseLogTypeCourseCampusB, RequestID: "REQ-EXP-201"}); err != nil { t.Fatalf("Expenditure(201) 失败: %v", err) } school = readSchool(t, db, user, 1, 10) if got := cents(t, school.Balance); got != 5000 { t.Fatalf("201 后 balance 应 5000 分,实得 %d", got) } if got := cents(t, school.TotalExpenditure); got != 5000 { t.Fatalf("201 后 total_expenditure 应 5000 分,实得 %d", got) } if got := cents(t, school.Spend); got != 3000 { t.Fatalf("201 不应改 spend(应仍 3000 分),实得 %d", got) } // 全局表同列同步 global := readGlobal(t, db, user) if got := cents(t, global.Balance); got != 5000 { t.Fatalf("全局 balance 应 5000 分,实得 %d", got) } if got := cents(t, global.TotalExpenditure); got != 5000 { t.Fatalf("全局 total_expenditure 应 5000 分,实得 %d", got) } // 流水金额恒为负(dump: `VALUES(...,-inputMoney,...)`) logs := logsByType(t, db, user, model.ExpenseLogTypeHistoryDeduct) if len(logs) != 1 { t.Fatalf("200 流水应 1 条,实得 %d", len(logs)) } if got := cents(t, logs[0].Money); got != -3000 { t.Fatalf("200 流水 money 应 -3000 分,实得 %d", got) } } // TestExpenditureInvalidType 白名单外 type → 2002(dump 会静默改成 200,Go 显式拒绝)。 func TestExpenditureInvalidType(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) _, err := svc.Expenditure(context.Background(), ExpenditureInput{ UserID: fundUserID(7), InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "10.00"), Type: 999, }) requireAppCode(t, err, 2002) } // ---------------------------------------------------------------- 退费(SP4,BUG#2) // TestRefundInstitutionFilterPreventsCrossInstitutionUpdate BUG#2 回归: // 同一 campus_id 在两个机构都有账户行时,只允许更新入参机构那一行。 // // dump 的 WHERE 是 `userid + campusid`(无 schoolid)→ 两行都会被改;Go 补 institution_id。 func TestRefundInstitutionFilterPreventsCrossInstitutionUpdate(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundSuperScope(), nil) // 超管视角:底层两行都可见 ctx := context.Background() user := fundUserID(8) // 两个机构、同一个 campus_id=10 for _, inst := range []int64{1, 2} { if err := db.Create(&model.UserExpenseSchool{ UserID: user, InstitutionID: inst, CampusID: 10, Balance: "100.00", TotalExpenditure: "50.00", }).Error; err != nil { t.Fatalf("seed 校区账户(inst=%d) 失败: %v", inst, err) } } if err := db.Create(&model.UserExpense{UserID: user, Balance: "200.00", TotalExpenditure: "50.00"}).Error; err != nil { t.Fatalf("seed 全局账户失败: %v", err) } res, err := svc.Refund(ctx, RefundInput{ UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "20.00"), Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-REFUND-1", }) if err != nil { t.Fatalf("Refund 失败: %v", err) } if !res.Changed { t.Fatalf("Refund 应 changed=true") } if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 12000 { t.Fatalf("目标机构 balance 应 12000 分,实得 %d", got) } // 另一机构必须零变动(BUG#2 的核心断言) if got := cents(t, readSchool(t, db, user, 2, 10).Balance); got != 10000 { t.Fatalf("另一机构 balance 必须不变(10000 分),实得 %d", got) } if got := cents(t, readSchool(t, db, user, 1, 10).TotalExpenditure); got != 3000 { t.Fatalf("目标机构 total_expenditure 应 3000 分(退费回冲),实得 %d", got) } logs := logsByType(t, db, user, model.ExpenseLogTypeCourseCampus) if len(logs) != 1 || cents(t, logs[0].Money) != 2000 { t.Fatalf("退费流水应为 +2000 分一条,实得 %+v", logs) } } // TestRefundRejectsNonPositiveMoney R6(§3.8-b):money<=0 → **2002**,且不写任何流水。 // // 口径变更说明:dump(SP4)对 `inputMoney<=0` 是 `IF inputMoney>0` 静默跳过(本次之前实现为 // changed=false 的 no-op),v0.1.14 的 R6 把五处入口(recharge/expenditure/baddebt/refund/transfer) // 统一为显式 2002,故此处期望值由"成功 + changed=false"改为错误码 2002。 func TestRefundRejectsNonPositiveMoney(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) user := fundUserID(9) for _, amount := range []string{"0.00", "-1.00"} { _, err := svc.Refund(context.Background(), RefundInput{ UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, amount), Type: model.ExpenseLogTypeCourseCampus, }) requireAppCode(t, err, 2002) } if count := countLogsByType(t, db, user, model.ExpenseLogTypeCourseCampus); count != 0 { t.Fatalf("非法金额不得写流水,实得 %d 条", count) } if count := countLogsByType(t, db, user, model.ExpenseLogTypeRecharge); count != 0 { t.Fatalf("非法金额不得写充值流水,实得 %d 条", count) } } // TestNonPositiveMoneyRejectedEverywhere R6 的**五处全覆盖**(charge/expenditure/baddebt/refund/transfer): // money<=0 → 2002,且**零副作用**(不建户、不改账户、不占 `(request_id,type)` 幂等键)。 // // 为什么要显式钉"不占幂等键":dump 的 recharge/expenditure/baddebt 分支会把负值**归 0 后照常记账**, // 留下 money=0 的流水并把 `(request_id,type)` 占死,导致"同键后续真金额被幂等跳过"(资金静默丢失)。 func TestNonPositiveMoneyRejectedEverywhere(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundSuperScope(), nil) ctx := context.Background() user := fundUserID(26) // 预置账户,确保"零副作用"的断言不是"账户本来就不存在"导致的假绿 if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00"), RequestID: "REQ-R6-SEED"}); err != nil { t.Fatalf("准备余额失败: %v", err) } before := readSchool(t, db, user, 1, 10) logsBefore := len(balanceLogs(t, db, user)) cases := []struct { name string call func(amount money.Amount) error }{ {"recharge", func(a money.Amount) error { _, e := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: a, RequestID: "REQ-R6-RECHARGE"}) return e }}, {"expenditure", func(a money.Amount) error { _, e := svc.Expenditure(ctx, ExpenditureInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: a, Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-R6-EXPEND"}) return e }}, {"baddebt", func(a money.Amount) error { _, e := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: a, RequestID: "REQ-R6-BADDEBT"}) return e }}, {"refund", func(a money.Amount) error { _, e := svc.Refund(ctx, RefundInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: a, Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-R6-REFUND"}) return e }}, {"transfer", func(a money.Amount) error { _, e := svc.Transfer(ctx, TransferInput{UserID: user, FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20, Money: a, RequestID: "REQ-R6-TRANSFER"}) return e }}, } for _, amount := range []string{"0.00", "-10.00"} { for _, c := range cases { err := c.call(mustAmount(t, amount)) if err == nil { t.Fatalf("%s(%s) 应返回 2002,实得成功", c.name, amount) } requireAppCode(t, err, 2002) } } after := readSchool(t, db, user, 1, 10) if cents(t, after.Balance) != cents(t, before.Balance) || cents(t, after.TotalIncome) != cents(t, before.TotalIncome) || cents(t, after.TotalExpenditure) != cents(t, before.TotalExpenditure) || cents(t, after.SpecialExpenses) != cents(t, before.SpecialExpenses) { t.Fatalf("R6 拒绝的请求不得改动账户:before=%+v after=%+v", before, after) } if got := len(balanceLogs(t, db, user)); got != logsBefore { t.Fatalf("R6 拒绝的请求不得写流水(幂等键不得被占):before=%d after=%d", logsBefore, got) } // 幂等键未被占死:同键 + 真金额必须能正常入账 if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "10.00"), RequestID: "REQ-R6-RECHARGE"}); err != nil { t.Fatalf("被拒请求不得占死幂等键,同键真金额应能入账: %v", err) } if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 11000 { t.Fatalf("真金额入账后余额应 11000 分,实得 %d", got) } } // TestRefundAccountMissing 账户未建户 → 3001(dump 静默无操作;Go 显式失败)。 func TestRefundAccountMissing(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) _, err := svc.Refund(context.Background(), RefundInput{ UserID: fundUserID(10), InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "5.00"), Type: model.ExpenseLogTypeCourseCampus, }) requireAppCode(t, err, 3001) } // ---------------------------------------------------------------- 可退金额(SP5,两步修正) // seedExpenseAccounts 落全局 + 校区两个账户行(SP5 第一步截断需要两张表同时有值)。 func seedExpenseAccounts(t *testing.T, db *gorm.DB, userID, campusBalance, globalBalance string) { t.Helper() if err := db.Create(&model.UserExpense{UserID: userID, Balance: globalBalance}).Error; err != nil { t.Fatalf("seed 全局账户(%s) 失败: %v", userID, err) } if err := db.Create(&model.UserExpenseSchool{UserID: userID, InstitutionID: 1, CampusID: 10, Balance: campusBalance}).Error; err != nil { t.Fatalf("seed 校区账户(%s) 失败: %v", userID, err) } } // seedGiftUsed 落一对「充值工单 + 活动报名」行(SP5 第二步「已用赠送额」的唯一数据来源)。 // // 关键映射(照 dump 改写的依据):工单的 `id`(旧 UUID)→ 目标态 `biz_no`; // 报名的 `requestid` → 目标态 `request_no`;JOIN 走 `biz_no = request_no`。 func seedGiftUsed(t *testing.T, db *gorm.DB, userID, bizNo string, activityID int64, approved int8, activityMoney, activityMoneyUsed string) { t.Helper() if err := db.Create(&model.UserBalanceRequest{ BizNo: bizNo, Type: model.BalanceRequestTypeRecharge, UserID: userID, InstitutionID: 1, CampusID: 10, Approved: approved, }).Error; err != nil { t.Fatalf("seed 充值工单(%s) 失败: %v", bizNo, err) } reqNo := bizNo if err := db.Create(&model.CoursesActivitySigned{ ActivityID: activityID, UserID: userID, InstitutionID: 1, CampusID: 10, ActivityMoney: activityMoney, ActivityMoneyUsed: activityMoneyUsed, RequestNo: &reqNo, }).Error; err != nil { t.Fatalf("seed 活动报名(%s) 失败: %v", bizNo, err) } } // TestRefundableAmount SP5 门禁用例(§8 AC-04 ⑦ 的 5 组表驱动 + 退化分支)。 // // 五组必须区分得开"单步实现"与"三步实现"(原 case 组区分不出,正是错口径活到交付的原因): // // ① 基线无赠送 → 只有"读余额"也能过 // ② 截断 → 只有实现 ① 截断才能过(campus 200 > total 120) // ③ 减项 → 只有实现 ③ 减赠送额才能过(100 − 20 = 80) // ④ 归零 → 减项后为负必须取 0(不是负数透出) // ⑤ 截断+减项叠加 → 只有两步都实现且**顺序正确**才能过(min(200,120) − 30 = 90; // 若先减后截、或漏截断,会得到 170 / 150 / 70 等错值) func TestRefundableAmount(t *testing.T) { const ( inst = int64(1) camp = int64(10) ) type gift struct { BizNo string Activity int64 Approved int8 Money string MoneyUsed string } cases := []struct { name string campusBal *string // nil = 校区账户不存在 globalBal *string // nil = 全局账户不存在 gifts []gift wantCents int64 }{ {name: "①基线_无赠送", campusBal: strPtr("88.50"), globalBal: strPtr("100.00"), wantCents: 8850}, {name: "②截断_campus>total", campusBal: strPtr("200.00"), globalBal: strPtr("120.00"), wantCents: 12000}, {name: "③减项_赠送已用", campusBal: strPtr("100.00"), globalBal: strPtr("150.00"), gifts: []gift{{"BR-SP5-3", 3, model.BalanceRequestApprovedPassed, "50.00", "20.00"}}, wantCents: 8000}, {name: "④归零_赠送>截断基数", campusBal: strPtr("50.00"), globalBal: strPtr("50.00"), gifts: []gift{{"BR-SP5-4", 4, model.BalanceRequestApprovedPassed, "90.00", "80.00"}}, wantCents: 0}, {name: "⑤截断+减项叠加", campusBal: strPtr("200.00"), globalBal: strPtr("120.00"), gifts: []gift{{"BR-SP5-5", 5, model.BalanceRequestApprovedPassed, "100.00", "30.00"}}, wantCents: 9000}, {name: "⑥赠送用尽不参与", campusBal: strPtr("100.00"), globalBal: strPtr("100.00"), gifts: []gift{{"BR-SP5-6", 6, model.BalanceRequestApprovedPassed, "50.00", "50.00"}}, wantCents: 10000}, {name: "⑦未审批不参与", campusBal: strPtr("100.00"), globalBal: strPtr("100.00"), gifts: []gift{{"BR-SP5-7", 7, model.BalanceRequestApprovedPending, "50.00", "20.00"}}, wantCents: 10000}, {name: "⑧多笔赠送累加", campusBal: strPtr("100.00"), globalBal: strPtr("100.00"), gifts: []gift{ {"BR-SP5-8a", 8, model.BalanceRequestApprovedPassed, "50.00", "20.00"}, {"BR-SP5-8b", 9, model.BalanceRequestApprovedPassed, "50.00", "10.00"}, }, wantCents: 7000}, {name: "⑨校区账户不存在", globalBal: strPtr("100.00"), wantCents: 0}, {name: "⑩全局账户不存在", campusBal: strPtr("50.00"), wantCents: 0}, {name: "⑪校区欠费", campusBal: strPtr("-25.00"), globalBal: strPtr("100.00"), wantCents: 0}, {name: "⑫全局非正", campusBal: strPtr("50.00"), globalBal: strPtr("0.00"), wantCents: 0}, } db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) ctx := context.Background() for i, c := range cases { user := fundUserID(200 + i) if c.globalBal != nil { if err := db.Create(&model.UserExpense{UserID: user, Balance: *c.globalBal}).Error; err != nil { t.Fatalf("%s: seed 全局账户失败: %v", c.name, err) } } if c.campusBal != nil { if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: inst, CampusID: camp, Balance: *c.campusBal}).Error; err != nil { t.Fatalf("%s: seed 校区账户失败: %v", c.name, err) } } for _, g := range c.gifts { seedGiftUsed(t, db, user, g.BizNo, g.Activity, g.Approved, g.Money, g.MoneyUsed) } got, err := svc.RefundableAmount(ctx, user, inst, camp) if err != nil { t.Fatalf("%s: RefundableAmount 失败: %v", c.name, err) } if got.Cents() != c.wantCents { t.Fatalf("%s: 实得 %d 分,期望 %d 分", c.name, got.Cents(), c.wantCents) } if got < 0 { t.Fatalf("%s: 可退额不得为负,实得 %d", c.name, got.Cents()) } } // campusID <= 0 → 3140(入参校验,与账户数据无关) if _, err := svc.RefundableAmount(ctx, fundUserID(200), inst, 0); err != nil { requireAppCode(t, err, 3140) } // 越界口径提醒:可退额只做"查询",不因赠送额而改动账户(只读断言) if got := readSchool(t, db, fundUserID(201), inst, camp).Balance; cents(t, got) != 20000 { t.Fatalf("RefundableAmount 必须只读(②用例校区余额应仍 20000 分),实得 %s", got) } } // strPtr 取字符串指针(表驱动里表达"该账户存在且余额为 X")。 func strPtr(s string) *string { return &s } // ---------------------------------------------------------------- 坏账(SP7,双加) // TestBadDebtAddsBalanceAndSpecialExpenses dump 口径「余额+,特殊费用+」: // **两列同增**(全局 + 校区都是),108 流水为正。 func TestBadDebtAddsBalanceAndSpecialExpenses(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) ctx := context.Background() user := fundUserID(14) if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00")}); err != nil { t.Fatalf("准备余额失败: %v", err) } res, err := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "30.00")}) if err != nil { t.Fatalf("BadDebt 失败: %v", err) } // 校区:balance 100 → 130(双加),special_expenses 0 → 30 school := readSchool(t, db, user, 1, 10) if got := cents(t, school.Balance); got != 13000 { t.Fatalf("校区 balance 应 10000+3000=13000 分(dump 双加),实得 %d", got) } if got := cents(t, school.SpecialExpenses); got != 3000 { t.Fatalf("校区 special_expenses 应 3000 分,实得 %d", got) } // 全局:同样双加 global := readGlobal(t, db, user) if got := cents(t, global.Balance); got != 13000 { t.Fatalf("全局 balance 应 13000 分(双表同步双加),实得 %d", got) } if got := cents(t, global.SpecialExpenses); got != 3000 { t.Fatalf("全局 special_expenses 应 3000 分,实得 %d", got) } // 返回体:余额已是 +Money 之后的值 if res.Balance.Cents() != 13000 || res.SpecialExpenses.Cents() != 3000 { t.Fatalf("返回体应 balance=13000/special=3000,实得 %d/%d", res.Balance.Cents(), res.SpecialExpenses.Cents()) } // 流水:108 = +3000 一条 logs := logsByType(t, db, user, model.ExpenseLogTypeBadDebt) if len(logs) != 1 || cents(t, logs[0].Money) != 3000 { t.Fatalf("108 流水应为 +3000 分一条,实得 %+v", logs) } } // ---------------------------------------------------------------- 账务恒等式(AC-17 ④ 口径) // balanceLogs 取某用户全部流水(按 id ASC)。 func balanceLogs(t *testing.T, db *gorm.DB, userID string) []model.UserExpenseLog { t.Helper() var rows []model.UserExpenseLog if err := db.Where("user_id = ?", userID).Order("id ASC").Find(&rows).Error; err != nil { t.Fatalf("读全部流水(%s) 失败: %v", userID, err) } return rows } // sumLogMoney 流水金额**代数和**(Go 侧定点累加;禁 SQL SUM(decimal) 回 REAL)。 func sumLogMoney(t *testing.T, rows []model.UserExpenseLog) money.Amount { t.Helper() total := money.Amount(0) for i := range rows { amount, err := money.Parse(rows[i].Money) if err != nil { t.Fatalf("流水(%d) 金额非法: %v", rows[i].ID, err) } total = total.Add(amount) } return total } // classifyBadDebtLogs 按 dump 兼容口径把 type=108 分成两类。 // // 判别式(**唯一、可机械执行**,设计 §3.8-b (f) A18):取 108 流水的 `logid`(= `request_id`)—— // - **同 logid 存在 type=207 且 logid 非空(`<> ”`)** → 差额型(`CustomRefund` 的退费差额): // 该 108 的金额已含在 207 的 −requested 全额里,**不构成**一次独立的余额增加; // - **不存在(含 logid 为空/NULL)** → 独立坏账(`FundService.BadDebt`):balance 与 // special_expenses 双加,该 108 **就是**一次余额增加。 func classifyBadDebtLogs(t *testing.T, db *gorm.DB, userID string) ( independent, differential []model.UserExpenseLog, ) { t.Helper() all := balanceLogs(t, db, userID) refundKeys := make(map[string]bool) for i := range all { if all[i].Type != model.ExpenseLogTypeRefund { continue } if key := logKeyOf(all[i]); key != "" { refundKeys[key] = true } } for i := range all { if all[i].Type != model.ExpenseLogTypeBadDebt { continue } if key := logKeyOf(all[i]); key != "" && refundKeys[key] { differential = append(differential, all[i]) continue } independent = append(independent, all[i]) } return independent, differential } // logKeyOf 取流水的 logid(= request_id)原文;NULL/空 → ""(与 A18 判定 SQL 的 `log_id <> ”` 同口径)。 func logKeyOf(row model.UserExpenseLog) string { if row.LogID == nil { return "" } return strings.TrimSpace(*row.LogID) } // TestBalanceIdentityIndependentBadDebtCounted 独立坏账(108 无 207 同伴)**参与**恒等式: // 余额增量恰等于该 108 的金额,且「balance = Σ全部流水」成立(旧口径下这里是唯一破口)。 func TestBalanceIdentityIndependentBadDebtCounted(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) ctx := context.Background() user := fundUserID(24) if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00"), RequestID: "REQ-ID-BAL-RECHARGE"}); err != nil { t.Fatalf("准备余额失败: %v", err) } before := cents(t, readSchool(t, db, user, 1, 10).Balance) if before != 10000 { t.Fatalf("准备余额应为 10000 分,实得 %d", before) } if _, err := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "30.00"), RequestID: "REQ-ID-BAL-BADDEBT"}); err != nil { t.Fatalf("BadDebt 失败: %v", err) } after := cents(t, readSchool(t, db, user, 1, 10).Balance) independent, differential := classifyBadDebtLogs(t, db, user) if len(independent) != 1 || len(differential) != 0 { t.Fatalf("独立坏账应 1 条且无差额型,实得 independent=%d differential=%d", len(independent), len(differential)) } // ① 108 参与:余额增量 == 108 金额(+3000) if after-before != cents(t, independent[0].Money) { t.Fatalf("独立坏账应使余额 +%d 分,实得增量 %d", cents(t, independent[0].Money), after-before) } // ② 全局口径「balance = Σ全部流水」 all := balanceLogs(t, db, user) if sum := sumLogMoney(t, all).Cents(); sum != after { t.Fatalf("恒等式 balance=Σ流水 不成立:Σ=%d balance=%d", sum, after) } } // TestBalanceIdentityDifferentialBadDebtExcluded 退费差额型(108 有 207 同伴)**不参与** // 「108 → 余额增量」的归因:坏账部分已含在 207 的 −requested 全额里, // 若把它再算一次独立余额增加,余额会凭空多出 badDebt(正是旧口径/新口径最容易混的一处)。 func TestBalanceIdentityDifferentialBadDebtExcluded(t *testing.T) { db := newFundServiceDB(t) fund := NewFundService(db, fundSuperScope(), nil) svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil) ctx := context.Background() user := fundUserID(25) seedCampus(t, db, 10, 1, true) if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00"), RequestID: "REQ-ID-DIFF-RECHARGE"}); err != nil { t.Fatalf("准备余额失败: %v", err) } before := cents(t, readSchool(t, db, user, 1, 10).Balance) // 150 > 可退 100 → refunded 100、badDebt 50 if _, err := svc.CustomRefund(ctx, CustomRefundInput{ UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "150.00"), RequestID: "REQ-ID-DIFF-REFUND", }); err != nil { t.Fatalf("CustomRefund 失败: %v", err) } after := cents(t, readSchool(t, db, user, 1, 10).Balance) independent, differential := classifyBadDebtLogs(t, db, user) if len(differential) != 1 || len(independent) != 0 { t.Fatalf("退费差额应 1 条差额型且无独立坏账,实得 independent=%d differential=%d", len(independent), len(differential)) } logs207 := logsByType(t, db, user, model.ExpenseLogTypeRefund) if len(logs207) != 1 { t.Fatalf("207 应 1 条,实得 %d", len(logs207)) } // ① 差额型 108 的 logid 必须与 207 相同(判别式的实现载体) if differential[0].LogID == nil || logs207[0].LogID == nil || *differential[0].LogID != *logs207[0].LogID { t.Fatalf("差额型 108 应与 207 同 logid,实得 108=%+v 207=%+v", differential[0].LogID, logs207[0].LogID) } // ② 不参与:光看 207 就已经把这次余额变动记完了 // before + 207 = after + badDebt > after → 差额恰是 badDebt,且必须靠 108 补平; // 但把该 108 再当成「一次独立余额增加」就会重复计入 → 合计比真实余额多一个 badDebt。 onlyRefund := before + cents(t, logs207[0].Money) if onlyRefund == after { t.Fatalf("207 单独记账不应等于余额变化(差额型 108 必须存在):before=%d 207=%d after=%d", before, cents(t, logs207[0].Money), after) } pair := onlyRefund + cents(t, differential[0].Money) if pair != after { t.Fatalf("差额型 108 与 207 的代数和应等于净扣减:before(%d)+207(%d)+108(%d)=%d,after=%d", before, cents(t, logs207[0].Money), cents(t, differential[0].Money), pair, after) } if pair+cents(t, differential[0].Money) == after { t.Fatalf("差额型 108 不得再算一次独立的余额增加(重复计入会凭空多出 %d 分)", cents(t, differential[0].Money)) } // ③ 但「balance = Σ全部流水」整体仍成立(108 与 207 成对出现,代数和 = 净扣减) all := balanceLogs(t, db, user) if sum := sumLogMoney(t, all).Cents(); sum != after { t.Fatalf("恒等式 balance=Σ流水 不成立:Σ=%d balance=%d", sum, after) } } // ---------------------------------------------------------------- 累计消费(SP11,BUG#1) // TestAccumulateSpendOnlySpend 只更新 spend(不动 balance),且**不写流水**;负数归 0。 func TestAccumulateSpendOnlySpend(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) ctx := context.Background() user := fundUserID(15) // 账户不存在时也应能建户并写 spend(dump 的 INSERT 分支;BUG#1 修正前该分支是 3 值对 2 列必报错) res, err := svc.AccumulateSpend(ctx, SpendInput{UserID: user, InstitutionID: 1, CampusID: 10, SpendMoney: mustAmount(t, "25.00")}) if err != nil { t.Fatalf("AccumulateSpend 失败: %v", err) } if res.Spend.Cents() != 2500 { t.Fatalf("返回 spend 应 2500 分,实得 %d", res.Spend.Cents()) } if got := cents(t, readSchool(t, db, user, 1, 10).Spend); got != 2500 { t.Fatalf("校区 spend 应 2500 分,实得 %d", got) } if got := cents(t, readGlobal(t, db, user).Spend); got != 2500 { t.Fatalf("全局 spend 应 2500 分,实得 %d", got) } if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 0 { t.Fatalf("spend SP 不得改 balance(应 0),实得 %d", got) } var total int64 if err := db.Model(&model.UserExpenseLog{}).Where("user_id = ?", user).Count(&total).Error; err != nil { t.Fatalf("统计流水失败: %v", err) } if total != 0 { t.Fatalf("spend SP 不写流水(dump 无 INSERT log),实得 %d 条", total) } // 负数一律归 0(dump INSERT 分支同口径;spend 只增) res, err = svc.AccumulateSpend(ctx, SpendInput{UserID: user, InstitutionID: 1, CampusID: 10, SpendMoney: mustAmount(t, "-10.00")}) if err != nil { t.Fatalf("AccumulateSpend(负数) 失败: %v", err) } if res.Spend.Cents() != 2500 { t.Fatalf("负数应被归 0(spend 仍 2500 分),实得 %d", res.Spend.Cents()) } } // ---------------------------------------------------------------- 幂等键与金额越界(负例) // TestInsertLogDuplicateMapsToRepeated 撞 uk_request_type → 内部哨兵 3114(不是 5002)。 func TestInsertLogDuplicateMapsToRepeated(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) ctx := context.Background() user := fundUserID(16) mk := func() *model.UserExpenseLog { return newExpenseLog(user, model.ExpenseLogTypeRecharge, mustAmount(t, "1.00"), "REQ-DUP-1", "dup", "", nil) } if err := db.Transaction(func(tx *gorm.DB) error { return svc.insertLog(repository.WithTxContext(ctx, tx), tx, mk()) }); err != nil { t.Fatalf("首次写流水失败: %v", err) } err := db.Transaction(func(tx *gorm.DB) error { return svc.insertLog(repository.WithTxContext(ctx, tx), tx, mk()) }) if !errors.Is(err, apperr.ErrRepeated) { t.Fatalf("同 request_id+type 二次写入应映射为 3114 幂等哨兵,实得: %v", err) } } // TestAmountOverflowRejectedBeforeWrite AC-04 的落库负例: // 超出 decimal(12,2) 全域的金额必须在**写库前**被 `Amount.Validate()` 拦下(ErrOverflow), // 且账户零残留(不是"写了个截断值")。 func TestAmountOverflowRejectedBeforeWrite(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) ctx := context.Background() user := fundUserID(17) if err := db.Create(&model.UserExpense{UserID: user, Balance: "0.00"}).Error; err != nil { t.Fatalf("seed 全局账户失败: %v", err) } if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10, Balance: "0.00"}).Error; err != nil { t.Fatalf("seed 校区账户失败: %v", err) } overflow := money.MaxAmount + 1 err := db.Transaction(func(tx *gorm.DB) error { _, e := svc.repo.AddColumnsGlobalThenCampus(ctx, tx, "OverflowTest", user, 1, 10, repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: overflow}) return e }) if !errors.Is(err, money.ErrOverflow) { t.Fatalf("越界金额应返回 money.ErrOverflow,实得: %v", err) } var appErr *infraerrors.AppError if !errors.As(err, &appErr) { t.Fatalf("越界金额应返回 *AppError,实得: %v", err) } // 仓储层(AC-03 既有形状)把越界金额挂在 2001 哨兵上;Service 层对**入参**金额用 2002 // (见 validatePositiveAmount)。此处断言的是"落库前被拦下"这一红约束,两层都属 2xxx 参数错。 if appErr.Code != infraerrors.ErrBadParam.Code && appErr.Code != infraerrors.ErrBadFormat.Code { t.Fatalf("越界金额应映射为 2xxx 参数错,实得 %d: %v", appErr.Code, err) } if got := cents(t, readGlobal(t, db, user).Balance); got != 0 { t.Fatalf("越界金额不得落库(余额应仍 0),实得 %d", got) } }