ソースを参照

feat(academic): AC-12 Handler A(财务 + 账单端点 1~17、21 + DTO/校验)

设计 §8 AC-12 交付物(5 个 handler 文件)+ 2 个支撑文件(理由见下):

新增 handler(internal/academic/handler/)
- handler.go:装配(Config/New)+ 四件公共助手
  · mustScope   → scope.FromFiber 取不到 = 1001(验收 ③)
  · validateReq → infra/validator.Validate 失败 = 2001(验收 ② 前半)
  · parseMoney  → 金额**字符串**经 money.Parse,非法 = 2002(验收 ② 后半 / ④)
  · parsePage   → 只转调 response.ParsePage(**全仓唯一**钳制点,pageSize>200→200,验收 ⑤)
  另含 18 条路径常量,**一律不含 /api**(§4 口径表:/api 是代理层外部前缀,不进后端路由表)
- dto_fund.go:请求 DTO(金额字段一律 `string` + validate tag;白名单/金额值非法归 2002)
- fund_handler.go:端点 1~16(账户快照/可退额/充值/消费/退费/自定义退费/转结/坏账/清零/
  累计消费/流水/工单列表/工单创建/通过/驳回/撤销),每个方法头标端点号 + 服务方法 + 错误码
- bill_handler.go:端点 17(/student/bill,汇总+明细分页两段)与端点 21(dashboard,P2)
- fund_handler_test.go:11 个用例覆盖六条验收 + 3 条业务冒烟

新增支撑服务(设计 §8 AC-12 清单未列,属**偏差**,理由如下)
- service/finance_query_service.go:端点 1/11/12/21 的只读编排(账户快照 / 流水 / 工单 / 仪表盘)。
  存在的理由:Handler 层纪律是"不碰 DB"(infra/scope 与 org/identity handler 同款);
  塞进 FundService 又会让"这个服务会不会动钱"不可一眼判断。
  · ListLogs 的硬边界:t_user_expense_log **无** institution_id/campus_id(§3.8-f 约束 2)
    ⇒ 非超管**必须**带 userId(否则 2001 + 明确文案),绝不静默返回全机构全量(那才是真越权)。
  · Dashboard:流水表无 campus 列 ⇒ 期间发生额**无法**按校区聚合,故返回"账户累计列横截面"
    并显式置 flowsByCampusSupported=false(不让前端以为是字段缺失)。
- service/finance_request_service.go:端点 13~16 的工单状态机 + 审批资金编排。
  存在的理由:端点 14 不是"改个状态"——必须**按类型分派**到对应资金方法(100 充值 / 101 坏账 /
  202 退费 / 300 转结)且资金写入与状态回写**同一事务**(§10.3);放在 Handler 会同时违反
  "Handler 不写业务"与"事务边界只在 Service"。
- repository 追加(**纯新增方法,未改既有签名**):ExpenseRepo.ListLogsFiltered /
  AggregateCampusAccounts、BalanceRequestRepo.ListFiltered / GetByID / UpdateResponseMsg。

复用而非重写(六条验收之外的自查)
- 金额全部 money.Amount 定点;禁止 SQL SUM(decimal),聚合在 Go 侧累加
- 错误码一律引 apperr / infraerrors 常量,Handler 内**无 31xx 数字字面量**
- 端点 5 的 changed **恒 true**(§3.8-R6 定稿:money<=0 一律 2002),未按"实际变化"置 false
- 端点 14 的幂等键 = 工单 biz_no:资金侧撞 uk_request_type 即回放首次结果;二次审批 CAS 影响 0 行 → 3002

验证(§5.1 纪律 4,三条变异实测均"改错即红、还原即绿")
- 变异① 分页不钳制(parsePage 直读 query)→ TestPageSizeClamp 红(实得 500/期望 200)
- 变异② 金额 DTO 改 float64 → TestMoneyMustBeJSONString 红(实得 **200**,即钱被按浮点接受)
- 变异③ 去掉某端点 scope 守卫 → TestMissingScopeIs1001 红(实得 2001/期望 1001)
- 脱敏:TestNoSensitiveFields 断言响应文本不含 imgs/private.png/payReturnData,且**必须含**被查工单
  (防"空响应也算通过"的假绿)
- 跨机构:工单 approve 别机构单 → 3001,列表 total=0(Scoped 生效)

测试:handler 11 用例全绿;go build / go vet / go test -short ./... 全绿;新增文件 gofmt 零差异。
Alex 14 時間 前
親
コミット
38ff4dcd18

+ 141 - 0
internal/academic/handler/bill_handler.go

