Ver código fonte

feat(academic): AC-13d 第三批——端点60 收支台账(3 条路由,平台通用字典必须放行)

## 形状(契约 v1.9 已裁决,本次只落地)
- GET  /admin/expenditures        记录分页;行 9 字段(补 expenditureId、删 direction、userId→createUserId)
- POST /admin/expenditures        记账 → {id,bizNo}
- GET  /admin/expenditures/types  字典 → {types:[{id,type,name,parentId}]}
无 POST /types;路由表 62 → **65 条 method+path**(AC-14 40 + AC-13c 15 + AC-13d 10)。

## 四处口径(每处都有"写错就红"的用例)
1. **平台通用字典 institution_id=0 必须放行**:`t_expenditure` 的 0 不是"未归属"而是全平台类型
   (房租/水电/学费收入)。若套作用域过滤,记录还在、金额分页都对,**只有 type/name 静默变空**;
   更严重的是记账时字典看不见 ⇒ 机构**根本记不了账**(3001)。字典读取因此刻意不走 Scoped,
   显式 `institution_id = 0 OR institution_id = ?`;`type` 过滤的子查询同口径(否则按方向筛选时整批消失)。
2. **不做 SQL JOIN**:记录表走 Scoped 生成裸列名,JOIN 会 `ambiguous column name: institution_id`
   (ListStudentSchedule 已踩过)。改为"分页取记录 → 按 expenditure_id 批量取字典",
   让"平台字典必须放行"这条规则只出现在一处。
3. **year/month 按库认定的落库时刻算**:create_time 由 DB 默认值填,插入后同事务回读再回写分桶;
   用应用时钟会在跨月那一秒把账记到相邻月份,报表只表现为"某月少一条"。
4. **cost 恒存正数**:money ≤ 0 → 2002(方向由字典 type 决定;放行负数会让"支出 -100"与
   "收入 +100"成为同一条记录的两种写法)。bizNo 后端生成 26 位 ULID,前端不可指定。

## 顺带
- `teacherIDOrNil` → `optionalTrimmedPtr`(两处复用:teacher_id 通用档 + create_user_id 记账人)。
- from 闭下界 / to 开上界,纯日期形态自动补到次日 00:00(`to=2026-04-10` = 含当日整天)。

五个变异实测变红(见交付报告):字典过滤平台行 → 记不了账 + 字典缺项;type 子查询过滤平台行 →
方向筛选 0 条;不回写分桶 → year/month NULL;不补作用域校区 → 1004;to 不补次日 → 当天记录查不到。
Q 1 dia atrás
pai
commit
e1ce7ce182

+ 122 - 0
internal/academic/handler/dto_expenditure.go

@@ -0,0 +1,122 @@
+// 收支端点(端点 60)请求 DTO / 响应视图——AC-13d 第三批。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §3.4 D 表 60(v1.9 重写)。
+//
+// ---------------------------------------------------------------------------
+// 契约原写法与目标态的四处冲突,逐条落地(v1.9 已裁决,这里只写明"为什么")
+// ---------------------------------------------------------------------------
+//
+//	① 原行字段 `direction`  **删除**:与 `type` 是同一事实的两个名字,两张表都没有该列。
+//	   前端用 `type` 判方向(1 支出 / 2 收入)。留着一个"看起来能读"的字段,迟早有人拿它
+//	   和 `type` 比对,然后发现两者永远一致(因为它俩本来就是一件事)。
+//	② 原 `name` 在**记录表不存在** ⇒ 由字典 `t_expenditure.name` 提供(后端补齐,前端不拼)。
+//	③ 原行**缺 `expenditureId`** ⇒ 补上:只有 `name` 没有 id,前端"编辑"按钮无法回填
+//	   (名称可重名,且改一次名就对不上了)。
+//	④ 原 `userId` ⇒ **`createUserId`**:记录表只有 `create_user_id`,语义是**记账人**。
+//	   契约里 `userId` 读起来像"收付对象",而"收付对象"这一列**不存在**(要加列 + 迁移)。
+//	   名字不改,前端会把"谁记的账"当"付给谁"展示 —— 数据有值、语义全错。
+//
+// 另:`money` ← `cost`(列名 `cost`,契约字段名 `money`;不改前端字段名,只映射)。
+package handler
+
+import "time"
+
+// CreateExpenditureLogReq 端点 60 记账请求体(`POST /admin/expenditures`)。
+//
+// 字段 → 目标列(`t_expenditure_log`):
+//
+//	institutionId → institution_id(**必填 >0**;不从 token 猜,超管要能替机构记账)
+//	campusId      → campus_id(0 = 机构级;未传且有校区作用域时取作用域校区)
+//	expenditureId → expenditure_id(必填 >0;须在字典中且对本机构可见,否则 3001)
+//	money         → cost(**元字符串**,必填 > 0;方向由字典 `type` 决定,本列恒存正数)
+//	remark        → remark(可空)
+//
+// ⚠ 三个"故意不收"的字段:
+//
+//	`bizNo`       后端生成(26 位 ULID)。让前端传单号 = 可枚举、可撞唯一键。
+//	`type`        来自字典 `t_expenditure.type`,**不是入参**:否则"支出类型 + 收入方向"这种
+//	              自相矛盾的记录会被写进去,报表净额怎么算都是错的。
+//	`createUserId` 取当前登录用户(作用域 `userId`),不接受前端指定(否则可伪造记账人)。
+type CreateExpenditureLogReq struct {
+	// InstitutionID 机构 ID(必填 >0;非超管须与作用域机构一致,否则 1004)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区 ID(0 = 机构级;负数 → 2002)。
+	CampusID int64 `json:"campusId"`
+	// ExpenditureID 收支类型 ID(必填 >0;字典 `t_expenditure.id`)。
+	ExpenditureID int64 `json:"expenditureId" validate:"required,gt=0"`
+	// Money 金额(元字符串,如 "100.00";必填且 > 0)。
+	Money string `json:"money" validate:"required"`
+	// Remark 备注(可空;超长 → 2002)。
+	Remark string `json:"remark" validate:"max=2000"`
+}
+
+// expenditureLogView 收支记录行(契约 D 表 60 的 9 个字段)。
+//
+// 字段 → 来源列(**逐列溯源**,契约 §3.4 ③ 门槛):
+//
+//	id            ← t_expenditure_log.id
+//	bizNo         ← biz_no
+//	expenditureId ← expenditure_id
+//	type          ← t_expenditure.type(**字典**;记录表无此列)
+//	name          ← t_expenditure.name(**字典**;记录表无此列)
+//	money         ← cost(金额,恒 2 位小数字符串)
+//	createUserId  ← create_user_id(可空 → `null`;**记账人**,不是收付对象)
+//	campusId      ← campus_id
+//	createTime    ← create_time
+//
+// 契约里**不再有** `direction` / `userId`(理由见文件头 ①④)。
+//
+// 字典未命中时的取值口径:`type=0`、`name=""`(**不编"未知类型"**——造的值追不到列,
+// 且前端按 `type` 分组时会被这个假值带偏)。
+type expenditureLogView struct {
+	// ID 记录 ID。
+	ID int64 `json:"id"`
+	// BizNo 业务单号(对外唯一标识;不可变)。
+	BizNo string `json:"bizNo"`
+	// ExpenditureID 收支类型 ID(编辑表单回填用)。
+	ExpenditureID int64 `json:"expenditureId"`
+	// Type 收支方向(1 支出 / 2 收入;来自字典)。
+	Type int32 `json:"type"`
+	// Name 收支类型名称(来自字典)。
+	Name string `json:"name"`
+	// Money 金额(元,恒 2 位小数字符串)。
+	Money string `json:"money"`
+	// CreateUserID 记账人 ULID(可空 → `null`)。
+	CreateUserID *string `json:"createUserId"`
+	// CampusID 校区 ID(0 = 机构级)。
+	CampusID int64 `json:"campusId"`
+	// CreateTime 记账时间(`create_time`)。
+	CreateTime time.Time `json:"createTime"`
+}
+
+// expenditureTypeView 收支字典项(契约:`types:[{id, type, name, parentId}]`)。
+//
+// 字段 → 来源列:`id` ← id;`type` ← type;`name` ← name;`parentId` ← parent_id(0 = 顶级)。
+// **不含 `institutionId`**:契约没给,且前端也用不上(平台通用字典与机构字典在同一个下拉里)。
+type expenditureTypeView struct {
+	// ID 类型 ID。
+	ID int64 `json:"id"`
+	// Type 收支方向(1 支出 / 2 收入)。
+	Type int32 `json:"type"`
+	// Name 类型名称。
+	Name string `json:"name"`
+	// ParentID 父类型 ID(0 = 顶级;自引用树)。
+	ParentID int64 `json:"parentId"`
+}
+
+// expenditureTypesResp 字典响应包裹(契约形状是 `{"types":[...]}`,不是裸数组)。
+type expenditureTypesResp struct {
+	// Types 字典项(含平台通用字典,按 type/parentId/id 递增)。
+	Types []expenditureTypeView `json:"types"`
+}
+
+// createdExpenditureLogView 记账响应(契约:POST → `{id, bizNo}`)。
+//
+// 只回这两个字段是**有意**的:金额/类型/时间都已经由请求确定,回显没有信息量;
+// 而 `bizNo` 是前端后续对账/查单的唯一 handle。
+type createdExpenditureLogView struct {
+	// ID 新记录 ID。
+	ID int64 `json:"id"`
+	// BizNo 新记录业务单号。
+	BizNo string `json:"bizNo"`
+}

+ 330 - 0
internal/academic/handler/expenditure_handler.go

