|
|
@@ -0,0 +1,933 @@
|
|
|
+// 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
|
|
|
+}
|