expense_repo_test.go 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  1. // ExpenseRepo 单测(AC-03):UPSERT 双表 / 原子表达式 / FOR UPDATE / 幂等键 / 分页 / scoping。
  2. //
  3. // 测试栈(与 AC-06 活动仓储同一套手法):
  4. // - **行为用例**:glebarez/sqlite 临时文件库(纯 Go,CGO_ENABLED=0 可跑);
  5. // - **SQL 形状用例**:给 GORM 挂"抓 SQL"的 logger(`sqlCapture`)——
  6. // DryRun/真实库都会经 `Logger.Trace` 暴露 Explain 后的完整 SQL,因此断言的是**仓储真实生成的语句**
  7. // (不是测试里手抄一份),能真正校验「原子表达式 / 全局→校区顺序 / 不含 SELECT / FOR UPDATE」;
  8. // - **MySQL 方言用例**:`mysql.New(...SkipInitializeWithVersion)` + `DisableAutomaticPing` 的 DryRun 句柄,
  9. // 不连库即可拿到 MySQL 方言 SQL(sqlite 不渲染行锁 clause,只有 MySQL 方言能证明 FOR UPDATE 真进了 SQL)。
  10. package repository
  11. import (
  12. "context"
  13. "errors"
  14. "path/filepath"
  15. "strings"
  16. "sync"
  17. "testing"
  18. "time"
  19. gormsqlite "github.com/glebarez/sqlite"
  20. "gorm.io/driver/mysql"
  21. "gorm.io/gorm"
  22. "gorm.io/gorm/logger"
  23. "yuansiwei/unified-backend/internal/academic/model"
  24. "yuansiwei/unified-backend/internal/academic/money"
  25. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  26. appscope "yuansiwei/unified-backend/internal/infra/scope"
  27. )
  28. // sqlCapture 抓取 GORM 执行的 SQL(线程安全;`stmts` 为最近一次 Reset 之后的全部语句)。
  29. //
  30. // 为什么这样做:仓储方法内部不暴露 *gorm.DB,测试又要断言"真实生成的 SQL 形状",
  31. // 唯一无侵入的通道就是 GORM 的 logger。
  32. type sqlCapture struct {
  33. mu sync.Mutex
  34. stmts []string
  35. }
  36. // LogMode 实现 logger.Interface(测试恒为 Info 级别,不做分级)。
  37. func (c *sqlCapture) LogMode(logger.LogLevel) logger.Interface { return c }
  38. // Info 实现 logger.Interface(测试不关心日志正文)。
  39. func (c *sqlCapture) Info(context.Context, string, ...interface{}) {}
  40. // Warn 实现 logger.Interface。
  41. func (c *sqlCapture) Warn(context.Context, string, ...interface{}) {}
  42. // Error 实现 logger.Interface。
  43. func (c *sqlCapture) Error(context.Context, string, ...interface{}) {}
  44. // Trace 实现 logger.Interface:记录 Explain 后的 SQL 文本。
  45. func (c *sqlCapture) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
  46. sql, _ := fc()
  47. if strings.TrimSpace(sql) == "" {
  48. return
  49. }
  50. c.mu.Lock()
  51. defer c.mu.Unlock()
  52. c.stmts = append(c.stmts, sql)
  53. }
  54. // Reset 清空已捕获语句(每个断言段开始前调用)。
  55. func (c *sqlCapture) Reset() {
  56. c.mu.Lock()
  57. defer c.mu.Unlock()
  58. c.stmts = nil
  59. }
  60. // All 返回已捕获语句的快照。
  61. func (c *sqlCapture) All() []string {
  62. c.mu.Lock()
  63. defer c.mu.Unlock()
  64. out := make([]string, len(c.stmts))
  65. copy(out, c.stmts)
  66. return out
  67. }
  68. // Last 返回最后一条语句(无语句时返回空串)。
  69. func (c *sqlCapture) Last() string {
  70. c.mu.Lock()
  71. defer c.mu.Unlock()
  72. if len(c.stmts) == 0 {
  73. return ""
  74. }
  75. return c.stmts[len(c.stmts)-1]
  76. }
  77. // compactSQL 去掉空白,便于跨方言断言 SQL 片段(sqlite/MySQL 的 Explain 空格风格不同)。
  78. func compactSQL(sql string) string {
  79. return strings.Join(strings.Fields(sql), "")
  80. }
  81. // newMySQLDryRunDB MySQL 方言 DryRun 句柄 + SQL 抓取器(**不建立连接**,仅渲染方言级 SQL)。
  82. func newMySQLDryRunDB(t *testing.T) (*gorm.DB, *sqlCapture) {
  83. t.Helper()
  84. capture := &sqlCapture{}
  85. db, err := gorm.Open(mysql.New(mysql.Config{
  86. DSN: "academic:academic@tcp(127.0.0.1:3306)/academic?charset=utf8mb4&parseTime=True",
  87. SkipInitializeWithVersion: true,
  88. }), &gorm.Config{DryRun: true, DisableAutomaticPing: true, Logger: capture})
  89. if err != nil {
  90. t.Fatalf("gorm.Open(MySQL DryRun) 失败: %v", err)
  91. }
  92. return db, capture
  93. }
  94. // newExpenseRepoDB 余额账户域真实库(文件库;账户/校区账户/流水 **3 表** AutoMigrate)。
  95. //
  96. // ⚠ 为什么只迁 3 表(探索结论,登记为跨表索引重名技术债):
  97. // SQLite 的索引名是**数据库级全局唯一**(MySQL 是表级),而 AC-01 的 27 表里
  98. // `uk_biz_no`(7 张单号表)与 `idx_inst_campus`(6 张作用域表)跨表重名 ——
  99. // 因此**不能在同一个 sqlite 库**里 AutoMigrate 这些表(会报 `index ... already exists`)。
  100. // 本测试的全部用例只需余额 3 表(其余表各自在对应测试文件里单独建库),故按"无冲突子集"建库。
  101. // 真库(MySQL)侧不受影响,由 `TestDevDBAutoMigrate` 全量 27 表覆盖。
  102. func newExpenseRepoDB(t *testing.T) *gorm.DB {
  103. db, _ := newExpenseRepoDBWithCapture(t)
  104. return db
  105. }
  106. // newExpenseRepoDBWithCapture 同上,额外返回 SQL 抓取器(AutoMigrate 期间产生的语句会先被 Reset)。
  107. func newExpenseRepoDBWithCapture(t *testing.T) (*gorm.DB, *sqlCapture) {
  108. t.Helper()
  109. dsn := filepath.Join(t.TempDir(), "academic_expense_repo_test.db")
  110. capture := &sqlCapture{}
  111. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{Logger: capture})
  112. if err != nil {
  113. t.Fatalf("gorm.Open(余额文件库) 失败: %v", err)
  114. }
  115. t.Cleanup(func() {
  116. if sqlDB, e := db.DB(); e == nil {
  117. _ = sqlDB.Close()
  118. }
  119. })
  120. if err := db.AutoMigrate(
  121. &model.UserExpense{},
  122. &model.UserExpenseSchool{},
  123. &model.UserExpenseLog{},
  124. ); err != nil {
  125. t.Fatalf("AutoMigrate(余额 3 表) 失败: %v", err)
  126. }
  127. capture.Reset()
  128. return db, capture
  129. }
  130. // seedExpenseAccounts 建全局账户 + 两个校区账户(inst=1:campus 2 / campus 3)。
  131. func seedExpenseAccounts(t *testing.T, db *gorm.DB, userID string) {
  132. t.Helper()
  133. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  134. runner := NewTxRunner(db)
  135. ctx := context.Background()
  136. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  137. if e := repo.UpsertGlobal(ctx, tx, &model.UserExpense{UserID: userID}); e != nil {
  138. return e
  139. }
  140. if e := repo.UpsertCampus(ctx, tx, &model.UserExpenseSchool{UserID: userID, InstitutionID: 1, CampusID: 2}); e != nil {
  141. return e
  142. }
  143. return repo.UpsertCampus(ctx, tx, &model.UserExpenseSchool{UserID: userID, InstitutionID: 1, CampusID: 3})
  144. }); err != nil {
  145. t.Fatalf("建账户失败: %v", err)
  146. }
  147. }
  148. // TestExpenseUpsertNoClobber UPSERT 双表:重复建户不覆盖已有余额/统计列(只建户,不改钱)。
  149. func TestExpenseUpsertNoClobber(t *testing.T) {
  150. db := newExpenseRepoDB(t)
  151. const user = "01HUSER000000000000000101"
  152. seedExpenseAccounts(t, db, user)
  153. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  154. runner := NewTxRunner(db)
  155. ctx := context.Background()
  156. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  157. _, e := repo.AddBalance(ctx, tx, user, 2, mustAmount(t, "10.50"))
  158. return e
  159. }); err != nil {
  160. t.Fatalf("AddBalance 失败: %v", err)
  161. }
  162. // 再次 UPSERT(模拟并发重复建户 / 重放):余额必须保持不变
  163. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  164. if e := repo.UpsertGlobal(ctx, tx, &model.UserExpense{UserID: user, Balance: "999.00"}); e != nil {
  165. return e
  166. }
  167. return repo.UpsertCampus(ctx, tx, &model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2, Balance: "999.00"})
  168. }); err != nil {
  169. t.Fatalf("重复 UPSERT 失败: %v", err)
  170. }
  171. assertBalance(t, repo, ctx, user, 0, "10.50")
  172. assertBalance(t, repo, ctx, user, 2, "10.50")
  173. }
  174. // TestExpenseUpsertSQLShape UPSERT 语句形状:带冲突目标 + DO NOTHING(不覆盖余额列)。
  175. func TestExpenseUpsertSQLShape(t *testing.T) {
  176. db, capture := newExpenseRepoDBWithCapture(t)
  177. const user = "01HUSER000000000000000111"
  178. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  179. runner := NewTxRunner(db)
  180. ctx := context.Background()
  181. capture.Reset()
  182. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  183. if e := repo.UpsertGlobal(ctx, tx, &model.UserExpense{UserID: user}); e != nil {
  184. return e
  185. }
  186. return repo.UpsertCampus(ctx, tx, &model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2})
  187. }); err != nil {
  188. t.Fatalf("UPSERT 失败: %v", err)
  189. }
  190. var globalSQL, campusSQL string
  191. for _, stmt := range capture.All() {
  192. switch {
  193. case strings.Contains(stmt, "t_user_expense_school"):
  194. campusSQL = compactSQL(stmt)
  195. case strings.Contains(stmt, "t_user_expense`"):
  196. globalSQL = compactSQL(stmt)
  197. }
  198. }
  199. if !strings.Contains(globalSQL, "ONCONFLICT") || !strings.Contains(globalSQL, "DONOTHING") {
  200. t.Fatalf("全局 UPSERT 缺少 ON CONFLICT DO NOTHING:%s", globalSQL)
  201. }
  202. if !strings.Contains(globalSQL, "user_id") {
  203. t.Fatalf("全局 UPSERT 冲突目标缺少 user_id:%s", globalSQL)
  204. }
  205. if !strings.Contains(campusSQL, "ONCONFLICT") ||
  206. !strings.Contains(campusSQL, "user_id") ||
  207. !strings.Contains(campusSQL, "institution_id") ||
  208. !strings.Contains(campusSQL, "campus_id") {
  209. t.Fatalf("校区 UPSERT 冲突键不完整(应含 user_id+institution_id+campus_id):%s", campusSQL)
  210. }
  211. // 关键红线:冲突分支不得 SET balance(否则并发充值丢更新)
  212. if strings.Contains(campusSQL, "DOUPDATE") || strings.Contains(globalSQL, "DOUPDATE") {
  213. t.Fatalf("UPSERT 不得覆盖已有余额列:global=%s campus=%s", globalSQL, campusSQL)
  214. }
  215. }
  216. // TestExpenseAddBalanceSQLShape 原子表达式形状 + 双写顺序(全局 → 校区)+ 无读-改-写。
  217. func TestExpenseAddBalanceSQLShape(t *testing.T) {
  218. db, capture := newExpenseRepoDBWithCapture(t)
  219. const user = "01HUSER000000000000000112"
  220. seedExpenseAccounts(t, db, user)
  221. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  222. runner := NewTxRunner(db)
  223. ctx := context.Background()
  224. capture.Reset()
  225. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  226. _, e := repo.AddBalance(ctx, tx, user, 2, mustAmount(t, "10.50"))
  227. return e
  228. }); err != nil {
  229. t.Fatalf("AddBalance 失败: %v", err)
  230. }
  231. stmts := capture.All()
  232. var updates []string
  233. for _, s := range stmts {
  234. if strings.Contains(strings.ToUpper(s), "SELECT") {
  235. t.Fatalf("资金增减路径出现 SELECT(读-改-写反例):%s", s)
  236. }
  237. if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(s)), "UPDATE") {
  238. updates = append(updates, compactSQL(s))
  239. }
  240. }
  241. if len(updates) != 2 {
  242. t.Fatalf("期望 2 条 UPDATE(全局+校区),实得 %d:%v", len(updates), stmts)
  243. }
  244. if !strings.Contains(updates[0], "`balance`=balance+") || !strings.Contains(updates[0], "t_user_expense`") {
  245. t.Fatalf("第 1 条 UPDATE 应作用于全局账户且为原子表达式:%s", updates[0])
  246. }
  247. if !strings.Contains(updates[1], "`balance`=balance+") || !strings.Contains(updates[1], "t_user_expense_school") {
  248. t.Fatalf("第 2 条 UPDATE 应作用于校区账户:%s", updates[1])
  249. }
  250. // 固定顺序「全局 → 校区」是防死锁红线
  251. if strings.Contains(updates[0], "school") {
  252. t.Fatalf("双写顺序错误:必须先全局后校区,实得第 1 条 = %s", updates[0])
  253. }
  254. }
  255. // TestExpenseAddColumnFamily 四个统计列各自原子累加,且都不动 balance(红线)。
  256. func TestExpenseAddColumnFamily(t *testing.T) {
  257. db, capture := newExpenseRepoDBWithCapture(t)
  258. const user = "01HUSER000000000000000113"
  259. seedExpenseAccounts(t, db, user)
  260. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  261. runner := NewTxRunner(db)
  262. ctx := context.Background()
  263. cases := []struct {
  264. name string
  265. column string
  266. call func(tx *gorm.DB) error
  267. }{
  268. {"AddSpend", expenseColSpend, func(tx *gorm.DB) error {
  269. _, e := repo.AddSpend(ctx, tx, user, 0, mustAmount(t, "8.00"))
  270. return e
  271. }},
  272. {"AddSpecialExpense", expenseColSpecialExpenses, func(tx *gorm.DB) error {
  273. _, e := repo.AddSpecialExpense(ctx, tx, user, 0, mustAmount(t, "2.50"))
  274. return e
  275. }},
  276. {"AddTotalIncome", expenseColTotalIncome, func(tx *gorm.DB) error {
  277. _, e := repo.AddTotalIncome(ctx, tx, user, 0, mustAmount(t, "100.00"))
  278. return e
  279. }},
  280. {"AddTotalExpenditure", expenseColTotalExpenditure, func(tx *gorm.DB) error {
  281. _, e := repo.AddTotalExpenditure(ctx, tx, user, 0, mustAmount(t, "30.00"))
  282. return e
  283. }},
  284. }
  285. for _, tc := range cases {
  286. capture.Reset()
  287. if err := runner.WithinTx(ctx, tc.call); err != nil {
  288. t.Fatalf("[%s] 失败: %v", tc.name, err)
  289. }
  290. sql := compactSQL(capture.Last())
  291. if !strings.Contains(sql, "`"+tc.column+"`="+tc.column+"+") {
  292. t.Fatalf("[%s] 缺少原子表达式 `%s = %s + ?`:%s", tc.name, tc.column, tc.column, sql)
  293. }
  294. if strings.Contains(sql, "`balance`") {
  295. t.Fatalf("[%s] 统计列累加不得动 balance:%s", tc.name, sql)
  296. }
  297. }
  298. var global model.UserExpense
  299. if err := db.Where("user_id = ?", user).Take(&global).Error; err != nil {
  300. t.Fatalf("读取全局账户失败: %v", err)
  301. }
  302. // 注:sqlite 的 decimal 走 NUMERIC 亲和性,回读可能是 "8"(省略尾零),故一律按分值比较
  303. wants := map[string]string{
  304. expenseColSpend: "8.00",
  305. expenseColSpecialExpenses: "2.50",
  306. expenseColTotalIncome: "100.00",
  307. expenseColTotalExpenditure: "30.00",
  308. }
  309. got := map[string]string{
  310. expenseColSpend: global.Spend,
  311. expenseColSpecialExpenses: global.SpecialExpenses,
  312. expenseColTotalIncome: global.TotalIncome,
  313. expenseColTotalExpenditure: global.TotalExpenditure,
  314. }
  315. for column, want := range wants {
  316. if parseAmount(t, got[column]) != parseAmount(t, want) {
  317. t.Fatalf("列 %s = %s,期望 %s", column, got[column], want)
  318. }
  319. }
  320. if parseAmount(t, global.Balance) != 0 {
  321. t.Fatalf("balance = %s,统计列累加不得动余额(红线)", global.Balance)
  322. }
  323. }
  324. // TestExpenseAddBalanceBehavior 双写 / 负数 / 零增量 / 未建户报错。
  325. func TestExpenseAddBalanceBehavior(t *testing.T) {
  326. db := newExpenseRepoDB(t)
  327. const user = "01HUSER000000000000000102"
  328. seedExpenseAccounts(t, db, user)
  329. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  330. runner := NewTxRunner(db)
  331. ctx := context.Background()
  332. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  333. affected, e := repo.AddBalance(ctx, tx, user, 2, mustAmount(t, "100.00"))
  334. if e != nil {
  335. return e
  336. }
  337. if affected != 1 {
  338. t.Fatalf("AddBalance 全局受影响行数 = %d,期望 1", affected)
  339. }
  340. return nil
  341. }); err != nil {
  342. t.Fatalf("AddBalance 失败: %v", err)
  343. }
  344. assertBalance(t, repo, ctx, user, 0, "100.00")
  345. assertBalance(t, repo, ctx, user, 2, "100.00")
  346. // 消费 -30.25(全局 + 校区同步)
  347. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  348. _, e := repo.AddBalance(ctx, tx, user, 2, mustAmount(t, "-30.25"))
  349. return e
  350. }); err != nil {
  351. t.Fatalf("AddBalance(负数) 失败: %v", err)
  352. }
  353. assertBalance(t, repo, ctx, user, 0, "69.75")
  354. assertBalance(t, repo, ctx, user, 2, "69.75")
  355. // 零增量:空操作(0 行,不报错)
  356. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  357. affected, e := repo.AddBalance(ctx, tx, user, 2, money.Amount(0))
  358. if e != nil {
  359. return e
  360. }
  361. if affected != 0 {
  362. t.Fatalf("零增量受影响行数 = %d,期望 0", affected)
  363. }
  364. return nil
  365. }); err != nil {
  366. t.Fatalf("零增量失败: %v", err)
  367. }
  368. // 未建户 → ErrAccountNotFound(禁止静默跳过)
  369. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  370. _, e := repo.AddBalance(ctx, tx, "01HUSER000000000000000999", 2, mustAmount(t, "1.00"))
  371. if !errors.Is(e, ErrAccountNotFound) {
  372. t.Fatalf("未建户错误 = %v,期望 ErrAccountNotFound", e)
  373. }
  374. return nil
  375. }); err != nil {
  376. t.Fatalf("未建户用例失败: %v", err)
  377. }
  378. }
  379. // TestExpenseForUpdateSQL MySQL 方言下必须渲染 `FOR UPDATE`(行锁防超退),校区查询还须叠机构作用域。
  380. func TestExpenseForUpdateSQL(t *testing.T) {
  381. mdb, capture := newMySQLDryRunDB(t)
  382. repo := NewExpenseRepo(mdb, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  383. ctx := context.Background()
  384. capture.Reset()
  385. // DryRun 下 Take 必然返回 ErrRecordNotFound;本条用例只断言 SQL 形状,忽略返回值
  386. _, _ = repo.GetForUpdate(ctx, mdb, "01HUSER000000000000000114")
  387. if sql := capture.Last(); !strings.Contains(sql, "FOR UPDATE") {
  388. t.Fatalf("GetForUpdate 全局账户 SQL 缺少 FOR UPDATE:%q", sql)
  389. }
  390. capture.Reset()
  391. _, _ = repo.GetCampusForUpdate(ctx, mdb, "01HUSER000000000000000114", 2)
  392. sql := capture.Last()
  393. if !strings.Contains(sql, "FOR UPDATE") {
  394. t.Fatalf("GetCampusForUpdate SQL 缺少 FOR UPDATE:%q", sql)
  395. }
  396. if !strings.Contains(sql, "institution_id") {
  397. t.Fatalf("校区账户查询未叠加机构作用域:%q", sql)
  398. }
  399. }
  400. // TestExpenseForUpdateBehavior 行为:命中返回行;跨机构/不存在 → ErrNotFound(不泄露存在性)。
  401. func TestExpenseForUpdateBehavior(t *testing.T) {
  402. db := newExpenseRepoDB(t)
  403. const user = "01HUSER000000000000000105"
  404. seedExpenseAccounts(t, db, user)
  405. ctx := context.Background()
  406. runner := NewTxRunner(db)
  407. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  408. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  409. row, e := repo.GetForUpdate(ctx, tx, user)
  410. if e != nil {
  411. return e
  412. }
  413. if row.UserID != user {
  414. t.Fatalf("GetForUpdate 返回 user_id = %s", row.UserID)
  415. }
  416. campusRow, e := repo.GetCampusForUpdate(ctx, tx, user, 2)
  417. if e != nil {
  418. return e
  419. }
  420. if campusRow.CampusID != 2 {
  421. t.Fatalf("GetCampusForUpdate 返回 campus_id = %d", campusRow.CampusID)
  422. }
  423. return nil
  424. }); err != nil {
  425. t.Fatalf("GetForUpdate 失败: %v", err)
  426. }
  427. // 跨机构(scope=2)读 inst=1 的校区账户 → ErrNotFound
  428. other := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 2, CampusID: 2})
  429. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  430. _, e := other.GetCampusForUpdate(ctx, tx, user, 2)
  431. if !errors.Is(e, infraerrors.ErrNotFound) {
  432. t.Fatalf("跨机构读取错误 = %v,期望 ErrNotFound", e)
  433. }
  434. return nil
  435. }); err != nil {
  436. t.Fatalf("跨机构用例失败: %v", err)
  437. }
  438. // 不存在 → ErrNotFound
  439. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  440. _, e := repo.GetForUpdate(ctx, tx, "01HUSER000000000000000998")
  441. if !errors.Is(e, infraerrors.ErrNotFound) {
  442. t.Fatalf("不存在账户错误 = %v,期望 ErrNotFound", e)
  443. }
  444. return nil
  445. }); err != nil {
  446. t.Fatalf("不存在用例失败: %v", err)
  447. }
  448. // 超管跨机构可见(作用域放行)
  449. admin := NewExpenseRepo(db, &appscope.Scope{SuperAdmin: true, InstitutionID: 9})
  450. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  451. _, e := admin.GetCampusForUpdate(ctx, tx, user, 2)
  452. return e
  453. }); err != nil {
  454. t.Fatalf("超管读取失败: %v", err)
  455. }
  456. }
  457. // TestExpenseCampusScopingWrite 普通角色不得给别的机构账户记账(Scoped 命中 0 行 → 事务回滚)。
  458. func TestExpenseCampusScopingWrite(t *testing.T) {
  459. db := newExpenseRepoDB(t)
  460. const user = "01HUSER000000000000000106"
  461. seedExpenseAccounts(t, db, user)
  462. ctx := context.Background()
  463. runner := NewTxRunner(db)
  464. other := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 2, CampusID: 2})
  465. err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  466. _, e := other.AddBalance(ctx, tx, user, 2, mustAmount(t, "1.00"))
  467. if !errors.Is(e, ErrAccountNotFound) {
  468. t.Fatalf("跨机构记账错误 = %v,期望 ErrAccountNotFound(scoping 拦截)", e)
  469. }
  470. return e
  471. })
  472. if err == nil {
  473. t.Fatal("跨机构记账本应失败(事务应回滚)")
  474. }
  475. // 事务回滚后余额未变(全局更新被一并撤销)
  476. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  477. assertBalance(t, repo, ctx, user, 0, "0.00")
  478. assertBalance(t, repo, ctx, user, 2, "0.00")
  479. }
  480. // TestExpenseInsertLogIdempotent 幂等键 uk_request_type:同一 request_id+type 第二次写入识别为重复。
  481. func TestExpenseInsertLogIdempotent(t *testing.T) {
  482. db := newExpenseRepoDB(t)
  483. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  484. runner := NewTxRunner(db)
  485. ctx := context.Background()
  486. reqID := "REQ-0001"
  487. const user = "01HUSER000000000000000107"
  488. write := func(log *model.UserExpenseLog) error {
  489. return runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.InsertLog(ctx, tx, log) })
  490. }
  491. if err := write(&model.UserExpenseLog{UserID: user, Type: model.ExpenseLogTypeRecharge,
  492. RequestID: &reqID, Money: "100.00"}); err != nil {
  493. t.Fatalf("首次写流水失败: %v", err)
  494. }
  495. err := write(&model.UserExpenseLog{UserID: user, Type: model.ExpenseLogTypeRecharge,
  496. RequestID: &reqID, Money: "100.00"})
  497. if !errors.Is(err, ErrDuplicateLog) {
  498. t.Fatalf("重复请求错误 = %v,期望 ErrDuplicateLog(幂等命中,非 5002)", err)
  499. }
  500. var appErr *infraerrors.AppError
  501. if !errors.As(err, &appErr) || appErr.Code != infraerrors.CodeUnique {
  502. t.Fatalf("重复请求错误码不符:%v", err)
  503. }
  504. // 不同 type 不算重复(type 参与幂等键)
  505. if err := write(&model.UserExpenseLog{UserID: user, Type: model.ExpenseLogTypeRefund,
  506. RequestID: &reqID, Money: "-100.00"}); err != nil {
  507. t.Fatalf("不同 type 应视为新请求:%v", err)
  508. }
  509. }
  510. // TestExpenseListLogsPagingAndFilters 分页(空列表非 nil、钳制 200、倒序)+ campusIDs 明确拒绝。
  511. func TestExpenseListLogsPagingAndFilters(t *testing.T) {
  512. db := newExpenseRepoDB(t)
  513. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  514. ctx := context.Background()
  515. empty, err := repo.ListLogs(ctx, "01HUSER000000000000000108", nil, time.Time{}, time.Time{}, PageQuery{})
  516. if err != nil {
  517. t.Fatalf("空结果查询失败: %v", err)
  518. }
  519. if empty == nil {
  520. t.Fatal("空结果必须是非 nil 切片")
  521. }
  522. if len(empty) != 0 {
  523. t.Fatalf("空结果长度 = %d", len(empty))
  524. }
  525. const user = "01HUSER000000000000000109"
  526. base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
  527. for i := 0; i < 5; i++ {
  528. reqID := "REQ-PAGE-" + string(rune('A'+i))
  529. row := &model.UserExpenseLog{
  530. UserID: user, Type: model.ExpenseLogTypeRecharge, RequestID: &reqID,
  531. Money: "1.00", CreateTime: base.Add(time.Duration(i) * time.Minute),
  532. }
  533. if err := db.Create(row).Error; err != nil {
  534. t.Fatalf("seed 流水失败: %v", err)
  535. }
  536. }
  537. page1, err := repo.ListLogs(ctx, user, nil, time.Time{}, time.Time{}, PageQuery{Page: 1, PageSize: 2})
  538. if err != nil {
  539. t.Fatalf("分页查询失败: %v", err)
  540. }
  541. if len(page1) != 2 {
  542. t.Fatalf("第 1 页长度 = %d,期望 2", len(page1))
  543. }
  544. if !page1[0].CreateTime.After(page1[1].CreateTime) {
  545. t.Fatalf("未按 create_time 倒序:%v / %v", page1[0].CreateTime, page1[1].CreateTime)
  546. }
  547. page3, err := repo.ListLogs(ctx, user, nil, time.Time{}, time.Time{}, PageQuery{Page: 3, PageSize: 2})
  548. if err != nil {
  549. t.Fatalf("第 3 页查询失败: %v", err)
  550. }
  551. if len(page3) != 1 {
  552. t.Fatalf("第 3 页长度 = %d,期望 1", len(page3))
  553. }
  554. // pageSize 钳制:999 → 200
  555. if got := (PageQuery{PageSize: 999}).Limit(); got != MaxPageSize {
  556. t.Fatalf("pageSize 钳制 = %d,期望 %d", got, MaxPageSize)
  557. }
  558. if got := (PageQuery{}).Limit(); got != DefaultPageSize {
  559. t.Fatalf("默认 pageSize = %d,期望 %d", got, DefaultPageSize)
  560. }
  561. if got := (PageQuery{Page: 3, PageSize: 10}).Offset(); got != 20 {
  562. t.Fatalf("offset = %d,期望 20", got)
  563. }
  564. // 时间窗过滤(闭区间)
  565. inWindow, err := repo.ListLogs(ctx, user, nil, base.Add(time.Minute), base.Add(2*time.Minute), PageQuery{PageSize: 10})
  566. if err != nil {
  567. t.Fatalf("时间窗查询失败: %v", err)
  568. }
  569. if len(inWindow) != 2 {
  570. t.Fatalf("时间窗命中 %d 条,期望 2", len(inWindow))
  571. }
  572. if _, err := repo.ListLogs(ctx, user, []int64{2}, time.Time{}, time.Time{}, PageQuery{}); !errors.Is(err, infraerrors.ErrBadParam) {
  573. t.Fatalf("campusIDs 非空错误 = %v,期望 ErrBadParam", err)
  574. }
  575. }
  576. // TestExpenseSumLogMoneyGoSide 对账汇总在 Go 侧定点累加(禁 SQL SUM 回 REAL)。
  577. func TestExpenseSumLogMoneyGoSide(t *testing.T) {
  578. db, capture := newExpenseRepoDBWithCapture(t)
  579. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  580. ctx := context.Background()
  581. const user = "01HUSER000000000000000110"
  582. rows := []*model.UserExpenseLog{
  583. {UserID: user, Type: model.ExpenseLogTypeRecharge, Money: "100.00"},
  584. {UserID: user, Type: model.ExpenseLogTypeCourseCampus, Money: "-30.25"},
  585. {UserID: user, Type: model.ExpenseLogTypeRefund, Money: "-69.75"},
  586. {UserID: user, Type: model.ExpenseLogTypeRecharge, Money: "0.01"},
  587. }
  588. for i := range rows {
  589. if err := db.Create(rows[i]).Error; err != nil {
  590. t.Fatalf("seed 流水失败: %v", err)
  591. }
  592. }
  593. capture.Reset()
  594. total, err := repo.SumLogMoney(ctx, user, nil, nil)
  595. if err != nil {
  596. t.Fatalf("汇总失败: %v", err)
  597. }
  598. if got := total.String(); got != "0.01" {
  599. t.Fatalf("全类型合计 = %s,期望 0.01", got)
  600. }
  601. for _, stmt := range capture.All() {
  602. if strings.Contains(strings.ToUpper(stmt), "SUM(") {
  603. t.Fatalf("汇总不得下推到 SQL(禁 SUM 回 REAL):%s", stmt)
  604. }
  605. }
  606. rechargeOnly, err := repo.SumLogMoney(ctx, user, nil, []int{int(model.ExpenseLogTypeRecharge)})
  607. if err != nil {
  608. t.Fatalf("按类型汇总失败: %v", err)
  609. }
  610. if got := rechargeOnly.String(); got != "100.01" {
  611. t.Fatalf("充值合计 = %s,期望 100.01", got)
  612. }
  613. if _, err := repo.SumLogMoney(ctx, user, []int64{2}, nil); !errors.Is(err, infraerrors.ErrBadParam) {
  614. t.Fatalf("campusIDs 非空错误 = %v,期望 ErrBadParam", err)
  615. }
  616. }
  617. // assertBalance 断言账户余额(campusID=0 查全局)。
  618. func assertBalance(t *testing.T, repo *ExpenseRepo, ctx context.Context, user string, campusID int64, want string) {
  619. t.Helper()
  620. got, err := repo.GetBalance(ctx, user, campusID)
  621. if err != nil {
  622. t.Fatalf("读取余额失败(campus=%d): %v", campusID, err)
  623. }
  624. if got.String() != want {
  625. t.Fatalf("余额(campus=%d) = %s,期望 %s", campusID, got.String(), want)
  626. }
  627. }
  628. // mustAmount 断言式解析金额(测试内固定字面量,解析失败直接判红)。
  629. func mustAmount(t *testing.T, s string) money.Amount {
  630. t.Helper()
  631. return parseAmount(t, s)
  632. }
  633. // parseAmount 把 decimal 字符串解析成 money.Amount(空串按 0 处理)。
  634. //
  635. // 为什么不直接比字符串:sqlite 的 decimal 列是 NUMERIC 亲和性,回读可能省略尾零("8" vs "8.00"),
  636. // 字符串相等会假红;按"分"比较才是金额语义。
  637. func parseAmount(t *testing.T, s string) money.Amount {
  638. t.Helper()
  639. if strings.TrimSpace(s) == "" {
  640. return 0
  641. }
  642. a, err := money.Parse(s)
  643. if err != nil {
  644. t.Fatalf("解析金额 %q 失败: %v", s, err)
  645. }
  646. return a
  647. }