@@ -0,0 +1,141 @@
+// 账单与仪表盘端点处理器(端点 17 `GET /student/bill`、端点 21 `GET /admin/finance/dashboard`)。
+//
+// 依据:unified/03-migration/academic-domain-design.md
+//   - §4.1 端点 17(`/student/bill`,SP25 get_bill)与端点 21(`/admin/finance/dashboard`,P2)
+//   - §3.4 #25:账单 = **汇总对象 + 明细分页** 两段返回
+//   - §3.8-f 约束 2:`t_user_expense_log` 无 campus 列 ⇒ 仪表盘不给"按校区拆流水"
+//
+// 为什么端点 17 挂在 `/student` 段却写在 finance handler 文件组里:它复用 AC-10 的 BillService
+// (同一 SP),且与财务对账是同一份数字口径;段位只影响**角色白名单与挂载点**(AC-14 接),
+// 不影响本文件的方法归属。
+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"
+)
+
+// billSelfRoles 允许"查他人账单"的角色(`/student/bill?userId=` 的越权判定)。
+//
+// 语义:
+//   - 家长代查(parent)、平台超管(super_admin)、财务对账(finance)、教务(academic)可指定 userId;
+//   - 学生(student)**只能查自己**(userId 与作用域不一致 → 1003)。
+//
+// 为什么家长放行:移动端 `/student/*` 的角色白名单本就是 `student,parent`(§4.1 端点 17),
+// 家长 token 的 canonical_user_id 是家长本人,不可能是学生 id,若不放行则家长端永远查不到孩子账单。
+// 孩子的归属校验由 `BillService` 注入的 MembershipChecker 兜底(不在本表内做"亲子关系"判定——
+// 那需要 org 域的家庭关系表,属跨域建模)。
+var billSelfRoles = map[string]struct{}{
+	"super_admin": {},
+	"parent":      {},
+	"finance":     {},
+	"academic":    {},
+}
+
+// GetBill 学生账单(端点 17 `GET /student/bill`,SP25 `sp_user_expense_get_bill`)。
+//
+// 入参(query):
+//   - `userId` 学生(可空:空 = 作用域本人;非空且角色不在 billSelfRoles → 1003);
+//   - `campusIds` 校区逗号串(dump 第二入参;**只回显、不过滤**——dump 里它是死参数);
+//   - `from`/`to` 时间窗(**`to` 不含上界**:要含当日必须传次日或 23:59:59);
+//   - `page`/`pageSize` 分页(钳制上限 200)。
+//
+// 出参(成功):`{summary{9 列 + 2 个对账辅助列 + campusIds}, list[], total, page, pageSize, pages, from, to}`。
+// 出参(失败):1001(无作用域)/2001(缺 from/to、from>to)/2002(campusIds 或时间格式非法)/1003/1004/5002。
+//
+// 响应形状说明:账单是"汇总 + 明细"两段,**不能**用 `response.Page`(那会把 summary 挤掉),
+// 因此这里用 `response.OK` 手工拼 `PageData` 的 5 个键(list/total/page/pageSize/pages),
+// 保证前端 `useListPage` 的读法一致(分页元信息仍在 data 内,不散落顶层)。
+func (h *Handler) GetBill(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	target := strings.TrimSpace(c.Query("userId"))
+	if target == "" {
+		target = sc.UserID
+	}
+	if target != sc.UserID && !sc.SuperAdmin {
+		if _, ok := billSelfRoles[sc.Role]; !ok {
+			return response.Fail(c, infraerrors.WithCause(infraerrors.ErrForbidden,
+				errString("该角色只能查询本人账单")))
+		}
+	}
+	from, hasFrom, ferr := parseTimeParam(c.Query("from"), "from")
+	if ferr != nil {
+		return response.Fail(c, ferr)
+	}
+	to, hasTo, terr := parseTimeParam(c.Query("to"), "to")
+	if terr != nil {
+		return response.Fail(c, terr)
+	}
+	if !hasFrom || !hasTo {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("账单查询需要 from 与 to(账户区间对账的两个边界都是必填)")))
+	}
+	page, pageSize := parsePage(c)
+	svc := h.servicesFor(sc)
+	res, err := svc.bill.GetBill(c.Context(), service.GetBillInput{
+		UserID: target, CampusIDs: c.Query("campusIds"),
+		From: from, To: to, Page: page, PageSize: pageSize,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	pages := int64(0)
+	if res.PageSize > 0 {
+		pages = (res.Total + int64(res.PageSize) - 1) / int64(res.PageSize)
+	}
+	return response.OK(c, fiber.Map{
+		"summary":  res.Summary,
+		"list":     res.Details,
+		"total":    res.Total,
+		"page":     res.Page,
+		"pageSize": res.PageSize,
+		"pages":    pages,
+		"from":     res.From,
+		"to":       res.To,
+	})
+}
+
+// GetDashboard 财务仪表盘(端点 21 `GET /admin/finance/dashboard`,**P2**)。
+//
+// 入参(query):`from`/`to`(必填)、`campusId`(可选,>0 只统计该校区)。
+// 出参(成功):`DashboardResult`(余额分布 + 累计收支 + `flowsByCampusSupported=false`)。
+// 出参(失败):1001/2001(缺时间窗 / from>to)/5002。
+//
+// ⚠ 口径(与 service.DashboardResult 注释同源):本端点是**账户累计列横截面**,
+// 不是 `[from,to]` 区间发生额——因为 `t_user_expense_log` 没有 campus/机构列,
+// 期间流水在数据层**无法**按校区聚合(§3.8-f 约束 2)。响应里显式给
+// `flowsByCampusSupported=false` 告知前端这个边界,避免被当成"字段漏了"。
+func (h *Handler) GetDashboard(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	from, hasFrom, ferr := parseTimeParam(c.Query("from"), "from")
+	if ferr != nil {
+		return response.Fail(c, ferr)
+	}
+	to, hasTo, terr := parseTimeParam(c.Query("to"), "to")
+	if terr != nil {
+		return response.Fail(c, terr)
+	}
+	if !hasFrom || !hasTo {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("dashboard 需要 from 与 to")))
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.query.Dashboard(c.Context(), service.DashboardInput{
+		From: from, To: to, CampusID: parseIDParam(c.Query("campusId")),
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}

+ 188 - 0
internal/academic/handler/dto_fund.go

@@ -0,0 +1,188 @@
+// 财务域请求 DTO(端点 1~17、21 的入参形状 + 少量响应拼装结构)。
+//
+// 依据:unified/03-migration/academic-domain-design.md §4.1(资金)/ §4.2 端点 17(账单)/ §4.6(响应契约)。
+//
+// ---------------------------------------------------------------------------
+// 三条 DTO 铁律(AC-12 验收项 ②/④/⑥ 的落点)
+// ---------------------------------------------------------------------------
+//
+//  1. **金额一律 `string`**(`"100.00"`),Handler 用 `parseMoney` → `money.Parse` 解析。
+//     绝不声明成 `float64`/`money.Amount`:前者引入二进制浮点(0.1+0.2 级误差进对账),
+//     后者让 JSON number 也能解码(等于偷偷接受 float)。前端契约 v1.2 已按字符串对接。
+//  2. **必填/格式 2001 由 `validate` tag 表达**(`required` / `gt=0` / `oneof=...`);
+//     **值非法 2002 由 Handler 显式判定**(金额 Parse 失败、枚举不在白名单)。
+//     两者分工见 `parseMoney` 注释,不要互相顶替。
+//  3. **响应 DTO 逐字段声明,不直接序列化 model**:`imgs`(凭证)/`pay_return_data`(渠道原始报文)
+//     这类字段必须"压根不在结构里",而不是"靠 model 的 `json:"-"` 兜底"——
+//     兜底只要有人加一个 `json:"-"` 漏掉字母就会泄露(AC-12 验收 ⑥)。
+//
+// 命名:字段用 camelCase JSON(前端契约同源),Go 侧保持缩写惯例(ID 后缀全大写)。
+package handler
+
+import "time"
+
+// RechargeReq 端点 3 充值请求体(`POST /admin/finance/recharges`)。
+//
+// 字段:
+//   - UserID 充值用户(ULID;必填);
+//   - InstitutionID / CampusID 机构/校区(必填 >0:资金落库必须有明确归属);
+//   - Money 金额字符串(必填;>0 由 Service 校验 → 2002);
+//   - Type 流水类型(可空:<100 一律归 100,对齐 dump `IF inputType<100`);
+//   - RequestID 幂等请求号(可空:空 = 不享幂等,重复提交会二次入账——前端务必传);
+//   - Remark 备注(可空:按类型取默认文案)。
+type RechargeReq struct {
+	UserID        string `json:"userId" validate:"required,max=26"`
+	InstitutionID int64  `json:"institutionId" validate:"required,gt=0"`
+	CampusID      int64  `json:"campusId" validate:"required,gt=0"`
+	Money         string `json:"money" validate:"required"`
+	Type          int32  `json:"type"`
+	RequestID     string `json:"requestId" validate:"max=64"`
+	Remark        string `json:"remark" validate:"max=1000"`
+}
+
+// ExpenditureReq 端点 4 消费请求体(`POST /admin/finance/expenditures`)。
+//
+// `Type` 必填且必须属消费白名单(101/104/105/200/201/204/205)——不在白名单 → **2002**
+// (白名单来自 model.ExpenseLogType*,Service 也会再校验一次;Handler 抢先是为了给出准确字段名)。
+type ExpenditureReq struct {
+	UserID        string `json:"userId" validate:"required,max=26"`
+	InstitutionID int64  `json:"institutionId" validate:"required,gt=0"`
+	CampusID      int64  `json:"campusId" validate:"required,gt=0"`
+	Money         string `json:"money" validate:"required"`
+	Type          int32  `json:"type" validate:"required"`
+	RequestID     string `json:"requestId" validate:"max=64"`
+	Remark        string `json:"remark" validate:"max=1000"`
+}
+
+// RefundReq 端点 5 退费请求体(`POST /admin/finance/refunds`)。
+//
+// `Type` 必填(流水类型,通常 207 余额退费);`Money<=0` 由 Service 统一 2002(§3.8-R6),
+// 因此响应体的 `changed` **恒 true**(字段仅为契约兼容保留,见 §4.1 端点 5 注)。
+type RefundReq struct {
+	UserID        string `json:"userId" validate:"required,max=26"`
+	InstitutionID int64  `json:"institutionId" validate:"required,gt=0"`
+	CampusID      int64  `json:"campusId" validate:"required,gt=0"`
+	Money         string `json:"money" validate:"required"`
+	Type          int32  `json:"type" validate:"required"`
+	RequestID     string `json:"requestId" validate:"max=64"`
+	Remark        string `json:"remark" validate:"max=1000"`
+}
+
+// CustomRefundReq 端点 6 自定义退费请求体(`POST /admin/finance/refunds/custom`)。
+//
+// `RequestID` **必填**(设计 §4.1 端点 6 原文):本流程含"活动失效"副作用,
+// 没有幂等键时重试会把同一批活动重复置为失效并重复写 207/108 双流水。
+type CustomRefundReq struct {
+	UserID        string `json:"userId" validate:"required,max=26"`
+	InstitutionID int64  `json:"institutionId" validate:"required,gt=0"`
+	CampusID      int64  `json:"campusId" validate:"required,gt=0"`
+	Money         string `json:"money" validate:"required"`
+	RequestID     string `json:"requestId" validate:"required,max=64"`
+	Remark        string `json:"remark" validate:"max=1000"`
+}
+
+// TransferReq 端点 7 课费转结请求体(`POST /admin/finance/transfers`)。
+//
+// 四个维度都必填:转出/转入的机构+校区(跨机构转结需超管作用域,Service 层校验)。
+type TransferReq struct {
+	UserID            string `json:"userId" validate:"required,max=26"`
+	FromInstitutionID int64  `json:"fromInstitutionId" validate:"required,gt=0"`
+	FromCampusID      int64  `json:"fromCampusId" validate:"required,gt=0"`
+	ToInstitutionID   int64  `json:"toInstitutionId" validate:"required,gt=0"`
+	ToCampusID        int64  `json:"toCampusId" validate:"required,gt=0"`
+	Money             string `json:"money" validate:"required"`
+	RequestID         string `json:"requestId" validate:"max=64"`
+	Remark            string `json:"remark" validate:"max=1000"`
+}
+
+// BadDebtReq 端点 8 坏账请求体(`POST /admin/finance/bad-debts`)。
+//
+// ⚠ dump 口径(§3.8-A18):独立坏账是 **`balance` 与 `special_expenses` 双增**,
+// 不是"只加特殊费用"——响应里 `balance` 已包含本次增量。
+type BadDebtReq struct {
+	UserID        string `json:"userId" validate:"required,max=26"`
+	InstitutionID int64  `json:"institutionId" validate:"required,gt=0"`
+	CampusID      int64  `json:"campusId" validate:"required,gt=0"`
+	Money         string `json:"money" validate:"required"`
+	RequestID     string `json:"requestId" validate:"max=64"`
+	Remark        string `json:"remark" validate:"max=1000"`
+}
+
+// ClearBalanceReq 端点 9 余额清零请求体(`POST /admin/finance/cleared-balances`)。
+//
+// **无金额入参**(清零额 = 清零前校区余额,由 Service 读库决定);dump 亦无请求号,
+// 故本端点不享幂等(重复调用第二次会因"余额已 0"命中 3120)。
+type ClearBalanceReq struct {
+	UserID        string `json:"userId" validate:"required,max=26"`
+	InstitutionID int64  `json:"institutionId" validate:"required,gt=0"`
+	CampusID      int64  `json:"campusId" validate:"required,gt=0"`
+	Remark        string `json:"remark" validate:"max=1000"`
+}
+
+// SpendReq 端点 10 累计消费请求体(`POST /admin/finance/spends`)。
+//
+// `SpendMoney` 语义(§3.1 #11):累加到 `spend` 列(**不动 balance**);
+// 负数由 Service 归 0(dump INSERT 分支同口径),故 Handler 不做 >0 断言。
+type SpendReq struct {
+	UserID        string `json:"userId" validate:"required,max=26"`
+	InstitutionID int64  `json:"institutionId" validate:"required,gt=0"`
+	CampusID      int64  `json:"campusId" validate:"required,gt=0"`
+	SpendMoney    string `json:"spendMoney" validate:"required"`
+}
+
+// CreateRequestReq 端点 13 创建余额工单请求体(`POST /admin/finance/requests`)。
+//
+// 字段:
+//   - Type 工单类型(必填;白名单 100/101/202/203/300 → 非法 2002);
+//   - ToInstitutionID/ToCampusID 仅 type=300 转结使用(其余类型传了也忽略);
+//   - Imgs 凭证图片(**只写不读**:响应里不会出现,见 RequestView)。
+type CreateRequestReq struct {
+	Type                 int32  `json:"type" validate:"required"`
+	UserID               string `json:"userId" validate:"required,max=26"`
+	InstitutionID        int64  `json:"institutionId" validate:"required,gt=0"`
+	CampusID             int64  `json:"campusId"`
+	ToInstitutionID      int64  `json:"toInstitutionId"`
+	ToCampusID           int64  `json:"toCampusId"`
+	Money                string `json:"money" validate:"required"`
+	TransferAccountsType int8   `json:"transferAccountsType"`
+	OrderNo              string `json:"orderNo" validate:"max=50"`
+	Item                 string `json:"item" validate:"max=1000"`
+	Imgs                 string `json:"imgs" validate:"max=65535"`
+	Remark               string `json:"remark" validate:"max=1000"`
+}
+
+// ApproveReq 端点 14 审批通过请求体(`POST /admin/finance/requests/:id/approve`)。
+//
+// 无必填字段(审批意见可空)——**没有 body 也能通过**(前端"一键通过"场景)。
+type ApproveReq struct {
+	ResponseMsg string `json:"responseMsg" validate:"max=1000"`
+}
+
+// RejectReq 端点 15 驳回请求体(`POST /admin/finance/requests/:id/reject`)。
+//
+// `ResponseMsg` **必填**:驳回必须留理由(对账/申诉依据)。
+type RejectReq struct {
+	ResponseMsg string `json:"responseMsg" validate:"required,max=1000"`
+}
+
+// BillQuery 端点 17 账单查询参数(`GET /student/bill`)。
+//
+// 字段:
+//   - UserID 目标学生(**非本人需 super_admin/parent**,见 BillHandler 权限判定);
+//   - CampusIDs 校区逗号串(dump 第二入参;**只回显不过滤**,见 service.GetBillInput);
+//   - From/To 时间窗(`to` **不含**上界);
+//   - Page/PageSize 分页(已钳制)。
+type BillQuery struct {
+	// UserID 学生(空 = 用作用域用户)。
+	UserID string
+	// CampusIDs 校区逗号串(可空)。
+	CampusIDs string
+	// From 起始时间(含)。
+	From time.Time
+	// To 结束时间(不含)。
+	To time.Time
+	// Page 页码。
+	Page int
+	// PageSize 每页条数。
+	PageSize int
+}

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

@@ -0,0 +1,636 @@
+// 财务端点处理器(端点 1~16,`/admin/finance/*`)。
+//
+// 依据:unified/03-migration/academic-domain-design.md §4.1(逐行对应 18 个端点里的 16 个);
+// 端点 17/21 在 bill_handler.go。路由挂载在 AC-14 完成(本文件只定义方法 + 路径常量)。
+//
+// ---------------------------------------------------------------------------
+// 端点 → 方法 → 服务(一览,评审按此表核对覆盖)
+// ---------------------------------------------------------------------------
+//
+//	1  GET  /admin/finance/accounts/:userId              GetAccount        → FinanceQueryService.AccountSnapshot
+//	2  GET  /admin/finance/accounts/:userId/refundable   GetRefundable     → FundService.RefundableAmount
+//	3  POST /admin/finance/recharges                     CreateRecharge    → FundService.Recharge
+//	4  POST /admin/finance/expenditures                  CreateExpenditure → FundService.Expenditure
+//	5  POST /admin/finance/refunds                       CreateRefund      → FundService.Refund
+//	6  POST /admin/finance/refunds/custom                CreateCustomRefund→ RefundService.CustomRefund
+//	7  POST /admin/finance/transfers                     CreateTransfer    → FundService.Transfer
+//	8  POST /admin/finance/bad-debts                     CreateBadDebt     → FundService.BadDebt
+//	9  POST /admin/finance/cleared-balances              ClearBalance      → RefundService.ClearBalance
+//	10 POST /admin/finance/spends                        AddSpend          → FundService.AccumulateSpend
+//	11 GET  /admin/finance/logs                          ListLogs          → FinanceQueryService.ListLogs
+//	12 GET  /admin/finance/requests                      ListRequests      → FinanceQueryService.ListRequests
+//	13 POST /admin/finance/requests                      CreateRequest     → BalanceRequestService.Create
+//	14 POST /admin/finance/requests/:id/approve          ApproveRequest    → BalanceRequestService.Approve
+//	15 POST /admin/finance/requests/:id/reject           RejectRequest     → BalanceRequestService.Reject
+//	16 POST /admin/finance/requests/:id/cancel           CancelRequest     → BalanceRequestService.Cancel
+//
+// ---------------------------------------------------------------------------
+// 三条口径备注(都是"看起来能省、其实一改就错"的地方)
+// ---------------------------------------------------------------------------
+//
+//  1. **端点 5 的 `changed` 恒 true**(§3.8-R6 定稿:金额 <=0 一律入口 2002):
+//     字段仅为契约兼容保留,语义 = "成功即发生账户写入"。**不要**改成"按实际变化置 false"——
+//     前端契约 v1.2 已按恒 true 对接,改成 false 会让前端走"未发生写入"的降级分支(静默不刷新)。
+//  2. **错误码一律引 apperr 常量**(31xx 段 19 个码),Handler 内**不出现数字字面量**:
+//     这样"错误码是否越界/是否重复"由 `apperr.codes_test.go` 统一守,handler 改动不会漏检。
+//     Handler 自己需要的通用码(1001/2001/2002/5002)引 infra/errors 的常量。
+//  3. **枚举白名单在 Handler 与 Service 各校验一次**:Handler 抢先是为了给出**字段级**文案
+//     ("type 不在消费白名单"),Service 再校验是红线(Handler 可被绕过/复用,业务不变量不能只靠接入层)。
+package handler
+
+import (
+	"strings"
+
+	"github.com/gofiber/fiber/v2"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/service"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/infra/response"
+)
+
+// billSpendTypes 端点 4 允许的消费类型白名单(与 dump `sp_user_expense_school_expenditure` 一致)。
+var expenditureAllowedTypes = map[int32]struct{}{
+	model.ExpenseLogTypeCourseCampus:  {}, // 101
+	model.ExpenseLogTypeCourseSchoolA: {}, // 104
+	model.ExpenseLogTypeCourseSchoolB: {}, // 105
+	model.ExpenseLogTypeHistoryDeduct: {}, // 200
+	model.ExpenseLogTypeCourseCampusB: {}, // 201
+	model.ExpenseLogTypeCourseSchoolC: {}, // 204
+	model.ExpenseLogTypeCourseSchoolD: {}, // 205
+}
+
+// requestAllowedTypes 端点 13 允许创建的工单类型白名单(§2「4.」类型枚举)。
+var requestAllowedTypes = map[int32]struct{}{
+	model.BalanceRequestTypeRecharge:       {}, // 100
+	model.BalanceRequestTypeBadDebt:        {}, // 101
+	model.BalanceRequestTypeRefund:         {}, // 202
+	model.BalanceRequestTypeCancelRecharge: {}, // 203
+	model.BalanceRequestTypeTransfer:       {}, // 300
+}
+
+// ---------------------------------------------------------------- 端点 1:账户快照
+
+// GetAccount 账户快照(端点 1 `GET /admin/finance/accounts/:userId`)。
+//
+// 入参(query,均可选):`institutionId`/`campusId`——越权判定输入 + 校区账户定位维度。
+// 出参(成功):`AccountSnapshot{userId, global{6 列}, campus|null}`。
+// 出参(失败):1001(无作用域)/2001(缺 userId)/1004(越权)/5002。
+func (h *Handler) GetAccount(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	userID := strings.TrimSpace(c.Params("userId"))
+	if userID == "" {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("userId 不能为空")))
+	}
+	svc := h.servicesFor(sc)
+	snapshot, err := svc.query.AccountSnapshot(c.Context(), userID,
+		parseIDParam(c.Query("institutionId")), parseIDParam(c.Query("campusId")))
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, snapshot)
+}
+
+// ---------------------------------------------------------------- 端点 2:可退金额
+
+// GetRefundable 可退金额(端点 2 `GET /admin/finance/accounts/:userId/refundable`)。
+//
+// 入参(query,**必填**):`institutionId`/`campusId`(缺 → 2001,由 Service.validateUserScope 判定)。
+// 出参(成功):`{refundableAmount}`(**两步修正**口径:截到全局余额 + 减已用赠送额,§3.1 #5)。
+// 出参(失败):1001/2001/2370… → 具体为 2001/3140/1004/5002。
+//
+// ⚠ 与端点 6 的同名字段**不是一回事**:端点 6 的 `refundableAmount` 是"校区 balance 本身"(SP9 口径),
+// 本端点返回的是两步修正后的值。前端/用例**不得**互相当基准(§4.6 口径警示)。
+func (h *Handler) GetRefundable(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	userID := strings.TrimSpace(c.Params("userId"))
+	if userID == "" {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("userId 不能为空")))
+	}
+	svc := h.servicesFor(sc)
+	amount, err := svc.fund.RefundableAmount(c.Context(), userID,
+		parseIDParam(c.Query("institutionId")), parseIDParam(c.Query("campusId")))
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, fiber.Map{"refundableAmount": amount})
+}
+
+// ---------------------------------------------------------------- 端点 3:充值
+
+// CreateRecharge 充值(端点 3 `POST /admin/finance/recharges`,SP1 `sp_user_expense_recharge`)。
+//
+// 出参(成功):`{logId,balance,repeated}`(`balance` = 充值后**校区**余额,`repeated=true` 表示幂等命中)。
+// 出参(失败):1001/2001(缺参)/2002(金额非法或 <=0)/1004/5002。
+func (h *Handler) CreateRecharge(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req RechargeReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	amount, ok := parseMoney(c, req.Money, "money")
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.fund.Recharge(c.Context(), service.RechargeInput{
+		UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
+		Money: amount, Type: req.Type, RequestID: strings.TrimSpace(req.RequestID),
+		Remark: req.Remark, OperatorID: sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// CreateExpenditure 消费(端点 4 `POST /admin/finance/expenditures`,SP3)。
+//
+// 出参(成功):`{logId,balance,repeated}`。
+// 出参(失败):1001/2001/2002(金额非法 / type 不在白名单)/1004/5002。
+func (h *Handler) CreateExpenditure(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req ExpenditureReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	if _, ok := expenditureAllowedTypes[req.Type]; !ok {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("type 不在消费白名单(101/104/105/200/201/204/205)")))
+	}
+	amount, ok := parseMoney(c, req.Money, "money")
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.fund.Expenditure(c.Context(), service.ExpenditureInput{
+		UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
+		Money: amount, Type: req.Type, RequestID: strings.TrimSpace(req.RequestID),
+		Remark: req.Remark, OperatorID: sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// CreateRefund 退费(端点 5 `POST /admin/finance/refunds`,SP4)。
+//
+// 出参(成功):`{logId,balance,changed,repeated}`——**`changed` 恒 true**(§3.8-R6:
+// `money<=0` 一律 2002,故"成功"必然伴随账户写入;字段仅契约兼容保留)。
+// 出参(失败):1001/2001/2002(金额非法/<=0 或 type 非法)/1004/5002。
+func (h *Handler) CreateRefund(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req RefundReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	amount, ok := parseMoney(c, req.Money, "money")
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.fund.Refund(c.Context(), service.RefundInput{
+		UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
+		Money: amount, Type: req.Type, RequestID: strings.TrimSpace(req.RequestID),
+		Remark: req.Remark, OperatorID: sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// CreateCustomRefund 自定义退费(端点 6 `POST /admin/finance/refunds/custom`,SP9 双 log 坏账)。
+//
+// 出参(成功):`CustomRefundResult`(§4.6 逐字段;`warnings[]` 空时 `[]`、`logId108` 无坏账时 null)。
+// 出参(失败):1001/2001(含缺 requestId)/2002(金额非法或 <=0)/3102(校区余额 <=0)/
+// 3104(校区不存在)/1004/5002。
+func (h *Handler) CreateCustomRefund(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req CustomRefundReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	amount, ok := parseMoney(c, req.Money, "money")
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.refund.CustomRefund(c.Context(), service.CustomRefundInput{
+		UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
+		Money: amount, RequestID: strings.TrimSpace(req.RequestID),
+		Remark: req.Remark, OperatorID: sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// CreateTransfer 课费转结(端点 7 `POST /admin/finance/transfers`,SP6)。
+//
+// 出参(成功):`{logId106,logId206,fromBalance,toBalance,repeated}`
+// (两条流水共用同一 `request_id`、`type` 不同故不撞幂等键;`balance` 本身不动)。
+// 出参(失败):1001/2001/2002/3140(转入方机构冲突)/3001(源账户缺失)/1004/5002。
+func (h *Handler) CreateTransfer(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req TransferReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	amount, ok := parseMoney(c, req.Money, "money")
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.fund.Transfer(c.Context(), service.TransferInput{
+		UserID:            req.UserID,
+		FromInstitutionID: req.FromInstitutionID, FromCampusID: req.FromCampusID,
+		ToInstitutionID: req.ToInstitutionID, ToCampusID: req.ToCampusID,
+		Money: amount, RequestID: strings.TrimSpace(req.RequestID),
+		Remark: req.Remark, OperatorID: sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// CreateBadDebt 坏账处理(端点 8 `POST /admin/finance/bad-debts`,SP7)。
+//
+// 出参(成功):`{logId,specialExpenses,balance,repeated}`
+// (⚠ dump 口径:坏账是 `balance` 与 `special_expenses` **双增**,故 `balance` 已含本次增量)。
+// 出参(失败):1001/2001/2002/1004/5002。
+func (h *Handler) CreateBadDebt(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req BadDebtReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	amount, ok := parseMoney(c, req.Money, "money")
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.fund.BadDebt(c.Context(), service.BadDebtInput{
+		UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
+		Money: amount, RequestID: strings.TrimSpace(req.RequestID),
+		Remark: req.Remark, OperatorID: sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// ClearBalance 余额清零(端点 9 `POST /admin/finance/cleared-balances`,SP10)。
+//
+// 出参(成功):`{logId208,cleared,balance,totalBalance,specialExpenses,expiredActivityIds,repeated}`。
+// 出参(失败):1001/2001/3120(三条前置校验之一不满足)/1004/5002。
+func (h *Handler) ClearBalance(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req ClearBalanceReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.refund.ClearBalance(c.Context(), service.ClearBalanceInput{
+		UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
+		Remark: req.Remark, OperatorID: sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// AddSpend 累计消费(端点 10 `POST /admin/finance/spends`,SP11)。
+//
+// 出参(成功):`{spend}`(**`spend` 列,不是 `balance`**——本端点不动余额)。
+// 出参(失败):1001/2001/2002/1004/5002。
+func (h *Handler) AddSpend(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req SpendReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	amount, ok := parseMoney(c, req.SpendMoney, "spendMoney")
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.fund.AccumulateSpend(c.Context(), service.SpendInput{
+		UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
+		SpendMoney: amount,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// ---------------------------------------------------------------- 端点 11 / 12:列表
+
+// ListLogs 流水列表(端点 11 `GET /admin/finance/logs`)。
+//
+// 入参(query):`userId`(**非超管必填**,理由见 FinanceQueryService.ListLogs)、
+// `type`、`from`、`to`、`page`、`pageSize`(钳制上限 200)。
+// 出参(成功):`PageData{list,total,page,pageSize,pages}`。
+// 出参(失败):1001/2001(缺 userId 且非超管)/2002(type/时间格式非法)/1004/5002。
+func (h *Handler) ListLogs(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	typeFilter, ok := parseOptionalInt32(c, "type")
+	if !ok {
+		return nil
+	}
+	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)
+	svc := h.servicesFor(sc)
+	in := service.ListLogsInput{
+		UserID: c.Query("userId"), Type: typeFilter, Page: page, PageSize: pageSize,
+	}
+	if !from.IsZero() {
+		in.From = &from
+	}
+	if !to.IsZero() {
+		in.To = &to
+	}
+	rows, total, err := svc.query.ListLogs(c.Context(), in)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.Page(c, rows, page, pageSize, total)
+}
+
+// ListRequests 工单列表(端点 12 `GET /admin/finance/requests`)。
+//
+// 入参(query):`type`(100/101/202/203/300)、`approved`(-2/-1/0/1)、`userId`、分页。
+// 出参(成功):`PageData`;list 元素为 `RequestView`(**不含 `imgs`**,AC-12 验收 ⑥)。
+// 出参(失败):1001/2002(type/approved 非法)/5002。
+func (h *Handler) ListRequests(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	typeFilter, ok := parseOptionalInt32(c, "type")
+	if !ok {
+		return nil
+	}
+	approved, ok := parseOptionalInt8(c, "approved")
+	if !ok {
+		return nil
+	}
+	page, pageSize := parsePage(c)
+	svc := h.servicesFor(sc)
+	rows, total, err := svc.query.ListRequests(c.Context(), service.ListRequestsInput{
+		Type: typeFilter, Approved: approved, UserID: c.Query("userId"),
+		Page: page, PageSize: pageSize,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.Page(c, rows, page, pageSize, total)
+}
+
+// ---------------------------------------------------------------- 端点 13~16:工单写路径
+
+// CreateRequest 创建余额工单(端点 13 `POST /admin/finance/requests`)。
+//
+// 出参(成功):`{id,bizNo}`(`approved` 恒 0 —— 资金变动只由端点 14 触发)。
+// 出参(失败):1001/2001(缺参/类型缺失)/2002(金额非法/类型不在白名单)/5002。
+func (h *Handler) CreateRequest(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req CreateRequestReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	if _, ok := requestAllowedTypes[req.Type]; !ok {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("type 不在工单白名单(100/101/202/203/300)")))
+	}
+	amount, ok := parseMoney(c, req.Money, "money")
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.requests.Create(c.Context(), service.CreateRequestInput{
+		Type: req.Type, UserID: req.UserID,
+		InstitutionID: req.InstitutionID, CampusID: req.CampusID,
+		ToInstitutionID: req.ToInstitutionID, ToCampusID: req.ToCampusID,
+		Money: amount, TransferAccountsType: req.TransferAccountsType,
+		OrderNo: req.OrderNo, Item: req.Item, Imgs: req.Imgs, Remark: req.Remark,
+		RequestUserID: sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// ApproveRequest 审批通过(端点 14 `POST /admin/finance/requests/:id/approve`)。
+//
+// **资金触发点**:同一事务内先调对应资金方法(幂等键 = 工单 `bizNo`)、后 CAS 回写 `approved=1`。
+// 出参(成功):`{approved:1,type,bizNo,result:{...},repeated}`。
+// 出参(失败):1001/2001/3002(工单已处理/并发被抢)/3140(类型 203 不支持,或转结缺转入维度)/
+// 资金方法的 31xx(3101/3102/3104/3120…)/1004/5002。
+func (h *Handler) ApproveRequest(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	id := parseIDParam(c.Params("id"))
+	if id <= 0 {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("工单 id 非法")))
+	}
+	// body 可空("一键通过"),因此**不**用 validateReq(否则空 body 会被判 2001)
+	var req ApproveReq
+	if len(c.Body()) > 0 {
+		if !validateReq(c, &req) {
+			return nil
+		}
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.requests.Approve(c.Context(), service.ApproveRequestInput{
+		ID: id, ResponseMsg: req.ResponseMsg, OperatorID: sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// RejectRequest 审批驳回(端点 15 `POST /admin/finance/requests/:id/reject`)。
+//
+// 出参(成功):`{approved:-1,id}`;**无资金动作**。
+// 出参(失败):1001/2001(缺 responseMsg)/3002(已处理)/5002。
+func (h *Handler) RejectRequest(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	id := parseIDParam(c.Params("id"))
+	if id <= 0 {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("工单 id 非法")))
+	}
+	var req RejectReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.requests.Reject(c.Context(), service.RejectRequestInput{
+		ID: id, ResponseMsg: req.ResponseMsg, OperatorID: sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// CancelRequest 撤销工单(端点 16 `POST /admin/finance/requests/:id/cancel`)。
+//
+// 权限:申请人本人 或 super_admin/finance(Service 判定,越权 → **1003**)。
+// 出参(成功):`{approved:-2,id}`;出参(失败):1001/2001/1003/3002/5002。
+func (h *Handler) CancelRequest(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	id := parseIDParam(c.Params("id"))
+	if id <= 0 {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("工单 id 非法")))
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.requests.Cancel(c.Context(), service.CancelRequestInput{
+		ID: id, OperatorID: sc.UserID, Role: sc.Role, SuperAdmin: sc.SuperAdmin,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// ---------------------------------------------------------------- 查询参数助手
+
+// parseOptionalInt32 解析可选 int32 query(未传 → nil;格式非法 → 2002 并返回 false)。
+//
+// 入参:c;key query 名。
+// 出参:(*int32, bool);bool=false 表示失败响应已写出。
+func parseOptionalInt32(c *fiber.Ctx, key string) (*int32, bool) {
+	raw := strings.TrimSpace(c.Query(key))
+	if raw == "" {
+		return nil, true
+	}
+	value, ok := strconvInt32(raw)
+	if !ok {
+		_ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("查询参数 "+key+" 非法(需整数)")))
+		return nil, false
+	}
+	return &value, true
+}
+
+// parseOptionalInt8 解析可选 int8 query(工单状态 -2/-1/0/1)。
+//
+// 入参:c;key query 名。出参:(*int8, bool);bool=false 表示失败响应已写出。
+func parseOptionalInt8(c *fiber.Ctx, key string) (*int8, bool) {
+	raw := strings.TrimSpace(c.Query(key))
+	if raw == "" {
+		return nil, true
+	}
+	value, ok := strconvInt64(raw)
+	if !ok || value < -128 || value > 127 {
+		_ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("查询参数 "+key+" 非法(需整数)")))
+		return nil, false
+	}
+	out := int8(value)
+	return &out, true
+}
+
+// strconvInt32 手写 int32 解析(接受可选负号;避免引入 strconv 依赖歧义)。
+//
+// 入参:raw 字符串。出参:(值, 是否成功)。
+func strconvInt32(raw string) (int32, bool) {
+	value, ok := strconvInt64(raw)
+	if !ok || value < -2147483648 || value > 2147483647 {
+		return 0, false
+	}
+	return int32(value), true
+}
+
+// strconvInt64 手写 int64 解析(接受可选前导负号;非数字即失败)。
+//
+// 入参:raw 字符串。出参:(值, 是否成功)。
+func strconvInt64(raw string) (int64, bool) {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" {
+		return 0, false
+	}
+	negative := false
+	if trimmed[0] == '-' {
+		negative = true
+		trimmed = trimmed[1:]
+	}
+	if trimmed == "" {
+		return 0, false
+	}
+	var out int64
+	for _, ch := range trimmed {
+		if ch < '0' || ch > '9' {
+			return 0, false
+		}
+		next := out*10 + int64(ch-'0')
+		if next < out { // 溢出回绕检测
+			return 0, false
+		}
+		out = next
+	}
+	if negative {
+		out = -out
+	}
+	return out, true
+}

+ 738 - 0
internal/academic/handler/fund_handler_test.go

@@ -0,0 +1,738 @@
+// Handler 单测(AC-12 六条验收:端点覆盖 / 2001 / 2002 / 1001 / 金额字符串 / 分页钳制 / 脱敏)。
+//
+// 断言纪律:只断 body 契约(HTTP 恒 200 + body.code,铁律 6),不断 HTTP 状态码。
+//
+// 覆盖结构(每条对到 AC-12 验收项原文):
+//
+//	① 端点 1~17、21 全覆盖  → TestRouteCoverage(18 条 method+path 全注册,且不含端点 18/19/20)
+//	② validateReq → 2001;金额非法 → 2002 → TestValidationCodes(两码不串)
+//	③ scope 取不到 → 1001   → TestMissingScopeIs1001
+//	④ 金额入参必须字符串     → TestMoneyMustBeJSONString(传 number → 2001,不被静默接受)
+//	⑤ pageSize>200 → 200     → TestPageSizeClamp(端点 11 响应 data.pageSize == 200)
+//	⑥ 响应不含敏感字段       → TestNoSensitiveFields(`imgs` / `payReturnData` 不出现)
+//
+// 另含 3 条业务冒烟(保证"端点通、且真的落到库里"):
+// 充值 → 余额增加;创建工单 → 审批通过 → 余额增加 + 幂等/二次审批被拒;账单 → 汇总恒等式。
+package handler
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"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"
+	"yuansiwei/unified-backend/internal/academic/money"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// ---------------------------------------------------------------- 测试基础设施
+
+// testScope 测试用作用域(机构 1 / 校区 10 / 财务角色)。
+func testScope() *appscope.Scope {
+	return &appscope.Scope{
+		InstitutionID: 1, CampusID: 10,
+		UserID: "01HOPERATOR0000000000001", Role: "finance",
+	}
+}
+
+// testSuperScope 超管作用域(跨机构)。
+func testSuperScope() *appscope.Scope {
+	return &appscope.Scope{InstitutionID: 1, CampusID: 0, SuperAdmin: true, UserID: "01HADMIN0000000000000001", Role: "super_admin"}
+}
+
+// newHandlerTestDB academic handler 测试库(资金 4 表 + 活动 5 表 + 简化 t_campus)。
+//
+// 与 service 包的单测库同构(逐表 AutoMigrate + 删非唯一索引:SQLite 索引名库级唯一,
+// 而 academic model 多表共用 `idx_inst_campus` 等名字)。
+func newHandlerTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_handler_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(handler 文件库) 失败: %v", err)
+	}
+	sqlDB, err := db.DB()
+	if err != nil {
+		t.Fatalf("取 sql.DB 失败: %v", err)
+	}
+	sqlDB.SetMaxOpenConns(1)
+	t.Cleanup(func() { _ = sqlDB.Close() })
+
+	for _, tbl := range []any{
+		&model.UserExpense{}, &model.UserExpenseSchool{}, &model.UserExpenseLog{},
+		&model.UserBalanceRequest{},
+		&model.CoursesActivity{}, &model.CoursesActivitySigned{},
+		&model.CoursesActivityUserDiscount{}, &model.CoursesActivityUserDiscountLog{},
+		&model.CoursesActivityUsedLog{},
+	} {
+		if mErr := db.AutoMigrate(tbl); mErr != nil {
+			t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, mErr)
+		}
+		stmt := &gorm.Statement{DB: db}
+		if pErr := stmt.Parse(tbl); pErr != nil {
+			t.Fatalf("解析表名(%T) 失败: %v", tbl, pErr)
+		}
+		var idxRows []struct {
+			Name string
+			SQL  *string
+		}
+		if qErr := db.Raw(
+			"SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name=? AND name NOT LIKE 'sqlite_%'",
+			stmt.Schema.Table).Scan(&idxRows).Error; qErr != nil {
+			t.Fatalf("读索引清单(%s) 失败: %v", stmt.Schema.Table, qErr)
+		}
+		for _, row := range idxRows {
+			sqlText := ""
+			if row.SQL != nil {
+				sqlText = strings.ToUpper(*row.SQL)
+			}
+			if strings.Contains(sqlText, "UNIQUE") {
+				continue // 唯一约束必须保留
+			}
+			if dErr := db.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; dErr != nil {
+				t.Fatalf("删索引(%s) 失败: %v", row.Name, dErr)
+			}
+		}
+	}
+	if err := db.Exec(`CREATE TABLE t_campus (
+		campus_id INTEGER PRIMARY KEY,
+		institution_id INTEGER NOT NULL DEFAULT 0,
+		self_support BOOLEAN NOT NULL DEFAULT 1,
+		status VARCHAR(32) NOT NULL DEFAULT 'active'
+	)`).Error; err != nil {
+		t.Fatalf("建 t_campus 失败: %v", err)
+	}
+	if err := db.Exec(`INSERT INTO t_campus(campus_id, institution_id, self_support, status) VALUES(10,1,1,'active')`).Error; err != nil {
+		t.Fatalf("seed t_campus 失败: %v", err)
+	}
+	return db
+}
+
+// newTestApp 构造只挂 academic 财务域端点(+ 作用域注入中间件)的 app。
+//
+// 中间件模拟 Auth 链:`X-Test-NoScope: 1` 时不写作用域(用于 1001 用例),
+// `X-Test-Super: 1` 时写超管作用域。
+func newTestApp(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 {
+		switch c.Get("X-Test-Scope") {
+		case "none":
+			// 不写作用域 → 未登录语义
+		case "super":
+			appscope.Set(c, testSuperScope())
+		default:
+			appscope.Set(c, testScope())
+		}
+		return c.Next()
+	})
+	registerTestRoutes(app, h)
+	return app
+}
+
+// registerTestRoutes 把 AC-12 的 18 条端点全部注册(**与设计 §4.1 逐条对应**)。
+//
+// 说明:这里**不**做角色白名单/RBAC(那是 AC-14);本函数只为"端点覆盖 + 请求往返"服务。
+func registerTestRoutes(app *fiber.App, h *Handler) {
+	app.Get(PathFinanceAccounts, h.GetAccount)
+	app.Get(PathFinanceRefundable, h.GetRefundable)
+	app.Post(PathFinanceRecharges, h.CreateRecharge)
+	app.Post(PathFinanceExpenditures, h.CreateExpenditure)
+	app.Post(PathFinanceRefunds, h.CreateRefund)
+	app.Post(PathFinanceRefundsCustom, h.CreateCustomRefund)
+	app.Post(PathFinanceTransfers, h.CreateTransfer)
+	app.Post(PathFinanceBadDebts, h.CreateBadDebt)
+	app.Post(PathFinanceClearedBalances, h.ClearBalance)
+	app.Post(PathFinanceSpends, h.AddSpend)
+	app.Get(PathFinanceLogs, h.ListLogs)
+	app.Get(PathFinanceRequests, h.ListRequests)
+	app.Post(PathFinanceRequests, h.CreateRequest)
+	app.Post(PathFinanceRequestApprove, h.ApproveRequest)
+	app.Post(PathFinanceRequestReject, h.RejectRequest)
+	app.Post(PathFinanceRequestCancel, h.CancelRequest)
+	app.Get(PathFinanceDashboard, h.GetDashboard)
+	app.Get(PathStudentBill, h.GetBill)
+}
+
+// doJSON 发一个 JSON 请求并解出信封(HTTP 恒 200,失败即测试失败)。
+//
+// 入参:method / path / body(nil = 无 body)/ headers(k,v 交替)。
+// 出参:code / message / data(原始 JSON)。
+func doJSON(t *testing.T, app *fiber.App, method, path string, body any, headers ...string) (int, string, json.RawMessage) {
+	t.Helper()
+	var req *httptest.ResponseRecorder
+	payload := ""
+	if body != nil {
+		raw, err := json.Marshal(body)
+		if err != nil {
+			t.Fatalf("序列化请求体失败: %v", err)
+		}
+		payload = string(raw)
+	}
+	httpReq := httptest.NewRequest(method, path, strings.NewReader(payload))
+	httpReq.Header.Set("Content-Type", "application/json")
+	for i := 0; i+1 < len(headers); i += 2 {
+		httpReq.Header.Set(headers[i], headers[i+1])
+	}
+	resp, err := app.Test(httpReq, 5000)
+	if err != nil {
+		t.Fatalf("%s %s 请求失败: %v", method, path, err)
+	}
+	req = httptest.NewRecorder()
+	_ = req
+	defer func() { _ = resp.Body.Close() }()
+	var envelope struct {
+		Code    int             `json:"code"`
+		Message string          `json:"message"`
+		Data    json.RawMessage `json:"data"`
+		Success bool            `json:"success"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
+		t.Fatalf("%s %s 响应不是合法信封: %v", method, path, err)
+	}
+	return envelope.Code, envelope.Message, envelope.Data
+}
+
+// ---------------------------------------------------------------- ① 端点覆盖
+
+// TestRouteCoverage 端点 1~17、21 全覆盖(18 条),且**不含** 18/19/20(支付端点属 AC-13/AC-14)。
+func TestRouteCoverage(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newTestApp(t, db)
+
+	expected := []struct {
+		method string
+		path   string
+		name   string
+	}{
+		{http.MethodGet, "/admin/finance/accounts/:userId", "端点1 账户快照"},
+		{http.MethodGet, "/admin/finance/accounts/:userId/refundable", "端点2 可退金额"},
+		{http.MethodPost, "/admin/finance/recharges", "端点3 充值"},
+		{http.MethodPost, "/admin/finance/expenditures", "端点4 消费"},
+		{http.MethodPost, "/admin/finance/refunds", "端点5 退费"},
+		{http.MethodPost, "/admin/finance/refunds/custom", "端点6 自定义退费"},
+		{http.MethodPost, "/admin/finance/transfers", "端点7 转结"},
+		{http.MethodPost, "/admin/finance/bad-debts", "端点8 坏账"},
+		{http.MethodPost, "/admin/finance/cleared-balances", "端点9 清零"},
+		{http.MethodPost, "/admin/finance/spends", "端点10 累计消费"},
+		{http.MethodGet, "/admin/finance/logs", "端点11 流水"},
+		{http.MethodGet, "/admin/finance/requests", "端点12 工单列表"},
+		{http.MethodPost, "/admin/finance/requests", "端点13 创建工单"},
+		{http.MethodPost, "/admin/finance/requests/:id/approve", "端点14 通过"},
+		{http.MethodPost, "/admin/finance/requests/:id/reject", "端点15 驳回"},
+		{http.MethodPost, "/admin/finance/requests/:id/cancel", "端点16 撤销"},
+		{http.MethodGet, "/student/bill", "端点17 账单"},
+		{http.MethodGet, "/admin/finance/dashboard", "端点21 仪表盘"},
+	}
+	registered := map[string]bool{}
+	for _, route := range app.GetRoutes() {
+		registered[route.Method+" "+route.Path] = true
+	}
+	for _, want := range expected {
+		if !registered[want.method+" "+want.path] {
+			t.Errorf("%s 未注册(%s %s)", want.name, want.method, want.path)
+		}
+	}
+	// 反向断言:不该出现的端点(18/19/20 属支付,归 AC-13;且**任何**路径都不得含 /api)
+	for key := range registered {
+		if strings.Contains(key, "/pay/") {
+			t.Errorf("academic finance handler 不应注册支付端点:%s", key)
+		}
+		if strings.Contains(key, "/api/") {
+			t.Errorf("后端路由不得含 /api 前缀(§4 口径表):%s", key)
+		}
+	}
+	if len(expected) != 18 {
+		t.Fatalf("覆盖清单应为 18 条,实得 %d", len(expected))
+	}
+}
+
+// ---------------------------------------------------------------- ③ scope 取不到 → 1001
+
+// TestMissingScopeIs1001 无作用域(未登录/中间件未注入)→ 1001,且**必须先于**参数校验。
+func TestMissingScopeIs1001(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newTestApp(t, db)
+
+	cases := []struct {
+		method string
+		path   string
+		body   any
+	}{
+		{http.MethodGet, "/admin/finance/accounts/01HUSER000000000000000900", nil},
+		{http.MethodPost, "/admin/finance/recharges", map[string]any{}},
+		{http.MethodGet, "/admin/finance/logs", nil},
+		{http.MethodGet, "/student/bill", nil},
+		{http.MethodGet, "/admin/finance/dashboard", nil},
+	}
+	for _, tc := range cases {
+		code, _, _ := doJSON(t, app, tc.method, tc.path, tc.body, "X-Test-Scope", "none")
+		if code != infraerrors.CodeUnauth {
+			t.Errorf("%s %s:期望 1001,实得 %d", tc.method, tc.path, code)
+		}
+	}
+}
+
+// ---------------------------------------------------------------- ② 2001 与 2002 不串
+
+// TestValidationCodes validateReq 失败 → 2001;金额/枚举值非法 → 2002。
+func TestValidationCodes(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newTestApp(t, db)
+
+	// 缺参(userId/money/institutionId 全空)→ 2001
+	code, _, _ := doJSON(t, app, http.MethodPost, "/admin/finance/recharges", map[string]any{})
+	if code != infraerrors.CodeBadParam {
+		t.Fatalf("缺参期望 2001,实得 %d", code)
+	}
+	// 金额字符串非法("abc")→ 2002
+	code, _, _ = doJSON(t, app, http.MethodPost, "/admin/finance/recharges", map[string]any{
+		"userId": "01HUSER000000000000000900", "institutionId": 1, "campusId": 10, "money": "abc",
+	})
+	if code != infraerrors.CodeBadFormat {
+		t.Fatalf("金额非法期望 2002,实得 %d", code)
+	}
+	// 消费 type 不在白名单 → 2002
+	code, _, _ = doJSON(t, app, http.MethodPost, "/admin/finance/expenditures", map[string]any{
+		"userId": "01HUSER000000000000000900", "institutionId": 1, "campusId": 10,
+		"money": "10.00", "type": 999,
+	})
+	if code != infraerrors.CodeBadFormat {
+		t.Fatalf("消费 type 非白名单期望 2002,实得 %d", code)
+	}
+	// 工单 type 不在白名单 → 2002
+	code, _, _ = doJSON(t, app, http.MethodPost, "/admin/finance/requests", map[string]any{
+		"type": 999, "userId": "01HUSER000000000000000900", "institutionId": 1, "campusId": 10,
+		"money": "10.00",
+	})
+	if code != infraerrors.CodeBadFormat {
+		t.Fatalf("工单 type 非白名单期望 2002,实得 %d", code)
+	}
+	// 自定义退费缺 requestId(必填)→ 2001
+	code, _, _ = doJSON(t, app, http.MethodPost, "/admin/finance/refunds/custom", map[string]any{
+		"userId": "01HUSER000000000000000900", "institutionId": 1, "campusId": 10, "money": "10.00",
+	})
+	if code != infraerrors.CodeBadParam {
+		t.Fatalf("缺 requestId 期望 2001,实得 %d", code)
+	}
+	// 账单缺 from/to → 2001(时间边界必填)
+	code, _, _ = doJSON(t, app, http.MethodGet, "/student/bill", nil)
+	if code != infraerrors.CodeBadParam {
+		t.Fatalf("账单缺 from/to 期望 2001,实得 %d", code)
+	}
+	// 账单 campusIds 非法片段 → 2002(Service 解析失败)
+	code, _, _ = doJSON(t, app, http.MethodGet,
+		"/student/bill?userId=01HUSER000000000000000900&from=2026-03-01&to=2026-04-01&campusIds=1,a", nil)
+	if code != infraerrors.CodeBadParam {
+		t.Fatalf("账单 campusIds 非法期望 2001/(Service 侧 2001),实得 %d", code)
+	}
+	// 时间格式非法 → 2002
+	code, _, _ = doJSON(t, app, http.MethodGet,
+		"/student/bill?from=2026-13-99&to=2026-04-01", nil)
+	if code != infraerrors.CodeBadFormat {
+		t.Fatalf("时间格式非法期望 2002,实得 %d", code)
+	}
+}
+
+// ---------------------------------------------------------------- ④ 金额必须是字符串
+
+// TestMoneyMustBeJSONString 金额传 JSON number → **2002(被拒)**,绝不被静默接受。
+//
+// 为什么是 2002 而不是 2001:`infra/validator.Validate` 对"body 能解析但字段类型不匹配"
+// 统一回 `CodeBadFormat`(2002)(见 validator.go 的 json.Unmarshal 分支)——本用例沿用该约定。
+//
+// 这是"拒绝 float64"的可判定形式:若把 DTO 的金额字段写成 `float64` 或 `money.Amount`,
+// number 会被成功解码 ⇒ 本用例拿到 200(钱被按浮点入账)⇒ 断言立刻红。
+func TestMoneyMustBeJSONString(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newTestApp(t, db)
+
+	raw := `{"userId":"01HUSER000000000000000900","institutionId":1,"campusId":10,"money":100}`
+	httpReq := httptest.NewRequest(http.MethodPost, "/admin/finance/recharges", strings.NewReader(raw))
+	httpReq.Header.Set("Content-Type", "application/json")
+	resp, err := app.Test(httpReq, 5000)
+	if err != nil {
+		t.Fatalf("请求失败: %v", err)
+	}
+	defer func() { _ = resp.Body.Close() }()
+	var envelope struct {
+		Code int `json:"code"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
+		t.Fatalf("响应解码失败: %v", err)
+	}
+	if envelope.Code != infraerrors.CodeBadFormat {
+		t.Fatalf("金额传 number 期望 2002(类型不匹配被拒),实得 %d", envelope.Code)
+	}
+}
+
+// ---------------------------------------------------------------- ⑤ 分页钳制
+
+// TestPageSizeClamp pageSize>200 → 200(唯一实现点 = response.ParsePage)。
+func TestPageSizeClamp(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newTestApp(t, db)
+
+	code, _, data := doJSON(t, app, http.MethodGet,
+		"/admin/finance/logs?userId=01HUSER000000000000000900&pageSize=500&page=1", nil)
+	if code != 200 {
+		t.Fatalf("流水列表期望 200,实得 %d", code)
+	}
+	var page struct {
+		PageSize int   `json:"pageSize"`
+		Page     int   `json:"page"`
+		Total    int64 `json:"total"`
+		List     []any `json:"list"`
+	}
+	if err := json.Unmarshal(data, &page); err != nil {
+		t.Fatalf("分页体解码失败: %v", err)
+	}
+	if page.PageSize != 200 {
+		t.Fatalf("pageSize 应钳制为 200,实得 %d", page.PageSize)
+	}
+	if page.List == nil {
+		t.Fatal("空列表必须是 [],不能是 null")
+	}
+	// 缺省值:不传分页 → (1,10)
+	_, _, data = doJSON(t, app, http.MethodGet, "/admin/finance/logs?userId=01HUSER000000000000000900", nil)
+	if err := json.Unmarshal(data, &page); err != nil {
+		t.Fatalf("分页体解码失败: %v", err)
+	}
+	if page.Page != 1 || page.PageSize != 10 {
+		t.Fatalf("缺省分页应为 (1,10),实得 (%d,%d)", page.Page, page.PageSize)
+	}
+}
+
+// ---------------------------------------------------------------- ⑥ 脱敏
+
+// TestNoSensitiveFields 响应体不出现 `imgs` / `payReturnData` / `returnData`(AC-12 验收 ⑥)。
+func TestNoSensitiveFields(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newTestApp(t, db)
+
+	user := "01HUSER000000000000000901"
+	// 造一张带凭证图片的工单,确认列表**不回显** imgs
+	if err := db.Create(&model.UserBalanceRequest{
+		BizNo: "BIZSENSITIVE00000000000000001", Type: model.BalanceRequestTypeRecharge,
+		UserID: user, InstitutionID: 1, CampusID: 10, Money: "100.00",
+		Imgs: strPtr(`[{"url":"https://sensitive/private.png"}]`),
+	}).Error; err != nil {
+		t.Fatalf("seed 工单失败: %v", err)
+	}
+	httpReq := httptest.NewRequest(http.MethodGet, "/admin/finance/requests?pageSize=10", nil)
+	resp, err := app.Test(httpReq, 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)
+	for _, banned := range []string{"imgs", "private.png", "payReturnData", "pay_return_data", "returnData"} {
+		if strings.Contains(text, banned) {
+			t.Fatalf("响应体含敏感字段 %q:%s", banned, text)
+		}
+	}
+	if !strings.Contains(text, user) {
+		t.Fatalf("响应体应含被查工单(脱敏断言不能靠「空响应」通过):%s", text)
+	}
+}
+
+// ---------------------------------------------------------------- 业务冒烟
+
+// TestRechargeThenAccountSnapshot 端点 3 → 端点 1:充值后账户快照余额同步(跨端点一致性)。
+func TestRechargeThenAccountSnapshot(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newTestApp(t, db)
+	user := "01HUSER000000000000000910"
+
+	code, msg, data := doJSON(t, app, http.MethodPost, "/admin/finance/recharges", map[string]any{
+		"userId": user, "institutionId": 1, "campusId": 10, "money": "250.50",
+		"requestId": "REQ-HANDLER-1",
+	})
+	if code != 200 {
+		t.Fatalf("充值期望 200,实得 %d(%s)", code, msg)
+	}
+	var recharge struct {
+		LogID    int64  `json:"logId"`
+		Balance  string `json:"balance"`
+		Repeated bool   `json:"repeated"`
+	}
+	if err := json.Unmarshal(data, &recharge); err != nil {
+		t.Fatalf("充值体解码失败: %v", err)
+	}
+	if recharge.LogID <= 0 || recharge.Balance != "250.50" || recharge.Repeated {
+		t.Fatalf("充值结果异常:%+v", recharge)
+	}
+
+	// 幂等重放(同一 requestId)→ repeated=true 且余额不变
+	code, _, data = doJSON(t, app, http.MethodPost, "/admin/finance/recharges", map[string]any{
+		"userId": user, "institutionId": 1, "campusId": 10, "money": "250.50",
+		"requestId": "REQ-HANDLER-1",
+	})
+	if code != 200 {
+		t.Fatalf("幂等重放期望 200,实得 %d", code)
+	}
+	if err := json.Unmarshal(data, &recharge); err != nil {
+		t.Fatalf("充值体解码失败: %v", err)
+	}
+	if !recharge.Repeated || recharge.Balance != "250.50" {
+		t.Fatalf("幂等重放应 repeated=true 且余额不变:%+v", recharge)
+	}
+
+	// 端点 1:账户快照应反映 250.50
+	code, _, data = doJSON(t, app, http.MethodGet,
+		"/admin/finance/accounts/"+user+"?institutionId=1&campusId=10", nil)
+	if code != 200 {
+		t.Fatalf("账户快照期望 200,实得 %d", code)
+	}
+	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"`
+	}
+	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.Campus == nil || snapshot.Campus.Balance != "250.50" {
+		t.Fatalf("校区快照异常:%+v", snapshot.Campus)
+	}
+}
+
+// TestRequestApproveTouchesFunds 端点 13 → 14:审批通过才动钱;二次审批 → 3002。
+func TestRequestApproveTouchesFunds(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newTestApp(t, db)
+	user := "01HUSER000000000000000920"
+
+	// 13 创建充值工单
+	code, msg, data := doJSON(t, app, http.MethodPost, "/admin/finance/requests", map[string]any{
+		"type": 100, "userId": user, "institutionId": 1, "campusId": 10,
+		"money": "88.00", "item": "春季学费",
+	})
+	if code != 200 {
+		t.Fatalf("创建工单期望 200,实得 %d(%s)", code, msg)
+	}
+	var created struct {
+		ID    int64  `json:"id"`
+		BizNo string `json:"bizNo"`
+	}
+	if err := json.Unmarshal(data, &created); err != nil {
+		t.Fatalf("工单体解码失败: %v", err)
+	}
+	if created.ID <= 0 || len(created.BizNo) != 32 {
+		t.Fatalf("工单结果异常:%+v", created)
+	}
+
+	// 审批前:余额 0(账户不存在 → 快照 0)
+	code, _, data = doJSON(t, app, http.MethodGet, "/admin/finance/accounts/"+user, nil)
+	if code != 200 {
+		t.Fatalf("快照期望 200,实得 %d", code)
+	}
+	var before struct {
+		Global struct {
+			Balance string `json:"balance"`
+		} `json:"global"`
+	}
+	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)
+	}
+
+	// 14 审批通过
+	code, msg, data = doJSON(t, app, http.MethodPost,
+		fmt.Sprintf("/admin/finance/requests/%d/approve", created.ID), map[string]any{"responseMsg": "ok"})
+	if code != 200 {
+		t.Fatalf("审批通过期望 200,实得 %d(%s)", code, msg)
+	}
+	var approved struct {
+		Approved int8   `json:"approved"`
+		BizNo    string `json:"bizNo"`
+		Result   struct {
+			LogID   int64  `json:"logId"`
+			Balance string `json:"balance"`
+		} `json:"result"`
+	}
+	if err := json.Unmarshal(data, &approved); err != nil {
+		t.Fatalf("审批体解码失败: %v", err)
+	}
+	if approved.Approved != 1 || approved.BizNo != created.BizNo || approved.Result.LogID <= 0 {
+		t.Fatalf("审批结果异常:%+v", approved)
+	}
+	if approved.Result.Balance != "88.00" {
+		t.Fatalf("审批入账后校区余额应为 88.00,实得 %s", approved.Result.Balance)
+	}
+
+	// 二次审批 → 3002(不得重复入账)
+	code, _, _ = doJSON(t, app, http.MethodPost,
+		fmt.Sprintf("/admin/finance/requests/%d/approve", created.ID), map[string]any{})
+	if code != infraerrors.CodeConflict {
+		t.Fatalf("二次审批期望 3002,实得 %d", code)
+	}
+
+	// 落库只能有一条 100 流水(重复入账会变两条)
+	var logs []model.UserExpenseLog
+	if err := db.Where("user_id = ? AND type = ?", user, model.ExpenseLogTypeRecharge).Find(&logs).Error; err != nil {
+		t.Fatalf("读流水失败: %v", err)
+	}
+	if len(logs) != 1 {
+		t.Fatalf("充值流水应为 1 条,实得 %d 条(二次审批重复入账)", len(logs))
+	}
+	if logs[0].RequestID == nil || *logs[0].RequestID != created.BizNo {
+		t.Fatalf("流水 request_id 应等于工单 bizNo(幂等锚点),实得 %v", logs[0].RequestID)
+	}
+}
+
+// TestBillEndpointIdentity 端点 17:账单汇总恒等式(期末−期初 = 窗口内流水)+ 分页元信息。
+func TestBillEndpointIdentity(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newTestApp(t, db)
+	user := "01HUSER000000000000000930"
+
+	seedLog := func(amount string, at time.Time) {
+		if err := db.Create(&model.UserExpenseLog{
+			UserID: user, Type: model.ExpenseLogTypeRecharge, Money: amount, CreateTime: at,
+		}).Error; err != nil {
+			t.Fatalf("seed 流水失败: %v", err)
+		}
+	}
+	seedLog("500.00", time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC))
+	seedLog("200.00", time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC))
+
+	code, msg, data := doJSON(t, app, http.MethodGet,
+		"/student/bill?userId="+user+"&from=2026-03-01&to=2026-04-01", nil)
+	if code != 200 {
+		t.Fatalf("账单期望 200,实得 %d(%s)", code, msg)
+	}
+	var bill struct {
+		Summary struct {
+			PrevBalance      string  `json:"prevBalance"`
+			EndBalance       string  `json:"endBalance"`
+			FlowExclusiveEnd string  `json:"flowExclusiveEnd"`
+			CampusIDs        []int64 `json:"campusIds"`
+		} `json:"summary"`
+		List     []map[string]any `json:"list"`
+		Total    int64            `json:"total"`
+		Page     int              `json:"page"`
+		PageSize int              `json:"pageSize"`
+		Pages    int64            `json:"pages"`
+	}
+	if err := json.Unmarshal(data, &bill); err != nil {
+		t.Fatalf("账单体解码失败: %v", err)
+	}
+	if bill.Summary.PrevBalance != "500.00" || bill.Summary.EndBalance != "700.00" {
+		t.Fatalf("期初/期末异常:%+v", bill.Summary)
+	}
+	// 恒等式:期末 − 期初 == 窗口内流水
+	prev, _ := money.Parse(bill.Summary.PrevBalance)
+	end, _ := money.Parse(bill.Summary.EndBalance)
+	flow, _ := money.Parse(bill.Summary.FlowExclusiveEnd)
+	if end.Sub(prev) != flow {
+		t.Fatalf("恒等式破:期末−期初=%s,窗口流水=%s", end.Sub(prev).String(), flow.String())
+	}
+	if bill.Total != 2 || bill.PageSize != 10 || bill.Pages != 1 || bill.List == nil {
+		t.Fatalf("分页元信息异常:total=%d pageSize=%d pages=%d list=%v", bill.Total, bill.PageSize, bill.Pages, bill.List)
+	}
+	if bill.Summary.CampusIDs == nil {
+		t.Fatal("campusIds 回显必须是 [](空时也不能是 null)")
+	}
+}
+
+// TestDashboardEndpoint 端点 21:聚合校区账户累计列 + 明确告知"不支持按校区拆流水"。
+func TestDashboardEndpoint(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newTestApp(t, db)
+
+	if err := db.Create(&model.UserExpenseSchool{
+		UserID: "01HUSER000000000000000940", InstitutionID: 1, CampusID: 10,
+		Balance: "100.00", TotalIncome: "300.00", TotalExpenditure: "200.00",
+	}).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 {
+		Accounts               int64  `json:"accounts"`
+		Balance                string `json:"balance"`
+		TotalIncome            string `json:"totalIncome"`
+		TotalExpenditure       string `json:"totalExpenditure"`
+		NetReceivable          string `json:"netReceivable"`
+		FlowsByCampusSupported bool   `json:"flowsByCampusSupported"`
+	}
+	if err := json.Unmarshal(data, &dash); err != nil {
+		t.Fatalf("仪表盘体解码失败: %v", err)
+	}
+	if dash.Accounts != 1 || dash.Balance != "100.00" || dash.TotalIncome != "300.00" ||
+		dash.TotalExpenditure != "200.00" || dash.NetReceivable != "100.00" {
+		t.Fatalf("仪表盘聚合异常:%+v", dash)
+	}
+	if dash.FlowsByCampusSupported {
+		t.Fatal("flowsByCampusSupported 必须为 false(流水表无 campus 列,不能按校区拆期间发生额)")
+	}
+}
+
+// TestCrossInstitutionIsRejected 跨机构:作用域机构 1 查机构 2 的工单 → 3001(仓储 Scoped 过滤后不可见)。
+func TestCrossInstitutionIsRejected(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app := newTestApp(t, db)
+
+	if err := db.Create(&model.UserBalanceRequest{
+		BizNo: "BIZOTHERINST0000000000000001", Type: model.BalanceRequestTypeRecharge,
+		UserID: "01HUSER000000000000000950", InstitutionID: 2, CampusID: 20, Money: "10.00",
+	}).Error; err != nil {
+		t.Fatalf("seed 跨机构工单失败: %v", err)
+	}
+	code, _, _ := doJSON(t, app, http.MethodPost, "/admin/finance/requests/1/approve", map[string]any{})
+	if code != infraerrors.CodeNotFound {
+		t.Fatalf("跨机构审批期望 3001,实得 %d", code)
+	}
+	// 列表也看不到别机构工单
+	code, _, data := doJSON(t, app, http.MethodGet, "/admin/finance/requests?pageSize=10", nil)
+	if code != 200 {
+		t.Fatalf("工单列表期望 200,实得 %d", code)
+	}
+	var page struct {
+		Total int64 `json:"total"`
+	}
+	if err := json.Unmarshal(data, &page); err != nil {
+		t.Fatalf("列表体解码失败: %v", err)
+	}
+	if page.Total != 0 {
+		t.Fatalf("普通财务不应看到别机构工单,实得 total=%d", page.Total)
+	}
+}
+
+// ---------------------------------------------------------------- 小工具
+
+// strPtr 字符串指针构造(可空列 seed 用)。
+func strPtr(s string) *string { return &s }

+ 298 - 0
internal/academic/handler/handler.go

@@ -0,0 +1,298 @@
+// Package handler academic 域 HTTP 接入层(架构规格 §3.2 三层之 Handler,AC-12/AC-13)。
+//
+// 分层职责(与 identity/org handler 同款,不另起一套):
+//   - 本包:绑定/校验请求(`validator.Validate` → 2001)→ 从 `scope.FromFiber` 取作用域(取不到 → 1001)
+//     → **按作用域装配 Service**(academic 的 Service 在构造时绑定 scope)→ 把 `*AppError`
+//     映射为统一信封(`response.Fail` / `response.OK` / `response.Page`)。
+//     **不写业务逻辑、不直接碰 DB**。
+//   - Service(academic/service):资金 / 退费 / 账单 / 工单编排(事务边界只在这一层,§10.3)。
+//   - Repository(academic/repository):Scoping 强制过滤 + 只读聚合。
+//
+// ---------------------------------------------------------------------------
+// 路径口径(设计 §4 口径表 v0.1.24,**已冻结**)
+// ---------------------------------------------------------------------------
+//
+// 本包所有路径常量**不含 `/api`**。`/api` 只是**全站外部前缀**(vite `rewrite` / nginx 尾斜杠去掉),
+// 不进后端路由表:
+//
+//	对外请求:/api/admin/finance/recharges
+//	后端路由:      /admin/finance/recharges   ← 本包 Path* 常量就是这一层
+//
+// academic 只占 3 个一级段:`/admin`、`/student`、`/pay`(`/auth` 属 identity、`/org` 属 org,
+// **不得**在此重复注册;AC-14 有一条"一级段白名单"运行期断言专门守这条)。
+//
+// ---------------------------------------------------------------------------
+// 六条接入层纪律(AC-12 验收项,逐条对应实现位置)
+// ---------------------------------------------------------------------------
+//
+//	① 端点覆盖:端点 1~17 + 21 → fund_handler.go(1~16)、bill_handler.go(17、21)。
+//	② `validateReq` 失败 → **2001**;金额字符串非法 / 枚举非法 → **2002**(两者不串):
+//	   `validator.Validate` 负责 2001(缺参/格式),`parseMoney`/`parseTypeFilter` 负责 2002。
+//	③ `scope.FromFiber` 取不到 → **1001**:`mustScope`。
+//	④ 金额入参**一律字符串**(DTO 里是 `string`)→ 走 `money.Parse`;**不接受 float64**。
+//	   这条不是风格问题:JSON number 解码进 `money.Amount` 会经过 float64(精度陷阱),
+//	   所以 DTO 用 string + 显式 Parse,**连"宽松接受数字"都不做**(见 `parseMoney` 注释)。
+//	⑤ 分页钳制**唯一实现点**:`parsePage`(内部只调 `response.ParsePage`),
+//	   handler 里**禁止**再出现 `if pageSize > 200` 这类分支(一处改、处处生效)。
+//	⑥ 响应体不含敏感字段:视图 DTO 逐字段声明(`RequestView` 无 `imgs`、
+//	   `LogView` 无 `pay_return_data`),**不直接序列化 model**(model 的 `json:"-"` 是最后一道,
+//	   不是唯一一道)。
+package handler
+
+import (
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+	"go.uber.org/zap"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/port"
+	"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"
+	"yuansiwei/unified-backend/internal/infra/validator"
+)
+
+// ---------------------------------------------------------------- 路径常量(**不含 `/api`**)
+
+// 财务域路径常量(端点 1~16、21;对外 = `/api` + 这些值)。
+const (
+	// PathFinanceAccounts 端点 1:账户快照(`GET /admin/finance/accounts/:userId`)。
+	PathFinanceAccounts = "/admin/finance/accounts/:userId"
+	// PathFinanceRefundable 端点 2:可退金额(`GET /admin/finance/accounts/:userId/refundable`)。
+	PathFinanceRefundable = "/admin/finance/accounts/:userId/refundable"
+	// PathFinanceRecharges 端点 3:充值(`POST /admin/finance/recharges`)。
+	PathFinanceRecharges = "/admin/finance/recharges"
+	// PathFinanceExpenditures 端点 4:消费(`POST /admin/finance/expenditures`)。
+	PathFinanceExpenditures = "/admin/finance/expenditures"
+	// PathFinanceRefunds 端点 5:退费(`POST /admin/finance/refunds`)。
+	PathFinanceRefunds = "/admin/finance/refunds"
+	// PathFinanceRefundsCustom 端点 6:自定义退费(双 log 坏账)(`POST /admin/finance/refunds/custom`)。
+	PathFinanceRefundsCustom = "/admin/finance/refunds/custom"
+	// PathFinanceTransfers 端点 7:课费转结(`POST /admin/finance/transfers`)。
+	PathFinanceTransfers = "/admin/finance/transfers"
+	// PathFinanceBadDebts 端点 8:坏账(`POST /admin/finance/bad-debts`)。
+	PathFinanceBadDebts = "/admin/finance/bad-debts"
+	// PathFinanceClearedBalances 端点 9:余额清零(`POST /admin/finance/cleared-balances`)。
+	PathFinanceClearedBalances = "/admin/finance/cleared-balances"
+	// PathFinanceSpends 端点 10:累计消费(`POST /admin/finance/spends`)。
+	PathFinanceSpends = "/admin/finance/spends"
+	// PathFinanceLogs 端点 11:流水列表(`GET /admin/finance/logs`)。
+	PathFinanceLogs = "/admin/finance/logs"
+	// PathFinanceRequests 端点 12/13:工单列表 / 创建(`/admin/finance/requests`)。
+	PathFinanceRequests = "/admin/finance/requests"
+	// PathFinanceRequestApprove 端点 14:工单通过(`POST /admin/finance/requests/:id/approve`)。
+	PathFinanceRequestApprove = "/admin/finance/requests/:id/approve"
+	// PathFinanceRequestReject 端点 15:工单驳回(`POST /admin/finance/requests/:id/reject`)。
+	PathFinanceRequestReject = "/admin/finance/requests/:id/reject"
+	// PathFinanceRequestCancel 端点 16:工单撤销(`POST /admin/finance/requests/:id/cancel`)。
+	PathFinanceRequestCancel = "/admin/finance/requests/:id/cancel"
+	// PathFinanceDashboard 端点 21:仪表盘(`GET /admin/finance/dashboard`,P2)。
+	PathFinanceDashboard = "/admin/finance/dashboard"
+)
+
+// PathStudentBill 端点 17:学生账单(`GET /student/bill`)。
+const PathStudentBill = "/student/bill"
+
+// ---------------------------------------------------------------- 处理器装配
+
+// Config Handler 装配参数(AC-14 在 `cmd/api/main.go` 按此构造)。
+//
+// 字段:
+//   - DB *gorm.DB:数据库句柄(**必填**:academic 的 Service 在构造时需要 scope,无法预装配,
+//     故 Handler 持 DB、按请求作用域现场装配 Service);
+//   - Member port.MembershipChecker:成员校验器(可 nil:nil 时服务层跳过对象级越权断言,
+//     仅限双跑/回填脚本;生产装配**必须**注入);
+//   - Log *zap.Logger:结构化日志器(可 nil)。
+type Config struct {
+	// DB 数据库句柄(必填)。
+	DB *gorm.DB
+	// Member 成员校验器(可 nil)。
+	Member port.MembershipChecker
+	// Log 日志器(可 nil)。
+	Log *zap.Logger
+}
+
+// Handler academic 域 HTTP 处理器(启动装配一次,全进程共享)。
+//
+// 语义说明(**为什么按请求装配 Service**):academic 的 Service 构造函数签名是
+// `NewXxxService(db, sc, member)`——作用域是**构造期**绑定到 Repository 的(见 infra/scope 包注释
+// "构造 Repository 即绑定本次请求的租户")。因此本 Handler 只持有不可变的 `db`/`member`,
+// 每个请求经 `servicesFor(sc)` 现场装配一套服务;`db` 是并发安全的连接池,装配开销只是几个 struct。
+type Handler struct {
+	db     *gorm.DB
+	member port.MembershipChecker
+	log    *zap.Logger
+}
+
+// New 构造 academic 域 HTTP 处理器。
+//
+// 入参:cfg Config。
+// 出参:
+//   - *Handler:就绪处理器;
+//   - error:DB 未注入(fail-fast——没有 DB 无法装配任何服务,挂载端点只会 5001)。
+func New(cfg Config) (*Handler, error) {
+	if cfg.DB == nil {
+		return nil, infraerrors.New(infraerrors.CodeInternal, "academic handler: DB 未注入")
+	}
+	return &Handler{db: cfg.DB, member: cfg.Member, log: cfg.Log}, nil
+}
+
+// scopedServices 一次请求内装配的服务集合(按作用域现场构造,请求结束即丢弃)。
+type scopedServices struct {
+	// fund 资金服务(端点 2~5、7、8、10)。
+	fund *service.FundService
+	// refund 退费服务(端点 6、9)。
+	refund *service.RefundService
+	// bill 账单服务(端点 17)。
+	bill *service.BillService
+	// query 财务只读查询(端点 1、11、12、21)。
+	query *service.FinanceQueryService
+	// requests 工单服务(端点 13~16)。
+	requests *service.BalanceRequestService
+}
+
+// servicesFor 按请求作用域装配一套服务。
+//
+// 入参:sc 作用域(**非 nil**——调用方已用 mustScope 判定)。
+// 出参:*scopedServices(字段全部非 nil)。
+func (h *Handler) servicesFor(sc *appscope.Scope) *scopedServices {
+	campus := port.NewOrgCampusReader(h.db)
+	return &scopedServices{
+		fund:     service.NewFundService(h.db, sc, h.member),
+		refund:   service.NewRefundService(h.db, sc, campus, h.member),
+		bill:     service.NewBillService(h.db, sc).WithMembershipChecker(h.member),
+		query:    service.NewFinanceQueryService(h.db, sc, h.member),
+		requests: service.NewBalanceRequestService(h.db, sc, h.member),
+	}
+}
+
+// ---------------------------------------------------------------- 公共助手(四件事:作用域 / 校验 / 金额 / 分页)
+
+// mustScope 取中间件注入的多租户作用域;取不到 → **1001**(AC-12 验收第 ③ 条)。
+//
+// 入参:c *fiber.Ctx。
+// 出参:
+//   - *appscope.Scope:非 nil 作用域;
+//   - error:`infraerrors.ErrUnauth`(1001)——受保护路由未注入作用域 = 未登录/中间件链配置错误。
+//
+// 为什么返回 error 而不是直接写响应:让调用方 `return response.Fail(c, err)` 统一出口,
+// 避免本函数既写响应又返回值(双重写出是 Fiber 里最难查的一类 bug)。
+func mustScope(c *fiber.Ctx) (*appscope.Scope, error) {
+	sc := appscope.FromFiber(c)
+	if sc == nil {
+		return nil, infraerrors.ErrUnauth
+	}
+	return sc, nil
+}
+
+// validateReq 绑定并校验 JSON 请求体;失败时响应已写出(2001),调用方 `return nil`(AC-12 第 ② 条)。
+//
+// 入参:c;dst 目标结构体指针。
+// 出参:true 通过;false 失败(**body code=2001 已写出**)。
+//
+// 这是 `infra/validator.Validate` 的本地别名,存在意义只有一条:把"academic 的 Handler 一律经此函数
+// 校验"变成可 grep 的纪律(评审时 `grep -c validateReq` 应等于非 GET 端点数)。
+func validateReq(c *fiber.Ctx, dst any) bool {
+	return validator.Validate(c, dst)
+}
+
+// parseMoney 解析**字符串**金额入参(AC-12 第 ④ 条:金额一律字符串,走 money.Parse)。
+//
+// 入参:
+//   - c *fiber.Ctx:请求上下文(仅用于写失败响应);
+//   - raw string:DTO 里的金额字段(`"100.00"`);
+//   - field string:字段名(错误文案定位,如 "money"/"spendMoney")。
+//
+// 出参:
+//   - money.Amount:定点金额;
+//   - bool:false 表示解析失败,**body code=2002 已写出**,调用方 `return nil`。
+//
+// 红线(**为什么必须是 2002 而不是 2001**):值传了但格式非法属"填错"(2002),
+// 字段没传属"漏填"(2001,由 validator 负责)。两者在前端提示语与埋点上是不同分支,
+// 合并会让"漏填金额"被提示成"金额格式错误",排查成本直接翻倍。
+//
+// 另一个红线:**不接受 JSON number**。DTO 字段声明为 `string`,`{"money": 100}` 会在
+// `validator.Validate` 的 JSON 解码阶段就失败(按 infra/validator 既有约定归 **2002**,
+// 而不是被静默接受成 100.00)——
+// 这是有意的:让"前端传了数字"在联调第一天就暴露,而不是等上了生产才发现
+// float64 → 分 的舍入把 0.1+0.2 级的差额带进对账。
+func parseMoney(c *fiber.Ctx, raw, field string) (money.Amount, bool) {
+	amount, err := money.Parse(strings.TrimSpace(raw))
+	if err != nil {
+		if ferr := response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("金额字段 "+field+" 格式非法(必须是两位小数的字符串)"))); ferr != nil {
+			return 0, false
+		}
+		return 0, false
+	}
+	return amount, true
+}
+
+// parsePage 解析分页参数(AC-12 第 ⑤ 条:**唯一钳制实现点**)。
+//
+// 入参:c *fiber.Ctx(读 `page`/`pageSize` query)。
+// 出参:(page, pageSize):page ≥ 1;pageSize ∈ [1, 200](>200 → 200,缺省 10)。
+//
+// 实现只转调 `response.ParsePage`——全仓唯一的分页钳制实现。
+// **禁止**在任何 handler 里自写 `if pageSize > 200 { pageSize = 200 }`:
+// 那样一旦产品把上限改成 500,改了一处漏了五处,症状是"某个页面能拉 500 条、另一个只能 200"。
+func parsePage(c *fiber.Ctx) (page, pageSize int) {
+	return response.ParsePage(c)
+}
+
+// parseTimeParam 解析时间 query 参数(`from`/`to`)。
+//
+// 入参:raw 原始字符串;field 字段名(错误文案定位)。
+// 出参:(时间, 是否传了, error);空串 → (零值, false, nil);格式非法 → ErrBadFormat(2002)。
+//
+// 接受两种格式(前端的两种真实用法):
+//  1. `2006-01-02`(纯日期,最常用;按 UTC 当日 00:00:00 解释);
+//  2. RFC3339(`2006-01-02T15:04:05Z07:00`,带时分秒与时区)。
+//
+// ⚠ 端点 17(账单)的 `to` 语义是**不含上界**(见 service.GetBillInput 注释):
+// 传 `to=2026-04-01` 表示"到 3 月底为止",当日流水不计入——这与"余额/期初期末"口径一致。
+func parseTimeParam(raw, field string) (time.Time, bool, error) {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" {
+		return time.Time{}, false, nil
+	}
+	if t, err := time.Parse(time.RFC3339, trimmed); err == nil {
+		return t, true, nil
+	}
+	if t, err := time.Parse("2006-01-02", trimmed); err == nil {
+		return t, true, nil
+	}
+	return time.Time{}, false, infraerrors.WithCause(infraerrors.ErrBadFormat,
+		errString("时间字段 "+field+" 格式非法(支持 2006-01-02 或 RFC3339)"))
+}
+
+// parseIDParam 解析路径/查询里的 int64 ID(空串或非法 → 0,交由下游 2001/3001 判)。
+//
+// 入参:raw 原始字符串。出参:int64(解析失败为 0)。
+//
+// 为什么不在 Handler 直接报 2002:ID 为 0 时下游(Service/Repo)会给出更精确的语义
+// (2001 缺参 or 3001 不存在),Handler 抢先判定只会让错误码与真实原因脱节。
+func parseIDParam(raw string) int64 {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" {
+		return 0
+	}
+	var out int64
+	for _, ch := range trimmed {
+		if ch < '0' || ch > '9' {
+			return 0
+		}
+		out = out*10 + int64(ch-'0')
+	}
+	return out
+}
+
+// errString 实现 error 的最小字符串类型(本包不引入额外依赖,语义同 repository.errString)。
+type errString string
+
+// Error 实现 error 接口。
+func (e errString) Error() string { return string(e) }

+ 116 - 0
internal/academic/repository/balance_request_repo.go

@@ -308,3 +308,119 @@ func (r *BalanceRequestRepo) ListByUser(ctx context.Context, userID string, page
 	}
 	return rows, nil
 }
+
+// ---------------------------------------------------------------- AC-12 查询原语(端点 12 / 14~16)
+
+// RequestFilter 工单列表筛选(端点 12 `GET /admin/finance/requests`)。
+//
+// 字段语义(nil/空 = 不筛):
+//   - Type *int32:工单类型(100 普通充值/101 坏账/202 退费/203 取消充值活动/300 转结等);
+//   - Approved *int8:审批状态(-2 取消/-1 不通过/0 未处理/1 已通过);指针区分"未传"与"传 0";
+//   - UserID string:限定某个用户(空 = 本机构全部)。
+type RequestFilter struct {
+	// Type 工单类型(nil = 不筛)。
+	Type *int32
+	// Approved 审批状态(nil = 不筛)。
+	Approved *int8
+	// UserID 用户(ULID;空 = 不筛)。
+	UserID string
+}
+
+// ListFiltered 分页查工单(带类型/状态/用户筛选,附总条数)。
+//
+// 入参:ctx;f RequestFilter;page PageQuery(内部钳制 pageSize ∈ [1,200])。
+// 出参:
+//   - []model.UserBalanceRequest:`id DESC`(新单在前);空结果非 nil 切片;
+//   - int64:总条数;
+//   - ErrDBError。
+//
+// 作用域:经 `Scoped`(普通角色只见本机构;超管全量)。**不用** `institutionScoped`:
+// 工单是纯机构内资源,`campus_id` 等值过滤也合理(财务按自己校区看单)。
+func (r *BalanceRequestRepo) ListFiltered(
+	ctx context.Context, f RequestFilter, page PageQuery,
+) ([]model.UserBalanceRequest, int64, error) {
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, 0, err
+	}
+	base := r.Scoped(db.Model(&model.UserBalanceRequest{}))
+	if f.Type != nil {
+		base = base.Where("type = ?", *f.Type)
+	}
+	if f.Approved != nil {
+		base = base.Where("approved = ?", *f.Approved)
+	}
+	if strings.TrimSpace(f.UserID) != "" {
+		base = base.Where("user_id = ?", strings.TrimSpace(f.UserID))
+	}
+	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.UserBalanceRequest, 0, pageSize)
+	if qErr := base.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.UserBalanceRequest, 0)
+	}
+	return rows, total, nil
+}
+
+// GetByID 按主键读工单(**不加锁**,审批前置校验/详情回读用)。
+//
+// 入参:ctx;id 工单 ID(<=0 → ErrBadParam)。
+// 出参:*model.UserBalanceRequest + ErrNotFound(不存在或跨机构)/ ErrBadParam / ErrDBError。
+//
+// 与 GetForUpdate 的分工:写路径(审批 CAS)**必须**用 `GetForUpdate`(tx 内加行锁);
+// 本方法只用于"读一眼",**禁止**在事务写路径里用它替代加锁读(会退化成无锁读-改-写)。
+func (r *BalanceRequestRepo) GetByID(ctx context.Context, id int64) (*model.UserBalanceRequest, error) {
+	if id <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: balance request id 必须 > 0"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.UserBalanceRequest
+	if qErr := r.Scoped(db.Model(&model.UserBalanceRequest{})).Where("id = ?", id).Take(&out).Error; qErr != nil {
+		return nil, wrapQueryErr(qErr, "GetByID(t_user_balance_request)")
+	}
+	return &out, nil
+}
+
+// UpdateResponseMsg 回写审批意见(`response_msg`),审批动作的**附属写**(不改状态、不做 CAS)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**:与审批状态推进同事务,避免"状态变了、意见没写进去");
+//   - id 工单 ID(<=0 → ErrBadParam);
+//   - msg 审批意见(空串 → 写 NULL,保持"无意见"语义)。
+//
+// 出参:受影响行数(0 = 工单不存在/跨机构);ErrBadParam / ErrDBError。
+//
+// 为什么单独一条语句而不并进 UpdateApproved:`UpdateApproved` 是**状态机 CAS**(`WHERE approved=0`),
+// 而意见回写发生在 CAS **成功之后**(此时 approved 已非 0,CAS 条件必然不成立)。
+// 把两者塞进一条 SQL 会让 CAS 语义变模糊(评审时无法一眼看出"状态推进只发生一次")。
+func (r *BalanceRequestRepo) UpdateResponseMsg(
+	ctx context.Context, tx *gorm.DB, id int64, msg string,
+) (int64, error) {
+	if err := requireTx(tx, "UpdateResponseMsg"); err != nil {
+		return 0, err
+	}
+	if id <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateResponseMsg 需要 id"))
+	}
+	var value any
+	if strings.TrimSpace(msg) != "" {
+		value = msg
+	}
+	res := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.UserBalanceRequest{})).
+		Where("id = ?", id).
+		UpdateColumn("response_msg", value)
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}

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

@@ -920,3 +920,155 @@ func (r *ExpenseRepo) SumLogMoney(ctx context.Context, userID string, campusIDs
 	}
 	return total, nil
 }
+
+// ---------------------------------------------------------------- AC-12 查询原语(端点 1 / 11 / 21)
+
+// LogFilter 流水筛选条件(端点 11 `GET /admin/finance/logs`)。
+//
+// 字段语义(nil/空 = 不筛,与"探针为零值即不过滤"的既有 filter 惯例一致):
+//   - UserID string:为空表示**跨用户**(机构内全量;超管跨机构)——这是财务查账的主场景;
+//   - Type *int32:流水类型(model.ExpenseLogType*);用指针区分"未传"与"传了 0";
+//   - From/To *time.Time:`create_time` 闭区间(§3.8-E2:`create_time` 是**业务时间**,参与筛选)。
+//
+// 红线:`t_user_expense_log` **无** institution_id / campus_id 列(§3.8-f 约束 2),
+// 因此"跨用户"查询**无法**在本方法里做机构过滤 —— 越权由 Service 层断言
+// (财务角色只见本机构用户;实现见 service.FinanceQueryService.ListLogs)。
+type LogFilter struct {
+	// UserID 用户(ULID;空 = 跨用户)。
+	UserID string
+	// Type 流水类型(nil = 不筛)。
+	Type *int32
+	// From 起始时间(闭区间下界;nil = 不筛)。
+	From *time.Time
+	// To 结束时间(闭区间上界;nil = 不筛)。
+	To *time.Time
+}
+
+// ListLogsFiltered 分页查流水(带类型/时间/用户筛选,附总条数)。
+//
+// 入参:ctx;f LogFilter;page PageQuery(内部钳制 pageSize ∈ [1,200])。
+// 出参:
+//   - []model.UserExpenseLog:`create_time DESC, id DESC`(倒序,只增表);空结果非 nil 切片;
+//   - int64:满足筛选条件的总条数(分页器用);
+//   - ErrDBError。
+//
+// 为什么新开一个方法而不是改 ListLogs:`ListLogs` 是 AC-03 的既有契约(无 type 过滤、
+// 不返回 total、且对 campusIDs 非空显式 ErrBadParam),改签名会波及既有调用与用例。
+// 本方法是"财务查账"专用视图,两者共存、互不复用。
+func (r *ExpenseRepo) ListLogsFiltered(
+	ctx context.Context, f LogFilter, page PageQuery,
+) ([]model.UserExpenseLog, int64, error) {
+	if r == nil || r.DB == nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ListLogsFiltered 未装配 DB"))
+	}
+	db, err := r.queryDB(ctx, nil)
+	if err != nil {
+		return nil, 0, err
+	}
+	base := db.Model(&model.UserExpenseLog{})
+	if strings.TrimSpace(f.UserID) != "" {
+		base = base.Where("user_id = ?", strings.TrimSpace(f.UserID))
+	}
+	if f.Type != nil {
+		base = base.Where("type = ?", *f.Type)
+	}
+	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 err := base.Count(&total).Error; err != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	pageSize := page.Limit()
+	rows := make([]model.UserExpenseLog, 0, pageSize)
+	qErr := base.Order("create_time DESC").Order("id DESC").
+		Limit(pageSize).Offset(page.Offset()).
+		Find(&rows).Error
+	if qErr != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	if rows == nil {
+		rows = make([]model.UserExpenseLog, 0)
+	}
+	return rows, total, nil
+}
+
+// CampusAccountAgg 校区账户聚合结果(端点 21 仪表盘的"余额分布")。
+//
+// 全部金额为 `money.Amount`(**Go 侧定点累加**,禁止 SQL SUM(decimal),见本文件头金额纪律)。
+type CampusAccountAgg struct {
+	// Accounts 校区账户行数。
+	Accounts int64
+	// Balance 校区余额合计(元;含负数欠费账户,故可能为负)。
+	Balance money.Amount
+	// TotalIncome 累计充值合计(元)。
+	TotalIncome money.Amount
+	// TotalExpenditure 累计消费合计(元)。
+	TotalExpenditure money.Amount
+	// SpecialExpenses 特殊费用合计(元)。
+	SpecialExpenses money.Amount
+}
+
+// AggregateCampusAccounts 聚合本机构(可选指定校区)的校区账户累计列(端点 21)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - institutionID 机构 ID(>0;**普通角色必须与作用域一致**,此处只做机构过滤 + 越权断言交由 Service);
+//   - campusID 校区 ID(>0 时只聚合该校区;0 = 全机构所有校区)。
+//
+// 出参:CampusAccountAgg;ErrBadParam(institutionID<=0)/ ErrDBError。
+//
+// 作用域:`institutionScoped`(机构强制,超管除外;**不叠** `Sc.CampusID` 等值,
+// 由入参 campusID 显式指定,理由同转结/跨校区场景)。
+//
+// ⚠ 口径边界(必须知道):`t_user_expense_log` 无 campus 列 ⇒ **期间流水无法按校区聚合**(§3.8-f 约束 2)。
+// 本方法返回的是"账户累计列"(total_income / total_expenditure / balance),
+// 不是"区间发生额";from/to 的区间口径在 Service 层以"累计列快照"呈现(端点 21 为 P2,已登记)。
+func (r *ExpenseRepo) AggregateCampusAccounts(
+	ctx context.Context, institutionID, campusID int64,
+) (CampusAccountAgg, error) {
+	if institutionID <= 0 {
+		return CampusAccountAgg{}, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: AggregateCampusAccounts 需要 institution_id(>0)"))
+	}
+	db, err := r.queryDB(ctx, nil)
+	if err != nil {
+		return CampusAccountAgg{}, 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 err := q.Select("balance", "total_income", "total_expenditure", "special_expenses").Find(&rows).Error; err != nil {
+		return CampusAccountAgg{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	agg := CampusAccountAgg{Accounts: int64(len(rows))}
+	for i := range rows {
+		balance, perr := money.Parse(rows[i].Balance)
+		if perr != nil {
+			return CampusAccountAgg{}, infraerrors.WithCause(infraerrors.ErrDBError, perr)
+		}
+		income, perr := money.Parse(rows[i].TotalIncome)
+		if perr != nil {
+			return CampusAccountAgg{}, infraerrors.WithCause(infraerrors.ErrDBError, perr)
+		}
+		expenditure, perr := money.Parse(rows[i].TotalExpenditure)
+		if perr != nil {
+			return CampusAccountAgg{}, infraerrors.WithCause(infraerrors.ErrDBError, perr)
+		}
+		special, perr := money.Parse(rows[i].SpecialExpenses)
+		if perr != nil {
+			return CampusAccountAgg{}, infraerrors.WithCause(infraerrors.ErrDBError, perr)
+		}
+		agg.Balance = agg.Balance.Add(balance)
+		agg.TotalIncome = agg.TotalIncome.Add(income)
+		agg.TotalExpenditure = agg.TotalExpenditure.Add(expenditure)
+		agg.SpecialExpenses = agg.SpecialExpenses.Add(special)
+	}
+	return agg, nil
+}

+ 586 - 0
internal/academic/service/finance_query_service.go

@@ -0,0 +1,586 @@
+// 财务只读查询服务(端点 1 / 11 / 12 / 21 的编排层)。
+//
+// 依据:unified/03-migration/academic-domain-design.md
+//   - §4.1 端点 1(账户快照)、11(流水列表)、12(工单列表)、21(仪表盘,P2)
+//   - §3.8-f 约束 2:「`t_user_expense_log` **无 campus 维度 ⇒ 恒等式/汇总只能按 user 做**」
+//     —— 这条约束直接决定了本服务两个"必须显式拒绝"的边界,见 ListLogs / Dashboard 注释。
+//   - §1.7 例外一:全局钱包与流水无作用域列,**越权断言必须在 Service 层**(不能靠 Scoped)。
+//
+// 为什么单独一个查询服务(而不是塞进 handler 或 FundService):
+//   - Handler 层纪律是"不碰 DB"(infra/scope 包注释、org/identity handler 同款);
+//   - FundService 是**资金写路径**(每个方法都在事务里改余额),把 4 个只读聚合塞进去会
+//     让"这个服务会不会动钱"变得不可一眼判断(评审成本最高的一类退化)。
+//     本服务**全程只读、不开事务**(唯一例外是它调用的仓储方法本身不开事务)。
+package service
+
+import (
+	"context"
+	"strings"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/port"
+	"yuansiwei/unified-backend/internal/academic/repository"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// FinanceQueryService 财务只读查询服务(端点 1 / 11 / 12 / 21)。
+//
+// 字段:
+//   - expense 余额账户仓储(全局钱包 / 校区账户 / 流水);
+//   - requests 余额工单仓储(工单列表);
+//   - member 成员校验器(可 nil:nil 时跳过对象级越权断言,仅限双跑/回填脚本);
+//   - sc 请求作用域(可 nil:系统任务视角)。
+type FinanceQueryService struct {
+	expense  *repository.ExpenseRepo
+	requests *repository.BalanceRequestRepo
+	member   port.MembershipChecker
+	sc       *appscope.Scope
+}
+
+// NewFinanceQueryService 构造财务只读查询服务。
+//
+// 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域);member port.MembershipChecker(可 nil)。
+// 出参:*FinanceQueryService。
+func NewFinanceQueryService(db *gorm.DB, sc *appscope.Scope, member port.MembershipChecker) *FinanceQueryService {
+	return &FinanceQueryService{
+		expense:  repository.NewExpenseRepo(db, sc),
+		requests: repository.NewBalanceRequestRepo(db, sc),
+		member:   member,
+		sc:       sc,
+	}
+}
+
+// ---------------------------------------------------------------- 端点 1:账户快照
+
+// AccountSnapshot 账户快照(端点 1 `GET /admin/finance/accounts/:userId`)。
+//
+// 语义:**全局钱包** + (可选)**指定校区账户**两段。金额全部 `money.Amount`(两位小数字符串输出)。
+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 int64 `json:"institutionId"`
+	// CampusID 校区 ID(0=机构级账户)。
+	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 校区特殊费用(元)。
+	SpecialExpenses money.Amount `json:"specialExpenses"`
+}
+
+// AccountSnapshot 读账户快照(端点 1)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - userID 用户(ULID;必填);
+//   - institutionID 机构 ID(可选:>0 时作为越权判定输入与校区账户定位维度);
+//   - campusID 校区 ID(可选:>0 时附带该校区账户;=0 则不返回 campus 段)。
+//
+// 出参:
+//   - *AccountSnapshot;全局账户不存在时 Global 全 0 且 **不报错**(新用户尚未建户,
+//     与 dump 的"账户不存在 → 余额 0"同口径;前端按 0 展示);
+//   - ErrBadParam(2001) 缺 user_id / ErrUnscoped(1004) 越权 / ErrDBError(5002)。
+//
+// 越权:全局钱包无作用域列(§1.7 例外一)⇒ **必须**在这里经 member 断言该用户属于本机构;
+// 未注入 member(双跑脚本)时跳过——这条跳过的后果写进注释,评审可一眼看到边界。
+func (s *FinanceQueryService) AccountSnapshot(
+	ctx context.Context, userID string, institutionID, campusID int64,
+) (*AccountSnapshot, error) {
+	if strings.TrimSpace(userID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: AccountSnapshot 需要 user_id"))
+	}
+	if err := s.assertMember(ctx, userID, institutionID, campusID); err != nil {
+		return nil, err
+	}
+	out := &AccountSnapshot{UserID: userID}
+
+	global, err := s.expense.GetGlobalAccount(ctx, nil, userID)
+	if err != nil {
+		if isNotFoundErr(err) {
+			// 账户不存在:全 0(dump 的 ISNULL 归零分支),不是错误
+			return out, nil
+		}
+		return nil, err
+	}
+	out.Global, err = buildAccountBalanceView(global)
+	if err != nil {
+		return nil, err
+	}
+
+	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(或作用域机构)"))
+	}
+	campus, cErr := s.expense.GetCampusAccount(ctx, nil, userID, instID, campusID)
+	if cErr != nil {
+		if isNotFoundErr(cErr) {
+			return out, nil // 该校区未建户 → campus = null(与"余额 0"区分)
+		}
+		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
+	}
+	expenditure, err := parseAccountAmount(row.TotalExpenditure, "t_user_expense.total_expenditure")
+	if err != nil {
+		return AccountBalanceView{}, err
+	}
+	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
+	}
+	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
+}
+
+// ---------------------------------------------------------------- 端点 11:流水列表
+
+// ListLogsInput 流水列表入参(端点 11 `GET /admin/finance/logs`)。
+//
+// 字段:
+//   - UserID 用户(**非超管必填**,见 ListLogs 注释);
+//   - Type 流水类型(nil 不筛);
+//   - From / To `create_time` 闭区间(nil 不筛);
+//   - Page / PageSize 分页(<=0 → ClampPage 归一;>200 → 200)。
+type ListLogsInput struct {
+	// UserID 用户(ULID;非超管必填)。
+	UserID string
+	// Type 流水类型(model.ExpenseLogType*;nil 不筛)。
+	Type *int32
+	// From 起始时间(闭区间;nil 不筛)。
+	From *time.Time
+	// To 结束时间(闭区间;nil 不筛)。
+	To *time.Time
+	// Page 页码。
+	Page int
+	// PageSize 每页条数。
+	PageSize int
+}
+
+// LogView 流水分页行(端点 11 响应 list 元素)。
+//
+// 安全:只暴露对账必需列;`remark` 之外的自由文本(如 `t_order_pay.pay_return_data`、
+// 工单 `imgs`)**不在本视图内**(AC-12 验收第 ⑥ 条)。
+type LogView struct {
+	// ID 流水 ID。
+	ID int64 `json:"id"`
+	// UserID 用户(ULID)。
+	UserID string `json:"userId"`
+	// Type 流水类型(model.ExpenseLogType*)。
+	Type int32 `json:"type"`
+	// LogID 业务单号(旧 logid;可空 → "")。
+	LogID string `json:"logId"`
+	// Money 变更金额(元;符号表方向)。
+	Money money.Amount `json:"money"`
+	// BalanceAfter 该操作完成后**全局账户**余额快照(历史行 NULL → null)。
+	BalanceAfter *money.Amount `json:"balanceAfter"`
+	// Remark 备注(脱敏后透出;写入侧已禁止敏感信息)。
+	Remark string `json:"remark"`
+	// CreateTime 业务时间(§3.8-E2 ①类)。
+	CreateTime time.Time `json:"createTime"`
+}
+
+// ListLogs 分页查流水(端点 11)。
+//
+// 入参:ctx;in ListLogsInput。
+// 出参:([]LogView, total, error);错误码 2001(缺 userId 且非超管)/1004/5002。
+//
+// **作用域红线(必须读)**:`t_user_expense_log` **没有** institution_id / campus_id
+// (§3.8-f 约束 2),因此"跨用户查全机构流水"在数据层**无法**按机构过滤——一旦放开,
+// 普通财务就能读到**别的机构**用户的流水(真实越权)。
+// 处置:**非超管必须带 userId**(否则 2001),并由 member 断言该用户属于本机构;
+// 超管可跨用户(但仍**不**跨库过滤,需要机构维度请按 userId 逐用户查)。
+// 这不是"少做一步",而是把"做不到"显式化(2001 + 明确文案),绝不静默返回全量。
+func (s *FinanceQueryService) ListLogs(ctx context.Context, in ListLogsInput) ([]LogView, int64, error) {
+	userID := strings.TrimSpace(in.UserID)
+	isSuper := s.sc != nil && s.sc.SuperAdmin
+	if userID == "" {
+		if !isSuper {
+			return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+				errString("service: ListLogs 非超管必须指定 userId(流水表无机构列,无法按机构过滤,禁止全量导出)"))
+		}
+	} else if err := s.assertMember(ctx, userID, 0, 0); err != nil {
+		return nil, 0, err
+	}
+	if in.From != nil && in.To != nil && in.From.After(*in.To) {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: ListLogs 的 from 不能晚于 to"))
+	}
+
+	page := repository.PageQuery{Page: in.Page, PageSize: in.PageSize}
+	rows, total, err := s.expense.ListLogsFiltered(ctx, repository.LogFilter{
+		UserID: userID, Type: in.Type, From: in.From, To: in.To,
+	}, page)
+	if err != nil {
+		return nil, 0, err
+	}
+	views := make([]LogView, 0, len(rows))
+	for i := range rows {
+		view, vErr := buildLogView(&rows[i])
+		if vErr != nil {
+			return nil, 0, vErr
+		}
+		views = append(views, *view)
+	}
+	return views, total, nil
+}
+
+// buildLogView 把流水行转成视图(金额定点解析 + 空值归一)。
+//
+// 入参:row 流水行(非 nil)。出参:*LogView;金额非法 → ErrDBError。
+func buildLogView(row *model.UserExpenseLog) (*LogView, error) {
+	amount, err := money.Parse(row.Money)
+	if err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	view := &LogView{
+		ID: row.ID, UserID: row.UserID, Type: row.Type, Money: amount, CreateTime: row.CreateTime,
+	}
+	if row.LogID != nil {
+		view.LogID = *row.LogID
+	}
+	if row.Remark != nil {
+		view.Remark = *row.Remark
+	}
+	if row.BalanceAfter != nil {
+		after, perr := money.Parse(*row.BalanceAfter)
+		if perr != nil {
+			return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
+		}
+		view.BalanceAfter = &after
+	}
+	return view, nil
+}
+
+// ---------------------------------------------------------------- 端点 12:工单列表
+
+// ListRequestsInput 工单列表入参(端点 12 `GET /admin/finance/requests`)。
+type ListRequestsInput struct {
+	// Type 工单类型(100/101/202/203/300;nil 不筛)。
+	Type *int32
+	// Approved 审批状态(-2/-1/0/1;nil 不筛)。
+	Approved *int8
+	// UserID 用户(空 = 本机构全部)。
+	UserID string
+	// Page / PageSize 分页。
+	Page     int
+	PageSize int
+}
+
+// RequestView 工单列表行(端点 12 响应 list 元素)。
+//
+// 安全:**不含 `imgs`**(凭证图片可能含个人信息/银行流水,model 侧已 `json:"-"`,
+// 这里同样不纳入视图);如需下载凭证,走单独的受控端点(本期不做)。
+type RequestView struct {
+	// ID 工单 ID。
+	ID int64 `json:"id"`
+	// BizNo 业务单号(对外单号;幂等锚点)。
+	BizNo string `json:"bizNo"`
+	// Type 工单类型。
+	Type int32 `json:"type"`
+	// UserID 用户。
+	UserID string `json:"userId"`
+	// InstitutionID 机构 ID。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区 ID。
+	CampusID int64 `json:"campusId"`
+	// ToInstitutionID 转入机构(仅 type=300)。
+	ToInstitutionID *int64 `json:"toInstitutionId"`
+	// ToCampusID 转入校区(仅 type=300)。
+	ToCampusID *int64 `json:"toCampusId"`
+	// Money 金额(元)。
+	Money money.Amount `json:"money"`
+	// TransferAccountsType 收款方式(0/1/2/3)。
+	TransferAccountsType int8 `json:"transferAccountsType"`
+	// OrderNo 交易单号(可空)。
+	OrderNo string `json:"orderNo"`
+	// Item 项目(可空)。
+	Item string `json:"item"`
+	// Remark 备注(可空)。
+	Remark string `json:"remark"`
+	// RequestUserID 申请人。
+	RequestUserID string `json:"requestUserId"`
+	// RequestTime 申请时间(可空)。
+	RequestTime *time.Time `json:"requestTime"`
+	// ResponseUserID 审批人(可空)。
+	ResponseUserID string `json:"responseUserId"`
+	// ResponseTime 审批时间(可空)。
+	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"`
+}
+
+// ListRequests 分页查工单(端点 12)。
+//
+// 入参:ctx;in ListRequestsInput。出参:([]RequestView, total, error);2001/5002。
+//
+// 作用域:仓储经 `Scoped`(普通角色只见本机构;超管全量)——工单表**有** institution_id,
+// 所以这里不需要 ListLogs 那种"非超管必须带 userId"的兜底。
+func (s *FinanceQueryService) ListRequests(ctx context.Context, in ListRequestsInput) ([]RequestView, int64, error) {
+	page := repository.PageQuery{Page: in.Page, PageSize: in.PageSize}
+	rows, total, err := s.requests.ListFiltered(ctx, repository.RequestFilter{
+		Type: in.Type, Approved: in.Approved, UserID: strings.TrimSpace(in.UserID),
+	}, page)
+	if err != nil {
+		return nil, 0, err
+	}
+	views := make([]RequestView, 0, len(rows))
+	for i := range rows {
+		view, vErr := buildRequestView(&rows[i])
+		if vErr != nil {
+			return nil, 0, vErr
+		}
+		views = append(views, *view)
+	}
+	return views, total, nil
+}
+
+// buildRequestView 把工单行转成视图(金额定点解析 + 可空列归一)。
+//
+// 入参:row 工单行(非 nil)。出参:*RequestView;金额非法 → ErrDBError。
+func buildRequestView(row *model.UserBalanceRequest) (*RequestView, error) {
+	amount, err := money.Parse(row.Money)
+	if err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	view := &RequestView{
+		ID: row.ID, BizNo: row.BizNo, Type: row.Type, UserID: row.UserID,
+		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,
+	}
+	if row.OrderNo != nil {
+		view.OrderNo = *row.OrderNo
+	}
+	if row.Item != nil {
+		view.Item = *row.Item
+	}
+	if row.Remark != nil {
+		view.Remark = *row.Remark
+	}
+	if row.ResponseUserID != nil {
+		view.ResponseUserID = *row.ResponseUserID
+	}
+	if row.ResponseMsg != nil {
+		view.ResponseMsg = *row.ResponseMsg
+	}
+	return view, nil
+}
+
+// ---------------------------------------------------------------- 端点 21:仪表盘(P2)
+
+// DashboardInput 仪表盘入参(端点 21 `GET /admin/finance/dashboard`)。
+type DashboardInput struct {
+	// From / To 统计窗口回显(本端点按"账户累计列"聚合,窗口仅回显 + 供前端对齐筛选器,见 Dashboard 注释)。
+	From time.Time
+	To   time.Time
+	// CampusID 校区 ID(>0 只统计该校区;0 = 全机构)。
+	CampusID int64
+}
+
+// DashboardResult 仪表盘结果(余额分布 + 累计收支)。
+//
+// ⚠ 口径(P2 简化,已在设计 §4.1 #21「P2,可延后」范围内):
+// `t_user_expense_log` 无 campus 列 ⇒ **无法**按校区/机构聚合"区间发生额"(§3.8-f 约束 2)。
+// 因此本结果是**账户累计列**的横截面(total_income / total_expenditure / balance / special_expenses),
+// **不是** [From, To] 区间增量。`from`/`to` 仅回显,供前端呈现筛选器状态;
+// 若产品确需"区间发生额分布",须先给出"流水 → 机构/校区"的归属路径(当前 schema 不支持),
+// 属跨域建模变更,不在本期。
+type DashboardResult struct {
+	// From / To 回显窗口。
+	From time.Time `json:"from"`
+	To   time.Time `json:"to"`
+	// CampusID 统计维度(0 = 全机构)。
+	CampusID int64 `json:"campusId"`
+	// Accounts 参与统计的校区账户数。
+	Accounts int64 `json:"accounts"`
+	// Balance 校区余额合计(元)。
+	Balance money.Amount `json:"balance"`
+	// TotalIncome 累计充值合计(元)。
+	TotalIncome money.Amount `json:"totalIncome"`
+	// TotalExpenditure 累计消费合计(元)。
+	TotalExpenditure money.Amount `json:"totalExpenditure"`
+	// SpecialExpenses 特殊费用合计(元)。
+	SpecialExpenses money.Amount `json:"specialExpenses"`
+	// NetReceivable 应收净额 = TotalIncome − TotalExpenditure(元;**派生列**,便于看板直接展示)。
+	NetReceivable money.Amount `json:"netReceivable"`
+	// FlowsByCampusSupported 恒 `false`:明确告知前端"本端点不支持按区间/校区拆分流水",
+	// 免得前端以为字段缺失是自己拼错(口径见上方类型注释)。
+	FlowsByCampusSupported bool `json:"flowsByCampusSupported"`
+}
+
+// Dashboard 财务仪表盘(端点 21,P2)。
+//
+// 入参:ctx;in DashboardInput(From/To 必填;CampusID 可选)。
+// 出参:*DashboardResult;2001(缺时间窗)/1004/5002。
+func (s *FinanceQueryService) Dashboard(ctx context.Context, in DashboardInput) (*DashboardResult, error) {
+	if in.From.IsZero() || in.To.IsZero() {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: Dashboard 需要 from/to"))
+	}
+	if in.From.After(in.To) {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: Dashboard 的 from 不能晚于 to"))
+	}
+	if s.sc == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrUnauth,
+			errString("service: Dashboard 需要请求作用域(机构维度不可缺省)"))
+	}
+	agg, err := s.expense.AggregateCampusAccounts(ctx, s.sc.InstitutionID, in.CampusID)
+	if err != nil {
+		return nil, err
+	}
+	return &DashboardResult{
+		From: in.From, To: in.To, CampusID: in.CampusID, Accounts: agg.Accounts,
+		Balance: agg.Balance, TotalIncome: agg.TotalIncome,
+		TotalExpenditure: agg.TotalExpenditure, SpecialExpenses: agg.SpecialExpenses,
+		NetReceivable:          agg.TotalIncome.Sub(agg.TotalExpenditure),
+		FlowsByCampusSupported: false,
+	}, nil
+}
+
+// ---------------------------------------------------------------- 共享断言
+
+// assertMember 对象级越权断言(全局钱包/流水无作用域列,必须在这里断)。
+//
+// 入参:ctx;userID 目标用户;institutionID/campusID 作用域维度(0 = 不限/取作用域)。
+// 出参:nil 通过;ErrUnscoped(1004) 越界;ErrDBError。
+//
+// 三种放行情形(顺序即优先级):
+//  1. member 未注入(双跑脚本/系统任务)→ 跳过(**这是唯一的"不断言"入口**,评审可见);
+//  2. 作用域为超管 → 跳过;
+//  3. 其余 → `member.AssertInScope(userID, 机构, 校区)`。
+//     机构/校区为 0 时由实现方按"机构内任一校区"语义判定(port 契约已写明)。
+func (s *FinanceQueryService) assertMember(
+	ctx context.Context, userID string, institutionID, campusID int64,
+) error {
+	if s.member == nil {
+		return nil
+	}
+	if s.sc != nil && s.sc.SuperAdmin {
+		return nil
+	}
+	instID, campID := institutionID, campusID
+	if instID <= 0 && s.sc != nil {
+		instID = s.sc.InstitutionID
+	}
+	if campID <= 0 && s.sc != nil {
+		campID = s.sc.CampusID
+	}
+	return s.member.AssertInScope(ctx, userID, instID, campID)
+}

