fund_service_transfer_test.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  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. "gorm.io/gorm"
  14. "yuansiwei/unified-backend/internal/academic/model"
  15. )
  16. // TestTransferLeavesBalanceUntouchedAndWritesDumpDirections 转结核心口径(★dump 漂移项)。
  17. //
  18. // dump(yuansiwei_finance.sql:764661):
  19. //
  20. // 源:total_expenditure−m, total_income−m ;目标:total_expenditure+m, total_income+m
  21. // log 106 = **+m**(转结出);log 206 = **−m**(转结入);**两端 balance 都不动**
  22. //
  23. // 与 P0-04 规格(106 负 / 206 正 且动 balance)相反——本用例就是把 dump 口径钉死的地方。
  24. func TestTransferLeavesBalanceUntouchedAndWritesDumpDirections(t *testing.T) {
  25. db := newFundServiceDB(t)
  26. svc := NewFundService(db, fundSuperScope(), nil) // 转结跨机构/跨校区:用超管作用域
  27. ctx := context.Background()
  28. user := fundUserID(20)
  29. // t_campus 登记(R4-bis:转入方建户的机构**只认校区表**,入参机构必须与之一致)
  30. seedCampus(t, db, 10, 1, true)
  31. seedCampus(t, db, 20, 1, true)
  32. // 源校区(机构 1 / 校区 10)余额 100,目标校区(机构 1 / 校区 20)的**账户**尚不存在
  33. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  34. Money: mustAmount(t, "100.00"), RequestID: "REQ-TRANSFER-SEED"}); err != nil {
  35. t.Fatalf("准备源校区余额失败: %v", err)
  36. }
  37. res, err := svc.Transfer(ctx, TransferInput{
  38. UserID: user,
  39. FromInstitutionID: 1, FromCampusID: 10,
  40. ToInstitutionID: 1, ToCampusID: 20,
  41. Money: mustAmount(t, "40.00"), RequestID: "REQ-TRANSFER-1", Remark: "课费转结",
  42. })
  43. if err != nil {
  44. t.Fatalf("Transfer 失败: %v", err)
  45. }
  46. if res.LogID106 <= 0 || res.LogID206 <= 0 {
  47. t.Fatalf("两条流水 ID 都应 > 0,实得 %+v", res)
  48. }
  49. from := readSchool(t, db, user, 1, 10)
  50. to := readSchool(t, db, user, 1, 20)
  51. // ① **balance 不动**(dump 全程没有 balance 赋值)
  52. if got := cents(t, from.Balance); got != 10000 {
  53. t.Fatalf("源校区 balance 不得变动(应 10000 分),实得 %d", got)
  54. }
  55. if got := cents(t, to.Balance); got != 0 {
  56. t.Fatalf("目标校区 balance 不得变动(应 0 分),实得 %d", got)
  57. }
  58. // ② 源:total_expenditure/total_income 各 −40;目标:各 +40
  59. // 注意:充值只累加 balance/total_income,不动 total_expenditure,
  60. // 故源 total_expenditure 是 0 − 4000 = **−4000**(dump 无下限保护,按 dump 保留负值)
  61. if got := cents(t, from.TotalExpenditure); got != -4000 {
  62. t.Fatalf("源校区 total_expenditure 应 -4000 分(0−4000,dump 无下限),实得 %d", got)
  63. }
  64. if got := cents(t, from.TotalIncome); got != 6000 {
  65. t.Fatalf("源校区 total_income 应 6000 分,实得 %d", got)
  66. }
  67. if got := cents(t, to.TotalExpenditure); got != 4000 {
  68. t.Fatalf("目标校区 total_expenditure 应 4000 分,实得 %d", got)
  69. }
  70. if got := cents(t, to.TotalIncome); got != 4000 {
  71. t.Fatalf("目标校区 total_income 应 4000 分,实得 %d", got)
  72. }
  73. // ③ 全局钱包不参与转结
  74. global := readGlobal(t, db, user)
  75. if got := cents(t, global.Balance); got != 10000 {
  76. t.Fatalf("全局 balance 不应变动(应 10000 分),实得 %d", got)
  77. }
  78. if got := cents(t, global.TotalExpenditure); got != 0 {
  79. t.Fatalf("全局 total_expenditure 不应变动(应 0),实得 %d", got)
  80. }
  81. // ④ 流水方向:106 = +4000,206 = −4000(★与"出/入"直觉相反,按 dump)
  82. logs106 := logsByType(t, db, user, model.ExpenseLogTypeTransferOut)
  83. logs206 := logsByType(t, db, user, model.ExpenseLogTypeTransferIn)
  84. if len(logs106) != 1 || cents(t, logs106[0].Money) != 4000 {
  85. t.Fatalf("106 流水应为 +4000 分一条,实得 %+v", logs106)
  86. }
  87. if len(logs206) != 1 || cents(t, logs206[0].Money) != -4000 {
  88. t.Fatalf("206 流水应为 -4000 分一条,实得 %+v", logs206)
  89. }
  90. }
  91. // TestTransferIdempotentSameRequestID 同 requestId 再转 → 两条流水都不新增、金额不再变动。
  92. func TestTransferIdempotentSameRequestID(t *testing.T) {
  93. db := newFundServiceDB(t)
  94. svc := NewFundService(db, fundSuperScope(), nil)
  95. ctx := context.Background()
  96. user := fundUserID(21)
  97. seedCampus(t, db, 10, 1, true)
  98. seedCampus(t, db, 20, 1, true)
  99. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  100. Money: mustAmount(t, "100.00")}); err != nil {
  101. t.Fatalf("准备余额失败: %v", err)
  102. }
  103. in := TransferInput{
  104. UserID: user,
  105. FromInstitutionID: 1, FromCampusID: 10,
  106. ToInstitutionID: 1, ToCampusID: 20,
  107. Money: mustAmount(t, "10.00"), RequestID: "REQ-TRANSFER-IDEMPOTENT",
  108. }
  109. first, err := svc.Transfer(ctx, in)
  110. if err != nil {
  111. t.Fatalf("首次转结失败: %v", err)
  112. }
  113. second, err := svc.Transfer(ctx, in)
  114. if err != nil {
  115. t.Fatalf("重复转结应幂等成功: %v", err)
  116. }
  117. if !second.Repeated {
  118. t.Fatalf("重复转结应 repeated=true")
  119. }
  120. if second.LogID106 != first.LogID106 || second.LogID206 != first.LogID206 {
  121. t.Fatalf("重复转结应回放首次流水 %+v,实得 %+v", first, second)
  122. }
  123. if got := len(logsByType(t, db, user, model.ExpenseLogTypeTransferOut)); got != 1 {
  124. t.Fatalf("106 流水应只 1 条,实得 %d", got)
  125. }
  126. if got := len(logsByType(t, db, user, model.ExpenseLogTypeTransferIn)); got != 1 {
  127. t.Fatalf("206 流水应只 1 条,实得 %d", got)
  128. }
  129. if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 9000 {
  130. t.Fatalf("源校区 total_income 应只减一次(9000 分),实得 %d", got)
  131. }
  132. }
  133. // TestTransferSameCampusRejected 同校区自转(净效应为零)→ 3140。
  134. func TestTransferSameCampusRejected(t *testing.T) {
  135. db := newFundServiceDB(t)
  136. svc := NewFundService(db, fundScope(), nil)
  137. _, err := svc.Transfer(context.Background(), TransferInput{
  138. UserID: fundUserID(22),
  139. FromInstitutionID: 1, FromCampusID: 10,
  140. ToInstitutionID: 1, ToCampusID: 10,
  141. Money: mustAmount(t, "10.00"),
  142. })
  143. requireAppCode(t, err, 3140)
  144. }
  145. // TestTransferSourceAccountMissing 源账户不存在 → 3001(dump 静默 no-op;Go 显式失败)。
  146. func TestTransferSourceAccountMissing(t *testing.T) {
  147. db := newFundServiceDB(t)
  148. svc := NewFundService(db, fundSuperScope(), nil)
  149. _, err := svc.Transfer(context.Background(), TransferInput{
  150. UserID: fundUserID(23),
  151. FromInstitutionID: 1, FromCampusID: 10,
  152. ToInstitutionID: 1, ToCampusID: 20,
  153. Money: mustAmount(t, "10.00"),
  154. })
  155. requireAppCode(t, err, 3001)
  156. }
  157. // TestTransferRejectsInvalidAmount 金额 <=0 / 超上限 → 2002(不改任何账户)。
  158. func TestTransferRejectsInvalidAmount(t *testing.T) {
  159. db := newFundServiceDB(t)
  160. svc := NewFundService(db, fundSuperScope(), nil)
  161. ctx := context.Background()
  162. user := fundUserID(24)
  163. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  164. Money: mustAmount(t, "100.00")}); err != nil {
  165. t.Fatalf("准备余额失败: %v", err)
  166. }
  167. base := TransferInput{
  168. UserID: user,
  169. FromInstitutionID: 1, FromCampusID: 10,
  170. ToInstitutionID: 1, ToCampusID: 20,
  171. }
  172. for _, amount := range []string{"0.00", "-1.00", "1000000.01"} {
  173. in := base
  174. in.Money = mustAmount(t, amount)
  175. _, err := svc.Transfer(ctx, in)
  176. var want = 2002
  177. requireAppCode(t, err, want)
  178. }
  179. if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 10000 {
  180. t.Fatalf("非法金额不得改动账户(应仍 10000 分),实得 %d", got)
  181. }
  182. }
  183. // TestTransferLocksCampusAccountsAscending 多账户加锁顺序:按 campus_id ASC(§1.4 防死锁)。
  184. //
  185. // 断言口径:抓真实执行的 SQL 序列,要求"较小 campus_id 的校区锁语句"先出现。
  186. func TestTransferLocksCampusAccountsAscending(t *testing.T) {
  187. db, rec := newFundServiceDBWithRecorder(t)
  188. svc := NewFundService(db, fundSuperScope(), nil)
  189. ctx := context.Background()
  190. user := fundUserID(25)
  191. seedCampus(t, db, 10, 1, true)
  192. seedCampus(t, db, 20, 1, true)
  193. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 20,
  194. Money: mustAmount(t, "100.00")}); err != nil {
  195. t.Fatalf("准备余额失败: %v", err)
  196. }
  197. // 源校区 campus_id=20,目标校区 campus_id=10 → 加锁必须先 10 后 20(升序)
  198. rec.Reset()
  199. if _, err := svc.Transfer(ctx, TransferInput{
  200. UserID: user,
  201. FromInstitutionID: 1, FromCampusID: 20,
  202. ToInstitutionID: 1, ToCampusID: 10,
  203. Money: mustAmount(t, "10.00"), RequestID: "REQ-TRANSFER-LOCK",
  204. }); err != nil {
  205. t.Fatalf("Transfer 失败: %v", err)
  206. }
  207. order := campusLockOrder(t, rec.Snapshot())
  208. if order.firstCampusID != 10 || order.secondCampusID != 20 {
  209. t.Fatalf("校区加锁顺序应为 10 → 20(campus_id ASC),实得 %d → %d",
  210. order.firstCampusID, order.secondCampusID)
  211. }
  212. }
  213. // campusLockOrder 从 SQL 序列里提取"两次校区账户 SELECT ... FOR UPDATE 命中的 campus_id"。
  214. //
  215. // 实现说明:sqlite 不渲染 `FOR UPDATE`,故用**语句里的 campus_id 字面量**判定命中的是哪一行
  216. // (GetCampusForUpdateExact 的 WHERE 一定带 `campus_id = ?` 参数,GORM 会把参数内联进日志)。
  217. type lockOrder struct {
  218. firstCampusID int64
  219. secondCampusID int64
  220. }
  221. // campusLockOrder 解析加锁顺序(见类型注释)。
  222. func campusLockOrder(t *testing.T, stmts []string) lockOrder {
  223. t.Helper()
  224. var out lockOrder
  225. seen := 0
  226. for _, sql := range stmts {
  227. if !strings.Contains(sql, "FROM") || !strings.Contains(sql, "t_user_expense_school") {
  228. continue
  229. }
  230. if !strings.Contains(sql, "LIMIT") {
  231. continue // 只要点查(加锁读)语句,跳过 UPDATE
  232. }
  233. id := extractCampusIDParam(t, sql)
  234. if id <= 0 {
  235. continue
  236. }
  237. seen++
  238. switch seen {
  239. case 1:
  240. out.firstCampusID = id
  241. case 2:
  242. out.secondCampusID = id
  243. }
  244. if seen == 2 {
  245. return out
  246. }
  247. }
  248. t.Fatalf("未捕获到两次校区账户加锁语句(seen=%d),实际 SQL=%v", seen, stmts)
  249. return out
  250. }
  251. // campusIDParamRe 匹配语句中被内联的 `campus_id = N` 参数(GORM 日志 Explain 会内联实参)。
  252. var campusIDParamRe = regexp.MustCompile(`campus_id\s*=\s*(-?\d+)`)
  253. // extractCampusIDParam 从单条 SQL 中取出 campus_id 实参(取不到 → 0)。
  254. func extractCampusIDParam(t *testing.T, sql string) int64 {
  255. t.Helper()
  256. m := campusIDParamRe.FindStringSubmatch(sql)
  257. if len(m) < 2 {
  258. return 0
  259. }
  260. v, err := strconv.ParseInt(m[1], 10, 64)
  261. if err != nil {
  262. return 0
  263. }
  264. return v
  265. }
  266. // ---------------------------------------------------------------- R4 / R4-bis 边界
  267. // TestTransferTargetAccountCreatedWhenMissing R4 ④:转入方**账户**缺失 = **建户**(不是 3001)。
  268. //
  269. // 反向断言:建户用的机构必须是 t_campus 的权威值,且新户余额恒 0(转结不动 balance)。
  270. func TestTransferTargetAccountCreatedWhenMissing(t *testing.T) {
  271. db := newFundServiceDB(t)
  272. svc := NewFundService(db, fundSuperScope(), nil)
  273. ctx := context.Background()
  274. user := fundUserID(26)
  275. seedCampus(t, db, 10, 1, true)
  276. seedCampus(t, db, 20, 1, true)
  277. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  278. Money: mustAmount(t, "100.00"), RequestID: "REQ-R4-SEED"}); err != nil {
  279. t.Fatalf("准备源校区余额失败: %v", err)
  280. }
  281. if _, err := svc.Transfer(ctx, TransferInput{UserID: user,
  282. FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20,
  283. Money: mustAmount(t, "30.00"), RequestID: "REQ-R4-CREATE"}); err != nil {
  284. t.Fatalf("转入方缺失应建户成功,实得错误: %v", err)
  285. }
  286. to := readSchool(t, db, user, 1, 20) // 不存在会 Fatal
  287. if got := cents(t, to.Balance); got != 0 {
  288. t.Fatalf("转结不动 balance,新户余额应 0,实得 %d", got)
  289. }
  290. if got := cents(t, to.TotalIncome); got != 3000 {
  291. t.Fatalf("新户 total_income 应 +3000,实得 %d", got)
  292. }
  293. }
  294. // TestTransferRejectsInstitutionMismatch R4-bis ③:入参 toInstitutionId 与校区归属机构矛盾 → **3140**,
  295. // 且**表零变化**:两张账户表行数都不变、不写流水、源校区与全局的金额逐列不变。
  296. //
  297. // 为什么必须断言"表零变化"而不是"目标校区 count=0":dump 按入参机构建户会留下**机构错配的僵尸账户**,
  298. // 而账户只增不删、UK 又让它按正确机构永远查不到 → 不可自愈。只断言"该 campus 没有行"会漏掉
  299. // "建到了错机构下"这一种(campus 相同、institution 不同)。
  300. func TestTransferRejectsInstitutionMismatch(t *testing.T) {
  301. db := newFundServiceDB(t)
  302. svc := NewFundService(db, fundSuperScope(), nil)
  303. ctx := context.Background()
  304. user := fundUserID(27)
  305. seedCampus(t, db, 10, 1, true)
  306. seedCampus(t, db, 20, 1, true) // 校区 20 真实归属机构 1
  307. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  308. Money: mustAmount(t, "100.00"), RequestID: "REQ-R4BIS-SEED"}); err != nil {
  309. t.Fatalf("准备源校区余额失败: %v", err)
  310. }
  311. srcBefore := readSchool(t, db, user, 1, 10)
  312. globalBefore := readGlobal(t, db, user)
  313. logsBefore := len(balanceLogs(t, db, user))
  314. globalRowsBefore := countRows(t, db, &model.UserExpense{}, user)
  315. schoolRowsBefore := countRows(t, db, &model.UserExpenseSchool{}, user)
  316. // 入参声明机构 2,但校区 20 属于机构 1 → 3140
  317. _, err := svc.Transfer(ctx, TransferInput{UserID: user,
  318. FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 2, ToCampusID: 20,
  319. Money: mustAmount(t, "30.00"), RequestID: "REQ-R4BIS-BAD"})
  320. requireAppCode(t, err, 3140)
  321. // ① 两张账户表行数都不变(既没建错机构的户,也没多建全局户)
  322. if got := countRows(t, db, &model.UserExpenseSchool{}, user); got != schoolRowsBefore {
  323. t.Fatalf("R4-bis 拒绝时校区账户行数必须不变:前 %d 后 %d", schoolRowsBefore, got)
  324. }
  325. if got := countRows(t, db, &model.UserExpense{}, user); got != globalRowsBefore {
  326. t.Fatalf("R4-bis 拒绝时全局账户行数必须不变:前 %d 后 %d", globalRowsBefore, got)
  327. }
  328. // ② 目标校区在**任何机构**下都不得有行(防"建到错机构"的僵尸户)
  329. var anywhere int64
  330. if e := db.Model(&model.UserExpenseSchool{}).Where("user_id = ? AND campus_id = ?", user, 20).
  331. Count(&anywhere).Error; e != nil {
  332. t.Fatalf("统计目标校区账户失败: %v", e)
  333. }
  334. if anywhere != 0 {
  335. t.Fatalf("R4-bis 拒绝时不得建户(任意机构下),实得 %d 行", anywhere)
  336. }
  337. // ③ 不写流水
  338. if got := len(balanceLogs(t, db, user)); got != logsBefore {
  339. t.Fatalf("R4-bis 拒绝时不得写流水:前 %d 后 %d", logsBefore, got)
  340. }
  341. // ④ 源校区与全局金额逐列不变
  342. srcAfter := readSchool(t, db, user, 1, 10)
  343. if cents(t, srcAfter.Balance) != cents(t, srcBefore.Balance) ||
  344. cents(t, srcAfter.TotalIncome) != cents(t, srcBefore.TotalIncome) ||
  345. cents(t, srcAfter.TotalExpenditure) != cents(t, srcBefore.TotalExpenditure) {
  346. t.Fatalf("R4-bis 拒绝时源校区不得变动:前 %+v 后 %+v", srcBefore, srcAfter)
  347. }
  348. globalAfter := readGlobal(t, db, user)
  349. if cents(t, globalAfter.Balance) != cents(t, globalBefore.Balance) ||
  350. cents(t, globalAfter.TotalIncome) != cents(t, globalBefore.TotalIncome) ||
  351. cents(t, globalAfter.TotalExpenditure) != cents(t, globalBefore.TotalExpenditure) {
  352. t.Fatalf("R4-bis 拒绝时全局账户不得变动:前 %+v 后 %+v", globalBefore, globalAfter)
  353. }
  354. }
  355. // countRows 统计某 user 在给定表的行数("零副作用"断言用)。
  356. func countRows(t *testing.T, db *gorm.DB, table any, userID string) int64 {
  357. t.Helper()
  358. var n int64
  359. if err := db.Model(table).Where("user_id = ?", userID).Count(&n).Error; err != nil {
  360. t.Fatalf("统计 %T 行数失败: %v", table, err)
  361. }
  362. return n
  363. }
  364. // TestTransferTargetCampusUnknown R4-bis ②:转入校区在 t_campus 里查不到 / 未归属机构 → **3104**。
  365. func TestTransferTargetCampusUnknown(t *testing.T) {
  366. db := newFundServiceDB(t)
  367. svc := NewFundService(db, fundSuperScope(), nil)
  368. ctx := context.Background()
  369. user := fundUserID(28)
  370. seedCampus(t, db, 10, 1, true)
  371. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  372. Money: mustAmount(t, "100.00"), RequestID: "REQ-R4-UNKNOWN-SEED"}); err != nil {
  373. t.Fatalf("准备源校区余额失败: %v", err)
  374. }
  375. // 校区 99 未登记 → 3104(无法确定机构,拒绝建户)
  376. _, err := svc.Transfer(ctx, TransferInput{UserID: user,
  377. FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 99,
  378. Money: mustAmount(t, "10.00"), RequestID: "REQ-R4-UNKNOWN"})
  379. requireAppCode(t, err, 3104)
  380. // 未归属机构(institution_id = 0)同样 3104
  381. seedCampus(t, db, 98, 0, true)
  382. _, err = svc.Transfer(ctx, TransferInput{UserID: user,
  383. FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 98,
  384. Money: mustAmount(t, "10.00"), RequestID: "REQ-R4-UNKNOWN2"})
  385. requireAppCode(t, err, 3104)
  386. }
  387. // TestTransferRejectsInstitutionMismatchWhenTargetExists R4-bis 的**"已存在"侧**:
  388. // 目标账户**已按正确机构存在**,但入参机构写错 → 仍必须 **3140**,且目标行/源行/全局**逐列不变**。
  389. //
  390. // 与 `TestTransferRejectsInstitutionMismatch`(目标缺失 → 不许建户)互补:
  391. // 两者合起来才证明「定位必须带机构」。为什么单独立这条:
  392. // 目标账户加锁走 `GetCampusForUpdateExact(user, 入参机构, campus)` 的**精确三元组**,
  393. // 入参机构错 → 查不到按正确机构存在的那行 → 落进"建户"分支 → 过 3140(不建户、不更新)。
  394. // 若有人把该查询放宽成 `WHERE user_id AND campus_id`(看起来更"宽容"),本例就会变成
  395. // "按错机构 UPDATE 0 行 + 转出照常成功" ⇒ 转入静默丢失;而这类错**通用恒等式与差值冻结都抓不到**
  396. // (转结不动 balance,106+206=0)。故必须由本例直接钉住。
  397. func TestTransferRejectsInstitutionMismatchWhenTargetExists(t *testing.T) {
  398. db := newFundServiceDB(t)
  399. svc := NewFundService(db, fundSuperScope(), nil)
  400. ctx := context.Background()
  401. user := fundUserID(29)
  402. seedCampus(t, db, 10, 1, true)
  403. seedCampus(t, db, 20, 1, true)
  404. // 源校区充值 → 源账户 (1,10) 有余额;再向目标校区 (1,20) 充一笔 → 目标账户**已存在**
  405. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  406. Money: mustAmount(t, "100.00"), RequestID: "REQ-R4E-SRC"}); err != nil {
  407. t.Fatalf("准备源校区失败: %v", err)
  408. }
  409. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 20,
  410. Money: mustAmount(t, "50.00"), RequestID: "REQ-R4E-DST"}); err != nil {
  411. t.Fatalf("准备目标校区失败: %v", err)
  412. }
  413. srcBefore := readSchool(t, db, user, 1, 10)
  414. dstBefore := readSchool(t, db, user, 1, 20)
  415. globalBefore := readGlobal(t, db, user)
  416. logsBefore := len(balanceLogs(t, db, user))
  417. schoolRowsBefore := countRows(t, db, &model.UserExpenseSchool{}, user)
  418. // 目标账户存在,但入参声明机构 2(真实归属机构 1)→ 3140
  419. _, err := svc.Transfer(ctx, TransferInput{UserID: user,
  420. FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 2, ToCampusID: 20,
  421. Money: mustAmount(t, "30.00"), RequestID: "REQ-R4E-BAD"})
  422. requireAppCode(t, err, 3140)
  423. // ① 目标行逐列不变(防"按错机构更新 0 行"后的静默半成功)
  424. dstAfter := readSchool(t, db, user, 1, 20)
  425. if cents(t, dstAfter.Balance) != cents(t, dstBefore.Balance) ||
  426. cents(t, dstAfter.TotalIncome) != cents(t, dstBefore.TotalIncome) ||
  427. cents(t, dstAfter.TotalExpenditure) != cents(t, dstBefore.TotalExpenditure) {
  428. t.Fatalf("R4-bis 拒绝时目标校区不得变动:前 %+v 后 %+v", dstBefore, dstAfter)
  429. }
  430. // ② 源行逐列不变(转出侧不得先扣)
  431. srcAfter := readSchool(t, db, user, 1, 10)
  432. if cents(t, srcAfter.Balance) != cents(t, srcBefore.Balance) ||
  433. cents(t, srcAfter.TotalIncome) != cents(t, srcBefore.TotalIncome) ||
  434. cents(t, srcAfter.TotalExpenditure) != cents(t, srcBefore.TotalExpenditure) {
  435. t.Fatalf("R4-bis 拒绝时源校区不得变动:前 %+v 后 %+v", srcBefore, srcAfter)
  436. }
  437. // ③ 全局账户逐列不变
  438. globalAfter := readGlobal(t, db, user)
  439. if cents(t, globalAfter.Balance) != cents(t, globalBefore.Balance) ||
  440. cents(t, globalAfter.TotalIncome) != cents(t, globalBefore.TotalIncome) ||
  441. cents(t, globalAfter.TotalExpenditure) != cents(t, globalBefore.TotalExpenditure) {
  442. t.Fatalf("R4-bis 拒绝时全局账户不得变动:前 %+v 后 %+v", globalBefore, globalAfter)
  443. }
  444. // ④ 无新行(任意机构下 campus 20 只有 1 行)、无流水
  445. if got := countRows(t, db, &model.UserExpenseSchool{}, user); got != schoolRowsBefore {
  446. t.Fatalf("R4-bis 拒绝时不得新增账户行:前 %d 后 %d", schoolRowsBefore, got)
  447. }
  448. var dstRows int64
  449. if e := db.Model(&model.UserExpenseSchool{}).Where("user_id = ? AND campus_id = ?", user, 20).
  450. Count(&dstRows).Error; e != nil {
  451. t.Fatalf("统计目标校区行数失败: %v", e)
  452. }
  453. if dstRows != 1 {
  454. t.Fatalf("目标校区任意机构下应仍只有 1 行,实得 %d", dstRows)
  455. }
  456. if got := len(balanceLogs(t, db, user)); got != logsBefore {
  457. t.Fatalf("R4-bis 拒绝时不得写流水:前 %d 后 %d", logsBefore, got)
  458. }
  459. // ⑤ 差值冻结未受影响(这类错虽抓不到,但至少确认本次拒绝没引入偏差)
  460. if got, want := campusGlobalDelta(t, db, user), int64(0); got != want {
  461. t.Fatalf("拒绝前后差值应保持 %d,实得 %d", want, got)
  462. }
  463. }