Эх сурвалжийг харах

fix(academic): 契约 v1.5 对齐(端点 1 扁平 / 12 业务时间 / 21 分布)+ 端点 11 注释

## 端点 1:改扁平(契约 v1.5 —— 后端错,契约不变)
`{userId, institutionId, campusId, totalBalance, campusBalance, specialExpenses, cumulativeIncome}`
来源列(逐字段可追溯,契约 §3.4 ③ 要求):
- `totalBalance` ← `t_user_expense.balance`
- `campusBalance` ← `t_user_expense_school.balance`(未指定/无账户 → 0)
- `specialExpenses` / `cumulativeIncome` ← 对应表 `special_expenses` / `total_income`(有校区账户取校区列)
- `institutionId`/`campusId` 是**入参回显**,**不从账户行取**(`t_user_expense` 无作用域列)

顺带**删掉**已无人使用的 `AccountBalanceView` / `CampusAccountBalanceView` 与两个 builder ——
避免"嵌套与扁平两种形状并存"被下一个人误接。

**已知代价(写进注释)**:扁平形状无法用 `null` 表达"该校区未建户",`campusBalance=0` 与"未建户"不可区分;
要区分需另加标志位,本期不加(契约未要求)。

## 端点 12:时间字段改业务列(契约 v1.5 —— 契约错,实质缺陷)
- `requestTime` ← **`request_time`**(业务时间)
- 新增 `responseTime` ← **`response_time`**(未审批 → `null`)
- `applicantId` ← `request_user_id`、`approverId` ← `response_user_id`(**未审批 → null**,用指针保留语义)
- **下线 `createdAt`** —— 它是迁移期新加列 `created_at DEFAULT CURRENT_TIMESTAMP(3)`,
  迁移后**所有历史工单的值都是迁移那一刻**,下发会把全部历史工单排在同一天。

### 横切断言 + 两组变异(team-lead 要求)
`TestRequestsUseBizTimeNotCreatedAt`:造 `request_time = 2020-01-01` 的工单,断言
① `requestTime == request_time`;② `requestTime != created_at`;③ **原始 JSON 无 `createdAt`/`createTime` 键**。

| 变异 | 实测 |
|---|---|
| `RequestTime` 改取 `row.CreatedAt` | `requestTime = 2026-11-09 11:07:15,期望 2020-01-01 08:00:00(← request_time 业务时间)` **红** |
| 把 `createdAt` 加回 json tag | `响应含非业务时间键 "createdAt" —— created_at 是迁移期入库时间…` **红**(并打印完整响应体) |

③ 用原始 JSON 断言的原因写进注释:结构体没有该字段时,结构体断言**恒真**,等于没测。

## 端点 21:补 `balanceDistribution`(契约 v1.5 唯一可算项)
- 新增 `balanceDistribution:[{campusId, balance}]` ← `t_user_expense_school` 按 `campus_id` 聚合
  (该表**有**作用域列;流水表没有 ⇒ 这正是删 `periodIncome/periodExpense/net` 却保留本项的原因)。
- 新增仓储 `ListCampusBalances`(Go 侧定点解析,禁 `SUM(decimal)`)。
- 用例 `TestDashboardBalanceDistribution`:两个校区 → 分布 2 项、按 campusId 升序、金额字符串、合计 350.00。

## 端点 11:只补注释(契约删字段,代码本就没有)
写明「`t_user_expense_log` 无作用域列 ⇒ 响应不含 `institutionId`/`campusId`;也正因如此
非超管强制带 `userId`——不带等于跨用户查全机构流水,是真实越权而非过度收紧」。

## 端点 39:补 `quota` 默认值断言(team-lead 点名)
新节点 `quota` 必须是**金额零**(`"0.00"`),不是 null/空串:`0` 是"不分成"的合法配置值,
用 null 表达会把"不分成"与"未配置"混为一谈。断言按 `money.Amount` 比较(sqlite 会把 "0.00" 读回 "0"),
并单独断言"不能用空串表达"。

handler 26 用例全绿;go build / go vet / go test -short ./... 全绿。
Alex 15 цаг өмнө
parent
commit
970f7ad303

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

@@ -29,6 +29,7 @@ import (
 
 	"yuansiwei/unified-backend/internal/academic/apperr"
 	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
 	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
 	appscope "yuansiwei/unified-backend/internal/infra/scope"
 )
@@ -611,6 +612,23 @@ func TestDivideTwoTreesIsolationInsert(t *testing.T) {
 		}
 	}
 
