fund_service_test.go 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147
  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/port"
  28. "yuansiwei/unified-backend/internal/academic/repository"
  29. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  30. appscope "yuansiwei/unified-backend/internal/infra/scope"
  31. )
  32. // ---------------------------------------------------------------- 测试基础设施
  33. // sqlRecorder GORM logger:按执行顺序收集 SQL(不改写、不过滤)。
  34. //
  35. // 用途:断言"全局账户锁先于校区账户锁"这类**顺序性**红约束(顺序错 = 与并发充值交叉即成环)。
  36. // 只实现 logger.Interface 的最小集合(Trace 收到 Explain 后的完整 SQL)。
  37. type sqlRecorder struct {
  38. mu sync.Mutex
  39. stmts []string
  40. }
  41. // Printf 收口(logger.Writer 兼容方法)。
  42. func (r *sqlRecorder) Printf(format string, args ...any) {
  43. msg := strings.TrimSpace(fmt.Sprintf(format, args...))
  44. if msg == "" {
  45. return
  46. }
  47. r.mu.Lock()
  48. defer r.mu.Unlock()
  49. r.stmts = append(r.stmts, msg)
  50. }
  51. // LogMode 实现 logger.Interface(不做级别过滤)。
  52. func (r *sqlRecorder) LogMode(logger.LogLevel) logger.Interface { return r }
  53. // Info 实现 logger.Interface(单测不需要普通日志)。
  54. func (r *sqlRecorder) Info(context.Context, string, ...any) {}
  55. // Warn 实现 logger.Interface。
  56. func (r *sqlRecorder) Warn(context.Context, string, ...any) {}
  57. // Error 实现 logger.Interface。
  58. func (r *sqlRecorder) Error(context.Context, string, ...any) {}
  59. // Trace 实现 logger.Interface:把本次语句追加进 stmts。
  60. func (r *sqlRecorder) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
  61. sql, _ := fc()
  62. r.Printf("%s", sql)
  63. }
  64. // Snapshot 返回当前收集到的语句快照(调用方不得修改返回切片内部元素语义)。
  65. func (r *sqlRecorder) Snapshot() []string {
  66. r.mu.Lock()
  67. defer r.mu.Unlock()
  68. out := make([]string, len(r.stmts))
  69. copy(out, r.stmts)
  70. return out
  71. }
  72. // Reset 清空收集。
  73. func (r *sqlRecorder) Reset() {
  74. r.mu.Lock()
  75. defer r.mu.Unlock()
  76. r.stmts = nil
  77. }
  78. // migrateFundTables 逐表 AutoMigrate,并**只删除非唯一索引**。
  79. //
  80. // 为什么只删非唯一:SQLite 索引名库级唯一(`idx_inst_campus` 同时出现在
  81. // t_user_expense_school 与 t_courses_activity_signed),一次性迁移会报 already exists;
  82. // 但 `uk_request_type` / `uk_user_activity` / `uk_user_inst_campus` 是**幂等与唯一性的实现载体**,
  83. // 删掉会让"并发重复提交只落 1 条流水"的用例变成永远通过的假绿。
  84. func migrateFundTables(t *testing.T, db *gorm.DB, tables ...any) {
  85. t.Helper()
  86. for _, tbl := range tables {
  87. if err := db.AutoMigrate(tbl); err != nil {
  88. t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err)
  89. }
  90. stmt := &gorm.Statement{DB: db}
  91. if err := stmt.Parse(tbl); err != nil {
  92. t.Fatalf("解析表名(%T) 失败: %v", tbl, err)
  93. }
  94. type idxRow struct {
  95. Name string
  96. SQL *string
  97. }
  98. var rows []idxRow
  99. if err := db.Raw(
  100. "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
  101. stmt.Schema.Table).Scan(&rows).Error; err != nil {
  102. t.Fatalf("读取索引清单(%s) 失败: %v", stmt.Schema.Table, err)
  103. }
  104. for _, row := range rows {
  105. sqlText := ""
  106. if row.SQL != nil {
  107. sqlText = strings.ToUpper(*row.SQL)
  108. }
  109. if strings.Contains(sqlText, "UNIQUE") {
  110. continue // 唯一约束必须保留(幂等键/业务 UK)
  111. }
  112. if err := db.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; err != nil {
  113. t.Fatalf("删除索引(%s) 失败: %v", row.Name, err)
  114. }
  115. }
  116. }
  117. }
  118. // newFundServiceDB 余额域测试库(余额 3 表 + 活动 2 表 + 简化 t_campus)。
  119. //
  120. // t_campus 用原生 DDL 建(academic 侧只有 port 的只读 row struct,不参与 AutoMigrate,
  121. // 这正是设计 §1.3「跨域只读」的落地形态:academic 不建 org 的表,只读)。
  122. func newFundServiceDB(t *testing.T) *gorm.DB {
  123. t.Helper()
  124. db, _ := newFundServiceDBWithRecorder(t)
  125. return db
  126. }
  127. // newFundServiceDBWithRecorder 同 newFundServiceDB,并返回 SQL 记录器。
  128. func newFundServiceDBWithRecorder(t *testing.T) (*gorm.DB, *sqlRecorder) {
  129. t.Helper()
  130. dsn := filepath.Join(t.TempDir(), "academic_fund_service_test.db")
  131. rec := &sqlRecorder{}
  132. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{Logger: rec})
  133. if err != nil {
  134. t.Fatalf("gorm.Open(余额域文件库) 失败: %v", err)
  135. }
  136. sqlDB, err := db.DB()
  137. if err != nil {
  138. t.Fatalf("取 sql.DB 失败: %v", err)
  139. }
  140. // 并发用例需要"多 goroutine 抢同一库":SQLite 写事务必须串行,
  141. // 单连接池即可让 20 个 goroutine 依次推进(生产 MySQL 是行锁串行,此处用连接池串行等价模拟)。
  142. sqlDB.SetMaxOpenConns(1)
  143. t.Cleanup(func() { _ = sqlDB.Close() })
  144. migrateFundTables(t, db,
  145. &model.UserExpense{}, &model.UserExpenseSchool{}, &model.UserExpenseLog{},
  146. &model.CoursesActivity{}, &model.CoursesActivitySigned{},
  147. // t_user_balance_request:SP5 可退额第二步(已用赠送额)要按「充值工单 × 活动报名」汇总,
  148. // 缺表会让 SP5 用例退化成"永远查不到赠送额"的假绿。
  149. &model.UserBalanceRequest{},
  150. )
  151. // 跨域只读表(org 域 t_campus 的最小列集;self_support 为 1=自营 / 0=加盟)
  152. if err := db.Exec(`CREATE TABLE t_campus (
  153. campus_id INTEGER PRIMARY KEY,
  154. institution_id INTEGER NOT NULL DEFAULT 0,
  155. self_support BOOLEAN NOT NULL DEFAULT 1,
  156. status VARCHAR(32) NOT NULL DEFAULT 'active'
  157. )`).Error; err != nil {
  158. t.Fatalf("建 t_campus 失败: %v", err)
  159. }
  160. return db, rec
  161. }
  162. // fundUserID 生成 26 位类 ULID 的测试用户 ID(列宽 varchar(26))。
  163. func fundUserID(i int) string { return fmt.Sprintf("01HUSER%019d", i) }
  164. // fundScope 构造普通校区作用域(机构 1 / 校区 10)。
  165. func fundScope() *appscope.Scope {
  166. return &appscope.Scope{InstitutionID: 1, CampusID: 10, UserID: "01HOPERATOR0000000000001", Role: "finance"}
  167. }
  168. // fundScopeFor 构造指定机构/校区的作用域(跨机构用例)。
  169. func fundScopeFor(institutionID, campusID int64) *appscope.Scope {
  170. return &appscope.Scope{InstitutionID: institutionID, CampusID: campusID, UserID: "01HOPERATOR0000000000001", Role: "finance"}
  171. }
  172. // fundSuperScope 平台超管作用域(跨机构视角,用于"必须能同时看到两个机构"的用例)。
  173. func fundSuperScope() *appscope.Scope {
  174. return &appscope.Scope{InstitutionID: 1, CampusID: 0, UserID: "01HSUPERADMIN0000000001", Role: "super_admin", SuperAdmin: true}
  175. }
  176. // mustAmount 解析金额(测试内固定字面量)。
  177. func mustAmount(t *testing.T, s string) money.Amount {
  178. t.Helper()
  179. a, err := money.Parse(s)
  180. if err != nil {
  181. t.Fatalf("金额解析失败 %q: %v", s, err)
  182. }
  183. return a
  184. }
  185. // seedCampus 落一行 t_campus(self_support: true=自营 / false=加盟)。
  186. func seedCampus(t *testing.T, db *gorm.DB, campusID, institutionID int64, selfSupport bool) {
  187. t.Helper()
  188. if err := db.Exec(
  189. "INSERT INTO t_campus(campus_id, institution_id, self_support, status) VALUES(?,?,?,?)",
  190. campusID, institutionID, selfSupport, "active").Error; err != nil {
  191. t.Fatalf("seed t_campus(%d) 失败: %v", campusID, err)
  192. }
  193. }
  194. // readGlobal 读全局钱包。
  195. func readGlobal(t *testing.T, db *gorm.DB, userID string) model.UserExpense {
  196. t.Helper()
  197. var row model.UserExpense
  198. if err := db.Where("user_id = ?", userID).Take(&row).Error; err != nil {
  199. t.Fatalf("读全局账户(%s) 失败: %v", userID, err)
  200. }
  201. return row
  202. }
  203. // readSchool 读校区账户。
  204. func readSchool(t *testing.T, db *gorm.DB, userID string, institutionID, campusID int64) model.UserExpenseSchool {
  205. t.Helper()
  206. var row model.UserExpenseSchool
  207. if err := db.Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID).
  208. Take(&row).Error; err != nil {
  209. t.Fatalf("读校区账户(%s/%d/%d) 失败: %v", userID, institutionID, campusID, err)
  210. }
  211. return row
  212. }
  213. // logsByType 取某用户的指定 type 流水(按 id ASC)。
  214. func logsByType(t *testing.T, db *gorm.DB, userID string, typ int32) []model.UserExpenseLog {
  215. t.Helper()
  216. var rows []model.UserExpenseLog
  217. if err := db.Where("user_id = ? AND type = ?", userID, typ).Order("id ASC").Find(&rows).Error; err != nil {
  218. t.Fatalf("读流水(type=%d) 失败: %v", typ, err)
  219. }
  220. return rows
  221. }
  222. // countLogs 统计某用户指定 type 的流水条数。
  223. func countLogsByType(t *testing.T, db *gorm.DB, userID string, typ int32) int {
  224. t.Helper()
  225. return len(logsByType(t, db, userID, typ))
  226. }
  227. // ---------------------------------------------------------------- 充值(SP1 / SP2)
  228. // TestRechargeUpdatesBothTables 充值双表同事务:全局 + 校区 balance/total_income 同步,流水含 balance_after。
  229. func TestRechargeUpdatesBothTables(t *testing.T) {
  230. db := newFundServiceDB(t)
  231. svc := NewFundService(db, fundScope(), nil)
  232. ctx := context.Background()
  233. user := fundUserID(1)
  234. res, err := svc.Recharge(ctx, RechargeInput{
  235. UserID: user, InstitutionID: 1, CampusID: 10,
  236. Money: mustAmount(t, "100.00"), RequestID: "REQ-RECHARGE-1",
  237. })
  238. if err != nil {
  239. t.Fatalf("Recharge 失败: %v", err)
  240. }
  241. if res.Repeated {
  242. t.Fatalf("首次充值不应 repeated=true")
  243. }
  244. if res.LogID <= 0 {
  245. t.Fatalf("logId 应 > 0,实得 %d", res.LogID)
  246. }
  247. if got := cents(t, res.Balance.String()); got != 10000 {
  248. t.Fatalf("返回余额=%d 分,期望 10000", got)
  249. }
  250. global := readGlobal(t, db, user)
  251. if got := cents(t, global.Balance); got != 10000 {
  252. t.Fatalf("全局 balance=%d 分,期望 10000", got)
  253. }
  254. if got := cents(t, global.TotalIncome); got != 10000 {
  255. t.Fatalf("全局 total_income=%d 分,期望 10000", got)
  256. }
  257. school := readSchool(t, db, user, 1, 10)
  258. if got := cents(t, school.Balance); got != 10000 {
  259. t.Fatalf("校区 balance=%d 分,期望 10000", got)
  260. }
  261. if got := cents(t, school.TotalIncome); got != 10000 {
  262. t.Fatalf("校区 total_income=%d 分,期望 10000", got)
  263. }
  264. logs := logsByType(t, db, user, model.ExpenseLogTypeRecharge)
  265. if len(logs) != 1 {
  266. t.Fatalf("充值流水应为 1 条,实得 %d", len(logs))
  267. }
  268. if got := cents(t, logs[0].Money); got != 10000 {
  269. t.Fatalf("流水 money=%d 分,期望 10000(方向为正)", got)
  270. }
  271. if logs[0].RequestID == nil || *logs[0].RequestID != "REQ-RECHARGE-1" {
  272. t.Fatalf("流水 request_id 未落:%+v", logs[0].RequestID)
  273. }
  274. if logs[0].BalanceAfter == nil || cents(t, *logs[0].BalanceAfter) != 10000 {
  275. t.Fatalf("流水 balance_after 快照错误:%+v", logs[0].BalanceAfter)
  276. }
  277. if logs[0].Remark == nil || *logs[0].Remark != defaultRechargeRemark {
  278. t.Fatalf("流水 remark 默认文案错误:%+v", logs[0].Remark)
  279. }
  280. }
  281. // TestRechargeKindSetsSchoolRemark SP1 / SP2 的唯一差异:默认备注文案。
  282. func TestRechargeKindSetsSchoolRemark(t *testing.T) {
  283. db := newFundServiceDB(t)
  284. svc := NewFundService(db, fundScope(), nil)
  285. ctx := context.Background()
  286. user := fundUserID(2)
  287. if _, err := svc.Recharge(ctx, RechargeInput{
  288. UserID: user, InstitutionID: 1, CampusID: 10,
  289. Money: mustAmount(t, "10.00"), RequestID: "REQ-KIND-1", Kind: RechargeKindSchool,
  290. }); err != nil {
  291. t.Fatalf("Recharge(Kind=School) 失败: %v", err)
  292. }
  293. logs := logsByType(t, db, user, model.ExpenseLogTypeRecharge)
  294. if len(logs) != 1 || logs[0].Remark == nil || *logs[0].Remark != defaultSchoolRechargeRemark {
  295. t.Fatalf("SP2 默认备注应为 %q,实得 %+v", defaultSchoolRechargeRemark, logs)
  296. }
  297. }
  298. // TestRechargeIdempotentSameRequestID 同 requestId 第二次提交 → repeated=true、不重复入账。
  299. func TestRechargeIdempotentSameRequestID(t *testing.T) {
  300. db := newFundServiceDB(t)
  301. svc := NewFundService(db, fundScope(), nil)
  302. ctx := context.Background()
  303. user := fundUserID(3)
  304. in := RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  305. Money: mustAmount(t, "50.00"), RequestID: "REQ-IDEMPOTENT-1"}
  306. first, err := svc.Recharge(ctx, in)
  307. if err != nil {
  308. t.Fatalf("首次充值失败: %v", err)
  309. }
  310. second, err := svc.Recharge(ctx, in)
  311. if err != nil {
  312. t.Fatalf("重复充值应返回成功(幂等),实得错误: %v", err)
  313. }
  314. if !second.Repeated {
  315. t.Fatalf("重复提交应 repeated=true")
  316. }
  317. if second.LogID != first.LogID {
  318. t.Fatalf("重复提交应回放首次 logId=%d,实得 %d", first.LogID, second.LogID)
  319. }
  320. if count := countLogsByType(t, db, user, model.ExpenseLogTypeRecharge); count != 1 {
  321. t.Fatalf("流水应只 1 条,实得 %d", count)
  322. }
  323. if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 5000 {
  324. t.Fatalf("余额应只变一次(5000 分),实得 %d", got)
  325. }
  326. }
  327. // TestRechargeConcurrentSameRequestID 20 goroutine 同 requestId → 1 条流水、余额只变一次。
  328. //
  329. // SQLite 无行锁,用单连接池把写事务串行化(等价于 MySQL 的行锁串行语义);
  330. // 断言点与生产一致:**流水条数**与**余额增量**(不是"是否报错")。
  331. func TestRechargeConcurrentSameRequestID(t *testing.T) {
  332. db := newFundServiceDB(t)
  333. svc := NewFundService(db, fundScope(), nil)
  334. ctx := context.Background()
  335. user := fundUserID(4)
  336. in := RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  337. Money: mustAmount(t, "30.00"), RequestID: "REQ-CONCURRENT-1"}
  338. const goroutines = 20
  339. var wg sync.WaitGroup
  340. results := make([]*RechargeResult, goroutines)
  341. errs := make([]error, goroutines)
  342. for i := 0; i < goroutines; i++ {
  343. wg.Add(1)
  344. go func(idx int) {
  345. defer wg.Done()
  346. results[idx], errs[idx] = svc.Recharge(ctx, in)
  347. }(i)
  348. }
  349. wg.Wait()
  350. for i, err := range errs {
  351. if err != nil {
  352. t.Fatalf("并发第 %d 次充值失败: %v", i, err)
  353. }
  354. }
  355. if count := countLogsByType(t, db, user, model.ExpenseLogTypeRecharge); count != 1 {
  356. t.Fatalf("并发同 requestId 应只 1 条流水,实得 %d", count)
  357. }
  358. if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 3000 {
  359. t.Fatalf("余额应只入账一次(3000 分),实得 %d", got)
  360. }
  361. repeated := 0
  362. for _, r := range results {
  363. if r != nil && r.Repeated {
  364. repeated++
  365. }
  366. }
  367. if repeated != goroutines-1 {
  368. t.Fatalf("应有 %d 次 repeated=true,实得 %d", goroutines-1, repeated)
  369. }
  370. }
  371. // TestRechargeValidation 入参校验:空用户 2001 / 校区缺失 3140 / 金额<=0 与超上限 2002。
  372. func TestRechargeValidation(t *testing.T) {
  373. db := newFundServiceDB(t)
  374. svc := NewFundService(db, fundScope(), nil)
  375. ctx := context.Background()
  376. cases := []struct {
  377. name string
  378. in RechargeInput
  379. want int
  380. }{
  381. {"空用户", RechargeInput{UserID: "", InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "1.00")}, 2001},
  382. {"校区缺失", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 0, Money: mustAmount(t, "1.00")}, 3140},
  383. {"机构缺失", RechargeInput{UserID: fundUserID(5), InstitutionID: 0, CampusID: 10, Money: mustAmount(t, "1.00")}, 3140},
  384. {"金额为 0", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10, Money: 0}, 2002},
  385. {"金额为负", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "-1.00")}, 2002},
  386. {"超单笔上限", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10,
  387. Money: mustAmount(t, "1000000.01")}, 2002},
  388. }
  389. for _, tc := range cases {
  390. t.Run(tc.name, func(t *testing.T) {
  391. _, err := svc.Recharge(ctx, tc.in)
  392. requireAppCode(t, err, tc.want)
  393. })
  394. }
  395. }
  396. // ---------------------------------------------------------------- 消费(SP3)
  397. // TestExpenditureTypeBranches dump 的两个列组合分支 + 流水方向为负。
  398. func TestExpenditureTypeBranches(t *testing.T) {
  399. db := newFundServiceDB(t)
  400. svc := NewFundService(db, fundScope(), nil)
  401. ctx := context.Background()
  402. user := fundUserID(6)
  403. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  404. Money: mustAmount(t, "100.00")}); err != nil {
  405. t.Fatalf("准备余额失败: %v", err)
  406. }
  407. // type=200(历史扣费):balance−、total_expenditure+、spend+
  408. if _, err := svc.Expenditure(ctx, ExpenditureInput{UserID: user, InstitutionID: 1, CampusID: 10,
  409. Money: mustAmount(t, "30.00"), Type: model.ExpenseLogTypeHistoryDeduct, RequestID: "REQ-EXP-200"}); err != nil {
  410. t.Fatalf("Expenditure(200) 失败: %v", err)
  411. }
  412. school := readSchool(t, db, user, 1, 10)
  413. if got := cents(t, school.Balance); got != 7000 {
  414. t.Fatalf("200 后 balance 应 7000 分,实得 %d", got)
  415. }
  416. if got := cents(t, school.TotalExpenditure); got != 3000 {
  417. t.Fatalf("200 后 total_expenditure 应 3000 分,实得 %d", got)
  418. }
  419. if got := cents(t, school.Spend); got != 3000 {
  420. t.Fatalf("200 后 spend 应 3000 分,实得 %d", got)
  421. }
  422. // type=201(校区报课消费):balance−、total_expenditure+,**spend 不动**
  423. if _, err := svc.Expenditure(ctx, ExpenditureInput{UserID: user, InstitutionID: 1, CampusID: 10,
  424. Money: mustAmount(t, "20.00"), Type: model.ExpenseLogTypeCourseCampusB, RequestID: "REQ-EXP-201"}); err != nil {
  425. t.Fatalf("Expenditure(201) 失败: %v", err)
  426. }
  427. school = readSchool(t, db, user, 1, 10)
  428. if got := cents(t, school.Balance); got != 5000 {
  429. t.Fatalf("201 后 balance 应 5000 分,实得 %d", got)
  430. }
  431. if got := cents(t, school.TotalExpenditure); got != 5000 {
  432. t.Fatalf("201 后 total_expenditure 应 5000 分,实得 %d", got)
  433. }
  434. if got := cents(t, school.Spend); got != 3000 {
  435. t.Fatalf("201 不应改 spend(应仍 3000 分),实得 %d", got)
  436. }
  437. // 全局表同列同步
  438. global := readGlobal(t, db, user)
  439. if got := cents(t, global.Balance); got != 5000 {
  440. t.Fatalf("全局 balance 应 5000 分,实得 %d", got)
  441. }
  442. if got := cents(t, global.TotalExpenditure); got != 5000 {
  443. t.Fatalf("全局 total_expenditure 应 5000 分,实得 %d", got)
  444. }
  445. // 流水金额恒为负(dump: `VALUES(...,-inputMoney,...)`)
  446. logs := logsByType(t, db, user, model.ExpenseLogTypeHistoryDeduct)
  447. if len(logs) != 1 {
  448. t.Fatalf("200 流水应 1 条,实得 %d", len(logs))
  449. }
  450. if got := cents(t, logs[0].Money); got != -3000 {
  451. t.Fatalf("200 流水 money 应 -3000 分,实得 %d", got)
  452. }
  453. }
  454. // TestExpenditureInvalidType 白名单外 type → 2002(dump 会静默改成 200,Go 显式拒绝)。
  455. func TestExpenditureInvalidType(t *testing.T) {
  456. db := newFundServiceDB(t)
  457. svc := NewFundService(db, fundScope(), nil)
  458. _, err := svc.Expenditure(context.Background(), ExpenditureInput{
  459. UserID: fundUserID(7), InstitutionID: 1, CampusID: 10,
  460. Money: mustAmount(t, "10.00"), Type: 999,
  461. })
  462. requireAppCode(t, err, 2002)
  463. }
  464. // ---------------------------------------------------------------- 退费(SP4,BUG#2)
  465. // TestRefundInstitutionFilterPreventsCrossInstitutionUpdate BUG#2 回归:
  466. // 同一 campus_id 在两个机构都有账户行时,只允许更新入参机构那一行。
  467. //
  468. // dump 的 WHERE 是 `userid + campusid`(无 schoolid)→ 两行都会被改;Go 补 institution_id。
  469. func TestRefundInstitutionFilterPreventsCrossInstitutionUpdate(t *testing.T) {
  470. db := newFundServiceDB(t)
  471. svc := NewFundService(db, fundSuperScope(), nil) // 超管视角:底层两行都可见
  472. ctx := context.Background()
  473. user := fundUserID(8)
  474. // 两个机构、同一个 campus_id=10
  475. for _, inst := range []int64{1, 2} {
  476. if err := db.Create(&model.UserExpenseSchool{
  477. UserID: user, InstitutionID: inst, CampusID: 10,
  478. Balance: "100.00", TotalExpenditure: "50.00",
  479. }).Error; err != nil {
  480. t.Fatalf("seed 校区账户(inst=%d) 失败: %v", inst, err)
  481. }
  482. }
  483. if err := db.Create(&model.UserExpense{UserID: user, Balance: "200.00", TotalExpenditure: "50.00"}).Error; err != nil {
  484. t.Fatalf("seed 全局账户失败: %v", err)
  485. }
  486. res, err := svc.Refund(ctx, RefundInput{
  487. UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "20.00"),
  488. Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-REFUND-1",
  489. })
  490. if err != nil {
  491. t.Fatalf("Refund 失败: %v", err)
  492. }
  493. if !res.Changed {
  494. t.Fatalf("Refund 应 changed=true")
  495. }
  496. if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 12000 {
  497. t.Fatalf("目标机构 balance 应 12000 分,实得 %d", got)
  498. }
  499. // 另一机构必须零变动(BUG#2 的核心断言)
  500. if got := cents(t, readSchool(t, db, user, 2, 10).Balance); got != 10000 {
  501. t.Fatalf("另一机构 balance 必须不变(10000 分),实得 %d", got)
  502. }
  503. if got := cents(t, readSchool(t, db, user, 1, 10).TotalExpenditure); got != 3000 {
  504. t.Fatalf("目标机构 total_expenditure 应 3000 分(退费回冲),实得 %d", got)
  505. }
  506. logs := logsByType(t, db, user, model.ExpenseLogTypeCourseCampus)
  507. if len(logs) != 1 || cents(t, logs[0].Money) != 2000 {
  508. t.Fatalf("退费流水应为 +2000 分一条,实得 %+v", logs)
  509. }
  510. }
  511. // TestRefundRejectsNonPositiveMoney R6(§3.8-b):money<=0 → **2002**,且不写任何流水。
  512. //
  513. // 口径变更说明:dump(SP4)对 `inputMoney<=0` 是 `IF inputMoney>0` 静默跳过(本次之前实现为
  514. // changed=false 的 no-op),v0.1.14 的 R6 把五处入口(recharge/expenditure/baddebt/refund/transfer)
  515. // 统一为显式 2002,故此处期望值由"成功 + changed=false"改为错误码 2002。
  516. func TestRefundRejectsNonPositiveMoney(t *testing.T) {
  517. db := newFundServiceDB(t)
  518. svc := NewFundService(db, fundScope(), nil)
  519. user := fundUserID(9)
  520. for _, amount := range []string{"0.00", "-1.00"} {
  521. _, err := svc.Refund(context.Background(), RefundInput{
  522. UserID: user, InstitutionID: 1, CampusID: 10,
  523. Money: mustAmount(t, amount), Type: model.ExpenseLogTypeCourseCampus,
  524. })
  525. requireAppCode(t, err, 2002)
  526. }
  527. if count := countLogsByType(t, db, user, model.ExpenseLogTypeCourseCampus); count != 0 {
  528. t.Fatalf("非法金额不得写流水,实得 %d 条", count)
  529. }
  530. if count := countLogsByType(t, db, user, model.ExpenseLogTypeRecharge); count != 0 {
  531. t.Fatalf("非法金额不得写充值流水,实得 %d 条", count)
  532. }
  533. }
  534. // TestNonPositiveMoneyRejectedEverywhere R6 的**五处全覆盖**(charge/expenditure/baddebt/refund/transfer):
  535. // money<=0 → 2002,且**零副作用**(不建户、不改账户、不占 `(request_id,type)` 幂等键)。
  536. //
  537. // 为什么要显式钉"不占幂等键":dump 的 recharge/expenditure/baddebt 分支会把负值**归 0 后照常记账**,
  538. // 留下 money=0 的流水并把 `(request_id,type)` 占死,导致"同键后续真金额被幂等跳过"(资金静默丢失)。
  539. func TestNonPositiveMoneyRejectedEverywhere(t *testing.T) {
  540. db := newFundServiceDB(t)
  541. svc := NewFundService(db, fundSuperScope(), nil)
  542. ctx := context.Background()
  543. user := fundUserID(26)
  544. // 预置账户,确保"零副作用"的断言不是"账户本来就不存在"导致的假绿
  545. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  546. Money: mustAmount(t, "100.00"), RequestID: "REQ-R6-SEED"}); err != nil {
  547. t.Fatalf("准备余额失败: %v", err)
  548. }
  549. before := readSchool(t, db, user, 1, 10)
  550. logsBefore := len(balanceLogs(t, db, user))
  551. cases := []struct {
  552. name string
  553. call func(amount money.Amount) error
  554. }{
  555. {"recharge", func(a money.Amount) error {
  556. _, e := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  557. Money: a, RequestID: "REQ-R6-RECHARGE"})
  558. return e
  559. }},
  560. {"expenditure", func(a money.Amount) error {
  561. _, e := svc.Expenditure(ctx, ExpenditureInput{UserID: user, InstitutionID: 1, CampusID: 10,
  562. Money: a, Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-R6-EXPEND"})
  563. return e
  564. }},
  565. {"baddebt", func(a money.Amount) error {
  566. _, e := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
  567. Money: a, RequestID: "REQ-R6-BADDEBT"})
  568. return e
  569. }},
  570. {"refund", func(a money.Amount) error {
  571. _, e := svc.Refund(ctx, RefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
  572. Money: a, Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-R6-REFUND"})
  573. return e
  574. }},
  575. {"transfer", func(a money.Amount) error {
  576. _, e := svc.Transfer(ctx, TransferInput{UserID: user,
  577. FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20,
  578. Money: a, RequestID: "REQ-R6-TRANSFER"})
  579. return e
  580. }},
  581. }
  582. for _, amount := range []string{"0.00", "-10.00"} {
  583. for _, c := range cases {
  584. err := c.call(mustAmount(t, amount))
  585. if err == nil {
  586. t.Fatalf("%s(%s) 应返回 2002,实得成功", c.name, amount)
  587. }
  588. requireAppCode(t, err, 2002)
  589. }
  590. }
  591. after := readSchool(t, db, user, 1, 10)
  592. if cents(t, after.Balance) != cents(t, before.Balance) ||
  593. cents(t, after.TotalIncome) != cents(t, before.TotalIncome) ||
  594. cents(t, after.TotalExpenditure) != cents(t, before.TotalExpenditure) ||
  595. cents(t, after.SpecialExpenses) != cents(t, before.SpecialExpenses) {
  596. t.Fatalf("R6 拒绝的请求不得改动账户:before=%+v after=%+v", before, after)
  597. }
  598. if got := len(balanceLogs(t, db, user)); got != logsBefore {
  599. t.Fatalf("R6 拒绝的请求不得写流水(幂等键不得被占):before=%d after=%d", logsBefore, got)
  600. }
  601. // 幂等键未被占死:同键 + 真金额必须能正常入账
  602. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  603. Money: mustAmount(t, "10.00"), RequestID: "REQ-R6-RECHARGE"}); err != nil {
  604. t.Fatalf("被拒请求不得占死幂等键,同键真金额应能入账: %v", err)
  605. }
  606. if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 11000 {
  607. t.Fatalf("真金额入账后余额应 11000 分,实得 %d", got)
  608. }
  609. }
  610. // TestRefundAccountMissing 账户未建户 → 3001(dump 静默无操作;Go 显式失败)。
  611. func TestRefundAccountMissing(t *testing.T) {
  612. db := newFundServiceDB(t)
  613. svc := NewFundService(db, fundScope(), nil)
  614. _, err := svc.Refund(context.Background(), RefundInput{
  615. UserID: fundUserID(10), InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "5.00"),
  616. Type: model.ExpenseLogTypeCourseCampus,
  617. })
  618. requireAppCode(t, err, 3001)
  619. }
  620. // ---------------------------------------------------------------- 可退金额(SP5,两步修正)
  621. // seedExpenseAccounts 落全局 + 校区两个账户行(SP5 第一步截断需要两张表同时有值)。
  622. func seedExpenseAccounts(t *testing.T, db *gorm.DB, userID, campusBalance, globalBalance string) {
  623. t.Helper()
  624. if err := db.Create(&model.UserExpense{UserID: userID, Balance: globalBalance}).Error; err != nil {
  625. t.Fatalf("seed 全局账户(%s) 失败: %v", userID, err)
  626. }
  627. if err := db.Create(&model.UserExpenseSchool{UserID: userID, InstitutionID: 1, CampusID: 10,
  628. Balance: campusBalance}).Error; err != nil {
  629. t.Fatalf("seed 校区账户(%s) 失败: %v", userID, err)
  630. }
  631. }
  632. // seedGiftUsed 落一对「充值工单 + 活动报名」行(SP5 第二步「已用赠送额」的唯一数据来源)。
  633. //
  634. // 关键映射(照 dump 改写的依据):工单的 `id`(旧 UUID)→ 目标态 `biz_no`;
  635. // 报名的 `requestid` → 目标态 `request_no`;JOIN 走 `biz_no = request_no`。
  636. func seedGiftUsed(t *testing.T, db *gorm.DB, userID, bizNo string, activityID int64,
  637. approved int8, activityMoney, activityMoneyUsed string) {
  638. t.Helper()
  639. if err := db.Create(&model.UserBalanceRequest{
  640. BizNo: bizNo, Type: model.BalanceRequestTypeRecharge, UserID: userID,
  641. InstitutionID: 1, CampusID: 10, Approved: approved,
  642. }).Error; err != nil {
  643. t.Fatalf("seed 充值工单(%s) 失败: %v", bizNo, err)
  644. }
  645. reqNo := bizNo
  646. if err := db.Create(&model.CoursesActivitySigned{
  647. ActivityID: activityID, UserID: userID, InstitutionID: 1, CampusID: 10,
  648. ActivityMoney: activityMoney, ActivityMoneyUsed: activityMoneyUsed, RequestNo: &reqNo,
  649. }).Error; err != nil {
  650. t.Fatalf("seed 活动报名(%s) 失败: %v", bizNo, err)
  651. }
  652. }
  653. // TestRefundableAmount SP5 门禁用例(§8 AC-04 ⑦ 的 5 组表驱动 + 退化分支)。
  654. //
  655. // 五组必须区分得开"单步实现"与"三步实现"(原 case 组区分不出,正是错口径活到交付的原因):
  656. //
  657. // ① 基线无赠送 → 只有"读余额"也能过
  658. // ② 截断 → 只有实现 ① 截断才能过(campus 200 > total 120)
  659. // ③ 减项 → 只有实现 ③ 减赠送额才能过(100 − 20 = 80)
  660. // ④ 归零 → 减项后为负必须取 0(不是负数透出)
  661. // ⑤ 截断+减项叠加 → 只有两步都实现且**顺序正确**才能过(min(200,120) − 30 = 90;
  662. // 若先减后截、或漏截断,会得到 170 / 150 / 70 等错值)
  663. func TestRefundableAmount(t *testing.T) {
  664. const (
  665. inst = int64(1)
  666. camp = int64(10)
  667. )
  668. type gift struct {
  669. BizNo string
  670. Activity int64
  671. Approved int8
  672. Money string
  673. MoneyUsed string
  674. }
  675. cases := []struct {
  676. name string
  677. campusBal *string // nil = 校区账户不存在
  678. globalBal *string // nil = 全局账户不存在
  679. gifts []gift
  680. wantCents int64
  681. }{
  682. {name: "①基线_无赠送", campusBal: strPtr("88.50"), globalBal: strPtr("100.00"), wantCents: 8850},
  683. {name: "②截断_campus>total", campusBal: strPtr("200.00"), globalBal: strPtr("120.00"), wantCents: 12000},
  684. {name: "③减项_赠送已用", campusBal: strPtr("100.00"), globalBal: strPtr("150.00"),
  685. gifts: []gift{{"BR-SP5-3", 3, model.BalanceRequestApprovedPassed, "50.00", "20.00"}},
  686. wantCents: 8000},
  687. {name: "④归零_赠送>截断基数", campusBal: strPtr("50.00"), globalBal: strPtr("50.00"),
  688. gifts: []gift{{"BR-SP5-4", 4, model.BalanceRequestApprovedPassed, "90.00", "80.00"}},
  689. wantCents: 0},
  690. {name: "⑤截断+减项叠加", campusBal: strPtr("200.00"), globalBal: strPtr("120.00"),
  691. gifts: []gift{{"BR-SP5-5", 5, model.BalanceRequestApprovedPassed, "100.00", "30.00"}},
  692. wantCents: 9000},
  693. {name: "⑥赠送用尽不参与", campusBal: strPtr("100.00"), globalBal: strPtr("100.00"),
  694. gifts: []gift{{"BR-SP5-6", 6, model.BalanceRequestApprovedPassed, "50.00", "50.00"}},
  695. wantCents: 10000},
  696. {name: "⑦未审批不参与", campusBal: strPtr("100.00"), globalBal: strPtr("100.00"),
  697. gifts: []gift{{"BR-SP5-7", 7, model.BalanceRequestApprovedPending, "50.00", "20.00"}},
  698. wantCents: 10000},
  699. {name: "⑧多笔赠送累加", campusBal: strPtr("100.00"), globalBal: strPtr("100.00"),
  700. gifts: []gift{
  701. {"BR-SP5-8a", 8, model.BalanceRequestApprovedPassed, "50.00", "20.00"},
  702. {"BR-SP5-8b", 9, model.BalanceRequestApprovedPassed, "50.00", "10.00"},
  703. },
  704. wantCents: 7000},
  705. {name: "⑨校区账户不存在", globalBal: strPtr("100.00"), wantCents: 0},
  706. {name: "⑩全局账户不存在", campusBal: strPtr("50.00"), wantCents: 0},
  707. {name: "⑪校区欠费", campusBal: strPtr("-25.00"), globalBal: strPtr("100.00"), wantCents: 0},
  708. {name: "⑫全局非正", campusBal: strPtr("50.00"), globalBal: strPtr("0.00"), wantCents: 0},
  709. }
  710. db := newFundServiceDB(t)
  711. svc := NewFundService(db, fundScope(), nil)
  712. ctx := context.Background()
  713. for i, c := range cases {
  714. user := fundUserID(200 + i)
  715. if c.globalBal != nil {
  716. if err := db.Create(&model.UserExpense{UserID: user, Balance: *c.globalBal}).Error; err != nil {
  717. t.Fatalf("%s: seed 全局账户失败: %v", c.name, err)
  718. }
  719. }
  720. if c.campusBal != nil {
  721. if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: inst, CampusID: camp,
  722. Balance: *c.campusBal}).Error; err != nil {
  723. t.Fatalf("%s: seed 校区账户失败: %v", c.name, err)
  724. }
  725. }
  726. for _, g := range c.gifts {
  727. seedGiftUsed(t, db, user, g.BizNo, g.Activity, g.Approved, g.Money, g.MoneyUsed)
  728. }
  729. got, err := svc.RefundableAmount(ctx, user, inst, camp)
  730. if err != nil {
  731. t.Fatalf("%s: RefundableAmount 失败: %v", c.name, err)
  732. }
  733. if got.Cents() != c.wantCents {
  734. t.Fatalf("%s: 实得 %d 分,期望 %d 分", c.name, got.Cents(), c.wantCents)
  735. }
  736. if got < 0 {
  737. t.Fatalf("%s: 可退额不得为负,实得 %d", c.name, got.Cents())
  738. }
  739. }
  740. // campusID <= 0 → 3140(入参校验,与账户数据无关)
  741. if _, err := svc.RefundableAmount(ctx, fundUserID(200), inst, 0); err != nil {
  742. requireAppCode(t, err, 3140)
  743. }
  744. // 越界口径提醒:可退额只做"查询",不因赠送额而改动账户(只读断言)
  745. if got := readSchool(t, db, fundUserID(201), inst, camp).Balance; cents(t, got) != 20000 {
  746. t.Fatalf("RefundableAmount 必须只读(②用例校区余额应仍 20000 分),实得 %s", got)
  747. }
  748. }
  749. // strPtr 取字符串指针(表驱动里表达"该账户存在且余额为 X")。
  750. func strPtr(s string) *string { return &s }
  751. // ---------------------------------------------------------------- 坏账(SP7,双加)
  752. // TestBadDebtAddsBalanceAndSpecialExpenses dump 口径「余额+,特殊费用+」:
  753. // **两列同增**(全局 + 校区都是),108 流水为正。
  754. func TestBadDebtAddsBalanceAndSpecialExpenses(t *testing.T) {
  755. db := newFundServiceDB(t)
  756. svc := NewFundService(db, fundScope(), nil)
  757. ctx := context.Background()
  758. user := fundUserID(14)
  759. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  760. Money: mustAmount(t, "100.00")}); err != nil {
  761. t.Fatalf("准备余额失败: %v", err)
  762. }
  763. res, err := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
  764. Money: mustAmount(t, "30.00")})
  765. if err != nil {
  766. t.Fatalf("BadDebt 失败: %v", err)
  767. }
  768. // 校区:balance 100 → 130(双加),special_expenses 0 → 30
  769. school := readSchool(t, db, user, 1, 10)
  770. if got := cents(t, school.Balance); got != 13000 {
  771. t.Fatalf("校区 balance 应 10000+3000=13000 分(dump 双加),实得 %d", got)
  772. }
  773. if got := cents(t, school.SpecialExpenses); got != 3000 {
  774. t.Fatalf("校区 special_expenses 应 3000 分,实得 %d", got)
  775. }
  776. // 全局:同样双加
  777. global := readGlobal(t, db, user)
  778. if got := cents(t, global.Balance); got != 13000 {
  779. t.Fatalf("全局 balance 应 13000 分(双表同步双加),实得 %d", got)
  780. }
  781. if got := cents(t, global.SpecialExpenses); got != 3000 {
  782. t.Fatalf("全局 special_expenses 应 3000 分,实得 %d", got)
  783. }
  784. // 返回体:余额已是 +Money 之后的值
  785. if res.Balance.Cents() != 13000 || res.SpecialExpenses.Cents() != 3000 {
  786. t.Fatalf("返回体应 balance=13000/special=3000,实得 %d/%d",
  787. res.Balance.Cents(), res.SpecialExpenses.Cents())
  788. }
  789. // 流水:108 = +3000 一条
  790. logs := logsByType(t, db, user, model.ExpenseLogTypeBadDebt)
  791. if len(logs) != 1 || cents(t, logs[0].Money) != 3000 {
  792. t.Fatalf("108 流水应为 +3000 分一条,实得 %+v", logs)
  793. }
  794. }
  795. // ---------------------------------------------------------------- 账务恒等式(AC-17 ④ 口径)
  796. // balanceLogs 取某用户全部流水(按 id ASC)。
  797. func balanceLogs(t *testing.T, db *gorm.DB, userID string) []model.UserExpenseLog {
  798. t.Helper()
  799. var rows []model.UserExpenseLog
  800. if err := db.Where("user_id = ?", userID).Order("id ASC").Find(&rows).Error; err != nil {
  801. t.Fatalf("读全部流水(%s) 失败: %v", userID, err)
  802. }
  803. return rows
  804. }
  805. // sumLogMoney 流水金额**代数和**(Go 侧定点累加;禁 SQL SUM(decimal) 回 REAL)。
  806. func sumLogMoney(t *testing.T, rows []model.UserExpenseLog) money.Amount {
  807. t.Helper()
  808. total := money.Amount(0)
  809. for i := range rows {
  810. amount, err := money.Parse(rows[i].Money)
  811. if err != nil {
  812. t.Fatalf("流水(%d) 金额非法: %v", rows[i].ID, err)
  813. }
  814. total = total.Add(amount)
  815. }
  816. return total
  817. }
  818. // classifyBadDebtLogs 按 dump 兼容口径把 type=108 分成两类。
  819. //
  820. // 判别式(**唯一、可机械执行**,设计 §3.8-b (f) A18):取 108 流水的 `logid`(= `request_id`)——
  821. // - **同 logid 存在 type=207 且 logid 非空(`<> ”`)** → 差额型(`CustomRefund` 的退费差额):
  822. // 该 108 的金额已含在 207 的 −requested 全额里,**不构成**一次独立的余额增加;
  823. // - **不存在(含 logid 为空/NULL)** → 独立坏账(`FundService.BadDebt`):balance 与
  824. // special_expenses 双加,该 108 **就是**一次余额增加。
  825. func classifyBadDebtLogs(t *testing.T, db *gorm.DB, userID string) (
  826. independent, differential []model.UserExpenseLog,
  827. ) {
  828. t.Helper()
  829. all := balanceLogs(t, db, userID)
  830. refundKeys := make(map[string]bool)
  831. for i := range all {
  832. if all[i].Type != model.ExpenseLogTypeRefund {
  833. continue
  834. }
  835. if key := logKeyOf(all[i]); key != "" {
  836. refundKeys[key] = true
  837. }
  838. }
  839. for i := range all {
  840. if all[i].Type != model.ExpenseLogTypeBadDebt {
  841. continue
  842. }
  843. if key := logKeyOf(all[i]); key != "" && refundKeys[key] {
  844. differential = append(differential, all[i])
  845. continue
  846. }
  847. independent = append(independent, all[i])
  848. }
  849. return independent, differential
  850. }
  851. // logKeyOf 取流水的 logid(= request_id)原文;NULL/空 → ""(与 A18 判定 SQL 的 `log_id <> ”` 同口径)。
  852. func logKeyOf(row model.UserExpenseLog) string {
  853. if row.LogID == nil {
  854. return ""
  855. }
  856. return strings.TrimSpace(*row.LogID)
  857. }
  858. // TestBalanceIdentityIndependentBadDebtCounted 独立坏账(108 无 207 同伴)**参与**恒等式:
  859. // 余额增量恰等于该 108 的金额,且「balance = Σ全部流水」成立(旧口径下这里是唯一破口)。
  860. func TestBalanceIdentityIndependentBadDebtCounted(t *testing.T) {
  861. db := newFundServiceDB(t)
  862. svc := NewFundService(db, fundScope(), nil)
  863. ctx := context.Background()
  864. user := fundUserID(24)
  865. if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  866. Money: mustAmount(t, "100.00"), RequestID: "REQ-ID-BAL-RECHARGE"}); err != nil {
  867. t.Fatalf("准备余额失败: %v", err)
  868. }
  869. before := cents(t, readSchool(t, db, user, 1, 10).Balance)
  870. if before != 10000 {
  871. t.Fatalf("准备余额应为 10000 分,实得 %d", before)
  872. }
  873. if _, err := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
  874. Money: mustAmount(t, "30.00"), RequestID: "REQ-ID-BAL-BADDEBT"}); err != nil {
  875. t.Fatalf("BadDebt 失败: %v", err)
  876. }
  877. after := cents(t, readSchool(t, db, user, 1, 10).Balance)
  878. independent, differential := classifyBadDebtLogs(t, db, user)
  879. if len(independent) != 1 || len(differential) != 0 {
  880. t.Fatalf("独立坏账应 1 条且无差额型,实得 independent=%d differential=%d",
  881. len(independent), len(differential))
  882. }
  883. // ① 108 参与:余额增量 == 108 金额(+3000)
  884. if after-before != cents(t, independent[0].Money) {
  885. t.Fatalf("独立坏账应使余额 +%d 分,实得增量 %d",
  886. cents(t, independent[0].Money), after-before)
  887. }
  888. // ② 全局口径「balance = Σ全部流水」
  889. all := balanceLogs(t, db, user)
  890. if sum := sumLogMoney(t, all).Cents(); sum != after {
  891. t.Fatalf("恒等式 balance=Σ流水 不成立:Σ=%d balance=%d", sum, after)
  892. }
  893. }
  894. // TestBalanceIdentityDifferentialBadDebtExcluded 退费差额型(108 有 207 同伴)**不参与**
  895. // 「108 → 余额增量」的归因:坏账部分已含在 207 的 −requested 全额里,
  896. // 若把它再算一次独立余额增加,余额会凭空多出 badDebt(正是旧口径/新口径最容易混的一处)。
  897. func TestBalanceIdentityDifferentialBadDebtExcluded(t *testing.T) {
  898. db := newFundServiceDB(t)
  899. fund := NewFundService(db, fundSuperScope(), nil)
  900. svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
  901. ctx := context.Background()
  902. user := fundUserID(25)
  903. seedCampus(t, db, 10, 1, true)
  904. if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
  905. Money: mustAmount(t, "100.00"), RequestID: "REQ-ID-DIFF-RECHARGE"}); err != nil {
  906. t.Fatalf("准备余额失败: %v", err)
  907. }
  908. before := cents(t, readSchool(t, db, user, 1, 10).Balance)
  909. // 150 > 可退 100 → refunded 100、badDebt 50
  910. if _, err := svc.CustomRefund(ctx, CustomRefundInput{
  911. UserID: user, InstitutionID: 1, CampusID: 10,
  912. Money: mustAmount(t, "150.00"), RequestID: "REQ-ID-DIFF-REFUND",
  913. }); err != nil {
  914. t.Fatalf("CustomRefund 失败: %v", err)
  915. }
  916. after := cents(t, readSchool(t, db, user, 1, 10).Balance)
  917. independent, differential := classifyBadDebtLogs(t, db, user)
  918. if len(differential) != 1 || len(independent) != 0 {
  919. t.Fatalf("退费差额应 1 条差额型且无独立坏账,实得 independent=%d differential=%d",
  920. len(independent), len(differential))
  921. }
  922. logs207 := logsByType(t, db, user, model.ExpenseLogTypeRefund)
  923. if len(logs207) != 1 {
  924. t.Fatalf("207 应 1 条,实得 %d", len(logs207))
  925. }
  926. // ① 差额型 108 的 logid 必须与 207 相同(判别式的实现载体)
  927. if differential[0].LogID == nil || logs207[0].LogID == nil ||
  928. *differential[0].LogID != *logs207[0].LogID {
  929. t.Fatalf("差额型 108 应与 207 同 logid,实得 108=%+v 207=%+v",
  930. differential[0].LogID, logs207[0].LogID)
  931. }
  932. // ② 不参与:光看 207 就已经把这次余额变动记完了
  933. // before + 207 = after + badDebt > after → 差额恰是 badDebt,且必须靠 108 补平;
  934. // 但把该 108 再当成「一次独立余额增加」就会重复计入 → 合计比真实余额多一个 badDebt。
  935. onlyRefund := before + cents(t, logs207[0].Money)
  936. if onlyRefund == after {
  937. t.Fatalf("207 单独记账不应等于余额变化(差额型 108 必须存在):before=%d 207=%d after=%d",
  938. before, cents(t, logs207[0].Money), after)
  939. }
  940. pair := onlyRefund + cents(t, differential[0].Money)
  941. if pair != after {
  942. t.Fatalf("差额型 108 与 207 的代数和应等于净扣减:before(%d)+207(%d)+108(%d)=%d,after=%d",
  943. before, cents(t, logs207[0].Money), cents(t, differential[0].Money), pair, after)
  944. }
  945. if pair+cents(t, differential[0].Money) == after {
  946. t.Fatalf("差额型 108 不得再算一次独立的余额增加(重复计入会凭空多出 %d 分)",
  947. cents(t, differential[0].Money))
  948. }
  949. // ③ 但「balance = Σ全部流水」整体仍成立(108 与 207 成对出现,代数和 = 净扣减)
  950. all := balanceLogs(t, db, user)
  951. if sum := sumLogMoney(t, all).Cents(); sum != after {
  952. t.Fatalf("恒等式 balance=Σ流水 不成立:Σ=%d balance=%d", sum, after)
  953. }
  954. }
  955. // ---------------------------------------------------------------- 累计消费(SP11,BUG#1)
  956. // TestAccumulateSpendOnlySpend 只更新 spend(不动 balance),且**不写流水**;负数归 0。
  957. func TestAccumulateSpendOnlySpend(t *testing.T) {
  958. db := newFundServiceDB(t)
  959. svc := NewFundService(db, fundScope(), nil)
  960. ctx := context.Background()
  961. user := fundUserID(15)
  962. // 账户不存在时也应能建户并写 spend(dump 的 INSERT 分支;BUG#1 修正前该分支是 3 值对 2 列必报错)
  963. res, err := svc.AccumulateSpend(ctx, SpendInput{UserID: user, InstitutionID: 1, CampusID: 10,
  964. SpendMoney: mustAmount(t, "25.00")})
  965. if err != nil {
  966. t.Fatalf("AccumulateSpend 失败: %v", err)
  967. }
  968. if res.Spend.Cents() != 2500 {
  969. t.Fatalf("返回 spend 应 2500 分,实得 %d", res.Spend.Cents())
  970. }
  971. if got := cents(t, readSchool(t, db, user, 1, 10).Spend); got != 2500 {
  972. t.Fatalf("校区 spend 应 2500 分,实得 %d", got)
  973. }
  974. if got := cents(t, readGlobal(t, db, user).Spend); got != 2500 {
  975. t.Fatalf("全局 spend 应 2500 分,实得 %d", got)
  976. }
  977. if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 0 {
  978. t.Fatalf("spend SP 不得改 balance(应 0),实得 %d", got)
  979. }
  980. var total int64
  981. if err := db.Model(&model.UserExpenseLog{}).Where("user_id = ?", user).Count(&total).Error; err != nil {
  982. t.Fatalf("统计流水失败: %v", err)
  983. }
  984. if total != 0 {
  985. t.Fatalf("spend SP 不写流水(dump 无 INSERT log),实得 %d 条", total)
  986. }
  987. // 负数一律归 0(dump INSERT 分支同口径;spend 只增)
  988. res, err = svc.AccumulateSpend(ctx, SpendInput{UserID: user, InstitutionID: 1, CampusID: 10,
  989. SpendMoney: mustAmount(t, "-10.00")})
  990. if err != nil {
  991. t.Fatalf("AccumulateSpend(负数) 失败: %v", err)
  992. }
  993. if res.Spend.Cents() != 2500 {
  994. t.Fatalf("负数应被归 0(spend 仍 2500 分),实得 %d", res.Spend.Cents())
  995. }
  996. }
  997. // ---------------------------------------------------------------- 幂等键与金额越界(负例)
  998. // TestInsertLogDuplicateMapsToRepeated 撞 uk_request_type → 内部哨兵 3114(不是 5002)。
  999. func TestInsertLogDuplicateMapsToRepeated(t *testing.T) {
  1000. db := newFundServiceDB(t)
  1001. svc := NewFundService(db, fundScope(), nil)
  1002. ctx := context.Background()
  1003. user := fundUserID(16)
  1004. mk := func() *model.UserExpenseLog {
  1005. return newExpenseLog(user, model.ExpenseLogTypeRecharge, mustAmount(t, "1.00"),
  1006. "REQ-DUP-1", "dup", "", nil)
  1007. }
  1008. if err := db.Transaction(func(tx *gorm.DB) error {
  1009. return svc.insertLog(repository.WithTxContext(ctx, tx), tx, mk())
  1010. }); err != nil {
  1011. t.Fatalf("首次写流水失败: %v", err)
  1012. }
  1013. err := db.Transaction(func(tx *gorm.DB) error {
  1014. return svc.insertLog(repository.WithTxContext(ctx, tx), tx, mk())
  1015. })
  1016. if !errors.Is(err, apperr.ErrRepeated) {
  1017. t.Fatalf("同 request_id+type 二次写入应映射为 3114 幂等哨兵,实得: %v", err)
  1018. }
  1019. }
  1020. // TestAmountOverflowRejectedBeforeWrite AC-04 的落库负例:
  1021. // 超出 decimal(12,2) 全域的金额必须在**写库前**被 `Amount.Validate()` 拦下(ErrOverflow),
  1022. // 且账户零残留(不是"写了个截断值")。
  1023. func TestAmountOverflowRejectedBeforeWrite(t *testing.T) {
  1024. db := newFundServiceDB(t)
  1025. svc := NewFundService(db, fundScope(), nil)
  1026. ctx := context.Background()
  1027. user := fundUserID(17)
  1028. if err := db.Create(&model.UserExpense{UserID: user, Balance: "0.00"}).Error; err != nil {
  1029. t.Fatalf("seed 全局账户失败: %v", err)
  1030. }
  1031. if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
  1032. Balance: "0.00"}).Error; err != nil {
  1033. t.Fatalf("seed 校区账户失败: %v", err)
  1034. }
  1035. overflow := money.MaxAmount + 1
  1036. err := db.Transaction(func(tx *gorm.DB) error {
  1037. _, e := svc.repo.AddColumnsGlobalThenCampus(ctx, tx, "OverflowTest", user, 1, 10,
  1038. repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: overflow})
  1039. return e
  1040. })
  1041. if !errors.Is(err, money.ErrOverflow) {
  1042. t.Fatalf("越界金额应返回 money.ErrOverflow,实得: %v", err)
  1043. }
  1044. var appErr *infraerrors.AppError
  1045. if !errors.As(err, &appErr) {
  1046. t.Fatalf("越界金额应返回 *AppError,实得: %v", err)
  1047. }
  1048. // 仓储层(AC-03 既有形状)把越界金额挂在 2001 哨兵上;Service 层对**入参**金额用 2002
  1049. // (见 validatePositiveAmount)。此处断言的是"落库前被拦下"这一红约束,两层都属 2xxx 参数错。
  1050. if appErr.Code != infraerrors.ErrBadParam.Code && appErr.Code != infraerrors.ErrBadFormat.Code {
  1051. t.Fatalf("越界金额应映射为 2xxx 参数错,实得 %d: %v", appErr.Code, err)
  1052. }
  1053. if got := cents(t, readGlobal(t, db, user).Balance); got != 0 {
  1054. t.Fatalf("越界金额不得落库(余额应仍 0),实得 %d", got)
  1055. }
  1056. }