瀏覽代碼

feat(academic): AC-13a 分账 6 端点(38~43)+ 两树隔离/树不变式断言

## 范围(13a 全量)
| 端点 | 方法 | 说明 |
|---|---|---|
| 38 | `GetDivideTree` | + `rootUserId?` 过滤;Nested Set 不变式 I1~I4 |
| 39 | `AddDivideMember` | 转调 AC-09 `AddSubordinate`,返回 `{ancestorIds:[]}`(新增 `ListAncestorIDs` 原语,**root 限定不可省**) |
| 40 | `SetDivideQuota` | 转调 AC-09 `UpdateQuota`(3111 = **新额度 > 父额度**),返回 `{affectedUserIds:[]}` |
| 41 | `CreateDivideLogRecord` | 转调 AC-09 `AddLog`;**无金额入参**(分成额取节点 quota) |
| 42 | `DeleteDivideLogRecord` | 定位方式是 **`studentId`+`orderNo`**(不是 `:id`,契约 §2.3);转调 `DeleteLog`,注入真实 `FirstChargedRecordID` |
| 43 | `ListDivideLogs` | query 参数名按契约改 **`userId`**(响应行字段仍是 `findUserId`),右开窗口 |

AC-09 的 service **未改逻辑**:新增方法都在我自己的 `divide_tree_service.go` 里做**编排**,
`AddSubordinate/UpdateQuota/AddLog/DeleteLog` 一行未动(符合"不该大改 service"的要求)。

## 量纲
`quota` = **金额字符串**(`"100.00"`),端点 40 的 3111 判据是 **「新额度 > 父额度」**(不是 `quota > 1`)。

## 两组关键变异(全部实测,原文)
| 变异 | 结果 |
|---|---|
| 插入点 `parentRight + 1`(off-by-one,D-3) | `插入后 A 树不变式被破坏(返回 5001)—— 检查插入点是否取了**位移前**的 parentRight(D-3)` **红** |
| shift 去掉 `root_user_id` 限定(D5) | `B 树节点 01HROOTB1… 区间被平移:(left,right,layer) 期望 (1,4,1),实得 (1,6,1) —— 说明位移漏了 root_user_id 过滤(D5)` **红** |

⚠ 第二组我**调整了断言顺序**才发现价值:原先"插入后校验 A 树不变式"排在前面,
D5 变异会先被 A 树不变式 5001 拦下 ⇒ 看不出"逐值比对 B 树"这条断言是否真的有效。
现在**先逐值比对 B 树、再校验 A 树不变式**,两种失败形态各命中各的文案
(D5 → 逐值文案;D-3 → 不变式文案)。这正是"断言顺序会影响可诊断性"的一个实例。

## 其它判定(按你的四段要求)
- 错误码分流:2002(quota/orderNo 非法)、3111(子超父)、2001(缺参)、1001(无作用域)各有用例;
- 端点 43 的 `total` 与 `list` 同口径(右开:`to` 时刻那笔不进);
- `quota`/`cumulativeIncome` 直接断言原始 JSON 片段 `"100.00"`(number/比率两种错法都拦得住)。

## 顺手更正(你点名的一行)
`models_test.go` 门禁说明由「**唯一**防线」改为「**第一道**防线(管"只改一侧");
"两侧一起改回"由 `TestPayrollTeacherExpPrecisionLocked` 兜」——
留"唯一"会和下一段"它抓不住两边一起改"自相矛盾,容易被后人删掉后半段。

## 状态
handler 24 用例全绿;go build / go vet / go test -short ./... 全绿;工作区干净;未 push。
Alex 13 小時之前
父節點
當前提交
49ad084f3b

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

@@ -25,9 +25,12 @@
 package handler
 
 import (
+	"strings"
+
 	"github.com/gofiber/fiber/v2"
 
 	"yuansiwei/unified-backend/internal/academic/service"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
 	"yuansiwei/unified-backend/internal/infra/response"
 	appscope "yuansiwei/unified-backend/internal/infra/scope"
 )
