// 分账树查询服务(端点 38 分账树 / 端点 43 分账流水)——AC-13。 // // 依据:unified/03-migration/academic-frontend-contract-v1.md // - §3.4 B 表端点 38:`tree:[{userId, realName, parentUserId|null, quota:金额, // cumulativeIncome:金额, children:[…]}]`;**`quota` = 每次分成金额(`DECIMAL(12,2)`), // 不是 0~1 比率**(§1.5 B2,mock 曾错成比率) // - §3.4 C 表端点 43:`分页`;行 `{id, findUserId, studentId, orderNo, money, remark, createTime}`; // 该端点**无 dump SP 对照** ⇒ 排序与窗口由 Go 定(Go 新增查询) // - 设计 §3.3 #22~#25(分账 4 SP,AC-09 已交付)/ §1.7「全局无作用域表·例外二」 // // --------------------------------------------------------------------------- // 为什么本服务要做「树不变式校验」而不是直接拼 JSON // --------------------------------------------------------------------------- // // Nested Set 的 `left_val/right_val` 是**位移**维护的(插入 +2、删除 −2), // 一旦某次位移漏了一行或错了 root 限定,树的形状就坏了,但**单看某一行数据完全正常**: // 响应体照常能拼出来、前端照常能渲染一棵"看起来对"的树。这类损坏的症状是 // "分账金额算错/某层级消失",排查成本极高。 // // 因此本服务在拼树的同时**断言四条不变式**(任何一条不成立就不返回树,直接报数据损坏): // // I1 `left < right` 且所有 `left/right` 恰为 `1..2N` 的**无重复无空洞**排列; // I2 任意两节点区间**嵌套或相离**(不允许部分重叠); // I3 父区间**严格包含**子区间; // I4 `layer = parent.layer + 1`(根 `layer = 1`,且根的 `parent_user_id` 为 NULL)。 // // 这四条正是"测试绿但树坏了"唯一能抓住的东西,也是端点 38 用例的断言主体。 package service import ( "context" "sort" "strings" "time" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" "yuansiwei/unified-backend/internal/academic/repository" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // RealNameResolver 用户姓名解析器(可选依赖;nil 时 `realName` 返回空串)。 // // 为什么做成可选函数而不是硬编码查表:姓名住在 identity/org 域(`t_user.real_name`), // academic 不持有该表的结构(设计 §1.3 跨域只读要经 port)。 // 生产装配由 AC-14 在 main 注入真实实现;未注入时字段留空——**不编造**姓名。 type RealNameResolver func(ctx context.Context, userIDs []string) (map[string]string, error) // DivideTreeService 分账树 / 分账流水查询服务(只读,无事务)。 // // 字段: // - repo 分账仓储(Nested Set 节点 + 流水); // - names 姓名解析器(可 nil); // - sc 请求作用域(可 nil:系统任务视角)。 type DivideTreeService struct { repo *repository.DivideRepo names RealNameResolver sc *appscope.Scope } // NewDivideTreeService 构造分账查询服务。 // // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域);names RealNameResolver(可 nil)。 // 出参:*DivideTreeService。 func NewDivideTreeService(db *gorm.DB, sc *appscope.Scope, names RealNameResolver) *DivideTreeService { return &DivideTreeService{repo: repository.NewDivideRepo(db), names: names, sc: sc} } // ---------------------------------------------------------------- 端点 38:分账树 // DivideTreeNode 分账树节点(契约 §3.4 B 表端点 38 逐字段对齐)。 // // 量纲纪律(**mock 曾在此错一次**): // - `Quota` = **金额**(元/次分成,`DECIMAL(12,2)`,两位小数字符串); // - `CumulativeIncome` = **金额**(累计分成收入)。 // // 两者都**不是** 0~1 的比率,也不得写成 number。 type DivideTreeNode struct { // UserID 节点用户(ULID)。 UserID string `json:"userId"` // RealName 姓名(未注入姓名解析器时为空串——**不编造**)。 RealName string `json:"realName"` // ParentUserID 上级用户(根为 null)。 ParentUserID *string `json:"parentUserId"` // Quota 每次分成金额(元;`quota` 是金额不是比率)。 Quota money.Amount `json:"quota"` // CumulativeIncome 累计分成收入(元)。 CumulativeIncome money.Amount `json:"cumulativeIncome"` // Layer 层级(根=1;用于前端缩进与"层数上限 100"提示)。 Layer int32 `json:"layer"` // Children 子节点(空 → `[]`,绝不 null)。 Children []*DivideTreeNode `json:"children"` } // DivideTreeResult 分账树响应(**包一层 `tree`**,契约 §3.4 结构约定 2 的显式例外)。 type DivideTreeResult struct { // Tree 森林(多棵树时按根 user_id 升序;空 → `[]`)。 Tree []*DivideTreeNode `json:"tree"` // NodeCount 节点总数(前端可做"共 N 人"提示,也便于对账)。 NodeCount int `json:"nodeCount"` // RootCount 树的棵数(多根场景下前端分组渲染)。 RootCount int `json:"rootCount"` } // GetTree 组装分账树并校验 Nested Set 不变式(端点 38)。 // // 入参:ctx。 // 出参: // - *DivideTreeResult:森林(按根 `user_id` 升序、子节点按 `left_val` 升序); // - ErrInternal(5001) + 明确文案:**树不变式被破坏**(数据损坏,不能返回一棵"看起来对"的树); // - ErrDBError(5002):读库失败;金额字面量非法时同样 5002。 // // 空表:返回 `{tree: [], nodeCount: 0, rootCount: 0}`(不是 nil、不是错误)。 func (s *DivideTreeService) GetTree(ctx context.Context) (*DivideTreeResult, error) { rows, err := s.repo.ListAllNodes(ctx) if err != nil { return nil, err } if len(rows) == 0 { return &DivideTreeResult{Tree: []*DivideTreeNode{}, NodeCount: 0, RootCount: 0}, nil } if err := assertNestedSetInvariants(rows); err != nil { return nil, err } names := map[string]string{} if s.names != nil { ids := make([]string, 0, len(rows)) for i := range rows { ids = append(ids, rows[i].UserID) } if resolved, nerr := s.names(ctx, ids); nerr == nil && resolved != nil { names = resolved } // 姓名解析失败**不阻断**树查询(姓名是展示位;树与金额才是数据)—— // 这条与"金额/树结构任何异常都阻断"的不对称是有意的:前者是装饰、后者是对账依据。 } nodes := make(map[string]*DivideTreeNode, len(rows)) for i := range rows { quota, perr := money.Parse(rows[i].Quota) if perr != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr) } income, perr := money.Parse(rows[i].TotalIncome) if perr != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr) } nodes[rows[i].UserID] = &DivideTreeNode{ UserID: rows[i].UserID, RealName: names[rows[i].UserID], ParentUserID: rows[i].ParentUserID, Quota: quota, CumulativeIncome: income, Layer: rows[i].Layer, Children: []*DivideTreeNode{}, } } roots := make([]*DivideTreeNode, 0, 4) for i := range rows { // rows 已按 (root_user_id, left_val) 排序 ⇒ 子节点天然有序 node := nodes[rows[i].UserID] if rows[i].ParentUserID == nil || strings.TrimSpace(*rows[i].ParentUserID) == "" { roots = append(roots, node) continue } parent, ok := nodes[*rows[i].ParentUserID] if !ok { // 父节点缺失 = 数据损坏(孤儿行)。dump 的 SP30 有"孤儿行跳过"先例(A16), // 但**查询树**不能静默丢行:丢行会让"分成总额少了某人"看起来像正常数据。 return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 分账树存在孤儿节点(parent_user_id 指向不存在的用户: "+ *rows[i].ParentUserID+")")) } parent.Children = append(parent.Children, node) } sort.Slice(roots, func(a, b int) bool { return roots[a].UserID < roots[b].UserID }) return &DivideTreeResult{Tree: roots, NodeCount: len(rows), RootCount: len(roots)}, nil } // assertNestedSetInvariants 校验 Nested Set 四条不变式(I1~I4,见文件头)。 // // 入参:rows 全部节点(**同一棵树内可比;多棵树按 root_user_id 分组后各自校验**)。 // 出参:nil 通过;ErrInternal(5001) + 具体违反项。 // // 为什么不"自动修复":位移错位无法从当前数据推断"原本该是多少"; // 猜测式修复会把"确定性错误"变成"随机正确"。这里的职责是**让损坏可见**。 func assertNestedSetInvariants(rows []model.UserDivideInto) error { // 按 root 分组(root_user_id 为 NULL 时退化为自身为根) groups := map[string][]model.UserDivideInto{} order := make([]string, 0, 4) for i := range rows { root := rows[i].UserID if rows[i].RootUserID != nil && strings.TrimSpace(*rows[i].RootUserID) != "" { root = *rows[i].RootUserID } if _, ok := groups[root]; !ok { order = append(order, root) } groups[root] = append(groups[root], rows[i]) } for _, root := range order { nodes := groups[root] // I1a:left < right seen := make(map[int32]string, len(nodes)*2) pairs := make([]struct{ left, right int32 }, 0, len(nodes)) for i := range nodes { if nodes[i].LeftVal >= nodes[i].RightVal { return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 分账树不变式 I1 破(left >= right): user="+nodes[i].UserID)) } for _, v := range []int32{nodes[i].LeftVal, nodes[i].RightVal} { if v < 1 { return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 分账树不变式 I1 破(区间值 < 1): user="+nodes[i].UserID)) } if prev, dup := seen[v]; dup { return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 分账树不变式 I1 破(区间值重复 "+itoa32(v)+ ":user="+prev+" 与 user="+nodes[i].UserID+")")) } seen[v] = nodes[i].UserID } pairs = append(pairs, struct{ left, right int32 }{nodes[i].LeftVal, nodes[i].RightVal}) } // I1b:恰为 1..2N 无空洞 want := 2 * len(nodes) if len(seen) != want { return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 分账树不变式 I1 破(区间值数量 "+itoa32(int32(len(seen)))+ " ≠ 2N="+itoa32(int32(want))+")")) } for v := 1; v <= want; v++ { if _, ok := seen[int32(v)]; !ok { return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 分账树不变式 I1 破(区间值有空洞,缺 "+itoa32(int32(v))+")")) } } // I2:任意两节点区间嵌套或相离(不允许部分重叠) for i := 0; i < len(pairs); i++ { for j := i + 1; j < len(pairs); j++ { a, b := pairs[i], pairs[j] nested := (a.left < b.left && b.right < a.right) || (b.left < a.left && a.right < b.right) disjoint := a.right < b.left || b.right < a.left if !nested && !disjoint { return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 分账树不变式 I2 破(区间部分重叠: ["+ itoa32(a.left)+","+itoa32(a.right)+"] 与 ["+ itoa32(b.left)+","+itoa32(b.right)+"])")) } } } } // I3/I4:父区间严格包含子区间 + layer = parent.layer + 1 byUser := make(map[string]model.UserDivideInto, len(rows)) for i := range rows { byUser[rows[i].UserID] = rows[i] } for i := range rows { node := rows[i] if node.ParentUserID == nil || strings.TrimSpace(*node.ParentUserID) == "" { if node.Layer != 1 { return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 分账树不变式 I4 破(根节点 layer≠1): user="+node.UserID)) } continue } parent, ok := byUser[*node.ParentUserID] if !ok { return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 分账树不变式 I3/I4 破(父节点不存在): user="+node.UserID)) } if !(parent.LeftVal < node.LeftVal && node.RightVal < parent.RightVal) { return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 分账树不变式 I3 破(父区间未严格包含子区间): user="+node.UserID)) } if node.Layer != parent.Layer+1 { return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 分账树不变式 I4 破(layer≠parent.layer+1): user="+node.UserID)) } } return nil } // itoa32 最小 int32 → string 转换(避免为一个错误文案引入 strconv 依赖)。 // // 入参:v 整数。出参:十进制字符串(负数带前导 '-')。 func itoa32(v int32) string { if v == 0 { return "0" } negative := v < 0 if negative { v = -v } buf := [12]byte{} pos := len(buf) for v > 0 { pos-- buf[pos] = byte('0' + v%10) v /= 10 } if negative { pos-- buf[pos] = '-' } return string(buf[pos:]) } // ---------------------------------------------------------------- 端点 43:分账流水 // DivideLogQuery 分账流水查询入参(端点 43)。 type DivideLogQuery struct { // FindUserID 分成获得者(空 = 不筛)。 FindUserID string // StudentID 学生(空 = 不筛)。 StudentID string // From 起始时间(含;零值 = 不筛)。 From *time.Time // To 结束时间(**不含**;零值 = 不筛)。 To *time.Time // Page / PageSize 分页(<=0 归一;>200 钳 200)。 Page int PageSize int } // DivideLogView 分账流水行(契约 §3.4 C 表端点 43 逐字段)。 type DivideLogView struct { // ID 流水 ID。 ID int64 `json:"id"` // FindUserID 分成获得者(ULID)。 FindUserID string `json:"findUserId"` // StudentID 学生(ULID)。 StudentID string `json:"studentId"` // OrderNo 订单号(可空 → "")。 OrderNo string `json:"orderNo"` // Money 分成金额(元)。 Money money.Amount `json:"money"` // Remark 备注。 Remark string `json:"remark"` // CreateTime 业务时间(RFC3339)。 CreateTime time.Time `json:"createTime"` } // ListLogs 分页查分账流水(端点 43)。 // // 入参:ctx;q DivideLogQuery。 // 出参:([]DivideLogView, total, error);`from > to` → 2001。 func (s *DivideTreeService) ListLogs(ctx context.Context, q DivideLogQuery) ([]DivideLogView, int64, error) { if q.From != nil && q.To != nil && q.From.After(*q.To) { return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 分账流水的 from 不能晚于 to")) } page := repository.PageQuery{Page: q.Page, PageSize: q.PageSize} rows, total, err := s.repo.ListLogsFiltered(ctx, repository.DivideLogFilter{ FindUserID: strings.TrimSpace(q.FindUserID), StudentID: strings.TrimSpace(q.StudentID), From: q.From, To: q.To, }, page) if err != nil { return nil, 0, err } views := make([]DivideLogView, 0, len(rows)) for i := range rows { amount, perr := money.Parse(rows[i].Money) if perr != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, perr) } view := DivideLogView{ // 表列 `user_id` → 契约响应字段 `findUserId`(名字听前端的,语义听 dump 的) ID: rows[i].ID, FindUserID: rows[i].UserID, StudentID: rows[i].StudentID, Money: amount, CreateTime: rows[i].CreateTime, } if rows[i].OrderNo != nil { view.OrderNo = *rows[i].OrderNo } if rows[i].Remark != nil { view.Remark = *rows[i].Remark } views = append(views, view) } return views, total, nil }