|
|
@@ -0,0 +1,353 @@
|
|
|
+// 余额台账两条门禁(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)
|
|
|
+ }
|
|
|
+}
|