divide_tree_service.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. // 分账树查询服务(端点 38 分账树 / 端点 43 分账流水)——AC-13。
  2. //
  3. // 依据:unified/03-migration/academic-frontend-contract-v1.md
  4. // - §3.4 B 表端点 38:`tree:[{userId, realName, parentUserId|null, quota:金额,
  5. // cumulativeIncome:金额, children:[…]}]`;**`quota` = 每次分成金额(`DECIMAL(12,2)`),
  6. // 不是 0~1 比率**(§1.5 B2,mock 曾错成比率)
  7. // - §3.4 C 表端点 43:`分页`;行 `{id, findUserId, studentId, orderNo, money, remark, createTime}`;
  8. // 该端点**无 dump SP 对照** ⇒ 排序与窗口由 Go 定(Go 新增查询)
  9. // - 设计 §3.3 #22~#25(分账 4 SP,AC-09 已交付)/ §1.7「全局无作用域表·例外二」
  10. //
  11. // ---------------------------------------------------------------------------
  12. // 为什么本服务要做「树不变式校验」而不是直接拼 JSON
  13. // ---------------------------------------------------------------------------
  14. //
  15. // Nested Set 的 `left_val/right_val` 是**位移**维护的(插入 +2、删除 −2),
  16. // 一旦某次位移漏了一行或错了 root 限定,树的形状就坏了,但**单看某一行数据完全正常**:
  17. // 响应体照常能拼出来、前端照常能渲染一棵"看起来对"的树。这类损坏的症状是
  18. // "分账金额算错/某层级消失",排查成本极高。
  19. //
  20. // 因此本服务在拼树的同时**断言四条不变式**(任何一条不成立就不返回树,直接报数据损坏):
  21. //
  22. // I1 `left < right` 且所有 `left/right` 恰为 `1..2N` 的**无重复无空洞**排列;
  23. // I2 任意两节点区间**嵌套或相离**(不允许部分重叠);
  24. // I3 父区间**严格包含**子区间;
  25. // I4 `layer = parent.layer + 1`(根 `layer = 1`,且根的 `parent_user_id` 为 NULL)。
  26. //
  27. // 这四条正是"测试绿但树坏了"唯一能抓住的东西,也是端点 38 用例的断言主体。
  28. package service
  29. import (
  30. "context"
  31. "sort"
  32. "strings"
  33. "time"
  34. "gorm.io/gorm"
  35. "yuansiwei/unified-backend/internal/academic/model"
  36. "yuansiwei/unified-backend/internal/academic/money"
  37. "yuansiwei/unified-backend/internal/academic/repository"
  38. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  39. appscope "yuansiwei/unified-backend/internal/infra/scope"
  40. )
  41. // RealNameResolver 用户姓名解析器(可选依赖;nil 时 `realName` 返回空串)。
  42. //
  43. // 为什么做成可选函数而不是硬编码查表:姓名住在 identity/org 域(`t_user.real_name`),
  44. // academic 不持有该表的结构(设计 §1.3 跨域只读要经 port)。
  45. // 生产装配由 AC-14 在 main 注入真实实现;未注入时字段留空——**不编造**姓名。
  46. type RealNameResolver func(ctx context.Context, userIDs []string) (map[string]string, error)
  47. // DivideTreeService 分账树 / 分账流水查询服务(只读,无事务)。
  48. //
  49. // 字段:
  50. // - repo 分账仓储(Nested Set 节点 + 流水);
  51. // - names 姓名解析器(可 nil);
  52. // - sc 请求作用域(可 nil:系统任务视角)。
  53. type DivideTreeService struct {
  54. repo *repository.DivideRepo
  55. names RealNameResolver
  56. sc *appscope.Scope
  57. // db 数据库句柄(写路径编排用:`writer()` 现场装配 AC-09 的 `DivideService`)。
  58. db *gorm.DB
  59. }
  60. // NewDivideTreeService 构造分账查询服务。
  61. //
  62. // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域);names RealNameResolver(可 nil)。
  63. // 出参:*DivideTreeService。
  64. func NewDivideTreeService(db *gorm.DB, sc *appscope.Scope, names RealNameResolver) *DivideTreeService {
  65. return &DivideTreeService{repo: repository.NewDivideRepo(db), names: names, sc: sc, db: db}
  66. }
  67. // ---------------------------------------------------------------- 端点 38:分账树
  68. // DivideTreeNode 分账树节点(契约 §3.4 B 表端点 38 逐字段对齐)。
  69. //
  70. // 量纲纪律(**mock 曾在此错一次**):
  71. // - `Quota` = **金额**(元/次分成,`DECIMAL(12,2)`,两位小数字符串);
  72. // - `CumulativeIncome` = **金额**(累计分成收入)。
  73. //
  74. // 两者都**不是** 0~1 的比率,也不得写成 number。
  75. type DivideTreeNode struct {
  76. // UserID 节点用户(ULID)。
  77. UserID string `json:"userId"`
  78. // RealName 姓名(未注入姓名解析器时为空串——**不编造**)。
  79. RealName string `json:"realName"`
  80. // ParentUserID 上级用户(根为 null)。
  81. ParentUserID *string `json:"parentUserId"`
  82. // Quota 每次分成金额(元;`quota` 是金额不是比率)。
  83. Quota money.Amount `json:"quota"`
  84. // CumulativeIncome 累计分成收入(元)。
  85. CumulativeIncome money.Amount `json:"cumulativeIncome"`
  86. // Layer 层级(根=1;用于前端缩进与"层数上限 100"提示)。
  87. Layer int32 `json:"layer"`
  88. // Children 子节点(空 → `[]`,绝不 null)。
  89. Children []*DivideTreeNode `json:"children"`
  90. }
  91. // DivideTreeResult 分账树响应(**包一层 `tree`**,契约 §3.4 结构约定 2 的显式例外)。
  92. type DivideTreeResult struct {
  93. // Tree 森林(多棵树时按根 user_id 升序;空 → `[]`)。
  94. Tree []*DivideTreeNode `json:"tree"`
  95. // NodeCount 节点总数(前端可做"共 N 人"提示,也便于对账)。
  96. NodeCount int `json:"nodeCount"`
  97. // RootCount 树的棵数(多根场景下前端分组渲染)。
  98. RootCount int `json:"rootCount"`
  99. }
  100. // GetTree 组装分账树并校验 Nested Set 不变式(端点 38)。
  101. //
  102. // 入参:ctx;rootUserID 可选(契约端点 38 的 `rootUserId?`:非空只取该棵树)。
  103. // 出参:
  104. // - *DivideTreeResult:森林(按根 `user_id` 升序、子节点按 `left_val` 升序);
  105. // - ErrInternal(5001) + 明确文案:**树不变式被破坏**(数据损坏,不能返回一棵"看起来对"的树);
  106. // - ErrDBError(5002):读库失败;金额字面量非法时同样 5002。
  107. //
  108. // 空表:返回 `{tree: [], nodeCount: 0, rootCount: 0}`(不是 nil、不是错误)。
  109. func (s *DivideTreeService) GetTree(ctx context.Context, rootUserID string) (*DivideTreeResult, error) {
  110. rows, err := s.repo.ListAllNodes(ctx, strings.TrimSpace(rootUserID))
  111. if err != nil {
  112. return nil, err
  113. }
  114. if len(rows) == 0 {
  115. return &DivideTreeResult{Tree: []*DivideTreeNode{}, NodeCount: 0, RootCount: 0}, nil
  116. }
  117. if err := assertNestedSetInvariants(rows); err != nil {
  118. return nil, err
  119. }
  120. names := map[string]string{}
  121. if s.names != nil {
  122. ids := make([]string, 0, len(rows))
  123. for i := range rows {
  124. ids = append(ids, rows[i].UserID)
  125. }
  126. if resolved, nerr := s.names(ctx, ids); nerr == nil && resolved != nil {
  127. names = resolved
  128. }
  129. // 姓名解析失败**不阻断**树查询(姓名是展示位;树与金额才是数据)——
  130. // 这条与"金额/树结构任何异常都阻断"的不对称是有意的:前者是装饰、后者是对账依据。
  131. }
  132. nodes := make(map[string]*DivideTreeNode, len(rows))
  133. for i := range rows {
  134. quota, perr := money.Parse(rows[i].Quota)
  135. if perr != nil {
  136. return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  137. }
  138. income, perr := money.Parse(rows[i].TotalIncome)
  139. if perr != nil {
  140. return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  141. }
  142. nodes[rows[i].UserID] = &DivideTreeNode{
  143. UserID: rows[i].UserID, RealName: names[rows[i].UserID],
  144. ParentUserID: rows[i].ParentUserID, Quota: quota, CumulativeIncome: income,
  145. Layer: rows[i].Layer, Children: []*DivideTreeNode{},
  146. }
  147. }
  148. roots := make([]*DivideTreeNode, 0, 4)
  149. for i := range rows { // rows 已按 (root_user_id, left_val) 排序 ⇒ 子节点天然有序
  150. node := nodes[rows[i].UserID]
  151. if rows[i].ParentUserID == nil || strings.TrimSpace(*rows[i].ParentUserID) == "" {
  152. roots = append(roots, node)
  153. continue
  154. }
  155. parent, ok := nodes[*rows[i].ParentUserID]
  156. if !ok {
  157. // 父节点缺失 = 数据损坏(孤儿行)。dump 的 SP30 有"孤儿行跳过"先例(A16),
  158. // 但**查询树**不能静默丢行:丢行会让"分成总额少了某人"看起来像正常数据。
  159. return nil, infraerrors.WithCause(infraerrors.ErrInternal,
  160. errString("service: 分账树存在孤儿节点(parent_user_id 指向不存在的用户: "+
  161. *rows[i].ParentUserID+")"))
  162. }
  163. parent.Children = append(parent.Children, node)
  164. }
  165. sort.Slice(roots, func(a, b int) bool { return roots[a].UserID < roots[b].UserID })
  166. return &DivideTreeResult{Tree: roots, NodeCount: len(rows), RootCount: len(roots)}, nil
  167. }
  168. // assertNestedSetInvariants 校验 Nested Set 四条不变式(I1~I4,见文件头)。
  169. //
  170. // 入参:rows 全部节点(**同一棵树内可比;多棵树按 root_user_id 分组后各自校验**)。
  171. // 出参:nil 通过;ErrInternal(5001) + 具体违反项。
  172. //
  173. // 为什么不"自动修复":位移错位无法从当前数据推断"原本该是多少";
  174. // 猜测式修复会把"确定性错误"变成"随机正确"。这里的职责是**让损坏可见**。
  175. func assertNestedSetInvariants(rows []model.UserDivideInto) error {
  176. // 按 root 分组(root_user_id 为 NULL 时退化为自身为根)
  177. groups := map[string][]model.UserDivideInto{}
  178. order := make([]string, 0, 4)
  179. for i := range rows {
  180. root := rows[i].UserID
  181. if rows[i].RootUserID != nil && strings.TrimSpace(*rows[i].RootUserID) != "" {
  182. root = *rows[i].RootUserID
  183. }
  184. if _, ok := groups[root]; !ok {
  185. order = append(order, root)
  186. }
  187. groups[root] = append(groups[root], rows[i])
  188. }
  189. for _, root := range order {
  190. nodes := groups[root]
  191. // I1a:left < right
  192. seen := make(map[int32]string, len(nodes)*2)
  193. pairs := make([]struct{ left, right int32 }, 0, len(nodes))
  194. for i := range nodes {
  195. if nodes[i].LeftVal >= nodes[i].RightVal {
  196. return infraerrors.WithCause(infraerrors.ErrInternal,
  197. errString("service: 分账树不变式 I1 破(left >= right): user="+nodes[i].UserID))
  198. }
  199. for _, v := range []int32{nodes[i].LeftVal, nodes[i].RightVal} {
  200. if v < 1 {
  201. return infraerrors.WithCause(infraerrors.ErrInternal,
  202. errString("service: 分账树不变式 I1 破(区间值 < 1): user="+nodes[i].UserID))
  203. }
  204. if prev, dup := seen[v]; dup {
  205. return infraerrors.WithCause(infraerrors.ErrInternal,
  206. errString("service: 分账树不变式 I1 破(区间值重复 "+itoa32(v)+
  207. ":user="+prev+" 与 user="+nodes[i].UserID+")"))
  208. }
  209. seen[v] = nodes[i].UserID
  210. }
  211. pairs = append(pairs, struct{ left, right int32 }{nodes[i].LeftVal, nodes[i].RightVal})
  212. }
  213. // I1b:恰为 1..2N 无空洞
  214. want := 2 * len(nodes)
  215. if len(seen) != want {
  216. return infraerrors.WithCause(infraerrors.ErrInternal,
  217. errString("service: 分账树不变式 I1 破(区间值数量 "+itoa32(int32(len(seen)))+
  218. " ≠ 2N="+itoa32(int32(want))+")"))
  219. }
  220. for v := 1; v <= want; v++ {
  221. if _, ok := seen[int32(v)]; !ok {
  222. return infraerrors.WithCause(infraerrors.ErrInternal,
  223. errString("service: 分账树不变式 I1 破(区间值有空洞,缺 "+itoa32(int32(v))+")"))
  224. }
  225. }
  226. // I2:任意两节点区间嵌套或相离(不允许部分重叠)
  227. for i := 0; i < len(pairs); i++ {
  228. for j := i + 1; j < len(pairs); j++ {
  229. a, b := pairs[i], pairs[j]
  230. nested := (a.left < b.left && b.right < a.right) || (b.left < a.left && a.right < b.right)
  231. disjoint := a.right < b.left || b.right < a.left
  232. if !nested && !disjoint {
  233. return infraerrors.WithCause(infraerrors.ErrInternal,
  234. errString("service: 分账树不变式 I2 破(区间部分重叠: ["+
  235. itoa32(a.left)+","+itoa32(a.right)+"] 与 ["+
  236. itoa32(b.left)+","+itoa32(b.right)+"])"))
  237. }
  238. }
  239. }
  240. }
  241. // I3/I4:父区间严格包含子区间 + layer = parent.layer + 1
  242. byUser := make(map[string]model.UserDivideInto, len(rows))
  243. for i := range rows {
  244. byUser[rows[i].UserID] = rows[i]
  245. }
  246. for i := range rows {
  247. node := rows[i]
  248. if node.ParentUserID == nil || strings.TrimSpace(*node.ParentUserID) == "" {
  249. if node.Layer != 1 {
  250. return infraerrors.WithCause(infraerrors.ErrInternal,
  251. errString("service: 分账树不变式 I4 破(根节点 layer≠1): user="+node.UserID))
  252. }
  253. continue
  254. }
  255. parent, ok := byUser[*node.ParentUserID]
  256. if !ok {
  257. return infraerrors.WithCause(infraerrors.ErrInternal,
  258. errString("service: 分账树不变式 I3/I4 破(父节点不存在): user="+node.UserID))
  259. }
  260. if !(parent.LeftVal < node.LeftVal && node.RightVal < parent.RightVal) {
  261. return infraerrors.WithCause(infraerrors.ErrInternal,
  262. errString("service: 分账树不变式 I3 破(父区间未严格包含子区间): user="+node.UserID))
  263. }
  264. if node.Layer != parent.Layer+1 {
  265. return infraerrors.WithCause(infraerrors.ErrInternal,
  266. errString("service: 分账树不变式 I4 破(layer≠parent.layer+1): user="+node.UserID))
  267. }
  268. }
  269. return nil
  270. }
  271. // itoa32 最小 int32 → string 转换(避免为一个错误文案引入 strconv 依赖)。
  272. //
  273. // 入参:v 整数。出参:十进制字符串(负数带前导 '-')。
  274. func itoa32(v int32) string {
  275. if v == 0 {
  276. return "0"
  277. }
  278. negative := v < 0
  279. if negative {
  280. v = -v
  281. }
  282. buf := [12]byte{}
  283. pos := len(buf)
  284. for v > 0 {
  285. pos--
  286. buf[pos] = byte('0' + v%10)
  287. v /= 10
  288. }
  289. if negative {
  290. pos--
  291. buf[pos] = '-'
  292. }
  293. return string(buf[pos:])
  294. }
  295. // ---------------------------------------------------------------- 端点 43:分账流水
  296. // DivideLogQuery 分账流水查询入参(端点 43)。
  297. type DivideLogQuery struct {
  298. // FindUserID 分成获得者(空 = 不筛)。
  299. FindUserID string
  300. // StudentID 学生(空 = 不筛)。
  301. StudentID string
  302. // From 起始时间(含;零值 = 不筛)。
  303. From *time.Time
  304. // To 结束时间(**不含**;零值 = 不筛)。
  305. To *time.Time
  306. // Page / PageSize 分页(<=0 归一;>200 钳 200)。
  307. Page int
  308. PageSize int
  309. }
  310. // DivideLogView 分账流水行(契约 §3.4 C 表端点 43 逐字段)。
  311. type DivideLogView struct {
  312. // ID 流水 ID。
  313. ID int64 `json:"id"`
  314. // FindUserID 分成获得者(ULID)。
  315. FindUserID string `json:"findUserId"`
  316. // StudentID 学生(ULID)。
  317. StudentID string `json:"studentId"`
  318. // OrderNo 订单号(可空 → "")。
  319. OrderNo string `json:"orderNo"`
  320. // Money 分成金额(元)。
  321. Money money.Amount `json:"money"`
  322. // Remark 备注。
  323. Remark string `json:"remark"`
  324. // CreateTime 业务时间(RFC3339)。
  325. CreateTime time.Time `json:"createTime"`
  326. }
  327. // ListLogs 分页查分账流水(端点 43)。
  328. //
  329. // 入参:ctx;q DivideLogQuery。
  330. // 出参:([]DivideLogView, total, error);`from > to` → 2001。
  331. func (s *DivideTreeService) ListLogs(ctx context.Context, q DivideLogQuery) ([]DivideLogView, int64, error) {
  332. if q.From != nil && q.To != nil && q.From.After(*q.To) {
  333. return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  334. errString("service: 分账流水的 from 不能晚于 to"))
  335. }
  336. page := repository.PageQuery{Page: q.Page, PageSize: q.PageSize}
  337. rows, total, err := s.repo.ListLogsFiltered(ctx, repository.DivideLogFilter{
  338. FindUserID: strings.TrimSpace(q.FindUserID),
  339. StudentID: strings.TrimSpace(q.StudentID),
  340. From: q.From, To: q.To,
  341. }, page)
  342. if err != nil {
  343. return nil, 0, err
  344. }
  345. views := make([]DivideLogView, 0, len(rows))
  346. for i := range rows {
  347. amount, perr := money.Parse(rows[i].Money)
  348. if perr != nil {
  349. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  350. }
  351. view := DivideLogView{
  352. // 表列 `user_id` → 契约响应字段 `findUserId`(名字听前端的,语义听 dump 的)
  353. ID: rows[i].ID, FindUserID: rows[i].UserID, StudentID: rows[i].StudentID,
  354. Money: amount, CreateTime: rows[i].CreateTime,
  355. }
  356. if rows[i].OrderNo != nil {
  357. view.OrderNo = *rows[i].OrderNo
  358. }
  359. if rows[i].Remark != nil {
  360. view.Remark = *rows[i].Remark
  361. }
  362. views = append(views, view)
  363. }
  364. return views, total, nil
  365. }
  366. // ---------------------------------------------------------------- 端点 39 / 40:加入下线 / 改额度
  367. // AddMemberResult 加入下线结果(端点 39 响应 `{ancestorIds:[]}`)。
  368. type AddMemberResult struct {
  369. // AncestorIDs 新节点的祖先链(由外向内;**不含自身**)。
  370. AncestorIDs []string `json:"ancestorIds"`
  371. }
  372. // AddMember 加入下线(端点 39 `POST /admin/divide-into/members`)。
  373. //
  374. // 入参:ctx;parentUserID 上级(ULID);subUserID 下线(ULID)。
  375. // 出参:*AddMemberResult(祖先链);错误码 2001/3111(深度超 100)/5002。
  376. //
  377. // 编排说明:写动作复用 **AC-09 已交付的 `DivideService.AddSubordinate`**(本服务不重写位移逻辑);
  378. // 随后读一次新节点的祖先链返回给前端(dump `divide_into_add` 末尾的 `SELECT userid` 就是它)。
  379. // ⚠ 新节点 `quota` 默认 **0.00**(D6/DDL 默认)⇒ 建完必须调端点 40 设额度,否则该链
  380. // **一分钱都不分成且不报错**(静默不入账,契约 §2.3 已提示前端引导用户设额度)。
  381. func (s *DivideTreeService) AddMember(ctx context.Context, parentUserID, subUserID string) (*AddMemberResult, error) {
  382. if strings.TrimSpace(parentUserID) == "" || strings.TrimSpace(subUserID) == "" {
  383. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  384. errString("service: AddMember 需要 parentUserId 与 subUserId"))
  385. }
  386. if err := s.writer().AddSubordinate(ctx, parentUserID, subUserID); err != nil {
  387. return nil, err
  388. }
  389. node, err := s.repo.GetByUserID(ctx, subUserID)
  390. if err != nil {
  391. return nil, err
  392. }
  393. root := subUserID
  394. if node.RootUserID != nil && strings.TrimSpace(*node.RootUserID) != "" {
  395. root = *node.RootUserID
  396. }
  397. ids, err := s.repo.ListAncestorIDs(ctx, node.LeftVal, node.RightVal, root)
  398. if err != nil {
  399. return nil, err
  400. }
  401. if ids == nil {
  402. ids = []string{}
  403. }
  404. return &AddMemberResult{AncestorIDs: ids}, nil
  405. }
  406. // SetQuotaResult 改额度结果(端点 40 响应 `{affectedUserIds:[]}`)。
  407. type SetQuotaResult struct {
  408. // AffectedUserIDs 受影响用户(**含自身**;dump 末尾 `LEFT>=uLeft AND RIGHT<=uRight AND quota>=newQuota`)。
  409. AffectedUserIDs []string `json:"affectedUserIds"`
  410. }
  411. // SetQuota 改分成额度(端点 40 `PUT /admin/divide-into/members/:userId/quota`)。
  412. //
  413. // 入参:ctx;userID 节点;quota 新额度(**金额**,`DECIMAL(12,2)` —— 不是 0~1 比率)。
  414. // 出参:*SetQuotaResult;错误码 2001/2002(quota 非法)/3111(**子额度 > 父额度**,见下)/3001/5002。
  415. //
  416. // 3111 的判据(**必须精确,mock 曾把 quota 当比率而写错**):
  417. // **「新额度 > 父节点额度」** 才 3111 —— 不是 `quota > 1`、也不是比率越界。
  418. // 该判据由 AC-09 的 `DivideService.UpdateQuota` 实现(dump 无此校验,属 D9 有意增强),
  419. // 本方法**只编排、不重复实现**:先写(含子树级联下降),再读一次受影响列表。
  420. func (s *DivideTreeService) SetQuota(ctx context.Context, userID string, quota money.Amount) (*SetQuotaResult, error) {
  421. if strings.TrimSpace(userID) == "" {
  422. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: SetQuota 需要 userId"))
  423. }
  424. if err := quota.Validate(); err != nil {
  425. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, err)
  426. }
  427. if quota.IsNegative() {
  428. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
  429. errString("service: 分成额度不能为负(quota 是金额,不是比率)"))
  430. }
  431. if err := s.writer().UpdateQuota(ctx, userID, quota); err != nil {
  432. return nil, err
  433. }
  434. node, err := s.repo.GetByUserID(ctx, userID)
  435. if err != nil {
  436. return nil, err
  437. }
  438. ids, err := s.repo.ListSubtreeUserIDsWithQuotaAtLeast(ctx, node.LeftVal, node.RightVal, quota)
  439. if err != nil {
  440. return nil, err
  441. }
  442. if ids == nil {
  443. ids = []string{}
  444. }
  445. return &SetQuotaResult{AffectedUserIDs: ids}, nil
  446. }
  447. // writer 取写路径服务(AC-09 已交付的 `DivideService`;本服务只编排、不重写位移/级联逻辑)。
  448. func (s *DivideTreeService) writer() *DivideService {
  449. // firstCharged 传 nil:端点 39/40 不触碰"首扣记录"判据(那是端点 42 撤销路径的依赖),
  450. // 只有调用 `RevokeLog` 才需要它 —— 本服务不调,故 nil 安全(若将来调用会得到显式错误而非 panic)。
  451. return NewDivideService(s.db, nil)
  452. }
  453. // AddLog 记分账流水(端点 41)——**转调 AC-09 的 `DivideService.AddLog`**,不重复实现上行链。
  454. //
  455. // 入参:ctx;findUserID 起点(**本人不拿分成,从 parent 起上行**);stuID 学生;orderNo 订单/上课记录号;remark 备注。
  456. // 出参:2001/2002/3111/3001/5002(由 AC-09 实现给出)。
  457. //
  458. // ⚠ 排查顺序(设计 §3.3 #21 连锁陷阱,三条成因**有且只有三条**):
  459. //
  460. // ① 节点 `quota=0`(新建默认值,最常见);② `findUserID` 本身是根(无父 → 正确空结果);
  461. // ③ `findUserID` 不是树节点(未入体系 → 静默不入账,也是正确行为)。
  462. func (s *DivideTreeService) AddLog(ctx context.Context, findUserID, stuID string, orderNo int64, remark string) error {
  463. return s.writer().AddLog(ctx, findUserID, stuID, orderNo, remark)
  464. }
  465. // RevokeLog 撤销分账流水(端点 42)——**转调 AC-09 的 `DivideService.DeleteLog`**。
  466. //
  467. // D11 已拍板(按 dump):存在「首扣记录」即**改绑保留**(`total_income` 不回滚),
  468. // 仅无首扣记录时才回滚并物理删除。该判据依赖 `FirstChargedRecordReader`,
  469. // 故本方法的 writer 必须注入真实 reader(见 `writerWithFirstCharged`)。
  470. func (s *DivideTreeService) RevokeLog(ctx context.Context, stuID string, orderNo int64) error {
  471. return s.writerWithFirstCharged().DeleteLog(ctx, stuID, orderNo)
  472. }
  473. // writerWithFirstCharged 取注入首扣读取器的写服务(撤销路径**必须**有它,否则无法判"是否首扣")。
  474. //
  475. // 说明:`port.NewOrgFirstChargedReader(db)` 是 AC-11 交付的真实实现(读 `t_courses_record_student`);
  476. // 若装配失败/表缺失,`DeleteLog` 会返回显式错误而不是静默按"无首扣"处理(防误回滚分成)。
  477. func (s *DivideTreeService) writerWithFirstCharged() *DivideService {
  478. var reader FirstChargedRecordReader
  479. if s.db != nil {
  480. // AC-11 交付的真实实现(`repository.RecordRepo.FirstChargedRecordID`,四个谓词逐字对齐 dump 763811)。
  481. // 作用域传 nil:撤销路径按 `student_id` 精确取数(该实现内部不依赖 scoping),
  482. // 越权由端点角色白名单 + `mustScope` 保证(分账是全局域,见 handler 文件头)。
  483. reader = repository.NewRecordRepo(s.db, nil)
  484. }
  485. return NewDivideService(s.db, reader)
  486. }