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