Browse Source

feat(academic)!: F32 按 A3 拆分 3111 —— 业务规则给用户看,数据损坏不外露

拍板 2026-11-11。背景:v1.10 曾承诺「3111 四义按 message 分流」,实测做不到 ——
具体文案在 Cause 里,response.Fail 只取 Msg,四种 3111 的 message 一字不差
(实测五种触发点响应体完全相同,见下)。继续共码的结果是:数据损坏报"额度超上限",
运维照着去查额度,方向完全错。

按「谁该看到」分流,而不是按「错误发生在哪」:

  ① 业务规则(用户能理解能改正)→ 保留 3111,且**对外文案带具体数字**
     层级超 3 层 → "分账层级已达上限:最多 3 层(超管 → 校区主任 → 老师),不能再添加第 4 层"
     额度超父   → "分成额度不得超过上级:本次 200.00 元,上级额度 100.00 元"
  ② 数据损坏(用户无能为力)→ 改判 5001,对外只出"系统繁忙"
     层级超 100 / 树自环 / 上行链超 100 层,真实原因(含第几层、自环)只进日志
  ③ 端点 41(记分账流水)不再返回 3111

实现:
  - errors.AppError 新增 PublicMsg 字段 + WithPublicMsg():把"给用户看的"与
    "给运维看的"拆成两个字段。PublicMsg 由出错点显式填写,只放可对外的事实;
    Cause 仍旧永不进响应体。安全红线写进注释(禁 SQL/表名/堆栈/他人金额)。
  - response.Fail 改为 PublicMsg > Msg > "系统繁忙",并对 code >= 5000 补记日志。

顺带修一个既有缺陷:Fail 的注释写着"原始错误仅用于服务端日志",
但实现里**根本没有日志语句** —— 不补的话,改判 5001 会让数据异常彻底静默,
比改之前更糟。现用标准库 log 兜底(无 trace_id、与 zap 不同轨,记 F34)。

测试(该包此前零测试的也补上):
  - infra/response 新建 5 条:PublicMsg 优先级 / 回落 Msg / Cause 不泄漏 /
    非 AppError 降级 / 仅 5xxx 记日志。此前该包**没有任何测试文件**,
    这正是"注释承诺日志、实现漏掉"长期没被发现的原因。
  - handler 新增 2 条端到端:3111 响应体带两个金额;5001 且不含"自环"等内部描述。
  - service:成环用例改判 5001 并断言 PublicMsg 为空;新增**真自环**用例
    (此前 ③ 的自环路径零覆盖 —— 删掉它只留层数兜底,整包仍绿,但生产会白跑 100 层)。

三条变异探针(均实测红后还原,核对无 MUTATION 残留):
  1. Fail 不读 PublicMsg   → handler 用例红(service 那条仍绿,它断言字段不经过渲染)
  2. 四个改判点回滚成 3111 → handler 1 条 + service 2 条全红
  3. 格式门禁:改动文件 5 个零差异;errors.go 的 49 行差异与 HEAD 完全相同(纯基线,
     Go 1.19 doc comment 重排);response.go 由 HEAD 的 7 行降到 3 行

回归(数字取自落盘输出):build / vet ✅;service 98.3s ✅;handler 105.1s ✅;
infra/response、infra/errors、apperr 全 ✅(5 包 5 PASS / 0 FAIL)。

文档:契约升 v1.11 + 变更记录;frontend-sync/api/03-divide.md 六处重写
(§0 修订行 / 端点 39 / 端点 41 / §3.2 术语表 / 5001 行 / 检查清单);
台账 F32 标记已落地、新增 F34。
Q 1 giờ trước cách đây
mục cha
commit
1e7baa7305

+ 15 - 6
internal/academic/apperr/codes.go

