bill_service_test.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. // BillService 单测(AC-10):账单汇总 / 明细分页 / 三种比较语义并存 / 赠送额回退口径。
  2. //
  3. // 断言纪律(对照 §5.1 纪律 4「横切优先」):
  4. // 1. **账单恒等式**(跨字段):`EndBalance − PrevBalance == FlowExclusiveEnd`,
  5. // 且 `EndBalance` 必须与 **`t_user_expense.balance` 账户表**一致(跨表)——
  6. // 只断言"字段非空"或"条数对了"的实现,在"漏算某个 type / 符号写反"时依然全绿。
  7. // 2. **两种时间口径并存**(dump 的既有不对称,不得统一):
  8. // `FlowInclusiveEnd − FlowExclusiveEnd == 恰好落在 t = To 的那一笔`,
  9. // 且该笔**计入**「当期发生额」(BETWEEN 含右端)而**不计入**「期末余额」(`< To`)。
  10. // 3. **期初/期末不随分页变化**:翻页只影响明细。
  11. // 4. **赠送额三段链**:`GiveUsed = Gift + GivePrevBalance − GiveEndBalance`(dump 764194)。
  12. package service
  13. import (
  14. "context"
  15. "path/filepath"
  16. "reflect"
  17. "testing"
  18. "time"
  19. gormsqlite "github.com/glebarez/sqlite"
  20. "gorm.io/gorm"
  21. "yuansiwei/unified-backend/internal/academic/model"
  22. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  23. )
  24. // ---------------------------------------------------------------- 辅助
  25. // newBillServiceDB 账单域测试库(流水 4 表 + 全局钱包)。
  26. func newBillServiceDB(t *testing.T) *gorm.DB {
  27. t.Helper()
  28. dsn := filepath.Join(t.TempDir(), "academic_bill_service_test.db")
  29. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  30. if err != nil {
  31. t.Fatalf("gorm.Open(账单域文件库) 失败: %v", err)
  32. }
  33. sqlDB, err := db.DB()
  34. if err != nil {
  35. t.Fatalf("取 sql.DB 失败: %v", err)
  36. }
  37. sqlDB.SetMaxOpenConns(1)
  38. t.Cleanup(func() { _ = sqlDB.Close() })
  39. migrateFundTables(t, db,
  40. &model.UserExpense{}, &model.UserExpenseLog{}, &model.UserBalanceRequest{},
  41. &model.CoursesActivitySigned{}, &model.CoursesActivityUsedLog{},
  42. )
  43. return db
  44. }
  45. // seedBillLog 落一行资金流水(logID 为 nil 表示旧库 `logid IS NULL`)。
  46. func seedBillLog(t *testing.T, db *gorm.DB, userID string, logType int32,
  47. amount string, createTime time.Time, logID *string) {
  48. t.Helper()
  49. row := &model.UserExpenseLog{
  50. UserID: userID, Type: logType, Money: amount, CreateTime: createTime, LogID: logID,
  51. }
  52. if err := db.Create(row).Error; err != nil {
  53. t.Fatalf("seed 流水(type=%d) 失败: %v", logType, err)
  54. }
  55. }
  56. // seedBillAccount 落一行全局钱包(**余额必须与流水累计一致**,否则跨表恒等式用例失去意义)。
  57. func seedBillAccount(t *testing.T, db *gorm.DB, userID, balance string) {
  58. t.Helper()
  59. row := &model.UserExpense{UserID: userID, Balance: balance}
  60. if err := db.Create(row).Error; err != nil {
  61. t.Fatalf("seed 全局钱包 失败: %v", err)
  62. }
  63. }
  64. // seedBillRechargeRequest 落一行已通过的普通充值工单(赠送额链路的 join 起点)。
  65. func seedBillRechargeRequest(t *testing.T, db *gorm.DB, bizNo, userID string, responseTime time.Time) {
  66. t.Helper()
  67. approved := model.BalanceRequestApprovedPassed
  68. respAt := responseTime
  69. row := &model.UserBalanceRequest{
  70. BizNo: bizNo, Type: model.BalanceRequestTypeRecharge, UserID: userID,
  71. Approved: approved, ResponseTime: &respAt,
  72. }
  73. if err := db.Create(row).Error; err != nil {
  74. t.Fatalf("seed 充值工单 失败: %v", err)
  75. }
  76. }
  77. // seedBillSigned 落一行活动报名(赠送额来源)。
  78. func seedBillSigned(t *testing.T, db *gorm.DB, activityID int64, userID, activityMoney, requestNo string) {
  79. t.Helper()
  80. no := requestNo
  81. row := &model.CoursesActivitySigned{
  82. ActivityID: activityID, UserID: userID, ActivityMoney: activityMoney, RequestNo: &no,
  83. }
  84. if err := db.Create(row).Error; err != nil {
  85. t.Fatalf("seed 活动报名 失败: %v", err)
  86. }
  87. }
  88. // seedBillUsed 落一行老带新获得/使用记录。
  89. func seedBillUsed(t *testing.T, db *gorm.DB, activityID int64, userID, used string, createTime time.Time) {
  90. t.Helper()
  91. row := &model.CoursesActivityUsedLog{
  92. ActivityID: activityID, UserID: userID, ActivityMoneyUsed: used, CreateTime: createTime,
  93. }
  94. if err := db.Create(row).Error; err != nil {
  95. t.Fatalf("seed used_log 失败: %v", err)
  96. }
  97. }
  98. // billFixture 标准账单种子(返回值即"按 dump 手算"的期望,用例逐字段比对)。
  99. //
  100. // 时间轴(window: from=2026-03-01T00:00, to=2026-04-01T00:00):
  101. //
  102. // 02-20 +500.00 type=100 log=REQ-A ← 只看得到"期初"
  103. // 03-05 -100.00 type=101 ← 当期消费
  104. // 03-10 used_log(9001) -20.00 ← 赠送额使用
  105. // 03-31 +30.00 type=107 ← 当期推荐额
  106. // 04-01 +200.00 type=100 log=REQ-B ← **恰好落在 to**:进"当期发生额"、不进"期末"
  107. //
  108. // 请求工单:REQ-A response_time=03-02(区间内,但**没有区间内的 type=100 流水指向它**)→ 不参与赠送;
  109. //
  110. // REQ-B response_time=04-01(BETWEEN 含右端,命中)→ signed(9001, 50.00) 计入当期赠送。
  111. func billFixture(t *testing.T) (*gorm.DB, *BillService, string, time.Time, time.Time) {
  112. t.Helper()
  113. db := newBillServiceDB(t)
  114. userID := fundUserID(900)
  115. reqA, reqB := "REQ-A", "REQ-B"
  116. seedBillLog(t, db, userID, model.ExpenseLogTypeRecharge, "500.00",
  117. time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), &reqA)
  118. seedBillLog(t, db, userID, model.ExpenseLogTypeCourseCampus, "-100.00",
  119. time.Date(2026, 3, 5, 9, 0, 0, 0, time.UTC), nil)
  120. seedBillLog(t, db, userID, model.ExpenseLogTypeRecommend, "30.00",
  121. time.Date(2026, 3, 31, 18, 0, 0, 0, time.UTC), nil)
  122. seedBillLog(t, db, userID, model.ExpenseLogTypeRecharge, "200.00",
  123. time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), &reqB)
  124. // 账户余额 = Σ全部流水 = 500 − 100 + 30 + 200
  125. seedBillAccount(t, db, userID, "630.00")
  126. seedBillRechargeRequest(t, db, "REQ-A", userID, time.Date(2026, 3, 2, 8, 0, 0, 0, time.UTC))
  127. seedBillRechargeRequest(t, db, "REQ-B", userID, time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC))
  128. seedBillSigned(t, db, 9001, userID, "50.00", "REQ-B")
  129. seedBillUsed(t, db, 9001, userID, "-20.00", time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC))
  130. svc := NewBillService(db, fundSuperScope())
  131. from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
  132. to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
  133. return db, svc, userID, from, to
  134. }
  135. // ---------------------------------------------------------------- 汇总口径(dump 764168-764194)
  136. // TestGetBillSummaryMatchesDump 主用例:9 个汇总列逐个对齐 dump 的 SELECT 顺序与口径。
  137. func TestGetBillSummaryMatchesDump(t *testing.T) {
  138. _, svc, userID, from, to := billFixture(t)
  139. res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to})
  140. if err != nil {
  141. t.Fatalf("GetBill 失败: %v", err)
  142. }
  143. s := res.Summary
  144. // 764176 期初 / 764174 期末(均 `< 边界`)
  145. if got, want := s.PrevBalance.String(), "500.00"; got != want {
  146. t.Errorf("期初余额 = %s,期望 %s", got, want)
  147. }
  148. if got, want := s.EndBalance.String(), "430.00"; got != want {
  149. t.Errorf("期末余额 = %s,期望 %s(04-01 那笔因 `< to` 不计入)", got, want)
  150. }
  151. // 764170 当期推荐额 / 764172 当期消费 / 充值(764168 游标)
  152. if got, want := s.RecommendAmount.String(), "30.00"; got != want {
  153. t.Errorf("当期推荐额 = %s,期望 %s", got, want)
  154. }
  155. if got, want := s.Spend.String(), "-100.00"; got != want {
  156. t.Errorf("当期消费 = %s,期望 %s", got, want)
  157. }
  158. if got, want := s.Recharge.String(), "200.00"; got != want {
  159. t.Errorf("当期充值 = %s,期望 %s(BETWEEN 含右端,04-01 计入)", got, want)
  160. }
  161. // 赠送额三段链(764178-764194)
  162. if got, want := s.Gift.String(), "50.00"; got != want {
  163. t.Errorf("当期赠送额 = %s,期望 %s", got, want)
  164. }
  165. if got, want := s.GivePrevBalance.String(), "0.00"; got != want {
  166. t.Errorf("期初赠送结余 = %s,期望 %s(RIGHT JOIN 使'无 used_log 的活动'不进结余)", got, want)
  167. }
  168. if got, want := s.GiveEndBalance.String(), "30.00"; got != want {
  169. t.Errorf("期末赠送结余 = %s,期望 %s(signed 50 + used −20)", got, want)
  170. }
  171. if got, want := s.GiveUsed.String(), "20.00"; got != want {
  172. t.Errorf("当期使用赠送 = %s,期望 %s(= 赠送 50 + 上期 0 − 期末 30)", got, want)
  173. }
  174. }
  175. // TestGetBillIdentityAcrossAccountTable 横切断言 ①:期末余额 == 账户表余额(跨表自证)。
  176. //
  177. // 取 to 覆盖**全部流水**的窗口:此时"期末余额"就是账户快照。
  178. // 若实现漏算某个 type、或把某类流水符号写反,本用例立刻红(dump 的账户余额正是 Σ全部流水)。
  179. func TestGetBillIdentityAcrossAccountTable(t *testing.T) {
  180. db, svc, userID, from, _ := billFixture(t)
  181. var account model.UserExpense
  182. if err := db.Where("user_id = ?", userID).Take(&account).Error; err != nil {
  183. t.Fatalf("读全局钱包失败: %v", err)
  184. }
  185. to := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) // 覆盖 04-01 那笔
  186. res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to})
  187. if err != nil {
  188. t.Fatalf("GetBill 失败: %v", err)
  189. }
  190. if res.Summary.EndBalance != mustAmount(t, account.Balance) {
  191. t.Fatalf("期末余额 %s != 账户表余额 %s", res.Summary.EndBalance.String(), account.Balance)
  192. }
  193. // 恒等式:期末 − 期初 == 窗口内(左闭右开)全类型流水合计
  194. delta := res.Summary.EndBalance.Sub(res.Summary.PrevBalance)
  195. if delta != res.Summary.FlowExclusiveEnd {
  196. t.Fatalf("恒等式破:期末−期初 = %s,窗口内流水 = %s",
  197. delta.String(), res.Summary.FlowExclusiveEnd.String())
  198. }
  199. if got, want := res.Summary.Gift.String(), "50.00"; got != want {
  200. t.Errorf("当期赠送额 = %s,期望 %s", got, want)
  201. }
  202. }
  203. // TestGetBillTimeBoundaryDiffersByExactlyOneFlow 横切断言 ②:两种比较语义并在 `t = To` 处差一笔。
  204. //
  205. // 断言三件事(缺任何一条,实现都可能"统一成一种语义"而全绿):
  206. // 1. `FlowInclusiveEnd − FlowExclusiveEnd` == 恰好那一笔(+200.00);
  207. // 2. `EndBalance − PrevBalance` == `FlowExclusiveEnd`(**不含**该笔);
  208. // 3. 该笔**计入**「当期充值」(BETWEEN),因此 Recharge 与 (期末−期初) 天然对不上。
  209. func TestGetBillTimeBoundaryDiffersByExactlyOneFlow(t *testing.T) {
  210. _, svc, userID, from, to := billFixture(t)
  211. res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to})
  212. if err != nil {
  213. t.Fatalf("GetBill 失败: %v", err)
  214. }
  215. s := res.Summary
  216. if got, want := s.FlowExclusiveEnd.String(), "-70.00"; got != want {
  217. t.Errorf("窗口内(左闭右开)流水 = %s,期望 %s", got, want)
  218. }
  219. if got, want := s.FlowInclusiveEnd.String(), "130.00"; got != want {
  220. t.Errorf("窗口内(两端闭)流水 = %s,期望 %s", got, want)
  221. }
  222. diff := s.FlowInclusiveEnd.Sub(s.FlowExclusiveEnd)
  223. if got, want := diff.String(), "200.00"; got != want {
  224. t.Fatalf("两种口径的差额 = %s,期望 %s(必须恰好等于落在 t=to 的那笔)", got, want)
  225. }
  226. if delta := s.EndBalance.Sub(s.PrevBalance); delta != s.FlowExclusiveEnd {
  227. t.Fatalf("期末−期初(%s) != FlowExclusiveEnd(%s)", delta.String(), s.FlowExclusiveEnd.String())
  228. }
  229. }
  230. // TestGetBillSummaryInvariantAcrossPagination 横切断言 ③:翻页只影响明细(汇总逐字段不变)。
  231. //
  232. // v1.4 起明细**按窗口过滤**(`from <= create_time < to`),故 fixture 窗口内只有 2 笔
  233. // (03-05 消费、03-31 推荐);pageSize=2 时第 1 页满、第 2 页空——这正好同时验证
  234. // "翻页不改变汇总"与"total 与 list 同口径"(total=2 而非历史 4 笔)。
  235. func TestGetBillSummaryInvariantAcrossPagination(t *testing.T) {
  236. _, svc, userID, from, to := billFixture(t)
  237. ctx := context.Background()
  238. page1, err := svc.GetBill(ctx, GetBillInput{UserID: userID, From: from, To: to, Page: 1, PageSize: 2})
  239. if err != nil {
  240. t.Fatalf("第 1 页失败: %v", err)
  241. }
  242. page2, err := svc.GetBill(ctx, GetBillInput{UserID: userID, From: from, To: to, Page: 2, PageSize: 2})
  243. if err != nil {
  244. t.Fatalf("第 2 页失败: %v", err)
  245. }
  246. if !reflect.DeepEqual(page1.Summary, page2.Summary) {
  247. t.Fatalf("汇总随分页变化(必须不随分页变化):\n第1页 %+v\n第2页 %+v", page1.Summary, page2.Summary)
  248. }
  249. if page1.Total != 2 || page2.Total != 2 {
  250. t.Fatalf("明细总数 = %d/%d,期望 2/2(**窗口内**流水,不是历史 4 笔)", page1.Total, page2.Total)
  251. }
  252. if len(page1.Details) != 2 || len(page2.Details) != 0 {
  253. t.Fatalf("分页失效:len=%d/%d,期望第 1 页满 2 条、第 2 页 0 条", len(page1.Details), len(page2.Details))
  254. }
  255. // 明细按 `create_time DESC, id DESC`:窗口内倒序应是 03-31(107) → 03-05(101)
  256. if page1.Details[0].Type != model.ExpenseLogTypeRecommend || page1.Details[0].Money.String() != "30.00" {
  257. t.Fatalf("第 1 页首行应为 03-31 的 107/+30.00,实得 %+v", page1.Details[0])
  258. }
  259. if page1.Details[1].Type != model.ExpenseLogTypeCourseCampus {
  260. t.Fatalf("第 1 页次行应为 03-05 的 101,实得 %+v", page1.Details[1])
  261. }
  262. }
  263. // TestGetBillDetailsRespectWindow 明细必须受查询窗口约束(v1.4):右开,且与 FlowExclusiveEnd 同口径。
  264. //
  265. // 三笔流水分别在窗口前(02-20)、窗口内(03-05/03-31)、窗口右端点上(04-01 = `to`):
  266. // - 窗口前那笔**不得**出现在明细(否则前端选"3 月"会看到 2 月流水);
  267. // - 落在 `t = to` 的那笔**不得**出现在明细(右开),但它**计入** `FlowInclusiveEnd`
  268. // —— 这就是 summary 与明细之间唯一的、且被显式下发的口径差。
  269. func TestGetBillDetailsRespectWindow(t *testing.T) {
  270. _, svc, userID, from, to := billFixture(t)
  271. res, err := svc.GetBill(context.Background(), GetBillInput{
  272. UserID: userID, From: from, To: to, Page: 1, PageSize: 10,
  273. })
  274. if err != nil {
  275. t.Fatalf("GetBill 失败: %v", err)
  276. }
  277. if len(res.Details) != 2 {
  278. t.Fatalf("窗口内明细应为 2 条,实得 %d 条:%+v", len(res.Details), res.Details)
  279. }
  280. for _, d := range res.Details {
  281. if d.CreateTime.Before(from) {
  282. t.Fatalf("明细含**窗口前**流水(%s < from %s)", d.CreateTime, from)
  283. }
  284. if !d.CreateTime.Before(to) {
  285. t.Fatalf("明细含**窗口右端点**流水(%s >= to %s):右开窗口必须排除", d.CreateTime, to)
  286. }
  287. if d.LogID == "REQ-A" || d.LogID == "REQ-B" {
  288. t.Fatalf("明细含窗口外工单流水 %s(REQ-A 在窗口前、REQ-B 恰在 to)", d.LogID)
  289. }
  290. }
  291. // 口径差显式可见:右端点那笔进 inclusive、不进明细
  292. diff := res.Summary.FlowInclusiveEnd.Sub(res.Summary.FlowExclusiveEnd)
  293. if got, want := diff.String(), "200.00"; got != want {
  294. t.Fatalf("inclusive−exclusive = %s,期望 %s(= 落 to 的那笔,它不进明细)", got, want)
  295. }
  296. }
  297. // TestGetBillCampusIDsIsParsedOnlyNotFiltered dump 第二入参是死参数:只解析回显、不过滤。
  298. //
  299. // 用 "1,2,3" 与 "" 两次查询比对汇总**完全一致**——若有人"顺手实现"了校区过滤,
  300. // 本用例立刻红(这会静默改变账单口径,见 bill_service.go 文件头)。
  301. func TestGetBillCampusIDsIsParsedOnlyNotFiltered(t *testing.T) {
  302. _, svc, userID, from, to := billFixture(t)
  303. ctx := context.Background()
  304. plain, err := svc.GetBill(ctx, GetBillInput{UserID: userID, From: from, To: to})
  305. if err != nil {
  306. t.Fatalf("无 campusIds 查询失败: %v", err)
  307. }
  308. withCampus, err := svc.GetBill(ctx, GetBillInput{
  309. UserID: userID, CampusIDs: "10, 20", From: from, To: to,
  310. })
  311. if err != nil {
  312. t.Fatalf("带 campusIds 查询失败: %v", err)
  313. }
  314. if !reflect.DeepEqual(withCampus.Summary.CampusIDs, []int64{10, 20}) {
  315. t.Fatalf("campusIds 回显 = %v,期望 [10 20]", withCampus.Summary.CampusIDs)
  316. }
  317. withCampus.Summary.CampusIDs = plain.Summary.CampusIDs
  318. if !reflect.DeepEqual(plain.Summary, withCampus.Summary) {
  319. t.Fatalf("campusIds 影响了汇总(dump 里它是死参数,不得过滤):\n无 %+v\n有 %+v",
  320. plain.Summary, withCampus.Summary)
  321. }
  322. if plain.Total != withCampus.Total {
  323. t.Fatalf("campusIds 影响了明细条数:%d vs %d", plain.Total, withCampus.Total)
  324. }
  325. }
  326. // TestGetBillEmptyAccount 无流水的学生:全部汇总为 0、明细为空切片(不是 null)。
  327. func TestGetBillEmptyAccount(t *testing.T) {
  328. db := newBillServiceDB(t)
  329. svc := NewBillService(db, fundSuperScope())
  330. res, err := svc.GetBill(context.Background(), GetBillInput{
  331. UserID: fundUserID(901),
  332. From: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
  333. To: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC),
  334. })
  335. if err != nil {
  336. t.Fatalf("GetBill 失败: %v", err)
  337. }
  338. if res.Details == nil {
  339. t.Fatal("明细为 nil,应为长度 0 的切片")
  340. }
  341. if len(res.Details) != 0 || res.Total != 0 {
  342. t.Fatalf("无流水却返回明细 %d 条 / total=%d", len(res.Details), res.Total)
  343. }
  344. if !res.Summary.EndBalance.IsZero() || !res.Summary.PrevBalance.IsZero() ||
  345. !res.Summary.Gift.IsZero() || !res.Summary.GiveUsed.IsZero() {
  346. t.Fatalf("无流水时汇总应全 0,实得 %+v", res.Summary)
  347. }
  348. if res.Page != 1 || res.PageSize != 10 {
  349. t.Fatalf("分页默认值应为 (1,10),实得 (%d,%d)", res.Page, res.PageSize)
  350. }
  351. }
  352. // TestGetBillRejectsBadParams 入参守卫(2001);非法 campusIds 必须报错而不是"当没传"。
  353. func TestGetBillRejectsBadParams(t *testing.T) {
  354. db := newBillServiceDB(t)
  355. svc := NewBillService(db, fundSuperScope())
  356. ctx := context.Background()
  357. from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
  358. to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
  359. cases := []struct {
  360. name string
  361. in GetBillInput
  362. }{
  363. {"缺 user_id", GetBillInput{From: from, To: to}},
  364. {"缺 from", GetBillInput{UserID: fundUserID(1), To: to}},
  365. {"缺 to", GetBillInput{UserID: fundUserID(1), From: from}},
  366. {"from 晚于 to", GetBillInput{UserID: fundUserID(1), From: to, To: from}},
  367. {"campusIds 非法片段", GetBillInput{UserID: fundUserID(1), CampusIDs: "1,a", From: from, To: to}},
  368. {"campusIds 非正数", GetBillInput{UserID: fundUserID(1), CampusIDs: "0", From: from, To: to}},
  369. }
  370. for _, tc := range cases {
  371. t.Run(tc.name, func(t *testing.T) {
  372. _, err := svc.GetBill(ctx, tc.in)
  373. wantAppErr(t, err, infraerrors.CodeBadParam, tc.name)
  374. })
  375. }
  376. }
  377. // TestGetBillDetailTitleAndMoney 明细展示字段:两位小数与类型名(金额不得输出浮点)。
  378. func TestGetBillDetailTitleAndMoney(t *testing.T) {
  379. _, svc, userID, from, to := billFixture(t)
  380. res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to, PageSize: 10})
  381. if err != nil {
  382. t.Fatalf("GetBill 失败: %v", err)
  383. }
  384. wantTitles := map[int32]string{
  385. model.ExpenseLogTypeRecharge: "充值",
  386. model.ExpenseLogTypeCourseCampus: "课程消费(校区)",
  387. model.ExpenseLogTypeRecommend: "老带新推荐",
  388. }
  389. if len(res.Details) == 0 {
  390. t.Fatal("窗口内应有明细,实得 0 条")
  391. }
  392. for _, d := range res.Details {
  393. if want, ok := wantTitles[d.Type]; ok && d.TypeTitle != want {
  394. t.Errorf("type=%d 标题 = %q,期望 %q", d.Type, d.TypeTitle, want)
  395. }
  396. if d.Money.String() != "200.00" && d.Money.String() != "-100.00" &&
  397. d.Money.String() != "30.00" && d.Money.String() != "500.00" {
  398. t.Errorf("明细金额格式异常: %s(应两位小数)", d.Money.String())
  399. }
  400. }
  401. // 窗口内倒序首行 = 03-31 的 107(logId 为空),末行 = 03-05 的 101
  402. if res.Details[0].Type != model.ExpenseLogTypeRecommend {
  403. t.Errorf("首行 type = %d,期望 107(窗口内倒序首行)", res.Details[0].Type)
  404. }
  405. if res.Details[len(res.Details)-1].Type != model.ExpenseLogTypeCourseCampus {
  406. t.Errorf("末行 type = %d,期望 101", res.Details[len(res.Details)-1].Type)
  407. }
  408. }
  409. // TestBillServiceErrorCodeContract DB 未装配时必须 5001(不能把配置错误伪装成业务错误)。
  410. func TestBillServiceErrorCodeContract(t *testing.T) {
  411. svc := NewBillService(nil, fundSuperScope())
  412. _, err := svc.GetBill(context.Background(), GetBillInput{
  413. UserID: fundUserID(1),
  414. From: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
  415. To: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC),
  416. })
  417. wantAppErr(t, err, infraerrors.CodeInternal, "DB 未装配")
  418. }