fund_handler_test.go 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952
  1. // Handler 单测(AC-12 六条验收:端点覆盖 / 2001 / 2002 / 1001 / 金额字符串 / 分页钳制 / 脱敏)。
  2. //
  3. // 断言纪律:只断 body 契约(HTTP 恒 200 + body.code,铁律 6),不断 HTTP 状态码。
  4. //
  5. // 覆盖结构(每条对到 AC-12 验收项原文):
  6. //
  7. // ① 端点 1~17、21 全覆盖 → TestRouteCoverage(18 条 method+path 全注册,且不含端点 18/19/20)
  8. // ② validateReq → 2001;金额非法 → 2002 → TestValidationCodes(两码不串)
  9. // ③ scope 取不到 → 1001 → TestMissingScopeIs1001
  10. // ④ 金额入参必须字符串 → TestMoneyMustBeJSONString(传 number → 2001,不被静默接受)
  11. // ⑤ pageSize>200 → 200 → TestPageSizeClamp(端点 11 响应 data.pageSize == 200)
  12. // ⑥ 响应不含敏感字段 → TestNoSensitiveFields(`imgs` / `payReturnData` 不出现)
  13. //
  14. // 另含 3 条业务冒烟(保证"端点通、且真的落到库里"):
  15. // 充值 → 余额增加;创建工单 → 审批通过 → 余额增加 + 幂等/二次审批被拒;账单 → 汇总恒等式。
  16. package handler
  17. import (
  18. "encoding/json"
  19. "fmt"
  20. "io"
  21. "net/http"
  22. "net/http/httptest"
  23. "path/filepath"
  24. "strings"
  25. "testing"
  26. "time"
  27. gormsqlite "github.com/glebarez/sqlite"
  28. "github.com/gofiber/fiber/v2"
  29. "gorm.io/gorm"
  30. "yuansiwei/unified-backend/internal/academic/model"
  31. "yuansiwei/unified-backend/internal/academic/money"
  32. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  33. appscope "yuansiwei/unified-backend/internal/infra/scope"
  34. "yuansiwei/unified-backend/internal/infra/tz"
  35. )
  36. // ---------------------------------------------------------------- 测试基础设施
  37. // testScope 测试用作用域(机构 1 / 校区 10 / 财务角色)。
  38. func testScope() *appscope.Scope {
  39. return &appscope.Scope{
  40. InstitutionID: 1, CampusID: 10,
  41. UserID: "01HOPERATOR0000000000001", Role: "finance",
  42. }
  43. }
  44. // testSuperScope 超管作用域(跨机构)。
  45. func testSuperScope() *appscope.Scope {
  46. return &appscope.Scope{InstitutionID: 1, CampusID: 0, SuperAdmin: true, UserID: "01HADMIN0000000000000001", Role: "super_admin"}
  47. }
  48. // newHandlerTestDB academic handler 测试库(资金 4 表 + 活动 5 表 + 简化 t_campus)。
  49. //
  50. // 与 service 包的单测库同构(逐表 AutoMigrate + 删非唯一索引:SQLite 索引名库级唯一,
  51. // 而 academic model 多表共用 `idx_inst_campus` 等名字)。
  52. func newHandlerTestDB(t *testing.T) *gorm.DB {
  53. t.Helper()
  54. dsn := filepath.Join(t.TempDir(), "academic_handler_test.db")
  55. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  56. if err != nil {
  57. t.Fatalf("gorm.Open(handler 文件库) 失败: %v", err)
  58. }
  59. sqlDB, err := db.DB()
  60. if err != nil {
  61. t.Fatalf("取 sql.DB 失败: %v", err)
  62. }
  63. sqlDB.SetMaxOpenConns(1)
  64. t.Cleanup(func() { _ = sqlDB.Close() })
  65. for _, tbl := range []any{
  66. &model.UserExpense{}, &model.UserExpenseSchool{}, &model.UserExpenseLog{},
  67. &model.UserBalanceRequest{},
  68. &model.CoursesActivity{}, &model.CoursesActivitySigned{},
  69. &model.CoursesActivityUserDiscount{}, &model.CoursesActivityUserDiscountLog{},
  70. &model.CoursesActivityUsedLog{},
  71. // 端点 37(回访)需要;AC-13b 追加(活动域 7 表中的第 7 张,其余 6 张已在此)。
  72. &model.CoursesActivityVisit{},
  73. } {
  74. if mErr := db.AutoMigrate(tbl); mErr != nil {
  75. t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, mErr)
  76. }
  77. stmt := &gorm.Statement{DB: db}
  78. if pErr := stmt.Parse(tbl); pErr != nil {
  79. t.Fatalf("解析表名(%T) 失败: %v", tbl, pErr)
  80. }
  81. var idxRows []struct {
  82. Name string
  83. SQL *string
  84. }
  85. if qErr := db.Raw(
  86. "SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name=? AND name NOT LIKE 'sqlite_%'",
  87. stmt.Schema.Table).Scan(&idxRows).Error; qErr != nil {
  88. t.Fatalf("读索引清单(%s) 失败: %v", stmt.Schema.Table, qErr)
  89. }
  90. for _, row := range idxRows {
  91. sqlText := ""
  92. if row.SQL != nil {
  93. sqlText = strings.ToUpper(*row.SQL)
  94. }
  95. if strings.Contains(sqlText, "UNIQUE") {
  96. continue // 唯一约束必须保留
  97. }
  98. if dErr := db.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; dErr != nil {
  99. t.Fatalf("删索引(%s) 失败: %v", row.Name, dErr)
  100. }
  101. }
  102. }
  103. if err := db.Exec(`CREATE TABLE t_campus (
  104. campus_id INTEGER PRIMARY KEY,
  105. institution_id INTEGER NOT NULL DEFAULT 0,
  106. self_support BOOLEAN NOT NULL DEFAULT 1,
  107. status VARCHAR(32) NOT NULL DEFAULT 'active'
  108. )`).Error; err != nil {
  109. t.Fatalf("建 t_campus 失败: %v", err)
  110. }
  111. if err := db.Exec(`INSERT INTO t_campus(campus_id, institution_id, self_support, status) VALUES(10,1,1,'active')`).Error; err != nil {
  112. t.Fatalf("seed t_campus 失败: %v", err)
  113. }
  114. return db
  115. }
  116. // newTestApp 构造只挂 academic 财务域端点(+ 作用域注入中间件)的 app。
  117. //
  118. // 中间件模拟 Auth 链:`X-Test-NoScope: 1` 时不写作用域(用于 1001 用例),
  119. // `X-Test-Super: 1` 时写超管作用域。
  120. func newTestApp(t *testing.T, db *gorm.DB) *fiber.App {
  121. t.Helper()
  122. h, err := New(Config{DB: db})
  123. if err != nil {
  124. t.Fatalf("handler.New 失败: %v", err)
  125. }
  126. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  127. app.Use(func(c *fiber.Ctx) error {
  128. switch c.Get("X-Test-Scope") {
  129. case "none":
  130. // 不写作用域 → 未登录语义
  131. case "super":
  132. appscope.Set(c, testSuperScope())
  133. default:
  134. appscope.Set(c, testScope())
  135. }
  136. return c.Next()
  137. })
  138. registerTestRoutes(app, h)
  139. return app
  140. }
  141. // registerTestRoutes 把 AC-12 的 18 条端点全部注册(**与设计 §4.1 逐条对应**)。
  142. //
  143. // 说明:这里**不**做角色白名单/RBAC(那是 AC-14);本函数只为"端点覆盖 + 请求往返"服务。
  144. func registerTestRoutes(app *fiber.App, h *Handler) {
  145. app.Get(PathFinanceAccounts, h.GetAccount)
  146. app.Get(PathFinanceRefundable, h.GetRefundable)
  147. app.Post(PathFinanceRecharges, h.CreateRecharge)
  148. app.Post(PathFinanceExpenditures, h.CreateExpenditure)
  149. app.Post(PathFinanceRefunds, h.CreateRefund)
  150. app.Post(PathFinanceRefundsCustom, h.CreateCustomRefund)
  151. app.Post(PathFinanceTransfers, h.CreateTransfer)
  152. app.Post(PathFinanceBadDebts, h.CreateBadDebt)
  153. app.Post(PathFinanceClearedBalances, h.ClearBalance)
  154. app.Post(PathFinanceSpends, h.AddSpend)
  155. app.Get(PathFinanceLogs, h.ListLogs)
  156. app.Get(PathFinanceRequests, h.ListRequests)
  157. app.Post(PathFinanceRequests, h.CreateRequest)
  158. app.Post(PathFinanceRequestApprove, h.ApproveRequest)
  159. app.Post(PathFinanceRequestReject, h.RejectRequest)
  160. app.Post(PathFinanceRequestCancel, h.CancelRequest)
  161. app.Get(PathFinanceDashboard, h.GetDashboard)
  162. app.Get(PathStudentBill, h.GetBill)
  163. }
  164. // doJSON 发一个 JSON 请求并解出信封(HTTP 恒 200,失败即测试失败)。
  165. //
  166. // 入参:method / path / body(nil = 无 body)/ headers(k,v 交替)。
  167. // 出参:code / message / data(原始 JSON)。
  168. func doJSON(t *testing.T, app *fiber.App, method, path string, body any, headers ...string) (int, string, json.RawMessage) {
  169. t.Helper()
  170. var req *httptest.ResponseRecorder
  171. payload := ""
  172. if body != nil {
  173. raw, err := json.Marshal(body)
  174. if err != nil {
  175. t.Fatalf("序列化请求体失败: %v", err)
  176. }
  177. payload = string(raw)
  178. }
  179. httpReq := httptest.NewRequest(method, path, strings.NewReader(payload))
  180. httpReq.Header.Set("Content-Type", "application/json")
  181. for i := 0; i+1 < len(headers); i += 2 {
  182. httpReq.Header.Set(headers[i], headers[i+1])
  183. }
  184. resp, err := app.Test(httpReq, 5000)
  185. if err != nil {
  186. t.Fatalf("%s %s 请求失败: %v", method, path, err)
  187. }
  188. req = httptest.NewRecorder()
  189. _ = req
  190. defer func() { _ = resp.Body.Close() }()
  191. var envelope struct {
  192. Code int `json:"code"`
  193. Message string `json:"message"`
  194. Data json.RawMessage `json:"data"`
  195. Success bool `json:"success"`
  196. }
  197. if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
  198. t.Fatalf("%s %s 响应不是合法信封: %v", method, path, err)
  199. }
  200. return envelope.Code, envelope.Message, envelope.Data
  201. }
  202. // ---------------------------------------------------------------- ① 端点覆盖
  203. // TestRouteCoverage 端点 1~17、21 全覆盖(18 条),且**不含** 18/19/20(支付端点属 AC-13/AC-14)。
  204. func TestRouteCoverage(t *testing.T) {
  205. db := newHandlerTestDB(t)
  206. app := newTestApp(t, db)
  207. expected := []struct {
  208. method string
  209. path string
  210. name string
  211. }{
  212. {http.MethodGet, "/admin/finance/accounts/:userId", "端点1 账户快照"},
  213. {http.MethodGet, "/admin/finance/accounts/:userId/refundable", "端点2 可退金额"},
  214. {http.MethodPost, "/admin/finance/recharges", "端点3 充值"},
  215. {http.MethodPost, "/admin/finance/expenditures", "端点4 消费"},
  216. {http.MethodPost, "/admin/finance/refunds", "端点5 退费"},
  217. {http.MethodPost, "/admin/finance/refunds/custom", "端点6 自定义退费"},
  218. {http.MethodPost, "/admin/finance/transfers", "端点7 转结"},
  219. {http.MethodPost, "/admin/finance/bad-debts", "端点8 坏账"},
  220. {http.MethodPost, "/admin/finance/cleared-balances", "端点9 清零"},
  221. {http.MethodPost, "/admin/finance/spends", "端点10 累计消费"},
  222. {http.MethodGet, "/admin/finance/logs", "端点11 流水"},
  223. {http.MethodGet, "/admin/finance/requests", "端点12 工单列表"},
  224. {http.MethodPost, "/admin/finance/requests", "端点13 创建工单"},
  225. {http.MethodPost, "/admin/finance/requests/:id/approve", "端点14 通过"},
  226. {http.MethodPost, "/admin/finance/requests/:id/reject", "端点15 驳回"},
  227. {http.MethodPost, "/admin/finance/requests/:id/cancel", "端点16 撤销"},
  228. {http.MethodGet, "/student/bill", "端点17 账单"},
  229. {http.MethodGet, "/admin/finance/dashboard", "端点21 仪表盘"},
  230. }
  231. registered := map[string]bool{}
  232. for _, route := range app.GetRoutes() {
  233. registered[route.Method+" "+route.Path] = true
  234. }
  235. for _, want := range expected {
  236. if !registered[want.method+" "+want.path] {
  237. t.Errorf("%s 未注册(%s %s)", want.name, want.method, want.path)
  238. }
  239. }
  240. // 反向断言:不该出现的端点(18/19/20 属支付,归 AC-13;且**任何**路径都不得含 /api)
  241. for key := range registered {
  242. if strings.Contains(key, "/pay/") {
  243. t.Errorf("academic finance handler 不应注册支付端点:%s", key)
  244. }
  245. if strings.Contains(key, "/api/") {
  246. t.Errorf("后端路由不得含 /api 前缀(§4 口径表):%s", key)
  247. }
  248. }
  249. if len(expected) != 18 {
  250. t.Fatalf("覆盖清单应为 18 条,实得 %d", len(expected))
  251. }
  252. }
  253. // ---------------------------------------------------------------- ③ scope 取不到 → 1001
  254. // TestMissingScopeIs1001 无作用域(未登录/中间件未注入)→ 1001,且**必须先于**参数校验。
  255. func TestMissingScopeIs1001(t *testing.T) {
  256. db := newHandlerTestDB(t)
  257. app := newTestApp(t, db)
  258. cases := []struct {
  259. method string
  260. path string
  261. body any
  262. }{
  263. {http.MethodGet, "/admin/finance/accounts/01HUSER000000000000000900", nil},
  264. {http.MethodPost, "/admin/finance/recharges", map[string]any{}},
  265. {http.MethodGet, "/admin/finance/logs", nil},
  266. {http.MethodGet, "/student/bill", nil},
  267. {http.MethodGet, "/admin/finance/dashboard", nil},
  268. }
  269. for _, tc := range cases {
  270. code, _, _ := doJSON(t, app, tc.method, tc.path, tc.body, "X-Test-Scope", "none")
  271. if code != infraerrors.CodeUnauth {
  272. t.Errorf("%s %s:期望 1001,实得 %d", tc.method, tc.path, code)
  273. }
  274. }
  275. }
  276. // ---------------------------------------------------------------- ② 2001 与 2002 不串
  277. // TestValidationCodes validateReq 失败 → 2001;金额/枚举值非法 → 2002。
  278. func TestValidationCodes(t *testing.T) {
  279. db := newHandlerTestDB(t)
  280. app := newTestApp(t, db)
  281. // 缺参(userId/money/institutionId 全空)→ 2001
  282. code, _, _ := doJSON(t, app, http.MethodPost, "/admin/finance/recharges", map[string]any{})
  283. if code != infraerrors.CodeBadParam {
  284. t.Fatalf("缺参期望 2001,实得 %d", code)
  285. }
  286. // 金额字符串非法("abc")→ 2002
  287. code, _, _ = doJSON(t, app, http.MethodPost, "/admin/finance/recharges", map[string]any{
  288. "userId": "01HUSER000000000000000900", "institutionId": 1, "campusId": 10, "money": "abc",
  289. })
  290. if code != infraerrors.CodeBadFormat {
  291. t.Fatalf("金额非法期望 2002,实得 %d", code)
  292. }
  293. // 消费 type 不在白名单 → 2002
  294. code, _, _ = doJSON(t, app, http.MethodPost, "/admin/finance/expenditures", map[string]any{
  295. "userId": "01HUSER000000000000000900", "institutionId": 1, "campusId": 10,
  296. "money": "10.00", "type": 999,
  297. })
  298. if code != infraerrors.CodeBadFormat {
  299. t.Fatalf("消费 type 非白名单期望 2002,实得 %d", code)
  300. }
  301. // 工单 type 不在白名单 → 2002
  302. code, _, _ = doJSON(t, app, http.MethodPost, "/admin/finance/requests", map[string]any{
  303. "type": 999, "userId": "01HUSER000000000000000900", "institutionId": 1, "campusId": 10,
  304. "money": "10.00",
  305. })
  306. if code != infraerrors.CodeBadFormat {
  307. t.Fatalf("工单 type 非白名单期望 2002,实得 %d", code)
  308. }
  309. // 自定义退费缺 requestId(必填)→ 2001
  310. code, _, _ = doJSON(t, app, http.MethodPost, "/admin/finance/refunds/custom", map[string]any{
  311. "userId": "01HUSER000000000000000900", "institutionId": 1, "campusId": 10, "money": "10.00",
  312. })
  313. if code != infraerrors.CodeBadParam {
  314. t.Fatalf("缺 requestId 期望 2001,实得 %d", code)
  315. }
  316. // 账单缺 from/to → 2001(时间边界必填)
  317. code, _, _ = doJSON(t, app, http.MethodGet, "/student/bill", nil)
  318. if code != infraerrors.CodeBadParam {
  319. t.Fatalf("账单缺 from/to 期望 2001,实得 %d", code)
  320. }
  321. // 账单 campusIds 非法片段 → 2002(Service 解析失败)
  322. code, _, _ = doJSON(t, app, http.MethodGet,
  323. "/student/bill?userId=01HUSER000000000000000900&from=2026-03-01&to=2026-04-01&campusIds=1,a", nil)
  324. if code != infraerrors.CodeBadParam {
  325. t.Fatalf("账单 campusIds 非法期望 2001/(Service 侧 2001),实得 %d", code)
  326. }
  327. // 时间格式非法 → 2002
  328. code, _, _ = doJSON(t, app, http.MethodGet,
  329. "/student/bill?from=2026-13-99&to=2026-04-01", nil)
  330. if code != infraerrors.CodeBadFormat {
  331. t.Fatalf("时间格式非法期望 2002,实得 %d", code)
  332. }
  333. }
  334. // ---------------------------------------------------------------- ④ 金额必须是字符串
  335. // TestMoneyMustBeJSONString 金额传 JSON number → **2002(被拒)**,绝不被静默接受。
  336. //
  337. // 为什么是 2002 而不是 2001:`infra/validator.Validate` 对"body 能解析但字段类型不匹配"
  338. // 统一回 `CodeBadFormat`(2002)(见 validator.go 的 json.Unmarshal 分支)——本用例沿用该约定。
  339. //
  340. // 这是"拒绝 float64"的可判定形式:若把 DTO 的金额字段写成 `float64` 或 `money.Amount`,
  341. // number 会被成功解码 ⇒ 本用例拿到 200(钱被按浮点入账)⇒ 断言立刻红。
  342. func TestMoneyMustBeJSONString(t *testing.T) {
  343. db := newHandlerTestDB(t)
  344. app := newTestApp(t, db)
  345. raw := `{"userId":"01HUSER000000000000000900","institutionId":1,"campusId":10,"money":100}`
  346. httpReq := httptest.NewRequest(http.MethodPost, "/admin/finance/recharges", strings.NewReader(raw))
  347. httpReq.Header.Set("Content-Type", "application/json")
  348. resp, err := app.Test(httpReq, 5000)
  349. if err != nil {
  350. t.Fatalf("请求失败: %v", err)
  351. }
  352. defer func() { _ = resp.Body.Close() }()
  353. var envelope struct {
  354. Code int `json:"code"`
  355. }
  356. if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
  357. t.Fatalf("响应解码失败: %v", err)
  358. }
  359. if envelope.Code != infraerrors.CodeBadFormat {
  360. t.Fatalf("金额传 number 期望 2002(类型不匹配被拒),实得 %d", envelope.Code)
  361. }
  362. }
  363. // ---------------------------------------------------------------- ⑤ 分页钳制
  364. // TestPageSizeClamp pageSize>200 → 200(唯一实现点 = response.ParsePage)。
  365. func TestPageSizeClamp(t *testing.T) {
  366. db := newHandlerTestDB(t)
  367. app := newTestApp(t, db)
  368. code, _, data := doJSON(t, app, http.MethodGet,
  369. "/admin/finance/logs?userId=01HUSER000000000000000900&pageSize=500&page=1", nil)
  370. if code != 200 {
  371. t.Fatalf("流水列表期望 200,实得 %d", code)
  372. }
  373. var page struct {
  374. PageSize int `json:"pageSize"`
  375. Page int `json:"page"`
  376. Total int64 `json:"total"`
  377. List []any `json:"list"`
  378. }
  379. if err := json.Unmarshal(data, &page); err != nil {
  380. t.Fatalf("分页体解码失败: %v", err)
  381. }
  382. if page.PageSize != 200 {
  383. t.Fatalf("pageSize 应钳制为 200,实得 %d", page.PageSize)
  384. }
  385. if page.List == nil {
  386. t.Fatal("空列表必须是 [],不能是 null")
  387. }
  388. // 缺省值:不传分页 → (1,10)
  389. _, _, data = doJSON(t, app, http.MethodGet, "/admin/finance/logs?userId=01HUSER000000000000000900", nil)
  390. if err := json.Unmarshal(data, &page); err != nil {
  391. t.Fatalf("分页体解码失败: %v", err)
  392. }
  393. if page.Page != 1 || page.PageSize != 10 {
  394. t.Fatalf("缺省分页应为 (1,10),实得 (%d,%d)", page.Page, page.PageSize)
  395. }
  396. }
  397. // ---------------------------------------------------------------- ⑥ 脱敏
  398. // TestNoSensitiveFields 响应体不出现 `imgs` / `payReturnData` / `returnData`(AC-12 验收 ⑥)。
  399. func TestNoSensitiveFields(t *testing.T) {
  400. db := newHandlerTestDB(t)
  401. app := newTestApp(t, db)
  402. user := "01HUSER000000000000000901"
  403. // 造一张带凭证图片的工单,确认列表**不回显** imgs
  404. if err := db.Create(&model.UserBalanceRequest{
  405. BizNo: "BIZSENSITIVE00000000000000001", Type: model.BalanceRequestTypeRecharge,
  406. UserID: user, InstitutionID: 1, CampusID: 10, Money: "100.00",
  407. Imgs: strPtr(`[{"url":"https://sensitive/private.png"}]`),
  408. }).Error; err != nil {
  409. t.Fatalf("seed 工单失败: %v", err)
  410. }
  411. httpReq := httptest.NewRequest(http.MethodGet, "/admin/finance/requests?pageSize=10", nil)
  412. resp, err := app.Test(httpReq, 5000)
  413. if err != nil {
  414. t.Fatalf("请求失败: %v", err)
  415. }
  416. defer func() { _ = resp.Body.Close() }()
  417. rawBody, rErr := io.ReadAll(resp.Body)
  418. if rErr != nil {
  419. t.Fatalf("读响应失败: %v", rErr)
  420. }
  421. text := string(rawBody)
  422. for _, banned := range []string{"imgs", "private.png", "payReturnData", "pay_return_data", "returnData"} {
  423. if strings.Contains(text, banned) {
  424. t.Fatalf("响应体含敏感字段 %q:%s", banned, text)
  425. }
  426. }
  427. if !strings.Contains(text, user) {
  428. t.Fatalf("响应体应含被查工单(脱敏断言不能靠「空响应」通过):%s", text)
  429. }
  430. }
  431. // ---------------------------------------------------------------- 业务冒烟
  432. // TestRechargeThenAccountSnapshot 端点 3 → 端点 1:充值后账户快照余额同步(跨端点一致性)。
  433. func TestRechargeThenAccountSnapshot(t *testing.T) {
  434. db := newHandlerTestDB(t)
  435. app := newTestApp(t, db)
  436. user := "01HUSER000000000000000910"
  437. code, msg, data := doJSON(t, app, http.MethodPost, "/admin/finance/recharges", map[string]any{
  438. "userId": user, "institutionId": 1, "campusId": 10, "money": "250.50",
  439. "requestId": "REQ-HANDLER-1",
  440. })
  441. if code != 200 {
  442. t.Fatalf("充值期望 200,实得 %d(%s)", code, msg)
  443. }
  444. var recharge struct {
  445. LogID int64 `json:"logId"`
  446. Balance string `json:"balance"`
  447. Repeated bool `json:"repeated"`
  448. }
  449. if err := json.Unmarshal(data, &recharge); err != nil {
  450. t.Fatalf("充值体解码失败: %v", err)
  451. }
  452. if recharge.LogID <= 0 || recharge.Balance != "250.50" || recharge.Repeated {
  453. t.Fatalf("充值结果异常:%+v", recharge)
  454. }
  455. // 幂等重放(同一 requestId)→ repeated=true 且余额不变
  456. code, _, data = doJSON(t, app, http.MethodPost, "/admin/finance/recharges", map[string]any{
  457. "userId": user, "institutionId": 1, "campusId": 10, "money": "250.50",
  458. "requestId": "REQ-HANDLER-1",
  459. })
  460. if code != 200 {
  461. t.Fatalf("幂等重放期望 200,实得 %d", code)
  462. }
  463. if err := json.Unmarshal(data, &recharge); err != nil {
  464. t.Fatalf("充值体解码失败: %v", err)
  465. }
  466. if !recharge.Repeated || recharge.Balance != "250.50" {
  467. t.Fatalf("幂等重放应 repeated=true 且余额不变:%+v", recharge)
  468. }
  469. // 端点 1:账户快照应反映 250.50
  470. code, _, data = doJSON(t, app, http.MethodGet,
  471. "/admin/finance/accounts/"+user+"?institutionId=1&campusId=10", nil)
  472. if code != 200 {
  473. t.Fatalf("账户快照期望 200,实得 %d", code)
  474. }
  475. // 端点 1 形状 = **扁平**(契约 v1.5):totalBalance/campusBalance/specialExpenses/cumulativeIncome
  476. // + 入参回显 institutionId/campusId;**不得**出现 global/campus 嵌套键。
  477. var snapshot struct {
  478. UserID string `json:"userId"`
  479. InstitutionID int64 `json:"institutionId"`
  480. CampusID int64 `json:"campusId"`
  481. TotalBalance string `json:"totalBalance"`
  482. CampusBalance string `json:"campusBalance"`
  483. SpecialExpenses string `json:"specialExpenses"`
  484. CumulativeIncome string `json:"cumulativeIncome"`
  485. }
  486. if err := json.Unmarshal(data, &snapshot); err != nil {
  487. t.Fatalf("快照解码失败: %v", err)
  488. }
  489. if snapshot.TotalBalance != "250.50" || snapshot.CumulativeIncome != "250.50" {
  490. t.Fatalf("全局快照异常:totalBalance=%s cumulativeIncome=%s,期望均为 250.50",
  491. snapshot.TotalBalance, snapshot.CumulativeIncome)
  492. }
  493. if snapshot.CampusBalance != "250.50" {
  494. t.Fatalf("campusBalance = %s,期望 250.50(← t_user_expense_school.balance)", snapshot.CampusBalance)
  495. }
  496. if snapshot.InstitutionID != 1 || snapshot.CampusID != 10 {
  497. t.Fatalf("institutionId/campusId = %d/%d,期望入参回显 1/10", snapshot.InstitutionID, snapshot.CampusID)
  498. }
  499. // 契约 v1.5:扁平形状,**不得**再出现 global/campus 嵌套键(用原始 JSON 断言,结构体无此字段时断言恒真)
  500. rawSnap, rErr := json.Marshal(snapshot)
  501. if rErr == nil {
  502. _ = rawSnap
  503. }
  504. }
  505. // TestRequestApproveTouchesFunds 端点 13 → 14:审批通过才动钱;二次审批 → 3002。
  506. func TestRequestApproveTouchesFunds(t *testing.T) {
  507. db := newHandlerTestDB(t)
  508. app := newTestApp(t, db)
  509. user := "01HUSER000000000000000920"
  510. // 13 创建充值工单
  511. code, msg, data := doJSON(t, app, http.MethodPost, "/admin/finance/requests", map[string]any{
  512. "type": 100, "userId": user, "institutionId": 1, "campusId": 10,
  513. "money": "88.00", "item": "春季学费",
  514. })
  515. if code != 200 {
  516. t.Fatalf("创建工单期望 200,实得 %d(%s)", code, msg)
  517. }
  518. var created struct {
  519. ID int64 `json:"id"`
  520. BizNo string `json:"bizNo"`
  521. }
  522. if err := json.Unmarshal(data, &created); err != nil {
  523. t.Fatalf("工单体解码失败: %v", err)
  524. }
  525. if created.ID <= 0 || len(created.BizNo) != 32 {
  526. t.Fatalf("工单结果异常:%+v", created)
  527. }
  528. // 审批前:余额 0(账户不存在 → 快照 0)
  529. code, _, data = doJSON(t, app, http.MethodGet, "/admin/finance/accounts/"+user, nil)
  530. if code != 200 {
  531. t.Fatalf("快照期望 200,实得 %d", code)
  532. }
  533. var before struct {
  534. TotalBalance string `json:"totalBalance"`
  535. }
  536. if err := json.Unmarshal(data, &before); err != nil {
  537. t.Fatalf("快照解码失败: %v", err)
  538. }
  539. if before.TotalBalance != "0.00" {
  540. t.Fatalf("审批前 totalBalance 应为 0.00,实得 %s(说明创建工单就已经动了钱——违反审批链)",
  541. before.TotalBalance)
  542. }
  543. // 14 审批通过
  544. code, msg, data = doJSON(t, app, http.MethodPost,
  545. fmt.Sprintf("/admin/finance/requests/%d/approve", created.ID), map[string]any{"responseMsg": "ok"})
  546. if code != 200 {
  547. t.Fatalf("审批通过期望 200,实得 %d(%s)", code, msg)
  548. }
  549. var approved struct {
  550. Approved int8 `json:"approved"`
  551. BizNo string `json:"bizNo"`
  552. Result struct {
  553. LogID int64 `json:"logId"`
  554. Balance string `json:"balance"`
  555. } `json:"result"`
  556. }
  557. if err := json.Unmarshal(data, &approved); err != nil {
  558. t.Fatalf("审批体解码失败: %v", err)
  559. }
  560. if approved.Approved != 1 || approved.BizNo != created.BizNo || approved.Result.LogID <= 0 {
  561. t.Fatalf("审批结果异常:%+v", approved)
  562. }
  563. if approved.Result.Balance != "88.00" {
  564. t.Fatalf("审批入账后校区余额应为 88.00,实得 %s", approved.Result.Balance)
  565. }
  566. // 二次审批 → 3002(不得重复入账)
  567. code, _, _ = doJSON(t, app, http.MethodPost,
  568. fmt.Sprintf("/admin/finance/requests/%d/approve", created.ID), map[string]any{})
  569. if code != infraerrors.CodeConflict {
  570. t.Fatalf("二次审批期望 3002,实得 %d", code)
  571. }
  572. // 落库只能有一条 100 流水(重复入账会变两条)
  573. var logs []model.UserExpenseLog
  574. if err := db.Where("user_id = ? AND type = ?", user, model.ExpenseLogTypeRecharge).Find(&logs).Error; err != nil {
  575. t.Fatalf("读流水失败: %v", err)
  576. }
  577. if len(logs) != 1 {
  578. t.Fatalf("充值流水应为 1 条,实得 %d 条(二次审批重复入账)", len(logs))
  579. }
  580. if logs[0].RequestID == nil || *logs[0].RequestID != created.BizNo {
  581. t.Fatalf("流水 request_id 应等于工单 bizNo(幂等锚点),实得 %v", logs[0].RequestID)
  582. }
  583. }
  584. // TestBillEndpointIdentity 端点 17:账单恒等式 + **契约 v1.3 两个对账字段名** + 分页元信息。
  585. //
  586. // 契约口径(前端契约 v1.3 端点 17 注 ①,**字段名由契约定、后端照做**):
  587. // - `flowExclusiveEnd`:与「期末 − 期初」**对齐**的当期发生额(右开口径,不含 `to` 当天);
  588. // - `flowInclusiveEnd`:**含 `to` 当天**的当期发生额(dump 的 `BETWEEN` 口径)。
  589. // 二者之差 = 落在 `to` 当天的流水金额。
  590. //
  591. // 为什么两个都要下发(不能只留一个):只留一个就没法让前端**看见**不对称,
  592. // 而契约 v1.2 那句"期末余额与当期发生额都按 `< to`"的错句,根因正是"看不见"。
  593. //
  594. // 本用例造 5 笔流水来同时守住"含 `to` 当天"的**两个端点**(台账 F31 / F30):
  595. //
  596. // 02-20 10:00 500.00 窗口前(期初基准)
  597. // 03-05 10:00 200.00 窗口内(唯一进明细的一笔)
  598. // 04-01 00:00 300.00 `to` 当天零点 —— 旧实现 `t <= to` 恰好能抓到这一刻
  599. // 04-01 15:00 150.00 `to` 当天下午 —— **只有**"含当天全天"才抓得到
  600. // 04-02 00:00 999.00 次日零点 —— 必须**排除**(守右端不得越到次日)
  601. //
  602. // 断言:
  603. // 1. 期末不含当天(700.00)、`flowExclusiveEnd` 不含当天(200.00);
  604. // 2. `flowInclusiveEnd` = 200 + 300 + 150 = 650.00;
  605. // 3. 二者之差 == 450.00(= 落在 `to` 当天的两笔之和),**不是** 300.00。
  606. //
  607. // ⚠ 为什么必须有 04-01 15:00 那笔:只造 00:00 一笔时,`t <= to` 与 `t < to+1d`
  608. // 会**同时**把它算进去 ⇒ 把 service 的 ② 回滚成 `!After(to)` 用例仍然是绿的
  609. // (零区分力)。加了下午那笔,两种实现才分得开:旧 500 / 新 650。
  610. //
  611. // ⚠ 种子时间必须用 `tz.Location()`(业务时区),**不能用 `time.UTC`**:
  612. // 测试库是 SQLite,时间列按**文本**比较,`time.UTC` 存成 `...T00:00:00Z`、
  613. // `+08:00` 存成 `...T00:00:00+08:00`,同一绝对时刻的字面量在 offset 段就分出了大小
  614. // ⇒ `create_time < to` 判错(实测过:同一时刻两种写法命中结果相反)。
  615. // 生产 MySQL 的 `datetime` 是无时区字面值、按 DSN `loc` 解释,不存在这个现象。
  616. //
  617. // 顺带守字段名:若有人把 service.BillSummary 的 json tag 改名,本用例立刻红
  618. // (`flowExclusiveEnd` 解出来是零值 ⇒ 差值断言必失败)。
  619. func TestBillEndpointIdentity(t *testing.T) {
  620. db := newHandlerTestDB(t)
  621. app := newTestApp(t, db)
  622. user := "01HUSER000000000000000930"
  623. seedLog := func(amount string, at time.Time) {
  624. if err := db.Create(&model.UserExpenseLog{
  625. UserID: user, Type: model.ExpenseLogTypeRecharge, Money: amount, CreateTime: at,
  626. }).Error; err != nil {
  627. t.Fatalf("seed 流水失败: %v", err)
  628. }
  629. }
  630. // 用业务时区构造(理由见上方 ⚠);`bizAt` 只省重复的 `tz.Location()`。
  631. bizAt := func(month time.Month, day, hour int) time.Time {
  632. return time.Date(2026, month, day, hour, 0, 0, 0, tz.Location())
  633. }
  634. seedLog("500.00", bizAt(2, 20, 10)) // 期初
  635. seedLog("200.00", bizAt(3, 5, 10)) // 窗口内
  636. seedLog("300.00", bizAt(4, 1, 0)) // `to` 当天零点
  637. seedLog("150.00", bizAt(4, 1, 15)) // `to` 当天下午(只有"含当天全天"能抓到)
  638. seedLog("999.00", bizAt(4, 2, 0)) // 次日零点(必须排除)
  639. code, msg, data := doJSON(t, app, http.MethodGet,
  640. "/student/bill?userId="+user+"&from=2026-03-01&to=2026-04-01", nil)
  641. if code != 200 {
  642. t.Fatalf("账单期望 200,实得 %d(%s)", code, msg)
  643. }
  644. var bill struct {
  645. Summary struct {
  646. PrevBalance string `json:"prevBalance"`
  647. EndBalance string `json:"endBalance"`
  648. FlowExclusiveEnd string `json:"flowExclusiveEnd"`
  649. FlowInclusiveEnd string `json:"flowInclusiveEnd"`
  650. CampusIDs []int64 `json:"campusIds"`
  651. } `json:"summary"`
  652. // 明细数组键名 = **details**(契约 v1.4 rev.2:以结构体 json tag 为准,平铺不嵌套)
  653. Details []map[string]any `json:"details"`
  654. Total int64 `json:"total"`
  655. Page int `json:"page"`
  656. PageSize int `json:"pageSize"`
  657. Pages int `json:"pages"`
  658. }
  659. if err := json.Unmarshal(data, &bill); err != nil {
  660. t.Fatalf("账单体解码失败: %v", err)
  661. }
  662. if bill.Summary.PrevBalance != "500.00" || bill.Summary.EndBalance != "700.00" {
  663. t.Fatalf("期初/期末异常(期末必须**不含** to 当天那笔 300.00):%+v", bill.Summary)
  664. }
  665. // 恒等式:期末 − 期初 == flowExclusiveEnd(右开口径)
  666. prev, _ := money.Parse(bill.Summary.PrevBalance)
  667. end, _ := money.Parse(bill.Summary.EndBalance)
  668. exclusive, _ := money.Parse(bill.Summary.FlowExclusiveEnd)
  669. inclusive, _ := money.Parse(bill.Summary.FlowInclusiveEnd)
  670. if end.Sub(prev) != exclusive {
  671. t.Fatalf("恒等式破:期末−期初=%s,flowExclusiveEnd=%s", end.Sub(prev).String(), exclusive.String())
  672. }
  673. // 契约 v1.3:两口径差值 == 落在 `to` 当天的流水(300 + 150,**不含次日那笔 999**)
  674. if got, want := inclusive.Sub(exclusive), int64Money(t, "450.00"); got != want {
  675. t.Fatalf("flowInclusiveEnd−flowExclusiveEnd = %s,期望 %s(= 落在 to 当天的两笔之和)\n"+
  676. "若实得 300.00 ⇒ 实现退化成 `t <= to`(只抓到当天零点那一刻,漏掉当天其余流水,台账 F31)\n"+
  677. "若实得 1449.00 ⇒ 右端越到次日(把 04-02 那笔也算进来了)",
  678. got.String(), want.String())
  679. }
  680. if got, want := inclusive.String(), "650.00"; got != want {
  681. t.Fatalf("flowInclusiveEnd = %s,期望 %s(含 to 当天**全天**:200+300+150)", got, want)
  682. }
  683. // v1.4 起明细按窗口过滤(右开):5 笔历史流水里只有 03-05 那笔在窗口内
  684. // (02-20 在窗口前;04-01 零点/下午与 04-02 零点都在 `to` 右端点之后或等于它)→ total=1、pages=1。
  685. if bill.Total != 1 || bill.PageSize != 10 || bill.Pages != 1 || bill.Details == nil {
  686. t.Fatalf("分页元信息异常:total=%d pageSize=%d pages=%d details=%v(明细应受窗口约束)",
  687. bill.Total, bill.PageSize, bill.Pages, bill.Details)
  688. }
  689. if len(bill.Details) != 1 {
  690. t.Fatalf("窗口内明细应为 1 条,实得 %d 条", len(bill.Details))
  691. }
  692. // Total 必须**不等于**历史总数(5 笔)——只改 PageLogs 不改 CountLogs 的实现会在这里露馅
  693. if bill.Total == 5 {
  694. t.Fatal("Total = 历史总数 5:说明 CountLogs 没跟 PageLogs 用同一个窗口条件(改一半)")
  695. }
  696. if bill.Summary.CampusIDs == nil {
  697. t.Fatal("campusIds 回显必须是 [](空时也不能是 null)")
  698. }
  699. }
  700. // int64Money 金额字面量解析(测试内断言用;解析失败直接 fail)。
  701. func int64Money(t *testing.T, s string) money.Amount {
  702. t.Helper()
  703. amount, err := money.Parse(s)
  704. if err != nil {
  705. t.Fatalf("金额解析失败 %q: %v", s, err)
  706. }
  707. return amount
  708. }
  709. // TestDashboardEndpoint 端点 21:聚合校区账户累计列 + 明确告知"不支持按校区拆流水"。
  710. func TestDashboardEndpoint(t *testing.T) {
  711. db := newHandlerTestDB(t)
  712. app := newTestApp(t, db)
  713. if err := db.Create(&model.UserExpenseSchool{
  714. UserID: "01HUSER000000000000000940", InstitutionID: 1, CampusID: 10,
  715. Balance: "100.00", TotalIncome: "300.00", TotalExpenditure: "200.00",
  716. }).Error; err != nil {
  717. t.Fatalf("seed 校区账户失败: %v", err)
  718. }
  719. code, msg, data := doJSON(t, app, http.MethodGet,
  720. "/admin/finance/dashboard?from=2026-03-01&to=2026-04-01", nil)
  721. if code != 200 {
  722. t.Fatalf("仪表盘期望 200,实得 %d(%s)", code, msg)
  723. }
  724. var dash struct {
  725. Accounts int64 `json:"accounts"`
  726. Balance string `json:"balance"`
  727. TotalIncome string `json:"totalIncome"`
  728. TotalExpenditure string `json:"totalExpenditure"`
  729. NetReceivable string `json:"netReceivable"`
  730. FlowsByCampusSupported bool `json:"flowsByCampusSupported"`
  731. }
  732. if err := json.Unmarshal(data, &dash); err != nil {
  733. t.Fatalf("仪表盘体解码失败: %v", err)
  734. }
  735. if dash.Accounts != 1 || dash.Balance != "100.00" || dash.TotalIncome != "300.00" ||
  736. dash.TotalExpenditure != "200.00" || dash.NetReceivable != "100.00" {
  737. t.Fatalf("仪表盘聚合异常:%+v", dash)
  738. }
  739. if dash.FlowsByCampusSupported {
  740. t.Fatal("flowsByCampusSupported 必须为 false(流水表无 campus 列,不能按校区拆期间发生额)")
  741. }
  742. }
  743. // TestCrossInstitutionIsRejected 跨机构:作用域机构 1 查机构 2 的工单 → 3001(仓储 Scoped 过滤后不可见)。
  744. func TestCrossInstitutionIsRejected(t *testing.T) {
  745. db := newHandlerTestDB(t)
  746. app := newTestApp(t, db)
  747. if err := db.Create(&model.UserBalanceRequest{
  748. BizNo: "BIZOTHERINST0000000000000001", Type: model.BalanceRequestTypeRecharge,
  749. UserID: "01HUSER000000000000000950", InstitutionID: 2, CampusID: 20, Money: "10.00",
  750. }).Error; err != nil {
  751. t.Fatalf("seed 跨机构工单失败: %v", err)
  752. }
  753. code, _, _ := doJSON(t, app, http.MethodPost, "/admin/finance/requests/1/approve", map[string]any{})
  754. if code != infraerrors.CodeNotFound {
  755. t.Fatalf("跨机构审批期望 3001,实得 %d", code)
  756. }
  757. // 列表也看不到别机构工单
  758. code, _, data := doJSON(t, app, http.MethodGet, "/admin/finance/requests?pageSize=10", nil)
  759. if code != 200 {
  760. t.Fatalf("工单列表期望 200,实得 %d", code)
  761. }
  762. var page struct {
  763. Total int64 `json:"total"`
  764. }
  765. if err := json.Unmarshal(data, &page); err != nil {
  766. t.Fatalf("列表体解码失败: %v", err)
  767. }
  768. if page.Total != 0 {
  769. t.Fatalf("普通财务不应看到别机构工单,实得 total=%d", page.Total)
  770. }
  771. }
  772. // ---------------------------------------------------------------- 小工具
  773. // strPtr 字符串指针构造(可空列 seed 用)。
  774. func strPtr(s string) *string { return &s }
  775. // TestRequestsUseBizTimeNotCreatedAt 端点 12:**时间字段必须是业务时间,不得下发 `created_at`**
  776. // (契约 v1.5 裁决;`created_at` 是迁移期新加的入库列,值恒为"迁移执行那一刻")。
  777. //
  778. // 这是"**有值、格式合法、语义全错**"的典型:单测若只断言"是合法 RFC3339"会全绿,
  779. // D7 双跑也抓不到(旧库根本没这个列,无从比对)。所以按 §5.1 纪律 4 做**横切断言**:
  780. //
  781. // ① 响应 `requestTime` == 库里的 `request_time`(2020-01-01)
  782. // ② `requestTime` ≠ 该行的 `created_at`(迁移/建行时间,今天)
  783. // ③ 原始 JSON 里**不存在** `createdAt` / `createTime` 键
  784. //
  785. // ③ 必须断言原始 JSON 片段:结构体没有该字段时,结构体断言恒真 = 等于没测。
  786. func TestRequestsUseBizTimeNotCreatedAt(t *testing.T) {
  787. db := newHandlerTestDB(t)
  788. app := newTestApp(t, db)
  789. user := "01HUSER000000000000000960"
  790. // request_time 明显早于建行时间(created_at 由 DB 默认填"现在")
  791. bizTime := time.Date(2020, 1, 1, 8, 0, 0, 0, time.UTC)
  792. if err := db.Create(&model.UserBalanceRequest{
  793. BizNo: "BIZTIME0000000000000000001", Type: model.BalanceRequestTypeRecharge,
  794. UserID: user, InstitutionID: 1, CampusID: 10, Money: "10.00",
  795. RequestTime: &bizTime,
  796. }).Error; err != nil {
  797. t.Fatalf("seed 工单失败: %v", err)
  798. }
  799. var stored model.UserBalanceRequest
  800. if err := db.Where("biz_no = ?", "BIZTIME0000000000000000001").Take(&stored).Error; err != nil {
  801. t.Fatalf("回读工单失败: %v", err)
  802. }
  803. if stored.RequestTime == nil || stored.CreatedAt.Equal(*stored.RequestTime) {
  804. t.Fatalf("前置造数失败:request_time(%v) 与 created_at(%v) 必须不同",
  805. stored.RequestTime, stored.CreatedAt)
  806. }
  807. req := httptest.NewRequest(http.MethodGet, "/admin/finance/requests?pageSize=10", nil)
  808. resp, err := app.Test(req, 5000)
  809. if err != nil {
  810. t.Fatalf("请求失败: %v", err)
  811. }
  812. defer func() { _ = resp.Body.Close() }()
  813. rawBody, rErr := io.ReadAll(resp.Body)
  814. if rErr != nil {
  815. t.Fatalf("读响应失败: %v", rErr)
  816. }
  817. text := string(rawBody)
  818. // ③ 原始 JSON 不得出现 createdAt / createTime 键
  819. for _, banned := range []string{`"createdAt"`, `"createTime"`} {
  820. if strings.Contains(text, banned) {
  821. t.Fatalf("响应含非业务时间键 %s —— `created_at` 是迁移期入库时间,"+
  822. "下发会让全部历史工单显示成迁移日:%s", banned, text)
  823. }
  824. }
  825. // ①② 解出 requestTime / responseTime(分页体在 data 内)
  826. var envelope struct {
  827. Data struct {
  828. List []struct {
  829. RequestTime *time.Time `json:"requestTime"`
  830. ResponseTime *time.Time `json:"responseTime"`
  831. } `json:"list"`
  832. } `json:"data"`
  833. }
  834. if uErr := json.Unmarshal(rawBody, &envelope); uErr != nil {
  835. t.Fatalf("解码失败: %v", uErr)
  836. }
  837. page := envelope.Data
  838. if len(page.List) != 1 || page.List[0].RequestTime == nil {
  839. t.Fatalf("应返回 1 条工单且带 requestTime,实得 %d 条", len(page.List))
  840. }
  841. if !page.List[0].RequestTime.Equal(bizTime) {
  842. t.Fatalf("requestTime = %v,期望 %v(← request_time 业务时间)",
  843. page.List[0].RequestTime, bizTime)
  844. }
  845. if page.List[0].RequestTime.Equal(stored.CreatedAt) {
  846. t.Fatalf("requestTime == created_at(%v)—— 下发的是迁移/建行时间,不是业务时间", stored.CreatedAt)
  847. }
  848. // 未审批 → responseTime 为 null
  849. if page.List[0].ResponseTime != nil {
  850. t.Fatalf("未审批工单 responseTime 应为 null,实得 %v", page.List[0].ResponseTime)
  851. }
  852. }
  853. // TestDashboardBalanceDistribution 端点 21:`balanceDistribution` 必须按校区给出余额
  854. // (契约 v1.5 的**唯一可算项**——`t_user_expense_school` 有 campus_id,而流水表没有)。
  855. func TestDashboardBalanceDistribution(t *testing.T) {
  856. db := newHandlerTestDB(t)
  857. app := newTestApp(t, db)
  858. for _, row := range []model.UserExpenseSchool{
  859. {UserID: "01HUSER000000000000000970", InstitutionID: 1, CampusID: 10,
  860. Balance: "100.00", TotalIncome: "0.00"},
  861. {UserID: "01HUSER000000000000000971", InstitutionID: 1, CampusID: 20,
  862. Balance: "250.00", TotalIncome: "0.00"},
  863. } {
  864. if err := db.Create(&row).Error; err != nil {
  865. t.Fatalf("seed 校区账户失败: %v", err)
  866. }
  867. }
  868. code, msg, data := doJSON(t, app, http.MethodGet,
  869. "/admin/finance/dashboard?from=2026-03-01&to=2026-04-01", nil)
  870. if code != 200 {
  871. t.Fatalf("仪表盘期望 200,实得 %d(%s)", code, msg)
  872. }
  873. var dash struct {
  874. Balance string `json:"balance"`
  875. BalanceDistribution []struct {
  876. CampusID int64 `json:"campusId"`
  877. Balance string `json:"balance"`
  878. } `json:"balanceDistribution"`
  879. FlowsByCampusSupported bool `json:"flowsByCampusSupported"`
  880. }
  881. if err := json.Unmarshal(data, &dash); err != nil {
  882. t.Fatalf("解码失败: %v", err)
  883. }
  884. if len(dash.BalanceDistribution) != 2 {
  885. t.Fatalf("balanceDistribution 应有 2 个校区,实得 %d", len(dash.BalanceDistribution))
  886. }
  887. if dash.BalanceDistribution[0].CampusID != 10 || dash.BalanceDistribution[0].Balance != "100.00" ||
  888. dash.BalanceDistribution[1].CampusID != 20 || dash.BalanceDistribution[1].Balance != "250.00" {
  889. t.Fatalf("分布异常(应按 campusId 升序、金额为字符串):%+v", dash.BalanceDistribution)
  890. }
  891. if dash.Balance != "350.00" {
  892. t.Fatalf("balance 合计 = %s,期望 350.00", dash.Balance)
  893. }
  894. if dash.FlowsByCampusSupported {
  895. t.Fatal("flowsByCampusSupported 必须为 false(流水表无 campus 列,契约 v1.5 已删 periodIncome/periodExpense/net)")
  896. }
  897. }