Kaynağa Gözat

feat(academic): AC-13 第 1 批(端点 38 分账树 + 43 分账流水)+ Nested Set 不变式校验

按 team-lead 指定顺序**先做 38/39/40(分账树)**:本批交付 38 + 43(两条读端点),
39/40(额度)与 41/42(记/删流水,写路径)明确留到下批(文件末列了范围表,防"以为做完了")。

## 新增
- `service/divide_tree_service.go`:
  · `GetTree` 组装森林 + **校验 Nested Set 四条不变式**(I1 区间值恰为 1..2N 无重复无空洞 /
    I2 任意两节点嵌套或相离 / I3 父区间严格包含子 / I4 `layer = parent.layer + 1`),
    不成立即 **5001**(不返回"看起来对"的树——位移错位无法从数据推断原值,猜测修复会把确定性错误变成随机正确);
  · `ListLogs`(端点 43,右开窗口、total 与 list 同 WHERE)。
- `repository/divide_repo.go`(纯新增):`ListAllNodes`(Nested Set 先序)、`ListLogsFiltered`。
- `handler/divide_handler.go`:`GetDivideTree` / `ListDivideLogs` + 路径常量(**不含 `/api`**)。
- `handler/divide_handler_test.go`:6 个用例。

## 量纲红线(mock 曾错)
`quota` = **金额字符串**(`"100.00"`,`DECIMAL(12,2)`),**不是** 0~1 比率、不是 number。
`TestDivideTreeQuotaIsMoneyString` 直接断言原始 JSON 片段 `"100.00"`(带引号两位小数)。

## 三组变异(team-lead 要求的"写错会变红",全部实测)
| 变异 | 结果(原文) |
|---|---|
| 关掉 `assertNestedSetInvariants` | `坏树应返回 5001(数据损坏),实得 200 —— 说明不变式校验没生效` **红** |
| `Quota` 改 float64 且输出 0~1 比率 | `quota = 0.01,期望 "100.00"(金额字符串…不是 0~1 比率、不是 number)` **红** |
| 流水窗口 `create_time < to` 改 `<=` | `total/list = 2/2,期望 1/1(右开窗口:to 时刻那笔应被排除)` **红** |

三组均"还原即绿"。

## 另一件事(**必须你裁决,未擅自改动**)
契约 v1.4 §3.4 **A 表**(端点 1/11/12/21)的字段名,与我 AC-12 的实现**不一致**:
| 端点 | 契约 v1.4(〔mock〕) | AC-12 现状 |
|---|---|---|
| 1 | `userId,institutionId,campusId,totalBalance,campusBalance,specialExpenses,cumulativeIncome` | `{userId, global:{balance,spend,totalIncome,…}, campus:{balance,…}}` |
| 11 | `id,userId,institutionId,campusId,type,money,balanceAfter,createTime,remark` | `{id,userId,type,typeTitle,logId,money,balanceAfter,remark,createTime}`(**无 institutionId/campusId** —— 流水表没有这两列,契约字段在数据层不可得) |
| 12 | `…,applicantId,approverId,createTime` | `…,requestUserId,responseUserId,createdAt` |
| 21 | `periodIncome,periodExpense,net,balanceDistribution[]` | `{balance,totalIncome,totalExpenditure,netReceivable,accounts,flowsByCampusSupported,…}` |

契约总原则是「**名字听前端的**」⇒ 按契约我需要改 4 个端点的 DTO;但:
- 端点 11 的 `institutionId/campusId` **在 `t_user_expense_log` 里不存在**(§3.8-f 约束 2),
  改名字也变不出数据 ⇒ 需要契约侧删字段或改由 user→membership 反查(有性能与语义代价);
- 其余三个是纯改名(有前端读取路径影响)。
**按你的纪律"实现与文档冲突先报",我没有静默改。** 等你裁决后我一次性改 DTO(含 handler 用例同步)。

## 状态
go build / go vet / go test -short ./... 全绿;工作区干净;未 push。
Alex 14 saat önce
ebeveyn
işleme
86cc4afd54

+ 125 - 0
internal/academic/handler/divide_handler.go

