balance_request_repo_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. // BalanceRequestRepo 单测(AC-03):biz_no 幂等 / 审批 CAS / FOR UPDATE / 分页 / scoping。
  2. //
  3. // 测试栈:glebarez/sqlite 文件库(**只迁 t_user_balance_request 单表**——sqlite 索引名是库级全局,
  4. // `uk_biz_no` 与其它单号表重名,混迁会撞名,原因见 expense_repo_test.go 注释)。
  5. package repository
  6. import (
  7. "context"
  8. "errors"
  9. "path/filepath"
  10. "strings"
  11. "testing"
  12. "time"
  13. gormsqlite "github.com/glebarez/sqlite"
  14. "gorm.io/gorm"
  15. "yuansiwei/unified-backend/internal/academic/model"
  16. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  17. appscope "yuansiwei/unified-backend/internal/infra/scope"
  18. )
  19. // newBalanceRequestRepoDB 工单单表文件库。
  20. func newBalanceRequestRepoDB(t *testing.T) *gorm.DB {
  21. t.Helper()
  22. dsn := filepath.Join(t.TempDir(), "academic_balance_request_repo_test.db")
  23. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  24. if err != nil {
  25. t.Fatalf("gorm.Open(工单文件库) 失败: %v", err)
  26. }
  27. t.Cleanup(func() {
  28. if sqlDB, e := db.DB(); e == nil {
  29. _ = sqlDB.Close()
  30. }
  31. })
  32. if err := db.AutoMigrate(&model.UserBalanceRequest{}); err != nil {
  33. t.Fatalf("AutoMigrate(t_user_balance_request) 失败: %v", err)
  34. }
  35. return db
  36. }
  37. // TestBalanceRequestInsertAndDuplicate biz_no 幂等:撞 uk_biz_no 识别为重复单号(3003),不抛 5002。
  38. func TestBalanceRequestInsertAndDuplicate(t *testing.T) {
  39. db := newBalanceRequestRepoDB(t)
  40. repo := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  41. runner := NewTxRunner(db)
  42. ctx := context.Background()
  43. row := &model.UserBalanceRequest{
  44. BizNo: "BIZ-0001", Type: model.BalanceRequestTypeRecharge,
  45. UserID: "01HUSER000000000000000201", Money: "100.00",
  46. RequestUserID: "01HUSER000000000000000201",
  47. }
  48. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.Insert(ctx, tx, row) }); err != nil {
  49. t.Fatalf("创建工单失败: %v", err)
  50. }
  51. if row.InstitutionID != 1 || row.CampusID != 2 {
  52. t.Fatalf("作用域列未补齐:inst=%d campus=%d", row.InstitutionID, row.CampusID)
  53. }
  54. if row.Approved != model.BalanceRequestApprovedPending {
  55. t.Fatalf("新建工单 approved = %d,期望 0(未处理)", row.Approved)
  56. }
  57. dup := &model.UserBalanceRequest{
  58. BizNo: "BIZ-0001", Type: model.BalanceRequestTypeRecharge,
  59. UserID: "01HUSER000000000000000201", Money: "100.00",
  60. RequestUserID: "01HUSER000000000000000201",
  61. }
  62. err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.Insert(ctx, tx, dup) })
  63. if !errors.Is(err, ErrDuplicateBizNo) {
  64. t.Fatalf("重复单号错误 = %v,期望 ErrDuplicateBizNo", err)
  65. }
  66. var appErr *infraerrors.AppError
  67. if !errors.As(err, &appErr) || appErr.Code != infraerrors.CodeUnique {
  68. t.Fatalf("重复单号错误码不符:%v", err)
  69. }
  70. }
  71. // TestBalanceRequestUpdateApprovedCAS 审批 CAS:0→终态只成功一次,重复调用 affected=0。
  72. func TestBalanceRequestUpdateApprovedCAS(t *testing.T) {
  73. db := newBalanceRequestRepoDB(t)
  74. repo := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  75. runner := NewTxRunner(db)
  76. ctx := context.Background()
  77. row := &model.UserBalanceRequest{
  78. BizNo: "BIZ-0002", Type: model.BalanceRequestTypeRefund,
  79. UserID: "01HUSER000000000000000202", Money: "50.00",
  80. RequestUserID: "01HUSER000000000000000202",
  81. }
  82. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.Insert(ctx, tx, row) }); err != nil {
  83. t.Fatalf("创建工单失败: %v", err)
  84. }
  85. approver := "01HUSER000000000000000299"
  86. now := time.Date(2026, 2, 1, 10, 0, 0, 0, time.UTC)
  87. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  88. affected, e := repo.UpdateApproved(ctx, tx, row.ID, model.BalanceRequestApprovedPassed, &approver, &now)
  89. if e != nil {
  90. return e
  91. }
  92. if affected != 1 {
  93. t.Fatalf("首次审批 affected = %d,期望 1", affected)
  94. }
  95. return nil
  96. }); err != nil {
  97. t.Fatalf("首次审批失败: %v", err)
  98. }
  99. // 重复审批(第二次点"通过"):affected=0(幂等,不得二次入账)
  100. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  101. affected, e := repo.UpdateApproved(ctx, tx, row.ID, model.BalanceRequestApprovedPassed, &approver, &now)
  102. if e != nil {
  103. return e
  104. }
  105. if affected != 0 {
  106. t.Fatalf("重复审批 affected = %d,期望 0(CAS 防重复入账)", affected)
  107. }
  108. return nil
  109. }); err != nil {
  110. t.Fatalf("重复审批失败: %v", err)
  111. }
  112. var stored model.UserBalanceRequest
  113. if err := db.Where("id = ?", row.ID).Take(&stored).Error; err != nil {
  114. t.Fatalf("回读工单失败: %v", err)
  115. }
  116. if stored.Approved != model.BalanceRequestApprovedPassed {
  117. t.Fatalf("approved = %d,期望 1", stored.Approved)
  118. }
  119. if stored.ResponseUserID == nil || *stored.ResponseUserID != approver {
  120. t.Fatalf("审批人未落库:%v", stored.ResponseUserID)
  121. }
  122. // 非法目标状态 → ErrBadParam
  123. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  124. _, e := repo.UpdateApproved(ctx, tx, row.ID, model.BalanceRequestApprovedPending, nil, nil)
  125. if !errors.Is(e, infraerrors.ErrBadParam) {
  126. t.Fatalf("非法目标状态错误 = %v,期望 ErrBadParam", e)
  127. }
  128. return nil
  129. }); err != nil {
  130. t.Fatalf("非法目标状态用例失败: %v", err)
  131. }
  132. }
  133. // TestBalanceRequestForUpdateSQL MySQL 方言:FOR UPDATE + 机构作用域同时出现在审批加锁读里。
  134. func TestBalanceRequestForUpdateSQL(t *testing.T) {
  135. mdb, capture := newMySQLDryRunDB(t)
  136. repo := NewBalanceRequestRepo(mdb, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  137. ctx := context.Background()
  138. capture.Reset()
  139. _, _ = repo.GetForUpdate(ctx, mdb, 7) // DryRun 必然 ErrRecordNotFound,只断言 SQL 形状
  140. sql := capture.Last()
  141. if !strings.Contains(sql, "FOR UPDATE") {
  142. t.Fatalf("缺少 FOR UPDATE:%q", sql)
  143. }
  144. if !strings.Contains(sql, "institution_id") {
  145. t.Fatalf("缺少机构作用域:%q", sql)
  146. }
  147. }
  148. // TestBalanceRequestScoping 作用域:跨机构读不到(ErrNotFound)、超管可见。
  149. func TestBalanceRequestScoping(t *testing.T) {
  150. db := newBalanceRequestRepoDB(t)
  151. runner := NewTxRunner(db)
  152. ctx := context.Background()
  153. owner := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  154. row := &model.UserBalanceRequest{
  155. BizNo: "BIZ-0003", Type: model.BalanceRequestTypeBadDebt,
  156. UserID: "01HUSER000000000000000203", Money: "10.00",
  157. RequestUserID: "01HUSER000000000000000203",
  158. }
  159. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return owner.Insert(ctx, tx, row) }); err != nil {
  160. t.Fatalf("创建工单失败: %v", err)
  161. }
  162. other := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 2, CampusID: 5})
  163. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  164. _, e := other.GetForUpdate(ctx, tx, row.ID)
  165. if !errors.Is(e, infraerrors.ErrNotFound) {
  166. t.Fatalf("跨机构读取错误 = %v,期望 ErrNotFound", e)
  167. }
  168. return nil
  169. }); err != nil {
  170. t.Fatalf("跨机构用例失败: %v", err)
  171. }
  172. if list, err := other.ListByUser(ctx, row.UserID, PageQuery{}); err != nil || len(list) != 0 {
  173. t.Fatalf("跨机构列表应查不到:len=%d err=%v", len(list), err)
  174. }
  175. admin := NewBalanceRequestRepo(db, &appscope.Scope{SuperAdmin: true, InstitutionID: 9})
  176. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  177. _, e := admin.GetForUpdate(ctx, tx, row.ID)
  178. return e
  179. }); err != nil {
  180. t.Fatalf("超管读取失败: %v", err)
  181. }
  182. if list, err := admin.ListByUser(ctx, row.UserID, PageQuery{}); err != nil || len(list) != 1 {
  183. t.Fatalf("超管列表应命中 1 条:len=%d err=%v", len(list), err)
  184. }
  185. }
  186. // TestBalanceRequestListByUserPaging 分页:空结果非 nil、倒序、pageSize 钳制。
  187. func TestBalanceRequestListByUserPaging(t *testing.T) {
  188. db := newBalanceRequestRepoDB(t)
  189. repo := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  190. ctx := context.Background()
  191. const user = "01HUSER000000000000000204"
  192. empty, err := repo.ListByUser(ctx, user, PageQuery{})
  193. if err != nil {
  194. t.Fatalf("空结果查询失败: %v", err)
  195. }
  196. if empty == nil || len(empty) != 0 {
  197. t.Fatalf("空结果必须是非 nil 空切片:%v", empty)
  198. }
  199. for i := 0; i < 3; i++ {
  200. row := &model.UserBalanceRequest{
  201. BizNo: "BIZ-PAGE-" + string(rune('A'+i)), Type: model.BalanceRequestTypeRecharge,
  202. UserID: user, Money: "1.00", RequestUserID: user,
  203. InstitutionID: 1, CampusID: 2,
  204. }
  205. if err := db.Create(row).Error; err != nil {
  206. t.Fatalf("seed 工单失败: %v", err)
  207. }
  208. }
  209. page, err := repo.ListByUser(ctx, user, PageQuery{Page: 1, PageSize: 2})
  210. if err != nil {
  211. t.Fatalf("分页查询失败: %v", err)
  212. }
  213. if len(page) != 2 {
  214. t.Fatalf("第 1 页长度 = %d,期望 2", len(page))
  215. }
  216. if page[0].ID < page[1].ID {
  217. t.Fatalf("未按 id 倒序:%d / %d", page[0].ID, page[1].ID)
  218. }
  219. if got := (PageQuery{PageSize: 5000}).Limit(); got != MaxPageSize {
  220. t.Fatalf("pageSize 钳制 = %d,期望 %d", got, MaxPageSize)
  221. }
  222. }
  223. // ---------------------------------------------------------------- SP5 已用赠送额
  224. // newBalanceRequestGiftDB 工单 + 活动报名双表库(SP5 已用赠送额需要 JOIN 两表)。
  225. //
  226. // 与 newBalanceRequestRepoDB 分开建库的原因:那套用例刻意只迁单表(注释说明索引名冲突理由),
  227. // 本用例必须同时有 t_courses_activity_signed 才能验证 `biz_no = request_no` 的 JOIN 口径。
  228. func newBalanceRequestGiftDB(t *testing.T) *gorm.DB {
  229. t.Helper()
  230. dsn := filepath.Join(t.TempDir(), "academic_balance_request_gift_test.db")
  231. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  232. if err != nil {
  233. t.Fatalf("gorm.Open(工单+报名文件库) 失败: %v", err)
  234. }
  235. t.Cleanup(func() {
  236. if sqlDB, e := db.DB(); e == nil {
  237. _ = sqlDB.Close()
  238. }
  239. })
  240. if err := db.AutoMigrate(&model.UserBalanceRequest{}, &model.CoursesActivitySigned{}); err != nil {
  241. t.Fatalf("AutoMigrate(工单+报名) 失败: %v", err)
  242. }
  243. return db
  244. }
  245. // seedGiftPair 落「充值工单 + 活动报名」一对行。
  246. func seedGiftPair(t *testing.T, db *gorm.DB, bizNo, userID string, institutionID, campusID, activityID int64,
  247. approved int8, activityMoney, activityMoneyUsed string) {
  248. t.Helper()
  249. if err := db.Create(&model.UserBalanceRequest{
  250. BizNo: bizNo, Type: model.BalanceRequestTypeRecharge, UserID: userID,
  251. InstitutionID: institutionID, CampusID: campusID, Approved: approved,
  252. }).Error; err != nil {
  253. t.Fatalf("seed 工单(%s) 失败: %v", bizNo, err)
  254. }
  255. reqNo := bizNo
  256. if err := db.Create(&model.CoursesActivitySigned{
  257. ActivityID: activityID, UserID: userID, InstitutionID: institutionID, CampusID: campusID,
  258. ActivityMoney: activityMoney, ActivityMoneyUsed: activityMoneyUsed, RequestNo: &reqNo,
  259. }).Error; err != nil {
  260. t.Fatalf("seed 报名(%s) 失败: %v", bizNo, err)
  261. }
  262. }
  263. // TestBalanceRequestSumRechargeActivityUsedGift SP5 第二步的四个过滤条件 + 多行累加 + 参数校验;
  264. // 另含**旧 dump 列名直接照抄会恒不命中**的负例(`br.id = cas.request_no` 的反向证明)。
  265. func TestBalanceRequestSumRechargeActivityUsedGift(t *testing.T) {
  266. db := newBalanceRequestGiftDB(t)
  267. repo := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  268. ctx := context.Background()
  269. const user = "01HUSER000000000000000205"
  270. // ① 正常命中:20.00;② 第二行再 5.00 → 合计 25.00(验证 Go 侧定点累加多行)
  271. seedGiftPair(t, db, "BIZ-GIFT-1", user, 1, 2, 1, model.BalanceRequestApprovedPassed, "50.00", "20.00")
  272. seedGiftPair(t, db, "BIZ-GIFT-2", user, 1, 2, 2, model.BalanceRequestApprovedPassed, "30.00", "5.00")
  273. // ③ 其他校区:不参与
  274. seedGiftPair(t, db, "BIZ-GIFT-3", user, 1, 9, 3, model.BalanceRequestApprovedPassed, "50.00", "40.00")
  275. // ④ 其他机构:不参与
  276. seedGiftPair(t, db, "BIZ-GIFT-4", user, 2, 2, 4, model.BalanceRequestApprovedPassed, "50.00", "40.00")
  277. // ⑤ 未审批:不参与
  278. seedGiftPair(t, db, "BIZ-GIFT-5", user, 1, 2, 5, model.BalanceRequestApprovedPending, "50.00", "40.00")
  279. // ⑥ 赠送已用尽(used == activity_money):不参与
  280. seedGiftPair(t, db, "BIZ-GIFT-6", user, 1, 2, 6, model.BalanceRequestApprovedPassed, "50.00", "50.00")
  281. // ⑦ 赠送额为负(抵扣,不进赠送池):不参与
  282. seedGiftPair(t, db, "BIZ-GIFT-7", user, 1, 2, 7, model.BalanceRequestApprovedPassed, "-10.00", "3.00")
  283. // ⑧ 非充值工单(type != 100):不参与
  284. if err := db.Create(&model.UserBalanceRequest{
  285. BizNo: "BIZ-GIFT-8", Type: model.BalanceRequestTypeRefund, UserID: user,
  286. InstitutionID: 1, CampusID: 2, Approved: model.BalanceRequestApprovedPassed,
  287. }).Error; err != nil {
  288. t.Fatalf("seed 退费工单失败: %v", err)
  289. }
  290. reqNo8 := "BIZ-GIFT-8"
  291. if err := db.Create(&model.CoursesActivitySigned{
  292. ActivityID: 8, UserID: user, InstitutionID: 1, CampusID: 2,
  293. ActivityMoney: "50.00", ActivityMoneyUsed: "45.00", RequestNo: &reqNo8,
  294. }).Error; err != nil {
  295. t.Fatalf("seed 报名(BIZ-GIFT-8) 失败: %v", err)
  296. }
  297. // ⑨ **列名映射负例**:报名单号只等于工单的自增 id("1"),不等于 biz_no → 不得命中。
  298. // 照抄 dump 的 `t1.id = t2.requestid` 时会命中这一行(可退额被少扣),正确映射下不命中。
  299. reqNo9 := "1"
  300. if err := db.Create(&model.CoursesActivitySigned{
  301. ActivityID: 9, UserID: user, InstitutionID: 1, CampusID: 2,
  302. ActivityMoney: "999.00", ActivityMoneyUsed: "999.00", RequestNo: &reqNo9,
  303. }).Error; err != nil {
  304. t.Fatalf("seed 报名(按 id 伪匹配) 失败: %v", err)
  305. }
  306. got, err := repo.SumRechargeActivityUsedGift(ctx, user, 1, 2)
  307. if err != nil {
  308. t.Fatalf("SumRechargeActivityUsedGift 失败: %v", err)
  309. }
  310. if got.Cents() != 2500 {
  311. t.Fatalf("已用赠送额应 2500 分(20.00+5.00),实得 %d", got.Cents())
  312. }
  313. // ⑩ 无匹配行 → 0(不是 ErrNotFound)
  314. if v, err := repo.SumRechargeActivityUsedGift(ctx, "01HUSER000000000000000206", 1, 2); err != nil || v != 0 {
  315. t.Fatalf("无匹配行应返回 0 且无错,实得 (%d, %v)", v, err)
  316. }
  317. // ⑪ 参数校验:user_id 空 / institution<=0 / campus<=0 → ErrBadParam
  318. if _, err := repo.SumRechargeActivityUsedGift(ctx, " ", 1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
  319. t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
  320. }
  321. if _, err := repo.SumRechargeActivityUsedGift(ctx, user, 0, 2); !errors.Is(err, infraerrors.ErrBadParam) {
  322. t.Fatalf("institution_id=0 应 ErrBadParam,实得 %v", err)
  323. }
  324. if _, err := repo.SumRechargeActivityUsedGift(ctx, user, 1, 0); !errors.Is(err, infraerrors.ErrBadParam) {
  325. t.Fatalf("campus_id=0 应 ErrBadParam,实得 %v", err)
  326. }
  327. }
  328. // TestBalanceRequestSumGiftSQL MySQL 方言:JOIN 键必须是 `biz_no = request_no`(不是 id),
  329. // 且作用域过滤必须是**别名限定**列(裸列名在两表同名列下会二义,MySQL 直接报错)。
  330. func TestBalanceRequestSumGiftSQL(t *testing.T) {
  331. mdb, capture := newMySQLDryRunDB(t)
  332. repo := NewBalanceRequestRepo(mdb, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  333. ctx := context.Background()
  334. capture.Reset()
  335. _, _ = repo.SumRechargeActivityUsedGift(ctx, "01HUSER000000000000000207", 1, 2)
  336. sql := capture.Last()
  337. for _, want := range []string{
  338. "t_user_balance_request AS br",
  339. "t_courses_activity_signed AS cas",
  340. "ON br.biz_no = cas.request_no",
  341. "br.institution_id",
  342. "br.campus_id",
  343. "br.approved",
  344. "cas.activity_money > 0",
  345. "cas.activity_money_used < cas.activity_money",
  346. } {
  347. if !strings.Contains(sql, want) {
  348. t.Fatalf("SQL 缺少片段 %q:%q", want, sql)
  349. }
  350. }
  351. if strings.Contains(sql, "br.id = cas.request_no") {
  352. t.Fatalf("JOIN 不得用自增 id 对字符串单号(会恒不命中):%q", sql)
  353. }
  354. }