fund_service_test.go 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
  1. // FundService 单测(AC-04):充值双表 / 幂等(含 20 并发)/ 消费三列组合 / 退费 BUG#2 /
  2. // 可退金额 / 坏账 / 累计消费 / 落库金额越界负例。
  3. //
  4. // 测试栈(与 AC-03 仓储测试同一套手法):
  5. // - 行为用例:glebarez/sqlite 文件库(真实事务、真实唯一键 uk_request_type);
  6. // - SQL 顺序用例:给 GORM 挂"抓 SQL"的 logger(sqlRecorder),断言仓储**真实生成**的语句顺序;
  7. // - 索引纪律:academic model 多表共用 `idx_inst_campus` 等索引名,SQLite 的索引名是**库级**唯一
  8. // (MySQL 表级),故 migrateFundTables 逐表迁移后**只删非唯一索引**——
  9. // `uk_request_type` / `uk_user_activity` / `uk_user_inst_campus` 必须保留,
  10. // 否则幂等用例会静默变成"永远不撞键"的假绿。
  11. package service
  12. import (
  13. "context"
  14. "errors"
  15. "fmt"
  16. "path/filepath"
  17. "strings"
  18. "sync"
  19. "testing"
  20. "time"
  21. gormsqlite "github.com/glebarez/sqlite"
  22. "gorm.io/gorm"
  23. "gorm.io/gorm/logger"
  24. "yuansiwei/unified-backend/internal/academic/apperr"
  25. "yuansiwei/unified-backend/internal/academic/model"
  26. "yuansiwei/unified-backend/internal/academic/money"
  27. "yuansiwei/unified-backend/internal/academic/repository"
  28. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  29. appscope "yuansiwei/unified-backend/internal/infra/scope"
  30. )
  31. // ---------------------------------------------------------------- 测试基础设施
  32. // sqlRecorder GORM logger:按执行顺序收集 SQL(不改写、不过滤)。
  33. //
  34. // 用途:断言"全局账户锁先于校区账户锁"这类**顺序性**红约束(顺序错 = 与并发充值交叉即成环)。
  35. // 只实现 logger.Interface 的最小集合(Trace 收到 Explain 后的完整 SQL)。
  36. type sqlRecorder struct {
  37. mu sync.Mutex
  38. stmts []string
  39. }
  40. // Printf 收口(logger.Writer 兼容方法)。
  41. func (r *sqlRecorder) Printf(format string, args ...any) {
  42. msg := strings.TrimSpace(fmt.Sprintf(format, args...))
  43. if msg == "" {
  44. return
  45. }
  46. r.mu.Lock()
  47. defer r.mu.Unlock()
  48. r.stmts = append(r.stmts, msg)
  49. }
  50. // LogMode 实现 logger.Interface(不做级别过滤)。
  51. func (r *sqlRecorder) LogMode(logger.LogLevel) logger.Interface { return r }
  52. // Info 实现 logger.Interface(单测不需要普通日志)。
  53. func (r *sqlRecorder) Info(context.Context, string, ...any) {}
  54. // Warn 实现 logger.Interface。
  55. func (r *sqlRecorder) Warn(context.Context, string, ...any) {}
  56. // Error 实现 logger.Interface。
  57. func (r *sqlRecorder) Error(context.Context, string, ...any) {}
  58. // Trace 实现 logger.Interface:把本次语句追加进 stmts。
  59. func (r *sqlRecorder) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
  60. sql, _ := fc()
  61. r.Printf("%s", sql)
  62. }
  63. // Snapshot 返回当前收集到的语句快照(调用方不得修改返回切片内部元素语义)。
  64. func (r *sqlRecorder) Snapshot() []string {
  65. r.mu.Lock()
  66. defer r.mu.Unlock()
  67. out := make([]string, len(r.stmts))
  68. copy(out, r.stmts)
  69. return out
  70. }
  71. // Reset 清空收集。
  72. func (r *sqlRecorder) Reset() {
  73. r.mu.Lock()
  74. defer r.mu.Unlock()
  75. r.stmts = nil
  76. }
  77. // migrateFundTables 逐表 AutoMigrate,并**只删除非唯一索引**。
  78. //
  79. // 为什么只删非唯一:SQLite 索引名库级唯一(`idx_inst_campus` 同时出现在
  80. // t_user_expense_school 与 t_courses_activity_signed),一次性迁移会报 already exists;
  81. // 但 `uk_request_type` / `uk_user_activity` / `uk_user_inst_campus` 是**幂等与唯一性的实现载体**,
  82. // 删掉会让"并发重复提交只落 1 条流水"的用例变成永远通过的假绿。
  83. func migrateFundTables(t *testing.T, db *gorm.DB, tables ...any) {
  84. t.Helper()
  85. for _, tbl := range tables {
  86. if err := db.AutoMigrate(tbl); err != nil {
  87. t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err)
  88. }
  89. stmt := &gorm.Statement{DB: db}
  90. if err := stmt.Parse(tbl); err != nil {
  91. t.Fatalf("解析表名(%T) 失败: %v", tbl, err)
  92. }
  93. type idxRow struct {
  94. Name string
  95. SQL *string
  96. }
  97. var rows []idxRow
  98. if err := db.Raw(
  99. "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
  100. stmt.Schema.Table).Scan(&rows).Error; err != nil {
  101. t.Fatalf("读取索引清单(%s) 失败: %v", stmt.Schema.Table, err)
  102. }
  103. for _, row := range rows {
  104. sqlText := ""
  105. if row.SQL != nil {
  106. sqlText = strings.ToUpper(*row.SQL)
  107. }
  108. if strings.Contains(sqlText, "UNIQUE") {
  109. continue // 唯一约束必须保留(幂等键/业务 UK)
  110. }
  111. if err := db.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; err != nil {
  112. t.Fatalf("删除索引(%s) 失败: %v", row.Name, err)
  113. }
  114. }
  115. }
  116. }
  117. // newFundServiceDB 余额域测试库(余额 3 表 + 活动 2 表 + 简化 t_campus)。
  118. //
  119. // t_campus 用原生 DDL 建(academic 侧只有 port 的只读 row struct,不参与 AutoMigrate,
  120. // 这正是设计 §1.3「跨域只读」的落地形态:academic 不建 org 的表,只读)。
  121. func newFundServiceDB(t *testing.T) *gorm.DB {
  122. t.Helper()
  123. db, _ := newFundServiceDBWithRecorder(t)
  124. return db
  125. }
  126. // newFundServiceDBWithRecorder 同 newFundServiceDB,并返回 SQL 记录器。
  127. func newFundServiceDBWithRecorder(t *testing.T) (*gorm.DB, *sqlRecorder) {
  128. t.Helper()
  129. dsn := filepath.Join(t.TempDir(), "academic_fund_service_test.db")
  130. rec := &sqlRecorder{}
  131. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{Logger: rec})
  132. if err != nil {
  133. t.Fatalf("gorm.Open(余额域文件库) 失败: %v", err)
  134. }
  135. sqlDB, err := db.DB()
  136. if err != nil {
  137. t.Fatalf("取 sql.DB 失败: %v", err)
  138. }
  139. // 并发用例需要"多 goroutine 抢同一库":SQLite 写事务必须串行,
  140. // 单连接池即可让 20 个 goroutine 依次推进(生产 MySQL 是行锁串行,此处用连接池串行等价模拟)。
  141. sqlDB.SetMaxOpenConns(1)
  142. t.Cleanup(func() { _ = sqlDB.Close() })
  143. migrateFundTables(t, db,
  144. &model.UserExpense{}, &model.UserExpenseSchool{}, &model.UserExpenseLog{},
  145. &model.CoursesActivity{}, &model.CoursesActivitySigned{},
  146. )
  147. // 跨域只读表(org 域 t_campus 的最小列集;self_support 为 1=自营 / 0=加盟)
  148. if err := db.Exec(`CREATE TABLE t_campus (
  149. campus_id INTEGER PRIMARY KEY,
  150. institution_id INTEGER NOT NULL DEFAULT 0,
  151. self_support BOOLEAN NOT NULL DEFAULT 1,
  152. status VARCHAR(32) NOT NULL DEFAULT 'active'
  153. )`).Error; err != nil {
  154. t.Fatalf("建 t_campus 失败: %v", err)
  155. }
  156. return db, rec
  157. }
  158. // fundUserID 生成 26 位类 ULID 的测试用户 ID(列宽 varchar(26))。
  159. func fundUserID(i int) string { return fmt.Sprintf("01HUSER%019d", i) }
  160. // fundScope 构造普通校区作用域(机构 1 / 校区 10)。
  161. func fundScope() *appscope.Scope {
  162. return &appscope.Scope{InstitutionID: 1, CampusID: 10, UserID: "01HOPERATOR0000000000001", Role: "finance"}
  163. }
  164. // fundScopeFor 构造指定机构/校区的作用域(跨机构用例)。
  165. func fundScopeFor(institutionID, campusID int64) *appscope.Scope {
  166. return &appscope.Scope{InstitutionID: institutionID, CampusID: campusID, UserID: "01HOPERATOR0000000000001", Role: "finance"}
  167. }
  168. // fundSuperScope 平台超管作用域(跨机构视角,用于"必须能同时看到两个机构"的用例)。
  169. func fundSuperScope() *appscope.Scope {
  170. return &appscope.Scope{InstitutionID: 1, CampusID: 0, UserID: "01HSUPERADMIN0000000001", Role: "super_admin", SuperAdmin: true}
  171. }
  172. // mustAmount 解析金额(测试内固定字面量)。
  173. func mustAmount(t *testing.T, s string) money.Amount {
  174. t.Helper()
  175. a, err := money.Parse(s)
  176. if err != nil {
  177. t.Fatalf("金额解析失败 %q: %v", s, err)
  178. }
  179. return a
  180. }
  181. // seedCampus 落一行 t_campus(self_support: true=自营 / false=加盟)。
  182. func seedCampus(t *testing.T, db *gorm.DB, campusID, institutionID int64, selfSupport bool) {
  183. t.Helper()
  184. if err := db.Exec(
  185. "INSERT INTO t_campus(campus_id, institution_id, self_support, status) VALUES(?,?,?,?)",
  186. campusID, institutionID, selfSupport, "active").Error; err != nil {
  187. t.Fatalf("seed t_campus(%d) 失败: %v", campusID, err)
  188. }
  189. }
  190. // readGlobal 读全局钱包。
  191. func readGlobal(t *testing.T, db *gorm.DB, userID string) model.UserExpense {
  192. t.Helper()
  193. var row model.UserExpense
  194. if err := db.Where("user_id = ?", userID).Take(&row).Error; err != nil {
  195. t.Fatalf("读全局账户(%s) 失败: %v", userID, err)
  196. }
  197. return row
  198. }
  199. // readSchool 读校区账户。
  200. func readSchool(t *testing.T, db *gorm.DB, userID string, institutionID, campusID int64) model.UserExpenseSchool {
  201. t.Helper()
  202. var row model.UserExpenseSchool
  203. if err := db.Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID).
  204. Take(&row).Error; err != nil {
  205. t.Fatalf("读校区账户(%s/%d/%d) 失败: %v", userID, institutionID, campusID, err)
  206. }
  207. return row
  208. }
  209. // logsByType 取某用户的指定 type 流水(按 id ASC)。
  210. func logsByType(t *testing.T, db *gorm.DB, userID string, typ int32) []model.UserExpenseLog {
  211. t.Helper()
  212. var rows []model.UserExpenseLog
  213. if err := db.Where("user_id = ? AND type = ?", userID, typ).Order("id ASC").Find(&rows).Error; err != nil {
  214. t.Fatalf("读流水(type=%d) 失败: %v", typ, err)
  215. }
  216. return rows
  217. }
  218. // countLogs 统计某用户指定 type 的流水条数。
  219. func countLogsByType(t *testing.T, db *gorm.DB, userID string, typ int32) int {
  220. t.Helper()
  221. return len(logsByType(t, db, userID, typ))
  222. }
  223. // ---------------------------------------------------------------- 充值(SP1 / SP2)
  224. // TestRechargeUpdatesBothTables 充值双表同事务:全局 + 校区 balance/total_income 同步,流水含 balance_after。
  225. func TestRechargeUpdatesBothTables(t *testing.T) {
  226. db := newFundServiceDB(t)
  227. svc := NewFundService(db, fundScope(), nil)
  228. ctx := context.Background()
  229. user := fundUserID(1)
  230. res, err := svc.Recharge(ctx, RechargeInput{
  231. UserID: user, InstitutionID: 1, CampusID: 10,
  232. Money: mustAmount(t, "100.00"), RequestID: "REQ-RECHARGE-1",
  233. })
  234. if err != nil {
  235. t.Fatalf("Recharge 失败: %v", err)
  236. }
  237. if res.Repeated {
  238. t.Fatalf("首次充值不应 repeated=true")
  239. }
  240. if res.LogID <= 0 {
  241. t.Fatalf("logId 应 > 0,实得 %d", res.LogID)
  242. }
  243. if got := cents(t, res.Balance.String()); got != 10000 {
  244. t.Fatalf("返回余额=%d 分,期望 10000", got)
  245. }
  246. global := readGlobal(t, db, user)
  247. if got := cents(t, global.Balance); got != 10000 {
  248. t.Fatalf("全局 balance=%d 分,期望 10000", got)
  249. }
  250. if got := cents(t, global.TotalIncome); got != 10000 {
  251. t.Fatalf("全局 total_income=%d 分,期望 10000", got)
  252. }
  253. school := readSchool(t, db, user, 1, 10)
  254. if got := cents(t, school.Balance); got != 10000 {
  255. t.Fatalf("校区 balance=%d 分,期望 10000", got)
  256. }
  257. if got := cents(t, school.TotalIncome); got != 10000 {
  258. t.Fatalf("校区 total_income=%d 分,期望 10000", got)
  259. }
  260. logs := logsByType(t, db, user, model.ExpenseLogTypeRecharge)
  261. if len(logs) != 1 {
  262. t.Fatalf("充值流水应为 1 条,实得 %d", len(logs))
  263. }
  264. if got := cents(t, logs[0].Money); got != 10000 {
  265. t.Fatalf("流水 money=%d 分,期望 10000(方向为正)", got)
  266. }
  267. if logs[0].RequestID == nil || *logs[0].RequestID != "REQ-RECHARGE-1" {
  268. t.Fatalf("流水 request_id 未落:%+v", logs[0].RequestID)
  269. }
  270. if logs[0].BalanceAfter == nil || cents(t, *logs[0].BalanceAfter) != 10000 {
  271. t.Fatalf("流水 balance_after 快照错误:%+v", logs[0].BalanceAfter)
  272. }
  273. if logs[0].Remark == nil || *logs[0].Remark != defaultRechargeRemark {
  274. t.Fatalf("流水 remark 默认文案错误:%+v", logs[0].Remark)
  275. }
  276. }
  277. // TestRechargeKindSetsSchoolRemark SP1 / SP2 的唯一差异:默认备注文案。
  278. func TestRechargeKindSetsSchoolRemark(t *testing.T) {
  279. db := newFundServiceDB(t)
  280. svc := NewFundService(db, fundScope(), nil)
  281. ctx := context.Background()
  282. user := fundUserID(2)
  283. if _, err := svc.Recharge(ctx, RechargeInput{
  284. UserID: user, InstitutionID: 1, CampusID: 10,
  285. Money: mustAmount(t, "10.00"), RequestID: "REQ-KIND-1", Kind: RechargeKindSchool,
  286. }); err != nil {
  287. t.Fatalf("Recharge(Kind=School) 失败: %v", err)
  288. }
  289. logs := logsByType(t, db, user, model.ExpenseLogTypeRecharge)
  290. if len(logs) != 1 || logs[0].Remark == nil || *logs[0].Remark != defaultSchoolRechargeRemark {
  291. t.Fatalf("SP2 默认备注应为 %q,实得 %+v", defaultSchoolRechargeRemark, logs)
  292. }
  293. }
  294. // TestRechargeIdempotentSameRequestID 同 requestId 第二次提交 → repeated=true、不重复入账。
  295. func TestRechargeIdempotentSameRequestID(t *testing.T) {
  296. db := newFundServiceDB(t)
  297. svc := NewFundService(db, fundScope(), nil)
  298. ctx := context.Background()
  299. user := fundUserID(3)
  300. in := RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  301. Money: mustAmount(t, "50.00"), RequestID: "REQ-IDEMPOTENT-1"}
  302. first, err := svc.Recharge(ctx, in)
  303. if err != nil {
  304. t.Fatalf("首次充值失败: %v", err)
  305. }
  306. second, err := svc.Recharge(ctx, in)
  307. if err != nil {
  308. t.Fatalf("重复充值应返回成功(幂等),实得错误: %v", err)
  309. }
  310. if !second.Repeated {
  311. t.Fatalf("重复提交应 repeated=true")
  312. }
  313. if second.LogID != first.LogID {
  314. t.Fatalf("重复提交应回放首次 logId=%d,实得 %d", first.LogID, second.LogID)
  315. }
  316. if count := countLogsByType(t, db, user, model.ExpenseLogTypeRecharge); count != 1 {
  317. t.Fatalf("流水应只 1 条,实得 %d", count)
  318. }
  319. if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 5000 {
  320. t.Fatalf("余额应只变一次(5000 分),实得 %d", got)
  321. }
  322. }
  323. // TestRechargeConcurrentSameRequestID 20 goroutine 同 requestId → 1 条流水、余额只变一次。
  324. //
  325. // SQLite 无行锁,用单连接池把写事务串行化(等价于 MySQL 的行锁串行语义);
  326. // 断言点与生产一致:**流水条数**与**余额增量**(不是"是否报错")。
  327. func TestRechargeConcurrentSameRequestID(t *testing.T) {
  328. db := newFundServiceDB(t)
  329. svc := NewFundService(db, fundScope(), nil)
  330. ctx := context.Background()
  331. user := fundUserID(4)
  332. in := RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  333. Money: mustAmount(t, "30.00"), RequestID: "REQ-CONCURRENT-1"}
  334. const goroutines = 20
  335. var wg sync.WaitGroup
  336. results := make([]*RechargeResult, goroutines)
  337. errs := make([]error, goroutines)
  338. for i := 0; i < goroutines; i++ {
  339. wg.Add(1)
  340. go func(idx int) {
  341. defer wg.Done()
  342. results[idx], errs[idx] = svc.Recharge(ctx, in)
  343. }(i)
  344. }
  345. wg.Wait()
  346. for i, err := range errs {
  347. if err != nil {
  348. t.Fatalf("并发第 %d 次充值失败: %v", i, err)
  349. }
  350. }
  351. if count := countLogsByType(t, db, user, model.ExpenseLogTypeRecharge); count != 1 {
  352. t.Fatalf("并发同 requestId 应只 1 条流水,实得 %d", count)
  353. }
  354. if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 3000 {
  355. t.Fatalf("余额应只入账一次(3000 分),实得 %d", got)
  356. }
  357. repeated := 0
  358. for _, r := range results {
  359. if r != nil && r.Repeated {
  360. repeated++
  361. }
  362. }
  363. if repeated != goroutines-1 {
  364. t.Fatalf("应有 %d 次 repeated=true,实得 %d", goroutines-1, repeated)
  365. }
  366. }
  367. // TestRechargeValidation 入参校验:空用户 2001 / 校区缺失 3140 / 金额<=0 与超上限 2002。
  368. func TestRechargeValidation(t *testing.T) {
  369. db := newFundServiceDB(t)
  370. svc := NewFundService(db, fundScope(), nil)
  371. ctx := context.Background()
  372. cases := []struct {
  373. name string
  374. in RechargeInput
  375. want int
  376. }{
  377. {"空用户", RechargeInput{UserID: "", InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "1.00")}, 2001},
  378. {"校区缺失", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 0, Money: mustAmount(t, "1.00")}, 3140},
  379. {"机构缺失", RechargeInput{UserID: fundUserID(5), InstitutionID: 0, CampusID: 10, Money: mustAmount(t, "1.00")}, 3140},
  380. {"金额为 0", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10, Money: 0}, 2002},
  381. {"金额为负", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "-1.00")}, 2002},
  382. {"超单笔上限", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10,
  383. Money: mustAmount(t, "1000000.01")}, 2002},
  384. }
  385. for _, tc := range cases {
  386. t.Run(tc.name, func(t *testing.T) {
  387. _, err := svc.Recharge(ctx, tc.in)
  388. requireAppCode(t, err, tc.want)
  389. })
  390. }
  391. }
  392. // ---------------------------------------------------------------- 消费(SP3)
  393. // TestExpenditureTypeBranches dump 的两个列组合分支 + 流水方向为负。
  394. func TestExpenditureTypeBranches(t *testing.T) {
  395. db := newFundServiceDB(t)
  396. svc := NewFundService(db, fundScope(), nil)
  397. ctx := context.Background()
  398. user := fundUserID(6)
  399. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  400. Money: mustAmount(t, "100.00")}); err != nil {
  401. t.Fatalf("准备余额失败: %v", err)
  402. }
  403. // type=200(历史扣费):balance−、total_expenditure+、spend+
  404. if _, err := svc.Expenditure(ctx, ExpenditureInput{UserID: user, InstitutionID: 1, CampusID: 10,
  405. Money: mustAmount(t, "30.00"), Type: model.ExpenseLogTypeHistoryDeduct, RequestID: "REQ-EXP-200"}); err != nil {
  406. t.Fatalf("Expenditure(200) 失败: %v", err)
  407. }
  408. school := readSchool(t, db, user, 1, 10)
  409. if got := cents(t, school.Balance); got != 7000 {
  410. t.Fatalf("200 后 balance 应 7000 分,实得 %d", got)
  411. }
  412. if got := cents(t, school.TotalExpenditure); got != 3000 {
  413. t.Fatalf("200 后 total_expenditure 应 3000 分,实得 %d", got)
  414. }
  415. if got := cents(t, school.Spend); got != 3000 {
  416. t.Fatalf("200 后 spend 应 3000 分,实得 %d", got)
  417. }
  418. // type=201(校区报课消费):balance−、total_expenditure+,**spend 不动**
  419. if _, err := svc.Expenditure(ctx, ExpenditureInput{UserID: user, InstitutionID: 1, CampusID: 10,
  420. Money: mustAmount(t, "20.00"), Type: model.ExpenseLogTypeCourseCampusB, RequestID: "REQ-EXP-201"}); err != nil {
  421. t.Fatalf("Expenditure(201) 失败: %v", err)
  422. }
  423. school = readSchool(t, db, user, 1, 10)
  424. if got := cents(t, school.Balance); got != 5000 {
  425. t.Fatalf("201 后 balance 应 5000 分,实得 %d", got)
  426. }
  427. if got := cents(t, school.TotalExpenditure); got != 5000 {
  428. t.Fatalf("201 后 total_expenditure 应 5000 分,实得 %d", got)
  429. }
  430. if got := cents(t, school.Spend); got != 3000 {
  431. t.Fatalf("201 不应改 spend(应仍 3000 分),实得 %d", got)
  432. }
  433. // 全局表同列同步
  434. global := readGlobal(t, db, user)
  435. if got := cents(t, global.Balance); got != 5000 {
  436. t.Fatalf("全局 balance 应 5000 分,实得 %d", got)
  437. }
  438. if got := cents(t, global.TotalExpenditure); got != 5000 {
  439. t.Fatalf("全局 total_expenditure 应 5000 分,实得 %d", got)
  440. }
  441. // 流水金额恒为负(dump: `VALUES(...,-inputMoney,...)`)
  442. logs := logsByType(t, db, user, model.ExpenseLogTypeHistoryDeduct)
  443. if len(logs) != 1 {
  444. t.Fatalf("200 流水应 1 条,实得 %d", len(logs))
  445. }
  446. if got := cents(t, logs[0].Money); got != -3000 {
  447. t.Fatalf("200 流水 money 应 -3000 分,实得 %d", got)
  448. }
  449. }
  450. // TestExpenditureInvalidType 白名单外 type → 2002(dump 会静默改成 200,Go 显式拒绝)。
  451. func TestExpenditureInvalidType(t *testing.T) {
  452. db := newFundServiceDB(t)
  453. svc := NewFundService(db, fundScope(), nil)
  454. _, err := svc.Expenditure(context.Background(), ExpenditureInput{
  455. UserID: fundUserID(7), InstitutionID: 1, CampusID: 10,
  456. Money: mustAmount(t, "10.00"), Type: 999,
  457. })
  458. requireAppCode(t, err, 2002)
  459. }
  460. // ---------------------------------------------------------------- 退费(SP4,BUG#2)
  461. // TestRefundInstitutionFilterPreventsCrossInstitutionUpdate BUG#2 回归:
  462. // 同一 campus_id 在两个机构都有账户行时,只允许更新入参机构那一行。
  463. //
  464. // dump 的 WHERE 是 `userid + campusid`(无 schoolid)→ 两行都会被改;Go 补 institution_id。
  465. func TestRefundInstitutionFilterPreventsCrossInstitutionUpdate(t *testing.T) {
  466. db := newFundServiceDB(t)
  467. svc := NewFundService(db, fundSuperScope(), nil) // 超管视角:底层两行都可见
  468. ctx := context.Background()
  469. user := fundUserID(8)
  470. // 两个机构、同一个 campus_id=10
  471. for _, inst := range []int64{1, 2} {
  472. if err := db.Create(&model.UserExpenseSchool{
  473. UserID: user, InstitutionID: inst, CampusID: 10,
  474. Balance: "100.00", TotalExpenditure: "50.00",
  475. }).Error; err != nil {
  476. t.Fatalf("seed 校区账户(inst=%d) 失败: %v", inst, err)
  477. }
  478. }
  479. if err := db.Create(&model.UserExpense{UserID: user, Balance: "200.00", TotalExpenditure: "50.00"}).Error; err != nil {
  480. t.Fatalf("seed 全局账户失败: %v", err)
  481. }
  482. res, err := svc.Refund(ctx, RefundInput{
  483. UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "20.00"),
  484. Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-REFUND-1",
  485. })
  486. if err != nil {
  487. t.Fatalf("Refund 失败: %v", err)
  488. }
  489. if !res.Changed {
  490. t.Fatalf("Refund 应 changed=true")
  491. }
  492. if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 12000 {
  493. t.Fatalf("目标机构 balance 应 12000 分,实得 %d", got)
  494. }
  495. // 另一机构必须零变动(BUG#2 的核心断言)
  496. if got := cents(t, readSchool(t, db, user, 2, 10).Balance); got != 10000 {
  497. t.Fatalf("另一机构 balance 必须不变(10000 分),实得 %d", got)
  498. }
  499. if got := cents(t, readSchool(t, db, user, 1, 10).TotalExpenditure); got != 3000 {
  500. t.Fatalf("目标机构 total_expenditure 应 3000 分(退费回冲),实得 %d", got)
  501. }
  502. logs := logsByType(t, db, user, model.ExpenseLogTypeCourseCampus)
  503. if len(logs) != 1 || cents(t, logs[0].Money) != 2000 {
  504. t.Fatalf("退费流水应为 +2000 分一条,实得 %+v", logs)
  505. }
  506. }
  507. // TestRefundZeroMoneyIsNoop money<=0 → changed=false(对齐 dump `IF inputMoney>0`),不写流水。
  508. func TestRefundZeroMoneyIsNoop(t *testing.T) {
  509. db := newFundServiceDB(t)
  510. svc := NewFundService(db, fundScope(), nil)
  511. user := fundUserID(9)
  512. res, err := svc.Refund(context.Background(), RefundInput{
  513. UserID: user, InstitutionID: 1, CampusID: 10, Money: 0, Type: model.ExpenseLogTypeCourseCampus,
  514. })
  515. if err != nil {
  516. t.Fatalf("money=0 应返回成功(no-op),实得错误: %v", err)
  517. }
  518. if res.Changed || res.Repeated || res.LogID != 0 {
  519. t.Fatalf("money=0 应 changed=false 且无流水,实得 %+v", res)
  520. }
  521. if count := countLogsByType(t, db, user, model.ExpenseLogTypeCourseCampus); count != 0 {
  522. t.Fatalf("no-op 不应写流水,实得 %d 条", count)
  523. }
  524. }
  525. // TestRefundAccountMissing 账户未建户 → 3001(dump 静默无操作;Go 显式失败)。
  526. func TestRefundAccountMissing(t *testing.T) {
  527. db := newFundServiceDB(t)
  528. svc := NewFundService(db, fundScope(), nil)
  529. _, err := svc.Refund(context.Background(), RefundInput{
  530. UserID: fundUserID(10), InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "5.00"),
  531. Type: model.ExpenseLogTypeCourseCampus,
  532. })
  533. requireAppCode(t, err, 3001)
  534. }
  535. // ---------------------------------------------------------------- 可退金额(SP5)
  536. // TestRefundableAmount 账户不存在 → 0;负余额 → 0;正余额 → 余额。
  537. func TestRefundableAmount(t *testing.T) {
  538. db := newFundServiceDB(t)
  539. svc := NewFundService(db, fundScope(), nil)
  540. ctx := context.Background()
  541. // 账户不存在
  542. if got, err := svc.RefundableAmount(ctx, fundUserID(11), 1, 10); err != nil || got != 0 {
  543. t.Fatalf("账户不存在应返回 0 且无错,实得 (%d, %v)", got, err)
  544. }
  545. // 负数余额(欠费)
  546. if err := db.Create(&model.UserExpenseSchool{UserID: fundUserID(12), InstitutionID: 1, CampusID: 10,
  547. Balance: "-25.00"}).Error; err != nil {
  548. t.Fatalf("seed 欠费账户失败: %v", err)
  549. }
  550. if got, err := svc.RefundableAmount(ctx, fundUserID(12), 1, 10); err != nil || got != 0 {
  551. t.Fatalf("负余额应返回 0,实得 (%d, %v)", got, err)
  552. }
  553. // 正余额
  554. if err := db.Create(&model.UserExpenseSchool{UserID: fundUserID(13), InstitutionID: 1, CampusID: 10,
  555. Balance: "88.50"}).Error; err != nil {
  556. t.Fatalf("seed 正余额账户失败: %v", err)
  557. }
  558. if got, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 10); err != nil || got.Cents() != 8850 {
  559. t.Fatalf("正余额应返回 8850 分,实得 (%d, %v)", got, err)
  560. }
  561. // 校区缺失 → 3140
  562. if _, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 0); err != nil {
  563. requireAppCode(t, err, 3140)
  564. }
  565. }
  566. // ---------------------------------------------------------------- 坏账(SP7)
  567. // TestBadDebtOnlySpecialExpenses 只加 special_expenses(**不动 balance**),108 流水为正。
  568. func TestBadDebtOnlySpecialExpenses(t *testing.T) {
  569. db := newFundServiceDB(t)
  570. svc := NewFundService(db, fundScope(), nil)
  571. ctx := context.Background()
  572. user := fundUserID(14)
  573. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  574. Money: mustAmount(t, "100.00")}); err != nil {
  575. t.Fatalf("准备余额失败: %v", err)
  576. }
  577. res, err := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
  578. Money: mustAmount(t, "30.00")})
  579. if err != nil {
  580. t.Fatalf("BadDebt 失败: %v", err)
  581. }
  582. school := readSchool(t, db, user, 1, 10)
  583. if got := cents(t, school.Balance); got != 10000 {
  584. t.Fatalf("坏账不应改 balance(应仍 10000 分),实得 %d", got)
  585. }
  586. if got := cents(t, school.SpecialExpenses); got != 3000 {
  587. t.Fatalf("special_expenses 应 3000 分,实得 %d", got)
  588. }
  589. global := readGlobal(t, db, user)
  590. if got := cents(t, global.SpecialExpenses); got != 3000 {
  591. t.Fatalf("全局 special_expenses 应 3000 分(双表同步),实得 %d", got)
  592. }
  593. if got := cents(t, global.Balance); got != 10000 {
  594. t.Fatalf("全局 balance 不应变(应仍 10000 分),实得 %d", got)
  595. }
  596. if res.SpecialExpenses.Cents() != 3000 {
  597. t.Fatalf("返回 specialExpenses 应 3000 分,实得 %d", res.SpecialExpenses.Cents())
  598. }
  599. logs := logsByType(t, db, user, model.ExpenseLogTypeBadDebt)
  600. if len(logs) != 1 || cents(t, logs[0].Money) != 3000 {
  601. t.Fatalf("108 流水应为 +3000 分一条,实得 %+v", logs)
  602. }
  603. }
  604. // ---------------------------------------------------------------- 累计消费(SP11,BUG#1)
  605. // TestAccumulateSpendOnlySpend 只更新 spend(不动 balance),且**不写流水**;负数归 0。
  606. func TestAccumulateSpendOnlySpend(t *testing.T) {
  607. db := newFundServiceDB(t)
  608. svc := NewFundService(db, fundScope(), nil)
  609. ctx := context.Background()
  610. user := fundUserID(15)
  611. // 账户不存在时也应能建户并写 spend(dump 的 INSERT 分支;BUG#1 修正前该分支是 3 值对 2 列必报错)
  612. res, err := svc.AccumulateSpend(ctx, SpendInput{UserID: user, InstitutionID: 1, CampusID: 10,
  613. SpendMoney: mustAmount(t, "25.00")})
  614. if err != nil {
  615. t.Fatalf("AccumulateSpend 失败: %v", err)
  616. }
  617. if res.Spend.Cents() != 2500 {
  618. t.Fatalf("返回 spend 应 2500 分,实得 %d", res.Spend.Cents())
  619. }
  620. if got := cents(t, readSchool(t, db, user, 1, 10).Spend); got != 2500 {
  621. t.Fatalf("校区 spend 应 2500 分,实得 %d", got)
  622. }
  623. if got := cents(t, readGlobal(t, db, user).Spend); got != 2500 {
  624. t.Fatalf("全局 spend 应 2500 分,实得 %d", got)
  625. }
  626. if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 0 {
  627. t.Fatalf("spend SP 不得改 balance(应 0),实得 %d", got)
  628. }
  629. var total int64
  630. if err := db.Model(&model.UserExpenseLog{}).Where("user_id = ?", user).Count(&total).Error; err != nil {
  631. t.Fatalf("统计流水失败: %v", err)
  632. }
  633. if total != 0 {
  634. t.Fatalf("spend SP 不写流水(dump 无 INSERT log),实得 %d 条", total)
  635. }
  636. // 负数一律归 0(dump INSERT 分支同口径;spend 只增)
  637. res, err = svc.AccumulateSpend(ctx, SpendInput{UserID: user, InstitutionID: 1, CampusID: 10,
  638. SpendMoney: mustAmount(t, "-10.00")})
  639. if err != nil {
  640. t.Fatalf("AccumulateSpend(负数) 失败: %v", err)
  641. }
  642. if res.Spend.Cents() != 2500 {
  643. t.Fatalf("负数应被归 0(spend 仍 2500 分),实得 %d", res.Spend.Cents())
  644. }
  645. }
  646. // ---------------------------------------------------------------- 幂等键与金额越界(负例)
  647. // TestInsertLogDuplicateMapsToRepeated 撞 uk_request_type → 内部哨兵 3114(不是 5002)。
  648. func TestInsertLogDuplicateMapsToRepeated(t *testing.T) {
  649. db := newFundServiceDB(t)
  650. svc := NewFundService(db, fundScope(), nil)
  651. ctx := context.Background()
  652. user := fundUserID(16)
  653. mk := func() *model.UserExpenseLog {
  654. return newExpenseLog(user, model.ExpenseLogTypeRecharge, mustAmount(t, "1.00"),
  655. "REQ-DUP-1", "dup", "", nil)
  656. }
  657. if err := db.Transaction(func(tx *gorm.DB) error {
  658. return svc.insertLog(repository.WithTxContext(ctx, tx), tx, mk())
  659. }); err != nil {
  660. t.Fatalf("首次写流水失败: %v", err)
  661. }
  662. err := db.Transaction(func(tx *gorm.DB) error {
  663. return svc.insertLog(repository.WithTxContext(ctx, tx), tx, mk())
  664. })
  665. if !errors.Is(err, apperr.ErrRepeated) {
  666. t.Fatalf("同 request_id+type 二次写入应映射为 3114 幂等哨兵,实得: %v", err)
  667. }
  668. }
  669. // TestAmountOverflowRejectedBeforeWrite AC-04 的落库负例:
  670. // 超出 decimal(12,2) 全域的金额必须在**写库前**被 `Amount.Validate()` 拦下(ErrOverflow),
  671. // 且账户零残留(不是"写了个截断值")。
  672. func TestAmountOverflowRejectedBeforeWrite(t *testing.T) {
  673. db := newFundServiceDB(t)
  674. svc := NewFundService(db, fundScope(), nil)
  675. ctx := context.Background()
  676. user := fundUserID(17)
  677. if err := db.Create(&model.UserExpense{UserID: user, Balance: "0.00"}).Error; err != nil {
  678. t.Fatalf("seed 全局账户失败: %v", err)
  679. }
  680. if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
  681. Balance: "0.00"}).Error; err != nil {
  682. t.Fatalf("seed 校区账户失败: %v", err)
  683. }
  684. overflow := money.MaxAmount + 1
  685. err := db.Transaction(func(tx *gorm.DB) error {
  686. _, e := svc.repo.AddColumnsGlobalThenCampus(ctx, tx, "OverflowTest", user, 1, 10,
  687. repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: overflow})
  688. return e
  689. })
  690. if !errors.Is(err, money.ErrOverflow) {
  691. t.Fatalf("越界金额应返回 money.ErrOverflow,实得: %v", err)
  692. }
  693. var appErr *infraerrors.AppError
  694. if !errors.As(err, &appErr) {
  695. t.Fatalf("越界金额应返回 *AppError,实得: %v", err)
  696. }
  697. // 仓储层(AC-03 既有形状)把越界金额挂在 2001 哨兵上;Service 层对**入参**金额用 2002
  698. // (见 validatePositiveAmount)。此处断言的是"落库前被拦下"这一红约束,两层都属 2xxx 参数错。
  699. if appErr.Code != infraerrors.ErrBadParam.Code && appErr.Code != infraerrors.ErrBadFormat.Code {
  700. t.Fatalf("越界金额应映射为 2xxx 参数错,实得 %d: %v", appErr.Code, err)
  701. }
  702. if got := cents(t, readGlobal(t, db, user).Balance); got != 0 {
  703. t.Fatalf("越界金额不得落库(余额应仍 0),实得 %d", got)
  704. }
  705. }