// FundService.Transfer 单测(AC-04 ④):课费转结按 **dump 语义**钉死—— // 不动 balance、106=+m / 206=−m、两侧 total_expenditure/total_income 反向调整、 // 目标账户不存在则建户、同校区自转与源账户缺失显式失败、同 requestId 幂等。 // // 测试栈与 fund_service_test.go 同一套(sqlite 文件库 + 真实唯一键;助手复用同 package)。 package service import ( "context" "regexp" "strconv" "strings" "testing" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" ) // TestTransferLeavesBalanceUntouchedAndWritesDumpDirections 转结核心口径(★dump 漂移项)。 // // dump(yuansiwei_finance.sql:764661): // // 源:total_expenditure−m, total_income−m ;目标:total_expenditure+m, total_income+m // log 106 = **+m**(转结出);log 206 = **−m**(转结入);**两端 balance 都不动** // // 与 P0-04 规格(106 负 / 206 正 且动 balance)相反——本用例就是把 dump 口径钉死的地方。 func TestTransferLeavesBalanceUntouchedAndWritesDumpDirections(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundSuperScope(), nil) // 转结跨机构/跨校区:用超管作用域 ctx := context.Background() user := fundUserID(20) // t_campus 登记(R4-bis:转入方建户的机构**只认校区表**,入参机构必须与之一致) seedCampus(t, db, 10, 1, true) seedCampus(t, db, 20, 1, true) // 源校区(机构 1 / 校区 10)余额 100,目标校区(机构 1 / 校区 20)的**账户**尚不存在 if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00"), RequestID: "REQ-TRANSFER-SEED"}); err != nil { t.Fatalf("准备源校区余额失败: %v", err) } res, err := svc.Transfer(ctx, TransferInput{ UserID: user, FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20, Money: mustAmount(t, "40.00"), RequestID: "REQ-TRANSFER-1", Remark: "课费转结", }) if err != nil { t.Fatalf("Transfer 失败: %v", err) } if res.LogID106 <= 0 || res.LogID206 <= 0 { t.Fatalf("两条流水 ID 都应 > 0,实得 %+v", res) } from := readSchool(t, db, user, 1, 10) to := readSchool(t, db, user, 1, 20) // ① **balance 不动**(dump 全程没有 balance 赋值) if got := cents(t, from.Balance); got != 10000 { t.Fatalf("源校区 balance 不得变动(应 10000 分),实得 %d", got) } if got := cents(t, to.Balance); got != 0 { t.Fatalf("目标校区 balance 不得变动(应 0 分),实得 %d", got) } // ② 源:total_expenditure/total_income 各 −40;目标:各 +40 // 注意:充值只累加 balance/total_income,不动 total_expenditure, // 故源 total_expenditure 是 0 − 4000 = **−4000**(dump 无下限保护,按 dump 保留负值) if got := cents(t, from.TotalExpenditure); got != -4000 { t.Fatalf("源校区 total_expenditure 应 -4000 分(0−4000,dump 无下限),实得 %d", got) } if got := cents(t, from.TotalIncome); got != 6000 { t.Fatalf("源校区 total_income 应 6000 分,实得 %d", got) } if got := cents(t, to.TotalExpenditure); got != 4000 { t.Fatalf("目标校区 total_expenditure 应 4000 分,实得 %d", got) } if got := cents(t, to.TotalIncome); got != 4000 { t.Fatalf("目标校区 total_income 应 4000 分,实得 %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.TotalExpenditure); got != 0 { t.Fatalf("全局 total_expenditure 不应变动(应 0),实得 %d", got) } // ④ 流水方向:106 = +4000,206 = −4000(★与"出/入"直觉相反,按 dump) logs106 := logsByType(t, db, user, model.ExpenseLogTypeTransferOut) logs206 := logsByType(t, db, user, model.ExpenseLogTypeTransferIn) if len(logs106) != 1 || cents(t, logs106[0].Money) != 4000 { t.Fatalf("106 流水应为 +4000 分一条,实得 %+v", logs106) } if len(logs206) != 1 || cents(t, logs206[0].Money) != -4000 { t.Fatalf("206 流水应为 -4000 分一条,实得 %+v", logs206) } } // TestTransferIdempotentSameRequestID 同 requestId 再转 → 两条流水都不新增、金额不再变动。 func TestTransferIdempotentSameRequestID(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundSuperScope(), nil) ctx := context.Background() user := fundUserID(21) seedCampus(t, db, 10, 1, true) seedCampus(t, db, 20, 1, true) if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00")}); err != nil { t.Fatalf("准备余额失败: %v", err) } in := TransferInput{ UserID: user, FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20, Money: mustAmount(t, "10.00"), RequestID: "REQ-TRANSFER-IDEMPOTENT", } first, err := svc.Transfer(ctx, in) if err != nil { t.Fatalf("首次转结失败: %v", err) } second, err := svc.Transfer(ctx, in) if err != nil { t.Fatalf("重复转结应幂等成功: %v", err) } if !second.Repeated { t.Fatalf("重复转结应 repeated=true") } if second.LogID106 != first.LogID106 || second.LogID206 != first.LogID206 { t.Fatalf("重复转结应回放首次流水 %+v,实得 %+v", first, second) } if got := len(logsByType(t, db, user, model.ExpenseLogTypeTransferOut)); got != 1 { t.Fatalf("106 流水应只 1 条,实得 %d", got) } if got := len(logsByType(t, db, user, model.ExpenseLogTypeTransferIn)); got != 1 { t.Fatalf("206 流水应只 1 条,实得 %d", got) } if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 9000 { t.Fatalf("源校区 total_income 应只减一次(9000 分),实得 %d", got) } } // TestTransferSameCampusRejected 同校区自转(净效应为零)→ 3140。 func TestTransferSameCampusRejected(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundScope(), nil) _, err := svc.Transfer(context.Background(), TransferInput{ UserID: fundUserID(22), FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 10, Money: mustAmount(t, "10.00"), }) requireAppCode(t, err, 3140) } // TestTransferSourceAccountMissing 源账户不存在 → 3001(dump 静默 no-op;Go 显式失败)。 func TestTransferSourceAccountMissing(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundSuperScope(), nil) _, err := svc.Transfer(context.Background(), TransferInput{ UserID: fundUserID(23), FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20, Money: mustAmount(t, "10.00"), }) requireAppCode(t, err, 3001) } // TestTransferRejectsInvalidAmount 金额 <=0 / 超上限 → 2002(不改任何账户)。 func TestTransferRejectsInvalidAmount(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundSuperScope(), nil) ctx := context.Background() user := fundUserID(24) if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00")}); err != nil { t.Fatalf("准备余额失败: %v", err) } base := TransferInput{ UserID: user, FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20, } for _, amount := range []string{"0.00", "-1.00", "1000000.01"} { in := base in.Money = mustAmount(t, amount) _, err := svc.Transfer(ctx, in) var want = 2002 requireAppCode(t, err, want) } if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 10000 { t.Fatalf("非法金额不得改动账户(应仍 10000 分),实得 %d", got) } } // TestTransferLocksCampusAccountsAscending 多账户加锁顺序:按 campus_id ASC(§1.4 防死锁)。 // // 断言口径:抓真实执行的 SQL 序列,要求"较小 campus_id 的校区锁语句"先出现。 func TestTransferLocksCampusAccountsAscending(t *testing.T) { db, rec := newFundServiceDBWithRecorder(t) svc := NewFundService(db, fundSuperScope(), nil) ctx := context.Background() user := fundUserID(25) seedCampus(t, db, 10, 1, true) seedCampus(t, db, 20, 1, true) if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 20, Money: mustAmount(t, "100.00")}); err != nil { t.Fatalf("准备余额失败: %v", err) } // 源校区 campus_id=20,目标校区 campus_id=10 → 加锁必须先 10 后 20(升序) rec.Reset() if _, err := svc.Transfer(ctx, TransferInput{ UserID: user, FromInstitutionID: 1, FromCampusID: 20, ToInstitutionID: 1, ToCampusID: 10, Money: mustAmount(t, "10.00"), RequestID: "REQ-TRANSFER-LOCK", }); err != nil { t.Fatalf("Transfer 失败: %v", err) } order := campusLockOrder(t, rec.Snapshot()) if order.firstCampusID != 10 || order.secondCampusID != 20 { t.Fatalf("校区加锁顺序应为 10 → 20(campus_id ASC),实得 %d → %d", order.firstCampusID, order.secondCampusID) } } // campusLockOrder 从 SQL 序列里提取"两次校区账户 SELECT ... FOR UPDATE 命中的 campus_id"。 // // 实现说明:sqlite 不渲染 `FOR UPDATE`,故用**语句里的 campus_id 字面量**判定命中的是哪一行 // (GetCampusForUpdateExact 的 WHERE 一定带 `campus_id = ?` 参数,GORM 会把参数内联进日志)。 type lockOrder struct { firstCampusID int64 secondCampusID int64 } // campusLockOrder 解析加锁顺序(见类型注释)。 func campusLockOrder(t *testing.T, stmts []string) lockOrder { t.Helper() var out lockOrder seen := 0 for _, sql := range stmts { if !strings.Contains(sql, "FROM") || !strings.Contains(sql, "t_user_expense_school") { continue } if !strings.Contains(sql, "LIMIT") { continue // 只要点查(加锁读)语句,跳过 UPDATE } id := extractCampusIDParam(t, sql) if id <= 0 { continue } seen++ switch seen { case 1: out.firstCampusID = id case 2: out.secondCampusID = id } if seen == 2 { return out } } t.Fatalf("未捕获到两次校区账户加锁语句(seen=%d),实际 SQL=%v", seen, stmts) return out } // campusIDParamRe 匹配语句中被内联的 `campus_id = N` 参数(GORM 日志 Explain 会内联实参)。 var campusIDParamRe = regexp.MustCompile(`campus_id\s*=\s*(-?\d+)`) // extractCampusIDParam 从单条 SQL 中取出 campus_id 实参(取不到 → 0)。 func extractCampusIDParam(t *testing.T, sql string) int64 { t.Helper() m := campusIDParamRe.FindStringSubmatch(sql) if len(m) < 2 { return 0 } v, err := strconv.ParseInt(m[1], 10, 64) if err != nil { return 0 } return v } // ---------------------------------------------------------------- R4 / R4-bis 边界 // TestTransferTargetAccountCreatedWhenMissing R4 ④:转入方**账户**缺失 = **建户**(不是 3001)。 // // 反向断言:建户用的机构必须是 t_campus 的权威值,且新户余额恒 0(转结不动 balance)。 func TestTransferTargetAccountCreatedWhenMissing(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundSuperScope(), nil) ctx := context.Background() user := fundUserID(26) seedCampus(t, db, 10, 1, true) seedCampus(t, db, 20, 1, true) if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00"), RequestID: "REQ-R4-SEED"}); err != nil { t.Fatalf("准备源校区余额失败: %v", err) } if _, err := svc.Transfer(ctx, TransferInput{UserID: user, FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20, Money: mustAmount(t, "30.00"), RequestID: "REQ-R4-CREATE"}); err != nil { t.Fatalf("转入方缺失应建户成功,实得错误: %v", err) } to := readSchool(t, db, user, 1, 20) // 不存在会 Fatal if got := cents(t, to.Balance); got != 0 { t.Fatalf("转结不动 balance,新户余额应 0,实得 %d", got) } if got := cents(t, to.TotalIncome); got != 3000 { t.Fatalf("新户 total_income 应 +3000,实得 %d", got) } } // TestTransferRejectsInstitutionMismatch R4-bis ③:入参 toInstitutionId 与校区归属机构矛盾 → **3140**, // 且**表零变化**:两张账户表行数都不变、不写流水、源校区与全局的金额逐列不变。 // // 为什么必须断言"表零变化"而不是"目标校区 count=0":dump 按入参机构建户会留下**机构错配的僵尸账户**, // 而账户只增不删、UK 又让它按正确机构永远查不到 → 不可自愈。只断言"该 campus 没有行"会漏掉 // "建到了错机构下"这一种(campus 相同、institution 不同)。 func TestTransferRejectsInstitutionMismatch(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundSuperScope(), nil) ctx := context.Background() user := fundUserID(27) seedCampus(t, db, 10, 1, true) seedCampus(t, db, 20, 1, true) // 校区 20 真实归属机构 1 if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00"), RequestID: "REQ-R4BIS-SEED"}); err != nil { t.Fatalf("准备源校区余额失败: %v", err) } srcBefore := readSchool(t, db, user, 1, 10) globalBefore := readGlobal(t, db, user) logsBefore := len(balanceLogs(t, db, user)) globalRowsBefore := countRows(t, db, &model.UserExpense{}, user) schoolRowsBefore := countRows(t, db, &model.UserExpenseSchool{}, user) // 入参声明机构 2,但校区 20 属于机构 1 → 3140 _, err := svc.Transfer(ctx, TransferInput{UserID: user, FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 2, ToCampusID: 20, Money: mustAmount(t, "30.00"), RequestID: "REQ-R4BIS-BAD"}) requireAppCode(t, err, 3140) // ① 两张账户表行数都不变(既没建错机构的户,也没多建全局户) if got := countRows(t, db, &model.UserExpenseSchool{}, user); got != schoolRowsBefore { t.Fatalf("R4-bis 拒绝时校区账户行数必须不变:前 %d 后 %d", schoolRowsBefore, got) } if got := countRows(t, db, &model.UserExpense{}, user); got != globalRowsBefore { t.Fatalf("R4-bis 拒绝时全局账户行数必须不变:前 %d 后 %d", globalRowsBefore, got) } // ② 目标校区在**任何机构**下都不得有行(防"建到错机构"的僵尸户) var anywhere int64 if e := db.Model(&model.UserExpenseSchool{}).Where("user_id = ? AND campus_id = ?", user, 20). Count(&anywhere).Error; e != nil { t.Fatalf("统计目标校区账户失败: %v", e) } if anywhere != 0 { t.Fatalf("R4-bis 拒绝时不得建户(任意机构下),实得 %d 行", anywhere) } // ③ 不写流水 if got := len(balanceLogs(t, db, user)); got != logsBefore { t.Fatalf("R4-bis 拒绝时不得写流水:前 %d 后 %d", logsBefore, got) } // ④ 源校区与全局金额逐列不变 srcAfter := readSchool(t, db, user, 1, 10) if cents(t, srcAfter.Balance) != cents(t, srcBefore.Balance) || cents(t, srcAfter.TotalIncome) != cents(t, srcBefore.TotalIncome) || cents(t, srcAfter.TotalExpenditure) != cents(t, srcBefore.TotalExpenditure) { t.Fatalf("R4-bis 拒绝时源校区不得变动:前 %+v 后 %+v", srcBefore, srcAfter) } globalAfter := readGlobal(t, db, user) if cents(t, globalAfter.Balance) != cents(t, globalBefore.Balance) || cents(t, globalAfter.TotalIncome) != cents(t, globalBefore.TotalIncome) || cents(t, globalAfter.TotalExpenditure) != cents(t, globalBefore.TotalExpenditure) { t.Fatalf("R4-bis 拒绝时全局账户不得变动:前 %+v 后 %+v", globalBefore, globalAfter) } } // countRows 统计某 user 在给定表的行数("零副作用"断言用)。 func countRows(t *testing.T, db *gorm.DB, table any, userID string) int64 { t.Helper() var n int64 if err := db.Model(table).Where("user_id = ?", userID).Count(&n).Error; err != nil { t.Fatalf("统计 %T 行数失败: %v", table, err) } return n } // TestTransferTargetCampusUnknown R4-bis ②:转入校区在 t_campus 里查不到 / 未归属机构 → **3104**。 func TestTransferTargetCampusUnknown(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundSuperScope(), nil) ctx := context.Background() user := fundUserID(28) seedCampus(t, db, 10, 1, true) if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00"), RequestID: "REQ-R4-UNKNOWN-SEED"}); err != nil { t.Fatalf("准备源校区余额失败: %v", err) } // 校区 99 未登记 → 3104(无法确定机构,拒绝建户) _, err := svc.Transfer(ctx, TransferInput{UserID: user, FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 99, Money: mustAmount(t, "10.00"), RequestID: "REQ-R4-UNKNOWN"}) requireAppCode(t, err, 3104) // 未归属机构(institution_id = 0)同样 3104 seedCampus(t, db, 98, 0, true) _, err = svc.Transfer(ctx, TransferInput{UserID: user, FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 98, Money: mustAmount(t, "10.00"), RequestID: "REQ-R4-UNKNOWN2"}) requireAppCode(t, err, 3104) } // TestTransferRejectsInstitutionMismatchWhenTargetExists R4-bis 的**"已存在"侧**: // 目标账户**已按正确机构存在**,但入参机构写错 → 仍必须 **3140**,且目标行/源行/全局**逐列不变**。 // // 与 `TestTransferRejectsInstitutionMismatch`(目标缺失 → 不许建户)互补: // 两者合起来才证明「定位必须带机构」。为什么单独立这条: // 目标账户加锁走 `GetCampusForUpdateExact(user, 入参机构, campus)` 的**精确三元组**, // 入参机构错 → 查不到按正确机构存在的那行 → 落进"建户"分支 → 过 3140(不建户、不更新)。 // 若有人把该查询放宽成 `WHERE user_id AND campus_id`(看起来更"宽容"),本例就会变成 // "按错机构 UPDATE 0 行 + 转出照常成功" ⇒ 转入静默丢失;而这类错**通用恒等式与差值冻结都抓不到** // (转结不动 balance,106+206=0)。故必须由本例直接钉住。 func TestTransferRejectsInstitutionMismatchWhenTargetExists(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundSuperScope(), nil) ctx := context.Background() user := fundUserID(29) seedCampus(t, db, 10, 1, true) seedCampus(t, db, 20, 1, true) // 源校区充值 → 源账户 (1,10) 有余额;再向目标校区 (1,20) 充一笔 → 目标账户**已存在** if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00"), RequestID: "REQ-R4E-SRC"}); err != nil { t.Fatalf("准备源校区失败: %v", err) } if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 20, Money: mustAmount(t, "50.00"), RequestID: "REQ-R4E-DST"}); err != nil { t.Fatalf("准备目标校区失败: %v", err) } srcBefore := readSchool(t, db, user, 1, 10) dstBefore := readSchool(t, db, user, 1, 20) globalBefore := readGlobal(t, db, user) logsBefore := len(balanceLogs(t, db, user)) schoolRowsBefore := countRows(t, db, &model.UserExpenseSchool{}, user) // 目标账户存在,但入参声明机构 2(真实归属机构 1)→ 3140 _, err := svc.Transfer(ctx, TransferInput{UserID: user, FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 2, ToCampusID: 20, Money: mustAmount(t, "30.00"), RequestID: "REQ-R4E-BAD"}) requireAppCode(t, err, 3140) // ① 目标行逐列不变(防"按错机构更新 0 行"后的静默半成功) dstAfter := readSchool(t, db, user, 1, 20) if cents(t, dstAfter.Balance) != cents(t, dstBefore.Balance) || cents(t, dstAfter.TotalIncome) != cents(t, dstBefore.TotalIncome) || cents(t, dstAfter.TotalExpenditure) != cents(t, dstBefore.TotalExpenditure) { t.Fatalf("R4-bis 拒绝时目标校区不得变动:前 %+v 后 %+v", dstBefore, dstAfter) } // ② 源行逐列不变(转出侧不得先扣) srcAfter := readSchool(t, db, user, 1, 10) if cents(t, srcAfter.Balance) != cents(t, srcBefore.Balance) || cents(t, srcAfter.TotalIncome) != cents(t, srcBefore.TotalIncome) || cents(t, srcAfter.TotalExpenditure) != cents(t, srcBefore.TotalExpenditure) { t.Fatalf("R4-bis 拒绝时源校区不得变动:前 %+v 后 %+v", srcBefore, srcAfter) } // ③ 全局账户逐列不变 globalAfter := readGlobal(t, db, user) if cents(t, globalAfter.Balance) != cents(t, globalBefore.Balance) || cents(t, globalAfter.TotalIncome) != cents(t, globalBefore.TotalIncome) || cents(t, globalAfter.TotalExpenditure) != cents(t, globalBefore.TotalExpenditure) { t.Fatalf("R4-bis 拒绝时全局账户不得变动:前 %+v 后 %+v", globalBefore, globalAfter) } // ④ 无新行(任意机构下 campus 20 只有 1 行)、无流水 if got := countRows(t, db, &model.UserExpenseSchool{}, user); got != schoolRowsBefore { t.Fatalf("R4-bis 拒绝时不得新增账户行:前 %d 后 %d", schoolRowsBefore, got) } var dstRows int64 if e := db.Model(&model.UserExpenseSchool{}).Where("user_id = ? AND campus_id = ?", user, 20). Count(&dstRows).Error; e != nil { t.Fatalf("统计目标校区行数失败: %v", e) } if dstRows != 1 { t.Fatalf("目标校区任意机构下应仍只有 1 行,实得 %d", dstRows) } if got := len(balanceLogs(t, db, user)); got != logsBefore { t.Fatalf("R4-bis 拒绝时不得写流水:前 %d 后 %d", logsBefore, got) } // ⑤ 差值冻结未受影响(这类错虽抓不到,但至少确认本次拒绝没引入偏差) if got, want := campusGlobalDelta(t, db, user), int64(0); got != want { t.Fatalf("拒绝前后差值应保持 %d,实得 %d", want, got) } }