divide_repo_test.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. // DivideRepo 单测(AC-09):左右值位移 / 嵌套集不变式守卫 / 两级幂等键 / 撤销原语 / 额度级联原语。
  2. //
  3. // 注意:本仓储**不继承 BaseRepo**(全局无作用域表),因此没有 scoping 用例;
  4. // 越权防线在 Handler 角色白名单(AC-14)+ Service 显式参数,本层只保证数据正确性。
  5. package repository
  6. import (
  7. "context"
  8. "errors"
  9. "path/filepath"
  10. "strings"
  11. "testing"
  12. "time"
  13. gormsqlite "github.com/glebarez/sqlite"
  14. "gorm.io/gorm"
  15. "yuansiwei/unified-backend/internal/academic/model"
  16. "yuansiwei/unified-backend/internal/academic/money"
  17. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  18. )
  19. // newDivideRepoDB 分账域真实库(文件库;2 表 AutoMigrate)。
  20. func newDivideRepoDB(t *testing.T) *gorm.DB {
  21. t.Helper()
  22. dsn := filepath.Join(t.TempDir(), "academic_divide_repo_test.db")
  23. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  24. if err != nil {
  25. t.Fatalf("gorm.Open(分账文件库) 失败: %v", err)
  26. }
  27. t.Cleanup(func() {
  28. if sqlDB, e := db.DB(); e == nil {
  29. _ = sqlDB.Close()
  30. }
  31. })
  32. if err := db.AutoMigrate(&model.UserDivideInto{}, &model.UserDivideIntoLog{}); err != nil {
  33. t.Fatalf("AutoMigrate(分账 2 表) 失败: %v", err)
  34. }
  35. return db
  36. }
  37. // seedDivideNode 插入分账树节点(显式左右值,用于原语级用例)。
  38. func seedDivideNode(t *testing.T, db *gorm.DB, userID string, typ int8, left, right, layer int32, quota string, parent, root *string) {
  39. t.Helper()
  40. row := &model.UserDivideInto{
  41. UserID: userID,
  42. Type: typ,
  43. Quota: quota,
  44. TotalIncome: "0.00",
  45. TotalIncomePayed: "0.00",
  46. RootUserID: root,
  47. ParentUserID: parent,
  48. LeftVal: left,
  49. RightVal: right,
  50. Layer: layer,
  51. }
  52. if err := db.Create(row).Error; err != nil {
  53. t.Fatalf("seed 分账节点(%s) 失败: %v", userID, err)
  54. }
  55. }
  56. // loadNode 读节点(断言辅助)。
  57. func loadNode(t *testing.T, db *gorm.DB, userID string) model.UserDivideInto {
  58. t.Helper()
  59. var row model.UserDivideInto
  60. if err := db.Where("user_id = ?", userID).First(&row).Error; err != nil {
  61. t.Fatalf("读节点(%s) 失败: %v", userID, err)
  62. }
  63. return row
  64. }
  65. // centsOf 把库里的金额字符串归一为「分」(sqlite NUMERIC 亲和回读会丢 "0.00" 尾巴,故不得比字符串)。
  66. func centsOf(t *testing.T, s string) int64 {
  67. t.Helper()
  68. amt, err := money.Parse(s)
  69. if err != nil {
  70. t.Fatalf("金额解析失败 %q: %v", s, err)
  71. }
  72. return amt.Cents()
  73. }
  74. // TestDivideGetByUserID 命中 / 未命中(ErrNotFound)/ 参数防御。
  75. func TestDivideGetByUserID(t *testing.T) {
  76. db := newDivideRepoDB(t)
  77. root := "01HROOT000000000000000001"
  78. seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 2, 1, "10.00", nil, &root)
  79. repo := NewDivideRepo(db)
  80. ctx := context.Background()
  81. got, err := repo.GetByUserID(ctx, root)
  82. if err != nil {
  83. t.Fatalf("GetByUserID 失败: %v", err)
  84. }
  85. if got.LeftVal != 1 || got.RightVal != 2 || got.Layer != 1 {
  86. t.Fatalf("节点左右值/层级不符: %+v", got)
  87. }
  88. if _, err := repo.GetByUserID(ctx, "01HNONE000000000000000001"); !errors.Is(err, infraerrors.ErrNotFound) {
  89. t.Fatalf("不存在应 ErrNotFound,实得 %v", err)
  90. }
  91. if _, err := repo.GetByUserID(ctx, ""); !errors.Is(err, infraerrors.ErrBadParam) {
  92. t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
  93. }
  94. if _, err := (&DivideRepo{}).GetByUserID(ctx, root); !errors.Is(err, infraerrors.ErrInternal) {
  95. t.Fatalf("DB 未装配应 ErrInternal,实得 %v", err)
  96. }
  97. }
  98. // TestDivideShiftLeftRight 左右值位移(+2/-2 与阈值语义)。
  99. func TestDivideShiftLeftRight(t *testing.T) {
  100. db := newDivideRepoDB(t)
  101. root := "01HROOT000000000000000002"
  102. a, b := "01HNODEA00000000000000002", "01HNODEB00000000000000002"
  103. seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &root)
  104. seedDivideNode(t, db, a, model.DivideTypeOneOff, 2, 3, 2, "5.00", &root, &root)
  105. seedDivideNode(t, db, b, model.DivideTypeOneOff, 4, 5, 2, "5.00", &root, &root)
  106. repo := NewDivideRepo(db)
  107. runner := NewTxRunner(db)
  108. ctx := context.Background()
  109. // 在 root 右值前插入:先 right>=6 +2,再 left>=6 +2(无节点满足)
  110. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  111. n, e := repo.ShiftRight(ctx, tx, root, 6, 2)
  112. if e != nil {
  113. return e
  114. }
  115. if n != 1 {
  116. t.Fatalf("ShiftRight(6,+2) 应影响 1 行(root),实得 %d", n)
  117. }
  118. _, e = repo.ShiftLeft(ctx, tx, root, 6, 2)
  119. return e
  120. }); err != nil {
  121. t.Fatalf("位移失败: %v", err)
  122. }
  123. if got := loadNode(t, db, root); got.RightVal != 8 {
  124. t.Fatalf("root.right 应为 8,实得 %d", got.RightVal)
  125. }
  126. // 在 a 右值前插入:right>=3 全体 +2(root、a、b),left>=3 全体 +2(b)
  127. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  128. if _, e := repo.ShiftRight(ctx, tx, root, 3, 2); e != nil {
  129. return e
  130. }
  131. _, e := repo.ShiftLeft(ctx, tx, root, 3, 2)
  132. return e
  133. }); err != nil {
  134. t.Fatalf("位移失败: %v", err)
  135. }
  136. if got := loadNode(t, db, root); got.LeftVal != 1 || got.RightVal != 10 {
  137. t.Fatalf("root 应为 (1,10),实得 (%d,%d)", got.LeftVal, got.RightVal)
  138. }
  139. if got := loadNode(t, db, a); got.LeftVal != 2 || got.RightVal != 5 {
  140. t.Fatalf("a 应为 (2,5),实得 (%d,%d)", got.LeftVal, got.RightVal)
  141. }
  142. if got := loadNode(t, db, b); got.LeftVal != 6 || got.RightVal != 7 {
  143. t.Fatalf("b 应为 (6,7),实得 (%d,%d)", got.LeftVal, got.RightVal)
  144. }
  145. // 防御:tx nil / root 缺失 / 阈值非法 / delta=0
  146. if _, err := repo.ShiftRight(ctx, nil, root, 1, 2); !errors.Is(err, infraerrors.ErrInternal) {
  147. t.Fatalf("tx nil 应 ErrInternal,实得 %v", err)
  148. }
  149. if _, err := repo.ShiftRight(ctx, repo.DB, "", 1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
  150. t.Fatalf("root 缺失应 ErrBadParam(多树隔离必填),实得 %v", err)
  151. }
  152. if _, err := repo.ShiftLeft(ctx, repo.DB, root, -1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
  153. t.Fatalf("阈值 <0 应 ErrBadParam,实得 %v", err)
  154. }
  155. if n, err := repo.ShiftLeft(ctx, repo.DB, root, 1, 0); err != nil || n != 0 {
  156. t.Fatalf("delta=0 应空操作,实得 n=%d err=%v", n, err)
  157. }
  158. }
  159. // TestDivideShiftRootIsolation 两棵树互不影响(audit V-02,dump 763707-763708 的 root 限定)。
  160. //
  161. // 造两棵树的 left/right **区间完全重叠**(T1: 1..6,T2: 1..6)。T2 一字不动是本用例的核心断言 ——
  162. // 若 shift 漏了 `root_user_id = ?`,T2 的节点会被同一次位移连带改写(双跑 diff 必红)。
  163. func TestDivideShiftRootIsolation(t *testing.T) {
  164. db := newDivideRepoDB(t)
  165. t1Root, t2Root := "01HROOT000000000000000101", "01HROOT000000000000000102"
  166. t1a, t2a := "01HNODEA00000000000000101", "01HNODEA00000000000000102"
  167. seedDivideNode(t, db, t1Root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &t1Root)
  168. seedDivideNode(t, db, t1a, model.DivideTypeOneOff, 2, 3, 2, "5.00", &t1Root, &t1Root)
  169. // T2 用同一套左右值(区间重叠,最能暴露漏 root 限定的问题)
  170. seedDivideNode(t, db, t2Root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &t2Root)
  171. seedDivideNode(t, db, t2a, model.DivideTypeOneOff, 4, 5, 2, "5.00", &t2Root, &t2Root)
  172. repo := NewDivideRepo(db)
  173. runner := NewTxRunner(db)
  174. ctx := context.Background()
  175. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  176. if _, e := repo.ShiftRight(ctx, tx, t1Root, 3, 2); e != nil {
  177. return e
  178. }
  179. _, e := repo.ShiftLeft(ctx, tx, t1Root, 3, 2)
  180. return e
  181. }); err != nil {
  182. t.Fatalf("T1 位移失败: %v", err)
  183. }
  184. // T1 被正确改写
  185. if got := loadNode(t, db, t1Root); got.LeftVal != 1 || got.RightVal != 8 {
  186. t.Fatalf("T1 root 应为 (1,8),实得 (%d,%d)", got.LeftVal, got.RightVal)
  187. }
  188. // T2 **一字不动**(漏 root 限定时这里会红)
  189. if got := loadNode(t, db, t2Root); got.LeftVal != 1 || got.RightVal != 6 {
  190. t.Fatalf("T2 root 不应被波及,应为 (1,6),实得 (%d,%d)", got.LeftVal, got.RightVal)
  191. }
  192. if got := loadNode(t, db, t2a); got.LeftVal != 4 || got.RightVal != 5 {
  193. t.Fatalf("T2 子节点不应被波及,应为 (4,5),实得 (%d,%d)", got.LeftVal, got.RightVal)
  194. }
  195. }
  196. // TestDivideInsertNodeGuards 嵌套集不变式(left<right)与事务必传。
  197. func TestDivideInsertNodeGuards(t *testing.T) {
  198. db := newDivideRepoDB(t)
  199. repo := NewDivideRepo(db)
  200. ctx := context.Background()
  201. if err := repo.InsertNode(ctx, nil, &model.UserDivideInto{UserID: "u1", LeftVal: 1, RightVal: 2}); !errors.Is(err, infraerrors.ErrInternal) {
  202. t.Fatalf("tx nil 应 ErrInternal,实得 %v", err)
  203. }
  204. if err := repo.InsertNode(ctx, db, &model.UserDivideInto{UserID: "u1", LeftVal: 5, RightVal: 5}); !errors.Is(err, infraerrors.ErrBadParam) {
  205. t.Fatalf("left>=right 应 ErrBadParam(不变式守卫),实得 %v", err)
  206. }
  207. if err := repo.InsertNode(ctx, db, &model.UserDivideInto{UserID: " ", LeftVal: 1, RightVal: 2}); !errors.Is(err, infraerrors.ErrBadParam) {
  208. t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
  209. }
  210. if err := repo.InsertNode(ctx, db, &model.UserDivideInto{
  211. UserID: "01HOK00000000000000000001", Type: model.DivideTypeOneOff, Quota: "1.00",
  212. TotalIncome: "0.00", TotalIncomePayed: "0.00", LeftVal: 1, RightVal: 2, Layer: 1,
  213. }); err != nil {
  214. t.Fatalf("InsertNode 失败: %v", err)
  215. }
  216. }
  217. // TestDivideGetSubtree 子树查询(严格区间 + 类型过滤 + 前序排序)。
  218. func TestDivideGetSubtree(t *testing.T) {
  219. db := newDivideRepoDB(t)
  220. root := "01HROOT000000000000000003"
  221. a, b := "01HNODEA00000000000000003", "01HNODEB00000000000000003"
  222. seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &root)
  223. seedDivideNode(t, db, a, model.DivideTypeOneOff, 2, 3, 2, "5.00", &root, &root)
  224. seedDivideNode(t, db, b, model.DivideTypePerLesson, 4, 5, 2, "5.00", &root, &root)
  225. repo := NewDivideRepo(db)
  226. ctx := context.Background()
  227. all, err := repo.GetSubtree(ctx, 1, 6, 0)
  228. if err != nil {
  229. t.Fatalf("GetSubtree 失败: %v", err)
  230. }
  231. if len(all) != 2 || all[0].UserID != a || all[1].UserID != b {
  232. t.Fatalf("子树应为 [a,b](left 升序),实得 %+v", all)
  233. }
  234. onlyOne, err := repo.GetSubtree(ctx, 1, 6, model.DivideTypePerLesson)
  235. if err != nil {
  236. t.Fatalf("GetSubtree(type) 失败: %v", err)
  237. }
  238. if len(onlyOne) != 1 || onlyOne[0].UserID != b {
  239. t.Fatalf("type=2 子树应仅 [b],实得 %+v", onlyOne)
  240. }
  241. if _, err := repo.GetSubtree(ctx, 6, 1, 0); !errors.Is(err, infraerrors.ErrBadParam) {
  242. t.Fatalf("非法区间应 ErrBadParam,实得 %v", err)
  243. }
  244. }
  245. // TestDivideFindLogKeys 两级幂等键(type=1 忽略订单;type=2 含订单)。
  246. func TestDivideFindLogKeys(t *testing.T) {
  247. db := newDivideRepoDB(t)
  248. repo := NewDivideRepo(db)
  249. ctx := context.Background()
  250. stu := "01HSTU00000000000000000001"
  251. u := "01HUSER000000000000000021"
  252. if err := db.Create(&model.UserDivideIntoLog{
  253. Type: model.DivideTypeOneOff, UserID: u, StudentID: stu, OrderNo: ptrString("100"),
  254. Money: "10.00", CreateTime: time.Now(),
  255. }).Error; err != nil {
  256. t.Fatalf("seed log 失败: %v", err)
  257. }
  258. if err := db.Create(&model.UserDivideIntoLog{
  259. Type: model.DivideTypePerLesson, UserID: u, StudentID: stu, OrderNo: ptrString("200"),
  260. Money: "5.00", CreateTime: time.Now(),
  261. }).Error; err != nil {
  262. t.Fatalf("seed log 失败: %v", err)
  263. }
  264. // type=1:任意 order 均命中同一行(一次付清口径)
  265. got, err := repo.FindLog(ctx, u, stu, model.DivideTypeOneOff, "999")
  266. if err != nil {
  267. t.Fatalf("FindLog(type1) 失败: %v", err)
  268. }
  269. if c := centsOf(t, got.Money); c != 1000 {
  270. t.Fatalf("type=1 应命中 10.00 的行,实得 %q(%d 分)", got.Money, c)
  271. }
  272. // type=2:order 必须匹配
  273. if _, err := repo.FindLog(ctx, u, stu, model.DivideTypePerLesson, "200"); err != nil {
  274. t.Fatalf("FindLog(type2,200) 应命中: %v", err)
  275. }
  276. if _, err := repo.FindLog(ctx, u, stu, model.DivideTypePerLesson, "999"); !errors.Is(err, infraerrors.ErrNotFound) {
  277. t.Fatalf("type=2 不同 order 应 ErrNotFound,实得 %v", err)
  278. }
  279. if _, err := repo.FindLog(ctx, "", stu, model.DivideTypeOneOff, "1"); !errors.Is(err, infraerrors.ErrBadParam) {
  280. t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
  281. }
  282. }
  283. // TestDivideListLogsByStudentOrder 两参撤销入口:按 (student, order) 取全量上行明细。
  284. func TestDivideListLogsByStudentOrder(t *testing.T) {
  285. db := newDivideRepoDB(t)
  286. repo := NewDivideRepo(db)
  287. ctx := context.Background()
  288. stu := "01HSTU00000000000000000002"
  289. for _, u := range []string{"01HUSER000000000000000031", "01HUSER000000000000000032", "01HUSER000000000000000033"} {
  290. if err := db.Create(&model.UserDivideIntoLog{
  291. Type: model.DivideTypePerLesson, UserID: u, StudentID: stu, OrderNo: ptrString("777"),
  292. Money: "1.00", CreateTime: time.Now(),
  293. }).Error; err != nil {
  294. t.Fatalf("seed log 失败: %v", err)
  295. }
  296. }
  297. rows, err := repo.ListLogsByStudentOrder(ctx, stu, "777")
  298. if err != nil {
  299. t.Fatalf("ListLogsByStudentOrder 失败: %v", err)
  300. }
  301. if len(rows) != 3 {
  302. t.Fatalf("应返回同一订单的 3 条上行明细,实得 %d", len(rows))
  303. }
  304. if rows[0].ID >= rows[1].ID || rows[1].ID >= rows[2].ID {
  305. t.Fatalf("应按 id 升序返回,实得 %+v", rows)
  306. }
  307. empty, err := repo.ListLogsByStudentOrder(ctx, stu, "888")
  308. if err != nil || len(empty) != 0 {
  309. t.Fatalf("无匹配应返回空切片,实得 %v err=%v", empty, err)
  310. }
  311. if _, err := repo.ListLogsByStudentOrder(ctx, stu, ""); !errors.Is(err, infraerrors.ErrBadParam) {
  312. t.Fatalf("空 order_no 应 ErrBadParam,实得 %v", err)
  313. }
  314. }
  315. // TestDivideQuotaPrimitives 额度更新 / 级联只降不升 / 收入累加。
  316. func TestDivideQuotaPrimitives(t *testing.T) {
  317. db := newDivideRepoDB(t)
  318. root := "01HROOT000000000000000004"
  319. a := "01HNODEA00000000000000004"
  320. seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &root)
  321. seedDivideNode(t, db, a, model.DivideTypeOneOff, 2, 5, 2, "8.00", &root, &root)
  322. repo := NewDivideRepo(db)
  323. runner := NewTxRunner(db)
  324. ctx := context.Background()
  325. // 级联只降:子树限制 4.00 → a(8.00) 降到 4.00
  326. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  327. n, e := repo.ClampSubtreeQuota(ctx, tx, root, 1, 6, model.DivideTypeOneOff, money.FromCents(400))
  328. if e != nil {
  329. return e
  330. }
  331. if n != 1 {
  332. t.Fatalf("应压制 1 个子节点,实得 %d", n)
  333. }
  334. return nil
  335. }); err != nil {
  336. t.Fatalf("ClampSubtreeQuota 失败: %v", err)
  337. }
  338. if got := loadNode(t, db, a); centsOf(t, got.Quota) != 400 {
  339. t.Fatalf("a.quota 应压到 4.00,实得 %q", got.Quota)
  340. }
  341. // 再压到 6.00(高于现值)→ 不回升(影响 0 行)
  342. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  343. n, e := repo.ClampSubtreeQuota(ctx, tx, root, 1, 6, model.DivideTypeOneOff, money.FromCents(600))
  344. if e != nil {
  345. return e
  346. }
  347. if n != 0 {
  348. t.Fatalf("压制只降不升,应 0 行,实得 %d", n)
  349. }
  350. return nil
  351. }); err != nil {
  352. t.Fatalf("ClampSubtreeQuota(仅降) 失败: %v", err)
  353. }
  354. // 收入累加 + 冲正
  355. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  356. if _, e := repo.IncTotalIncome(ctx, tx, a, money.FromCents(1250)); e != nil {
  357. return e
  358. }
  359. _, e := repo.IncTotalIncome(ctx, tx, a, money.FromCents(-250))
  360. return e
  361. }); err != nil {
  362. t.Fatalf("IncTotalIncome 失败: %v", err)
  363. }
  364. if got := loadNode(t, db, a); centsOf(t, got.TotalIncome) != 1000 {
  365. t.Fatalf("a.total_income 应为 10.00,实得 %q", got.TotalIncome)
  366. }
  367. // 额度更新 + 负额度拒绝
  368. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  369. _, e := repo.UpdateNodeQuota(ctx, tx, a, money.FromCents(1500))
  370. return e
  371. }); err != nil {
  372. t.Fatalf("UpdateNodeQuota 失败: %v", err)
  373. }
  374. if got := loadNode(t, db, a); centsOf(t, got.Quota) != 1500 {
  375. t.Fatalf("a.quota 应为 15.00,实得 %q", got.Quota)
  376. }
  377. if _, err := repo.UpdateNodeQuota(ctx, db, a, money.FromCents(-1)); !errors.Is(err, infraerrors.ErrBadParam) {
  378. t.Fatalf("负额度应 ErrBadParam,实得 %v", err)
  379. }
  380. if _, err := repo.ClampSubtreeQuota(ctx, db, root, 5, 1, model.DivideTypeOneOff, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) {
  381. t.Fatalf("非法区间应 ErrBadParam,实得 %v", err)
  382. }
  383. // audit A6:分成类型非法 → ErrBadParam(白名单 0/1/2)
  384. if _, err := repo.ClampSubtreeQuota(ctx, db, root, 1, 6, 9, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) {
  385. t.Fatalf("非法分成类型应 ErrBadParam,实得 %v", err)
  386. }
  387. }
  388. // TestClampSubtreeQuotaTypeFilter 混合类型:级联只压**同一分成类型**的子节点(audit A6,dump 763873)。
  389. //
  390. // 父(type=1, quota 10) ├─ A(type=1, 8) └─ B(type=2, 8)
  391. // 父降到 4 → 只有 A 被压到 4,**B 保持 8**(按课次线的额度不受一次付清线影响)。
  392. // 若漏了 `type = ?` 过滤,B 会被一起压扁 → 本用例红。
  393. func TestClampSubtreeQuotaTypeFilter(t *testing.T) {
  394. db := newDivideRepoDB(t)
  395. root := "01HROOT000000000000000201"
  396. a := "01HNODEA00000000000000201"
  397. b := "01HNODEB00000000000000201"
  398. seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &root)
  399. seedDivideNode(t, db, a, model.DivideTypeOneOff, 2, 3, 2, "8.00", &root, &root)
  400. seedDivideNode(t, db, b, model.DivideTypePerLesson, 4, 5, 2, "8.00", &root, &root)
  401. repo := NewDivideRepo(db)
  402. runner := NewTxRunner(db)
  403. ctx := context.Background()
  404. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  405. n, e := repo.ClampSubtreeQuota(ctx, tx, root, 1, 6, model.DivideTypeOneOff, money.FromCents(400))
  406. if e != nil {
  407. return e
  408. }
  409. if n != 1 {
  410. t.Fatalf("只应压制同类型 1 个节点(A),实得 %d", n)
  411. }
  412. return nil
  413. }); err != nil {
  414. t.Fatalf("ClampSubtreeQuota 失败: %v", err)
  415. }
  416. if got := centsOf(t, loadNode(t, db, a).Quota); got != 400 {
  417. t.Fatalf("A(type=1) 应压到 4.00,实得 %d 分", got)
  418. }
  419. if got := centsOf(t, loadNode(t, db, b).Quota); got != 800 {
  420. t.Fatalf("B(type=2) 不得被跨类型压制,应保持 8.00,实得 %d 分", got)
  421. }
  422. // divideType=0(admin 全量场景)→ 两种类型一起压
  423. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  424. n, e := repo.ClampSubtreeQuota(ctx, tx, root, 1, 6, 0, money.FromCents(200))
  425. if e != nil {
  426. return e
  427. }
  428. if n != 2 {
  429. t.Fatalf("divideType=0 应压制 2 个节点,实得 %d", n)
  430. }
  431. return nil
  432. }); err != nil {
  433. t.Fatalf("ClampSubtreeQuota(0) 失败: %v", err)
  434. }
  435. }
  436. // TestDivideLogPrimitives InsertLog 类型校验 / UpdateLogOrderNo 改绑 / DeleteLog 删行。
  437. func TestDivideLogPrimitives(t *testing.T) {
  438. db := newDivideRepoDB(t)
  439. repo := NewDivideRepo(db)
  440. runner := NewTxRunner(db)
  441. ctx := context.Background()
  442. stu := "01HSTU00000000000000000003"
  443. u := "01HUSER000000000000000041"
  444. if err := repo.InsertLog(ctx, db, &model.UserDivideIntoLog{Type: 9, UserID: u, StudentID: stu}); !errors.Is(err, infraerrors.ErrBadParam) {
  445. t.Fatalf("非法 type 应 ErrBadParam,实得 %v", err)
  446. }
  447. if err := repo.InsertLog(ctx, db, &model.UserDivideIntoLog{Type: model.DivideTypeOneOff, UserID: "", StudentID: stu}); !errors.Is(err, infraerrors.ErrBadParam) {
  448. t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
  449. }
  450. var logID int64
  451. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  452. row := &model.UserDivideIntoLog{
  453. Type: model.DivideTypePerLesson, UserID: u, StudentID: stu, OrderNo: ptrString("777"),
  454. Money: "3.50", CreateTime: time.Now(),
  455. }
  456. if e := repo.InsertLog(ctx, tx, row); e != nil {
  457. return e
  458. }
  459. logID = row.ID
  460. return nil
  461. }); err != nil {
  462. t.Fatalf("InsertLog 失败: %v", err)
  463. }
  464. // 改绑:order_no=888 + 备注
  465. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  466. n, e := repo.UpdateLogOrderNo(ctx, tx, logID, "888", "推广分成")
  467. if e != nil {
  468. return e
  469. }
  470. if n != 1 {
  471. t.Fatalf("改绑应影响 1 行,实得 %d", n)
  472. }
  473. return nil
  474. }); err != nil {
  475. t.Fatalf("UpdateLogOrderNo 失败: %v", err)
  476. }
  477. var row model.UserDivideIntoLog
  478. if err := db.Where("id = ?", logID).First(&row).Error; err != nil {
  479. t.Fatalf("读 log 失败: %v", err)
  480. }
  481. if row.OrderNo == nil || *row.OrderNo != "888" {
  482. t.Fatalf("order_no 应改为 888,实得 %v", row.OrderNo)
  483. }
  484. if row.Remark == nil || *row.Remark != "推广分成" {
  485. t.Fatalf("remark 应写推广分成,实得 %v", row.Remark)
  486. }
  487. // 删除
  488. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.DeleteLog(ctx, tx, logID) }); err != nil {
  489. t.Fatalf("DeleteLog 失败: %v", err)
  490. }
  491. var count int64
  492. if err := db.Model(&model.UserDivideIntoLog{}).Count(&count).Error; err != nil {
  493. t.Fatalf("count 失败: %v", err)
  494. }
  495. if count != 0 {
  496. t.Fatalf("删除后应为 0 行,实得 %d", count)
  497. }
  498. if err := repo.DeleteLog(ctx, nil, 1); !errors.Is(err, infraerrors.ErrInternal) {
  499. t.Fatalf("tx nil 应 ErrInternal,实得 %v", err)
  500. }
  501. }
  502. // TestDivideUpdateNodeParentFillsRoot 改挂原语显式写 root(**仅数据修复用**,不在 dump 流程内)。
  503. func TestDivideUpdateNodeParentFillsRoot(t *testing.T) {
  504. db := newDivideRepoDB(t)
  505. root := "01HROOT000000000000000005"
  506. orphan := "01HORPH000000000000000005"
  507. seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 2, 1, "10.00", nil, &root)
  508. seedDivideNode(t, db, orphan, model.DivideTypeOneOff, 100, 101, 1, "0.00", nil, nil) // root 为 NULL(dump 脏数据形态)
  509. repo := NewDivideRepo(db)
  510. runner := NewTxRunner(db)
  511. ctx := context.Background()
  512. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  513. _, e := repo.UpdateNodeParent(ctx, tx, orphan, root, root, 2)
  514. return e
  515. }); err != nil {
  516. t.Fatalf("UpdateNodeParent 失败: %v", err)
  517. }
  518. got := loadNode(t, db, orphan)
  519. if got.ParentUserID == nil || *got.ParentUserID != root {
  520. t.Fatalf("parent 应为 %s,实得 %v", root, got.ParentUserID)
  521. }
  522. if got.RootUserID == nil || *got.RootUserID != root {
  523. t.Fatalf("root_user_id 必须显式补齐(dump 缺陷修正),实得 %v", got.RootUserID)
  524. }
  525. if got.Layer != 2 {
  526. t.Fatalf("layer 应为 2,实得 %d", got.Layer)
  527. }
  528. if _, err := repo.UpdateNodeParent(ctx, db, orphan, "", root, 2); !errors.Is(err, infraerrors.ErrBadParam) {
  529. t.Fatalf("空 parent 应 ErrBadParam,实得 %v", err)
  530. }
  531. if _, err := repo.UpdateNodeParent(ctx, db, orphan, root, root, 0); !errors.Is(err, infraerrors.ErrBadParam) {
  532. t.Fatalf("layer<1 应 ErrBadParam,实得 %v", err)
  533. }
  534. }
  535. // TestIsNotFound 便捷判定(避免各处抄 errors.Is)。
  536. func TestIsNotFound(t *testing.T) {
  537. if IsNotFound(nil) {
  538. t.Fatal("nil 不应判为 NotFound")
  539. }
  540. if !IsNotFound(infraerrors.WithCause(infraerrors.ErrNotFound, errors.New("x"))) {
  541. t.Fatal("WithCause(ErrNotFound) 应判为 NotFound")
  542. }
  543. if !strings.Contains(infraerrors.ErrNotFound.Error(), "不存在") {
  544. t.Fatal("ErrNotFound 文案契约被改动")
  545. }
  546. }