balance_ledger_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. // 余额台账两条门禁(AC-17 ④⑴⑵)的单测:通用恒等式 + `balance_after` 分组链 + 差值冻结。
  2. //
  3. // 口径来源(设计 v0.1.16):
  4. //
  5. // §3.8-(f) ① 主判据(**全类型一律计入、零分支**):
  6. // current_balance = baseline_balance + Σ(log.money WHERE user_id=? AND create_time > baseline_at)
  7. // 10 条写路径逐条成立;**不得把 type=108 从 Σ 里剔除**(剔了只剩 207=−requested,恒等式反而破)。
  8. // A18 的「独立坏账 / 差额型」只用于**报告归因**,不参与判据。
  9. // §3.8-f ②-bis `balance_after`:
  10. // 语义 = 该操作完成后**全局账户**余额(不是校区余额);同操作多行**同值**;
  11. // 链校验按 `request_id` 分组(NULL 行按 id 单行成组):
  12. // balance_after(op_n) = balance_after(op_{n−1}) + Σ(money of op_n)
  13. // 老带新双边两行属**不同 user** → 各取本 user 的全局快照,**不要求相等**。
  14. // §3.8-(f) ⑵ 校区侧**差值冻结**:`Σ_campus balance − global balance` 在每次写入前后不变
  15. // (每条写路径对全局与其校区加同一增量);只要求"不再变化",不要求存量相等。
  16. package service
  17. import (
  18. "context"
  19. "errors"
  20. "testing"
  21. "gorm.io/gorm"
  22. "yuansiwei/unified-backend/internal/academic/model"
  23. "yuansiwei/unified-backend/internal/academic/money"
  24. "yuansiwei/unified-backend/internal/academic/port"
  25. )
  26. // ---------------------------------------------------------------- ① 通用恒等式
  27. // assertGeneralIdentity 断言 §3.8-(f) ① 的主判据(**零分支、全类型计入**)。
  28. //
  29. // 入参:db / userID / baselineBalance(本题从 0 起算就是 0)。
  30. // 语义:`Σ(全部流水 money) == 全局账户 balance`。任何写路径漏记/多记一条流水,本断言即红。
  31. func assertGeneralIdentity(t *testing.T, db *gorm.DB, userID string, baselineCents int64) {
  32. t.Helper()
  33. sum := sumLogMoney(t, balanceLogs(t, db, userID)).Cents()
  34. got := cents(t, readGlobal(t, db, userID).Balance)
  35. if baselineCents+sum != got {
  36. t.Fatalf("通用恒等式破式:baseline(%d) + Σ流水(%d) = %d ≠ 全局 balance(%d)",
  37. baselineCents, sum, baselineCents+sum, got)
  38. }
  39. }
  40. // ---------------------------------------------------------------- ② balance_after 分组链
  41. // logOpKey 返回流水所属"操作"的分组键。
  42. //
  43. // 口径(§3.8-f ②-bis):`request_id` 非空 → 同 `request_id` 的行属同一操作;
  44. // 为空/NULL(历史行、清零 208 等无请求号的操作)→ 按 `id` 单行成组(组内只有它自己)。
  45. func logOpKey(row model.UserExpenseLog) string {
  46. if key := logKeyOf(row); key != "" {
  47. return "req:" + key
  48. }
  49. return "id:" + money.FromCents(row.ID).String()
  50. }
  51. // assertBalanceAfterChain 断言 `balance_after` 的**分组链**(禁止逐行做 n=(n−1)+money)。
  52. //
  53. // 规则:
  54. // 1. 同一操作内多行 `balance_after` **必须同值**(转结 106/206、退费 108/207);
  55. // 2. 组间链式:`after(op_n) == after(op_{n−1}) + Σ(money of op_n)`;
  56. // 3. 末组的 `balance_after` == 当前全局账户余额(快照语义是"全局")。
  57. //
  58. // 入参:db / userID。出参:无(失败直接 t.Fatalf)。
  59. func assertBalanceAfterChain(t *testing.T, db *gorm.DB, userID string) {
  60. t.Helper()
  61. rows := balanceLogs(t, db, userID) // 按 id ASC
  62. type group struct {
  63. key string
  64. after int64
  65. sum int64
  66. }
  67. groups := make([]group, 0, len(rows))
  68. index := make(map[string]int, len(rows))
  69. for i := range rows {
  70. if rows[i].BalanceAfter == nil {
  71. t.Fatalf("流水(id=%d,type=%d) 缺 balance_after 快照", rows[i].ID, rows[i].Type)
  72. }
  73. after := cents(t, *rows[i].BalanceAfter)
  74. key := logOpKey(rows[i])
  75. amt := cents(t, rows[i].Money)
  76. pos, ok := index[key]
  77. if !ok {
  78. index[key] = len(groups)
  79. groups = append(groups, group{key: key, after: after, sum: amt})
  80. continue
  81. }
  82. // 规则 1:同操作多行必须同值
  83. if groups[pos].after != after {
  84. t.Fatalf("同一操作(%s) 的 balance_after 不一致:%d vs %d(应同值)",
  85. key, groups[pos].after, after)
  86. }
  87. groups[pos].sum += amt
  88. }
  89. // 规则 2:组间链式
  90. var prev int64
  91. for i, g := range groups {
  92. if i > 0 && prev+g.sum != g.after {
  93. t.Fatalf("第 %d 组(%s) 链式破式:上组 after(%d) + 本组 Σmoney(%d) = %d ≠ 本组 after(%d)",
  94. i+1, g.key, prev, g.sum, prev+g.sum, g.after)
  95. }
  96. prev = g.after
  97. }
  98. // 规则 3:末组快照 == 当前全局余额
  99. if len(groups) > 0 {
  100. if got := cents(t, readGlobal(t, db, userID).Balance); prev != got {
  101. t.Fatalf("末条 balance_after(%d) 应等于当前全局账户余额(%d)", prev, got)
  102. }
  103. }
  104. }
  105. // TestBalanceAfterIsGlobalSnapshotAndGrouped `balance_after` = 全局快照 + 同操作同值 + 分组链。
  106. //
  107. // 关键反向断言(把"取校区余额"的实现打红):校区 A 与全局余额**故意不相等**——
  108. // 先在 A 充 100、再在 B 充 50,则全局 150 而 A 仍 100;此时 A 上的坏账 30:
  109. // - 全局 180 → 流水 balance_after 必须是 **18000**;
  110. // - 若实现取的是校区余额会写 13000(差评:这就是本次返工要修的点)。
  111. func TestBalanceAfterIsGlobalSnapshotAndGrouped(t *testing.T) {
  112. db := newFundServiceDB(t)
  113. svc := NewFundService(db, fundSuperScope(), nil)
  114. ctx := context.Background()
  115. user := fundUserID(300)
  116. // ① 单行操作:校区 A 充 100 → 全局 100
  117. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  118. Money: mustAmount(t, "100.00"), RequestID: "REQ-BA-1"}); err != nil {
  119. t.Fatalf("Recharge(A) 失败: %v", err)
  120. }
  121. // ② 单行操作:校区 B 充 50 → 全局 150,A 仍 100(两者**故意不等**)
  122. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 20,
  123. Money: mustAmount(t, "50.00"), RequestID: "REQ-BA-2"}); err != nil {
  124. t.Fatalf("Recharge(B) 失败: %v", err)
  125. }
  126. if got := cents(t, readGlobal(t, db, user).Balance); got != 15000 {
  127. t.Fatalf("准备态:全局应 15000 分,实得 %d", got)
  128. }
  129. if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 10000 {
  130. t.Fatalf("准备态:校区 A 应 10000 分,实得 %d", got)
  131. }
  132. // ③ 坏账 30(校区 A):全局 180 / 校区 A 130 → 快照必须是 18000
  133. if _, err := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
  134. Money: mustAmount(t, "30.00"), RequestID: "REQ-BA-3"}); err != nil {
  135. t.Fatalf("BadDebt 失败: %v", err)
  136. }
  137. logs108 := logsByType(t, db, user, model.ExpenseLogTypeBadDebt)
  138. if len(logs108) != 1 || logs108[0].BalanceAfter == nil {
  139. t.Fatalf("108 流水应 1 条且带快照,实得 %+v", logs108)
  140. }
  141. if got := cents(t, *logs108[0].BalanceAfter); got != 18000 {
  142. t.Fatalf("balance_after 必须是**全局**快照 18000 分(校区余额是 13000),实得 %d", got)
  143. }
  144. // ④ 多行同操作:转结 40(校区 A → 校区 B),balance 不动 → 106/206 同一全局快照
  145. if _, err := svc.Transfer(ctx, TransferInput{UserID: user,
  146. FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20,
  147. Money: mustAmount(t, "40.00"), RequestID: "REQ-BA-4"}); err != nil {
  148. t.Fatalf("Transfer 失败: %v", err)
  149. }
  150. l106 := logsByType(t, db, user, model.ExpenseLogTypeTransferOut)
  151. l206 := logsByType(t, db, user, model.ExpenseLogTypeTransferIn)
  152. if len(l106) != 1 || len(l206) != 1 {
  153. t.Fatalf("转结应各 1 条流水,实得 %d/%d", len(l106), len(l206))
  154. }
  155. if l106[0].BalanceAfter == nil || l206[0].BalanceAfter == nil {
  156. t.Fatalf("转结流水必须带 balance_after:106=%+v 206=%+v", l106[0], l206[0])
  157. }
  158. if *l106[0].BalanceAfter != *l206[0].BalanceAfter {
  159. t.Fatalf("转结 106/206 的 balance_after 必须同值:106=%s 206=%s(同一操作多行同值)",
  160. *l106[0].BalanceAfter, *l206[0].BalanceAfter)
  161. }
  162. if got := cents(t, *l106[0].BalanceAfter); got != 18000 {
  163. t.Fatalf("转结不改 balance,快照应仍 18000 分,实得 %d", got)
  164. }
  165. // ⑤ 多行同操作:自定义退费(150 > 可退 90 → badDebt 60),108/207 同一全局快照
  166. refund := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  167. seedCampus(t, db, 10, 1, true)
  168. if _, err := refund.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
  169. Money: mustAmount(t, "150.00"), RequestID: "REQ-BA-5"}); err != nil {
  170. t.Fatalf("CustomRefund 失败: %v", err)
  171. }
  172. l108 := logsByType(t, db, user, model.ExpenseLogTypeBadDebt)
  173. var last108 model.UserExpenseLog
  174. for i := range l108 {
  175. if l108[i].RequestID != nil && *l108[i].RequestID == "REQ-BA-5" {
  176. last108 = l108[i]
  177. }
  178. }
  179. l207 := logsByType(t, db, user, model.ExpenseLogTypeRefund)
  180. if last108.ID == 0 || len(l207) != 1 {
  181. t.Fatalf("退费应产生 1 条 108 与 1 条 207,实得 108(id=%d)/207(%d)", last108.ID, len(l207))
  182. }
  183. if last108.BalanceAfter == nil || l207[0].BalanceAfter == nil {
  184. t.Fatalf("退费流水必须带 balance_after:108=%+v 207=%+v", last108, l207[0])
  185. }
  186. if *last108.BalanceAfter != *l207[0].BalanceAfter {
  187. t.Fatalf("退费 108/207 的 balance_after 必须同值:108=%s 207=%s",
  188. *last108.BalanceAfter, *l207[0].BalanceAfter)
  189. }
  190. // ⑥ 分组链 + 通用恒等式一起收口
  191. assertBalanceAfterChain(t, db, user)
  192. assertGeneralIdentity(t, db, user, 0)
  193. }
  194. // ---------------------------------------------------------------- ② 差值冻结
  195. // campusGlobalDelta 计算 `Σ_campus balance − 全局 balance`(冻结量的定义)。
  196. //
  197. // 账户尚未建户时按 0 计(首个写路径之前是常态:建户发生在事务内的第一个 UPSERT)。
  198. func campusGlobalDelta(t *testing.T, db *gorm.DB, userID string) int64 {
  199. t.Helper()
  200. var rows []model.UserExpenseSchool
  201. if err := db.Where("user_id = ?", userID).Find(&rows).Error; err != nil {
  202. t.Fatalf("读校区账户失败: %v", err)
  203. }
  204. sum := int64(0)
  205. for i := range rows {
  206. sum += cents(t, rows[i].Balance)
  207. }
  208. var global model.UserExpense
  209. globalCents := int64(0)
  210. err := db.Where("user_id = ?", userID).Take(&global).Error
  211. switch {
  212. case err == nil:
  213. globalCents = cents(t, global.Balance)
  214. case errors.Is(err, gorm.ErrRecordNotFound):
  215. globalCents = 0 // 未建户
  216. default:
  217. t.Fatalf("读全局账户失败: %v", err)
  218. }
  219. return sum - globalCents
  220. }
  221. // TestCampusGlobalDeltaFrozen 差值冻结(§3.8-(f) ⑵):每条写路径对全局与其校区加**同一增量**,
  222. // 故 `Σ_campus balance − global balance` 在所有写操作前后**不变**。
  223. //
  224. // 覆盖:充值(两个校区)/ 转结 / 退费 / 坏账(双加)/ 自定义退费(差额)/ 取消充值活动 / 余额清零。
  225. // 这条断言的作用:任一路径只更新了单边(dump 的"双表撕裂"),差值立刻漂移 → 红。
  226. func TestCampusGlobalDeltaFrozen(t *testing.T) {
  227. db := newFundServiceDB(t)
  228. fund := NewFundService(db, fundSuperScope(), nil)
  229. refund := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  230. ctx := context.Background()
  231. user := fundUserID(301)
  232. seedCampus(t, db, 10, 1, true)
  233. seedCampus(t, db, 20, 1, true)
  234. seedRechargeActivityForCancel(t, db, user, 77)
  235. steps := []struct {
  236. name string
  237. run func()
  238. }{
  239. {"recharge@A", func() {
  240. mustNoErr(t, func() error {
  241. _, e := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  242. Money: mustAmount(t, "200.00"), RequestID: "REQ-FR-1"})
  243. return e
  244. })
  245. }},
  246. {"recharge@B", func() {
  247. mustNoErr(t, func() error {
  248. _, e := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 20,
  249. Money: mustAmount(t, "100.00"), RequestID: "REQ-FR-2"})
  250. return e
  251. })
  252. }},
  253. {"transfer", func() {
  254. mustNoErr(t, func() error {
  255. _, e := fund.Transfer(ctx, TransferInput{UserID: user,
  256. FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20,
  257. Money: mustAmount(t, "50.00"), RequestID: "REQ-FR-3"})
  258. return e
  259. })
  260. }},
  261. {"fund.refund", func() {
  262. mustNoErr(t, func() error {
  263. _, e := fund.Refund(ctx, RefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
  264. Money: mustAmount(t, "20.00"), Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-FR-4"})
  265. return e
  266. })
  267. }},
  268. {"baddebt", func() {
  269. mustNoErr(t, func() error {
  270. _, e := fund.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
  271. Money: mustAmount(t, "10.00"), RequestID: "REQ-FR-5"})
  272. return e
  273. })
  274. }},
  275. {"customRefund", func() {
  276. mustNoErr(t, func() error {
  277. _, e := refund.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
  278. Money: mustAmount(t, "30.00"), RequestID: "REQ-FR-6"})
  279. return e
  280. })
  281. }},
  282. {"cancelRechargeActivity", func() {
  283. mustNoErr(t, func() error {
  284. _, e := refund.CancelRechargeActivity(ctx, CancelRechargeActivityInput{UserID: user,
  285. ActivityID: 77, CampusID: 10, Money: mustAmount(t, "5.00"), RequestID: "REQ-FR-7"})
  286. return e
  287. })
  288. }},
  289. {"clearBalance", func() {
  290. mustNoErr(t, func() error {
  291. _, e := refund.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 20})
  292. return e
  293. })
  294. }},
  295. }
  296. delta := campusGlobalDelta(t, db, user)
  297. for _, step := range steps {
  298. step.run()
  299. if got := campusGlobalDelta(t, db, user); got != delta {
  300. t.Fatalf("%s 之后差值冻结被破坏:前 %d 后 %d(说明该路径只更新了全局/校区单边)",
  301. step.name, delta, got)
  302. }
  303. assertGeneralIdentity(t, db, user, 0)
  304. }
  305. // 至少确认确实发生了资金变动(防止"全都没生效 → 差值自然不变"的假绿)
  306. if got := cents(t, readGlobal(t, db, user).Balance); got == 0 {
  307. t.Fatalf("准备态异常:全部写路径跑完后全局余额仍为 0,差值为 %d", delta)
  308. }
  309. }
  310. // mustNoErr 执行 fn 并要求无错误(表驱动里扁平化错误处理)。
  311. func mustNoErr(t *testing.T, fn func() error) {
  312. t.Helper()
  313. if err := fn(); err != nil {
  314. t.Fatalf("步骤失败: %v", err)
  315. }
  316. }
  317. // seedRechargeActivityForCancel 为「取消充值活动」准备活动 + 报名行(type=1 充值赠送)。
  318. func seedRechargeActivityForCancel(t *testing.T, db *gorm.DB, userID string, activityID int64) {
  319. t.Helper()
  320. if err := db.Create(&model.CoursesActivity{
  321. ID: activityID, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  322. Status: model.ActivityStatusEnabled, Money: "50.00",
  323. }).Error; err != nil {
  324. t.Fatalf("seed 活动(%d) 失败: %v", activityID, err)
  325. }
  326. if err := db.Create(&model.CoursesActivitySigned{
  327. ActivityID: activityID, UserID: userID, InstitutionID: 1, CampusID: 10,
  328. Status: model.ActivitySignedSigned, ActivityMoney: "50.00",
  329. }).Error; err != nil {
  330. t.Fatalf("seed 报名(%d) 失败: %v", activityID, err)
  331. }
  332. }