@@ -0,0 +1,330 @@
+// 收支端点处理器(端点 60:3 条路由)——AC-13d 第三批。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 行 60(角色 `super_admin, finance`)/
+// §3.4 D 表 60(v1.9 重写:JOIN 字典、补 `expenditureId`、删 `direction`、`userId`→`createUserId`);
+// 设计 `academic-domain-design.md` §4.5(端点清单)。
+//
+// ---------------------------------------------------------------------------
+// 端点 → 服务方法对照(3 条,**没有 `POST /types`**)
+// ---------------------------------------------------------------------------
+//
+//	60a GET  /admin/expenditures         ListExpenditures      → ExpenditureService.ListLogs
+//	60b POST /admin/expenditures         CreateExpenditure     → ExpenditureService.CreateLog
+//	60c GET  /admin/expenditures/types   ListExpenditureTypes  → ExpenditureService.ListTypes
+//
+// 契约原写法 `GET/POST /admin/expenditures[/types]` 字面像 4 条,实为 3 条(v1.9 明确不做 `POST /types`)。
+// `POST /types` 若将来要加,**必须先解决"平台通用字典能否由机构改"**——让任一机构新建类型并不难,
+// 难的是删/改时的引用完整性(`expenditure_log.expenditure_id` 指向它,删一条字典会让历史记录失去类型名)。
+//
+// ---------------------------------------------------------------------------
+// 静态段 vs `:id` 段(本批**无真冲突**,如实说明)
+// ---------------------------------------------------------------------------
+//
+// `/admin/expenditures/types` 与未来可能的 `/admin/expenditures/:id` 属**同 depth**,
+// 但本批**没有** `:id` 路由 ⇒ 当前不存在"谁先注册"的问题。仍把 `types` 排在前面注册:
+// 将来若加 `GET /:id`,只要保持"静态段在 `:id` 之前"这条纪律(route.go 表顺序即注册顺序),
+// 就不会出现 `:id` 吃掉 "types"(解析失败 → 2002)这种"改一行、整页报错"的故障。
+// **不编造一条假冲突来凑"变红用例"**(AC-13c 已按此口径如实登记过)。
+//
+// ---------------------------------------------------------------------------
+// 三条纪律
+// ---------------------------------------------------------------------------
+//
+//  1. **Handler 不碰 DB**:只做"取作用域 → 解析参数 → 调 Service → 写响应"(本文件无一条 SQL)。
+//  2. **金额一律走 `money.Amount`**:入参 `money` 是**字符串**(`parseMoney`),出参也序列化成字符串
+//     (2 位小数)。**任何一处出现 float64**,对账迟早差几分钱,而且查不出来。
+//  3. **机构/校区越权统一 1004**:查询侧由 Service `assertInstitution` 报,
+//     写入侧由 `assertScopeMatch` 报;SQL 层的 `Scoped` 是第二道,不替代 1004(空列表 ≠ 越权)。
+package handler
+
+import (
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+
+	"yuansiwei/unified-backend/internal/academic/service"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/infra/response"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// ---------------------------------------------------------------- 路径常量(**不含 `/api`**)
+
+const (
+	// PathExpenditures 端点 60:收支记录列表 / 记账(`/admin/expenditures`)。
+	PathExpenditures = "/admin/expenditures"
+	// PathExpenditureTypes 端点 60:收支类型字典(`/admin/expenditures/types`;**静态段先于 `:id`**)。
+	PathExpenditureTypes = "/admin/expenditures/types"
+)
+
+// ListExpenditureLogs 收支记录列表(端点 60 `GET /admin/expenditures`)。
+//
+// 入参(query):`institutionId?`(超管可借此收窄;传了则非超管必须等于作用域机构,否则 1004)、
+// `campusId?`、`expenditureId?`、`type?`(1 支出 / 2 收入)、`from?`/`to?`、分页。
+//
+// `from`/`to` 口径(**两句话,别记反**):
+//
+//	`from` 恒为**闭**下界(`>=`);
+//	`to` 恒为**开**上界(`<`)——但纯日期形态会自动补到次日 00:00,
+//	所以 `to=2026-04-10` 的实际含义是"**含 4 月 10 日一整天**"。
+//
+// 为什么不做成"闭区间":`create_time` 是 `datetime(3)`,`<= 2026-04-10 00:00:00`
+// 会把 4 月 10 日白天记的账全部漏掉(用户以为"今天没记账")。
+// 为什么保留两种形态:前端"日期选择器"只给 `2006-01-02`,而报表导出会带时分秒;
+// 若一律当"当天零点",后者的语义会静默变化。
+// 出参(成功):`{list,total,page,pageSize,pages}`,行见 `expenditureLogView`(9 字段,含字典 `type`/`name`)。
+// 出参(失败):1001/2001/2002(`type`/`campusId`/时间格式非法)/**1004**(跨机构/跨校区)/5002。
+//
+// ⚠ **`type` 与 `name` 是字典列**(记录表没有):服务层按 `expenditure_id` 批量查字典补齐,
+// 且**包含平台通用字典 `institution_id = 0`**(那不是未归属数据,是"房租/水电/学费收入"这类全平台类型)。
+func (h *Handler) ListExpenditureLogs(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	institutionID, err := optionalInstitutionQuery(c, sc)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	campusID, err := parseIntQuery(c, "campusId")
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	if campusID < 0 {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("campusId 不得为负")))
+	}
+	var campusPtr *int64
+	if strings.TrimSpace(c.Query("campusId")) != "" {
+		// 校区维度越权:非超管给了别的校区 = 1004(不是"空列表")
+		if serr := assertScopeMatches(sc, effectiveInstitution(sc, institutionID), campusID, "收支记录列表"); serr != nil {
+			return response.Fail(c, serr)
+		}
+		v := campusID
+		campusPtr = &v
+	}
+	expenditureID, err := parseIntQuery(c, "expenditureId")
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	if expenditureID < 0 {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("expenditureId 不得为负")))
+	}
+	var expenditurePtr *int64
+	if strings.TrimSpace(c.Query("expenditureId")) != "" {
+		v := expenditureID
+		expenditurePtr = &v
+	}
+	direction, _, err := parseEnumQuery(c, "type", map[int64]struct{}{
+		int64(service.ExpenditureDirectionExpense): {},
+		int64(service.ExpenditureDirectionIncome):  {},
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var directionPtr *int32
+	if direction != nil {
+		v := int32(*direction)
+		directionPtr = &v
+	}
+	from, hasFrom, err := parseTimeParam(c.Query("from"), "from")
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	to, hasTo, err := parseTimeParam(c.Query("to"), "to")
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	if hasTo {
+		to = normalizeToUpperBound(c.Query("to"), to)
+	}
+	if hasFrom && hasTo && to.Before(from) {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("to 不能早于 from")))
+	}
+	page, pageSize := parsePage(c)
+	svc := h.servicesFor(sc)
+	rows, total, err := svc.expenditure.ListLogs(c.Context(), service.ExpenditureLogQuery{
+		InstitutionID: institutionID,
+		CampusID:      campusPtr,
+		ExpenditureID: expenditurePtr,
+		Type:          directionPtr,
+		From:          timePtr(from, hasFrom),
+		To:            timePtr(to, hasTo),
+		Page:          page,
+		PageSize:      pageSize,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	views := make([]expenditureLogView, 0, len(rows))
+	for i := range rows {
+		row := &rows[i]
+		views = append(views, expenditureLogView{
+			ID:            row.ID,
+			BizNo:         row.BizNo,
+			ExpenditureID: row.ExpenditureID,
+			Type:          row.Type,
+			Name:          row.Name,
+			Money:         row.Money.String(), // 恒 2 位小数(定点,无 float 路径)
+			CreateUserID:  row.CreateUserID,   // 可空指针:NULL → null(不是 "")
+			CampusID:      row.CampusID,
+			CreateTime:    row.CreateTime,
+		})
+	}
+	return response.Page(c, views, page, pageSize, total)
+}
+
+// CreateExpenditureLog 记一笔收支(端点 60 `POST /admin/expenditures`)。
+//
+// 入参(body):见 `CreateExpenditureLogReq`(`expenditureId` + `money` 必填)。
+// 出参(成功):`{id, bizNo}`。
+// 出参(失败):1001/2001(缺机构/缺类型/缺金额)/2002(金额 ≤0 或格式非法、校区为负)/
+// **3001**(收支类型不存在或对本机构不可见)/1004(跨机构/跨校区)/5002。
+//
+// 事务:字典可见性校验 + 插记录 + 回写报表分桶(`year`/`month`)**同一事务**。
+func (h *Handler) CreateExpenditureLog(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req CreateExpenditureLogReq
+	if !validateReq(c, &req) {
+		return nil // body code=2001 已写出
+	}
+	institutionID, err := resolveInstitutionID(sc, req.InstitutionID)
+	if err != nil {
+		return response.Fail(c, err) // 2001 缺机构 / 1004 跨机构
+	}
+	if req.CampusID < 0 {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("campusId 不得为负")))
+	}
+	campusID := req.CampusID
+	if campusID == 0 && sc.CampusID > 0 {
+		// 未传校区且有校区作用域 → 取作用域校区。**只会收窄,不会放宽**:
+		// 反过来"补 0"会让一条本该记在某校区的账落到机构级,校区报表直接少一条。
+		campusID = sc.CampusID
+	}
+	if serr := assertScopeMatches(sc, institutionID, campusID, "记账"); serr != nil {
+		return response.Fail(c, serr)
+	}
+	amount, ok := parseMoney(c, req.Money, "money")
+	if !ok {
+		return nil // body code=2002 已写出
+	}
+	svc := h.servicesFor(sc)
+	created, err := svc.expenditure.CreateLog(c.Context(), service.CreateExpenditureLogInput{
+		InstitutionID: institutionID,
+		CampusID:      campusID,
+		ExpenditureID: req.ExpenditureID,
+		Money:         amount,
+		Remark:        req.Remark,
+		CreateUserID:  sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err) // 2002/3001/1004/5002
+	}
+	return response.OK(c, createdExpenditureLogView{ID: created.ID, BizNo: created.BizNo})
+}
+
+// ListExpenditureTypes 收支类型字典(端点 60 `GET /admin/expenditures/types`)。
+//
+// 入参(query):`institutionId?`(传了则非超管必须等于作用域机构,否则 1004;不传 = 作用域机构)。
+// 出参(成功):`{types:[{id,type,name,parentId}]}`(按 `type,parentId,id` 递增)。
+// 出参(失败):1001/2002(`institutionId` 非法)/**1004**/5002。
+//
+// ⚠ **平台通用字典(`institution_id = 0`)恒在结果里**。若被作用域过滤掉,机构视角下
+// "新增收支"的下拉框会只剩自己建的几条(通常 0 条)⇒ 功能看起来是坏的,而数据其实好好的。
+// 超管未指定机构时只返回平台字典(否则多机构的同名类型会混在一起,选项重复、父子关系错乱)。
+func (h *Handler) ListExpenditureTypes(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	institutionID, err := optionalInstitutionQuery(c, sc)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	svc := h.servicesFor(sc)
+	types, err := svc.expenditure.ListTypes(c.Context(), institutionID)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	views := make([]expenditureTypeView, 0, len(types))
+	for i := range types {
+		views = append(views, expenditureTypeView{
+			ID:       types[i].ID,
+			Type:     types[i].Type,
+			Name:     types[i].Name,
+			ParentID: types[i].ParentID,
+		})
+	}
+	return response.OK(c, expenditureTypesResp{Types: views})
+}
+
+// optionalInstitutionQuery 解析可选的 `institutionId` query(**super 可借此收窄,其余必须一致**)。
+//
+// 入参:c;sc 作用域。出参:(institutionID, error):
+//   - 未传 → (0, nil):交给作用域(普通用户由 SQL 层 `Scoped` 强制;超管 = 跨机构视角);
+//   - 传了 >0 → 走 `resolveInstitutionID`(非超管不等于作用域机构 → **1004**);
+//   - 传了 ≤0 → **2002**("给了但无意义",不静默当没传——否则前端拼错参数名会毫无反馈)。
+func optionalInstitutionQuery(c *fiber.Ctx, sc *appscope.Scope) (int64, error) {
+	raw := strings.TrimSpace(c.Query("institutionId"))
+	if raw == "" {
+		return 0, nil
+	}
+	value, err := parseIntQuery(c, "institutionId")
+	if err != nil {
+		return 0, err
+	}
+	if value <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("institutionId 必须 > 0(不筛机构请不要传该参数)"))
+	}
+	return resolveInstitutionID(sc, value)
+}
+
+// timePtr 把 (时间, 是否传了) 归一为可选指针(未传 → nil,交给仓储"不筛")。
+//
+// 入参:t 解析结果;has 是否传了。出参:*time.Time。
+func timePtr(t time.Time, has bool) *time.Time {
+	if !has {
+		return nil
+	}
+	return &t
+}
+
+// normalizeToUpperBound 归一 `to` 上界:纯日期(`2006-01-02`)→ 次日 00:00(配合仓储的开区间 `<`)。
+//
+// 入参:raw 原始 query;parsed 已解析的时间。出参:用于 `create_time < ?` 的上界。
+//
+// 判定"纯日期"用**原始串长度**(10),而不是猜时间是否为 0 点:前端真传
+// `2026-04-10T00:00:00Z` 时语义是"到该时刻为止"(开区间),不该被自动扩成整天——
+// 两种情况字节不同、意图不同,用长度判定最诚实。
+func normalizeToUpperBound(raw string, parsed time.Time) time.Time {
+	if len(strings.TrimSpace(raw)) == len("2006-01-02") {
+		return parsed.AddDate(0, 0, 1)
+	}
+	return parsed
+}
+
+// effectiveInstitution 折算"本次请求实际作用于哪个机构"(供 `assertScopeMatches` 使用)。
+//
+// 入参:sc 作用域(可 nil);institutionID 显式传的机构(0 = 未传)。
+// 出参:机构 ID(显式优先;否则作用域机构;都取不到 → 0)。
+//
+// 用途:把"可选的 institutionId query"与"作用域机构"合成一个确定值,再交给统一的
+// `assertScopeMatches` 做越权判断——**避免每个 Handler 各写一遍"显式 or 作用域"的分支**,
+// 那种重复分支最容易出现"查询侧校验了、写入侧忘了"的不对称。
+func effectiveInstitution(sc *appscope.Scope, institutionID int64) int64 {
+	if institutionID > 0 {
+		return institutionID
+	}
+	if sc == nil {
+		return 0
+	}
+	return sc.InstitutionID
+}

+ 604 - 0
internal/academic/handler/expenditure_handler_test.go

