Kaynağa Gözat

test(academic): 补 R4-bis「目标账户已存在」侧用例 + 把「定位必须带机构」的推理写进注释

## 1) 新用例 `TestTransferRejectsInstitutionMismatchWhenTargetExists`
场景与既有那条互补:
- 既有 `TestTransferRejectsInstitutionMismatch`:目标账户**缺失** → 不许建户(堵僵尸户);
- 新增:目标账户**已按正确机构存在**、入参机构写错 → 仍须 **3140**(堵"按错机构更新 0 行"的静默半成功)。

断言:目标行 / 源行 / 全局账户三处 `balance`+`total_income`+`total_expenditure` **逐列不变**;
两张账户表行数不变;目标 campus 在任意机构下仍只有 1 行;流水条数不变;差值冻结保持。

## 2) 该用例是**变异可红**的(已实测,不是口头声称)
把 `ExpenseRepo.GetCampusForUpdateExact` 的 WHERE 从
`user_id + institution_id + campus_id` 放宽为 `user_id + campus_id`(模拟后人"优化"):
```
--- FAIL: TestTransferRejectsInstitutionMismatchWhenTargetExists
    错误码=3001,期望 3140(Transfer(in) 校区账户未建户…)
```
还原后即绿。说明本例确实钉住了"定位必须带机构"这一性质。

## 3) 注释:把不显然的性质写下来(架构师复核时指出)
在 `Transfer` 的 R4-bis 段补三条推理:
- 目标账户加锁走 `GetCampusForUpdateExact(user, **入参机构**, campus)` 的**精确三元组**;
- 入参机构错 → 永远查不到按正确机构存在的那行 → 必然进建户分支 → 必然过 3140
  ⇒ 覆盖到"账户已存在"路径,不存在"按错机构更新 0 行、转出成功而转入静默失败"的窗口;
- **一旦放宽成 `WHERE user_id AND campus_id`,3140 立刻形同虚设,且通用恒等式与差值冻结都抓不到**
  (转结不动 balance、106+206=0)——这正是新增用例存在的原因。

go build / go vet / go test -short ./... 全绿。
Q 17 saat önce
ebeveyn
işleme
bc7f2a2d17

+ 8 - 0
internal/academic/service/fund_service.go

@@ -1031,6 +1031,14 @@ func (s *FundService) Transfer(ctx context.Context, in TransferInput) (*Transfer
 			//   ① inst := 校区表里的 institution_id(同事务只读)  ② 取不到 → 3104
 			//   ③ 入参 toInstitutionId ≠ inst → 3140(不建户、不落任何行)  ④ 用 inst 建户
 			// 正常数据下 ②③ 恒不触发(两值相同),只在非法入参时拒绝。
+			//
+			// ⚠ **本段能否覆盖"目标账户已存在",取决于上面的加锁查询必须是精确三元组**:
+			// `GetCampusForUpdateExact(user, **入参机构**, campus)` 在入参机构错时**永远查不到**
+			// 按正确机构存在的那行 → 必然落进本分支 → 必然过 3140。因此"错入参"既不会建户、
+			// 也不会走 ④ 的 UPDATE,不存在"按错机构更新 0 行 → 转出成功而转入静默失败"的窗口。
+			// **一旦有人把该查询放宽成 `WHERE user_id AND campus_id`(看着更"宽容")**,
+			// 3140 立刻形同虚设,而且通用恒等式与差值冻结都抓不到(转结不动 balance、106+206=0)——
+			// 这正是 `TestTransferRejectsInstitutionMismatchWhenTargetExists` 存在的意义。
 			inst, ie := s.resolveTargetInstitution(txCtx, tx, in.ToCampusID, in.ToInstitutionID)
 			if ie != nil {
 				return ie

+ 82 - 0
internal/academic/service/fund_service_transfer_test.go

@@ -416,3 +416,85 @@ func TestTransferTargetCampusUnknown(t *testing.T) {
 		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)
+	}
+}