divide_service_test.go 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959
  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. "testing"
  13. "time"
  14. gormsqlite "github.com/glebarez/sqlite"
  15. "gorm.io/gorm"
  16. "yuansiwei/unified-backend/internal/academic/apperr"
  17. "yuansiwei/unified-backend/internal/academic/model"
  18. "yuansiwei/unified-backend/internal/academic/money"
  19. "yuansiwei/unified-backend/internal/academic/repository"
  20. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  21. )
  22. // newDivideServiceDB 分账域真实库(2 表 AutoMigrate)。
  23. func newDivideServiceDB(t *testing.T) *gorm.DB {
  24. t.Helper()
  25. dsn := filepath.Join(t.TempDir(), "academic_divide_service_test.db")
  26. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  27. if err != nil {
  28. t.Fatalf("gorm.Open(分账服务文件库) 失败: %v", err)
  29. }
  30. t.Cleanup(func() {
  31. if sqlDB, e := db.DB(); e == nil {
  32. _ = sqlDB.Close()
  33. }
  34. })
  35. if err := db.AutoMigrate(&model.UserDivideInto{}, &model.UserDivideIntoLog{}); err != nil {
  36. t.Fatalf("AutoMigrate(分账 2 表) 失败: %v", err)
  37. }
  38. return db
  39. }
  40. // nodeID 生成 26 位类 ULID 的测试用户 ID(t_user_divide_into.user_id 为 VARCHAR(26))。
  41. func nodeID(i int) string { return fmt.Sprintf("01H%023d", i) }
  42. // seedRoot 直接落一棵树的根节点(dump 中根节点由后台/迁移建立,不在 4 个 SP 范围内)。
  43. func seedRoot(t *testing.T, db *gorm.DB, userID string, typ int8, quota string) {
  44. t.Helper()
  45. root := userID
  46. row := &model.UserDivideInto{
  47. UserID: userID,
  48. Type: typ,
  49. Quota: quota,
  50. TotalIncome: "0.00",
  51. TotalIncomePayed: "0.00",
  52. RootUserID: &root,
  53. LeftVal: 1,
  54. RightVal: 2,
  55. Layer: 1,
  56. }
  57. if err := db.Create(row).Error; err != nil {
  58. t.Fatalf("seed 根节点(%s) 失败: %v", userID, err)
  59. }
  60. }
  61. // loadDivideNode 读节点。
  62. func loadDivideNode(t *testing.T, db *gorm.DB, userID string) model.UserDivideInto {
  63. t.Helper()
  64. var row model.UserDivideInto
  65. if err := db.Where("user_id = ?", userID).First(&row).Error; err != nil {
  66. t.Fatalf("读节点(%s) 失败: %v", userID, err)
  67. }
  68. return row
  69. }
  70. // cents 金额字符串 → 分(sqlite NUMERIC 回读会丢 ".00",不得比字符串)。
  71. func cents(t *testing.T, s string) int64 {
  72. t.Helper()
  73. amt, err := money.Parse(s)
  74. if err != nil {
  75. t.Fatalf("金额解析失败 %q: %v", s, err)
  76. }
  77. return amt.Cents()
  78. }
  79. // fakeFirstCharged 首次扣费记录端口的测试替身。
  80. type fakeFirstCharged struct {
  81. recordID int64
  82. found bool
  83. err error
  84. calls int
  85. }
  86. // FirstChargedRecordID 实现 FirstChargedRecordReader。
  87. func (f *fakeFirstCharged) FirstChargedRecordID(_ context.Context, _ string) (int64, bool, error) {
  88. f.calls++
  89. return f.recordID, f.found, f.err
  90. }
  91. // TestAddSubordinateNestedSetInvariants 100 节点随机树:left<right、区间嵌套无交叉、
  92. // 节点数 = 宽度/2、值集合恰好 1..2N、父区间包含子区间。
  93. func TestAddSubordinateNestedSetInvariants(t *testing.T) {
  94. db := newDivideServiceDB(t)
  95. svc := NewDivideService(db, nil)
  96. ctx := context.Background()
  97. root := nodeID(0)
  98. seedRoot(t, db, root, model.DivideTypeOneOff, "10.00")
  99. created := []string{root}
  100. rnd := rand.New(rand.NewSource(20260109))
  101. const total = 100
  102. for i := 1; i < total; i++ {
  103. parent := created[rnd.Intn(len(created))]
  104. child := nodeID(i)
  105. if err := svc.AddSubordinate(ctx, parent, child); err != nil {
  106. t.Fatalf("AddSubordinate(%s→%s) 失败: %v", parent, child, err)
  107. }
  108. created = append(created, child)
  109. }
  110. var nodes []model.UserDivideInto
  111. if err := db.Find(&nodes).Error; err != nil {
  112. t.Fatalf("读全树失败: %v", err)
  113. }
  114. if len(nodes) != total {
  115. t.Fatalf("节点数应为 %d,实得 %d", total, len(nodes))
  116. }
  117. // ① left < right 且值集合 = {1..2N}(每值恰好一次)
  118. seen := make(map[int32]string, total*2)
  119. maxRight := int32(0)
  120. for i := range nodes {
  121. n := nodes[i]
  122. if n.LeftVal >= n.RightVal {
  123. t.Fatalf("节点 %s 违反 left<right:(%d,%d)", n.UserID, n.LeftVal, n.RightVal)
  124. }
  125. for _, v := range []int32{n.LeftVal, n.RightVal} {
  126. if prev, dup := seen[v]; dup {
  127. t.Fatalf("左/右值 %d 被重复使用(%s 与 %s),嵌套集区间损坏", v, prev, n.UserID)
  128. }
  129. seen[v] = n.UserID
  130. }
  131. if n.RightVal > maxRight {
  132. maxRight = n.RightVal
  133. }
  134. }
  135. if len(seen) != total*2 || maxRight != int32(total*2) {
  136. t.Fatalf("值集合应为 1..%d(共 %d 个),实得 %d 个、max=%d", total*2, total*2, len(seen), maxRight)
  137. }
  138. // ② 节点数 = 宽度/2(Nested Set 恒等式)
  139. if got := int(maxRight / 2); got != total {
  140. t.Fatalf("节点数应 = maxRight/2 = %d,实得 %d", total, got)
  141. }
  142. // ③ 任意两节点区间:包含或相离,**绝不交叉**
  143. for i := 0; i < len(nodes); i++ {
  144. for j := i + 1; j < len(nodes); j++ {
  145. a, b := nodes[i], nodes[j]
  146. nested := (a.LeftVal < b.LeftVal && b.RightVal < a.RightVal) ||
  147. (b.LeftVal < a.LeftVal && a.RightVal < b.RightVal)
  148. disjoint := a.RightVal < b.LeftVal || b.RightVal < a.LeftVal
  149. if !nested && !disjoint {
  150. t.Fatalf("区间交叉:%s(%d,%d) vs %s(%d,%d)", a.UserID, a.LeftVal, a.RightVal, b.UserID, b.LeftVal, b.RightVal)
  151. }
  152. }
  153. }
  154. // ④ 父区间严格包含子区间,且 layer = 父 layer+1
  155. byID := make(map[string]model.UserDivideInto, len(nodes))
  156. for i := range nodes {
  157. byID[nodes[i].UserID] = nodes[i]
  158. }
  159. for i := range nodes {
  160. n := nodes[i]
  161. if n.ParentUserID == nil || *n.ParentUserID == "" {
  162. if n.UserID != root {
  163. t.Fatalf("非根节点 %s 缺少 parent", n.UserID)
  164. }
  165. continue
  166. }
  167. parent, ok := byID[*n.ParentUserID]
  168. if !ok {
  169. t.Fatalf("节点 %s 的 parent %s 不在树中", n.UserID, *n.ParentUserID)
  170. }
  171. if !(parent.LeftVal < n.LeftVal && n.RightVal < parent.RightVal) {
  172. t.Fatalf("%s 未嵌套在父 %s 区间内:父(%d,%d) 子(%d,%d)",
  173. n.UserID, parent.UserID, parent.LeftVal, parent.RightVal, n.LeftVal, n.RightVal)
  174. }
  175. if n.Layer != parent.Layer+1 {
  176. t.Fatalf("%s layer 应为父+1(%d),实得 %d", n.UserID, parent.Layer+1, n.Layer)
  177. }
  178. }
  179. }
  180. // TestAddSubordinateGuards 入参/上级缺失/自环。
  181. func TestAddSubordinateGuards(t *testing.T) {
  182. db := newDivideServiceDB(t)
  183. svc := NewDivideService(db, nil)
  184. ctx := context.Background()
  185. root := nodeID(0)
  186. seedRoot(t, db, root, model.DivideTypeOneOff, "10.00")
  187. if err := svc.AddSubordinate(ctx, "", nodeID(1)); !errors.Is(err, apperr.ErrBusinessParam) {
  188. t.Fatalf("空上级应 3140,实得 %v", err)
  189. }
  190. if err := svc.AddSubordinate(ctx, root, root); !errors.Is(err, apperr.ErrBusinessParam) {
  191. t.Fatalf("自环应 3140,实得 %v", err)
  192. }
  193. if err := svc.AddSubordinate(ctx, nodeID(99), nodeID(1)); !errors.Is(err, infraerrors.ErrNotFound) {
  194. t.Fatalf("上级不存在应 3001,实得 %v", err)
  195. }
  196. var zero *DivideService
  197. if err := zero.AddSubordinate(ctx, root, nodeID(1)); !errors.Is(err, infraerrors.ErrInternal) {
  198. t.Fatalf("未装配服务应 ErrInternal,实得 %v", err)
  199. }
  200. }
  201. // TestAddSubordinateReparentBranch 已存在节点分支:改挂父链并显式补齐 root(dump NULL 缺陷修正)。
  202. // TestAddSubordinateExistingNodeNoop 已存在 subid → **完全 no-op**(audit D4,dump 763695)。
  203. //
  204. // dump 的 `if exists(... where userid = subid) then ... end if;` 分支体内**没有任何写动作**:
  205. // 已存在即视为幂等成功,parent/root/layer/左右值一律不动。
  206. // 旧实现会"改挂父链",等于凭空移动整棵子树且不重排左右值 → 破坏嵌套集不变式(本次删除)。
  207. func TestAddSubordinateExistingNodeNoop(t *testing.T) {
  208. db := newDivideServiceDB(t)
  209. svc := NewDivideService(db, nil)
  210. ctx := context.Background()
  211. root := nodeID(0)
  212. orphan := nodeID(50)
  213. seedRoot(t, db, root, model.DivideTypeOneOff, "10.00")
  214. // 模拟 dump 脏数据:subid 已存在、parent/root 均为 NULL
  215. if err := db.Create(&model.UserDivideInto{
  216. UserID: orphan, Type: model.DivideTypeOneOff, Quota: "0.00",
  217. TotalIncome: "0.00", TotalIncomePayed: "0.00",
  218. LeftVal: 100, RightVal: 101, Layer: 1,
  219. }).Error; err != nil {
  220. t.Fatalf("seed 孤立节点失败: %v", err)
  221. }
  222. if err := svc.AddSubordinate(ctx, root, orphan); err != nil {
  223. t.Fatalf("已存在节点应幂等成功(no-op),实得 %v", err)
  224. }
  225. got := loadDivideNode(t, db, orphan)
  226. if got.ParentUserID != nil {
  227. t.Fatalf("已存在节点不得改挂父链(dump 763695 分支体无写动作),实得 parent=%v", *got.ParentUserID)
  228. }
  229. if got.RootUserID != nil {
  230. t.Fatalf("已存在节点不得写 root_user_id,实得 %v", *got.RootUserID)
  231. }
  232. if got.Layer != 1 {
  233. t.Fatalf("layer 不应变化(应 1),实得 %d", got.Layer)
  234. }
  235. if got.LeftVal != 100 || got.RightVal != 101 {
  236. t.Fatalf("左右值不应重排,实得 (%d,%d)", got.LeftVal, got.RightVal)
  237. }
  238. // 重复调用同样 no-op(幂等)
  239. if err := svc.AddSubordinate(ctx, root, orphan); err != nil {
  240. t.Fatalf("重复调用应幂等成功,实得 %v", err)
  241. }
  242. }
  243. // TestAddSubordinateDepthLimit 层级上限 100 → 3111。
  244. func TestAddSubordinateDepthLimit(t *testing.T) {
  245. db := newDivideServiceDB(t)
  246. svc := NewDivideService(db, nil)
  247. ctx := context.Background()
  248. root := nodeID(0)
  249. seedRoot(t, db, root, model.DivideTypeOneOff, "1.00")
  250. prev := root
  251. // 链到 layer=100
  252. for i := 1; i <= 99; i++ {
  253. child := nodeID(i)
  254. if err := svc.AddSubordinate(ctx, prev, child); err != nil {
  255. t.Fatalf("建链 AddSubordinate(%s→%s) 失败: %v", prev, child, err)
  256. }
  257. prev = child
  258. }
  259. if got := loadDivideNode(t, db, prev); got.Layer != 100 {
  260. t.Fatalf("链尾 layer 应为 100,实得 %d", got.Layer)
  261. }
  262. if err := svc.AddSubordinate(ctx, prev, nodeID(1000)); !errors.Is(err, apperr.ErrDivideQuotaExceeded) {
  263. t.Fatalf("超过 100 层应 3111,实得 %v", err)
  264. }
  265. }
  266. // TestAddLogBFSThreeLevels 上行链 BFS:**从上级起算**(dump `:763744` 子查询口径),
  267. // 本人不参与分成 —— c 报课,只有 b(直接上级)与 a(根)各得一条明细。
  268. func TestAddLogBFSThreeLevels(t *testing.T) {
  269. db := newDivideServiceDB(t)
  270. svc := NewDivideService(db, nil)
  271. ctx := context.Background()
  272. a, b, c := nodeID(0), nodeID(1), nodeID(2)
  273. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  274. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  275. t.Fatalf("建链失败: %v", err)
  276. }
  277. if err := svc.AddSubordinate(ctx, b, c); err != nil {
  278. t.Fatalf("建链失败: %v", err)
  279. }
  280. if err := svc.UpdateQuota(ctx, b, money.FromCents(500)); err != nil {
  281. t.Fatalf("设额度 B 失败: %v", err)
  282. }
  283. if err := svc.UpdateQuota(ctx, c, money.FromCents(200)); err != nil {
  284. t.Fatalf("设额度 C 失败: %v", err)
  285. }
  286. stu := nodeID(900)
  287. if err := svc.AddLog(ctx, c, stu, 777, "推荐分成"); err != nil {
  288. t.Fatalf("AddLog 失败: %v", err)
  289. }
  290. var rows []model.UserDivideIntoLog
  291. if err := db.Find(&rows).Error; err != nil {
  292. t.Fatalf("读明细失败: %v", err)
  293. }
  294. if len(rows) != 2 {
  295. t.Fatalf("上行链应生成 2 条明细(本人不参与),实得 %d", len(rows))
  296. }
  297. want := map[string]int64{b: 500, a: 1000}
  298. for i := range rows {
  299. expect, ok := want[rows[i].UserID]
  300. if !ok {
  301. t.Fatalf("出现意外分成用户 %s", rows[i].UserID)
  302. }
  303. if got := cents(t, rows[i].Money); got != expect {
  304. t.Fatalf("用户 %s 分成应为 %d 分,实得 %d", rows[i].UserID, expect, got)
  305. }
  306. if rows[i].OrderNo == nil || *rows[i].OrderNo != "777" {
  307. t.Fatalf("order_no 应为 777,实得 %v", rows[i].OrderNo)
  308. }
  309. if rows[i].Remark == nil || *rows[i].Remark != "推荐分成" {
  310. t.Fatalf("remark 应为推荐分成,实得 %v", rows[i].Remark)
  311. }
  312. // 明细 type = **该节点自己的 type**:a 是预置节点(type=2),b 是 AddSubordinate 新建节点
  313. // → audit D6 规定新节点固定 1(不继承父的 2),故这里逐用户校验
  314. wantType := map[string]int8{a: model.DivideTypePerLesson, b: model.DivideTypeOneOff}
  315. if rows[i].Type != wantType[rows[i].UserID] {
  316. t.Fatalf("用户 %s 的明细 type 应为 %d(D6:新节点固定 1),实得 %d",
  317. rows[i].UserID, wantType[rows[i].UserID], rows[i].Type)
  318. }
  319. }
  320. // total_income 各自累加自己的 quota
  321. for user, expect := range want {
  322. if got := cents(t, loadDivideNode(t, db, user).TotalIncome); got != expect {
  323. t.Fatalf("%s.total_income 应为 %d 分,实得 %d", user, expect, got)
  324. }
  325. }
  326. // D1 红线:本人(c)不参与分成,任何列都不得被写入
  327. if got := cents(t, loadDivideNode(t, db, c).TotalIncome); got != 0 {
  328. t.Fatalf("本人 %s 不应获得分成(total_income 应 0),实得 %d 分", c, got)
  329. }
  330. for i := range rows {
  331. if rows[i].UserID == c {
  332. t.Fatal("本人不得生成分成明细(dump 起点是 findUserid 的上级)")
  333. }
  334. }
  335. }
  336. // TestAddLogBFSZeroQuotaMiddle 上行链**中间层 quota=0**:只跳该层,**不中断**上行(audit D2)。
  337. //
  338. // 链:A(quota 10.00, type=2) > B(quota 0.00, type=1) > C(quota 2.00, type=1),find=C。
  339. //
  340. // 期望(dump `yuansiwei_finance.sql:763760`:额度<=0 的节点不生成明细):
  341. // - 起点是 C 的上级 B(本人不参与)→ B.quota=0 → **不生成明细**、total_income 不累加;
  342. // - B 的 0 额度**不得当作断链**:上行必须继续到 A,A 照常入账 10.00。
  343. // → 明细**恰 1 条**(仅 A),B/C 的 total_income 均为 0。
  344. //
  345. // 为什么单独需要这条用例:TestAddLogBFSThreeLevels / TestAddLogBFSDepthBoundary 给链上**每个**节点
  346. // 都配了额度,恰好绕过 D2 的"零额度跳层"分支;若实现把 `quota<=0` 写成 `return err`/`break`,
  347. // 上述两条仍全绿,只有本用例会红。
  348. func TestAddLogBFSZeroQuotaMiddle(t *testing.T) {
  349. db := newDivideServiceDB(t)
  350. svc := NewDivideService(db, nil)
  351. ctx := context.Background()
  352. a, b, c := nodeID(0), nodeID(1), nodeID(2)
  353. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  354. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  355. t.Fatalf("建链失败(%s→%s): %v", a, b, err)
  356. }
  357. if err := svc.AddSubordinate(ctx, b, c); err != nil {
  358. t.Fatalf("建链失败(%s→%s): %v", b, c, err)
  359. }
  360. // 中间层 B 保持新建节点的默认额度 0.00(不配置)——正是本用例要覆盖的场景
  361. if got := cents(t, loadDivideNode(t, db, b).Quota); got != 0 {
  362. t.Fatalf("前置条件:B 额度应为 0.00(AddSubordinate 默认值),实得 %d 分", got)
  363. }
  364. // 链尾 C 的额度与入账无关(本人不参与分成),仅为贴合"末级也配了额度"的真实形态;
  365. // 这里直接落库而不走 UpdateQuota:后者有"子不得超父"硬上限(C 的父 B=0.00),
  366. // 配 2.00 会被 3111 拒绝,与本用例要验证的 D2 跳层逻辑无关。
  367. if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", c).
  368. UpdateColumn("quota", "2.00").Error; err != nil {
  369. t.Fatalf("配置 C 额度失败: %v", err)
  370. }
  371. stu := nodeID(900)
  372. if err := svc.AddLog(ctx, c, stu, 777, ""); err != nil {
  373. t.Fatalf("AddLog 失败(中间层零额度不得报错): %v", err)
  374. }
  375. // 恰 1 条明细(仅 A):B 被 D2 跳过,且跳过不得终止上行
  376. if count := countLogs(t, db); count != 1 {
  377. t.Fatalf("中间层 quota=0 时应恰 1 条明细(仅 A),实得 %d", count)
  378. }
  379. var rows []model.UserDivideIntoLog
  380. if err := db.Find(&rows).Error; err != nil {
  381. t.Fatalf("读明细失败: %v", err)
  382. }
  383. if len(rows) != 1 {
  384. t.Fatalf("明细应恰 1 条,实得 %d", len(rows))
  385. }
  386. if rows[0].UserID != a {
  387. t.Fatalf("唯一明细应属于 A(跨过零额度中间层后的最上级),实得 %s", rows[0].UserID)
  388. }
  389. if got := cents(t, rows[0].Money); got != 1000 {
  390. t.Fatalf("A 的分成应为 10.00(1000 分),实得 %d", got)
  391. }
  392. if rows[0].OrderNo == nil || *rows[0].OrderNo != "777" {
  393. t.Fatalf("order_no 应为 777,实得 %v", rows[0].OrderNo)
  394. }
  395. // 收入口径:只有 A 累加;B(零额度被跳过)与 C(本人)都必须为 0
  396. if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 1000 {
  397. t.Fatalf("A.total_income 应为 1000 分,实得 %d", got)
  398. }
  399. for _, u := range []string{b, c} {
  400. if got := cents(t, loadDivideNode(t, db, u).TotalIncome); got != 0 {
  401. t.Fatalf("%s.total_income 应为 0(B 零额度不入账 / C 本人不参与),实得 %d 分", u, got)
  402. }
  403. }
  404. // 幂等不因"跳过 B"而失效:重复调用不得为 A 再写一条
  405. if err := svc.AddLog(ctx, c, stu, 777, ""); err != nil {
  406. t.Fatalf("AddLog 重复调用应幂等成功,实得 %v", err)
  407. }
  408. if count := countLogs(t, db); count != 1 {
  409. t.Fatalf("重复调用后仍应 1 条明细(幂等),实得 %d", count)
  410. }
  411. }
  412. // TestAddLogTwoLevelIdempotency 两级幂等:type=1 忽略订单;type=2 含订单。
  413. //
  414. // 结构:root(拿分成的一方)+ child(报课/被推荐人)—— **分成只入 root**(本人不参与,dump 口径)。
  415. func TestAddLogTwoLevelIdempotency(t *testing.T) {
  416. ctx := context.Background()
  417. stu := nodeID(901)
  418. t.Run("type=1 一次付清(同 user+student 只一条)", func(t *testing.T) {
  419. db := newDivideServiceDB(t)
  420. svc := NewDivideService(db, nil)
  421. u, child := nodeID(0), nodeID(1)
  422. seedRoot(t, db, u, model.DivideTypeOneOff, "10.00")
  423. if err := svc.AddSubordinate(ctx, u, child); err != nil {
  424. t.Fatalf("建链失败: %v", err)
  425. }
  426. if err := svc.AddLog(ctx, child, stu, 777, ""); err != nil {
  427. t.Fatalf("AddLog 失败: %v", err)
  428. }
  429. if err := svc.AddLog(ctx, child, stu, 888, ""); err != nil { // 不同订单:应与 type=1 无关
  430. t.Fatalf("AddLog(第二次) 失败: %v", err)
  431. }
  432. var count int64
  433. if err := db.Model(&model.UserDivideIntoLog{}).Count(&count).Error; err != nil {
  434. t.Fatalf("count 失败: %v", err)
  435. }
  436. if count != 1 {
  437. t.Fatalf("type=1 应仅 1 条明细,实得 %d", count)
  438. }
  439. if got := cents(t, loadDivideNode(t, db, u).TotalIncome); got != 1000 {
  440. t.Fatalf("total_income 应只累加一次(1000 分),实得 %d", got)
  441. }
  442. })
  443. t.Run("type=2 按课次(同 user+student+order 只一条)", func(t *testing.T) {
  444. db := newDivideServiceDB(t)
  445. svc := NewDivideService(db, nil)
  446. u, child := nodeID(0), nodeID(1)
  447. seedRoot(t, db, u, model.DivideTypePerLesson, "10.00")
  448. if err := svc.AddSubordinate(ctx, u, child); err != nil {
  449. t.Fatalf("建链失败: %v", err)
  450. }
  451. for i := 0; i < 2; i++ { // 同一订单重复请求
  452. if err := svc.AddLog(ctx, child, stu, 777, ""); err != nil {
  453. t.Fatalf("AddLog(重复) 失败: %v", err)
  454. }
  455. }
  456. if count := countLogs(t, db); count != 1 {
  457. t.Fatalf("同订单重复请求应幂等(1 条),实得 %d", count)
  458. }
  459. if err := svc.AddLog(ctx, child, stu, 888, ""); err != nil { // 新订单:应新增
  460. t.Fatalf("AddLog(新订单) 失败: %v", err)
  461. }
  462. if count := countLogs(t, db); count != 2 {
  463. t.Fatalf("新订单应新增明细(2 条),实得 %d", count)
  464. }
  465. if got := cents(t, loadDivideNode(t, db, u).TotalIncome); got != 2000 {
  466. t.Fatalf("total_income 应为 2000 分,实得 %d", got)
  467. }
  468. })
  469. }
  470. // countLogs 统计分成明细行数(测试辅助)。
  471. func countLogs(t *testing.T, db *gorm.DB) int64 {
  472. t.Helper()
  473. var count int64
  474. if err := db.Model(&model.UserDivideIntoLog{}).Count(&count).Error; err != nil {
  475. t.Fatalf("count 失败: %v", err)
  476. }
  477. return count
  478. }
  479. // TestAddLogStartFromParentDumpSemantics 审计 P0-2(dump `yuansiwei_finance.sql:763744`):
  480. // 上行分成的起点是 **findUserid 的上级**,本人不参与;findUserid 是根 → 无明细(幂等空操作)。
  481. //
  482. // 修复前从本人开始 → 本人也记一份,整整多算一代(金额错发)。
  483. func TestAddLogStartFromParentDumpSemantics(t *testing.T) {
  484. ctx := context.Background()
  485. stu := nodeID(901)
  486. t.Run("根用户无明细(子查询为 NULL)", func(t *testing.T) {
  487. db := newDivideServiceDB(t)
  488. svc := NewDivideService(db, nil)
  489. root := nodeID(0)
  490. seedRoot(t, db, root, model.DivideTypePerLesson, "10.00")
  491. if err := svc.AddLog(ctx, root, stu, 777, ""); err != nil {
  492. t.Fatalf("根用户报课应静默不入账(不报错),实得 %v", err)
  493. }
  494. if count := countLogs(t, db); count != 0 {
  495. t.Fatalf("根用户无上级 → 不应生成明细,实得 %d", count)
  496. }
  497. if got := cents(t, loadDivideNode(t, db, root).TotalIncome); got != 0 {
  498. t.Fatalf("根用户 total_income 应保持 0,实得 %d 分", got)
  499. }
  500. })
  501. t.Run("只给上级及更高层分成、本人无明细", func(t *testing.T) {
  502. db := newDivideServiceDB(t)
  503. svc := NewDivideService(db, nil)
  504. a, b, c := nodeID(0), nodeID(1), nodeID(2)
  505. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  506. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  507. t.Fatalf("建链失败: %v", err)
  508. }
  509. if err := svc.AddSubordinate(ctx, b, c); err != nil {
  510. t.Fatalf("建链失败: %v", err)
  511. }
  512. if err := svc.UpdateQuota(ctx, b, money.FromCents(500)); err != nil {
  513. t.Fatalf("设额度 B 失败: %v", err)
  514. }
  515. if err := svc.UpdateQuota(ctx, c, money.FromCents(200)); err != nil {
  516. t.Fatalf("设额度 C 失败: %v", err)
  517. }
  518. if err := svc.AddLog(ctx, c, stu, 777, ""); err != nil {
  519. t.Fatalf("AddLog 失败: %v", err)
  520. }
  521. var rows []model.UserDivideIntoLog
  522. if err := db.Find(&rows).Error; err != nil {
  523. t.Fatalf("读明细失败: %v", err)
  524. }
  525. got := make(map[string]int64, len(rows))
  526. for i := range rows {
  527. got[rows[i].UserID] = cents(t, rows[i].Money)
  528. }
  529. if len(got) != 2 {
  530. t.Fatalf("应只有上级 b 与根 a 两条明细,实得 %d:%v", len(got), got)
  531. }
  532. if _, ok := got[c]; ok {
  533. t.Fatal("本人 c 不得出现在分成明细中(多算一代 = 金额错发)")
  534. }
  535. if got[b] != 500 || got[a] != 1000 {
  536. t.Fatalf("分成金额不符:b=%d a=%d(期望 500/1000)", got[b], got[a])
  537. }
  538. if cents(t, loadDivideNode(t, db, c).TotalIncome) != 0 {
  539. t.Fatal("本人 c 的 total_income 必须保持 0")
  540. }
  541. })
  542. t.Run("上级已断链(不在树中)→ 无明细", func(t *testing.T) {
  543. db := newDivideServiceDB(t)
  544. svc := NewDivideService(db, nil)
  545. a, b := nodeID(0), nodeID(1)
  546. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  547. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  548. t.Fatalf("建链失败: %v", err)
  549. }
  550. // 人为把 b 的上级指向一个不存在的节点(脏数据 / 上级已退网)
  551. if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", b).
  552. UpdateColumn("parent_user_id", nodeID(777)).Error; err != nil {
  553. t.Fatalf("造断链失败: %v", err)
  554. }
  555. if err := svc.AddLog(ctx, b, stu, 777, ""); err != nil {
  556. t.Fatalf("断链应静默不入账,实得 %v", err)
  557. }
  558. if count := countLogs(t, db); count != 0 {
  559. t.Fatalf("断链不应生成明细,实得 %d", count)
  560. }
  561. })
  562. }
  563. // TestAddLogNonTreeNodeNoop 非分账树节点 → 静默不入账(不报错)。
  564. func TestAddLogNonTreeNodeNoop(t *testing.T) {
  565. db := newDivideServiceDB(t)
  566. svc := NewDivideService(db, nil)
  567. ctx := context.Background()
  568. if err := svc.AddLog(ctx, nodeID(404), nodeID(901), 777, ""); err != nil {
  569. t.Fatalf("非树节点应静默不入账,实得 %v", err)
  570. }
  571. if count := countLogs(t, db); count != 0 {
  572. t.Fatalf("非树节点不应生成明细,实得 %d", count)
  573. }
  574. if err := svc.AddLog(ctx, nodeID(1), "", 777, ""); !errors.Is(err, apperr.ErrBusinessParam) {
  575. t.Fatalf("空学生应 3140,实得 %v", err)
  576. }
  577. if err := svc.AddLog(ctx, nodeID(1), nodeID(901), 0, ""); !errors.Is(err, apperr.ErrBusinessParam) {
  578. t.Fatalf("recordID<=0 应 3140,实得 %v", err)
  579. }
  580. }
  581. // TestAddLogCycleGuard 数据成环 → 迭代层数兜底 3111(不死循环、不栈溢出)。
  582. func TestAddLogCycleGuard(t *testing.T) {
  583. db := newDivideServiceDB(t)
  584. svc := NewDivideService(db, nil)
  585. ctx := context.Background()
  586. a, b := nodeID(0), nodeID(1)
  587. seedRoot(t, db, a, model.DivideTypeOneOff, "10.00")
  588. seedRoot(t, db, b, model.DivideTypeOneOff, "10.00")
  589. // 人为造自环(脏数据)
  590. if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", a).
  591. Updates(map[string]any{"parent_user_id": b}).Error; err != nil {
  592. t.Fatalf("造环失败: %v", err)
  593. }
  594. if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", b).
  595. Updates(map[string]any{"parent_user_id": a}).Error; err != nil {
  596. t.Fatalf("造环失败: %v", err)
  597. }
  598. err := svc.AddLog(ctx, a, nodeID(901), 777, "")
  599. if !errors.Is(err, apperr.ErrDivideQuotaExceeded) {
  600. t.Fatalf("成环应 3111(层级兜底),实得 %v", err)
  601. }
  602. // 事务回滚:环上的入账不应留下
  603. if count := countLogs(t, db); count != 0 {
  604. t.Fatalf("报错后应整体回滚(0 条明细),实得 %d", count)
  605. }
  606. }
  607. // TestDeleteLogRollbackBranch 撤销分支②:无首次扣费记录 → 回滚 total_income 并删行。
  608. func TestDeleteLogRollbackBranch(t *testing.T) {
  609. db := newDivideServiceDB(t)
  610. svc := NewDivideService(db, nil) // 端口未注入 → 一律走回滚
  611. ctx := context.Background()
  612. a, b := nodeID(0), nodeID(1)
  613. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  614. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  615. t.Fatalf("建链失败: %v", err)
  616. }
  617. if err := svc.UpdateQuota(ctx, b, money.FromCents(500)); err != nil {
  618. t.Fatalf("设额度失败: %v", err)
  619. }
  620. stu := nodeID(901)
  621. if err := svc.AddLog(ctx, b, stu, 777, ""); err != nil {
  622. t.Fatalf("AddLog 失败: %v", err)
  623. }
  624. // b 报课 → 只有上级 a 得一条(本人不参与分成,dump 口径)
  625. if count := countLogs(t, db); count != 1 {
  626. t.Fatalf("应为 1 条明细(仅上级),实得 %d", count)
  627. }
  628. if err := svc.DeleteLog(ctx, stu, 777); err != nil {
  629. t.Fatalf("DeleteLog 失败: %v", err)
  630. }
  631. if count := countLogs(t, db); count != 0 {
  632. t.Fatalf("回滚删除后应 0 条明细,实得 %d", count)
  633. }
  634. if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 0 {
  635. t.Fatalf("a.total_income 应回滚为 0,实得 %d 分", got)
  636. }
  637. if got := cents(t, loadDivideNode(t, db, b).TotalIncome); got != 0 {
  638. t.Fatalf("b.total_income 应回滚为 0,实得 %d 分", got)
  639. }
  640. // 幂等:再次撤销 → 空操作
  641. if err := svc.DeleteLog(ctx, stu, 777); err != nil {
  642. t.Fatalf("重复撤销应幂等成功,实得 %v", err)
  643. }
  644. }
  645. // TestDeleteLogRebindBranch 撤销分支①:已有首次扣费记录(≠当前订单)→ 改绑保留分成。
  646. func TestDeleteLogRebindBranch(t *testing.T) {
  647. db := newDivideServiceDB(t)
  648. port := &fakeFirstCharged{recordID: 888, found: true}
  649. svc := NewDivideService(db, port)
  650. ctx := context.Background()
  651. a, b := nodeID(0), nodeID(1)
  652. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  653. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  654. t.Fatalf("建链失败: %v", err)
  655. }
  656. stu := nodeID(901)
  657. if err := svc.AddLog(ctx, b, stu, 777, ""); err != nil {
  658. t.Fatalf("AddLog 失败: %v", err)
  659. }
  660. if err := svc.DeleteLog(ctx, stu, 777); err != nil {
  661. t.Fatalf("DeleteLog 失败: %v", err)
  662. }
  663. var rows []model.UserDivideIntoLog
  664. if err := db.Find(&rows).Error; err != nil {
  665. t.Fatalf("读明细失败: %v", err)
  666. }
  667. if len(rows) != 1 {
  668. t.Fatalf("改绑分支应保留明细(1 条),实得 %d", len(rows))
  669. }
  670. if rows[0].OrderNo == nil || *rows[0].OrderNo != "888" {
  671. t.Fatalf("order_no 应改绑为 888,实得 %v", rows[0].OrderNo)
  672. }
  673. if rows[0].Remark == nil || *rows[0].Remark != remarkRebind {
  674. t.Fatalf("remark 应为 %q,实得 %v", remarkRebind, rows[0].Remark)
  675. }
  676. if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 1000 {
  677. t.Fatalf("改绑不回滚收入(应仍 1000 分),实得 %d", got)
  678. }
  679. if port.calls == 0 {
  680. t.Fatal("改绑判定应调用首次扣费端口")
  681. }
  682. }
  683. // TestDeleteLogFirstEqualsOrderRebind 首扣记录**就是**当前订单 → 仍然改绑保留(audit D11,dump 762547)。
  684. //
  685. // dump 的改绑分支不与当前 orderID 比较:撤的正是"当前这单"而它本身就是首扣记录,
  686. // 带 `first != orderID` 条件会误判成"无可改绑"→ 回滚删除,把真实产生的分成整组冲掉(少发钱)。
  687. func TestDeleteLogFirstEqualsOrderRebind(t *testing.T) {
  688. db := newDivideServiceDB(t)
  689. port := &fakeFirstCharged{recordID: 777, found: true}
  690. svc := NewDivideService(db, port)
  691. ctx := context.Background()
  692. a, b := nodeID(0), nodeID(1)
  693. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  694. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  695. t.Fatalf("建链失败: %v", err)
  696. }
  697. stu := nodeID(901)
  698. if err := svc.AddLog(ctx, b, stu, 777, ""); err != nil {
  699. t.Fatalf("AddLog 失败: %v", err)
  700. }
  701. before := loadDivideNode(t, db, a).TotalIncome
  702. // 把改绑前的 create_time 拨到 2020:改绑要求"重置为 now()",用 2020 作参照可绕开
  703. // sqlite CURRENT_TIMESTAMP 的秒级精度(同秒内 After() 无法区分)
  704. old2020 := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
  705. if err := db.Model(&model.UserDivideIntoLog{}).Where("user_id = ?", a).
  706. UpdateColumn("create_time", old2020).Error; err != nil {
  707. t.Fatalf("回拨 create_time 失败: %v", err)
  708. }
  709. if err := svc.DeleteLog(ctx, stu, 777); err != nil {
  710. t.Fatalf("DeleteLog 失败: %v", err)
  711. }
  712. if count := countLogs(t, db); count != 1 {
  713. t.Fatalf("首扣记录=当前订单 → 应改绑保留(1 条),实得 %d", count)
  714. }
  715. var rows []model.UserDivideIntoLog
  716. if err := db.Find(&rows).Error; err != nil {
  717. t.Fatalf("读明细失败: %v", err)
  718. }
  719. if rows[0].OrderNo == nil || *rows[0].OrderNo != "777" {
  720. t.Fatalf("order_no 应仍为 777(改绑到首扣记录),实得 %v", rows[0].OrderNo)
  721. }
  722. if rows[0].Remark == nil || *rows[0].Remark != remarkRebind {
  723. t.Fatalf("remark 应为 %q,实得 %v", remarkRebind, rows[0].Remark)
  724. }
  725. if got := loadDivideNode(t, db, a).TotalIncome; got != before {
  726. t.Fatalf("改绑不回滚 total_income(应仍 %q),实得 %q", before, got)
  727. }
  728. if !rows[0].CreateTime.After(old2020) {
  729. t.Fatalf("改绑后 create_time 应重置为 now()(> 2020),实得 %s", rows[0].CreateTime)
  730. }
  731. if err := svc.DeleteLog(ctx, stu, 0); !errors.Is(err, apperr.ErrBusinessParam) {
  732. t.Fatalf("orderID<=0 应 3140,实得 %v", err)
  733. }
  734. }
  735. // TestUpdateQuotaCascade 额度级联下降(子不得超父)+ 参数/缺失分支。
  736. func TestUpdateQuotaCascade(t *testing.T) {
  737. db := newDivideServiceDB(t)
  738. svc := NewDivideService(db, nil)
  739. ctx := context.Background()
  740. a, b, c := nodeID(0), nodeID(1), nodeID(2)
  741. seedRoot(t, db, a, model.DivideTypeOneOff, "20.00")
  742. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  743. t.Fatalf("建链失败: %v", err)
  744. }
  745. if err := svc.AddSubordinate(ctx, b, c); err != nil {
  746. t.Fatalf("建链失败: %v", err)
  747. }
  748. if err := svc.UpdateQuota(ctx, b, money.FromCents(1500)); err != nil {
  749. t.Fatalf("设额度 B 失败: %v", err)
  750. }
  751. if err := svc.UpdateQuota(ctx, c, money.FromCents(1200)); err != nil {
  752. t.Fatalf("设额度 C 失败: %v", err)
  753. }
  754. // 父 A 降到 10.00 → B(15)、C(12) 一并压到 10.00
  755. if err := svc.UpdateQuota(ctx, a, money.FromCents(1000)); err != nil {
  756. t.Fatalf("UpdateQuota(A,10) 失败: %v", err)
  757. }
  758. for _, id := range []string{a, b, c} {
  759. if got := cents(t, loadDivideNode(t, db, id).Quota); got != 1000 {
  760. t.Fatalf("%s.quota 应级联压到 1000 分,实得 %d", id, got)
  761. }
  762. }
  763. // 子节点超额(> 父)→ 3111
  764. if err := svc.UpdateQuota(ctx, b, money.FromCents(9999)); !errors.Is(err, apperr.ErrDivideQuotaExceeded) {
  765. t.Fatalf("超过上级应 3111,实得 %v", err)
  766. }
  767. // 参数/缺失
  768. if err := svc.UpdateQuota(ctx, b, money.FromCents(-1)); !errors.Is(err, apperr.ErrBusinessParam) {
  769. t.Fatalf("负额度应 3140,实得 %v", err)
  770. }
  771. if err := svc.UpdateQuota(ctx, nodeID(404), money.FromCents(100)); !errors.Is(err, infraerrors.ErrNotFound) {
  772. t.Fatalf("节点不存在应 3001,实得 %v", err)
  773. }
  774. if err := svc.UpdateQuota(ctx, "", money.FromCents(100)); !errors.Is(err, apperr.ErrBusinessParam) {
  775. t.Fatalf("空 userID 应 3140,实得 %v", err)
  776. }
  777. }
  778. // TestUpdateQuotaNoCascadeWhenOrigZero 原 quota=0 时不级联(audit A6,dump 763872 `IF (uQuota>0)`)。
  779. //
  780. // 语义:还没配置过额度的节点,被"设成某个值"时不该顺手压低子树(子树额度是独立配置的阶梯)。
  781. // 对照:节点原额度 > 0 时级联照旧生效(后半段断言)。
  782. func TestUpdateQuotaNoCascadeWhenOrigZero(t *testing.T) {
  783. db := newDivideServiceDB(t)
  784. svc := NewDivideService(db, nil)
  785. ctx := context.Background()
  786. a, b := nodeID(0), nodeID(1)
  787. seedRoot(t, db, a, model.DivideTypeOneOff, "0.00") // 原 quota = 0
  788. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  789. t.Fatalf("建链失败: %v", err)
  790. }
  791. // 直接把子节点额度设成 5.00(绕过服务,构造"父 0 / 子 5"的起始态)
  792. if err := db.Exec("UPDATE t_user_divide_into SET quota = ? WHERE user_id = ?", "5.00", b).Error; err != nil {
  793. t.Fatalf("配置子额度失败: %v", err)
  794. }
  795. // 父原 quota=0 → 设成 10.00 **不级联**:子保持 5.00
  796. if err := svc.UpdateQuota(ctx, a, money.FromCents(1000)); err != nil {
  797. t.Fatalf("UpdateQuota 失败: %v", err)
  798. }
  799. if got := cents(t, loadDivideNode(t, db, a).Quota); got != 1000 {
  800. t.Fatalf("父额度应为 10.00,实得 %d 分", got)
  801. }
  802. if got := cents(t, loadDivideNode(t, db, b).Quota); got != 500 {
  803. t.Fatalf("原 quota=0 不应级联:子应保持 5.00,实得 %d 分", got)
  804. }
  805. // 父原 quota=10.00 > 0 → 降到 3.00 **会级联**:子被压到 3.00
  806. if err := svc.UpdateQuota(ctx, a, money.FromCents(300)); err != nil {
  807. t.Fatalf("UpdateQuota(第二次) 失败: %v", err)
  808. }
  809. if got := cents(t, loadDivideNode(t, db, a).Quota); got != 300 {
  810. t.Fatalf("父额度应为 3.00,实得 %d 分", got)
  811. }
  812. if got := cents(t, loadDivideNode(t, db, b).Quota); got != 300 {
  813. t.Fatalf("原 quota>0 应级联压到 3.00,实得 %d 分", got)
  814. }
  815. }
  816. // TestDivideServiceNestedTxPassThrough 事务透传纪律:本服务在**外层事务内**被调用时
  817. // 必须经 WithTxContext 复用同一 tx(不重复 BEGIN),且随外层回滚一并撤销。
  818. func TestDivideServiceNestedTxPassThrough(t *testing.T) {
  819. db := newDivideServiceDB(t)
  820. svc := NewDivideService(db, nil)
  821. runner := repository.NewTxRunner(db)
  822. ctx := context.Background()
  823. a, b := nodeID(0), nodeID(1)
  824. seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
  825. if err := svc.AddSubordinate(ctx, a, b); err != nil {
  826. t.Fatalf("建链失败: %v", err)
  827. }
  828. stu := nodeID(901)
  829. boom := errors.New("outer-rollback")
  830. err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  831. txCtx := repository.WithTxContext(ctx, tx)
  832. if aerr := svc.AddLog(txCtx, b, stu, 777, ""); aerr != nil {
  833. return aerr
  834. }
  835. // 事务内应可见刚写入的明细(说明确实跑在同一 tx 上)
  836. var inside int64
  837. if cerr := tx.Model(&model.UserDivideIntoLog{}).Count(&inside).Error; cerr != nil {
  838. return cerr
  839. }
  840. if inside != 1 {
  841. t.Fatalf("外层事务内应可见 1 条明细(仅上级,本人不参与),实得 %d", inside)
  842. }
  843. return boom
  844. })
  845. if !errors.Is(err, infraerrors.ErrDBError) {
  846. t.Fatalf("非业务错误应归一为 5002,实得 %v", err)
  847. }
  848. if count := countLogs(t, db); count != 0 {
  849. t.Fatalf("外层回滚后明细应全部消失(无独立事务提前提交),实得 %d", count)
  850. }
  851. if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 0 {
  852. t.Fatalf("回滚后 total_income 应为 0,实得 %d 分", got)
  853. }
  854. }
  855. // TestAddLogBFSDepthBoundary 100 层链(layer 1..100)恰好在上限内:BFS 必须完整走完到根
  856. // (101 级才触发 3111,见 TestAddLogCycleGuard)。同时验证"迭代不递归"在大深度下不栈溢出。
  857. //
  858. // 明细条数 = 99(链尾本人不参与分成,dump 起点是其上级,故比全链少 1 条)。
  859. func TestAddLogBFSDepthBoundary(t *testing.T) {
  860. db := newDivideServiceDB(t)
  861. svc := NewDivideService(db, nil)
  862. ctx := context.Background()
  863. root := nodeID(0)
  864. seedRoot(t, db, root, model.DivideTypePerLesson, "1.00")
  865. prev := root
  866. for i := 1; i <= 99; i++ {
  867. child := nodeID(i)
  868. if err := svc.AddSubordinate(ctx, prev, child); err != nil {
  869. t.Fatalf("建链失败(%s→%s): %v", prev, child, err)
  870. }
  871. prev = child
  872. }
  873. tail := prev
  874. if got := loadDivideNode(t, db, tail).Layer; got != MaxDivideDepth {
  875. t.Fatalf("链尾 layer 应为 %d,实得 %d", MaxDivideDepth, got)
  876. }
  877. // audit D2:quota<=0 的节点不生成明细 → 给链上**每个节点**配上 1.00 额度,
  878. // 否则只有根节点会入账(本用例要测的是 BFS 深度边界,不是 D2)
  879. if err := db.Exec("UPDATE t_user_divide_into SET quota = ?", "1.00").Error; err != nil {
  880. t.Fatalf("统一配置额度失败: %v", err)
  881. }
  882. if err := svc.AddLog(ctx, tail, nodeID(901), 777, ""); err != nil {
  883. t.Fatalf("%d 层链的 BFS 入账应在限内成功,实得 %v", MaxDivideDepth, err)
  884. }
  885. // 链尾本人不参与 → 明细 = 层数 - 1
  886. if count := countLogs(t, db); count != int64(MaxDivideDepth-1) {
  887. t.Fatalf("%d 层链应生成 %d 条明细(本人不参与),实得 %d", MaxDivideDepth, MaxDivideDepth-1, count)
  888. }
  889. // 根节点收入应恰好为一次 quota
  890. if got := cents(t, loadDivideNode(t, db, root).TotalIncome); got != 100 {
  891. t.Fatalf("根节点 total_income 应为 100 分,实得 %d", got)
  892. }
  893. }