@@ -39,12 +39,21 @@ const (
 	CodeGiftAmountShort = 3109
 	// CodeActivityUnavailable 活动已过期或不可用(活动 status/时间窗校验)。
 	CodeActivityUnavailable = 3110
-	// CodeDivideQuotaExceeded 分账层级超限或额度超上限,**同一个码四种判据**(前端按 message 区分):
+	// CodeDivideQuotaExceeded 分账额度/层级超限,**同一个码两种业务判据**(对外文案各带具体数字):
 	//  ① 建节点时层级超**业务**上限 3 层(divide_into_add,F20 硬规则,2026-11-10);
-	//  ② 建节点时层级超**结构**上限 100 层(divide_into_add,防 BFS 不终止/爆栈,脏数据才触发);
-	//  ③ 记流水时上行链路超 100 层或成环(log_add 的 BFS 兜底);
-	//  ④ 改额度时新额度 > 父节点额度(update_quota 超上线)。
-	// ①② 与 ③ 都叫"层级",但不是同一个概念(业务规则 vs 数据结构自保),文案不得混用。
+	//  ② 改额度时新额度 > 父节点额度(update_quota 超上线)。
+	//
+	// ⚠ 2026-11-11 拍板 **A3**:原先另有两条判据(层级超**结构**上限 100 / 上行链超 100 或成环)
+	// 属**数据损坏**而非业务规则,已改判 **5001**(`infraerrors.ErrInternal`),不再走本码。
+	//
+	//	理由一:用户对数据损坏无能为力(只能找管理员),报"额度超限"会把运维引向额度,方向完全错;
+	//	理由二:前端**读不到** Cause(`response.Fail` 只取文案字段)⇒ 同码不同 Cause 在响应体里
+	//	        本来就不成立,靠 message 分流是一个从未兑现的约定(台账 F32)。
+	//
+	// 现两条 3111 一律通过 `errors.WithPublicMsg` 填写对外文案:
+	//
+	//	① → "分账层级已达上限:最多 3 层(超管 → 校区主任 → 老师),不能再添加第 N 层"
+	//	② → "分成额度不得超过上级:本次 X 元,上级额度 Y 元"
 	CodeDivideQuotaExceeded = 3111
 	// CodePayrollPlanMissing 课酬方案未配置(get_teacher_detail 无可用方案)。
 	CodePayrollPlanMissing = 3112
@@ -94,7 +103,7 @@ var (
 	ErrGiftAmountShort = infraerrors.New(CodeGiftAmountShort, "可用赠送金额不足")
 	// ErrActivityUnavailable 活动已过期或不可用(3110)。
 	ErrActivityUnavailable = infraerrors.New(CodeActivityUnavailable, "活动已过期或不可用")
-	// ErrDivideQuotaExceeded 分账层级超限或额度超上限(3111;四种判据见 CodeDivideQuotaExceeded)。
+	// ErrDivideQuotaExceeded 分账层级超限或额度超上限(3111;两种判据见 CodeDivideQuotaExceeded)。
 	ErrDivideQuotaExceeded = infraerrors.New(CodeDivideQuotaExceeded, "分账层级超限或额度超上限")
 	// ErrPayrollPlanMissing 课酬方案未配置(3112)。
 	ErrPayrollPlanMissing = infraerrors.New(CodePayrollPlanMissing, "课酬方案未配置")

+ 5 - 1
internal/academic/handler/divide_handler.go

@@ -255,7 +255,11 @@ type DivideLogReq struct {
 // CreateDivideLogRecord 记分账流水(端点 41 `POST /admin/divide-into/logs`)。
 //
 // 出参(成功):`{logged:true}`(dump 的 `log_add` 不返回 id;前端只关心"是否入账")。
-// 出参(失败):1001/2001/2002(orderNo 非数字)/3111(上行链路超 100 层或成环)/3001/5002。
+// 出参(失败):1001/2001/2002(orderNo 非数字)/5001(上行链路超 100 层或树自环 = **数据损坏**)/3001/5002。
+//
+// ⚠ 2026-11-11 拍板 A3:本端点**不再返回 3111**。此前的 3111 表示"链路成环/超长",
+// 那是数据损坏而非业务规则 —— 用户对"树坏了"无能为力,报"额度超上限"会把运维引向额度。
+// 现对外统一 5001(文案"系统繁忙"),真实原因进日志。前端**不要**给本端点写 3111 分支。
 //
 // 3111 在这里是**第 3 种语义**:不是"层级超 3 层",而是沿 `parent_user_id` 上行 BFS 时
 // 撞到结构上限(超过 100 级或成环)。撞到它说明**库里的链路本身有问题**(历史脏数据),

+ 75 - 2
internal/academic/handler/divide_handler_test.go

@@ -431,11 +431,16 @@ func TestDivideTreeRootUserIDFilter(t *testing.T) {
 	}
 }
 
-// TestDivideQuotaRejectsChildAboveParent 端点 40:**子额度 > 父额度 → 3111**(D9 有意增强)。
+// TestDivideQuotaRejectsChildAboveParent 端点 40:**子额度 > 父额度 → 3111,且响应体带两个金额**。
 //
 // 3111 判据必须精确:是「新额度 > 父额度」,**不是** `quota > 1`、也不是比率越界。
 // 量纲同样在此验证:quota 传金额字符串 `"200.00"`(父 100.00)→ 3111;
 // 若谁把 quota 当比率(<1 合法),这条会红在"该拒没拒"上。
+//
+// 2026-11-11 拍板 A3 后新增的**端到端**判据:`message` 必须带具体金额。
+// 此前 message 恒为 sentinel 的"分账层级超限或额度超上限",用户不知道超了多少 ——
+// 那是 F32 的核心问题。断言放在 handler 层(而不是只断言 service 的 PublicMsg),
+// 因为只有这里能同时证明 `WithPublicMsg → AppError.PublicMsg → response.Fail` 三段都通。
 func TestDivideQuotaRejectsChildAboveParent(t *testing.T) {
 	db := newDivideTestDB(t)
 	root, child := "01HROOTQ00000000000000001", "01HCHILDQ0000000000000001"
@@ -456,7 +461,8 @@ func TestDivideQuotaRejectsChildAboveParent(t *testing.T) {
 	}
 	defer func() { _ = resp.Body.Close() }()
 	var envelope struct {
-		Code int `json:"code"`
+		Code    int    `json:"code"`
+		Message string `json:"message"`
 	}
 	if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
 		t.Fatalf("解码失败: %v", dErr)
@@ -464,8 +470,75 @@ func TestDivideQuotaRejectsChildAboveParent(t *testing.T) {
 	if envelope.Code != apperr.CodeDivideQuotaExceeded {
 		t.Fatalf("子额度超父应 3111,实得 %d(判据必须是「新额度 > 父额度」,不是 quota>1)", envelope.Code)
 	}
+	// **对外文案带数字**(A3):这是前端契约里钉死的一句话,改文案必须同步改契约与本断言
+	wantMsg := "分成额度不得超过上级:本次 200.00 元,上级额度 100.00 元"
+	if envelope.Message != wantMsg {
+		t.Fatalf("3111 响应 message 不符:\n  期望 %q\n  实得 %q\n"+
+			"若实得 '分账层级超限或额度超上限',说明 PublicMsg 没传到这里(A3 未落地)",
+			wantMsg, envelope.Message)
+	}
 }
 
+// TestDivideLogDataCorruptionIs5001 端点 41:分账树**数据损坏**(自环)→ 5001,**不是 3111**。
+//
+// 这是 2026-11-11 拍板 A3 的端到端验收:此前自环报 3111,前端会提示"额度超上限",
+// 运维照着去查额度 —— 方向完全错(真实原因是树的数据坏了)。
+//
+// 本用例同时锁住两件事:
+//  1. 错误码是 5001(不是 3111);
+//  2. `message` 是 sentinel 默认的"系统繁忙",**不得**把"parent 指向自身"这类内部结构描述漏给前端。
+func TestDivideLogDataCorruptionIs5001(t *testing.T) {
+	db := newDivideTestDB(t)
+	loop := "01HLOOPXX0000000000000001"
+	// 真自环:parent 指向自身。正常接口建不出来(AddSubordinate 有 3140 拦截),只能人为造脏数据。
+	// ⚠ 必须先按"根"落库再改父:`seedDivideNode` 在插入**前**会查父节点,
+	//    自环时父就是自己、此刻还不存在 ⇒ 直接传 parent=self 会 fail 在"读父节点"。
+	seedDivideNode(t, db, &loop, nil, 2, 3, 2, "10.00")
+	if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", loop).
+		Updates(map[string]any{"parent_user_id": loop}).Error; err != nil {
+		t.Fatalf("造自环失败: %v", err)
+	}
+
+	app := newDivideTestApp(t, db)
+	h, _ := New(Config{DB: db})
+	app.Post(PathDivideLogs, h.CreateDivideLogRecord)
+
+	body := `{"findUserId":"` + loop + `","studentId":"01HSTUDENT0000000000000001","orderNo":"777"}`
+	req := httptest.NewRequest(http.MethodPost, "/admin/divide-into/logs", strings.NewReader(body))
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := app.Test(req, 5000)
+	if err != nil {
+		t.Fatalf("请求失败: %v", err)
+	}
+	defer func() { _ = resp.Body.Close() }()
+	var envelope struct {
+		Code    int    `json:"code"`
+		Message string `json:"message"`
+	}
+	if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
+		t.Fatalf("解码失败: %v", dErr)
+	}
+	if envelope.Code != infraerrors.CodeInternal {
+		t.Fatalf("分账树自环属数据损坏,对外应 5001,实得 %d(message=%q)\n"+
+			"若为 3111 ⇒ A3 未落地:数据损坏与业务规则共码,前端会把运维引向额度",
+			envelope.Code, envelope.Message)
+	}
+	if envelope.Code == apperr.CodeDivideQuotaExceeded {
+		t.Fatal("仍是 3111:A3 要求三个数据异常场景(结构上限/自环/链超长)全部改判 5001")
+	}
+	if envelope.Message != "系统繁忙" {
+		t.Fatalf("5001 对外文案应为 sentinel 默认值「系统繁忙」,实得 %q\n"+
+			"(数据损坏不得给 PublicMsg,否则会把内部结构描述漏给用户)", envelope.Message)
+	}
+	// 落库反证:自环应在入循环前拒绝,一条明细都不该留下
+	var logCount int64
+	if err := db.Model(&model.UserDivideIntoLog{}).Count(&logCount).Error; err != nil {
+		t.Fatalf("count 失败: %v", err)
+	}
+	if logCount != 0 {
+		t.Fatalf("自环应整体回滚,实得 %d 条明细", logCount)
+	}
+}
 // TestDivideQuotaRejectsRatioStyle 端点 40:`quota` 传比率(`0.5`)也必须被当金额接受为 **0.50 元**,
 // 而不是"50%"。本用例锁定"按金额解释"这一事实,并断言非法字符串 → 2002。
 func TestDivideQuotaRejectsRatioStyle(t *testing.T) {

+ 58 - 21
internal/academic/service/divide_service.go

@@ -33,7 +33,7 @@ import (
 	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
 )
 
-// MaxDivideDepth 是分账树的**结构上限**(根 layer=1;超限 → apperr.ErrDivideQuotaExceeded 3111)。
+// MaxDivideDepth 是分账树的**结构上限**(根 layer=1;超限 → **5001 系统异常**,不再用 3111)。
 //
 // 它的含义**不是**业务规则,而是"算法必须终止"的安全阀:AddSubordinate 要插链,
 // 撤销/入账分支还要沿 parent 链做**上行 BFS**,一旦数据成环或链路极长,
@@ -52,7 +52,15 @@ import (
 const MaxDivideDepth int32 = 100
 
 // 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(业务确认为**硬规则**,不是建议)。
 //
@@ -144,13 +152,19 @@ func NewDivideService(db *gorm.DB, firstCharged FirstChargedRecordReader) *Divid
 // 出参:
 //   - nil:成功(新建节点,或"已存在节点"分支改挂成功);
 //   - 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):入参非法。
 //
-// 层级判定严格按「先 ① 后 ②」:3 是业务规则的常规失败点(用户操作就会撞到),
-// 100 是异常数据的兜底(正常业务撞不到)。顺序反了会把"业务上不让建"错报成"数据异常"。
+// 层级判定严格按「先 3111 后 5001」:3 是业务规则的常规失败点(用户操作就会撞到),
+// 100 是异常数据的兜底(正常业务撞不到)。顺序反了会把"业务上不让建"错报成"系统异常"。
+//
+// ⚠ 为什么两种语义**用不同错误码**而不是"同码不同文案"(台账 F32,2026-11-11 拍板 A3):
+//
+//	用户对 3111 的处理是"改层级",对 5001 的处理是"找管理员"。
+//	把数据损坏包装成 3111,前端会提示"层级超限",运维会去查层级 —— 方向完全错。
 //
 // 左右值算法(Nested Set 插入最右子节点,**服务端计算**,调用方不传 left/right):
 //
@@ -193,16 +207,25 @@ func (s *DivideService) AddSubordinate(ctx context.Context, parentUserID, subUse
 		//    文案与 ② 必须可区分:① 是产品限制("你不能再往下建了"),
 		//    ② 是数据结构自保("链路长得不正常,可能是脏数据/成环")。
 		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))+
-					" 层:超管→校区主任→老师),目标层级 "+strconv.Itoa(int(nextLayer))))
+					" 层:超管→校区主任→老师),目标层级 "+strconv.Itoa(int(nextLayer))),
+				"分账层级已达上限:最多 "+strconv.Itoa(int(MaxDivideBizDepth))+
+					" 层(超管 → 校区主任 → 老师),不能再添加第 "+strconv.Itoa(int(nextLayer))+" 层")
 		}
 		// ② 结构上限(纵深防御,保留不动):防上行 BFS 不终止 / 递归爆栈。
 		//    与 ① 不是同一个概念,见 MaxDivideDepth 的注释;删掉它会让脏数据把服务拖死。