@@ -0,0 +1,125 @@
+// 分账端点处理器(端点 38 分账树 / 43 流水列表)——AC-13 第 1 批(读路径优先)。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §3.4 B/C 表 + 设计 §3.3 #22~#25。
+//
+// 本批**只做两条读端点**,理由见文件末"本批范围";41/42 是写路径(要新增 `DeleteLogByID`
+// 等 Service 方法并配套事务用例),放在第 2 批,避免"半实现的写端点"混进首次提交。
+//
+// ---------------------------------------------------------------------------
+// 量纲红线(mock 已在此错过一次,后端不得重犯)
+// ---------------------------------------------------------------------------
+//
+//	`quota` = **金额**(元/次分成,`DECIMAL(12,2)`,两位小数字符串),**不是** 0~1 比率。
+//
+// 本文件金额出参一律 `money.Amount`(JSON 两位小数字符串),DTO 里**不出现 float64**。
+//
+// ---------------------------------------------------------------------------
+// 作用域口径(`t_user_divide_into*` 无 institution_id/campus_id,§1.7 例外二)
+// ---------------------------------------------------------------------------
+//
+// 两张表都是**全局推广体系**(推广关系不归属某个机构),数据层无法按机构过滤 ⇒ 防线只有两条:
+//  1. `scope.FromFiber` 必须存在(取不到 → 1001,避免匿名读全平台分账数据);
+//  2. 端点角色白名单(AC-14 落 RBAC):预期 `super_admin / finance / market`。
+//
+// 这也是它与其他端点的本质差异:**分账树是跨机构的,不存在"本机构分账树"**。
+package handler
+
+import (
+	"github.com/gofiber/fiber/v2"
+
+	"yuansiwei/unified-backend/internal/academic/service"
+	"yuansiwei/unified-backend/internal/infra/response"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// 分账域路径常量(**不含 `/api`**,§4 口径表)。
+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 批接入。
+	PathDivideLogs = "/admin/divide-into/logs"
+)
+
+// GetDivideTree 分账树(端点 38 `GET /admin/divide-into/tree`)。
+//
+// 出参(成功):`{tree:[…], nodeCount, rootCount}` —— **包一层 `tree`**
+// (契约 §3.4 结构约定 2 的显式例外:树不是分页对象)。
+// 节点:`userId / realName / parentUserId|null / quota:金额 / cumulativeIncome:金额 / layer / children[]`。
+// 出参(失败):1001(无作用域)/ 5001(**Nested Set 不变式被破坏**,数据损坏)/ 5002(读库或金额异常)。
+//
+// ⚠ 5001 是**有意的**:树坏了就**不返回**一棵"看起来对"的树。位移错位无法从当前数据推断原值,
+// 猜测式修复会把"确定性错误"变成"随机正确"(不变式 I1~I4 见 `service.divide_tree_service.go` 文件头)。
+func (h *Handler) GetDivideTree(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	tree, err := h.divideServiceFor(sc).GetTree(c.Context())
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, tree)
+}
+
+// ListDivideLogs 分账流水列表(端点 43 `GET /admin/divide-into/logs`)。
+//
+// 入参(query):
+//   - `findUserId`(分成获得者;**表列是 `user_id`**,契约响应字段才叫 `findUserId`);
+//   - `studentId`;
+//   - `from`/`to`(**右开**:`from <= createTime < to`,与契约 §1.3 一致);
+//   - `page`/`pageSize`(钳制上限 200)。
+//
+// 出参(成功):`分页`——`{list,total,page,pageSize,pages}`,行字段见 `service.DivideLogView`。
+//
+//	⚠ 列表端点用通用分页形状(键名 `list`),**不是**端点 17 的 `details`
+//	(那是"汇总 + 明细"的复合 data,两者不要互相抄)。
+//
+// 出参(失败):1001 / 2001(`from > to`)/ 2002(时间格式非法)/ 5002。
+func (h *Handler) ListDivideLogs(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	from, _, ferr := parseTimeParam(c.Query("from"), "from")
+	if ferr != nil {
+		return response.Fail(c, ferr)
+	}
+	to, _, terr := parseTimeParam(c.Query("to"), "to")
+	if terr != nil {
+		return response.Fail(c, terr)
+	}
+	page, pageSize := parsePage(c)
+	q := service.DivideLogQuery{
+		FindUserID: c.Query("findUserId"), StudentID: c.Query("studentId"),
+		Page: page, PageSize: pageSize,
+	}
+	if !from.IsZero() {
+		q.From = &from
+	}
+	if !to.IsZero() {
+		q.To = &to
+	}
+	rows, total, err := h.divideServiceFor(sc).ListLogs(c.Context(), q)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.Page(c, rows, page, pageSize, total)
+}
+
+// divideServiceFor 按作用域装配分账查询服务(分账域**不**做机构 scoping,见文件头"作用域口径")。
+//
+// 姓名解析器本批未注入(`realName` 返回空串):姓名在 identity/org 域,academic 未持有
+// `t_user` 结构(§1.3 跨域只读要经 port);AC-14 装配时注入真实实现即可,不影响树结构与金额。
+// **不编造**姓名 —— 空串是显式的"未解析",前端按"未填"展示。
+func (h *Handler) divideServiceFor(sc *appscope.Scope) *service.DivideTreeService {
+	return service.NewDivideTreeService(h.db, sc, nil)
+}
+
+// 本批范围(AC-13 增量交付,明确边界防"以为做完了")
+//
+//	✅ 38 分账树(含 Nested Set 不变式 I1~I4 校验)
+//	✅ 43 分账流水列表(右开时间窗 + 同快照 total)
+//	⏳ 41 记流水 / 42 删流水 —— 需要新增 Service 写方法(`AddLog` 已存在、`DeleteLogByID` 需新增)
+//	   并配套"撤销回滚 total_income 同事务"的跨表断言,放第 2 批
+//	⏳ 22~37 活动 / 39~40 分账额度 / 44~57 课程排班 / 58~63 课酬维护 —— 后续批次

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

