divide_handler_test.go 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718
  1. // 分账 handler 单测(AC-13 第 1 批:端点 38 / 43)。
  2. //
  3. // 断言纪律(team-lead 的 AC-13 验收方式:**不收"用例全绿",要"写错会变红"**):
  4. //
  5. // 端点 38 分账树 —— 主断言是 **Nested Set 四条不变式**(I1~I4),不是"接口返回 200":
  6. // · `TestDivideTreeInvariantsHold`:造一棵 5 节点两层的树,逐条验 I1(1..2N 无重复无空洞)/
  7. // I2(嵌套或相离)/ I3(父严格包含子)/ I4(layer=parent.layer+1);
  8. // · `TestDivideTreeRejectsBrokenNesting`:**故意造坏树**(右值错位 1),断言 5001 + 文案命中
  9. // 具体不变式 —— 这是"树坏了能被发现"的证明("接口能返回一棵树"这种断言在树坏时依然绿);
  10. // · `TestDivideTreeQuotaIsMoneyString`:`quota` 必须是**金额字符串**("100.00"),
  11. // 不是 0~1 比率、不是 number(mock 曾错成比率)。
  12. // 端点 43 流水 —— `TestDivideLogsWindowAndTotals`:右开窗口(`createTime = to` 的行不得出现),
  13. // 且 `total` 与 `list` 同口径(不等于历史全量)。
  14. package handler
  15. import (
  16. "encoding/json"
  17. "fmt"
  18. "net/http"
  19. "net/http/httptest"
  20. "path/filepath"
  21. "strings"
  22. "testing"
  23. "time"
  24. gormsqlite "github.com/glebarez/sqlite"
  25. "github.com/gofiber/fiber/v2"
  26. "gorm.io/gorm"
  27. "yuansiwei/unified-backend/internal/academic/apperr"
  28. "yuansiwei/unified-backend/internal/academic/model"
  29. "yuansiwei/unified-backend/internal/academic/money"
  30. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  31. appscope "yuansiwei/unified-backend/internal/infra/scope"
  32. "yuansiwei/unified-backend/internal/infra/tz"
  33. )
  34. // newDivideTestDB 分账测试库(两张分账表;其余表由 handler 其它用例各自建)。
  35. func newDivideTestDB(t *testing.T) *gorm.DB {
  36. t.Helper()
  37. dsn := filepath.Join(t.TempDir(), "academic_divide_handler_test.db")
  38. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  39. if err != nil {
  40. t.Fatalf("gorm.Open(分账库) 失败: %v", err)
  41. }
  42. sqlDB, err := db.DB()
  43. if err != nil {
  44. t.Fatalf("取 sql.DB 失败: %v", err)
  45. }
  46. sqlDB.SetMaxOpenConns(1)
  47. t.Cleanup(func() { _ = sqlDB.Close() })
  48. if mErr := db.AutoMigrate(&model.UserDivideInto{}, &model.UserDivideIntoLog{}); mErr != nil {
  49. t.Fatalf("AutoMigrate(分账 2 表) 失败: %v", mErr)
  50. }
  51. return db
  52. }
  53. // newDivideTestApp 只挂分账两条端点的 app(+ 作用域注入中间件)。
  54. func newDivideTestApp(t *testing.T, db *gorm.DB) *fiber.App {
  55. t.Helper()
  56. h, err := New(Config{DB: db})
  57. if err != nil {
  58. t.Fatalf("handler.New 失败: %v", err)
  59. }
  60. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  61. app.Use(func(c *fiber.Ctx) error {
  62. if c.Get("X-Test-Scope") == "none" {
  63. return c.Next()
  64. }
  65. appscope.Set(c, testScope())
  66. return c.Next()
  67. })
  68. app.Get(PathDivideTree, h.GetDivideTree)
  69. app.Get(PathDivideLogs, h.ListDivideLogs)
  70. return app
  71. }
  72. // seedDivideNode 落一行分账节点。
  73. func seedDivideNode(t *testing.T, db *gorm.DB, userID, parentUserID *string,
  74. left, right, layer int32, quota string) {
  75. t.Helper()
  76. row := &model.UserDivideInto{
  77. UserID: userIDOf(userID), ParentUserID: parentUserID,
  78. LeftVal: left, RightVal: right, Layer: layer, Quota: quota, TotalIncome: "0.00",
  79. }
  80. if parentUserID == nil {
  81. root := *userID
  82. row.RootUserID = &root
  83. } else {
  84. // 根取父的根(测试里只有两层,直接指向根)
  85. root := *userID
  86. if *parentUserID != "" {
  87. var parent model.UserDivideInto
  88. if err := db.Where("user_id = ?", *parentUserID).Take(&parent).Error; err != nil {
  89. t.Fatalf("读父节点(%s) 失败: %v", *parentUserID, err)
  90. }
  91. if parent.RootUserID != nil {
  92. root = *parent.RootUserID
  93. }
  94. }
  95. row.RootUserID = &root
  96. }
  97. if err := db.Create(row).Error; err != nil {
  98. t.Fatalf("seed 分账节点(%s) 失败: %v", row.UserID, err)
  99. }
  100. }
  101. // userIDOf 解引用(nil → "",用于父节点为空的安全构造)。
  102. func userIDOf(p *string) string {
  103. if p == nil {
  104. return ""
  105. }
  106. return *p
  107. }
  108. // divideTreeResp 端点 38 的响应体(足够断言树结构与量纲)。
  109. type divideTreeResp struct {
  110. Tree []struct {
  111. UserID string `json:"userId"`
  112. ParentUserID *string `json:"parentUserId"`
  113. Quota json.RawMessage `json:"quota"`
  114. CumulativeIncome json.RawMessage `json:"cumulativeIncome"`
  115. Layer int32 `json:"layer"`
  116. Children []struct {
  117. UserID string `json:"userId"`
  118. Quota json.RawMessage `json:"quota"`
  119. Layer int32 `json:"layer"`
  120. Children []struct {
  121. UserID string `json:"userId"`
  122. } `json:"children"`
  123. } `json:"children"`
  124. } `json:"tree"`
  125. NodeCount int `json:"nodeCount"`
  126. RootCount int `json:"rootCount"`
  127. }
  128. // getDivideTree 发请求并解出树响应(HTTP 恒 200)。
  129. func getDivideTree(t *testing.T, app *fiber.App) (int, divideTreeResp) {
  130. t.Helper()
  131. req := httptest.NewRequest(http.MethodGet, PathDivideTree, nil)
  132. resp, err := app.Test(req, 5000)
  133. if err != nil {
  134. t.Fatalf("请求分账树失败: %v", err)
  135. }
  136. defer func() { _ = resp.Body.Close() }()
  137. var envelope struct {
  138. Code int `json:"code"`
  139. Data json.RawMessage `json:"data"`
  140. }
  141. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  142. t.Fatalf("响应解码失败: %v", dErr)
  143. }
  144. var out divideTreeResp
  145. if envelope.Code == 200 && len(envelope.Data) > 0 {
  146. if uErr := json.Unmarshal(envelope.Data, &out); uErr != nil {
  147. t.Fatalf("树体解码失败: %v", uErr)
  148. }
  149. }
  150. return envelope.Code, out
  151. }
  152. // TestDivideTreeInvariantsHold 端点 38:两层 5 节点树,**四条不变式**逐条成立。
  153. //
  154. // 造形(root=1..10, A=2..5, A1=3..4, B=6..9, B1=7..8):
  155. //
  156. // root(layer1) ├── A(layer2) ── A1(layer3)
  157. // └── B(layer2) ── B1(layer3)
  158. //
  159. // 若实现的位移逻辑使区间重复/空洞/部分重叠,**本用例会红**(而"接口返回 200 + 有 tree 字段"不会)。
  160. func TestDivideTreeInvariantsHold(t *testing.T) {
  161. db := newDivideTestDB(t)
  162. root, a, a1, b, b1 := "01HROOT000000000000000001", "01HA0000000000000000001",
  163. "01HA10000000000000000001", "01HB0000000000000000001", "01HB10000000000000000001"
  164. seedDivideNode(t, db, &root, nil, 1, 10, 1, "100.00")
  165. seedDivideNode(t, db, &a, &root, 2, 5, 2, "50.00")
  166. seedDivideNode(t, db, &a1, &a, 3, 4, 3, "20.00")
  167. seedDivideNode(t, db, &b, &root, 6, 9, 2, "30.00")
  168. seedDivideNode(t, db, &b1, &b, 7, 8, 3, "10.00")
  169. app := newDivideTestApp(t, db)
  170. code, body := getDivideTree(t, app)
  171. if code != 200 {
  172. t.Fatalf("合法树应返回 200,实得 %d", code)
  173. }
  174. if body.NodeCount != 5 || body.RootCount != 1 || len(body.Tree) != 1 {
  175. t.Fatalf("nodeCount/rootCount/len(tree) = %d/%d/%d,期望 5/1/1",
  176. body.NodeCount, body.RootCount, len(body.Tree))
  177. }
  178. // I4:根 layer=1、parent 为 null
  179. if body.Tree[0].Layer != 1 || body.Tree[0].ParentUserID != nil {
  180. t.Fatalf("根节点 layer/parent = %d/%v,期望 1/nil", body.Tree[0].Layer, body.Tree[0].ParentUserID)
  181. }
  182. // I3/I4:子节点 layer=2 且挂在根下
  183. if len(body.Tree[0].Children) != 2 {
  184. t.Fatalf("根的直接子节点 = %d,期望 2", len(body.Tree[0].Children))
  185. }
  186. for _, child := range body.Tree[0].Children {
  187. if child.Layer != 2 {
  188. t.Errorf("子节点 %s layer = %d,期望 2", child.UserID, child.Layer)
  189. }
  190. }
  191. // 逐级检查孙节点(A1 / B1 各 1 个)
  192. grandTotal := 0
  193. for _, child := range body.Tree[0].Children {
  194. grandTotal += len(child.Children)
  195. }
  196. if grandTotal != 2 {
  197. t.Fatalf("孙节点总数 = %d,期望 2(A1、B1)", grandTotal)
  198. }
  199. }
  200. // TestDivideTreeRejectsBrokenNesting 端点 38:**故意造坏树** ⇒ 5001 + 文案指到具体不变式。
  201. //
  202. // 造法:给 B 的右值从 9 改成 5(与 A 的右值相同)⇒ I1(区间值重复)。
  203. // 这是"树坏了能被发现"的证明:只断言"返回 200 且有 tree"的实现,在这种情况下**依然绿**。
  204. func TestDivideTreeRejectsBrokenNesting(t *testing.T) {
  205. db := newDivideTestDB(t)
  206. root, a, b := "01HROOT000000000000000002", "01HA0000000000000000002", "01HB0000000000000000002"
  207. seedDivideNode(t, db, &root, nil, 1, 6, 1, "100.00")
  208. seedDivideNode(t, db, &a, &root, 2, 5, 2, "50.00")
  209. seedDivideNode(t, db, &b, &root, 6, 5, 2, "30.00") // ⚠ 坏行:left(6) >= right(5)
  210. _ = b
  211. app := newDivideTestApp(t, db)
  212. code, _ := getDivideTree(t, app)
  213. if code != infraerrors.CodeInternal {
  214. t.Fatalf("坏树应返回 5001(数据损坏),实得 %d —— 说明不变式校验没生效", code)
  215. }
  216. }
  217. // TestDivideTreeQuotaIsMoneyString 端点 38:`quota` / `cumulativeIncome` 必须是**金额字符串**。
  218. //
  219. // mock 曾把 `quota` 当 0~1 比率 ⇒ 前端拿到 "0.35" 当 35%(差 100 倍)。
  220. // 本用例把量纲钉死:JSON 里必须是 `"100.00"` 这样的**带引号两位小数**,
  221. // 不能是 `100`(number)、也不能是 `1`/`0.35`(比率)。
  222. func TestDivideTreeQuotaIsMoneyString(t *testing.T) {
  223. db := newDivideTestDB(t)
  224. root := "01HROOT000000000000000003"
  225. seedDivideNode(t, db, &root, nil, 1, 2, 1, "100.00")
  226. app := newDivideTestApp(t, db)
  227. code, body := getDivideTree(t, app)
  228. if code != 200 || len(body.Tree) != 1 {
  229. t.Fatalf("应返回 1 棵单节点树,实得 code=%d len=%d", code, len(body.Tree))
  230. }
  231. raw := string(body.Tree[0].Quota)
  232. if raw != `"100.00"` {
  233. t.Fatalf(`quota = %s,期望 "100.00"(金额字符串,两位小数;不是 0~1 比率、不是 number)`, raw)
  234. }
  235. if inc := string(body.Tree[0].CumulativeIncome); inc != `"0.00"` {
  236. t.Fatalf(`cumulativeIncome = %s,期望 "0.00"`, inc)
  237. }
  238. }
  239. // TestDivideTreeEmptyIsArray 空库:`tree` 必须是 `[]`(不能是 null),且不报错。
  240. func TestDivideTreeEmptyIsArray(t *testing.T) {
  241. db := newDivideTestDB(t)
  242. app := newDivideTestApp(t, db)
  243. req := httptest.NewRequest(http.MethodGet, PathDivideTree, nil)
  244. resp, err := app.Test(req, 5000)
  245. if err != nil {
  246. t.Fatalf("请求失败: %v", err)
  247. }
  248. defer func() { _ = resp.Body.Close() }()
  249. raw := new(strings.Builder)
  250. buf := make([]byte, 4096)
  251. n, _ := resp.Body.Read(buf)
  252. raw.Write(buf[:n])
  253. if strings.Contains(raw.String(), `"tree":null`) {
  254. t.Fatalf("空树必须是 [],不能是 null:%s", raw.String())
  255. }
  256. if !strings.Contains(raw.String(), `"tree":[]`) {
  257. t.Fatalf("空树应为 `tree:[]`:%s", raw.String())
  258. }
  259. }
  260. // TestDivideTreeMissingScopeIs1001 无作用域 → 1001(分账是全局数据,更不能匿名读)。
  261. func TestDivideTreeMissingScopeIs1001(t *testing.T) {
  262. db := newDivideTestDB(t)
  263. app := newDivideTestApp(t, db)
  264. req := httptest.NewRequest(http.MethodGet, PathDivideTree, nil)
  265. req.Header.Set("X-Test-Scope", "none")
  266. resp, err := app.Test(req, 5000)
  267. if err != nil {
  268. t.Fatalf("请求失败: %v", err)
  269. }
  270. defer func() { _ = resp.Body.Close() }()
  271. var envelope struct {
  272. Code int `json:"code"`
  273. }
  274. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  275. t.Fatalf("解码失败: %v", dErr)
  276. }
  277. if envelope.Code != infraerrors.CodeUnauth {
  278. t.Fatalf("无作用域应 1001,实得 %d", envelope.Code)
  279. }
  280. }
  281. // TestDivideLogsWindowAndTotals 端点 43:右开窗口 + `total` 与 `list` 同口径。
  282. //
  283. // 造 3 条流水:窗口前(02-20)、窗口内(03-05)、**恰好等于 `to`**(04-01)。
  284. // 断言:`total=1`(不是历史 3)、仅 1 行、且不出现 `to` 时刻的行 —— 若实现漏了窗口条件
  285. // (或 `to` 写成闭区间),这里会红。金额同样必须是字符串。
  286. func TestDivideLogsWindowAndTotals(t *testing.T) {
  287. db := newDivideTestDB(t)
  288. user := "01HFINDER00000000000000001"
  289. stu := "01HSTUDENT0000000000000001"
  290. // 种子时间必须与 `parseTimeParam` 用**同一个业务时区**(`tz.Location()`),
  291. // **不能用 `time.UTC`**:测试库是 SQLite,时间列按**文本**比较 ——
  292. // `time.UTC` 存成 `...T00:00:00Z`、`+08:00` 存成 `...T00:00:00+08:00`,
  293. // 同一绝对时刻的字面量在 offset 段就分出了大小 ⇒ `create_time < to` 判错
  294. //(实测:两种写法对同一时刻的命中结果相反)。生产 MySQL 的 `datetime` 是无时区
  295. // 字面值、按 DSN `loc` 解释,不存在该现象。见台账 F31。
  296. for _, at := range []time.Time{
  297. time.Date(2026, 2, 20, 10, 0, 0, 0, tz.Location()), // 窗口前
  298. time.Date(2026, 3, 5, 10, 0, 0, 0, tz.Location()), // 窗口内
  299. time.Date(2026, 4, 1, 0, 0, 0, 0, tz.Location()), // == to(右开:不得出现)
  300. } {
  301. if err := db.Create(&model.UserDivideIntoLog{
  302. Type: 1, UserID: user, StudentID: stu, Money: "12.34", CreateTime: at,
  303. }).Error; err != nil {
  304. t.Fatalf("seed 流水失败: %v", err)
  305. }
  306. }
  307. app := newDivideTestApp(t, db)
  308. path := fmt.Sprintf("%s?findUserId=%s&from=2026-03-01&to=2026-04-01", PathDivideLogs, user)
  309. req := httptest.NewRequest(http.MethodGet, path, nil)
  310. resp, err := app.Test(req, 5000)
  311. if err != nil {
  312. t.Fatalf("请求失败: %v", err)
  313. }
  314. defer func() { _ = resp.Body.Close() }()
  315. var envelope struct {
  316. Code int `json:"code"`
  317. Data struct {
  318. List []map[string]any `json:"list"`
  319. Total int64 `json:"total"`
  320. Pages int `json:"pages"`
  321. } `json:"data"`
  322. }
  323. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  324. t.Fatalf("解码失败: %v", dErr)
  325. }
  326. if envelope.Code != 200 {
  327. t.Fatalf("期望 200,实得 %d", envelope.Code)
  328. }
  329. if envelope.Data.Total != 1 || len(envelope.Data.List) != 1 {
  330. t.Fatalf("total/list = %d/%d,期望 1/1(右开窗口:to 时刻那笔应被排除;历史 3 笔不应计入)",
  331. envelope.Data.Total, len(envelope.Data.List))
  332. }
  333. row := envelope.Data.List[0]
  334. if got, ok := row["findUserId"].(string); !ok || got != user {
  335. t.Fatalf("findUserId = %v,期望 %s(表列 user_id → 契约字段 findUserId)", row["findUserId"], user)
  336. }
  337. if money, ok := row["money"].(string); !ok || money != "12.34" {
  338. t.Fatalf("money = %v,期望字符串 \"12.34\"", row["money"])
  339. }
  340. if envelope.Data.Pages != 1 {
  341. t.Fatalf("pages = %d,期望 1", envelope.Data.Pages)
  342. }
  343. }
  344. // TestDivideLogsRejectsReversedWindow from > to → 2001(不静默返回空集)。
  345. func TestDivideLogsRejectsReversedWindow(t *testing.T) {
  346. db := newDivideTestDB(t)
  347. app := newDivideTestApp(t, db)
  348. req := httptest.NewRequest(http.MethodGet,
  349. PathDivideLogs+"?from=2026-04-01&to=2026-03-01", nil)
  350. resp, err := app.Test(req, 5000)
  351. if err != nil {
  352. t.Fatalf("请求失败: %v", err)
  353. }
  354. defer func() { _ = resp.Body.Close() }()
  355. var envelope struct {
  356. Code int `json:"code"`
  357. }
  358. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  359. t.Fatalf("解码失败: %v", dErr)
  360. }
  361. if envelope.Code != infraerrors.CodeBadParam {
  362. t.Fatalf("from>to 应 2001,实得 %d", envelope.Code)
  363. }
  364. }
  365. // ---------------------------------------------------------------- 13a 补充用例(39/40/41/42 + D5 两树隔离)
  366. // TestDivideTreeRootUserIDFilter 端点 38 的 `rootUserId?`:只返回指定那棵树。
  367. //
  368. // 断言:两棵树共存时,带 rootUserId 只得到 1 棵、节点数只算该树;
  369. // 不带时得到森林(rootCount=2)。**跨树混淆是 Nested Set 最典型的错**(两树区间各自从 1 起)。
  370. func TestDivideTreeRootUserIDFilter(t *testing.T) {
  371. db := newDivideTestDB(t)
  372. rootA, childA := "01HROOTA00000000000000001", "01HCHILDA0000000000000001"
  373. rootB := "01HROOTB00000000000000001"
  374. seedDivideNode(t, db, &rootA, nil, 1, 4, 1, "100.00")
  375. seedDivideNode(t, db, &childA, &rootA, 2, 3, 2, "50.00")
  376. seedDivideNode(t, db, &rootB, nil, 1, 2, 1, "80.00")
  377. app := newDivideTestApp(t, db)
  378. code, forest := getDivideTree(t, app)
  379. if code != 200 || forest.RootCount != 2 || forest.NodeCount != 3 {
  380. t.Fatalf("不带 rootUserId 应得森林 2 棵/3 节点,实得 code=%d root=%d node=%d",
  381. code, forest.RootCount, forest.NodeCount)
  382. }
  383. req := httptest.NewRequest(http.MethodGet, PathDivideTree+"?rootUserId="+rootA, nil)
  384. resp, err := app.Test(req, 5000)
  385. if err != nil {
  386. t.Fatalf("请求失败: %v", err)
  387. }
  388. defer func() { _ = resp.Body.Close() }()
  389. var envelope struct {
  390. Code int `json:"code"`
  391. Data json.RawMessage `json:"data"`
  392. }
  393. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  394. t.Fatalf("解码失败: %v", dErr)
  395. }
  396. var one divideTreeResp
  397. if uErr := json.Unmarshal(envelope.Data, &one); uErr != nil {
  398. t.Fatalf("树体解码失败: %v", uErr)
  399. }
  400. if envelope.Code != 200 || one.RootCount != 1 || one.NodeCount != 2 {
  401. t.Fatalf("带 rootUserId 应得 1 棵/2 节点,实得 code=%d root=%d node=%d",
  402. envelope.Code, one.RootCount, one.NodeCount)
  403. }
  404. if one.Tree[0].UserID != rootA {
  405. t.Fatalf("返回的树根 = %s,期望 %s(不得串到另一棵树)", one.Tree[0].UserID, rootA)
  406. }
  407. }
  408. // TestDivideQuotaRejectsChildAboveParent 端点 40:**子额度 > 父额度 → 3111,且响应体带两个金额**。
  409. //
  410. // 3111 判据必须精确:是「新额度 > 父额度」,**不是** `quota > 1`、也不是比率越界。
  411. // 量纲同样在此验证:quota 传金额字符串 `"200.00"`(父 100.00)→ 3111;
  412. // 若谁把 quota 当比率(<1 合法),这条会红在"该拒没拒"上。
  413. //
  414. // 2026-11-11 拍板 A3 后新增的**端到端**判据:`message` 必须带具体金额。
  415. // 此前 message 恒为 sentinel 的"分账层级超限或额度超上限",用户不知道超了多少 ——
  416. // 那是 F32 的核心问题。断言放在 handler 层(而不是只断言 service 的 PublicMsg),
  417. // 因为只有这里能同时证明 `WithPublicMsg → AppError.PublicMsg → response.Fail` 三段都通。
  418. func TestDivideQuotaRejectsChildAboveParent(t *testing.T) {
  419. db := newDivideTestDB(t)
  420. root, child := "01HROOTQ00000000000000001", "01HCHILDQ0000000000000001"
  421. seedDivideNode(t, db, &root, nil, 1, 4, 1, "100.00")
  422. seedDivideNode(t, db, &child, &root, 2, 3, 2, "50.00")
  423. app := newDivideTestApp(t, db)
  424. h, _ := New(Config{DB: db})
  425. app.Put(PathDivideMemberQuota, h.SetDivideQuota)
  426. body := `{"quota":"200.00"}` // 子 200 > 父 100
  427. req := httptest.NewRequest(http.MethodPut, "/admin/divide-into/members/"+child+"/quota",
  428. strings.NewReader(body))
  429. req.Header.Set("Content-Type", "application/json")
  430. resp, err := app.Test(req, 5000)
  431. if err != nil {
  432. t.Fatalf("请求失败: %v", err)
  433. }
  434. defer func() { _ = resp.Body.Close() }()
  435. var envelope struct {
  436. Code int `json:"code"`
  437. Message string `json:"message"`
  438. }
  439. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  440. t.Fatalf("解码失败: %v", dErr)
  441. }
  442. if envelope.Code != apperr.CodeDivideQuotaExceeded {
  443. t.Fatalf("子额度超父应 3111,实得 %d(判据必须是「新额度 > 父额度」,不是 quota>1)", envelope.Code)
  444. }
  445. // **对外文案带数字**(A3):这是前端契约里钉死的一句话,改文案必须同步改契约与本断言
  446. wantMsg := "分成额度不得超过上级:本次 200.00 元,上级额度 100.00 元"
  447. if envelope.Message != wantMsg {
  448. t.Fatalf("3111 响应 message 不符:\n 期望 %q\n 实得 %q\n"+
  449. "若实得 '分账层级超限或额度超上限',说明 PublicMsg 没传到这里(A3 未落地)",
  450. wantMsg, envelope.Message)
  451. }
  452. }
  453. // TestDivideLogDataCorruptionIs5001 端点 41:分账树**数据损坏**(自环)→ 5001,**不是 3111**。
  454. //
  455. // 这是 2026-11-11 拍板 A3 的端到端验收:此前自环报 3111,前端会提示"额度超上限",
  456. // 运维照着去查额度 —— 方向完全错(真实原因是树的数据坏了)。
  457. //
  458. // 本用例同时锁住两件事:
  459. // 1. 错误码是 5001(不是 3111);
  460. // 2. `message` 是 sentinel 默认的"系统繁忙",**不得**把"parent 指向自身"这类内部结构描述漏给前端。
  461. func TestDivideLogDataCorruptionIs5001(t *testing.T) {
  462. db := newDivideTestDB(t)
  463. loop := "01HLOOPXX0000000000000001"
  464. // 真自环:parent 指向自身。正常接口建不出来(AddSubordinate 有 3140 拦截),只能人为造脏数据。
  465. // ⚠ 必须先按"根"落库再改父:`seedDivideNode` 在插入**前**会查父节点,
  466. // 自环时父就是自己、此刻还不存在 ⇒ 直接传 parent=self 会 fail 在"读父节点"。
  467. seedDivideNode(t, db, &loop, nil, 2, 3, 2, "10.00")
  468. if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", loop).
  469. Updates(map[string]any{"parent_user_id": loop}).Error; err != nil {
  470. t.Fatalf("造自环失败: %v", err)
  471. }
  472. app := newDivideTestApp(t, db)
  473. h, _ := New(Config{DB: db})
  474. app.Post(PathDivideLogs, h.CreateDivideLogRecord)
  475. body := `{"findUserId":"` + loop + `","studentId":"01HSTUDENT0000000000000001","orderNo":"777"}`
  476. req := httptest.NewRequest(http.MethodPost, "/admin/divide-into/logs", strings.NewReader(body))
  477. req.Header.Set("Content-Type", "application/json")
  478. resp, err := app.Test(req, 5000)
  479. if err != nil {
  480. t.Fatalf("请求失败: %v", err)
  481. }
  482. defer func() { _ = resp.Body.Close() }()
  483. var envelope struct {
  484. Code int `json:"code"`
  485. Message string `json:"message"`
  486. }
  487. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  488. t.Fatalf("解码失败: %v", dErr)
  489. }
  490. if envelope.Code != infraerrors.CodeInternal {
  491. t.Fatalf("分账树自环属数据损坏,对外应 5001,实得 %d(message=%q)\n"+
  492. "若为 3111 ⇒ A3 未落地:数据损坏与业务规则共码,前端会把运维引向额度",
  493. envelope.Code, envelope.Message)
  494. }
  495. if envelope.Code == apperr.CodeDivideQuotaExceeded {
  496. t.Fatal("仍是 3111:A3 要求三个数据异常场景(结构上限/自环/链超长)全部改判 5001")
  497. }
  498. if envelope.Message != "系统繁忙" {
  499. t.Fatalf("5001 对外文案应为 sentinel 默认值「系统繁忙」,实得 %q\n"+
  500. "(数据损坏不得给 PublicMsg,否则会把内部结构描述漏给用户)", envelope.Message)
  501. }
  502. // 落库反证:自环应在入循环前拒绝,一条明细都不该留下
  503. var logCount int64
  504. if err := db.Model(&model.UserDivideIntoLog{}).Count(&logCount).Error; err != nil {
  505. t.Fatalf("count 失败: %v", err)
  506. }
  507. if logCount != 0 {
  508. t.Fatalf("自环应整体回滚,实得 %d 条明细", logCount)
  509. }
  510. }
  511. // TestDivideQuotaRejectsRatioStyle 端点 40:`quota` 传比率(`0.5`)也必须被当金额接受为 **0.50 元**,
  512. // 而不是"50%"。本用例锁定"按金额解释"这一事实,并断言非法字符串 → 2002。
  513. func TestDivideQuotaRejectsRatioStyle(t *testing.T) {
  514. db := newDivideTestDB(t)
  515. root, child := "01HROOTR00000000000000001", "01HCHILDR0000000000000001"
  516. seedDivideNode(t, db, &root, nil, 1, 4, 1, "100.00")
  517. seedDivideNode(t, db, &child, &root, 2, 3, 2, "50.00")
  518. app := newDivideTestApp(t, db)
  519. h, _ := New(Config{DB: db})
  520. app.Put(PathDivideMemberQuota, h.SetDivideQuota)
  521. // 非法金额字符串 → 2002
  522. req := httptest.NewRequest(http.MethodPut, "/admin/divide-into/members/"+child+"/quota",
  523. strings.NewReader(`{"quota":"abc"}`))
  524. req.Header.Set("Content-Type", "application/json")
  525. resp, err := app.Test(req, 5000)
  526. if err != nil {
  527. t.Fatalf("请求失败: %v", err)
  528. }
  529. defer func() { _ = resp.Body.Close() }()
  530. var envelope struct {
  531. Code int `json:"code"`
  532. }
  533. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  534. t.Fatalf("解码失败: %v", dErr)
  535. }
  536. if envelope.Code != infraerrors.CodeBadFormat {
  537. t.Fatalf("非法 quota 应 2002,实得 %d", envelope.Code)
  538. }
  539. }
  540. // TestDivideLogRequiresNumericOrderNo 端点 41/42:`orderNo` 非数字 → 2002(不静默当成 record 0)。
  541. func TestDivideLogRequiresNumericOrderNo(t *testing.T) {
  542. db := newDivideTestDB(t)
  543. app := newDivideTestApp(t, db)
  544. h, _ := New(Config{DB: db})
  545. app.Post(PathDivideLogs, h.CreateDivideLogRecord)
  546. req := httptest.NewRequest(http.MethodPost, PathDivideLogs, strings.NewReader(
  547. `{"findUserId":"01HFINDER00000000000000002","studentId":"01HSTUDENT0000000000000002","orderNo":"abc"}`))
  548. req.Header.Set("Content-Type", "application/json")
  549. resp, err := app.Test(req, 5000)
  550. if err != nil {
  551. t.Fatalf("请求失败: %v", err)
  552. }
  553. defer func() { _ = resp.Body.Close() }()
  554. var envelope struct {
  555. Code int `json:"code"`
  556. }
  557. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  558. t.Fatalf("解码失败: %v", dErr)
  559. }
  560. if envelope.Code != infraerrors.CodeBadFormat {
  561. t.Fatalf("orderNo 非数字应 2002,实得 %d", envelope.Code)
  562. }
  563. }
  564. // TestDivideLogAddRejectsMissingScope 端点 41 无作用域 → 1001(先于参数校验)。
  565. func TestDivideLogAddRejectsMissingScope(t *testing.T) {
  566. db := newDivideTestDB(t)
  567. app := newDivideTestApp(t, db)
  568. h, _ := New(Config{DB: db})
  569. app.Post(PathDivideLogs, h.CreateDivideLogRecord)
  570. req := httptest.NewRequest(http.MethodPost, PathDivideLogs, strings.NewReader(`{}`))
  571. req.Header.Set("Content-Type", "application/json")
  572. req.Header.Set("X-Test-Scope", "none")
  573. resp, err := app.Test(req, 5000)
  574. if err != nil {
  575. t.Fatalf("请求失败: %v", err)
  576. }
  577. defer func() { _ = resp.Body.Close() }()
  578. var envelope struct {
  579. Code int `json:"code"`
  580. }
  581. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  582. t.Fatalf("解码失败: %v", dErr)
  583. }
  584. if envelope.Code != infraerrors.CodeUnauth {
  585. t.Fatalf("无作用域应 1001,实得 %d", envelope.Code)
  586. }
  587. }
  588. // TestDivideTwoTreesIsolationInsert 设计 §5.1 纪律 3 / §3.8-D5:**插入不得平移另一棵树**。
  589. //
  590. // D5 的失败形态很隐蔽:`ShiftRight/ShiftLeft` 若漏掉 `root_user_id` 过滤,
  591. // 位移会按 `left_val >= parentRight` 命中**另一棵树**里数值更大的节点 ⇒ 该树区间整体被平移。
  592. // 单看本树完全正常(区间仍自洽),所以**只有逐值比对另一棵树**才能发现。
  593. //
  594. // 断言:在 A 树插入新节点后,B 树每行的 (left,right,layer,quota) **逐值不变**。
  595. func TestDivideTwoTreesIsolationInsert(t *testing.T) {
  596. db := newDivideTestDB(t)
  597. rootA, rootB, childB := "01HROOTA10000000000000001", "01HROOTB10000000000000001",
  598. "01HCHILDB1000000000000001"
  599. seedDivideNode(t, db, &rootA, nil, 1, 2, 1, "100.00")
  600. seedDivideNode(t, db, &rootB, nil, 1, 4, 1, "80.00")
  601. seedDivideNode(t, db, &childB, &rootB, 2, 3, 2, "40.00")
  602. before := map[string][3]int32{}
  603. var rowsBefore []model.UserDivideInto
  604. if err := db.Where("root_user_id = ?", rootB).Order("left_val").Find(&rowsBefore).Error; err != nil {
  605. t.Fatalf("读 B 树失败: %v", err)
  606. }
  607. for _, r := range rowsBefore {
  608. before[r.UserID] = [3]int32{r.LeftVal, r.RightVal, r.Layer}
  609. }
  610. // 在 A 树插入新下线(A 根 left=1,right=2 ⇒ 插入点 parentRight=2 ⇒ 新节点 (2,3))
  611. app := newDivideTestApp(t, db)
  612. h, _ := New(Config{DB: db})
  613. app.Post(PathDivideMembers, h.AddDivideMember)
  614. body := `{"parentUserId":"` + rootA + `","subUserId":"01HNEWA100000000000000001"}`
  615. req := httptest.NewRequest(http.MethodPost, PathDivideMembers, strings.NewReader(body))
  616. req.Header.Set("Content-Type", "application/json")
  617. resp, err := app.Test(req, 5000)
  618. if err != nil {
  619. t.Fatalf("请求失败: %v", err)
  620. }
  621. defer func() { _ = resp.Body.Close() }()
  622. var envelope struct {
  623. Code int `json:"code"`
  624. }
  625. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  626. t.Fatalf("解码失败: %v", dErr)
  627. }
  628. if envelope.Code != 200 {
  629. t.Fatalf("加入下线应 200,实得 %d", envelope.Code)
  630. }
  631. // ① 先**逐值比对** B 树:一字不动 —— 这是 D5(漏 root 过滤)的**唯一可判形式**:
  632. // 漏了 root 时,`left>=threshold` 会把 B 树里数值更大的节点一起 +2,
  633. // 而 A 树自身看起来仍可能自洽(或两边一起坏),单看 A 树分不清"谁错了"。
  634. // 逐值比对 B 树:一字不动
  635. var rowsAfter []model.UserDivideInto
  636. if err := db.Where("root_user_id = ?", rootB).Order("left_val").Find(&rowsAfter).Error; err != nil {
  637. t.Fatalf("读 B 树失败: %v", err)
  638. }
  639. if len(rowsAfter) != len(before) {
  640. t.Fatalf("B 树节点数变化:%d → %d(A 树插入不该影响 B 树)", len(before), len(rowsAfter))
  641. }
  642. for _, r := range rowsAfter {
  643. want, ok := before[r.UserID]
  644. if !ok {
  645. t.Fatalf("B 树出现新节点 %s(不该有)", r.UserID)
  646. }
  647. if r.LeftVal != want[0] || r.RightVal != want[1] || r.Layer != want[2] {
  648. t.Fatalf("B 树节点 %s 区间被平移:(left,right,layer) 期望 (%d,%d,%d),实得 (%d,%d,%d)"+
  649. " —— 说明位移漏了 root_user_id 过滤(D5)",
  650. r.UserID, want[0], want[1], want[2], r.LeftVal, r.RightVal, r.Layer)
  651. }
  652. }
  653. // ①bis 端点 39 的新节点 `quota` 必须是**金额零** `"0.00"`(D6/DDL 默认),
  654. // 不是 null、不是空串、不是"未设置"——`0` 是"该节点不分成"的合法配置值,
  655. // 用 null 表达会让"不分成"与"未配置"混为一谈(契约 §2.3 提示前端引导设额度即基于此)。
  656. var newNode model.UserDivideInto
  657. if qErr := db.Where("user_id = ?", "01HNEWA100000000000000001").Take(&newNode).Error; qErr != nil {
  658. t.Fatalf("读新节点失败: %v", qErr)
  659. }
  660. // 断言"金额零"这一语义(sqlite 的 NUMERIC 亲和性会把 "0.00" 读回 "0",MySQL 读回 "0.00",
  661. // 两者定点值相同;故按 money.Amount 比较,再单独断言"不能用空串/null 表达")
  662. quotaAmt, quotaErr := money.Parse(newNode.Quota)
  663. if quotaErr != nil || !quotaAmt.IsZero() {
  664. t.Fatalf("新节点 quota = %q(解析 err=%v),期望金额零 0.00", newNode.Quota, quotaErr)
  665. }
  666. if strings.TrimSpace(newNode.Quota) == "" {
  667. t.Fatal("新节点 quota 不能用空串/null 表达 —— 0 是「不分成」的合法配置值,与「未配置」不是一回事")
  668. }
  669. // ② 再自证"插入后 A 树自身仍然自洽"(不变式 I1~I4)——
  670. // 若插入点取错(拿位移后的 parentRight、或 off-by-one),A 树会出现空洞/重叠 ⇒ 5001。
  671. // 放在最后:D-3 的失败形态只影响 A 树,B 树比对对它无感。
  672. if codeAfter, _ := getDivideTree(t, app); codeAfter != 200 {
  673. t.Fatalf("插入后 A 树不变式被破坏(返回 %d)—— 检查插入点是否取了**位移前**的 parentRight(D-3)", codeAfter)
  674. }
  675. }