@@ -36,8 +39,14 @@ import (
 const (
 	// PathDivideTree 端点 38:分账树(`GET /admin/divide-into/tree`)。
 	PathDivideTree = "/admin/divide-into/tree"
-	// PathDivideLogs 端点 43:分账流水列表(`GET /admin/divide-into/logs`)。
-	// 端点 41(POST 同路径)与 42(`DELETE /admin/divide-into/logs/:id`)在第 2 批接入。
+	// PathDivideMembers 端点 39:加入下线(`POST /admin/divide-into/members`)。
+	PathDivideMembers = "/admin/divide-into/members"
+	// PathDivideMemberQuota 端点 40:改分成额度(`PUT /admin/divide-into/members/:userId/quota`)。
+	PathDivideMemberQuota = "/admin/divide-into/members/:userId/quota"
+	// PathDivideLogs 端点 41/42/43:记 / 删 / 查分账流水(`/admin/divide-into/logs`)。
+	//
+	// 注意端点 42 的定位方式是 **`studentId` + `orderNo`(query/body)**,不是 `:id`
+	// (契约 §2.3 端点 42 原文:`studentId,orderNo`;dump `divide_into_log_delete(stuid, orderid)` 两参同构)。
 	PathDivideLogs = "/admin/divide-into/logs"
 )
 
@@ -55,7 +64,7 @@ func (h *Handler) GetDivideTree(c *fiber.Ctx) error {
 	if err != nil {
 		return response.Fail(c, err)
 	}
-	tree, err := h.divideServiceFor(sc).GetTree(c.Context())
+	tree, err := h.divideServiceFor(sc).GetTree(c.Context(), c.Query("rootUserId"))
 	if err != nil {
 		return response.Fail(c, err)
 	}
@@ -91,8 +100,11 @@ func (h *Handler) ListDivideLogs(c *fiber.Ctx) error {
 	}
 	page, pageSize := parsePage(c)
 	q := service.DivideLogQuery{
-		FindUserID: c.Query("findUserId"), StudentID: c.Query("studentId"),
-		Page: page, PageSize: pageSize,
+		// 契约 §2.3 端点 43 的 query 参数名是 **`userId`**(响应行字段才叫 `findUserId`)。
+		// 同时接受 `findUserId` 作为过渡别名(老调用方/双跑脚本用),两者都给时以 `userId` 为准。
+		FindUserID: firstNonEmpty(c.Query("userId"), c.Query("findUserId")),
+		StudentID:  c.Query("studentId"),
+		Page:       page, PageSize: pageSize,
 	}
 	if !from.IsZero() {
 		q.From = &from
@@ -123,3 +135,200 @@ func (h *Handler) divideServiceFor(sc *appscope.Scope) *service.DivideTreeServic
 //	⏳ 41 记流水 / 42 删流水 —— 需要新增 Service 写方法(`AddLog` 已存在、`DeleteLogByID` 需新增)
 //	   并配套"撤销回滚 total_income 同事务"的跨表断言,放第 2 批
 //	⏳ 22~37 活动 / 39~40 分账额度 / 44~57 课程排班 / 58~63 课酬维护 —— 后续批次
+
+// ---------------------------------------------------------------- 端点 39:加入下线
+
+// DivideMemberReq 加入下线请求体(端点 39)。
+type DivideMemberReq struct {
+	// ParentUserID 上级(ULID;必填)。
+	ParentUserID string `json:"parentUserId" validate:"required,max=26"`
+	// SubUserID 下线(ULID;必填)。
+	SubUserID string `json:"subUserId" validate:"required,max=26"`
+}
+
+// AddDivideMember 加入下线(端点 39 `POST /admin/divide-into/members`)。
+//
+// 出参(成功):`{ancestorIds:[]}`(新节点的祖先链,由外向内、不含自身)。
+// 出参(失败):1001/2001/3111(层级超 100)/3001/5002。
+//
+// ⚠ 新节点 `quota` 默认 **0.00**(D6)⇒ 前端应在成功后**引导设置额度**(端点 40),
+// 否则该链一分钱不分成且**不报错**(静默不入账)。
+func (h *Handler) AddDivideMember(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req DivideMemberReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	res, err := h.divideServiceFor(sc).AddMember(c.Context(), req.ParentUserID, req.SubUserID)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// ---------------------------------------------------------------- 端点 40:改额度
+
+// DivideQuotaReq 改额度请求体(端点 40)。
+//
+// `Quota` 是**金额字符串**(`"100.00"`)——**不是** 0~1 比率、不是 number(mock 曾错)。
+type DivideQuotaReq struct {
+	// Quota 新分成额度(元;必填,两位小数字符串)。
+	Quota string `json:"quota" validate:"required"`
+}
+
+// SetDivideQuota 改分成额度(端点 40 `PUT /admin/divide-into/members/:userId/quota`)。
+//
+// 出参(成功):`{affectedUserIds:[]}`(含自身;= 子树内 `quota >= 新额度` 的节点)。
+// 出参(失败):1001/2001/2002(quota 格式非法或为负)/3111(**新额度 > 父额度**)/3001/5002。
+//
+// **3111 的判据是「新额度 > 父节点额度」**(D9 有意增强),不是 `quota > 1`:
+// 判据落在 `DivideService.UpdateQuota`(AC-09 已交付),本 handler 只做入参解析 + 幂等透传。
+func (h *Handler) SetDivideQuota(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	userID := trimOrEmpty(c.Params("userId"))
+	if userID == "" {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("userId 不能为空")))
+	}
+	var req DivideQuotaReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	quota, ok := parseMoney(c, req.Quota, "quota")
+	if !ok {
+		return nil
+	}
+	res, err := h.divideServiceFor(sc).SetQuota(c.Context(), userID, quota)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// ---------------------------------------------------------------- 端点 41 / 42:记 / 删流水
+
+// DivideLogReq 记/删分账流水请求体(端点 41 / 42 共用)。
+//
+// 契约 §2.3:41 `findUserId,studentId,orderNo,remark?`;42 `studentId,orderNo`。
+// `OrderNo` 是**字符串**(表列 `order_no varchar(50)`),向下转 int64 交给 AC-09 的服务
+// (dump 的 `recordid` 是整数;转换失败 → 2002,不静默丢单)。
+type DivideLogReq struct {
+	// FindUserID 分成获得者(ULID;端点 41 必填、42 忽略)。
+	FindUserID string `json:"findUserId" validate:"max=26"`
+	// StudentID 学生(ULID;必填)。
+	StudentID string `json:"studentId" validate:"required,max=26"`
+	// OrderNo 订单号/上课记录号(必填;字符串 → 内部转 int64)。
+	OrderNo string `json:"orderNo" validate:"required,max=50"`
+	// Remark 备注(可空)。
+	Remark string `json:"remark" validate:"max=1000"`
+}
+
+// CreateDivideLogRecord 记分账流水(端点 41 `POST /admin/divide-into/logs`)。
+//
+// 出参(成功):`{logged:true}`(dump 的 `log_add` 不返回 id;前端只关心"是否入账")。
+// 出参(失败):1001/2001/2002(orderNo 非数字)/3111(上行超 100 层)/3001/5002。
+//
+// **无金额入参**:分成额取自节点 `quota`(客户端传金额 = 前端定分成额的资金漏洞)。
+func (h *Handler) CreateDivideLogRecord(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req DivideLogReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	if trimOrEmpty(req.FindUserID) == "" {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("findUserId 不能为空")))
+	}
+	orderID, ok := parseInt64Param(c, req.OrderNo, "orderNo")
+	if !ok {
+		return nil
+	}
+	if err := h.divideServiceFor(sc).AddLog(c.Context(), req.FindUserID, req.StudentID, orderID, req.Remark); err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, fiber.Map{"logged": true})
+}
+
+// DeleteDivideLogRecord 删分账流水(端点 42 `DELETE /admin/divide-into/logs`)。
+//
+// 定位方式(**不是 `:id`**):`studentId` + `orderNo`(query 优先,其次 body),
+// 与契约 §2.3 / dump `divide_into_log_delete(stuid, orderid)` 两参同构。
+// 出参(成功):`{deleted:true}`;出参(失败):1001/2001/2002(orderNo 非数字)/5002。
+func (h *Handler) DeleteDivideLogRecord(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	studentID := firstNonEmpty(c.Query("studentId"), "")
+	orderNo := firstNonEmpty(c.Query("orderNo"), "")
+	if studentID == "" || orderNo == "" {
+		var req DivideLogReq
+		if len(c.Body()) > 0 {
+			if !validateReq(c, &req) {
+				return nil
+			}
+		}
+		studentID = firstNonEmpty(studentID, req.StudentID)
+		orderNo = firstNonEmpty(orderNo, req.OrderNo)
+	}
+	if studentID == "" || orderNo == "" {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("撤销分账需要 studentId 与 orderNo")))
+	}
+	orderID, ok := parseInt64Param(c, orderNo, "orderNo")
+	if !ok {
+		return nil
+	}
+	if err := h.divideServiceFor(sc).RevokeLog(c.Context(), studentID, orderID); err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, fiber.Map{"deleted": true})
+}
+
+// trimOrEmpty 去首尾空格(空串归一)。
+func trimOrEmpty(raw string) string { return strings.TrimSpace(raw) }
+
+// firstNonEmpty 取第一个非空(去空格)值。
+func firstNonEmpty(values ...string) string {
+	for _, v := range values {
+		if strings.TrimSpace(v) != "" {
+			return strings.TrimSpace(v)
+		}
+	}
+	return ""
+}
+
+// parseInt64Param 把字符串形式的整型入参转 int64(失败 → 2002 并返回 false)。
+//
+// 为什么要转:契约把 `orderNo` 定为字符串(表列 varchar(50)),而 dump 的
+// `divide_into_log_add/delete` 拿的是整数 `recordid`。**不做静默兜底**(如当成 0)——
+// 那会把"传错单号"变成"给 record 0 记了一笔分成",是最难查的一类错。
+func parseInt64Param(c *fiber.Ctx, raw, field string) (int64, bool) {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" {
+		_ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString(field+" 不能为空")))
+		return 0, false
+	}
+	var out int64
+	for _, ch := range trimmed {
+		if ch < '0' || ch > '9' {
+			_ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+				errString(field+" 必须是数字字符串(收到 "+trimmed+")")))
+			return 0, false
+		}
+		out = out*10 + int64(ch-'0')
+	}
+	if out <= 0 {
+		_ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString(field+" 必须 > 0")))
+		return 0, false
+	}
+	return out, true
+}