+		//
+		//    **改判 5001**(F32 / A3):正常业务永远撞不到这条,撞到即"树的数据坏了"。
+		//    报 3111"额度超上限"会把运维引向额度,方向完全错;且用户对"脏数据"无能为力。
+		//    对外只出"系统繁忙",真实原因在 Cause 里进日志(见 response.Fail 的 >=5000 分支)。
 		if nextLayer > MaxDivideDepth {
-			return infraerrors.WithCause(apperr.ErrDivideQuotaExceeded,
+			return infraerrors.WithCause(infraerrors.ErrInternal,
 				errString("service: 分账层级超结构上限 "+strconv.Itoa(int(MaxDivideDepth))+
-					",目标层级 "+strconv.Itoa(int(nextLayer))))
+					",目标层级 "+strconv.Itoa(int(nextLayer))+"(数据异常,需人工核查分账树)"))
 		}
 
 		rootID := resolveRoot(parent)
@@ -258,7 +281,7 @@ func (s *DivideService) AddSubordinate(ctx context.Context, parentUserID, subUse
 //
 // 出参:
 //   - nil:成功(含"起始用户不是树节点 → 不入账"的空操作、以及幂等命中);
-//   - ErrDivideQuotaExceeded(3111):上行层数超 100(数据成环/异常链);
+//   - ErrInternal(5001):上行层数超 100 或树自环(**数据损坏**,不再用 3111,见 F32 / A3);
 //   - ErrBusinessParam / ErrDBError;
 //   - 若节点 quota 金额被写坏(非十进制)→ ErrDBError(数据异常,不静默归零)。
 //
@@ -309,8 +332,11 @@ func (s *DivideService) AddLog(ctx context.Context, findUserID, stuID string, re
 		}
 		startID := strings.TrimSpace(*self.ParentUserID)
 		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)