@@ -0,0 +1,604 @@
+// 收支端点单测(端点 60:记录列表 / 记账 / 类型字典)——AC-13d 第三批。
+//
+// 断言纪律同前几批:只断 body 契约,**每个端点至少一条"写错会变红"的断言**,尽量落在库上。
+//
+// 覆盖:
+//
+//	60 GET 记录   平台通用字典(institution_id=0)**必须**补齐 type/name;契约删掉的
+//	              `direction`/`userId` **不得**出现;`money` 恒 2 位;`createUserId` 可空;
+//	              `type` 过滤不得漏掉"平台字典类型的记录"
+//	60 POST 记账   写库列(institution/campus/cost/记账人/bizNo);year/month 与 create_time 同月;
+//	              金额 ≤0 → 2002;类型不存在 / 他机构私有类型 → 3001
+//	60 GET types  平台字典 + 本机构字典都在;他机构私有类型不在;超管不指定机构只看到平台字典
+//	作用域        跨机构/跨校区一律 **1004**(不是"空列表")
+package handler
+
+import (
+	"strings"
+	"testing"
+	"time"
+
+	"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"
+)
+
+// newExpenditureTestDB 端点 60 测试库(资金/活动基座 + 收支 2 表)。
+//
+// 索引处理与 `newCourseTestDB` 同源:SQLite 的索引名是**库级唯一**(MySQL 是表级),
+// `uk_biz_no` 在多张表上同名,不清会直接建表失败。**测试库放弃唯一约束,生产 MySQL 不受影响**。
+func newExpenditureTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	base := newHandlerTestDB(t)
+	extra := []any{&model.Expenditure{}, &model.ExpenditureLog{}}
+	var leftovers []string
+	if err := base.Raw(
+		"SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'").Scan(&leftovers).Error; err != nil {
+		t.Fatalf("读取遗留索引失败: %v", err)
+	}
+	for _, name := range leftovers {
+		if err := base.Exec("DROP INDEX IF EXISTS `" + name + "`").Error; err != nil {
+			t.Fatalf("删除遗留索引(%s) 失败: %v", name, err)
+		}
+	}
+	for _, tbl := range extra {
+		if err := base.AutoMigrate(tbl); err != nil {
+			t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err)
+		}
+		stmt := &gorm.Statement{DB: base}
+		if err := stmt.Parse(tbl); err != nil {
+			t.Fatalf("解析表名(%T) 失败: %v", tbl, err)
+		}
+		var idxRows []struct {
+			Name string
+			SQL  *string
+		}
+		if err := base.Raw(
+			"SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name=? AND name NOT LIKE 'sqlite_%'",
+			stmt.Schema.Table).Scan(&idxRows).Error; err != nil {
+			t.Fatalf("读索引清单(%s) 失败: %v", stmt.Schema.Table, err)
+		}
+		for _, row := range idxRows {
+			if err := base.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; err != nil {
+				t.Fatalf("删索引(%s) 失败: %v", row.Name, err)
+			}
+		}
+	}
+	return base
+}
+
+// newExpenditureTestApp 挂端点 60 的 3 条路由(**不做角色白名单**:那是 e2e 的职责)。
+//
+// 作用域由 `X-Test-Scope` 头选择:
+//
+//	finance(缺省)机构 1 / 校区 10;finance-noCampus 机构 1 / 校区 0;
+//	finance-other 机构 2 / 校区 20;super 超管。
+func newExpenditureTestApp(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":
+			// 无作用域 → 1001
+		case "finance-noCampus":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 0, UserID: testFinanceUser, Role: RoleFinance,
+			})
+		case "finance-other":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 2, CampusID: 20, UserID: "01HFINANCE0000000000000002", Role: RoleFinance,
+			})
+		case "super":
+			appscope.Set(c, testSuperScope())
+		case "super-noInst":
+			// 平台超管但**没有机构上下文**(InstitutionID=0):字典只看平台通用那批、
+			// 记账必须显式给 institutionId(服务端不猜)。
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 0, CampusID: 0, SuperAdmin: true, UserID: "01HADMIN0000000000000002",
+				Role: RoleSuperAdmin,
+			})
+		default:
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 10, UserID: testFinanceUser, Role: RoleFinance,
+			})
+		}
+		return c.Next()
+	})
+	app.Get(PathExpenditureTypes, h.ListExpenditureTypes)
+	app.Get(PathExpenditures, h.ListExpenditureLogs)
+	app.Post(PathExpenditures, h.CreateExpenditureLog)
+	return app
+}
+
+// parseCostCents 把库里的 `cost` 字符串解析成"分"(跨方言比较用)。
+//
+// 入参:text 库值(SQLite 可能回 "100"、MySQL 回 "100.00")。出参:(分, error)。
+//
+// 为什么不直接比字符串:SQLite 的 numeric 亲和性会吃掉尾部 0,断言字面量必红;
+// 而"按数值比"并不放松口径——真正的 2 位小数断言在**响应**上(`money` 由 `money.Amount.String()`
+// 产出,与方言无关)。两处各测各的:库里存得对、出口格式化得对。
+func parseCostCents(text string) (int64, error) {
+	amount, err := money.Parse(strings.TrimSpace(text))
+	if err != nil {
+		return 0, err
+	}
+	return amount.Cents(), nil
+}
+
+// testFinanceUser 端点 60 测试用的记账人(财务,机构 1)。
+const testFinanceUser = "01HFINANCE0000000000000001"
+
+// seedExpenditureType 造一条收支字典(institutionID = 0 即**平台通用字典**)。
+func seedExpenditureType(t *testing.T, db *gorm.DB, institutionID int64, typ int32, name string) *model.Expenditure {
+	t.Helper()
+	row := &model.Expenditure{InstitutionID: institutionID, Type: typ, Name: name, ParentID: 0}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("造收支字典(%s) 失败: %v", name, err)
+	}
+	return row
+}
+
+// TestExpenditureCreateAndListUsesPlatformDictionary 端点 60 主链路:记账 + 列表(字典补齐)。
+//
+// 这条用例是整个端点的防线,**四个断言各自对应一个真实可犯的错**:
+//
+//	① `name == "房租"` / `type == 1`:字典是**平台通用**(`institution_id = 0`)。
+//	   若给字典查询套了作用域(`WHERE institution_id = 1`),平台字典 JOIN 不到 ⇒
+//	   `name=""`、`type=0` —— 记录还在、金额分页都对,**只有类型这一列静默变空**。
+//	   (团队裁决原文:"0 是平台通用字典,JOIN 时别被作用域过滤掉"。)
+//	② 行里**不能有** `direction` / `userId`:契约 v1.9 删掉了它们(两表都没有这两列)。
+//	   留着 `direction` 会让人拿它跟 `type` 比对,而两者永远一致(本来就是一件事)。
+//	③ `money == "100.00"`:金额走定点,恒 2 位小数;"100" 说明没走 `money.Amount`。
+//	④ 库里的 `year`/`month` 必须等于 `create_time` 的年月:分桶列若按应用时钟算,
+//	   跨月那一秒的账会落到相邻月份,报表表现为"某月少一条"。
+func TestExpenditureCreateAndListUsesPlatformDictionary(t *testing.T) {
+	db := newExpenditureTestDB(t)
+	app := newExpenditureTestApp(t, db)
+	// 平台通用字典(institution_id = 0):机构视角下**必须**可见。
+	platformType := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
+
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
+		"institutionId": 1,
+		"campusId":      10,
+		"expenditureId": platformType.ID,
+		"money":         "100.00",
+		"remark":        "2026-11 房租",
+	}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("记账 = %d(%s),期望 200", code, msg)
+	}
+	created := jsonMap(t, data)
+	logID, _ := created["id"].(float64)
+	if logID <= 0 {
+		t.Fatalf("记账返回 id = %v,期望 >0", created["id"])
+	}
+	bizNo, _ := created["bizNo"].(string)
+	if len(bizNo) != 26 {
+		t.Fatalf("bizNo = %q(长度 %d),期望 26 位 ULID(后端生成,前端不可指定)", bizNo, len(bizNo))
+	}
+
+	// 库值:作用域列 / 金额 / 记账人 / 分桶列
+	var row model.ExpenditureLog
+	if err := db.Where("id = ?", int64(logID)).Take(&row).Error; err != nil {
+		t.Fatalf("回读收支记录失败: %v", err)
+	}
+	if row.InstitutionID != 1 || row.CampusID != 10 {
+		t.Fatalf("库中 institution/campus = %d/%d,期望 1/10", row.InstitutionID, row.CampusID)
+	}
+	if row.ExpenditureID != platformType.ID {
+		t.Fatalf("库中 expenditure_id = %d,期望 %d", row.ExpenditureID, platformType.ID)
+	}
+	// ⚠ 库值按**数值**比:SQLite 的 numeric 亲和性会把 "100.00" 存成 100.0、读回 "100"
+	// (MySQL 的 decimal(12,2) 会原样返回 "100.00")。字节级的"2 位小数"由响应 `money` 断言兜
+	//(出口经 `money.Amount.String()`,与方言无关)。
+	if cents, perr := parseCostCents(row.Cost); perr != nil || cents != 10000 {
+		t.Fatalf("库中 cost = %q(%d 分,错 %v),期望 10000 分 = 100 元", row.Cost, cents, perr)
+	}
+	if row.CreateUserID == nil || *row.CreateUserID != testFinanceUser {
+		t.Fatalf("库中 create_user_id = %v,期望 %q(记账人 = 当前登录用户,不接受前端指定)",
+			row.CreateUserID, testFinanceUser)
+	}
+	if row.Year == nil || row.Month == nil {
+		t.Fatalf("库中 year/month = %v/%v,期望非空(报表分桶列,NULL 会让月报少掉新记录)",
+			row.Year, row.Month)
+	}
+	if int(*row.Year) != row.CreateTime.Year() || int(*row.Month) != int(row.CreateTime.Month()) {
+		t.Fatalf("库中分桶 = %d-%d,而 create_time = %s:分桶必须由**库认定的落库时刻**推导,"+
+			"用应用时钟会在跨月那一秒把账记到相邻月份(报表上只表现为「某月少一条」)",
+			*row.Year, *row.Month, row.CreateTime.Format(time.RFC3339))
+	}
+
+	// 列表:字典补齐 type/name(**平台通用字典不能被作用域过滤掉**)
+	code, msg, data = doJSON(t, app, fiber.MethodGet, PathExpenditures, nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("收支列表 = %d(%s),期望 200", code, msg)
+	}
+	items := pageList(t, data)
+	if len(items) != 1 {
+		t.Fatalf("记录条数 = %d,期望 1", len(items))
+	}
+	item, _ := items[0].(map[string]any)
+	if got, _ := item["name"].(string); got != "房租" {
+		t.Fatalf("name = %q,期望 \"房租\"(来自**平台通用字典** institution_id=0);"+
+			"空串说明字典查询被作用域过滤了 —— 记录还在、金额都对,只有类型静默变空", got)
+	}
+	if got, _ := item["type"].(float64); got != float64(model.ExpenditureTypeExpense) {
+		t.Fatalf("type = %v,期望 %d(1 支出 / 2 收入,来自字典)", item["type"], model.ExpenditureTypeExpense)
+	}
+	if got, _ := item["expenditureId"].(float64); got != float64(platformType.ID) {
+		t.Fatalf("expenditureId = %v,期望 %d(缺它前端无法回填编辑表单)", item["expenditureId"], platformType.ID)
+	}
+	if got, _ := item["money"].(string); got != "100.00" {
+		t.Fatalf("money = %v,期望 \"100.00\"(定点 2 位;\"100\" 说明没走 money.Amount)", item["money"])
+	}
+	if got, _ := item["bizNo"].(string); got != bizNo {
+		t.Fatalf("列表 bizNo = %q,期望 %q", item["bizNo"], bizNo)
+	}
+	if got, _ := item["createUserId"].(string); got != testFinanceUser {
+		t.Fatalf("createUserId = %v,期望 %q(← create_user_id,语义 = 记账人)", item["createUserId"], testFinanceUser)
+	}
+	if got, _ := item["campusId"].(float64); got != 10 {
+		t.Fatalf("campusId = %v,期望 10", item["campusId"])
+	}
+	if raw, ok := item["createTime"].(string); !ok {
+		t.Fatalf("createTime = %v,期望时间字符串", item["createTime"])
+	} else if _, perr := time.Parse(time.RFC3339, raw); perr != nil {
+		t.Fatalf("createTime = %q 不是 RFC3339: %v", raw, perr)
+	}
+	// 契约 v1.9 删掉的两个字段**不得出现**(出现即"同一事实两个名字",前端会拿去比对)
+	if _, ok := item["direction"]; ok {
+		t.Fatalf("响应里有 direction:契约 v1.9 已删(与 type 同义,两表都没有该列)")
+	}
+	if _, ok := item["userId"]; ok {
+		t.Fatalf("响应里有 userId:应为 createUserId(记录表只有 create_user_id = 记账人," +
+			"没有「收付对象」列;叫 userId 会让前端把记账人当收付对象展示)")
+	}
+}
+
+// TestExpenditureTypeFilterKeepsPlatformTypes 端点 60 `type` 过滤:**平台字典类型的记录不能消失**。
+//
+// 变红条件:`type` 过滤的子查询把字典按作用域收窄(`WHERE type=? AND institution_id=1`)⇒
+// 平台通用类型不在子查询结果里 ⇒ 按"支出"筛选时整批记录凭空消失。
+// 这比"少一列"更难查:用户会认为"这个月没有支出"。
+func TestExpenditureTypeFilterKeepsPlatformTypes(t *testing.T) {
+	db := newExpenditureTestDB(t)
+	app := newExpenditureTestApp(t, db)
+	expenseType := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
+	incomeType := seedExpenditureType(t, db, 0, model.ExpenditureTypeIncome, "学费收入")
+
+	for _, seed := range []struct {
+		typ   *model.Expenditure
+		money string
+	}{
+		{expenseType, "100.00"},
+		{incomeType, "200.00"},
+	} {
+		code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
+			"institutionId": 1, "campusId": 10,
+			"expenditureId": seed.typ.ID, "money": seed.money,
+		}, "X-Test-Token", "t")
+		if code != 200 {
+			t.Fatalf("记账(%s) = %d(%s),期望 200", seed.typ.Name, code, msg)
+		}
+	}
+
+	// type=1(支出)→ 只剩 100.00 那条
+	code, msg, data := doJSON(t, app, fiber.MethodGet, PathExpenditures+"?type=1", nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("按 type=1 筛选 = %d(%s),期望 200", code, msg)
+	}
+	items := pageList(t, data)
+	if len(items) != 1 {
+		t.Fatalf("type=1 记录条数 = %d,期望 1(0 条说明含平台通用字典的子查询被作用域过滤了)", len(items))
+	}
+	only, _ := items[0].(map[string]any)
+	if got, _ := only["money"].(string); got != "100.00" {
+		t.Fatalf("type=1 的记录 money = %v,期望 \"100.00\"", only["money"])
+	}
+
+	// type=2(收入)→ 只剩 200.00
+	code, _, data = doJSON(t, app, fiber.MethodGet, PathExpenditures+"?type=2", nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("按 type=2 筛选 = %d,期望 200", code)
+	}
+	items = pageList(t, data)
+	if len(items) != 1 {
+		t.Fatalf("type=2 记录条数 = %d,期望 1", len(items))
+	}
+	only, _ = items[0].(map[string]any)
+	if got, _ := only["money"].(string); got != "200.00" {
+		t.Fatalf("type=2 的记录 money = %v,期望 \"200.00\"", only["money"])
+	}
+
+	// type=3 → 2002(枚举非法,不是"空列表")
+	if code, msg, _ := doJSON(t, app, fiber.MethodGet, PathExpenditures+"?type=3", nil, "X-Test-Token", "t"); code != infraerrors.ErrBadFormat.Code {
+		t.Fatalf("type=3 = %d(%s),期望 2002", code, msg)
+	}
+}
+
+// TestExpenditureTypesIncludesPlatformAndOwn 端点 60 字典:平台 + 本机构都在,他机构的不在。
+//
+// 变红条件:
+//   - 只返回本机构字典(丢了平台字典)→ 机构下拉框空,"新增收支"看起来坏了;
+//   - 只返回平台字典(丢了本机构自建)→ 自建类型永远选不到;
+//   - 不做机构过滤 → 他机构的私有类型混进来(选项重复、`parentId` 指向别人的树)。
+func TestExpenditureTypesIncludesPlatformAndOwn(t *testing.T) {
+	db := newExpenditureTestDB(t)
+	app := newExpenditureTestApp(t, db)
+	platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
+	own := seedExpenditureType(t, db, 1, model.ExpenditureTypeExpense, "机构1自建")
+	otherInst := seedExpenditureType(t, db, 2, model.ExpenditureTypeExpense, "机构2私有")
+
+	names := func(scopeHeader, query string) map[string]float64 {
+		t.Helper()
+		headers := []string{"X-Test-Token", "t"}
+		if scopeHeader != "" {
+			headers = append(headers, "X-Test-Scope", scopeHeader)
+		}
+		code, msg, data := doJSON(t, app, fiber.MethodGet, PathExpenditureTypes+query, nil, headers...)
+		if code != 200 {
+			t.Fatalf("字典(%s%s) = %d(%s),期望 200", scopeHeader, query, code, msg)
+		}
+		payload := jsonMap(t, data)
+		list, ok := payload["types"].([]any)
+		if !ok {
+			t.Fatalf("响应缺少 types 数组(契约形状是 {\"types\":[...]}):%v", payload)
+		}
+		out := make(map[string]float64, len(list))
+		for _, item := range list {
+			row, _ := item.(map[string]any)
+			name, _ := row["name"].(string)
+			id, _ := row["id"].(float64)
+			out[name] = id
+			if _, ok := row["type"]; !ok {
+				t.Fatalf("字典项缺少 type:%v", row)
+			}
+			if _, ok := row["parentId"]; !ok {
+				t.Fatalf("字典项缺少 parentId:%v", row)
+			}
+		}
+		return out
+	}
+
+	// 机构 1 视角:平台 + 自建;不含机构 2 的私有类型
+	got := names("finance", "")
+	if got["房租"] != float64(platform.ID) {
+		t.Fatalf("机构视角看不到平台通用字典(房租):%v —— 平台字典 institution_id=0 必须放行", got)
+	}
+	if got["机构1自建"] != float64(own.ID) {
+		t.Fatalf("机构视角看不到自己的字典(机构1自建):%v", got)
+	}
+	if _, ok := got["机构2私有"]; ok {
+		t.Fatalf("机构视角看到了别的机构的私有字典(机构2私有):越权泄露(应为 1004 或不可见)")
+	}
+
+	// 超管(作用域带机构 1)不传 institutionId → 平台 + 机构 1(沿用作用域机构,语义最直白)
+	superGot := names("super", "")
+	if _, ok := superGot["房租"]; !ok {
+		t.Fatalf("超管视角缺少平台字典:%v", superGot)
+	}
+	if _, ok := superGot["机构1自建"]; !ok {
+		t.Fatalf("超管(作用域机构 1)应看到机构 1 的字典:%v", superGot)
+	}
+	// 超管**没有**机构上下文(作用域机构 0)→ 只看到平台字典。
+	// 若此时不过滤,多机构的同名类型会混在一起(选项重复、`parentId` 指向别人的树)。
+	superNoInst := names("super-noInst", "")
+	if _, ok := superNoInst["房租"]; !ok {
+		t.Fatalf("超管无机构上下文时缺少平台字典:%v", superNoInst)
+	}
+	if _, ok := superNoInst["机构1自建"]; ok {
+		t.Fatalf("超管无机构上下文时不应混入机构私有字典(机构1自建):%v", superNoInst)
+	}
+
+	// 超管指定 institutionId=1 → 平台 + 机构 1
+	superScoped := names("super", "?institutionId=1")
+	if _, ok := superScoped["房租"]; !ok {
+		t.Fatalf("超管指定机构后缺少平台字典:%v", superScoped)
+	}
+	if superScoped["机构1自建"] != float64(own.ID) {
+		t.Fatalf("超管指定机构后缺少该机构字典:%v", superScoped)
+	}
+	if _, ok := superScoped["机构2私有"]; ok {
+		t.Fatalf("超管指定机构 1 却看到了机构 2 的字典:%v", superScoped)
+	}
+	_ = otherInst
+}
+
+// TestExpenditureCreateValidation 端点 60 记账的参数/类型校验(每条对应一种静默错法)。
+func TestExpenditureCreateValidation(t *testing.T) {
+	db := newExpenditureTestDB(t)
+	app := newExpenditureTestApp(t, db)
+	platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
+	otherInstType := seedExpenditureType(t, db, 2, model.ExpenditureTypeExpense, "机构2私有")
+
+	cases := []struct {
+		name string
+		body map[string]any
+		want int
+	}{
+		{"缺 expenditureId → 2001", map[string]any{"institutionId": 1, "money": "10.00"},
+			infraerrors.ErrBadParam.Code},
+		{"缺 money → 2001", map[string]any{"institutionId": 1, "expenditureId": platform.ID},
+			infraerrors.ErrBadParam.Code},
+		{"money = 0 → 2002(零元记账只会污染对账)",
+			map[string]any{"institutionId": 1, "expenditureId": platform.ID, "money": "0.00"},
+			infraerrors.ErrBadFormat.Code},
+		{"money 为负 → 2002(cost 恒存正数,方向由 type 决定)",
+			map[string]any{"institutionId": 1, "expenditureId": platform.ID, "money": "-1.00"},
+			infraerrors.ErrBadFormat.Code},
+		{"money 非数字 → 2002",
+			map[string]any{"institutionId": 1, "expenditureId": platform.ID, "money": "abc"},
+			infraerrors.ErrBadFormat.Code},
+		{"类型不存在 → 3001",
+			map[string]any{"institutionId": 1, "expenditureId": 99999, "money": "10.00"},
+			infraerrors.ErrNotFound.Code},
+		{"他机构私有类型 → 3001(不泄露存在性)",
+			map[string]any{"institutionId": 1, "expenditureId": otherInstType.ID, "money": "10.00"},
+			infraerrors.ErrNotFound.Code},
+		{"缺机构(非超管作用域机构已给出,故用超管无机构验证见下)", nil, 0},
+	}
+	for _, tc := range cases {
+		if tc.body == nil {
+			continue
+		}
+		code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, tc.body, "X-Test-Token", "t")
+		if code != tc.want {
+			t.Errorf("%s:code = %d(%s),期望 %d", tc.name, code, msg, tc.want)
+		}
+	}
+
+	// 超管**无机构上下文**且没给 institutionId → 2001(服务端不猜机构:记到哪个机构的账必须显式)
+	code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
+		"expenditureId": platform.ID, "money": "10.00",
+	}, "X-Test-Token", "t", "X-Test-Scope", "super-noInst")
+	if code != infraerrors.ErrBadParam.Code {
+		t.Fatalf("超管缺 institutionId = %d(%s),期望 2001", code, msg)
+	}
+
+	// 对照:合法请求必须 200(否则上面的红可能是"全都红"造成的假阳性)
+	code, msg, _ = doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
+		"institutionId": 1, "expenditureId": platform.ID, "money": "10.00",
+	}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("合法记账 = %d(%s),期望 200", code, msg)
+	}
+}
+
+// TestExpenditureScopeViolation 端点 60 越权一律 **1004**(不是"空列表")。
+//
+// 为什么非要 1004:SQL 层 `Scoped` 会把跨机构查询变成空列表,症状是"这家机构一条收支都没有",
+// 排查会先怀疑数据丢失/迁移没跑,而不是"你越权了"。
+func TestExpenditureScopeViolation(t *testing.T) {
+	db := newExpenditureTestDB(t)
+	app := newExpenditureTestApp(t, db)
+	platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
+
+	// 机构 1 的财务想看机构 2 → 1004
+	if code, msg, _ := doJSON(t, app, fiber.MethodGet,
+		PathExpenditures+"?institutionId=2", nil, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
+		t.Fatalf("跨机构查列表 = %d(%s),期望 1004", code, msg)
+	}
+	// 校区 10 的财务想看校区 20 → 1004
+	if code, msg, _ := doJSON(t, app, fiber.MethodGet,
+		PathExpenditures+"?campusId=20", nil, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
+		t.Fatalf("跨校区查列表 = %d(%s),期望 1004", code, msg)
+	}
+	// 记账到别的机构 → 1004
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
+		"institutionId": 2, "expenditureId": platform.ID, "money": "10.00",
+	}, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
+		t.Fatalf("跨机构记账 = %d(%s),期望 1004", code, msg)
+	}
+	// 记账到别的校区 → 1004
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
+		"institutionId": 1, "campusId": 20, "expenditureId": platform.ID, "money": "10.00",
+	}, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
+		t.Fatalf("跨校区记账 = %d(%s),期望 1004", code, msg)
+	}
+	// 对照:本机构本校区必须 200
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
+		"institutionId": 1, "campusId": 10, "expenditureId": platform.ID, "money": "10.00",
+	}, "X-Test-Token", "t"); code != 200 {
+		t.Fatalf("本机构本校区记账 = %d(%s),期望 200", code, msg)
+	}
+}
+
+// TestExpenditureCampusDefaultsToScope 未传 `campusId` 且有校区作用域时,落到作用域校区。
+//
+// 变红条件:默认补 0(机构级)→ 一条本该记在某校区的账落到机构级,**校区报表少一条**
+// 而机构级报表多一条;两个报表各自都能"自洽",只有对账时才发现。
+func TestExpenditureCampusDefaultsToScope(t *testing.T) {
+	db := newExpenditureTestDB(t)
+	app := newExpenditureTestApp(t, db)
+	platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
+
+	code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
+		"institutionId": 1, "expenditureId": platform.ID, "money": "10.00",
+	}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("记账(不传校区)= %d(%s),期望 200", code, msg)
+	}
+	var row model.ExpenditureLog
+	if err := db.Order("id DESC").Take(&row).Error; err != nil {
+		t.Fatalf("回读收支记录失败: %v", err)
+	}
+	if row.CampusID != 10 {
+		t.Fatalf("库中 campus_id = %d,期望 10(作用域校区);0 = 落到机构级,"+
+			"会让校区报表少一条、机构级多一条,两边各自"+
+			"看起来都自洽", row.CampusID)
+	}
+
+	// 校区作用域为 0 的用户(机构级财务):campusId 保持 0 = 机构级记账
+	code, msg, _ = doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
+		"institutionId": 1, "expenditureId": platform.ID, "money": "20.00",
+	}, "X-Test-Token", "t", "X-Test-Scope", "finance-noCampus")
+	if code != 200 {
+		t.Fatalf("机构级财务记账 = %d(%s),期望 200", code, msg)
+	}
+	var instRow model.ExpenditureLog
+	if err := db.Order("id DESC").Take(&instRow).Error; err != nil {
+		t.Fatalf("回读收支记录失败: %v", err)
+	}
+	if instRow.CampusID != 0 {
+		t.Fatalf("机构级财务记账 campus_id = %d,期望 0(机构级)", instRow.CampusID)
+	}
+}
+
+// TestExpenditureListTimeRange 端点 60 时间区间过滤(`from`/`to` 含两端)。
+//
+// 变红条件:`to` 写成不含上界(`<`)→ 当天的账查不到,用户会以为"今天没记账"。
+func TestExpenditureListTimeRange(t *testing.T) {
+	db := newExpenditureTestDB(t)
+	app := newExpenditureTestApp(t, db)
+	platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
+
+	// 直接造库:一条 2026-03-10,一条 2026-04-10(绕开记账接口,才能控时间)
+	for _, seed := range []struct {
+		day   string
+		money string
+		bizNo string
+	}{
+		{"2026-03-10 08:00:00", "100.00", "01HTESTMARCH0000000000001"},
+		{"2026-04-10 08:00:00", "200.00", "01HTESTAPRIL00000000000001"},
+	} {
+		row := &model.ExpenditureLog{
+			BizNo: seed.bizNo, InstitutionID: 1, CampusID: 10,
+			ExpenditureID: platform.ID, Cost: seed.money,
+		}
+		if err := db.Create(row).Error; err != nil {
+			t.Fatalf("造记录失败: %v", err)
+		}
+		if err := db.Exec("UPDATE t_expenditure_log SET create_time = ? WHERE id = ?",
+			seed.day, row.ID).Error; err != nil {
+			t.Fatalf("回填 create_time 失败: %v", err)
+		}
+	}
+
+	code, msg, data := doJSON(t, app, fiber.MethodGet,
+		PathExpenditures+"?from=2026-04-01&to=2026-04-10", nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("按区间筛 = %d(%s),期望 200", code, msg)
+	}
+	items := pageList(t, data)
+	if len(items) != 1 {
+		t.Fatalf("区间内记录 = %d 条,期望 1(`to` 若写成不含上界,当天的账会查不到)", len(items))
+	}
+	only, _ := items[0].(map[string]any)
+	if got, _ := only["money"].(string); got != "200.00" {
+		t.Fatalf("区间内记录 money = %v,期望 \"200.00\"", only["money"])
+	}
+
+	// to < from → 2002(不静默返回空)
+	if code, msg, _ := doJSON(t, app, fiber.MethodGet,
+		PathExpenditures+"?from=2026-04-10&to=2026-04-01", nil, "X-Test-Token", "t"); code != infraerrors.ErrBadFormat.Code {
+		t.Fatalf("to<from = %d(%s),期望 2002", code, msg)
+	}
+}

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