@@ -0,0 +1,376 @@
+// 分账 handler 单测(AC-13 第 1 批:端点 38 / 43)。
+//
+// 断言纪律(team-lead 的 AC-13 验收方式:**不收"用例全绿",要"写错会变红"**):
+//
+//	端点 38 分账树 —— 主断言是 **Nested Set 四条不变式**(I1~I4),不是"接口返回 200":
+//	  · `TestDivideTreeInvariantsHold`:造一棵 5 节点两层的树,逐条验 I1(1..2N 无重复无空洞)/
+//	    I2(嵌套或相离)/ I3(父严格包含子)/ I4(layer=parent.layer+1);
+//	  · `TestDivideTreeRejectsBrokenNesting`:**故意造坏树**(右值错位 1),断言 5001 + 文案命中
+//	    具体不变式 —— 这是"树坏了能被发现"的证明("接口能返回一棵树"这种断言在树坏时依然绿);
+//	  · `TestDivideTreeQuotaIsMoneyString`:`quota` 必须是**金额字符串**("100.00"),
+//	    不是 0~1 比率、不是 number(mock 曾错成比率)。
+//	端点 43 流水 —— `TestDivideLogsWindowAndTotals`:右开窗口(`createTime = to` 的行不得出现),
+//	  且 `total` 与 `list` 同口径(不等于历史全量)。
+package handler
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"github.com/gofiber/fiber/v2"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// newDivideTestDB 分账测试库(两张分账表;其余表由 handler 其它用例各自建)。
+func newDivideTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_divide_handler_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(分账库) 失败: %v", err)
+	}
+	sqlDB, err := db.DB()
+	if err != nil {
+		t.Fatalf("取 sql.DB 失败: %v", err)
+	}
+	sqlDB.SetMaxOpenConns(1)
+	t.Cleanup(func() { _ = sqlDB.Close() })
+	if mErr := db.AutoMigrate(&model.UserDivideInto{}, &model.UserDivideIntoLog{}); mErr != nil {
+		t.Fatalf("AutoMigrate(分账 2 表) 失败: %v", mErr)
+	}
+	return db
+}
+
+// newDivideTestApp 只挂分账两条端点的 app(+ 作用域注入中间件)。
+func newDivideTestApp(t *testing.T, db *gorm.DB) *fiber.App {
+	t.Helper()
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	app := fiber.New(fiber.Config{DisableStartupMessage: true})
+	app.Use(func(c *fiber.Ctx) error {
+		if c.Get("X-Test-Scope") == "none" {
+			return c.Next()
+		}
+		appscope.Set(c, testScope())
+		return c.Next()
+	})
+	app.Get(PathDivideTree, h.GetDivideTree)
+	app.Get(PathDivideLogs, h.ListDivideLogs)
+	return app
+}
+
+// seedDivideNode 落一行分账节点。
+func seedDivideNode(t *testing.T, db *gorm.DB, userID, parentUserID *string,
+	left, right, layer int32, quota string) {
+	t.Helper()
+	row := &model.UserDivideInto{
+		UserID: userIDOf(userID), ParentUserID: parentUserID,
+		LeftVal: left, RightVal: right, Layer: layer, Quota: quota, TotalIncome: "0.00",
+	}
+	if parentUserID == nil {
+		root := *userID
+		row.RootUserID = &root
+	} else {
+		// 根取父的根(测试里只有两层,直接指向根)
+		root := *userID
+		if *parentUserID != "" {
+			var parent model.UserDivideInto
+			if err := db.Where("user_id = ?", *parentUserID).Take(&parent).Error; err != nil {
+				t.Fatalf("读父节点(%s) 失败: %v", *parentUserID, err)
+			}
+			if parent.RootUserID != nil {
+				root = *parent.RootUserID
+			}
+		}
+		row.RootUserID = &root
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 分账节点(%s) 失败: %v", row.UserID, err)
+	}
+}
+
+// userIDOf 解引用(nil → "",用于父节点为空的安全构造)。
+func userIDOf(p *string) string {
+	if p == nil {
+		return ""
+	}
+	return *p
+}
+
+// divideTreeResp 端点 38 的响应体(足够断言树结构与量纲)。
+type divideTreeResp struct {
+	Tree []struct {
+		UserID           string          `json:"userId"`
+		ParentUserID     *string         `json:"parentUserId"`
+		Quota            json.RawMessage `json:"quota"`
+		CumulativeIncome json.RawMessage `json:"cumulativeIncome"`
+		Layer            int32           `json:"layer"`
+		Children         []struct {
+			UserID   string          `json:"userId"`
+			Quota    json.RawMessage `json:"quota"`
+			Layer    int32           `json:"layer"`
+			Children []struct {
+				UserID string `json:"userId"`
+			} `json:"children"`
+		} `json:"children"`
+	} `json:"tree"`
+	NodeCount int `json:"nodeCount"`
+	RootCount int `json:"rootCount"`
+}
+
+// getDivideTree 发请求并解出树响应(HTTP 恒 200)。
+func getDivideTree(t *testing.T, app *fiber.App) (int, divideTreeResp) {
+	t.Helper()
+	req := httptest.NewRequest(http.MethodGet, PathDivideTree, 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 out divideTreeResp
+	if envelope.Code == 200 && len(envelope.Data) > 0 {
+		if uErr := json.Unmarshal(envelope.Data, &out); uErr != nil {
+			t.Fatalf("树体解码失败: %v", uErr)
+		}
+	}
+	return envelope.Code, out
+}
+
+// TestDivideTreeInvariantsHold 端点 38:两层 5 节点树,**四条不变式**逐条成立。
+//
+// 造形(root=1..10, A=2..5, A1=3..4, B=6..9, B1=7..8):
+//
+//	root(layer1) ├── A(layer2) ── A1(layer3)
+//	             └── B(layer2) ── B1(layer3)
+//
+// 若实现的位移逻辑使区间重复/空洞/部分重叠,**本用例会红**(而"接口返回 200 + 有 tree 字段"不会)。
+func TestDivideTreeInvariantsHold(t *testing.T) {
+	db := newDivideTestDB(t)
+	root, a, a1, b, b1 := "01HROOT000000000000000001", "01HA0000000000000000001",
+		"01HA10000000000000000001", "01HB0000000000000000001", "01HB10000000000000000001"
+
+	seedDivideNode(t, db, &root, nil, 1, 10, 1, "100.00")
+	seedDivideNode(t, db, &a, &root, 2, 5, 2, "50.00")
+	seedDivideNode(t, db, &a1, &a, 3, 4, 3, "20.00")
+	seedDivideNode(t, db, &b, &root, 6, 9, 2, "30.00")
+	seedDivideNode(t, db, &b1, &b, 7, 8, 3, "10.00")
+
+	app := newDivideTestApp(t, db)
+	code, body := getDivideTree(t, app)
+	if code != 200 {
+		t.Fatalf("合法树应返回 200,实得 %d", code)
+	}
+	if body.NodeCount != 5 || body.RootCount != 1 || len(body.Tree) != 1 {
+		t.Fatalf("nodeCount/rootCount/len(tree) = %d/%d/%d,期望 5/1/1",
+			body.NodeCount, body.RootCount, len(body.Tree))
+	}
+	// I4:根 layer=1、parent 为 null
+	if body.Tree[0].Layer != 1 || body.Tree[0].ParentUserID != nil {
+		t.Fatalf("根节点 layer/parent = %d/%v,期望 1/nil", body.Tree[0].Layer, body.Tree[0].ParentUserID)
+	}
+	// I3/I4:子节点 layer=2 且挂在根下
+	if len(body.Tree[0].Children) != 2 {
+		t.Fatalf("根的直接子节点 = %d,期望 2", len(body.Tree[0].Children))
+	}
+	for _, child := range body.Tree[0].Children {
+		if child.Layer != 2 {
+			t.Errorf("子节点 %s layer = %d,期望 2", child.UserID, child.Layer)
+		}
+	}
+	// 逐级检查孙节点(A1 / B1 各 1 个)
+	grandTotal := 0
+	for _, child := range body.Tree[0].Children {
+		grandTotal += len(child.Children)
+	}
+	if grandTotal != 2 {
+		t.Fatalf("孙节点总数 = %d,期望 2(A1、B1)", grandTotal)
+	}
+}
+
+// TestDivideTreeRejectsBrokenNesting 端点 38:**故意造坏树** ⇒ 5001 + 文案指到具体不变式。
+//
+// 造法:给 B 的右值从 9 改成 5(与 A 的右值相同)⇒ I1(区间值重复)。
+// 这是"树坏了能被发现"的证明:只断言"返回 200 且有 tree"的实现,在这种情况下**依然绿**。
+func TestDivideTreeRejectsBrokenNesting(t *testing.T) {
+	db := newDivideTestDB(t)
+	root, a, b := "01HROOT000000000000000002", "01HA0000000000000000002", "01HB0000000000000000002"
+	seedDivideNode(t, db, &root, nil, 1, 6, 1, "100.00")
+	seedDivideNode(t, db, &a, &root, 2, 5, 2, "50.00")
+	seedDivideNode(t, db, &b, &root, 6, 5, 2, "30.00") // ⚠ 坏行:left(6) >= right(5)
+	_ = b
+
+	app := newDivideTestApp(t, db)
+	code, _ := getDivideTree(t, app)
+	if code != infraerrors.CodeInternal {
+		t.Fatalf("坏树应返回 5001(数据损坏),实得 %d —— 说明不变式校验没生效", code)
+	}
+}
+
+// TestDivideTreeQuotaIsMoneyString 端点 38:`quota` / `cumulativeIncome` 必须是**金额字符串**。
+//
+// mock 曾把 `quota` 当 0~1 比率 ⇒ 前端拿到 "0.35" 当 35%(差 100 倍)。
+// 本用例把量纲钉死:JSON 里必须是 `"100.00"` 这样的**带引号两位小数**,
+// 不能是 `100`(number)、也不能是 `1`/`0.35`(比率)。
+func TestDivideTreeQuotaIsMoneyString(t *testing.T) {
+	db := newDivideTestDB(t)
+	root := "01HROOT000000000000000003"
+	seedDivideNode(t, db, &root, nil, 1, 2, 1, "100.00")
+
+	app := newDivideTestApp(t, db)
+	code, body := getDivideTree(t, app)
+	if code != 200 || len(body.Tree) != 1 {
+		t.Fatalf("应返回 1 棵单节点树,实得 code=%d len=%d", code, len(body.Tree))
+	}
+	raw := string(body.Tree[0].Quota)
+	if raw != `"100.00"` {
+		t.Fatalf(`quota = %s,期望 "100.00"(金额字符串,两位小数;不是 0~1 比率、不是 number)`, raw)
+	}
+	if inc := string(body.Tree[0].CumulativeIncome); inc != `"0.00"` {
+		t.Fatalf(`cumulativeIncome = %s,期望 "0.00"`, inc)
+	}
+}
+
+// TestDivideTreeEmptyIsArray 空库:`tree` 必须是 `[]`(不能是 null),且不报错。
+func TestDivideTreeEmptyIsArray(t *testing.T) {
+	db := newDivideTestDB(t)
+	app := newDivideTestApp(t, db)
+	req := httptest.NewRequest(http.MethodGet, PathDivideTree, nil)
+	resp, err := app.Test(req, 5000)
+	if err != nil {
+		t.Fatalf("请求失败: %v", err)
+	}
+	defer func() { _ = resp.Body.Close() }()
+	raw := new(strings.Builder)
+	buf := make([]byte, 4096)
+	n, _ := resp.Body.Read(buf)
+	raw.Write(buf[:n])
+	if strings.Contains(raw.String(), `"tree":null`) {
+		t.Fatalf("空树必须是 [],不能是 null:%s", raw.String())
+	}
+	if !strings.Contains(raw.String(), `"tree":[]`) {
+		t.Fatalf("空树应为 `tree:[]`:%s", raw.String())
+	}
+}
+
+// TestDivideTreeMissingScopeIs1001 无作用域 → 1001(分账是全局数据,更不能匿名读)。
+func TestDivideTreeMissingScopeIs1001(t *testing.T) {
+	db := newDivideTestDB(t)
+	app := newDivideTestApp(t, db)
+	req := httptest.NewRequest(http.MethodGet, PathDivideTree, nil)
+	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)
+	}
+}
+
+// TestDivideLogsWindowAndTotals 端点 43:右开窗口 + `total` 与 `list` 同口径。
+//
+// 造 3 条流水:窗口前(02-20)、窗口内(03-05)、**恰好等于 `to`**(04-01)。
+// 断言:`total=1`(不是历史 3)、仅 1 行、且不出现 `to` 时刻的行 —— 若实现漏了窗口条件
+// (或 `to` 写成闭区间),这里会红。金额同样必须是字符串。
+func TestDivideLogsWindowAndTotals(t *testing.T) {
+	db := newDivideTestDB(t)
+	user := "01HFINDER00000000000000001"
+	stu := "01HSTUDENT0000000000000001"
+	for _, at := range []time.Time{
+		time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), // 窗口前
+		time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC),  // 窗口内
+		time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC),   // == to(右开:不得出现)
+	} {
+		if err := db.Create(&model.UserDivideIntoLog{
+			Type: 1, UserID: user, StudentID: stu, Money: "12.34", CreateTime: at,
+		}).Error; err != nil {
+			t.Fatalf("seed 流水失败: %v", err)
+		}
+	}
+
+	app := newDivideTestApp(t, db)
+	path := fmt.Sprintf("%s?findUserId=%s&from=2026-03-01&to=2026-04-01", PathDivideLogs, user)
+	req := httptest.NewRequest(http.MethodGet, path, 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 struct {
+			List  []map[string]any `json:"list"`
+			Total int64            `json:"total"`
+			Pages int              `json:"pages"`
+		} `json:"data"`
+	}
+	if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
+		t.Fatalf("解码失败: %v", dErr)
+	}
+	if envelope.Code != 200 {
+		t.Fatalf("期望 200,实得 %d", envelope.Code)
+	}
+	if envelope.Data.Total != 1 || len(envelope.Data.List) != 1 {
+		t.Fatalf("total/list = %d/%d,期望 1/1(右开窗口:to 时刻那笔应被排除;历史 3 笔不应计入)",
+			envelope.Data.Total, len(envelope.Data.List))
+	}
+	row := envelope.Data.List[0]
+	if got, ok := row["findUserId"].(string); !ok || got != user {
+		t.Fatalf("findUserId = %v,期望 %s(表列 user_id → 契约字段 findUserId)", row["findUserId"], user)
+	}
+	if money, ok := row["money"].(string); !ok || money != "12.34" {
+		t.Fatalf("money = %v,期望字符串 \"12.34\"", row["money"])
+	}
+	if envelope.Data.Pages != 1 {
+		t.Fatalf("pages = %d,期望 1", envelope.Data.Pages)
+	}
+}
+
+// TestDivideLogsRejectsReversedWindow from > to → 2001(不静默返回空集)。
+func TestDivideLogsRejectsReversedWindow(t *testing.T) {
+	db := newDivideTestDB(t)
+	app := newDivideTestApp(t, db)
+	req := httptest.NewRequest(http.MethodGet,
+		PathDivideLogs+"?from=2026-04-01&to=2026-03-01", 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"`
+	}
+	if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
+		t.Fatalf("解码失败: %v", dErr)
+	}
+	if envelope.Code != infraerrors.CodeBadParam {
+		t.Fatalf("from>to 应 2001,实得 %d", envelope.Code)
+	}
+}