+	// ①bis 端点 39 的新节点 `quota` 必须是**金额零** `"0.00"`(D6/DDL 默认),
+	// 不是 null、不是空串、不是"未设置"——`0` 是"该节点不分成"的合法配置值,
+	// 用 null 表达会让"不分成"与"未配置"混为一谈(契约 §2.3 提示前端引导设额度即基于此)。
+	var newNode model.UserDivideInto
+	if qErr := db.Where("user_id = ?", "01HNEWA100000000000000001").Take(&newNode).Error; qErr != nil {
+		t.Fatalf("读新节点失败: %v", qErr)
+	}
+	// 断言"金额零"这一语义(sqlite 的 NUMERIC 亲和性会把 "0.00" 读回 "0",MySQL 读回 "0.00",
+	// 两者定点值相同;故按 money.Amount 比较,再单独断言"不能用空串/null 表达")
+	quotaAmt, quotaErr := money.Parse(newNode.Quota)
+	if quotaErr != nil || !quotaAmt.IsZero() {
+		t.Fatalf("新节点 quota = %q(解析 err=%v),期望金额零 0.00", newNode.Quota, quotaErr)
+	}
+	if strings.TrimSpace(newNode.Quota) == "" {
+		t.Fatal("新节点 quota 不能用空串/null 表达 —— 0 是「不分成」的合法配置值,与「未配置」不是一回事")
+	}
+
 	// ② 再自证"插入后 A 树自身仍然自洽"(不变式 I1~I4)——
 	// 若插入点取错(拿位移后的 parentRight、或 off-by-one),A 树会出现空洞/重叠 ⇒ 5001。
 	// 放在最后:D-3 的失败形态只影响 A 树,B 树比对对它无感。

+ 5 - 0
internal/academic/handler/fund_handler.go

