|
@@ -416,3 +416,85 @@ func TestTransferTargetCampusUnknown(t *testing.T) {
|
|
|
Money: mustAmount(t, "10.00"), RequestID: "REQ-R4-UNKNOWN2"})
|
|
Money: mustAmount(t, "10.00"), RequestID: "REQ-R4-UNKNOWN2"})
|
|
|
requireAppCode(t, err, 3104)
|
|
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)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|