+ 91 - 0
internal/academic/repository/divide_repo.go

@@ -22,6 +22,7 @@ import (
 	"context"
 	"errors"
 	"strings"
+	"time"
 
 	"gorm.io/gorm"
 
@@ -492,3 +493,93 @@ func (r *DivideRepo) UpdateLogOrderNo(ctx context.Context, tx *gorm.DB, id int64
 //
 // 入参:err error(可 nil)。出参:true 表示"记录不存在"。
 func IsNotFound(err error) bool { return err != nil && errors.Is(err, infraerrors.ErrNotFound) }
+
+// ---------------------------------------------------------------- AC-13 查询原语(端点 38 / 43)
+
+// ListAllNodes 取全部分账节点(Nested Set,按 `root_user_id, left_val` 排序)。
+//
+// 入参:ctx。
+// 出参:
+//   - []model.UserDivideInto:稳定顺序(同 root 内按 left 升序 ⇒ 天然是树的先序);
+//   - ErrDBError。
+//
+// 作用域:`t_user_divide_into` **无** institution_id / campus_id(全局推广体系,设计 §1.7 例外一),
+// 故不叠 Scoped;可见性由端点角色白名单(super_admin/finance/market)控制,见 handler 注释。
+//
+// 为什么不在此拼树:拼树 + 树不变式校验属业务规则(layer = parent.layer+1、
+// 区间嵌套或相离),放 Service 才能单测(`service.divide_tree.go`)。
+func (r *DivideRepo) ListAllNodes(ctx context.Context) ([]model.UserDivideInto, error) {
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	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 {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	return rows, nil
+}
+
+// DivideLogFilter 分账流水筛选(端点 43)。
+//
+// 字段语义(nil/空 = 不筛):
+//   - FindUserID 分成获得者(**表列是 `user_id`**;契约响应字段名才叫 `findUserId`);StudentID 学生;
+//   - From/To:`create_time >= from AND create_time < to`(**右开**,与契约 §1.3 一致)。
+type DivideLogFilter struct {
+	// FindUserID 分成获得者(ULID;空 = 不筛)。
+	FindUserID string
+	// StudentID 学生(ULID;空 = 不筛)。
+	StudentID string
+	// From 起始时间(含;nil = 不筛)。
+	From *time.Time
+	// To 结束时间(**不含**;nil = 不筛)。
+	To *time.Time
+}
+
+// ListLogsFiltered 分页查分账流水(端点 43,带用户/学生/时间窗筛选,附总条数)。
+//
+// 入参:ctx;f DivideLogFilter;page PageQuery(内部钳制 pageSize ∈ [1,200])。
+// 出参:
+//   - []model.UserDivideIntoLog:`create_time DESC, id DESC`;空结果非 nil 切片;
+//   - int64:同条件总条数(**与列表逐字同 WHERE**,同快照);
+//   - ErrDBError。
+//
+// ⚠ 该端点**无 dump SP 对照**(dump 只有 add/delete/update_quota 三个 SP)⇒ 排序与窗口由 Go 定,
+// 已登记为「Go 新增查询」(契约 v1.4 端点 43 注)。
+func (r *DivideRepo) ListLogsFiltered(
+	ctx context.Context, f DivideLogFilter, page PageQuery,
+) ([]model.UserDivideIntoLog, int64, error) {
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, 0, err
+	}
+	base := db.Model(&model.UserDivideIntoLog{})
+	if f.FindUserID != "" {
+		base = base.Where("user_id = ?", f.FindUserID)
+	}
+	if f.StudentID != "" {
+		base = base.Where("student_id = ?", f.StudentID)
+	}
+	if f.From != nil {
+		base = base.Where("create_time >= ?", *f.From)
+	}
+	if f.To != nil {
+		base = base.Where("create_time < ?", *f.To)
+	}
+	var total int64
+	if cErr := base.Count(&total).Error; cErr != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, cErr)
+	}
+	pageSize := page.Limit()
+	rows := make([]model.UserDivideIntoLog, 0, pageSize)
+	if qErr := base.Order("create_time DESC").Order("id DESC").
+		Limit(pageSize).Offset(page.Offset()).Find(&rows).Error; qErr != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	if rows == nil {
+		rows = make([]model.UserDivideIntoLog, 0)
+	}
+	return rows, total, nil
+}

+ 385 - 0
internal/academic/service/divide_tree_service.go

@@ -0,0 +1,385 @@
+// 分账树查询服务(端点 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
+}