@@ -323,8 +349,10 @@ func (s *DivideService) AddLog(ctx context.Context, findUserID, stuID string, re
 
 		for depth := int32(0); cur != nil; depth++ {
 			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 {
 				return err
@@ -334,8 +362,11 @@ func (s *DivideService) AddLog(ctx context.Context, findUserID, stuID string, re
 			}
 			parentID := strings.TrimSpace(*cur.ParentUserID)
 			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)
 			if errors.Is(perr, infraerrors.ErrNotFound) {
@@ -490,7 +521,8 @@ func (s *DivideService) DeleteLog(ctx context.Context, stuID string, orderID int
 // 出参:
 //   - nil:成功(含"子树内无超标节点"的空更新);
 //   - ErrNotFound(3001):节点不存在;
-//   - ErrDivideQuotaExceeded(3111):新额度**超过上级**额度(子不得超父);
+//   - ErrDivideQuotaExceeded(3111):新额度**超过上级**额度(子不得超父)。
+//     对外文案带两个金额("本次 200.00 元,上级额度 100.00 元"),见 F32 / A3;
 //   - ErrBusinessParam / ErrDBError。
 //
 // 级联规则(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
 				}
 				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) {
 				return perr

+ 98 - 9
internal/academic/service/divide_service_test.go

@@ -319,14 +319,29 @@ func TestAddSubordinateBizDepthLimit(t *testing.T) {
 		t.Fatalf("链尾 layer 应为 %d(业务上限内),实得 %d", bizDepth, got)
 	}
 
-	// 第 4 层:必须 3111,且**中文文案指向业务上限**(不能与结构上限混淆)
+	// 第 4 层:必须 3111,且**对外文案带具体层级**(A3 验收点,2026-11-11 拍板)
 	beforeTail := loadDivideNode(t, db, prev)
 	err := svc.AddSubordinate(ctx, prev, nodeID(1000))
 	if !errors.Is(err, apperr.ErrDivideQuotaExceeded) {
 		t.Fatalf("超过 %d 层应 3111,实得 %v", bizDepth, err)
 	}
-	if msg := err.Error(); !strings.Contains(msg, "业务上限") {
-		t.Fatalf("第 4 层的错误文案应指向**业务上限**,实得 %q", msg)
+	// ⚠ 断言的是 **PublicMsg**(进响应体的文案),不是 `err.Error()`。
+	// 旧断言读 `err.Error()` = Msg + ": " + Cause —— 那是日志内容,前端**看不到**;
+	// 用它断言等于"只验证了后端自己知道原因",与前端能否显示无关(F32 的成因)。
+	appErr, ok := err.(*infraerrors.AppError)
+	if !ok {
+		t.Fatalf("错误类型应为 *AppError,实得 %T", err)
+	}
+	// 下面这条文案**是前端契约的一部分**(见 frontend-sync/api/03-divide.md):
+	// 改文案必须同时改契约文档与本断言,否则前端会显示与文档不符的内容。
+	wantMsg := fmt.Sprintf("分账层级已达上限:最多 %d 层(超管 → 校区主任 → 老师),不能再添加第 %d 层",
+		bizDepth, bizDepth+1)
+	if appErr.PublicMsg != wantMsg {
+		t.Fatalf("3111 对外文案不符:\n  期望 %q\n  实得 %q", wantMsg, appErr.PublicMsg)
+	}
+	// 反证:内部诊断信息不得混进对外文案
+	if strings.Contains(appErr.PublicMsg, "service:") {
+		t.Fatalf("对外文案不得带内部前缀,实得 %q", appErr.PublicMsg)
 	}
 	// 反证:被拒后不得留下任何节点(事务回滚 + 左右值未位移)
 	if after := loadDivideNode(t, db, prev); after.RightVal != beforeTail.RightVal {
@@ -696,7 +711,10 @@ func TestAddLogNonTreeNodeNoop(t *testing.T) {
 	}
 }
 
-// TestAddLogCycleGuard 数据成环 → 迭代层数兜底 3111(不死循环、不栈溢出)。
+// TestAddLogCycleGuard 数据成环 → 迭代层数兜底 **5001**(不死循环、不栈溢出)。
+//
+// ⚠ 2026-11-11 起成环**不再报 3111**(台账 F32 / 拍板 A3):自环属数据损坏,
+// 用户无从处理,对外应表现为系统异常;报"额度超上限"会把运维引向错误方向。
 func TestAddLogCycleGuard(t *testing.T) {
 	db := newDivideServiceDB(t)
 	svc := NewDivideService(db, nil)
@@ -715,8 +733,27 @@ func TestAddLogCycleGuard(t *testing.T) {
 	}
 
 	err := svc.AddLog(ctx, a, nodeID(901), 777, "")
-	if !errors.Is(err, apperr.ErrDivideQuotaExceeded) {
-		t.Fatalf("成环应 3111(层级兜底),实得 %v", err)
+	if !errors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("成环应 5001(数据异常),实得 %v", err)
+	}
+	// 反证:不得再退化成 3111(否则等于 A3 没落地)
+	if errors.Is(err, apperr.ErrDivideQuotaExceeded) {
+		t.Fatal("成环错误仍匹配 3111:A3 要求数据损坏走 5001,不能与业务规则共码")
+	}
+	// 对外文案必须是 5001 的默认文案,**不得**把"parent 指向自身"漏给前端
+	appErr, ok := err.(*infraerrors.AppError)
+	if !ok {
+		t.Fatalf("错误类型应为 *AppError,实得 %T", err)
+	}
+	if appErr.PublicMsg != "" {
+		t.Fatalf("数据损坏不得设置对外文案(会把内部结构描述漏给用户),实得 %q", appErr.PublicMsg)
+	}
+	// 但 Cause 必须留痕(运维靠它定位)。
+	// ⚠ 本用例造的是 **a ↔ b 互指环**,不是"自己指向自己"的自环 ⇒ 命中 ④ 层数兜底,
+	// 文案是"疑似成环"而不是"自环"。真自环(③)由 TestAddLogSelfLoopIs5001 单独覆盖 ——
+	// 两条代码路径不同(③ 在循环内判 `parentID == cur.UserID`,④ 在循环头判层数)。
+	if appErr.Cause == nil || !strings.Contains(appErr.Cause.Error(), "疑似成环") {
+		t.Fatalf("Cause 应保留成环诊断信息,实得 %v", appErr.Cause)
 	}
 	// 事务回滚:环上的入账不应留下
 	if count := countLogs(t, db); count != 0 {
@@ -724,6 +761,48 @@ func TestAddLogCycleGuard(t *testing.T) {
 	}
 }
 
+// TestAddLogSelfLoopIs5001 **真自环**(节点 parent 指向自己)→ 5001,且对外不出内部结构描述。
+//
+// 与 TestAddLogCycleGuard 的区别(两条路径,别混):
+//
+//	TestAddLogCycleGuard     a ↔ b 互指 → 循环内 parentID != cur.UserID,只能靠**层数**兜底(④);
+//	本用例                   a.parent = a → 在进入循环**之前**就命中 `startID == self.UserID`(③)。
+//
+// 为什么要单独测:③ 的代码路径(F32/A3 之后改成 5001)此前**零覆盖** ——
+// 把 ③ 删掉、只留 ④ 层数兜底,整个包仍然全绿(层数兜底会接住同一个环),
+// 但生产上会白白遍历 100 次并生成 100 条明细再回滚(性能与日志噪音都放大 100 倍)。
+func TestAddLogSelfLoopIs5001(t *testing.T) {
+	db := newDivideServiceDB(t)
+	svc := NewDivideService(db, nil)
+	ctx := context.Background()
+	a := nodeID(0)
+	seedRoot(t, db, a, model.DivideTypeOneOff, "10.00")
+	// 人为造真自环:parent 指向自身(脏数据)
+	if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", a).
+		Updates(map[string]any{"parent_user_id": a}).Error; err != nil {
+		t.Fatalf("造自环失败: %v", err)
+	}
+
+	err := svc.AddLog(ctx, a, nodeID(902), 778, "")
+	if !errors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("真自环应 5001(数据异常),实得 %v", err)
+	}
+	appErr, ok := err.(*infraerrors.AppError)
+	if !ok {
+		t.Fatalf("错误类型应为 *AppError,实得 %T", err)
+	}
+	if appErr.PublicMsg != "" {
+		t.Fatalf("数据损坏不得设置对外文案,实得 %q", appErr.PublicMsg)
+	}
+	if appErr.Cause == nil || !strings.Contains(appErr.Cause.Error(), "自环") {
+		t.Fatalf("Cause 应指出是自环,实得 %v", appErr.Cause)
+	}
+	// 关键:必须在**进入循环前**就退出 ⇒ 一条明细都不该生成
+	if count := countLogs(t, db); count != 0 {
+		t.Fatalf("自环应在入循环前拒绝(不应先写 100 条再回滚),实得 %d 条", count)
+	}
+}
+
 // TestDeleteLogRollbackBranch 撤销分支②:无首次扣费记录 → 回滚 total_income 并删行。
 func TestDeleteLogRollbackBranch(t *testing.T) {
 	db := newDivideServiceDB(t)
@@ -888,9 +967,19 @@ func TestUpdateQuotaCascade(t *testing.T) {
 		}
 	}
 
-	// 子节点超额(> 父)→ 3111
-	if err := svc.UpdateQuota(ctx, b, money.FromCents(9999)); !errors.Is(err, apperr.ErrDivideQuotaExceeded) {
-		t.Fatalf("超过上级应 3111,实得 %v", err)
+	// 子节点超额(> 父)→ 3111,且**对外文案带两个金额**(A3 验收点)
+	errQuota := svc.UpdateQuota(ctx, b, money.FromCents(9999))
+	if !errors.Is(errQuota, apperr.ErrDivideQuotaExceeded) {
+		t.Fatalf("超过上级应 3111,实得 %v", errQuota)
+	}
+	if qErr, ok := errQuota.(*infraerrors.AppError); ok {
+		// 父 A 此刻额度 1000 分 = 10.00;本次 9999 分 = 99.99
+		wantQuotaMsg := "分成额度不得超过上级:本次 99.99 元,上级额度 10.00 元"
+		if qErr.PublicMsg != wantQuotaMsg {
+			t.Fatalf("3111 额度文案不符:\n  期望 %q\n  实得 %q", wantQuotaMsg, qErr.PublicMsg)
+		}
+	} else {
+		t.Fatalf("错误类型应为 *AppError,实得 %T", errQuota)
 	}
 	// 参数/缺失
 	if err := svc.UpdateQuota(ctx, b, money.FromCents(-1)); !errors.Is(err, apperr.ErrBusinessParam) {

+ 53 - 3
internal/infra/errors/errors.go

@@ -38,9 +38,23 @@ import "fmt"
 //   - 需要自定义 code/msg 时用 New(code, msg);
 //   - 需要保留底层原因时用 WithCause(sentinel, cause)。
 type AppError struct {
-	Code  int
-	Msg   string
-	Cause error
+	Code int
+	// Msg 该错误码的**默认对外文案**(来自 sentinel,全码唯一)。
+	Msg string
+	// PublicMsg 本次错误的**对外文案覆盖**(空 = 用 Msg)。
+	//
+	// 为什么需要它(而不是直接读 Cause):`Cause` 的定位是**诊断信息**——
+	// 里面会塞 SQL 片段、内部分层名、甚至 gorm 原始错误,**绝不能进响应体**。
+	// 但有一类错误(如"额度不得超过上级")必须把**具体数字**告诉操作人,
+	// 否则用户只看到"额度超限"却不知道超了多少、该填多少。
+	//
+	// 因此把"给用户看的"与"给运维看的"拆成两个字段:
+	//   - PublicMsg 由**出错点显式填写**,只放可对外的事实(数字、层级、上限值);
+	//   - Cause 仍旧只进日志(`Error()` 拼进字符串),永不进响应体。
+	//
+	// 安全红线:PublicMsg 里**禁止**放 SQL、表名、堆栈、内部路径、其它用户的金额。
+	PublicMsg string
+	Cause     error
 	// base 指向"被 WithCause 附挂底层原因的源 sentinel"(如 ErrNotFound)。
 	// 仅内部用于 Unwrap 的解包链,导出接口/JSON 均不感知。
 	base *AppError
@@ -130,6 +144,42 @@ func WithCause(base *AppError, cause error) *AppError {
 	return &AppError{Code: base.Code, Msg: base.Msg, Cause: cause, base: base}
 }
 
+// WithPublicMsg 在 WithCause 的基础上**额外**给出一条可对外展示的具体文案。
+//
+// 入参:
+//   - base *AppError:错误码来源 sentinel(nil → 降级 ErrInternal);
+//   - cause error:诊断信息(只进日志);
+//   - publicMsg string:**进响应体**的具体文案,由出错点填写可对外的事实。
+//
+// 出参:
+//   - *AppError:Code 同 base;PublicMsg 覆盖对外文案;Cause 保留诊断链。
+//
+// 使用纪律(三条,缺一就会退化成"把内部细节漏给前端"):
+//
+//	① 只在"用户需要知道具体值才能改正操作"的场景用 —— 例如"额度不得超过上级:
+//	   本次 200.00 元 > 上级额度 100.00 元"。用户看得到数字才会改;
+//	② 数据异常/内部故障**不要**用它 —— 那些场景用户无法处理,应走 5001 并留日志
+//	   (拿"分账树自环"给校区主任看没有任何意义,还会泄漏内部结构);
+//	③ publicMsg 里禁止出现 SQL、表名、堆栈、其它用户的数据。
+//
+// 样例:
+//
+//	infraerrors.WithPublicMsg(apperr.ErrDivideQuotaExceeded,
+//	    errString("service: 额度超父(本次 200.00 > 父 100.00)"),
+//	    "分成额度不得超过上级:本次 200.00 元,上级额度 100.00 元")
+func WithPublicMsg(base *AppError, cause error, publicMsg string) *AppError {
+	if base == nil {
+		return &AppError{
+			Code: ErrInternal.Code, Msg: ErrInternal.Msg,
+			PublicMsg: publicMsg, Cause: cause, base: ErrInternal,
+		}
+	}
+	return &AppError{
+		Code: base.Code, Msg: base.Msg,
+		PublicMsg: publicMsg, Cause: cause, base: base,
+	}
+}
+
 // 分段错误码常量(与 R.Code 对齐;新接口一律引用这些常量,禁止散落 magic number)。
 const (
 	CodeOK            = 200

+ 26 - 3
internal/infra/response/response.go

@@ -21,6 +21,8 @@
 package response
 
 import (
+	"log"
+
 	"yuansiwei/unified-backend/internal/infra/errors"
 
 	"github.com/gofiber/fiber/v2"
@@ -139,8 +141,10 @@ func Page(c *fiber.Ctx, list interface{}, page, pageSize int, total int64) error
 // 入参:
 //   - c *fiber.Ctx:Fiber 请求上下文。
 //   - err error:业务错误。约定:
-//     - *errors.AppError:按其 Code/Msg 原样写出(可预期业务错误,Service 层应返回此类 sentinel);
-//     - 其它 error:降级为 errors.ErrInternal(code=5001 系统繁忙),原始错误仅用于服务端日志,不进入响应体。
+//   - *errors.AppError:按其 Code 写出,文案取 **PublicMsg 优先、其次 Msg**(可预期业务错误,
+//     Service 层应返回此类 sentinel;需要告诉用户具体数字时用 `errors.WithPublicMsg`);
+//   - 其它 error:降级为 errors.ErrInternal(code=5001 系统繁忙)。
+//   - 注意:`Cause` **永不**进响应体(可能含 SQL / 内部分层名 / gorm 原始错误),只进日志。
 //
 // 出参:
 //   - error:仅写出层失败时返回。
@@ -148,6 +152,8 @@ func Page(c *fiber.Ctx, list interface{}, page, pageSize int, total int64) error
 // 异常场景:
 //   - err 为 nil:按内部错误 5001 处理(调用方逻辑错误,应在单测中暴露);
 //   - AppError.Msg 为空:兜底为"系统繁忙",防止把空文案透传给前端。
+//   - code >= 5000:额外用标准库 log 打一条(含 Cause),保证服务端故障有痕;
+//     1xxx~3xxx 不记日志(可预期业务错误,记了会淹没真问题)。
 //
 // 样例:
 //
@@ -160,7 +166,24 @@ func Fail(c *fiber.Ctx, err error) error {
 	if !ok {
 		e = errors.ErrInternal // 非业务错误(含 err==nil)一律降级 5001,细节只进日志
 	}
-	msg := e.Msg
+	// 5001/5002 等**服务端故障**必须留痕:这类错误对用户是"系统繁忙",
+	// 若不记日志,排查时既看不到错误码来源也看不到 Cause(数据异常会彻底静默)。
+	//
+	// 为什么这里用标准库 log 而不是项目里的 zap:本包是**纯响应封装**,
+	// 注入 logger 会改变 Fail 的签名并波及全部调用点;标准库 log 写到 stderr
+	// 可被容器日志采集,且零配置就有痕。接 zap 结构化日志登记为台账 F34。
+	//
+	// 只记 5xxx(服务端故障);1xxx~3xxx 是可预期业务错误,记日志会淹没真问题。
+	if e.Code >= 5000 {
+		log.Printf("[response.Fail] code=%d msg=%s cause=%v", e.Code, e.Msg, e.Cause)
+	}
+	// 对外文案:PublicMsg(出错点填写的具体事实)优先,其次 sentinel 的默认 Msg。
+	//
+	// ⚠ Cause **永不**进响应体:里面可能有 SQL 片段、内部分层名、gorm 原始错误。
+	msg := e.PublicMsg
+	if msg == "" {
+		msg = e.Msg
+	}
 	if msg == "" {
 		msg = "系统繁忙"
 	}

+ 145 - 0
internal/infra/response/response_test.go

@@ -0,0 +1,145 @@
+// response.Fail 单测 —— 统一错误出口的三条对外契约。
+//
+// 为什么本文件是新建的:`internal/infra/response` 此前**零测试**。
+// 这解释了一个长期存在的缺陷为什么没人发现——`Fail` 的注释写着
+// "原始错误仅用于服务端日志,不进入响应体",而实现里**根本没有日志语句**;
+// 注释与实现不一致,且没有任何用例覆盖这条路径。
+//
+// 本文件锁住四件事(每条都能在回滚实现后变红):
+//
+//	① 对外文案优先级:PublicMsg > Msg > "系统繁忙";
+//	② Cause **永不**进响应体(可能含 SQL / 内部分层名 / gorm 原始错误);
+//	③ 非 *AppError 一律降级 5001,不泄漏原始错误文本;
+//	④ code >= 5000 必须留日志(否则服务端故障彻底静默);1xxx~3xxx 不记(否则淹没真问题)。
+package response
+
+import (
+	"bytes"
+	"encoding/json"
+	stdErrors "errors"
+	"log"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gofiber/fiber/v2"
+
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// failOnce 用给定 error 走一次 Fail,返回解析后的 (code, message) 与原始 body。
+func failOnce(t *testing.T, err error) (code int, message, body string) {
+	t.Helper()
+	app := fiber.New(fiber.Config{DisableStartupMessage: true})
+	app.Get("/x", func(c *fiber.Ctx) error { return Fail(c, err) })
+
+	resp, terr := app.Test(httptest.NewRequest("GET", "/x", nil), 5000)
+	if terr != nil {
+		t.Fatalf("请求失败: %v", terr)
+	}
+	defer func() { _ = resp.Body.Close() }()
+	var buf bytes.Buffer
+	if _, cerr := buf.ReadFrom(resp.Body); cerr != nil {
+		t.Fatalf("读 body 失败: %v", cerr)
+	}
+	body = buf.String()
+	var envelope struct {
+		Code    int    `json:"code"`
+		Message string `json:"message"`
+	}
+	if uerr := json.Unmarshal([]byte(body), &envelope); uerr != nil {
+		t.Fatalf("解码失败: %v body=%s", uerr, body)
+	}
+	return envelope.Code, envelope.Message, body
+}
+
+// TestFailPrefersPublicMsg ① PublicMsg 优先于 sentinel 的 Msg。
+//
+// 回滚方式:把 `msg := e.PublicMsg` 改回 `msg := e.Msg` ⇒ 本用例红。
+func TestFailPrefersPublicMsg(t *testing.T) {
+	err := infraerrors.WithPublicMsg(infraerrors.ErrNotFound,
+		stdErrors.New("debug: row 42"), "找不到该学员(编号 42)")
+	code, msg, _ := failOnce(t, err)
+	if code != infraerrors.CodeNotFound {
+		t.Fatalf("code 应为 3001,实得 %d", code)
+	}
+	if msg != "找不到该学员(编号 42)" {
+		t.Fatalf("应优先使用 PublicMsg,实得 %q", msg)
+	}
+}
+
+// TestFailFallsBackToMsg ② 未给 PublicMsg 时回落到 sentinel 的 Msg。
+func TestFailFallsBackToMsg(t *testing.T) {
+	_, msg, _ := failOnce(t, infraerrors.WithCause(infraerrors.ErrNotFound,
+		stdErrors.New("debug: row 42")))
+	if msg != infraerrors.ErrNotFound.Msg {
+		t.Fatalf("应回落到 Msg %q,实得 %q", infraerrors.ErrNotFound.Msg, msg)
+	}
+}
+
+// TestFailCauseNeverLeaksToBody ③ Cause 不得出现在响应体(即使 PublicMsg 与 Msg 都为空)。
+//
+// 这是安全判据:Cause 里常有 SQL 片段、表名、gorm 原始错误。
+// 回滚方式:让 Fail 改用 `e.Error()` 作为文案 ⇒ 本用例立刻红。
+func TestFailCauseNeverLeaksToBody(t *testing.T) {
+	secret := "SELECT * FROM t_user WHERE password_hash = 'abc'"
+	err := infraerrors.WithCause(infraerrors.ErrInternal, stdErrors.New(secret))
+	code, msg, body := failOnce(t, err)
+	if code != infraerrors.CodeInternal {
+		t.Fatalf("code 应为 5001,实得 %d", code)
+	}
+	if msg != "系统繁忙" {
+		t.Fatalf("5001 对外文案应为「系统繁忙」,实得 %q", msg)
+	}
+	if strings.Contains(body, "SELECT") || strings.Contains(body, "password_hash") {
+		t.Fatalf("响应体泄漏了 Cause 内容:%s", body)
+	}
+}
+
+// TestFailNonAppErrorDegradesTo5001 ④ 非 *AppError 降级 5001,且原始文本不外泄。
+func TestFailNonAppErrorDegradesTo5001(t *testing.T) {
+	code, msg, body := failOnce(t, stdErrors.New("dial tcp 10.0.0.5:3306: connect refused"))
+	if code != infraerrors.CodeInternal {
+		t.Fatalf("非 AppError 应降级 5001,实得 %d", code)
+	}
+	if msg != "系统繁忙" {
+		t.Fatalf("降级文案应为「系统繁忙」,实得 %q", msg)
+	}
+	if strings.Contains(body, "3306") || strings.Contains(body, "10.0.0.5") {
+		t.Fatalf("响应体泄漏了内部地址:%s", body)
+	}
+}
+
+// TestFailLogsServerFaultsOnly ⑤ code>=5000 记日志,1xxx~3xxx 不记。
+//
+// 为什么这条重要:5001 对用户只有"系统繁忙",若不留日志,数据异常(如分账树自环)
+// 会彻底静默——用户报障时既看不到码也看不到原因。反过来,业务错误(3001 等)
+// 是**预期内**的,逐个记日志会把真问题淹没。
+func TestFailLogsServerFaultsOnly(t *testing.T) {
+	var buf bytes.Buffer
+	oldOut, oldFlags := log.Writer(), log.Flags()
+	log.SetOutput(&buf)
+	log.SetFlags(0)
+	defer func() { log.SetOutput(oldOut); log.SetFlags(oldFlags) }()
+
+	// 5001:必须留痕,且日志里要能看到 Cause
+	buf.Reset()
+	failOnce(t, infraerrors.WithCause(infraerrors.ErrInternal, stdErrors.New("分账树自环(需人工修树)")))
+	logged := buf.String()
+	if logged == "" {
+		t.Fatal("5001 未记日志:服务端故障会彻底静默,排查时无从下手")
+	}
+	if !strings.Contains(logged, "分账树自环") {
+		t.Fatalf("日志应包含 Cause 以便定位,实得 %q", logged)
+	}
+	if strings.Contains(logged, "系统繁忙") {
+		t.Logf("提示:日志同时含对外文案是正常的(便于把用户报的文案对应到日志)")
+	}
+
+	// 3001:业务错误,不得记日志
+	buf.Reset()
+	failOnce(t, infraerrors.WithCause(infraerrors.ErrNotFound, stdErrors.New("no rows")))
+	if got := buf.String(); got != "" {
+		t.Fatalf("业务错误(3001)不应记日志(会淹没真问题),实得 %q", got)
+	}
+}