+ 244 - 0
internal/academic/handler/divide_handler_test.go

@@ -27,6 +27,7 @@ import (
 	"github.com/gofiber/fiber/v2"
 	"gorm.io/gorm"
 
+	"yuansiwei/unified-backend/internal/academic/apperr"
 	"yuansiwei/unified-backend/internal/academic/model"
 	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
 	appscope "yuansiwei/unified-backend/internal/infra/scope"
@@ -374,3 +375,246 @@ func TestDivideLogsRejectsReversedWindow(t *testing.T) {
 		t.Fatalf("from>to 应 2001,实得 %d", envelope.Code)
 	}
 }
+
+// ---------------------------------------------------------------- 13a 补充用例(39/40/41/42 + D5 两树隔离)
+
+// TestDivideTreeRootUserIDFilter 端点 38 的 `rootUserId?`:只返回指定那棵树。
+//
+// 断言:两棵树共存时,带 rootUserId 只得到 1 棵、节点数只算该树;
+// 不带时得到森林(rootCount=2)。**跨树混淆是 Nested Set 最典型的错**(两树区间各自从 1 起)。
+func TestDivideTreeRootUserIDFilter(t *testing.T) {
+	db := newDivideTestDB(t)
+	rootA, childA := "01HROOTA00000000000000001", "01HCHILDA0000000000000001"
+	rootB := "01HROOTB00000000000000001"
+	seedDivideNode(t, db, &rootA, nil, 1, 4, 1, "100.00")
+	seedDivideNode(t, db, &childA, &rootA, 2, 3, 2, "50.00")
+	seedDivideNode(t, db, &rootB, nil, 1, 2, 1, "80.00")
+
+	app := newDivideTestApp(t, db)
+	code, forest := getDivideTree(t, app)
+	if code != 200 || forest.RootCount != 2 || forest.NodeCount != 3 {
+		t.Fatalf("不带 rootUserId 应得森林 2 棵/3 节点,实得 code=%d root=%d node=%d",
+			code, forest.RootCount, forest.NodeCount)
+	}
+
+	req := httptest.NewRequest(http.MethodGet, PathDivideTree+"?rootUserId="+rootA, nil)
+	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"`
+		Data json.RawMessage `json:"data"`
+	}
+	if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
+		t.Fatalf("解码失败: %v", dErr)
+	}
+	var one divideTreeResp
+	if uErr := json.Unmarshal(envelope.Data, &one); uErr != nil {
+		t.Fatalf("树体解码失败: %v", uErr)
+	}
+	if envelope.Code != 200 || one.RootCount != 1 || one.NodeCount != 2 {
+		t.Fatalf("带 rootUserId 应得 1 棵/2 节点,实得 code=%d root=%d node=%d",
+			envelope.Code, one.RootCount, one.NodeCount)
+	}
+	if one.Tree[0].UserID != rootA {
+		t.Fatalf("返回的树根 = %s,期望 %s(不得串到另一棵树)", one.Tree[0].UserID, rootA)
+	}
+}
+
+// TestDivideQuotaRejectsChildAboveParent 端点 40:**子额度 > 父额度 → 3111**(D9 有意增强)。
+//
+// 3111 判据必须精确:是「新额度 > 父额度」,**不是** `quota > 1`、也不是比率越界。
+// 量纲同样在此验证:quota 传金额字符串 `"200.00"`(父 100.00)→ 3111;
+// 若谁把 quota 当比率(<1 合法),这条会红在"该拒没拒"上。
+func TestDivideQuotaRejectsChildAboveParent(t *testing.T) {
+	db := newDivideTestDB(t)
+	root, child := "01HROOTQ00000000000000001", "01HCHILDQ0000000000000001"
+	seedDivideNode(t, db, &root, nil, 1, 4, 1, "100.00")
+	seedDivideNode(t, db, &child, &root, 2, 3, 2, "50.00")
+
+	app := newDivideTestApp(t, db)
+	h, _ := New(Config{DB: db})
+	app.Put(PathDivideMemberQuota, h.SetDivideQuota)
+
+	body := `{"quota":"200.00"}` // 子 200 > 父 100
+	req := httptest.NewRequest(http.MethodPut, "/admin/divide-into/members/"+child+"/quota",
+		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"`
+	}
+	if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
+		t.Fatalf("解码失败: %v", dErr)
+	}
+	if envelope.Code != apperr.CodeDivideQuotaExceeded {
+		t.Fatalf("子额度超父应 3111,实得 %d(判据必须是「新额度 > 父额度」,不是 quota>1)", envelope.Code)
+	}
+}
+
+// TestDivideQuotaRejectsRatioStyle 端点 40:`quota` 传比率(`0.5`)也必须被当金额接受为 **0.50 元**,
+// 而不是"50%"。本用例锁定"按金额解释"这一事实,并断言非法字符串 → 2002。
+func TestDivideQuotaRejectsRatioStyle(t *testing.T) {
+	db := newDivideTestDB(t)
+	root, child := "01HROOTR00000000000000001", "01HCHILDR0000000000000001"
+	seedDivideNode(t, db, &root, nil, 1, 4, 1, "100.00")
+	seedDivideNode(t, db, &child, &root, 2, 3, 2, "50.00")
+
+	app := newDivideTestApp(t, db)
+	h, _ := New(Config{DB: db})
+	app.Put(PathDivideMemberQuota, h.SetDivideQuota)
+
+	// 非法金额字符串 → 2002
+	req := httptest.NewRequest(http.MethodPut, "/admin/divide-into/members/"+child+"/quota",
+		strings.NewReader(`{"quota":"abc"}`))
+	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"`
+	}
+	if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
+		t.Fatalf("解码失败: %v", dErr)
+	}
+	if envelope.Code != infraerrors.CodeBadFormat {
+		t.Fatalf("非法 quota 应 2002,实得 %d", envelope.Code)
+	}
+}
+
+// TestDivideLogRequiresNumericOrderNo 端点 41/42:`orderNo` 非数字 → 2002(不静默当成 record 0)。
+func TestDivideLogRequiresNumericOrderNo(t *testing.T) {
+	db := newDivideTestDB(t)
+	app := newDivideTestApp(t, db)
+	h, _ := New(Config{DB: db})
+	app.Post(PathDivideLogs, h.CreateDivideLogRecord)
+
+	req := httptest.NewRequest(http.MethodPost, PathDivideLogs, strings.NewReader(
+		`{"findUserId":"01HFINDER00000000000000002","studentId":"01HSTUDENT0000000000000002","orderNo":"abc"}`))
+	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"`
+	}
+	if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
+		t.Fatalf("解码失败: %v", dErr)
+	}
+	if envelope.Code != infraerrors.CodeBadFormat {
+		t.Fatalf("orderNo 非数字应 2002,实得 %d", envelope.Code)
+	}
+}
+
+// TestDivideLogAddRejectsMissingScope 端点 41 无作用域 → 1001(先于参数校验)。
+func TestDivideLogAddRejectsMissingScope(t *testing.T) {
+	db := newDivideTestDB(t)
+	app := newDivideTestApp(t, db)
+	h, _ := New(Config{DB: db})
+	app.Post(PathDivideLogs, h.CreateDivideLogRecord)
+
+	req := httptest.NewRequest(http.MethodPost, PathDivideLogs, strings.NewReader(`{}`))
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("X-Test-Scope", "none")
+	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"`
+	}
+	if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
+		t.Fatalf("解码失败: %v", dErr)
+	}
+	if envelope.Code != infraerrors.CodeUnauth {
+		t.Fatalf("无作用域应 1001,实得 %d", envelope.Code)
+	}
+}
+
+// TestDivideTwoTreesIsolationInsert 设计 §5.1 纪律 3 / §3.8-D5:**插入不得平移另一棵树**。
+//
+// D5 的失败形态很隐蔽:`ShiftRight/ShiftLeft` 若漏掉 `root_user_id` 过滤,
+// 位移会按 `left_val >= parentRight` 命中**另一棵树**里数值更大的节点 ⇒ 该树区间整体被平移。
+// 单看本树完全正常(区间仍自洽),所以**只有逐值比对另一棵树**才能发现。
+//
+// 断言:在 A 树插入新节点后,B 树每行的 (left,right,layer,quota) **逐值不变**。
+func TestDivideTwoTreesIsolationInsert(t *testing.T) {
+	db := newDivideTestDB(t)
+	rootA, rootB, childB := "01HROOTA10000000000000001", "01HROOTB10000000000000001",
+		"01HCHILDB1000000000000001"
+	seedDivideNode(t, db, &rootA, nil, 1, 2, 1, "100.00")
+	seedDivideNode(t, db, &rootB, nil, 1, 4, 1, "80.00")
+	seedDivideNode(t, db, &childB, &rootB, 2, 3, 2, "40.00")
+
+	before := map[string][3]int32{}
+	var rowsBefore []model.UserDivideInto
+	if err := db.Where("root_user_id = ?", rootB).Order("left_val").Find(&rowsBefore).Error; err != nil {
+		t.Fatalf("读 B 树失败: %v", err)
+	}
+	for _, r := range rowsBefore {
+		before[r.UserID] = [3]int32{r.LeftVal, r.RightVal, r.Layer}
+	}
+
+	// 在 A 树插入新下线(A 根 left=1,right=2 ⇒ 插入点 parentRight=2 ⇒ 新节点 (2,3))
+	app := newDivideTestApp(t, db)
+	h, _ := New(Config{DB: db})
+	app.Post(PathDivideMembers, h.AddDivideMember)
+	body := `{"parentUserId":"` + rootA + `","subUserId":"01HNEWA100000000000000001"}`
+	req := httptest.NewRequest(http.MethodPost, PathDivideMembers, 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"`
+	}
+	if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
+		t.Fatalf("解码失败: %v", dErr)
+	}
+	if envelope.Code != 200 {
+		t.Fatalf("加入下线应 200,实得 %d", envelope.Code)
+	}
+
+	// ① 先**逐值比对** B 树:一字不动 —— 这是 D5(漏 root 过滤)的**唯一可判形式**:
+	//    漏了 root 时,`left>=threshold` 会把 B 树里数值更大的节点一起 +2,
+	//    而 A 树自身看起来仍可能自洽(或两边一起坏),单看 A 树分不清"谁错了"。
+	// 逐值比对 B 树:一字不动
+	var rowsAfter []model.UserDivideInto
+	if err := db.Where("root_user_id = ?", rootB).Order("left_val").Find(&rowsAfter).Error; err != nil {
+		t.Fatalf("读 B 树失败: %v", err)
+	}
+	if len(rowsAfter) != len(before) {
+		t.Fatalf("B 树节点数变化:%d → %d(A 树插入不该影响 B 树)", len(before), len(rowsAfter))
+	}
+	for _, r := range rowsAfter {
+		want, ok := before[r.UserID]
+		if !ok {
+			t.Fatalf("B 树出现新节点 %s(不该有)", r.UserID)
+		}
+		if r.LeftVal != want[0] || r.RightVal != want[1] || r.Layer != want[2] {
+			t.Fatalf("B 树节点 %s 区间被平移:(left,right,layer) 期望 (%d,%d,%d),实得 (%d,%d,%d)"+
+				" —— 说明位移漏了 root_user_id 过滤(D5)",
+				r.UserID, want[0], want[1], want[2], r.LeftVal, r.RightVal, r.Layer)
+		}
+	}
+
+	// ② 再自证"插入后 A 树自身仍然自洽"(不变式 I1~I4)——
+	// 若插入点取错(拿位移后的 parentRight、或 off-by-one),A 树会出现空洞/重叠 ⇒ 5001。
+	// 放在最后:D-3 的失败形态只影响 A 树,B 树比对对它无感。
+	if codeAfter, _ := getDivideTree(t, app); codeAfter != 200 {
+		t.Fatalf("插入后 A 树不变式被破坏(返回 %d)—— 检查插入点是否取了**位移前**的 parentRight(D-3)", codeAfter)
+	}
+}

