|
@@ -33,7 +33,7 @@ import (
|
|
|
infraerrors "yuansiwei/unified-backend/internal/infra/errors"
|
|
infraerrors "yuansiwei/unified-backend/internal/infra/errors"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
-// MaxDivideDepth 是分账树的**结构上限**(根 layer=1;超限 → apperr.ErrDivideQuotaExceeded 3111)。
|
|
|
|
|
|
|
+// MaxDivideDepth 是分账树的**结构上限**(根 layer=1;超限 → **5001 系统异常**,不再用 3111)。
|
|
|
//
|
|
//
|
|
|
// 它的含义**不是**业务规则,而是"算法必须终止"的安全阀:AddSubordinate 要插链,
|
|
// 它的含义**不是**业务规则,而是"算法必须终止"的安全阀:AddSubordinate 要插链,
|
|
|
// 撤销/入账分支还要沿 parent 链做**上行 BFS**,一旦数据成环或链路极长,
|
|
// 撤销/入账分支还要沿 parent 链做**上行 BFS**,一旦数据成环或链路极长,
|
|
@@ -52,7 +52,15 @@ import (
|
|
|
const MaxDivideDepth int32 = 100
|
|
const MaxDivideDepth int32 = 100
|
|
|
|
|
|
|
|
// MaxDivideBizDepth 是分账层级的**业务规则**上限:最多 3 层(超管 → 校区主任 → 老师)。
|
|
// MaxDivideBizDepth 是分账层级的**业务规则**上限:最多 3 层(超管 → 校区主任 → 老师)。
|
|
|
-// 超限一律 apperr.ErrDivideQuotaExceeded(3111),与其它三种 3111 语义共用错误码、靠 message 区分。
|
|
|
|
|
|
|
+// 超限一律 apperr.ErrDivideQuotaExceeded(3111),且**对外文案带具体层级**(`errors.WithPublicMsg`)。
|
|
|
|
|
+//
|
|
|
|
|
+// ⚠ 2026-11-11 拍板 A3 之后的 3111 只剩**两种**语义,且两种都带具体数字:
|
|
|
|
|
+//
|
|
|
|
|
+// ① 层级超本常量(业务规则,用户可理解可改正)
|
|
|
|
|
+// ② 新额度超上级额度(同上)
|
|
|
|
|
+//
|
|
|
|
|
+// 其余三种(结构上限 100 / 树自环 / 上行链超长)属**数据损坏**,改判 **5001**,
|
|
|
|
|
+// 不再用 3111 —— 详见 `response.Fail` 的 >=5000 分支与台账 F32。
|
|
|
//
|
|
//
|
|
|
// 拍板日期 2026-11-10(业务确认为**硬规则**,不是建议)。
|
|
// 拍板日期 2026-11-10(业务确认为**硬规则**,不是建议)。
|
|
|
//
|
|
//
|
|
@@ -144,13 +152,19 @@ func NewDivideService(db *gorm.DB, firstCharged FirstChargedRecordReader) *Divid
|
|
|
// 出参:
|
|
// 出参:
|
|
|
// - nil:成功(新建节点,或"已存在节点"分支改挂成功);
|
|
// - nil:成功(新建节点,或"已存在节点"分支改挂成功);
|
|
|
// - ErrNotFound(3001):上级不存在或不在分账体系;
|
|
// - ErrNotFound(3001):上级不存在或不在分账体系;
|
|
|
-// - ErrDivideQuotaExceeded(3111):层级超限。**有两种语义,文案不同、判序不同**:
|
|
|
|
|
-// ① 业务上限:父 layer+1 > MaxDivideBizDepth(3) —— 超管→校区主任→老师,第 4 层直接拒;
|
|
|
|
|
-// ② 结构上限:父 layer+1 > MaxDivideDepth(100) —— 纯粹的数据结构自保,防止 BFS 不终止;
|
|
|
|
|
|
|
+// - ErrDivideQuotaExceeded(3111):**业务规则**层级超限(父 layer+1 > MaxDivideBizDepth(3))
|
|
|
|
|
+// —— 超管→校区主任→老师,第 4 层直接拒。对外文案带"最多 3 层 / 不能添加第 N 层";
|
|
|
|
|
+// - ErrInternal(5001):**数据异常**父 layer+1 > MaxDivideDepth(100)
|
|
|
|
|
+// —— 纯粹的数据结构自保(防 BFS 不终止)。正常业务碰不到,撞到即树的数据坏了;
|
|
|
// - ErrBusinessParam(3140):入参非法。
|
|
// - ErrBusinessParam(3140):入参非法。
|
|
|
//
|
|
//
|
|
|
-// 层级判定严格按「先 ① 后 ②」:3 是业务规则的常规失败点(用户操作就会撞到),
|
|
|
|
|
-// 100 是异常数据的兜底(正常业务撞不到)。顺序反了会把"业务上不让建"错报成"数据异常"。
|
|
|
|
|
|
|
+// 层级判定严格按「先 3111 后 5001」:3 是业务规则的常规失败点(用户操作就会撞到),
|
|
|
|
|
+// 100 是异常数据的兜底(正常业务撞不到)。顺序反了会把"业务上不让建"错报成"系统异常"。
|
|
|
|
|
+//
|
|
|
|
|
+// ⚠ 为什么两种语义**用不同错误码**而不是"同码不同文案"(台账 F32,2026-11-11 拍板 A3):
|
|
|
|
|
+//
|
|
|
|
|
+// 用户对 3111 的处理是"改层级",对 5001 的处理是"找管理员"。
|
|
|
|
|
+// 把数据损坏包装成 3111,前端会提示"层级超限",运维会去查层级 —— 方向完全错。
|
|
|
//
|
|
//
|
|
|
// 左右值算法(Nested Set 插入最右子节点,**服务端计算**,调用方不传 left/right):
|
|
// 左右值算法(Nested Set 插入最右子节点,**服务端计算**,调用方不传 left/right):
|
|
|
//
|
|
//
|
|
@@ -193,16 +207,25 @@ func (s *DivideService) AddSubordinate(ctx context.Context, parentUserID, subUse
|
|
|
// 文案与 ② 必须可区分:① 是产品限制("你不能再往下建了"),
|
|
// 文案与 ② 必须可区分:① 是产品限制("你不能再往下建了"),
|
|
|
// ② 是数据结构自保("链路长得不正常,可能是脏数据/成环")。
|
|
// ② 是数据结构自保("链路长得不正常,可能是脏数据/成环")。
|
|
|
if nextLayer > MaxDivideBizDepth {
|
|
if nextLayer > MaxDivideBizDepth {
|
|
|
- return infraerrors.WithCause(apperr.ErrDivideQuotaExceeded,
|
|
|
|
|
|
|
+ // ① 业务规则:用户**能看懂且能改正**的失败 ⇒ 3111,且对外文案带具体层级。
|
|
|
|
|
+ // 走 WithPublicMsg 而不是 WithCause:Cause 只进日志,前端读不到
|
|
|
|
|
+ // (台账 F32;2026-11-11 拍板 A3 —— 业务规则给用户看,数据异常不外露)。
|
|
|
|
|
+ return infraerrors.WithPublicMsg(apperr.ErrDivideQuotaExceeded,
|
|
|
errString("service: 分账层级超业务上限(最多 "+strconv.Itoa(int(MaxDivideBizDepth))+
|
|
errString("service: 分账层级超业务上限(最多 "+strconv.Itoa(int(MaxDivideBizDepth))+
|
|
|
- " 层:超管→校区主任→老师),目标层级 "+strconv.Itoa(int(nextLayer))))
|
|
|
|
|
|
|
+ " 层:超管→校区主任→老师),目标层级 "+strconv.Itoa(int(nextLayer))),
|
|
|
|
|
+ "分账层级已达上限:最多 "+strconv.Itoa(int(MaxDivideBizDepth))+
|
|
|
|
|
+ " 层(超管 → 校区主任 → 老师),不能再添加第 "+strconv.Itoa(int(nextLayer))+" 层")
|
|
|
}
|
|
}
|
|
|
// ② 结构上限(纵深防御,保留不动):防上行 BFS 不终止 / 递归爆栈。
|
|
// ② 结构上限(纵深防御,保留不动):防上行 BFS 不终止 / 递归爆栈。
|
|
|
// 与 ① 不是同一个概念,见 MaxDivideDepth 的注释;删掉它会让脏数据把服务拖死。
|
|
// 与 ① 不是同一个概念,见 MaxDivideDepth 的注释;删掉它会让脏数据把服务拖死。
|
|
|
|
|
+ //
|
|
|
|
|
+ // **改判 5001**(F32 / A3):正常业务永远撞不到这条,撞到即"树的数据坏了"。
|
|
|
|
|
+ // 报 3111"额度超上限"会把运维引向额度,方向完全错;且用户对"脏数据"无能为力。
|
|
|
|
|
+ // 对外只出"系统繁忙",真实原因在 Cause 里进日志(见 response.Fail 的 >=5000 分支)。
|
|
|
if nextLayer > MaxDivideDepth {
|
|
if nextLayer > MaxDivideDepth {
|
|
|
- return infraerrors.WithCause(apperr.ErrDivideQuotaExceeded,
|
|
|
|
|
|
|
+ return infraerrors.WithCause(infraerrors.ErrInternal,
|
|
|
errString("service: 分账层级超结构上限 "+strconv.Itoa(int(MaxDivideDepth))+
|
|
errString("service: 分账层级超结构上限 "+strconv.Itoa(int(MaxDivideDepth))+
|
|
|
- ",目标层级 "+strconv.Itoa(int(nextLayer))))
|
|
|
|
|
|
|
+ ",目标层级 "+strconv.Itoa(int(nextLayer))+"(数据异常,需人工核查分账树)"))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
rootID := resolveRoot(parent)
|
|
rootID := resolveRoot(parent)
|
|
@@ -258,7 +281,7 @@ func (s *DivideService) AddSubordinate(ctx context.Context, parentUserID, subUse
|
|
|
//
|
|
//
|
|
|
// 出参:
|
|
// 出参:
|
|
|
// - nil:成功(含"起始用户不是树节点 → 不入账"的空操作、以及幂等命中);
|
|
// - nil:成功(含"起始用户不是树节点 → 不入账"的空操作、以及幂等命中);
|
|
|
-// - ErrDivideQuotaExceeded(3111):上行层数超 100(数据成环/异常链);
|
|
|
|
|
|
|
+// - ErrInternal(5001):上行层数超 100 或树自环(**数据损坏**,不再用 3111,见 F32 / A3);
|
|
|
// - ErrBusinessParam / ErrDBError;
|
|
// - ErrBusinessParam / ErrDBError;
|
|
|
// - 若节点 quota 金额被写坏(非十进制)→ ErrDBError(数据异常,不静默归零)。
|
|
// - 若节点 quota 金额被写坏(非十进制)→ ErrDBError(数据异常,不静默归零)。
|
|
|
//
|
|
//
|
|
@@ -309,8 +332,11 @@ func (s *DivideService) AddLog(ctx context.Context, findUserID, stuID string, re
|
|
|
}
|
|
}
|
|
|
startID := strings.TrimSpace(*self.ParentUserID)
|
|
startID := strings.TrimSpace(*self.ParentUserID)
|
|
|
if startID == self.UserID {
|
|
if startID == self.UserID {
|
|
|
- return infraerrors.WithCause(apperr.ErrDivideQuotaExceeded,
|
|
|
|
|
- errString("service: 分账树自环(parent 指向自身)"))
|
|
|
|
|
|
|
+ // ③ 自环 = **数据损坏**(F32 / A3 → 5001,不再是 3111):
|
|
|
|
|
+ // 用户对"分账树自环"无能为力,报 3111"额度超上限"会把运维引向额度;
|
|
|
|
|
+ // 且"parent 指向自身"属内部结构描述,不该出现在响应体里。
|
|
|
|
|
+ return infraerrors.WithCause(infraerrors.ErrInternal,
|
|
|
|
|
+ errString("service: 分账树自环(parent 指向自身,起点节点需人工修树)"))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
cur, err := s.repo.GetByUserID(txCtx, startID)
|
|
cur, err := s.repo.GetByUserID(txCtx, startID)
|
|
@@ -323,8 +349,10 @@ func (s *DivideService) AddLog(ctx context.Context, findUserID, stuID string, re
|
|
|
|
|
|
|
|
for depth := int32(0); cur != nil; depth++ {
|
|
for depth := int32(0); cur != nil; depth++ {
|
|
|
if depth >= MaxDivideDepth {
|
|
if depth >= MaxDivideDepth {
|
|
|
- return infraerrors.WithCause(apperr.ErrDivideQuotaExceeded,
|
|
|
|
|
- errString("service: 上行分成层级超上限(疑似成环) "+strconv.Itoa(int(MaxDivideDepth))))
|
|
|
|
|
|
|
+ // ④ 上行超长(疑似成环)= 数据损坏 → 5001(F32 / A3),同 ③ 的理由。
|
|
|
|
|
+ return infraerrors.WithCause(infraerrors.ErrInternal,
|
|
|
|
|
+ errString("service: 上行分成层级超上限(疑似成环)已达 "+strconv.Itoa(int(MaxDivideDepth))+
|
|
|
|
|
+ " 层,需人工核查分账树"))
|
|
|
}
|
|
}
|
|
|
if err := s.appendShare(txCtx, tx, cur, stuID, orderNo, remark); err != nil {
|
|
if err := s.appendShare(txCtx, tx, cur, stuID, orderNo, remark); err != nil {
|
|
|
return err
|
|
return err
|
|
@@ -334,8 +362,11 @@ func (s *DivideService) AddLog(ctx context.Context, findUserID, stuID string, re
|
|
|
}
|
|
}
|
|
|
parentID := strings.TrimSpace(*cur.ParentUserID)
|
|
parentID := strings.TrimSpace(*cur.ParentUserID)
|
|
|
if parentID == cur.UserID {
|
|
if parentID == cur.UserID {
|
|
|
- return infraerrors.WithCause(apperr.ErrDivideQuotaExceeded,
|
|
|
|
|
- errString("service: 分账树自环(parent 指向自身)"))
|
|
|
|
|
|
|
+ // ③ 自环(上行途中)= 数据损坏 → 5001(F32 / A3)。
|
|
|
|
|
+ // 带上"第几层"便于运维直接定位环的位置,但不泄漏到响应体。
|
|
|
|
|
+ return infraerrors.WithCause(infraerrors.ErrInternal,
|
|
|
|
|
+ errString("service: 分账树自环(parent 指向自身,上行第 "+strconv.Itoa(int(depth))+
|
|
|
|
|
+ " 层需人工修树)"))
|
|
|
}
|
|
}
|
|
|
next, perr := s.repo.GetByUserID(txCtx, parentID)
|
|
next, perr := s.repo.GetByUserID(txCtx, parentID)
|
|
|
if errors.Is(perr, infraerrors.ErrNotFound) {
|
|
if errors.Is(perr, infraerrors.ErrNotFound) {
|
|
@@ -490,7 +521,8 @@ func (s *DivideService) DeleteLog(ctx context.Context, stuID string, orderID int
|
|
|
// 出参:
|
|
// 出参:
|
|
|
// - nil:成功(含"子树内无超标节点"的空更新);
|
|
// - nil:成功(含"子树内无超标节点"的空更新);
|
|
|
// - ErrNotFound(3001):节点不存在;
|
|
// - ErrNotFound(3001):节点不存在;
|
|
|
-// - ErrDivideQuotaExceeded(3111):新额度**超过上级**额度(子不得超父);
|
|
|
|
|
|
|
+// - ErrDivideQuotaExceeded(3111):新额度**超过上级**额度(子不得超父)。
|
|
|
|
|
+// 对外文案带两个金额("本次 200.00 元,上级额度 100.00 元"),见 F32 / A3;
|
|
|
// - ErrBusinessParam / ErrDBError。
|
|
// - ErrBusinessParam / ErrDBError。
|
|
|
//
|
|
//
|
|
|
// 级联规则(audit A6,dump `yuansiwei_finance.sql:763870-763874` **两个前置条件**):
|
|
// 级联规则(audit A6,dump `yuansiwei_finance.sql:763870-763874` **两个前置条件**):
|
|
@@ -538,8 +570,13 @@ func (s *DivideService) UpdateQuota(ctx context.Context, userID string, quota mo
|
|
|
return aerr
|
|
return aerr
|
|
|
}
|
|
}
|
|
|
if quota.GreaterThan(parentQuota) {
|
|
if quota.GreaterThan(parentQuota) {
|
|
|
- return infraerrors.WithCause(apperr.ErrDivideQuotaExceeded,
|
|
|
|
|
- errString("service: 分成额度不得超过上级("+quota.String()+" > "+parentQuota.String()+")"))
|
|
|
|
|
|
|
+ // ⑤ 业务规则:用户**能看懂且能改正**(填小一点即可)⇒ 3111 + 对外带两个金额。
|
|
|
|
|
+ // 只给"额度超限"用户不知道该填多少——这正是 F32 拍板 A3 要解决的问题。
|
|
|
|
|
+ return infraerrors.WithPublicMsg(apperr.ErrDivideQuotaExceeded,
|
|
|
|
|
+ errString("service: 分成额度不得超过上级(本次 "+quota.String()+
|
|
|
|
|
+ " > 上级 "+parentQuota.String()+")"),
|
|
|
|
|
+ "分成额度不得超过上级:本次 "+quota.String()+" 元,上级额度 "+
|
|
|
|
|
+ parentQuota.String()+" 元")
|
|
|
}
|
|
}
|
|
|
} else if !errors.Is(perr, infraerrors.ErrNotFound) {
|
|
} else if !errors.Is(perr, infraerrors.ErrNotFound) {
|
|
|
return perr
|
|
return perr
|