+ 512 - 0
internal/academic/service/finance_request_service.go

@@ -0,0 +1,512 @@
+// 余额工单服务(端点 13~16:创建 / 通过 / 驳回 / 撤销)。
+//
+// 依据:unified/03-migration/academic-domain-design.md
+//   - §4.1 端点 13~16;§2「4. t_user_balance_request」的资金变动触发点定义:
+//     **「approved 由 0 → 1 的审批动作」是唯一触发点,且 Service 同事务内先调资金方法、后回写工单**
+//   - §10.3 事务模板:`repository.WithinTx` + 事务内调下游 service **必须** `WithTxContext` 透传
+//   - §5.1 纪律 5(幂等回放等价):审批通过时以工单 `biz_no` 作 `request_id`——
+//     同一工单被重复点"通过"不会二次入账(资金侧 `uk_request_type` 兜底,且 CAS 只让一次成功)。
+//
+// ---------------------------------------------------------------------------
+// 为什么本服务存在(设计 §8 的 AC-12 文件清单里没有它)
+// ---------------------------------------------------------------------------
+//
+// AC-12 的文件清单只列了 handler 文件,但端点 14「通过」不是"读一眼再写一个状态":
+// 它必须**按工单类型分派到对应资金方法**(100 充值 / 101 坏账 / 202 退费 / 300 转结),
+// 且资金写入与工单状态回写必须**同一个事务**(§10.3)。把这套编排放在 Handler 里
+// 会同时违反两条纪律(Handler 不写业务、事务边界只在 Service),故新增本服务承载,
+// 已在交付报告中登记为偏差。
+//
+// ---------------------------------------------------------------------------
+// 审批状态机(只允许 0 → {1, -1, -2},终态不可再变)
+// ---------------------------------------------------------------------------
+//
+//	0 未处理 ──approve──▶ 1  已通过(同事务触发资金方法;以 biz_no 作幂等 request_id)
+//	0 未处理 ──reject───▶ -1 不通过(无资金动作)
+//	0 未处理 ──cancel───▶ -2 取消(仅申请人本人或 super_admin/finance;无资金动作)
+//	终态再点 → 3002(数据冲突):**不做"静默成功"**,因为"钱到底动没动"必须一眼可判
+package service
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"fmt"
+	"strings"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/apperr"
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/port"
+	"yuansiwei/unified-backend/internal/academic/repository"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// 工单审批状态在"可操作"判断中的语义常量(复用 model 的取值,此处只做局部命名便于阅读)。
+const (
+	// requestPending 未处理(唯一可被 approve/reject/cancel 的状态)。
+	requestPending int8 = model.BalanceRequestApprovedPending
+	// requestPassed 已通过(终态)。
+	requestPassed int8 = model.BalanceRequestApprovedPassed
+	// requestRejected 不通过(终态)。
+	requestRejected int8 = model.BalanceRequestApprovedRejected
+	// requestCancelled 取消(终态)。
+	requestCancelled int8 = model.BalanceRequestApprovedCancelled
+)
+
+// BalanceRequestService 余额工单服务(创建 / 审批 / 驳回 / 撤销)。
+//
+// 字段:
+//   - requests 工单仓储(加锁读 + CAS 推进);
+//   - runner 事务运行器(资金方法 + 工单回写同一事务);
+//   - fund 资金服务(充值/消费/退费/转结/坏账的**唯一实现**,本服务只分派、不重复实现);
+//   - member 成员校验器(可 nil);
+//   - sc 请求作用域(可 nil:系统任务视角)。
+type BalanceRequestService struct {
+	requests *repository.BalanceRequestRepo
+	runner   *repository.TxRunner
+	fund     *FundService
+	member   port.MembershipChecker
+	sc       *appscope.Scope
+}
+
+// NewBalanceRequestService 构造余额工单服务。
+//
+// 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域);member port.MembershipChecker(可 nil)。
+// 出参:*BalanceRequestService(内部 FundService 用同一 db/scope 装配,保证同一事务可透传)。
+func NewBalanceRequestService(db *gorm.DB, sc *appscope.Scope, member port.MembershipChecker) *BalanceRequestService {
+	return &BalanceRequestService{
+		requests: repository.NewBalanceRequestRepo(db, sc),
+		runner:   repository.NewTxRunner(db),
+		fund:     NewFundService(db, sc, member),
+		member:   member,
+		sc:       sc,
+	}
+}
+
+// ---------------------------------------------------------------- 端点 13:创建工单
+
+// CreateRequestInput 创建工单入参(端点 13 `POST /admin/finance/requests`)。
+//
+// 字段:
+//   - Type 工单类型(100 普通充值 / 101 坏账 / 202 退费 / 203 取消充值活动 / 300 转结);
+//   - UserID 目标用户;
+//   - InstitutionID/CampusID 作用域维度;
+//   - Money 金额(**已由 Handler 用 money.Parse 解析**,拒绝 float);
+//   - TransferAccountsType 收款方式(0/1/2/3);
+//   - OrderNo/Item/Imgs/Remark 附注(Imgs 只写不读,API 不回显);
+//   - ToInstitutionID/ToCampusID 仅 type=300 转结使用。
+type CreateRequestInput struct {
+	// Type 工单类型。
+	Type int32
+	// UserID 目标用户(ULID)。
+	UserID string
+	// InstitutionID 机构 ID。
+	InstitutionID int64
+	// CampusID 校区 ID。
+	CampusID int64
+	// ToInstitutionID 转入机构(仅 300)。
+	ToInstitutionID int64
+	// ToCampusID 转入校区(仅 300)。
+	ToCampusID int64
+	// Money 金额(元)。
+	Money money.Amount
+	// TransferAccountsType 收款方式。
+	TransferAccountsType int8
+	// OrderNo 交易单号(可空)。
+	OrderNo string
+	// Item 项目(可空)。
+	Item string
+	// Imgs 凭证图片 JSON 串(可空;**仅落库,不回显**)。
+	Imgs string
+	// Remark 备注(可空)。
+	Remark string
+	// RequestUserID 申请人(ULID;由 Handler 从作用域取)。
+	RequestUserID string
+}
+
+// CreateRequestResult 创建工单结果(端点 13 响应 `{id,bizNo}`)。
+type CreateRequestResult struct {
+	// ID 工单 ID。
+	ID int64 `json:"id"`
+	// BizNo 业务单号(对外单号,同时是审批通过时的幂等 request_id)。
+	BizNo string `json:"bizNo"`
+}
+
+// requestTypesCreatable 可创建的工单类型白名单(与 §2「4.」的类型枚举对齐)。
+var requestTypesCreatable = map[int32]struct{}{
+	model.BalanceRequestTypeRecharge:       {}, // 100
+	model.BalanceRequestTypeBadDebt:        {}, // 101
+	model.BalanceRequestTypeRefund:         {}, // 202
+	model.BalanceRequestTypeCancelRecharge: {}, // 203
+	model.BalanceRequestTypeTransfer:       {}, // 300
+}
+
+// Create 创建余额工单(端点 13)。
+//
+// 入参:ctx;in CreateRequestInput。
+// 出参:*CreateRequestResult;错误码 2001(缺参/类型非白名单)/2002(金额非法)/5002。
+//
+// 红线(设计 §2):`approved` **一律 0**(不得创建即通过)——资金变动必须走审批链;
+// 本方法也**不**触发任何资金动作(真正的触发点是 Approve)。
+func (s *BalanceRequestService) Create(ctx context.Context, in CreateRequestInput) (*CreateRequestResult, error) {
+	if strings.TrimSpace(in.UserID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: CreateRequest 需要 user_id"))
+	}
+	if _, ok := requestTypesCreatable[in.Type]; !ok {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: CreateRequest 工单类型不在白名单(100/101/202/203/300)"))
+	}
+	if in.InstitutionID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: CreateRequest 需要 institution_id(>0)"))
+	}
+	if in.Type == model.BalanceRequestTypeTransfer {
+		if in.ToInstitutionID <= 0 || in.ToCampusID <= 0 {
+			return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+				errString("service: 转结工单必须指定 toInstitutionId/toCampusId"))
+		}
+	} else if err := validatePositiveAmount(in.Money, "CreateRequest"); err != nil {
+		return nil, err
+	}
+	if in.Type == model.BalanceRequestTypeTransfer {
+		if err := validatePositiveAmount(in.Money, "CreateRequest"); err != nil {
+			return nil, err
+		}
+	}
+
+	bizNo, err := newRequestBizNo()
+	if err != nil {
+		return nil, err
+	}
+	row := &model.UserBalanceRequest{
+		BizNo: bizNo, Type: in.Type, UserID: in.UserID,
+		InstitutionID: in.InstitutionID, CampusID: in.CampusID,
+		Money: in.Money.String(), TransferAccountsType: in.TransferAccountsType,
+		RequestUserID: in.RequestUserID, Approved: requestPending,
+	}
+	now := time.Now()
+	row.RequestTime = &now
+	if in.Type == model.BalanceRequestTypeTransfer {
+		toInst, toCampus := in.ToInstitutionID, in.ToCampusID
+		row.ToInstitutionID = &toInst
+		row.ToCampusID = &toCampus
+	}
+	row.OrderNo = optionalString(in.OrderNo)
+	row.Item = optionalString(in.Item)
+	row.Imgs = optionalString(in.Imgs)
+	row.Remark = optionalString(in.Remark)
+
+	if err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		return s.requests.Insert(repository.WithTxContext(ctx, tx), tx, row)
+	}); err != nil {
+		if strings.Contains(err.Error(), "重复的业务单号") {
+			return nil, infraerrors.WithCause(infraerrors.ErrConflict,
+				errString("service: 工单号重复(biz_no 撞键),请重试"))
+		}
+		return nil, err
+	}
+	return &CreateRequestResult{ID: row.ID, BizNo: row.BizNo}, nil
+}
+
+// newRequestBizNo 生成工单业务单号(32 位 hex = 16 字节随机,列宽 `varchar(32)`)。
+//
+// 为什么不用 ULID:旧库 `t_user_balance_request.id` 是 32 位 UUID(无连字符),
+// 目标态 `biz_no` 尺寸就是按它定的;这里用同一长度/字符集的随机串,
+// 迁移与回滚时"旧 UUID ↔ 新 biz_no"在形态上不可区分(不需要额外转换规则)。
+//
+// 出参:32 位小写 hex;随机源不可用 → ErrInternal(**不降级成时间戳**:可预测单号会被撞键)。
+func newRequestBizNo() (string, error) {
+	buf := make([]byte, 16)
+	if _, err := rand.Read(buf); err != nil {
+		return "", infraerrors.WithCause(infraerrors.ErrInternal,
+			errString("service: 工单号随机源不可用: "+err.Error()))
+	}
+	return hex.EncodeToString(buf), nil
+}
+
+// ---------------------------------------------------------------- 端点 14:审批通过
+
+// ApproveRequestInput 审批通过入参(端点 14 `POST /admin/finance/requests/:id/approve`)。
+type ApproveRequestInput struct {
+	// ID 工单 ID。
+	ID int64
+	// ResponseMsg 审批意见(可空)。
+	ResponseMsg string
+	// OperatorID 审批人(ULID;写 response_user_id)。
+	OperatorID string
+}
+
+// ApproveRequestResult 审批通过结果(端点 14 响应 `{approved:1,result:{...}}`)。
+type ApproveRequestResult struct {
+	// Approved 恒 1(通过)。
+	Approved int8 `json:"approved"`
+	// Type 工单类型(便于前端按类型解释 result 形状)。
+	Type int32 `json:"type"`
+	// BizNo 工单号(= 资金流水的 request_id,对账锚点)。
+	BizNo string `json:"bizNo"`
+	// Result 对应资金方法的返回值(各类型形状不同:`{logId,balance,repeated}` / `{logId,specialExpenses,...}` / `{logId106,logId206,...}`)。
+	Result any `json:"result"`
+	// Repeated true = 资金侧幂等命中(工单此前已通过过、或同一 biz_no 已入账)。
+	Repeated bool `json:"repeated"`
+}
+
+// Approve 审批通过(端点 14):**同一事务**内先调资金方法、后回写工单状态。
+//
+// 入参:ctx;in ApproveRequestInput。
+// 出参:*ApproveRequestResult;错误码 2001/3001/3002/31xx(资金方法透传)/1004/5002。
+//
+// 时序(不可调整,§10.3 + §5.1 纪律 5):
+//
+//	① 事务开始
+//	② `GetForUpdate`:加行锁读工单(断言 approved == 0,否则 3002 —— 终态不再触发资金)
+//	③ 按类型分派资金方法(`WithTxContext(ctx, tx)` 透传同一事务)
+//	   幂等键 = 工单 `biz_no`:重复审批时资金侧撞 `uk_request_type` → 回放首次结果
+//	④ `UpdateApproved(+1)` CAS(`WHERE approved = 0`):影响 0 行 ⇒ 并发被抢 ⇒ 3002 回滚
+//	⑤ 提交
+//
+// ⚠ 203(取消充值活动)**在本端点不支持**:`t_user_balance_request` 没有 activity_id 列
+// (dump 也没有),金额/活动只能靠 `item`/`order_no` 自由文本猜——猜错就是把钱退给错的活动。
+// 该动作已有专用端点 30(`RefundService.CancelRechargeActivity`,入参含 activityId),
+// 因此这里显式 3140 并给出文案,**不静默跳过**(跳过会让"审批通过"变成什么都没发生)。
+func (s *BalanceRequestService) Approve(ctx context.Context, in ApproveRequestInput) (*ApproveRequestResult, error) {
+	if in.ID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Approve 需要工单 id"))
+	}
+	out := &ApproveRequestResult{}
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		req, err := s.requests.GetForUpdate(txCtx, tx, in.ID)
+		if err != nil {
+			return err
+		}
+		if req.Approved != requestPending {
+			return infraerrors.WithCause(infraerrors.ErrConflict,
+				errString(fmt.Sprintf("service: 工单 %d 已处理(approved=%d),不会重复触发资金动作",
+					req.ID, req.Approved)))
+		}
+		amount, perr := money.Parse(req.Money)
+		if perr != nil {
+			return infraerrors.WithCause(infraerrors.ErrDBError, perr)
+		}
+		result, repeated, derr := s.dispatchByType(txCtx, req, amount, in.OperatorID)
+		if derr != nil {
+			return derr
+		}
+		now := time.Now()
+		operator := in.OperatorID
+		rows, uerr := s.requests.UpdateApproved(txCtx, tx, req.ID, requestPassed, &operator, &now)
+		if uerr != nil {
+			return uerr
+		}
+		if rows == 0 {
+			return infraerrors.WithCause(infraerrors.ErrConflict,
+				errString("service: 工单状态已被并发改动(CAS 影响 0 行)"))
+		}
+		if strings.TrimSpace(in.ResponseMsg) != "" {
+			if _, merr := s.requests.UpdateResponseMsg(txCtx, tx, req.ID, in.ResponseMsg); merr != nil {
+				return merr
+			}
+		}
+		out.Approved = requestPassed
+		out.Type = req.Type
+		out.BizNo = req.BizNo
+		out.Result = result
+		out.Repeated = repeated
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// dispatchByType 按工单类型分派到对应资金方法(**唯一实现复用,不重复写资金逻辑**)。
+//
+// 入参:txCtx 携带事务的上下文(必须透传给下游 service);req 工单行;amount 已解析金额;operatorID 操作人。
+// 出参:(资金方法返回值, 是否幂等命中, error)。
+//
+// 分派表(工单类型 → 资金方法 → 流水类型):
+//
+//	100 普通充值      → FundService.Recharge        → type=100
+//	101 坏账          → FundService.BadDebt         → type=108(balance 与 special_expenses 双增)
+//	202 退费          → FundService.Refund          → type=207
+//	300 转结          → FundService.Transfer        → type=106/206(两条流水共用 request_id)
+//	203 取消充值活动  → **不支持**(见 Approve 注释)→ 3140
+func (s *BalanceRequestService) dispatchByType(
+	txCtx context.Context, req *model.UserBalanceRequest, amount money.Amount, operatorID string,
+) (any, bool, error) {
+	remark := ""
+	if req.Remark != nil {
+		remark = *req.Remark
+	}
+	switch req.Type {
+	case model.BalanceRequestTypeRecharge:
+		res, err := s.fund.Recharge(txCtx, RechargeInput{
+			UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
+			Money: amount, Type: model.ExpenseLogTypeRecharge, RequestID: req.BizNo,
+			Remark: remark, OperatorID: operatorID,
+		})
+		if err != nil {
+			return nil, false, err
+		}
+		return res, res.Repeated, nil
+	case model.BalanceRequestTypeBadDebt:
+		res, err := s.fund.BadDebt(txCtx, BadDebtInput{
+			UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
+			Money: amount, RequestID: req.BizNo, Remark: remark, OperatorID: operatorID,
+		})
+		if err != nil {
+			return nil, false, err
+		}
+		return res, res.Repeated, nil
+	case model.BalanceRequestTypeRefund:
+		res, err := s.fund.Refund(txCtx, RefundInput{
+			UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
+			Money: amount, Type: model.ExpenseLogTypeRefund, RequestID: req.BizNo,
+			Remark: remark, OperatorID: operatorID,
+		})
+		if err != nil {
+			return nil, false, err
+		}
+		return res, res.Repeated, nil
+	case model.BalanceRequestTypeTransfer:
+		if req.ToInstitutionID == nil || req.ToCampusID == nil {
+			return nil, false, infraerrors.WithCause(apperr.ErrBusinessParam,
+				errString("service: 转结工单缺少 toInstitutionId/toCampusId,无法执行"))
+		}
+		res, err := s.fund.Transfer(txCtx, TransferInput{
+			UserID:            req.UserID,
+			FromInstitutionID: req.InstitutionID, FromCampusID: req.CampusID,
+			ToInstitutionID: *req.ToInstitutionID, ToCampusID: *req.ToCampusID,
+			Money: amount, RequestID: req.BizNo, Remark: remark, OperatorID: operatorID,
+		})
+		if err != nil {
+			return nil, false, err
+		}
+		return res, res.Repeated, nil
+	case model.BalanceRequestTypeCancelRecharge:
+		return nil, false, infraerrors.WithCause(apperr.ErrBusinessParam,
+			errString("service: 工单类型 203(取消充值活动)无 activity_id 列,不能在本端点执行;"+
+				"请用活动端点 30 直接取消(RefundService.CancelRechargeActivity)"))
+	default:
+		return nil, false, infraerrors.WithCause(apperr.ErrBusinessParam,
+			errString(fmt.Sprintf("service: 工单类型 %d 不支持审批入账", req.Type)))
+	}
+}
+
+// ---------------------------------------------------------------- 端点 15 / 16:驳回 / 撤销
+
+// RejectRequestInput 驳回入参(端点 15 `POST /admin/finance/requests/:id/reject`)。
+type RejectRequestInput struct {
+	// ID 工单 ID。
+	ID int64
+	// ResponseMsg 驳回意见(**必填**:驳回必须说明理由,便于对账与申诉)。
+	ResponseMsg string
+	// OperatorID 审批人。
+	OperatorID string
+}
+
+// RejectRequestResult 驳回结果(端点 15 响应 `{approved:-1}`)。
+type RejectRequestResult struct {
+	// Approved 恒 -1。
+	Approved int8 `json:"approved"`
+	// ID 工单 ID。
+	ID int64 `json:"id"`
+}
+
+// Reject 驳回工单(端点 15):仅 approved=0 可驳回;**无任何资金动作**。
+//
+// 入参:ctx;in RejectRequestInput。出参:*RejectRequestResult;2001/3002/5002。
+func (s *BalanceRequestService) Reject(ctx context.Context, in RejectRequestInput) (*RejectRequestResult, error) {
+	if in.ID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Reject 需要工单 id"))
+	}
+	if strings.TrimSpace(in.ResponseMsg) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Reject 需要 responseMsg"))
+	}
+	if err := s.pendingTransition(ctx, in.ID, requestRejected, in.OperatorID); err != nil {
+		return nil, err
+	}
+	return &RejectRequestResult{Approved: requestRejected, ID: in.ID}, nil
+}
+
+// CancelRequestInput 撤销入参(端点 16 `POST /admin/finance/requests/:id/cancel`)。
+type CancelRequestInput struct {
+	// ID 工单 ID。
+	ID int64
+	// OperatorID 操作人(申请人本人或 super_admin/finance)。
+	OperatorID string
+	// Role 操作人角色(作用域角色码;用于"本人或财务"判定)。
+	Role string
+	// SuperAdmin 是否平台超管。
+	SuperAdmin bool
+}
+
+// CancelRequestResult 撤销结果(端点 16 响应 `{approved:-2}`)。
+type CancelRequestResult struct {
+	// Approved 恒 -2。
+	Approved int8 `json:"approved"`
+	// ID 工单 ID。
+	ID int64 `json:"id"`
+}
+
+// Cancel 撤销工单(端点 16):仅 approved=0 且**申请人本人或 super_admin/finance**。
+//
+// 入参:ctx;in CancelRequestInput。出参:*CancelRequestResult;2001/1003/3002/5002。
+//
+// 权限判据(两条**或**关系,都必须能自证失败):
+//  1. `operatorID == req.request_user_id`(本人撤回自己的申请);
+//  2. 角色 ∈ {super_admin, finance}(管理员兜底处理僵尸单)。
+//
+// 注意:**不要求** campus 一致(申请人可能是机构级财务代提),但机构由仓储 Scoped 保证。
+func (s *BalanceRequestService) Cancel(ctx context.Context, in CancelRequestInput) (*CancelRequestResult, error) {
+	if in.ID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Cancel 需要工单 id"))
+	}
+	req, err := s.requests.GetByID(ctx, in.ID)
+	if err != nil {
+		return nil, err
+	}
+	isOwner := strings.TrimSpace(in.OperatorID) != "" && in.OperatorID == req.RequestUserID
+	isManager := in.SuperAdmin || in.Role == "super_admin" || in.Role == "finance"
+	if !isOwner && !isManager {
+		return nil, infraerrors.WithCause(infraerrors.ErrForbidden,
+			errString("service: 撤单仅限申请人本人或 super_admin/finance"))
+	}
+	if err := s.pendingTransition(ctx, in.ID, requestCancelled, in.OperatorID); err != nil {
+		return nil, err
+	}
+	return &CancelRequestResult{Approved: requestCancelled, ID: in.ID}, nil
+}
+
+// pendingTransition 终态推进的公共实现(驳回/撤销共用:**无资金动作**,只做 CAS)。
+//
+// 入参:ctx;id 工单 ID;to 目标终态(-1 驳回 / -2 取消);operatorID 操作人。
+// 出参:nil 成功;3002(工单已被处理/不存在)/* 仓储错误。
+//
+// 实现要点:**一次 CAS 搞定状态判断与推进**(`WHERE approved = 0`),不做"先读后写"——
+// 先读会让两个并发请求都读到 0 再各自写,虽然终态相同但 response_time/审批人会互相覆盖。
+func (s *BalanceRequestService) pendingTransition(ctx context.Context, id int64, to int8, operatorID string) error {
+	return s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		now := time.Now()
+		var operator *string
+		if strings.TrimSpace(operatorID) != "" {
+			operator = &operatorID
+		}
+		rows, err := s.requests.UpdateApproved(txCtx, tx, id, to, operator, &now)
+		if err != nil {
+			return err
+		}
+		if rows == 0 {
+			return infraerrors.WithCause(infraerrors.ErrConflict,
+				errString(fmt.Sprintf("service: 工单 %d 不存在或已处理(仅 approved=0 可推进)", id)))
+		}
+		return nil
+	})
+}