@@ -178,6 +178,9 @@ type scopedServices struct {
 	payroll *service.PayrollService
 	// payrollPlan 课酬方案配置(端点 58:方案 + 明细 CRUD;**写入面**,AC-10 未覆盖)。
 	payrollPlan *service.PayrollPlanService
+	// expenditure 机构收支台账(端点 60:记录列表/记账/类型字典)。
+	// ⚠ 与"学生钱包/消费"(`t_user_expense*`,端点 2~9)是**两套账**,列级无联动,不要混算。
+	expenditure *service.ExpenditureService
 }
 
 // servicesFor 按请求作用域装配一套服务。
@@ -215,6 +218,7 @@ func (h *Handler) servicesFor(sc *appscope.Scope) *scopedServices {
 		maintenance:      service.NewMaintenanceService(h.db, sc),
 		payroll:          payroll,
 		payrollPlan:      service.NewPayrollPlanService(h.db, sc),
+		expenditure:      service.NewExpenditureService(h.db, sc),
 	}
 }
 

+ 1 - 1
internal/academic/handler/payroll_handler_test.go

@@ -170,7 +170,7 @@ func TestPayrollPlanCreateAndList(t *testing.T) {
 //
 //	字段声明成 `string` 且用 `strValue()` 兜底   → NULL 变成 "",本条断言红;
 //	写入侧把空 teacherId 写成 `""` 而不是 NULL   → 库里 teacher_id = '',本条"库值必须 IS NULL"红;
-//	`teacherIDOrNil` 不 trim                    → 历史脏值 " " 被当成有值下发,本条红(见下用例)。
+//	`optionalTrimmedPtr` 不 trim                    → 历史脏值 " " 被当成有值下发,本条红(见下用例)。
 //
 // 为什么值得单独立一条用例:`teacherId` 是"通用档"与"老师专属档"的唯一区分依据。
 // 若 NULL 被折叠成 "",前端 `if (teacherId)` 两支都走"通用档",**专属档的规则会被当成通用档生效**——

+ 30 - 7
internal/academic/handler/route.go

@@ -1,11 +1,11 @@
-// academic 域**路由装配单元**(AC-14 第一批 40 条 + AC-13c 15 条 = 55 条 method+path 的接线 + RBAC 种子来源)。
+// academic 域**路由装配单元**(AC-14 40 条 + AC-13c 15 条 + AC-13d 10 条 = 65 条 method+path 的接线 + RBAC 种子来源)。
 //
 // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.1/§2.2/§2.3(method/path/角色白名单,
 // **唯一权威**)/ §1.6(后端路由不含 `/api`)/ §2(63 端点清单);设计 `academic-domain-design.md`
 // AC-14 行(四条运行期断言 + 种子 1:1 对齐 + 一级段白名单断言对象 = 本装配单元)。
 //
 // ---------------------------------------------------------------------------
-// 一、本批范围(**只挂已交付的端点**:AC-14 第一批 40 条 + AC-13c 15 条)
+// 一、本批范围(**只挂已交付的端点**:AC-14 40 条 + AC-13c 15 条 + AC-13d 10 条)
 // ---------------------------------------------------------------------------
 //
 //	财务 16  端点 1~16   fund_handler.go
@@ -13,11 +13,15 @@
 //	活动 16  端点 22~37  activity_handler.go
 //	分账 6   端点 38~43  divide_handler.go
 //	课程 15  端点 44~57  course_handler.go(AC-13c;端点 45 = POST + PUT 两条 method+path)
+//	课酬 4   端点 58×3、59 payroll_handler.go(AC-13d;58 = GET/POST/PUT 三条)
+//	收支 3   端点 60×3   expenditure_handler.go(AC-13d;记录 GET/POST + 字典 GET)
+//	维护 3   端点 61~63  payroll_handler.go(AC-13d)
 //
-// **总数 55 条 method+path**(40 + 15),对应**契约 54 个已实现端点**
-// (63 个端点里未实现的 9 个:18/19/20 支付端点 + 58~63 课酬/收支/维护;端点 45 因 POST+PUT 占两条路由)。
+// **总数 65 条 method+path**(40 + 15 + 10),对应**契约 61 个已实现端点**
+// (63 个端点里未实现的 2 个:18/19/20 支付端点 —— 实为 3 个端点号、共 3 条路由;
+// 另有端点 45/58/60 因"多 method"占多条路由:45 两条、58 三条、60 三条)。
 //
-// **端点 18/19/20(`/pay/*`)本批不挂**:handler 尚未实现(不属 AC-12 的 1~17/21,
+// **端点 18/19/20(`/pay/*`)不挂**:handler 尚未实现(不属 AC-12 的 1~17/21,
 // 也不属 AC-13 的 22~63)。**不挂空壳/占位路由**——空壳会让"未实现"看起来像"已实现但返回怪码",
 // 比 404 更难查(前端拿到的不是 404 而是业务错误码,会去查业务逻辑)。
 //
@@ -153,7 +157,7 @@ type Registration struct {
 	Prefixes []string
 }
 
-// Routes 返回全部已交付路由(55 条:AC-14 40 条 + AC-13c 15 条;**顺序即注册顺序,静态段全部在 `:id` 之前**)。
+// Routes 返回全部已交付路由(65 条:AC-14 40 + AC-13c 15 + AC-13d 10;**顺序即注册顺序,静态段全部在 `:id` 之前**)。
 //
 // ⚠ 顺序规则(Fiber 按注册顺序匹配;违反即"静态段被 `:id` 吃掉"):
 //
@@ -183,7 +187,7 @@ func Routes(h *Handler) []Route {
 			Handler: h.CreateRecharge},
 		{No: 4, Method: fiber.MethodPost, Path: PathFinanceExpenditures, Name: "消费",
 			Roles:   []string{RoleSuperAdmin, RoleFinance},
-			Handler: h.CreateExpenditure},
+			Handler: h.CreateExpenditureLog},
 		{No: 7, Method: fiber.MethodPost, Path: PathFinanceTransfers, Name: "课费转结",
 			Roles:   []string{RoleSuperAdmin, RoleFinance},
 			Handler: h.CreateTransfer},
