Selaa lähdekoodia

test(academic): R4-bis 冲突态加强为「表零变化」+ 注明两个快照 helper 不可合并的理由

## 1) R4-bis 用例加强(回应 team-lead 的第 2 点确认)
`TestTransferRejectsInstitutionMismatch` 原来只断言"目标校区(机构 1 下)无账户行",现改为**表零变化**:
- `t_user_expense_school` 行数不变(既没建错机构的户,也没多建);
- `t_user_expense` 行数不变;
- 目标 `campus_id` 在**任意机构**下都无行(专防"建到错机构"的僵尸户:campus 相同、institution 不同);
- 流水条数不变;
- 源校区与全局账户的 `balance`/`total_income`/`total_expenditure` **逐列**不变。

理由:僵尸账户不可自愈(账户只增不删 + UK 使其按正确机构永远查不到),
只断言"该 campus 在预期机构下没有行"会漏掉"建到错机构"这一种。

## 2) 注释收口(回应架构师建议)
`globalBalanceAfter` / `campusBalanceAfter` 头部互相加"**不得合并**"警示,并写明口径分离理由:
两者是两列不同的列、服务两个不同消费者(流水 `balance_after` 全局口径 vs 响应体 `Balance` 校区口径),
多校区场景下数值本来就不相等——后人在"优化"时最容易把它们并成一个。

## 3) 确认项(team-lead 第 1 点)
`Transfer` 建户处确为 `UpsertCampus(... InstitutionID: inst, CampusID: in.ToCampusID)`,
`inst` 来自 `resolveTargetInstitution` 的校区表权威值,**不是入参** `in.ToInstitutionID`。

## 4) DDL 三处同步已完成(上一提交 23bbe7b,此处复核)
doc 副本 `.doc-ddl/` 已用后即删;fixture 哈希 = a9220e7a5b04cfa4846202b57ddb9470546a03540d41934fe208e81c4b7e10bc;
`tr -cd '\r' | wc -c` = 0;`TestDDLHashLock*` / `TestDDLTableCoverage` 全绿。

go build / go vet / go test -short ./... 全绿。
Q 17 tuntia sitten
vanhempi
sitoutus
0be6417eb6

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

@@ -373,6 +373,11 @@ func parseAccountAmount(raw, col string) (money.Amount, error) {
 
 // globalBalanceAfter 取「该笔操作完成后**全局账户**(t_user_expense)余额」快照。
 //
+// ⚠ **与 `campusBalanceAfter` 不得合并**(后人最容易"优化"成同一个函数):两者是**两列不同的列**、
+// 服务于**两个不同的消费者**,且在"多校区"场景下数值本来就不相等——
+//   - 本函数 → 流水 `t_user_expense_log.balance_after`(对账链,全局口径);
+//   - 另一个 → API 响应体 `Result.Balance`(用户体验,校区口径)。
+//
 // 用途(唯一):流水列 `t_user_expense_log.balance_after` 的取值口径(设计 §3.8-f ②-bis 裁定):
 //   - 语义 = **全局账户余额**,不是校区余额(`t_user_expense_log` 上没有 `campus_id`,
 //     只有 user 级全局链才能做 `balance_after(op_n) = balance_after(op_{n−1}) + Σ(money of op_n)` 校验);
@@ -393,6 +398,8 @@ func globalBalanceAfter(
 
 // campusBalanceAfter 取「该笔操作完成后**校区账户**余额」快照。
 //
+// ⚠ **与 `globalBalanceAfter` 不得合并**:见后者头注释(两个消费者、两列、多校区下数值本就不等)。
+//
 // 用途(唯一):**API 响应体的 `Balance` 字段**(各 Result.Balance 的契约是"校区账户余额",
 // 与流水的 `balance_after` 语义不同,**不得**混用同一个值)。
 //

+ 47 - 8
internal/academic/service/fund_service_transfer_test.go

@@ -12,6 +12,8 @@ import (
 	"strings"
 	"testing"
 
+	"gorm.io/gorm"
+
 	"yuansiwei/unified-backend/internal/academic/model"
 )
 
@@ -315,7 +317,11 @@ func TestTransferTargetAccountCreatedWhenMissing(t *testing.T) {
 }
 
 // TestTransferRejectsInstitutionMismatch R4-bis ③:入参 toInstitutionId 与校区归属机构矛盾 → **3140**,
-// 且**不建户、不写流水、不动任何账户**(dump 会按错机构建僵尸户 → 同校区双账户 → 差值冻结破)。
+// 且**表零变化**:两张账户表行数都不变、不写流水、源校区与全局的金额逐列不变。
+//
+// 为什么必须断言"表零变化"而不是"目标校区 count=0":dump 按入参机构建户会留下**机构错配的僵尸账户**,
+// 而账户只增不删、UK 又让它按正确机构永远查不到 → 不可自愈。只断言"该 campus 没有行"会漏掉
+// "建到了错机构下"这一种(campus 相同、institution 不同)。
 func TestTransferRejectsInstitutionMismatch(t *testing.T) {
 	db := newFundServiceDB(t)
 	svc := NewFundService(db, fundSuperScope(), nil)
@@ -328,7 +334,11 @@ func TestTransferRejectsInstitutionMismatch(t *testing.T) {
 		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,
@@ -336,20 +346,49 @@ func TestTransferRejectsInstitutionMismatch(t *testing.T) {
 		Money: mustAmount(t, "30.00"), RequestID: "REQ-R4BIS-BAD"})
 	requireAppCode(t, err, 3140)
 
-	var cnt int64
-	if e := db.Model(&model.UserExpenseSchool{}).
-		Where("user_id = ? AND campus_id = ?", user, 20).Count(&cnt).Error; e != nil {
+	// ① 两张账户表行数都不变(既没建错机构的户,也没多建全局户)
+	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 cnt != 0 {
-		t.Fatalf("R4-bis 拒绝时不得建户,实得 %d 行(机构错配的僵尸账户)", cnt)
+	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)
 	}
-	if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 10000 {
-		t.Fatalf("R4-bis 拒绝时源校区不得变动(应 10000),实得 %d", 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**。