// 余额台账两条门禁(AC-17 ④⑴⑵)的单测:通用恒等式 + `balance_after` 分组链 + 差值冻结。 // // 口径来源(设计 v0.1.16): // // §3.8-(f) ① 主判据(**全类型一律计入、零分支**): // current_balance = baseline_balance + Σ(log.money WHERE user_id=? AND create_time > baseline_at) // 10 条写路径逐条成立;**不得把 type=108 从 Σ 里剔除**(剔了只剩 207=−requested,恒等式反而破)。 // A18 的「独立坏账 / 差额型」只用于**报告归因**,不参与判据。 // §3.8-f ②-bis `balance_after`: // 语义 = 该操作完成后**全局账户**余额(不是校区余额);同操作多行**同值**; // 链校验按 `request_id` 分组(NULL 行按 id 单行成组): // balance_after(op_n) = balance_after(op_{n−1}) + Σ(money of op_n) // 老带新双边两行属**不同 user** → 各取本 user 的全局快照,**不要求相等**。 // §3.8-(f) ⑵ 校区侧**差值冻结**:`Σ_campus balance − global balance` 在每次写入前后不变 // (每条写路径对全局与其校区加同一增量);只要求"不再变化",不要求存量相等。 package service import ( "context" "errors" "testing" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" "yuansiwei/unified-backend/internal/academic/port" ) // ---------------------------------------------------------------- ① 通用恒等式 // assertGeneralIdentity 断言 §3.8-(f) ① 的主判据(**零分支、全类型计入**)。 // // 入参:db / userID / baselineBalance(本题从 0 起算就是 0)。 // 语义:`Σ(全部流水 money) == 全局账户 balance`。任何写路径漏记/多记一条流水,本断言即红。 func assertGeneralIdentity(t *testing.T, db *gorm.DB, userID string, baselineCents int64) { t.Helper() sum := sumLogMoney(t, balanceLogs(t, db, userID)).Cents() got := cents(t, readGlobal(t, db, userID).Balance) if baselineCents+sum != got { t.Fatalf("通用恒等式破式:baseline(%d) + Σ流水(%d) = %d ≠ 全局 balance(%d)", baselineCents, sum, baselineCents+sum, got) } } // ---------------------------------------------------------------- ② balance_after 分组链 // logOpKey 返回流水所属"操作"的分组键。 // // 口径(§3.8-f ②-bis):`request_id` 非空 → 同 `request_id` 的行属同一操作; // 为空/NULL(历史行、清零 208 等无请求号的操作)→ 按 `id` 单行成组(组内只有它自己)。 func logOpKey(row model.UserExpenseLog) string { if key := logKeyOf(row); key != "" { return "req:" + key } return "id:" + money.FromCents(row.ID).String() } // assertBalanceAfterChain 断言 `balance_after` 的**分组链**(禁止逐行做 n=(n−1)+money)。 // // 规则: // 1. 同一操作内多行 `balance_after` **必须同值**(转结 106/206、退费 108/207); // 2. 组间链式:`after(op_n) == after(op_{n−1}) + Σ(money of op_n)`; // 3. 末组的 `balance_after` == 当前全局账户余额(快照语义是"全局")。 // // 入参:db / userID。出参:无(失败直接 t.Fatalf)。 func assertBalanceAfterChain(t *testing.T, db *gorm.DB, userID string) { t.Helper() rows := balanceLogs(t, db, userID) // 按 id ASC type group struct { key string after int64 sum int64 } groups := make([]group, 0, len(rows)) index := make(map[string]int, len(rows)) for i := range rows { if rows[i].BalanceAfter == nil { t.Fatalf("流水(id=%d,type=%d) 缺 balance_after 快照", rows[i].ID, rows[i].Type) } after := cents(t, *rows[i].BalanceAfter) key := logOpKey(rows[i]) amt := cents(t, rows[i].Money) pos, ok := index[key] if !ok { index[key] = len(groups) groups = append(groups, group{key: key, after: after, sum: amt}) continue } // 规则 1:同操作多行必须同值 if groups[pos].after != after { t.Fatalf("同一操作(%s) 的 balance_after 不一致:%d vs %d(应同值)", key, groups[pos].after, after) } groups[pos].sum += amt } // 规则 2:组间链式 var prev int64 for i, g := range groups { if i > 0 && prev+g.sum != g.after { t.Fatalf("第 %d 组(%s) 链式破式:上组 after(%d) + 本组 Σmoney(%d) = %d ≠ 本组 after(%d)", i+1, g.key, prev, g.sum, prev+g.sum, g.after) } prev = g.after } // 规则 3:末组快照 == 当前全局余额 if len(groups) > 0 { if got := cents(t, readGlobal(t, db, userID).Balance); prev != got { t.Fatalf("末条 balance_after(%d) 应等于当前全局账户余额(%d)", prev, got) } } } // TestBalanceAfterIsGlobalSnapshotAndGrouped `balance_after` = 全局快照 + 同操作同值 + 分组链。 // // 关键反向断言(把"取校区余额"的实现打红):校区 A 与全局余额**故意不相等**—— // 先在 A 充 100、再在 B 充 50,则全局 150 而 A 仍 100;此时 A 上的坏账 30: // - 全局 180 → 流水 balance_after 必须是 **18000**; // - 若实现取的是校区余额会写 13000(差评:这就是本次返工要修的点)。 func TestBalanceAfterIsGlobalSnapshotAndGrouped(t *testing.T) { db := newFundServiceDB(t) svc := NewFundService(db, fundSuperScope(), nil) ctx := context.Background() user := fundUserID(300) // ① 单行操作:校区 A 充 100 → 全局 100 if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00"), RequestID: "REQ-BA-1"}); err != nil { t.Fatalf("Recharge(A) 失败: %v", err) } // ② 单行操作:校区 B 充 50 → 全局 150,A 仍 100(两者**故意不等**) if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 20, Money: mustAmount(t, "50.00"), RequestID: "REQ-BA-2"}); err != nil { t.Fatalf("Recharge(B) 失败: %v", err) } if got := cents(t, readGlobal(t, db, user).Balance); got != 15000 { t.Fatalf("准备态:全局应 15000 分,实得 %d", got) } if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 10000 { t.Fatalf("准备态:校区 A 应 10000 分,实得 %d", got) } // ③ 坏账 30(校区 A):全局 180 / 校区 A 130 → 快照必须是 18000 if _, err := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "30.00"), RequestID: "REQ-BA-3"}); err != nil { t.Fatalf("BadDebt 失败: %v", err) } logs108 := logsByType(t, db, user, model.ExpenseLogTypeBadDebt) if len(logs108) != 1 || logs108[0].BalanceAfter == nil { t.Fatalf("108 流水应 1 条且带快照,实得 %+v", logs108) } if got := cents(t, *logs108[0].BalanceAfter); got != 18000 { t.Fatalf("balance_after 必须是**全局**快照 18000 分(校区余额是 13000),实得 %d", got) } // ④ 多行同操作:转结 40(校区 A → 校区 B),balance 不动 → 106/206 同一全局快照 if _, err := svc.Transfer(ctx, TransferInput{UserID: user, FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20, Money: mustAmount(t, "40.00"), RequestID: "REQ-BA-4"}); err != nil { t.Fatalf("Transfer 失败: %v", err) } l106 := logsByType(t, db, user, model.ExpenseLogTypeTransferOut) l206 := logsByType(t, db, user, model.ExpenseLogTypeTransferIn) if len(l106) != 1 || len(l206) != 1 { t.Fatalf("转结应各 1 条流水,实得 %d/%d", len(l106), len(l206)) } if l106[0].BalanceAfter == nil || l206[0].BalanceAfter == nil { t.Fatalf("转结流水必须带 balance_after:106=%+v 206=%+v", l106[0], l206[0]) } if *l106[0].BalanceAfter != *l206[0].BalanceAfter { t.Fatalf("转结 106/206 的 balance_after 必须同值:106=%s 206=%s(同一操作多行同值)", *l106[0].BalanceAfter, *l206[0].BalanceAfter) } if got := cents(t, *l106[0].BalanceAfter); got != 18000 { t.Fatalf("转结不改 balance,快照应仍 18000 分,实得 %d", got) } // ⑤ 多行同操作:自定义退费(150 > 可退 90 → badDebt 60),108/207 同一全局快照 refund := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil) seedCampus(t, db, 10, 1, true) if _, err := refund.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "150.00"), RequestID: "REQ-BA-5"}); err != nil { t.Fatalf("CustomRefund 失败: %v", err) } l108 := logsByType(t, db, user, model.ExpenseLogTypeBadDebt) var last108 model.UserExpenseLog for i := range l108 { if l108[i].RequestID != nil && *l108[i].RequestID == "REQ-BA-5" { last108 = l108[i] } } l207 := logsByType(t, db, user, model.ExpenseLogTypeRefund) if last108.ID == 0 || len(l207) != 1 { t.Fatalf("退费应产生 1 条 108 与 1 条 207,实得 108(id=%d)/207(%d)", last108.ID, len(l207)) } if last108.BalanceAfter == nil || l207[0].BalanceAfter == nil { t.Fatalf("退费流水必须带 balance_after:108=%+v 207=%+v", last108, l207[0]) } if *last108.BalanceAfter != *l207[0].BalanceAfter { t.Fatalf("退费 108/207 的 balance_after 必须同值:108=%s 207=%s", *last108.BalanceAfter, *l207[0].BalanceAfter) } // ⑥ 分组链 + 通用恒等式一起收口 assertBalanceAfterChain(t, db, user) assertGeneralIdentity(t, db, user, 0) } // ---------------------------------------------------------------- ② 差值冻结 // campusGlobalDelta 计算 `Σ_campus balance − 全局 balance`(冻结量的定义)。 // // 账户尚未建户时按 0 计(首个写路径之前是常态:建户发生在事务内的第一个 UPSERT)。 func campusGlobalDelta(t *testing.T, db *gorm.DB, userID string) int64 { t.Helper() var rows []model.UserExpenseSchool if err := db.Where("user_id = ?", userID).Find(&rows).Error; err != nil { t.Fatalf("读校区账户失败: %v", err) } sum := int64(0) for i := range rows { sum += cents(t, rows[i].Balance) } var global model.UserExpense globalCents := int64(0) err := db.Where("user_id = ?", userID).Take(&global).Error switch { case err == nil: globalCents = cents(t, global.Balance) case errors.Is(err, gorm.ErrRecordNotFound): globalCents = 0 // 未建户 default: t.Fatalf("读全局账户失败: %v", err) } return sum - globalCents } // TestCampusGlobalDeltaFrozen 差值冻结(§3.8-(f) ⑵):每条写路径对全局与其校区加**同一增量**, // 故 `Σ_campus balance − global balance` 在所有写操作前后**不变**。 // // 覆盖:充值(两个校区)/ 转结 / 退费 / 坏账(双加)/ 自定义退费(差额)/ 取消充值活动 / 余额清零。 // 这条断言的作用:任一路径只更新了单边(dump 的"双表撕裂"),差值立刻漂移 → 红。 func TestCampusGlobalDeltaFrozen(t *testing.T) { db := newFundServiceDB(t) fund := NewFundService(db, fundSuperScope(), nil) refund := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil) ctx := context.Background() user := fundUserID(301) seedCampus(t, db, 10, 1, true) seedCampus(t, db, 20, 1, true) seedRechargeActivityForCancel(t, db, user, 77) steps := []struct { name string run func() }{ {"recharge@A", func() { mustNoErr(t, func() error { _, e := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "200.00"), RequestID: "REQ-FR-1"}) return e }) }}, {"recharge@B", func() { mustNoErr(t, func() error { _, e := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 20, Money: mustAmount(t, "100.00"), RequestID: "REQ-FR-2"}) return e }) }}, {"transfer", func() { mustNoErr(t, func() error { _, e := fund.Transfer(ctx, TransferInput{UserID: user, FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20, Money: mustAmount(t, "50.00"), RequestID: "REQ-FR-3"}) return e }) }}, {"fund.refund", func() { mustNoErr(t, func() error { _, e := fund.Refund(ctx, RefundInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "20.00"), Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-FR-4"}) return e }) }}, {"baddebt", func() { mustNoErr(t, func() error { _, e := fund.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "10.00"), RequestID: "REQ-FR-5"}) return e }) }}, {"customRefund", func() { mustNoErr(t, func() error { _, e := refund.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "30.00"), RequestID: "REQ-FR-6"}) return e }) }}, {"cancelRechargeActivity", func() { mustNoErr(t, func() error { _, e := refund.CancelRechargeActivity(ctx, CancelRechargeActivityInput{UserID: user, ActivityID: 77, CampusID: 10, Money: mustAmount(t, "5.00"), RequestID: "REQ-FR-7"}) return e }) }}, {"clearBalance", func() { mustNoErr(t, func() error { _, e := refund.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 20}) return e }) }}, } delta := campusGlobalDelta(t, db, user) for _, step := range steps { step.run() if got := campusGlobalDelta(t, db, user); got != delta { t.Fatalf("%s 之后差值冻结被破坏:前 %d 后 %d(说明该路径只更新了全局/校区单边)", step.name, delta, got) } assertGeneralIdentity(t, db, user, 0) } // 至少确认确实发生了资金变动(防止"全都没生效 → 差值自然不变"的假绿) if got := cents(t, readGlobal(t, db, user).Balance); got == 0 { t.Fatalf("准备态异常:全部写路径跑完后全局余额仍为 0,差值为 %d", delta) } } // mustNoErr 执行 fn 并要求无错误(表驱动里扁平化错误处理)。 func mustNoErr(t *testing.T, fn func() error) { t.Helper() if err := fn(); err != nil { t.Fatalf("步骤失败: %v", err) } } // seedRechargeActivityForCancel 为「取消充值活动」准备活动 + 报名行(type=1 充值赠送)。 func seedRechargeActivityForCancel(t *testing.T, db *gorm.DB, userID string, activityID int64) { t.Helper() if err := db.Create(&model.CoursesActivity{ ID: activityID, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10, Status: model.ActivityStatusEnabled, Money: "50.00", }).Error; err != nil { t.Fatalf("seed 活动(%d) 失败: %v", activityID, err) } if err := db.Create(&model.CoursesActivitySigned{ ActivityID: activityID, UserID: userID, InstitutionID: 1, CampusID: 10, Status: model.ActivitySignedSigned, ActivityMoney: "50.00", }).Error; err != nil { t.Fatalf("seed 报名(%d) 失败: %v", activityID, err) } }