@@ -385,6 +389,25 @@ func Routes(h *Handler) []Route {
 		{No: 63, Method: fiber.MethodPost, Path: PathMaintenanceActivitySync, Name: "活动数据同步",
 			Roles:   []string{RoleSuperAdmin},
 			Handler: h.SyncActivityData},
+
+		// ---------------------------------------------------------------- 收支(端点 60,3 条路由)
+		//
+		// 静态段 `/admin/expenditures/types` **排在 `/admin/expenditures` 之前**:本批没有
+		// `/admin/expenditures/:id`,因此**不存在真冲突**(如实登记,不硬凑变红用例);
+		// 但保持"字面段先于 `:param` 段"这条纪律,将来加 `GET /:id` 时不会把 "types" 吃掉
+		// (被吃掉的表现是 `:id` 解析 "types" 失败 → **2002**,页面整块报错而路由看起来"已注册")。
+		//
+		// 角色是契约 §2.5 的 `super_admin, finance`:收支台账属财务面,教务(academic)**不给**——
+		// 它管的是课程/排课,不是机构账。
+		{No: 60, Method: fiber.MethodGet, Path: PathExpenditureTypes, Name: "收支类型字典",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.ListExpenditureTypes},
+		{No: 60, Method: fiber.MethodGet, Path: PathExpenditures, Name: "收支记录列表",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.ListExpenditureLogs},
+		{No: 60, Method: fiber.MethodPost, Path: PathExpenditures, Name: "记一笔收支",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CreateExpenditureLog},
 	}
 }
 

+ 9 - 5
internal/academic/handler/route_e2e_test.go