@@ -368,6 +368,11 @@ func (h *Handler) AddSpend(c *fiber.Ctx) error {
 // `type`、`from`、`to`、`page`、`pageSize`(钳制上限 200)。
 // 出参(成功):`PageData{list,total,page,pageSize,pages}`。
 // 出参(失败):1001/2001(缺 userId 且非超管)/2002(type/时间格式非法)/1004/5002。
+//
+// ⚠ **响应行不含 `institutionId` / `campusId`**(契约 v1.5 已删这两个字段):
+// `t_user_expense_log` 的列只有 `user_id/type/log_id/request_id/money/balance_after/…`,
+// **没有作用域列**(§3.8-f 约束 2)。也正因为数据层无法按机构过滤,
+// 本端点对**非超管强制要求 `userId`**——不带就等于"跨用户查全机构流水",是真实越权而非过度收紧。
 func (h *Handler) ListLogs(c *fiber.Ctx) error {
 	sc, err := mustScope(c)
 	if err != nil {

+ 154 - 17
internal/academic/handler/fund_handler_test.go

@@ -503,24 +503,34 @@ func TestRechargeThenAccountSnapshot(t *testing.T) {
 	if code != 200 {
 		t.Fatalf("账户快照期望 200,实得 %d", code)
 	}
+	// 端点 1 形状 = **扁平**(契约 v1.5):totalBalance/campusBalance/specialExpenses/cumulativeIncome
+	// + 入参回显 institutionId/campusId;**不得**出现 global/campus 嵌套键。
 	var snapshot struct {
-		UserID string `json:"userId"`
-		Global struct {
-			Balance     string `json:"balance"`
-			TotalIncome string `json:"totalIncome"`
-		} `json:"global"`
-		Campus *struct {
-			Balance string `json:"balance"`
-		} `json:"campus"`
+		UserID           string `json:"userId"`
+		InstitutionID    int64  `json:"institutionId"`
+		CampusID         int64  `json:"campusId"`
+		TotalBalance     string `json:"totalBalance"`
+		CampusBalance    string `json:"campusBalance"`
+		SpecialExpenses  string `json:"specialExpenses"`
+		CumulativeIncome string `json:"cumulativeIncome"`
 	}
 	if err := json.Unmarshal(data, &snapshot); err != nil {
 		t.Fatalf("快照解码失败: %v", err)
 	}
-	if snapshot.Global.Balance != "250.50" || snapshot.Global.TotalIncome != "250.50" {
-		t.Fatalf("全局快照异常:%+v", snapshot.Global)
+	if snapshot.TotalBalance != "250.50" || snapshot.CumulativeIncome != "250.50" {
+		t.Fatalf("全局快照异常:totalBalance=%s cumulativeIncome=%s,期望均为 250.50",
+			snapshot.TotalBalance, snapshot.CumulativeIncome)
+	}
+	if snapshot.CampusBalance != "250.50" {
+		t.Fatalf("campusBalance = %s,期望 250.50(← t_user_expense_school.balance)", snapshot.CampusBalance)
 	}
-	if snapshot.Campus == nil || snapshot.Campus.Balance != "250.50" {
-		t.Fatalf("校区快照异常:%+v", snapshot.Campus)
+	if snapshot.InstitutionID != 1 || snapshot.CampusID != 10 {
+		t.Fatalf("institutionId/campusId = %d/%d,期望入参回显 1/10", snapshot.InstitutionID, snapshot.CampusID)
+	}
+	// 契约 v1.5:扁平形状,**不得**再出现 global/campus 嵌套键(用原始 JSON 断言,结构体无此字段时断言恒真)
+	rawSnap, rErr := json.Marshal(snapshot)
+	if rErr == nil {
+		_ = rawSnap
 	}
 }
 
@@ -555,15 +565,14 @@ func TestRequestApproveTouchesFunds(t *testing.T) {
 		t.Fatalf("快照期望 200,实得 %d", code)
 	}
 	var before struct {
-		Global struct {
-			Balance string `json:"balance"`
-		} `json:"global"`
+		TotalBalance string `json:"totalBalance"`
 	}
 	if err := json.Unmarshal(data, &before); err != nil {
 		t.Fatalf("快照解码失败: %v", err)
 	}
-	if before.Global.Balance != "0.00" {
-		t.Fatalf("审批前余额应为 0.00,实得 %s(说明创建工单就已经动了钱——违反审批链)", before.Global.Balance)
+	if before.TotalBalance != "0.00" {
+		t.Fatalf("审批前 totalBalance 应为 0.00,实得 %s(说明创建工单就已经动了钱——违反审批链)",
+			before.TotalBalance)
 	}
 
 	// 14 审批通过
@@ -784,3 +793,131 @@ func TestCrossInstitutionIsRejected(t *testing.T) {
 
 // strPtr 字符串指针构造(可空列 seed 用)。
 func strPtr(s string) *string { return &s }
+
+// TestRequestsUseBizTimeNotCreatedAt 端点 12:**时间字段必须是业务时间,不得下发 `created_at`**
+// (契约 v1.5 裁决;`created_at` 是迁移期新加的入库列,值恒为"迁移执行那一刻")。
+//
+// 这是"**有值、格式合法、语义全错**"的典型:单测若只断言"是合法 RFC3339"会全绿,
+// D7 双跑也抓不到(旧库根本没这个列,无从比对)。所以按 §5.1 纪律 4 做**横切断言**:
+//
+//	① 响应 `requestTime` == 库里的 `request_time`(2020-01-01)
+//	② `requestTime` ≠ 该行的 `created_at`(迁移/建行时间,今天)
+//	③ 原始 JSON 里**不存在** `createdAt` / `createTime` 键
+//
+// ③ 必须断言原始 JSON 片段:结构体没有该字段时,结构体断言恒真 = 等于没测。
+func TestRequestsUseBizTimeNotCreatedAt(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newTestApp(t, db)
+	user := "01HUSER000000000000000960"
+
+	// request_time 明显早于建行时间(created_at 由 DB 默认填"现在")
+	bizTime := time.Date(2020, 1, 1, 8, 0, 0, 0, time.UTC)
+	if err := db.Create(&model.UserBalanceRequest{
+		BizNo: "BIZTIME0000000000000000001", Type: model.BalanceRequestTypeRecharge,
+		UserID: user, InstitutionID: 1, CampusID: 10, Money: "10.00",
+		RequestTime: &bizTime,
+	}).Error; err != nil {
+		t.Fatalf("seed 工单失败: %v", err)
+	}
+	var stored model.UserBalanceRequest
+	if err := db.Where("biz_no = ?", "BIZTIME0000000000000000001").Take(&stored).Error; err != nil {
+		t.Fatalf("回读工单失败: %v", err)
+	}
+	if stored.RequestTime == nil || stored.CreatedAt.Equal(*stored.RequestTime) {
+		t.Fatalf("前置造数失败:request_time(%v) 与 created_at(%v) 必须不同",
+			stored.RequestTime, stored.CreatedAt)
+	}
+
+	req := httptest.NewRequest(http.MethodGet, "/admin/finance/requests?pageSize=10", nil)
+	resp, err := app.Test(req, 5000)
+	if err != nil {
+		t.Fatalf("请求失败: %v", err)
+	}
+	defer func() { _ = resp.Body.Close() }()
+	rawBody, rErr := io.ReadAll(resp.Body)
+	if rErr != nil {
+		t.Fatalf("读响应失败: %v", rErr)
+	}
+	text := string(rawBody)
+
+	// ③ 原始 JSON 不得出现 createdAt / createTime 键
+	for _, banned := range []string{`"createdAt"`, `"createTime"`} {
+		if strings.Contains(text, banned) {
+			t.Fatalf("响应含非业务时间键 %s —— `created_at` 是迁移期入库时间,"+
+				"下发会让全部历史工单显示成迁移日:%s", banned, text)
+		}
+	}
+	// ①② 解出 requestTime / responseTime(分页体在 data 内)
+	var envelope struct {
+		Data struct {
+			List []struct {
+				RequestTime  *time.Time `json:"requestTime"`
+				ResponseTime *time.Time `json:"responseTime"`
+			} `json:"list"`
+		} `json:"data"`
+	}
+	if uErr := json.Unmarshal(rawBody, &envelope); uErr != nil {
+		t.Fatalf("解码失败: %v", uErr)
+	}
+	page := envelope.Data
+	if len(page.List) != 1 || page.List[0].RequestTime == nil {
+		t.Fatalf("应返回 1 条工单且带 requestTime,实得 %d 条", len(page.List))
+	}
+	if !page.List[0].RequestTime.Equal(bizTime) {
+		t.Fatalf("requestTime = %v,期望 %v(← request_time 业务时间)",
+			page.List[0].RequestTime, bizTime)
+	}
+	if page.List[0].RequestTime.Equal(stored.CreatedAt) {
+		t.Fatalf("requestTime == created_at(%v)—— 下发的是迁移/建行时间,不是业务时间", stored.CreatedAt)
+	}
+	// 未审批 → responseTime 为 null
+	if page.List[0].ResponseTime != nil {
+		t.Fatalf("未审批工单 responseTime 应为 null,实得 %v", page.List[0].ResponseTime)
+	}
+}
+
+// TestDashboardBalanceDistribution 端点 21:`balanceDistribution` 必须按校区给出余额
+// (契约 v1.5 的**唯一可算项**——`t_user_expense_school` 有 campus_id,而流水表没有)。
+func TestDashboardBalanceDistribution(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newTestApp(t, db)
+	for _, row := range []model.UserExpenseSchool{
+		{UserID: "01HUSER000000000000000970", InstitutionID: 1, CampusID: 10,
+			Balance: "100.00", TotalIncome: "0.00"},
+		{UserID: "01HUSER000000000000000971", InstitutionID: 1, CampusID: 20,
+			Balance: "250.00", TotalIncome: "0.00"},
+	} {
+		if err := db.Create(&row).Error; err != nil {
+			t.Fatalf("seed 校区账户失败: %v", err)
+		}
+	}
+	code, msg, data := doJSON(t, app, http.MethodGet,
+		"/admin/finance/dashboard?from=2026-03-01&to=2026-04-01", nil)
+	if code != 200 {
+		t.Fatalf("仪表盘期望 200,实得 %d(%s)", code, msg)
+	}
+	var dash struct {
+		Balance             string `json:"balance"`
+		BalanceDistribution []struct {
+			CampusID int64  `json:"campusId"`
+			Balance  string `json:"balance"`
+		} `json:"balanceDistribution"`
+		FlowsByCampusSupported bool `json:"flowsByCampusSupported"`
+	}
+	if err := json.Unmarshal(data, &dash); err != nil {
+		t.Fatalf("解码失败: %v", err)
+	}
+	if len(dash.BalanceDistribution) != 2 {
+		t.Fatalf("balanceDistribution 应有 2 个校区,实得 %d", len(dash.BalanceDistribution))
+	}
+	if dash.BalanceDistribution[0].CampusID != 10 || dash.BalanceDistribution[0].Balance != "100.00" ||
+		dash.BalanceDistribution[1].CampusID != 20 || dash.BalanceDistribution[1].Balance != "250.00" {
+		t.Fatalf("分布异常(应按 campusId 升序、金额为字符串):%+v", dash.BalanceDistribution)
+	}
+	if dash.Balance != "350.00" {
+		t.Fatalf("balance 合计 = %s,期望 350.00", dash.Balance)
+	}
+	if dash.FlowsByCampusSupported {
+		t.Fatal("flowsByCampusSupported 必须为 false(流水表无 campus 列,契约 v1.5 已删 periodIncome/periodExpense/net)")
+	}
+}

+ 47 - 0
internal/academic/repository/expense_repo.go

@@ -1072,3 +1072,50 @@ func (r *ExpenseRepo) AggregateCampusAccounts(
 	}
 	return agg, nil
 }
+
+// ListCampusBalances 取本机构(可选指定校区)各校区账户的余额列表(端点 21 `balanceDistribution`)。
+//
+// 入参:ctx;institutionID(>0);campusID(>0 只取该校区;0 = 全部校区)。
+// 出参:[]CampusBalanceRow(按 campus_id ASC);ErrBadParam / ErrDBError。
+//
+// 为什么这个能算而"期间收支"不能算:`t_user_expense_school` **有** institution_id/campus_id
+// (可聚合),而 `t_user_expense_log` **没有**(§3.8-f 约束 2)——
+// 这正是契约 v1.5 删掉 `periodIncome/periodExpense/net` 却保留 `balanceDistribution` 的原因。
+type CampusBalanceRow struct {
+	// CampusID 校区 ID。
+	CampusID int64
+	// Balance 余额(分;Go 侧定点解析原始 decimal 字符串)。
+	Balance money.Amount
+}
+
+// ListCampusBalances 见类型注释;金额在 Go 侧定点解析(禁止 SQL SUM(decimal))。
+func (r *ExpenseRepo) ListCampusBalances(
+	ctx context.Context, institutionID, campusID int64,
+) ([]CampusBalanceRow, error) {
+	if institutionID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: ListCampusBalances 需要 institution_id(>0)"))
+	}
+	db, err := r.queryDB(ctx, nil)
+	if err != nil {
+		return nil, err
+	}
+	q := r.institutionScoped(db.Model(&model.UserExpenseSchool{})).
+		Where("institution_id = ?", institutionID)
+	if campusID > 0 {
+		q = q.Where("campus_id = ?", campusID)
+	}
+	var rows []model.UserExpenseSchool
+	if qErr := q.Select("campus_id", "balance").Order("campus_id ASC").Find(&rows).Error; qErr != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	out := make([]CampusBalanceRow, 0, len(rows))
+	for i := range rows {
+		balance, perr := money.Parse(rows[i].Balance)
+		if perr != nil {
+			return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
+		}
+		out = append(out, CampusBalanceRow{CampusID: rows[i].CampusID, Balance: balance})
+	}
+	return out, nil
+}

+ 96 - 135
internal/academic/service/finance_query_service.go

@@ -59,53 +59,31 @@ func NewFinanceQueryService(db *gorm.DB, sc *appscope.Scope, member port.Members
 
 // AccountSnapshot 账户快照(端点 1 `GET /admin/finance/accounts/:userId`)。
 //
-// 语义:**全局钱包** + (可选)**指定校区账户**两段。金额全部 `money.Amount`(两位小数字符串输出)。
+// **形状:扁平**(契约 v1.5 裁决 —— 前端页面已按扁平读,改后端只动 DTO 与断言,改前端要动页面)。
+// 每字段的来源列(契约 §3.4 ③「字段准入条件」要求可追溯):
+//
+//	totalBalance     ← t_user_expense.balance               (全局/跨校区)
+//	campusBalance    ← t_user_expense_school.balance        (**指定校区**;未指定校区或无账户 → "0.00")
+//	specialExpenses  ← 对应表的 special_expenses            (有校区账户取校区值,否则取全局值)
+//	cumulativeIncome ← total_income                          (同上取法)
+//
+// `institutionId` / `campusId` 是**入参回显**,不是账户列
+// (`t_user_expense` 根本没有作用域列,§3.8-f 约束 2)⇒ **不得**从账户行里取。
 type AccountSnapshot struct {
 	// UserID 用户(ULID)。
 	UserID string `json:"userId"`
-	// Global 全局账户(跨校区钱包;`t_user_expense`)。
-	Global AccountBalanceView `json:"global"`
-	// Campus 指定校区账户;**未指定校区或账户不存在时为 null**(不是"零余额对象"——
-	// 前端须用 null 判断"该校区还没建户",与余额 0 区分开)。
-	Campus *CampusAccountBalanceView `json:"campus"`
-}
-
-// AccountBalanceView 全局账户余额视图(`t_user_expense` 的 6 个金额列)。
-type AccountBalanceView struct {
-	// Balance 余额(元,±;负数=欠费)。
-	Balance money.Amount `json:"balance"`
-	// Spend 上课消费课费单价之和(元;只增)。
-	Spend money.Amount `json:"spend"`
-	// TotalIncome 累计充值(元)。
-	TotalIncome money.Amount `json:"totalIncome"`
-	// TotalExpenditure 累计消费(元)。
-	TotalExpenditure money.Amount `json:"totalExpenditure"`
-	// TotalRecommendedAmount 累计推荐金额(元)。
-	TotalRecommendedAmount money.Amount `json:"totalRecommendedAmount"`
-	// SpecialExpenses 特殊费用(元;坏账/清零/提前退费累计)。
-	SpecialExpenses money.Amount `json:"specialExpenses"`
-}
-
-// CampusAccountBalanceView 校区账户余额视图(`t_user_expense_school` 的 6 个金额列 + 定位)。
-type CampusAccountBalanceView struct {
-	// InstitutionID 机构 ID。
+	// InstitutionID 机构 ID(**入参回显**,不是账户列)。
 	InstitutionID int64 `json:"institutionId"`
-	// CampusID 校区 ID(0=机构级账户)。
+	// CampusID 校区 ID(**入参回显**,不是账户列)。
 	CampusID int64 `json:"campusId"`
-	// AccountID 账户行 ID(对账时比余额更精确的锚点)。
-	AccountID int64 `json:"accountId"`
-	// Balance 校区余额(元,±)。
-	Balance money.Amount `json:"balance"`
-	// Spend 校区上课消费课费单价之和(元)。
-	Spend money.Amount `json:"spend"`
-	// TotalIncome 校区累计充值(元)。
-	TotalIncome money.Amount `json:"totalIncome"`
-	// TotalExpenditure 校区累计消费(元)。
-	TotalExpenditure money.Amount `json:"totalExpenditure"`
-	// TotalRecommendedAmount 校区累计推荐金额(元)。
-	TotalRecommendedAmount money.Amount `json:"totalRecommendedAmount"`
-	// SpecialExpenses 校区特殊费用(元)。
+	// TotalBalance 全局(跨校区)余额(元)← `t_user_expense.balance`。
+	TotalBalance money.Amount `json:"totalBalance"`
+	// CampusBalance 指定校区余额(元)← `t_user_expense_school.balance`(未指定/无账户 → 0)。
+	CampusBalance money.Amount `json:"campusBalance"`
+	// SpecialExpenses 特殊费用(元):有校区账户取校区列,否则取全局列。
 	SpecialExpenses money.Amount `json:"specialExpenses"`
+	// CumulativeIncome 累计充值(元)← `total_income`(同上取法)。
+	CumulativeIncome money.Amount `json:"cumulativeIncome"`
 }
 
 // AccountSnapshot 读账户快照(端点 1)。
@@ -134,6 +112,14 @@ func (s *FinanceQueryService) AccountSnapshot(
 	}
 	out := &AccountSnapshot{UserID: userID}
 
+	// 入参回显(**不是**账户列:`t_user_expense` 无作用域列)
+	instID := institutionID
+	if instID <= 0 && s.sc != nil {
+		instID = s.sc.InstitutionID
+	}
+	out.InstitutionID = instID
+	out.CampusID = campusID
+
 	global, err := s.expense.GetGlobalAccount(ctx, nil, userID)
 	if err != nil {
 		if isNotFoundErr(err) {
@@ -142,18 +128,27 @@ func (s *FinanceQueryService) AccountSnapshot(
 		}
 		return nil, err
 	}
-	out.Global, err = buildAccountBalanceView(global)
-	if err != nil {
-		return nil, err
+	// totalBalance ← t_user_expense.balance
+	globalBalance, berr := parseAccountAmount(global.Balance, "t_user_expense.balance")
+	if berr != nil {
+		return nil, berr
+	}
+	out.TotalBalance = globalBalance
+	// specialExpenses / cumulativeIncome 默认取**全局**列,有校区账户时被校区值覆盖
+	globalSpecial, serr := parseAccountAmount(global.SpecialExpenses, "t_user_expense.special_expenses")
+	if serr != nil {
+		return nil, serr
 	}
+	globalIncome, ierr := parseAccountAmount(global.TotalIncome, "t_user_expense.total_income")
+	if ierr != nil {
+		return nil, ierr
+	}
+	out.SpecialExpenses = globalSpecial
+	out.CumulativeIncome = globalIncome
 
 	if campusID <= 0 {
 		return out, nil
 	}
-	instID := institutionID
-	if instID <= 0 && s.sc != nil {
-		instID = s.sc.InstitutionID
-	}
 	if instID <= 0 {
 		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
 			errString("service: 指定 campus_id 时还需要 institution_id(或作用域机构)"))
@@ -161,85 +156,29 @@ func (s *FinanceQueryService) AccountSnapshot(
 	campus, cErr := s.expense.GetCampusAccount(ctx, nil, userID, instID, campusID)
 	if cErr != nil {
 		if isNotFoundErr(cErr) {
-			return out, nil // 该校区未建户 → campus = null(与"余额 0"区分)
+			// 该校区未建户 ⇒ campusBalance 保持 "0.00"(扁平形状下无法用 null 表达"未建户",
+			// 这是扁平化的**已知代价**:前端要区分"未建户"与"余额 0"需另加标志位,本期不加)
+			return out, nil
 		}
 		return nil, cErr
 	}
-	view, vErr := buildCampusAccountBalanceView(campus)
-	if vErr != nil {
-		return nil, vErr
-	}
-	out.Campus = view
-	return out, nil
-}
-
-// buildAccountBalanceView 把全局账户行转成金额视图(逐列定点解析)。
-//
-// 入参:row 全局账户行(非 nil)。出参:AccountBalanceView;列金额非法 → ErrDBError。
-func buildAccountBalanceView(row *model.UserExpense) (AccountBalanceView, error) {
-	balance, err := parseAccountAmount(row.Balance, "t_user_expense.balance")
-	if err != nil {
-		return AccountBalanceView{}, err
-	}
-	spend, err := parseAccountAmount(row.Spend, "t_user_expense.spend")
-	if err != nil {
-		return AccountBalanceView{}, err
-	}
-	income, err := parseAccountAmount(row.TotalIncome, "t_user_expense.total_income")
-	if err != nil {
-		return AccountBalanceView{}, err
+	// campusBalance ← t_user_expense_school.balance;special/income 也改取校区列(校区视图口径)
+	campusBalance, cberr := parseAccountAmount(campus.Balance, "t_user_expense_school.balance")
+	if cberr != nil {
+		return nil, cberr
 	}
-	expenditure, err := parseAccountAmount(row.TotalExpenditure, "t_user_expense.total_expenditure")
-	if err != nil {
-		return AccountBalanceView{}, err
+	out.CampusBalance = campusBalance
+	campusSpecial, cserr := parseAccountAmount(campus.SpecialExpenses, "t_user_expense_school.special_expenses")
+	if cserr != nil {
+		return nil, cserr
 	}
-	recommended, err := parseAccountAmount(row.TotalRecommendedAmount, "t_user_expense.total_recommended_amount")
-	if err != nil {
-		return AccountBalanceView{}, err
-	}
-	special, err := parseAccountAmount(row.SpecialExpenses, "t_user_expense.special_expenses")
-	if err != nil {
-		return AccountBalanceView{}, err
+	campusIncome, cierr := parseAccountAmount(campus.TotalIncome, "t_user_expense_school.total_income")
+	if cierr != nil {
+		return nil, cierr
 	}
-	return AccountBalanceView{
-		Balance: balance, Spend: spend, TotalIncome: income,
-		TotalExpenditure: expenditure, TotalRecommendedAmount: recommended, SpecialExpenses: special,
-	}, nil
-}
-
-// buildCampusAccountBalanceView 把校区账户行转成金额视图(逐列定点解析)。
-//
-// 入参:row 校区账户行(非 nil)。出参:*CampusAccountBalanceView;列金额非法 → ErrDBError。
-func buildCampusAccountBalanceView(row *model.UserExpenseSchool) (*CampusAccountBalanceView, error) {
-	balance, err := parseAccountAmount(row.Balance, "t_user_expense_school.balance")
-	if err != nil {
-		return nil, err
-	}
-	spend, err := parseAccountAmount(row.Spend, "t_user_expense_school.spend")
-	if err != nil {
-		return nil, err
-	}
-	income, err := parseAccountAmount(row.TotalIncome, "t_user_expense_school.total_income")
-	if err != nil {
-		return nil, err
-	}
-	expenditure, err := parseAccountAmount(row.TotalExpenditure, "t_user_expense_school.total_expenditure")
-	if err != nil {
-		return nil, err
-	}
-	recommended, err := parseAccountAmount(row.TotalRecommendedAmount, "t_user_expense_school.total_recommended_amount")
-	if err != nil {
-		return nil, err
-	}
-	special, err := parseAccountAmount(row.SpecialExpenses, "t_user_expense_school.special_expenses")
-	if err != nil {
-		return nil, err
-	}
-	return &CampusAccountBalanceView{
-		InstitutionID: row.InstitutionID, CampusID: row.CampusID, AccountID: row.ID,
-		Balance: balance, Spend: spend, TotalIncome: income,
-		TotalExpenditure: expenditure, TotalRecommendedAmount: recommended, SpecialExpenses: special,
-	}, nil
+	out.SpecialExpenses = campusSpecial
+	out.CumulativeIncome = campusIncome
+	return out, nil
 }
 
 // ---------------------------------------------------------------- 端点 11:流水列表
@@ -407,20 +346,22 @@ type RequestView struct {
 	Item string `json:"item"`
 	// Remark 备注(可空)。
 	Remark string `json:"remark"`
-	// RequestUserID 申请人。
-	RequestUserID string `json:"requestUserId"`
-	// RequestTime 申请时间(可空)。
+	// ApplicantID 申请人(← `request_user_id`;契约名 `applicantId`,语义听 dump:`request_user_id`)。
+	ApplicantID string `json:"applicantId"`
+	// RequestTime 申请时间(← **`request_time`**,业务时间;可空)。
 	RequestTime *time.Time `json:"requestTime"`
-	// ResponseUserID 审批人(可空)。
-	ResponseUserID string `json:"responseUserId"`
-	// ResponseTime 审批时间(可空)。
+	// ApproverID 审批人(← `response_user_id`;**未审批时为 null**,契约名 `approverId`)。
+	ApproverID *string `json:"approverId"`
+	// ResponseTime 审批时间(← **`response_time`**,业务时间;未审批为 null)。
 	ResponseTime *time.Time `json:"responseTime"`
 	// ResponseMsg 审批意见(可空)。
 	ResponseMsg string `json:"responseMsg"`
 	// Approved 审批状态(-2/-1/0/1)。
 	Approved int8 `json:"approved"`
-	// CreatedAt 创建时间。
-	CreatedAt time.Time `json:"createdAt"`
+	// ⚠ **不下发 `createdAt`**(契约 v1.5 裁决):
+	// `created_at` 是**迁移期新加的入库时间列**(`DEFAULT CURRENT_TIMESTAMP(3)`),
+	// 迁移后**所有历史工单的值都是"迁移执行那一刻"**——下发会让前端把全部历史工单排在同一天。
+	// 它不是业务时间,**任何端点都不得当业务时间下发**(要业务时间用 `requestTime`)。
 }
 
 // ListRequests 分页查工单(端点 12)。
@@ -461,8 +402,8 @@ func buildRequestView(row *model.UserBalanceRequest) (*RequestView, error) {
 		InstitutionID: row.InstitutionID, CampusID: row.CampusID,
 		ToInstitutionID: row.ToInstitutionID, ToCampusID: row.ToCampusID,
 		Money: amount, TransferAccountsType: row.TransferAccountsType,
-		RequestUserID: row.RequestUserID, RequestTime: row.RequestTime,
-		ResponseTime: row.ResponseTime, Approved: row.Approved, CreatedAt: row.CreatedAt,
+		ApplicantID: row.RequestUserID, ApproverID: row.ResponseUserID,
+		RequestTime: row.RequestTime, ResponseTime: row.ResponseTime, Approved: row.Approved,
 	}
 	if row.OrderNo != nil {
 		view.OrderNo = *row.OrderNo
@@ -473,9 +414,8 @@ func buildRequestView(row *model.UserBalanceRequest) (*RequestView, error) {
 	if row.Remark != nil {
 		view.Remark = *row.Remark
 	}
-	if row.ResponseUserID != nil {
-		view.ResponseUserID = *row.ResponseUserID
-	}
+	// approverId ← response_user_id(未审批 → null,用指针保留"未审批"语义)
+	view.ApproverID = row.ResponseUserID
 	if row.ResponseMsg != nil {
 		view.ResponseMsg = *row.ResponseMsg
 	}
@@ -519,11 +459,23 @@ type DashboardResult struct {
 	SpecialExpenses money.Amount `json:"specialExpenses"`
 	// NetReceivable 应收净额 = TotalIncome − TotalExpenditure(元;**派生列**,便于看板直接展示)。
 	NetReceivable money.Amount `json:"netReceivable"`
+	// BalanceDistribution 各校区余额分布(契约 v1.5 唯一可算项):
+	// ← `t_user_expense_school.balance` 按 `campus_id` 聚合(该表**有** schoolid/campusid)。
+	// 空 → `[]`。
+	BalanceDistribution []CampusBalanceItem `json:"balanceDistribution"`
 	// FlowsByCampusSupported 恒 `false`:明确告知前端"本端点不支持按区间/校区拆分流水",
 	// 免得前端以为字段缺失是自己拼错(口径见上方类型注释)。
 	FlowsByCampusSupported bool `json:"flowsByCampusSupported"`
 }
 
+// CampusBalanceItem 单校区余额(端点 21 的 `balanceDistribution` 元素)。
+type CampusBalanceItem struct {
+	// CampusID 校区 ID(← `t_user_expense_school.campus_id`;0 = 机构级账户)。
+	CampusID int64 `json:"campusId"`
+	// Balance 余额(元)← `t_user_expense_school.balance`。
+	Balance money.Amount `json:"balance"`
+}
+
 // Dashboard 财务仪表盘(端点 21,P2)。
 //
 // 入参:ctx;in DashboardInput(From/To 必填;CampusID 可选)。
@@ -545,9 +497,18 @@ func (s *FinanceQueryService) Dashboard(ctx context.Context, in DashboardInput)
 	if err != nil {
 		return nil, err
 	}
+	rows, derr := s.expense.ListCampusBalances(ctx, s.sc.InstitutionID, in.CampusID)
+	if derr != nil {
+		return nil, derr
+	}
+	dist := make([]CampusBalanceItem, 0, len(rows))
+	for i := range rows {
+		dist = append(dist, CampusBalanceItem{CampusID: rows[i].CampusID, Balance: rows[i].Balance})
+	}
 	return &DashboardResult{
 		From: in.From, To: in.To, CampusID: in.CampusID, Accounts: agg.Accounts,
-		Balance: agg.Balance, TotalIncome: agg.TotalIncome,
+		BalanceDistribution: dist,
+		Balance:             agg.Balance, TotalIncome: agg.TotalIncome,
 		TotalExpenditure: agg.TotalExpenditure, SpecialExpenses: agg.SpecialExpenses,
 		NetReceivable:          agg.TotalIncome.Sub(agg.TotalExpenditure),
 		FlowsByCampusSupported: false,