| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933 |
- // RefundService 单测(AC-05):自定义退费双 log(含账务恒等式)/ 坏账边界 / 锁序 /
- // 20 并发幂等 / 自营与加盟的失效范围 / 失败零残留 / 余额清零(★campusBalance 基数)/ 取消充值活动。
- //
- // 断言纪律(本文件的核心价值):
- // 1. **恒等式断言**而不是"各比各的":`requested = refunded + badDebt` 且
- // `balance_before + 207.money + 108.money = balance_after`(三条流水代数和 = 净扣减);
- // 2. **顺序性**:108 必须在 207 之前写入(dump 顺序);全局锁必须在校区锁之前(§7.2 防死锁);
- // 3. **反向断言**:加盟校区不得失效 `campus_id=0` 的活动、type=3 活动不得被失效、
- // 失败用例必须余额/流水/报名状态**零残留**。
- package service
- import (
- "context"
- "errors"
- "regexp"
- "strings"
- "sync"
- "testing"
- "gorm.io/gorm"
- "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"
- )
- // ---------------------------------------------------------------- 辅助
- // seedActivity 落活动 + 报名行(status=1 报名)。
- func seedActivity(t *testing.T, db *gorm.DB, id int64, typ int8, institutionID, campusID int64) {
- t.Helper()
- act := &model.CoursesActivity{
- ID: id, Type: typ, InstitutionID: institutionID, CampusID: campusID,
- Status: model.ActivityStatusEnabled, Money: "100.00",
- }
- if err := db.Create(act).Error; err != nil {
- t.Fatalf("seed 活动(%d) 失败: %v", id, err)
- }
- }
- // seedSigned 落报名行(status 可指定;user 必须与退款用户一致才会被失效)。
- func seedSigned(t *testing.T, db *gorm.DB, userID string, activityID int64, status int32,
- institutionID, campusID int64) {
- t.Helper()
- row := &model.CoursesActivitySigned{
- ActivityID: activityID, UserID: userID, InstitutionID: institutionID, CampusID: campusID,
- Status: status, ActivityMoney: "100.00", ActivityMoneyUsed: "0.00",
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 报名(%d) 失败: %v", activityID, err)
- }
- }
- // signedStatus 读报名行状态。
- func signedStatus(t *testing.T, db *gorm.DB, userID string, activityID int64) int32 {
- t.Helper()
- var row model.CoursesActivitySigned
- if err := db.Where("user_id = ? AND activity_id = ?", userID, activityID).Take(&row).Error; err != nil {
- t.Fatalf("读报名(%d) 失败: %v", activityID, err)
- }
- return row.Status
- }
- // expirableIDsOf 排序后的活动 ID(把返回切片转成便于断言的集合)。
- func expirableIDsOf(ids []int64) map[int64]bool {
- out := make(map[int64]bool, len(ids))
- for _, id := range ids {
- out[id] = true
- }
- return out
- }
- // ---------------------------------------------------------------- 自定义退费:全额
- // TestCustomRefundFullAmount 全额退:无 108、207=−requested、双表同额扣、total_income 同步减。
- func TestCustomRefundFullAmount(t *testing.T) {
- db := newFundServiceDB(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- user := fundUserID(30)
- seedCampus(t, db, 10, 1, true)
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "500.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- res, err := svc.CustomRefund(ctx, CustomRefundInput{
- UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "200.00"), RequestID: "REQ-CUSTOM-FULL",
- })
- if err != nil {
- t.Fatalf("CustomRefund 失败: %v", err)
- }
- if res.Repeated {
- t.Fatalf("首次退费不应 repeated=true")
- }
- if res.BadDebt.Cents() != 0 || res.LogID108 != nil {
- t.Fatalf("全额退不应产生坏账:badDebt=%d logId108=%v", res.BadDebt.Cents(), res.LogID108)
- }
- if len(res.Warnings) != 0 {
- t.Fatalf("全额退 warnings 应为空,实得 %+v", res.Warnings)
- }
- if res.Refunded.Cents() != 20000 || res.Requested.Cents() != 20000 {
- t.Fatalf("refunded/requested 应各 20000 分,实得 %d/%d", res.Refunded.Cents(), res.Requested.Cents())
- }
- if res.Balance.Cents() != 30000 {
- t.Fatalf("退费后校区余额应 30000 分,实得 %d", res.Balance.Cents())
- }
- // 双表同额扣减 + total_income 同步减
- if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 30000 {
- t.Fatalf("校区 balance 应 30000 分,实得 %d", got)
- }
- if got := cents(t, readGlobal(t, db, user).Balance); got != 30000 {
- t.Fatalf("全局 balance 应同额扣(30000 分),实得 %d", got)
- }
- if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 30000 {
- t.Fatalf("校区 total_income 应 30000 分,实得 %d", got)
- }
- if got := cents(t, readGlobal(t, db, user).TotalIncome); got != 30000 {
- t.Fatalf("全局 total_income 应 30000 分,实得 %d", got)
- }
- // 207 = -requested(全额)
- logs := logsByType(t, db, user, model.ExpenseLogTypeRefund)
- if len(logs) != 1 {
- t.Fatalf("207 流水应 1 条,实得 %d", len(logs))
- }
- if got := cents(t, logs[0].Money); got != -20000 {
- t.Fatalf("207 流水应 -20000 分,实得 %d", got)
- }
- if logs[0].Remark == nil || *logs[0].Remark != defaultCustomRefundRemark {
- t.Fatalf("207 默认备注应为 %q,实得 %+v", defaultCustomRefundRemark, logs[0].Remark)
- }
- }
- // ---------------------------------------------------------------- 自定义退费:坏账 + 恒等式
- // TestCustomRefundBadDebtIdentity 部分退(requested > 可退):
- // 双 log 顺序 + 账务恒等式 + special_expenses 累加 + warnings[3103]。
- func TestCustomRefundBadDebtIdentity(t *testing.T) {
- db := newFundServiceDB(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- user := fundUserID(31)
- seedCampus(t, db, 10, 1, true)
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "320.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- before := cents(t, readSchool(t, db, user, 1, 10).Balance)
- // requested=500 > 可退 320 → badDebt=180
- res, err := svc.CustomRefund(ctx, CustomRefundInput{
- UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "500.00"), RequestID: "REQ-CUSTOM-BADDEBT", Remark: "退学退费",
- })
- if err != nil {
- t.Fatalf("CustomRefund 失败: %v", err)
- }
- // ① 金额拆分恒等式:requested = refunded + badDebt
- if res.Requested.Cents() != 50000 {
- t.Fatalf("requested 应 50000 分,实得 %d", res.Requested.Cents())
- }
- if res.Refunded.Cents() != 32000 {
- t.Fatalf("refunded 应 32000 分,实得 %d", res.Refunded.Cents())
- }
- if res.BadDebt.Cents() != 18000 {
- t.Fatalf("badDebt 应 18000 分,实得 %d", res.BadDebt.Cents())
- }
- if res.Requested.Cents() != res.Refunded.Cents()+res.BadDebt.Cents() {
- t.Fatalf("恒等式 requested = refunded + badDebt 不成立:%d vs %d+%d",
- res.Requested.Cents(), res.Refunded.Cents(), res.BadDebt.Cents())
- }
- if res.RefundableAmount.Cents() != 32000 {
- t.Fatalf("refundableAmount 快照应 32000 分,实得 %d", res.RefundableAmount.Cents())
- }
- // ② 双 log 内容与**顺序**:108 先、207 后
- logs108 := logsByType(t, db, user, model.ExpenseLogTypeBadDebt)
- logs207 := logsByType(t, db, user, model.ExpenseLogTypeRefund)
- if len(logs108) != 1 || len(logs207) != 1 {
- t.Fatalf("108/207 应各 1 条,实得 %d/%d", len(logs108), len(logs207))
- }
- if got := cents(t, logs108[0].Money); got != 18000 {
- t.Fatalf("108 流水应 +18000 分(坏账部分),实得 %d", got)
- }
- if got := cents(t, logs207[0].Money); got != -50000 {
- t.Fatalf("207 流水应 -50000 分(**全额**,与扣减额不同),实得 %d", got)
- }
- if logs108[0].ID >= logs207[0].ID {
- t.Fatalf("108 必须先于 207 写入(dump 顺序),实得 108.id=%d 207.id=%d", logs108[0].ID, logs207[0].ID)
- }
- if logs108[0].Remark == nil || !strings.Contains(*logs108[0].Remark, "退费的金额大于可退费部分,超出部分为坏账部分。") {
- t.Fatalf("108 流水 remark 必须含 dump 指定文案,实得 %+v", logs108[0].Remark)
- }
- if logs108[0].RequestID == nil || *logs108[0].RequestID != "REQ-CUSTOM-BADDEBT" {
- t.Fatalf("108 的 request_id 应落幂等键,实得 %+v", logs108[0].RequestID)
- }
- // ③ **账务恒等式**:balance_before + 207 + 108 = balance_after(代数和 = 净扣减)
- schoolAfter := readSchool(t, db, user, 1, 10)
- after := cents(t, schoolAfter.Balance)
- sum := cents(t, logs207[0].Money) + cents(t, logs108[0].Money)
- if before+sum != after {
- t.Fatalf("恒等式不成立:before(%d) + ∑log(%d) = %d ≠ after(%d)", before, sum, before+sum, after)
- }
- if after != before-32000 {
- t.Fatalf("净扣减应等于 refunded(32000 分):before=%d after=%d", before, after)
- }
- // 双账户同额(全局也净减 refunded,而不是 requested)
- if got := cents(t, readGlobal(t, db, user).Balance); got != after {
- t.Fatalf("全局余额应与校区同额(%d 分),实得 %d", after, got)
- }
- // ④ special_expenses 双表各 +badDebt
- if got := cents(t, schoolAfter.SpecialExpenses); got != 18000 {
- t.Fatalf("校区 special_expenses 应 18000 分,实得 %d", got)
- }
- if got := cents(t, readGlobal(t, db, user).SpecialExpenses); got != 18000 {
- t.Fatalf("全局 special_expenses 应 18000 分,实得 %d", got)
- }
- // ⑤ warnings[3103](warn 级,不阻断)
- if len(res.Warnings) != 1 || res.Warnings[0].Code != apperr.CodeRefundableShort {
- t.Fatalf("应有 1 条 3103 warning,实得 %+v", res.Warnings)
- }
- if !strings.Contains(res.Warnings[0].Message, "180.00") {
- t.Fatalf("warning 文案应含差额 180.00,实得 %q", res.Warnings[0].Message)
- }
- if res.LogID108 == nil || *res.LogID108 != logs108[0].ID {
- t.Fatalf("logId108 应回填 %d,实得 %+v", logs108[0].ID, res.LogID108)
- }
- if res.LogID207 != logs207[0].ID {
- t.Fatalf("logId207 应回填 %d,实得 %d", logs207[0].ID, res.LogID207)
- }
- }
- // TestCustomRefundExactBalance requested == balance 边界:badDebt=0、余额归零。
- func TestCustomRefundExactBalance(t *testing.T) {
- db := newFundServiceDB(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- user := fundUserID(32)
- seedCampus(t, db, 10, 1, true)
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "120.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- res, err := svc.CustomRefund(ctx, CustomRefundInput{
- UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "120.00"), RequestID: "REQ-CUSTOM-EXACT",
- })
- if err != nil {
- t.Fatalf("CustomRefund 失败: %v", err)
- }
- if res.BadDebt.Cents() != 0 || res.LogID108 != nil {
- t.Fatalf("恰好退完不应有坏账,实得 %+v", res)
- }
- if res.Balance.Cents() != 0 {
- t.Fatalf("退完后余额应 0,实得 %d", res.Balance.Cents())
- }
- }
- // ---------------------------------------------------------------- 自定义退费:锁序
- // TestCustomRefundLocksGlobalBeforeCampus 加锁顺序 **全局 → 校区**(§7.2;错序 = 与充值并发成环)。
- //
- // 断言口径:抓真实 SQL 序列,要求第一条 `t_user_expense`(全局)点查出现在
- // 第一条 `t_user_expense_school`(校区)点查之前。
- func TestCustomRefundLocksGlobalBeforeCampus(t *testing.T) {
- db, rec := newFundServiceDBWithRecorder(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- user := fundUserID(33)
- seedCampus(t, db, 10, 1, true)
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "100.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- rec.Reset()
- if _, err := svc.CustomRefund(ctx, CustomRefundInput{
- UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "10.00"), RequestID: "REQ-CUSTOM-LOCK",
- }); err != nil {
- t.Fatalf("CustomRefund 失败: %v", err)
- }
- stmts := rec.Snapshot()
- globalIdx := firstTouchIndex(stmts, `\bt_user_expense\b`)
- campusIdx := firstTouchIndex(stmts, `\bt_user_expense_school\b`)
- if globalIdx < 0 || campusIdx < 0 {
- t.Fatalf("未捕获到两次账户点查(global=%d campus=%d):%v", globalIdx, campusIdx, stmts)
- }
- if globalIdx > campusIdx {
- t.Fatalf("锁序错误:全局账户(第 %d 条)必须早于校区账户(第 %d 条)", globalIdx, campusIdx)
- }
- }
- // ---------------------------------------------------------------- 自定义退费:并发幂等
- // TestCustomRefundConcurrentSameRequestID 20 并发同 requestId → 1 条 207 + 1 条 108、余额只变一次。
- //
- // SQLite 无行锁:用单连接池把写事务串行化(等价 MySQL 行锁串行)。
- // 断言点是**流水条数与余额增量**,而不是"有没有报错"——这才是资金正确性。
- func TestCustomRefundConcurrentSameRequestID(t *testing.T) {
- db := newFundServiceDB(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- user := fundUserID(34)
- seedCampus(t, db, 10, 1, true)
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "300.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- in := CustomRefundInput{
- UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "400.00"), RequestID: "REQ-CUSTOM-RACE", // 400 > 300 → 必然产生 108
- }
- const goroutines = 20
- var wg sync.WaitGroup
- results := make([]*CustomRefundResult, 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.CustomRefund(ctx, in)
- }(i)
- }
- wg.Wait()
- for i, err := range errs {
- if err != nil {
- t.Fatalf("并发第 %d 次退费失败: %v", i, err)
- }
- }
- if n := len(logsByType(t, db, user, model.ExpenseLogTypeRefund)); n != 1 {
- t.Fatalf("207 流水应只 1 条,实得 %d", n)
- }
- if n := len(logsByType(t, db, user, model.ExpenseLogTypeBadDebt)); n != 1 {
- t.Fatalf("108 流水应只 1 条,实得 %d", n)
- }
- school := readSchool(t, db, user, 1, 10)
- if got := cents(t, school.Balance); got != 0 {
- t.Fatalf("余额应只扣一次(300→0),实得 %d", got)
- }
- if got := cents(t, school.SpecialExpenses); got != 10000 {
- t.Fatalf("special_expenses 应只加一次(10000 分 = 400−300),实得 %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)
- }
- }
- // TestCustomRefundIdempotentReplaysFirstResult 顺序重复调用 → repeated=true 且字段回放首次结果。
- func TestCustomRefundIdempotentReplaysFirstResult(t *testing.T) {
- db := newFundServiceDB(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- user := fundUserID(35)
- seedCampus(t, db, 10, 1, true)
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "100.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- in := CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "150.00"), RequestID: "REQ-CUSTOM-REPLAY"}
- first, err := svc.CustomRefund(ctx, in)
- if err != nil {
- t.Fatalf("首次退费失败: %v", err)
- }
- second, err := svc.CustomRefund(ctx, in)
- if err != nil {
- t.Fatalf("重复退费应幂等成功: %v", err)
- }
- if !second.Repeated {
- t.Fatalf("重复退费应 repeated=true")
- }
- if second.LogID207 != first.LogID207 {
- t.Fatalf("应回放首次 logId207=%d,实得 %d", first.LogID207, second.LogID207)
- }
- if second.BadDebt.Cents() != first.BadDebt.Cents() || second.Refunded.Cents() != first.Refunded.Cents() {
- t.Fatalf("应回放首次坏账/退款额:first=%+v second=%+v", first, second)
- }
- if second.LogID108 == nil || first.LogID108 == nil || *second.LogID108 != *first.LogID108 {
- t.Fatalf("应回放首次 logId108,first=%+v second=%+v", first.LogID108, second.LogID108)
- }
- if n := len(logsByType(t, db, user, model.ExpenseLogTypeRefund)); n != 1 {
- t.Fatalf("207 流水应只 1 条,实得 %d", n)
- }
- }
- // ---------------------------------------------------------------- 活动失效范围
- // TestCustomRefundExpirySelfSupportCoversInstitutionPool 自营校区:失效本校区 + `campus_id=0` 池活动,
- // 且**不得**动 type=3 / 他机构 / 已过期(status=4)的行。
- func TestCustomRefundExpirySelfSupportCoversInstitutionPool(t *testing.T) {
- db := newFundServiceDB(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- user := fundUserID(36)
- seedCampus(t, db, 10, 1, true) // 自营
- // 活动 101 本校区(10) type=2 → 失效;102 全机构池(0) type=2 → 自营也失效
- // 103 代金券 type=3 → 不动;104 他机构(2) → 不动;105 已过期报名 → 不在候选;106 他校区(11) type=2 → 不动
- seedActivity(t, db, 101, model.ActivityTypeRecharge, 1, 10)
- seedActivity(t, db, 102, model.ActivityTypeRecharge, 1, 0)
- seedActivity(t, db, 103, model.ActivityTypeVoucher, 1, 10)
- seedActivity(t, db, 104, model.ActivityTypeRecharge, 2, 10)
- seedActivity(t, db, 105, model.ActivityTypeRecharge, 1, 10)
- seedActivity(t, db, 106, model.ActivityTypeRecharge, 1, 11)
- for _, id := range []int64{101, 102, 103, 104, 106} {
- seedSigned(t, db, user, id, model.ActivitySignedSigned, 1, 10)
- }
- seedSigned(t, db, user, 105, model.ActivitySignedExpired, 1, 10)
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "100.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- res, err := svc.CustomRefund(ctx, CustomRefundInput{
- UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "10.00"), RequestID: "REQ-EXPIRY-SELF",
- })
- if err != nil {
- t.Fatalf("CustomRefund 失败: %v", err)
- }
- got := expirableIDsOf(res.ExpiredActivityIDs)
- if !got[101] {
- t.Fatalf("自营校区应失效本校区活动 101,实得 %v", res.ExpiredActivityIDs)
- }
- if !got[102] {
- t.Fatalf("自营校区应失效全机构池活动 102(campus_id=0),实得 %v", res.ExpiredActivityIDs)
- }
- if got[103] {
- t.Fatalf("type=3 代金券活动不得失效(dump 只处理 type=2),实得 %v", res.ExpiredActivityIDs)
- }
- if got[104] {
- t.Fatalf("他机构活动不得失效,实得 %v", res.ExpiredActivityIDs)
- }
- if got[105] {
- t.Fatalf("已过期(status=4)的报名不应出现在失效列表,实得 %v", res.ExpiredActivityIDs)
- }
- if got[106] {
- t.Fatalf("他校区活动(campus_id=11)在自营分支也不得失效,实得 %v", res.ExpiredActivityIDs)
- }
- // 落库断言(不只信返回值)
- if s := signedStatus(t, db, user, 101); s != model.ActivitySignedExpired {
- t.Fatalf("报名 101 应为 4,实得 %d", s)
- }
- if s := signedStatus(t, db, user, 102); s != model.ActivitySignedExpired {
- t.Fatalf("报名 102 应为 4,实得 %d", s)
- }
- if s := signedStatus(t, db, user, 103); s != model.ActivitySignedSigned {
- t.Fatalf("报名 103(type=3)必须保持 1,实得 %d", s)
- }
- if s := signedStatus(t, db, user, 104); s != model.ActivitySignedSigned {
- t.Fatalf("报名 104(他机构)必须保持 1,实得 %d", s)
- }
- if s := signedStatus(t, db, user, 106); s != model.ActivitySignedSigned {
- t.Fatalf("报名 106(他校区)必须保持 1,实得 %d", s)
- }
- }
- // TestCustomRefundExpiryFranchiseExcludesInstitutionPool 加盟校区:**只**失效 `campus_id = campusID`,
- // 不得碰 `campus_id=0` 的全机构池活动(R1:错走加盟分支会误失效他校区活动)。
- func TestCustomRefundExpiryFranchiseExcludesInstitutionPool(t *testing.T) {
- db := newFundServiceDB(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- user := fundUserID(37)
- seedCampus(t, db, 10, 1, false) // 加盟
- seedActivity(t, db, 201, model.ActivityTypeRecharge, 1, 10)
- seedActivity(t, db, 202, model.ActivityTypeRecharge, 1, 0)
- seedSigned(t, db, user, 201, model.ActivitySignedSigned, 1, 10)
- seedSigned(t, db, user, 202, model.ActivitySignedSigned, 1, 10)
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "100.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- res, err := svc.CustomRefund(ctx, CustomRefundInput{
- UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "10.00"), RequestID: "REQ-EXPIRY-FRANCHISE",
- })
- if err != nil {
- t.Fatalf("CustomRefund 失败: %v", err)
- }
- got := expirableIDsOf(res.ExpiredActivityIDs)
- if !got[201] {
- t.Fatalf("加盟校区应失效本校区活动 201,实得 %v", res.ExpiredActivityIDs)
- }
- if got[202] {
- t.Fatalf("加盟校区**不得**失效全机构池活动 202(campus_id=0),实得 %v", res.ExpiredActivityIDs)
- }
- if s := signedStatus(t, db, user, 202); s != model.ActivitySignedSigned {
- t.Fatalf("报名 202 必须保持 1,实得 %d", s)
- }
- }
- // ---------------------------------------------------------------- 失败回滚(零残留)
- // TestCustomRefundRollbackLeavesNoResidue 注入失败(活动失效步骤报错)→
- // 余额/流水/报名状态**零残留**(同事务整体回滚)。
- //
- // 注入方式:删掉 `t_courses_activity_signed` 表 → 失效器查询必然报错。
- // 这样失败点落在"资金已写、活动未失效"的最坏位置,正好验证原子性。
- func TestCustomRefundRollbackLeavesNoResidue(t *testing.T) {
- db := newFundServiceDB(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- user := fundUserID(38)
- seedCampus(t, db, 10, 1, true)
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "100.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- beforeSchool := readSchool(t, db, user, 1, 10)
- beforeGlobal := readGlobal(t, db, user)
- if err := db.Exec("DROP TABLE t_courses_activity_signed").Error; err != nil {
- t.Fatalf("注入失败(删表)失败: %v", err)
- }
- _, err := svc.CustomRefund(ctx, CustomRefundInput{
- UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "150.00"), RequestID: "REQ-ROLLBACK",
- })
- if err == nil {
- t.Fatalf("活动失效失败时退费必须整体失败,实得 nil")
- }
- afterSchool := readSchool(t, db, user, 1, 10)
- if cents(t, afterSchool.Balance) != cents(t, beforeSchool.Balance) {
- t.Fatalf("回滚后校区余额必须不变:before=%s after=%s", beforeSchool.Balance, afterSchool.Balance)
- }
- if cents(t, afterSchool.TotalIncome) != cents(t, beforeSchool.TotalIncome) {
- t.Fatalf("回滚后校区 total_income 必须不变:before=%s after=%s",
- beforeSchool.TotalIncome, afterSchool.TotalIncome)
- }
- if cents(t, afterSchool.SpecialExpenses) != 0 {
- t.Fatalf("回滚后 special_expenses 必须为 0,实得 %s", afterSchool.SpecialExpenses)
- }
- afterGlobal := readGlobal(t, db, user)
- if cents(t, afterGlobal.Balance) != cents(t, beforeGlobal.Balance) {
- t.Fatalf("回滚后全局余额必须不变:before=%s after=%s", beforeGlobal.Balance, afterGlobal.Balance)
- }
- if n := len(logsByType(t, db, user, model.ExpenseLogTypeRefund)); n != 0 {
- t.Fatalf("回滚后不得残留 207 流水,实得 %d 条", n)
- }
- if n := len(logsByType(t, db, user, model.ExpenseLogTypeBadDebt)); n != 0 {
- t.Fatalf("回滚后不得残留 108 流水,实得 %d 条", n)
- }
- }
- // ---------------------------------------------------------------- 错误码
- // TestCustomRefundErrorCodes 3102 / 2002 / 3104 / 2001。
- func TestCustomRefundErrorCodes(t *testing.T) {
- db := newFundServiceDB(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- seedCampus(t, db, 10, 1, true)
- t.Run("余额为0_3102", func(t *testing.T) {
- user := fundUserID(40)
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "50.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- // 先全额退完(余额归 0)
- if _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "50.00"), RequestID: "REQ-ERR-3102-A"}); err != nil {
- t.Fatalf("准备归零失败: %v", err)
- }
- _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "10.00"), RequestID: "REQ-ERR-3102-B"})
- requireAppCode(t, err, apperr.CodeNoBalanceToRefund)
- })
- t.Run("校区账户不存在_3102", func(t *testing.T) {
- user := fundUserID(41)
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "10.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- // campus 12 无账户(全局有余额)
- _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 12,
- Money: mustAmount(t, "5.00"), RequestID: "REQ-ERR-3102-C"})
- requireAppCode(t, err, apperr.CodeNoBalanceToRefund)
- })
- t.Run("金额为负_2002", func(t *testing.T) {
- _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: fundUserID(42), InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "-1.00"), RequestID: "REQ-ERR-2002"})
- requireAppCode(t, err, 2002)
- })
- t.Run("金额超单笔上限_2002", func(t *testing.T) {
- _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: fundUserID(42), InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "1000000.01"), RequestID: "REQ-ERR-2002B"})
- requireAppCode(t, err, 2002)
- })
- t.Run("缺requestId_2002", func(t *testing.T) {
- _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: fundUserID(42), InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "1.00")})
- requireAppCode(t, err, 2002)
- })
- t.Run("校区不存在_3104", func(t *testing.T) {
- user := fundUserID(43)
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "10.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- // 手工造一个 t_campus 里不存在的校区账户(模拟历史脏数据/校区被删)
- if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 99,
- Balance: "10.00"}).Error; err != nil {
- t.Fatalf("seed 脏校区账户失败: %v", err)
- }
- _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 99,
- Money: mustAmount(t, "5.00"), RequestID: "REQ-ERR-3104"})
- requireAppCode(t, err, apperr.CodeCampusNotFound)
- // R1 + 原子性:3104 时不得留下任何资金痕迹
- if got := cents(t, readSchool(t, db, user, 1, 99).Balance); got != 1000 {
- t.Fatalf("3104 回滚后余额必须不变(1000 分),实得 %d", got)
- }
- if n := len(logsByType(t, db, user, model.ExpenseLogTypeRefund)); n != 0 {
- t.Fatalf("3104 不得写流水,实得 %d 条", n)
- }
- })
- t.Run("缺用户_2001", func(t *testing.T) {
- _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: "", InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "1.00"), RequestID: "REQ-ERR-2001"})
- requireAppCode(t, err, 2001)
- })
- }
- // ---------------------------------------------------------------- 余额清零(SP10)
- // TestClearBalanceUsesCampusBalanceAsBase ★漂移项:全局表扣减基数是 **campusBalance**(不是 totalBalance)。
- //
- // 场景:校区 10 有 100,校区 20 有 100(全局合计 200)。清零校区 10 后:
- // 校区 10 余额 0、全局余额 100(= 200 − 100,而不是 0)。
- func TestClearBalanceUsesCampusBalanceAsBase(t *testing.T) {
- db := newFundServiceDB(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- user := fundUserID(50)
- seedCampus(t, db, 10, 1, true)
- seedActivity(t, db, 301, model.ActivityTypeRecharge, 1, 10)
- seedSigned(t, db, user, 301, model.ActivitySignedSigned, 1, 10)
- for _, campusID := range []int64{10, 20} {
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: campusID,
- Money: mustAmount(t, "100.00")}); err != nil {
- t.Fatalf("准备余额(campus=%d)失败: %v", campusID, err)
- }
- }
- res, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10})
- if err != nil {
- t.Fatalf("ClearBalance 失败: %v", err)
- }
- if res.Cleared.Cents() != 10000 {
- t.Fatalf("cleared 应 10000 分,实得 %d", res.Cleared.Cents())
- }
- if res.CampusBalanceBefore.Cents() != 10000 || res.TotalBalanceBefore.Cents() != 20000 {
- t.Fatalf("清零前快照错误:campus=%d total=%d",
- res.CampusBalanceBefore.Cents(), res.TotalBalanceBefore.Cents())
- }
- if res.Balance.Cents() != 0 {
- t.Fatalf("清零后校区余额应 0,实得 %d", res.Balance.Cents())
- }
- if res.TotalBalance.Cents() != 10000 {
- t.Fatalf("★ 清零后全局余额应 10000 分(200−100,基数是 campusBalance),实得 %d",
- res.TotalBalance.Cents())
- }
- school := readSchool(t, db, user, 1, 10)
- if got := cents(t, school.Balance); got != 0 {
- t.Fatalf("校区 balance 应 0,实得 %d", got)
- }
- if got := cents(t, school.SpecialExpenses); got != 10000 {
- t.Fatalf("校区 special_expenses 应 10000 分,实得 %d", got)
- }
- if got := cents(t, school.Spend); got != 10000 {
- t.Fatalf("校区 spend 应 10000 分(dump 清零同时累加 spend),实得 %d", got)
- }
- global := readGlobal(t, db, user)
- if got := cents(t, global.Balance); got != 10000 {
- t.Fatalf("全局 balance 应 10000 分,实得 %d", got)
- }
- if got := cents(t, global.SpecialExpenses); got != 10000 {
- t.Fatalf("全局 special_expenses 应 10000 分,实得 %d", got)
- }
- // 流水 208 = −campusBalance
- logs := logsByType(t, db, user, model.ExpenseLogTypeClearBalance)
- if len(logs) != 1 || cents(t, logs[0].Money) != -10000 {
- t.Fatalf("208 流水应为 -10000 分一条,实得 %+v", logs)
- }
- if logs[0].Remark == nil || *logs[0].Remark != defaultClearBalanceRemark {
- t.Fatalf("208 默认备注应为 %q,实得 %+v", defaultClearBalanceRemark, logs[0].Remark)
- }
- // 活动失效(清零同样触发,与 SP9 同源)
- if !expirableIDsOf(res.ExpiredActivityIDs)[301] {
- t.Fatalf("清零应失效充值活动 301,实得 %v", res.ExpiredActivityIDs)
- }
- if s := signedStatus(t, db, user, 301); s != model.ActivitySignedExpired {
- t.Fatalf("报名 301 应为 4,实得 %d", s)
- }
- // 再次清零 → 3120(天然幂等,不会重复清零)
- if _, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10}); err == nil {
- t.Fatalf("余额已为 0 时再次清零必须失败(3120)")
- } else {
- requireAppCode(t, err, apperr.CodeClearBalanceRejected)
- }
- }
- // TestClearBalancePreconditions 三条前置校验 → 3120(dump 静默 LEAVE,Go 显式)。
- func TestClearBalancePreconditions(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- seedCampus(t, db, 10, 1, true)
- seedCampus(t, db, 11, 1, true)
- t.Run("校区余额为0", func(t *testing.T) {
- user := fundUserID(51)
- if err := db.Create(&model.UserExpense{UserID: user, Balance: "100.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)
- }
- _, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10})
- requireAppCode(t, err, apperr.CodeClearBalanceRejected)
- })
- t.Run("全局余额非正", func(t *testing.T) {
- user := fundUserID(52)
- if err := db.Create(&model.UserExpense{UserID: user, Balance: "-10.00"}).Error; err != nil {
- t.Fatalf("seed 全局失败: %v", err)
- }
- if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
- Balance: "50.00"}).Error; err != nil {
- t.Fatalf("seed 校区失败: %v", err)
- }
- _, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10})
- requireAppCode(t, err, apperr.CodeClearBalanceRejected)
- })
- t.Run("校区余额大于全局", func(t *testing.T) {
- user := fundUserID(53)
- if err := db.Create(&model.UserExpense{UserID: user, Balance: "50.00"}).Error; err != nil {
- t.Fatalf("seed 全局失败: %v", err)
- }
- if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
- Balance: "80.00"}).Error; err != nil {
- t.Fatalf("seed 校区失败: %v", err)
- }
- _, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10})
- requireAppCode(t, err, apperr.CodeClearBalanceRejected)
- })
- t.Run("校区账户不存在", func(t *testing.T) {
- user := fundUserID(54)
- if err := db.Create(&model.UserExpense{UserID: user, Balance: "50.00"}).Error; err != nil {
- t.Fatalf("seed 全局失败: %v", err)
- }
- _, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 11})
- requireAppCode(t, err, apperr.CodeClearBalanceRejected)
- })
- }
- // ---------------------------------------------------------------- 取消充值活动(SP8)
- // TestCancelRechargeActivity 正常路径:跨域读机构 → 余额/总充值减 → 报名 status=0 → 209 流水为负。
- func TestCancelRechargeActivity(t *testing.T) {
- db := newFundServiceDB(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- user := fundUserID(60)
- seedCampus(t, db, 10, 1, true)
- seedActivity(t, db, 401, model.ActivityTypeRecharge, 1, 10)
- seedSigned(t, db, user, 401, model.ActivitySignedSigned, 1, 10)
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "200.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- res, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
- UserID: user, ActivityID: 401, CampusID: 10,
- Money: mustAmount(t, "50.00"), RequestID: "REQ-CANCEL-1",
- })
- if err != nil {
- t.Fatalf("CancelRechargeActivity 失败: %v", err)
- }
- if res.InstitutionID != 1 {
- t.Fatalf("应由校区反查机构 1,实得 %d", res.InstitutionID)
- }
- school := readSchool(t, db, user, 1, 10)
- if got := cents(t, school.Balance); got != 15000 {
- t.Fatalf("取消后校区余额应 15000 分,实得 %d", got)
- }
- if got := cents(t, school.TotalIncome); got != 15000 {
- t.Fatalf("取消后校区 total_income 应 15000 分,实得 %d", got)
- }
- if got := cents(t, readGlobal(t, db, user).Balance); got != 15000 {
- t.Fatalf("取消后全局余额应 15000 分(双表同步),实得 %d", got)
- }
- // 报名状态归 0(dump:SET status=0,不是 4)
- if s := signedStatus(t, db, user, 401); s != model.ActivitySignedCancelled {
- t.Fatalf("报名状态应为 0(取消),实得 %d", s)
- }
- logs := logsByType(t, db, user, model.ExpenseLogTypeCancelRechargeAct)
- if len(logs) != 1 || cents(t, logs[0].Money) != -5000 {
- t.Fatalf("209 流水应为 -5000 分一条,实得 %+v", logs)
- }
- // 幂等:同 requestId 再调 → repeated=true、不再扣钱
- again, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
- UserID: user, ActivityID: 401, CampusID: 10,
- Money: mustAmount(t, "50.00"), RequestID: "REQ-CANCEL-1",
- })
- if err != nil {
- t.Fatalf("重复取消应幂等成功: %v", err)
- }
- if !again.Repeated {
- t.Fatalf("重复取消应 repeated=true")
- }
- if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 15000 {
- t.Fatalf("重复取消不得再扣(应仍 15000 分),实得 %d", got)
- }
- }
- // TestCancelRechargeActivityCampusMissing 校区不存在 → 3104(dump 返回 success=0 + 文案)。
- func TestCancelRechargeActivityCampusMissing(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- _, err := svc.CancelRechargeActivity(context.Background(), CancelRechargeActivityInput{
- UserID: fundUserID(61), ActivityID: 401, CampusID: 999,
- Money: mustAmount(t, "10.00"), RequestID: "REQ-CANCEL-3104",
- })
- requireAppCode(t, err, apperr.CodeCampusNotFound)
- }
- // ---------------------------------------------------------------- AC-05 落库越界负例
- // TestClearBalanceAmountOverflow AC-05 的落库负例:批量列更新(AddCampusColumns)在写库前
- // 拦下超出 decimal(12,2) 的金额(`Amount.Validate()` → ErrOverflow),账户零残留。
- func TestClearBalanceAmountOverflow(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- user := fundUserID(62)
- if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
- Balance: "0.00"}).Error; err != nil {
- t.Fatalf("seed 校区账户失败: %v", err)
- }
- err := db.Transaction(func(tx *gorm.DB) error {
- _, e := svc.repo.AddCampusColumns(ctx, tx, "OverflowTest", user, 1, 10,
- repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: money.MaxAmount + 1})
- 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,实得 %T", err)
- }
- if appErr.Code != infraerrors.ErrBadParam.Code && appErr.Code != infraerrors.ErrBadFormat.Code {
- t.Fatalf("越界金额应映射为 2xxx 参数错,实得 %d", appErr.Code)
- }
- if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 0 {
- t.Fatalf("越界金额不得落库(total_income 应仍 0),实得 %d", got)
- }
- }
- // ---------------------------------------------------------------- 顺序性工具
- // firstTouchIndex 返回第一条"命中 tableRe"的 SQL 下标(未命中 → -1;`-1` 便于比较)。
- //
- // 用正则而非 strings.Contains 的原因:`t_user_expense` 是 `t_user_expense_school` 的前缀,
- // 用 `\b` 词界才能把两张表区分开(`_` 属词字符,故 `t_user_expense\b` 不会命中 school)。
- func firstTouchIndex(stmts []string, tableRe string) int {
- re := regexp.MustCompile(tableRe)
- for i, sql := range stmts {
- if re.MatchString(sql) {
- return i
- }
- }
- return -1
- }
|