@@ -107,7 +107,7 @@ func requestPath(tmpl string) string {
 
 // ---------------------------------------------------------------- ① 注册表 ↔ 契约
 
-// TestRouteRegistryMatchesContract 55 条路由与契约 §2.1/§2.2/§2.3/§2.4 逐条对齐。
+// TestRouteRegistryMatchesContract 65 条路由与契约 §2.1/§2.2/§2.3/§2.4 逐条对齐。
 //
 // 期望值**手写**(测试即契约快照):与 `route.go` 的生成逻辑不同源,才有对齐价值——
 // 若也写成"从路由表推导",这条用例就只能证明"路由表等于自己"。
@@ -188,13 +188,17 @@ func TestRouteRegistryMatchesContract(t *testing.T) {
 		{58, "POST", "/admin/payroll/plans", "super_admin,academic,finance"},
 		{58, "PUT", "/admin/payroll/plans/:id", "super_admin,academic,finance"},
 		{59, "POST", "/admin/payroll/quote", "super_admin,academic,finance"},
+		// ---- AC-13d 第三批:收支(端点 60,3 条 method+path;`/types` 是静态段,排在记录路径之前)
+		{60, "GET", "/admin/expenditures/types", "super_admin,finance"},
+		{60, "GET", "/admin/expenditures", "super_admin,finance"},
+		{60, "POST", "/admin/expenditures", "super_admin,finance"},
 		{61, "POST", "/admin/maintenance/courses-max-people", "super_admin"},
 		{62, "POST", "/admin/maintenance/courses-campus", "super_admin"},
 		{63, "POST", "/admin/maintenance/activity-sync", "super_admin"},
 	}
 
 	if len(routes) != len(want) {
-		t.Fatalf("路由条数 = %d,期望 %d(AC-14 40 + AC-13c 15 + AC-13d 7 = 62 条 method+path)", len(routes), len(want))
+		t.Fatalf("路由条数 = %d,期望 %d(AC-14 40 + AC-13c 15 + AC-13d 10 = 65 条 method+path)", len(routes), len(want))
 	}
 
 	// 逐条对齐**用集合比**而不是按下标比:注册顺序是"静态段优先"(≠ 契约端点号顺序),
@@ -226,7 +230,7 @@ func TestRouteRegistryMatchesContract(t *testing.T) {
 		delete(gotByKey, key)
 	}
 	for key := range gotByKey {
-		t.Errorf("注册了契约外的路由 %s(当前只挂已交付的 62 条)", key)
+		t.Errorf("注册了契约外的路由 %s(当前只挂已交付的 65 条)", key)
 	}
 
 	// 反向断言:18/19/20(支付)**本批不得挂**——handler 尚未实现,挂空壳会让"未实现"
@@ -334,8 +338,8 @@ func TestAssertUniqueMethodPath(t *testing.T) {
 	if err := AssertUniqueMethodPath(app.GetRoutes()); err != nil {
 		t.Fatalf("40 条路由不该有重复:%v", err)
 	}
-	if len(reg.Routes) != 62 {
-		t.Fatalf("注册条数 = %d,期望 62(40 + 15 + 7)", len(reg.Routes))
+	if len(reg.Routes) != 65 {
+		t.Fatalf("注册条数 = %d,期望 65(40 + 15 + 10)", len(reg.Routes))
 	}
 
 	// 正例:同 path 不同 method 是**合法**的(端点 12/13 共用 /admin/finance/requests)

+ 335 - 0
internal/academic/repository/expenditure_repo.go

@@ -0,0 +1,335 @@
+// 收支字典 + 收支记录的数据访问(端点 60:`/admin/expenditures`)——AC-13d 第三批。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 行 60 / §3.4 D 表 60(v1.9 重写);
+// 目标态 DDL 两张表:
+//
+//	t_expenditure      : id, type, parent_id, name, remark, institution_id
+//	t_expenditure_log  : id, biz_no, institution_id, campus_id, expenditure_id, cost,
+//	                     create_user_id, create_time, year, month, remark, imgs
+//
+// 端点 60 的三条口径(契约 v1.9 已定,这里只实现,不再议):
+//
+//	① `type` / `name` 记录表**没有这两列** ⇒ 必须查字典 `t_expenditure`:
+//	   `type`(1 支出 / 2 收入) 与 `name`(类型名)都从字典拿;同时补 `expenditureId`,
+//	   否则前端无法回填编辑表单(有 name 没 id,改不了)。
+//	② 不实现 `direction`:与 `type` 是同一事实的两个名字,两表都没有该列。
+//	③ `userId` → `createUserId`(← `create_user_id`,语义 = **记账人**)。
+//	   记录表**没有"收付对象"列**,需要只能加列 + 迁移(本轮不做)。
+//
+// ---------------------------------------------------------------------------
+// ⚠ 本文件最要紧的一处:**平台通用字典(`institution_id = 0`)不能被作用域过滤掉**
+// ---------------------------------------------------------------------------
+//
+// `t_expenditure.institution_id` 的 `0` **不是**"未归属/脏数据",而是**平台通用字典**
+// (model.Expenditure 列注释:"0=平台通用字典")。机构可以自建类型,但绝大多数类型是平台那批。
+//
+// 若对字典表也套 `BaseRepo.Scoped`(生成 `WHERE institution_id = 1`),后果是:
+//
+//	记录列表 JOIN 不到类型名 ⇒ 所有"平台通用类型"的收支记录 `name` 全是空、`type` 全是 0;
+//	而记录本身查得到、金额分页都对、`bizNo` 都在 —— **只有"类型"这一列静默变空**。
+//	前端会把这批记录渲染成"未知类型",用户以为数据丢了,实际是被作用域过滤掉的字典行。
+//
+// 更糟的是这种缺陷**在机构视角下"看起来像没配字典"**,排查方向会先跑到"字典维护页是不是漏了"。
+//
+// 因此本文件对字典表的读取**刻意不用 `Scoped`**,而是显式写:
+//
+//	WHERE (institution_id = 0 OR institution_id = ?)     -- ? = 请求机构(超管无机构时只取 0)
+//
+// 记录表(`t_expenditure_log`)**有**真实作用域列且无平台行语义 ⇒ 照常走 `Scoped`。
+//
+// ---------------------------------------------------------------------------
+// 两条实现纪律
+// ---------------------------------------------------------------------------
+//
+//  1. **不做 SQL JOIN**(虽然契约文案写的是 "JOIN 字典"):记录表走 `Scoped` 生成的是**裸列名**
+//     (`institution_id = ?`),一旦 JOIN 就 `ambiguous column name: institution_id`
+//     (本项目已踩过:`ListStudentSchedule`)。改为"分页取记录 → 按 `expenditure_id` 批量取字典"
+//     两步走,语义等价、无歧义、且字典命中天然带上了"平台通用"那一批。
+//     ⇒ 反过来看:**两步走不是妥协,而是让"平台字典必须放行"这条规则只出现在一处**(`ListTypesByIDs`)。
+//  2. `type` 过滤走**子查询**(`expenditure_id IN (SELECT id FROM t_expenditure WHERE type = ? ...)`),
+//     不下发"先查一批 id 再 IN":字典可能上百行,把 IN 列表拼进 SQL 反而更容易写错。
+package repository
+
+import (
+	"context"
+	"strings"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// ExpenditureRepo 收支记录 + 收支字典仓储。
+//
+// 作用域:记录表 `t_expenditure_log` 带 `institution_id`/`campus_id` ⇒ `CampusScoped=true`;
+// 字典表按"0 = 平台通用"特判(见文件头),不走 `Scoped`。
+type ExpenditureRepo struct {
+	*BaseRepo
+}
+
+// NewExpenditureRepo 构造收支仓储。
+//
+// 入参:db 连接池;sc 请求作用域(nil = 系统任务视角——字典读取需要特判,记录查询会 panic,见 Scoped)。
+// 出参:*ExpenditureRepo(CampusScoped=true)。
+func NewExpenditureRepo(db *gorm.DB, sc *appscope.Scope) *ExpenditureRepo {
+	r := &ExpenditureRepo{BaseRepo: NewBaseRepo(db, sc)}
+	r.CampusScoped = true
+	return r
+}
+
+// ExpenditureLogFilter 收支记录列表过滤(端点 60 GET)。
+//
+// 字段:
+//   - InstitutionID:机构(0 = 不额外筛,作用域由 `Scoped` 强制;非超管传了别的机构 → Service 层 1004);
+//   - CampusID:校区(nil 不筛);
+//   - ExpenditureID:收支类型(nil 不筛;按字典 id 精确匹配);
+//   - Type:收支方向(nil 不筛;1 支出 / 2 收入,走字典子查询);
+//   - From / To:创建时间区间(含两端;nil 不筛)。
+//
+// ⚠ `Type` 与 `ExpenditureID` 同时给出时是**交集**(正常业务不会传,传了也不该"或")。
+type ExpenditureLogFilter struct {
+	InstitutionID int64
+	CampusID      *int64
+	ExpenditureID *int64
+	Type          *int32
+	From          *time.Time
+	To            *time.Time
+}
+
+// ListLogs 分页查询收支记录(按 `create_time DESC, id DESC`:最新记账在前)。
+//
+// 入参:ctx;f 过滤;pg 分页。
+// 出参:([]model.ExpenditureLog, total, error);空结果非 nil 由调用方保证;ErrDBError(5002)。
+//
+// 排序为什么带 `id DESC` 兜底:`create_time` 是 `datetime(3)`,同一毫秒批量导入会出现重复值,
+// 只按时间排序时**分页边界会重复/漏行**(MySQL 对相等键的排序不稳定)。加主键兜底即确定性。
+func (r *ExpenditureRepo) ListLogs(ctx context.Context, f ExpenditureLogFilter, pg PageQuery) ([]model.ExpenditureLog, int64, error) {
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, 0, err
+	}
+	build := func() *gorm.DB {
+		q := r.Scoped(db.Model(&model.ExpenditureLog{}))
+		if f.InstitutionID > 0 {
+			// 超管跨机构时用于收窄;非超管与 Scoped 同值(重复条件是等价的,只为让超管能筛)。
+			q = q.Where("institution_id = ?", f.InstitutionID)
+		}
+		if f.CampusID != nil {
+			q = q.Where("campus_id = ?", *f.CampusID)
+		}
+		if f.ExpenditureID != nil {
+			q = q.Where("expenditure_id = ?", *f.ExpenditureID)
+		}
+		if f.Type != nil {
+			q = q.Where("expenditure_id IN (?)", r.typeSubquery(db, *f.Type))
+		}
+		if f.From != nil {
+			q = q.Where("create_time >= ?", *f.From)
+		}
+		if f.To != nil {
+			// **上界是开区间**(`<`):Handler 已把"纯日期"形态补到次日 00:00
+			// (`to=2026-04-10` 含当日全部流水)。若这里写成 `<=`,
+			// 次日 00:00:00.000 的那条会被多算进前一天——与端点 17(账单 `to` 不含上界)同口径。
+			q = q.Where("create_time < ?", *f.To)
+		}
+		return q
+	}
+	var total int64
+	if err := build().Count(&total).Error; err != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	page, size := pg.Normalize()
+	rows := make([]model.ExpenditureLog, 0, size)
+	if err := build().
+		Order("create_time DESC, id DESC").
+		Offset(PageOffset(page, size)).Limit(size).
+		Find(&rows).Error; err != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return rows, total, nil
+}
+
+// typeSubquery 构造"某收支方向对应的字典 id 集合"子查询。
+//
+// 入参:db 查询句柄;typ 收支方向(1 支出 / 2 收入)。
+// 出参:*gorm.DB(用作 `expenditure_id IN (?)` 的子查询)。
+//
+// ⚠ 子查询里**必须**放行 `institution_id = 0`(平台通用字典),理由同文件头:
+// 只查本机构会造成"按方向筛选时,平台通用类型的记录整批消失"——比列表少一列更难查。
+// 超管(作用域机构为 0 或 SuperAdmin)不额外收窄机构:平台视角下应能看到所有机构的类型。
+func (r *ExpenditureRepo) typeSubquery(db *gorm.DB, typ int32) *gorm.DB {
+	q := db.Model(&model.Expenditure{}).Select("id").Where("type = ?", typ)
+	if r.Sc == nil || r.Sc.SuperAdmin {
+		return q
+	}
+	return q.Where("institution_id = 0 OR institution_id = ?", r.Sc.InstitutionID)
+}
+
+// CreateLog 写入一条收支记录(**接收 tx**,仓储不开事务),并回填 DB 侧落库时间。
+//
+// 入参:ctx;tx(必传);row 记录行(InstitutionID>0、ExpenditureID>0、Cost 为非负定点字符串)。
+// 出参:nil 成功(回填自增 ID 与 `CreateTime`);ErrBadParam(2002) / ErrDBError(5002)。
+//
+// ⚠ **不写 `create_time`**:该列 `DEFAULT CURRENT_TIMESTAMP`,GORM 对"零值 + default 标签"
+// 会跳过该列、由 DB 填(本项目在 `t_courses.status` 上踩过**反向**的坑:`default:1` 让显式 0 被吞)。
+// 因此应用侧写 `time.Now()` 是错的(应用与库时钟可能差几分钟,而 `year`/`month` 是按它算的分桶)。
+//
+// ⇒ 插入后**在同一事务内回读** `create_time` 填进 `row.CreateTime`,让上层用"库认定的落库时刻"
+// 去算 `year`/`month`,而不是用应用时钟。差几秒在跨月/跨年的那一瞬间就是:
+// **"1 月 31 日 23:59:59 记的账出现在 2 月报表里"**——金额、条数、汇总全对,只有月份错,
+// 这种缺陷只能靠对账时"某月少一条、另一月多一条"发现。
+func (r *ExpenditureRepo) CreateLog(ctx context.Context, tx *gorm.DB, row *model.ExpenditureLog) error {
+	if err := requireTx(tx, "CreateLog"); err != nil {
+		return err
+	}
+	if row == nil || row.ExpenditureID <= 0 {
+		return infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: CreateLog 需要 expenditure_id > 0"))
+	}
+	if strings.TrimSpace(row.BizNo) == "" {
+		return infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: CreateLog 需要 biz_no"))
+	}
+	ctx = ctxOrBackground(ctx)
+	if err := tx.WithContext(ctx).Create(row).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	// 回读落库时间(同一事务,读得到自己刚插的行)。
+	var persisted model.ExpenditureLog
+	if err := tx.WithContext(ctx).
+		Select("create_time").
+		Where("id = ?", row.ID).
+		Take(&persisted).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	row.CreateTime = persisted.CreateTime
+	return nil
+}
+
+// SetLogBuckets 回写报表分桶列 `year` / `month`(同一事务;由 Service 按落库时间算出)。
+//
+// 入参:ctx;tx(必传);id 记录 ID(>0);year 年(如 2026);month 月(1~12)。
+// 出参:nil 成功;ErrBadParam(2002) / ErrDBError(5002)。
+//
+// 单独一次 UPDATE 而不是塞进 INSERT:`create_time` 由 DB 默认值决定,INSERT 时**还没读到**它,
+// 硬把应用时钟的 y/m 写进去就是上面 `CreateLog` 注释里那个"跨月错桶"缺陷的来源。
+func (r *ExpenditureRepo) SetLogBuckets(ctx context.Context, tx *gorm.DB, id int64, year int16, month int8) error {
+	if err := requireTx(tx, "SetLogBuckets"); err != nil {
+		return err
+	}
+	if id <= 0 {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SetLogBuckets 需要 id > 0"))
+	}
+	if month < 1 || month > 12 {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: month 必须在 1~12"))
+	}
+	err := tx.WithContext(ctxOrBackground(ctx)).
+		Model(&model.ExpenditureLog{}).
+		Where("id = ?", id).
+		Updates(map[string]any{"year": year, "month": month}).Error
+	if err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}
+
+// ListTypesByIDs 按 id 批量取收支字典(**含平台通用字典**)。
+//
+// 入参:ctx;ids 类型 id 集合(空 → 空切片,不发 SQL);institutionID 请求机构(0 = 只取平台字典)。
+// 出参:([]model.Expenditure, error);ErrDBError(5002)。
+//
+// 过滤口径:`institution_id = 0 OR institution_id = ?` —— `0` 是平台通用字典,**必须放行**。
+// 返回的 map 由 Service 组装,未命中(字典被删/`expenditure_id = 0`)的行保持 `type=0/name=""`,
+// **不伪造"未知类型"字符串**(那是新造业务值,契约追溯不到列)。
+func (r *ExpenditureRepo) ListTypesByIDs(ctx context.Context, ids []int64, institutionID int64) ([]model.Expenditure, error) {
+	if len(ids) == 0 {
+		return make([]model.Expenditure, 0), nil
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	rows := make([]model.Expenditure, 0, len(ids))
+	if err := r.dictionaryScope(db, institutionID).Where("id IN ?", ids).Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return rows, nil
+}
+
+// ListTypes 取某机构的全部收支字典(**含平台通用字典**),按 `type, parent_id, id` 递增。
+//
+// 入参:ctx;institutionID 请求机构(0 = 只取平台字典)。
+// 出参:([]model.Expenditure, error);ErrDBError(5002)。
+//
+// 排序口径(稳定输出,便于前端做两级联动):先按 `type`(1 支出 / 2 收入,支出在前),
+// 再按 `parent_id`(顶级 0 在前),最后 `id`。SQL 里显式写序,不依赖存储顺序。
+func (r *ExpenditureRepo) ListTypes(ctx context.Context, institutionID int64) ([]model.Expenditure, error) {
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	rows := make([]model.Expenditure, 0, 32)
+	if err := r.dictionaryScope(db, institutionID).
+		Order("type ASC, parent_id ASC, id ASC").
+		Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return rows, nil
+}
+
+// GetTypeByID 取单个收支字典项(**含平台通用字典**)。
+//
+// 入参:ctx;id 类型 id(<=0 → ErrBadParam);institutionID 请求机构(0 = 只认平台字典)。
+// 出参:*model.Expenditure;ErrBadParam(2002) / ErrNotFound(3001) / ErrDBError(5002)。
+//
+// 用途(唯一):端点 60 POST 记账时校验 `expenditureId` 真实存在且对本机构可见。
+// 不存在与"别人的私有类型"**同码 3001**(不泄露存在性)。
+func (r *ExpenditureRepo) GetTypeByID(ctx context.Context, id, institutionID int64) (*model.Expenditure, error) {
+	if id <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 收支类型 id 必须 > 0"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.Expenditure
+	qErr := r.dictionaryScope(db, institutionID).Where("id = ?", id).Take(&out).Error
+	if qErr != nil {
+		return nil, wrapQueryErr(qErr, "GetTypeByID(t_expenditure)")
+	}
+	return &out, nil
+}
+
+// dictionaryScope 字典表的作用域条件(**唯一实现点**:`0` = 平台通用字典必须放行)。
+//
+// 入参:db;institutionID 请求机构(<=0 = 只取平台字典)。
+// 出参:*gorm.DB(已注入 `institution_id` 条件)。
+//
+// 为什么机构为 0 时**不**写成"不过滤":超管没有机构上下文时若不过滤,会把所有机构的同名类型
+// 混在一起(`parentId` 还是各机构自己的 id),下拉框选项重复且父子关系错乱。
+// 因此超管未指定机构时**只看平台字典**,要看某机构的私有类型就显式传 `institutionId`。
+func (r *ExpenditureRepo) dictionaryScope(db *gorm.DB, institutionID int64) *gorm.DB {
+	if institutionID <= 0 {
+		return db.Where("institution_id = 0")
+	}
+	return db.Where("institution_id = 0 OR institution_id = ?", institutionID)
+}
+
+// db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。
+//
+// 入参:ctx(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
+func (r *ExpenditureRepo) db(ctx context.Context) (*gorm.DB, error) {
+	if r == nil || r.DB == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ExpenditureRepo.DB 为 nil"))
+	}
+	if tx := TxFromContext(ctx); tx != nil {
+		return tx, nil
+	}
+	if ctx == nil {
+		return r.DB, nil
+	}
+	return r.DB.WithContext(ctx), nil
+}

+ 395 - 0
internal/academic/service/expenditure_service.go

@@ -0,0 +1,395 @@
+// 收支记录 + 收支字典服务(端点 60)——AC-13d 第三批。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 行 60 / §3.4 D 表 60(v1.9 重写);
+// 设计 `academic-domain-design.md` §4.5 端点清单(角色 `super_admin, finance`)。
+//
+// ---------------------------------------------------------------------------
+// 为什么新增(AC-10 / AC-11 / AC-13c 都没覆盖过这张表)
+// ---------------------------------------------------------------------------
+//
+//	能力                     现状(此前批次)                       冲突
+//	ListLogs                `t_expenditure_log` 从未被读取          无(新只读查询)
+//	ListTypes               `t_expenditure` 从未被读取             无(新只读查询)
+//	CreateLog               从未有过写入面(资金域的钱走      无(新事务;**不碰**
+//	                        `t_user_expense*` 与学生钱包,       `t_user_expense*`,
+//	                        与"机构收支台账"是两套账)           两套账不得混)
+//
+// 三条硬约束(团队批准):不改既有方法签名与行为、新增放新文件、逐条登记(上表)。
+//
+// ---------------------------------------------------------------------------
+// ⚠ 两套账不能混(这是本端点最容易犯的错)
+// ---------------------------------------------------------------------------
+//
+//	学生钱包 / 消费  → `t_user_expense` / `t_user_expense_school` / `t_user_expense_log`(端点 2~9)
+//	机构收支台账    → `t_expenditure_log`(本端点)
+//
+// 二者**没有任何列级联动**:记账不会改学生余额,学生退费也不会自动生成一条机构支出。
+// 把两者混起来算"利润"是产品口径问题,不是后端能替它决定的;本服务只做台账 CRUD。
+//
+// ---------------------------------------------------------------------------
+// 三处口径(都写在这里,避免下一批改的时候靠猜)
+// ---------------------------------------------------------------------------
+//
+//  1. **`cost` 恒存正数**,方向由字典 `type`(1 支出 / 2 收入)决定(model 列红线)。
+//     ⇒ 入参 `money` 必须 > 0:负数会让"支出 + 负数"与"收入 + 正数"变成同一条记录的两种写法,
+//     报表口径立刻分叉。传 0 也拒(零元记账没有业务意义,只会污染对账)。
+//  2. **`bizNo` 由后端生成**(26 位 ULID):前端不能指定单号,否则可枚举/可撞号;
+//     `biz_no` 是唯一键,撞了就是 5002(不该等到那时候才发现)。
+//  3. **`year` / `month` 分桶列按"库认定的落库时刻"算**(`create_time` 由 DB 默认值填,
+//     应用插入后回读再回写分桶)。用应用时钟会在跨月那一秒把记录记到错月份,
+//     而报表上只表现为"某月少一条"。
+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/repository"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// 收支方向枚举(与 `t_expenditure.type` 列一致;契约 D 表:1 支出 / 2 收入)。
+const (
+	// ExpenditureDirectionExpense 支出(1)。
+	ExpenditureDirectionExpense int32 = 1
+	// ExpenditureDirectionIncome 收入(2)。
+	ExpenditureDirectionIncome int32 = 2
+)
+
+// ExpenditureService 机构收支台账服务(端点 60 的三条路由)。
+type ExpenditureService struct {
+	// repo 收支仓储(记录走 scoping,字典按"0 = 平台通用"特判)。
+	repo *repository.ExpenditureRepo
+	// runner 事务运行器(记账 = 校验字典 + 插记录 + 回写分桶,必须同一事务)。
+	runner *repository.TxRunner
+	// sc 请求作用域(机构/校区越权断言)。
+	sc *appscope.Scope
+	// now 时钟(仅用于判断"入参是否给出日期"等非落库时间场景;落库时间一律取 DB)。
+	now func() time.Time
+}
+
+// NewExpenditureService 构造收支台账服务。
+//
+// 入参:db 连接池;sc 请求作用域(nil = 系统任务视角,此时记录查询会 fail-fast)。
+// 出参:*ExpenditureService。
+func NewExpenditureService(db *gorm.DB, sc *appscope.Scope) *ExpenditureService {
+	return &ExpenditureService{
+		repo:   repository.NewExpenditureRepo(db, sc),
+		runner: repository.NewTxRunner(db),
+		sc:     sc,
+		now:    time.Now,
+	}
+}
+
+// ExpenditureLogQuery 端点 60 GET 记录的查询入参。
+//
+// 字段:InstitutionID 机构(0 = 依赖作用域;超管可借此收窄);CampusID 校区(nil 不筛);
+// ExpenditureID 收支类型(nil 不筛);Type 方向(nil 不筛);From/To 创建时间区间(含两端);
+// Page/PageSize 分页。
+type ExpenditureLogQuery struct {
+	InstitutionID int64
+	CampusID      *int64
+	ExpenditureID *int64
+	Type          *int32
+	From          *time.Time
+	To            *time.Time
+	Page          int
+	PageSize      int
+}
+
+// ExpenditureLogView 收支记录对外视图(契约 D 表 60 的 9 个字段,**逐列溯源**)。
+//
+// 字段来源:
+//
+//	ID            ← t_expenditure_log.id
+//	BizNo         ← biz_no(业务单号,对外唯一标识;不可变)
+//	ExpenditureID ← expenditure_id(**补这个字段**,否则前端无法回填编辑表单)
+//	Type          ← t_expenditure.type(**字典**:1 支出 / 2 收入;记录表无此列)
+//	Name          ← t_expenditure.name(**字典**;记录表无此列)
+//	Money         ← cost(金额 2 位小数;**列名是 cost、字段名是 money**,契约口径)
+//	CreateUserID  ← create_user_id(**记账人**,可空 → `null`;不是"收付对象",记录表没有那列)
+//	CampusID      ← campus_id
+//	CreateTime    ← create_time
+//
+// 契约里原有的 `direction` / `userId` **不下发**:前者与 `type` 同义(两表都没有该列),
+// 后者语义是"收付对象"而记录表只有"记账人"(改名 `createUserId` 才不误导)。
+type ExpenditureLogView struct {
+	ID            int64
+	BizNo         string
+	ExpenditureID int64
+	Type          int32
+	Name          string
+	Money         money.Amount
+	CreateUserID  *string
+	CampusID      int64
+	CreateTime    time.Time
+}
+
+// ExpenditureTypeView 收支字典项(契约:`{id, type, name, parentId}`)。
+//
+// 字段来源:ID ← id;Type ← type;Name ← name;ParentID ← parent_id(0 = 顶级,自引用树)。
+type ExpenditureTypeView struct {
+	ID       int64
+	Type     int32
+	Name     string
+	ParentID int64
+}
+
+// CreateExpenditureLogInput 端点 60 POST 记账入参。
+//
+// 字段:InstitutionID(必填 >0,非超管须等于作用域机构);CampusID(0 = 机构级);
+// ExpenditureID(必填 >0,须在字典中且对本机构可见);Money(必填 >0,元字符串解析后传入);
+// Remark(可空);CreateUserID(记账人 = 当前登录用户,由 Handler 从作用域取)。
+type CreateExpenditureLogInput struct {
+	InstitutionID int64
+	CampusID      int64
+	ExpenditureID int64
+	Money         money.Amount
+	Remark        string
+	CreateUserID  string
+}
+
+// CreatedExpenditureLog 记账结果(契约:POST → `{id, bizNo}`)。
+type CreatedExpenditureLog struct {
+	// ID 记录 ID(自增)。
+	ID int64
+	// BizNo 业务单号(后端生成的 26 位 ULID)。
+	BizNo string
+}
+
+// ListLogs 分页查询收支记录(字典名/方向由批量查字典补齐)。
+//
+// 入参:ctx;q 查询入参。
+// 出参:([]ExpenditureLogView, total, error);1004(跨机构)/5002。
+//
+// 为什么"分页取记录 + 批量取字典"而不是 SQL JOIN:记录表走 `Scoped` 生成的是**裸列名**,
+// JOIN 后 `institution_id` 会 `ambiguous column name`(本项目已踩过);
+// 且两步走能让"平台通用字典必须放行"这条规则只出现在 `ListTypesByIDs` 一处。
+func (s *ExpenditureService) ListLogs(ctx context.Context, q ExpenditureLogQuery) ([]ExpenditureLogView, int64, error) {
+	if err := s.assertInstitution(q.InstitutionID); err != nil {
+		return nil, 0, err
+	}
+	rows, total, err := s.repo.ListLogs(ctx, repository.ExpenditureLogFilter{
+		InstitutionID: q.InstitutionID,
+		CampusID:      q.CampusID,
+		ExpenditureID: q.ExpenditureID,
+		Type:          q.Type,
+		From:          q.From,
+		To:            q.To,
+	}, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
+	if err != nil {
+		return nil, 0, err
+	}
+	if len(rows) == 0 {
+		return make([]ExpenditureLogView, 0), total, nil
+	}
+	// 批量取字典(**含平台通用字典**),避免 N+1;`expenditure_id = 0` 不查(不存在该 id)。
+	ids := make([]int64, 0, len(rows))
+	seen := make(map[int64]struct{}, len(rows))
+	for i := range rows {
+		id := rows[i].ExpenditureID
+		if id <= 0 {
+			continue
+		}
+		if _, ok := seen[id]; ok {
+			continue
+		}
+		seen[id] = struct{}{}
+		ids = append(ids, id)
+	}
+	types, err := s.repo.ListTypesByIDs(ctx, ids, s.dictionaryInstitution(q.InstitutionID))
+	if err != nil {
+		return nil, 0, err
+	}
+	dict := make(map[int64]*model.Expenditure, len(types))
+	for i := range types {
+		dict[types[i].ID] = &types[i]
+	}
+	views := make([]ExpenditureLogView, 0, len(rows))
+	for i := range rows {
+		row := &rows[i]
+		cost, perr := parseAmount(row.Cost)
+		if perr != nil {
+			return nil, 0, perr
+		}
+		view := ExpenditureLogView{
+			ID:            row.ID,
+			BizNo:         row.BizNo,
+			ExpenditureID: row.ExpenditureID,
+			Money:         cost,
+			CreateUserID:  optionalTrimmedPtr(row.CreateUserID),
+			CampusID:      row.CampusID,
+			CreateTime:    row.CreateTime,
+		}
+		// 字典命中才有 type/name;未命中保持 0 / ""(见下)。
+		if t, ok := dict[row.ExpenditureID]; ok {
+			view.Type = t.Type
+			view.Name = strings.TrimSpace(t.Name)
+		}
+		// ⚠ 字典未命中(类型被删 / `expenditure_id = 0`)时 **type=0、name=""**,不编造"未知类型":
+		// 编一个字符串等于凭空造业务值(契约 §3.4 ③:每个字段必须能追到列,造的值追不到),
+		// 而且前端再想按 type 分组时会被这个假值带偏。空值至少诚实。
+		views = append(views, view)
+	}
+	return views, total, nil
+}
+
+// ListTypes 取收支字典(契约:`GET /admin/expenditures/types` → `types:[{id,type,name,parentId}]`)。
+//
+// 入参:ctx;institutionID 机构(<=0 = 只取平台通用字典,见 `dictionaryInstitution`)。
+// 出参:([]ExpenditureTypeView, error);1004(跨机构)/5002。
+//
+// **平台通用字典(`institution_id = 0`)恒在结果里**:它不是"未归属数据",
+// 而是全平台共用的类型(如"房租/水电/学费收入")。若被作用域过滤掉,
+// 机构视角下"新增收支"的下拉框会只剩自己建的几条(通常为 0 条)⇒ 功能像是坏了。
+func (s *ExpenditureService) ListTypes(ctx context.Context, institutionID int64) ([]ExpenditureTypeView, error) {
+	if err := s.assertInstitution(institutionID); err != nil {
+		return nil, err
+	}
+	rows, err := s.repo.ListTypes(ctx, s.dictionaryInstitution(institutionID))
+	if err != nil {
+		return nil, err
+	}
+	views := make([]ExpenditureTypeView, 0, len(rows))
+	for i := range rows {
+		views = append(views, ExpenditureTypeView{
+			ID:       rows[i].ID,
+			Type:     rows[i].Type,
+			Name:     strings.TrimSpace(rows[i].Name),
+			ParentID: rows[i].ParentID,
+		})
+	}
+	return views, nil
+}
+
+// CreateLog 记一笔收支(校验字典可见性 → 插记录 → 按库时间回写分桶,**同一事务**)。
+//
+// 入参:ctx;in CreateExpenditureLogInput。
+// 出参:*CreatedExpenditureLog(id + bizNo);2001(缺机构/类型)/2002(金额≤0、校区为负)/
+// 3001(收支类型不存在或不可见)/1004(跨机构/跨校区)/5002。
+func (s *ExpenditureService) CreateLog(ctx context.Context, in CreateExpenditureLogInput) (*CreatedExpenditureLog, error) {
+	if in.InstitutionID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: 记账需要 institutionId(>0)"))
+	}
+	if in.ExpenditureID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: 记账需要 expenditureId(>0)"))
+	}
+	if in.CampusID < 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: campusId 不得为负"))
+	}
+	if !(in.Money.GreaterThan(0)) {
+		// 零/负都拒:`cost` 恒存正数,方向由字典 type 决定(model 列红线)。
+		// 放行负数会让"支出 -100"与"收入 +100"成为同一条记录的两种写法,报表口径立刻分叉。
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: 记账金额必须大于 0(收支方向由 expenditureId 对应的 type 决定,"+
+				"cost 恒存正数;传负数会与反方向的正数记账重复)"))
+	}
+	if err := in.Money.Validate(); err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, err)
+	}
+	if err := s.assertScopeMatch(in.InstitutionID, in.CampusID); err != nil {
+		return nil, err
+	}
+	bizNo, err := newBizNo()
+	if err != nil {
+		return nil, err
+	}
+	row := &model.ExpenditureLog{
+		BizNo:         bizNo,
+		InstitutionID: in.InstitutionID,
+		CampusID:      in.CampusID,
+		ExpenditureID: in.ExpenditureID,
+		// 金额列是 `decimal(12,2)` 字符串:走 `money.Amount.String()`(定点,恒 2 位小数)。
+		Cost:         in.Money.String(),
+		CreateUserID: stringPtr(in.CreateUserID),
+		Remark:       stringPtr(in.Remark),
+	}
+	err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		// 字典可见性校验放在事务内:并发删字典时不会出现"校验通过但字典已没了"的窗口。
+		if _, terr := s.repo.GetTypeByID(txCtx, in.ExpenditureID, s.dictionaryInstitution(in.InstitutionID)); terr != nil {
+			return terr // 2002(id 非法)/ 3001(不存在或不可见)/ 5002
+		}
+		if cerr := s.repo.CreateLog(txCtx, tx, row); cerr != nil {
+			return cerr
+		}
+		// 分桶按**库认定的落库时刻**算(`create_time` 由 DB 默认值填,应用侧时钟会跨月错桶)。
+		year := int16(row.CreateTime.Year())
+		month := int8(row.CreateTime.Month())
+		return s.repo.SetLogBuckets(txCtx, tx, row.ID, year, month)
+	})
+	if err != nil {
+		return nil, err
+	}
+	return &CreatedExpenditureLog{ID: row.ID, BizNo: row.BizNo}, nil
+}
+
+// assertInstitution 机构维度越权断言(非超管必须与作用域机构一致)。
+//
+// 入参:institutionID(0 = 未指定,依赖作用域,放行)。出参:1004 或 nil。
+//
+// 为什么要单独一道(`Scoped` 已经在 SQL 层过滤):SQL 层过滤会把"跨机构查询"变成**空列表**,
+// 症状是"这家机构一条收支都没有",而不是"你越权了"。查询类端点必须显式报 1004,
+// 否则前端会以为数据丢了(本项目在 AC-13c 已按此口径统一)。
+func (s *ExpenditureService) assertInstitution(institutionID int64) error {
+	if s.sc == nil {
+		return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 收支服务缺少作用域"))
+	}
+	if s.sc.SuperAdmin || institutionID <= 0 {
+		return nil
+	}
+	if institutionID != s.sc.InstitutionID {
+		return infraerrors.WithCause(infraerrors.ErrUnscoped,
+			errString("service: 收支记录机构与当前作用域机构不一致"))
+	}
+	return nil
+}
+
+// assertScopeMatch 写入时的机构 + 校区双维度断言(非超管)。
+//
+// 入参:institutionID;campusID。出参:1004 或 nil。
+//
+// 校区规则与课程域一致:作用域带校区(>0)时,写入的 `campus_id` 必须等于它,
+// 否则就是"往别的校区记账"——而收支记录是**按校区出报表**的,越权写入会直接污染他校区报表。
+func (s *ExpenditureService) assertScopeMatch(institutionID, campusID int64) error {
+	if s.sc == nil {
+		return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 收支服务缺少作用域"))
+	}
+	if s.sc.SuperAdmin {
+		return nil
+	}
+	if institutionID != s.sc.InstitutionID {
+		return infraerrors.WithCause(infraerrors.ErrUnscoped,
+			errString("service: 记账机构与当前作用域机构不一致"))
+	}
+	if s.sc.CampusID > 0 && campusID != s.sc.CampusID {
+		return infraerrors.WithCause(infraerrors.ErrUnscoped,
+			errString("service: 记账校区与当前作用域校区不一致"))
+	}
+	return nil
+}
+
+// dictionaryInstitution 折算"查字典时该用哪个机构"。
+//
+// 入参:institutionID 查询/写入的机构(可 0)。出参:用于字典过滤的机构 ID(0 = 只取平台通用字典)。
+//
+// 规则:调用方给了机构就用它;没给(超管未指定)→ 用作用域机构;仍为 0 → 只取平台字典。
+// **任何一支都包含 `institution_id = 0`**(见 repo.dictionaryScope):平台通用字典永不缺席。
+func (s *ExpenditureService) dictionaryInstitution(institutionID int64) int64 {
+	if institutionID > 0 {
+		return institutionID
+	}
+	if s.sc != nil {
+		return s.sc.InstitutionID
+	}
+	return 0
+}

