refund_service_test.go 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933
  1. // RefundService 单测(AC-05):自定义退费双 log(含账务恒等式)/ 坏账边界 / 锁序 /
  2. // 20 并发幂等 / 自营与加盟的失效范围 / 失败零残留 / 余额清零(★campusBalance 基数)/ 取消充值活动。
  3. //
  4. // 断言纪律(本文件的核心价值):
  5. // 1. **恒等式断言**而不是"各比各的":`requested = refunded + badDebt` 且
  6. // `balance_before + 207.money + 108.money = balance_after`(三条流水代数和 = 净扣减);
  7. // 2. **顺序性**:108 必须在 207 之前写入(dump 顺序);全局锁必须在校区锁之前(§7.2 防死锁);
  8. // 3. **反向断言**:加盟校区不得失效 `campus_id=0` 的活动、type=3 活动不得被失效、
  9. // 失败用例必须余额/流水/报名状态**零残留**。
  10. package service
  11. import (
  12. "context"
  13. "errors"
  14. "regexp"
  15. "strings"
  16. "sync"
  17. "testing"
  18. "gorm.io/gorm"
  19. "yuansiwei/unified-backend/internal/academic/apperr"
  20. "yuansiwei/unified-backend/internal/academic/model"
  21. "yuansiwei/unified-backend/internal/academic/money"
  22. "yuansiwei/unified-backend/internal/academic/port"
  23. "yuansiwei/unified-backend/internal/academic/repository"
  24. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  25. )
  26. // ---------------------------------------------------------------- 辅助
  27. // seedActivity 落活动 + 报名行(status=1 报名)。
  28. func seedActivity(t *testing.T, db *gorm.DB, id int64, typ int8, institutionID, campusID int64) {
  29. t.Helper()
  30. act := &model.CoursesActivity{
  31. ID: id, Type: typ, InstitutionID: institutionID, CampusID: campusID,
  32. Status: model.ActivityStatusEnabled, Money: "100.00",
  33. }
  34. if err := db.Create(act).Error; err != nil {
  35. t.Fatalf("seed 活动(%d) 失败: %v", id, err)
  36. }
  37. }
  38. // seedSigned 落报名行(status 可指定;user 必须与退款用户一致才会被失效)。
  39. func seedSigned(t *testing.T, db *gorm.DB, userID string, activityID int64, status int32,
  40. institutionID, campusID int64) {
  41. t.Helper()
  42. row := &model.CoursesActivitySigned{
  43. ActivityID: activityID, UserID: userID, InstitutionID: institutionID, CampusID: campusID,
  44. Status: status, ActivityMoney: "100.00", ActivityMoneyUsed: "0.00",
  45. }
  46. if err := db.Create(row).Error; err != nil {
  47. t.Fatalf("seed 报名(%d) 失败: %v", activityID, err)
  48. }
  49. }
  50. // signedStatus 读报名行状态。
  51. func signedStatus(t *testing.T, db *gorm.DB, userID string, activityID int64) int32 {
  52. t.Helper()
  53. var row model.CoursesActivitySigned
  54. if err := db.Where("user_id = ? AND activity_id = ?", userID, activityID).Take(&row).Error; err != nil {
  55. t.Fatalf("读报名(%d) 失败: %v", activityID, err)
  56. }
  57. return row.Status
  58. }
  59. // expirableIDsOf 排序后的活动 ID(把返回切片转成便于断言的集合)。
  60. func expirableIDsOf(ids []int64) map[int64]bool {
  61. out := make(map[int64]bool, len(ids))
  62. for _, id := range ids {
  63. out[id] = true
  64. }
  65. return out
  66. }
  67. // ---------------------------------------------------------------- 自定义退费:全额
  68. // TestCustomRefundFullAmount 全额退:无 108、207=−requested、双表同额扣、total_income 同步减。
  69. func TestCustomRefundFullAmount(t *testing.T) {
  70. db := newFundServiceDB(t)
  71. fund := NewFundService(db, fundSuperScope(), nil)
  72. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  73. ctx := context.Background()
  74. user := fundUserID(30)
  75. seedCampus(t, db, 10, 1, true)
  76. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  77. Money: mustAmount(t, "500.00")}); err != nil {
  78. t.Fatalf("准备余额失败: %v", err)
  79. }
  80. res, err := svc.CustomRefund(ctx, CustomRefundInput{
  81. UserID: user, InstitutionID: 1, CampusID: 10,
  82. Money: mustAmount(t, "200.00"), RequestID: "REQ-CUSTOM-FULL",
  83. })
  84. if err != nil {
  85. t.Fatalf("CustomRefund 失败: %v", err)
  86. }
  87. if res.Repeated {
  88. t.Fatalf("首次退费不应 repeated=true")
  89. }
  90. if res.BadDebt.Cents() != 0 || res.LogID108 != nil {
  91. t.Fatalf("全额退不应产生坏账:badDebt=%d logId108=%v", res.BadDebt.Cents(), res.LogID108)
  92. }
  93. if len(res.Warnings) != 0 {
  94. t.Fatalf("全额退 warnings 应为空,实得 %+v", res.Warnings)
  95. }
  96. if res.Refunded.Cents() != 20000 || res.Requested.Cents() != 20000 {
  97. t.Fatalf("refunded/requested 应各 20000 分,实得 %d/%d", res.Refunded.Cents(), res.Requested.Cents())
  98. }
  99. if res.Balance.Cents() != 30000 {
  100. t.Fatalf("退费后校区余额应 30000 分,实得 %d", res.Balance.Cents())
  101. }
  102. // 双表同额扣减 + total_income 同步减
  103. if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 30000 {
  104. t.Fatalf("校区 balance 应 30000 分,实得 %d", got)
  105. }
  106. if got := cents(t, readGlobal(t, db, user).Balance); got != 30000 {
  107. t.Fatalf("全局 balance 应同额扣(30000 分),实得 %d", got)
  108. }
  109. if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 30000 {
  110. t.Fatalf("校区 total_income 应 30000 分,实得 %d", got)
  111. }
  112. if got := cents(t, readGlobal(t, db, user).TotalIncome); got != 30000 {
  113. t.Fatalf("全局 total_income 应 30000 分,实得 %d", got)
  114. }
  115. // 207 = -requested(全额)
  116. logs := logsByType(t, db, user, model.ExpenseLogTypeRefund)
  117. if len(logs) != 1 {
  118. t.Fatalf("207 流水应 1 条,实得 %d", len(logs))
  119. }
  120. if got := cents(t, logs[0].Money); got != -20000 {
  121. t.Fatalf("207 流水应 -20000 分,实得 %d", got)
  122. }
  123. if logs[0].Remark == nil || *logs[0].Remark != defaultCustomRefundRemark {
  124. t.Fatalf("207 默认备注应为 %q,实得 %+v", defaultCustomRefundRemark, logs[0].Remark)
  125. }
  126. }
  127. // ---------------------------------------------------------------- 自定义退费:坏账 + 恒等式
  128. // TestCustomRefundBadDebtIdentity 部分退(requested > 可退):
  129. // 双 log 顺序 + 账务恒等式 + special_expenses 累加 + warnings[3103]。
  130. func TestCustomRefundBadDebtIdentity(t *testing.T) {
  131. db := newFundServiceDB(t)
  132. fund := NewFundService(db, fundSuperScope(), nil)
  133. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  134. ctx := context.Background()
  135. user := fundUserID(31)
  136. seedCampus(t, db, 10, 1, true)
  137. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  138. Money: mustAmount(t, "320.00")}); err != nil {
  139. t.Fatalf("准备余额失败: %v", err)
  140. }
  141. before := cents(t, readSchool(t, db, user, 1, 10).Balance)
  142. // requested=500 > 可退 320 → badDebt=180
  143. res, err := svc.CustomRefund(ctx, CustomRefundInput{
  144. UserID: user, InstitutionID: 1, CampusID: 10,
  145. Money: mustAmount(t, "500.00"), RequestID: "REQ-CUSTOM-BADDEBT", Remark: "退学退费",
  146. })
  147. if err != nil {
  148. t.Fatalf("CustomRefund 失败: %v", err)
  149. }
  150. // ① 金额拆分恒等式:requested = refunded + badDebt
  151. if res.Requested.Cents() != 50000 {
  152. t.Fatalf("requested 应 50000 分,实得 %d", res.Requested.Cents())
  153. }
  154. if res.Refunded.Cents() != 32000 {
  155. t.Fatalf("refunded 应 32000 分,实得 %d", res.Refunded.Cents())
  156. }
  157. if res.BadDebt.Cents() != 18000 {
  158. t.Fatalf("badDebt 应 18000 分,实得 %d", res.BadDebt.Cents())
  159. }
  160. if res.Requested.Cents() != res.Refunded.Cents()+res.BadDebt.Cents() {
  161. t.Fatalf("恒等式 requested = refunded + badDebt 不成立:%d vs %d+%d",
  162. res.Requested.Cents(), res.Refunded.Cents(), res.BadDebt.Cents())
  163. }
  164. if res.RefundableAmount.Cents() != 32000 {
  165. t.Fatalf("refundableAmount 快照应 32000 分,实得 %d", res.RefundableAmount.Cents())
  166. }
  167. // ② 双 log 内容与**顺序**:108 先、207 后
  168. logs108 := logsByType(t, db, user, model.ExpenseLogTypeBadDebt)
  169. logs207 := logsByType(t, db, user, model.ExpenseLogTypeRefund)
  170. if len(logs108) != 1 || len(logs207) != 1 {
  171. t.Fatalf("108/207 应各 1 条,实得 %d/%d", len(logs108), len(logs207))
  172. }
  173. if got := cents(t, logs108[0].Money); got != 18000 {
  174. t.Fatalf("108 流水应 +18000 分(坏账部分),实得 %d", got)
  175. }
  176. if got := cents(t, logs207[0].Money); got != -50000 {
  177. t.Fatalf("207 流水应 -50000 分(**全额**,与扣减额不同),实得 %d", got)
  178. }
  179. if logs108[0].ID >= logs207[0].ID {
  180. t.Fatalf("108 必须先于 207 写入(dump 顺序),实得 108.id=%d 207.id=%d", logs108[0].ID, logs207[0].ID)
  181. }
  182. if logs108[0].Remark == nil || !strings.Contains(*logs108[0].Remark, "退费的金额大于可退费部分,超出部分为坏账部分。") {
  183. t.Fatalf("108 流水 remark 必须含 dump 指定文案,实得 %+v", logs108[0].Remark)
  184. }
  185. if logs108[0].RequestID == nil || *logs108[0].RequestID != "REQ-CUSTOM-BADDEBT" {
  186. t.Fatalf("108 的 request_id 应落幂等键,实得 %+v", logs108[0].RequestID)
  187. }
  188. // ③ **账务恒等式**:balance_before + 207 + 108 = balance_after(代数和 = 净扣减)
  189. schoolAfter := readSchool(t, db, user, 1, 10)
  190. after := cents(t, schoolAfter.Balance)
  191. sum := cents(t, logs207[0].Money) + cents(t, logs108[0].Money)
  192. if before+sum != after {
  193. t.Fatalf("恒等式不成立:before(%d) + ∑log(%d) = %d ≠ after(%d)", before, sum, before+sum, after)
  194. }
  195. if after != before-32000 {
  196. t.Fatalf("净扣减应等于 refunded(32000 分):before=%d after=%d", before, after)
  197. }
  198. // 双账户同额(全局也净减 refunded,而不是 requested)
  199. if got := cents(t, readGlobal(t, db, user).Balance); got != after {
  200. t.Fatalf("全局余额应与校区同额(%d 分),实得 %d", after, got)
  201. }
  202. // ④ special_expenses 双表各 +badDebt
  203. if got := cents(t, schoolAfter.SpecialExpenses); got != 18000 {
  204. t.Fatalf("校区 special_expenses 应 18000 分,实得 %d", got)
  205. }
  206. if got := cents(t, readGlobal(t, db, user).SpecialExpenses); got != 18000 {
  207. t.Fatalf("全局 special_expenses 应 18000 分,实得 %d", got)
  208. }
  209. // ⑤ warnings[3103](warn 级,不阻断)
  210. if len(res.Warnings) != 1 || res.Warnings[0].Code != apperr.CodeRefundableShort {
  211. t.Fatalf("应有 1 条 3103 warning,实得 %+v", res.Warnings)
  212. }
  213. if !strings.Contains(res.Warnings[0].Message, "180.00") {
  214. t.Fatalf("warning 文案应含差额 180.00,实得 %q", res.Warnings[0].Message)
  215. }
  216. if res.LogID108 == nil || *res.LogID108 != logs108[0].ID {
  217. t.Fatalf("logId108 应回填 %d,实得 %+v", logs108[0].ID, res.LogID108)
  218. }
  219. if res.LogID207 != logs207[0].ID {
  220. t.Fatalf("logId207 应回填 %d,实得 %d", logs207[0].ID, res.LogID207)
  221. }
  222. }
  223. // TestCustomRefundExactBalance requested == balance 边界:badDebt=0、余额归零。
  224. func TestCustomRefundExactBalance(t *testing.T) {
  225. db := newFundServiceDB(t)
  226. fund := NewFundService(db, fundSuperScope(), nil)
  227. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  228. ctx := context.Background()
  229. user := fundUserID(32)
  230. seedCampus(t, db, 10, 1, true)
  231. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  232. Money: mustAmount(t, "120.00")}); err != nil {
  233. t.Fatalf("准备余额失败: %v", err)
  234. }
  235. res, err := svc.CustomRefund(ctx, CustomRefundInput{
  236. UserID: user, InstitutionID: 1, CampusID: 10,
  237. Money: mustAmount(t, "120.00"), RequestID: "REQ-CUSTOM-EXACT",
  238. })
  239. if err != nil {
  240. t.Fatalf("CustomRefund 失败: %v", err)
  241. }
  242. if res.BadDebt.Cents() != 0 || res.LogID108 != nil {
  243. t.Fatalf("恰好退完不应有坏账,实得 %+v", res)
  244. }
  245. if res.Balance.Cents() != 0 {
  246. t.Fatalf("退完后余额应 0,实得 %d", res.Balance.Cents())
  247. }
  248. }
  249. // ---------------------------------------------------------------- 自定义退费:锁序
  250. // TestCustomRefundLocksGlobalBeforeCampus 加锁顺序 **全局 → 校区**(§7.2;错序 = 与充值并发成环)。
  251. //
  252. // 断言口径:抓真实 SQL 序列,要求第一条 `t_user_expense`(全局)点查出现在
  253. // 第一条 `t_user_expense_school`(校区)点查之前。
  254. func TestCustomRefundLocksGlobalBeforeCampus(t *testing.T) {
  255. db, rec := newFundServiceDBWithRecorder(t)
  256. fund := NewFundService(db, fundSuperScope(), nil)
  257. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  258. ctx := context.Background()
  259. user := fundUserID(33)
  260. seedCampus(t, db, 10, 1, true)
  261. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  262. Money: mustAmount(t, "100.00")}); err != nil {
  263. t.Fatalf("准备余额失败: %v", err)
  264. }
  265. rec.Reset()
  266. if _, err := svc.CustomRefund(ctx, CustomRefundInput{
  267. UserID: user, InstitutionID: 1, CampusID: 10,
  268. Money: mustAmount(t, "10.00"), RequestID: "REQ-CUSTOM-LOCK",
  269. }); err != nil {
  270. t.Fatalf("CustomRefund 失败: %v", err)
  271. }
  272. stmts := rec.Snapshot()
  273. globalIdx := firstTouchIndex(stmts, `\bt_user_expense\b`)
  274. campusIdx := firstTouchIndex(stmts, `\bt_user_expense_school\b`)
  275. if globalIdx < 0 || campusIdx < 0 {
  276. t.Fatalf("未捕获到两次账户点查(global=%d campus=%d):%v", globalIdx, campusIdx, stmts)
  277. }
  278. if globalIdx > campusIdx {
  279. t.Fatalf("锁序错误:全局账户(第 %d 条)必须早于校区账户(第 %d 条)", globalIdx, campusIdx)
  280. }
  281. }
  282. // ---------------------------------------------------------------- 自定义退费:并发幂等
  283. // TestCustomRefundConcurrentSameRequestID 20 并发同 requestId → 1 条 207 + 1 条 108、余额只变一次。
  284. //
  285. // SQLite 无行锁:用单连接池把写事务串行化(等价 MySQL 行锁串行)。
  286. // 断言点是**流水条数与余额增量**,而不是"有没有报错"——这才是资金正确性。
  287. func TestCustomRefundConcurrentSameRequestID(t *testing.T) {
  288. db := newFundServiceDB(t)
  289. fund := NewFundService(db, fundSuperScope(), nil)
  290. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  291. ctx := context.Background()
  292. user := fundUserID(34)
  293. seedCampus(t, db, 10, 1, true)
  294. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  295. Money: mustAmount(t, "300.00")}); err != nil {
  296. t.Fatalf("准备余额失败: %v", err)
  297. }
  298. in := CustomRefundInput{
  299. UserID: user, InstitutionID: 1, CampusID: 10,
  300. Money: mustAmount(t, "400.00"), RequestID: "REQ-CUSTOM-RACE", // 400 > 300 → 必然产生 108
  301. }
  302. const goroutines = 20
  303. var wg sync.WaitGroup
  304. results := make([]*CustomRefundResult, goroutines)
  305. errs := make([]error, goroutines)
  306. for i := 0; i < goroutines; i++ {
  307. wg.Add(1)
  308. go func(idx int) {
  309. defer wg.Done()
  310. results[idx], errs[idx] = svc.CustomRefund(ctx, in)
  311. }(i)
  312. }
  313. wg.Wait()
  314. for i, err := range errs {
  315. if err != nil {
  316. t.Fatalf("并发第 %d 次退费失败: %v", i, err)
  317. }
  318. }
  319. if n := len(logsByType(t, db, user, model.ExpenseLogTypeRefund)); n != 1 {
  320. t.Fatalf("207 流水应只 1 条,实得 %d", n)
  321. }
  322. if n := len(logsByType(t, db, user, model.ExpenseLogTypeBadDebt)); n != 1 {
  323. t.Fatalf("108 流水应只 1 条,实得 %d", n)
  324. }
  325. school := readSchool(t, db, user, 1, 10)
  326. if got := cents(t, school.Balance); got != 0 {
  327. t.Fatalf("余额应只扣一次(300→0),实得 %d", got)
  328. }
  329. if got := cents(t, school.SpecialExpenses); got != 10000 {
  330. t.Fatalf("special_expenses 应只加一次(10000 分 = 400−300),实得 %d", got)
  331. }
  332. repeated := 0
  333. for _, r := range results {
  334. if r != nil && r.Repeated {
  335. repeated++
  336. }
  337. }
  338. if repeated != goroutines-1 {
  339. t.Fatalf("应有 %d 次 repeated=true,实得 %d", goroutines-1, repeated)
  340. }
  341. }
  342. // TestCustomRefundIdempotentReplaysFirstResult 顺序重复调用 → repeated=true 且字段回放首次结果。
  343. func TestCustomRefundIdempotentReplaysFirstResult(t *testing.T) {
  344. db := newFundServiceDB(t)
  345. fund := NewFundService(db, fundSuperScope(), nil)
  346. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  347. ctx := context.Background()
  348. user := fundUserID(35)
  349. seedCampus(t, db, 10, 1, true)
  350. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  351. Money: mustAmount(t, "100.00")}); err != nil {
  352. t.Fatalf("准备余额失败: %v", err)
  353. }
  354. in := CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
  355. Money: mustAmount(t, "150.00"), RequestID: "REQ-CUSTOM-REPLAY"}
  356. first, err := svc.CustomRefund(ctx, in)
  357. if err != nil {
  358. t.Fatalf("首次退费失败: %v", err)
  359. }
  360. second, err := svc.CustomRefund(ctx, in)
  361. if err != nil {
  362. t.Fatalf("重复退费应幂等成功: %v", err)
  363. }
  364. if !second.Repeated {
  365. t.Fatalf("重复退费应 repeated=true")
  366. }
  367. if second.LogID207 != first.LogID207 {
  368. t.Fatalf("应回放首次 logId207=%d,实得 %d", first.LogID207, second.LogID207)
  369. }
  370. if second.BadDebt.Cents() != first.BadDebt.Cents() || second.Refunded.Cents() != first.Refunded.Cents() {
  371. t.Fatalf("应回放首次坏账/退款额:first=%+v second=%+v", first, second)
  372. }
  373. if second.LogID108 == nil || first.LogID108 == nil || *second.LogID108 != *first.LogID108 {
  374. t.Fatalf("应回放首次 logId108,first=%+v second=%+v", first.LogID108, second.LogID108)
  375. }
  376. if n := len(logsByType(t, db, user, model.ExpenseLogTypeRefund)); n != 1 {
  377. t.Fatalf("207 流水应只 1 条,实得 %d", n)
  378. }
  379. }
  380. // ---------------------------------------------------------------- 活动失效范围
  381. // TestCustomRefundExpirySelfSupportCoversInstitutionPool 自营校区:失效本校区 + `campus_id=0` 池活动,
  382. // 且**不得**动 type=3 / 他机构 / 已过期(status=4)的行。
  383. func TestCustomRefundExpirySelfSupportCoversInstitutionPool(t *testing.T) {
  384. db := newFundServiceDB(t)
  385. fund := NewFundService(db, fundSuperScope(), nil)
  386. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  387. ctx := context.Background()
  388. user := fundUserID(36)
  389. seedCampus(t, db, 10, 1, true) // 自营
  390. // 活动 101 本校区(10) type=2 → 失效;102 全机构池(0) type=2 → 自营也失效
  391. // 103 代金券 type=3 → 不动;104 他机构(2) → 不动;105 已过期报名 → 不在候选;106 他校区(11) type=2 → 不动
  392. seedActivity(t, db, 101, model.ActivityTypeRecharge, 1, 10)
  393. seedActivity(t, db, 102, model.ActivityTypeRecharge, 1, 0)
  394. seedActivity(t, db, 103, model.ActivityTypeVoucher, 1, 10)
  395. seedActivity(t, db, 104, model.ActivityTypeRecharge, 2, 10)
  396. seedActivity(t, db, 105, model.ActivityTypeRecharge, 1, 10)
  397. seedActivity(t, db, 106, model.ActivityTypeRecharge, 1, 11)
  398. for _, id := range []int64{101, 102, 103, 104, 106} {
  399. seedSigned(t, db, user, id, model.ActivitySignedSigned, 1, 10)
  400. }
  401. seedSigned(t, db, user, 105, model.ActivitySignedExpired, 1, 10)
  402. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  403. Money: mustAmount(t, "100.00")}); err != nil {
  404. t.Fatalf("准备余额失败: %v", err)
  405. }
  406. res, err := svc.CustomRefund(ctx, CustomRefundInput{
  407. UserID: user, InstitutionID: 1, CampusID: 10,
  408. Money: mustAmount(t, "10.00"), RequestID: "REQ-EXPIRY-SELF",
  409. })
  410. if err != nil {
  411. t.Fatalf("CustomRefund 失败: %v", err)
  412. }
  413. got := expirableIDsOf(res.ExpiredActivityIDs)
  414. if !got[101] {
  415. t.Fatalf("自营校区应失效本校区活动 101,实得 %v", res.ExpiredActivityIDs)
  416. }
  417. if !got[102] {
  418. t.Fatalf("自营校区应失效全机构池活动 102(campus_id=0),实得 %v", res.ExpiredActivityIDs)
  419. }
  420. if got[103] {
  421. t.Fatalf("type=3 代金券活动不得失效(dump 只处理 type=2),实得 %v", res.ExpiredActivityIDs)
  422. }
  423. if got[104] {
  424. t.Fatalf("他机构活动不得失效,实得 %v", res.ExpiredActivityIDs)
  425. }
  426. if got[105] {
  427. t.Fatalf("已过期(status=4)的报名不应出现在失效列表,实得 %v", res.ExpiredActivityIDs)
  428. }
  429. if got[106] {
  430. t.Fatalf("他校区活动(campus_id=11)在自营分支也不得失效,实得 %v", res.ExpiredActivityIDs)
  431. }
  432. // 落库断言(不只信返回值)
  433. if s := signedStatus(t, db, user, 101); s != model.ActivitySignedExpired {
  434. t.Fatalf("报名 101 应为 4,实得 %d", s)
  435. }
  436. if s := signedStatus(t, db, user, 102); s != model.ActivitySignedExpired {
  437. t.Fatalf("报名 102 应为 4,实得 %d", s)
  438. }
  439. if s := signedStatus(t, db, user, 103); s != model.ActivitySignedSigned {
  440. t.Fatalf("报名 103(type=3)必须保持 1,实得 %d", s)
  441. }
  442. if s := signedStatus(t, db, user, 104); s != model.ActivitySignedSigned {
  443. t.Fatalf("报名 104(他机构)必须保持 1,实得 %d", s)
  444. }
  445. if s := signedStatus(t, db, user, 106); s != model.ActivitySignedSigned {
  446. t.Fatalf("报名 106(他校区)必须保持 1,实得 %d", s)
  447. }
  448. }
  449. // TestCustomRefundExpiryFranchiseExcludesInstitutionPool 加盟校区:**只**失效 `campus_id = campusID`,
  450. // 不得碰 `campus_id=0` 的全机构池活动(R1:错走加盟分支会误失效他校区活动)。
  451. func TestCustomRefundExpiryFranchiseExcludesInstitutionPool(t *testing.T) {
  452. db := newFundServiceDB(t)
  453. fund := NewFundService(db, fundSuperScope(), nil)
  454. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  455. ctx := context.Background()
  456. user := fundUserID(37)
  457. seedCampus(t, db, 10, 1, false) // 加盟
  458. seedActivity(t, db, 201, model.ActivityTypeRecharge, 1, 10)
  459. seedActivity(t, db, 202, model.ActivityTypeRecharge, 1, 0)
  460. seedSigned(t, db, user, 201, model.ActivitySignedSigned, 1, 10)
  461. seedSigned(t, db, user, 202, model.ActivitySignedSigned, 1, 10)
  462. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  463. Money: mustAmount(t, "100.00")}); err != nil {
  464. t.Fatalf("准备余额失败: %v", err)
  465. }
  466. res, err := svc.CustomRefund(ctx, CustomRefundInput{
  467. UserID: user, InstitutionID: 1, CampusID: 10,
  468. Money: mustAmount(t, "10.00"), RequestID: "REQ-EXPIRY-FRANCHISE",
  469. })
  470. if err != nil {
  471. t.Fatalf("CustomRefund 失败: %v", err)
  472. }
  473. got := expirableIDsOf(res.ExpiredActivityIDs)
  474. if !got[201] {
  475. t.Fatalf("加盟校区应失效本校区活动 201,实得 %v", res.ExpiredActivityIDs)
  476. }
  477. if got[202] {
  478. t.Fatalf("加盟校区**不得**失效全机构池活动 202(campus_id=0),实得 %v", res.ExpiredActivityIDs)
  479. }
  480. if s := signedStatus(t, db, user, 202); s != model.ActivitySignedSigned {
  481. t.Fatalf("报名 202 必须保持 1,实得 %d", s)
  482. }
  483. }
  484. // ---------------------------------------------------------------- 失败回滚(零残留)
  485. // TestCustomRefundRollbackLeavesNoResidue 注入失败(活动失效步骤报错)→
  486. // 余额/流水/报名状态**零残留**(同事务整体回滚)。
  487. //
  488. // 注入方式:删掉 `t_courses_activity_signed` 表 → 失效器查询必然报错。
  489. // 这样失败点落在"资金已写、活动未失效"的最坏位置,正好验证原子性。
  490. func TestCustomRefundRollbackLeavesNoResidue(t *testing.T) {
  491. db := newFundServiceDB(t)
  492. fund := NewFundService(db, fundSuperScope(), nil)
  493. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  494. ctx := context.Background()
  495. user := fundUserID(38)
  496. seedCampus(t, db, 10, 1, true)
  497. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  498. Money: mustAmount(t, "100.00")}); err != nil {
  499. t.Fatalf("准备余额失败: %v", err)
  500. }
  501. beforeSchool := readSchool(t, db, user, 1, 10)
  502. beforeGlobal := readGlobal(t, db, user)
  503. if err := db.Exec("DROP TABLE t_courses_activity_signed").Error; err != nil {
  504. t.Fatalf("注入失败(删表)失败: %v", err)
  505. }
  506. _, err := svc.CustomRefund(ctx, CustomRefundInput{
  507. UserID: user, InstitutionID: 1, CampusID: 10,
  508. Money: mustAmount(t, "150.00"), RequestID: "REQ-ROLLBACK",
  509. })
  510. if err == nil {
  511. t.Fatalf("活动失效失败时退费必须整体失败,实得 nil")
  512. }
  513. afterSchool := readSchool(t, db, user, 1, 10)
  514. if cents(t, afterSchool.Balance) != cents(t, beforeSchool.Balance) {
  515. t.Fatalf("回滚后校区余额必须不变:before=%s after=%s", beforeSchool.Balance, afterSchool.Balance)
  516. }
  517. if cents(t, afterSchool.TotalIncome) != cents(t, beforeSchool.TotalIncome) {
  518. t.Fatalf("回滚后校区 total_income 必须不变:before=%s after=%s",
  519. beforeSchool.TotalIncome, afterSchool.TotalIncome)
  520. }
  521. if cents(t, afterSchool.SpecialExpenses) != 0 {
  522. t.Fatalf("回滚后 special_expenses 必须为 0,实得 %s", afterSchool.SpecialExpenses)
  523. }
  524. afterGlobal := readGlobal(t, db, user)
  525. if cents(t, afterGlobal.Balance) != cents(t, beforeGlobal.Balance) {
  526. t.Fatalf("回滚后全局余额必须不变:before=%s after=%s", beforeGlobal.Balance, afterGlobal.Balance)
  527. }
  528. if n := len(logsByType(t, db, user, model.ExpenseLogTypeRefund)); n != 0 {
  529. t.Fatalf("回滚后不得残留 207 流水,实得 %d 条", n)
  530. }
  531. if n := len(logsByType(t, db, user, model.ExpenseLogTypeBadDebt)); n != 0 {
  532. t.Fatalf("回滚后不得残留 108 流水,实得 %d 条", n)
  533. }
  534. }
  535. // ---------------------------------------------------------------- 错误码
  536. // TestCustomRefundErrorCodes 3102 / 2002 / 3104 / 2001。
  537. func TestCustomRefundErrorCodes(t *testing.T) {
  538. db := newFundServiceDB(t)
  539. fund := NewFundService(db, fundSuperScope(), nil)
  540. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  541. ctx := context.Background()
  542. seedCampus(t, db, 10, 1, true)
  543. t.Run("余额为0_3102", func(t *testing.T) {
  544. user := fundUserID(40)
  545. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  546. Money: mustAmount(t, "50.00")}); err != nil {
  547. t.Fatalf("准备余额失败: %v", err)
  548. }
  549. // 先全额退完(余额归 0)
  550. if _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
  551. Money: mustAmount(t, "50.00"), RequestID: "REQ-ERR-3102-A"}); err != nil {
  552. t.Fatalf("准备归零失败: %v", err)
  553. }
  554. _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
  555. Money: mustAmount(t, "10.00"), RequestID: "REQ-ERR-3102-B"})
  556. requireAppCode(t, err, apperr.CodeNoBalanceToRefund)
  557. })
  558. t.Run("校区账户不存在_3102", func(t *testing.T) {
  559. user := fundUserID(41)
  560. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  561. Money: mustAmount(t, "10.00")}); err != nil {
  562. t.Fatalf("准备余额失败: %v", err)
  563. }
  564. // campus 12 无账户(全局有余额)
  565. _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 12,
  566. Money: mustAmount(t, "5.00"), RequestID: "REQ-ERR-3102-C"})
  567. requireAppCode(t, err, apperr.CodeNoBalanceToRefund)
  568. })
  569. t.Run("金额为负_2002", func(t *testing.T) {
  570. _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: fundUserID(42), InstitutionID: 1, CampusID: 10,
  571. Money: mustAmount(t, "-1.00"), RequestID: "REQ-ERR-2002"})
  572. requireAppCode(t, err, 2002)
  573. })
  574. t.Run("金额超单笔上限_2002", func(t *testing.T) {
  575. _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: fundUserID(42), InstitutionID: 1, CampusID: 10,
  576. Money: mustAmount(t, "1000000.01"), RequestID: "REQ-ERR-2002B"})
  577. requireAppCode(t, err, 2002)
  578. })
  579. t.Run("缺requestId_2002", func(t *testing.T) {
  580. _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: fundUserID(42), InstitutionID: 1, CampusID: 10,
  581. Money: mustAmount(t, "1.00")})
  582. requireAppCode(t, err, 2002)
  583. })
  584. t.Run("校区不存在_3104", func(t *testing.T) {
  585. user := fundUserID(43)
  586. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  587. Money: mustAmount(t, "10.00")}); err != nil {
  588. t.Fatalf("准备余额失败: %v", err)
  589. }
  590. // 手工造一个 t_campus 里不存在的校区账户(模拟历史脏数据/校区被删)
  591. if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 99,
  592. Balance: "10.00"}).Error; err != nil {
  593. t.Fatalf("seed 脏校区账户失败: %v", err)
  594. }
  595. _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 99,
  596. Money: mustAmount(t, "5.00"), RequestID: "REQ-ERR-3104"})
  597. requireAppCode(t, err, apperr.CodeCampusNotFound)
  598. // R1 + 原子性:3104 时不得留下任何资金痕迹
  599. if got := cents(t, readSchool(t, db, user, 1, 99).Balance); got != 1000 {
  600. t.Fatalf("3104 回滚后余额必须不变(1000 分),实得 %d", got)
  601. }
  602. if n := len(logsByType(t, db, user, model.ExpenseLogTypeRefund)); n != 0 {
  603. t.Fatalf("3104 不得写流水,实得 %d 条", n)
  604. }
  605. })
  606. t.Run("缺用户_2001", func(t *testing.T) {
  607. _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: "", InstitutionID: 1, CampusID: 10,
  608. Money: mustAmount(t, "1.00"), RequestID: "REQ-ERR-2001"})
  609. requireAppCode(t, err, 2001)
  610. })
  611. }
  612. // ---------------------------------------------------------------- 余额清零(SP10)
  613. // TestClearBalanceUsesCampusBalanceAsBase ★漂移项:全局表扣减基数是 **campusBalance**(不是 totalBalance)。
  614. //
  615. // 场景:校区 10 有 100,校区 20 有 100(全局合计 200)。清零校区 10 后:
  616. // 校区 10 余额 0、全局余额 100(= 200 − 100,而不是 0)。
  617. func TestClearBalanceUsesCampusBalanceAsBase(t *testing.T) {
  618. db := newFundServiceDB(t)
  619. fund := NewFundService(db, fundSuperScope(), nil)
  620. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  621. ctx := context.Background()
  622. user := fundUserID(50)
  623. seedCampus(t, db, 10, 1, true)
  624. seedActivity(t, db, 301, model.ActivityTypeRecharge, 1, 10)
  625. seedSigned(t, db, user, 301, model.ActivitySignedSigned, 1, 10)
  626. for _, campusID := range []int64{10, 20} {
  627. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: campusID,
  628. Money: mustAmount(t, "100.00")}); err != nil {
  629. t.Fatalf("准备余额(campus=%d)失败: %v", campusID, err)
  630. }
  631. }
  632. res, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10})
  633. if err != nil {
  634. t.Fatalf("ClearBalance 失败: %v", err)
  635. }
  636. if res.Cleared.Cents() != 10000 {
  637. t.Fatalf("cleared 应 10000 分,实得 %d", res.Cleared.Cents())
  638. }
  639. if res.CampusBalanceBefore.Cents() != 10000 || res.TotalBalanceBefore.Cents() != 20000 {
  640. t.Fatalf("清零前快照错误:campus=%d total=%d",
  641. res.CampusBalanceBefore.Cents(), res.TotalBalanceBefore.Cents())
  642. }
  643. if res.Balance.Cents() != 0 {
  644. t.Fatalf("清零后校区余额应 0,实得 %d", res.Balance.Cents())
  645. }
  646. if res.TotalBalance.Cents() != 10000 {
  647. t.Fatalf("★ 清零后全局余额应 10000 分(200−100,基数是 campusBalance),实得 %d",
  648. res.TotalBalance.Cents())
  649. }
  650. school := readSchool(t, db, user, 1, 10)
  651. if got := cents(t, school.Balance); got != 0 {
  652. t.Fatalf("校区 balance 应 0,实得 %d", got)
  653. }
  654. if got := cents(t, school.SpecialExpenses); got != 10000 {
  655. t.Fatalf("校区 special_expenses 应 10000 分,实得 %d", got)
  656. }
  657. if got := cents(t, school.Spend); got != 10000 {
  658. t.Fatalf("校区 spend 应 10000 分(dump 清零同时累加 spend),实得 %d", got)
  659. }
  660. global := readGlobal(t, db, user)
  661. if got := cents(t, global.Balance); got != 10000 {
  662. t.Fatalf("全局 balance 应 10000 分,实得 %d", got)
  663. }
  664. if got := cents(t, global.SpecialExpenses); got != 10000 {
  665. t.Fatalf("全局 special_expenses 应 10000 分,实得 %d", got)
  666. }
  667. // 流水 208 = −campusBalance
  668. logs := logsByType(t, db, user, model.ExpenseLogTypeClearBalance)
  669. if len(logs) != 1 || cents(t, logs[0].Money) != -10000 {
  670. t.Fatalf("208 流水应为 -10000 分一条,实得 %+v", logs)
  671. }
  672. if logs[0].Remark == nil || *logs[0].Remark != defaultClearBalanceRemark {
  673. t.Fatalf("208 默认备注应为 %q,实得 %+v", defaultClearBalanceRemark, logs[0].Remark)
  674. }
  675. // 活动失效(清零同样触发,与 SP9 同源)
  676. if !expirableIDsOf(res.ExpiredActivityIDs)[301] {
  677. t.Fatalf("清零应失效充值活动 301,实得 %v", res.ExpiredActivityIDs)
  678. }
  679. if s := signedStatus(t, db, user, 301); s != model.ActivitySignedExpired {
  680. t.Fatalf("报名 301 应为 4,实得 %d", s)
  681. }
  682. // 再次清零 → 3120(天然幂等,不会重复清零)
  683. if _, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10}); err == nil {
  684. t.Fatalf("余额已为 0 时再次清零必须失败(3120)")
  685. } else {
  686. requireAppCode(t, err, apperr.CodeClearBalanceRejected)
  687. }
  688. }
  689. // TestClearBalancePreconditions 三条前置校验 → 3120(dump 静默 LEAVE,Go 显式)。
  690. func TestClearBalancePreconditions(t *testing.T) {
  691. db := newFundServiceDB(t)
  692. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  693. ctx := context.Background()
  694. seedCampus(t, db, 10, 1, true)
  695. seedCampus(t, db, 11, 1, true)
  696. t.Run("校区余额为0", func(t *testing.T) {
  697. user := fundUserID(51)
  698. if err := db.Create(&model.UserExpense{UserID: user, Balance: "100.00"}).Error; err != nil {
  699. t.Fatalf("seed 全局失败: %v", err)
  700. }
  701. if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
  702. Balance: "0.00"}).Error; err != nil {
  703. t.Fatalf("seed 校区失败: %v", err)
  704. }
  705. _, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10})
  706. requireAppCode(t, err, apperr.CodeClearBalanceRejected)
  707. })
  708. t.Run("全局余额非正", func(t *testing.T) {
  709. user := fundUserID(52)
  710. if err := db.Create(&model.UserExpense{UserID: user, Balance: "-10.00"}).Error; err != nil {
  711. t.Fatalf("seed 全局失败: %v", err)
  712. }
  713. if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
  714. Balance: "50.00"}).Error; err != nil {
  715. t.Fatalf("seed 校区失败: %v", err)
  716. }
  717. _, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10})
  718. requireAppCode(t, err, apperr.CodeClearBalanceRejected)
  719. })
  720. t.Run("校区余额大于全局", func(t *testing.T) {
  721. user := fundUserID(53)
  722. if err := db.Create(&model.UserExpense{UserID: user, Balance: "50.00"}).Error; err != nil {
  723. t.Fatalf("seed 全局失败: %v", err)
  724. }
  725. if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
  726. Balance: "80.00"}).Error; err != nil {
  727. t.Fatalf("seed 校区失败: %v", err)
  728. }
  729. _, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10})
  730. requireAppCode(t, err, apperr.CodeClearBalanceRejected)
  731. })
  732. t.Run("校区账户不存在", func(t *testing.T) {
  733. user := fundUserID(54)
  734. if err := db.Create(&model.UserExpense{UserID: user, Balance: "50.00"}).Error; err != nil {
  735. t.Fatalf("seed 全局失败: %v", err)
  736. }
  737. _, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 11})
  738. requireAppCode(t, err, apperr.CodeClearBalanceRejected)
  739. })
  740. }
  741. // ---------------------------------------------------------------- 取消充值活动(SP8)
  742. // TestCancelRechargeActivity 正常路径:跨域读机构 → 余额/总充值减 → 报名 status=0 → 209 流水为负。
  743. func TestCancelRechargeActivity(t *testing.T) {
  744. db := newFundServiceDB(t)
  745. fund := NewFundService(db, fundSuperScope(), nil)
  746. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  747. ctx := context.Background()
  748. user := fundUserID(60)
  749. seedCampus(t, db, 10, 1, true)
  750. seedActivity(t, db, 401, model.ActivityTypeRecharge, 1, 10)
  751. seedSigned(t, db, user, 401, model.ActivitySignedSigned, 1, 10)
  752. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  753. Money: mustAmount(t, "200.00")}); err != nil {
  754. t.Fatalf("准备余额失败: %v", err)
  755. }
  756. res, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
  757. UserID: user, ActivityID: 401, CampusID: 10,
  758. Money: mustAmount(t, "50.00"), RequestID: "REQ-CANCEL-1",
  759. })
  760. if err != nil {
  761. t.Fatalf("CancelRechargeActivity 失败: %v", err)
  762. }
  763. if res.InstitutionID != 1 {
  764. t.Fatalf("应由校区反查机构 1,实得 %d", res.InstitutionID)
  765. }
  766. school := readSchool(t, db, user, 1, 10)
  767. if got := cents(t, school.Balance); got != 15000 {
  768. t.Fatalf("取消后校区余额应 15000 分,实得 %d", got)
  769. }
  770. if got := cents(t, school.TotalIncome); got != 15000 {
  771. t.Fatalf("取消后校区 total_income 应 15000 分,实得 %d", got)
  772. }
  773. if got := cents(t, readGlobal(t, db, user).Balance); got != 15000 {
  774. t.Fatalf("取消后全局余额应 15000 分(双表同步),实得 %d", got)
  775. }
  776. // 报名状态归 0(dump:SET status=0,不是 4)
  777. if s := signedStatus(t, db, user, 401); s != model.ActivitySignedCancelled {
  778. t.Fatalf("报名状态应为 0(取消),实得 %d", s)
  779. }
  780. logs := logsByType(t, db, user, model.ExpenseLogTypeCancelRechargeAct)
  781. if len(logs) != 1 || cents(t, logs[0].Money) != -5000 {
  782. t.Fatalf("209 流水应为 -5000 分一条,实得 %+v", logs)
  783. }
  784. // 幂等:同 requestId 再调 → repeated=true、不再扣钱
  785. again, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
  786. UserID: user, ActivityID: 401, CampusID: 10,
  787. Money: mustAmount(t, "50.00"), RequestID: "REQ-CANCEL-1",
  788. })
  789. if err != nil {
  790. t.Fatalf("重复取消应幂等成功: %v", err)
  791. }
  792. if !again.Repeated {
  793. t.Fatalf("重复取消应 repeated=true")
  794. }
  795. if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 15000 {
  796. t.Fatalf("重复取消不得再扣(应仍 15000 分),实得 %d", got)
  797. }
  798. }
  799. // TestCancelRechargeActivityCampusMissing 校区不存在 → 3104(dump 返回 success=0 + 文案)。
  800. func TestCancelRechargeActivityCampusMissing(t *testing.T) {
  801. db := newFundServiceDB(t)
  802. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  803. _, err := svc.CancelRechargeActivity(context.Background(), CancelRechargeActivityInput{
  804. UserID: fundUserID(61), ActivityID: 401, CampusID: 999,
  805. Money: mustAmount(t, "10.00"), RequestID: "REQ-CANCEL-3104",
  806. })
  807. requireAppCode(t, err, apperr.CodeCampusNotFound)
  808. }
  809. // ---------------------------------------------------------------- AC-05 落库越界负例
  810. // TestClearBalanceAmountOverflow AC-05 的落库负例:批量列更新(AddCampusColumns)在写库前
  811. // 拦下超出 decimal(12,2) 的金额(`Amount.Validate()` → ErrOverflow),账户零残留。
  812. func TestClearBalanceAmountOverflow(t *testing.T) {
  813. db := newFundServiceDB(t)
  814. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  815. ctx := context.Background()
  816. user := fundUserID(62)
  817. if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
  818. Balance: "0.00"}).Error; err != nil {
  819. t.Fatalf("seed 校区账户失败: %v", err)
  820. }
  821. err := db.Transaction(func(tx *gorm.DB) error {
  822. _, e := svc.repo.AddCampusColumns(ctx, tx, "OverflowTest", user, 1, 10,
  823. repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: money.MaxAmount + 1})
  824. return e
  825. })
  826. if !errors.Is(err, money.ErrOverflow) {
  827. t.Fatalf("越界金额应返回 money.ErrOverflow,实得: %v", err)
  828. }
  829. var appErr *infraerrors.AppError
  830. if !errors.As(err, &appErr) {
  831. t.Fatalf("越界金额应返回 *AppError,实得 %T", err)
  832. }
  833. if appErr.Code != infraerrors.ErrBadParam.Code && appErr.Code != infraerrors.ErrBadFormat.Code {
  834. t.Fatalf("越界金额应映射为 2xxx 参数错,实得 %d", appErr.Code)
  835. }
  836. if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 0 {
  837. t.Fatalf("越界金额不得落库(total_income 应仍 0),实得 %d", got)
  838. }
  839. }
  840. // ---------------------------------------------------------------- 顺序性工具
  841. // firstTouchIndex 返回第一条"命中 tableRe"的 SQL 下标(未命中 → -1;`-1` 便于比较)。
  842. //
  843. // 用正则而非 strings.Contains 的原因:`t_user_expense` 是 `t_user_expense_school` 的前缀,
  844. // 用 `\b` 词界才能把两张表区分开(`_` 属词字符,故 `t_user_expense\b` 不会命中 school)。
  845. func firstTouchIndex(stmts []string, tableRe string) int {
  846. re := regexp.MustCompile(tableRe)
  847. for i, sql := range stmts {
  848. if re.MatchString(sql) {
  849. return i
  850. }
  851. }
  852. return -1
  853. }