divide_service_test.go 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237
  1. // DivideService 单测(AC-09):100 节点随机树不变式 / BFS 上游入账 / 两级幂等 / 撤销两分支 /
  2. // 额度级联下降。
  3. //
  4. // 测试栈:glebarez/sqlite 文件库(真实事务)。每个用例独立 DB,避免树区间互相污染。
  5. package service
  6. import (
  7. "context"
  8. "errors"
  9. "fmt"
  10. "math/rand"
  11. "path/filepath"
  12. "strings"
  13. "testing"
  14. "time"
  15. gormsqlite "github.com/glebarez/sqlite"
  16. "gorm.io/gorm"
  17. "yuansiwei/unified-backend/internal/academic/apperr"
  18. "yuansiwei/unified-backend/internal/academic/model"
  19. "yuansiwei/unified-backend/internal/academic/money"
  20. "yuansiwei/unified-backend/internal/academic/repository"
  21. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  22. )
  23. // newDivideServiceDB 分账域真实库(2 表 AutoMigrate)。
  24. func newDivideServiceDB(t *testing.T) *gorm.DB {
  25. t.Helper()
  26. dsn := filepath.Join(t.TempDir(), "academic_divide_service_test.db")
  27. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  28. if err != nil {
  29. t.Fatalf("gorm.Open(分账服务文件库) 失败: %v", err)
  30. }
  31. t.Cleanup(func() {
  32. if sqlDB, e := db.DB(); e == nil {
  33. _ = sqlDB.Close()
  34. }
  35. })
  36. if err := db.AutoMigrate(&model.UserDivideInto{}, &model.UserDivideIntoLog{}); err != nil {
  37. t.Fatalf("AutoMigrate(分账 2 表) 失败: %v", err)
  38. }
  39. return db
  40. }
  41. // nodeID 生成 26 位类 ULID 的测试用户 ID(t_user_divide_into.user_id 为 VARCHAR(26))。
  42. func nodeID(i int) string { return fmt.Sprintf("01H%023d", i) }
  43. // seedRoot 直接落一棵树的根节点(dump 中根节点由后台/迁移建立,不在 4 个 SP 范围内)。
  44. func seedRoot(t *testing.T, db *gorm.DB, userID string, typ int8, quota string) {
  45. t.Helper()
  46. root := userID
  47. row := &model.UserDivideInto{
  48. UserID: userID,
  49. Type: typ,
  50. Quota: quota,
  51. TotalIncome: "0.00",
  52. TotalIncomePayed: "0.00",
  53. RootUserID: &root,
  54. LeftVal: 1,
  55. RightVal: 2,
  56. Layer: 1,
  57. }
  58. if err := db.Create(row).Error; err != nil {
  59. t.Fatalf("seed 根节点(%s) 失败: %v", userID, err)
  60. }
  61. }
  62. // loadDivideNode 读节点。
  63. func loadDivideNode(t *testing.T, db *gorm.DB, userID string) model.UserDivideInto {
  64. t.Helper()
  65. var row model.UserDivideInto
  66. if err := db.Where("user_id = ?", userID).First(&row).Error; err != nil {
  67. t.Fatalf("读节点(%s) 失败: %v", userID, err)
  68. }
  69. return row
  70. }
  71. // cents 金额字符串 → 分(sqlite NUMERIC 回读会丢 ".00",不得比字符串)。
  72. func cents(t *testing.T, s string) int64 {
  73. t.Helper()
  74. amt, err := money.Parse(s)
  75. if err != nil {
  76. t.Fatalf("金额解析失败 %q: %v", s, err)
  77. }
  78. return amt.Cents()
  79. }
  80. // fakeFirstCharged 首次扣费记录端口的测试替身。
  81. type fakeFirstCharged struct {
  82. recordID int64
  83. found bool
  84. err error
  85. calls int
  86. }
  87. // FirstChargedRecordID 实现 FirstChargedRecordReader。
  88. func (f *fakeFirstCharged) FirstChargedRecordID(_ context.Context, _ string) (int64, bool, error) {
  89. f.calls++
  90. return f.recordID, f.found, f.err
  91. }
  92. // TestAddSubordinateNestedSetInvariants 100 节点随机树:left<right、区间嵌套无交叉、
  93. // 节点数 = 宽度/2、值集合恰好 1..2N、父区间包含子区间。
  94. func TestAddSubordinateNestedSetInvariants(t *testing.T) {
  95. db := newDivideServiceDB(t)
  96. svc := NewDivideService(db, nil)
  97. ctx := context.Background()
  98. root := nodeID(0)
  99. seedRoot(t, db, root, model.DivideTypeOneOff, "10.00")
  100. created := []string{root}
  101. rnd := rand.New(rand.NewSource(20260109))
  102. const total = 100
  103. // 层高登记:F20 之后 AddSubordinate 有 3 层业务上限,**随机挂父会随机撞 3111**。
  104. // 本用例测的是嵌套集不变量(100 个节点),不是层数上限,
  105. // 所以把随机空间限制在「还能再挂子节点」的节点里(layer < MaxDivideBizDepth),
  106. // 保持原意:树可以很宽,但不许超过业务层数。
  107. layers := map[string]int32{root: 1}
  108. for i := 1; i < total; i++ {
  109. candidates := make([]string, 0, len(created))
  110. for _, u := range created {
  111. if layers[u] < MaxDivideBizDepth {
  112. candidates = append(candidates, u)
  113. }
  114. }
  115. if len(candidates) == 0 { // 兜底:根节点 layer=1 恒可挂,理论上不会发生
  116. t.Fatalf("没有可挂子节点的父节点(layer < %d)", MaxDivideBizDepth)
  117. }
  118. parent := candidates[rnd.Intn(len(candidates))]
  119. child := nodeID(i)
  120. if err := svc.AddSubordinate(ctx, parent, child); err != nil {
  121. t.Fatalf("AddSubordinate(%s→%s) 失败: %v", parent, child, err)
  122. }
  123. layers[child] = layers[parent] + 1
  124. created = append(created, child)
  125. }
  126. var nodes []model.UserDivideInto
  127. if err := db.Find(&nodes).Error; err != nil {
  128. t.Fatalf("读全树失败: %v", err)
  129. }
  130. if len(nodes) != total {
  131. t.Fatalf("节点数应为 %d,实得 %d", total, len(nodes))
  132. }
  133. // ① left < right 且值集合 = {1..2N}(每值恰好一次)
  134. seen := make(map[int32]string, total*2)
  135. maxRight := int32(0)
  136. for i := range nodes {
  137. n := nodes[i]
  138. if n.LeftVal >= n.RightVal {
  139. t.Fatalf("节点 %s 违反 left<right:(%d,%d)", n.UserID, n.LeftVal, n.RightVal)
  140. }
  141. for _, v := range []int32{n.LeftVal, n.RightVal} {
  142. if prev, dup := seen[v]; dup {
  143. t.Fatalf("左/右值 %d 被重复使用(%s 与 %s),嵌套集区间损坏", v, prev, n.UserID)
  144. }
  145. seen[v] = n.UserID
  146. }
  147. if n.RightVal > maxRight {
  148. maxRight = n.RightVal
  149. }
  150. }
  151. if len(seen) != total*2 || maxRight != int32(total*2) {
  152. t.Fatalf("值集合应为 1..%d(共 %d 个),实得 %d 个、max=%d", total*2, total*2, len(seen), maxRight)
  153. }
  154. // ② 节点数 = 宽度/2(Nested Set 恒等式)
  155. if got := int(maxRight / 2); got != total {
  156. t.Fatalf("节点数应 = maxRight/2 = %d,实得 %d", total, got)
  157. }
  158. // ③ 任意两节点区间:包含或相离,**绝不交叉**
  159. for i := 0; i < len(nodes); i++ {
  160. for j := i + 1; j < len(nodes); j++ {
  161. a, b := nodes[i], nodes[j]
  162. nested := (a.LeftVal < b.LeftVal && b.RightVal < a.RightVal) ||
  163. (b.LeftVal < a.LeftVal && a.RightVal < b.RightVal)
  164. disjoint := a.RightVal < b.LeftVal || b.RightVal < a.LeftVal
  165. if !nested && !disjoint {
  166. t.Fatalf("区间交叉:%s(%d,%d) vs %s(%d,%d)", a.UserID, a.LeftVal, a.RightVal, b.UserID, b.LeftVal, b.RightVal)
  167. }
  168. }
  169. }
  170. // ④ 父区间严格包含子区间,且 layer = 父 layer+1
  171. byID := make(map[string]model.UserDivideInto, len(nodes))
  172. for i := range nodes {
  173. byID[nodes[i].UserID] = nodes[i]
  174. }
  175. for i := range nodes {
  176. n := nodes[i]
  177. if n.ParentUserID == nil || *n.ParentUserID == "" {
  178. if n.UserID != root {
  179. t.Fatalf("非根节点 %s 缺少 parent", n.UserID)
  180. }
  181. continue
  182. }
  183. parent, ok := byID[*n.ParentUserID]
  184. if !ok {
  185. t.Fatalf("节点 %s 的 parent %s 不在树中", n.UserID, *n.ParentUserID)
  186. }
  187. if !(parent.LeftVal < n.LeftVal && n.RightVal < parent.RightVal) {
  188. t.Fatalf("%s 未嵌套在父 %s 区间内:父(%d,%d) 子(%d,%d)",
  189. n.UserID, parent.UserID, parent.LeftVal, parent.RightVal, n.LeftVal, n.RightVal)
  190. }
  191. if n.Layer != parent.Layer+1 {
  192. t.Fatalf("%s layer 应为父+1(%d),实得 %d", n.UserID, parent.Layer+1, n.Layer)
  193. }
  194. }
  195. }
  196. // TestAddSubordinateGuards 入参/上级缺失/自环。
  197. func TestAddSubordinateGuards(t *testing.T) {
  198. db := newDivideServiceDB(t)
  199. svc := NewDivideService(db, nil)
  200. ctx := context.Background()
  201. root := nodeID(0)
  202. seedRoot(t, db, root, model.DivideTypeOneOff, "10.00")
  203. if err := svc.AddSubordinate(ctx, "", nodeID(1)); !errors.Is(err, apperr.ErrBusinessParam) {
  204. t.Fatalf("空上级应 3140,实得 %v", err)
  205. }
  206. if err := svc.AddSubordinate(ctx, root, root); !errors.Is(err, apperr.ErrBusinessParam) {
  207. t.Fatalf("自环应 3140,实得 %v", err)
  208. }
  209. if err := svc.AddSubordinate(ctx, nodeID(99), nodeID(1)); !errors.Is(err, infraerrors.ErrNotFound) {
  210. t.Fatalf("上级不存在应 3001,实得 %v", err)
  211. }
  212. var zero *DivideService
  213. if err := zero.AddSubordinate(ctx, root, nodeID(1)); !errors.Is(err, infraerrors.ErrInternal) {
  214. t.Fatalf("未装配服务应 ErrInternal,实得 %v", err)
  215. }
  216. }
  217. // TestAddSubordinateReparentBranch 已存在节点分支:改挂父链并显式补齐 root(dump NULL 缺陷修正)。
  218. // TestAddSubordinateExistingNodeNoop 已存在 subid → **完全 no-op**(audit D4,dump 763695)。
  219. //
  220. // dump 的 `if exists(... where userid = subid) then ... end if;` 分支体内**没有任何写动作**:
  221. // 已存在即视为幂等成功,parent/root/layer/左右值一律不动。
  222. // 旧实现会"改挂父链",等于凭空移动整棵子树且不重排左右值 → 破坏嵌套集不变式(本次删除)。
  223. func TestAddSubordinateExistingNodeNoop(t *testing.T) {
  224. db := newDivideServiceDB(t)
  225. svc := NewDivideService(db, nil)
  226. ctx := context.Background()
  227. root := nodeID(0)
  228. orphan := nodeID(50)
  229. seedRoot(t, db, root, model.DivideTypeOneOff, "10.00")
  230. // 模拟 dump 脏数据:subid 已存在、parent/root 均为 NULL
  231. if err := db.Create(&model.UserDivideInto{
  232. UserID: orphan, Type: model.DivideTypeOneOff, Quota: "0.00",
  233. TotalIncome: "0.00", TotalIncomePayed: "0.00",
  234. LeftVal: 100, RightVal: 101, Layer: 1,
  235. }).Error; err != nil {
  236. t.Fatalf("seed 孤立节点失败: %v", err)
  237. }
  238. if err := svc.AddSubordinate(ctx, root, orphan); err != nil {
  239. t.Fatalf("已存在节点应幂等成功(no-op),实得 %v", err)
  240. }
  241. got := loadDivideNode(t, db, orphan)
  242. if got.ParentUserID != nil {
  243. t.Fatalf("已存在节点不得改挂父链(dump 763695 分支体无写动作),实得 parent=%v", *got.ParentUserID)
  244. }
  245. if got.RootUserID != nil {
  246. t.Fatalf("已存在节点不得写 root_user_id,实得 %v", *got.RootUserID)
  247. }
  248. if got.Layer != 1 {
  249. t.Fatalf("layer 不应变化(应 1),实得 %d", got.Layer)
  250. }
  251. if got.LeftVal != 100 || got.RightVal != 101 {
  252. t.Fatalf("左右值不应重排,实得 (%d,%d)", got.LeftVal, got.RightVal)
  253. }
  254. // 重复调用同样 no-op(幂等)
  255. if err := svc.AddSubordinate(ctx, root, orphan); err != nil {
  256. t.Fatalf("重复调用应幂等成功,实得 %v", err)
  257. }
  258. }
  259. // TestAddSubordinateBizDepthLimit 业务层级上限 3 层 → 3111(超管 → 校区主任 → 老师)。
  260. //
  261. // 边界取**恰好**:layer=1,2,3 允许(3 = MaxDivideBizDepth),第 4 层被拒。
  262. // 这条断言的产品含义:前端 `divide-into.ts:26 MAX_DIVIDE_DEPTH = 3` 只是"界面不让你点",
  263. // 若后端也允许第 4 层,绕过前端就能建出前端渲染不出来的树。
  264. func TestAddSubordinateBizDepthLimit(t *testing.T) {
  265. db := newDivideServiceDB(t)
  266. svc := NewDivideService(db, nil)
  267. ctx := context.Background()
  268. // ⚠ 这里**故意写死 3**,不用 MaxDivideBizDepth。
  269. // 原因:这条用例验的是"产品拍板的规则",规则的**值本身就是被测对象**。
  270. // 若写成 `for i := 1; i < int(MaxDivideBizDepth)`,常量被改成 100 时用例会跟着
  271. // 建到 100 层、再断言第 101 层被拒 —— 仍然通过,看起来"守住了规则",其实什么也没守
  272. // (实测过:常量改 100,用常量写法的版本仍 PASS)。这种自指断言正是空过断言的一种。
  273. const bizDepth = 3 // 硬编码:超管 → 校区主任 → 老师(拍板 2026-11-10)
  274. if MaxDivideBizDepth != bizDepth {
  275. t.Fatalf("常量 MaxDivideBizDepth=%d 与拍板值 %d 不一致", MaxDivideBizDepth, bizDepth)
  276. }
  277. root := nodeID(0)
  278. seedRoot(t, db, root, model.DivideTypeOneOff, "1.00")
  279. prev := root
  280. // **正向断言不可省**:建到 3 层,每一层都必须真建出来。
  281. // 只断言"第 4 层被拒"是不够的 —— 把上限误写成 2 时,这个用例只测被拒的那半也照样绿,
  282. // 而生产上第 3 层就再也建不出来了(存量里 MAX(layer)=2,说明第 3 层从未被验证过)。
  283. for i := 1; i < bizDepth; i++ {
  284. child := nodeID(i)
  285. if err := svc.AddSubordinate(ctx, prev, child); err != nil {
  286. t.Fatalf("建第 %d 层失败(上限 %d 应允许建到第 %d 层): %v", i+1, bizDepth, bizDepth, err)
  287. }
  288. if got := loadDivideNode(t, db, child).Layer; got != int32(i+1) {
  289. t.Fatalf("第 %d 层的 layer 应为 %d,实得 %d", i+1, i+1, got)
  290. }
  291. prev = child
  292. }
  293. if got := loadDivideNode(t, db, prev).Layer; got != bizDepth {
  294. t.Fatalf("链尾 layer 应为 %d(业务上限内),实得 %d", bizDepth, got)
  295. }
  296. // 第 4 层:必须 3111,且**对外文案带具体层级**(A3 验收点,2026-11-11 拍板)
  297. beforeTail := loadDivideNode(t, db, prev)
  298. err := svc.AddSubordinate(ctx, prev, nodeID(1000))
  299. if !errors.Is(err, apperr.ErrDivideQuotaExceeded) {
  300. t.Fatalf("超过 %d 层应 3111,实得 %v", bizDepth, err)
  301. }
  302. // ⚠ 断言的是 **PublicMsg**(进响应体的文案),不是 `err.Error()`。
  303. // 旧断言读 `err.Error()` = Msg + ": " + Cause —— 那是日志内容,前端**看不到**;
  304. // 用它断言等于"只验证了后端自己知道原因",与前端能否显示无关(F32 的成因)。
  305. appErr, ok := err.(*infraerrors.AppError)
  306. if !ok {
  307. t.Fatalf("错误类型应为 *AppError,实得 %T", err)
  308. }
  309. // 下面这条文案**是前端契约的一部分**(见 frontend-sync/api/03-divide.md):
  310. // 改文案必须同时改契约文档与本断言,否则前端会显示与文档不符的内容。
  311. wantMsg := fmt.Sprintf("分账层级已达上限:最多 %d 层(超管 → 校区主任 → 老师),不能再添加第 %d 层",
  312. bizDepth, bizDepth+1)
  313. if appErr.PublicMsg != wantMsg {
  314. t.Fatalf("3111 对外文案不符:\n 期望 %q\n 实得 %q", wantMsg, appErr.PublicMsg)
  315. }
  316. // 反证:内部诊断信息不得混进对外文案
  317. if strings.Contains(appErr.PublicMsg, "service:") {
  318. t.Fatalf("对外文案不得带内部前缀,实得 %q", appErr.PublicMsg)
  319. }
  320. // 反证:被拒后不得留下任何节点(事务回滚 + 左右值未位移)
  321. if after := loadDivideNode(t, db, prev); after.RightVal != beforeTail.RightVal {
  322. t.Fatalf("被拒后父节点 right_val 不应位移:前 %d,后 %d", beforeTail.RightVal, after.RightVal)
  323. }
  324. var nodeCount int64
  325. if err := db.Model(&model.UserDivideInto{}).Count(&nodeCount).Error; err != nil {
  326. t.Fatalf("count 失败: %v", err)
  327. }
  328. if nodeCount != int64(bizDepth) {
  329. t.Fatalf("被拒后节点数应仍为 %d,实得 %d", bizDepth, nodeCount)
  330. }
  331. }
  332. // TestDivideStructuralDepthConstant 只钉一个数字:结构上限仍是 100。
  333. //
  334. // 为什么单独写:MaxDivideBizDepth(3) 上线后,"100" 看起来像是多余的历史遗留,
  335. // 很容易被下一个人当成重复常量删掉。它不是 —— 它守的是"上行 BFS 必须终止 /
  336. // 递归不能爆栈",删掉后一棵脏数据(成环/超长链)就能把服务拖死。
  337. // 这里只断言常量值,**不**去构造 100 层链(那是 TestAddLogBFSDepthBoundary 的事)。
  338. func TestDivideStructuralDepthConstant(t *testing.T) {
  339. if MaxDivideDepth != 100 {
  340. t.Fatalf("结构上限 MaxDivideDepth 必须保持 100(防 BFS 不终止/递归爆栈),实得 %d", MaxDivideDepth)
  341. }
  342. if MaxDivideBizDepth != 3 {
  343. t.Fatalf("业务上限 MaxDivideBizDepth 必须为 3(超管→校区主任→老师),实得 %d", MaxDivideBizDepth)
  344. }
  345. if MaxDivideBizDepth >= MaxDivideDepth {
  346. t.Fatalf("业务上限(%d) 必须严格小于结构上限(%d):两者不是同一个概念,也不该相等",
  347. MaxDivideBizDepth, MaxDivideDepth)
  348. }
  349. }
  350. // TestAddLogBFSThreeLevels 上行链 BFS:**从上级起算**(dump `:763744` 子查询口径),
  351. // 本人不参与分成 —— c 报课,只有 b(直接上级)与 a(根)各得一条明细。
  352. func TestAddLogBFSThreeLevels(t *testing.T) {
  353. db := newDivideServiceDB(t)
  354. svc := NewDivideService(db, nil)
  355. ctx := context.Background()
  356. a, b, c := nodeID(0), nodeID(1), nodeID(2)
  357. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  358. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  359. t.Fatalf("建链失败: %v", err)
  360. }
  361. if err := svc.AddSubordinate(ctx, b, c); err != nil {
  362. t.Fatalf("建链失败: %v", err)
  363. }
  364. if err := svc.UpdateQuota(ctx, b, money.FromCents(500)); err != nil {
  365. t.Fatalf("设额度 B 失败: %v", err)
  366. }
  367. if err := svc.UpdateQuota(ctx, c, money.FromCents(200)); err != nil {
  368. t.Fatalf("设额度 C 失败: %v", err)
  369. }
  370. stu := nodeID(900)
  371. if err := svc.AddLog(ctx, c, stu, 777, "推荐分成"); err != nil {
  372. t.Fatalf("AddLog 失败: %v", err)
  373. }
  374. var rows []model.UserDivideIntoLog
  375. if err := db.Find(&rows).Error; err != nil {
  376. t.Fatalf("读明细失败: %v", err)
  377. }
  378. if len(rows) != 2 {
  379. t.Fatalf("上行链应生成 2 条明细(本人不参与),实得 %d", len(rows))
  380. }
  381. want := map[string]int64{b: 500, a: 1000}
  382. for i := range rows {
  383. expect, ok := want[rows[i].UserID]
  384. if !ok {
  385. t.Fatalf("出现意外分成用户 %s", rows[i].UserID)
  386. }
  387. if got := cents(t, rows[i].Money); got != expect {
  388. t.Fatalf("用户 %s 分成应为 %d 分,实得 %d", rows[i].UserID, expect, got)
  389. }
  390. if rows[i].OrderNo == nil || *rows[i].OrderNo != "777" {
  391. t.Fatalf("order_no 应为 777,实得 %v", rows[i].OrderNo)
  392. }
  393. if rows[i].Remark == nil || *rows[i].Remark != "推荐分成" {
  394. t.Fatalf("remark 应为推荐分成,实得 %v", rows[i].Remark)
  395. }
  396. // 明细 type = **该节点自己的 type**:a 是预置节点(type=2),b 是 AddSubordinate 新建节点
  397. // → audit D6 规定新节点固定 1(不继承父的 2),故这里逐用户校验
  398. wantType := map[string]int8{a: model.DivideTypePerLesson, b: model.DivideTypeOneOff}
  399. if rows[i].Type != wantType[rows[i].UserID] {
  400. t.Fatalf("用户 %s 的明细 type 应为 %d(D6:新节点固定 1),实得 %d",
  401. rows[i].UserID, wantType[rows[i].UserID], rows[i].Type)
  402. }
  403. }
  404. // total_income 各自累加自己的 quota
  405. for user, expect := range want {
  406. if got := cents(t, loadDivideNode(t, db, user).TotalIncome); got != expect {
  407. t.Fatalf("%s.total_income 应为 %d 分,实得 %d", user, expect, got)
  408. }
  409. }
  410. // D1 红线:本人(c)不参与分成,任何列都不得被写入
  411. if got := cents(t, loadDivideNode(t, db, c).TotalIncome); got != 0 {
  412. t.Fatalf("本人 %s 不应获得分成(total_income 应 0),实得 %d 分", c, got)
  413. }
  414. for i := range rows {
  415. if rows[i].UserID == c {
  416. t.Fatal("本人不得生成分成明细(dump 起点是 findUserid 的上级)")
  417. }
  418. }
  419. }
  420. // TestAddLogBFSZeroQuotaMiddle 上行链**中间层 quota=0**:只跳该层,**不中断**上行(audit D2)。
  421. //
  422. // 链:A(quota 10.00, type=2) > B(quota 0.00, type=1) > C(quota 2.00, type=1),find=C。
  423. //
  424. // 期望(dump `yuansiwei_finance.sql:763760`:额度<=0 的节点不生成明细):
  425. // - 起点是 C 的上级 B(本人不参与)→ B.quota=0 → **不生成明细**、total_income 不累加;
  426. // - B 的 0 额度**不得当作断链**:上行必须继续到 A,A 照常入账 10.00。
  427. // → 明细**恰 1 条**(仅 A),B/C 的 total_income 均为 0。
  428. //
  429. // 为什么单独需要这条用例:TestAddLogBFSThreeLevels / TestAddLogBFSDepthBoundary 给链上**每个**节点
  430. // 都配了额度,恰好绕过 D2 的"零额度跳层"分支;若实现把 `quota<=0` 写成 `return err`/`break`,
  431. // 上述两条仍全绿,只有本用例会红。
  432. func TestAddLogBFSZeroQuotaMiddle(t *testing.T) {
  433. db := newDivideServiceDB(t)
  434. svc := NewDivideService(db, nil)
  435. ctx := context.Background()
  436. a, b, c := nodeID(0), nodeID(1), nodeID(2)
  437. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  438. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  439. t.Fatalf("建链失败(%s→%s): %v", a, b, err)
  440. }
  441. if err := svc.AddSubordinate(ctx, b, c); err != nil {
  442. t.Fatalf("建链失败(%s→%s): %v", b, c, err)
  443. }
  444. // 中间层 B 保持新建节点的默认额度 0.00(不配置)——正是本用例要覆盖的场景
  445. if got := cents(t, loadDivideNode(t, db, b).Quota); got != 0 {
  446. t.Fatalf("前置条件:B 额度应为 0.00(AddSubordinate 默认值),实得 %d 分", got)
  447. }
  448. // 链尾 C 的额度与入账无关(本人不参与分成),仅为贴合"末级也配了额度"的真实形态;
  449. // 这里直接落库而不走 UpdateQuota:后者有"子不得超父"硬上限(C 的父 B=0.00),
  450. // 配 2.00 会被 3111 拒绝,与本用例要验证的 D2 跳层逻辑无关。
  451. if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", c).
  452. UpdateColumn("quota", "2.00").Error; err != nil {
  453. t.Fatalf("配置 C 额度失败: %v", err)
  454. }
  455. stu := nodeID(900)
  456. if err := svc.AddLog(ctx, c, stu, 777, ""); err != nil {
  457. t.Fatalf("AddLog 失败(中间层零额度不得报错): %v", err)
  458. }
  459. // 恰 1 条明细(仅 A):B 被 D2 跳过,且跳过不得终止上行
  460. if count := countLogs(t, db); count != 1 {
  461. t.Fatalf("中间层 quota=0 时应恰 1 条明细(仅 A),实得 %d", count)
  462. }
  463. var rows []model.UserDivideIntoLog
  464. if err := db.Find(&rows).Error; err != nil {
  465. t.Fatalf("读明细失败: %v", err)
  466. }
  467. if len(rows) != 1 {
  468. t.Fatalf("明细应恰 1 条,实得 %d", len(rows))
  469. }
  470. if rows[0].UserID != a {
  471. t.Fatalf("唯一明细应属于 A(跨过零额度中间层后的最上级),实得 %s", rows[0].UserID)
  472. }
  473. if got := cents(t, rows[0].Money); got != 1000 {
  474. t.Fatalf("A 的分成应为 10.00(1000 分),实得 %d", got)
  475. }
  476. if rows[0].OrderNo == nil || *rows[0].OrderNo != "777" {
  477. t.Fatalf("order_no 应为 777,实得 %v", rows[0].OrderNo)
  478. }
  479. // 收入口径:只有 A 累加;B(零额度被跳过)与 C(本人)都必须为 0
  480. if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 1000 {
  481. t.Fatalf("A.total_income 应为 1000 分,实得 %d", got)
  482. }
  483. for _, u := range []string{b, c} {
  484. if got := cents(t, loadDivideNode(t, db, u).TotalIncome); got != 0 {
  485. t.Fatalf("%s.total_income 应为 0(B 零额度不入账 / C 本人不参与),实得 %d 分", u, got)
  486. }
  487. }
  488. // 幂等不因"跳过 B"而失效:重复调用不得为 A 再写一条
  489. if err := svc.AddLog(ctx, c, stu, 777, ""); err != nil {
  490. t.Fatalf("AddLog 重复调用应幂等成功,实得 %v", err)
  491. }
  492. if count := countLogs(t, db); count != 1 {
  493. t.Fatalf("重复调用后仍应 1 条明细(幂等),实得 %d", count)
  494. }
  495. }
  496. // TestAddLogTwoLevelIdempotency 两级幂等:type=1 忽略订单;type=2 含订单。
  497. //
  498. // 结构:root(拿分成的一方)+ child(报课/被推荐人)—— **分成只入 root**(本人不参与,dump 口径)。
  499. func TestAddLogTwoLevelIdempotency(t *testing.T) {
  500. ctx := context.Background()
  501. stu := nodeID(901)
  502. t.Run("type=1 一次付清(同 user+student 只一条)", func(t *testing.T) {
  503. db := newDivideServiceDB(t)
  504. svc := NewDivideService(db, nil)
  505. u, child := nodeID(0), nodeID(1)
  506. seedRoot(t, db, u, model.DivideTypeOneOff, "10.00")
  507. if err := svc.AddSubordinate(ctx, u, child); err != nil {
  508. t.Fatalf("建链失败: %v", err)
  509. }
  510. if err := svc.AddLog(ctx, child, stu, 777, ""); err != nil {
  511. t.Fatalf("AddLog 失败: %v", err)
  512. }
  513. if err := svc.AddLog(ctx, child, stu, 888, ""); err != nil { // 不同订单:应与 type=1 无关
  514. t.Fatalf("AddLog(第二次) 失败: %v", err)
  515. }
  516. var count int64
  517. if err := db.Model(&model.UserDivideIntoLog{}).Count(&count).Error; err != nil {
  518. t.Fatalf("count 失败: %v", err)
  519. }
  520. if count != 1 {
  521. t.Fatalf("type=1 应仅 1 条明细,实得 %d", count)
  522. }
  523. if got := cents(t, loadDivideNode(t, db, u).TotalIncome); got != 1000 {
  524. t.Fatalf("total_income 应只累加一次(1000 分),实得 %d", got)
  525. }
  526. })
  527. t.Run("type=2 按课次(同 user+student+order 只一条)", func(t *testing.T) {
  528. db := newDivideServiceDB(t)
  529. svc := NewDivideService(db, nil)
  530. u, child := nodeID(0), nodeID(1)
  531. seedRoot(t, db, u, model.DivideTypePerLesson, "10.00")
  532. if err := svc.AddSubordinate(ctx, u, child); err != nil {
  533. t.Fatalf("建链失败: %v", err)
  534. }
  535. for i := 0; i < 2; i++ { // 同一订单重复请求
  536. if err := svc.AddLog(ctx, child, stu, 777, ""); err != nil {
  537. t.Fatalf("AddLog(重复) 失败: %v", err)
  538. }
  539. }
  540. if count := countLogs(t, db); count != 1 {
  541. t.Fatalf("同订单重复请求应幂等(1 条),实得 %d", count)
  542. }
  543. if err := svc.AddLog(ctx, child, stu, 888, ""); err != nil { // 新订单:应新增
  544. t.Fatalf("AddLog(新订单) 失败: %v", err)
  545. }
  546. if count := countLogs(t, db); count != 2 {
  547. t.Fatalf("新订单应新增明细(2 条),实得 %d", count)
  548. }
  549. if got := cents(t, loadDivideNode(t, db, u).TotalIncome); got != 2000 {
  550. t.Fatalf("total_income 应为 2000 分,实得 %d", got)
  551. }
  552. })
  553. }
  554. // countLogs 统计分成明细行数(测试辅助)。
  555. func countLogs(t *testing.T, db *gorm.DB) int64 {
  556. t.Helper()
  557. var count int64
  558. if err := db.Model(&model.UserDivideIntoLog{}).Count(&count).Error; err != nil {
  559. t.Fatalf("count 失败: %v", err)
  560. }
  561. return count
  562. }
  563. // TestAddLogStartFromParentDumpSemantics 审计 P0-2(dump `yuansiwei_finance.sql:763744`):
  564. // 上行分成的起点是 **findUserid 的上级**,本人不参与;findUserid 是根 → 无明细(幂等空操作)。
  565. //
  566. // 修复前从本人开始 → 本人也记一份,整整多算一代(金额错发)。
  567. func TestAddLogStartFromParentDumpSemantics(t *testing.T) {
  568. ctx := context.Background()
  569. stu := nodeID(901)
  570. t.Run("根用户无明细(子查询为 NULL)", func(t *testing.T) {
  571. db := newDivideServiceDB(t)
  572. svc := NewDivideService(db, nil)
  573. root := nodeID(0)
  574. seedRoot(t, db, root, model.DivideTypePerLesson, "10.00")
  575. if err := svc.AddLog(ctx, root, stu, 777, ""); err != nil {
  576. t.Fatalf("根用户报课应静默不入账(不报错),实得 %v", err)
  577. }
  578. if count := countLogs(t, db); count != 0 {
  579. t.Fatalf("根用户无上级 → 不应生成明细,实得 %d", count)
  580. }
  581. if got := cents(t, loadDivideNode(t, db, root).TotalIncome); got != 0 {
  582. t.Fatalf("根用户 total_income 应保持 0,实得 %d 分", got)
  583. }
  584. })
  585. t.Run("只给上级及更高层分成、本人无明细", func(t *testing.T) {
  586. db := newDivideServiceDB(t)
  587. svc := NewDivideService(db, nil)
  588. a, b, c := nodeID(0), nodeID(1), nodeID(2)
  589. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  590. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  591. t.Fatalf("建链失败: %v", err)
  592. }
  593. if err := svc.AddSubordinate(ctx, b, c); err != nil {
  594. t.Fatalf("建链失败: %v", err)
  595. }
  596. if err := svc.UpdateQuota(ctx, b, money.FromCents(500)); err != nil {
  597. t.Fatalf("设额度 B 失败: %v", err)
  598. }
  599. if err := svc.UpdateQuota(ctx, c, money.FromCents(200)); err != nil {
  600. t.Fatalf("设额度 C 失败: %v", err)
  601. }
  602. if err := svc.AddLog(ctx, c, stu, 777, ""); err != nil {
  603. t.Fatalf("AddLog 失败: %v", err)
  604. }
  605. var rows []model.UserDivideIntoLog
  606. if err := db.Find(&rows).Error; err != nil {
  607. t.Fatalf("读明细失败: %v", err)
  608. }
  609. got := make(map[string]int64, len(rows))
  610. for i := range rows {
  611. got[rows[i].UserID] = cents(t, rows[i].Money)
  612. }
  613. if len(got) != 2 {
  614. t.Fatalf("应只有上级 b 与根 a 两条明细,实得 %d:%v", len(got), got)
  615. }
  616. if _, ok := got[c]; ok {
  617. t.Fatal("本人 c 不得出现在分成明细中(多算一代 = 金额错发)")
  618. }
  619. if got[b] != 500 || got[a] != 1000 {
  620. t.Fatalf("分成金额不符:b=%d a=%d(期望 500/1000)", got[b], got[a])
  621. }
  622. if cents(t, loadDivideNode(t, db, c).TotalIncome) != 0 {
  623. t.Fatal("本人 c 的 total_income 必须保持 0")
  624. }
  625. })
  626. t.Run("上级已断链(不在树中)→ 无明细", func(t *testing.T) {
  627. db := newDivideServiceDB(t)
  628. svc := NewDivideService(db, nil)
  629. a, b := nodeID(0), nodeID(1)
  630. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  631. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  632. t.Fatalf("建链失败: %v", err)
  633. }
  634. // 人为把 b 的上级指向一个不存在的节点(脏数据 / 上级已退网)
  635. if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", b).
  636. UpdateColumn("parent_user_id", nodeID(777)).Error; err != nil {
  637. t.Fatalf("造断链失败: %v", err)
  638. }
  639. if err := svc.AddLog(ctx, b, stu, 777, ""); err != nil {
  640. t.Fatalf("断链应静默不入账,实得 %v", err)
  641. }
  642. if count := countLogs(t, db); count != 0 {
  643. t.Fatalf("断链不应生成明细,实得 %d", count)
  644. }
  645. })
  646. }
  647. // TestAddLogNonTreeNodeNoop 非分账树节点 → 静默不入账(不报错)。
  648. func TestAddLogNonTreeNodeNoop(t *testing.T) {
  649. db := newDivideServiceDB(t)
  650. svc := NewDivideService(db, nil)
  651. ctx := context.Background()
  652. if err := svc.AddLog(ctx, nodeID(404), nodeID(901), 777, ""); err != nil {
  653. t.Fatalf("非树节点应静默不入账,实得 %v", err)
  654. }
  655. if count := countLogs(t, db); count != 0 {
  656. t.Fatalf("非树节点不应生成明细,实得 %d", count)
  657. }
  658. if err := svc.AddLog(ctx, nodeID(1), "", 777, ""); !errors.Is(err, apperr.ErrBusinessParam) {
  659. t.Fatalf("空学生应 3140,实得 %v", err)
  660. }
  661. if err := svc.AddLog(ctx, nodeID(1), nodeID(901), 0, ""); !errors.Is(err, apperr.ErrBusinessParam) {
  662. t.Fatalf("recordID<=0 应 3140,实得 %v", err)
  663. }
  664. }
  665. // TestAddLogCycleGuard 数据成环 → 迭代层数兜底 **5001**(不死循环、不栈溢出)。
  666. //
  667. // ⚠ 2026-11-11 起成环**不再报 3111**(台账 F32 / 拍板 A3):自环属数据损坏,
  668. // 用户无从处理,对外应表现为系统异常;报"额度超上限"会把运维引向错误方向。
  669. func TestAddLogCycleGuard(t *testing.T) {
  670. db := newDivideServiceDB(t)
  671. svc := NewDivideService(db, nil)
  672. ctx := context.Background()
  673. a, b := nodeID(0), nodeID(1)
  674. seedRoot(t, db, a, model.DivideTypeOneOff, "10.00")
  675. seedRoot(t, db, b, model.DivideTypeOneOff, "10.00")
  676. // 人为造自环(脏数据)
  677. if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", a).
  678. Updates(map[string]any{"parent_user_id": b}).Error; err != nil {
  679. t.Fatalf("造环失败: %v", err)
  680. }
  681. if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", b).
  682. Updates(map[string]any{"parent_user_id": a}).Error; err != nil {
  683. t.Fatalf("造环失败: %v", err)
  684. }
  685. err := svc.AddLog(ctx, a, nodeID(901), 777, "")
  686. if !errors.Is(err, infraerrors.ErrInternal) {
  687. t.Fatalf("成环应 5001(数据异常),实得 %v", err)
  688. }
  689. // 反证:不得再退化成 3111(否则等于 A3 没落地)
  690. if errors.Is(err, apperr.ErrDivideQuotaExceeded) {
  691. t.Fatal("成环错误仍匹配 3111:A3 要求数据损坏走 5001,不能与业务规则共码")
  692. }
  693. // 对外文案必须是 5001 的默认文案,**不得**把"parent 指向自身"漏给前端
  694. appErr, ok := err.(*infraerrors.AppError)
  695. if !ok {
  696. t.Fatalf("错误类型应为 *AppError,实得 %T", err)
  697. }
  698. if appErr.PublicMsg != "" {
  699. t.Fatalf("数据损坏不得设置对外文案(会把内部结构描述漏给用户),实得 %q", appErr.PublicMsg)
  700. }
  701. // 但 Cause 必须留痕(运维靠它定位)。
  702. // ⚠ 本用例造的是 **a ↔ b 互指环**,不是"自己指向自己"的自环 ⇒ 命中 ④ 层数兜底,
  703. // 文案是"疑似成环"而不是"自环"。真自环(③)由 TestAddLogSelfLoopIs5001 单独覆盖 ——
  704. // 两条代码路径不同(③ 在循环内判 `parentID == cur.UserID`,④ 在循环头判层数)。
  705. if appErr.Cause == nil || !strings.Contains(appErr.Cause.Error(), "疑似成环") {
  706. t.Fatalf("Cause 应保留成环诊断信息,实得 %v", appErr.Cause)
  707. }
  708. // 事务回滚:环上的入账不应留下
  709. if count := countLogs(t, db); count != 0 {
  710. t.Fatalf("报错后应整体回滚(0 条明细),实得 %d", count)
  711. }
  712. }
  713. // TestAddLogSelfLoopIs5001 **真自环**(节点 parent 指向自己)→ 5001,且对外不出内部结构描述。
  714. //
  715. // 与 TestAddLogCycleGuard 的区别(两条路径,别混):
  716. //
  717. // TestAddLogCycleGuard a ↔ b 互指 → 循环内 parentID != cur.UserID,只能靠**层数**兜底(④);
  718. // 本用例 a.parent = a → 在进入循环**之前**就命中 `startID == self.UserID`(③)。
  719. //
  720. // 为什么要单独测:③ 的代码路径(F32/A3 之后改成 5001)此前**零覆盖** ——
  721. // 把 ③ 删掉、只留 ④ 层数兜底,整个包仍然全绿(层数兜底会接住同一个环),
  722. // 但生产上会白白遍历 100 次并生成 100 条明细再回滚(性能与日志噪音都放大 100 倍)。
  723. func TestAddLogSelfLoopIs5001(t *testing.T) {
  724. db := newDivideServiceDB(t)
  725. svc := NewDivideService(db, nil)
  726. ctx := context.Background()
  727. a := nodeID(0)
  728. seedRoot(t, db, a, model.DivideTypeOneOff, "10.00")
  729. // 人为造真自环:parent 指向自身(脏数据)
  730. if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", a).
  731. Updates(map[string]any{"parent_user_id": a}).Error; err != nil {
  732. t.Fatalf("造自环失败: %v", err)
  733. }
  734. err := svc.AddLog(ctx, a, nodeID(902), 778, "")
  735. if !errors.Is(err, infraerrors.ErrInternal) {
  736. t.Fatalf("真自环应 5001(数据异常),实得 %v", err)
  737. }
  738. appErr, ok := err.(*infraerrors.AppError)
  739. if !ok {
  740. t.Fatalf("错误类型应为 *AppError,实得 %T", err)
  741. }
  742. if appErr.PublicMsg != "" {
  743. t.Fatalf("数据损坏不得设置对外文案,实得 %q", appErr.PublicMsg)
  744. }
  745. if appErr.Cause == nil || !strings.Contains(appErr.Cause.Error(), "自环") {
  746. t.Fatalf("Cause 应指出是自环,实得 %v", appErr.Cause)
  747. }
  748. // 关键:必须在**进入循环前**就退出 ⇒ 一条明细都不该生成
  749. if count := countLogs(t, db); count != 0 {
  750. t.Fatalf("自环应在入循环前拒绝(不应先写 100 条再回滚),实得 %d 条", count)
  751. }
  752. }
  753. // TestDeleteLogRollbackBranch 撤销分支②:无首次扣费记录 → 回滚 total_income 并删行。
  754. func TestDeleteLogRollbackBranch(t *testing.T) {
  755. db := newDivideServiceDB(t)
  756. svc := NewDivideService(db, nil) // 端口未注入 → 一律走回滚
  757. ctx := context.Background()
  758. a, b := nodeID(0), nodeID(1)
  759. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  760. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  761. t.Fatalf("建链失败: %v", err)
  762. }
  763. if err := svc.UpdateQuota(ctx, b, money.FromCents(500)); err != nil {
  764. t.Fatalf("设额度失败: %v", err)
  765. }
  766. stu := nodeID(901)
  767. if err := svc.AddLog(ctx, b, stu, 777, ""); err != nil {
  768. t.Fatalf("AddLog 失败: %v", err)
  769. }
  770. // b 报课 → 只有上级 a 得一条(本人不参与分成,dump 口径)
  771. if count := countLogs(t, db); count != 1 {
  772. t.Fatalf("应为 1 条明细(仅上级),实得 %d", count)
  773. }
  774. if err := svc.DeleteLog(ctx, stu, 777); err != nil {
  775. t.Fatalf("DeleteLog 失败: %v", err)
  776. }
  777. if count := countLogs(t, db); count != 0 {
  778. t.Fatalf("回滚删除后应 0 条明细,实得 %d", count)
  779. }
  780. if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 0 {
  781. t.Fatalf("a.total_income 应回滚为 0,实得 %d 分", got)
  782. }
  783. if got := cents(t, loadDivideNode(t, db, b).TotalIncome); got != 0 {
  784. t.Fatalf("b.total_income 应回滚为 0,实得 %d 分", got)
  785. }
  786. // 幂等:再次撤销 → 空操作
  787. if err := svc.DeleteLog(ctx, stu, 777); err != nil {
  788. t.Fatalf("重复撤销应幂等成功,实得 %v", err)
  789. }
  790. }
  791. // TestDeleteLogRebindBranch 撤销分支①:已有首次扣费记录(≠当前订单)→ 改绑保留分成。
  792. func TestDeleteLogRebindBranch(t *testing.T) {
  793. db := newDivideServiceDB(t)
  794. port := &fakeFirstCharged{recordID: 888, found: true}
  795. svc := NewDivideService(db, port)
  796. ctx := context.Background()
  797. a, b := nodeID(0), nodeID(1)
  798. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  799. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  800. t.Fatalf("建链失败: %v", err)
  801. }
  802. stu := nodeID(901)
  803. if err := svc.AddLog(ctx, b, stu, 777, ""); err != nil {
  804. t.Fatalf("AddLog 失败: %v", err)
  805. }
  806. if err := svc.DeleteLog(ctx, stu, 777); err != nil {
  807. t.Fatalf("DeleteLog 失败: %v", err)
  808. }
  809. var rows []model.UserDivideIntoLog
  810. if err := db.Find(&rows).Error; err != nil {
  811. t.Fatalf("读明细失败: %v", err)
  812. }
  813. if len(rows) != 1 {
  814. t.Fatalf("改绑分支应保留明细(1 条),实得 %d", len(rows))
  815. }
  816. if rows[0].OrderNo == nil || *rows[0].OrderNo != "888" {
  817. t.Fatalf("order_no 应改绑为 888,实得 %v", rows[0].OrderNo)
  818. }
  819. if rows[0].Remark == nil || *rows[0].Remark != remarkRebind {
  820. t.Fatalf("remark 应为 %q,实得 %v", remarkRebind, rows[0].Remark)
  821. }
  822. if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 1000 {
  823. t.Fatalf("改绑不回滚收入(应仍 1000 分),实得 %d", got)
  824. }
  825. if port.calls == 0 {
  826. t.Fatal("改绑判定应调用首次扣费端口")
  827. }
  828. }
  829. // TestDeleteLogFirstEqualsOrderRebind 首扣记录**就是**当前订单 → 仍然改绑保留(audit D11,dump 762547)。
  830. //
  831. // dump 的改绑分支不与当前 orderID 比较:撤的正是"当前这单"而它本身就是首扣记录,
  832. // 带 `first != orderID` 条件会误判成"无可改绑"→ 回滚删除,把真实产生的分成整组冲掉(少发钱)。
  833. func TestDeleteLogFirstEqualsOrderRebind(t *testing.T) {
  834. db := newDivideServiceDB(t)
  835. port := &fakeFirstCharged{recordID: 777, found: true}
  836. svc := NewDivideService(db, port)
  837. ctx := context.Background()
  838. a, b := nodeID(0), nodeID(1)
  839. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  840. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  841. t.Fatalf("建链失败: %v", err)
  842. }
  843. stu := nodeID(901)
  844. if err := svc.AddLog(ctx, b, stu, 777, ""); err != nil {
  845. t.Fatalf("AddLog 失败: %v", err)
  846. }
  847. before := loadDivideNode(t, db, a).TotalIncome
  848. // 把改绑前的 create_time 拨到 2020:改绑要求"重置为 now()",用 2020 作参照可绕开
  849. // sqlite CURRENT_TIMESTAMP 的秒级精度(同秒内 After() 无法区分)
  850. old2020 := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
  851. if err := db.Model(&model.UserDivideIntoLog{}).Where("user_id = ?", a).
  852. UpdateColumn("create_time", old2020).Error; err != nil {
  853. t.Fatalf("回拨 create_time 失败: %v", err)
  854. }
  855. if err := svc.DeleteLog(ctx, stu, 777); err != nil {
  856. t.Fatalf("DeleteLog 失败: %v", err)
  857. }
  858. if count := countLogs(t, db); count != 1 {
  859. t.Fatalf("首扣记录=当前订单 → 应改绑保留(1 条),实得 %d", count)
  860. }
  861. var rows []model.UserDivideIntoLog
  862. if err := db.Find(&rows).Error; err != nil {
  863. t.Fatalf("读明细失败: %v", err)
  864. }
  865. if rows[0].OrderNo == nil || *rows[0].OrderNo != "777" {
  866. t.Fatalf("order_no 应仍为 777(改绑到首扣记录),实得 %v", rows[0].OrderNo)
  867. }
  868. if rows[0].Remark == nil || *rows[0].Remark != remarkRebind {
  869. t.Fatalf("remark 应为 %q,实得 %v", remarkRebind, rows[0].Remark)
  870. }
  871. if got := loadDivideNode(t, db, a).TotalIncome; got != before {
  872. t.Fatalf("改绑不回滚 total_income(应仍 %q),实得 %q", before, got)
  873. }
  874. if !rows[0].CreateTime.After(old2020) {
  875. t.Fatalf("改绑后 create_time 应重置为 now()(> 2020),实得 %s", rows[0].CreateTime)
  876. }
  877. if err := svc.DeleteLog(ctx, stu, 0); !errors.Is(err, apperr.ErrBusinessParam) {
  878. t.Fatalf("orderID<=0 应 3140,实得 %v", err)
  879. }
  880. }
  881. // TestUpdateQuotaCascade 额度级联下降(子不得超父)+ 参数/缺失分支。
  882. func TestUpdateQuotaCascade(t *testing.T) {
  883. db := newDivideServiceDB(t)
  884. svc := NewDivideService(db, nil)
  885. ctx := context.Background()
  886. a, b, c := nodeID(0), nodeID(1), nodeID(2)
  887. seedRoot(t, db, a, model.DivideTypeOneOff, "20.00")
  888. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  889. t.Fatalf("建链失败: %v", err)
  890. }
  891. if err := svc.AddSubordinate(ctx, b, c); err != nil {
  892. t.Fatalf("建链失败: %v", err)
  893. }
  894. if err := svc.UpdateQuota(ctx, b, money.FromCents(1500)); err != nil {
  895. t.Fatalf("设额度 B 失败: %v", err)
  896. }
  897. if err := svc.UpdateQuota(ctx, c, money.FromCents(1200)); err != nil {
  898. t.Fatalf("设额度 C 失败: %v", err)
  899. }
  900. // 父 A 降到 10.00 → B(15)、C(12) 一并压到 10.00
  901. if err := svc.UpdateQuota(ctx, a, money.FromCents(1000)); err != nil {
  902. t.Fatalf("UpdateQuota(A,10) 失败: %v", err)
  903. }
  904. for _, id := range []string{a, b, c} {
  905. if got := cents(t, loadDivideNode(t, db, id).Quota); got != 1000 {
  906. t.Fatalf("%s.quota 应级联压到 1000 分,实得 %d", id, got)
  907. }
  908. }
  909. // 子节点超额(> 父)→ 3111,且**对外文案带两个金额**(A3 验收点)
  910. errQuota := svc.UpdateQuota(ctx, b, money.FromCents(9999))
  911. if !errors.Is(errQuota, apperr.ErrDivideQuotaExceeded) {
  912. t.Fatalf("超过上级应 3111,实得 %v", errQuota)
  913. }
  914. if qErr, ok := errQuota.(*infraerrors.AppError); ok {
  915. // 父 A 此刻额度 1000 分 = 10.00;本次 9999 分 = 99.99
  916. wantQuotaMsg := "分成额度不得超过上级:本次 99.99 元,上级额度 10.00 元"
  917. if qErr.PublicMsg != wantQuotaMsg {
  918. t.Fatalf("3111 额度文案不符:\n 期望 %q\n 实得 %q", wantQuotaMsg, qErr.PublicMsg)
  919. }
  920. } else {
  921. t.Fatalf("错误类型应为 *AppError,实得 %T", errQuota)
  922. }
  923. // 参数/缺失
  924. if err := svc.UpdateQuota(ctx, b, money.FromCents(-1)); !errors.Is(err, apperr.ErrBusinessParam) {
  925. t.Fatalf("负额度应 3140,实得 %v", err)
  926. }
  927. if err := svc.UpdateQuota(ctx, nodeID(404), money.FromCents(100)); !errors.Is(err, infraerrors.ErrNotFound) {
  928. t.Fatalf("节点不存在应 3001,实得 %v", err)
  929. }
  930. if err := svc.UpdateQuota(ctx, "", money.FromCents(100)); !errors.Is(err, apperr.ErrBusinessParam) {
  931. t.Fatalf("空 userID 应 3140,实得 %v", err)
  932. }
  933. }
  934. // TestUpdateQuotaClampMustNotCrossTree 调额级联**只压本树**:不得碰其它树里区间数值重叠的节点。
  935. //
  936. // # 这个用例钉的是台账 F25(P0)
  937. //
  938. // `ClampSubtreeQuota` 的 WHERE 原本只有 `left_val > ? AND right_val < ?` + `quota > ?` + `type`,
  939. // **没有 `root_user_id`**。而每棵树的 `left_val` 都从 1 起 ⇒ 多棵树根的区间在**数值上必然互相重叠**
  940. // (旧库实测:86 行 / 26 棵树 / 跨树重叠 325 对 = C(26,2) 满重叠)。
  941. // ⇒ 调 A 树的额度,会把 B 树里"区间数值落在 A 子树范围内、且额度更高"的节点一起压低,
  942. // 后续分成静默少算。根因是**照抄 dump**:legacy `sp_user_divide_into_update_quota` 本身就缺这个过滤。
  943. //
  944. // # 为什么必须用"两棵结构相同的树"来测
  945. //
  946. // 因为缺陷的触发条件就是"区间数值重叠"。本用例造 A(1,4)→B(2,3) 与 D(1,4)→E(2,3):
  947. // **E 的 (2,3) 与 B 的 (2,3) 数值完全相同**。若缺 root 过滤,`left>1 AND right<4` 会同时命中 B 和 E。
  948. // 下面有前置自检确保这个重叠真的成立 —— 否则用例会变成"测不到东西的绿"。
  949. //
  950. // # 与双跑的关系
  951. //
  952. // 这条缺陷**双跑抓不到**(legacy 与 target 同错 ⇒ equality ⇒ PASS,见 BACKLOG B1),
  953. // 所以它是**唯一能抓它的地方**。双跑的 DV-7 会以 INTENTIONAL-STRICT 形态登记两侧不同。
  954. func TestUpdateQuotaClampMustNotCrossTree(t *testing.T) {
  955. db := newDivideServiceDB(t)
  956. svc := NewDivideService(db, nil)
  957. ctx := context.Background()
  958. a, b := nodeID(0), nodeID(1)
  959. d, e := nodeID(2), nodeID(3)
  960. seedRoot(t, db, a, model.DivideTypeOneOff, "20.00")
  961. seedRoot(t, db, d, model.DivideTypeOneOff, "20.00")
  962. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  963. t.Fatalf("建链 A→B 失败: %v", err)
  964. }
  965. if err := svc.AddSubordinate(ctx, d, e); err != nil {
  966. t.Fatalf("建链 D→E 失败: %v", err)
  967. }
  968. // 两个子节点都配 15.00(高于下面要压到的 10.00)
  969. if err := svc.UpdateQuota(ctx, b, money.FromCents(1500)); err != nil {
  970. t.Fatalf("设额度 B 失败: %v", err)
  971. }
  972. if err := svc.UpdateQuota(ctx, e, money.FromCents(1500)); err != nil {
  973. t.Fatalf("设额度 E 失败: %v", err)
  974. }
  975. // ---- 把两棵树的区间摆成**真实数据那种形态**:每棵树的 left 都从 1 起 ----
  976. // 为什么必须手动摆:`AddSubordinate` 的右移会在同表内让后续树拿到递增的 left/right
  977. // (实测第二棵树会变成 D(1,6)/E(4,5),与 B(2,3) 不重叠 ⇒ 缺陷根本触发不到)。
  978. // 而旧库真实数据正是"26 棵树、每棵 left 都从 1 起、跨树重叠 325 对"。这里直接落库对齐真实形态。
  979. if err := db.Exec("UPDATE t_user_divide_into SET left_val = 1, right_val = 4 WHERE user_id = ?", d).Error; err != nil {
  980. t.Fatalf("摆 D 区间失败: %v", err)
  981. }
  982. if err := db.Exec("UPDATE t_user_divide_into SET left_val = 2, right_val = 3 WHERE user_id = ?", e).Error; err != nil {
  983. t.Fatalf("摆 E 区间失败: %v", err)
  984. }
  985. // ---- 前置自检:两棵树的子节点区间必须**数值重叠** ----
  986. // 不成立 ⇒ 本用例根本触发不到 F25,跑出来的绿是假的。
  987. bn, en := loadDivideNode(t, db, b), loadDivideNode(t, db, e)
  988. if bn.LeftVal != en.LeftVal || bn.RightVal != en.RightVal {
  989. t.Fatalf("前置不成立:B(%d,%d) 与 E(%d,%d) 区间不重叠,本用例测不到 F25",
  990. bn.LeftVal, bn.RightVal, en.LeftVal, en.RightVal)
  991. }
  992. // ---- 调 A 的额度:只应压本树的 B ----
  993. if err := svc.UpdateQuota(ctx, a, money.FromCents(1000)); err != nil {
  994. t.Fatalf("UpdateQuota(A,10) 失败: %v", err)
  995. }
  996. if got := cents(t, loadDivideNode(t, db, b).Quota); got != 1000 {
  997. t.Fatalf("本树 B 应被压到 1000 分(级联正常路径),实得 %d", got)
  998. }
  999. if got := cents(t, loadDivideNode(t, db, e).Quota); got != 1500 {
  1000. t.Fatalf("**F25 回归**:另一棵树 D→E 的节点 E 被误压,quota 应保持 1500 分,实得 %d 分"+
  1001. "(说明 ClampSubtreeQuota 丢了 root_user_id 过滤;E 与 B 区间数值都是 (%d,%d))",
  1002. got, bn.LeftVal, bn.RightVal)
  1003. }
  1004. // 另一棵树的根也不该被碰(它自己额度更高、且区间包含 B)
  1005. if got := cents(t, loadDivideNode(t, db, d).Quota); got != 2000 {
  1006. t.Fatalf("另一棵树的根 D 被误改,quota 应保持 2000 分,实得 %d", got)
  1007. }
  1008. }
  1009. // TestUpdateQuotaNoCascadeWhenOrigZero 原 quota=0 时不级联(audit A6,dump 763872 `IF (uQuota>0)`)。
  1010. //
  1011. // 语义:还没配置过额度的节点,被"设成某个值"时不该顺手压低子树(子树额度是独立配置的阶梯)。
  1012. // 对照:节点原额度 > 0 时级联照旧生效(后半段断言)。
  1013. func TestUpdateQuotaNoCascadeWhenOrigZero(t *testing.T) {
  1014. db := newDivideServiceDB(t)
  1015. svc := NewDivideService(db, nil)
  1016. ctx := context.Background()
  1017. a, b := nodeID(0), nodeID(1)
  1018. seedRoot(t, db, a, model.DivideTypeOneOff, "0.00") // 原 quota = 0
  1019. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  1020. t.Fatalf("建链失败: %v", err)
  1021. }
  1022. // 直接把子节点额度设成 5.00(绕过服务,构造"父 0 / 子 5"的起始态)
  1023. if err := db.Exec("UPDATE t_user_divide_into SET quota = ? WHERE user_id = ?", "5.00", b).Error; err != nil {
  1024. t.Fatalf("配置子额度失败: %v", err)
  1025. }
  1026. // 父原 quota=0 → 设成 10.00 **不级联**:子保持 5.00
  1027. if err := svc.UpdateQuota(ctx, a, money.FromCents(1000)); err != nil {
  1028. t.Fatalf("UpdateQuota 失败: %v", err)
  1029. }
  1030. if got := cents(t, loadDivideNode(t, db, a).Quota); got != 1000 {
  1031. t.Fatalf("父额度应为 10.00,实得 %d 分", got)
  1032. }
  1033. if got := cents(t, loadDivideNode(t, db, b).Quota); got != 500 {
  1034. t.Fatalf("原 quota=0 不应级联:子应保持 5.00,实得 %d 分", got)
  1035. }
  1036. // 父原 quota=10.00 > 0 → 降到 3.00 **会级联**:子被压到 3.00
  1037. if err := svc.UpdateQuota(ctx, a, money.FromCents(300)); err != nil {
  1038. t.Fatalf("UpdateQuota(第二次) 失败: %v", err)
  1039. }
  1040. if got := cents(t, loadDivideNode(t, db, a).Quota); got != 300 {
  1041. t.Fatalf("父额度应为 3.00,实得 %d 分", got)
  1042. }
  1043. if got := cents(t, loadDivideNode(t, db, b).Quota); got != 300 {
  1044. t.Fatalf("原 quota>0 应级联压到 3.00,实得 %d 分", got)
  1045. }
  1046. }
  1047. // TestDivideServiceNestedTxPassThrough 事务透传纪律:本服务在**外层事务内**被调用时
  1048. // 必须经 WithTxContext 复用同一 tx(不重复 BEGIN),且随外层回滚一并撤销。
  1049. func TestDivideServiceNestedTxPassThrough(t *testing.T) {
  1050. db := newDivideServiceDB(t)
  1051. svc := NewDivideService(db, nil)
  1052. runner := repository.NewTxRunner(db)
  1053. ctx := context.Background()
  1054. a, b := nodeID(0), nodeID(1)
  1055. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  1056. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  1057. t.Fatalf("建链失败: %v", err)
  1058. }
  1059. stu := nodeID(901)
  1060. boom := errors.New("outer-rollback")
  1061. err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  1062. txCtx := repository.WithTxContext(ctx, tx)
  1063. if aerr := svc.AddLog(txCtx, b, stu, 777, ""); aerr != nil {
  1064. return aerr
  1065. }
  1066. // 事务内应可见刚写入的明细(说明确实跑在同一 tx 上)
  1067. var inside int64
  1068. if cerr := tx.Model(&model.UserDivideIntoLog{}).Count(&inside).Error; cerr != nil {
  1069. return cerr
  1070. }
  1071. if inside != 1 {
  1072. t.Fatalf("外层事务内应可见 1 条明细(仅上级,本人不参与),实得 %d", inside)
  1073. }
  1074. return boom
  1075. })
  1076. if !errors.Is(err, infraerrors.ErrDBError) {
  1077. t.Fatalf("非业务错误应归一为 5002,实得 %v", err)
  1078. }
  1079. if count := countLogs(t, db); count != 0 {
  1080. t.Fatalf("外层回滚后明细应全部消失(无独立事务提前提交),实得 %d", count)
  1081. }
  1082. if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 0 {
  1083. t.Fatalf("回滚后 total_income 应为 0,实得 %d 分", got)
  1084. }
  1085. }
  1086. // seedChain 直接落一条 layer=1..n 的**单链**,绕过 AddSubordinate。
  1087. //
  1088. // 为什么必须绕过:F20 之后 AddSubordinate 有 3 层业务上限,用公开接口建不出 100 层链。
  1089. // 但「结构上限 / 上行 BFS 必须终止」要验的恰恰是**脏数据**(成环、超长链)下的行为,
  1090. // 这种数据在现实里只能由历史迁移产生 —— 所以这里直接写库,等价于"库里已经有这么一棵树",
  1091. // 与"用户用接口建出来的树"是两件事,不能混。
  1092. //
  1093. // 嵌套集取值(单链,n 个节点):节点 i(1-based)left = 2i-1,right = 2(n-i+1)。
  1094. // **parent_user_id 必须自己连上**:上行 BFS 的唯一依据就是这列(不是 layer、不是嵌套集区间),
  1095. // 只写 layer/left/right 而漏掉 parent 会造出"看似 100 层、实际 100 个孤点"的假数据,
  1096. // 断言就会以"0 条明细"的形式失败 —— 空过的反面:数据没连上,测试反而更红了。
  1097. func seedChain(t *testing.T, db *gorm.DB, n int32, typ int8, quota string) []string {
  1098. t.Helper()
  1099. if n <= 0 {
  1100. t.Fatalf("seedChain 需要 n>0,实得 %d", n)
  1101. }
  1102. ids := make([]string, 0, n)
  1103. for i := int32(1); i <= n; i++ {
  1104. ids = append(ids, nodeID(int(i)))
  1105. }
  1106. root := ids[0]
  1107. rows := make([]model.UserDivideInto, 0, n)
  1108. for i := int32(1); i <= n; i++ {
  1109. rootCopy := root
  1110. var parent *string
  1111. if i > 1 {
  1112. p := ids[i-2]
  1113. parent = &p
  1114. }
  1115. rows = append(rows, model.UserDivideInto{
  1116. UserID: ids[i-1],
  1117. Type: typ,
  1118. Quota: quota,
  1119. TotalIncome: "0.00",
  1120. TotalIncomePayed: "0.00",
  1121. RootUserID: &rootCopy,
  1122. ParentUserID: parent,
  1123. LeftVal: 2*i - 1,
  1124. RightVal: 2 * (n - i + 1),
  1125. Layer: i,
  1126. })
  1127. }
  1128. if err := db.Create(&rows).Error; err != nil {
  1129. t.Fatalf("seed %d 层链失败: %v", n, err)
  1130. }
  1131. return ids
  1132. }
  1133. // TestAddLogBFSDepthBoundary 100 层链(layer 1..100)恰好在上限内:BFS 必须完整走完到根
  1134. // (101 级才触发 3111,见 TestAddLogCycleGuard)。同时验证"迭代不递归"在大深度下不栈溢出。
  1135. //
  1136. // 链由 seedChain 直接写库(F20 的 3 层业务上限不允许用 AddSubordinate 建这么深),
  1137. // 语义是"库里已存在历史的超长链":**读路径不能因为链长就崩**,但**写路径不许再造新的**。
  1138. //
  1139. // 明细条数 = 99(链尾本人不参与分成,dump 起点是其上级,故比全链少 1 条)。
  1140. func TestAddLogBFSDepthBoundary(t *testing.T) {
  1141. db := newDivideServiceDB(t)
  1142. svc := NewDivideService(db, nil)
  1143. ctx := context.Background()
  1144. // audit D2:quota<=0 的节点不生成明细 → 每节点自带 1.00 额度(seedChain 里已给),
  1145. // 否则只有根节点会入账(本用例要测的是 BFS 深度边界,不是 D2)
  1146. ids := seedChain(t, db, MaxDivideDepth, model.DivideTypePerLesson, "1.00")
  1147. tail := ids[len(ids)-1]
  1148. if got := loadDivideNode(t, db, tail).Layer; got != MaxDivideDepth {
  1149. t.Fatalf("链尾 layer 应为 %d,实得 %d", MaxDivideDepth, got)
  1150. }
  1151. if err := svc.AddLog(ctx, tail, nodeID(901), 777, ""); err != nil {
  1152. t.Fatalf("%d 层链的 BFS 入账应在限内成功,实得 %v", MaxDivideDepth, err)
  1153. }
  1154. // 链尾本人不参与 → 明细 = 层数 - 1
  1155. if count := countLogs(t, db); count != int64(MaxDivideDepth-1) {
  1156. t.Fatalf("%d 层链应生成 %d 条明细(本人不参与),实得 %d", MaxDivideDepth, MaxDivideDepth-1, count)
  1157. }
  1158. // 根节点收入应恰好为一次 quota
  1159. if got := cents(t, loadDivideNode(t, db, ids[0]).TotalIncome); got != 100 {
  1160. t.Fatalf("根节点 total_income 应为 100 分,实得 %d", got)
  1161. }
  1162. }