+ 9 - 7
internal/academic/service/payroll_plan_service.go

@@ -511,7 +511,7 @@ func toPlanDetail(d *model.CoursePaymentDetail) (PayrollPlanDetail, error) {
 	}
 	return PayrollPlanDetail{
 		DetailID:      d.ID,
-		TeacherID:     teacherIDOrNil(d.TeacherID),
+		TeacherID:     optionalTrimmedPtr(d.TeacherID),
 		ClassifyID:    d.ClassifyID,
 		ClassType:     d.ClassType,
 		GradeID:       d.GradeID,
@@ -528,15 +528,17 @@ func toPlanDetail(d *model.CoursePaymentDetail) (PayrollPlanDetail, error) {
 	}, nil
 }
 
-// teacherIDOrNil 把可空老师 ID 归一为"有值才有指针"(NULL / 空白 → nil)。
+// optionalTrimmedPtr 把"可空文本列"归一为"有值才有指针"(NULL / 空白 → nil)。
 //
-// 入参:v 列值指针。出参:非空白字符串的指针;NULL 或纯空白 → nil。
+// 入参:v 列值指针。出参:去空白后非空串的指针;NULL 或纯空白 → nil。
 //
-// 为什么空白也归 nil:`teacher_id` 是 `varchar`,历史数据里存在 `''`(旧系统用空串代替 NULL)。
-// 若把 `''` 原样下发,前端 `if (teacherId)` 得到 false —— 行为上等同 NULL,但**字节不同**,
+// 使用点(两处,口径相同):`teacher_id`(课酬明细的"通用档")与 `create_user_id`(收支记账人)。
+//
+// 为什么空白也归 nil:两列都是 `varchar`,历史数据里存在 `''`(旧系统用空串代替 NULL)。
+// 若把 `''` 原样下发,前端 `if (x)` 得到 false —— 行为上等同 NULL,但**字节不同**,
 // 又回到"同一语义两种表示"的老坑。这里统一收口为 `null`,让出口只有三种取值:
-// `null`(通用档)/ 合法 ULID / 见不到别的。
-func teacherIDOrNil(v *string) *string {
+// `null`(不限定/未记录)/ 合法 ULID / 见不到别的。
+func optionalTrimmedPtr(v *string) *string {
 	if v == nil {
 		return nil
 	}