| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353 |
- // 余额台账两条门禁(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)
- }
- }
|