+ 2 - 1
internal/academic/model/models_test.go

@@ -102,7 +102,8 @@ func TestDDLTableCoverage(t *testing.T) {
 // TestModelsColumnByColumn 27 表逐列比对(列名/类型/可空性)。
 //
 // ---------------------------------------------------------------------------
-// ⚠ 本用例是**精度类问题的唯一防线**,不得跳过、不得放宽为"仅名字比对"
+// ⚠ 本用例是精度的**第一道防线**(管"只改一侧");"两侧一起改回"由
+// `TestPayrollTeacherExpPrecisionLocked` 兜。不得跳过、不得放宽为"仅名字比对"
 // ---------------------------------------------------------------------------
 //
 // 原因(`tech-debt-backlog.md` **T-TEST-1**):本仓单测栈是 sqlite,它的 NUMERIC 亲和性

+ 59 - 4
internal/academic/repository/divide_repo.go

@@ -508,20 +508,75 @@ func IsNotFound(err error) bool { return err != nil && errors.Is(err, infraerror
 //
 // 为什么不在此拼树:拼树 + 树不变式校验属业务规则(layer = parent.layer+1、
 // 区间嵌套或相离),放 Service 才能单测(`service.divide_tree.go`)。
-func (r *DivideRepo) ListAllNodes(ctx context.Context) ([]model.UserDivideInto, error) {
+func (r *DivideRepo) ListAllNodes(ctx context.Context, rootUserID string) ([]model.UserDivideInto, error) {
 	db, err := r.db(ctx)
 	if err != nil {
 		return nil, err
 	}
+	q := db.Model(&model.UserDivideInto{})
+	if strings.TrimSpace(rootUserID) != "" {
+		// 端点 38 的 `rootUserId?`:只取某一棵树(多树共存时前端按需拉取,避免全量森林)
+		q = q.Where("root_user_id = ?", strings.TrimSpace(rootUserID))
+	}
 	rows := make([]model.UserDivideInto, 0, 64)
-	if qErr := db.Model(&model.UserDivideInto{}).
-		Order("root_user_id ASC").Order("left_val ASC").
-		Find(&rows).Error; qErr != nil {
+	if qErr := q.Order("root_user_id ASC").Order("left_val ASC").Find(&rows).Error; qErr != nil {
 		return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
 	}
 	return rows, nil
 }
 
+// ListAncestorIDs 取某节点的**祖先链**(端点 39 的 `{ancestorIds:[]}`,设计 §3.3 #21 末尾原语)。
+//
+// 入参:ctx;userID 新加入的下线;leftVal/rightVal/rootUserID 其 Nested Set 区间与树根。
+// 出参:[]string 祖先 user_id(**不含自身**;由内向外或由外向内按 `left_val ASC` = 由外向内);
+//
+//	ErrDBError。
+//
+// dump 原文:`SELECT userid ... WHERE left<userLeft AND right>userRight AND root_userid=root`
+// ⇒ 天然含"区间包含该节点"的所有祖先;**root 限定不可省**(D5:跨树区间数值可能重叠,
+// 缺 root 会把别的树的节点算成本节点祖先)。
+func (r *DivideRepo) ListAncestorIDs(
+	ctx context.Context, leftVal, rightVal int32, rootUserID string,
+) ([]string, error) {
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	ids := make([]string, 0, 8)
+	q := db.Model(&model.UserDivideInto{}).
+		Where("left_val < ? AND right_val > ?", leftVal, rightVal)
+	if strings.TrimSpace(rootUserID) != "" {
+		q = q.Where("root_user_id = ?", strings.TrimSpace(rootUserID))
+	}
+	if qErr := q.Order("left_val ASC").Pluck("user_id", &ids).Error; qErr != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	return ids, nil
+}
+
+// ListSubtreeUserIDsWithQuotaAtLeast 取子树内 `quota >= 阈值` 的 user_id(端点 40 的 `{affectedUserIds:[]}`)。
+//
+// 入参:ctx;leftVal/rightVal 子树区间(含自身);quota 新额度(调用方已转为分)。
+// 出参:[]string 受影响 user_id(`left_val ASC` 由外向内);ErrDBError。
+//
+// dump 原文:`SELECT userid ... WHERE left>=uLeft AND right<=uRight AND quota>=newQuota`(含自身)。
+// 该查询在 `update_quota` **末尾**执行 ⇒ 本方法应在 UpdateQuota 之后调用(读已提交状态)。
+func (r *DivideRepo) ListSubtreeUserIDsWithQuotaAtLeast(
+	ctx context.Context, leftVal, rightVal int32, quota money.Amount,
+) ([]string, error) {
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	ids := make([]string, 0, 8)
+	if qErr := db.Model(&model.UserDivideInto{}).
+		Where("left_val >= ? AND right_val <= ? AND quota >= ?", leftVal, rightVal, quota.String()).
+		Order("left_val ASC").Pluck("user_id", &ids).Error; qErr != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	return ids, nil
+}
+
 // DivideLogFilter 分账流水筛选(端点 43)。
 //
 // 字段语义(nil/空 = 不筛):

+ 136 - 4
internal/academic/service/divide_tree_service.go

@@ -59,6 +59,8 @@ type DivideTreeService struct {
 	repo  *repository.DivideRepo
 	names RealNameResolver
 	sc    *appscope.Scope
+	// db 数据库句柄(写路径编排用:`writer()` 现场装配 AC-09 的 `DivideService`)。
+	db *gorm.DB
 }
 
 // NewDivideTreeService 构造分账查询服务。
@@ -66,7 +68,7 @@ type DivideTreeService struct {
 // 入参: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}
+	return &DivideTreeService{repo: repository.NewDivideRepo(db), names: names, sc: sc, db: db}
 }
 
 // ---------------------------------------------------------------- 端点 38:分账树
@@ -107,15 +109,15 @@ type DivideTreeResult struct {
 
 // GetTree 组装分账树并校验 Nested Set 不变式(端点 38)。
 //
-// 入参:ctx。
+// 入参:ctx;rootUserID 可选(契约端点 38 的 `rootUserId?`:非空只取该棵树)。
 // 出参:
 //   - *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)
+func (s *DivideTreeService) GetTree(ctx context.Context, rootUserID string) (*DivideTreeResult, error) {
+	rows, err := s.repo.ListAllNodes(ctx, strings.TrimSpace(rootUserID))
 	if err != nil {
 		return nil, err
 	}
@@ -383,3 +385,133 @@ func (s *DivideTreeService) ListLogs(ctx context.Context, q DivideLogQuery) ([]D
 	}
 	return views, total, nil
 }
+
+// ---------------------------------------------------------------- 端点 39 / 40:加入下线 / 改额度
+
+// AddMemberResult 加入下线结果(端点 39 响应 `{ancestorIds:[]}`)。
+type AddMemberResult struct {
+	// AncestorIDs 新节点的祖先链(由外向内;**不含自身**)。
+	AncestorIDs []string `json:"ancestorIds"`
+}
+
+// AddMember 加入下线(端点 39 `POST /admin/divide-into/members`)。
+//
+// 入参:ctx;parentUserID 上级(ULID);subUserID 下线(ULID)。
+// 出参:*AddMemberResult(祖先链);错误码 2001/3111(深度超 100)/5002。
+//
+// 编排说明:写动作复用 **AC-09 已交付的 `DivideService.AddSubordinate`**(本服务不重写位移逻辑);
+// 随后读一次新节点的祖先链返回给前端(dump `divide_into_add` 末尾的 `SELECT userid` 就是它)。
+// ⚠ 新节点 `quota` 默认 **0.00**(D6/DDL 默认)⇒ 建完必须调端点 40 设额度,否则该链
+// **一分钱都不分成且不报错**(静默不入账,契约 §2.3 已提示前端引导用户设额度)。
+func (s *DivideTreeService) AddMember(ctx context.Context, parentUserID, subUserID string) (*AddMemberResult, error) {
+	if strings.TrimSpace(parentUserID) == "" || strings.TrimSpace(subUserID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: AddMember 需要 parentUserId 与 subUserId"))
+	}
+	if err := s.writer().AddSubordinate(ctx, parentUserID, subUserID); err != nil {
+		return nil, err
+	}
+	node, err := s.repo.GetByUserID(ctx, subUserID)
+	if err != nil {
+		return nil, err
+	}
+	root := subUserID
+	if node.RootUserID != nil && strings.TrimSpace(*node.RootUserID) != "" {
+		root = *node.RootUserID
+	}
+	ids, err := s.repo.ListAncestorIDs(ctx, node.LeftVal, node.RightVal, root)
+	if err != nil {
+		return nil, err
+	}
+	if ids == nil {
+		ids = []string{}
+	}
+	return &AddMemberResult{AncestorIDs: ids}, nil
+}
+
+// SetQuotaResult 改额度结果(端点 40 响应 `{affectedUserIds:[]}`)。
+type SetQuotaResult struct {
+	// AffectedUserIDs 受影响用户(**含自身**;dump 末尾 `LEFT>=uLeft AND RIGHT<=uRight AND quota>=newQuota`)。
+	AffectedUserIDs []string `json:"affectedUserIds"`
+}
+
+// SetQuota 改分成额度(端点 40 `PUT /admin/divide-into/members/:userId/quota`)。
+//
+// 入参:ctx;userID 节点;quota 新额度(**金额**,`DECIMAL(12,2)` —— 不是 0~1 比率)。
+// 出参:*SetQuotaResult;错误码 2001/2002(quota 非法)/3111(**子额度 > 父额度**,见下)/3001/5002。
+//
+// 3111 的判据(**必须精确,mock 曾把 quota 当比率而写错**):
+// **「新额度 > 父节点额度」** 才 3111 —— 不是 `quota > 1`、也不是比率越界。
+// 该判据由 AC-09 的 `DivideService.UpdateQuota` 实现(dump 无此校验,属 D9 有意增强),
+// 本方法**只编排、不重复实现**:先写(含子树级联下降),再读一次受影响列表。
+func (s *DivideTreeService) SetQuota(ctx context.Context, userID string, quota money.Amount) (*SetQuotaResult, error) {
+	if strings.TrimSpace(userID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: SetQuota 需要 userId"))
+	}
+	if err := quota.Validate(); err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, err)
+	}
+	if quota.IsNegative() {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: 分成额度不能为负(quota 是金额,不是比率)"))
+	}
+	if err := s.writer().UpdateQuota(ctx, userID, quota); err != nil {
+		return nil, err
+	}
+	node, err := s.repo.GetByUserID(ctx, userID)
+	if err != nil {
+		return nil, err
+	}
+	ids, err := s.repo.ListSubtreeUserIDsWithQuotaAtLeast(ctx, node.LeftVal, node.RightVal, quota)
+	if err != nil {
+		return nil, err
+	}
+	if ids == nil {
+		ids = []string{}
+	}
+	return &SetQuotaResult{AffectedUserIDs: ids}, nil
+}
+
+// writer 取写路径服务(AC-09 已交付的 `DivideService`;本服务只编排、不重写位移/级联逻辑)。
+func (s *DivideTreeService) writer() *DivideService {
+	// firstCharged 传 nil:端点 39/40 不触碰"首扣记录"判据(那是端点 42 撤销路径的依赖),
+	// 只有调用 `RevokeLog` 才需要它 —— 本服务不调,故 nil 安全(若将来调用会得到显式错误而非 panic)。
+	return NewDivideService(s.db, nil)
+}
+
+// AddLog 记分账流水(端点 41)——**转调 AC-09 的 `DivideService.AddLog`**,不重复实现上行链。
+//
+// 入参:ctx;findUserID 起点(**本人不拿分成,从 parent 起上行**);stuID 学生;orderNo 订单/上课记录号;remark 备注。
+// 出参:2001/2002/3111/3001/5002(由 AC-09 实现给出)。
+//
+// ⚠ 排查顺序(设计 §3.3 #21 连锁陷阱,三条成因**有且只有三条**):
+//
+//	① 节点 `quota=0`(新建默认值,最常见);② `findUserID` 本身是根(无父 → 正确空结果);
+//	③ `findUserID` 不是树节点(未入体系 → 静默不入账,也是正确行为)。
+func (s *DivideTreeService) AddLog(ctx context.Context, findUserID, stuID string, orderNo int64, remark string) error {
+	return s.writer().AddLog(ctx, findUserID, stuID, orderNo, remark)
+}
+
+// RevokeLog 撤销分账流水(端点 42)——**转调 AC-09 的 `DivideService.DeleteLog`**。
+//
+// D11 已拍板(按 dump):存在「首扣记录」即**改绑保留**(`total_income` 不回滚),
+// 仅无首扣记录时才回滚并物理删除。该判据依赖 `FirstChargedRecordReader`,
+// 故本方法的 writer 必须注入真实 reader(见 `writerWithFirstCharged`)。
+func (s *DivideTreeService) RevokeLog(ctx context.Context, stuID string, orderNo int64) error {
+	return s.writerWithFirstCharged().DeleteLog(ctx, stuID, orderNo)
+}
+
+// writerWithFirstCharged 取注入首扣读取器的写服务(撤销路径**必须**有它,否则无法判"是否首扣")。
+//
+// 说明:`port.NewOrgFirstChargedReader(db)` 是 AC-11 交付的真实实现(读 `t_courses_record_student`);
+// 若装配失败/表缺失,`DeleteLog` 会返回显式错误而不是静默按"无首扣"处理(防误回滚分成)。
+func (s *DivideTreeService) writerWithFirstCharged() *DivideService {
+	var reader FirstChargedRecordReader
+	if s.db != nil {
+		// AC-11 交付的真实实现(`repository.RecordRepo.FirstChargedRecordID`,四个谓词逐字对齐 dump 763811)。
+		// 作用域传 nil:撤销路径按 `student_id` 精确取数(该实现内部不依赖 scoping),
+		// 越权由端点角色白名单 + `mustScope` 保证(分账是全局域,见 handler 文件头)。
+		reader = repository.NewRecordRepo(s.db, nil)
+	}
+	return NewDivideService(s.db, reader)
+}