fund_service_transfer_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. // FundService.Transfer 单测(AC-04 ④):课费转结按 **dump 语义**钉死——
  2. // 不动 balance、106=+m / 206=−m、两侧 total_expenditure/total_income 反向调整、
  3. // 目标账户不存在则建户、同校区自转与源账户缺失显式失败、同 requestId 幂等。
  4. //
  5. // 测试栈与 fund_service_test.go 同一套(sqlite 文件库 + 真实唯一键;助手复用同 package)。
  6. package service
  7. import (
  8. "context"
  9. "regexp"
  10. "strconv"
  11. "strings"
  12. "testing"
  13. "yuansiwei/unified-backend/internal/academic/model"
  14. )
  15. // TestTransferLeavesBalanceUntouchedAndWritesDumpDirections 转结核心口径(★dump 漂移项)。
  16. //
  17. // dump(yuansiwei_finance.sql:764661):
  18. //
  19. // 源:total_expenditure−m, total_income−m ;目标:total_expenditure+m, total_income+m
  20. // log 106 = **+m**(转结出);log 206 = **−m**(转结入);**两端 balance 都不动**
  21. //
  22. // 与 P0-04 规格(106 负 / 206 正 且动 balance)相反——本用例就是把 dump 口径钉死的地方。
  23. func TestTransferLeavesBalanceUntouchedAndWritesDumpDirections(t *testing.T) {
  24. db := newFundServiceDB(t)
  25. svc := NewFundService(db, fundSuperScope(), nil) // 转结跨机构/跨校区:用超管作用域
  26. ctx := context.Background()
  27. user := fundUserID(20)
  28. // 源校区(机构 1 / 校区 10)余额 100,目标校区(机构 1 / 校区 20)尚不存在
  29. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  30. Money: mustAmount(t, "100.00"), RequestID: "REQ-TRANSFER-SEED"}); err != nil {
  31. t.Fatalf("准备源校区余额失败: %v", err)
  32. }
  33. res, err := svc.Transfer(ctx, TransferInput{
  34. UserID: user,
  35. FromInstitutionID: 1, FromCampusID: 10,
  36. ToInstitutionID: 1, ToCampusID: 20,
  37. Money: mustAmount(t, "40.00"), RequestID: "REQ-TRANSFER-1", Remark: "课费转结",
  38. })
  39. if err != nil {
  40. t.Fatalf("Transfer 失败: %v", err)
  41. }
  42. if res.LogID106 <= 0 || res.LogID206 <= 0 {
  43. t.Fatalf("两条流水 ID 都应 > 0,实得 %+v", res)
  44. }
  45. from := readSchool(t, db, user, 1, 10)
  46. to := readSchool(t, db, user, 1, 20)
  47. // ① **balance 不动**(dump 全程没有 balance 赋值)
  48. if got := cents(t, from.Balance); got != 10000 {
  49. t.Fatalf("源校区 balance 不得变动(应 10000 分),实得 %d", got)
  50. }
  51. if got := cents(t, to.Balance); got != 0 {
  52. t.Fatalf("目标校区 balance 不得变动(应 0 分),实得 %d", got)
  53. }
  54. // ② 源:total_expenditure/total_income 各 −40;目标:各 +40
  55. // 注意:充值只累加 balance/total_income,不动 total_expenditure,
  56. // 故源 total_expenditure 是 0 − 4000 = **−4000**(dump 无下限保护,按 dump 保留负值)
  57. if got := cents(t, from.TotalExpenditure); got != -4000 {
  58. t.Fatalf("源校区 total_expenditure 应 -4000 分(0−4000,dump 无下限),实得 %d", got)
  59. }
  60. if got := cents(t, from.TotalIncome); got != 6000 {
  61. t.Fatalf("源校区 total_income 应 6000 分,实得 %d", got)
  62. }
  63. if got := cents(t, to.TotalExpenditure); got != 4000 {
  64. t.Fatalf("目标校区 total_expenditure 应 4000 分,实得 %d", got)
  65. }
  66. if got := cents(t, to.TotalIncome); got != 4000 {
  67. t.Fatalf("目标校区 total_income 应 4000 分,实得 %d", got)
  68. }
  69. // ③ 全局钱包不参与转结
  70. global := readGlobal(t, db, user)
  71. if got := cents(t, global.Balance); got != 10000 {
  72. t.Fatalf("全局 balance 不应变动(应 10000 分),实得 %d", got)
  73. }
  74. if got := cents(t, global.TotalExpenditure); got != 0 {
  75. t.Fatalf("全局 total_expenditure 不应变动(应 0),实得 %d", got)
  76. }
  77. // ④ 流水方向:106 = +4000,206 = −4000(★与"出/入"直觉相反,按 dump)
  78. logs106 := logsByType(t, db, user, model.ExpenseLogTypeTransferOut)
  79. logs206 := logsByType(t, db, user, model.ExpenseLogTypeTransferIn)
  80. if len(logs106) != 1 || cents(t, logs106[0].Money) != 4000 {
  81. t.Fatalf("106 流水应为 +4000 分一条,实得 %+v", logs106)
  82. }
  83. if len(logs206) != 1 || cents(t, logs206[0].Money) != -4000 {
  84. t.Fatalf("206 流水应为 -4000 分一条,实得 %+v", logs206)
  85. }
  86. }
  87. // TestTransferIdempotentSameRequestID 同 requestId 再转 → 两条流水都不新增、金额不再变动。
  88. func TestTransferIdempotentSameRequestID(t *testing.T) {
  89. db := newFundServiceDB(t)
  90. svc := NewFundService(db, fundSuperScope(), nil)
  91. ctx := context.Background()
  92. user := fundUserID(21)
  93. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  94. Money: mustAmount(t, "100.00")}); err != nil {
  95. t.Fatalf("准备余额失败: %v", err)
  96. }
  97. in := TransferInput{
  98. UserID: user,
  99. FromInstitutionID: 1, FromCampusID: 10,
  100. ToInstitutionID: 1, ToCampusID: 20,
  101. Money: mustAmount(t, "10.00"), RequestID: "REQ-TRANSFER-IDEMPOTENT",
  102. }
  103. first, err := svc.Transfer(ctx, in)
  104. if err != nil {
  105. t.Fatalf("首次转结失败: %v", err)
  106. }
  107. second, err := svc.Transfer(ctx, in)
  108. if err != nil {
  109. t.Fatalf("重复转结应幂等成功: %v", err)
  110. }
  111. if !second.Repeated {
  112. t.Fatalf("重复转结应 repeated=true")
  113. }
  114. if second.LogID106 != first.LogID106 || second.LogID206 != first.LogID206 {
  115. t.Fatalf("重复转结应回放首次流水 %+v,实得 %+v", first, second)
  116. }
  117. if got := len(logsByType(t, db, user, model.ExpenseLogTypeTransferOut)); got != 1 {
  118. t.Fatalf("106 流水应只 1 条,实得 %d", got)
  119. }
  120. if got := len(logsByType(t, db, user, model.ExpenseLogTypeTransferIn)); got != 1 {
  121. t.Fatalf("206 流水应只 1 条,实得 %d", got)
  122. }
  123. if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 9000 {
  124. t.Fatalf("源校区 total_income 应只减一次(9000 分),实得 %d", got)
  125. }
  126. }
  127. // TestTransferSameCampusRejected 同校区自转(净效应为零)→ 3140。
  128. func TestTransferSameCampusRejected(t *testing.T) {
  129. db := newFundServiceDB(t)
  130. svc := NewFundService(db, fundScope(), nil)
  131. _, err := svc.Transfer(context.Background(), TransferInput{
  132. UserID: fundUserID(22),
  133. FromInstitutionID: 1, FromCampusID: 10,
  134. ToInstitutionID: 1, ToCampusID: 10,
  135. Money: mustAmount(t, "10.00"),
  136. })
  137. requireAppCode(t, err, 3140)
  138. }
  139. // TestTransferSourceAccountMissing 源账户不存在 → 3001(dump 静默 no-op;Go 显式失败)。
  140. func TestTransferSourceAccountMissing(t *testing.T) {
  141. db := newFundServiceDB(t)
  142. svc := NewFundService(db, fundSuperScope(), nil)
  143. _, err := svc.Transfer(context.Background(), TransferInput{
  144. UserID: fundUserID(23),
  145. FromInstitutionID: 1, FromCampusID: 10,
  146. ToInstitutionID: 1, ToCampusID: 20,
  147. Money: mustAmount(t, "10.00"),
  148. })
  149. requireAppCode(t, err, 3001)
  150. }
  151. // TestTransferRejectsInvalidAmount 金额 <=0 / 超上限 → 2002(不改任何账户)。
  152. func TestTransferRejectsInvalidAmount(t *testing.T) {
  153. db := newFundServiceDB(t)
  154. svc := NewFundService(db, fundSuperScope(), nil)
  155. ctx := context.Background()
  156. user := fundUserID(24)
  157. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  158. Money: mustAmount(t, "100.00")}); err != nil {
  159. t.Fatalf("准备余额失败: %v", err)
  160. }
  161. base := TransferInput{
  162. UserID: user,
  163. FromInstitutionID: 1, FromCampusID: 10,
  164. ToInstitutionID: 1, ToCampusID: 20,
  165. }
  166. for _, amount := range []string{"0.00", "-1.00", "1000000.01"} {
  167. in := base
  168. in.Money = mustAmount(t, amount)
  169. _, err := svc.Transfer(ctx, in)
  170. var want = 2002
  171. requireAppCode(t, err, want)
  172. }
  173. if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 10000 {
  174. t.Fatalf("非法金额不得改动账户(应仍 10000 分),实得 %d", got)
  175. }
  176. }
  177. // TestTransferLocksCampusAccountsAscending 多账户加锁顺序:按 campus_id ASC(§1.4 防死锁)。
  178. //
  179. // 断言口径:抓真实执行的 SQL 序列,要求"较小 campus_id 的校区锁语句"先出现。
  180. func TestTransferLocksCampusAccountsAscending(t *testing.T) {
  181. db, rec := newFundServiceDBWithRecorder(t)
  182. svc := NewFundService(db, fundSuperScope(), nil)
  183. ctx := context.Background()
  184. user := fundUserID(25)
  185. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 20,
  186. Money: mustAmount(t, "100.00")}); err != nil {
  187. t.Fatalf("准备余额失败: %v", err)
  188. }
  189. // 源校区 campus_id=20,目标校区 campus_id=10 → 加锁必须先 10 后 20(升序)
  190. rec.Reset()
  191. if _, err := svc.Transfer(ctx, TransferInput{
  192. UserID: user,
  193. FromInstitutionID: 1, FromCampusID: 20,
  194. ToInstitutionID: 1, ToCampusID: 10,
  195. Money: mustAmount(t, "10.00"), RequestID: "REQ-TRANSFER-LOCK",
  196. }); err != nil {
  197. t.Fatalf("Transfer 失败: %v", err)
  198. }
  199. order := campusLockOrder(t, rec.Snapshot())
  200. if order.firstCampusID != 10 || order.secondCampusID != 20 {
  201. t.Fatalf("校区加锁顺序应为 10 → 20(campus_id ASC),实得 %d → %d",
  202. order.firstCampusID, order.secondCampusID)
  203. }
  204. }
  205. // campusLockOrder 从 SQL 序列里提取"两次校区账户 SELECT ... FOR UPDATE 命中的 campus_id"。
  206. //
  207. // 实现说明:sqlite 不渲染 `FOR UPDATE`,故用**语句里的 campus_id 字面量**判定命中的是哪一行
  208. // (GetCampusForUpdateExact 的 WHERE 一定带 `campus_id = ?` 参数,GORM 会把参数内联进日志)。
  209. type lockOrder struct {
  210. firstCampusID int64
  211. secondCampusID int64
  212. }
  213. // campusLockOrder 解析加锁顺序(见类型注释)。
  214. func campusLockOrder(t *testing.T, stmts []string) lockOrder {
  215. t.Helper()
  216. var out lockOrder
  217. seen := 0
  218. for _, sql := range stmts {
  219. if !strings.Contains(sql, "FROM") || !strings.Contains(sql, "t_user_expense_school") {
  220. continue
  221. }
  222. if !strings.Contains(sql, "LIMIT") {
  223. continue // 只要点查(加锁读)语句,跳过 UPDATE
  224. }
  225. id := extractCampusIDParam(t, sql)
  226. if id <= 0 {
  227. continue
  228. }
  229. seen++
  230. switch seen {
  231. case 1:
  232. out.firstCampusID = id
  233. case 2:
  234. out.secondCampusID = id
  235. }
  236. if seen == 2 {
  237. return out
  238. }
  239. }
  240. t.Fatalf("未捕获到两次校区账户加锁语句(seen=%d),实际 SQL=%v", seen, stmts)
  241. return out
  242. }
  243. // campusIDParamRe 匹配语句中被内联的 `campus_id = N` 参数(GORM 日志 Explain 会内联实参)。
  244. var campusIDParamRe = regexp.MustCompile(`campus_id\s*=\s*(-?\d+)`)
  245. // extractCampusIDParam 从单条 SQL 中取出 campus_id 实参(取不到 → 0)。
  246. func extractCampusIDParam(t *testing.T, sql string) int64 {
  247. t.Helper()
  248. m := campusIDParamRe.FindStringSubmatch(sql)
  249. if len(m) < 2 {
  250. return 0
  251. }
  252. v, err := strconv.ParseInt(m[1], 10, 64)
  253. if err != nil {
  254. return 0
  255. }
  256. return v
  257. }