17 İşlemeler e5c21fff15 ... e1ce7ce182

Yazar SHA1 Mesaj Tarih
  Q e1ce7ce182 feat(academic): AC-13d 第三批——端点60 收支台账(3 条路由,平台通用字典必须放行) 9 saat önce
  Q 066cb3942a fix(academic): 端点58 teacherId 可空下发 null + 出口定点化(比例1位/金额2位/教龄3位)+ 纯函数单测 10 saat önce
  Q 2adee30f34 docs(academic): 写下「同一次匹配 ⇒ 同一份输出」纪律(detailId 与金额必须同源) 10 saat önce
  Q 0680aa366b feat(academic): AC-13d 第二批——端点58 课酬方案(两级形状/明细整体替换)+ 端点63 拆 2001/2002 10 saat önce
  Q b29d1fdbfa feat(academic): 教龄接上教师档案(calculatingAge 口径:365 天/年 + round 1 位) 10 saat önce
  Q 32dcb50419 fix(academic): D16 课酬只分给 type∈{1,2}(除数+写入范围按 PHP),恒等式改条件式 10 saat önce
  Q fcc2a30d6f feat(academic): AC-13d 第一批——端点 59 课酬试算 + 61~63 维护型(58/60 待契约裁决) 10 saat önce
  Q 4349997ea9 fix(academic): 课酬公式改为 PHP 权威顺序(先加 reward 再夹 min/max,上限优先)+ 补 teacher_reward_type=2 固定课酬表 10 saat önce
  Q f7af40f7cd docs(academic): D14 测试库弃唯一约束的注释补明(生产 MySQL 约束照常生效) 10 saat önce
  Q a293009816 feat(academic): D6 新增 3143 重复报名码 + D4 端点53 realName 跨域只读端口 10 saat önce
  Q 16618fe307 feat(academic): D10 课酬端口扩 Income/TeacherExp,按比例提成落地(percentage 百分数) 10 saat önce
  Q 612bf9b009 fix(academic): D11 新建课程 status 落 1——去掉 model tag 的 default:1(DDL 不动) 10 saat önce
  Q 9ed6936d41 fix(academic): 端点51 divide[] 按裁决改为 {userId,type,amount}(删 level/quota) 11 saat önce
  Q fec4cbecf5 feat(academic): AC-13c 课程 Handler(端点 44~57) 11 saat önce
  Q 32c3009ef8 docs(academic): AC-14 收尾——权限码清单导出 + divide 注释更正 + 种子注释 11 saat önce
  Q c1b8e47825 feat(academic): AC-14 第一批路由接线(40 端点)+ RBAC 种子 11 saat önce
  Q f20ccae732 fix(rbac): casbin gormAdapter 表名 rule_rows → casbin_rule(S1 遗留) 11 saat önce
35 değiştirilmiş dosya ile 12110 ekleme ve 32 silme
  1. 21 9
      cmd/api/main.go
  2. 80 0
      cmd/api/wiring_academic.go
  3. 9 0
      internal/academic/apperr/codes.go
  4. 1 0
      internal/academic/apperr/codes_test.go
  5. 1089 0
      internal/academic/handler/course_handler.go
  6. 1706 0
      internal/academic/handler/course_handler_test.go
  7. 8 1
      internal/academic/handler/divide_handler.go
  8. 509 0
      internal/academic/handler/dto_course.go
  9. 122 0
      internal/academic/handler/dto_expenditure.go
  10. 250 0
      internal/academic/handler/dto_payroll.go
  11. 330 0
      internal/academic/handler/expenditure_handler.go
  12. 604 0
      internal/academic/handler/expenditure_handler_test.go
  13. 35 1
      internal/academic/handler/handler.go
  14. 490 0
      internal/academic/handler/payroll_handler.go
  15. 719 0
      internal/academic/handler/payroll_handler_test.go
  16. 598 0
      internal/academic/handler/route.go
  17. 747 0
      internal/academic/handler/route_e2e_test.go
  18. 510 0
      internal/academic/handler/seed.go
  19. 7 3
      internal/academic/model/models_course.go
  20. 43 0
      internal/academic/port/user_adapter.go
  21. 353 0
      internal/academic/repository/course_extra_repo.go
  22. 335 0
      internal/academic/repository/expenditure_repo.go
  23. 223 0
      internal/academic/repository/payroll_plan_repo.go
  24. 38 0
      internal/academic/repository/payroll_repo.go
  25. 1344 0
      internal/academic/service/course_query_service.go
  26. 196 0
      internal/academic/service/decimal_format.go
  27. 171 0
      internal/academic/service/decimal_format_test.go
  28. 395 0
      internal/academic/service/expenditure_service.go
  29. 106 0
      internal/academic/service/payroll_age.go
  30. 94 0
      internal/academic/service/payroll_age_test.go
  31. 622 0
      internal/academic/service/payroll_plan_service.go
  32. 114 18
      internal/academic/service/record_service.go
  33. 113 0
      internal/identity/repository/user_name_repo.go
  34. 122 0
      internal/identity/repository/user_name_repo_test.go
  35. 6 0
      internal/infra/rbac/enforcer.go

+ 21 - 9
cmd/api/main.go

@@ -11,10 +11,15 @@
 //     公开路由须在 Auth Group 之前——铁律 8 路由纪律);
 //  7. 任务工作池启动与优雅退出(task 域,GracePeriodSec 内排空在途任务,§3.7)。
 //
-// 当前状态:identity 模块(两步登录 / me / refresh / logout)已装配(T4-6),
-// 含 DB 连接 + 核心表 AutoMigrate(guarded:仅 UNIFIED_AUTO_MIGRATE=1 时执行,生产幂等不漂移)、
-// Redis 连接 + token 存储 + RS256 签发器 + 仓储 + AuthService + Handler + 验签中间件 + /auth/* 路由。
-// 其余域(org/academic/teaching/ai)尚未实现,域模块就绪后在"业务域路由挂载点"注册。
+// 当前状态:
+//   - identity 模块(两步登录 / me / refresh / logout)已装配(T4-6),含 DB 连接 + 核心表 AutoMigrate
+//     (guarded:仅 UNIFIED_AUTO_MIGRATE=1 时执行,生产幂等不漂移)、Redis 连接 + token 存储 +
+//     RS256 签发器 + 仓储 + AuthService + Handler + 验签中间件 + /auth/* 路由;
+//   - org 模块(机构/校区只读 + RBAC 矩阵配置 `/org/role-permissions`)已装配(T6);
+//   - academic 模块 AC-14 第一批已接线(40 个端点:财务 16 + 账单/仪表盘 2 + 活动 16 + 分账 6),
+//     注册表与运行期断言在 `internal/academic/handler/route.go`,装配见 `wiring_academic.go`
+//     的 `registerAcademicRoutes`(main 只调这一个函数);
+//   - teaching/ai 域尚未实现,域模块就绪后在"业务域路由挂载点"注册。
 //
 // 启动方式(dev):
 //
@@ -313,12 +318,19 @@ func main() {
 	rbacCfg.Get("/:roleCode", rbacH.RolePermissions)
 	rbacCfg.Put("/:roleCode", rbacH.SaveRolePermissions)
 
-	// 其余域(academic/teaching/ai)路由挂载点(域模块实现后在此注册):
+	// academic 域路由接线(AC-14 第一批:财务 16 + 账单/仪表盘 2 + 活动 16 + 分账 6 = 40 个端点)。
+	// 端点清单、注册顺序(静态段先于 `:id`)、角色白名单全部收在
+	// `internal/academic/handler/route.go` 的注册表里;本处**只调用一个注册函数**。
+	// 注册函数内当场跑两条运行期断言:① 全局 `method+path` 唯一(Fiber 对重复注册是
+	// "先注册优先、后者静默失效");② academic 一级段 ⊆ {/admin,/student,/pay}。
+	// 任一断言失败 → 进程起不来(这类缺陷在运行期不可见、只能靠启动期 fail-fast 暴露)。
+	if err := registerAcademicRoutes(app, gdb, authMw, logger); err != nil {
+		logger.Error("academic 路由挂载失败", zap.Error(err))
+		os.Exit(1)
+	}
+
+	// 其余域(teaching/ai)路由挂载点(域模块实现后在此注册):
 	//
-	//	app.Get("/auth/jwks", identityHandler.JWKS)            // 公开
-	//	auth := app.Group("/", middleware.Auth(logger))        // 以下全部需登录
-	//	auth.Get("/auth/me", identityHandler.Me)
-	//	auth.Get("/admin/courses/:id", academicHandler.Get)    // PC 端
 	//	auth.Get("/student/homeworks", teachingHandler.List)   // 移动端
 
 	// 优雅退出(SIGTERM/SIGINT;GracePeriodSec 内排空存量请求,§7.3)

+ 80 - 0
cmd/api/wiring_academic.go

@@ -18,10 +18,19 @@
 package main
 
 import (
+	"context"
+	"os"
+
+	"github.com/gofiber/fiber/v2"
+	"go.uber.org/zap"
 	"gorm.io/gorm"
 
+	academichandler "yuansiwei/unified-backend/internal/academic/handler"
+	academicport "yuansiwei/unified-backend/internal/academic/port"
 	academicrepo "yuansiwei/unified-backend/internal/academic/repository"
 	academicservice "yuansiwei/unified-backend/internal/academic/service"
+	identityhandler "yuansiwei/unified-backend/internal/identity/handler"
+	identityrepo "yuansiwei/unified-backend/internal/identity/repository"
 )
 
 // 编译期契约断言(签名漂移即编译失败,杜绝"注入了但方法对不上"的隐蔽错误):
@@ -31,6 +40,7 @@ import (
 var (
 	_ academicservice.FirstChargedRecordReader = (*academicrepo.RecordRepo)(nil)
 	_ academicservice.DivideLogGranter         = (*academicservice.DivideService)(nil)
+	_ academicport.UserNameReader              = (*identityrepo.UserNameRepo)(nil)
 )
 
 // academicServices academic 域已装配的服务集合(AC-14 接线时在此继续追加 Handler/路由)。
@@ -62,3 +72,73 @@ func newAcademicServices(gdb *gorm.DB) *academicServices {
 		FirstChargedInjected: true,
 	}
 }
+
+// registerAcademicRoutes academic 域路由接线(AC-14 第一批:40 个已交付端点)。
+//
+// 这是 `cmd/api/main.go` 里**唯一**的 academic 挂载调用点(main 不关心端点清单、不关心顺序,
+// 全部收在 `internal/academic/handler/route.go` 的注册表里)。
+//
+// 入参:
+//   - app *fiber.App:应用实例;
+//   - gdb *gorm.DB:数据库句柄(academic 的 Service 按请求作用域现场装配,Handler 只持 DB);
+//   - authMw fiber.Handler:identity 的验签 + 作用域注入中间件(本批 40 端点**全部**需登录);
+//   - log *zap.Logger:结构化日志器。
+//
+// 出参:error——Handler 装配失败 / 路由表非法(含 `/api`、空白名单、未知角色码)/
+// 断言 ①(`method+path` 全局重复)/ 断言 ②(一级段越界,如误注册 `/auth/*`)。
+// **任一失败都必须让进程起不来**:这类缺陷在运行期是"静默失效"(Fiber 后注册者被丢弃、
+// 或某角色恒 1003),启动失败是唯一可靠的暴露点。
+//
+// 副作用(仅 dev):`UNIFIED_AUTO_MIGRATE=1` 时顺带写 RBAC 种子(三张表,`INSERT IGNORE` 幂等)。
+// 生产不在此处写权限表——按"DDL 先行"纪律随迁移脚本执行(种子 SQL 由
+// `academichandler.RBACSeed.SQL("mysql")` 渲染,与路由表 1:1,不会手抄漏行)。
+// 种子写入失败**不阻断启动**(表未建时告警;空策略是 fail-closed,不会放行越权)。
+func registerAcademicRoutes(app *fiber.App, gdb *gorm.DB, authMw fiber.Handler, log *zap.Logger) error {
+	academicH, err := academichandler.New(academichandler.Config{
+		DB: gdb,
+		// 成员校验器(Service 层对象级越权预检):academic 域经 port 只读 t_membership,
+		// 不去 import org 的 repository(依赖方向铁律,见 academic/port/orgadapter.go)。
+		Member: academicport.NewOrgMembershipChecker(gdb),
+		// 用户显示名只读端口(端点 53 的 `realName`,D4 裁决):接口在 academic/port,
+		// 实现放 identity(`t_user` 的属主),此处注入。**academic 对 identity 零 import**。
+		Users: identityrepo.NewUserNameRepo(gdb),
+		Log:   log,
+	})
+	if err != nil {
+		return err
+	}
+	reg, err := academichandler.Register(app, academicH, academichandler.RegisterOptions{
+		Auth:      authMw,
+		RoleGuard: identityhandler.RequireRole,
+	})
+	if err != nil {
+		return err
+	}
+	log.Info("academic 路由已挂载",
+		zap.Int("routes", len(reg.Routes)),
+		zap.Strings("firstSegments", reg.Prefixes))
+
+	if os.Getenv("UNIFIED_AUTO_MIGRATE") == "1" {
+		res, serr := academichandler.SeedRBAC(context.Background(), gdb, reg.Routes)
+		switch {
+		case serr != nil:
+			// **不阻断启动**:种子是"运维动作"而不是"启动前提"。
+			// 三张表里只有 t_permission/t_role_permission 在 AutoMigrate 名单内,`casbin_rule`
+			// 按"目标态 DDL 先行"纪律**不**由 GORM 建表(见 infra/rbac/enforcer.go 包注释),
+			// 因此 dev/预发若还没跑 DDL,这里一定报 "no such table"。
+			// 若此时 os.Exit(1),结果是"没跑 DDL 就完全起不来"(连带登录也不可用),
+			// 代价远大于"策略表为空"——策略为空是 fail-closed(非超管全 1003),不会放行越权。
+			log.Warn("academic RBAC 种子未写入(通常是 casbin_rule 等权限表尚未按 DDL 建表)",
+				zap.Error(serr))
+		default:
+			log.Info("academic RBAC 种子已应用(幂等:changed=0 表示已是最新)",
+				zap.Int("casbinRules", res.CasbinRules),
+				zap.Int64("insertedCasbinRules", res.InsertedCasbinRules),
+				zap.Int("permissions", res.Permissions),
+				zap.Int64("insertedPermissions", res.InsertedPermissions),
+				zap.Int("rolePermissions", res.RolePermissions),
+				zap.Int64("insertedRolePermissions", res.InsertedRolePermissions))
+		}
+	}
+	return nil
+}

+ 9 - 0
internal/academic/apperr/codes.go

@@ -57,6 +57,12 @@ const (
 	CodeBusinessParam = 3140
 	// CodeActivityFull 报名人数已满(signed_add:number_sign >= number_max)。
 	CodeActivityFull = 3141
+	// CodeEnrollmentDuplicate 重复报名(同一学生在本课程已有在读报课行)。
+	//
+	// 为什么单独立码而不复用 3140:§10.1 v0.1.19 明令 3140 只收"**参数之间**互相矛盾"
+	// (句式「A 与 B 矛盾」)。"该学生已报过名"是**业务状态冲突**,不是参数矛盾;
+	// 塞进 3140 会把 3140 变成垃圾桶(该禁令见 §10.1)。
+	CodeEnrollmentDuplicate = 3143
 )
 
 // 金额/申请/活动/分账/课酬/课程 业务错误的预置 sentinel(业务域直接复用,禁止散落 magic message)。
@@ -101,6 +107,8 @@ var (
 	ErrBusinessParam = infraerrors.New(CodeBusinessParam, "业务参数错误")
 	// ErrActivityFull 报名人数已满(3141)。
 	ErrActivityFull = infraerrors.New(CodeActivityFull, "报名人数已满")
+	// ErrEnrollmentDuplicate 重复报名(3143)。
+	ErrEnrollmentDuplicate = infraerrors.New(CodeEnrollmentDuplicate, "该学生已在本课程在读,请勿重复报名")
 )
 
 // CodeLowerBound / CodeUpperBound academic 业务码段位上下界(含端点,单测与门禁断言用)。
@@ -132,6 +140,7 @@ var all = []*infraerrors.AppError{
 	ErrScheduleConflict,
 	ErrBusinessParam,
 	ErrActivityFull,
+	ErrEnrollmentDuplicate,
 }
 
 // All 返回本包定义的 academic 业务错误全集(供单测断言段位/唯一性、供 Handler 生成错误码文档)。

+ 1 - 0
internal/academic/apperr/codes_test.go

@@ -56,6 +56,7 @@ func TestKnownCodes(t *testing.T) {
 		{"3122 排班冲突", ErrScheduleConflict.Code, 3122},
 		{"3140 业务参数错", ErrBusinessParam.Code, 3140},
 		{"3141 报名人数已满", ErrActivityFull.Code, 3141},
+		{"3143 重复报名", ErrEnrollmentDuplicate.Code, 3143},
 	}
 	for _, p := range pairs {
 		if p.got != p.want {

+ 1089 - 0
internal/academic/handler/course_handler.go

@@ -0,0 +1,1089 @@
+// 课程 / 排班 / 打卡 HTTP 处理器(端点 44~57,共 14 个契约端点 / 15 条 method+path)——AC-13c。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §2.4(端点表)/
+// §3.4 C 表(字段级定义,**唯一权威**)/ §3.4 总原则 ③(字段准入条件)/ §1.3(分页)/ §1.5(时间与量纲);
+// 设计 `academic-domain-design.md` §3.5(SP27 欠费核销)/ §3.8-E4(`unpaid` 废弃列)/ §4.4(端点清单)。
+//
+// ---------------------------------------------------------------------------
+// 端点 → 服务方法 / 权威口径对照(每个方法头再写一次,便于单点核对)
+// ---------------------------------------------------------------------------
+//
+//	44 GET  /admin/courses                                    CourseService.ListCourseRows(+teacherIds/recordCount)
+//	45 POST /admin/courses                                    CourseService.CreateCourse(AC-11)
+//	45 PUT  /admin/courses/:id                                CourseService.UpdateCourse(AC-11)
+//	46 GET  /admin/courses/:id/classes                        CourseService.ListClassRows(+Handler 内分页)
+//	47 POST /admin/courses/:id/classes                        AssertNoClassConflict(3122)+ CreateClass(AC-11)
+//	48 GET  /admin/courses/:id/records                        RecordService.ListRecordsByQuery(AC-11 List)
+//	49 POST /admin/courses/:id/records/generate               CourseService.GenerateRecords(AC-11)
+//	50 POST /admin/courses/records/:recordId/start            RecordService.StartRecord(AC-11)
+//	51 POST /admin/courses/records/:recordId/finish           RecordService.FinishRecord(AC-11 单事务)
+//	                                                          + RecordViewService.FinishView(只读溯源)
+//	52 POST /admin/courses/records/:recordId/attendance       RecordService.MarkAttendance(AC-11)
+//	53 GET  /admin/courses/:id/students                       CourseService.ListStudentRows
+//	54 POST /admin/courses/:id/students                       CourseService.Enroll
+//	55 GET  /admin/courses/arrears                            CourseService.ListArrearsRows(**remainder_receivable > 0**)
+//	56 POST /admin/courses/arrears/counteract                 MaintenanceService.CounteractArrears(AC-11,SP27)
+//	57 GET  /student/courses/schedule                         CourseService.ListScheduleRows
+//
+// ---------------------------------------------------------------------------
+// 路由注册顺序(**静态段一律先于 `:id`**;本批已核对:不存在真冲突)
+// ---------------------------------------------------------------------------
+//
+// 按 Fiber 的注册顺序匹配,同深度下"字面段必须在 `:param` 之前"。本批 15 条的静态段:
+//
+//	/admin/courses                        (GET/POST,2 段)
+//	/admin/courses/arrears                (GET,3 段)
+//	/admin/courses/arrears/counteract     (POST,4 段)
+//	/admin/courses/records/:recordId/...  (POST,5 段)
+//
+// 逐个核对"会被 `:id` 吃掉"的可能(`/admin/courses/:id` = 3 段 PUT,`/admin/courses/:id/classes`
+// = 4 段,`/admin/courses/:id/records|students` = 4 段,`/:id/records/generate` = 5 段):
+//
+//	GET  /admin/courses/arrears                vs 3 段 GET 的 `:id` 路由 → **不存在**(45 只有 POST/PUT)
+//	POST /admin/courses/arrears/counteract     vs `/admin/courses/:id/students`(4 段 POST)
+//	   → 第 4 段 `counteract` ≠ `students`,**不匹配**
+//	POST /admin/courses/records/:recordId/start vs `/admin/courses/:id/records/generate`(5 段 POST)
+//	   → 第 3 段 `records` 能匹配 `:id`,但第 4 段要 `records`、请求给的是 `:recordId` 的值,**不匹配**
+//
+// ⇒ **本批不存在真实的同深度静态/参数冲突**(如实登记,不硬凑一条变红的用例)。
+// 顺序纪律仍然保留(静态在前),并由 `route_e2e_test.go` 的 `staticShadowViolations` 持续校验。
+//
+// ---------------------------------------------------------------------------
+// 错误码分工(本文件只产出下面这几类,其余一律由 Service 产出后原样透传)
+// ---------------------------------------------------------------------------
+//
+//	2001 缺参/类型不符 —— `validateReq`(`validator.Validate`)
+//	2002 传了但非法 —— 金额 Parse 失败 / 枚举越界 / 时分格式非法 / 路径 id 非数字
+//	1001 无作用域 —— `mustScope`
+//	1004 跨作用域 —— `campusId`/`institutionId` 与作用域不一致(端点 54、56)
+//	3001/3104/3112/3121/3122/3140/5002 —— Service 产出,Handler **不翻译码值**
+//
+// ⚠ 本文件**不硬编码任何数字码**:一律引 `infraerrors.Err*` / `apperr.Err*` 常量。
+package handler
+
+import (
+	"context"
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+	"go.uber.org/zap"
+
+	"yuansiwei/unified-backend/internal/academic/money"
+	"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 (
+	// PathCourses 端点 44/45:课程列表 / 新建(`/admin/courses`)。
+	PathCourses = "/admin/courses"
+	// PathCourseDetail 端点 45:更新课程(`PUT /admin/courses/:id`)。
+	PathCourseDetail = "/admin/courses/:id"
+	// PathCourseArrears 端点 55:欠费列表(`GET /admin/courses/arrears`)。
+	PathCourseArrears = "/admin/courses/arrears"
+	// PathCourseArrearsCounteract 端点 56:欠费核销(`POST /admin/courses/arrears/counteract`)。
+	PathCourseArrearsCounteract = "/admin/courses/arrears/counteract"
+	// PathCourseRecordStart 端点 50:开始上课(`POST /admin/courses/records/:recordId/start`)。
+	PathCourseRecordStart = "/admin/courses/records/:recordId/start"
+	// PathCourseRecordFinish 端点 51:结束上课(`POST /admin/courses/records/:recordId/finish`)。
+	PathCourseRecordFinish = "/admin/courses/records/:recordId/finish"
+	// PathCourseRecordAttendance 端点 52:打卡(`POST /admin/courses/records/:recordId/attendance`)。
+	PathCourseRecordAttendance = "/admin/courses/records/:recordId/attendance"
+	// PathCourseClasses 端点 46/47:排班规则列表 / 新建(`/admin/courses/:id/classes`)。
+	PathCourseClasses = "/admin/courses/:id/classes"
+	// PathCourseRecords 端点 48:课表实例(`GET /admin/courses/:id/records`)。
+	PathCourseRecords = "/admin/courses/:id/records"
+	// PathCourseRecordsGenerate 端点 49:生成课表(`POST /admin/courses/:id/records/generate`)。
+	PathCourseRecordsGenerate = "/admin/courses/:id/records/generate"
+	// PathCourseStudents 端点 53/54:报课学生列表 / 报名(`/admin/courses/:id/students`)。
+	PathCourseStudents = "/admin/courses/:id/students"
+	// PathStudentSchedule 端点 57:学生课表(`GET /student/courses/schedule`)。
+	PathStudentSchedule = "/student/courses/schedule"
+)
+
+// 枚举白名单(2002 的唯一判据点——`oneof` 会把"传了非法值"误判成 2001)。
+var (
+	// courseStatusFilter 端点 44 的课程状态过滤白名单(0 未发布 / 1 发布未开始 / 2 进行中 / 3 已结束)。
+	courseStatusFilter = map[int64]struct{}{0: {}, 1: {}, 2: {}, 3: {}}
+	// recordStatusFilter 端点 48 的上课状态过滤白名单(0 未开始 / 1 上课中 / 2 已结束)。
+	recordStatusFilter = map[int64]struct{}{0: {}, 1: {}, 2: {}}
+	// studentStatusFilter 端点 53 的报课状态过滤白名单(1 正常 / 2 结束)。
+	studentStatusFilter = map[int64]struct{}{1: {}, 2: {}}
+)
+
+// ---------------------------------------------------------------- 端点 44:课程列表
+
+// ListCourses 课程列表(端点 44 `GET /admin/courses`)。
+//
+// 入参(query):`classify?`、`status?`(0~3)、`keyword?`、`page`/`pageSize`。
+// 出参(成功):`{list,total,page,pageSize,pages}`,行见 `courseRowView`
+// (含 `teacherIds` 与 `recordCount` 两个派生列)。
+// 出参(失败):1001/2002(status 越界或非数字、classify 非数字)/5002。
+//
+// 作用域:列表天然只含本机构(`Scoped`),跨机构课程**不出现**在结果里("看不见"而非 1004)。
+func (h *Handler) ListCourses(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	classifyID, err := parseIntQuery(c, "classify")
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	if classifyID < 0 {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("classify 不得为负")))
+	}
+	statusFilter, _, err := parseEnumQuery(c, "status", courseStatusFilter)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var statusPtr *int32
+	if statusFilter != nil {
+		v := int32(*statusFilter)
+		statusPtr = &v
+	}
+	page, pageSize := parsePage(c)
+	svc := h.servicesFor(sc)
+	rows, total, err := svc.course.ListCourseRows(c.Context(), service.CourseListQuery{
+		ClassifyID: classifyID,
+		Status:     statusPtr,
+		Keyword:    c.Query("keyword"),
+		Page:       page,
+		PageSize:   pageSize,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	views := make([]courseRowView, 0, len(rows))
+	for i := range rows {
+		row := &rows[i]
+		tids := row.TeacherIDs
+		if tids == nil {
+			tids = make([]string, 0)
+		}
+		views = append(views, courseRowView{
+			ID:            row.ID,
+			InstitutionID: row.InstitutionID,
+			Name:          row.Name,
+			Classify:      row.ClassifyID,
+			Status:        row.Status,
+			TeacherIDs:    tids,
+			RecordCount:   row.RecordCount,
+		})
+	}
+	return response.Page(c, views, page, pageSize, total)
+}
+
+// ---------------------------------------------------------------- 端点 45:新建课程
+
+// CreateCourse 新建课程模板(端点 45 `POST /admin/courses`)。
+//
+// 入参(body):见 `CreateCourseReq`(`name` 与 `duration` 必填)。
+// 出参(成功):`{id}`。
+// 出参(失败):1001/2001/2002(金额/课时/week/时间窗非法)/3001(分类不存在或越权)/1004/5002。
+//
+// 状态:新建恒落 `status=0`(未发布)——发布走状态机专用入口(AC-11 红线,本端点不开放 status)。
+func (h *Handler) CreateCourse(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req CreateCourseReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	start, end, err := parseActivityWindow(req.StartTime, req.EndTime)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	svc := h.servicesFor(sc)
+	row, err := svc.course.CreateCourse(c.Context(), service.CreateCourseInput{
+		SubjectID:     req.SubjectID,
+		GradeID:       req.GradeID,
+		Type:          req.Type,
+		ClassifyID:    req.ClassifyID,
+		Name:          strings.TrimSpace(req.Name),
+		Number:        req.Number,
+		Duration:      req.Duration,
+		Week:          req.Week,
+		StartTime:     start,
+		EndTime:       end,
+		Address:       optString(req.Address),
+		OriginalCost:  req.OriginalCost,
+		RealPrice:     req.RealPrice,
+		DiscountCash:  req.DiscountCash,
+		DiscountRatio: req.DiscountRatio,
+		MaxPeople:     req.MaxPeople,
+		Remark:        optString(req.Remark),
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, courseSavedResp{ID: row.ID})
+}
+
+// ---------------------------------------------------------------- 端点 45:更新课程
+
+// UpdateCourse 更新课程模板(端点 45 `PUT /admin/courses/:id`,**部分更新**语义)。
+//
+// 入参(body):见 `UpdateCourseReq`(nil = 不改;传了就校验)。
+// 出参(成功):`{id}`。
+// 出参(失败):1001/2001(没有任何待更新字段)/2002/3001/5002。
+//
+// 红线:**不改** `institution_id` / `campus_id` / `status`(各有专用入口,见 AC-11 注释)。
+func (h *Handler) UpdateCourse(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req UpdateCourseReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	in := service.UpdateCourseInput{
+		ID:            parseIDParam(c.Params("id")),
+		SubjectID:     req.SubjectID,
+		GradeID:       req.GradeID,
+		Type:          req.Type,
+		ClassifyID:    req.ClassifyID,
+		Name:          req.Name,
+		Number:        req.Number,
+		Duration:      req.Duration,
+		Week:          req.Week,
+		OriginalCost:  req.OriginalCost,
+		RealPrice:     req.RealPrice,
+		DiscountCash:  req.DiscountCash,
+		DiscountRatio: req.DiscountRatio,
+		MaxPeople:     req.MaxPeople,
+	}
+	if req.Address != nil {
+		in.Address = optString(*req.Address)
+	}
+	if req.Remark != nil {
+		in.Remark = optString(*req.Remark)
+	}
+	// 时间列:传了就必须能解析(空串 = 不修改,与"清空"区分——本接口不提供清空时间的能力)
+	if req.StartTime != nil {
+		at, has, perr := parseTimeParam(*req.StartTime, "startTime")
+		if perr != nil {
+			return response.Fail(c, perr)
+		}
+		if has {
+			in.StartTime = &at
+		}
+	}
+	if req.EndTime != nil {
+		at, has, perr := parseTimeParam(*req.EndTime, "endTime")
+		if perr != nil {
+			return response.Fail(c, perr)
+		}
+		if has {
+			in.EndTime = &at
+		}
+	}
+	svc := h.servicesFor(sc)
+	row, err := svc.course.UpdateCourse(c.Context(), in)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, courseSavedResp{ID: row.ID})
+}
+
+// ---------------------------------------------------------------- 端点 46:排班规则列表
+
+// ListCourseClasses 排班规则列表(端点 46 `GET /admin/courses/:id/classes`)。
+//
+// 入参(path):`:id` 课程 ID;(query):`page`/`pageSize`。
+// 出参(成功):`{list,total,page,pageSize,pages}`,行见 `classRowView`。
+// 出参(失败):1001/2002(id 非数字)/3001(课程不存在或不在作用域)/5002。
+//
+// ⚠ **分页在本 Handler 内做(内存切片)**:一门课程的排班规则数量**有界**(通常 ≤ 10 条,
+// 极端情况也就"每个 week 一条"共 7 条),AC-11 的 `ListClasses` 一次取全量更省一次往返。
+// 若将来出现"单课程上百条规则"的真实场景,应改为仓储层 `LIMIT/OFFSET`——
+// 已在交付报告登记为**已知取舍**(不是遗漏)。
+func (h *Handler) ListCourseClasses(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	page, pageSize := parsePage(c)
+	svc := h.servicesFor(sc)
+	rows, err := svc.course.ListClassRows(c.Context(), parseIDParam(c.Params("id")))
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	total := int64(len(rows))
+	views := make([]classRowView, 0, len(rows))
+	for _, row := range rows {
+		views = append(views, classRowView{
+			ClassID:   row.ClassID,
+			CourseID:  row.CourseID,
+			TeacherID: row.TeacherID,
+			Week:      row.Week,
+			Start:     row.Start,
+			End:       row.End,
+			CampusID:  row.CampusID,
+		})
+	}
+	views = paginateSlice(views, page, pageSize)
+	return response.Page(c, views, page, pageSize, total)
+}
+
+// ---------------------------------------------------------------- 端点 47:新建排班规则
+
+// CreateCourseClass 新建排班规则(端点 47 `POST /admin/courses/:id/classes`)。
+//
+// 入参(body):见 `CreateClassReq`(`start` 必填;`teacherId` 给了才做冲突判定)。
+// 出参(成功):`{classId}`。
+// 出参(失败):1001/2001/2002(时分格式/下课早于上课/week 非法)/
+// **3122(排班冲突)**/3001(课程不存在)/5002。
+//
+// 3122 的判据在 `CourseService.AssertNoClassConflict`(同老师 + 同校区 + 周规则有交集 + 时段重叠)。
+// 为什么先查再建(而不是靠唯一键):`t_courses_class` **没有** `(teacher, week, start)` 唯一键
+// (DDL 已锁定,见 model 注释),数据库不会替我们拦住重复排班 ⇒ 只能在应用层判。
+func (h *Handler) CreateCourseClass(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req CreateClassReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	coursesID := parseIDParam(c.Params("id"))
+	if coursesID <= 0 {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("课程 id 必须 > 0")))
+	}
+	in := service.CreateClassInput{
+		CoursesID:          coursesID,
+		ClassID:            req.ClassID,
+		TeacherID:          strings.TrimSpace(req.TeacherID),
+		Week:               req.Week,
+		StartTime:          req.Start,
+		EndTime:            req.End,
+		StandardLessonCost: req.StandardLessonCost,
+		LessonCost:         req.LessonCost,
+		DiscountCash:       req.DiscountCash,
+		Number:             req.Number,
+		Address:            optString(req.Address),
+		Remark:             optString(req.Remark),
+	}
+	svc := h.servicesFor(sc)
+	if err := svc.course.AssertNoClassConflict(c.Context(), in); err != nil {
+		return response.Fail(c, err) // 3122
+	}
+	row, err := svc.course.CreateClass(c.Context(), in)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, classCreatedResp{ClassID: row.ClassID})
+}
+
+// ---------------------------------------------------------------- 端点 48:课表实例
+
+// ListCourseRecords 课表实例列表(端点 48 `GET /admin/courses/:id/records`)。
+//
+// 入参(path):`:id` 课程 ID;(query):`teacherId?`、`status?`(0~2)、`from?`、`to?`、分页。
+// 出参(成功):`{list,total,page,pageSize,pages}`,行见 `recordRowView`(**无 `room`**,见 DTO 注释)。
+// 出参(失败):1001/2002(status 越界、时间格式非法)/5002。
+//
+// 时间窗**两端都含**(沿用 AC-11 `RecordFilter`):课表按"日期区间"看课,`to=2026-11-30`
+// 必须含 30 号当天的课(与契约 §1.3 的"`to` 右开"不同,属有意差异,已登记)。
+func (h *Handler) ListCourseRecords(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	statusFilter, _, err := parseEnumQuery(c, "status", recordStatusFilter)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var statusPtr *int32
+	if statusFilter != nil {
+		v := int32(*statusFilter)
+		statusPtr = &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)
+	}
+	var fromPtr, toPtr *time.Time
+	if hasFrom {
+		fromPtr = &from
+	}
+	if hasTo {
+		toPtr = &to
+	}
+	page, pageSize := parsePage(c)
+	svc := h.servicesFor(sc)
+	rows, total, err := svc.record.ListRecordsByQuery(c.Context(), service.RecordListQuery{
+		CoursesID: parseIDParam(c.Params("id")),
+		TeacherID: c.Query("teacherId"),
+		Status:    statusPtr,
+		From:      fromPtr,
+		To:        toPtr,
+		Page:      page,
+		PageSize:  pageSize,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	views := make([]recordRowView, 0, len(rows))
+	for i := range rows {
+		rec := &rows[i]
+		views = append(views, recordRowView{
+			RecordID:     rec.ID,
+			CourseID:     rec.CoursesID,
+			TeacherID:    stringValue(rec.TeacherID),
+			CampusID:     rec.CampusID,
+			StartTime:    rec.StartTime,
+			EndTime:      rec.EndTime,
+			Status:       rec.Status,
+			StudentCount: rec.StudentNumber,
+		})
+	}
+	return response.Page(c, views, page, pageSize, total)
+}
+
+// ---------------------------------------------------------------- 端点 49:生成课表
+
+// GenerateCourseRecords 按 week 规则生成排班实例(端点 49 `POST /admin/courses/:id/records/generate`)。
+//
+// 入参(body):见 `GenerateRecordsReq`(`days`/`from` 都可空)。
+// 出参(成功):`{generated,days,changed}`(幂等:同 courses+start_time 不重复插,第二次 generated=0)。
+// 出参(失败):1001/2002(from 格式非法)/3001/3140(该课程没有生效排班规则)/5002。
+func (h *Handler) GenerateCourseRecords(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req GenerateRecordsReq
+	if len(c.Body()) > 0 {
+		if !validateReq(c, &req) {
+			return nil
+		}
+	}
+	var fromPtr *time.Time
+	if strings.TrimSpace(req.From) != "" {
+		at, _, perr := parseTimeParam(req.From, "from")
+		if perr != nil {
+			return response.Fail(c, perr)
+		}
+		fromPtr = &at
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.course.GenerateRecords(c.Context(), service.GenerateRecordsInput{
+		CoursesID: parseIDParam(c.Params("id")),
+		Days:      req.Days,
+		From:      fromPtr,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	days := req.Days
+	if days <= 0 {
+		days = service.DefaultGenerateDays
+	}
+	if days > service.MaxGenerateDays {
+		days = service.MaxGenerateDays
+	}
+	return response.OK(c, generateRecordsResp{
+		Generated: res.Created,
+		Days:      days,
+		Changed:   res.Created > 0,
+	})
+}
+
+// ---------------------------------------------------------------- 端点 50:开始上课
+
+// StartCourseRecord 开始上课(端点 50 `POST /admin/courses/records/:recordId/start`)。
+//
+// 入参:无 body(契约 §2.4 端点 50 无请求字段)。
+// 出参(成功):`{recordId, status:1, courseId}`。
+// 出参(失败):1001/2001/3001(实例不存在或不在作用域)/**3121(状态非法:重复开始/已结束)**/5002。
+//
+// `actual_start_time` 只写一次(模型红线):重复调用 → 3121,不会写出第二个开课时间。
+func (h *Handler) StartCourseRecord(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	recordID := parseIDParam(c.Params("recordId"))
+	svc := h.servicesFor(sc)
+	res, err := svc.record.StartRecord(c.Context(), recordID)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	courseID := int64(0)
+	if rec, _, gerr := svc.record.Get(c.Context(), recordID); gerr == nil && rec != nil {
+		courseID = rec.CoursesID
+	}
+	return response.OK(c, startRecordResp{
+		RecordID: res.RecordID,
+		Status:   res.Status,
+		CourseID: courseID,
+	})
+}
+
+// ---------------------------------------------------------------- 端点 51:结束上课
+
+// FinishCourseRecord 结束上课(端点 51 `POST /admin/courses/records/:recordId/finish`)——**全项目最重的写端点**。
+//
+// 入参(body):见 `FinishRecordReq`(`items` 可空 = 按在读学生全勤)。
+// 出参(成功):`finishRecordResp`(`changed=false` = 幂等命中:此前已结束,本次**未写任何表**)。
+// 出参(失败):1001/2001/2002/**3001**(实例不存在)/**3121**(状态非法/并发竞争)/5002。
+//
+// **单事务四步联动**(AC-11 `RecordService.FinishRecord` 内,任一失败全部回滚):
+//
+//	① 打卡明细落库(t_courses_record_student:cost / give_cost / teacher_reward)
+//	② 扣课时(t_courses_student:finished_number+1 / remainder_number-1 / remainder_cost-cost)
+//	③ spend 累加(t_user_expense + t_user_expense_school:**只动 spend 统计列、不动 balance**)
+//	④ 分账入账(t_user_divide_into_log + total_income)
+//	⑤ 课酬(试算 → 按学生均分 → Σ = record.teacher_reward)
+//
+// 本 Handler **只做三件事**:解析入参、调服务、把结果装配成契约形状(溯源列的只读回读在
+// `RecordViewService.FinishView`,位于事务**之后**,因此装配失败不影响已提交的结账结果)。
+func (h *Handler) FinishCourseRecord(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req FinishRecordReq
+	if len(c.Body()) > 0 {
+		if !validateReq(c, &req) {
+			return nil
+		}
+	}
+	switch req.RewardType {
+	case 0, 1, 2:
+		// 合法:0 → 服务端按 1(正常结算)
+	default:
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("rewardType 只能是 1 或 2")))
+	}
+	items, ok := h.parseAttendanceItems(c, req.Items)
+	if !ok {
+		return nil
+	}
+	var endPtr *time.Time
+	if strings.TrimSpace(req.ActualEndTime) != "" {
+		at, _, perr := parseTimeParam(req.ActualEndTime, "actualEndTime")
+		if perr != nil {
+			return response.Fail(c, perr)
+		}
+		endPtr = &at
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.record.FinishRecord(c.Context(), service.FinishRecordInput{
+		RecordID:      parseIDParam(c.Params("recordId")),
+		ActualEndTime: endPtr,
+		Items:         items,
+		RewardType:    req.RewardType,
+		Remark:        optString(req.Remark),
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	view, err := svc.recordView.FinishView(c.Context(), res)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, toFinishRecordResp(view))
+}
+
+// ---------------------------------------------------------------- 端点 52:打卡
+
+// MarkCourseRecordAttendance 打卡/考勤(端点 52 `POST /admin/courses/records/:recordId/attendance`)。
+//
+// 入参(body):`list:[{userId,type,pay,cost,giveCost,remark?}]`(必填非空)。
+// 出参(成功):`{recordId, checked}`(`checked` = 该实例当前打卡人数)。
+// 出参(失败):1001/2001/2002/**3001**/**3121(课已结束,不能补打卡)**/3140(同一学生重复)/5002。
+//
+// 只写 cost/give_cost/pay/type,**不扣课时、不动余额、不算课酬**(那是端点 51 的联动);
+// 幂等:同一 (record, student) 重复打卡 → 覆盖该行,不产生第二行。
+func (h *Handler) MarkCourseRecordAttendance(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req MarkAttendanceReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	items, ok := h.parseAttendanceItems(c, req.List)
+	if !ok {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.record.MarkAttendance(c.Context(), service.MarkAttendanceInput{
+		RecordID: parseIDParam(c.Params("recordId")),
+		Items:    items,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, attendanceResp{RecordID: res.RecordID, Checked: res.StudentNumber})
+}
+
+// ---------------------------------------------------------------- 端点 53:报课学生
+
+// ListCourseStudents 报课学生列表(端点 53 `GET /admin/courses/:id/students`)。
+//
+// 入参(path):`:id` 课程 ID;(query):`status?`(1 正常 / 2 结束)、分页。
+// 出参(成功):`{list,total,page,pageSize,pages}`,行见 `studentRowView`。
+// 出参(失败):1001/2002/3001(课程不存在或不在作用域)/5002。
+//
+// ⚠ **欠费字段是 `remainderReceivable`(源列 `remainder_receivable`)**:`unpaid` 是 2018-05
+// 起停更的僵尸列(设计 §3.8-E4),**禁展示、禁下发**。
+func (h *Handler) ListCourseStudents(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	statusFilter, _, err := parseEnumQuery(c, "status", studentStatusFilter)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var statusPtr *int32
+	if statusFilter != nil {
+		v := int32(*statusFilter)
+		statusPtr = &v
+	}
+	page, pageSize := parsePage(c)
+	svc := h.servicesFor(sc)
+	rows, total, err := svc.course.ListStudentRows(c.Context(), service.StudentListQuery{
+		CoursesID: parseIDParam(c.Params("id")),
+		Status:    statusPtr,
+		Page:      page,
+		PageSize:  pageSize,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	// 姓名跨域只读(identity 域 `t_user.display_name`):一次批量取,**查不到 → null**。
+	names := h.displayNames(c.Context(), rows)
+	views := make([]studentRowView, 0, len(rows))
+	for i := range rows {
+		row := &rows[i]
+		var realName *string
+		if name, ok := names[row.UserID]; ok && name != "" {
+			v := name
+			realName = &v
+		}
+		views = append(views, studentRowView{
+			UserID:              row.UserID,
+			RealName:            realName,
+			Pay:                 row.Pay.String(),
+			Cost:                row.Cost.String(),
+			GiveCost:            row.GiveCost.String(),
+			RemainderReceivable: row.RemainderReceivable.String(),
+			Status:              row.Status,
+		})
+	}
+	return response.Page(c, views, page, pageSize, total)
+}
+
+// ---------------------------------------------------------------- 端点 54:报名
+
+// EnrollCourseStudent 报名 / 补报名(端点 54 `POST /admin/courses/:id/students`)。
+//
+// 入参(body):见 `EnrollReq`(`userId`/`pay`/`lessonCost` 必填)。
+// 出参(成功):`{enrolled:true, courseId, userId}`。
+// 出参(失败):1001/2001/2002/**3001**(课程不存在/越权)/**1004**(campusId 跨校区)/
+// **3143**(该学生已在本课程在读)/5002。
+//
+// 三条资金红线(见 `service.EnrollInput` 注释):不写流水、不动余额;
+// `confirm_discount_user_id` 写操作人(否则该行永远进不了欠费核销候选);重复报名拒绝。
+func (h *Handler) EnrollCourseStudent(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req EnrollReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	svc := h.servicesFor(sc)
+	row, err := svc.course.Enroll(c.Context(), service.EnrollInput{
+		CoursesID:           parseIDParam(c.Params("id")),
+		StudentID:           strings.TrimSpace(req.UserID),
+		ClassID:             req.ClassID,
+		CampusID:            req.CampusID,
+		Pay:                 req.Pay,
+		RemainderReceivable: req.RemainderReceivable,
+		LessonCost:          req.LessonCost,
+		Number:              req.Number,
+		GiveNumber:          req.GiveNumber,
+		DiscountCash:        req.DiscountCash,
+		GiveCost:            req.GiveCost,
+		RecommenderUserID:   req.RecommenderUserID,
+		Remark:              req.Remark,
+		OperatorID:          sc.UserID,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, enrollResp{Enrolled: true, CourseID: row.CoursesID, UserID: row.StudentID})
+}
+
+// ---------------------------------------------------------------- 端点 55:欠费列表
+
+// ListCourseArrears 欠费列表(端点 55 `GET /admin/courses/arrears`)。
+//
+// 入参(query):`studentId?`、`page`/`pageSize`。
+// 出参(成功):`{list,total,page,pageSize,pages}`,行见 `arrearsRowView`。
+// 出参(失败):1001/5002。
+//
+// **口径恒为 `remainder_receivable > 0`**(契约 §2.4 端点 55 注 / 设计 §3.8-E4)。
+// ⚠ 写成 `unpaid > 0` 不会报错、不会 500,只会**静默漏掉所有真实欠费行**
+// (`unpaid` 2018-05-17 起停止更新、真实数据几乎恒 0)——这是本端点唯一的致命错法。
+func (h *Handler) ListCourseArrears(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	page, pageSize := parsePage(c)
+	svc := h.servicesFor(sc)
+	rows, total, err := svc.course.ListArrearsRows(c.Context(), service.ArrearsListQuery{
+		StudentID: c.Query("studentId"),
+		Page:      page,
+		PageSize:  pageSize,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	views := make([]arrearsRowView, 0, len(rows))
+	for i := range rows {
+		row := &rows[i]
+		views = append(views, arrearsRowView{
+			StudentID:           row.StudentID,
+			CourseID:            row.CourseID,
+			RemainderReceivable: row.RemainderReceivable.String(),
+			Counteracted:        row.Counteracted,
+		})
+	}
+	return response.Page(c, views, page, pageSize, total)
+}
+
+// ---------------------------------------------------------------- 端点 56:欠费核销
+
+// CounteractCourseArrears 欠费核销(端点 56 `POST /admin/courses/arrears/counteract`,SP27 dump 763435)。
+//
+// 入参(body):`studentId,institutionId,campusId,money`(四个都必填)。
+// 出参(成功):`{counteracted, studentId, amount}`(`amount` = **本次实际核销额**,不是入参金额)。
+// 出参(失败):1001/2001/2002/**1004**(institutionId/campusId 与作用域不一致)/**3113**(核销冲突)/5002。
+//
+// 越权判据(**本端点唯一的作用域断言,必须在这里**):SP27 的 `institutionId`/`campusId` 是
+// **显式数据范围参数**(不是从 token 推断),因此非超管传了别的机构/校区必须在 Handler 拒掉——
+// 服务层按入参直接查库,数据层没有第二道防线。
+func (h *Handler) CounteractCourseArrears(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req CounteractArrearsReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	if err := assertScopeMatches(sc, req.InstitutionID, req.CampusID, "欠费核销"); err != nil {
+		return response.Fail(c, err)
+	}
+	amount, ok := parseMoney(c, req.Money, "money")
+	if !ok {
+		return nil
+	}
+	studentID := strings.TrimSpace(req.StudentID)
+	svc := h.servicesFor(sc)
+	res, err := svc.maintenance.CounteractArrears(c.Context(), service.CounteractArrearsInput{
+		StudentID:     studentID,
+		InstitutionID: req.InstitutionID,
+		CampusID:      req.CampusID,
+		Money:         amount,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, counteractResp{
+		Counteracted: res.Applied.GreaterThan(0),
+		StudentID:    studentID,
+		Amount:       res.Applied.String(),
+	})
+}
+
+// ---------------------------------------------------------------- 端点 57:学生课表
+
+// GetStudentSchedule 学生课表(端点 57 `GET /student/courses/schedule`)。
+//
+// 入参(query):`userId?`、`from?`、`to?`、分页。
+// 出参(成功):`{list,total,page,pageSize,pages}`,行见 `scheduleRowView`
+// (**字段名待移动端契约定稿**,契约 §3.4 C 行 57 原文如此)。
+// 出参(失败):1001/2001(既没传 userId 又没有登录用户)/2002(时间格式非法)/5002。
+//
+// 越权判据(**1004**):
+//
+//	学生 / 家长:`userId` 缺省 = 自己;传别人 → **1004**(家长与孩子的绑定关系不在本域,
+//	             一期不做"家长看孩子"——宁可拒,也不做无法验证的授权)
+//	老师:可以指定 `userId`(家访/回访场景),但仍受机构作用域约束(查不到 = 空列表)
+//	超管:不限
+func (h *Handler) GetStudentSchedule(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	userID := strings.TrimSpace(c.Query("userId"))
+	if userID == "" {
+		userID = strings.TrimSpace(sc.UserID)
+	}
+	if userID == "" {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("缺少 userId(学生课表必须指定学生)")))
+	}
+	switch sc.Role {
+	case RoleTeacher, RoleSuperAdmin:
+		// 允许指定他人
+	default:
+		if userID != strings.TrimSpace(sc.UserID) {
+			return response.Fail(c, infraerrors.WithCause(infraerrors.ErrUnscoped,
+				errString("只能查看本人的课表")))
+		}
+	}
+	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)
+	}
+	var fromPtr, toPtr *time.Time
+	if hasFrom {
+		fromPtr = &from
+	}
+	if hasTo {
+		toPtr = &to
+	}
+	page, pageSize := parsePage(c)
+	svc := h.servicesFor(sc)
+	rows, total, err := svc.course.ListScheduleRows(c.Context(), service.ScheduleQuery{
+		StudentID: userID,
+		From:      fromPtr,
+		To:        toPtr,
+		Page:      page,
+		PageSize:  pageSize,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	views := make([]scheduleRowView, 0, len(rows))
+	for i := range rows {
+		row := &rows[i]
+		views = append(views, scheduleRowView{
+			RecordID:   row.RecordID,
+			CourseID:   row.CourseID,
+			CourseName: row.CourseName,
+			TeacherID:  row.TeacherID,
+			CampusID:   row.CampusID,
+			StartTime:  row.StartTime,
+			EndTime:    row.EndTime,
+			Status:     row.Status,
+			Cost:       row.Cost.String(),
+			GiveCost:   row.GiveCost.String(),
+			Pay:        row.Pay,
+			Checked:    row.Checked,
+		})
+	}
+	return response.Page(c, views, page, pageSize, total)
+}
+
+// ---------------------------------------------------------------- 本地助手(只做解析/装配,不含业务判断)
+
+// parseAttendanceItems 把请求里的打卡明细转成服务入参(金额字符串 → `money.Amount`)。
+//
+// 入参:c;raw 请求明细。出参:(服务入参, 是否成功);失败时 **body code 已写出**(2002)。
+//
+// 只做"字符串 → 定点金额"的解析;`type` 枚举、重复学生、负金额等**业务校验留给 Service**
+// (唯一实现点,端点 51/52 共用同一套规则,避免两处判据漂移)。
+func (h *Handler) parseAttendanceItems(c *fiber.Ctx, raw []AttendanceItemReq) ([]service.AttendanceItem, bool) {
+	out := make([]service.AttendanceItem, 0, len(raw))
+	for i := range raw {
+		item := &raw[i]
+		var costPtr, givePtr *money.Amount
+		if strings.TrimSpace(item.Cost) != "" {
+			amount, ok := parseMoney(c, item.Cost, "items.cost")
+			if !ok {
+				return nil, false
+			}
+			costPtr = &amount
+		}
+		if strings.TrimSpace(item.GiveCost) != "" {
+			amount, ok := parseMoney(c, item.GiveCost, "items.giveCost")
+			if !ok {
+				return nil, false
+			}
+			givePtr = &amount
+		}
+		out = append(out, service.AttendanceItem{
+			StudentID: strings.TrimSpace(item.UserID),
+			Type:      item.Type,
+			Pay:       item.Pay,
+			Cost:      costPtr,
+			GiveCost:  givePtr,
+			Remark:    optString(item.Remark),
+		})
+	}
+	return out, true
+}
+
+// toFinishRecordResp 把服务视图转成端点 51 的响应(金额一律两位小数字符串)。
+//
+// 入参:view 服务层装配结果。出参:finishRecordResp(各数组空时为空切片,非 null)。
+func toFinishRecordResp(view *service.RecordFinishView) finishRecordResp {
+	divide := make([]divideGrantView, 0, len(view.Divide))
+	for i := range view.Divide {
+		d := &view.Divide[i]
+		divide = append(divide, divideGrantView{
+			UserID: d.UserID,
+			Type:   d.Type,
+			Amount: d.Amount.String(),
+		})
+	}
+	students := make([]perStudentView, 0, len(view.PerStudent))
+	for i := range view.PerStudent {
+		s := &view.PerStudent[i]
+		students = append(students, perStudentView{
+			UserID:       s.StudentID,
+			Type:         s.Type,
+			Pay:          s.Paid,
+			Cost:         s.Cost.String(),
+			GiveCost:     s.GiveCost.String(),
+			Counteracted: s.DivideGranted,
+		})
+	}
+	return finishRecordResp{
+		RecordID:   view.RecordID,
+		CourseID:   view.CourseID,
+		Status:     view.Status,
+		Changed:    view.Changed,
+		TotalSpend: view.TotalSpend.String(),
+		Divide:     divide,
+		Payroll: payrollTraceView{
+			DetailID: view.Payroll.DetailID,
+			Reward:   view.Payroll.Reward.String(),
+		},
+		PerStudent: students,
+	}
+}
+
+// assertScopeMatches 断言显式传入的机构/校区与当前作用域一致(越界 → **1004**)。
+//
+// 入参:sc 作用域;institutionID/campusID 入参值(>0,由 validate tag 保证);op 操作名(错误文案)。
+// 出参:nil 通过;`infraerrors.ErrUnscoped`(1004) 越界。
+//
+// 规则:
+//
+//	超管                          → 放行(平台视角,可代任意机构执行)
+//	机构不一致                    → 1004
+//	作用域校区 >0 且校区不一致     → 1004(校区角色只能操作本校区)
+//	作用域校区 = 0(机构级角色)   → 校区不限(机构内任意校区)
+func assertScopeMatches(sc *appscope.Scope, institutionID, campusID int64, op string) error {
+	if sc == nil {
+		return infraerrors.ErrUnauth
+	}
+	if sc.SuperAdmin {
+		return nil
+	}
+	if institutionID != sc.InstitutionID {
+		return infraerrors.WithCause(infraerrors.ErrUnscoped,
+			errString(op+":institutionId 与当前作用域机构不一致"))
+	}
+	if sc.CampusID > 0 && campusID != sc.CampusID {
+		return infraerrors.WithCause(infraerrors.ErrUnscoped,
+			errString(op+":campusId 与当前作用域校区不一致"))
+	}
+	return nil
+}
+
+// displayNames 批量解析学生显示名(端点 53 的 `realName`)。
+//
+// 入参:ctx;rows 报课学生行(取 `UserID` 去重后批量查)。出参:map[ULID]显示名(查不到的不在 map)。
+//
+// 三条降级策略(都不影响其它字段):
+//   - 端口未注入(`h.users == nil`,如单测/双跑脚本)→ **返回空 map**,`realName` 全部 null;
+//   - 端口报错 → **不冒泡**(列表本身是只读展示,为"少一列姓名"让整页 5002 不划算):
+//     记 warn 级日志(含条数,不含姓名内容——姓名属个人信息,不进日志);
+//   - 查不到某个 userID → 该行 `realName=null`(**不填 `""`**,前端要能区分两种"没名字")。
+func (h *Handler) displayNames(ctx context.Context, rows []service.StudentRow) map[string]string {
+	if h.users == nil || len(rows) == 0 {
+		return map[string]string{}
+	}
+	ids := make([]string, 0, len(rows))
+	for i := range rows {
+		ids = append(ids, rows[i].UserID)
+	}
+	names, err := h.users.DisplayNames(ctx, ids)
+	if err != nil {
+		if h.log != nil {
+			h.log.Warn("academic: 学生姓名解析失败(realName 降级为 null)",
+				zap.Int("count", len(ids)), zap.Error(err))
+		}
+		return map[string]string{}
+	}
+	if names == nil {
+		return map[string]string{}
+	}
+	return names
+}
+
+// optString 空串 → nil(落库 NULL),非空 → 去空白后的指针。
+//
+// 入参:raw 原始字符串。出参:*string。
+func optString(raw string) *string {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" {
+		return nil
+	}
+	return &trimmed
+}
+
+// stringValue 取字符串指针的值(nil → 空串)。
+//
+// 入参:s *string。出参:去空白后的字符串。
+func stringValue(s *string) string {
+	if s == nil {
+		return ""
+	}
+	return strings.TrimSpace(*s)
+}
+
+// paginateSlice 对内存切片做分页(端点 46 专用,见该 Handler 的"已知取舍"注释)。
+//
+// 入参:rows 全量切片;page/pageSize 已由 `parsePage` 钳制。
+// 出参:当前页的切片(**非 nil**;越界 → 空切片)。
+func paginateSlice[T any](rows []T, page, pageSize int) []T {
+	if page < 1 {
+		page = 1
+	}
+	if pageSize < 1 {
+		pageSize = 10 // repository.DefaultPageSize 同值(此处不引 repository:handler 对 repository 零 import)
+	}
+	start := (page - 1) * pageSize
+	if start >= len(rows) {
+		return make([]T, 0)
+	}
+	end := start + pageSize
+	if end > len(rows) {
+		end = len(rows)
+	}
+	return rows[start:end]
+}

+ 1706 - 0
internal/academic/handler/course_handler_test.go

@@ -0,0 +1,1706 @@
+// 课程 / 排班 / 打卡端点单测(AC-13c:端点 44~57)。
+//
+// 断言纪律(与前几批一致):
+//   - 只断 body 契约(HTTP 恒 200 + body.code),不断 HTTP 状态码;
+//   - **每个端点至少一条"写错会变红"的断言**,且尽量落在**数据库**上而不是只断响应体
+//     (响应体可以"改一个字段名"骗过自己,库里的列不会);
+//   - 错误码只比对 `apperr` / `infraerrors` 的常量,**不硬编码数字**。
+//
+// 覆盖结构:
+//
+//	44 课程列表          teacherIds 来自 t_courses_class、recordCount 来自 t_courses_record;
+//	                    跨机构课程**不出现**(Scoped)
+//	45 新建/更新课程     新建恒 status=0(未发布);PUT 是部分更新(未传字段不变)
+//	46 排班规则          status=2(已结束)的规则**必须出现**(onlyActive 会漏);week 原值
+//	47 新建规则          同老师+同周+时段重叠 → **3122**;不同周 → 200
+//	49 生成课表          幂等:第二次 generated=0 / changed=false
+//	50 开始上课          actual_start_time 落库;重复开始 → **3121**
+//	51 结束上课          跨 5 张表断言(record_student / expense / expense_school / divide_log / payroll)
+//	                    + 分账失败整体回滚(变异)
+//	52 打卡              已结束的课 → **3121**;未结束 → 写 cost 并回显 checked
+//	53 报课学生          欠费字段取 remainder_receivable(造 unpaid=999 的陷阱行)
+//	54 报名              快照恒等式 receivable = net_receipts + remainder_receivable;
+//	                    confirm_discount_user_id 必须写(否则永远进不了核销候选);重复 → 3140
+//	55 欠费列表          **remainder_receivable > 0**(造 unpaid=999/remainder=0 的陷阱行必须不出现)
+//	56 欠费核销          net_receipts 增、remainder_receivable 减(同一条 UPDATE);跨机构 → 1004
+//	57 学生课表          按学生过滤(另一个学生的课不出现);跨用户查 → 1004
+package handler
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+	"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"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// ---------------------------------------------------------------- 测试基础设施
+
+// 测试用常量(ULID 形态的假 ID,长度 26 与真实约束一致)。
+const (
+	courseTestStudent     = "01HSTUDENT00000000000001"
+	courseTestOther       = "01HSTUDENT00000000000002"
+	courseTestTeacher     = "01HTEACHER00000000000001"
+	courseTestTeacher2    = "01HTEACHER00000000000002"
+	courseTestRecommender = "01HRECOMMENDER00000000001"
+	courseTestUpline      = "01HUPLINE00000000000001"
+	courseTestOperator    = "01HOPERATOR0000000000001"
+)
+
+// newCourseTestDB 课程域测试库:资金四表(复用)+ 课程 6 表 + 课酬 3 表 + 分账 2 表。
+//
+// 与 `newHandlerTestDB` 同构地逐表 AutoMigrate 并删除**非唯一索引**(SQLite 索引名库级唯一,
+// 而 academic model 多表共用 `idx_inst_campus` 等名字)。
+func newCourseTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	base := newHandlerTestDB(t) // 资金 4 表 + 活动 6 表 + t_campus(机构 1 / 校区 10)
+	extra := []any{
+		&model.Courses{}, &model.CoursesClassify{}, &model.CoursesClass{},
+		&model.CoursesStudent{}, &model.CoursesRecord{}, &model.CoursesRecordStudent{},
+		&model.CoursePayment{}, &model.CoursePaymentDetail{}, &model.TeacherInformation{},
+		&model.UserDivideInto{}, &model.UserDivideIntoLog{},
+	}
+	// 先把 `newHandlerTestDB` 留下的**唯一索引**清干净:`uk_biz_no` 在 t_user_balance_request
+	// (资金域)与 3 张课程表上同名,SQLite 的索引名是**库级唯一**,而 MySQL 是**表级**
+	// ⇒ 不清会直接建表失败。
+	// ⚠ 测试库因此**放弃唯一约束**(含 `uk_biz_no` / `uk_user_inst_campus` 等)——
+	// 这只是**规避 sqlite 索引名冲突**的手段:**生产 MySQL 的唯一约束照常生效**,
+	// 幂等/去重行为不依赖测试库(那由 `INSERT IGNORE` + 唯一键在真库上保证)。
+	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 {
+			// ⚠ **唯一索引也一并删除**(与 `newHandlerTestDB` 不同):academic model 里
+			// `uk_biz_no` 同时出现在 t_courses_student / t_courses_record / t_courses_record_student
+			// 三张表上,而 SQLite 的索引名是**库级唯一**(MySQL 是表级)⇒ 第二张表建索引必报
+			// "index uk_biz_no already exists"。测试库因此放弃唯一约束(生产是 MySQL,不受影响;
+			// 服务层测试 `migrateLearningTables` 同款处理)。
+			if err := base.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; err != nil {
+				t.Fatalf("删索引(%s) 失败: %v", row.Name, err)
+			}
+		}
+	}
+	return base
+}
+
+// newCourseTestApp 挂 15 条课程域路由(**不做角色白名单**:那是 e2e 的职责,见 route_e2e_test.go)。
+//
+// 作用域由 `X-Test-Scope` 头选择:`none` = 不注入(1001)、`super` = 超管、`student` = 学生、
+// `teacher` = 老师、缺省 = 财务(机构 1 / 校区 10)。
+func newCourseTestApp(t *testing.T, db *gorm.DB) *fiber.App {
+	t.Helper()
+	return newCourseTestAppWithUsers(t, db, nil)
+}
+
+// newCourseTestAppWithUsers 同 `newCourseTestApp`,但可注入用户姓名端口(D4 用)。
+//
+// 入参:users `port.UserNameReader`(nil = 未注入,端点 53 的 realName 应全部为 null)。
+func newCourseTestAppWithUsers(t *testing.T, db *gorm.DB, users port.UserNameReader) *fiber.App {
+	t.Helper()
+	h, err := New(Config{DB: db, Users: users})
+	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 "super":
+			appscope.Set(c, testSuperScope())
+		case "student":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 10,
+				UserID: courseTestStudent, Role: RoleStudent,
+			})
+		case "teacher":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 10,
+				UserID: courseTestTeacher, Role: RoleTeacher,
+			})
+		default:
+			appscope.Set(c, testScope())
+		}
+		return c.Next()
+	})
+	// 注册顺序与 route.go 一致(静态段先于 `:id`)
+	app.Get(PathCourses, h.ListCourses)
+	app.Post(PathCourses, h.CreateCourse)
+	app.Get(PathCourseArrears, h.ListCourseArrears)
+	app.Post(PathCourseArrearsCounteract, h.CounteractCourseArrears)
+	app.Post(PathCourseRecordStart, h.StartCourseRecord)
+	app.Post(PathCourseRecordFinish, h.FinishCourseRecord)
+	app.Post(PathCourseRecordAttendance, h.MarkCourseRecordAttendance)
+	app.Put(PathCourseDetail, h.UpdateCourse)
+	app.Get(PathCourseClasses, h.ListCourseClasses)
+	app.Post(PathCourseClasses, h.CreateCourseClass)
+	app.Get(PathCourseRecords, h.ListCourseRecords)
+	app.Post(PathCourseRecordsGenerate, h.GenerateCourseRecords)
+	app.Get(PathCourseStudents, h.ListCourseStudents)
+	app.Post(PathCourseStudents, h.EnrollCourseStudent)
+	app.Get(PathStudentSchedule, h.GetStudentSchedule)
+	return app
+}
+
+// seedCourse 建一门课程(机构 1 / 指定校区;classify 默认 0)。
+func seedCourse(t *testing.T, db *gorm.DB, name string, campusID int64) *model.Courses {
+	t.Helper()
+	row := &model.Courses{
+		InstitutionID: 1, CampusID: campusID, Name: name,
+		Status: model.CoursesStatusUnpublished, Number: 10, Duration: "1.0", Week: "-1",
+		OriginalCost: "100.00", RealPrice: "100.00", DiscountCash: "0.00",
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("建课程失败: %v", err)
+	}
+	return row
+}
+
+// seedClassRule 建一条排班规则。
+func seedClassRule(t *testing.T, db *gorm.DB, coursesID, classID int64, teacher, week string, status int32, startHM, endHM string) *model.CoursesClass {
+	t.Helper()
+	start := mustHM(t, startHM)
+	var end *time.Time
+	if endHM != "" {
+		end = mustHM(t, endHM)
+	}
+	row := &model.CoursesClass{
+		Status: status, CoursesID: coursesID, ClassID: classID,
+		TeacherID: &teacher, InstitutionID: 1, CampusID: 10,
+		StandardLessonCost: "100.00", Number: 10, LessonCost: "100.00",
+		DiscountCash: "0.00", FinishedNumber: 0, Confirm: model.CoursesClassConfirmNo,
+		Week: week, StartTime: start, EndTime: end,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("建排班规则失败: %v", err)
+	}
+	return row
+}
+
+// mustHM 把 "HH:MM" 转成时分列(1970-01-01 + 时分,UTC),与服务层口径一致。
+func mustHM(t *testing.T, hm string) *time.Time {
+	t.Helper()
+	parts := strings.Split(hm, ":")
+	hour := mustAtoi(t, parts[0])
+	minute := 0
+	if len(parts) > 1 {
+		minute = mustAtoi(t, parts[1])
+	}
+	at := time.Date(1970, 1, 1, hour, minute, 0, 0, time.UTC)
+	return &at
+}
+
+// mustAtoi 测试用 Atoi(失败即 fatal)。
+func mustAtoi(t *testing.T, raw string) int {
+	t.Helper()
+	value := 0
+	for _, ch := range strings.TrimSpace(raw) {
+		if ch < '0' || ch > '9' {
+			t.Fatalf("非数字: %q", raw)
+		}
+		value = value*10 + int(ch-'0')
+	}
+	return value
+}
+
+// seedEnrollment 建报课行(**故意把 unpaid 写成垃圾值**,用于证明"欠费口径不用 unpaid")。
+func seedEnrollment(t *testing.T, db *gorm.DB, coursesID int64, studentID, bizNo string,
+	lessonCost, receivable, netReceipts, remainderReceivable, unpaid string) *model.CoursesStudent {
+	t.Helper()
+	confirm := courseTestOperator
+	row := &model.CoursesStudent{
+		BizNo: bizNo, CoursesID: coursesID, InstitutionID: 1, CampusID: 10,
+		StudentID: studentID, Type: model.CoursesStudentTypeNormal,
+		Status:     model.CoursesStudentStatusNormal,
+		Receivable: receivable, NetReceipts: netReceipts, Unpaid: unpaid,
+		LessonCost: lessonCost, Number: 10, FinishedNumber: 0,
+		DiscountCash: "0.00", GiveNumber: 0, GiveCost: "0.00",
+		RemainderNumber: 10, RemainderCost: netReceipts,
+		RemainderReceivable: remainderReceivable, BadDebt: "0.00",
+		ConfirmDiscountUserID: &confirm,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("建报课行失败: %v", err)
+	}
+	return row
+}
+
+// seedRecord 建排班实例。
+func seedRecord(t *testing.T, db *gorm.DB, coursesID, classID int64, bizNo string, status int32, at time.Time) *model.CoursesRecord {
+	t.Helper()
+	teacher := courseTestTeacher
+	end := at.Add(time.Hour)
+	row := &model.CoursesRecord{
+		BizNo: bizNo, CoursesID: coursesID, ClassID: classID,
+		TeacherID: &teacher, InstitutionID: 1, CampusID: 10, Status: status,
+		StartTime: &at, EndTime: &end,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("建排班实例失败: %v", err)
+	}
+	return row
+}
+
+// requireMoney 比较"元字符串"金额(**不比较字面量**)。
+//
+// 为什么不直接比字符串:SQLite 没有 DECIMAL 类型,`"100.00"` 读回来是 `"100"`;
+// 生产 MySQL 读回来是 `"100.00"`。断言要跨两种方言成立,就必须比**分值**而不是比字面量。
+func requireMoney(t *testing.T, label, got, want string) {
+	t.Helper()
+	if strings.TrimSpace(got) == "" {
+		t.Fatalf("%s 为空(读不到列),期望 %s", label, want)
+	}
+	g, gerr := money.Parse(got)
+	if gerr != nil {
+		t.Fatalf("%s = %q 不是合法金额: %v", label, got, gerr)
+	}
+	w, werr := money.Parse(want)
+	if werr != nil {
+		t.Fatalf("期望值 %q 不是合法金额: %v", want, werr)
+	}
+	if g.Cents() != w.Cents() {
+		t.Fatalf("%s = %q(%d 分),期望 %q(%d 分)", label, got, g.Cents(), want, w.Cents())
+	}
+}
+
+// jsonMap 把响应 data 解成 map(断言"某个键必须不存在"时用)。
+func jsonMap(t *testing.T, raw json.RawMessage) map[string]any {
+	t.Helper()
+	out := map[string]any{}
+	if len(raw) == 0 {
+		return out
+	}
+	if err := json.Unmarshal(raw, &out); err != nil {
+		t.Fatalf("响应 data 不是 JSON 对象: %v(原文 %s)", err, string(raw))
+	}
+	return out
+}
+
+// pageList 取分页响应的 list 字段([]any)。
+func pageList(t *testing.T, raw json.RawMessage) []any {
+	t.Helper()
+	got := jsonMap(t, raw)
+	items, ok := got["list"].([]any)
+	if !ok {
+		t.Fatalf("响应缺少 list 数组:%s", string(raw))
+	}
+	return items
+}
+
+// ---------------------------------------------------------------- 端点 44:课程列表
+
+// TestListCoursesAggregatesTeacherIDsAndRecordCount 端点 44 的两个派生列必须来自**另外两张表**。
+//
+// 变红条件:
+//   - `teacherIds` 恒空(漏 join `t_courses_class`)→ 断言 1 红;
+//   - `recordCount` 恒 0(漏 count `t_courses_record`)→ 断言 2 红;
+//   - 机构过滤失效(把机构 2 的课程也返回)→ 断言 3 红。
+func TestListCoursesAggregatesTeacherIDsAndRecordCount(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "数学A", 10)
+	// 两条规则、三个不同老师(含一条重复老师)→ teacherIds 必须去重且升序
+	seedClassRule(t, db, course.ID, 1001, courseTestTeacher2, "1", model.CoursesClassStatusOngoing, "09:00", "10:00")
+	seedClassRule(t, db, course.ID, 1001, courseTestTeacher, "3", model.CoursesClassStatusEnded, "11:00", "12:00")
+	seedClassRule(t, db, course.ID, 1002, courseTestTeacher2, "5", model.CoursesClassStatusOngoing, "13:00", "14:00")
+	for i := 0; i < 3; i++ {
+		seedRecord(t, db, course.ID, 1001, "REC"+string(rune('A'+i)), model.CoursesRecordStatusNotStarted,
+			time.Date(2026, 11, 10+i, 9, 0, 0, 0, time.Local))
+	}
+	// 另一机构的课程(不得出现在机构 1 的列表里)
+	other := &model.Courses{
+		InstitutionID: 2, CampusID: 20, Name: "别家课程", Status: model.CoursesStatusPublished,
+		Duration: "1.0", Week: "-1", OriginalCost: "0.00", RealPrice: "0.00", DiscountCash: "0.00",
+	}
+	if err := db.Create(other).Error; err != nil {
+		t.Fatalf("建跨机构课程失败: %v", err)
+	}
+
+	app := newCourseTestApp(t, db)
+	code, msg, data := doJSON(t, app, fiber.MethodGet, PathCourses, nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("GET %s = %d(%s),期望 200", PathCourses, code, msg)
+	}
+	payload := jsonMap(t, data)
+	items, _ := payload["list"].([]any)
+	if len(items) != 1 {
+		t.Fatalf("列表行数 = %d,期望 1(跨机构课程必须被 Scoped 过滤):%s", len(items), string(data))
+	}
+	row, _ := items[0].(map[string]any)
+
+	// 断言 1:teacherIds 去重 + 升序,且**来自 t_courses_class**
+	teacherIDs, _ := row["teacherIds"].([]any)
+	if len(teacherIDs) != 2 {
+		t.Fatalf("teacherIds = %v,期望 2 个(去重后 %s/%s)—— 空数组说明没查 t_courses_class",
+			row["teacherIds"], courseTestTeacher, courseTestTeacher2)
+	}
+	if teacherIDs[0] != courseTestTeacher || teacherIDs[1] != courseTestTeacher2 {
+		t.Fatalf("teacherIds = %v,期望升序 [%s %s]", teacherIDs, courseTestTeacher, courseTestTeacher2)
+	}
+
+	// 断言 2:recordCount 来自 t_courses_record 计数
+	if got, _ := row["recordCount"].(float64); got != 3 {
+		t.Fatalf("recordCount = %v,期望 3(t_courses_record 三行);0 说明没做计数聚合", row["recordCount"])
+	}
+
+	// 断言 3:作用域键(机构/校区)必须下发
+	if got, _ := row["institutionId"].(float64); got != 1 {
+		t.Fatalf("institutionId = %v,期望 1", row["institutionId"])
+	}
+}
+
+// ---------------------------------------------------------------- 端点 45:课程 CRUD
+
+// TestCreateCourseIsUnpublished 端点 45 新建:恒落 status=0(未发布),作用域列取 token 而非 body。
+//
+// 变红条件:把 body 的 status/institutionId 透传进库(或忘写作用域列)→ 库里 status/institution_id 不符。
+func TestCreateCourseIsUnpublished(t *testing.T) {
+	db := newCourseTestDB(t)
+	app := newCourseTestApp(t, db)
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathCourses, map[string]any{
+		"name": "英语B", "duration": "1.5", "number": 8, "realPrice": "200.00",
+		"week": "1,3,5", "status": 3, "institutionId": 99, // status/institutionId 必须被忽略
+	}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("POST %s = %d(%s),期望 200", PathCourses, code, msg)
+	}
+	id, _ := jsonMap(t, data)["id"].(float64)
+	if id <= 0 {
+		t.Fatalf("响应 id = %v,期望 >0", jsonMap(t, data)["id"])
+	}
+	var row model.Courses
+	if err := db.First(&row, int64(id)).Error; err != nil {
+		t.Fatalf("回读课程失败: %v", err)
+	}
+	// status 断言拆到 TestCreateCourseStatusIsUnpublished(D11 的回归门禁,含缺陷成因与修法)。
+	if row.InstitutionID != 1 {
+		t.Fatalf("institution_id = %d,期望 1(取作用域,不信 body 的 99)", row.InstitutionID)
+	}
+	if row.CampusID != 10 {
+		t.Fatalf("campus_id = %d,期望 10(作用域校区)", row.CampusID)
+	}
+	if row.Week != "1,3,5" {
+		t.Fatalf("week = %q,期望原值 1,3,5", row.Week)
+	}
+
+	// 2001:缺 name
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathCourses, map[string]any{
+		"duration": "1.0",
+	}, "X-Test-Token", "t"); code != infraerrors.ErrBadParam.Code {
+		t.Fatalf("缺 name = %d(%s),期望 2001", code, msg)
+	}
+}
+
+// TestCreateCourseStatusIsUnpublished **新建课程必须落 status=0(未发布)**——D11 的回归门禁。
+//
+// 缺陷原貌(2026-11-09 实测):`CourseService.CreateCourse` 显式写 `Status=0`,但
+// `model.Courses.Status` 的 gorm tag 带 `default:1` ⇒ GORM 对"有 default tag 且值为零"的列
+// **跳过该列**、由 DB 默认值填充 ⇒ 落库 `status=1`("新建即发布":课程直接出现在可报名列表)。
+// MySQL 与 SQLite 走同一段 GORM 回调,生产同样成立。
+//
+// 修复(team-lead 裁决):**去掉 `model.Courses.Status` 的 `default:1`(DDL 不动)**,
+// 而不是 `tx.Select("*").Create(row)` —— 后者会把 `created_at`/`updated_at` 等零值一并写入,
+// 正是 `a1971b7`(审计时间零值落库)修过的反向坑。
+//
+// 本用例是**回归门禁**:把 `default:1` 加回 tag,本用例必红(实测见交付报告)。
+func TestCreateCourseStatusIsUnpublished(t *testing.T) {
+	db := newCourseTestDB(t)
+	app := newCourseTestApp(t, db)
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathCourses, map[string]any{
+		"name": "状态测试课", "duration": "1.0",
+	}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("POST %s = %d(%s),期望 200", PathCourses, code, msg)
+	}
+	id, _ := jsonMap(t, data)["id"].(float64)
+	var row model.Courses
+	if err := db.First(&row, int64(id)).Error; err != nil {
+		t.Fatalf("回读课程失败: %v", err)
+	}
+	if row.Status != model.CoursesStatusUnpublished {
+		t.Fatalf("新建课程 status = %d,期望 0(未发布)—— status 只能走状态机入口", row.Status)
+	}
+}
+
+// TestUpdateCourseIsPartial 端点 45 更新:只改传入列(未传的 name/number 必须保持原值)。
+//
+// 变红条件:写成"整体替换"(未传字段被清空/归零)→ name 或 number 变空/0。
+func TestUpdateCourseIsPartial(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "物理C", 10)
+	course.Number = 12
+	if err := db.Save(course).Error; err != nil {
+		t.Fatalf("更新种子课程失败: %v", err)
+	}
+	app := newCourseTestApp(t, db)
+	code, msg, _ := doJSON(t, app, fiber.MethodPut, "/admin/courses/"+itoa(course.ID), map[string]any{
+		"remark": "只改备注",
+	}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("PUT 课程 = %d(%s),期望 200", code, msg)
+	}
+	var row model.Courses
+	if err := db.First(&row, course.ID).Error; err != nil {
+		t.Fatalf("回读课程失败: %v", err)
+	}
+	if row.Name != "物理C" {
+		t.Fatalf("name = %q,期望保持 物理C —— 部分更新被写成了整体替换", row.Name)
+	}
+	if row.Number != 12 {
+		t.Fatalf("number = %d,期望保持 12 —— 未传字段被清零", row.Number)
+	}
+	if row.Remark == nil || *row.Remark != "只改备注" {
+		t.Fatalf("remark = %v,期望 只改备注", row.Remark)
+	}
+}
+
+// itoa 测试用整数转字符串(避免引入 strconv 之外的依赖)。
+func itoa(v int64) string {
+	if v == 0 {
+		return "0"
+	}
+	digits := make([]byte, 0, 20)
+	for v > 0 {
+		digits = append([]byte{byte('0' + v%10)}, digits...)
+		v /= 10
+	}
+	return string(digits)
+}
+
+// ---------------------------------------------------------------- 端点 46:排班规则
+
+// TestListCourseClassesKeepsEndedRules 端点 46 必须列出**全部**规则(含 status=2 已结束)。
+//
+// 为什么这条重要:AC-11 的 `ListClasses(onlyActive=true)` 是"排班生成"专用过滤,
+// 若 handler 顺手复用它,已结束的规则会静默消失(前端看到的是"这个班没排过课")。
+// 变红条件:把 onlyActive 传成 true → total 从 2 变 1。
+func TestListCourseClassesKeepsEndedRules(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "化学D", 10)
+	seedClassRule(t, db, course.ID, 2001, courseTestTeacher, "1,3,5", model.CoursesClassStatusOngoing, "09:00", "10:00")
+	seedClassRule(t, db, course.ID, 2002, courseTestTeacher2, "2", model.CoursesClassStatusEnded, "14:00", "15:00")
+	app := newCourseTestApp(t, db)
+	code, msg, data := doJSON(t, app, fiber.MethodGet, "/admin/courses/"+itoa(course.ID)+"/classes", nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("GET classes = %d(%s),期望 200", code, msg)
+	}
+	total, _ := jsonMap(t, data)["total"].(float64)
+	if total != 2 {
+		t.Fatalf("total = %v,期望 2(含 status=2 的规则)—— 1 说明误用了 onlyActive 过滤", total)
+	}
+	items := pageList(t, data)
+	first, _ := items[0].(map[string]any)
+	// week 必须是**原值字符串**(契约写 number 1~7,但该列是多值字符串,见 classRowView 注释)
+	if week, _ := first["week"].(string); week != "1,3,5" {
+		t.Fatalf("week = %v(%T),期望原值字符串 \"1,3,5\"", first["week"], first["week"])
+	}
+	if _, hasRoom := first["room"]; hasRoom {
+		t.Fatalf("响应出现了 room 字段(%s):t_courses_class 没有 room 列,不得下发", string(data))
+	}
+}
+
+// ---------------------------------------------------------------- 端点 47:新建排班规则
+
+// TestCreateCourseClassConflictReturns3122 端点 47:同老师 + 同周 + 时段重叠 → 3122。
+//
+// 变红条件:去掉冲突判定 → 冲突请求返回 200(并且库里多出一条重叠规则)。
+func TestCreateCourseClassConflictReturns3122(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "生物E", 10)
+	seedClassRule(t, db, course.ID, 3001, courseTestTeacher, "1", model.CoursesClassStatusOngoing, "09:00", "10:00")
+	app := newCourseTestApp(t, db)
+
+	base := map[string]any{
+		"teacherId": courseTestTeacher, "week": "1", "start": "09:30", "end": "10:30",
+		"lessonCost": "100.00", "number": 10,
+	}
+	code, msg, _ := doJSON(t, app, fiber.MethodPost, "/admin/courses/"+itoa(course.ID)+"/classes", base, "X-Test-Token", "t")
+	if code != apperr.CodeScheduleConflict {
+		t.Fatalf("重叠排班 = %d(%s),期望 3122(排班冲突)", code, msg)
+	}
+	var count int64
+	db.Model(&model.CoursesClass{}).Where("courses_id = ?", course.ID).Count(&count)
+	if count != 1 {
+		t.Fatalf("冲突被拒后规则数 = %d,期望仍为 1(不得先写后报错)", count)
+	}
+
+	// 对照:换一天(week=2)必须成功,否则说明冲突判定过宽
+	okBody := map[string]any{
+		"teacherId": courseTestTeacher, "week": "2", "start": "09:30", "end": "10:30",
+		"lessonCost": "100.00", "number": 10,
+	}
+	code, msg, data := doJSON(t, app, fiber.MethodPost, "/admin/courses/"+itoa(course.ID)+"/classes", okBody, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("不同周的排班 = %d(%s),期望 200(冲突判定过宽)", code, msg)
+	}
+	if _, ok := jsonMap(t, data)["classId"]; !ok {
+		t.Fatalf("响应缺 classId:%s", string(data))
+	}
+}
+
+// ---------------------------------------------------------------- 端点 49:生成课表
+
+// TestGenerateRecordsIdempotent 端点 49 幂等:同 courses+start_time 不重复插。
+//
+// 变红条件:去掉 `FindByCoursesStart` 幂等判据 → 第二次 generated>0、库里行数翻倍。
+func TestGenerateRecordsIdempotent(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "地理F", 10)
+	seedClassRule(t, db, course.ID, 4001, courseTestTeacher, "-1", model.CoursesClassStatusOngoing, "09:00", "10:00")
+	app := newCourseTestApp(t, db)
+	body := map[string]any{"days": 3, "from": "2026-11-10"}
+
+	code, msg, data := doJSON(t, app, fiber.MethodPost, "/admin/courses/"+itoa(course.ID)+"/records/generate", body, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("生成课表 = %d(%s),期望 200", code, msg)
+	}
+	if got, _ := jsonMap(t, data)["generated"].(float64); got != 3 {
+		t.Fatalf("首次 generated = %v,期望 3", jsonMap(t, data)["generated"])
+	}
+	code, msg, data = doJSON(t, app, fiber.MethodPost, "/admin/courses/"+itoa(course.ID)+"/records/generate", body, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("二次生成 = %d(%s),期望 200", code, msg)
+	}
+	payload := jsonMap(t, data)
+	if got, _ := payload["generated"].(float64); got != 0 {
+		t.Fatalf("二次 generated = %v,期望 0(幂等:同 courses+start_time 不重复插)", payload["generated"])
+	}
+	if changed, _ := payload["changed"].(bool); changed {
+		t.Fatalf("二次 changed = true,期望 false(本次无新增)")
+	}
+	var count int64
+	db.Model(&model.CoursesRecord{}).Where("courses_id = ?", course.ID).Count(&count)
+	if count != 3 {
+		t.Fatalf("库中实例数 = %d,期望 3(幂等失败会翻倍成 6)", count)
+	}
+}
+
+// ---------------------------------------------------------------- 端点 50 / 52:开始上课 / 打卡
+
+// TestStartRecordWritesActualStart 端点 50:status 0→1 且 `actual_start_time` 落库;重复开始 → 3121。
+//
+// 变红条件:只改 status 不写 actual_start_time → 断言 1 红;重复开始返回 200 → 断言 2 红。
+func TestStartRecordWritesActualStart(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "历史G", 10)
+	record := seedRecord(t, db, course.ID, 5001, "RECSTART", model.CoursesRecordStatusNotStarted,
+		time.Now().Add(-time.Hour))
+	app := newCourseTestApp(t, db)
+	path := "/admin/courses/records/" + itoa(record.ID) + "/start"
+
+	code, msg, data := doJSON(t, app, fiber.MethodPost, path, nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("开始上课 = %d(%s),期望 200", code, msg)
+	}
+	payload := jsonMap(t, data)
+	if got, _ := payload["status"].(float64); got != float64(model.CoursesRecordStatusOngoing) {
+		t.Fatalf("status = %v,期望 1", payload["status"])
+	}
+	if got, _ := payload["courseId"].(float64); got != float64(course.ID) {
+		t.Fatalf("courseId = %v,期望 %d", payload["courseId"], course.ID)
+	}
+	var row struct {
+		Status      int32      `gorm:"column:status"`
+		ActualStart *time.Time `gorm:"column:actual_start_time"`
+	}
+	if err := db.Raw("SELECT status, actual_start_time FROM t_courses_record WHERE id = ?", record.ID).Scan(&row).Error; err != nil {
+		t.Fatalf("回读实例失败: %v", err)
+	}
+	if row.Status != model.CoursesRecordStatusOngoing || row.ActualStart == nil {
+		t.Fatalf("库里 status=%d actual_start_time=%v,期望 1 且非空(模型红线:开课时间必写)", row.Status, row.ActualStart)
+	}
+
+	// 重复开始 → 3121(不会写出第二个开课时间)
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, path, nil, "X-Test-Token", "t"); code != apperr.CodeRecordStateInvalid {
+		t.Fatalf("重复开始上课 = %d(%s),期望 3121", code, msg)
+	}
+}
+
+// TestAttendanceRejectsFinishedRecord 端点 52:已结束的课不能补打卡(3121);未结束则写 cost 并回显人数。
+//
+// 变红条件:去掉"已结束"判定 → 返回 200 且库中出现结课后追加的打卡行(income 对账缺口)。
+func TestAttendanceRejectsFinishedRecord(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "政治H", 10)
+	open := seedRecord(t, db, course.ID, 6001, "RECOPEN", model.CoursesRecordStatusOngoing,
+		time.Now().Add(-time.Hour))
+	closed := seedRecord(t, db, course.ID, 6001, "RECCLOSED", model.CoursesRecordStatusFinished,
+		time.Now().Add(-24*time.Hour))
+	app := newCourseTestApp(t, db)
+
+	body := map[string]any{"list": []map[string]any{
+		{"userId": courseTestStudent, "type": 1, "pay": 1, "cost": "100.00", "giveCost": "0.00"},
+	}}
+	code, msg, data := doJSON(t, app, fiber.MethodPost,
+		"/admin/courses/records/"+itoa(open.ID)+"/attendance", body, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("未结束课打卡 = %d(%s),期望 200", code, msg)
+	}
+	if got, _ := jsonMap(t, data)["checked"].(float64); got != 1 {
+		t.Fatalf("checked = %v,期望 1", jsonMap(t, data)["checked"])
+	}
+	var cost struct {
+		Cost string `gorm:"column:cost"`
+		Pay  int8   `gorm:"column:pay"`
+		Typ  int32  `gorm:"column:type"`
+	}
+	if err := db.Raw("SELECT cost, pay, type FROM t_courses_record_student WHERE record_id = ? AND student_id = ?",
+		open.ID, courseTestStudent).Scan(&cost).Error; err != nil {
+		t.Fatalf("回读打卡行失败: %v", err)
+	}
+	requireMoney(t, "打卡行 cost", cost.Cost, "100.00")
+
+	// 已结束的课 → 3121
+	code, msg, _ = doJSON(t, app, fiber.MethodPost,
+		"/admin/courses/records/"+itoa(closed.ID)+"/attendance", body, "X-Test-Token", "t")
+	if code != apperr.CodeRecordStateInvalid {
+		t.Fatalf("已结束课补打卡 = %d(%s),期望 3121", code, msg)
+	}
+}
+
+// ---------------------------------------------------------------- 端点 53:报课学生
+
+// TestListCourseStudentsUsesRemainderReceivable 端点 53 的欠费字段必须取 `remainder_receivable`。
+//
+// 陷阱行:`unpaid=999.00`(僵尸列的脏值)、`remainder_receivable=100.00`(真值)。
+// 变红条件:字段取成 `unpaid` → 响应里 remainderReceivable 变成 999.00;
+//
+//	把 unpaid 一起下发 → 断言"响应不含 unpaid 键"红。
+func TestListCourseStudentsUsesRemainderReceivable(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "语文I", 10)
+	seedEnrollment(t, db, course.ID, courseTestStudent, "ENROLL-1", "100.00", "400.00", "300.00", "100.00", "999.00")
+	app := newCourseTestApp(t, db)
+
+	code, msg, data := doJSON(t, app, fiber.MethodGet,
+		"/admin/courses/"+itoa(course.ID)+"/students", 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))
+	}
+	row, _ := items[0].(map[string]any)
+	if got, _ := row["remainderReceivable"].(string); got != "100.00" {
+		t.Fatalf("remainderReceivable = %v,期望 \"100.00\"(源列 remainder_receivable);"+
+			"若为 999.00 说明误取了废弃列 unpaid", row["remainderReceivable"])
+	}
+	if got, _ := row["pay"].(string); got != "400.00" {
+		t.Fatalf("pay = %v,期望 400.00(源列 receivable)", row["pay"])
+	}
+	if got, _ := row["cost"].(string); got != "300.00" {
+		t.Fatalf("cost = %v,期望 300.00(源列 net_receipts)", row["cost"])
+	}
+	if _, hasUnpaid := row["unpaid"]; hasUnpaid {
+		t.Fatalf("响应下发了 unpaid(%s):废弃列禁下发(设计 §3.8-E4)", string(data))
+	}
+	// 未注入姓名端口时 `realName` 必须是 **null**(键在、值为 nil):
+	// - 不下发该键 → 前端 `row.realName` undefined 与 "" 混在一起,无法区分;
+	// - 下发 "" → 把"没有这个人"和"姓名为空"混在一起。
+	nameValue, hasName := row["realName"]
+	if !hasName {
+		t.Fatalf("响应缺少 realName 键(%s):未注入端口时应下发 null,而不是省掉该键", string(data))
+	}
+	if nameValue != nil {
+		t.Fatalf("realName = %v,期望 null(未注入姓名端口)", nameValue)
+	}
+}
+
+// fakeUserNameReader 姓名端口测试替身(**只实现端口契约**:查不到的不进 map)。
+type fakeUserNameReader struct {
+	names map[string]string
+	err   error
+	// calls 记录调用次数(断言"批量一次"而不是逐行 N 次)
+	calls int
+	last  []string
+}
+
+// DisplayNames 实现 `port.UserNameReader`。
+func (f *fakeUserNameReader) DisplayNames(_ context.Context, userIDs []string) (map[string]string, error) {
+	f.calls++
+	f.last = append([]string{}, userIDs...)
+	if f.err != nil {
+		return nil, f.err
+	}
+	out := make(map[string]string, len(userIDs))
+	for _, id := range userIDs {
+		if name, ok := f.names[id]; ok {
+			out[id] = name
+		}
+	}
+	return out, nil
+}
+
+// TestListCourseStudentsResolvesRealName 端点 53 的 `realName`(D4:跨域只读 port)。
+//
+// 三行数据覆盖三种情形:
+//
+//	A:端口能查到 → `realName` = 姓名
+//	B:端口查不到 → `realName` = **null**(不是 "")
+//	C:端口返回空串 → 同样按"没名字"→ null(空串不该出现在响应里)
+//
+// 变红条件:
+//   - 端口没接(仍是 null)→ A 红;
+//   - 用空串兜底 → B/C 红;
+//   - 逐行查询 → `calls != 1` 红(N+1)。
+func TestListCourseStudentsResolvesRealName(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "姓名测试课", 10)
+	const studentB = "01HSTUDENT0000000000000B"
+	const studentC = "01HSTUDENT0000000000000C"
+	seedEnrollment(t, db, course.ID, courseTestStudent, "NAME-1", "100.00", "300.00", "300.00", "0.00", "0.00")
+	seedEnrollment(t, db, course.ID, studentB, "NAME-2", "100.00", "300.00", "300.00", "0.00", "0.00")
+	seedEnrollment(t, db, course.ID, studentC, "NAME-3", "100.00", "300.00", "300.00", "0.00", "0.00")
+	reader := &fakeUserNameReader{names: map[string]string{
+		courseTestStudent: "张三",
+		studentC:          "", // 端口返回空串:调用方必须当成"没名字"
+	}}
+	app := newCourseTestAppWithUsers(t, db, reader)
+
+	code, msg, data := doJSON(t, app, fiber.MethodGet,
+		"/admin/courses/"+itoa(course.ID)+"/students?pageSize=50", nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("报课学生列表 = %d(%s),期望 200", code, msg)
+	}
+	items := pageList(t, data)
+	if len(items) != 3 {
+		t.Fatalf("行数 = %d,期望 3", len(items))
+	}
+	byUser := make(map[string]map[string]any, len(items))
+	for _, item := range items {
+		row, _ := item.(map[string]any)
+		uid, _ := row["userId"].(string)
+		byUser[uid] = row
+	}
+	if got, _ := byUser[courseTestStudent]["realName"].(string); got != "张三" {
+		t.Fatalf("A 行 realName = %v,期望 张三(端口已查到,不得为 null)",
+			byUser[courseTestStudent]["realName"])
+	}
+	if v, ok := byUser[studentB]["realName"]; !ok || v != nil {
+		t.Fatalf("B 行 realName = %v,期望 null(端口查不到;空串会让前端无法区分两种没名字)", v)
+	}
+	if v, ok := byUser[studentC]["realName"]; !ok || v != nil {
+		t.Fatalf("C 行 realName = %v,期望 null(端口返回空串 ⇒ 等同于没名字)", v)
+	}
+	// 批量:一页 3 行只应调用端口 1 次(N+1 会变成 3 次)
+	if reader.calls != 1 {
+		t.Fatalf("姓名端口调用 %d 次,期望 1 次(批量)—— 多次说明逐行查", reader.calls)
+	}
+	if len(reader.last) != 3 {
+		t.Fatalf("端口收到 %d 个 ID,期望 3(一页全部学生一次传入)", len(reader.last))
+	}
+}
+
+// TestListCourseStudentsNameReaderFailureDegradesToNull 端口报错时**降级为 null**、列表仍 200。
+//
+// 为什么不让它 5002:列表页因"少一列姓名"整页失败,用户连报课/欠费都看不到——
+// 展示类依赖必须可降级(与 `MembershipChecker` 那种安全依赖相反)。
+func TestListCourseStudentsNameReaderFailureDegradesToNull(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "降级测试课", 10)
+	seedEnrollment(t, db, course.ID, courseTestStudent, "DEGRADE-1", "100.00", "300.00", "300.00", "0.00", "0.00")
+	reader := &fakeUserNameReader{err: errors.New("identity 库不可用")}
+	app := newCourseTestAppWithUsers(t, db, reader)
+	code, msg, data := doJSON(t, app, fiber.MethodGet,
+		"/admin/courses/"+itoa(course.ID)+"/students", 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))
+	}
+	row, _ := items[0].(map[string]any)
+	if v, ok := row["realName"]; !ok || v != nil {
+		t.Fatalf("realName = %v,期望 null(端口报错时降级)", v)
+	}
+	if got, _ := row["remainderReceivable"].(string); got != "0.00" {
+		t.Fatalf("remainderReceivable = %v,期望 0.00(降级不得影响其它字段)", row["remainderReceivable"])
+	}
+}
+
+// ---------------------------------------------------------------- 端点 54:报名
+
+// TestEnrollCreatesSnapshotAndRejectsDuplicate 端点 54 报课行快照恒等式 + 重复报名 3140 + 跨校区 1004。
+//
+// 变红条件:
+//   - receivable ≠ net_receipts + remainder_receivable → 对账恒等式断言红;
+//   - `confirm_discount_user_id` 为空 → 该行永远进不了欠费核销候选,断言红;
+//   - 重复报名静默成功 → 库里出现第二行(断言行数)。
+func TestEnrollCreatesSnapshotAndRejectsDuplicate(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "化学J", 10)
+	app := newCourseTestApp(t, db)
+	path := "/admin/courses/" + itoa(course.ID) + "/students"
+	body := map[string]any{
+		"userId": courseTestStudent, "pay": "500.00", "remainderReceivable": "200.00",
+		"lessonCost": "100.00", "number": 5, "giveNumber": 1,
+	}
+	code, msg, data := doJSON(t, app, fiber.MethodPost, path, body, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("报名 = %d(%s),期望 200", code, msg)
+	}
+	payload := jsonMap(t, data)
+	if enrolled, _ := payload["enrolled"].(bool); !enrolled {
+		t.Fatalf("enrolled = %v,期望 true", payload["enrolled"])
+	}
+	var row model.CoursesStudent
+	if err := db.Where("courses_id = ? AND student_id = ?", course.ID, courseTestStudent).First(&row).Error; err != nil {
+		t.Fatalf("回读报课行失败: %v", err)
+	}
+	// 恒等式:receivable = net_receipts + remainder_receivable(这是"应收"的唯一定义)
+	receivable, _ := money.Parse(row.Receivable)
+	netReceipts, _ := money.Parse(row.NetReceipts)
+	remainder, _ := money.Parse(row.RemainderReceivable)
+	if receivable.Cents() != netReceipts.Cents()+remainder.Cents() {
+		t.Fatalf("receivable(%s) ≠ net_receipts(%s) + remainder_receivable(%s):应收恒等式被破坏",
+			row.Receivable, row.NetReceipts, row.RemainderReceivable)
+	}
+	if row.RemainderNumber != 6 {
+		t.Fatalf("remainder_number = %d,期望 6(number 5 + give_number 1)", row.RemainderNumber)
+	}
+	if row.ConfirmDiscountUserID == nil || strings.TrimSpace(*row.ConfirmDiscountUserID) == "" {
+		t.Fatalf("confirm_discount_user_id 为空:该报课行将**永远进不了欠费核销候选**(§3.8-A17)")
+	}
+	requireMoney(t, "unpaid(废弃列)", row.Unpaid, "0.00")
+
+	// 重复报名 → 3140,且库里仍只有一行
+	code, msg, _ = doJSON(t, app, fiber.MethodPost, path, body, "X-Test-Token", "t")
+	if code != apperr.CodeEnrollmentDuplicate {
+		t.Fatalf("重复报名 = %d(%s),期望 3143(重复报名)", code, msg)
+	}
+	var count int64
+	db.Model(&model.CoursesStudent{}).Where("courses_id = ? AND student_id = ?", course.ID, courseTestStudent).Count(&count)
+	if count != 1 {
+		t.Fatalf("报课行数 = %d,期望 1", count)
+	}
+
+	// 跨校区报名 → 1004(作用域校区 10,传入 99)
+	crossBody := map[string]any{
+		"userId": courseTestOther, "pay": "100.00", "lessonCost": "100.00", "campusId": 99,
+	}
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, path, crossBody, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
+		t.Fatalf("跨校区报名 = %d(%s),期望 1004", code, msg)
+	}
+}
+
+// ---------------------------------------------------------------- 端点 55:欠费列表
+
+// TestArrearsListUsesRemainderReceivable **本端点最容易写错、且写错不会报错的一处**。
+//
+// 两条数据:
+//
+//	A:remainder_receivable=50.00 且 unpaid=0.00  ← **真欠费**(用 unpaid 判会被漏掉)
+//	B:remainder_receivable=0.00  且 unpaid=999.00 ← **不欠费**(用 unpaid 判会被误报)
+//
+// 变红条件:判据换成 `unpaid > 0` → 列表里出现 B、且不含 A(两条断言同时红)。
+func TestArrearsListUsesRemainderReceivable(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "英语K", 10)
+	seedEnrollment(t, db, course.ID, courseTestStudent, "ARR-1", "100.00", "300.00", "250.00", "50.00", "0.00")
+	seedEnrollment(t, db, course.ID, courseTestOther, "ARR-2", "100.00", "300.00", "300.00", "0.00", "999.00")
+	app := newCourseTestApp(t, db)
+
+	code, msg, data := doJSON(t, app, fiber.MethodGet, PathCourseArrears, 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(只有 remainder_receivable>0 的那条):%s", len(items), string(data))
+	}
+	row, _ := items[0].(map[string]any)
+	if got, _ := row["studentId"].(string); got != courseTestStudent {
+		t.Fatalf("欠费学生 = %v,期望 %s —— 若为 %s 说明判据用了废弃列 unpaid",
+			row["studentId"], courseTestStudent, courseTestOther)
+	}
+	if got, _ := row["remainderReceivable"].(string); got != "50.00" {
+		t.Fatalf("remainderReceivable = %v,期望 \"50.00\"", row["remainderReceivable"])
+	}
+	if got, _ := row["courseId"].(float64); got != float64(course.ID) {
+		t.Fatalf("courseId = %v,期望 %d", row["courseId"], course.ID)
+	}
+	if _, has := row["unpaid"]; has {
+		t.Fatalf("欠费列表下发了 unpaid:废弃列禁下发(设计 §3.8-E4)")
+	}
+}
+
+// ---------------------------------------------------------------- 端点 56:欠费核销
+
+// TestCounteractArrearsWritesBothColumns 端点 56:`net_receipts` 增、`remainder_receivable` 减(同一条 UPDATE);
+// 跨机构 → 1004。
+//
+// 变红条件:只写一列(实收加了、应收没减)→ 两列断言之一红;去掉机构校验 → 1004 用例红。
+func TestCounteractArrearsWritesBothColumns(t *testing.T) {
+	db := newCourseTestDB(t)
+	course := seedCourse(t, db, "数学L", 10)
+	// SP27 的候选集要 INNER JOIN t_courses_class(按 courses_id + class_id),故必须有一条规则
+	seedClassRule(t, db, course.ID, 7001, courseTestTeacher, "1", model.CoursesClassStatusOngoing, "09:00", "10:00")
+	enroll := seedEnrollment(t, db, course.ID, courseTestStudent, "CARR-1", "100.00", "300.00", "200.00", "100.00", "0.00")
+	// SP27 的候选集是 `t1 INNER JOIN t_courses_class ON (coursesid, classid)` ⇒ 报课行的
+	// class_id 必须与排班规则一致,否则候选集为空(核销变成静默 no-op)
+	if err := db.Model(&model.CoursesStudent{}).Where("id = ?", enroll.ID).
+		UpdateColumn("class_id", 7001).Error; err != nil {
+		t.Fatalf("对齐报课行 class_id 失败: %v", err)
+	}
+	app := newCourseTestApp(t, db)
+
+	body := map[string]any{
+		"studentId": courseTestStudent, "institutionId": 1, "campusId": 10, "money": "60.00",
+	}
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathCourseArrearsCounteract, body, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("欠费核销 = %d(%s),期望 200", code, msg)
+	}
+	payload := jsonMap(t, data)
+	if counteracted, _ := payload["counteracted"].(bool); !counteracted {
+		t.Fatalf("counteracted = %v,期望 true", payload["counteracted"])
+	}
+	if got, _ := payload["amount"].(string); got != "60.00" {
+		t.Fatalf("amount = %v,期望 \"60.00\"(实际核销额)", payload["amount"])
+	}
+	var row struct {
+		NetReceipts string `gorm:"column:net_receipts"`
+		Remainder   string `gorm:"column:remainder_receivable"`
+	}
+	if err := db.Raw("SELECT net_receipts, remainder_receivable FROM t_courses_student WHERE id = ?", enroll.ID).
+		Scan(&row).Error; err != nil {
+		t.Fatalf("回读报课行失败: %v", err)
+	}
+	requireMoney(t, "net_receipts", row.NetReceipts, "260.00")      // 200 + 60
+	requireMoney(t, "remainder_receivable", row.Remainder, "40.00") // 100 - 60(只加实收不减应收是典型错法)
+
+	// 跨机构(作用域机构 1,传入 2)→ 1004
+	crossBody := map[string]any{
+		"studentId": courseTestStudent, "institutionId": 2, "campusId": 10, "money": "10.00",
+	}
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathCourseArrearsCounteract, crossBody, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
+		t.Fatalf("跨机构核销 = %d(%s),期望 1004", code, msg)
+	}
+}
+
+// ---------------------------------------------------------------- 端点 57:学生课表
+
+// TestStudentScheduleIsPerStudent 端点 57:课表按学生过滤(另一个学生报的课不得出现);跨用户查 → 1004。
+//
+// 变红条件:漏掉 `s.student_id = ?` 过滤 → 两个学生都会看到两门课(行数 2)。
+func TestStudentScheduleIsPerStudent(t *testing.T) {
+	db := newCourseTestDB(t)
+	mine := seedCourse(t, db, "我的课", 10)
+	other := seedCourse(t, db, "别人的课", 10)
+	seedEnrollment(t, db, mine.ID, courseTestStudent, "SCH-1", "100.00", "100.00", "100.00", "0.00", "0.00")
+	seedEnrollment(t, db, other.ID, courseTestOther, "SCH-2", "100.00", "100.00", "100.00", "0.00", "0.00")
+	mineRecord := seedRecord(t, db, mine.ID, 8001, "SCHREC-1", model.CoursesRecordStatusOngoing,
+		time.Date(2026, 11, 20, 9, 0, 0, 0, time.Local))
+	seedRecord(t, db, other.ID, 8002, "SCHREC-2", model.CoursesRecordStatusOngoing,
+		time.Date(2026, 11, 20, 11, 0, 0, 0, time.Local))
+	// 本人已打卡 → cost 回显
+	att := &model.CoursesRecordStudent{
+		BizNo: "SCHATT-1", CoursesID: mine.ID, ClassID: 8001, InstitutionID: 1, CampusID: 10,
+		RecordID: mineRecord.ID, StudentID: courseTestStudent, Type: model.CoursesRecordStudentTypeNormal,
+		StartTime: *mineRecord.StartTime, Cost: "100.00", GiveCost: "10.00",
+		Pay: model.CoursesRecordStudentPayPaid,
+	}
+	if err := db.Create(att).Error; err != nil {
+		t.Fatalf("建打卡行失败: %v", err)
+	}
+	app := newCourseTestApp(t, db)
+
+	code, msg, data := doJSON(t, app, fiber.MethodGet, PathStudentSchedule, nil,
+		"X-Test-Token", "t", "X-Test-Scope", "student")
+	if code != 200 {
+		t.Fatalf("学生课表 = %d(%s),期望 200", code, msg)
+	}
+	items := pageList(t, data)
+	if len(items) != 1 {
+		t.Fatalf("课表行数 = %d,期望 1(只含本人报名的课程)—— 2 说明漏了 student_id 过滤:%s",
+			len(items), string(data))
+	}
+	row, _ := items[0].(map[string]any)
+	if got, _ := row["courseId"].(float64); got != float64(mine.ID) {
+		t.Fatalf("courseId = %v,期望 %d(本人课程)", row["courseId"], mine.ID)
+	}
+	if checked, _ := row["checked"].(bool); !checked {
+		t.Fatalf("checked = false,期望 true(本人已有打卡行)")
+	}
+	if got, _ := row["cost"].(string); got != "100.00" {
+		t.Fatalf("cost = %v,期望 \"100.00\"", row["cost"])
+	}
+	if got, _ := row["courseName"].(string); got != "我的课" {
+		t.Fatalf("courseName = %v,期望 我的课", row["courseName"])
+	}
+
+	// 学生查别人 → 1004
+	if code, msg, _ := doJSON(t, app, fiber.MethodGet,
+		PathStudentSchedule+"?userId="+courseTestOther, nil,
+		"X-Test-Token", "t", "X-Test-Scope", "student"); code != infraerrors.ErrUnscoped.Code {
+		t.Fatalf("学生查他人课表 = %d(%s),期望 1004", code, msg)
+	}
+}
+
+// TestFinishRecordResolvesTeacherExpFromProfile **教龄必须从教师档案解析,且按 365 天口径**。
+//
+// 夹具:老师在 `t_teacher_information.teacher_exp` 的起算日 = **365 天前**(教龄 = 1.0 年),
+// 两档规则:
+//
+//	档 A:[0.000, 0.999] reward=10.00   ← 教龄解析成 0(未接档案)时会落到这里
+//	档 B:[1.000, 5.000] reward=20.00   ← 正确解析(1.0 年)应命中这里
+//
+// 变红条件:
+//   - 适配器没有回落档案(`TeacherExp` 恒 0)→ 得 10.00;
+//   - 用"年份相减"算教龄(跨年边界差整整 1 岁)→ 也会落到错误档;
+//   - 教龄没按 1 位小数舍入 → 0.999/1.000 这类端点会跨档。
+func TestFinishRecordResolvesTeacherExpFromProfile(t *testing.T) {
+	db := newCourseTestDB(t)
+	fx := seedFinishFixture(t, db)
+	// 两档规则:把原夹具那条改成"档 A",再加一条"档 B"
+	if err := db.Model(&model.CoursePaymentDetail{}).Where("id = ?", fx.DetailID).
+		Updates(map[string]any{
+			"percentage": "0.0", "reward": "10.00",
+			"teacher_exp_min": "0.000", "teacher_exp_max": "0.999",
+		}).Error; err != nil {
+		t.Fatalf("改写档 A 失败: %v", err)
+	}
+	higher := &model.CoursePaymentDetail{
+		CoursePaymentID: fx.DetailID, // 占位,下面改成真 scheme id
+		GradeID:         -1, SubjectID: -1, ClassType: 1000, ClassifyID: 0,
+		Percentage: "0.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "20.00",
+		TeacherCert: false, TeacherExpMin: "1.000", TeacherExpMax: "5.000", Actived: true,
+	}
+	// 取回原 detail 的 scheme id
+	var schemeID int64
+	db.Raw("SELECT course_payment_id FROM t_course_payment_detail WHERE id = ?", fx.DetailID).Scan(&schemeID)
+	higher.CoursePaymentID = schemeID
+	if err := db.Create(higher).Error; err != nil {
+		t.Fatalf("建档 B 失败: %v", err)
+	}
+	// 教师档案:起算日 = 365 天前 ⇒ 教龄 1.0 年 ⇒ 命中档 B
+	expDate := time.Now().AddDate(0, 0, -365)
+	if err := db.Create(&model.TeacherInformation{
+		TeacherID: courseTestTeacher, TeacherExp: &expDate,
+	}).Error; err != nil {
+		t.Fatalf("建教师档案失败: %v", err)
+	}
+	app := newCourseTestApp(t, db)
+	code, msg, data := doJSON(t, app, fiber.MethodPost,
+		"/admin/courses/records/"+itoa(fx.Record.ID)+"/finish", map[string]any{}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("结束上课 = %d(%s),期望 200", code, msg)
+	}
+	payroll, _ := jsonMap(t, data)["payroll"].(map[string]any)
+	if got, _ := payroll["reward"].(string); got != "20.00" {
+		t.Fatalf("payroll.reward = %v,期望 20.00(教龄 1.0 年命中 [1.000,5.000] 档);"+
+			"10.00 说明教龄解析成 0(没接档案或用错换算口径)", payroll["reward"])
+	}
+	if got, _ := payroll["detailId"].(float64); got != float64(higher.ID) {
+		t.Fatalf("payroll.detailId = %v,期望 %d(档 B)", payroll["detailId"], higher.ID)
+	}
+}
+
+// ---------------------------------------------------------------- 端点 51:课酬分摊口径(D16)
+
+// TestFinishRecordRewardDistributionSkipsGiveRows **D16:课酬只分给 `type∈{1,2}`,赠送行恒 0**。
+//
+// PHP 原文(`CoursesRecordHelper.php:806-818`):`$stuCount = count(type IN (1,2))`,
+// 平摊与写回**都只针对 `type IN (1,2)`**,赠送行**永远保持 0**。
+//
+// 夹具:1 条 type=1(正常)+ 1 条 type=3(赠送),单节课课酬合计 = 100.00。
+//
+//	正确(PHP):正常行 **100.00**、赠送行 **0.00**、Σ(type∈{1,2}) == 100.00
+//	错误(除数用全部行数):正常行 50.00、赠送行 50.00 ⇒ **多条断言同时红**
+func TestFinishRecordRewardDistributionSkipsGiveRows(t *testing.T) {
+	db := newCourseTestDB(t)
+	fx := seedFinishFixture(t, db)
+	const giveStudent = "01HSTUDENT0000000000000G"
+	app := newCourseTestApp(t, db)
+	code, msg, data := doJSON(t, app, fiber.MethodPost,
+		"/admin/courses/records/"+itoa(fx.Record.ID)+"/finish",
+		map[string]any{"items": []map[string]any{
+			{"userId": courseTestStudent, "type": 1, "cost": "100.00"},
+			{"userId": giveStudent, "type": 3, "cost": "0.00"},
+		}}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("结束上课(含赠送生)= %d(%s),期望 200", code, msg)
+	}
+	payroll, _ := jsonMap(t, data)["payroll"].(map[string]any)
+	if got, _ := payroll["reward"].(string); got != "100.00" {
+		t.Fatalf("payroll.reward = %v,期望 100.00(单节课课酬合计,与分摊无关)", payroll["reward"])
+	}
+	requireMoney(t, "正常行 teacher_reward", readTeacherReward(t, db, fx.Record.ID, courseTestStudent), "100.00")
+	requireMoney(t, "赠送行 teacher_reward", readTeacherReward(t, db, fx.Record.ID, giveStudent), "0.00")
+	requireMoney(t, "Σ(type∈{1,2})", readTeacherRewardSumExcludingGive(t, db, fx.Record.ID), "100.00")
+	// student_number 是**全部行数**(含赠送),与平摊人数是两个数
+	var studentNumber int32
+	db.Raw("SELECT student_number FROM t_courses_record WHERE id = ?", fx.Record.ID).Scan(&studentNumber)
+	if studentNumber != 2 {
+		t.Fatalf("student_number = %d,期望 2(全部打卡行数,含赠送生)", studentNumber)
+	}
+}
+
+// TestFinishRecordAllGiveClassKeepsRecordReward **D16 边界:全班赠送(stuCount=0)**。
+//
+// PHP 的 `if($stuCount)` 不进入 ⇒ **一行都不写**(学生行全 0),但 record 上**仍写课酬**。
+// ⇒ 恒等式在这一情形**不成立**;断言只能是"所有行 0",**不得**断言 Σ == record
+// (无条件断言会让"全赠送班"恒红,最后被人改成跳过 = 把防线删掉)。
+func TestFinishRecordAllGiveClassKeepsRecordReward(t *testing.T) {
+	db := newCourseTestDB(t)
+	fx := seedFinishFixture(t, db)
+	app := newCourseTestApp(t, db)
+	code, msg, data := doJSON(t, app, fiber.MethodPost,
+		"/admin/courses/records/"+itoa(fx.Record.ID)+"/finish",
+		map[string]any{"items": []map[string]any{
+			{"userId": courseTestStudent, "type": 3, "cost": "0.00"},
+		}}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("结束上课(全班赠送)= %d(%s),期望 200", code, msg)
+	}
+	requireMoney(t, "赠送行 teacher_reward", readTeacherReward(t, db, fx.Record.ID, courseTestStudent), "0.00")
+	payroll, _ := jsonMap(t, data)["payroll"].(map[string]any)
+	if got, _ := payroll["reward"].(string); got != "100.00" {
+		t.Fatalf("payroll.reward = %v,期望 100.00(全班赠送照常算课酬,只是不分摊)", payroll["reward"])
+	}
+	var recReward string
+	db.Raw("SELECT teacher_reward FROM t_courses_record WHERE id = ?", fx.Record.ID).Scan(&recReward)
+	requireMoney(t, "record.teacher_reward", recReward, "100.00")
+}
+
+// readTeacherReward 读某学生在该课的 `teacher_reward`(无行 → 空串,断言会红)。
+func readTeacherReward(t *testing.T, db *gorm.DB, recordID int64, studentID string) string {
+	t.Helper()
+	var out struct {
+		Reward string `gorm:"column:teacher_reward"`
+	}
+	if err := db.Raw("SELECT teacher_reward FROM t_courses_record_student WHERE record_id = ? AND student_id = ?",
+		recordID, studentID).Scan(&out).Error; err != nil {
+		t.Fatalf("读 teacher_reward 失败: %v", err)
+	}
+	return out.Reward
+}
+
+// readTeacherRewardSumExcludingGive 求和非赠送行的 `teacher_reward`(D16 条件式恒等式)。
+func readTeacherRewardSumExcludingGive(t *testing.T, db *gorm.DB, recordID int64) string {
+	t.Helper()
+	var out struct {
+		Sum string `gorm:"column:sum_reward"`
+	}
+	if err := db.Raw("SELECT COALESCE(SUM(teacher_reward),0) AS sum_reward FROM t_courses_record_student "+
+		"WHERE record_id = ? AND type IN (1,2)", recordID).Scan(&out).Error; err != nil {
+		t.Fatalf("求和非赠送行 teacher_reward 失败: %v", err)
+	}
+	return out.Sum
+}
+
+// ---------------------------------------------------------------- 端点 51:结束上课(跨 5 表)
+
+// courseFinishFixture 端点 51 的完整夹具(结课链路的五张表全部就位)。
+type courseFinishFixture struct {
+	Course      *model.Courses
+	Record      *model.CoursesRecord
+	Enroll      *model.CoursesStudent
+	DetailID    int64
+	Recommender string
+	Upline      string
+}
+
+// seedFinishFixture 造一节课的完整数据:资金双账户 + 报课行 + 排班实例 + 分账树 + 课酬方案。
+//
+// 关键设计(每条都对应一个断言):
+//   - 报课行 lesson_cost=100 → 全勤结课的 cost 应为 100.00;
+//   - 分账树:推荐人 A 的上级 B 的 quota=20.00 → 结课后 B 应入账 20.00;
+//   - 课酬方案:命中 detail 的 reward=100.00 → record.teacher_reward 应为 100.00 且 detailId == 该行 ID。
+func seedFinishFixture(t *testing.T, db *gorm.DB) *courseFinishFixture {
+	t.Helper()
+	// ① 课程 + 分类(课酬匹配要用 class_type)
+	classify := &model.CoursesClassify{
+		InstitutionID: 1, CampusID: 10, ClassType: 1000, GradeID: 0, SubjectID: 0,
+		Name: "集体班", UnitPrice: "100.00", MinPeople: 1, MaxPeople: 10,
+	}
+	if err := db.Create(classify).Error; err != nil {
+		t.Fatalf("建分类失败: %v", err)
+	}
+	course := &model.Courses{
+		InstitutionID: 1, CampusID: 10, ClassifyID: classify.ID, Name: "结课测试课",
+		Status: model.CoursesStatusOngoing, Number: 10, Duration: "1.0", Week: "-1",
+		OriginalCost: "100.00", RealPrice: "100.00", DiscountCash: "0.00",
+	}
+	if err := db.Create(course).Error; err != nil {
+		t.Fatalf("建课程失败: %v", err)
+	}
+	// ② 资金双账户(spend 统计列的累加对象)
+	if err := db.Create(&model.UserExpense{
+		UserID: courseTestStudent, Balance: "1000.00", Spend: "0.00",
+		TotalIncome: "1000.00", TotalExpenditure: "0.00",
+		TotalRecommendedAmount: "0.00", SpecialExpenses: "0.00",
+	}).Error; err != nil {
+		t.Fatalf("建全局账户失败: %v", err)
+	}
+	if err := db.Create(&model.UserExpenseSchool{
+		UserID: courseTestStudent, InstitutionID: 1, CampusID: 10, Balance: "500.00", Spend: "0.00",
+		TotalIncome: "500.00", TotalExpenditure: "0.00",
+		TotalRecommendedAmount: "0.00", SpecialExpenses: "0.00",
+	}).Error; err != nil {
+		t.Fatalf("建校区账户失败: %v", err)
+	}
+	// ③ 报课行(带推荐人 → 结课触发分账)
+	confirm := courseTestOperator
+	recommender := courseTestRecommender
+	enroll := &model.CoursesStudent{
+		BizNo: "FINISH-ENROLL-1", CoursesID: course.ID, ClassID: 9001,
+		InstitutionID: 1, CampusID: 10, StudentID: courseTestStudent,
+		Type: model.CoursesStudentTypeNormal, Status: model.CoursesStudentStatusNormal,
+		Receivable: "1000.00", NetReceipts: "1000.00", Unpaid: "0.00",
+		LessonCost: "100.00", Number: 10, FinishedNumber: 0, DiscountCash: "0.00",
+		GiveNumber: 0, GiveCost: "0.00", RemainderNumber: 10, RemainderCost: "1000.00",
+		RemainderReceivable: "0.00", BadDebt: "0.00",
+		ConfirmDiscountUserID: &confirm, RecommenderUserID: &recommender,
+	}
+	if err := db.Create(enroll).Error; err != nil {
+		t.Fatalf("建报课行失败: %v", err)
+	}
+	// ④ 排班实例(上课中)
+	record := seedRecord(t, db, course.ID, 9001, "FINISH-REC-1", model.CoursesRecordStatusOngoing,
+		time.Now().Add(-time.Hour))
+	// ⑤ 分账树:B(根,quota 20)→ A(推荐人)
+	parent := courseTestUpline
+	root := courseTestUpline
+	if err := db.Create(&model.UserDivideInto{
+		UserID: courseTestUpline, Type: model.DivideTypePerLesson, Quota: "20.00", TotalIncome: "0.00",
+		RootUserID: &root, ParentUserID: nil, LeftVal: 1, RightVal: 4, Layer: 1,
+	}).Error; err != nil {
+		t.Fatalf("建分账根节点失败: %v", err)
+	}
+	if err := db.Create(&model.UserDivideInto{
+		UserID: courseTestRecommender, Type: model.DivideTypePerLesson, Quota: "0.00", TotalIncome: "0.00",
+		RootUserID: &root, ParentUserID: &parent, LeftVal: 2, RightVal: 3, Layer: 2,
+	}).Error; err != nil {
+		t.Fatalf("建推荐人节点失败: %v", err)
+	}
+	// ⑥ 课酬方案 + 规则(命中:reward=100.00)
+	effect := time.Now().AddDate(0, 0, -30)
+	scheme := &model.CoursePayment{
+		InstitutionID: 1, CampusID: 10, Actived: true, EffectDate: &effect,
+	}
+	if err := db.Create(scheme).Error; err != nil {
+		t.Fatalf("建课酬方案失败: %v", err)
+	}
+	detail := &model.CoursePaymentDetail{
+		CoursePaymentID: scheme.ID, GradeID: -1, SubjectID: -1, ClassType: 1000, ClassifyID: 0,
+		Percentage: "0.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "100.00",
+		TeacherCert: false, TeacherExpMin: "0.000", TeacherExpMax: "100.000", Actived: true,
+	}
+	if err := db.Create(detail).Error; err != nil {
+		t.Fatalf("建课酬规则失败: %v", err)
+	}
+	return &courseFinishFixture{
+		Course: course, Record: record, Enroll: enroll,
+		DetailID: detail.ID, Recommender: courseTestRecommender, Upline: courseTestUpline,
+	}
+}
+
+// TestFinishRecordCrossTableLinkage 端点 51:**一次调用 = 五张表联动**,逐表断言。
+//
+// 变红矩阵(每条断言对应一种真实错法):
+//
+//	① t_courses_record_student.cost/give_cost/teacher_reward  → 漏写打卡行/课酬分摊
+//	② t_user_expense.spend                                    → 漏累加全局统计列
+//	③ t_user_expense_school.spend                             → 只加了全局、漏了校区
+//	④ t_user_divide_into_log(money=quota)+ total_income      → 分账未入账/入错金额
+//	⑤ t_courses_record(status=2 + income + teacher_reward)   → 汇总列没落/落错
+//	⑥ t_courses_student(finished_number/remainder_*)         → 扣课时没扣
+func TestFinishRecordCrossTableLinkage(t *testing.T) {
+	db := newCourseTestDB(t)
+	fx := seedFinishFixture(t, db)
+	app := newCourseTestApp(t, db)
+
+	code, msg, data := doJSON(t, app, fiber.MethodPost,
+		"/admin/courses/records/"+itoa(fx.Record.ID)+"/finish", map[string]any{}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("结束上课 = %d(%s),期望 200", code, msg)
+	}
+	payload := jsonMap(t, data)
+	if got, _ := payload["status"].(float64); got != float64(model.CoursesRecordStatusFinished) {
+		t.Fatalf("status = %v,期望 2", payload["status"])
+	}
+	if changed, _ := payload["changed"].(bool); !changed {
+		t.Fatalf("changed = false,期望 true(首次结课必须真的写库)")
+	}
+	if got, _ := payload["totalSpend"].(string); got != "100.00" {
+		t.Fatalf("totalSpend = %v,期望 \"100.00\"", payload["totalSpend"])
+	}
+	payroll, _ := payload["payroll"].(map[string]any)
+	if got, _ := payroll["reward"].(string); got != "100.00" {
+		t.Fatalf("payroll.reward = %v,期望 \"100.00\"(方案 reward 列);"+
+			"为 0.00 说明课酬端口没接上(quoter=nil)", payroll["reward"])
+	}
+	if got, _ := payroll["detailId"].(float64); got != float64(fx.DetailID) {
+		t.Fatalf("payroll.detailId = %v,期望 %d(命中的课酬规则 ID)", payroll["detailId"], fx.DetailID)
+	}
+	divide, _ := payload["divide"].([]any)
+	if len(divide) != 1 {
+		t.Fatalf("divide 条数 = %d,期望 1(推荐人的上级应入账):%s", len(divide), string(data))
+	}
+	d0, _ := divide[0].(map[string]any)
+	if got, _ := d0["amount"].(string); got != "20.00" {
+		t.Fatalf("divide[0].amount = %v,期望 \"20.00\"(源列 money)", d0["amount"])
+	}
+	if got, _ := d0["userId"].(string); got != fx.Upline {
+		t.Fatalf("divide[0].userId = %v,期望 %s", d0["userId"], fx.Upline)
+	}
+	// 裁决后形状 `{userId, type, amount}`:type 必须来自日志行的 `type`(夹具用 type=2 按课次)
+	if got, _ := d0["type"].(float64); got != float64(model.DivideTypePerLesson) {
+		t.Fatalf("divide[0].type = %v,期望 %d(源列 type = 按课次)", d0["type"], model.DivideTypePerLesson)
+	}
+	// 防回潮:`level`/`quota` 无对应列,**任何形式的下发都不允许**(包括"填常量让它看起来有值")
+	for _, forbidden := range []string{"level", "quota"} {
+		if v, has := d0[forbidden]; has {
+			t.Fatalf("divide[0] 下发了 %q=%v:该字段在 t_user_divide_into_log 里没有对应列,"+
+				"填常量顶替属伪造历史(团队裁决已删)", forbidden, v)
+		}
+	}
+
+	// ① 打卡行三列
+	var att struct {
+		Cost     string     `gorm:"column:cost"`
+		GiveCost string     `gorm:"column:give_cost"`
+		Reward   string     `gorm:"column:teacher_reward"`
+		EndTime  *time.Time `gorm:"column:end_time"`
+	}
+	if err := db.Raw(`SELECT cost, give_cost, teacher_reward, end_time FROM t_courses_record_student
+		WHERE record_id = ? AND student_id = ?`, fx.Record.ID, courseTestStudent).Scan(&att).Error; err != nil {
+		t.Fatalf("回读打卡行失败: %v", err)
+	}
+	requireMoney(t, "打卡行 cost", att.Cost, "100.00")
+	requireMoney(t, "打卡行 give_cost", att.GiveCost, "0.00")
+	requireMoney(t, "打卡行 teacher_reward", att.Reward, "100.00")
+	if att.EndTime == nil {
+		t.Fatalf("打卡行 end_time 为空:非空才是「已完成」(分成归属判据)")
+	}
+
+	// ② 全局账户 spend(**balance 必须不动**)
+	var global struct {
+		Spend   string `gorm:"column:spend"`
+		Balance string `gorm:"column:balance"`
+	}
+	if err := db.Raw("SELECT spend, balance FROM t_user_expense WHERE user_id = ?", courseTestStudent).
+		Scan(&global).Error; err != nil {
+		t.Fatalf("回读全局账户失败: %v", err)
+	}
+	requireMoney(t, "t_user_expense.spend", global.Spend, "100.00")      // 漏累加统计列
+	requireMoney(t, "t_user_expense.balance", global.Balance, "1000.00") // spend 是统计列,**不得动 balance**
+
+	// ③ 校区账户 spend
+	var school struct {
+		Spend string `gorm:"column:spend"`
+	}
+	if err := db.Raw("SELECT spend FROM t_user_expense_school WHERE user_id = ? AND campus_id = 10",
+		courseTestStudent).Scan(&school).Error; err != nil {
+		t.Fatalf("回读校区账户失败: %v", err)
+	}
+	requireMoney(t, "t_user_expense_school.spend", school.Spend, "100.00") // 只加全局漏了校区
+
+	// ④ 分账明细 + total_income
+	var divideRow struct {
+		UserID  string `gorm:"column:user_id"`
+		Money   string `gorm:"column:money"`
+		OrderNo string `gorm:"column:order_no"`
+	}
+	if err := db.Raw("SELECT user_id, money, order_no FROM t_user_divide_into_log WHERE student_id = ?",
+		courseTestStudent).Scan(&divideRow).Error; err != nil {
+		t.Fatalf("回读分账明细失败: %v", err)
+	}
+	if divideRow.UserID != fx.Upline {
+		t.Fatalf("分账明细 user = %s,期望 %s", divideRow.UserID, fx.Upline)
+	}
+	requireMoney(t, "分账明细 money", divideRow.Money, "20.00")
+	if divideRow.OrderNo != itoa(fx.Record.ID) {
+		t.Fatalf("分账 order_no = %q,期望上课记录 ID %d(按课次幂等键)", divideRow.OrderNo, fx.Record.ID)
+	}
+	var upline struct {
+		TotalIncome string `gorm:"column:total_income"`
+	}
+	if err := db.Raw("SELECT total_income FROM t_user_divide_into WHERE user_id = ?", fx.Upline).
+		Scan(&upline).Error; err != nil {
+		t.Fatalf("回读分账节点失败: %v", err)
+	}
+	requireMoney(t, "t_user_divide_into.total_income", upline.TotalIncome, "20.00")
+
+	// ⑤ record 汇总列
+	var rec struct {
+		Status        int32  `gorm:"column:status"`
+		Income        string `gorm:"column:income"`
+		TeacherReward string `gorm:"column:teacher_reward"`
+		StudentNumber int32  `gorm:"column:student_number"`
+	}
+	if err := db.Raw("SELECT status, income, teacher_reward, student_number FROM t_courses_record WHERE id = ?",
+		fx.Record.ID).Scan(&rec).Error; err != nil {
+		t.Fatalf("回读实例失败: %v", err)
+	}
+	if rec.Status != model.CoursesRecordStatusFinished || rec.StudentNumber != 1 {
+		t.Fatalf("record = status %d / students %d,期望 2/1", rec.Status, rec.StudentNumber)
+	}
+	requireMoney(t, "record.income", rec.Income, "100.00")
+	requireMoney(t, "record.teacher_reward", rec.TeacherReward, "100.00")
+
+	// ⑥ 扣课时
+	var enrollment struct {
+		Finished      int32  `gorm:"column:finished_number"`
+		Remainder     int32  `gorm:"column:remainder_number"`
+		RemainderCost string `gorm:"column:remainder_cost"`
+	}
+	if err := db.Raw(`SELECT finished_number, remainder_number, remainder_cost FROM t_courses_student
+		WHERE id = ?`, fx.Enroll.ID).Scan(&enrollment).Error; err != nil {
+		t.Fatalf("回读报课行失败: %v", err)
+	}
+	if enrollment.Finished != 1 || enrollment.Remainder != 9 {
+		t.Fatalf("扣课时 = finished %d / remainder %d,期望 1/9", enrollment.Finished, enrollment.Remainder)
+	}
+	requireMoney(t, "remainder_cost", enrollment.RemainderCost, "900.00")
+
+	// 幂等:再结一次 → changed=false,且分账**不重复入账**
+	code, msg, data = doJSON(t, app, fiber.MethodPost,
+		"/admin/courses/records/"+itoa(fx.Record.ID)+"/finish", map[string]any{}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("重复结课 = %d(%s),期望 200(幂等成功)", code, msg)
+	}
+	if changed, _ := jsonMap(t, data)["changed"].(bool); changed {
+		t.Fatalf("重复结课 changed = true,期望 false(幂等命中:本次未写任何表)")
+	}
+	var logCount int64
+	db.Model(&model.UserDivideIntoLog{}).Where("student_id = ?", courseTestStudent).Count(&logCount)
+	if logCount != 1 {
+		t.Fatalf("分账明细条数 = %d,期望 1(重复结课不得重复入账)", logCount)
+	}
+}
+
+// TestFinishRecordRewardFormula 端点 51 的课酬公式 —— **逐条对齐 PHP 原文**
+// (`CoursesRecordHelper.php:781-821`,权威在应用层而非 dump)。
+//
+// 六个子例,每个对应一条"写错就错钱"的顺序/口径:
+//
+//	① 纯比例:pct=85.0 → 100×85% = **85.00**
+//	② 下限:85% 算出 85 < min 90 → **90.00**
+//	③ 上限:pct=200 → 200 > max 150 → **150.00**
+//	④ **顺序(核心)**:pct=100 + reward 50 = 150,先加再加夹 max 120 → **120.00**
+//	   (错误顺序"先夹 min/max 再加 reward"会得 100+50 = 150 ⇒ 多发 30)
+//	⑤ **顺序(下限侧)**:pct=0 + reward 50 = 50,夹到 min 80 → **80.00**
+//	   (错误顺序会得 clamp(0)=80 + 50 = 130 ⇒ 多发 50)
+//	⑥ **固定课酬 `teacher_reward_type=2`**:忽略 pct/reward,1 人 → makeupCost[1] = **70.00**
+//
+// 变红条件:把顺序写反(④⑤)、漏 elseif 上限优先(③)、漏 type=2 分支(⑥)、
+// 把 percentage 当 0~1 比率(①)—— 任一条都会红。
+func TestFinishRecordRewardFormula(t *testing.T) {
+	cases := []struct {
+		name       string
+		percentage string
+		minPayment string
+		maxPayment string
+		reward     string
+		rewardType int8
+		want       string
+	}{
+		{"纯比例 85%", "85.0", "0.00", "0.00", "0.00", 1, "85.00"},
+		{"下限抬到 90", "85.0", "90.00", "0.00", "0.00", 1, "90.00"},
+		{"上限压到 150", "200.0", "0.00", "150.00", "0.00", 1, "150.00"},
+		{"顺序:先加 reward 再夹上限(reward 被吃掉)", "100.0", "0.00", "120.00", "50.00", 1, "120.00"},
+		{"顺序:先加 reward 再夹下限", "0.0", "80.00", "0.00", "50.00", 1, "80.00"},
+		{"固定课酬 type=2:1 人 → 70", "85.0", "0.00", "0.00", "999.00", 2, "70.00"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			db := newCourseTestDB(t)
+			fx := seedFinishFixture(t, db)
+			if err := db.Model(&model.CoursePaymentDetail{}).Where("id = ?", fx.DetailID).
+				Updates(map[string]any{
+					"percentage":  tc.percentage,
+					"min_payment": tc.minPayment,
+					"max_payment": tc.maxPayment,
+					"reward":      tc.reward,
+				}).Error; err != nil {
+				t.Fatalf("改写课酬规则失败: %v", err)
+			}
+			app := newCourseTestApp(t, db)
+			code, msg, data := doJSON(t, app, fiber.MethodPost,
+				"/admin/courses/records/"+itoa(fx.Record.ID)+"/finish",
+				map[string]any{"rewardType": tc.rewardType}, "X-Test-Token", "t")
+			if code != 200 {
+				t.Fatalf("结束上课 = %d(%s),期望 200", code, msg)
+			}
+			payroll, _ := jsonMap(t, data)["payroll"].(map[string]any)
+			if got, _ := payroll["reward"].(string); got != tc.want {
+				t.Fatalf("payroll.reward = %v,期望 %s(percentage=%s min=%s max=%s reward=%s type=%d,课收入 100.00/1 人)",
+					payroll["reward"], tc.want, tc.percentage, tc.minPayment, tc.maxPayment, tc.reward, tc.rewardType)
+			}
+			// 落库一致性:record.teacher_reward 与打卡行 teacher_reward 都必须等于该值
+			var rec struct {
+				TeacherReward string `gorm:"column:teacher_reward"`
+			}
+			if err := db.Raw("SELECT teacher_reward FROM t_courses_record WHERE id = ?", fx.Record.ID).
+				Scan(&rec).Error; err != nil {
+				t.Fatalf("回读实例失败: %v", err)
+			}
+			requireMoney(t, "record.teacher_reward", rec.TeacherReward, tc.want)
+			var stu struct {
+				TeacherReward string `gorm:"column:teacher_reward"`
+			}
+			if err := db.Raw("SELECT teacher_reward FROM t_courses_record_student WHERE record_id = ? AND student_id = ?",
+				fx.Record.ID, courseTestStudent).Scan(&stu).Error; err != nil {
+				t.Fatalf("回读打卡行失败: %v", err)
+			}
+			requireMoney(t, "打卡行 teacher_reward", stu.TeacherReward, tc.want)
+		})
+	}
+}
+
+// TestFinishRecordRollsBackWhenDivideFails 端点 51 的**回滚变异**:让第 3 步(分账)失败,
+// 断言前两步(扣课时 / spend / 打卡行)**全部回滚**。
+//
+// 手法:请求前 `DROP TABLE t_user_divide_into_log` ⇒ 结课事务内 INSERT 分账明细必然失败。
+// 为什么不用"注入错误":handler 层测试无法替换内部服务(`servicesFor` 现场构造),
+// 而 DROP 表是**真实基础设施故障**的同构复现(表不存在 = SQL 报错 = 事务回滚)。
+//
+// 变红条件:把分账放到事务外(或吞掉错误)→ 断言 1 变红(接口返回 200),断言 2~5 变红(数据已落库)。
+func TestFinishRecordRollsBackWhenDivideFails(t *testing.T) {
+	db := newCourseTestDB(t)
+	fx := seedFinishFixture(t, db)
+	if err := db.Exec("DROP TABLE t_user_divide_into_log").Error; err != nil {
+		t.Fatalf("删分账明细表失败: %v", err)
+	}
+	app := newCourseTestApp(t, db)
+
+	code, msg, _ := doJSON(t, app, fiber.MethodPost,
+		"/admin/courses/records/"+itoa(fx.Record.ID)+"/finish", map[string]any{}, "X-Test-Token", "t")
+	// 断言 1:分账失败必须让整个请求失败,且**必须是数据库级失败**。
+	//
+	// 为什么钉死成 5002:夹具数据全部合法(实例存在且上课中、有在读学生、有课酬方案),
+	// 唯一缺失的是分账明细表 ⇒ 能导致失败的只可能是第 ④ 步的 INSERT。
+	// 若这里变成 2001/3121/3001,说明失败发生在第 ④ 步**之前**,该用例就退化成"空断言"。
+	if code == 200 {
+		t.Fatalf("分账失败时结课仍返回 200(%s):分账没在结课事务内", msg)
+	}
+	if code != infraerrors.ErrDBError.Code {
+		t.Fatalf("失败码 = %d(%s),期望 5002(数据库异常)——"+
+			"若不是 5002,说明失败发生在前几步,回滚断言会变成空断言", code, msg)
+	}
+
+	// 断言 2:报课行扣课时**必须回滚**
+	var enrollment struct {
+		Finished      int32  `gorm:"column:finished_number"`
+		Remainder     int32  `gorm:"column:remainder_number"`
+		RemainderCost string `gorm:"column:remainder_cost"`
+	}
+	if err := db.Raw(`SELECT finished_number, remainder_number, remainder_cost FROM t_courses_student
+		WHERE id = ?`, fx.Enroll.ID).Scan(&enrollment).Error; err != nil {
+		t.Fatalf("回读报课行失败: %v", err)
+	}
+	if enrollment.Finished != 0 || enrollment.Remainder != 10 {
+		t.Fatalf("扣课时未回滚:finished %d / remainder %d,期望 0/10",
+			enrollment.Finished, enrollment.Remainder)
+	}
+	requireMoney(t, "remainder_cost(回滚后)", enrollment.RemainderCost, "1000.00")
+
+	// 断言 3 / 4:两个账户的 spend 必须回滚
+	var global struct {
+		Spend string `gorm:"column:spend"`
+	}
+	db.Raw("SELECT spend FROM t_user_expense WHERE user_id = ?", courseTestStudent).Scan(&global)
+	requireMoney(t, "t_user_expense.spend(回滚后)", global.Spend, "0.00")
+	var school struct {
+		Spend string `gorm:"column:spend"`
+	}
+	db.Raw("SELECT spend FROM t_user_expense_school WHERE user_id = ? AND campus_id = 10", courseTestStudent).Scan(&school)
+	requireMoney(t, "t_user_expense_school.spend(回滚后)", school.Spend, "0.00")
+
+	// 断言 5:打卡行不得留下 cost>0 的痕迹
+	var att struct {
+		Cost string `gorm:"column:cost"`
+	}
+	db.Raw(`SELECT cost FROM t_courses_record_student WHERE record_id = ? AND student_id = ?`,
+		fx.Record.ID, courseTestStudent).Scan(&att)
+	if strings.TrimSpace(att.Cost) != "" {
+		amount, perr := money.Parse(att.Cost)
+		if perr != nil || amount.Cents() != 0 {
+			t.Fatalf("打卡行 cost = %q,期望无行或 0.00(未随失败回滚)", att.Cost)
+		}
+	}
+
+	// 断言 6:实例状态不得变成已结束
+	var rec struct {
+		Status int32 `gorm:"column:status"`
+	}
+	db.Raw("SELECT status FROM t_courses_record WHERE id = ?", fx.Record.ID).Scan(&rec)
+	if rec.Status == model.CoursesRecordStatusFinished {
+		t.Fatalf("实例 status = 2,期望仍为 1(未随失败回滚)")
+	}
+}
+
+// ---------------------------------------------------------------- 作用域 / 鉴权冒烟
+
+// TestCourseEndpointsRequireScope 15 条课程端点全部要求作用域(无 scope → 1001)。
+//
+// 这条防的是"某条端点漏挂 mustScope"(无作用域时 Scoped 会 panic → 5001,
+// 而 5001 看起来像"服务故障",会让人去查 DB 而不是查中间件链)。
+func TestCourseEndpointsRequireScope(t *testing.T) {
+	db := newCourseTestDB(t)
+	app := newCourseTestApp(t, db)
+	cases := []struct {
+		method string
+		path   string
+	}{
+		{fiber.MethodGet, PathCourses},
+		{fiber.MethodPost, PathCourses},
+		{fiber.MethodPut, "/admin/courses/1"},
+		{fiber.MethodGet, "/admin/courses/1/classes"},
+		{fiber.MethodPost, "/admin/courses/1/classes"},
+		{fiber.MethodGet, "/admin/courses/1/records"},
+		{fiber.MethodPost, "/admin/courses/1/records/generate"},
+		{fiber.MethodPost, "/admin/courses/records/1/start"},
+		{fiber.MethodPost, "/admin/courses/records/1/finish"},
+		{fiber.MethodPost, "/admin/courses/records/1/attendance"},
+		{fiber.MethodGet, "/admin/courses/1/students"},
+		{fiber.MethodPost, "/admin/courses/1/students"},
+		{fiber.MethodGet, PathCourseArrears},
+		{fiber.MethodPost, PathCourseArrearsCounteract},
+		{fiber.MethodGet, PathStudentSchedule},
+	}
+	for _, tc := range cases {
+		code, msg, _ := doJSON(t, app, tc.method, tc.path, nil, "X-Test-Token", "t", "X-Test-Scope", "none")
+		if code != infraerrors.ErrUnauth.Code {
+			t.Errorf("%s %s 无作用域时 code = %d(%s),期望 1001", tc.method, tc.path, code, msg)
+		}
+	}
+}

+ 8 - 1
internal/academic/handler/divide_handler.go

@@ -19,7 +19,14 @@
 //
 // 两张表都是**全局推广体系**(推广关系不归属某个机构),数据层无法按机构过滤 ⇒ 防线只有两条:
 //  1. `scope.FromFiber` 必须存在(取不到 → 1001,避免匿名读全平台分账数据);
-//  2. 端点角色白名单(AC-14 落 RBAC):预期 `super_admin / finance / market`。
+//  2. 端点角色白名单(AC-14 落 RBAC,**以契约 §2.3 为唯一权威**):
+//     端点 38~42 → `super_admin, academic`;端点 43(分账流水列表)→ `super_admin, academic, finance`。
+//
+// ⚠ **曾经写错的一处**:本文件原写"预期 `super_admin / finance / market`",与契约 §2.3 冲突。
+// `market` 是**旧系统角色残留**(`t_role` 字典里的历史角色码,新系统 6 个角色码见契约 §1.7,
+// 不含 `market`);按旧写法落种子会得到一条**没有任何角色命中**的白名单 + 一条多余策略,
+// 症状是"分账树对 academic 报 1003"(看起来像 RBAC 没配,实际是这里写错)。
+// 已按契约改正;`RoleMarket` 常量仍保留(路由表角色拼写校验用),但**不得**再出现在本域白名单里。
 //
 // 这也是它与其他端点的本质差异:**分账树是跨机构的,不存在"本机构分账树"**。
 package handler

+ 509 - 0
internal/academic/handler/dto_course.go

@@ -0,0 +1,509 @@
+// 课程域请求 / 响应 DTO(端点 44~57 的**入参形状**与响应拼装)——AC-13c。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md
+//   - §2.4 课程/排班/打卡(端点 44~57 的 method/path/角色/请求要点);
+//   - §3.4 C 表(响应字段名,**唯一权威**)+ 总原则 ③「字段准入条件」;
+//   - §1.5 B1/B2(时间与量纲:`startTime`/`endTime` 是业务时间列,金额是两位小数字符串)。
+//
+// ---------------------------------------------------------------------------
+// 三条 DTO 铁律(与 `dto_fund.go` / `dto_activity.go` 同源,**不另立一套**)
+// ---------------------------------------------------------------------------
+//
+//  1. **金额一律 `string`**(`"100.00"`),Handler 用 `parseMoney` / `parseMoneyOrZero` 解析;
+//     不声明 float64(浮点误差)也不声明 money.Amount(会让 JSON number 被静默接受)。
+//  2. **缺参 → 2001 由 `validate` tag 表达;值非法 → 2002 由 Handler 显式判**。枚举字段
+//     不写 `oneof=`(`oneof` 失败会被归成 2001,而"传了非法枚举"属 2002)。
+//  3. **响应字段逐个列溯源**:每个 JSON 键都必须能追到一个具体列,写不出来的一律不做
+//     (本批因此删了 `room` 与 `divide[].level`,并保留了 `week` 的字符串原值——见各结构注释)。
+//
+// ---------------------------------------------------------------------------
+// 本批**不接受**的请求字段(写了也无效,故不声明)
+// ---------------------------------------------------------------------------
+//
+//	institutionId   由请求作用域决定(非超管传别的机构 → 1004,由 `resolveInstitutionID` 判)
+//	status(课程)   发布状态机专用入口,不在 CRUD 里改(AC-11 `CreateCourse` 恒落"未发布")
+//	numberSign /
+//	unpaid / unpaid 类欠费列 —— **废弃列**(2018-05 停更),任何入参都不写它
+package handler
+
+import "time"
+
+// ---------------------------------------------------------------- 端点 45:课程 CRUD
+
+// CreateCourseReq 端点 45 新建课程请求体(`POST /admin/courses`)。
+//
+// 字段 → 目标列(DDL 第四部分 t_courses):
+//
+//	name→name / subjectId→subject_id / gradeId→grade_id / type→type / classifyId→classify_id /
+//	number→number / duration→duration / week→week / startTime→start_time / endTime→end_time /
+//	address→address / originalCost→original_cost / realPrice→real_price /
+//	discountCash→discount_cash / discountRatio→discount_ratio / maxPeople→max_people / remark→remark
+type CreateCourseReq struct {
+	// Name 课程名称(必填)。
+	Name string `json:"name" validate:"required,max=500"`
+	// SubjectID 科目(0 = 混合)。
+	SubjectID int32 `json:"subjectId"`
+	// GradeID 年级(0 = 混合)。
+	GradeID int32 `json:"gradeId"`
+	// Type 班型编码(0 → 1000 集体班)。
+	Type int32 `json:"type"`
+	// ClassifyID 课程分类(0 = 不挂分类;>0 时须存在且同作用域,否则 3001)。
+	ClassifyID int64 `json:"classifyId"`
+	// Number 上课次数(>=0)。
+	Number int32 `json:"number"`
+	// Duration 单节课时长("1.5" 这类课时字符串,1 位小数;**必填**,>0)。
+	Duration string `json:"duration" validate:"required,max=10"`
+	// Week 周规则("-1" 全部 / "0" 周日 / "1".."6" / 逗号多值;空 → "-1")。
+	Week string `json:"week" validate:"max=50"`
+	// StartTime 课程有效期起(RFC3339 或 `2006-01-02`;可空)。
+	StartTime string `json:"startTime"`
+	// EndTime 课程有效期止(同上;不得早于 StartTime → 否则 2002)。
+	EndTime string `json:"endTime"`
+	// Address 上课地址。
+	Address string `json:"address"`
+	// OriginalCost 原价(元字符串;可空 → "0.00")。
+	OriginalCost string `json:"originalCost"`
+	// RealPrice 实价(元字符串;可空 → "0.00")。
+	RealPrice string `json:"realPrice"`
+	// DiscountCash 现金折扣(元字符串;**允许负**,模型红线)。
+	DiscountCash string `json:"discountCash"`
+	// DiscountRatio 折扣率(整数百分比,仅展示/试算)。
+	DiscountRatio int32 `json:"discountRatio"`
+	// MaxPeople 最多人数(0 → 由修复 SP 从分类继承)。
+	MaxPeople int32 `json:"maxPeople"`
+	// Remark 备注。
+	Remark string `json:"remark"`
+}
+
+// UpdateCourseReq 端点 45 更新课程请求体(`PUT /admin/courses/:id`)。
+//
+// 语义:**部分更新**(nil = 不改)。与新建的唯一差异是"全部可空",因此一律用指针,
+// 以便区分"没传"(不改)与"传了零值"(改成 0 / 空串)。
+type UpdateCourseReq struct {
+	// Name 课程名称(传了就不得为空)。
+	Name *string `json:"name"`
+	// SubjectID 科目。
+	SubjectID *int32 `json:"subjectId"`
+	// GradeID 年级。
+	GradeID *int32 `json:"gradeId"`
+	// Type 班型。
+	Type *int32 `json:"type"`
+	// ClassifyID 分类(>=0;>0 须存在)。
+	ClassifyID *int64 `json:"classifyId"`
+	// Number 上课次数(>=0)。
+	Number *int32 `json:"number"`
+	// Duration 单节课时长(课时字符串,>0)。
+	Duration *string `json:"duration"`
+	// Week 周规则。
+	Week *string `json:"week"`
+	// StartTime 有效期起。
+	StartTime *string `json:"startTime"`
+	// EndTime 有效期止。
+	EndTime *string `json:"endTime"`
+	// Address 上课地址。
+	Address *string `json:"address"`
+	// OriginalCost 原价。
+	OriginalCost *string `json:"originalCost"`
+	// RealPrice 实价。
+	RealPrice *string `json:"realPrice"`
+	// DiscountCash 现金折扣(允许负)。
+	DiscountCash *string `json:"discountCash"`
+	// DiscountRatio 折扣率。
+	DiscountRatio *int32 `json:"discountRatio"`
+	// MaxPeople 最多人数。
+	MaxPeople *int32 `json:"maxPeople"`
+	// Remark 备注。
+	Remark *string `json:"remark"`
+}
+
+// ---------------------------------------------------------------- 端点 47:新建排班规则
+
+// CreateClassReq 端点 47 新建排班规则请求体(`POST /admin/courses/:id/classes`)。
+//
+// 时分用 `"HH:MM"` 字符串(而非 datetime):旧库该两列是"时分语义",date 部分无业务含义。
+type CreateClassReq struct {
+	// ClassID 班级 ID(旧库外部班级 id;0 = 不指定)。
+	ClassID int64 `json:"classId"`
+	// TeacherID 老师 ULID(可空:空 = 未定老师;给了才会做 3122 冲突判定)。
+	TeacherID string `json:"teacherId" validate:"max=26"`
+	// Week 周规则("-1"/"0".."6"/逗号多值;空 → "-1")。
+	Week string `json:"week" validate:"max=50"`
+	// Start 上课时分(`"09:00"`;**必填**)。
+	Start string `json:"start" validate:"required,max=5"`
+	// End 下课时分(`"10:30"`;可空 → 按课程 duration 推算;给了必须晚于 Start)。
+	End string `json:"end" validate:"max=5"`
+	// StandardLessonCost 课程标准单价(元字符串;可空 → "0.00")。
+	StandardLessonCost string `json:"standardLessonCost"`
+	// LessonCost 一次课课费(元字符串;可空 → "0.00";打卡扣课时依据)。
+	LessonCost string `json:"lessonCost"`
+	// DiscountCash 课程优惠(元字符串;允许负)。
+	DiscountCash string `json:"discountCash"`
+	// Number 开课总量(课次;>=0)。
+	Number int32 `json:"number"`
+	// Address 上课地址。
+	Address string `json:"address"`
+	// Remark 备注。
+	Remark string `json:"remark"`
+}
+
+// ---------------------------------------------------------------- 端点 49:生成课表
+
+// GenerateRecordsReq 端点 49 生成课表请求体(`POST /admin/courses/:id/records/generate`)。
+//
+// 两个字段都可空(空 → days=7、from=今天),但**传了必须合法**(非法 → 2002)。
+type GenerateRecordsReq struct {
+	// Days 生成窗口天数(<=0 → 7;>366 → 钳制 366)。
+	Days int `json:"days"`
+	// From 窗口起点(`2006-01-02` 或 RFC3339;只取日期部分;空 → 服务端当天)。
+	From string `json:"from"`
+}
+
+// ---------------------------------------------------------------- 端点 51 / 52:打卡明细
+
+// AttendanceItemReq 单条打卡明细(端点 52 请求 `list[]` 元素,端点 51 的 `items[]` 同构)。
+//
+// 字段 → 目标列(t_courses_record_student):
+//
+//	userId→student_id / type→type / pay→pay / cost→cost / giveCost→give_cost / remark→remark
+//
+// ⚠ `pay` 是**是否已支付**(int8 0/1),不是金额;金额是 `cost`。契约 §3.4 C 行 52 明确
+// 请求体为 `list:[{userId,type,pay,cost,giveCost}]`(mock 旧名 `attendance/payCost` 已废弃)。
+type AttendanceItemReq struct {
+	// UserID 学生 ULID(必填)。
+	UserID string `json:"userId" validate:"required,max=26"`
+	// Type 上课类型(1 正常 / 2 插班 / 3 赠送;0 → 1;其它 → 2002)。
+	Type int32 `json:"type"`
+	// Pay 是否已支付(0 未付 / 1 已付;nil = 不改,结课时按"已报课即已付")。
+	Pay *int8 `json:"pay"`
+	// Cost 本次课费(元字符串;可空 = 取报课记录的 lesson_cost 快照)。
+	Cost string `json:"cost"`
+	// GiveCost 本次使用赠送额(元字符串;可空 → 0)。
+	GiveCost string `json:"giveCost"`
+	// Remark 备注。
+	Remark string `json:"remark"`
+}
+
+// MarkAttendanceReq 端点 52 打卡请求体(`POST /admin/courses/records/:recordId/attendance`)。
+type MarkAttendanceReq struct {
+	// List 打卡明细(必填非空;同一学生出现两次 → 3140)。
+	List []AttendanceItemReq `json:"list" validate:"required,min=1,dive"`
+}
+
+// FinishRecordReq 端点 51 结束上课请求体(`POST /admin/courses/records/:recordId/finish`)。
+type FinishRecordReq struct {
+	// Items 本次打卡明细(**可空**:空 = 按在读学生全勤自动生成,成本取 lesson_cost 快照)。
+	Items []AttendanceItemReq `json:"items"`
+	// ActualEndTime 实际下课时间(RFC3339 或 `2006-01-02`;空 → 服务端当前时刻)。
+	ActualEndTime string `json:"actualEndTime"`
+	// RewardType 课酬结算方式(0 → 1 正常 / 2 按原课程老师课酬无最低课酬;其它 → 2002)。
+	RewardType int8 `json:"rewardType"`
+	// Remark 结课备注(当前仅日志展示,不落库——`t_courses_record.remark` 属排班备注)。
+	Remark string `json:"remark"`
+}
+
+// ---------------------------------------------------------------- 端点 54:报名
+
+// EnrollReq 端点 54 报名请求体(`POST /admin/courses/:id/students`)。
+//
+// ⚠ **契约 §2.4 端点 54 只给了响应字段,没有入参形状** ⇒ 本请求体是 AC-13c 自定义的
+// 最小可对账集合(诊断见 `service.EnrollInput` 注释),已在交付报告登记为「契约缺口待补」。
+type EnrollReq struct {
+	// UserID 学生 ULID(必填)。
+	UserID string `json:"userId" validate:"required,max=26"`
+	// ClassID 班级 ID(0 = 不指定)。
+	ClassID int64 `json:"classId"`
+	// CampusID 校区(0 = 用作用域校区;>0 且与作用域校区不一致 → 1004)。
+	CampusID int64 `json:"campusId"`
+	// Pay 本次实收(元字符串,必填;**不写资金流水**,只做报课行快照)。
+	Pay string `json:"pay" validate:"required"`
+	// RemainderReceivable 剩余应收(元字符串;可空 → 0 = 全额缴清)。
+	RemainderReceivable string `json:"remainderReceivable"`
+	// LessonCost 单节课费(元字符串,必填;扣课时依据)。
+	LessonCost string `json:"lessonCost" validate:"required"`
+	// Number 课次(>=0)。
+	Number int32 `json:"number"`
+	// GiveNumber 赠送课次(>=0)。
+	GiveNumber int32 `json:"giveNumber"`
+	// DiscountCash 现金折扣(元字符串;允许负)。
+	DiscountCash string `json:"discountCash"`
+	// GiveCost 已用赠送额(元字符串;可空 → 0)。
+	GiveCost string `json:"giveCost"`
+	// RecommenderUserID 推荐人 ULID(可空;分账上行起点)。
+	RecommenderUserID string `json:"recommenderUserId" validate:"max=26"`
+	// Remark 备注。
+	Remark string `json:"remark"`
+}
+
+// ---------------------------------------------------------------- 端点 56:欠费核销
+
+// CounteractArrearsReq 端点 56 欠费核销请求体(`POST /admin/courses/arrears/counteract`)。
+//
+// 契约 §2.4 行 56 原文:`studentId,institutionId,campusId,money`(**四个都必填**)。
+// `institutionId`/`campusId` 是 SP27 的**显式数据范围参数**(不是作用域推断),
+// 因此非超管传了与本作用域不一致的值 → **1004**(不是 2002)。
+type CounteractArrearsReq struct {
+	// StudentID 学生 ULID(必填)。
+	StudentID string `json:"studentId" validate:"required,max=26"`
+	// InstitutionID 机构 ID(必填 >0)。
+	InstitutionID int64 `json:"institutionId" validate:"required,gt=0"`
+	// CampusID 校区 ID(必填 >0)。
+	CampusID int64 `json:"campusId" validate:"required,gt=0"`
+	// Money 待核销金额(元字符串,必填;<=0 → 服务端 no-op,不是错误)。
+	Money string `json:"money" validate:"required"`
+}
+
+// ---------------------------------------------------------------- 响应拼装(只放契约要求的键)
+
+// courseSavedResp 端点 45 响应(契约 §3.4 C 行 45:只要求 `id`)。
+type courseSavedResp struct {
+	// ID 课程 ID。
+	ID int64 `json:"id"`
+}
+
+// courseRowView 端点 44 的行(契约 §3.4 C 行 44)。
+//
+// 键名 `classify`(不是 `classifyId`)是**契约既定**:前端按 `classify` 读,改名会静默空白。
+type courseRowView struct {
+	// ID 课程 ID。
+	ID int64 `json:"id"`
+	// InstitutionID 机构 ID。
+	InstitutionID int64 `json:"institutionId"`
+	// Name 课程名称。
+	Name string `json:"name"`
+	// Classify 课程分类 ID(源列 `classify_id`;契约键名 `classify`)。
+	Classify int64 `json:"classify"`
+	// Status 课程状态(0 未发布 / 1 发布未开始 / 2 进行中 / 3 已结束)。
+	Status int32 `json:"status"`
+	// TeacherIDs 排班规则上的老师(去重升序;空 → [])。
+	TeacherIDs []string `json:"teacherIds"`
+	// RecordCount 排班实例条数(全状态)。
+	RecordCount int64 `json:"recordCount"`
+}
+
+// classCreatedResp 端点 47 响应(契约 §3.4 C 行 47:`classId`)。
+type classCreatedResp struct {
+	// ClassID 新规则的班级 ID(`t_courses_class.class_id`)。
+	ClassID int64 `json:"classId"`
+}
+
+// classRowView 端点 46 的行(契约 §3.4 C 行 46 + 本批两处偏差)。
+//
+// ⚠ 与契约的两处差异(已在交付报告登记):
+//
+//	① `week` 下发**原值字符串**而不是 1~7 的数字:该列取值 `"-1"`(全部周)/`"0"`..`"6"`/
+//	   逗号多值,"1~7 数字"表达不了「全部周」与「多值」;
+//	② **无 `room`**:`t_courses_class` 没有 room 列(只有 address/remark),按 §3.4 ③ 不下发。
+type classRowView struct {
+	// ClassID 班级 ID(源列 `class_id`)。
+	ClassID int64 `json:"classId"`
+	// CourseID 课程 ID。
+	CourseID int64 `json:"courseId"`
+	// TeacherID 老师 ULID(可空 → 空串)。
+	TeacherID string `json:"teacherId"`
+	// Week 周规则原值("-1"/"0".."6"/逗号多值)。
+	Week string `json:"week"`
+	// Start 上课时分(**date 部分恒 1970-01-01**,只取时分;见 model.CoursesClass 注释)。
+	Start *time.Time `json:"start"`
+	// End 下课时分(可空)。
+	End *time.Time `json:"end"`
+	// CampusID 校区 ID。
+	CampusID int64 `json:"campusId"`
+}
+
+// recordRowView 端点 48 的行(契约 §3.4 C 行 48)。
+//
+// ⚠ **无 `room`**(同端点 46:`t_courses_record` 只有 `address`,没有 room 列)。
+type recordRowView struct {
+	// RecordID 排班实例 ID。
+	RecordID int64 `json:"recordId"`
+	// CourseID 课程 ID。
+	CourseID int64 `json:"courseId"`
+	// TeacherID 老师 ULID。
+	TeacherID string `json:"teacherId"`
+	// CampusID 校区 ID。
+	CampusID int64 `json:"campusId"`
+	// StartTime 上课时间。
+	StartTime *time.Time `json:"startTime"`
+	// EndTime 下课时间。
+	EndTime *time.Time `json:"endTime"`
+	// Status 上课状态(0 未开始 / 1 上课中 / 2 已结束)。
+	Status int32 `json:"status"`
+	// StudentCount 上课人数(源列 `student_number`,结课时写入)。
+	StudentCount int32 `json:"studentCount"`
+}
+
+// generateRecordsResp 端点 49 响应(契约 §3.4 C 行 49)。
+type generateRecordsResp struct {
+	// Generated 本次新建的排班实例条数(幂等:第二次为 0)。
+	Generated int `json:"generated"`
+	// Days 生效的生成窗口天数(已钳制)。
+	Days int `json:"days"`
+	// Changed 本次是否真的写了库(false = 无新增)。
+	Changed bool `json:"changed"`
+}
+
+// startRecordResp 端点 50 响应(契约 §3.4 C 行 50)。
+type startRecordResp struct {
+	// RecordID 排班实例 ID。
+	RecordID int64 `json:"recordId"`
+	// Status 新状态(恒 1 = 上课中)。
+	Status int32 `json:"status"`
+	// CourseID 课程 ID。
+	CourseID int64 `json:"courseId"`
+}
+
+// attendanceResp 端点 52 响应(契约 §3.4 C 行 52:`recordId` + `checked`)。
+type attendanceResp struct {
+	// RecordID 排班实例 ID。
+	RecordID int64 `json:"recordId"`
+	// Checked 当前打卡人数(= 该实例的打卡行总数,含此前已打卡的)。
+	Checked int32 `json:"checked"`
+}
+
+// divideGrantView 端点 51 `divide[]` 的元素 —— **团队裁决后的最终形状 `[{userId, type, amount}]`**。
+//
+// 与契约 v1.6 mock 的差异(**已批准,待契约升版,前端清单由 team-lead 统一出**):
+//
+//	删 `level` —— `t_user_divide_into_log` 无层级列;`t_user_divide_into.layer` 是当前层级,
+//	              不是入账当时的层级,用当前值顶替 = 伪造历史。
+//	删 `quota` —— 节点 quota 事后可被 `update_quota` 改,日志未留"当时额度",同理不得顶替。
+//	增 `type`  —— 源列 `type`(1 一次付清 / 2 按课次),语义清晰且**确实存在于日志行上**。
+//	增 `userId`— 源列 `user_id`(谁分了这笔钱),是数组最关键的维度。
+//	留 `amount`— 源列 `money`。
+//
+// ⚠ 不得为了迁就旧 mock 给 `level` 填常量(有值、恒为某常数、永远查不出是假的)。
+type divideGrantView struct {
+	// UserID 获得分成的用户(源列 `t_user_divide_into_log.user_id`)。
+	UserID string `json:"userId"`
+	// Type 分成类型(源列 `type`:1 一次付清 / 2 按课次)。
+	Type int32 `json:"type"`
+	// Amount 本次入账金额(源列 `money`)。
+	Amount string `json:"amount"`
+}
+
+// payrollTraceView 端点 51 `payroll` 对象。
+type payrollTraceView struct {
+	// DetailID 命中的课酬规则 ID(0 = 未配置方案/无法定位)。
+	DetailID int64 `json:"detailId"`
+	// Reward 本课课酬合计(源列 `t_courses_record.teacher_reward`,**落库值**)。
+	Reward string `json:"reward"`
+}
+
+// perStudentView 端点 51 `perStudent[]` 的元素(契约 §3.4 C 行 51,字段名与端点 52 请求同义)。
+type perStudentView struct {
+	// UserID 学生 ULID。
+	UserID string `json:"userId"`
+	// Type 上课类型(1 正常 / 2 插班 / 3 赠送)。
+	Type int32 `json:"type"`
+	// Pay 是否已支付。
+	Pay bool `json:"pay"`
+	// Cost 本次课费。
+	Cost string `json:"cost"`
+	// GiveCost 本次使用赠送额。
+	GiveCost string `json:"giveCost"`
+	// Counteracted 该学生本次是否触发了分账入账(源列:本次是否新增
+	// `t_user_divide_into_log` 行;**不是**欠费核销,契约命名与语义在报告登记)。
+	Counteracted bool `json:"counteracted"`
+}
+
+// finishRecordResp 端点 51 响应(契约 §3.4 C 行 51)。
+type finishRecordResp struct {
+	// RecordID 排班实例 ID。
+	RecordID int64 `json:"recordId"`
+	// CourseID 课程 ID。
+	CourseID int64 `json:"courseId"`
+	// Status 上课状态(恒 2 = 已结束)。
+	Status int32 `json:"status"`
+	// Changed 本次是否真的写了库(false = 幂等命中:未重复扣课时/未重复分账)。
+	Changed bool `json:"changed"`
+	// TotalSpend 本次扣课时合计(= `t_courses_record.income` = Σ record_student.cost)。
+	TotalSpend string `json:"totalSpend"`
+	// Divide 本次分账入账明细(空 → [])。
+	Divide []divideGrantView `json:"divide"`
+	// Payroll 课酬溯源。
+	Payroll payrollTraceView `json:"payroll"`
+	// PerStudent 逐学生打卡明细(空 → [])。
+	PerStudent []perStudentView `json:"perStudent"`
+}
+
+// studentRowView 端点 53 的行(契约 §3.4 C 行 53)。
+//
+// `realName` 来源:`t_user.display_name`,经 `port.UserNameReader` 跨域只读(D4 裁决)。
+// ⚠ **查不到时下发 `null` 而不是空串**:空串会让前端无法区分"没有这个人"与"姓名为空";
+// 端口未注入(nil)时同样是 `null`(页面少一列,不影响其它字段)。
+type studentRowView struct {
+	// UserID 学生 ULID(源列 `student_id`)。
+	UserID string `json:"userId"`
+	// RealName 学生显示名(源列 `t_user.display_name`;查不到 → null)。
+	RealName *string `json:"realName"`
+	// Pay 应收(源列 `receivable`)。
+	Pay string `json:"pay"`
+	// Cost 实收(源列 `net_receipts`)。
+	Cost string `json:"cost"`
+	// GiveCost 已用赠送额(源列 `give_cost`)。
+	GiveCost string `json:"giveCost"`
+	// RemainderReceivable 剩余应收(源列 `remainder_receivable`,**欠费真值**)。
+	RemainderReceivable string `json:"remainderReceivable"`
+	// Status 报课状态(1 正常 / 2 结束,数字码)。
+	Status int32 `json:"status"`
+}
+
+// enrollResp 端点 54 响应(契约 §3.4 C 行 54)。
+type enrollResp struct {
+	// Enrolled 是否已报名(成功恒 true)。
+	Enrolled bool `json:"enrolled"`
+	// CourseID 课程 ID。
+	CourseID int64 `json:"courseId"`
+	// UserID 学生 ULID。
+	UserID string `json:"userId"`
+}
+
+// arrearsRowView 端点 55 的行(契约 §3.4 C 行 55)。
+type arrearsRowView struct {
+	// StudentID 学生 ULID。
+	StudentID string `json:"studentId"`
+	// CourseID 课程 ID。
+	CourseID int64 `json:"courseId"`
+	// RemainderReceivable 剩余应收(>0;**唯一欠费口径**)。
+	RemainderReceivable string `json:"remainderReceivable"`
+	// Counteracted 该报课行是否发生过实收/核销(源列 `net_receipts > 0`;契约未定义判据,见报告)。
+	Counteracted bool `json:"counteracted"`
+}
+
+// counteractResp 端点 56 响应(契约 §3.4 C 行 56)。
+type counteractResp struct {
+	// Counteracted 本次是否真的核销了金额(false = 无欠费/金额<=0,服务端 no-op)。
+	Counteracted bool `json:"counteracted"`
+	// StudentID 学生 ULID。
+	StudentID string `json:"studentId"`
+	// Amount 实际核销总额(元;未核销 → "0.00")。
+	Amount string `json:"amount"`
+}
+
+// scheduleRowView 端点 57 的行(**字段名待移动端契约定稿**,见 service.ScheduleRow 注释)。
+type scheduleRowView struct {
+	// RecordID 排班实例 ID。
+	RecordID int64 `json:"recordId"`
+	// CourseID 课程 ID。
+	CourseID int64 `json:"courseId"`
+	// CourseName 课程名称(回查 t_courses.name)。
+	CourseName string `json:"courseName"`
+	// TeacherID 老师 ULID。
+	TeacherID string `json:"teacherId"`
+	// CampusID 校区 ID。
+	CampusID int64 `json:"campusId"`
+	// StartTime 上课时间。
+	StartTime *time.Time `json:"startTime"`
+	// EndTime 下课时间。
+	EndTime *time.Time `json:"endTime"`
+	// Status 上课状态(0/1/2)。
+	Status int32 `json:"status"`
+	// Cost 本次课费(无打卡行 → "0.00")。
+	Cost string `json:"cost"`
+	// GiveCost 本次使用赠送额(无打卡行 → "0.00")。
+	GiveCost string `json:"giveCost"`
+	// Pay 是否已支付(无打卡行 → false)。
+	Pay bool `json:"pay"`
+	// Checked 是否已有本人的打卡行。
+	Checked bool `json:"checked"`
+}

+ 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"`
+}

+ 250 - 0
internal/academic/handler/dto_payroll.go

@@ -0,0 +1,250 @@
+// 课酬 / 维护 端点请求 DTO(端点 59、61、62、63)——AC-13d 第一批。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5(端点表)/ §3.4 D 表(字段级);
+// 设计 `academic-domain-design.md` §4.5(端点清单与角色)/ §3.5(SP28/SP29)/ §3.6(SP30/SP31)。
+//
+// ---------------------------------------------------------------------------
+// 本批**只做 59/61/62/63**,58 与 60 因契约形状冲突暂停(已登记偏差,等裁决)
+// ---------------------------------------------------------------------------
+//
+//		58 课酬方案 —— 契约把"方案(t_course_payment)"与"明细(t_course_payment_detail)"**压成一行**:
+//		   `id/schoolId/campusId/effectDate` 是方案列,`teacherId/classifyId/percentage/min/max/reward` 是明细列,
+//		   `actived` 两级都有;且 `teacherExp:number` **一个字段要承载 `teacher_exp_min` + `teacher_exp_max` 两列**。
+//	  ⇒ 需要契约先回答三件事:入参是否嵌套 `details[]`、`id` 指方案还是明细、教龄区间怎么表达。
+//	    在此之前**不落代码**(按"字段准入条件"硬门槛,写不出源列就不进契约)。
+//
+//		60 收支 —— 契约 `GET/POST /admin/expenditures[/types]` 把**字典**(`t_expenditure`)与
+//		   **记录**(`t_expenditure_log`)放在同一组路径下,而行字段 `type/name/direction` 三个里
+//		   `name`/`direction` 在记录表**不存在**(记录表只有 `expenditure_id`,方向要 JOIN 字典的 `type`);
+//		   `bizNo` 只在记录表有。⇒ 需要契约把"字典"与"记录"的响应分别定义(当前一行行结构无法同时满足两者)。
+//
+// 下面四个端点的字段全部可逐列溯源,故先行落地。
+package handler
+
+import "time"
+
+// ---------------------------------------------------------------- 端点 59:课酬试算
+
+// PayrollQuoteReq 端点 59 课酬试算请求体(`POST /admin/payroll/quote`)。
+//
+// 契约 §2.5 行 59 原文入参:`teacherId,schoolId,campusId,classifyId,teacherExp,date`(六个都必填)。
+//
+// ⚠ **本端点无法给出"最终课酬金额"**:最终金额还需要 `income`(本次课收入)、`studentCount`、
+// `teacherRewardType` 三个入参,契约没给 ⇒ 本端点只返回**命中的规则**(含 percentage/min/max/reward),
+// 由前端自行展示;**最终金额只在结课(端点 51)产生**,两者共用同一 `computeRecordReward`(不重复实现公式)。
+// 已在交付报告登记为偏差(若产品要"试算金额",契约补三个入参即可)。
+type PayrollQuoteReq struct {
+	// TeacherID 老师 ULID(必填)。
+	TeacherID string `json:"teacherId" validate:"required,max=26"`
+	// SchoolID 机构 ID(必填 >0;旧口径 schoolid = 目标态 institution_id)。
+	SchoolID int64 `json:"schoolId" validate:"required,gt=0"`
+	// CampusID 校区 ID(0 = 机构级方案;契约要求必传,0 合法)。
+	CampusID int64 `json:"campusId"`
+	// ClassifyID 课程分类 ID(必填 >0;提供 grade/subject/class_type 三个维度)。
+	ClassifyID int64 `json:"classifyId" validate:"required,gt=0"`
+	// TeacherExp 教龄(年;可含小数;负数按 0,与 dump 763609 一致)。
+	TeacherExp float64 `json:"teacherExp"`
+	// Date 试算日期(`2006-01-02` 或 RFC3339;方案 `effect_date <= date`,**含当日**)。
+	Date string `json:"date" validate:"required"`
+}
+
+// payrollQuoteView 端点 59 响应(契约 §4.6 `PayrollQuote` 的 9 个字段,**不得裁掉溯源字段**)。
+//
+// 字段来源(逐列溯源):
+//
+//	detailId       ← t_course_payment_detail.id
+//	schemeId       ← t_course_payment.id
+//	schemeCampusId ← t_course_payment.campus_id(0 = 机构级方案)
+//	schemeScope    ← 由命中层级推导(campus | institution)——**不是列**,而是"从哪个 campus_id 查到的"
+//	ruleTier       ← 由命中规则类型推导(personal_classify | personal_grade_subject | cert_general | nocert_general)
+//	percentage     ← t_course_payment_detail.percentage(**百分数字符串**,非金额)
+//	min            ← min_payment;max ← max_payment;reward ← reward
+//
+// `schemeScope` / `ruleTier` 属**可推导的溯源标签**(不是新造业务值):它们回答"这条规则为什么被命中",
+// 是排障必需项(§4.6 v0.1.29 明确"不得裁掉")。
+type payrollQuoteView struct {
+	// DetailID 命中的课酬明细规则 ID。
+	DetailID int64 `json:"detailId"`
+	// SchemeID 命中的课酬方案 ID。
+	SchemeID int64 `json:"schemeId"`
+	// SchemeCampusID 命中方案所属校区(0 = 机构级)。
+	SchemeCampusID int64 `json:"schemeCampusId"`
+	// SchemeScope 方案层级(campus / institution)。
+	SchemeScope string `json:"schemeScope"`
+	// RuleTier 规则层级(personal_classify / personal_grade_subject / cert_general / nocert_general)。
+	RuleTier string `json:"ruleTier"`
+	// Percentage 课酬比例(**百分数字符串** "85.0",不是 0.85、不是金额)。
+	Percentage string `json:"percentage"`
+	// Min 最低课酬标准(元字符串;"0.00" = 不限制)。
+	Min string `json:"min"`
+	// Max 最高课酬标准(元字符串;"0.00" = 不限制)。
+	Max string `json:"max"`
+	// Reward 额外课酬(元字符串;在比例基础上追加)。
+	Reward string `json:"reward"`
+}
+
+// ---------------------------------------------------------------- 端点 61 / 62 / 63:维护
+
+// MaintenanceResultResp 维护类端点(61/62/63)的响应(契约 §3.4 D 表:`affected` + `message`)。
+//
+// `affected` = 本次**实际写库**的行数;`message` = 面向运维的一段人话(含"可重跑/已是最新"判据)。
+// 三个端点响应同构,用同一结构体(避免三份一模一样的东西漂移)。
+type MaintenanceResultResp struct {
+	// Affected 本次实际影响(写入)的行数。
+	Affected int64 `json:"affected"`
+	// Message 结果说明(含幂等判据:第二次执行应为 0 / 已是最新)。
+	Message string `json:"message"`
+}
+
+// ActivitySyncReq 端点 63 活动数据同步请求体(`POST /admin/maintenance/activity-sync`)。
+//
+// 契约 §2.5 行 63:`mode` ∈ {`used-log`, `signed`};缺省/非法 → **2001**(契约明确写 2001,
+// 不是 2002——因为"缺参"与"枚举非法"在这里都按"必须指定同步模式"处理)。
+type ActivitySyncReq struct {
+	// Mode 同步模式(`used-log` → SP30 重建 used_log;`signed` → SP31 重放 signed)。
+	//
+	// ⚠ **不写 `validate:"required"`**:`required` 失败统一归 2001,而本端点要把
+	// "缺失"(2001)与"传了但非法"(2002)分成两个码 ⇒ 缺失由 Handler 显式判。
+	Mode string `json:"mode"`
+}
+
+// ---------------------------------------------------------------- 端点 58:课酬方案 + 明细
+
+// PayrollPlanDetailReq 明细入参(POST / PUT 共用,**两级形状的内层**)。
+//
+// 字段 → 目标列(`t_course_payment_detail`):
+//
+//	teacherId→teacher_id / classifyId→classify_id / classType→class_type / gradeId→grade_id /
+//	subjectId→subject_id / teacherCert→teacher_cert / teacherExpMin→teacher_exp_min /
+//	teacherExpMax→teacher_exp_max / percentage→percentage / min→min_payment / max→max_payment /
+//	reward→reward / displayName→name / detailActived→actived
+//
+// ⚠ 三处命名**故意与契约 mock 不同**(D17 裁决,因两表同名列会互相覆盖):
+//
+//	`displayName` ← 明细 `name`(计算方式名);方案名走 `schemeName`;
+//	`detailActived` ← 明细 `actived`;方案级启用走 `actived`;
+//	教龄是**区间两字段** `teacherExpMin/teacherExpMax`(单值装不下区间,且前端改档会把 max 写坏)。
+type PayrollPlanDetailReq struct {
+	// TeacherID 老师 ULID(空 = 通用规则;个人规则才填)。
+	TeacherID string `json:"teacherId" validate:"max=26"`
+	// ClassifyID 课程分类(0 = 不限定分类;个人规则精确档用)。
+	ClassifyID int64 `json:"classifyId"`
+	// ClassType 班级类型(对应 t_courses_classify.class_type;必填 >0)。
+	ClassType int32 `json:"classType" validate:"required,gt=0"`
+	// GradeID 年级(-1 = 通配;0 = 混合)。
+	GradeID int32 `json:"gradeId"`
+	// SubjectID 科目(-1 = 通配;0 = 混合)。
+	SubjectID int32 `json:"subjectId"`
+	// TeacherCert 是否持证档(true = 持证通用档;false = 通用/无证档)。
+	TeacherCert bool `json:"teacherCert"`
+	// TeacherExpMin 教龄区间下界("1.000";空 → "0.000")。
+	TeacherExpMin string `json:"teacherExpMin" validate:"max=10"`
+	// TeacherExpMax 教龄区间上界("5.000";空 → "0.000";**不得小于 Min**,否则 2002)。
+	TeacherExpMax string `json:"teacherExpMax" validate:"max=10"`
+	// Percentage 课酬比例(**百分数** "85.0" = 85%;0~100,最多 1 位小数;必填)。
+	Percentage string `json:"percentage" validate:"required,max=10"`
+	// Min 最低课酬标准(元字符串;可空 → "0.00" = 不限制)。
+	Min string `json:"min"`
+	// Max 最高课酬标准(元字符串;可空 → "0.00" = 不限制)。
+	Max string `json:"max"`
+	// Reward 额外课酬(元字符串;可空 → "0.00")。
+	Reward string `json:"reward"`
+	// DisplayName 计算方式名称(源列 `name`;与方案名 schemeName 分开)。
+	DisplayName string `json:"displayName" validate:"max=300"`
+	// DetailActived 明细级启用(源列 `actived`;与方案级 actived 分开)。
+	DetailActived bool `json:"detailActived"`
+}
+
+// CreatePayrollPlanReq 端点 58 新建请求体(`POST /admin/payroll/plans`)。
+type CreatePayrollPlanReq struct {
+	// InstitutionID 机构(非超管须与作用域一致,否则 1004)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区(0 = 机构级方案)。
+	CampusID int64 `json:"campusId"`
+	// SchemeName 方案名称(必填;源列 `t_course_payment.name`)。
+	SchemeName string `json:"schemeName" validate:"required,max=200"`
+	// EffectDate 生效日期(`2006-01-02` 或 RFC3339;可空)。
+	EffectDate string `json:"effectDate"`
+	// Actived 方案级启用(源列 `t_course_payment.actived`)。
+	Actived bool `json:"actived"`
+	// Details 明细(可空 = 只建空方案;通常至少一条)。
+	Details []PayrollPlanDetailReq `json:"details"`
+}
+
+// UpdatePayrollPlanReq 端点 58 更新请求体(`PUT /admin/payroll/plans/:id`,`:id` = **方案 id**)。
+//
+// 语义:方案字段 nil = 不改;`details` 缺省(nil)= **不动明细**,给了(哪怕空数组)= **整体替换**。
+// 用 `*[]T` 表达这个区别——只有指针能区分"没传"与"传了空数组"。
+type UpdatePayrollPlanReq struct {
+	// InstitutionID 机构(非超管须与作用域一致)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区(nil = 不改)。
+	CampusID *int64 `json:"campusId"`
+	// SchemeName 方案名称(nil = 不改)。
+	SchemeName *string `json:"schemeName"`
+	// EffectDate 生效日期(nil = 不改)。
+	EffectDate *string `json:"effectDate"`
+	// Actived 方案级启用(nil = 不改)。
+	Actived *bool `json:"actived"`
+	// Details 明细(nil = 不动;`[]` = 清空;非空 = 整体替换)。
+	Details *[]PayrollPlanDetailReq `json:"details"`
+}
+
+// payrollPlanDetailView 端点 58 响应的明细(契约 D 表 + D17 裁决字段 + 出口定点化)。
+//
+// 出场口径(**三条都有"写错就红"的用例兜着**):
+//
+//	teacherId    → `null`(通用档,源列 NULL)或老师 ULID;**不下发空串**(空串无法区分"通用"与"脏数据")。
+//	percentage   → 恒 1 位小数("85.0")。`decimal(5,1)` 的位数就是它的量纲指纹:被按金额
+//	               格式化成 "85.00" 说明量纲被污染,数值恰好不变 ⇒ 只有位数断言抓得住。
+//	min/max/reward、teacherExpMin/Max → 金额 2 位、教龄 3 位(`money.Amount.String()` / `FormatYears`)。
+type payrollPlanDetailView struct {
+	// DetailID 明细 ID(源列 `t_course_payment_detail.id`)。
+	DetailID int64 `json:"detailId"`
+	// TeacherID 老师 ULID(源列 `teacher_id`;NULL → `null`)。
+	TeacherID *string `json:"teacherId"`
+	// ClassifyID 课程分类 ID。
+	ClassifyID int64 `json:"classifyId"`
+	// ClassType 班级类型。
+	ClassType int32 `json:"classType"`
+	// GradeID 年级(-1 通配)。
+	GradeID int32 `json:"gradeId"`
+	// SubjectID 科目(-1 通配)。
+	SubjectID int32 `json:"subjectId"`
+	// TeacherCert 是否持证档。
+	TeacherCert bool `json:"teacherCert"`
+	// TeacherExpMin 教龄下界(源列 `teacher_exp_min`)。
+	TeacherExpMin string `json:"teacherExpMin"`
+	// TeacherExpMax 教龄上界(源列 `teacher_exp_max`)。
+	TeacherExpMax string `json:"teacherExpMax"`
+	// Percentage 课酬比例(**百分数**,非金额)。
+	Percentage string `json:"percentage"`
+	// Min 最低课酬(源列 `min_payment`)。
+	Min string `json:"min"`
+	// Max 最高课酬(源列 `max_payment`)。
+	Max string `json:"max"`
+	// Reward 额外课酬(源列 `reward`)。
+	Reward string `json:"reward"`
+	// DisplayName 计算方式名(源列 `name`)。
+	DisplayName string `json:"displayName"`
+	// DetailActived 明细级启用(源列 `actived`)。
+	DetailActived bool `json:"detailActived"`
+}
+
+// payrollPlanView 端点 58 响应(方案 + 明细两级)。
+type payrollPlanView struct {
+	// ID 方案 ID(= `PUT /plans/:id` 的 `:id`)。
+	ID int64 `json:"id"`
+	// InstitutionID 机构(源列 `institution_id`)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区(源列 `campus_id`;0 = 机构级)。
+	CampusID int64 `json:"campusId"`
+	// SchemeName 方案名称(源列 `t_course_payment.name`)。
+	SchemeName string `json:"schemeName"`
+	// EffectDate 生效日期(源列 `effect_date`;可空 → null)。
+	EffectDate *time.Time `json:"effectDate"`
+	// Actived 方案级启用(源列 `t_course_payment.actived`)。
+	Actived bool `json:"actived"`
+	// Details 明细(空 → [])。
+	Details []payrollPlanDetailView `json:"details"`
+}

+ 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)
+	}
+}

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

@@ -113,6 +113,9 @@ type Config struct {
 	DB *gorm.DB
 	// Member 成员校验器(可 nil)。
 	Member port.MembershipChecker
+	// Users 用户显示名只读端口(**可 nil**:nil 时端点 53 的 `realName` 下发 null,
+	// 页面少一列姓名,不影响任何判据)。实现放 identity 侧,装配层注入(D4 裁决)。
+	Users port.UserNameReader
 	// Log 日志器(可 nil)。
 	Log *zap.Logger
 }
@@ -126,6 +129,7 @@ type Config struct {
 type Handler struct {
 	db     *gorm.DB
 	member port.MembershipChecker
+	users  port.UserNameReader
 	log    *zap.Logger
 }
 
@@ -139,7 +143,7 @@ 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
+	return &Handler{db: cfg.DB, member: cfg.Member, users: cfg.Users, log: cfg.Log}, nil
 }
 
 // scopedServices 一次请求内装配的服务集合(按作用域现场构造,请求结束即丢弃)。
@@ -162,6 +166,21 @@ type scopedServices struct {
 	activityDiscount *service.ActivityDiscountService
 	// recommend 老带新结算 + 取消充值活动(端点 29、30;SP18/SP8 转调)。
 	recommend *service.RecommendService
+	// course 课程 / 排班规则 / 排班生成 / 报课(端点 44~47、49、53~55、57;AC-11 + AC-13c 新增读装配)。
+	course *service.CourseService
+	// record 开始/结束上课、打卡(端点 48、50~52;AC-11 单事务联动)。
+	record *service.RecordService
+	// recordView 结课响应溯源装配(端点 51 的 `divide[]` / `payroll.detailId`;**只读、事务之后**)。
+	recordView *service.RecordViewService
+	// maintenance 欠费核销(端点 56;SP27)与维护型修复(端点 61~63;SP28~SP31)。
+	maintenance *service.MaintenanceService
+	// payroll 课酬试算(端点 59;SP26)。**与结课共用同一算法规格**(见 service.computeRecordReward)。
+	payroll *service.PayrollService
+	// payrollPlan 课酬方案配置(端点 58:方案 + 明细 CRUD;**写入面**,AC-10 未覆盖)。
+	payrollPlan *service.PayrollPlanService
+	// expenditure 机构收支台账(端点 60:记录列表/记账/类型字典)。
+	// ⚠ 与"学生钱包/消费"(`t_user_expense*`,端点 2~9)是**两套账**,列级无联动,不要混算。
+	expenditure *service.ExpenditureService
 }
 
 // servicesFor 按请求作用域装配一套服务。
@@ -175,6 +194,14 @@ type scopedServices struct {
 //   - `activityDiscount` 的越权防线在 Handler 角色白名单(设计 §3.2-A7),故不注入 checker。
 func (h *Handler) servicesFor(sc *appscope.Scope) *scopedServices {
 	campus := port.NewOrgCampusReader(h.db)
+	// 课酬试算:既作为端点 59 的独立服务(后续批次),也作为结课链路(端点 51)的课酬端口实现。
+	payroll := service.NewPayrollService(h.db, sc)
+	// 结课联动:分账(AC-09)+ 课酬(AC-10 端口适配)。两者都必须在**同一 DB 句柄**上构造,
+	// 否则事务内透传会失效(§10.3 红线)。
+	record := service.NewRecordService(h.db, sc, service.RecordServiceOptions{
+		Divide: service.NewDivideServiceWithRecordRepo(h.db, sc),
+		Quoter: service.NewRecordRewardQuoter(payroll),
+	})
 	return &scopedServices{
 		fund:             service.NewFundService(h.db, sc, h.member),
 		refund:           service.NewRefundService(h.db, sc, campus, h.member),
@@ -185,6 +212,13 @@ func (h *Handler) servicesFor(sc *appscope.Scope) *scopedServices {
 		activityAdmin:    service.NewActivityAdminService(h.db, sc).WithCampusReader(campus),
 		activityDiscount: service.NewActivityDiscountService(h.db, sc),
 		recommend:        service.NewRecommendService(h.db, sc).WithCampusReader(campus).WithMembershipChecker(h.member),
+		course:           service.NewCourseService(h.db, sc),
+		record:           record,
+		recordView:       service.NewRecordViewService(h.db, sc, payroll),
+		maintenance:      service.NewMaintenanceService(h.db, sc),
+		payroll:          payroll,
+		payrollPlan:      service.NewPayrollPlanService(h.db, sc),
+		expenditure:      service.NewExpenditureService(h.db, sc),
 	}
 }
 

+ 490 - 0
internal/academic/handler/payroll_handler.go

@@ -0,0 +1,490 @@
+// 课酬试算 + 维护型端点处理器(端点 59、61、62、63)——AC-13d 第一批。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 / §3.4 D 表;
+// 设计 `academic-domain-design.md` §4.5(角色白名单)/ §3.5(SP28/SP29)/ §3.6(SP30/SP31)/
+// §4.6(`PayrollQuote` 9 字段,**不得裁掉溯源字段**)。
+//
+// ---------------------------------------------------------------------------
+// 端点 → 服务方法对照
+// ---------------------------------------------------------------------------
+//
+//	59 POST /admin/payroll/quote                    PayrollService.QuoteTeacherReward(AC-10,SP26)
+//	61 POST /admin/maintenance/courses-max-people   MaintenanceService.BackfillCoursesMaxPeople(SP28)
+//	62 POST /admin/maintenance/courses-campus       MaintenanceService.BackfillCoursesCampus(SP29)
+//	63 POST /admin/maintenance/activity-sync        RebuildActivityUsedLog(SP30)/ ReplayActivitySigned(SP31)
+//
+// 本批**不含** 58(课酬方案 CRUD)与 60(收支字典/记录):契约把"方案+明细"、"字典+记录"
+// 各自压成一行,字段无法逐列溯源(详见 `dto_payroll.go` 文件头),已报偏差等裁决。
+//
+// ---------------------------------------------------------------------------
+// 三条纪律(与 course_handler.go 同源)
+// ---------------------------------------------------------------------------
+//
+//  1. **61~63 仅 `super_admin`**:路由白名单是第一道(契约 §2.5),Service 层 `assertSuperAdmin`
+//     是第二道(AC-11 已交付)。**两层都要有**——白名单写错时第二层仍拦得住。
+//  2. **维护端点是"全表级"动作**(SP30 会清 `used_log` 整表):Handler 不提供任何"部分执行"参数,
+//     也没有 dry-run;要重跑就再调一次(三个 SP 都幂等)。
+//  3. 维护类不做作用域过滤(跨机构修数据是**目的**),因此**越权防线只有白名单 + 超管断言**两条。
+package handler
+
+import (
+	"strconv"
+	"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"
+)
+
+// ---------------------------------------------------------------- 路径常量(**不含 `/api`**)
+
+const (
+	// PathPayrollPlans 端点 58:课酬方案列表 / 新建(`/admin/payroll/plans`)。
+	PathPayrollPlans = "/admin/payroll/plans"
+	// PathPayrollPlanDetail 端点 58:更新方案(`PUT /admin/payroll/plans/:id`,`:id` = **方案 id**)。
+	PathPayrollPlanDetail = "/admin/payroll/plans/:id"
+	// PathPayrollQuote 端点 59:课酬试算(`POST /admin/payroll/quote`)。
+	PathPayrollQuote = "/admin/payroll/quote"
+	// PathMaintenanceCoursesMaxPeople 端点 61:修复课程 max_people(SP28)。
+	PathMaintenanceCoursesMaxPeople = "/admin/maintenance/courses-max-people"
+	// PathMaintenanceCoursesCampus 端点 62:修复课程 campus_id(SP29)。
+	PathMaintenanceCoursesCampus = "/admin/maintenance/courses-campus"
+	// PathMaintenanceActivitySync 端点 63:活动数据同步(SP30/SP31)。
+	PathMaintenanceActivitySync = "/admin/maintenance/activity-sync"
+)
+
+// activitySyncModeUsedLog 端点 63 的 mode 取值:重建 `t_courses_activity_used_log`(SP30)。
+const activitySyncModeUsedLog = "used-log"
+
+// activitySyncModeSigned 端点 63 的 mode 取值:重放 `t_courses_activity_signed`(SP31)。
+const activitySyncModeSigned = "signed"
+
+// ---------------------------------------------------------------- 端点 58:课酬方案
+
+// ListPayrollPlans 课酬方案列表(端点 58 `GET /admin/payroll/plans`)。
+//
+// 入参(query):`institutionId`(必填 >0)、`campusId?`、`actived?`(0/1)、`keyword?`、分页。
+// 出参(成功):`{list,total,page,pageSize,pages}`,行为**两级形状**(方案 + `details[]`)。
+// 出参(失败):1001/2001(缺 institutionId)/2002(campusId/actived 非法)/**1004**(跨机构)/5002。
+//
+// 为什么要显式 `institutionId`:`t_course_payment_detail` 没有机构列、`PayrollRepo` 又不走 `Scoped`
+// ⇒ 机构维度只能由调用方给出,并由 `assertInstitution` 校验(非超管必须等于作用域机构)。
+func (h *Handler) ListPayrollPlans(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	institutionID, err := parseIntQuery(c, "institutionId")
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	if institutionID <= 0 {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("institutionId 必填且必须 > 0")))
+	}
+	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")) != "" {
+		v := campusID
+		campusPtr = &v
+	}
+	activedFilter, _, err := parseEnumQuery(c, "actived", map[int64]struct{}{0: {}, 1: {}})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var activedPtr *bool
+	if activedFilter != nil {
+		v := *activedFilter == 1
+		activedPtr = &v
+	}
+	page, pageSize := parsePage(c)
+	svc := h.servicesFor(sc)
+	plans, total, err := svc.payrollPlan.ListPlans(c.Context(), service.PayrollPlanQuery{
+		InstitutionID: institutionID,
+		CampusID:      campusPtr,
+		Actived:       activedPtr,
+		Keyword:       c.Query("keyword"),
+		Page:          page,
+		PageSize:      pageSize,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	views := make([]payrollPlanView, 0, len(plans))
+	for i := range plans {
+		views = append(views, toPayrollPlanView(&plans[i]))
+	}
+	return response.Page(c, views, page, pageSize, total)
+}
+
+// CreatePayrollPlan 新建课酬方案(端点 58 `POST /admin/payroll/plans`)。
+//
+// 入参(body):见 `CreatePayrollPlanReq`(`schemeName` 必填,`details[]` 可空)。
+// 出参(成功):`payrollPlanView`(含新方案 id 与回读的明细)。
+// 出参(失败):1001/2001/2002(比例非 0~100、教龄区间倒挂、金额非法)/1004/5002。
+//
+// 事务:方案 + 全部明细在**同一事务**内写入(明细写失败 ⇒ 方案不落库,不会留下"空方案")。
+func (h *Handler) CreatePayrollPlan(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req CreatePayrollPlanReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	institutionID, err := resolveInstitutionID(sc, req.InstitutionID)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	effectDate, hasEffect, err := parseTimeParam(req.EffectDate, "effectDate")
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	details, ok := h.parsePayrollPlanDetails(c, req.Details)
+	if !ok {
+		return nil
+	}
+	in := service.CreatePlanInput{
+		InstitutionID: institutionID,
+		CampusID:      req.CampusID,
+		SchemeName:    req.SchemeName,
+		Actived:       req.Actived,
+		Details:       details,
+		OperatorID:    sc.UserID,
+	}
+	if hasEffect {
+		in.EffectDate = &effectDate
+	}
+	svc := h.servicesFor(sc)
+	plan, err := svc.payrollPlan.CreatePlan(c.Context(), in)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, toPayrollPlanView(plan))
+}
+
+// UpdatePayrollPlan 更新课酬方案(端点 58 `PUT /admin/payroll/plans/:id`,`:id` = **方案 id**)。
+//
+// 入参(body):见 `UpdatePayrollPlanReq`(nil = 不改;`details` 给了就**整体替换**)。
+// 出参(成功):`payrollPlanView`(更新后回读)。
+// 出参(失败):1001/2001(没有任何待更新字段)/2002/3001/1004/5002。
+//
+// **整体替换明细**(D17 裁决):先按 `course_payment_id` 删除、再插入,同事务。
+// 不做逐条 diff —— 明细是**配置**不是流水,重写终态即可;diff 会引入"哪条被谁改了"的对账难题。
+func (h *Handler) UpdatePayrollPlan(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req UpdatePayrollPlanReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	institutionID, err := resolveInstitutionID(sc, req.InstitutionID)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	in := service.UpdatePlanInput{
+		ID:            parseIDParam(c.Params("id")),
+		InstitutionID: institutionID,
+		CampusID:      req.CampusID,
+		SchemeName:    req.SchemeName,
+		Actived:       req.Actived,
+	}
+	if req.EffectDate != nil {
+		at, has, perr := parseTimeParam(*req.EffectDate, "effectDate")
+		if perr != nil {
+			return response.Fail(c, perr)
+		}
+		if has {
+			in.EffectDate = &at
+		}
+	}
+	if req.Details != nil {
+		details, ok := h.parsePayrollPlanDetails(c, *req.Details)
+		if !ok {
+			return nil
+		}
+		in.Details = details
+		in.ReplaceDetail = true // 给了 details(哪怕是 `[]`)= 整体替换 / 清空
+	}
+	svc := h.servicesFor(sc)
+	plan, err := svc.payrollPlan.UpdatePlan(c.Context(), in)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, toPayrollPlanView(plan))
+}
+
+// parsePayrollPlanDetails 解析明细入参(金额字符串 → money.Amount;其余原样透传给 Service 校验)。
+//
+// 入参:c(仅用于写失败响应);raw 明细入参。出参:(服务入参, 是否成功);失败时 body code=2002。
+//
+// 金额用 `parseMoneyOrZero`(空串 = 0.00 = 不限制),**不用** `parseMoney`:
+// `min`/`max`/`reward` 都是可空字段,空串不等于"填错"。
+func (h *Handler) parsePayrollPlanDetails(c *fiber.Ctx, raw []PayrollPlanDetailReq) ([]service.PayrollPlanDetailInput, bool) {
+	out := make([]service.PayrollPlanDetailInput, 0, len(raw))
+	for i := range raw {
+		item := &raw[i]
+		minAmount, ok := parseMoneyOrZero(c, item.Min, "details.min")
+		if !ok {
+			return nil, false
+		}
+		maxAmount, ok := parseMoneyOrZero(c, item.Max, "details.max")
+		if !ok {
+			return nil, false
+		}
+		reward, ok := parseMoneyOrZero(c, item.Reward, "details.reward")
+		if !ok {
+			return nil, false
+		}
+		out = append(out, service.PayrollPlanDetailInput{
+			TeacherID:     strings.TrimSpace(item.TeacherID),
+			ClassifyID:    item.ClassifyID,
+			ClassType:     item.ClassType,
+			GradeID:       item.GradeID,
+			SubjectID:     item.SubjectID,
+			TeacherCert:   item.TeacherCert,
+			TeacherExpMin: item.TeacherExpMin,
+			TeacherExpMax: item.TeacherExpMax,
+			Percentage:    item.Percentage,
+			Min:           minAmount,
+			Max:           maxAmount,
+			Reward:        reward,
+			DisplayName:   item.DisplayName,
+			Actived:       item.DetailActived,
+		})
+	}
+	return out, true
+}
+
+// toPayrollPlanView 把服务视图转成响应(金额两位小数字符串;明细空 → [])。
+func toPayrollPlanView(plan *service.PayrollPlan) payrollPlanView {
+	details := make([]payrollPlanDetailView, 0, len(plan.Details))
+	for i := range plan.Details {
+		d := &plan.Details[i]
+		details = append(details, payrollPlanDetailView{
+			DetailID:      d.DetailID,
+			TeacherID:     d.TeacherID, // 已是 *string:NULL 原样下发 null,不折叠成 ""
+			ClassifyID:    d.ClassifyID,
+			ClassType:     d.ClassType,
+			GradeID:       d.GradeID,
+			SubjectID:     d.SubjectID,
+			TeacherCert:   d.TeacherCert,
+			TeacherExpMin: d.TeacherExpMin,
+			TeacherExpMax: d.TeacherExpMax,
+			Percentage:    d.Percentage,
+			Min:           d.Min.String(),
+			Max:           d.Max.String(),
+			Reward:        d.Reward.String(),
+			DisplayName:   d.DisplayName,
+			DetailActived: d.DetailActived,
+		})
+	}
+	return payrollPlanView{
+		ID:            plan.ID,
+		InstitutionID: plan.InstitutionID,
+		CampusID:      plan.CampusID,
+		SchemeName:    plan.SchemeName,
+		EffectDate:    plan.EffectDate,
+		Actived:       plan.Actived,
+		Details:       details,
+	}
+}
+
+// QuotePayroll 课酬试算(端点 59 `POST /admin/payroll/quote`,SP26 dump 763600~763670)。
+//
+// 入参(body):见 `PayrollQuoteReq`(六个字段全必填)。
+// 出参(成功):`payrollQuoteView`(9 字段:4 业务列 + 4 溯源字段 + detailId)。
+// 出参(失败):1001/2001(缺参/日期格式非法)/1004(schoolId 与作用域机构不一致)/
+// **3112**(两级方案都没有 or 四级规则一条都没命中)/5002。
+//
+// **与端点 51 的关系(重要)**:本端点返回的是**规则**(percentage/min/max/reward),
+// **不是最终课酬金额**——最终金额还需要 `income`/`studentCount`/`teacherRewardType`,
+// 契约没给这三个入参。二者共用同一 `computeRecordReward`(结课侧),本端点**不重复实现公式**,
+// 因此不会出现"试算一套、结课另一套"。
+func (h *Handler) QuotePayroll(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req PayrollQuoteReq
+	if !validateReq(c, &req) {
+		return nil // body code=2001 已写出
+	}
+	date, hasDate, derr := parseTimeParam(req.Date, "date")
+	if derr != nil {
+		return response.Fail(c, derr)
+	}
+	if !hasDate {
+		// `validate:"required"` 已挡住空串;这里只兜"传了空白串被 trim 掉"的情形
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("date 不能为空")))
+	}
+	if req.TeacherExp < 0 {
+		// dump 763609:负数按 0 兜底(不报错)。此处**不拒绝**,只归一,保持与旧系统一致。
+		req.TeacherExp = 0
+	}
+	svc := h.servicesFor(sc)
+	quote, err := svc.payroll.QuoteTeacherReward(c.Context(), service.QuotePayrollInput{
+		TeacherID:     strings.TrimSpace(req.TeacherID),
+		TeacherExp:    req.TeacherExp,
+		InstitutionID: req.SchoolID,
+		CampusID:      req.CampusID,
+		ClassifyID:    req.ClassifyID,
+		Date:          date,
+	})
+	if err != nil {
+		return response.Fail(c, err) // 2001/1004/3112/5002
+	}
+	// 出口定点化:`percentage` 恒 1 位小数(`decimal(5,1)`)。不定点化的话 SQLite 回 "85"、
+	// MySQL 回 "85.0",前端 diff 会把它当成"用户改过",且单测断言随数据库方言摇摆。
+	percentage, perr := service.FormatPercentage(quote.Percentage)
+	if perr != nil {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrDBError, perr))
+	}
+	return response.OK(c, payrollQuoteView{
+		DetailID:       quote.DetailID,
+		SchemeID:       quote.SchemeID,
+		SchemeCampusID: quote.SchemeCampusID,
+		SchemeScope:    quote.SchemeScope,
+		RuleTier:       quote.RuleTier,
+		Percentage:     percentage,
+		Min:            quote.Min.String(),
+		Max:            quote.Max.String(),
+		Reward:         quote.Reward.String(),
+	})
+}
+
+// BackfillCoursesMaxPeople 修复课程 `max_people`(端点 61 `POST /admin/maintenance/courses-max-people`,SP28)。
+//
+// 入参:无 body。
+// 出参(成功):`{affected, message}`(**可重跑**:第二次 `affected=0`)。
+// 出参(失败):1001/**1004**(非超管)/5002。
+//
+// 幂等判据(AC-11 已实现):只处理 `max_people = 0` 的行;分类也没有值时**跳过不写 0**
+// (写 0 等于不改,却会让运维以为"已修复")。
+func (h *Handler) BackfillCoursesMaxPeople(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.maintenance.BackfillCoursesMaxPeople(c.Context())
+	if err != nil {
+		return response.Fail(c, err) // 1004 非超管 / 5002
+	}
+	msg := "已回填 " + strconv.FormatInt(int64(res.Filled), 10) + " 门课程的 max_people"
+	if res.Filled == 0 {
+		msg = "无待回填课程(可重跑:已是最新)"
+	}
+	if res.Skipped > 0 {
+		msg += ";跳过 " + strconv.FormatInt(int64(res.Skipped), 10) + " 门(分类未配置,未写 0)"
+	}
+	return response.OK(c, MaintenanceResultResp{Affected: res.Affected, Message: msg})
+}
+
+// BackfillCoursesCampus 修复课程 `campus_id`(端点 62 `POST /admin/maintenance/courses-campus`,SP29)。
+//
+// 入参:无 body。
+// 出参(成功):`{affected, message}`(**可重跑**)。
+// 出参(失败):1001/**1004**(非超管)/5002。
+//
+// **反直觉口径(必须保留)**:多校区课程(去重校区数 ≥ 2)**不回填、保持 0**("机构级课程");
+// 无排班规则的课程(计数 = 0)同样不回填(0 ≠ 1)。
+func (h *Handler) BackfillCoursesCampus(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.maintenance.BackfillCoursesCampus(c.Context())
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	msg := "已回填 " + strconv.FormatInt(int64(res.Updated), 10) + " 门课程的 campus_id"
+	if res.Updated == 0 {
+		msg = "无待回填课程(可重跑:已是最新)"
+	}
+	if res.SkippedMultiCampus > 0 {
+		msg += ";多校区课程 " + strconv.FormatInt(int64(res.SkippedMultiCampus), 10) + " 门保持 0(机构级,不填)"
+	}
+	if res.SkippedNoRule > 0 {
+		msg += ";无排班规则 " + strconv.FormatInt(int64(res.SkippedNoRule), 10) + " 门未动"
+	}
+	return response.OK(c, MaintenanceResultResp{Affected: int64(res.Updated), Message: msg})
+}
+
+// SyncActivityData 活动数据同步(端点 63 `POST /admin/maintenance/activity-sync`,SP30 / SP31)。
+//
+// 入参(body):`mode` ∈ {`used-log`, `signed`};**缺失 → 2001;传了非法 → 2002**(团队裁决,与端点 25 一致)。
+// 出参(成功):`{affected, message}`。
+// 出参(失败):1001/2001(mode 缺失)/2002(mode 非法)/**1004**(非超管)/5002。
+//
+// 两个模式各自幂等:
+//
+//	used-log → `RebuildActivityUsedLog`:**同事务 DELETE + 批量 INSERT**(不用 TRUNCATE —— DDL 隐式提交
+//	           会"清空成功、重建失败"时数据全丢,见 §3.8-BUG#3);重跑结果一致。
+//	signed   → `ReplayActivitySigned`:只更新 `signed.status > 0` 的行(已取消报名不得被历史工单"复活")。
+func (h *Handler) SyncActivityData(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req ActivitySyncReq
+	if !validateReq(c, &req) {
+		return nil // body code=2001 已写出
+	}
+	mode := strings.TrimSpace(req.Mode)
+	if mode == "" {
+		// **缺失 → 2001**(未提供必要参数)
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("mode 不能为空(used-log 或 signed)")))
+	}
+	if mode != activitySyncModeUsedLog && mode != activitySyncModeSigned {
+		// **传了但不在白名单 → 2002**(枚举越界;与端点 25 的 `numberSign` 同一口径)。
+		// 与"缺失"分开的理由:前端提示不同("请选择同步模式" vs "取值非法"),且全局规则是
+		// "枚举越界 = 2002"——同一错误类别不该在两个端点用不同码。
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("mode 取值非法 "+mode+"(仅支持 used-log / signed)")))
+	}
+	svc := h.servicesFor(sc)
+	switch mode {
+	case activitySyncModeUsedLog:
+		res, serr := svc.maintenance.RebuildActivityUsedLog(c.Context())
+		if serr != nil {
+			return response.Fail(c, serr)
+		}
+		msg := "used_log 已重建:" + strconv.FormatInt(res.Inserted, 10) + " 行(扫描 " + strconv.FormatInt(int64(res.Scanned), 10) +
+			",删除旧行 " + strconv.FormatInt(res.Deleted, 10)
+		if res.Skipped > 0 {
+			msg += ";跳过 " + strconv.FormatInt(int64(res.Skipped), 10) + " 行(活动/用户为空,目标态 NOT NULL 无法表达)"
+		}
+		return response.OK(c, MaintenanceResultResp{Affected: res.Inserted, Message: msg + ")"})
+	case activitySyncModeSigned:
+		res, serr := svc.maintenance.ReplayActivitySigned(c.Context())
+		if serr != nil {
+			return response.Fail(c, serr)
+		}
+		msg := "signed 已重放:更新 " + strconv.FormatInt(res.Updated, 10) + " 行(扫描 " + strconv.FormatInt(int64(res.Scanned), 10) + ")"
+		if res.SkippedCanceled > 0 {
+			msg += ";跳过 " + strconv.FormatInt(res.SkippedCanceled, 10) + " 行(已取消报名,不得复活)"
+		}
+		return response.OK(c, MaintenanceResultResp{Affected: res.Updated, Message: msg})
+	default:
+		// 不可达:上面已把"缺失/非法"分别拦成 2001/2002。保留兜底是为了让"新增 mode 却忘了
+		// 加 case"变成显式错误,而不是静默落进某个分支(尤其不能静默走 used-log —— 那会清表)。
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrInternal,
+			errString("mode 分支未实现 "+mode)))
+	}
+}

+ 719 - 0
internal/academic/handler/payroll_handler_test.go

@@ -0,0 +1,719 @@
+// 课酬试算 + 维护型端点单测(端点 59、61、62、63)——AC-13d 第一批。
+//
+// 断言纪律同前几批:只断 body 契约,**每个端点至少一条"写错会变红"的断言**,尽量落在库上。
+//
+// 覆盖:
+//
+//	59 试算     命中规则 → 9 字段(percentage 是**百分数字符串**不是金额);无方案 → 3112;
+//	            跨机构 → 1004;日期格式非法 → 2002
+//	61 修 max_people  回填后库值 = 分类值;可重跑(第二次 affected=0);分类也 0 的行**不写**
+//	62 修 campus_id   单校区课程回填;**多校区课程保持 0**;无规则课程不动
+//	63 活动同步     mode=used-log 重建 used_log(DELETE+INSERT);mode=signed 只回写 status>0;
+//	            非法 mode → 2001;非超管 → 1004
+package handler
+
+import (
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/apperr"
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// newPayrollTestApp 挂 59/61/62/63 四条路由(**不做角色白名单**:白名单由 e2e 验,
+// 但 61~63 的"仅超管"还有 Service 层 `assertSuperAdmin` 第二道,本文件**要验第二道**)。
+//
+// 作用域:`X-Test-Scope: super` 超管;`academic` 教务(非超管,用于验 1004)。
+func newPayrollTestApp(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 "academic":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 10, UserID: "01HACADEMIC0000000000001", Role: RoleAcademic,
+			})
+		default:
+			appscope.Set(c, testSuperScope())
+		}
+		return c.Next()
+	})
+	app.Get(PathPayrollPlans, h.ListPayrollPlans)
+	app.Post(PathPayrollPlans, h.CreatePayrollPlan)
+	app.Put(PathPayrollPlanDetail, h.UpdatePayrollPlan)
+	app.Post(PathPayrollQuote, h.QuotePayroll)
+	app.Post(PathMaintenanceCoursesMaxPeople, h.BackfillCoursesMaxPeople)
+	app.Post(PathMaintenanceCoursesCampus, h.BackfillCoursesCampus)
+	app.Post(PathMaintenanceActivitySync, h.SyncActivityData)
+	return app
+}
+
+// ---------------------------------------------------------------- 端点 58:课酬方案
+
+// TestPayrollPlanCreateAndList 端点 58:两级形状(方案 + details)、字段名不与方案撞车、列表回读。
+//
+// 断言重点(都是 D17 裁决里"命名必须分开"的具体后果):
+//
+//	`schemeName` ≠ 明细 `displayName`:两者在库里都叫 `name`,若实现里同名字段,第二个会覆盖第一个;
+//	`actived`(方案级)≠ `detailActived`(明细级):两级启用状态必须能看到差异;
+//	教龄是**区间两字段** `teacherExpMin/teacherExpMax`,不是单值。
+func TestPayrollPlanCreateAndList(t *testing.T) {
+	db := newCourseTestDB(t)
+	app := newPayrollTestApp(t, db)
+	body := map[string]any{
+		"schemeName": "2026 集体班方案",
+		"campusId":   10,
+		"effectDate": "2026-01-01",
+		"actived":    true,
+		"details": []map[string]any{{
+			"classType": 1000, "gradeId": -1, "subjectId": -1,
+			"teacherExpMin": "1.000", "teacherExpMax": "5.000",
+			"percentage": "85.0", "min": "0.00", "max": "300.00", "reward": "20.00",
+			"displayName": "集体班-无证档", "detailActived": true,
+		}, {
+			"classType": 1000, "gradeId": -1, "subjectId": -1,
+			"teacherExpMin": "0.000", "teacherExpMax": "0.999",
+			"percentage": "60.0", "min": "0.00", "max": "0.00", "reward": "0.00",
+			"displayName": "集体班-新人档", "detailActived": false,
+		}},
+	}
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathPayrollPlans, body, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("新建课酬方案 = %d(%s),期望 200", code, msg)
+	}
+	payload := jsonMap(t, data)
+	if name, _ := payload["schemeName"].(string); name != "2026 集体班方案" {
+		t.Fatalf("schemeName = %v,期望 2026 集体班方案(与明细 displayName 分开)", payload["schemeName"])
+	}
+	if got, _ := payload["campusId"].(float64); got != 10 {
+		t.Fatalf("campusId = %v,期望 10", payload["campusId"])
+	}
+	planID, _ := payload["id"].(float64)
+	if planID <= 0 {
+		t.Fatalf("方案 id = %v,期望 >0", payload["id"])
+	}
+	details, _ := payload["details"].([]any)
+	if len(details) != 2 {
+		t.Fatalf("details 条数 = %d,期望 2", len(details))
+	}
+	first, _ := details[0].(map[string]any)
+	if name, _ := first["displayName"].(string); name != "集体班-无证档" {
+		t.Fatalf("明细 displayName = %v,期望 集体班-无证档(被 schemeName 覆盖就是同名字段)", first["displayName"])
+	}
+	// ⚠ 这三条从"比数值"升级为"比字节"(出口定点化之后才可能这么断言)。
+	// 定点化前 SQLite 的 numeric 亲和性把 "1.000" 读回 "1"、"85.0" 读回 "85",
+	// 只能写 mustDecimal 比数值 —— 而"位数"恰好是量纲指纹(百分比 1 位 / 教龄 3 位 / 金额 2 位),
+	// 比数值等于把这条防线让出去。现在出口恒按列 scale 定点化,两种方言字节一致,才钉得住。
+	if got, _ := first["teacherExpMin"].(string); got != "1.000" {
+		t.Fatalf("teacherExpMin = %v,期望 \"1.000\"(区间下界单独下发,且定点化到 decimal(10,3))",
+			first["teacherExpMin"])
+	}
+	if got, _ := first["teacherExpMax"].(string); got != "5.000" {
+		t.Fatalf("teacherExpMax = %v,期望 \"5.000\"(区间上界必须单独下发,否则前端改档会写坏)",
+			first["teacherExpMax"])
+	}
+	if got, _ := first["percentage"].(string); got != "85.0" {
+		t.Fatalf("percentage = %v,期望 \"85.0\"(**百分数且 1 位小数**);"+
+			"\"0.85\" 说明被当比率、\"8500\" 说明被 ×100、\"85.00\" 说明被当金额格式化", first["percentage"])
+	}
+	if got, _ := first["min"].(string); got != "0.00" {
+		t.Fatalf("min = %v,期望 \"0.00\"(金额 2 位小数;\"0\" 说明金额没走定点出口)", first["min"])
+	}
+	// `teacherId` 未填 ⇒ 通用档 ⇒ 必须是 JSON `null`,**不能是 ""**。
+	// 空串无法区分"不限定老师"(正常)与"限定了老师但 ID 为空"(脏数据)。
+	if v, ok := first["teacherId"]; !ok {
+		t.Fatalf("响应里没有 teacherId 字段(契约要求存在且可空)")
+	} else if v != nil {
+		t.Fatalf("teacherId = %#v,期望 null(未填老师 = 通用档);\"\" 会让前端把脏数据当通用档", v)
+	}
+	second, _ := details[1].(map[string]any)
+	if v, _ := second["detailActived"].(bool); v {
+		t.Fatalf("第二条 detailActived = true,期望 false(明细级启用与方案级 actived 必须能不同)")
+	}
+
+	// 列表:两级形状回读(GET 必须把 details 一并带回,否则前端要 N+1)
+	code, msg, data = doJSON(t, app, fiber.MethodGet,
+		PathPayrollPlans+"?institutionId=1&pageSize=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", len(items))
+	}
+	row, _ := items[0].(map[string]any)
+	if got, _ := row["id"].(float64); got != planID {
+		t.Fatalf("列表首行 id = %v,期望 %v", row["id"], planID)
+	}
+	listDetails, _ := row["details"].([]any)
+	if len(listDetails) != 2 {
+		t.Fatalf("列表行的 details = %d 条,期望 2(列表必须带明细,否则前端要逐行再查)", len(listDetails))
+	}
+}
+
+// TestPayrollPlanTeacherIDNullVersusULID 端点 58:`teacherId` 为 NULL 时下发 `null`,**不是 `""`**。
+//
+// 变红条件(每条都对应一个真实可犯的错):
+//
+//	字段声明成 `string` 且用 `strValue()` 兜底   → NULL 变成 "",本条断言红;
+//	写入侧把空 teacherId 写成 `""` 而不是 NULL   → 库里 teacher_id = '',本条"库值必须 IS NULL"红;
+//	`optionalTrimmedPtr` 不 trim                    → 历史脏值 " " 被当成有值下发,本条红(见下用例)。
+//
+// 为什么值得单独立一条用例:`teacherId` 是"通用档"与"老师专属档"的唯一区分依据。
+// 若 NULL 被折叠成 "",前端 `if (teacherId)` 两支都走"通用档",**专属档的规则会被当成通用档生效**——
+// 数值全对、类型全对,只有课酬算错,且错得很隐蔽(多算/少算都合法)。
+func TestPayrollPlanTeacherIDNullVersusULID(t *testing.T) {
+	db := newCourseTestDB(t)
+	app := newPayrollTestApp(t, db)
+	const teacherULID = "01HTEACHER0000000000000042"
+	body := map[string]any{
+		"schemeName": "专属档 vs 通用档", "campusId": 10, "actived": true,
+		"details": []map[string]any{
+			// 第 1 条:老师专属档
+			{"classType": 1000, "gradeId": -1, "subjectId": -1, "percentage": "90.0",
+				"teacherId": teacherULID, "displayName": "专属档"},
+			// 第 2 条:通用档(不传 teacherId)
+			{"classType": 1000, "gradeId": -1, "subjectId": -1, "percentage": "70.0",
+				"displayName": "通用档"},
+			// 第 3 条:传空串(等价于不传,**不许落成 ""**)
+			{"classType": 1000, "gradeId": -1, "subjectId": -1, "percentage": "60.0",
+				"teacherId": "", "displayName": "空串档"},
+		},
+	}
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathPayrollPlans, body, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("新建课酬方案 = %d(%s),期望 200", code, msg)
+	}
+	planID, _ := jsonMap(t, data)["id"].(float64)
+	assertTeacherID := func(when string, details []any) {
+		t.Helper()
+		if len(details) != 3 {
+			t.Fatalf("%s:details = %d 条,期望 3", when, len(details))
+		}
+		d0, _ := details[0].(map[string]any)
+		if got, _ := d0["teacherId"].(string); got != teacherULID {
+			t.Fatalf("%s:专属档 teacherId = %#v,期望 %q(有值必须原样下发)", when, d0["teacherId"], teacherULID)
+		}
+		for i := 1; i < 3; i++ {
+			di, _ := details[i].(map[string]any)
+			v, ok := di["teacherId"]
+			if !ok {
+				t.Fatalf("%s:第 %d 条没有 teacherId 字段(可空字段也必须存在)", when, i+1)
+			}
+			if v != nil {
+				t.Fatalf("%s:第 %d 条 teacherId = %#v,期望 null(不填/空串都是通用档);"+
+					"空串会让前端把「写了老师但 ID 是空」的脏数据也当通用档", when, i+1, v)
+			}
+		}
+	}
+	assertTeacherID("POST 响应", jsonMap(t, data)["details"].([]any))
+
+	// 列表回读:同一口径(GET 与 POST 不能两套规格)
+	code, msg, data = doJSON(t, app, fiber.MethodGet,
+		PathPayrollPlans+"?institutionId=1&pageSize=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", len(items))
+	}
+	row, _ := items[0].(map[string]any)
+	assertTeacherID("GET 列表", row["details"].([]any))
+
+	// 库值:空 teacherId 必须落 NULL(不是 "")——否则"通用档"在 SQL 里会变成
+	// `teacher_id = ''`,而 SQL 的三值逻辑下 `teacher_id = ''` 永远匹配不到"通用档"这句查询。
+	var rows []model.CoursePaymentDetail
+	if err := db.Where("course_payment_id = ?", int64(planID)).Order("id ASC").Find(&rows).Error; err != nil {
+		t.Fatalf("回读明细失败: %v", err)
+	}
+	if len(rows) != 3 {
+		t.Fatalf("库中明细 %d 条,期望 3", len(rows))
+	}
+	if rows[0].TeacherID == nil || *rows[0].TeacherID != teacherULID {
+		t.Fatalf("库中第 1 条 teacher_id = %v,期望 %q", rows[0].TeacherID, teacherULID)
+	}
+	for i := 1; i < 3; i++ {
+		if rows[i].TeacherID != nil {
+			t.Fatalf("库中第 %d 条 teacher_id = %q,期望 NULL(空串会被存成 '',通用档就永远匹配不到)",
+				i+1, *rows[i].TeacherID)
+		}
+	}
+}
+
+
+//
+// 变红条件:
+//   - 把 `:id` 当明细 id(按明细删改)→ 方案字段改不动 / 明细条数不对;
+//   - "整体替换"写成"追加" → 明细变 3 条(旧 2 + 新 1)。
+func TestPayrollPlanUpdateReplacesDetails(t *testing.T) {
+	db := newCourseTestDB(t)
+	app := newPayrollTestApp(t, db)
+	create := map[string]any{
+		"schemeName": "替换测试方案", "campusId": 10, "actived": true,
+		"details": []map[string]any{
+			{"classType": 1000, "gradeId": -1, "subjectId": -1, "percentage": "80.0", "displayName": "旧档A"},
+			{"classType": 1000, "gradeId": -1, "subjectId": -1, "percentage": "70.0", "displayName": "旧档B"},
+		},
+	}
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathPayrollPlans, create, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("建方案 = %d(%s),期望 200", code, msg)
+	}
+	planID, _ := jsonMap(t, data)["id"].(float64)
+
+	update := map[string]any{
+		"schemeName": "替换后方案名",
+		"details": []map[string]any{
+			{"classType": 1000, "gradeId": -1, "subjectId": -1, "percentage": "90.0", "displayName": "新档"},
+		},
+	}
+	code, msg, data = doJSON(t, app, fiber.MethodPut,
+		"/admin/payroll/plans/"+itoa(int64(planID)), update, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("更新方案 = %d(%s),期望 200", code, msg)
+	}
+	payload := jsonMap(t, data)
+	if name, _ := payload["schemeName"].(string); name != "替换后方案名" {
+		t.Fatalf("schemeName = %v,期望 替换后方案名", payload["schemeName"])
+	}
+	details, _ := payload["details"].([]any)
+	if len(details) != 1 {
+		t.Fatalf("替换后明细 = %d 条,期望 1(整体替换;3 条说明写成了追加)", len(details))
+	}
+	only, _ := details[0].(map[string]any)
+	if name, _ := only["displayName"].(string); name != "新档" {
+		t.Fatalf("明细 displayName = %v,期望 新档", only["displayName"])
+	}
+	// 库里确认旧明细已删(不是只改了响应)
+	var count int64
+	db.Model(&model.CoursePaymentDetail{}).Where("course_payment_id = ?", int64(planID)).Count(&count)
+	if count != 1 {
+		t.Fatalf("库中明细条数 = %d,期望 1(旧 2 条必须被删掉)", count)
+	}
+}
+
+// TestPayrollPlanValidation 端点 58 的写入校验:比例越界 / 教龄区间倒挂 → 2002。
+//
+// 两条都是"不报错但规则永不命中/课酬少 100 倍"的静默错法,必须在写入时拒。
+func TestPayrollPlanValidation(t *testing.T) {
+	db := newCourseTestDB(t)
+	app := newPayrollTestApp(t, db)
+	base := func(detail map[string]any) map[string]any {
+		return map[string]any{"schemeName": "校验方案", "campusId": 10, "details": []map[string]any{detail}}
+	}
+	cases := []struct {
+		name   string
+		detail map[string]any
+	}{
+		{"比例 > 100(把 0.85 当比率传成 85 反了)", map[string]any{"classType": 1000, "percentage": "850.0"}},
+		{"比例 2 位小数(列是 decimal(5,1))", map[string]any{"classType": 1000, "percentage": "85.25"}},
+		{"教龄区间倒挂(规则永不命中)", map[string]any{"classType": 1000, "percentage": "85.0",
+			"teacherExpMin": "5.000", "teacherExpMax": "1.000"}},
+	}
+	for _, tc := range cases {
+		code, msg, _ := doJSON(t, app, fiber.MethodPost, PathPayrollPlans, base(tc.detail), "X-Test-Token", "t")
+		if code != infraerrors.ErrBadParam.Code {
+			t.Errorf("%s = %d(%s),期望 2002", tc.name, code, msg)
+		}
+	}
+	// 对照:合法明细必须 200
+	ok := base(map[string]any{"classType": 1000, "percentage": "85.0",
+		"teacherExpMin": "1.000", "teacherExpMax": "2.000"})
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathPayrollPlans, ok, "X-Test-Token", "t"); code != 200 {
+		t.Fatalf("合法明细 = %d(%s),期望 200", code, msg)
+	}
+}
+
+// ---------------------------------------------------------------- 端点 59:课酬试算
+
+// TestPayrollQuoteReturnsRuleTraceableFields 端点 59 必须回 9 个**可溯源**字段(不能裁掉 4 个溯源字段)。
+//
+// 变红条件:
+//   - 漏 `schemeScope`/`ruleTier` → 前端无法定位"命中哪一档",且契约 §4.6 明确不得裁掉;
+//   - 把 `percentage` 当金额下发("85.0" → "85.00")→ 量纲污染断言红。
+func TestPayrollQuoteReturnsRuleTraceableFields(t *testing.T) {
+	db := newCourseTestDB(t)
+	classify := seedPayrollClassify(t, db, 1000)
+	scheme, detail := seedPayrollScheme(t, db, classify.ClassType, "85.0", "0.00", "0.00", "0.00")
+	app := newPayrollTestApp(t, db)
+
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathPayrollQuote, map[string]any{
+		"teacherId":  courseTestTeacher,
+		"schoolId":   1,
+		"campusId":   10,
+		"classifyId": classify.ID,
+		"teacherExp": 3.5,
+		"date":       "2026-11-09",
+	}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("课酬试算 = %d(%s),期望 200", code, msg)
+	}
+	payload := jsonMap(t, data)
+
+	if got, _ := payload["detailId"].(float64); got != float64(detail.ID) {
+		t.Fatalf("detailId = %v,期望 %d", payload["detailId"], detail.ID)
+	}
+	if got, _ := payload["schemeId"].(float64); got != float64(scheme.ID) {
+		t.Fatalf("schemeId = %v,期望 %d", payload["schemeId"], scheme.ID)
+	}
+	if got, _ := payload["schemeCampusId"].(float64); got != 10 {
+		t.Fatalf("schemeCampusId = %v,期望 10(校区级方案)", payload["schemeCampusId"])
+	}
+	if got, _ := payload["schemeScope"].(string); got != "campus" {
+		t.Fatalf("schemeScope = %v,期望 campus(命中校区级方案)", payload["schemeScope"])
+	}
+	if got, _ := payload["ruleTier"].(string); got == "" {
+		t.Fatalf("ruleTier 为空:溯源字段不得裁掉(契约 §4.6 v0.1.29)")
+	}
+	// 量纲红线:percentage 是**百分数字符串**,不是金额(不能被 money 格式化)。
+	// 出口已定点化到 1 位小数 ⇒ 这里可以直接比字节,**并且这条断言正是"量纲污染"的探测器**:
+	// 数值 85 在 "85.0"(百分比)/ "85.00"(金额)/ "0.85"(比率)三种写法下"数值断言"各有不同,
+	// 但 "85.00" 与 "85.0" 的差别只有位数 —— 只有字节断言抓得住"比例被按金额格式化"。
+	pctText, _ := payload["percentage"].(string)
+	if pctText != "85.0" {
+		t.Fatalf("percentage = %q,期望 \"85.0\"(百分数 + 1 位小数);"+
+			"\"0.85\" 说明被当比率、\"8500\" 说明被 ×100、\"85.00\" 说明量纲被当成了金额",
+			pctText)
+	}
+	// 数值断言保留一条:即使字节被人为改坏,也要能看出"是不是 85 这个数"
+	if pctValue, pctErr := strconv.ParseFloat(strings.TrimSpace(pctText), 64); pctErr != nil || pctValue != 85 {
+		t.Fatalf("percentage = %q(解析 %v,错 %v),数值必须是 85", pctText, pctValue, pctErr)
+	}
+	if got, _ := payload["min"].(string); got != "0.00" {
+		t.Fatalf("min = %v,期望 \"0.00\"", payload["min"])
+	}
+}
+
+// TestPayrollQuoteErrors 端点 59 的错误分流:无方案 → 3112;跨机构 → 1004;日期非法 → 2002。
+func TestPayrollQuoteErrors(t *testing.T) {
+	db := newCourseTestDB(t)
+	classify := seedPayrollClassify(t, db, 1000) // 只建分类,**不建方案**
+	app := newPayrollTestApp(t, db)
+	base := map[string]any{
+		"teacherId": courseTestTeacher, "schoolId": 1, "campusId": 10,
+		"classifyId": classify.ID, "teacherExp": 1, "date": "2026-11-09",
+	}
+
+	// 无方案 → 3112(结课侧会把它降级为 0 课酬,但试算入口必须暴露)
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathPayrollQuote, base, "X-Test-Token", "t"); code != apperr.CodePayrollPlanMissing {
+		t.Fatalf("无方案试算 = %d(%s),期望 3112", code, msg)
+	}
+	// 跨机构(作用域机构 1,传入 2)→ 1004。
+	// **必须用非超管作用域**:超管按设计可查任意机构(`assertInstitution` 放行),
+	// 用超管测 1004 会得到 3112(无方案),把"越权没拦"误判成"业务错"。
+	cross := map[string]any{
+		"teacherId": courseTestTeacher, "schoolId": 2, "campusId": 10,
+		"classifyId": classify.ID, "teacherExp": 1, "date": "2026-11-09",
+	}
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathPayrollQuote, cross,
+		"X-Test-Token", "t", "X-Test-Scope", "academic"); code != infraerrors.ErrUnscoped.Code {
+		t.Fatalf("跨机构试算 = %d(%s),期望 1004", code, msg)
+	}
+	// 日期格式非法 → 2002
+	bad := map[string]any{
+		"teacherId": courseTestTeacher, "schoolId": 1, "campusId": 10,
+		"classifyId": classify.ID, "teacherExp": 1, "date": "2026/11/09",
+	}
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathPayrollQuote, bad, "X-Test-Token", "t"); code != infraerrors.ErrBadFormat.Code {
+		t.Fatalf("非法日期 = %d(%s),期望 2002", code, msg)
+	}
+}
+
+// seedPayrollClassify 建课程分类(class_type 决定课酬规则维度)。
+func seedPayrollClassify(t *testing.T, db *gorm.DB, classType int32) *model.CoursesClassify {
+	t.Helper()
+	row := &model.CoursesClassify{
+		InstitutionID: 1, CampusID: 10, ClassType: classType, GradeID: 0, SubjectID: 0,
+		Name: "课酬测试班", UnitPrice: "100.00", MinPeople: 1, MaxPeople: 10,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("建分类失败: %v", err)
+	}
+	return row
+}
+
+// seedPayrollScheme 建一套"校区级方案 + 无证通用规则"(命中确定,便于逐字段断言)。
+func seedPayrollScheme(t *testing.T, db *gorm.DB, classType int32, percentage, minPayment, maxPayment, reward string) (*model.CoursePayment, *model.CoursePaymentDetail) {
+	t.Helper()
+	effect := time.Now().AddDate(0, 0, -30)
+	scheme := &model.CoursePayment{
+		InstitutionID: 1, CampusID: 10, Actived: true, EffectDate: &effect,
+	}
+	if err := db.Create(scheme).Error; err != nil {
+		t.Fatalf("建课酬方案失败: %v", err)
+	}
+	detail := &model.CoursePaymentDetail{
+		CoursePaymentID: scheme.ID, GradeID: -1, SubjectID: -1, ClassType: classType, ClassifyID: 0,
+		Percentage: percentage, MinPayment: minPayment, MaxPayment: maxPayment, Reward: reward,
+		TeacherCert: false, TeacherExpMin: "0.000", TeacherExpMax: "100.000", Actived: true,
+	}
+	if err := db.Create(detail).Error; err != nil {
+		t.Fatalf("建课酬规则失败: %v", err)
+	}
+	return scheme, detail
+}
+
+// ---------------------------------------------------------------- 端点 61:修 max_people
+
+// TestBackfillMaxPeopleIdempotent 端点 61:按分类回填 + 分类未配置的行**不写 0** + 可重跑。
+//
+// 变红条件:
+//   - 分类未配置(max_people=0)的课程被写成 0 → 该断言行"必须保持 NULL/0 且计入 skipped"红;
+//   - 第二次执行 affected≠0 → 幂等红。
+func TestBackfillMaxPeopleIdempotent(t *testing.T) {
+	db := newCourseTestDB(t)
+	// 分类 A:max_people=25;分类 B:max_people=0(未配置)
+	classifyA := seedPayrollClassify(t, db, 1000)
+	classifyA.MaxPeople = 25
+	if err := db.Save(classifyA).Error; err != nil {
+		t.Fatalf("更新分类失败: %v", err)
+	}
+	classifyB := seedPayrollClassify(t, db, 1001) // MaxPeople 默认 1000 → 显式压成 0
+	if err := db.Model(&model.CoursesClassify{}).Where("id = ?", classifyB.ID).
+		UpdateColumn("max_people", 0).Error; err != nil {
+		t.Fatalf("压零分类 B 失败: %v", err)
+	}
+	courseA := seedCourse(t, db, "待回填A", 10)
+	courseA.ClassifyID = classifyA.ID
+	db.Save(courseA)
+	courseB := seedCourse(t, db, "分类未配置B", 10)
+	courseB.ClassifyID = classifyB.ID
+	db.Save(courseB)
+
+	app := newPayrollTestApp(t, db)
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathMaintenanceCoursesMaxPeople, nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("修 max_people = %d(%s),期望 200", code, msg)
+	}
+	if got, _ := jsonMap(t, data)["affected"].(float64); got != 1 {
+		t.Fatalf("affected = %v,期望 1(只有 A 被回填;分类为 0 的行不得写 0)", jsonMap(t, data)["affected"])
+	}
+	var gotA, gotB int32
+	db.Raw("SELECT max_people FROM t_courses WHERE id = ?", courseA.ID).Scan(&gotA)
+	db.Raw("SELECT max_people FROM t_courses WHERE id = ?", courseB.ID).Scan(&gotB)
+	if gotA != 25 {
+		t.Fatalf("课程 A max_people = %d,期望 25(继承分类)", gotA)
+	}
+	if gotB != 0 {
+		t.Fatalf("课程 B max_people = %d,期望 0(分类未配置,不得写别的值)", gotB)
+	}
+	// 可重跑:第二次 affected = 0
+	code, msg, data = doJSON(t, app, fiber.MethodPost, PathMaintenanceCoursesMaxPeople, nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("二次修 max_people = %d(%s),期望 200", code, msg)
+	}
+	if got, _ := jsonMap(t, data)["affected"].(float64); got != 0 {
+		t.Fatalf("二次 affected = %v,期望 0(幂等)", jsonMap(t, data)["affected"])
+	}
+}
+
+// ---------------------------------------------------------------- 端点 62:修 campus_id
+
+// TestBackfillCoursesCampusSkipsMultiCampus 端点 62:**多校区课程必须保持 0**(机构级),单校区回填。
+//
+// 变红条件:把多校区课程也回填 → 课程被锁进某一个校区、其它校区看不到(越权/丢数据);
+//
+//	无排班规则的课程被回填(0≠1)→ affected 变 2。
+func TestBackfillCoursesCampusSkipsMultiCampus(t *testing.T) {
+	db := newCourseTestDB(t)
+	single := seedCourse(t, db, "单校区课", 0) // campus_id=0 待回填
+	multi := seedCourse(t, db, "多校区课", 0)  // 两条规则、两个校区 → 保持 0
+	noRule := seedCourse(t, db, "无规则课", 0) // 无规则 → 不回填
+	// 单校区:1 条规则(campus 10)
+	seedClassRule(t, db, single.ID, 9101, courseTestTeacher, "1", model.CoursesClassStatusOngoing, "09:00", "10:00")
+	// 多校区:2 条规则,其中一条改到 campus 20
+	seedClassRule(t, db, multi.ID, 9201, courseTestTeacher, "1", model.CoursesClassStatusOngoing, "09:00", "10:00")
+	otherCampus := seedClassRule(t, db, multi.ID, 9201, courseTestTeacher, "3", model.CoursesClassStatusOngoing, "11:00", "12:00")
+	otherCampus.CampusID = 20
+	if err := db.Save(otherCampus).Error; err != nil {
+		t.Fatalf("改规则校区失败: %v", err)
+	}
+	app := newPayrollTestApp(t, db)
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathMaintenanceCoursesCampus, nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("修 campus_id = %d(%s),期望 200", code, msg)
+	}
+	if got, _ := jsonMap(t, data)["affected"].(float64); got != 1 {
+		t.Fatalf("affected = %v,期望 1(只有单校区课程被回填)", jsonMap(t, data)["affected"])
+	}
+	var gotSingle, gotMulti, gotNoRule int64
+	db.Raw("SELECT campus_id FROM t_courses WHERE id = ?", single.ID).Scan(&gotSingle)
+	db.Raw("SELECT campus_id FROM t_courses WHERE id = ?", multi.ID).Scan(&gotMulti)
+	db.Raw("SELECT campus_id FROM t_courses WHERE id = ?", noRule.ID).Scan(&gotNoRule)
+	if gotSingle != 10 {
+		t.Fatalf("单校区课程 campus_id = %d,期望 10", gotSingle)
+	}
+	if gotMulti != 0 {
+		t.Fatalf("多校区课程 campus_id = %d,期望 0(机构级课程不得被锁进单校区)", gotMulti)
+	}
+	if gotNoRule != 0 {
+		t.Fatalf("无规则课程 campus_id = %d,期望 0(计数 0 ≠ 1,不回填)", gotNoRule)
+	}
+}
+
+// ---------------------------------------------------------------- 端点 63:活动数据同步
+
+// TestActivitySyncModes 端点 63:两种 mode 各走各的 SP;非法 mode → 2001;非超管 → 1004。
+//
+// 变红条件:
+//   - 非法 mode 落进某个分支(会被当成 used-log 而清表!)→ 2001 断言红;
+//   - mode=signed 把已取消报名(status=0)的行走回写 → 数据断言红。
+func TestActivitySyncModes(t *testing.T) {
+	db := newCourseTestDB(t)
+	// ---- used-log 模式:造 1 条 discount_log,重建后 used_log 应有 1 行
+	activity := &model.CoursesActivity{
+		InstitutionID: 1, CampusID: 10, Type: 2, Title: ptr("充值赠送活动"), Status: 1,
+		Money: "100.00",
+	}
+	if err := db.Create(activity).Error; err != nil {
+		t.Fatalf("建活动失败: %v", err)
+	}
+	const studentID = courseTestStudent
+	// SP30 的数据形状:**流水只指向池行**(`t_courses_activity_user_discount_log` 没有
+	// activity_id/user_id 列)⇒ 必须"池行 + 流水行"两条,重建才能 JOIN 出 activity/user。
+	pool := &model.CoursesActivityUserDiscount{
+		ActivityID: activity.ID, InstitutionID: 1, CampusID: 10, UserID: studentID,
+		Income: "100.00", DiscountAmount: "100.00", DiscountAmountUsed: "50.00",
+	}
+	if err := db.Create(pool).Error; err != nil {
+		t.Fatalf("建赠送池行失败: %v", err)
+	}
+	logRow := &model.CoursesActivityUserDiscountLog{
+		ActivityUserDiscountID: pool.ID,
+		DiscountAmountUsed:     "-50.00", Memo: ptr("使用赠送"),
+	}
+	if err := db.Create(logRow).Error; err != nil {
+		t.Fatalf("建赠送流水失败: %v", err)
+	}
+	app := newPayrollTestApp(t, db)
+
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathMaintenanceActivitySync,
+		map[string]any{"mode": "used-log"}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("used-log 同步 = %d(%s),期望 200", code, msg)
+	}
+	if got, _ := jsonMap(t, data)["affected"].(float64); got != 1 {
+		t.Fatalf("used-log affected = %v,期望 1(重建出 1 行)", jsonMap(t, data)["affected"])
+	}
+	var usedCount int64
+	db.Model(&model.CoursesActivityUsedLog{}).Count(&usedCount)
+	if usedCount != 1 {
+		t.Fatalf("used_log 行数 = %d,期望 1", usedCount)
+	}
+
+	// ---- signed 模式:status=0(已取消)的行不得被历史工单"复活"
+	signed := &model.CoursesActivitySigned{
+		ActivityID: activity.ID, UserID: studentID, Status: 0, RechargeAmount: "0.00",
+	}
+	if err := db.Create(signed).Error; err != nil {
+		t.Fatalf("建报名行失败: %v", err)
+	}
+	request := &model.UserBalanceRequest{
+		BizNo: "REQ-SYNC-1", InstitutionID: 1, CampusID: 10, UserID: studentID,
+		Type: 100, Money: "100.00", Approved: 1,
+	}
+	if err := db.Create(request).Error; err != nil {
+		t.Fatalf("建充值工单失败: %v", err)
+	}
+	pool2 := &model.CoursesActivityUserDiscount{
+		ActivityID: activity.ID, InstitutionID: 1, CampusID: 10, UserID: studentID,
+		BalanceRequestID: &request.ID, Income: "100.00", DiscountAmount: "100.00",
+	}
+	if err := db.Create(pool2).Error; err != nil {
+		t.Fatalf("建第二池行失败: %v", err)
+	}
+	code, msg, _ = doJSON(t, app, fiber.MethodPost, PathMaintenanceActivitySync,
+		map[string]any{"mode": "signed"}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("signed 同步 = %d(%s),期望 200", code, msg)
+	}
+	var signedStatus int8
+	db.Raw("SELECT status FROM t_courses_activity_signed WHERE id = ?", signed.ID).Scan(&signedStatus)
+	if signedStatus != 0 {
+		t.Fatalf("已取消报名的 status = %d,期望仍为 0(重放不得复活已取消报名)", signedStatus)
+	}
+
+	// ---- 非法 / 缺失 mode → 2001(**绝不能落进任何分支**:落进 used-log 会清表)
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathMaintenanceActivitySync,
+		map[string]any{"mode": "drop-all"}, "X-Test-Token", "t"); code != infraerrors.ErrBadFormat.Code {
+		t.Fatalf("非法 mode = %d(%s),期望 2002", code, msg)
+	}
+	// 缺失 mode → **2001**(未提供必要参数);传了非法值 → **2002**(枚举越界,与端点 25 一致)
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathMaintenanceActivitySync,
+		map[string]any{}, "X-Test-Token", "t"); code != infraerrors.ErrBadParam.Code {
+		t.Fatalf("缺失 mode = %d(%s),期望 2001", code, msg)
+	}
+	// 非法 mode 之后 used_log 必须还在(证明没有误清表)
+	db.Model(&model.CoursesActivityUsedLog{}).Count(&usedCount)
+	if usedCount != 1 {
+		t.Fatalf("非法 mode 后 used_log 行数 = %d,期望仍为 1(**误清表**)", usedCount)
+	}
+}
+
+// TestMaintenanceRequiresSuperAdmin 61~63 的**第二道防线**:非超管即使绕过路由白名单也要 1004。
+//
+// 为什么必须测第二道:路由白名单和种子是**配置**,配错只会让"该拦的没拦";
+// Service 层 `assertSuperAdmin` 是代码里的硬断言,两者独立,缺一不可。
+func TestMaintenanceRequiresSuperAdmin(t *testing.T) {
+	db := newCourseTestDB(t)
+	app := newPayrollTestApp(t, db)
+	cases := []struct {
+		name string
+		path string
+		body any
+	}{
+		{"61 修 max_people", PathMaintenanceCoursesMaxPeople, nil},
+		{"62 修 campus", PathMaintenanceCoursesCampus, nil},
+		{"63 活动同步", PathMaintenanceActivitySync, map[string]any{"mode": "used-log"}},
+	}
+	for _, tc := range cases {
+		code, msg, _ := doJSON(t, app, fiber.MethodPost, tc.path, tc.body,
+			"X-Test-Token", "t", "X-Test-Scope", "academic")
+		if code != infraerrors.ErrUnscoped.Code {
+			t.Errorf("%s 非超管 = %d(%s),期望 1004(Service 层 assertSuperAdmin)", tc.name, code, msg)
+		}
+	}
+	// 对照:超管通过(61 在空库上 affected=0,不是 1004)
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathMaintenanceCoursesMaxPeople, nil,
+		"X-Test-Token", "t"); code != 200 {
+		t.Errorf("61 超管 = %d(%s),期望 200", code, msg)
+	}
+}
+
+// ptr 返回字符串指针(测试夹具的可空列用)。
+func ptr(v string) *string { return &v }
+
+// mustDecimal 把 JSON 里的数值字段(字符串或数字)转 float(不比较字面量填充位数)。
+//
+// 为什么需要:SQLite 没有 DECIMAL 类型,`"1.000"` 读回来是 `"1"`;生产 MySQL 是 `"1.000"`。
+// 断言要跨方言成立,只能比数值。
+func mustDecimal(t *testing.T, v any) float64 {
+	t.Helper()
+	switch value := v.(type) {
+	case string:
+		parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
+		if err != nil {
+			t.Fatalf("%v 不是合法数值: %v", v, err)
+		}
+		return parsed
+	case float64:
+		return value
+	default:
+		t.Fatalf("%v 既不是字符串也不是数字(%T)", v, v)
+		return 0
+	}
+}

+ 598 - 0
internal/academic/handler/route.go

@@ -0,0 +1,598 @@
+// 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-13d 10 条)
+// ---------------------------------------------------------------------------
+//
+//	财务 16  端点 1~16   fund_handler.go
+//	账单 2   端点 17、21 bill_handler.go
+//	活动 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)
+//
+// **总数 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,
+// 也不属 AC-13 的 22~63)。**不挂空壳/占位路由**——空壳会让"未实现"看起来像"已实现但返回怪码",
+// 比 404 更难查(前端拿到的不是 404 而是业务错误码,会去查业务逻辑)。
+//
+// ---------------------------------------------------------------------------
+// 二、四条运行期断言(本文件是唯一实现点,e2e 复用同一函数,不另写一套)
+// ---------------------------------------------------------------------------
+//
+//	① AssertUniqueMethodPath:遍历 `app.GetRoutes()` 断言 `method+path` 全局唯一。
+//	   **为什么必须运行期**:Fiber 对重复 `method+path` 是"**先注册优先、后者静默失效**",
+//	   症状是"改了没生效";静态 grep 抓不到这种语义重叠,人眼也抓不到(40 条路由里有一对
+//	   重复,review 时几乎不可能看出来)。
+//
+//	② AssertFirstSegments:断言**本装配单元注册出的**一级段集合 ⊆ {`/admin`,`/student`,`/pay`}。
+//	   ⚠ **断言对象是 `Registration.Prefixes`(本域注册出来的段),不是全局 `app.GetRoutes()`**:
+//	   全局表天然含 identity 的 `/auth/*` 与 org 的 `/org/*`,对全局表断言**必红**,
+//	   维护者最后只会做两件事之一——删断言,或把白名单放宽成 `{admin,student,pay,auth,org}`,
+//	   两种都把"academic 不得重复注册他域路径"的意图彻底丢掉。**作用域故意不同,不得合并**。
+//
+//	③ 变异反例(证明 ② 不是"恒过"):`RegisterOptions.Routes` 允许注入自定义路由表,
+//	   e2e 用它注册一条 `/auth/xxx` → ② 必须变红。若把 ② 误写成"先过滤掉 /auth 再断言",
+//	   则该用例恒绿——这正是"守卫本身要能被证伪"的意义(设计 §10.3)。
+//
+//	④ 静态段优先:见 `Routes` 的注册顺序(静态段全部在 `:id` 之前)。
+//	   AC-13c 已逐个核对:`courses/arrears`、`courses/records/*` 与 `courses/:id*` **不存在**
+//	   同 depth 真冲突(反证见 course_handler.go 文件头),如实登记、不硬凑变红用例。
+//	   行为断言在 e2e:`GET /admin/activities/discounts/usable` 必须 200,
+//	   被 `:id` 吃掉会变成 2002(`:id` 解析 "discounts" 失败)。
+//
+// ---------------------------------------------------------------------------
+// 三、为什么中间件按"每条路由一条链"挂,而不是按 Group 挂
+// ---------------------------------------------------------------------------
+//
+// 40 条端点的角色白名单**只有 3 种组合**,但静态段/`:id` 段的注册顺序**跨组合交错**
+// (如 `discounts/*` 是 finance 组、`/admin/activities/:id` 是 academic 组,前者必须先注册)。
+// 用 Group 挂就必须在多个 group 之间来回跳,注册顺序变成"跟着 group 走",
+// 一旦新增端点很容易把静态段放到 `:id` 之后。因此改为**单层平铺 + 每行自带
+// `Auth → RoleGuard(白名单) → handler`**:注册顺序 = 表顺序,一眼可审;
+// 白名单与 `SeedRBAC` 的 casbin 策略同源(都取 `Route.Roles`),不会打字不一致。
+package handler
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+// 角色码常量(契约 §1.7 的 6 个 + `market`;与 org 域 `t_role` 字典一致)。
+const (
+	// RoleSuperAdmin 超级管理员(代码层恒放行,见 identity/handler.RequireRole)。
+	RoleSuperAdmin = "super_admin"
+	// RoleAcademic 教务。
+	RoleAcademic = "academic"
+	// RoleFinance 财务。
+	RoleFinance = "finance"
+	// RoleTeacher 老师。
+	RoleTeacher = "teacher"
+	// RoleStudent 学生(移动端)。
+	RoleStudent = "student"
+	// RoleParent 家长(移动端)。
+	RoleParent = "parent"
+	// RoleMarket 市场(推广/分账候选角色;本批 40 端点未用到,保留以支持白名单校验)。
+	RoleMarket = "market"
+)
+
+// knownRoleCodes 合法角色码集合(`Route.Roles` 的**拼写校验**用)。
+//
+// 为什么要在注册期校验角色码:白名单写错(如 `"finance "` 带尾空格、`"Finance"` 大小写错)
+// **不会让任何用例变红**——它只会让该角色在生产上永远 1003,而 1003 看起来像"权限没配",
+// 排查会先怀疑 RBAC 种子,最后才发现是路由表里的一个空格。注册期 fail-fast 一次即可。
+var knownRoleCodes = map[string]struct{}{
+	RoleSuperAdmin: {}, RoleAcademic: {}, RoleFinance: {},
+	RoleTeacher: {}, RoleStudent: {}, RoleParent: {}, RoleMarket: {},
+}
+
+// AcademicFirstSegments academic 域**允许**出现的一级段白名单(契约 §1.6/§2)。
+//
+// `/auth` 属 identity、`/org` 属 org,academic **不得**注册(重复注册同一 method+path 时
+// Fiber 先注册优先,identity 的 handler 会被 academic 的悄悄顶掉,症状是"登录接口返回 1003")。
+var AcademicFirstSegments = []string{"/admin", "/student", "/pay"}
+
+// Route 一条 academic 路由(**注册表行**:既是注册输入,也是 RBAC 种子的唯一来源)。
+//
+// 字段:
+//   - No int:契约端点号(§2 表内编号 1~63);用于种子排序与交付报告逐条对账。
+//   - Method string:HTTP method(大写,`GET`/`POST`/`PUT`/`DELETE`)。
+//   - Path string:**不含 `/api`** 的后端路径(契约 §1.6),参数段用 Fiber 的 `:name` 写法。
+//   - Name string:中文名(日志/种子的权限点名称用;不参与路由语义)。
+//   - Roles []string:契约 §2 该行给出的角色白名单(含 `super_admin`,便于审计与种子的可读性)。
+//   - Handler fiber.Handler:已交付的处理函数(**本批不改 handler 实现**)。
+type Route struct {
+	No      int
+	Method  string
+	Path    string
+	Name    string
+	Roles   []string
+	Handler fiber.Handler
+}
+
+// Key `method + path` 唯一键(断言 ① 与种子去重共用同一口径)。
+func (r Route) Key() string { return r.Method + " " + r.Path }
+
+// RoleGuard 角色白名单中间件**工厂**(由装配层注入 `identity/handler.RequireRole`)。
+//
+// 为什么不直接 import identity 的 handler:academic → identity 是跨域依赖,
+// 本域与 org 的耦合一律经 `port` 包或装配层注入(见 `academic/port` 包注释的依赖方向铁律)。
+// 注入后 `academic/handler` 对 identity **零 import**,签名漂移在 `cmd/api` 装配处即编译失败。
+type RoleGuard func(roles ...string) fiber.Handler
+
+// RegisterOptions 路由注册参数。
+//
+// 字段:
+//   - Auth fiber.Handler:验签 + 作用域注入中间件(**必填**;本批 40 端点**全部**需登录,
+//     公开端点将来在别的装配单元里挂,不得混进这里)。
+//   - RoleGuard RoleGuard:角色白名单中间件工厂(**必填**)。
+//   - Routes []Route:注册表(**nil = 用 `Routes(h)` 的全部路由**)。
+//     非 nil 只给"能自证失败"的变异用例用(e2e 注册一条 `/auth/xxx` 验证断言 ② 会红),
+//     生产装配恒传 nil。
+type RegisterOptions struct {
+	Auth      fiber.Handler
+	RoleGuard RoleGuard
+	Routes    []Route
+}
+
+// Registration 注册结果(断言 ② 的断言对象 + 种子生成输入 + 交付报告的对账表)。
+//
+// 字段:
+//   - Routes []Route:实际注册的路由(顺序 = 注册顺序)。
+//   - Prefixes []string:本装配单元注册出的**一级段**集合(去重、升序,如 `["/admin","/student"]`)。
+type Registration struct {
+	Routes   []Route
+	Prefixes []string
+}
+
+// Routes 返回全部已交付路由(65 条:AC-14 40 + AC-13c 15 + AC-13d 10;**顺序即注册顺序,静态段全部在 `:id` 之前**)。
+//
+// ⚠ 顺序规则(Fiber 按注册顺序匹配;违反即"静态段被 `:id` 吃掉"):
+//
+//	① 同深度下 **字面段必须先于 `:param` 段**:
+//	   `/admin/activities/discounts/*`(深度 4)必须先于 `/admin/activities/:id`(深度 3)——
+//	   深度不同时不会互相吃掉,但同深度的 `discounts|recharge-gifts` 与 `:id` 必须字面优先;
+//	② `/admin/finance/refunds/custom` 先于 `/admin/finance/refunds`(深度不同,仍按"具体优先"排);
+//	③ 本函数把**整个 40 条按"静态优先"统一排序**,而不是分段排——分段排最容易在
+//	   "以后再加一条"时破坏顺序,整表排序只有一处可错。
+func Routes(h *Handler) []Route {
+	return []Route{
+		// ---------------------------------------------------------------- 财务(端点 1~16、21)
+		{No: 2, Method: fiber.MethodGet, Path: PathFinanceRefundable, Name: "可退金额",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.GetRefundable},
+		{No: 1, Method: fiber.MethodGet, Path: PathFinanceAccounts, Name: "账户快照",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.GetAccount},
+		{No: 6, Method: fiber.MethodPost, Path: PathFinanceRefundsCustom, Name: "自定义退费(含坏账)",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CreateCustomRefund},
+		{No: 5, Method: fiber.MethodPost, Path: PathFinanceRefunds, Name: "退费",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CreateRefund},
+		{No: 3, Method: fiber.MethodPost, Path: PathFinanceRecharges, Name: "充值",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CreateRecharge},
+		{No: 4, Method: fiber.MethodPost, Path: PathFinanceExpenditures, Name: "消费",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CreateExpenditureLog},
+		{No: 7, Method: fiber.MethodPost, Path: PathFinanceTransfers, Name: "课费转结",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CreateTransfer},
+		{No: 8, Method: fiber.MethodPost, Path: PathFinanceBadDebts, Name: "坏账",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CreateBadDebt},
+		{No: 9, Method: fiber.MethodPost, Path: PathFinanceClearedBalances, Name: "余额清零",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.ClearBalance},
+		{No: 10, Method: fiber.MethodPost, Path: PathFinanceSpends, Name: "累计消费",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.AddSpend},
+		{No: 11, Method: fiber.MethodGet, Path: PathFinanceLogs, Name: "流水列表",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.ListLogs},
+		{No: 14, Method: fiber.MethodPost, Path: PathFinanceRequestApprove, Name: "工单通过",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.ApproveRequest},
+		{No: 15, Method: fiber.MethodPost, Path: PathFinanceRequestReject, Name: "工单驳回",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.RejectRequest},
+		// 端点 16:契约角色列写的是"申请人本人 或 super_admin/finance"。
+		// 路由白名单是**粗粒度**门禁,必须 ⊇ 可能成为申请人的角色(端点 13 允许 academic 建单),
+		// 否则"教务本人撤自己的单"会被路由层 1003 挡住、走不到 Service 的归属判定;
+		// **"本人"这一层由 Service 判**(`BalanceRequestService.Cancel` isOwner/isManager,越权 → 1003)。
+		{No: 16, Method: fiber.MethodPost, Path: PathFinanceRequestCancel, Name: "工单撤销",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.CancelRequest},
+		{No: 13, Method: fiber.MethodPost, Path: PathFinanceRequests, Name: "创建工单",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.CreateRequest},
+		{No: 12, Method: fiber.MethodGet, Path: PathFinanceRequests, Name: "工单列表",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.ListRequests},
+		{No: 21, Method: fiber.MethodGet, Path: PathFinanceDashboard, Name: "仪表盘",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.GetDashboard},
+		// 端点 17 是 `/student` 段(移动端账单)。
+		{No: 17, Method: fiber.MethodGet, Path: PathStudentBill, Name: "学生账单",
+			Roles:   []string{RoleStudent, RoleParent, RoleSuperAdmin},
+			Handler: h.GetBill},
+
+		// ---------------------------------------------------------------- 活动(端点 22~37)
+		{No: 22, Method: fiber.MethodGet, Path: PathActivities, Name: "活动列表",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance, RoleTeacher},
+			Handler: h.ListActivities},
+		{No: 23, Method: fiber.MethodPost, Path: PathActivities, Name: "新建活动",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.CreateActivity},
+		// ↓ 静态段(`discounts/*`、`recharge-gifts/*`)全部先于 `:id` 段注册。
+		{No: 31, Method: fiber.MethodGet, Path: PathActivityDiscountsUsable, Name: "可用赠送余额",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.GetActivityUsableDiscount},
+		{No: 32, Method: fiber.MethodPost, Path: PathActivityDiscountsUse, Name: "使用赠送",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.UseActivityDiscount},
+		{No: 33, Method: fiber.MethodPost, Path: PathActivityDiscountsRefund, Name: "赠送退回",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.RefundActivityDiscount},
+		{No: 34, Method: fiber.MethodPost, Path: PathActivityDiscountsRefundByActivity, Name: "按活动退回赠送",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.RefundDiscountByActivity},
+		{No: 35, Method: fiber.MethodPost, Path: PathActivityRechargeGiftsUse, Name: "充值赠送使用",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.UseRechargeGift},
+		{No: 36, Method: fiber.MethodPost, Path: PathActivityRechargeGiftsRefund, Name: "充值赠送退回",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.RefundRechargeGift},
+		{No: 24, Method: fiber.MethodGet, Path: PathActivityDetail, Name: "活动详情",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleTeacher},
+			Handler: h.GetActivity},
+		{No: 25, Method: fiber.MethodPut, Path: PathActivityDetail, Name: "更新活动",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.UpdateActivity},
+		{No: 26, Method: fiber.MethodPost, Path: PathActivitySign, Name: "活动报名",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleStudent, RoleParent},
+			Handler: h.SignActivity},
+		{No: 27, Method: fiber.MethodPost, Path: PathActivityCancelSign, Name: "取消报名",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleStudent, RoleParent},
+			Handler: h.CancelActivitySign},
+		{No: 28, Method: fiber.MethodGet, Path: PathActivitySigneds, Name: "报名列表",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.ListActivitySigneds},
+		{No: 29, Method: fiber.MethodPost, Path: PathActivityRecommendSettle, Name: "老带新结算",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.SettleActivityRecommend},
+		{No: 30, Method: fiber.MethodPost, Path: PathActivityCancelRecharge, Name: "取消充值活动",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CancelActivityRecharge},
+		{No: 37, Method: fiber.MethodPost, Path: PathActivityVisits, Name: "回访登记",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleTeacher},
+			Handler: h.CreateActivityVisit},
+
+		// ---------------------------------------------------------------- 分账(端点 38~43)
+		{No: 38, Method: fiber.MethodGet, Path: PathDivideTree, Name: "分账树",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.GetDivideTree},
+		{No: 39, Method: fiber.MethodPost, Path: PathDivideMembers, Name: "加入下线",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.AddDivideMember},
+		{No: 40, Method: fiber.MethodPut, Path: PathDivideMemberQuota, Name: "设置分成额度",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.SetDivideQuota},
+		{No: 41, Method: fiber.MethodPost, Path: PathDivideLogs, Name: "记分账流水",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.CreateDivideLogRecord},
+		{No: 42, Method: fiber.MethodDelete, Path: PathDivideLogs, Name: "删分账流水",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.DeleteDivideLogRecord},
+		{No: 43, Method: fiber.MethodGet, Path: PathDivideLogs, Name: "分账流水列表",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.ListDivideLogs},
+
+		// ---------------------------------------------------------------- 课程 / 排班 / 打卡(端点 44~57)
+		//
+		// 顺序规则同活动域:**静态段全部先于 `:id` 段**。本批 15 条的静态段是
+		// `courses`(2 段)/ `courses/arrears`(3 段)/ `courses/arrears/counteract`(4 段)/
+		// `courses/records/...`(5 段);`:id` 段是 `courses/:id`(3 段)与
+		// `courses/:id/classes|records|students`(4 段、5 段)。
+		//
+		// **已逐个核对:本批不存在"会被 `:id` 吃掉"的同 depth 真冲突**(详见 course_handler.go
+		// 文件头「路由注册顺序」的三条反证)——如实登记,不硬凑变红用例。
+		//
+		// 端点 45 是 `POST /admin/courses` + `PUT /admin/courses/:id` **两条** method+path
+		// (契约 §2.4 行 45 写 "POST/PUT"),因此 14 个契约端点 = 15 条路由。
+		{No: 44, Method: fiber.MethodGet, Path: PathCourses, Name: "课程列表",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance, RoleTeacher},
+			Handler: h.ListCourses},
+		{No: 45, Method: fiber.MethodPost, Path: PathCourses, Name: "新建课程",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.CreateCourse},
+		// ↓ 静态段(arrears、records/*)先于 `:id`
+		{No: 55, Method: fiber.MethodGet, Path: PathCourseArrears, Name: "欠费列表",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.ListCourseArrears},
+		{No: 56, Method: fiber.MethodPost, Path: PathCourseArrearsCounteract, Name: "欠费核销",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.CounteractCourseArrears},
+		{No: 50, Method: fiber.MethodPost, Path: PathCourseRecordStart, Name: "开始上课",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleTeacher},
+			Handler: h.StartCourseRecord},
+		{No: 51, Method: fiber.MethodPost, Path: PathCourseRecordFinish, Name: "结束上课",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleTeacher},
+			Handler: h.FinishCourseRecord},
+		{No: 52, Method: fiber.MethodPost, Path: PathCourseRecordAttendance, Name: "打卡",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleTeacher},
+			Handler: h.MarkCourseRecordAttendance},
+		// ↓ 以下是 `:id` 段(排在静态段之后)
+		{No: 45, Method: fiber.MethodPut, Path: PathCourseDetail, Name: "更新课程",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.UpdateCourse},
+		{No: 46, Method: fiber.MethodGet, Path: PathCourseClasses, Name: "排班规则列表",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.ListCourseClasses},
+		{No: 47, Method: fiber.MethodPost, Path: PathCourseClasses, Name: "新建排班规则",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.CreateCourseClass},
+		{No: 48, Method: fiber.MethodGet, Path: PathCourseRecords, Name: "课表实例",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleTeacher},
+			Handler: h.ListCourseRecords},
+		{No: 49, Method: fiber.MethodPost, Path: PathCourseRecordsGenerate, Name: "生成课表",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.GenerateCourseRecords},
+		{No: 53, Method: fiber.MethodGet, Path: PathCourseStudents, Name: "报课学生列表",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.ListCourseStudents},
+		{No: 54, Method: fiber.MethodPost, Path: PathCourseStudents, Name: "报名",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.EnrollCourseStudent},
+		// 端点 57 是 `/student` 段(移动端课表)。
+		{No: 57, Method: fiber.MethodGet, Path: PathStudentSchedule, Name: "学生课表",
+			Roles:   []string{RoleStudent, RoleParent, RoleTeacher},
+			Handler: h.GetStudentSchedule},
+
+		// ---------------------------------------------------------------- 课酬 / 维护(端点 59、61~63)
+		//
+		// 端点 58 已按 D17 裁决落地(两级形状);端点 60(收支)待下一批(3 条路由,JOIN 字典)。
+		// 端点 58 是 GET/POST(`/admin/payroll/plans`)+ PUT(`/admin/payroll/plans/:id`)**三条**
+		// method+path:`:id` 指**方案 id**(D17 裁决),明细随方案整体替换。
+		{No: 58, Method: fiber.MethodGet, Path: PathPayrollPlans, Name: "课酬方案列表",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.ListPayrollPlans},
+		{No: 58, Method: fiber.MethodPost, Path: PathPayrollPlans, Name: "新建课酬方案",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.CreatePayrollPlan},
+		{No: 58, Method: fiber.MethodPut, Path: PathPayrollPlanDetail, Name: "更新课酬方案",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.UpdatePayrollPlan},
+		{No: 59, Method: fiber.MethodPost, Path: PathPayrollQuote, Name: "课酬试算",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.QuotePayroll},
+		// 61~63 维护型:**仅 super_admin**(Service 层 `assertSuperAdmin` 是第二道防线)。
+		{No: 61, Method: fiber.MethodPost, Path: PathMaintenanceCoursesMaxPeople, Name: "修复课程最多人数",
+			Roles:   []string{RoleSuperAdmin},
+			Handler: h.BackfillCoursesMaxPeople},
+		{No: 62, Method: fiber.MethodPost, Path: PathMaintenanceCoursesCampus, Name: "修复课程校区",
+			Roles:   []string{RoleSuperAdmin},
+			Handler: h.BackfillCoursesCampus},
+		{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},
+	}
+}
+
+// Register 把 academic 域路由挂到 app 上,并**当场**跑断言 ①②(生产路径与 e2e 同一函数)。
+//
+// 入参:
+//   - app *fiber.App:应用实例(非 nil);
+//   - h *Handler:academic Handler(非 nil;本批不改 handler 实现,只接线);
+//   - opt RegisterOptions:中间件 + 可选注册表覆盖(生产恒 nil,变异用例才用)。
+//
+// 出参:
+//   - *Registration:注册结果(路由表 + 一级段集合);
+//   - error:参数缺失 / 路由表非法(空 handler、空白名单、未知角色码、含 `/api` 前缀)/
+//     断言 ①(`method+path` 重复)/ 断言 ②(一级段越界)。**任一失败都必须让进程起不来**——
+//     这类错误在生产上是"静默失效",启动失败是唯一可靠的暴露方式。
+func Register(app *fiber.App, h *Handler, opt RegisterOptions) (*Registration, error) {
+	if app == nil {
+		return nil, fmt.Errorf("academic route: app 未注入")
+	}
+	if opt.Auth == nil {
+		return nil, fmt.Errorf("academic route: Auth 中间件未注入(本域端点全部需登录)")
+	}
+	if opt.RoleGuard == nil {
+		return nil, fmt.Errorf("academic route: RoleGuard(角色白名单工厂)未注入")
+	}
+	routes := opt.Routes
+	if routes == nil {
+		if h == nil {
+			return nil, fmt.Errorf("academic route: Handler 未注入")
+		}
+		routes = Routes(h)
+	}
+	if err := validateRoutes(routes); err != nil {
+		return nil, err
+	}
+
+	for _, r := range routes {
+		// 单层平铺:`Auth → RoleGuard(本行白名单) → handler`。
+		// 顺序即匹配顺序,与 `Routes` 的"静态优先"排序一一对应(见文件头 §三)。
+		app.Add(r.Method, r.Path, opt.Auth, opt.RoleGuard(r.Roles...), r.Handler)
+	}
+
+	// 断言 ①:**全局**路由表 `method+path` 唯一(跨域重复也算错,故不只看本域 40 条)。
+	if err := AssertUniqueMethodPath(app.GetRoutes()); err != nil {
+		return nil, err
+	}
+	// 断言 ②:**本装配单元**的一级段白名单(不碰全局表,理由见文件头 §二)。
+	prefixes := FirstSegments(routes)
+	if err := AssertFirstSegments(prefixes); err != nil {
+		return nil, err
+	}
+	return &Registration{Routes: routes, Prefixes: prefixes}, nil
+}
+
+// validateRoutes 注册期校验路由表本身(拼写类错误在这里暴露,不留给运行期)。
+//
+// 入参:routes []Route。
+// 出参:nil 合法;否则描述第一条问题(fail-fast,一次报一条便于修)。
+func validateRoutes(routes []Route) error {
+	if len(routes) == 0 {
+		return fmt.Errorf("academic route: 路由表为空")
+	}
+	for _, r := range routes {
+		if r.Handler == nil {
+			return fmt.Errorf("academic route: 端点 %d(%s %s)未绑定 handler", r.No, r.Method, r.Path)
+		}
+		if r.No <= 0 {
+			return fmt.Errorf("academic route: %s %s 缺少契约端点号", r.Method, r.Path)
+		}
+		if !strings.HasPrefix(r.Path, "/") {
+			return fmt.Errorf("academic route: 端点 %d 路径必须以 / 开头(当前 %q)", r.No, r.Path)
+		}
+		// `/api` 是代理层外部前缀(契约 §1.6),**不得**进后端路由表:
+		// 一旦混入,对外就变成 `/api/api/...`(vite/nginx 已去掉一层),症状是"一类环境能通、另一类 404"。
+		if r.Path == "/api" || strings.HasPrefix(r.Path, "/api/") {
+			return fmt.Errorf("academic route: 端点 %d 路径不得含 /api 前缀(当前 %q,见契约 §1.6)", r.No, r.Path)
+		}
+		if len(r.Roles) == 0 {
+			// 空白名单在 `RequireRole` 里是 fail-closed(除超管外全 1003),
+			// 但那种"配置错误"在运行期只表现为 1003,看起来像 RBAC 种子没配 —— 注册期直接拒。
+			return fmt.Errorf("academic route: 端点 %d(%s %s)角色白名单为空", r.No, r.Method, r.Path)
+		}
+		for _, role := range r.Roles {
+			if _, ok := knownRoleCodes[role]; !ok {
+				return fmt.Errorf("academic route: 端点 %d 角色码 %q 非法(合法值:super_admin/academic/finance/teacher/student/parent/market)",
+					r.No, role)
+			}
+		}
+		switch r.Method {
+		case fiber.MethodGet, fiber.MethodPost, fiber.MethodPut, fiber.MethodDelete:
+		default:
+			return fmt.Errorf("academic route: 端点 %d method %q 非法", r.No, r.Method)
+		}
+	}
+	return nil
+}
+
+// FirstSegments 取路由表的一级段集合(去重、升序)。
+//
+// 入参:routes []Route(或任意带 Path 的路由行)。
+// 出参:[]string,如 `["/admin","/student"]`。
+//
+// 这是断言 ② 的数据来源:**集合必须是"本装配单元注册了什么",而不是"全局表里有什么"**
+// (见文件头 §二)。空路径段(如 `/`)会得到空串,断言会因"不在白名单"而报错——符合预期。
+func FirstSegments(routes []Route) []string {
+	seen := make(map[string]struct{}, len(routes))
+	for _, r := range routes {
+		seg := firstSegment(r.Path)
+		seen[seg] = struct{}{}
+	}
+	out := make([]string, 0, len(seen))
+	for seg := range seen {
+		out = append(out, seg)
+	}
+	sort.Strings(out)
+	return out
+}
+
+// firstSegment 取路径第一段(含前导 `/`;`/admin/finance` → `/admin`)。
+func firstSegment(path string) string {
+	trimmed := strings.TrimPrefix(path, "/")
+	if trimmed == "" {
+		return "/"
+	}
+	if idx := strings.IndexByte(trimmed, '/'); idx >= 0 {
+		return "/" + trimmed[:idx]
+	}
+	return "/" + trimmed
+}
+
+// AssertUniqueMethodPath 断言路由表 `method+path` **唯一**(断言 ①)。
+//
+// 入参:routes []fiber.Route(生产路径传 `app.GetRoutes()`,e2e 传同一份)。
+// 出参:nil 唯一;否则 error 列出**全部**重复项(一次报全,避免改一个跑一遍)。
+//
+// 为什么必须运行期断言:Fiber 的 `app.Add` 对重复 `method+path` 不报错、不 panic,
+// 后注册者**静默失效**。症状是"改了没生效/新接口返回旧逻辑",且:
+//   - 静态 grep 抓不到(两条注册语句长得不一样,只有 method+path 相同);
+//   - 单端点用例抓不到(打的还是先注册那个 handler,行为正确)。
+//
+// 注意:同一 path 不同 method 是**合法**的(如端点 12/13 共用 `/admin/finance/requests`),
+// 故键是 `method + " " + path`,不是 path。
+func AssertUniqueMethodPath(routes []fiber.Route) error {
+	seen := make(map[string]int, len(routes))
+	dups := make([]string, 0, 4)
+	for _, r := range routes {
+		key := r.Method + " " + r.Path
+		if n, ok := seen[key]; ok {
+			dups = append(dups, fmt.Sprintf("%s(重复 %d 次)", key, n+1))
+			seen[key] = n + 1
+			continue
+		}
+		seen[key] = 1
+	}
+	if len(dups) > 0 {
+		sort.Strings(dups)
+		return fmt.Errorf("academic route: 路由表存在重复 method+path(Fiber 会静默丢弃后注册者):%s",
+			strings.Join(dups, ";"))
+	}
+	return nil
+}
+
+// AssertFirstSegments 断言一级段集合 ⊆ {`/admin`,`/student`,`/pay`}(断言 ②)。
+//
+// 入参:prefixes []string(**必须是本装配单元注册出的段集合**,见 `FirstSegments`)。
+// 出参:nil 合法;否则 error 指出越界段。
+//
+// ⚠ 本函数**不做任何过滤**(不跳过 /auth、不跳过 /org):一旦加了过滤,断言就恒过,
+// 与"没写断言"等价,但看起来像有防线——比没有更危险。
+func AssertFirstSegments(prefixes []string) error {
+	allowed := make(map[string]struct{}, len(AcademicFirstSegments))
+	for _, seg := range AcademicFirstSegments {
+		allowed[seg] = struct{}{}
+	}
+	bad := make([]string, 0, 2)
+	for _, seg := range prefixes {
+		if _, ok := allowed[seg]; !ok {
+			bad = append(bad, seg)
+		}
+	}
+	if len(bad) > 0 {
+		sort.Strings(bad)
+		return fmt.Errorf(
+			"academic route: 一级段越界 %v(academic 只允许 %v;/auth 属 identity、/org 属 org,重复注册会让先注册者静默失效)",
+			bad, AcademicFirstSegments)
+	}
+	return nil
+}

+ 747 - 0
internal/academic/handler/route_e2e_test.go

@@ -0,0 +1,747 @@
+// AC-14 路由接线 e2e(**四条断言的守卫**——本文件的价值不在于"接口能返回 200")。
+//
+// 覆盖结构(每条对到 AC-14 验收项原文):
+//
+//	① 40 端点注册表与契约逐条对齐 → TestRouteRegistryMatchesContract
+//	   (含"不含 18/19/20"与"/pay 段本批不挂"的反向断言)
+//	② 断言①:全局 `method+path` 唯一 → TestAssertUniqueMethodPath(正例 + 变异反例)
+//	③ 断言②:一级段 ⊆ {/admin,/student,/pay} → TestFirstSegmentWhitelist(正例 + 变异反例)
+//	④ 断言④(行为):静态段优先 → TestStaticSegmentBeatsParam
+//	   被 `:id` 吃掉时 `GET /admin/activities/discounts/usable` 会返回 **2002**(`:id` 解析
+//	   "discounts" 失败),比"路由表里存在这条"更能证明顺序正确。
+//
+//	另含:全部 40 条都必须过 Auth(无 token → 1001)/ 角色白名单真的生效(非白名单角色 1003)/
+//	RBAC 种子幂等 + 与路由表 1:1 + **种子在真 Casbin 引擎下可判定**(含 `:param` 不过度匹配)。
+//
+// 断言纪律:只断 body 契约(HTTP 恒 200 + body.code,铁律 6),不断 HTTP 状态码。
+package handler
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+	"testing"
+
+	"github.com/gofiber/fiber/v2"
+	"gorm.io/gorm"
+
+	identityhandler "yuansiwei/unified-backend/internal/identity/handler"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	infrarabc "yuansiwei/unified-backend/internal/infra/rbac"
+	"yuansiwei/unified-backend/internal/infra/response"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// ---------------------------------------------------------------- 测试装配
+
+// newAcademicRouteTestApp 构造**只挂 academic 域**的 app(断言② 的合法断言对象)。
+//
+// 中间件链与生产一致:`stubAuth(验签+作用域注入)→ identity.RequireRole(本行白名单) → handler`。
+// `stubAuth` 用 `X-Test-Token` 模拟登录态(缺省 = 未登录 → 1001),`X-Test-Scope` 选角色。
+func newAcademicRouteTestApp(t *testing.T, db *gorm.DB, opt RegisterOptions) (*fiber.App, *Registration) {
+	t.Helper()
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	if opt.Auth == nil {
+		opt.Auth = stubAuth()
+	}
+	if opt.RoleGuard == nil {
+		// 用**生产同一个**工厂(identity/handler.RequireRole):若测试自写一份白名单判定,
+		// 装配处把 guard 传错也测不出来。
+		opt.RoleGuard = identityhandler.RequireRole
+	}
+	app := fiber.New(fiber.Config{DisableStartupMessage: true})
+	reg, err := Register(app, h, opt)
+	if err != nil {
+		t.Fatalf("Register 失败: %v", err)
+	}
+	return app, reg
+}
+
+// stubAuth 模拟 identity 的验签中间件:无 token → 1001;有 token → 按 `X-Test-Scope` 注入作用域。
+func stubAuth() fiber.Handler {
+	return func(c *fiber.Ctx) error {
+		if strings.TrimSpace(c.Get("X-Test-Token")) == "" {
+			return response.Fail(c, infraerrors.ErrUnauth) // 1001
+		}
+		switch c.Get("X-Test-Scope") {
+		case "super":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 0, SuperAdmin: true,
+				UserID: "01HADMIN0000000000000001", Role: RoleSuperAdmin,
+			})
+		case "academic":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 10,
+				UserID: "01HACADEMIC0000000000001", Role: RoleAcademic,
+			})
+		case "teacher":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 10,
+				UserID: "01HTEACHER00000000000001", Role: RoleTeacher,
+			})
+		case "student":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 10,
+				UserID: "01HSTUDENT00000000000001", Role: RoleStudent,
+			})
+		default:
+			appscope.Set(c, testScope()) // finance / 机构1 / 校区10
+		}
+		return c.Next()
+	}
+}
+
+// requestPath 把路由模板转成可请求的具体路径(`/admin/activities/:id` → `/admin/activities/1`)。
+func requestPath(tmpl string) string {
+	segs := strings.Split(tmpl, "/")
+	for i, seg := range segs {
+		if strings.HasPrefix(seg, ":") {
+			segs[i] = "1"
+		}
+	}
+	return strings.Join(segs, "/")
+}
+
+// ---------------------------------------------------------------- ① 注册表 ↔ 契约
+
+// TestRouteRegistryMatchesContract 65 条路由与契约 §2.1/§2.2/§2.3/§2.4 逐条对齐。
+//
+// 期望值**手写**(测试即契约快照):与 `route.go` 的生成逻辑不同源,才有对齐价值——
+// 若也写成"从路由表推导",这条用例就只能证明"路由表等于自己"。
+func TestRouteRegistryMatchesContract(t *testing.T) {
+	db := newHandlerTestDB(t)
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	routes := Routes(h)
+
+	want := []struct {
+		no     int
+		method string
+		path   string
+		roles  string // 逗号连接,便于逐条目视核对契约
+	}{
+		{1, "GET", "/admin/finance/accounts/:userId", "super_admin,finance,academic"},
+		{2, "GET", "/admin/finance/accounts/:userId/refundable", "super_admin,finance,academic"},
+		{3, "POST", "/admin/finance/recharges", "super_admin,finance"},
+		{4, "POST", "/admin/finance/expenditures", "super_admin,finance"},
+		{5, "POST", "/admin/finance/refunds", "super_admin,finance"},
+		{6, "POST", "/admin/finance/refunds/custom", "super_admin,finance"},
+		{7, "POST", "/admin/finance/transfers", "super_admin,finance"},
+		{8, "POST", "/admin/finance/bad-debts", "super_admin,finance"},
+		{9, "POST", "/admin/finance/cleared-balances", "super_admin,finance"},
+		{10, "POST", "/admin/finance/spends", "super_admin,finance,academic"},
+		{11, "GET", "/admin/finance/logs", "super_admin,finance,academic"},
+		{12, "GET", "/admin/finance/requests", "super_admin,finance,academic"},
+		{13, "POST", "/admin/finance/requests", "super_admin,finance,academic"},
+		{14, "POST", "/admin/finance/requests/:id/approve", "super_admin,finance"},
+		{15, "POST", "/admin/finance/requests/:id/reject", "super_admin,finance"},
+		// 端点 16:契约写"申请人本人 或 super_admin/finance";路由层是粗粒度门禁,
+		// 必须覆盖"可能成为申请人"的角色(端点 13 允许 academic 建单),"本人"由 Service 判。
+		{16, "POST", "/admin/finance/requests/:id/cancel", "super_admin,finance,academic"},
+		{17, "GET", "/student/bill", "student,parent,super_admin"},
+		{21, "GET", "/admin/finance/dashboard", "super_admin,finance,academic"},
+		{22, "GET", "/admin/activities", "super_admin,academic,finance,teacher"},
+		{23, "POST", "/admin/activities", "super_admin,academic"},
+		{24, "GET", "/admin/activities/:id", "super_admin,academic,teacher"},
+		{25, "PUT", "/admin/activities/:id", "super_admin,academic"},
+		{26, "POST", "/admin/activities/:id/sign", "super_admin,academic,student,parent"},
+		{27, "POST", "/admin/activities/:id/cancel-sign", "super_admin,academic,student,parent"},
+		{28, "GET", "/admin/activities/:id/signeds", "super_admin,academic,finance"},
+		{29, "POST", "/admin/activities/:id/recommend-settle", "super_admin,academic"},
+		{30, "POST", "/admin/activities/:id/cancel-recharge", "super_admin,finance"},
+		{31, "GET", "/admin/activities/discounts/usable", "super_admin,academic,finance"},
+		{32, "POST", "/admin/activities/discounts/use", "super_admin,academic,finance"},
+		{33, "POST", "/admin/activities/discounts/refund", "super_admin,academic,finance"},
+		{34, "POST", "/admin/activities/discounts/refund-by-activity", "super_admin,academic,finance"},
+		{35, "POST", "/admin/activities/recharge-gifts/use", "super_admin,academic,finance"},
+		{36, "POST", "/admin/activities/recharge-gifts/refund", "super_admin,academic,finance"},
+		{37, "POST", "/admin/activities/:id/visits", "super_admin,academic,teacher"},
+		{38, "GET", "/admin/divide-into/tree", "super_admin,academic"},
+		{39, "POST", "/admin/divide-into/members", "super_admin,academic"},
+		{40, "PUT", "/admin/divide-into/members/:userId/quota", "super_admin,academic"},
+		{41, "POST", "/admin/divide-into/logs", "super_admin,academic"},
+		{42, "DELETE", "/admin/divide-into/logs", "super_admin,academic"},
+		{43, "GET", "/admin/divide-into/logs", "super_admin,academic,finance"},
+		// ---- AC-13c:课程 / 排班 / 打卡(端点 44~57,14 个契约端点 = 15 条 method+path)
+		{44, "GET", "/admin/courses", "super_admin,academic,finance,teacher"},
+		{45, "POST", "/admin/courses", "super_admin,academic"},
+		{45, "PUT", "/admin/courses/:id", "super_admin,academic"},
+		{46, "GET", "/admin/courses/:id/classes", "super_admin,academic"},
+		{47, "POST", "/admin/courses/:id/classes", "super_admin,academic"},
+		{48, "GET", "/admin/courses/:id/records", "super_admin,academic,teacher"},
+		{49, "POST", "/admin/courses/:id/records/generate", "super_admin,academic"},
+		{50, "POST", "/admin/courses/records/:recordId/start", "super_admin,academic,teacher"},
+		{51, "POST", "/admin/courses/records/:recordId/finish", "super_admin,academic,teacher"},
+		{52, "POST", "/admin/courses/records/:recordId/attendance", "super_admin,academic,teacher"},
+		{53, "GET", "/admin/courses/:id/students", "super_admin,academic,finance"},
+		{54, "POST", "/admin/courses/:id/students", "super_admin,academic,finance"},
+		{55, "GET", "/admin/courses/arrears", "super_admin,academic,finance"},
+		{56, "POST", "/admin/courses/arrears/counteract", "super_admin,academic,finance"},
+		{57, "GET", "/student/courses/schedule", "student,parent,teacher"},
+		// ---- AC-13d 第一批:课酬试算 + 维护(端点 59、61~63;58/60 待契约裁决,本批不挂)
+		{58, "GET", "/admin/payroll/plans", "super_admin,academic,finance"},
+		{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 10 = 65 条 method+path)", len(routes), len(want))
+	}
+
+	// 逐条对齐**用集合比**而不是按下标比:注册顺序是"静态段优先"(≠ 契约端点号顺序),
+	// 两者是不同的纪律——端点覆盖面按集合校验(下面),顺序另有三条断言(本函数末尾)。
+	type contractRow struct {
+		no    int
+		roles string
+	}
+	gotByKey := make(map[string]contractRow, len(routes))
+	for _, r := range routes {
+		if _, dup := gotByKey[r.Key()]; dup {
+			t.Errorf("%s 在路由表里出现了两次", r.Key())
+		}
+		gotByKey[r.Key()] = contractRow{no: r.No, roles: strings.Join(r.Roles, ",")}
+	}
+	for _, w := range want {
+		key := w.method + " " + w.path
+		got, ok := gotByKey[key]
+		if !ok {
+			t.Errorf("端点 %d(%s %s)未注册", w.no, w.method, w.path)
+			continue
+		}
+		if got.no != w.no {
+			t.Errorf("%s 的端点号 = %d,期望 %d", key, got.no, w.no)
+		}
+		if got.roles != w.roles {
+			t.Errorf("端点 %d 角色白名单 = %q,期望 %q", w.no, got.roles, w.roles)
+		}
+		delete(gotByKey, key)
+	}
+	for key := range gotByKey {
+		t.Errorf("注册了契约外的路由 %s(当前只挂已交付的 65 条)", key)
+	}
+
+	// 反向断言:18/19/20(支付)**本批不得挂**——handler 尚未实现,挂空壳会让"未实现"
+	// 看起来像"已实现但返回怪码"(前端不会看到 404,会去查业务逻辑)。
+	for _, r := range routes {
+		if strings.HasPrefix(r.Path, "/pay") {
+			t.Errorf("端点 %d(%s %s)不该在本批注册:18/19/20 handler 尚未实现", r.No, r.Method, r.Path)
+		}
+		switch r.No {
+		case 18, 19, 20:
+			t.Errorf("端点 %d 不该在本批注册", r.No)
+		}
+	}
+
+	// 静态段先于 `:id`(断言④ 的**顺序**部分;行为断言见 TestStaticSegmentBeatsParam)。
+	//
+	// ⚠ 实测结论(变异测试记录,见交付报告):本批 40 条路径**不存在会被 `:id` 吃掉的同深度冲突**
+	// —— `:id` 是 3 段(`/admin/activities/:id`),而 `discounts/*` / `recharge-gifts/*` 都是 4 段,
+	// Fiber 按段数匹配,3 段路由匹配不上 4 段请求。因此"把 `:id` 提到静态段之前"这条变异
+	// **不会**让 `GET /admin/activities/discounts/usable` 变 2002(已实测:仍是 200)。
+	// 纪律仍然保留(同级静态优先),并且下面用一个**真正会冲突的同深度路由对**证明守卫不是恒过。
+	assertStaticBeforeParam(t, routes)
+
+	// 守卫自证:同一对路由,正确顺序 → 无违规;反序 → 必须报出违规。
+	// 这对路由(`/admin/x/:id/sign` vs `/admin/x/discounts/sign`)才是 Fiber 里真会被吃掉
+	// 的形态:**同 method、同段数、同尾段、中间一位是字面 vs `:param`**。
+	badPair := []Route{
+		{No: 1, Method: fiber.MethodPost, Path: "/admin/x/:id/sign", Roles: []string{RoleAcademic}},
+		{No: 2, Method: fiber.MethodPost, Path: "/admin/x/discounts/sign", Roles: []string{RoleAcademic}},
+	}
+	if violations := staticShadowViolations(badPair); len(violations) != 1 {
+		t.Errorf("顺序守卫未检出同深度冲突(本应 1 条):%v", violations)
+	}
+	goodPair := []Route{badPair[1], badPair[0]}
+	if violations := staticShadowViolations(goodPair); len(violations) != 0 {
+		t.Errorf("顺序守卫对正确顺序误报:%v", violations)
+	}
+}
+
+// assertStaticBeforeParam 检查"同 method、同段数、某一位是字面段 vs `:param`"的路由对,
+// 字面段必须**先注册**(后注册会被 `:param` 吃掉:Fiber 按注册顺序匹配)。
+func assertStaticBeforeParam(t *testing.T, routes []Route) {
+	t.Helper()
+	for _, v := range staticShadowViolations(routes) {
+		t.Error(v)
+	}
+}
+
+// staticShadowViolations 返回"会被 `:param` 吃掉"的路由对描述(空 = 顺序正确)。
+//
+// 纯函数,便于单测直接构造正/反序路由对验证守卫本身(守卫要能被证伪,见设计 §10.3)。
+func staticShadowViolations(routes []Route) []string {
+	violations := make([]string, 0, 2)
+	for i, param := range routes {
+		if !strings.Contains(param.Path, ":") {
+			continue
+		}
+		for j, literal := range routes {
+			if j <= i || literal.Method != param.Method {
+				continue // 只看"参数段先注册、字面段后注册"这一错误方向
+			}
+			if shadowedBy(param.Path, literal.Path) {
+				violations = append(violations, fmt.Sprintf(
+					"注册顺序错误:端点 %d(%s %s)注册在端点 %d(%s %s)之后,"+
+						"静态段会被 `:param` 吃掉(表现是 2002:id 解析失败)",
+					param.No, param.Method, param.Path, literal.No, literal.Method, literal.Path))
+			}
+		}
+	}
+	return violations
+}
+
+// shadowedBy 判断 paramPath(形如 `/x/:id/y`)是否会吃掉 literalPath(形如 `/x/discounts/y`):
+// 同深度、逐段相同、且 paramPath 在该位是 `:param` 而 literalPath 是字面段。
+func shadowedBy(paramPath, literalPath string) bool {
+	pc := pathSegments(paramPath)
+	lc := pathSegments(literalPath)
+	if len(pc) != len(lc) {
+		return false
+	}
+	wildcard := false
+	for i := range pc {
+		if isParamSegment(pc[i]) {
+			wildcard = true // 该位是参数 → 能匹配 literalPath 的字面段
+			continue
+		}
+		if pc[i] != lc[i] {
+			return false
+		}
+	}
+	return wildcard
+}
+
+// ---------------------------------------------------------------- ② 断言①:method+path 唯一
+
+// TestAssertUniqueMethodPath 断言① 的正例 + 变异反例。
+//
+// 变异反例的意义:Fiber 对重复 `method+path` 不报错、静默丢弃后注册者,
+// 若断言写错(如只比较 path、或只比对本域前 N 条),就抓不到这类缺陷。
+func TestAssertUniqueMethodPath(t *testing.T) {
+	// 正例:完整 40 条 + healthz(模拟全局表里有别的域的路由)
+	db := newHandlerTestDB(t)
+	app, reg := newAcademicRouteTestApp(t, db, RegisterOptions{})
+	app.Get("/healthz", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
+	if err := AssertUniqueMethodPath(app.GetRoutes()); err != nil {
+		t.Fatalf("40 条路由不该有重复:%v", err)
+	}
+	if len(reg.Routes) != 65 {
+		t.Fatalf("注册条数 = %d,期望 65(40 + 15 + 10)", len(reg.Routes))
+	}
+
+	// 正例:同 path 不同 method 是**合法**的(端点 12/13 共用 /admin/finance/requests)
+	if err := AssertUniqueMethodPath([]fiber.Route{
+		{Method: "GET", Path: "/admin/finance/requests"},
+		{Method: "POST", Path: "/admin/finance/requests"},
+	}); err != nil {
+		t.Fatalf("同 path 不同 method 不该被判重复:%v", err)
+	}
+
+	// 变异反例 A:纯函数层重复
+	if err := AssertUniqueMethodPath([]fiber.Route{
+		{Method: "GET", Path: "/admin/finance/logs"},
+		{Method: "GET", Path: "/admin/finance/logs"},
+	}); err == nil {
+		t.Fatal("断言① 未检出 method+path 重复(守卫失效)")
+	} else if !strings.Contains(err.Error(), "GET /admin/finance/logs") {
+		t.Errorf("断言① 报错未指出重复项:%v", err)
+	}
+
+	// 变异反例 B:装配层真注册一条重复路由 → Register 必须直接失败
+	// (模拟"第二批端点复制粘贴时把某条重复注册"这一真实事故)。
+	h, hErr := New(Config{DB: db})
+	if hErr != nil {
+		t.Fatalf("handler.New 失败: %v", hErr)
+	}
+	mutated := append([]Route{}, Routes(h)...)
+	mutated = append(mutated, Route{
+		No: 999, Method: fiber.MethodGet, Path: PathFinanceLogs, Name: "重复注册变异",
+		Roles: []string{RoleFinance}, Handler: h.ListLogs,
+	})
+	mutApp := fiber.New(fiber.Config{DisableStartupMessage: true})
+	if _, err := Register(mutApp, h, RegisterOptions{
+		Auth: stubAuth(), RoleGuard: identityhandler.RequireRole, Routes: mutated,
+	}); err == nil {
+		t.Fatal("Register 未拦住重复 method+path(断言① 在装配路径上失效)")
+	}
+}
+
+// ---------------------------------------------------------------- ③ 断言②:一级段白名单
+
+// TestFirstSegmentWhitelist 断言② 的正例 + **变异反例**(证明它不是"恒过")。
+//
+// 危险失败形态说明:若把断言写成"先过滤掉 /auth、/org 再判",则它永远绿——
+// 与本用例的变异反例(`/auth/xxx` 必须变红)正好互为验证。
+func TestFirstSegmentWhitelist(t *testing.T) {
+	// 正例:40 条只占 /admin 与 /student 两个一级段
+	db := newHandlerTestDB(t)
+	_, reg := newAcademicRouteTestApp(t, db, RegisterOptions{})
+	wantPrefixes := []string{"/admin", "/student"}
+	if strings.Join(reg.Prefixes, ",") != strings.Join(wantPrefixes, ",") {
+		t.Fatalf("一级段集合 = %v,期望 %v", reg.Prefixes, wantPrefixes)
+	}
+	if err := AssertFirstSegments(reg.Prefixes); err != nil {
+		t.Fatalf("本域一级段不该越界:%v", err)
+	}
+
+	// 纯函数层:/auth 与 /org 必须被拒(academic 不得重复注册他域路径)
+	for _, bad := range [][]string{
+		{"/admin", "/auth"},
+		{"/org"},
+		{"/admin", "/student", "/pay", "/teaching"},
+	} {
+		if err := AssertFirstSegments(bad); err == nil {
+			t.Errorf("断言② 未拒 %v(守卫失效:这会让 academic 悄悄顶掉 identity/org 的 handler)", bad)
+		}
+	}
+
+	// 变异反例(装配层):在 academic 装配里注册一条 `/auth/xxx` → Register 必须失败。
+	// 这正是团队纪律要求的"能自证失败":删掉注入行即转绿(变异实测输出见交付报告)。
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	mutated := append([]Route{}, Routes(h)...)
+	mutated = append(mutated, Route{
+		No: 998, Method: fiber.MethodGet, Path: "/auth/xxx", Name: "越界一级段变异",
+		Roles: []string{RoleSuperAdmin}, Handler: h.GetAccount,
+	})
+	mutApp := fiber.New(fiber.Config{DisableStartupMessage: true})
+	if _, err := Register(mutApp, h, RegisterOptions{
+		Auth: stubAuth(), RoleGuard: identityhandler.RequireRole, Routes: mutated,
+	}); err == nil {
+		t.Fatal("Register 未拦住 /auth/xxx(断言② 在装配路径上失效 = 恒过)")
+	} else if !strings.Contains(err.Error(), "/auth") {
+		t.Errorf("断言② 报错未指出越界段:%v", err)
+	}
+}
+
+// ---------------------------------------------------------------- ④ 静态段优先(行为)
+
+// TestStaticSegmentBeatsParam `GET /admin/activities/discounts/usable` 必须 **200**。
+//
+// 若它被 `/admin/activities/:id` 吃掉,`:id="discounts"` 解析失败 → **2002**。
+// 这条比"路由表里存在这条"可靠得多:路由表里两条都在,被吃掉时表里照样存在。
+func TestStaticSegmentBeatsParam(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app, _ := newAcademicRouteTestApp(t, db, RegisterOptions{})
+
+	code, msg, _ := doJSON(t, app, fiber.MethodGet,
+		"/admin/activities/discounts/usable?userId=01HSTUDENT00000000000001&institutionId=1&campusId=10",
+		nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("GET /admin/activities/discounts/usable = code %d(%s),期望 200 ——"+
+			"2002 说明被 :id 吃掉;403/1003 说明白名单或装配有问题", code, msg)
+	}
+
+	// 对照:真正走 `:id` 的详情端点也必须通(证明两条路由各自命中自己的 handler)
+	code, msg, _ = doJSON(t, app, fiber.MethodGet, "/admin/activities/1", nil, "X-Test-Token", "t")
+	if code == 2002 {
+		t.Fatalf("GET /admin/activities/1 = 2002(%s):`:id` 路由本身被静态段破坏了", msg)
+	}
+}
+
+// ---------------------------------------------------------------- ⑤ Auth / 角色白名单真的生效
+
+// TestAllRoutesRequireAuth 40 条**全部**需登录:不带 token 一律 1001。
+//
+// 这条防的是"某条端点漏挂 Auth"(公开暴露财务写接口),是最容易在复制粘贴中出错的一处。
+func TestAllRoutesRequireAuth(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app, reg := newAcademicRouteTestApp(t, db, RegisterOptions{})
+	for _, r := range reg.Routes {
+		code, msg, _ := doJSON(t, app, r.Method, requestPath(r.Path), nil) // 无 X-Test-Token
+		if code != 1001 {
+			t.Errorf("端点 %d(%s %s)无 token 时 code = %d(%s),期望 1001(未登录)——该端点漏挂 Auth?",
+				r.No, r.Method, r.Path, code, msg)
+		}
+	}
+}
+
+// TestRoleGuardApplied 白名单真的在路由层生效(非白名单角色 → 1003,白名单角色进 handler)。
+func TestRoleGuardApplied(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app, _ := newAcademicRouteTestApp(t, db, RegisterOptions{})
+
+	// 端点 3(充值)白名单 = super_admin/finance;academic → 1003
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathFinanceRecharges, nil,
+		"X-Test-Token", "t", "X-Test-Scope", "academic"); code != 1003 {
+		t.Errorf("端点3 academic 调充值 = %d(%s),期望 1003(白名单外)", code, msg)
+	}
+	// 同一端点 finance 通过守卫(进入 handler 后因缺 body → 2001,但**不是** 1003)
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathFinanceRecharges, nil,
+		"X-Test-Token", "t"); code == 1003 {
+		t.Errorf("端点3 finance 被 1003 挡住(%s):守卫过度收紧,种子/白名单不一致", msg)
+	}
+	// 端点 17(学生账单)白名单 = student/parent/super_admin;finance → 1003(跨端越权)
+	if code, msg, _ := doJSON(t, app, fiber.MethodGet, PathStudentBill, nil,
+		"X-Test-Token", "t"); code != 1003 {
+		t.Errorf("端点17 finance 调学生账单 = %d(%s),期望 1003", code, msg)
+	}
+	// 端点 26(报名)含 student → 不 1003
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, "/admin/activities/1/sign", nil,
+		"X-Test-Token", "t", "X-Test-Scope", "student"); code == 1003 {
+		t.Errorf("端点26 student 被 1003 挡住(%s):白名单写错", msg)
+	}
+}
+
+// ---------------------------------------------------------------- ⑥ RBAC 种子
+
+// TestRBACSeedMatchesRoutes 种子与路由表 **1:1**(挂什么就种什么,含反向可查)。
+func TestRBACSeedMatchesRoutes(t *testing.T) {
+	db := newHandlerTestDB(t)
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	routes := Routes(h)
+	seed, err := BuildRBACSeed(routes)
+	if err != nil {
+		t.Fatalf("BuildRBACSeed 失败: %v", err)
+	}
+
+	// 权限点:一端点一 code,且不重复
+	if len(seed.Permissions) != len(routes) {
+		t.Fatalf("权限点 %d 条,期望 %d(每端点一个)", len(seed.Permissions), len(routes))
+	}
+	codeSet := make(map[string]struct{}, len(seed.Permissions))
+	for _, p := range seed.Permissions {
+		if _, dup := codeSet[p.Code]; dup {
+			t.Errorf("权限点 code 重复:%s", p.Code)
+		}
+		codeSet[p.Code] = struct{}{}
+		if p.Type != "button" {
+			t.Errorf("权限点 %s type = %q,本批只生成 button(菜单树不由路由表推导)", p.Code, p.Type)
+		}
+	}
+
+	// casbin 规则:每条都对应"某角色 + 某路由 path",且 path 必须在路由表里
+	pathSet := make(map[string]struct{}, len(routes))
+	for _, r := range routes {
+		pathSet[r.Path] = struct{}{}
+	}
+	for _, rule := range seed.CasbinRules {
+		if rule.Ptype != "p" || rule.V1 != "*" {
+			t.Errorf("策略行 ptype/dom 非法:%+v(应与既有 /org 种子一致:p + dom=*)", rule)
+		}
+		if _, ok := pathSet[rule.V2]; !ok {
+			t.Errorf("策略路径 %q 不在路由表里(种子漂移)", rule.V2)
+		}
+	}
+
+	// 每个端点 × 每个白名单角色都必须有策略覆盖(合并 method 后:同 path 只留一条)
+	covered := make(map[string]string, len(seed.CasbinRules))
+	for _, rule := range seed.CasbinRules {
+		covered[rule.V0+" "+rule.V2] = rule.V3
+	}
+	for _, r := range routes {
+		for _, role := range r.Roles {
+			methods, ok := covered[role+" "+r.Path]
+			if !ok {
+				t.Errorf("端点 %d(%s %s)缺少角色 %s 的策略", r.No, r.Method, r.Path, role)
+				continue
+			}
+			if !strings.Contains(methods, r.Method) {
+				t.Errorf("端点 %d 策略 %s @ %s 的 method 正则 %q 未覆盖 %s", r.No, role, r.Path, methods, r.Method)
+			}
+		}
+	}
+}
+
+// TestRBACSeedIdempotent 种子**幂等**:连跑两次,行数不变、第二次插入 0 行。
+func TestRBACSeedIdempotent(t *testing.T) {
+	db := newRBACSeedTestDB(t)
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	routes := Routes(h)
+
+	first, err := SeedRBAC(t.Context(), db, routes)
+	if err != nil {
+		t.Fatalf("首次 SeedRBAC 失败: %v", err)
+	}
+	if first.InsertedCasbinRules != int64(first.CasbinRules) {
+		t.Errorf("首次插入策略 %d 行,期望全部 %d 行", first.InsertedCasbinRules, first.CasbinRules)
+	}
+	if first.InsertedPermissions != int64(first.Permissions) {
+		t.Errorf("首次插入权限点 %d 行,期望 %d 行", first.InsertedPermissions, first.Permissions)
+	}
+	if first.InsertedRolePermissions != int64(first.RolePermissions) {
+		t.Errorf("首次插入矩阵 %d 行,期望 %d 行", first.InsertedRolePermissions, first.RolePermissions)
+	}
+
+	second, err := SeedRBAC(t.Context(), db, routes)
+	if err != nil {
+		t.Fatalf("二次 SeedRBAC 失败: %v", err)
+	}
+	if second.InsertedCasbinRules != 0 || second.InsertedPermissions != 0 || second.InsertedRolePermissions != 0 {
+		t.Errorf("二次执行仍插入行(非幂等):casbin=%d perm=%d rolePerm=%d",
+			second.InsertedCasbinRules, second.InsertedPermissions, second.InsertedRolePermissions)
+	}
+
+	// 行数与生成数一致(唯一键吸收重复,不产生多余行)
+	var casbinCount, permCount, rolePermCount int64
+	db.Raw("SELECT COUNT(*) FROM casbin_rule").Scan(&casbinCount)
+	db.Raw("SELECT COUNT(*) FROM t_permission").Scan(&permCount)
+	db.Raw("SELECT COUNT(*) FROM t_role_permission").Scan(&rolePermCount)
+	if casbinCount != int64(first.CasbinRules) {
+		t.Errorf("casbin_rule 行数 = %d,期望 %d", casbinCount, first.CasbinRules)
+	}
+	if permCount != int64(first.Permissions) {
+		t.Errorf("t_permission 行数 = %d,期望 %d", permCount, first.Permissions)
+	}
+	if rolePermCount != int64(first.RolePermissions) {
+		t.Errorf("t_role_permission 行数 = %d,期望 %d", rolePermCount, first.RolePermissions)
+	}
+}
+
+// TestRBACSeedEnforcedByCasbin 种子在**真 Casbin 引擎**下可判定(含 `:param` 不过度匹配)。
+//
+// 这是"路径保留 Fiber `:param` 而非照抄 `/org/*` 的 `*`"这一决定的唯一硬证据:
+// `*` 在 keyMatch2 里是跨 `/` 的 `.*`,会把 `POST /admin/activities/1/sign` 也放给 teacher。
+func TestRBACSeedEnforcedByCasbin(t *testing.T) {
+	db := newRBACSeedTestDB(t)
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	if _, err := SeedRBAC(t.Context(), db, Routes(h)); err != nil {
+		t.Fatalf("SeedRBAC 失败: %v", err)
+	}
+	enf, err := infrarabc.NewEnforcer(db)
+	if err != nil {
+		t.Fatalf("NewEnforcer 失败: %v", err)
+	}
+	engine := enf.Get()
+
+	cases := []struct {
+		name   string
+		role   string
+		method string
+		path   string
+		want   bool
+	}{
+		{"端点3 充值:finance 放行", "finance", "POST", "/admin/finance/recharges", true},
+		{"端点3 充值:academic 拒绝", "academic", "POST", "/admin/finance/recharges", false},
+		{"端点1 账户快照:academic 放行(:userId 通配)", "academic", "GET", "/admin/finance/accounts/01HUSER", true},
+		{"端点14 工单通过:finance 放行", "finance", "POST", "/admin/finance/requests/9/approve", true},
+		{"端点14 审批:academic 拒绝(审批仅 finance/super_admin)", "academic", "POST", "/admin/finance/requests/9/approve", false},
+		{"端点24 详情:teacher 放行", "teacher", "GET", "/admin/activities/9", true},
+		{"端点26 报名:teacher 拒绝(:param 不跨段匹配)", "teacher", "POST", "/admin/activities/9/sign", false},
+		{"端点31 可用赠送:finance 放行(静态段)", "finance", "GET", "/admin/activities/discounts/usable", true},
+		{"端点17 学生账单:student 放行", "student", "GET", "/student/bill", true},
+		{"端点17 学生账单:finance 拒绝(跨端)", "finance", "GET", "/student/bill", false},
+		{"端点42 删分账流水:DELETE 与 GET 分开授权", "academic", "DELETE", "/admin/divide-into/logs", true},
+		{"端点43 分账流水:GET 也放行", "academic", "GET", "/admin/divide-into/logs", true},
+	}
+	for _, c := range cases {
+		got, eerr := engine.Enforce(c.role, "*", c.path, c.method)
+		if eerr != nil {
+			t.Fatalf("%s:Enforce 出错: %v", c.name, eerr)
+		}
+		if got != c.want {
+			t.Errorf("%s:Enforce(%s, *, %s, %s) = %v,期望 %v",
+				c.name, c.role, c.path, c.method, got, c.want)
+		}
+	}
+}
+
+// TestSeedSQLLooksLikeExistingDDL 渲染出的 SQL 与既有 `core-model-ddl.sql` 写法同构
+// (`INSERT IGNORE` + 反引号列名 + 五列 ptype/v0..v3),便于直接追加进文档而不用改写。
+func TestSeedSQLLooksLikeExistingDDL(t *testing.T) {
+	db := newHandlerTestDB(t)
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	seed, err := BuildRBACSeed(Routes(h))
+	if err != nil {
+		t.Fatalf("BuildRBACSeed 失败: %v", err)
+	}
+	sqlText, err := seed.SQL("mysql")
+	if err != nil {
+		t.Fatalf("SQL 渲染失败: %v", err)
+	}
+	t.Logf("academic RBAC 种子:casbin_rule=%d 条 / t_permission=%d 条 / t_role_permission=%d 条",
+		len(seed.CasbinRules), len(seed.Permissions), len(seed.RolePermissions))
+	for _, want := range []string{
+		"INSERT IGNORE INTO `casbin_rule` (`ptype`, `v0`, `v1`, `v2`, `v3`) VALUES",
+		"INSERT IGNORE INTO `t_permission`",
+		"INSERT IGNORE INTO `t_role_permission`",
+		"'p', 'finance', '*', '/admin/finance/recharges', 'POST'",
+		"'p', 'teacher', '*', '/admin/activities/:id', 'GET'",
+	} {
+		if !strings.Contains(sqlText, want) {
+			t.Errorf("种子 SQL 缺少 %q", want)
+		}
+	}
+	if _, err := seed.SQL("postgres"); err == nil {
+		t.Error("不支持的方言应报错,而不是静默产出非法 SQL")
+	}
+}
+
+// newRBACSeedTestDB 建三张 RBAC 表(与目标态 DDL 的唯一键一致)+ 复用 academic 测试库。
+//
+// 为什么在测试里手写 CREATE TABLE 而不复用 org 的 GORM model:
+// academic 不得 import org 域模型(依赖方向);这里只要**唯一键形态**一致,
+// 幂等的判定依据恰好就是唯一键(uk_policy / PK code / uk_role_permission)。
+func newRBACSeedTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	db := newHandlerTestDB(t)
+	stmts := []string{
+		"CREATE TABLE `casbin_rule` (" +
+			"id INTEGER PRIMARY KEY AUTOINCREMENT, ptype TEXT NOT NULL DEFAULT '', " +
+			"v0 TEXT NOT NULL DEFAULT '', v1 TEXT NOT NULL DEFAULT '', " +
+			"v2 TEXT NOT NULL DEFAULT '', v3 TEXT NOT NULL DEFAULT '', " +
+			"UNIQUE (ptype, v0, v1, v2, v3))",
+		"CREATE TABLE `t_permission` (" +
+			"code TEXT PRIMARY KEY, type TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '', " +
+			"parent_code TEXT, path TEXT, icon TEXT, sort INTEGER NOT NULL DEFAULT 0, " +
+			"status TEXT NOT NULL DEFAULT 'active')",
+		"CREATE TABLE `t_role_permission` (" +
+			"id INTEGER PRIMARY KEY AUTOINCREMENT, role_code TEXT NOT NULL, " +
+			"permission_code TEXT NOT NULL, UNIQUE (role_code, permission_code))",
+	}
+	for _, stmt := range stmts {
+		if err := db.Exec(stmt).Error; err != nil {
+			t.Fatalf("建 RBAC 表失败: %v", err)
+		}
+	}
+	return db
+}
+
+// TestFirstSegmentsSorted 一级段的输出顺序稳定(断言② 的报错文案与快照都依赖它)。
+func TestFirstSegmentsSorted(t *testing.T) {
+	got := FirstSegments([]Route{
+		{Path: "/student/bill"},
+		{Path: "/pay/orders"},
+		{Path: "/admin/finance/logs"},
+		{Path: "/admin/activities"},
+	})
+	want := []string{"/admin", "/pay", "/student"}
+	if strings.Join(got, ",") != strings.Join(want, ",") {
+		t.Fatalf("FirstSegments = %v,期望 %v(去重 + 升序)", got, want)
+	}
+	sorted := sort.SliceIsSorted(got, func(i, j int) bool { return got[i] < got[j] })
+	if !sorted {
+		t.Fatalf("FirstSegments 未排序:%v", got)
+	}
+	// /pay 是白名单内的段(端点 18/19/20 将来挂这里),不得报错
+	if err := AssertFirstSegments(got); err != nil {
+		t.Fatalf("/pay 属白名单,不该报错:%v", err)
+	}
+}

+ 510 - 0
internal/academic/handler/seed.go

@@ -0,0 +1,510 @@
+// RBAC 种子生成器(AC-14)——**从路由表生成,不手写清单**。
+//
+// ⚠ 两条已验证的结论,改这里之前先读:
+//
+//  1. casbin 路径必须用 Fiber 的 `:param`,不能用 `*`。
+//     keyMatch2 里 `*` 会展开成 `.*`(**跨 `/` 匹配**),`/admin/activities/*`
+//     会把 `POST /admin/activities/1/sign` 也放行 = **提权**。
+//     反证见 TestRBACSeedEnforcedByCasbin:`teacher POST /admin/activities/9/sign` 必须为 false。
+//     ⚠ 既有 /org 种子用的是 `*`,是否存在同样的跨段放行**尚未核实**,已登记待 S3 核。
+//
+//  2. 种子从路由表生成、不要手写清单。
+//     本批 40 端点,第二批还有 20 个;手写必漏,症状是"接口从 404 变成 403",比 404 更难查。
+//
+// 依据:unified/03-migration/academic-domain-design.md AC-14 行("种子幂等 + 与路由表 1:1 对齐")/
+// `01-architecture/casbin-rbac-design.md` §6(casbin p 规则种子写法:`dom=*`、`keyMatch2` 路径、
+// `regexMatch` 方法、`INSERT IGNORE` 幂等)/ `02-data-model/core-model-ddl.sql`(三张表的既有种子格式)。
+//
+// ---------------------------------------------------------------------------
+// 为什么必须"从路由表生成"
+// ---------------------------------------------------------------------------
+//
+// 本任务只挂**第一批 40 个**端点,后面还有第二批(44~63)。手写种子清单在第二批时**一定会漏**,
+// 而症状极其隐蔽:接口从 **404** 变成 **403(1003)**——404 一眼看出"没挂",
+// 403 看起来像"权限配错",排查者会去查 RBAC 矩阵/角色/token,最后才发现是种子少了一行。
+//
+// 因此三张表全部由 `Route` 派生:
+//
+//	casbin_rule      ← Route.Roles × Route.Path × Route.Method(**1:1** 可反查)
+//	t_permission     ← 每个端点一个 button 权限点(code 由 method+path 推导)
+//	t_role_permission← Route.Roles × 该端点的权限点 code
+//
+// ---------------------------------------------------------------------------
+// 幂等(重复执行不产生重复行)
+// ---------------------------------------------------------------------------
+//
+// 复用既有 DDL 的 `INSERT IGNORE` 写法(MySQL),依赖三张表已有的唯一键:
+//
+//	casbin_rule        UNIQUE uk_policy (ptype,v0,v1,v2,v3)
+//	t_permission       PRIMARY KEY (code)
+//	t_role_permission  UNIQUE uk_role_permission (role_code,permission_code)
+//
+// SQLite(单测库)没有 `INSERT IGNORE`,改写为 `INSERT OR IGNORE`(同语义、同依赖唯一键)。
+// 两种方言都**不做"先查后插"**:先查后插在并发下会插重(两次都查不到),
+// 而唯一键 + IGNORE 是数据库级的原子保证。
+//
+// ---------------------------------------------------------------------------
+// 与既有 `/auth`、`/org` 种子的关系(不另起一套)
+// ---------------------------------------------------------------------------
+//
+// 写法完全同构:`ptype=p` / `v0=角色码` / `v1=*`(不区分机构)/ `v2=path` / `v3=method 正则`,
+// 同一张 `casbin_rule`、同一套 model.conf,不新增表、不新增列、不改 model。
+//
+// **唯一的路径写法差异(有意,且更安全)**:既有种子把 `:id` 段写成 `*`
+// (如 `/org/institutions/*`),本生成器**原样保留 Fiber 的 `:param`** 写法
+// (如 `/admin/activities/:id`)。原因:
+//  1. `keyMatch2` 原生支持 `:param`(转成 `[^/]+`),语义与 Fiber 完全一致,无需翻译;
+//  2. `*` 在 keyMatch2 里是 `.*`(**跨 `/` 匹配**),`/admin/activities/*` 会连带命中
+//     `/admin/activities/1/sign`(POST)等本不该给该角色的端点 —— 那是一条**提权**路径。
+//     保留 `:param` 后规则与路由表逐字符可对齐,`git diff` 就能审出"这条策略给了谁"。
+//  3. 生成器不做"路径翻译"这一步,就不会出现"翻译函数写错、某类路径全放行"的静默缺陷。
+package handler
+
+import (
+	"context"
+	"fmt"
+	"sort"
+	"strings"
+
+	"gorm.io/gorm"
+)
+
+// CasbinRule `casbin_rule` 的一行(ptype + v0..v3,对齐 `infra/rbac` 的 `ruleRow`)。
+//
+// 字段语义(casbin model.conf `p = sub, dom, obj, act`):
+//   - Ptype:策略类型(本生成器恒 `p`;`g` 段留空供将来人级授权);
+//   - V0:`sub` = 角色码;
+//   - V1:`dom` = 机构 ID;本批恒 `*`(不区分机构,与既有 /org 种子一致);
+//   - V2:`obj` = 请求 path(keyMatch2,保留下 Fiber 的 `:param` 写法);
+//   - V3:`act` = HTTP method 正则(同 path 多 method 合并为 `GET|POST`)。
+type CasbinRule struct {
+	Ptype string
+	V0    string
+	V1    string
+	V2    string
+	V3    string
+}
+
+// PermissionSeed `t_permission` 的一行(权限点字典;本批只生成 `button` 型)。
+//
+// 为什么只生成 `button` 不生成 `menu`:菜单树是**UI 结构**(`parent_code`/`path`/`icon`/`sort`
+// 描述的是"页面怎么摆",与"后端有哪些接口"不是同一件事,路由表推不出层级与图标。
+// 硬造 menu 会把菜单树写坏(`/auth/issue` 的 menuTree 直接受影响)。菜单项由前端/产品排期单独提。
+type PermissionSeed struct {
+	Code       string
+	Type       string
+	Name       string
+	ParentCode string
+	Path       string
+	Icon       string
+	Sort       int
+	Status     string
+}
+
+// RolePermissionSeed `t_role_permission` 的一行(角色 × 权限点矩阵)。
+type RolePermissionSeed struct {
+	RoleCode       string
+	PermissionCode string
+}
+
+// RBACSeed 一次生成的三张表种子(顺序稳定,便于 diff 与单测快照)。
+type RBACSeed struct {
+	// CasbinRules 接口强制策略(`casbin_rule`,第②层)。
+	CasbinRules []CasbinRule
+	// Permissions 权限点字典(`t_permission`,第①层展示用)。
+	Permissions []PermissionSeed
+	// RolePermissions 角色矩阵(`t_role_permission`)。
+	RolePermissions []RolePermissionSeed
+}
+
+// SeedResult 种子落库结果(幂等判据:第二次执行时 `Inserted*` 全为 0)。
+type SeedResult struct {
+	// CasbinRules 本次生成的策略行数(不是插入行数)。
+	CasbinRules int
+	// Permissions 本次生成的权限点行数。
+	Permissions int
+	// RolePermissions 本次生成的角色矩阵行数。
+	RolePermissions int
+	// InsertedCasbinRules 本次**实际插入**的策略行数(重复行被唯一键忽略,不计入)。
+	InsertedCasbinRules int64
+	// InsertedPermissions 本次实际插入的权限点行数。
+	InsertedPermissions int64
+	// InsertedRolePermissions 本次实际插入的角色矩阵行数。
+	InsertedRolePermissions int64
+}
+
+// BuildRBACSeed 从路由表生成三张表种子。
+//
+// 入参:routes []Route(生产传 `Registration.Routes`,保证"挂什么就种什么")。
+// 出参:
+//   - *RBACSeed:三张表的行(顺序稳定);
+//   - error:权限点 code 冲突(两个端点推导出同一 code)。**冲突必须报错而不是去重**:
+//     去重会让某个端点的权限点在矩阵里"看起来配了、实际没有",是典型的静默失效。
+func BuildRBACSeed(routes []Route) (*RBACSeed, error) {
+	seed := &RBACSeed{
+		CasbinRules:     make([]CasbinRule, 0, len(routes)*2),
+		Permissions:     make([]PermissionSeed, 0, len(routes)),
+		RolePermissions: make([]RolePermissionSeed, 0, len(routes)*2),
+	}
+	codes := make(map[string]int, len(routes))
+	rolePerms := make(map[string]struct{}, len(routes)*2)
+
+	casbin := make(map[casbinKey]map[string]struct{}, len(routes))
+	order := make([]casbinKey, 0, len(routes))
+
+	for _, r := range routes {
+		// ---- t_permission(每个端点一个 button 权限点)
+		code := permissionCodeFor(r.Method, r.Path)
+		if prev, ok := codes[code]; ok {
+			return nil, fmt.Errorf(
+				"academic seed: 端点 %d 与端点 %d 推导出同一权限点 code %q(端点路径 %s %s / %s)——"+
+					"请检查 permissionCodeFor 的推导规则,不要用去重掩盖",
+				prev, r.No, code, r.Method, r.Path, r.Name)
+		}
+		codes[code] = r.No
+		seed.Permissions = append(seed.Permissions, PermissionSeed{
+			Code: code, Type: "button", Name: r.Name,
+			ParentCode: "", Path: "", Icon: "",
+			Sort: r.No, Status: "active",
+		})
+
+		// ---- t_role_permission(角色 × 本端点权限点)
+		for _, role := range r.Roles {
+			key := role + "\x00" + code
+			if _, dup := rolePerms[key]; dup {
+				continue
+			}
+			rolePerms[key] = struct{}{}
+			seed.RolePermissions = append(seed.RolePermissions, RolePermissionSeed{
+				RoleCode: role, PermissionCode: code,
+			})
+		}
+
+		// ---- casbin_rule(角色 × path,多 method 合并)
+		for _, role := range r.Roles {
+			key := casbinKey{role: role, path: r.Path}
+			if _, ok := casbin[key]; !ok {
+				casbin[key] = make(map[string]struct{}, 2)
+				order = append(order, key)
+			}
+			casbin[key][r.Method] = struct{}{}
+		}
+	}
+
+	// 按"首次出现顺序"输出(顺序稳定:注册顺序即契约端点顺序),method 按固定序合并。
+	for _, key := range order {
+		methods := make([]string, 0, len(casbin[key]))
+		for m := range casbin[key] {
+			methods = append(methods, m)
+		}
+		sort.Slice(methods, func(i, j int) bool { return methodRank(methods[i]) < methodRank(methods[j]) })
+		seed.CasbinRules = append(seed.CasbinRules, CasbinRule{
+			Ptype: "p", V0: key.role, V1: "*", V2: key.path, V3: strings.Join(methods, "|"),
+		})
+	}
+	return seed, nil
+}
+
+// casbinKey casbin 规则的去重键(角色 + path;method 合并到 v3)。
+type casbinKey struct {
+	role string
+	path string
+}
+
+// methodRank method 的稳定排序序(让 `GET|POST` 这样的正则可复现,避免 map 遍历顺序抖动)。
+func methodRank(method string) int {
+	switch method {
+	case "GET":
+		return 0
+	case "POST":
+		return 1
+	case "PUT":
+		return 2
+	case "PATCH":
+		return 3
+	case "DELETE":
+		return 4
+	default:
+		return 9
+	}
+}
+
+// permissionCodeFor 从 `method + path` 推导三段式权限点码 `academic.<资源>.<动作>`。
+//
+// 推导规则(**纯函数、无表驱动**:第二批端点接进来时零改动):
+//
+//	资源 = 路径去掉一级段(`/admin`|`/student`)与参数段后,字面段用 `_` 连接;
+//	       若"路径尾是 `/:param/<动词>`",该动词**不计入资源**(它是动作)。
+//	动作 = GET 且路径含参数段 → `get`;GET 无参数段 → `list`;
+//	       非 GET 且尾是 `/:param/<动词>` → 该动词(连字符转下划线);
+//	       其余 → POST `create` / PUT `update` / DELETE `delete`。
+//
+// 样例(可对照交付报告的 40 行注册表逐条核):
+//
+//	GET  /admin/finance/accounts/:userId          → academic.finance_accounts.get
+//	POST /admin/finance/refunds/custom            → academic.finance_refunds_custom.create
+//	POST /admin/finance/requests/:id/approve      → academic.finance_requests.approve
+//	GET  /admin/activities/discounts/usable       → academic.activities_discounts_usable.list
+//	POST /admin/activities/:id/cancel-sign        → academic.activities.cancel_sign
+//	GET  /student/bill                            → academic.bill.list
+//	PUT  /admin/divide-into/members/:userId/quota → academic.divide_into_members.quota
+//
+// ⚠ **本规则是本任务推导出来的,契约里没有 academic 的权限点码词表**(现有字典只有
+// `org.*` / `role.*`)。若产品/前端已有约定词表,只改本函数一处即可全量重算——
+// 这正是"从路由表生成"的价值:不存在"改了 40 行漏了 3 行"。
+func permissionCodeFor(method, path string) string {
+	segs := pathSegments(path)
+	// 去一级段(/admin 或 /student;将来 /pay 同理)。
+	if len(segs) > 0 {
+		segs = segs[1:]
+	}
+	if len(segs) == 0 {
+		// 理论上不会出现(本批所有端点都至少两段),兜底成一个稳定的 code。
+		return "academic.root." + strings.ToLower(method)
+	}
+
+	hasParam := false
+	for _, seg := range segs {
+		if isParamSegment(seg) {
+			hasParam = true
+			break
+		}
+	}
+
+	// 尾形态 `/:param/<动词>`:最后两段为"参数 + 字面段"。
+	tailVerb := ""
+	if n := len(segs); n >= 2 && isParamSegment(segs[n-2]) && !isParamSegment(segs[n-1]) {
+		tailVerb = normalizeSegment(segs[n-1])
+	}
+
+	literals := make([]string, 0, len(segs))
+	for _, seg := range segs {
+		if isParamSegment(seg) {
+			continue
+		}
+		literals = append(literals, normalizeSegment(seg))
+	}
+
+	action := ""
+	switch method {
+	case "GET":
+		if hasParam {
+			action = "get"
+		} else {
+			action = "list"
+		}
+	default:
+		if tailVerb != "" {
+			action = tailVerb
+			// 尾动词是动作,不进资源段(否则会出现 `..._approve.approve`)。
+			literals = literals[:len(literals)-1]
+		} else {
+			switch method {
+			case "POST":
+				action = "create"
+			case "PUT":
+				action = "update"
+			case "DELETE":
+				action = "delete"
+			default:
+				action = strings.ToLower(method)
+			}
+		}
+	}
+	return "academic." + strings.Join(literals, "_") + "." + action
+}
+
+// pathSegments 切分路径为段(丢弃前导空段)。
+func pathSegments(path string) []string {
+	trimmed := strings.Trim(path, "/")
+	if trimmed == "" {
+		return nil
+	}
+	return strings.Split(trimmed, "/")
+}
+
+// isParamSegment 判断是否参数段(Fiber 的 `:name` 与可选 `:name?` 均算)。
+func isParamSegment(seg string) bool { return strings.HasPrefix(seg, ":") }
+
+// normalizeSegment 归一化字面段:连字符转下划线(`refund-by-activity` → `refund_by_activity`)。
+func normalizeSegment(seg string) string { return strings.ReplaceAll(seg, "-", "_") }
+
+// SQL 渲染:与既有 `core-model-ddl.sql` 的种子写法逐字对齐(`INSERT IGNORE INTO ... VALUES`)。
+
+// CasbinRulesSQL 渲染 `casbin_rule` 的 INSERT 语句。
+//
+// 入参:dialect `mysql` / `sqlite`(决定 `INSERT IGNORE` 还是 `INSERT OR IGNORE`)。
+// 出参:单条多值 INSERT(无行时返回空串)。
+func (s *RBACSeed) CasbinRulesSQL(dialect string) (string, error) {
+	if len(s.CasbinRules) == 0 {
+		return "", nil
+	}
+	prefix, err := insertIgnorePrefix("casbin_rule", "`ptype`, `v0`, `v1`, `v2`, `v3`", dialect)
+	if err != nil {
+		return "", err
+	}
+	rows := make([]string, 0, len(s.CasbinRules))
+	for _, r := range s.CasbinRules {
+		rows = append(rows, fmt.Sprintf("(%s)", strings.Join([]string{
+			sqlString(r.Ptype), sqlString(r.V0), sqlString(r.V1), sqlString(r.V2), sqlString(r.V3),
+		}, ", ")))
+	}
+	return prefix + "\n  " + strings.Join(rows, ",\n  ") + ";", nil
+}
+
+// PermissionsSQL 渲染 `t_permission` 的 INSERT 语句。
+func (s *RBACSeed) PermissionsSQL(dialect string) (string, error) {
+	if len(s.Permissions) == 0 {
+		return "", nil
+	}
+	prefix, err := insertIgnorePrefix("t_permission",
+		"`code`, `type`, `name`, `parent_code`, `path`, `icon`, `sort`, `status`", dialect)
+	if err != nil {
+		return "", err
+	}
+	rows := make([]string, 0, len(s.Permissions))
+	for _, p := range s.Permissions {
+		parent := "NULL"
+		if p.ParentCode != "" {
+			parent = sqlString(p.ParentCode)
+		}
+		path := "NULL"
+		if p.Path != "" {
+			path = sqlString(p.Path)
+		}
+		icon := "NULL"
+		if p.Icon != "" {
+			icon = sqlString(p.Icon)
+		}
+		rows = append(rows, fmt.Sprintf("(%s)",
+			strings.Join([]string{
+				sqlString(p.Code), sqlString(p.Type), sqlString(p.Name),
+				parent, path, icon, fmt.Sprintf("%d", p.Sort), sqlString(p.Status),
+			}, ", ")))
+	}
+	return prefix + "\n  " + strings.Join(rows, ",\n  ") + ";", nil
+}
+
+// RolePermissionsSQL 渲染 `t_role_permission` 的 INSERT 语句。
+func (s *RBACSeed) RolePermissionsSQL(dialect string) (string, error) {
+	if len(s.RolePermissions) == 0 {
+		return "", nil
+	}
+	prefix, err := insertIgnorePrefix("t_role_permission", "`role_code`, `permission_code`", dialect)
+	if err != nil {
+		return "", err
+	}
+	rows := make([]string, 0, len(s.RolePermissions))
+	for _, rp := range s.RolePermissions {
+		rows = append(rows, fmt.Sprintf("(%s, %s)", sqlString(rp.RoleCode), sqlString(rp.PermissionCode)))
+	}
+	return prefix + "\n  " + strings.Join(rows, ",\n  ") + ";", nil
+}
+
+// SQL 渲染三张表的完整种子脚本(可直接追加进 `core-model-ddl.sql`,逐行可 diff)。
+func (s *RBACSeed) SQL(dialect string) (string, error) {
+	casbin, err := s.CasbinRulesSQL(dialect)
+	if err != nil {
+		return "", err
+	}
+	perms, err := s.PermissionsSQL(dialect)
+	if err != nil {
+		return "", err
+	}
+	roles, err := s.RolePermissionsSQL(dialect)
+	if err != nil {
+		return "", err
+	}
+	parts := []string{
+		"-- academic 域 RBAC 种子(AC-14,**由路由表生成,勿手改**:改路由后重新生成)",
+		"-- casbin_rule:第②层接口强制(角色 × path × method;dom=* 不区分机构;path 保留 Fiber 的 :param)",
+		casbin,
+		"-- t_permission:button 型权限点(菜单树是 UI 结构,不由路由表推导)",
+		perms,
+		"-- t_role_permission:角色 × 权限点矩阵",
+		roles,
+	}
+	out := make([]string, 0, len(parts))
+	for _, p := range parts {
+		if strings.TrimSpace(p) == "" {
+			continue
+		}
+		out = append(out, p)
+	}
+	return strings.Join(out, "\n") + "\n", nil
+}
+
+// insertIgnorePrefix 生成各方言的 `INSERT IGNORE` 前缀。
+func insertIgnorePrefix(table, columns, dialect string) (string, error) {
+	switch dialect {
+	case "mysql":
+		return "INSERT IGNORE INTO `" + table + "` (" + columns + ") VALUES", nil
+	case "sqlite":
+		return "INSERT OR IGNORE INTO `" + table + "` (" + columns + ") VALUES", nil
+	default:
+		return "", fmt.Errorf("academic seed: 不支持的方言 %q(仅 mysql/sqlite)", dialect)
+	}
+}
+
+// sqlString 把字符串渲染为 SQL 字面量(单引号转义;本生成器的输入来自路由表,仍做转义以防将来注错)。
+func sqlString(s string) string {
+	return "'" + strings.ReplaceAll(s, "'", "''") + "'"
+}
+
+// SeedRBAC 生成并落库 RBAC 种子(**幂等**:重复执行由唯一键 + IGNORE 吸收)。
+//
+// 入参:
+//   - ctx context.Context:执行上下文;
+//   - db *gorm.DB:数据库句柄(方言决定 IGNORE 关键字;仅支持 mysql/sqlite);
+//   - routes []Route:路由表(生产传 `Registration.Routes`——**挂什么就种什么**)。
+//
+// 出参:
+//   - *SeedResult:生成行数 + 实际插入行数(`Inserted*` 全 0 即"已是最新",可直接用于幂等断言);
+//   - error:权限点 code 冲突 / 方言不支持 / 执行失败。
+//
+// 装配位置:`cmd/api` 装配层在 `UNIFIED_AUTO_MIGRATE=1`(dev 建表守卫)时调用;
+// 生产按"DDL 先行"纪律用本函数渲染的 SQL(见 `SQL`)随迁移脚本执行,不在启动时写权限表。
+func SeedRBAC(ctx context.Context, db *gorm.DB, routes []Route) (*SeedResult, error) {
+	if db == nil {
+		return nil, fmt.Errorf("academic seed: DB 未注入")
+	}
+	seed, err := BuildRBACSeed(routes)
+	if err != nil {
+		return nil, err
+	}
+	dialect := db.Dialector.Name()
+	res := &SeedResult{
+		CasbinRules:     len(seed.CasbinRules),
+		Permissions:     len(seed.Permissions),
+		RolePermissions: len(seed.RolePermissions),
+	}
+
+	if sqlText, serr := seed.CasbinRulesSQL(dialect); serr != nil {
+		return nil, serr
+	} else if sqlText != "" {
+		tx := db.WithContext(ctx).Exec(sqlText)
+		if tx.Error != nil {
+			return nil, fmt.Errorf("academic seed: 写 casbin_rule 失败: %w", tx.Error)
+		}
+		res.InsertedCasbinRules = tx.RowsAffected
+	}
+	if sqlText, serr := seed.PermissionsSQL(dialect); serr != nil {
+		return nil, serr
+	} else if sqlText != "" {
+		tx := db.WithContext(ctx).Exec(sqlText)
+		if tx.Error != nil {
+			return nil, fmt.Errorf("academic seed: 写 t_permission 失败: %w", tx.Error)
+		}
+		res.InsertedPermissions = tx.RowsAffected
+	}
+	if sqlText, serr := seed.RolePermissionsSQL(dialect); serr != nil {
+		return nil, serr
+	} else if sqlText != "" {
+		tx := db.WithContext(ctx).Exec(sqlText)
+		if tx.Error != nil {
+			return nil, fmt.Errorf("academic seed: 写 t_role_permission 失败: %w", tx.Error)
+		}
+		res.InsertedRolePermissions = tx.RowsAffected
+	}
+	return res, nil
+}

+ 7 - 3
internal/academic/model/models_course.go

@@ -86,7 +86,11 @@ type Courses struct {
 	InstitutionID int64      `gorm:"index:idx_inst_campus_status,priority:1;column:institution_id;not null;default:0" json:"institutionId"` // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:创建时锁定|FK:→ t_institution|红线:scoping 强依赖
 	CampusID      int64      `gorm:"index:idx_inst_campus_status,priority:2;column:campus_id;not null;default:0" json:"campusId"`           // 校区 ID(原 campusid;作用域列)|枚举:无|写入:创建/回填(campus_id=0 的多校区课程不更新)|FK:→ t_campus|红线:backfill 仅唯一校区时回填(设计 §3.5 #29)
 	ClassifyID    int64      `gorm:"index:idx_classify;column:classify_id;not null;default:0" json:"classifyId"`                            // 课程分类 ID(原 classifyid)|枚举:无|写入:创建/编辑|FK:→ t_courses_classify.id|红线:回填 max_people 依赖本列
-	Status        int32      `gorm:"index:idx_inst_campus_status,priority:3;column:status;not null;default:1" json:"status"`                // 状态(0 未发布/1 发布未开始/2 进行中/3 已结束)|枚举:CoursesStatus*|写入:发布/结课任务|FK:无|红线:状态机单向推进
+	// ⚠ 本列**故意不写 `default:1`**:GORM 对"有 default tag 且字段为零值"的列会**跳过该列**、改由 DB 默认值填充,
+	// 而 0(未发布)恰恰是本列最常用的零值 ⇒ 新建课程会被静默写成 status=1("新建即发布")。
+	// 去掉 tag 后 GORM 总是显式写 status,0 能落库;DDL 的 `DEFAULT 1` 保持不变(只影响裸 SQL 插入)。
+	// 同类坑本项目已踩两次(另一次是 `create_time`):凡有 `default:` 又可能被赋零值的列,先问"零值能落库吗"。
+	Status        int32      `gorm:"index:idx_inst_campus_status,priority:3;column:status;not null" json:"status"`                // 状态(0 未发布/1 发布未开始/2 进行中/3 已结束)|枚举:CoursesStatus*|写入:发布/结课任务|FK:无|红线:状态机单向推进
 	Name          string     `gorm:"column:name;size:500;not null" json:"name"`                                                             // 课程名称|枚举:无|写入:创建/编辑|FK:无|红线:用户输入,展示转义
 	Number        int32      `gorm:"column:number;not null;default:0" json:"number"`                                                        // 上课次数(课次)|枚举:无|写入:创建/编辑|FK:无|红线:与 record 生成条数关联,改动须校验已开课
 	Duration      string     `gorm:"column:duration;type:decimal(5,1);not null;default:0.0" json:"duration"`                                // 单节课时长(课时,1 位小数,保留旧精度)|枚举:无|写入:创建/编辑|FK:无|红线:精度例外列(非 12,2)
@@ -223,7 +227,7 @@ type CoursesRecord struct {
 	ActualEndTime         *time.Time `gorm:"column:actual_end_time" json:"actualEndTime,omitempty"`                                                              // 实际下课时间(datetime(3))|枚举:无|写入:结束上课|FK:无|红线:结束上课的幂等判据(非空即已结束)
 	StudentNumber         int32      `gorm:"column:student_number;not null;default:0" json:"studentNumber"`                                                      // 上课人数|枚举:无|写入:结束上课汇总|FK:无|红线:与 record_student 行数一致
 	Income                string     `gorm:"column:income;type:decimal(12,2);not null;default:0.00" json:"income"`                                               // 课程收入(元)|枚举:无|写入:结束上课|FK:无|红线:= Σ record_student.cost(对账)
-	TeacherReward         string     `gorm:"column:teacher_reward;type:decimal(12,2);not null;default:0.00" json:"teacherReward"`                                // 老师课酬(元)|枚举:无|写入:结束上课(课酬试算)|FK:无|红线:= Σ record_student.teacher_reward(对账)
+	TeacherReward         string     `gorm:"column:teacher_reward;type:decimal(12,2);not null;default:0.00" json:"teacherReward"`                                // 老师课酬(元)|枚举:无|写入:结束上课(课酬试算)|FK:无|红线:**条件式**恒等 —— 有非赠送生时 = Σ(record_student.teacher_reward WHERE type∈{1,2});全班赠送(type 全为 3)时学生行全 0、本列仍写课酬(PHP `if($stuCount)` 语义,见 `applyTeacherRewards`)
 	TeacherRewardType     int8       `gorm:"column:teacher_reward_type;not null;default:1" json:"teacherRewardType"`                                             // 课酬结算方式(1 正常/2 按原课程老师课酬无最低课酬)|枚举:CoursesRecordRewardType*|写入:结束上课|FK:无|红线:影响试算分支
 	ExpectStudentIDs      *string    `gorm:"column:expect_student_ids;size:500" json:"expectStudentIds,omitempty"`                                               // 要求上课的学生 id 列表(逗号分隔;原 expect_studentids)|枚举:ULID 列表串|写入:排班生成|FK:弱关联 t_user|红线:字符串保留,勿当数组入 SQL
 	Remark                *string    `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                                                // 备注|枚举:无|写入:编辑|FK:无|红线:脱敏
@@ -251,7 +255,7 @@ type CoursesRecordStudent struct {
 	EndTime       *time.Time `gorm:"column:end_time" json:"endTime,omitempty"`                                                       // 下课时间(datetime(3))|枚举:无|写入:结束上课|FK:无|红线:非空 = 已完成(分成归属判据)
 	Cost          string     `gorm:"column:cost;type:decimal(12,2);not null;default:0.00" json:"cost"`                               // 本次课程费用(元;扣课时金额)|枚举:无|写入:结束上课|FK:无|红线:Σ cost = record.income(对账)
 	GiveCost      string     `gorm:"column:give_cost;type:decimal(12,2);not null;default:0.00" json:"giveCost"`                      // 本次使用的赠送金额(元)|枚举:无|写入:结束上课|FK:无|红线:与 discount_log 累计一致
-	TeacherReward string     `gorm:"column:teacher_reward;type:decimal(12,2);not null;default:0.00" json:"teacherReward"`            // 本次课老师课酬(元)|枚举:无|写入:结束上课(课酬试算)|FK:无|红线:Σ = record.teacher_reward(对账)
+	TeacherReward string     `gorm:"column:teacher_reward;type:decimal(12,2);not null;default:0.00" json:"teacherReward"`            // 本次课老师课酬(元)|枚举:无|写入:结束上课(课酬试算)|FK:无|红线:**`type=3`(赠送)行恒为 0**(PHP 只写 `type IN (1,2)`);非赠送行之和 = record.teacher_reward
 	Pay           int8       `gorm:"column:pay;not null;default:0" json:"pay"`                                                       // 是否已支付(0 未/1 已支付)|枚举:CoursesRecordStudentPay*|写入:扣费成功/欠费核销|FK:无|红线:未支付即欠费候选
 	Remark        *string    `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                            // 备注|枚举:无|写入:打卡/结束|FK:无|红线:脱敏
 }

+ 43 - 0
internal/academic/port/user_adapter.go

@@ -0,0 +1,43 @@
+// 用户姓名只读端口(academic → identity 单向只读,D4 裁决)——**接口在此,实现在 identity 侧**。
+//
+// ---------------------------------------------------------------------------
+// 为什么需要它
+// ---------------------------------------------------------------------------
+//
+// 端点 53(报课学生列表)契约要求 `realName`。姓名列在 `t_user.display_name`(identity 域),
+// academic 域**不持有** `t_user`,而"报课学生列表没有姓名"等于页面不可用 ⇒ 必须跨域只读。
+//
+// ---------------------------------------------------------------------------
+// 依赖方向(设计 §1.3 铁律)
+// ---------------------------------------------------------------------------
+//
+//	academic/handler → academic/port(**接口**) ← identity/repository(**实现**)
+//	                       ↑
+//	                   装配层注入(cmd/api/wiring_academic.go)
+//
+// 三条硬约束(评审逐条检查):
+//
+//  1. **academic 不 import identity**(本文件只定义接口与文档,零业务 import);
+//  2. 实现放 identity 侧(那里才是 `t_user` 的属主),本包不写任何 `t_user` SQL;
+//  3. 端口**只读**(`SELECT`),绝不跨域写。
+//
+// 与 `MembershipChecker` / `CampusReader` 的区别:那两个是**越权判定**依赖(缺失会放宽安全),
+// 本端口只是**展示**依赖 —— 未注入时页面少一列姓名,不影响任何判据(因此可以是 nil)。
+package port
+
+import "context"
+
+// UserNameReader 用户显示名只读端口(端点 53 的 `realName`)。
+//
+// 契约(实现方必须遵守):
+//   - **批量**取(一页最多 200 个学生 ⇒ 禁止逐条查询);
+//   - 返回 `map[userID]displayName`;**查不到的 userID 不要放进 map**(不要塞空串),
+//     调用方据此区分"没有这个人 / 姓名为空"并下发 `null`;
+//   - 任一 DB 错误如实返回,调用方决定是降级(下发 null)还是整体失败。
+type UserNameReader interface {
+	// DisplayNames 批量取显示名。
+	//
+	// 入参:ctx 请求上下文;userIDs 用户 ULID 列表(可重复、可空)。
+	// 出参:map[ULID]显示名(仅含查到的);error 非 nil 表示查询失败。
+	DisplayNames(ctx context.Context, userIDs []string) (map[string]string, error)
+}

+ 353 - 0
internal/academic/repository/course_extra_repo.go

@@ -0,0 +1,353 @@
+// 课程域**补充数据访问**(端点 44/46/48/53/54/55/57 所需,AC-13c 追加)——只新增方法,
+// **不修改** AC-11 已交付的任何仓储方法(`course_repo.go` / `record_repo.go` 一行未动)。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §3.4 C 表(字段级定义)+
+// §3.4 总原则 ③「字段准入条件」(每个响应字段必须能追溯到某个具体业务列)。
+//
+// ---------------------------------------------------------------------------
+// 为什么需要这些方法(AC-11 的读能力够不上契约字段)
+// ---------------------------------------------------------------------------
+//
+//	端点 44 `teacherIds`  ← `t_courses_class.teacher_id` 去重(`t_courses` **没有** teacher 列)
+//	端点 44 `recordCount` ← `t_courses_record` 计数(课程下的排班实例数)
+//	端点 53 报课学生分页  ← `t_courses_student` 按课程过滤 + 分页(AC-11 只有"取全部在读")
+//	端点 54 报名写入      ← `t_courses_student` INSERT(AC-11 没有报课入口,只有扣课时/核销)
+//	端点 55 欠费跨课程分页 ← `remainder_receivable > 0`(AC-11 只有"按单个学生取候选")
+//	端点 57 学生课表      ← `t_courses_record` ⨝ `t_courses_student`(按学生本人过滤)
+//
+// **聚合位置的选择**:`teacherIds` / `recordCount` 用"批量 IN 查询 + 内存映射"而不是
+// 逐行查(N+1):一页最多 200 门课程,逐行查 = 400 次往返;批量是 2 次。
+//
+// 事务纪律(与既有仓储一致):写方法接收 `tx *gorm.DB`(仓储不开事务);读方法经 db(ctx) 取句柄。
+package repository
+
+import (
+	"context"
+	"strings"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// ---------------------------------------------------------------------------
+// 端点 44:课程列表的聚合一列
+// ---------------------------------------------------------------------------
+
+// ListTeacherIDsByCourses 批量取"课程 → 老师 ULID 列表"(端点 44 的 `teacherIds`)。
+//
+// 入参:ctx;courseIDs 课程 ID 集合(空集合 → 返回空 map,不发 SQL)。
+// 出参:map[课程 ID][]老师 ULID(每课程内**去重 + 升序**;无排班规则的课程不出现该 key);
+// ErrDBError。
+//
+// 来源列:`t_courses_class.teacher_id`(`size:26` 的 ULID,可 NULL)。
+// 为什么 NULL/空串要剔除:契约 §1.5「用户 ID 是字符串(ULID 26 位)」——把 NULL 变成一个
+// 空串元素会让前端 `teacherIds.map(...)` 出现一个查不到的老师,是典型的静默脏数据。
+//
+// 排序(去重 + 升序)是**有意**的:契约未定义顺序,但固定顺序让"两次调用结果不同"
+// 不会成为前端偶发渲染差异的来源。
+func (r *CourseRepo) ListTeacherIDsByCourses(ctx context.Context, courseIDs []int64) (map[int64][]string, error) {
+	out := make(map[int64][]string, len(courseIDs))
+	ids := dedupPositive(courseIDs)
+	if len(ids) == 0 {
+		return out, nil
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	// 两列都带 institution_id/campus_id(Scoped 可注入);teacher_id 为 NULL 的行由 SQL 剔除。
+	type row struct {
+		CoursesID int64  `gorm:"column:courses_id"`
+		TeacherID string `gorm:"column:teacher_id"`
+	}
+	rows := make([]row, 0, len(ids))
+	q := r.Scoped(db.Model(&model.CoursesClass{})).
+		Select("courses_id, teacher_id").
+		Where("courses_id IN ?", ids).
+		Where("teacher_id IS NOT NULL AND teacher_id <> ''").
+		Distinct()
+	if err := q.Order("courses_id ASC, teacher_id ASC").Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	for i := range rows {
+		cid := rows[i].CoursesID
+		tid := strings.TrimSpace(rows[i].TeacherID)
+		if tid == "" {
+			continue
+		}
+		if last, ok := out[cid]; ok && len(last) > 0 && last[len(last)-1] == tid {
+			continue // DISTINCT 已去重;这里只防大小写/空格归一后的重复
+		}
+		out[cid] = append(out[cid], tid)
+	}
+	return out, nil
+}
+
+// CountRecordsByCourses 批量取"课程 → 排班实例条数"(端点 44 的 `recordCount`)。
+//
+// 入参:ctx;courseIDs(空集合 → 空 map)。
+// 出参:map[课程 ID]条数(无实例的课程不出现该 key,调用方按 0 处理);ErrDBError。
+//
+// 来源列:`t_courses_record.courses_id`。**计入全部状态**(0 未开始 / 1 上课中 / 2 已结束)——
+// 契约字段名是 `recordCount`(排班实例数),不是"已上课程数";按状态过滤会让"排了 10 节课、
+// 上了 1 节"的课程显示 1,与排班页的语义冲突。
+func (r *CourseRepo) CountRecordsByCourses(ctx context.Context, courseIDs []int64) (map[int64]int64, error) {
+	out := make(map[int64]int64, len(courseIDs))
+	ids := dedupPositive(courseIDs)
+	if len(ids) == 0 {
+		return out, nil
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	type row struct {
+		CoursesID int64 `gorm:"column:courses_id"`
+		Total     int64 `gorm:"column:total"`
+	}
+	rows := make([]row, 0, len(ids))
+	q := r.Scoped(db.Model(&model.CoursesRecord{})).
+		Select("courses_id, COUNT(*) AS total").
+		Where("courses_id IN ?", ids).
+		Group("courses_id")
+	if err := q.Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	for i := range rows {
+		out[rows[i].CoursesID] = rows[i].Total
+	}
+	return out, nil
+}
+
+// ---------------------------------------------------------------------------
+// 端点 53 / 55:报课学生(t_courses_student)
+// ---------------------------------------------------------------------------
+
+// CoursesStudentFilter 报课行查询过滤(端点 53 与 55 共用)。
+//
+// 字段:
+//   - CoursesID int64:课程 ID(<=0 不筛);
+//   - StudentID string:学生 ULID(空不筛);
+//   - Status *int8:报课状态(nil 不筛;1 正常 / 2 结束);
+//   - OnlyArrears bool:**只要 `remainder_receivable > 0`**(端点 55 的唯一判据,
+//     见设计 §3.8-E4:`unpaid` 是 2018-05 起停更的僵尸列,**不得**作为欠费判据)。
+type CoursesStudentFilter struct {
+	CoursesID   int64
+	StudentID   string
+	Status      *int8
+	OnlyArrears bool
+}
+
+// ListCoursesStudents 分页查询报课行(端点 53 / 55)。
+//
+// 入参:ctx;f 过滤;pg 分页(内部钳制 pageSize ∈ [1,200])。
+// 出参:([]model.CoursesStudent, total int64, error);空结果为**非 nil 空切片**;
+// ErrDBError。
+//
+// 排序:`id ASC`(报课先后)。**有意不用 `remainder_receivable DESC`**——金额排序会让
+// 同一页在"核销发生"后行序跳动,前端翻页会重复/漏行;欠费额度排序交给前端对当前页做。
+func (r *CourseRepo) ListCoursesStudents(ctx context.Context, f CoursesStudentFilter, pg PageQuery) ([]model.CoursesStudent, int64, error) {
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, 0, err
+	}
+	build := func() *gorm.DB {
+		q := r.Scoped(db.Model(&model.CoursesStudent{}))
+		if f.CoursesID > 0 {
+			q = q.Where("courses_id = ?", f.CoursesID)
+		}
+		if sid := strings.TrimSpace(f.StudentID); sid != "" {
+			q = q.Where("student_id = ?", sid)
+		}
+		if f.Status != nil {
+			q = q.Where("status = ?", *f.Status)
+		}
+		if f.OnlyArrears {
+			// 欠费判据(**唯一口径**):remainder_receivable > 0。
+			// ⚠ 禁止改成 `unpaid > 0`:该列 2018-05-17 起停止更新,真实数据几乎恒 0,
+			// 用它筛会把"确实欠费"的行**全部漏掉**,且列表显示"无人欠费"看起来完全正常。
+			q = q.Where("remainder_receivable > 0")
+		}
+		return q
+	}
+	var total int64
+	if err := build().Count(&total).Error; err != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	_, size := pg.Normalize()
+	rows := make([]model.CoursesStudent, 0, size)
+	if err := build().Order("id ASC").Offset(pg.Offset()).Limit(pg.Limit()).Find(&rows).Error; err != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return rows, total, nil
+}
+
+// CountActiveEnrollments 统计"某学生某课程的在读报课行数"(端点 54 的重复报名判据)。
+//
+// 入参:ctx;coursesID(>0);studentID(ULID)。
+// 出参:条数;ErrBadParam / ErrDBError。
+//
+// 为什么在仓储里数而不是"先查一条再看":重复报名是**写入前的排他判据**,
+// 用 COUNT 表达"是否存在在读行"可读且不会因多行脏数据漏判(>0 即重复)。
+func (r *CourseRepo) CountActiveEnrollments(ctx context.Context, coursesID int64, studentID string) (int64, error) {
+	if coursesID <= 0 || strings.TrimSpace(studentID) == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: CountActiveEnrollments 需要 courses_id/student_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return 0, err
+	}
+	var total int64
+	q := r.Scoped(db.Model(&model.CoursesStudent{})).
+		Where("courses_id = ? AND student_id = ?", coursesID, strings.TrimSpace(studentID)).
+		Where("status = ?", model.CoursesStudentStatusNormal)
+	if err := q.Count(&total).Error; err != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return total, nil
+}
+
+// CreateCoursesStudent 新建报课行(端点 54)。
+//
+// 入参:ctx;tx(**必传**);row(BizNo/CoursesID/StudentID 必填,金额列已由 Service 归一)。
+// 出参:nil 成功(回填自增 ID);ErrBadParam / ErrDBError。
+func (r *CourseRepo) CreateCoursesStudent(ctx context.Context, tx *gorm.DB, row *model.CoursesStudent) error {
+	if err := requireTx(tx, "CreateCoursesStudent"); err != nil {
+		return err
+	}
+	if row == nil || row.CoursesID <= 0 || strings.TrimSpace(row.StudentID) == "" || strings.TrimSpace(row.BizNo) == "" {
+		return infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: CreateCoursesStudent 需要 biz_no/courses_id/student_id"))
+	}
+	if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}
+
+// ---------------------------------------------------------------------------
+// 端点 57:学生课表(t_courses_record ⨝ t_courses_student)
+// ---------------------------------------------------------------------------
+
+// ListStudentSchedule 分页查询"某学生报名课程的排班实例"(端点 57)。
+//
+// 入参:ctx;studentID 学生 ULID(必填);from/to 上课时间窗(`from <= start_time < to`,
+// 右开,契约 §1.3;nil 表示该端不限);pg 分页。
+// 出参:([]model.CoursesRecord, total int64, error);ErrBadParam / ErrDBError。
+//
+// SQL 形状(**为什么走子查询而不是 JOIN**):
+//
+//			SELECT * FROM t_courses_record
+//			 WHERE <scope(t_courses_record)> AND courses_id IN (
+//			   SELECT courses_id FROM t_courses_student
+//			    WHERE <scope(t_courses_student)> AND student_id = ? AND status = 1
+//			 )
+//			 ORDER BY start_time ASC, id ASC
+//
+//	  - 只用 `record_student`(打卡行)会**漏掉还没打卡的未来课**,那正是"课表"最主要的用途;
+//	  - 只用 `record` 会列出学生没报的课程 ⇒ 必须经 `t_courses_student` 限定范围;
+//	  - `s.status = 1`(在读):已结课的老课程不该继续出现在课表里;
+//	  - ⚠ **不能写成 `JOIN t_courses_student`**:`BaseRepo.Scoped` 注入的是**裸列名**
+//	    `institution_id`,两张表都有该列 ⇒ MySQL/SQLite 都会报 `ambiguous column name`。
+//	    子查询让每张表的过滤条件各自解析,既消除歧义,作用域也没被放松(内外层都过 Scoped)。
+func (r *CourseRepo) ListStudentSchedule(ctx context.Context, studentID string, from, to *time.Time, pg PageQuery) ([]model.CoursesRecord, int64, error) {
+	sid := strings.TrimSpace(studentID)
+	if sid == "" {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: ListStudentSchedule 需要 student_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, 0, err
+	}
+	// 学生报名的课程集合(内层已按作用域过滤:跨机构的报课行不会把课程带出来)
+	enrolled := r.Scoped(db.Model(&model.CoursesStudent{}).Select("courses_id")).
+		Where("student_id = ?", sid).
+		Where("status = ?", model.CoursesStudentStatusNormal)
+	build := func() *gorm.DB {
+		q := r.Scoped(db.Model(&model.CoursesRecord{})).
+			Where("courses_id IN (?)", enrolled)
+		if from != nil && !from.IsZero() {
+			q = q.Where("start_time >= ?", *from)
+		}
+		if to != nil && !to.IsZero() {
+			q = q.Where("start_time < ?", *to)
+		}
+		return q
+	}
+	var total int64
+	if err := build().Count(&total).Error; err != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	_, size := pg.Normalize()
+	rows := make([]model.CoursesRecord, 0, size)
+	order := "start_time ASC, id ASC"
+	if err := build().Order(order).Offset(pg.Offset()).Limit(pg.Limit()).Find(&rows).Error; err != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return rows, total, nil
+}
+
+// ListRecordStudentsOfStudent 批量取"排班实例 → 该学生的打卡行"(端点 57 的考勤列)。
+//
+// 入参:ctx;studentID(ULID);recordIDs 排班实例 ID 集合(空 → 空 map,不发 SQL)。
+// 出参:map[recordID]model.CoursesRecordStudent(每个实例最多一行);ErrDBError。
+//
+// 用途:课表页要显示"这节课我的课费/赠送/是否已打卡",来源是 `t_courses_record_student`
+// 里**该学生**的那一行。批量而非逐行查,理由同 `ListTeacherIDsByCourses`(防 N+1)。
+func (r *RecordRepo) ListRecordStudentsOfStudent(ctx context.Context, studentID string, recordIDs []int64) (map[int64]model.CoursesRecordStudent, error) {
+	out := make(map[int64]model.CoursesRecordStudent, len(recordIDs))
+	sid := strings.TrimSpace(studentID)
+	ids := dedupPositive(recordIDs)
+	if sid == "" || len(ids) == 0 {
+		return out, nil
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	rows := make([]model.CoursesRecordStudent, 0, len(ids))
+	q := r.Scoped(db.Model(&model.CoursesRecordStudent{})).
+		Where("student_id = ?", sid).
+		Where("record_id IN ?", ids).
+		Order("id ASC")
+	if err := q.Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	for i := range rows {
+		// 同一 (record, student) 理论上一行;多行脏数据取**最早一行**(id ASC 的第一次),
+		// 不静默合并金额(合并会把两笔课费的算术事故藏起来)。
+		if _, exists := out[rows[i].RecordID]; exists {
+			continue
+		}
+		out[rows[i].RecordID] = rows[i]
+	}
+	return out, nil
+}
+
+// ListCoursesByIDs 批量取课程模板(端点 57 回显课程名用)。
+//
+// 入参:ctx;ids(空 → 空切片)。
+// 出参:[]model.Courses(**非 nil 空切片**);ErrDBError。
+//
+// 说明:走 `Scoped`(课程表带作用域列)——跨机构的课程 ID 查不到,属于预期的"看不见"。
+func (r *CourseRepo) ListCoursesByIDs(ctx context.Context, ids []int64) ([]model.Courses, error) {
+	out := make([]model.Courses, 0, len(ids))
+	clean := dedupPositive(ids)
+	if len(clean) == 0 {
+		return out, nil
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	q := r.Scoped(db.Model(&model.Courses{})).Where("id IN ?", clean).Order("id ASC")
+	if err := q.Find(&out).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return out, nil
+}

+ 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
+}

+ 223 - 0
internal/academic/repository/payroll_plan_repo.go

@@ -0,0 +1,223 @@
+// 课酬方案 + 明细的数据访问(端点 58:`/admin/payroll/plans`)——AC-13d 追加。
+//
+// 依据:团队裁决 D17(两级形状)+ 目标态 DDL 两张表:
+//
+//	t_course_payment        : id, institution_id, campus_id, name, effect_date, actived,
+//	                          create_user_id, create_time, memo
+//	t_course_payment_detail : id, course_payment_id, grade_id, subject_id, class_type, classify_id,
+//	                          teacher_id, name, percentage, min_payment, max_payment, reward,
+//	                          teacher_cert, teacher_exp_min, teacher_exp_max, actived, memo
+//
+// ---------------------------------------------------------------------------
+// 作用域(为什么不用 BaseRepo.Scoped)
+// ---------------------------------------------------------------------------
+//
+// `PayrollRepo` **不继承 BaseRepo**(见 `payroll_repo.go` 文件头:本包表混有无作用域列的表
+// `t_teacher_information`,`Scoped` 的裸列名会 `Unknown column`)。因此本文件的每个方法都
+// **显式接收 institutionID** 并 `WHERE institution_id = ?`,越权由 `assertInstitution` 统一拦:
+//
+//	非超管:institutionID 必须 == 作用域机构(否则 1004)
+//	超管 :可查任意机构(平台视角)
+//
+// ⚠ `t_course_payment_detail` **没有** institution_id 列 ⇒ 明细的作用域**只能经父方案**:
+// 本文件所有"按明细操作"的方法都要求调用方已用方案 id 校验过归属(Service 层保证)。
+//
+// ---------------------------------------------------------------------------
+// 事务纪律
+// ---------------------------------------------------------------------------
+//
+// 写方法一律接收 `tx *gorm.DB`(仓储不开事务);`ReplaceDetails` 的"先删后插"必须在
+// **同一事务**内(否则删完插失败 ⇒ 整案明细丢失)。
+package repository
+
+import (
+	"context"
+	"strings"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// PayrollPlanFilter 课酬方案列表过滤(端点 58 GET)。
+//
+// 字段:CampusID 校区(nil 不筛;0 = 只查机构级方案);Actived 启用(nil 不筛);
+// Keyword 方案名模糊(空不筛)。
+type PayrollPlanFilter struct {
+	CampusID *int64
+	Actived  *bool
+	Keyword  string
+}
+
+// ListPlans 分页查询课酬方案(按 effect_date DESC, id DESC:最新生效在前)。
+//
+// 入参:ctx;institutionID 机构(**必须 >0**,越权 → 1004);f 过滤;pg 分页。
+// 出参:([]model.CoursePayment, total, error);空结果非 nil;ErrBadParam/ErrUnscoped/ErrDBError。
+func (r *PayrollRepo) ListPlans(ctx context.Context, institutionID int64, f PayrollPlanFilter, pg PageQuery) ([]model.CoursePayment, int64, error) {
+	if err := r.assertInstitution(institutionID); err != nil {
+		return nil, 0, err
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, 0, err
+	}
+	build := func() *gorm.DB {
+		q := db.Model(&model.CoursePayment{}).Where("institution_id = ?", institutionID)
+		if f.CampusID != nil {
+			q = q.Where("campus_id = ?", *f.CampusID)
+		}
+		if f.Actived != nil {
+			q = q.Where("actived = ?", *f.Actived)
+		}
+		if kw := strings.TrimSpace(f.Keyword); kw != "" {
+			q = q.Where("name LIKE ?", "%"+kw+"%")
+		}
+		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.CoursePayment, 0, size)
+	if err := build().
+		Order("effect_date 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
+}
+
+// GetPlanByID 按主键取课酬方案(**必须属于该机构**,否则 3001 —— 不泄露存在性)。
+//
+// 入参:ctx;institutionID(>0);id 方案 ID(>0)。
+// 出参:*model.CoursePayment;ErrBadParam / ErrUnscoped / ErrNotFound / ErrDBError。
+func (r *PayrollRepo) GetPlanByID(ctx context.Context, institutionID, id int64) (*model.CoursePayment, error) {
+	if err := r.assertInstitution(institutionID); err != nil {
+		return nil, err
+	}
+	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.CoursePayment
+	qErr := db.Model(&model.CoursePayment{}).
+		Where("id = ? AND institution_id = ?", id, institutionID).
+		Take(&out).Error
+	if qErr != nil {
+		return nil, wrapQueryErr(qErr, "GetPlanByID(t_course_payment)")
+	}
+	return &out, nil
+}
+
+// ListDetailsByPlanIDs 批量取多个方案下的全部明细(按 id ASC,顺序稳定)。
+//
+// 入参:ctx;planIDs 方案 ID 集合(空 → 空切片,不发 SQL)。
+// 出参:[]model.CoursePaymentDetail;ErrDBError。
+//
+// 为什么批量:列表页一页最多 200 个方案,逐方案查 = 200 次往返。
+func (r *PayrollRepo) ListDetailsByPlanIDs(ctx context.Context, planIDs []int64) ([]model.CoursePaymentDetail, error) {
+	out := make([]model.CoursePaymentDetail, 0, len(planIDs))
+	ids := dedupPositive(planIDs)
+	if len(ids) == 0 {
+		return out, nil
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	if err := db.Model(&model.CoursePaymentDetail{}).
+		Where("course_payment_id IN ?", ids).
+		Order("course_payment_id ASC, id ASC").
+		Find(&out).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return out, nil
+}
+
+// CreatePlan 新建课酬方案(回填自增 ID)。
+//
+// 入参:ctx;tx(**必传**);row 方案行(Name/InstitutionID 必填)。
+// 出参:nil 成功;ErrBadParam / ErrDBError。
+func (r *PayrollRepo) CreatePlan(ctx context.Context, tx *gorm.DB, row *model.CoursePayment) error {
+	if err := requireTx(tx, "CreatePlan"); err != nil {
+		return err
+	}
+	if row == nil || strings.TrimSpace(strValueOrEmpty(row.Name)) == "" || row.InstitutionID <= 0 {
+		return infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: CreatePlan 需要 name 与 institution_id"))
+	}
+	if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}
+
+// UpdatePlan 按列白名单更新方案(只改调用方显式给出的列)。
+//
+// 入参:ctx;tx(**必传**);row(ID 必填);columns 列名(非空,调用方用字面量)。
+// 出参:int64 受影响行数(0 = 不存在/不在机构);ErrBadParam / ErrDBError。
+//
+// 红线:**不含 institution_id**(作用域列创建时锁定,不允许改租户)。
+func (r *PayrollRepo) UpdatePlan(ctx context.Context, tx *gorm.DB, row *model.CoursePayment, columns []string) (int64, error) {
+	if err := requireTx(tx, "UpdatePlan"); err != nil {
+		return 0, err
+	}
+	if row == nil || row.ID <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdatePlan 需要 id"))
+	}
+	if len(columns) == 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdatePlan 需要显式列清单"))
+	}
+	res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursePayment{}).
+		Where("id = ? AND institution_id = ?", row.ID, row.InstitutionID).
+		Select(columns).Updates(row)
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// ReplaceDetails 整体替换某方案的明细(**先按 course_payment_id 删、再批量插**)。
+//
+// 入参:ctx;tx(**必传**,删与插必须同一事务);planID 方案 ID(>0);
+// details 新明细(为空 → 只删不插,即"清空明细")。
+// 出参:nil 成功;ErrBadParam / ErrDBError。
+//
+// 为什么"整体替换"而不是逐条 diff:前端提交的是整案(D17 裁决 `PUT /:id` = 方案 id 整体替换),
+// diff 会引入"哪条被谁改了"的对账难题,而明细是**配置**不是流水,重写终态即可。
+func (r *PayrollRepo) ReplaceDetails(ctx context.Context, tx *gorm.DB, planID int64, details []*model.CoursePaymentDetail) error {
+	if err := requireTx(tx, "ReplaceDetails"); err != nil {
+		return err
+	}
+	if planID <= 0 {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ReplaceDetails 需要 plan_id"))
+	}
+	db := tx.WithContext(ctxOrBackground(ctx))
+	if err := db.Where("course_payment_id = ?", planID).Delete(&model.CoursePaymentDetail{}).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	if len(details) == 0 {
+		return nil
+	}
+	for i := range details {
+		details[i].CoursePaymentID = planID // 强制回填:防止调用方传错父 id
+	}
+	if err := db.Create(&details).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}
+
+// strValueOrEmpty 取字符串指针的值(nil → 空串;仓储内部小工具)。
+func strValueOrEmpty(s *string) string {
+	if s == nil {
+		return ""
+	}
+	return *s
+}

+ 38 - 0
internal/academic/repository/payroll_repo.go

@@ -190,6 +190,44 @@ func (r *PayrollRepo) TeacherHasCert(ctx context.Context, teacherID string) (boo
 	return row.TeacherCert, nil
 }
 
+// TeacherExpDate 取老师的**教龄起算日**(`t_teacher_information.teacher_exp`,DATE 可空)。
+//
+// 入参:ctx;teacherID 老师 ULID(空 → ErrBadParam)。
+// 出参:(起算日, 是否查到, error);**行不存在或该列为 NULL → (nil,false,nil)**(不是错误:
+// 旧库早期老师大量未录教龄,PHP `calculatingAge(empty)` 同样返回 0)。
+//
+// 为什么返回"日期"而不是"年数":换算必须在 Service 层按 PHP `calculatingAge()` 的
+// 口径做(固定 365 天/年、round 1 位),仓储只负责取**原值列**——仓储做换算会让
+// "同一列在两处用不同口径算"成为可能(贷方/报表各算一遍是最典型的漂移来源)。
+func (r *PayrollRepo) TeacherExpDate(ctx context.Context, teacherID string) (*time.Time, bool, error) {
+	id := strings.TrimSpace(teacherID)
+	if id == "" {
+		return nil, false, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: TeacherExpDate 需要 teacher_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, false, err
+	}
+	var row struct {
+		TeacherExp *time.Time `gorm:"column:teacher_exp"`
+	}
+	qErr := db.Model(&model.TeacherInformation{}).
+		Select("teacher_exp").
+		Where("teacher_id = ?", id).
+		Take(&row).Error
+	if qErr != nil {
+		if isNotFound(qErr) {
+			return nil, false, nil // 无档案:教龄按 0(与 TeacherHasCert 的无行语义一致)
+		}
+		return nil, false, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	if row.TeacherExp == nil || row.TeacherExp.IsZero() {
+		return nil, false, nil // 列为 NULL:未录教龄 → 0
+	}
+	return row.TeacherExp, true, nil
+}
+
 // FindActiveScheme 取生效的课酬方案(dump 763616-763619 的单次查询;两级回退由 Service 调用两次)。
 //
 // dump 原文(camupsid 为入参那一档):

+ 1344 - 0
internal/academic/service/course_query_service.go

@@ -0,0 +1,1344 @@
+// 课程域**补充服务**(端点 44~57 的读装配 / 报名写入 / 结课响应装配)——AC-13c 追加。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §2.4(端点表)/
+// §3.4 C 表(44~57 字段级定义)/ §3.4 总原则 ③「字段准入条件」/ §3.8-E4(`unpaid` 废弃列);
+// 设计 `academic-domain-design.md` §3.5(SP27 欠费核销)/ §4.4(端点清单)。
+//
+// ---------------------------------------------------------------------------
+// 本文件与 AC-11 的边界(**只新增,不改既有逻辑**)
+// ---------------------------------------------------------------------------
+//
+// AC-11 交付的 `course_service.go` / `record_service.go` / `maintenance_service.go` **一行未改**。
+// 契约要求的下列能力在 AC-11 没有对应方法,故在此**新增**:
+//
+//	端点 44  ListCourseRows      teacherIds(t_courses_class)+ recordCount(t_courses_record)
+//	端点 46  ListClassRows       week 原值下发(**不改写成 1~7 数字**,理由见 ClassRow.Week)
+//	端点 47  AssertNoClassConflict  冲突 → 3122(AC-11 的 CreateClass 无冲突判定)
+//	端点 53  ListStudentRows     报课学生分页(AC-11 只有"取全部在读")
+//	端点 54  Enroll             报课写入(AC-11 无报课入口)
+//	端点 55  ListArrearsRows     跨课程欠费分页(AC-11 只有"按单学生取候选")
+//	端点 57  ListScheduleRows    学生课表(record ⨝ courses_student)
+//	端点 51  RecordViewService   结课响应装配(divide[] / payroll 溯源列,**只读、不进结账事务**)
+//
+// ⚠ 这些是"补 handler 所需的数据能力",不是"修 AC-11 的 bug":AC-11 的服务方法本身
+// 在其声明范围内行为正确(已由 `course_service_test.go` / `record_service_test.go` 锁定)。
+//
+// ---------------------------------------------------------------------------
+// 三条量纲/语义红线(本文件逐条落地)
+// ---------------------------------------------------------------------------
+//
+//  1. 金额一律 `money.Amount` 或两位小数字符串,**不出现 float64**;
+//  2. 欠费判据**只有** `remainder_receivable`(`unpaid` 是 2018-05 停更的僵尸列,禁读禁判);
+//  3. 时间窗 `from` 左闭 / `to` 右开(契约 §1.3)。
+package service
+
+import (
+	"context"
+	"strconv"
+	"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/repository"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// ---------------------------------------------------------------------------
+// 端点 44:课程列表(含 teacherIds / recordCount)
+// ---------------------------------------------------------------------------
+
+// CourseRow 端点 44 的行(契约 §3.4 C 行 44:`id/institutionId/name/classify/status/teacherIds/recordCount`)。
+//
+// 字段来源(**逐字段列溯源**,契约 §3.4 ③):
+//
+//	ID            ← t_courses.id
+//	InstitutionID ← t_courses.institution_id
+//	Name          ← t_courses.name
+//	ClassifyID    ← t_courses.classify_id(契约名 `classify`)
+//	Status        ← t_courses.status
+//	TeacherIDs    ← t_courses_class.teacher_id(去重、升序;空 → [])
+//	RecordCount   ← COUNT(t_courses_record)(全状态,见 repository.CountRecordsByCourses 注释)
+type CourseRow struct {
+	// ID 课程 ID。
+	ID int64
+	// InstitutionID 机构 ID(作用域列)。
+	InstitutionID int64
+	// Name 课程名称。
+	Name string
+	// ClassifyID 课程分类 ID(契约 JSON 名 `classify`)。
+	ClassifyID int64
+	// Status 课程状态(0 未发布 / 1 发布未开始 / 2 进行中 / 3 已结束)。
+	Status int32
+	// TeacherIDs 该课程排班规则涉及的老师(去重升序;无规则 → 空切片)。
+	TeacherIDs []string
+	// RecordCount 该课程的排班实例条数(全状态)。
+	RecordCount int64
+}
+
+// CourseListQuery 端点 44 的查询入参(**Handler 只依赖本层类型**,不直接看到 repository 类型)。
+//
+// 字段:ClassifyID 分类过滤(<=0 不筛);Status 状态过滤(nil 不筛);Keyword 名称模糊;
+// Page/PageSize 分页(服务层钳制,上限 200)。
+type CourseListQuery struct {
+	// ClassifyID 课程分类 ID(<=0 不筛)。
+	ClassifyID int64
+	// Status 课程状态(nil 不筛;0 未发布 / 1 发布未开始 / 2 进行中 / 3 已结束)。
+	Status *int32
+	// Keyword 课程名称模糊匹配(空不筛)。
+	Keyword string
+	// Page 页码(1 基;<1 → 1)。
+	Page int
+	// PageSize 每页条数(<1 → 10;>200 → 200)。
+	PageSize int
+}
+
+// ListCourseRows 分页查询课程列表并补齐 `teacherIds` / `recordCount`(端点 44)。
+//
+// 入参:ctx;q 查询入参。
+// 出参:([]CourseRow, total, error);空结果为**非 nil 空切片**;5002。
+//
+// 两次批量查询而不是逐行查(一页最多 200 门课程,逐行 = 400 次往返)。
+func (s *CourseService) ListCourseRows(ctx context.Context, q CourseListQuery) ([]CourseRow, int64, error) {
+	courses, total, err := s.courses.ListCourses(ctx, repository.CourseFilter{
+		ClassifyID: q.ClassifyID, Status: q.Status, Keyword: q.Keyword,
+	}, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
+	if err != nil {
+		return nil, 0, err
+	}
+	rows := make([]CourseRow, 0, len(courses))
+	if len(courses) == 0 {
+		return rows, total, nil
+	}
+	ids := make([]int64, 0, len(courses))
+	for i := range courses {
+		ids = append(ids, courses[i].ID)
+	}
+	teachers, err := s.courses.ListTeacherIDsByCourses(ctx, ids)
+	if err != nil {
+		return nil, 0, err
+	}
+	counts, err := s.courses.CountRecordsByCourses(ctx, ids)
+	if err != nil {
+		return nil, 0, err
+	}
+	for i := range courses {
+		c := &courses[i]
+		tids := teachers[c.ID]
+		if tids == nil {
+			tids = make([]string, 0)
+		}
+		rows = append(rows, CourseRow{
+			ID:            c.ID,
+			InstitutionID: c.InstitutionID,
+			Name:          c.Name,
+			ClassifyID:    c.ClassifyID,
+			Status:        c.Status,
+			TeacherIDs:    tids,
+			RecordCount:   counts[c.ID],
+		})
+	}
+	return rows, total, nil
+}
+
+// ---------------------------------------------------------------------------
+// 端点 46 / 47:排班规则(t_courses_class)
+// ---------------------------------------------------------------------------
+
+// ClassRow 端点 46 的行(契约 §3.4 C 行 46)。
+//
+// 字段来源:
+//
+//	ClassID   ← t_courses_class.class_id(**不是主键 id**:契约名是 `classId`,且旧库该列是外部班级 id)
+//	CourseID  ← t_courses_class.courses_id
+//	TeacherID ← t_courses_class.teacher_id(NULL → 空串,契约 §1.5 用户 ID 是字符串)
+//	Week      ← t_courses_class.week(**原值字符串**)
+//	Start     ← t_courses_class.start_time(时分语义,1970-01-01 + 时分)
+//	End       ← t_courses_class.end_time(同上;可空)
+//	CampusID  ← t_courses_class.campus_id
+//
+// ⚠ **两处契约冲突(已登记,本实现按"列口径"落地,报告见交付说明)**:
+//
+//  1. 契约写 `week:number`「周日序(1~7)」。该列实际是**字符串**,取值 `"-1"`(全部周)/
+//     `"0"`..`"6"` / 逗号多值(`"1,3,5"`)。数字 1~7 **表达不了**「全部周」与「多值」——
+//     按契约发 number 会让最常见的 `-1` 与任意多值规则**静默失真**(前端把"每天"显示成某一天)。
+//     因此下发**原值字符串**;若前端确需数字,须先在契约里定义 `-1`/多值如何编码。
+//  2. 契约写 `room:string`。**`t_courses_class` 没有 room 列**(只有 `address` / `remark`)。
+//     按 §3.4 ③「找不到列的一律不写」⇒ **不下发 `room`**,也**不用 `address` 顶替**
+//     (地址 ≠ 教室,顶替会让前端把"上课地点"当"教室号"展示)。
+type ClassRow struct {
+	// ClassID 班级 ID(`t_courses_class.class_id`)。
+	ClassID int64
+	// CourseID 课程 ID。
+	CourseID int64
+	// TeacherID 老师 ULID(NULL → 空串)。
+	TeacherID string
+	// Week 周规则原值("-1" 全部周 / "0" 周日 / "1".."6" / 逗号多值)。
+	Week string
+	// Start 上课时分(date 部分无业务含义,恒 1970-01-01)。
+	Start *time.Time
+	// End 下课时分(可空 = 未配置)。
+	End *time.Time
+	// CampusID 校区 ID(0 = 机构级)。
+	CampusID int64
+}
+
+// ListClassRows 取某课程的全部排班规则(端点 46,**不分页**——见 Handler 的分页说明)。
+//
+// 入参:ctx;coursesID(<=0 → 2001)。
+// 出参:[]ClassRow(非 nil);3001(课程不存在/不在作用域)/5002。
+func (s *CourseService) ListClassRows(ctx context.Context, coursesID int64) ([]ClassRow, error) {
+	classes, err := s.ListClasses(ctx, coursesID)
+	if err != nil {
+		return nil, err
+	}
+	rows := make([]ClassRow, 0, len(classes))
+	for i := range classes {
+		c := &classes[i]
+		rows = append(rows, ClassRow{
+			ClassID:   c.ClassID,
+			CourseID:  c.CoursesID,
+			TeacherID: strValue(c.TeacherID),
+			Week:      c.Week,
+			Start:     c.StartTime,
+			End:       c.EndTime,
+			CampusID:  c.CampusID,
+		})
+	}
+	return rows, nil
+}
+
+// classSlot 排班时间槽(冲突判定的比较单位)。
+//
+// 字段:teacher 老师 ULID;campusID 校区;week 周规则;startMin/endMin 当日起止分钟数。
+type classSlot struct {
+	teacher  string
+	campusID int64
+	week     weekRule
+	startMin int
+	endMin   int
+}
+
+// AssertNoClassConflict 新建排班规则前的冲突判定(端点 47,冲突 → **3122**)。
+//
+//   - 入参:ctx;in 与 `CreateClass` 同形状的入参(只读用:coursesID/teacherID/week/start/end)。
+//   - 出参:nil 无冲突;apperr.ErrScheduleConflict(3122) 冲突;2002 时分格式非法;5002。
+//
+// 校区来源(**不取入参**):`CreateClassInput` 没有 `CampusID` 字段——新建规则的校区恒取
+// **作用域校区**(`repository.GetScopedID`)。因此冲突判定也必须以作用域校区为准,
+// 否则一条"给校区 10 排课"的请求会按"校区 0"去比、把真冲突漏掉。
+// 超管(作用域校区 = 0,可跨校区排课)**不做校区收敛**:保守地在全部校区里查同一老师的冲突
+// (宁可报一次 3122 让人确认,也不放过"同一老师同一时刻在两个校区"这种多半是误操作的情形)。
+func (s *CourseService) AssertNoClassConflict(ctx context.Context, in CreateClassInput) error {
+	teacher := strings.TrimSpace(in.TeacherID)
+	if teacher == "" {
+		return nil
+	}
+	_, scopeCampusID := s.courses.GetScopedID()
+	target, err := buildClassSlot(teacher, scopeCampusID, in.Week, in.StartTime, in.EndTime)
+	if err != nil {
+		return err
+	}
+	existing, err := s.courses.ListClassesByCourses(ctx, in.CoursesID, false)
+	if err != nil {
+		return err
+	}
+	for i := range existing {
+		cur := &existing[i]
+		if strValue(cur.TeacherID) != teacher {
+			continue
+		}
+		slot, serr := buildClassSlot(teacher, cur.CampusID, cur.Week, hmString(cur.StartTime), hmString(cur.EndTime))
+		if serr != nil {
+			// 存量规则的 week/时分写坏:**不阻断新建**(否则一个脏行会让该课程永久无法排班),
+			// 但也不能静默当作"无冲突"——用 5002 暴露给运维(脏数据必须被看见)。
+			return serr
+		}
+		if slot.campusID != target.campusID && target.campusID != 0 {
+			continue // 作用域校区 >0:只与本校区比;=0(超管):跨校区全查
+		}
+		if !weekRulesIntersect(slot.week, target.week) {
+			continue
+		}
+		if !slotsOverlap(slot, target) {
+			continue
+		}
+		return infraerrors.WithCause(apperr.ErrScheduleConflict,
+			errString("service: 老师在该时段已有排班规则(classId="+strconv.FormatInt(cur.ClassID, 10)+")"))
+	}
+	return nil
+}
+
+// buildClassSlot 把一条排班规则(或其入参)转成可比较的时间槽。
+//
+// 入参:teacher;campusID;week 周规则;startRaw/endRaw 时分("HH:MM",start 必填)。
+// 出参:classSlot;2002(week 非法 / 时分格式非法)。
+func buildClassSlot(teacher string, campusID int64, week, startRaw, endRaw string) (classSlot, error) {
+	rule, err := parseWeekRule(week)
+	if err != nil {
+		return classSlot{}, err
+	}
+	start, err := parseHHMM(startRaw, "上课时间")
+	if err != nil {
+		return classSlot{}, err
+	}
+	slot := classSlot{
+		teacher:  strings.TrimSpace(teacher),
+		campusID: campusID,
+		week:     rule,
+		startMin: start.Hour()*60 + start.Minute(),
+	}
+	if strings.TrimSpace(endRaw) != "" {
+		end, eerr := parseHHMM(endRaw, "下课时间")
+		if eerr != nil {
+			return classSlot{}, eerr
+		}
+		slot.endMin = end.Hour()*60 + end.Minute()
+	}
+	return slot, nil
+}
+
+// weekRulesIntersect 判断两条周规则是否有交集(`-1` 表示全部周)。
+//
+// 入参:a/b weekRule。出参:true 有交集。
+func weekRulesIntersect(a, b weekRule) bool {
+	if a.all || b.all {
+		return true
+	}
+	for day := 0; day < 7; day++ {
+		if a.days[day] && b.days[day] {
+			return true
+		}
+	}
+	return false
+}
+
+// slotsOverlap 判断两个时间槽是否重叠(`[start,end)` 半开区间;end 缺失按"点"处理)。
+//
+// 入参:a/b classSlot(调用方已保证同老师、同校区、周规则有交集)。
+// 出参:true 重叠。
+func slotsOverlap(a, b classSlot) bool {
+	if a.endMin <= a.startMin && b.endMin <= b.startMin {
+		return a.startMin == b.startMin // 双方都没有下课时间:只能按"同一时刻"判
+	}
+	aEnd := a.endMin
+	if aEnd <= a.startMin {
+		aEnd = a.startMin + 1 // 单侧缺失:按 1 分钟占位,避免"缺 end 就永不冲突"
+	}
+	bEnd := b.endMin
+	if bEnd <= b.startMin {
+		bEnd = b.startMin + 1
+	}
+	return a.startMin < bEnd && b.startMin < aEnd
+}
+
+// hmString 把时分列转 "HH:MM"(nil → 空串)。
+//
+// 入参:t 时分列(1970-01-01 + 时分)。出参:`"09:30"` 形态。
+func hmString(t *time.Time) string {
+	if t == nil {
+		return ""
+	}
+	return twoDigits(t.Hour()) + ":" + twoDigits(t.Minute())
+}
+
+// twoDigits 两位左补零(避免引入 fmt 开销与格式串分散)。
+func twoDigits(v int) string {
+	if v < 10 {
+		return "0" + strconv.Itoa(v)
+	}
+	return strconv.Itoa(v)
+}
+
+// ---------------------------------------------------------------------------
+// 端点 53:报课学生列表
+// ---------------------------------------------------------------------------
+
+// StudentRow 端点 53 的行(契约 §3.4 C 行 53)。
+//
+// 字段来源:
+//
+//	UserID               ← t_courses_student.student_id
+//	RealName             ← **不下发**(姓名在 identity 域,academic 不持有 t_user;契约 §3.4 C 写
+//	                       `realName:string`,但跨域只读须经 port ⇒ 本批留空不下发,登记偏差)
+//	Pay                  ← t_courses_student.receivable(应收)
+//	Cost                 ← t_courses_student.net_receipts(实收)
+//	GiveCost             ← t_courses_student.give_cost(已用赠送额)
+//	RemainderReceivable  ← t_courses_student.remainder_receivable(剩余应收 = **欠费真值**)
+//	Status               ← t_courses_student.status(1 正常 / 2 结束,数字码)
+type StudentRow struct {
+	// StudentCourseID 报课行 ID(`t_courses_student.id`;契约未列,内部对账用,不下发)。
+	StudentCourseID int64
+	// UserID 学生 ULID。
+	UserID string
+	// Pay 应收(`receivable`)。
+	Pay money.Amount
+	// Cost 实收(`net_receipts`)。
+	Cost money.Amount
+	// GiveCost 已用赠送额(`give_cost`)。
+	GiveCost money.Amount
+	// RemainderReceivable 剩余应收(`remainder_receivable`,欠费真值)。
+	RemainderReceivable money.Amount
+	// Status 报课状态(1 正常 / 2 结束)。
+	Status int32
+}
+
+// StudentListQuery 端点 53 的查询入参。
+//
+// 字段:CoursesID 课程(必填 >0);Status 报课状态(nil 不筛);Page/PageSize 分页。
+type StudentListQuery struct {
+	// CoursesID 课程 ID(必填 >0)。
+	CoursesID int64
+	// Status 报课状态(nil 不筛;1 正常 / 2 结束)。
+	Status *int32
+	// Page 页码(1 基)。
+	Page int
+	// PageSize 每页条数(上限 200)。
+	PageSize int
+}
+
+// ListStudentRows 分页查询报课学生(端点 53)。
+//
+// 入参:ctx;q 查询入参。
+// 出参:([]StudentRow, total, error);3001(课程不存在/不在作用域)/5002。
+// 空结果返回 `[]`(不是 3001:课程存在但没人报名是正常空态)。
+func (s *CourseService) ListStudentRows(ctx context.Context, q StudentListQuery) ([]StudentRow, int64, error) {
+	coursesID := q.CoursesID
+	if coursesID <= 0 {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
+	}
+	if _, err := s.courses.GetCourseByID(ctx, coursesID); err != nil {
+		return nil, 0, err // 3001
+	}
+	var st *int8
+	if q.Status != nil {
+		v := int8(*q.Status)
+		st = &v
+	}
+	rows, total, err := s.courses.ListCoursesStudents(ctx, repository.CoursesStudentFilter{
+		CoursesID: coursesID, Status: st,
+	}, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
+	if err != nil {
+		return nil, 0, err
+	}
+	out := make([]StudentRow, 0, len(rows))
+	for i := range rows {
+		row := &rows[i]
+		pay, err := parseAmount(row.Receivable)
+		if err != nil {
+			return nil, 0, err
+		}
+		cost, err := parseAmount(row.NetReceipts)
+		if err != nil {
+			return nil, 0, err
+		}
+		give, err := parseAmount(row.GiveCost)
+		if err != nil {
+			return nil, 0, err
+		}
+		remainder, err := parseAmount(row.RemainderReceivable)
+		if err != nil {
+			return nil, 0, err
+		}
+		out = append(out, StudentRow{
+			StudentCourseID:     row.ID,
+			UserID:              row.StudentID,
+			Pay:                 pay,
+			Cost:                cost,
+			GiveCost:            give,
+			RemainderReceivable: remainder, // ⚠ 欠费列:**只读本列**,不读 unpaid(§3.8-E4)
+			Status:              int32(row.Status),
+		})
+	}
+	return out, total, nil
+}
+
+// ---------------------------------------------------------------------------
+// 端点 54:报名 / 补报名
+// ---------------------------------------------------------------------------
+
+// EnrollInput 报名入参(端点 54)。
+//
+// ⚠ **契约 §2.4 端点 54 只给了响应字段(`enrolled/courseId/userId`),没有入参形状** ⇒
+// 本入参是 AC-13c 自定义的**最小可对账集合**,已在交付报告登记为「契约缺口待补」。
+//
+// 金额口径(**只做快照,不动任何资金账户**):
+//
+//	net_receipts        = Pay(本次实收)
+//	receivable          = Pay + RemainderReceivable(应收恒等式:应收 = 实收 + 剩余应收)
+//	remainder_receivable= RemainderReceivable(默认为 0 ⇒ 全额缴清)
+//	remainder_cost      = Pay(尚未消耗)
+//	remainder_number    = Number + GiveNumber - 0
+//	lesson_cost         = LessonCost(单节课费快照,扣课时依据)
+//
+// **为什么不写资金流水**:报课行是"应收/实收快照",真实收款走财务域端点 3(充值)/4(消费),
+// 本端点若凭空写流水会产生"没有来源的余额变动",破坏 AC-17 的余额恒等式。
+type EnrollInput struct {
+	// CoursesID 课程 ID(路径 `:id`)。
+	CoursesID int64
+	// StudentID 学生 ULID(必填)。
+	StudentID string
+	// ClassID 班级 ID(0 = 不指定)。
+	ClassID int64
+	// CampusID 校区(0 = 用作用域校区;>0 且与作用域校区不一致 → 1004)。
+	CampusID int64
+	// Pay 本次实收(元字符串,必填 ≥0)。
+	Pay string
+	// RemainderReceivable 剩余应收(元字符串,可空 → 0)。
+	RemainderReceivable string
+	// LessonCost 单节课费(元字符串,必填;扣课时依据)。
+	LessonCost string
+	// Number 课次(>=0)。
+	Number int32
+	// GiveNumber 赠送课次(>=0,可空 → 0)。
+	GiveNumber int32
+	// DiscountCash 现金折扣(元字符串,可空 → 0;**允许负**,模型红线)。
+	DiscountCash string
+	// GiveCost 已用赠送额(元字符串,可空 → 0)。
+	GiveCost string
+	// RecommenderUserID 推荐人(ULID,可空;分账上行起点)。
+	RecommenderUserID string
+	// Remark 备注。
+	Remark string
+	// OperatorID 操作人(作用域用户;写 confirm_discount_user_id —— 见 Enroll 注释)。
+	OperatorID string
+}
+
+// Enroll 报名 / 补报名(端点 54)。
+//
+// 入参:ctx;in 入参。
+// 出参:*model.CoursesStudent(含自增 ID);2001/2002(金额/课次非法)/
+// 3001(课程不存在或不在作用域)/1004(跨校区)/**3143**(该学生已在本课程在读)/
+// 5002(DB)。
+//
+// 三条与"资金"相关的强制约束:
+//  1. **不写流水、不动余额**(见 EnrollInput 注释);
+//  2. `confirm_discount_user_id` 写**操作人**(模型红线:该列为空 = 该报课行不可充值/不可核销,
+//     §3.8-A17 的 `confirm_discount_userid > 0` 判据在目标态即"本列非空")——
+//     漏写会让这行**永远进不了欠费核销候选**,且没有任何报错;
+//  3. 重复报名(同课程 + 同学生 + status=1 已存在)**拒绝**(3140)而不是静默成功:
+//     静默成功会让前端以为建了两行,而实际只有一行(后续扣课时只扣一行,账对不上)。
+func (s *CourseService) Enroll(ctx context.Context, in EnrollInput) (*model.CoursesStudent, error) {
+	studentID := strings.TrimSpace(in.StudentID)
+	if in.CoursesID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
+	}
+	if studentID == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 报名需要 studentId"))
+	}
+	if in.Number < 0 || in.GiveNumber < 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课次不能为负"))
+	}
+	pay, err := parseCourseMoney(in.Pay, "实收", false)
+	if err != nil {
+		return nil, err
+	}
+	if pay.IsZero() && strings.TrimSpace(in.Pay) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 报名需要 pay(本次实收)"))
+	}
+	remainder, err := parseCourseMoney(in.RemainderReceivable, "剩余应收", false)
+	if err != nil {
+		return nil, err
+	}
+	lessonCost, err := parseCourseMoney(in.LessonCost, "单节课费", false)
+	if err != nil {
+		return nil, err
+	}
+	if strings.TrimSpace(in.LessonCost) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 报名需要 lessonCost(单节课费)"))
+	}
+	discountCash, err := parseCourseMoney(in.DiscountCash, "现金折扣", true)
+	if err != nil {
+		return nil, err
+	}
+	giveCost, err := parseCourseMoney(in.GiveCost, "已用赠送额", false)
+	if err != nil {
+		return nil, err
+	}
+	institutionID, scopeCampusID := s.courses.GetScopedID()
+	if institutionID <= 0 {
+		return nil, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 缺少机构作用域(institution_id)"))
+	}
+	campusID := in.CampusID
+	if campusID <= 0 {
+		campusID = scopeCampusID
+	} else if scopeCampusID > 0 && campusID != scopeCampusID {
+		// 跨校区报名:作用域校区角色只能给本校区报名(1004,不是 3001——这是**作用域越界**)
+		return nil, infraerrors.WithCause(infraerrors.ErrUnscoped,
+			errString("service: campusId 与当前作用域校区不一致"))
+	}
+	bizNo, berr := newBizNo()
+	if berr != nil {
+		return nil, berr
+	}
+	row := &model.CoursesStudent{
+		BizNo:               bizNo,
+		CoursesID:           in.CoursesID,
+		ClassID:             in.ClassID,
+		InstitutionID:       institutionID,
+		CampusID:            campusID,
+		StudentID:           studentID,
+		Type:                model.CoursesStudentTypeNormal,
+		Status:              model.CoursesStudentStatusNormal,
+		Receivable:          pay.Add(remainder).String(),
+		NetReceipts:         pay.String(),
+		Unpaid:              "0.00", // ⚠ 废弃列:**有意写 0 且永不重算**(§3.8-E4 目标态不维护)
+		LessonCost:          lessonCost.String(),
+		Number:              in.Number,
+		FinishedNumber:      0,
+		DiscountCash:        discountCash.String(),
+		GiveNumber:          in.GiveNumber,
+		GiveCost:            giveCost.String(),
+		RemainderNumber:     in.Number + in.GiveNumber,
+		RemainderCost:       pay.String(),
+		RemainderReceivable: remainder.String(),
+		BadDebt:             "0.00",
+		Remark:              normalizeOptString(&in.Remark),
+	}
+	if uid := strings.TrimSpace(in.OperatorID); uid != "" {
+		row.ConfirmDiscountUserID = &uid
+	}
+	if rid := strings.TrimSpace(in.RecommenderUserID); rid != "" {
+		row.RecommenderUserID = &rid
+		now := s.now()
+		row.RecommenderTime = &now
+	}
+	err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		if _, cerr := s.courses.GetCourseByID(txCtx, in.CoursesID); cerr != nil {
+			return cerr // 3001:课程不存在 / 不在作用域
+		}
+		existing, cerr := s.courses.CountActiveEnrollments(txCtx, in.CoursesID, studentID)
+		if cerr != nil {
+			return cerr
+		}
+		if existing > 0 {
+			return infraerrors.WithCause(apperr.ErrEnrollmentDuplicate,
+				errString("service: 该学生已在本课程在读报课,请勿重复报名"))
+		}
+		return s.courses.CreateCoursesStudent(txCtx, tx, row)
+	})
+	if err != nil {
+		return nil, err
+	}
+	return row, nil
+}
+
+// ---------------------------------------------------------------------------
+// 端点 55:欠费列表
+// ---------------------------------------------------------------------------
+
+// ArrearsRow 端点 55 的行(契约 §3.4 C 行 55)。
+//
+// 字段来源:
+//
+//	StudentID           ← t_courses_student.student_id
+//	CourseID            ← t_courses_student.courses_id
+//	RemainderReceivable ← t_courses_student.remainder_receivable(**唯一欠费判据**)
+//	Counteracted         ← `net_receipts > 0`(该报课行发生过实收/核销)
+//
+// ⚠ `counteracted` 的语义澄清:契约只写 `counteracted:bool`,未定义判据;表里**没有**
+// "已核销"标记列,可追溯到列的最近语义是"该行发生过实收/核销"= `net_receipts > 0`。
+// 另一种读法(`remainder_receivable <= 0`)在本列表里**恒 false**(列表本身就是 >0 的行),
+// 恒 false 的布尔字段等于没下发,故不采用。已登记偏差待契约确认。
+type ArrearsRow struct {
+	// StudentID 学生 ULID。
+	StudentID string
+	// CourseID 课程 ID。
+	CourseID int64
+	// RemainderReceivable 剩余应收(> 0)。
+	RemainderReceivable money.Amount
+	// Counteracted 该报课行是否发生过实收/核销(`net_receipts > 0`)。
+	Counteracted bool
+}
+
+// ArrearsListQuery 端点 55 的查询入参。
+//
+// 字段:StudentID 学生过滤(空 = 全部欠费);Page/PageSize 分页。
+type ArrearsListQuery struct {
+	// StudentID 学生 ULID(空 = 不按学生筛)。
+	StudentID string
+	// Page 页码(1 基)。
+	Page int
+	// PageSize 每页条数(上限 200)。
+	PageSize int
+}
+
+// ListArrearsRows 分页查询欠费报课行(端点 55)。
+//
+// 入参:ctx;q 查询入参。
+// 出参:([]ArrearsRow, total, error);空 → `[]`;5002。
+//
+// **筛选条件恒为 `remainder_receivable > 0`**(设计 §3.8-E4 / 契约 §2.4 端点 55 注)。
+// 绝不能用 `unpaid > 0`:该列 2018-05-17 起停止更新,真实数据几乎恒 0 ⇒ 用它筛"看起来
+// 正常、实际把所有欠费漏掉"(这正是本端点最容易写错、且**没有任何报错**的一处)。
+func (s *CourseService) ListArrearsRows(ctx context.Context, q ArrearsListQuery) ([]ArrearsRow, int64, error) {
+	rows, total, err := s.courses.ListCoursesStudents(ctx, repository.CoursesStudentFilter{
+		StudentID:   q.StudentID,
+		OnlyArrears: true,
+	}, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
+	if err != nil {
+		return nil, 0, err
+	}
+	out := make([]ArrearsRow, 0, len(rows))
+	for i := range rows {
+		row := &rows[i]
+		remainder, err := parseAmount(row.RemainderReceivable)
+		if err != nil {
+			return nil, 0, err
+		}
+		netReceipts, err := parseAmount(row.NetReceipts)
+		if err != nil {
+			return nil, 0, err
+		}
+		out = append(out, ArrearsRow{
+			StudentID:           row.StudentID,
+			CourseID:            row.CoursesID,
+			RemainderReceivable: remainder,
+			Counteracted:        netReceipts.GreaterThan(0),
+		})
+	}
+	return out, total, nil
+}
+
+// ---------------------------------------------------------------------------
+// 端点 57:学生课表
+// ---------------------------------------------------------------------------
+
+// ScheduleRow 端点 57 的行(**字段名待移动端契约定稿**——契约 §3.4 C 行 57 原文
+// 「字段名由移动端契约定(本轮 PC mock 未实现)」⇒ 本结构是 AC-13c 的**最小列溯源集合**,
+// 每个字段都能追到列,已在交付报告登记为「待移动端契约确认」)。
+//
+// 字段来源:
+//
+//	RecordID    ← t_courses_record.id
+//	CourseID    ← t_courses_record.courses_id
+//	CourseName  ← t_courses.name(批量回查)
+//	TeacherID   ← t_courses_record.teacher_id(NULL → 空串)
+//	CampusID    ← t_courses_record.campus_id
+//	StartTime   ← t_courses_record.start_time
+//	EndTime     ← t_courses_record.end_time
+//	Status      ← t_courses_record.status(0 未开始 / 1 上课中 / 2 已结束)
+//	Cost        ← t_courses_record_student.cost(无打卡行 → 0.00)
+//	GiveCost    ← t_courses_record_student.give_cost(无打卡行 → 0.00)
+//	Pay         ← t_courses_record_student.pay(无打卡行 → false)
+//	Checked     ← 是否存在该学生的打卡行(有行即 true,不看 cost)
+type ScheduleRow struct {
+	// RecordID 排班实例 ID。
+	RecordID int64
+	// CourseID 课程 ID。
+	CourseID int64
+	// CourseName 课程名称(回查 t_courses;查不到 → 空串)。
+	CourseName string
+	// TeacherID 老师 ULID(可空 → 空串)。
+	TeacherID string
+	// CampusID 校区 ID。
+	CampusID int64
+	// StartTime 上课时间。
+	StartTime *time.Time
+	// EndTime 下课时间。
+	EndTime *time.Time
+	// Status 上课状态(0/1/2)。
+	Status int32
+	// Cost 本次课费(该学生的打卡行)。
+	Cost money.Amount
+	// GiveCost 本次使用赠送额。
+	GiveCost money.Amount
+	// Pay 是否已支付。
+	Pay bool
+	// Checked 是否已有该学生的打卡行。
+	Checked bool
+}
+
+// ScheduleQuery 端点 57 的查询入参。
+//
+// 字段:StudentID 学生(必填);From/To 上课时间窗(`from <= start_time < to`,右开;nil 不限);
+// Page/PageSize 分页。
+type ScheduleQuery struct {
+	// StudentID 学生 ULID(必填)。
+	StudentID string
+	// From 窗口起点(含;nil 不限)。
+	From *time.Time
+	// To 窗口终点(**不含**;nil 不限)。
+	To *time.Time
+	// Page 页码(1 基)。
+	Page int
+	// PageSize 每页条数(上限 200)。
+	PageSize int
+}
+
+// ListScheduleRows 查询某学生的课表(端点 57)。
+//
+// 入参:ctx;q 查询入参。
+// 出参:([]ScheduleRow, total, error);2001(缺 studentId)/5002。
+func (s *CourseService) ListScheduleRows(ctx context.Context, q ScheduleQuery) ([]ScheduleRow, int64, error) {
+	sid := strings.TrimSpace(q.StudentID)
+	if sid == "" {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课表需要 studentId"))
+	}
+	records, total, err := s.courses.ListStudentSchedule(ctx, sid, q.From, q.To,
+		repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
+	if err != nil {
+		return nil, 0, err
+	}
+	out := make([]ScheduleRow, 0, len(records))
+	if len(records) == 0 {
+		return out, total, nil
+	}
+	recordIDs := make([]int64, 0, len(records))
+	courseIDs := make([]int64, 0, len(records))
+	for i := range records {
+		recordIDs = append(recordIDs, records[i].ID)
+		courseIDs = append(courseIDs, records[i].CoursesID)
+	}
+	courses, err := s.courses.ListCoursesByIDs(ctx, courseIDs)
+	if err != nil {
+		return nil, 0, err
+	}
+	names := make(map[int64]string, len(courses))
+	for i := range courses {
+		names[courses[i].ID] = courses[i].Name
+	}
+	checked, err := s.records.ListRecordStudentsOfStudent(ctx, sid, recordIDs)
+	if err != nil {
+		return nil, 0, err
+	}
+	for i := range records {
+		rec := &records[i]
+		row := ScheduleRow{
+			RecordID:   rec.ID,
+			CourseID:   rec.CoursesID,
+			CourseName: names[rec.CoursesID],
+			TeacherID:  strValue(rec.TeacherID),
+			CampusID:   rec.CampusID,
+			StartTime:  rec.StartTime,
+			EndTime:    rec.EndTime,
+			Status:     rec.Status,
+		}
+		if att, ok := checked[rec.ID]; ok {
+			cost, cerr := parseAmount(att.Cost)
+			if cerr != nil {
+				return nil, 0, cerr
+			}
+			give, cerr := parseAmount(att.GiveCost)
+			if cerr != nil {
+				return nil, 0, cerr
+			}
+			row.Checked = true
+			row.Cost = cost
+			row.GiveCost = give
+			row.Pay = att.Pay == model.CoursesRecordStudentPayPaid
+		}
+		out = append(out, row)
+	}
+	return out, total, nil
+}
+
+// ---------------------------------------------------------------------------
+// 端点 51:结课响应装配(只读,不进结账事务)
+// ---------------------------------------------------------------------------
+
+// DivideGrantRow 单条分账入账明细(端点 51 的 `divide[]`)——**team-lead 裁决后的最终形状**
+// `[{userId, type, amount}]`。
+//
+// 字段来源(**逐字段列溯源**,契约 §3.4 ③):
+//
+//	UserID ← t_user_divide_into_log.user_id(这笔上课的钱分给了谁)
+//	Type   ← t_user_divide_into_log.type(分成类型 1 一次付清 / 2 按课次)
+//	Amount ← t_user_divide_into_log.money(本次入账金额)
+//
+// ⚠ **契约原 mock 的 `level` 与 `quota` 均不下发(团队裁决,2026-11-09)**:
+//
+//	`level` —— `t_user_divide_into_log` **没有层级列**;`t_user_divide_into.layer` 是**当前**层级,
+//	          不是"入账当时"的层级,拿当前值顶替历史值 = 「事后调整树结构 ⇒ 历史记录显示错」,
+//	          属静默错,比字段缺失更难查。
+//	`quota` —— 同上:节点 quota 事后可被 `update_quota` 改,日志里没留"当时额度";
+//	          用当前值顶替即是伪造历史。
+//
+// 为什么**不是**把整个数组删掉:它承载"这笔钱分给了谁、各多少"这个核心信息;
+// `level`/`quota` 只是附加维度,缺了不影响主信息。因此改为**补 `type`(有列)**、删两个无列字段。
+// 若产品确需"当时层级/当时额度":需在日志表加列并回填 = 迁移变更,另立需求(本批不做)。
+//
+// ⚠ 反模式提醒(团队明确禁止):为了让前端 mock 好看而给 `level` 填常量 1。
+// "字段有值、永远等于 1、没人能发现它是假的"是本类缺陷里最难查的一种。
+type DivideGrantRow struct {
+	// UserID 获得分成的用户(ULID,源列 `user_id`)。
+	UserID string
+	// Type 分成类型(源列 `type`:1 一次付清 / 2 按课次)。
+	Type int32
+	// Amount 本次入账金额(源列 `money`)。
+	Amount money.Amount
+}
+
+// PayrollTrace 课酬溯源(端点 51 的 `payroll`)。
+//
+// 字段来源:
+//
+//	DetailID ← t_course_payment_detail.id(命中的课酬规则;未配置方案 → 0)
+//	Reward   ← t_courses_record.teacher_reward(本课课酬合计,**落库值**,不是重新试算值)
+type PayrollTrace struct {
+	// DetailID 命中的课酬明细规则 ID(0 = 未配置方案 / 无法定位)。
+	DetailID int64
+	// Reward 本课课酬合计(= Σ record_student.teacher_reward)。
+	Reward money.Amount
+}
+
+// RecordFinishView 端点 51 的响应(契约 §3.4 C 行 51 的字段全集,去掉无列字段)。
+//
+// 字段来源:
+//
+//	RecordID   ← t_courses_record.id
+//	CourseID   ← t_courses_record.courses_id
+//	Status     ← t_courses_record.status(打完课恒 2)
+//	Changed    ← 由 `Repeated` 取反(幂等命中 ⇒ false,本次未写任何表)
+//	TotalSpend ← Σ t_courses_record_student.cost == t_courses_record.income
+//	Divide     ← t_user_divide_into_log(按 student+order_no 回读)
+//	Payroll    ← 见 PayrollTrace
+//	PerStudent ← t_courses_record_student(本次打卡行)
+type RecordFinishView struct {
+	// RecordID 上课记录 ID。
+	RecordID int64
+	// CourseID 课程 ID。
+	CourseID int64
+	// Status 上课状态(2 = 已结束)。
+	Status int32
+	// Changed 本次是否真的写了库(false = 幂等命中,未扣课时/未分账)。
+	Changed bool
+	// TotalSpend 本次扣课时合计。
+	TotalSpend money.Amount
+	// Divide 本次触发的分账入账明细(空 → [])。
+	Divide []DivideGrantRow
+	// Payroll 课酬溯源。
+	Payroll PayrollTrace
+	// PerStudent 逐学生打卡明细。
+	PerStudent []FinishedStudent
+}
+
+// RecordViewService 结课响应装配服务(**只读**;不参与结账事务、不写任何表)。
+//
+// ⚠ **纪律(本类隐患的唯一防线)**:凡"同一次匹配产生多个输出"(金额 / 档位 / 费率 / 额度 / detailId),
+// **必须来自同一次匹配结果**,不要各算一遍。本项目已经踩过:
+//
+//	结课侧用"教师档案 → 教龄"匹配出档 B(金额 20.00),
+//	而溯源侧 `lookupPayrollDetailID` 自己又试算了一次、且没带教龄 ⇒ 返回档 A(detailId 显示 1)。
+//	两个字段都合法、都非空、类型都对,**只有对账时才会发现"金额与档位不是同一次决定的"**。
+//
+// 因此本文件里"金额"与"detailId"共用同一份入参口径(见 `lookupPayrollDetailID` 的注释),
+// 且用例 `TestFinishRecordResolvesTeacherExpFromProfile` 会同时断言 `payroll.reward` 与 `payroll.detailId`
+// 落在同一档 —— 只断言其中一个,这类缺陷仍然漏网。
+//
+// 为什么单独一个服务:`RecordService.FinishRecord` 的返回值只带"结账需要的数据"
+// (金额合计、逐学生明细),而契约还要求 `divide[]` 与 `payroll.detailId` 两个**溯源**字段。
+// 把它们塞进 FinishRecord(AC-11)会让结账事务多做两次无关查询、并在事务内做展示拼装;
+// 因此改为"事务提交后再只读装配"——即使装配失败也不影响已落库的结账结果(装配失败只报 5002)。
+type RecordViewService struct {
+	records *repository.RecordRepo
+	courses *repository.CourseRepo
+	divide  *repository.DivideRepo
+	payroll *PayrollService
+	sc      *appscope.Scope
+}
+
+// NewRecordViewService 构造结课响应装配服务。
+//
+// 入参:db(必填);sc 作用域;payroll 课酬试算服务(**可 nil**:nil 时 `payroll.detailId` 恒 0,
+// 仍下发落库课酬 `reward`,前端"课酬金额"不受影响)。
+// 出参:*RecordViewService。
+func NewRecordViewService(db *gorm.DB, sc *appscope.Scope, payroll *PayrollService) *RecordViewService {
+	return &RecordViewService{
+		records: repository.NewRecordRepo(db, sc),
+		courses: repository.NewCourseRepo(db, sc),
+		divide:  repository.NewDivideRepo(db),
+		payroll: payroll,
+		sc:      sc,
+	}
+}
+
+// FinishView 把 `FinishRecordResult` 装配成契约要求的响应(端点 51)。
+//
+// 入参:ctx;res 结账结果(非 nil)。
+// 出参:*RecordFinishView;5002(回读失败)。**任一溯源信息取不到都不阻断**:
+// `divide[]` 为空时给 `[]`、`detailId` 取不到给 0——结账已经提交,展示缺项不该变成失败。
+func (s *RecordViewService) FinishView(ctx context.Context, res *FinishRecordResult) (*RecordFinishView, error) {
+	if res == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FinishView 需要结课结果"))
+	}
+	view := &RecordFinishView{
+		RecordID:   res.RecordID,
+		Status:     res.Status,
+		Changed:    !res.Repeated,
+		TotalSpend: res.Income,
+		Divide:     make([]DivideGrantRow, 0, 2),
+		Payroll:    PayrollTrace{Reward: res.TeacherReward},
+		PerStudent: res.Students,
+	}
+	rec, err := s.records.GetRecordByID(ctx, res.RecordID)
+	if err != nil {
+		return nil, err // 3001/5002:刚结完的课读不到 = 环境异常,按原码上抛
+	}
+	view.CourseID = rec.CoursesID
+	// 分账明细:按 (学生, orderNo=recordId) 回读(与 DivideService.AddLog 的幂等键同源)
+	for i := range res.Students {
+		studentID := res.Students[i].StudentID
+		logs, lerr := s.divide.ListLogsByStudentOrder(ctx, studentID, strconv.FormatInt(res.RecordID, 10))
+		if lerr != nil {
+			return nil, lerr
+		}
+		for j := range logs {
+			amount, perr := parseAmount(logs[j].Money)
+			if perr != nil {
+				return nil, perr
+			}
+			view.Divide = append(view.Divide, DivideGrantRow{
+				UserID: logs[j].UserID,
+				Type:   int32(logs[j].Type),
+				Amount: amount,
+			})
+		}
+	}
+	view.Payroll.DetailID = s.lookupPayrollDetailID(ctx, rec, res.ActualEndTime)
+	return view, nil
+}
+
+// lookupPayrollDetailID 回读本课命中的课酬规则 ID(只为**溯源展示**,不影响落库金额)。
+//
+// 入参:ctx;rec 上课记录;at 上课时间(方案 effect_date 判定)。
+// 出参:命中的 `t_course_payment_detail.id`;未配置方案 / 无老师 / 无分类 / 任何错误 → **0**。
+//
+// 三条"取不到就返回 0、不冒泡错误"的理由:
+//  1. 结账已提交,展示缺项不该让整个请求失败(`payroll.reward` 仍有值);
+//  2. 3112(无方案)在结账侧已被显式降级为 0 课酬,溯源侧同理;
+//  3. 教龄与结课侧同源(教师档案 → `calculatingAge` 口径),保证"金额档位"与"detailId"永远一致。
+func (s *RecordViewService) lookupPayrollDetailID(ctx context.Context, rec *model.CoursesRecord, at time.Time) int64 {
+	if s.payroll == nil || rec == nil {
+		return 0
+	}
+	teacherID := strValue(rec.TeacherID)
+	if teacherID == "" {
+		return 0
+	}
+	course, err := s.courses.GetCourseByID(ctx, rec.CoursesID)
+	if err != nil || course.ClassifyID <= 0 {
+		return 0
+	}
+	// ⚠ 教龄必须与结课侧**同一来源、同一口径**(否则"金额取了档 B、detailId 显示档 A"):
+	quote, qerr := s.payroll.QuoteTeacherReward(ctx, QuotePayrollInput{
+		TeacherID:     teacherID,
+		TeacherExp:    s.payroll.TeacherExpYears(ctx, teacherID, at),
+		InstitutionID: rec.InstitutionID,
+		CampusID:      rec.CampusID,
+		ClassifyID:    course.ClassifyID,
+		Date:          at,
+	})
+	if qerr != nil || quote == nil {
+		return 0
+	}
+	return quote.DetailID
+}
+
+// recordRewardQuoter 把 `PayrollService` 适配成 `RecordService` 的课酬端口(AC-11 `TeacherRewardQuoter`)。
+//
+// ---------------------------------------------------------------------------
+// 权威口径:**PHP 应用层**,不是 dump(`min_payment` 在存储过程里零命中)
+// ---------------------------------------------------------------------------
+//
+// 逐行照抄 `E://study//php//ysw-finance//yuansiwei-finance//modules//courses//helpers//CoursesRecordHelper.php:781-821`
+// (方法 `settlementExpenses`,正常结课与补签共用):
+//
+//	$percentage = $coursePaymentDetail->percentage/100;      // 百分数 → 比率("85.0" → 0.85)
+//	$addedCost  = $coursePaymentDetail->reward;              // 额外课酬
+//	$income     = round(sum(cost), 3);                       // 本课全部学生 cost 合计
+//	$teacherReward = 0;
+//	if ($income>0 || $attendClassStudentNumber) {            // 守卫:有学生即使收入 0 也算
+//	    $teacherReward = round($income*$percentage,3) + $addedCost;   // ① 先算提成、② 再加 reward
+//	    if ($maxPayment>0 && $teacherReward>$maxPayment)      $teacherReward = $maxPayment;   // ③ 先夹上限
+//	    elseif ($minPayment>0 && $teacherReward<$minPayment)  $teacherReward = $minPayment;   // ④ 再夹下限
+//	}
+//	if ($teacherRewardType==2) {                             // 固定课酬:**不看比例**
+//	    $teacherReward = self::$makeupCost[$stuCount] ?? self::$makeupCost['max'];
+//	}
+//	if ($attendClassStudentNumber<=0) $teacherReward = 0;    // 无人上课 → 课酬 0
+//
+// ⚠ **顺序是「加 reward → 再夹 [min,max]」,不是「夹完再加 reward」**:
+// 当"提成 + reward"超过 `max_payment` 时,结果是 **max(reward 被吃掉)**;
+// 反过来写会得到 `max + reward` = **多发钱**。这条顺序差有真实金额影响,改动前先回 PHP 原文。
+//
+// 另外三条照抄语义:
+//   - `max`/`min` 为 **0 = 不设限**(不是"上限为 0");
+//   - **`elseif` = 上限优先**:两者同时触发时取 `max`;
+//   - `percentage` 是**百分数**:`"85.0"` 表示 85%,不是 0.85,也不是 8500。
+//
+// 实现红线:
+//   - **全程整数分运算**,比例用"千分之一"整数承载(`"85.0"` → `850`),`+500 后整除 1000` = 四舍五入到分;
+//   - `percentage` 列写坏(非数字/负数/小数位过多)→ **ErrDBError 整体回滚**,不静默按 0 算
+//     ("课酬算成 0 却结课成功"是少发钱,比报错难查得多)。
+//
+// 教龄(v0.1.35 已接上):入参 `TeacherExp` 为 0 时,本适配器**回落到教师档案**
+// (`t_teacher_information.teacher_exp` 起算日 → 年数),换算按 PHP `calculatingAge()` 口径:
+// **固定 365 天/年**、**四舍五入 1 位小数**、空/非法/未来 → 0(见 `payroll_age.go`)。
+// 两者都没有(老师未录教龄)⇒ 0 ⇒ 只命中 `teacher_exp_min = 0` 的档——**这是旧系统既有行为**
+// (PHP 对空日期同样返回 0),不是缺陷;但会造成"少发钱",监控上看 warn 日志与课酬 0 的比例。
+type recordRewardQuoter struct {
+	svc *PayrollService
+}
+
+// QuoteRecordReward 实现 `service.TeacherRewardQuoter`。
+//
+// 入参:ctx;in 端口入参(含 `Income` 比例基数、`TeacherExp` 教龄)。
+// 出参:本课课酬(元);3112 原样上抛(由 RecordService 降级为 0 课酬);列写坏 → 5002 整体回滚。
+func (q recordRewardQuoter) QuoteRecordReward(ctx context.Context, in RewardQuoteInput) (money.Amount, error) {
+	if q.svc == nil {
+		return 0, nil
+	}
+	teacherExp := in.TeacherExp
+	if teacherExp == 0 && strings.TrimSpace(in.TeacherID) != "" {
+		// 结课链路拿不到教龄(AC-11 端口无此入参的来源)⇒ 回落到教师档案,口径见 `payroll_age.go`。
+		// 档案缺失/未录 → 0(旧系统同)。降级不报错:教龄只影响档位,不该把整节课打回。
+		teacherExp = q.svc.TeacherExpYears(ctx, in.TeacherID, in.RecordDate)
+	}
+	quote, err := q.svc.QuoteTeacherReward(ctx, QuotePayrollInput{
+		TeacherID:     in.TeacherID,
+		TeacherExp:    teacherExp,
+		InstitutionID: in.InstitutionID,
+		CampusID:      in.CampusID,
+		ClassifyID:    in.ClassifyID,
+		Date:          in.RecordDate,
+	})
+	if err != nil {
+		return 0, err
+	}
+	if quote == nil {
+		return 0, nil
+	}
+	return computeRecordReward(quote, in.Income, in.StudentCount, in.RewardType)
+}
+
+// computeRecordReward 由"命中的课酬规则 + 本次课收入/人数/结算方式"算出单节课课酬。
+//
+// 入参:quote 命中规则(Percentage/Min/Max/Reward);income 本次课收入;
+// studentCount 上课人数;rewardType 结算方式(1 正常 / 2 固定课酬)。
+// 出参:单节课课酬(分);2002(收入为负 = 调用方缺陷)/5002(percentage 列写坏)。
+//
+// 顺序**逐行对齐 PHP**(`CoursesRecordHelper.php:794-821`),见 `recordRewardQuoter` 注释:
+//
+//	守卫 → income×pct + reward → 夹上限 → 否则夹下限 → (type=2 覆盖为固定课酬)→ 无人则 0
+func computeRecordReward(quote *PayrollQuote, income money.Amount, studentCount int32, rewardType int8) (money.Amount, error) {
+	if quote == nil {
+		return 0, nil
+	}
+	if income.IsNegative() {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: 课酬试算的收入不能为负"))
+	}
+	total := money.Amount(0)
+	// 守卫:`if ($income>0 || $attendClassStudentNumber)` —— 收入 0 但有学生仍要算(0 + reward 再夹)
+	if income.GreaterThan(0) || studentCount > 0 {
+		base, perr := applyPercentage(quote.Percentage, income)
+		if perr != nil {
+			return 0, perr
+		}
+		// ① 先加 reward,② 再夹 —— 顺序不可交换(见类型注释)
+		total = base.Add(quote.Reward)
+		// ③ 上限优先(PHP 是 elseif:上限命中就不看下限,两者同时触发取 max)
+		if quote.Max.GreaterThan(0) && total.GreaterThan(quote.Max) {
+			total = quote.Max
+		} else if quote.Min.GreaterThan(0) && total.LessThan(quote.Min) {
+			total = quote.Min
+		}
+	}
+	// 固定课酬(`teacher_reward_type == 2`):**不看比例**,按人数查 PHP 硬编码表
+	if rewardType == model.CoursesRecordRewardTypeNoMinimum && studentCount > 0 {
+		total = makeupCostFor(studentCount)
+	}
+	// 无人上课 ⇒ 不产生课酬(PHP `:821`;即使上面算出 reward 也要归零)
+	if studentCount <= 0 {
+		total = 0
+	}
+	if total.IsNegative() {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+			errString("service: 课酬规则算出负值(percentage/min/max/reward 数据异常)"))
+	}
+	return total, nil
+}
+
+// makeupCostTable 固定课酬表(`teacher_reward_type == 2`):人数 → 单节课课酬(元)。
+//
+// **逐字复刻 PHP 魔法常量**(`CoursesRecordHelper.php:33`,注释原文"单独给学生补课上课的课酬,人数=>课酬"):
+//
+//	static public $makeupCost = [1=>70,2=>100,3=>120,4=>150,5=>170,6=>200,'max'=>200];
+//
+// ⚠ **这张表在数据库里没有**(没有字典表、没有配置项)⇒ 只能按常量复刻;
+// 改表必须同时改 PHP 与 Go 两处,否则结课与双跑会分叉。D7 双跑也只能从 PHP 行为反推。
+var makeupCostTable = map[int32]money.Amount{
+	1: money.FromCents(7000),
+	2: money.FromCents(10000),
+	3: money.FromCents(12000),
+	4: money.FromCents(15000),
+	5: money.FromCents(17000),
+	6: money.FromCents(20000),
+}
+
+// makeupCostMax 超出表区间时的兜底值(PHP `'max' => 200`)。
+const makeupCostMaxCents = int64(20000)
+
+// makeupCostFor 按人数取固定课酬(人数 > 6 → 200.00,与 PHP `?? $makeupCost['max']` 一致)。
+//
+// 入参:studentCount(>0,由调用方保证)。出参:单节课课酬。
+func makeupCostFor(studentCount int32) money.Amount {
+	if v, ok := makeupCostTable[studentCount]; ok {
+		return v
+	}
+	return money.FromCents(makeupCostMaxCents)
+}
+
+// percentageDenom 比例的分母(`percentage` 是百分数:`"85.0"` = 85% ⇒ 分母 100;
+// 再乘 10 是为了容纳 1 位小数 ⇒ 统一用"千分之一"整数运算,见 `applyPercentage`)。
+const percentageDenom = 1000
+
+// applyPercentage 计算"收入 × 比例"的金额(整数分运算,四舍五入到分)。
+//
+// 入参:pct 百分比字符串(`decimal(5,1)`,如 `"85.0"`;空串按 0);income 本次课收入(分)。
+// 出参:金额(分);`ErrDBError`(列写坏:非数字 / 多于 2 位小数 / 负数)。
+//
+// 算法:`pctMilli = 百分比 × 10`(`"85.0"` → 850),`base = round(income × pctMilli / 1000)`。
+// 为什么不用 `money.Parse` 再乘 float:比例列是 `decimal(5,1)`,用 float 会在 `×0.85` 这类
+// 运算里带进二进制尾差(如 0.1+0.2 级),资金口径不允许。
+func applyPercentage(pct string, income money.Amount) (money.Amount, error) {
+	text := strings.TrimSpace(pct)
+	if text == "" {
+		return 0, nil // 未配置比例:只算 min/max/reward 部分(dump 的 0 值语义)
+	}
+	if strings.HasPrefix(text, "-") {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+			errString("service: 课酬比例不能为负 "+text))
+	}
+	intPart, fracPart, _ := strings.Cut(text, ".")
+	if intPart == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+			errString("service: 课酬比例格式非法 "+text))
+	}
+	whole, perr := parseUintDigits(intPart)
+	if perr != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+			errString("service: 课酬比例格式非法 "+text))
+	}
+	// 小数位:只取 1 位(列是 decimal(5,1));第 2 位 >=5 时进位
+	tenth := int64(0)
+	if fracPart != "" {
+		if len(fracPart) > 2 {
+			return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+				errString("service: 课酬比例小数位过多 "+text))
+		}
+		first, ferr := parseUintDigits(fracPart[:1])
+		if ferr != nil {
+			return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+				errString("service: 课酬比例格式非法 "+text))
+		}
+		tenth = first
+		if len(fracPart) == 2 {
+			second, serr := parseUintDigits(fracPart[1:])
+			if serr != nil {
+				return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+					errString("service: 课酬比例格式非法 "+text))
+			}
+			if second >= 5 {
+				tenth++
+			}
+		}
+	}
+	pctMilli := whole*10 + tenth
+	if pctMilli == 0 {
+		return 0, nil
+	}
+	cents := income.Cents()
+	// 溢出兜底:decimal(12,2) 上限 ≈ 1e10 分,pctMilli ≤ 1000 ⇒ 乘积 ≤ 1e13,int64 安全
+	base := (cents*pctMilli + percentageDenom/2) / percentageDenom
+	return money.FromCents(base), nil
+}
+
+// parseUintDigits 把纯数字串转 int64(比例解析用;非数字 → error)。
+func parseUintDigits(digits string) (int64, error) {
+	if digits == "" {
+		return 0, errString("empty")
+	}
+	var out int64
+	for _, ch := range digits {
+		if ch < '0' || ch > '9' {
+			return 0, errString("not a digit")
+		}
+		out = out*10 + int64(ch-'0')
+		if out > 1_000_000 { // 比例列 decimal(5,1) 不会超过这个量级
+			return 0, errString("too large")
+		}
+	}
+	return out, nil
+}
+
+// ---------------------------------------------------------------------------
+// 端点 48:课表实例列表
+// ---------------------------------------------------------------------------
+
+// RecordListQuery 端点 48 的查询入参(把 AC-11 的 `repository.RecordFilter` 包一层,
+// 使 Handler 不必 import repository —— 本包依赖方向:handler → service → repository)。
+//
+// 字段:CoursesID 课程(<=0 不筛);TeacherID 老师(空不筛);Status 状态(nil 不筛);
+// From/To 上课时间窗(`start_time` 闭区间,与 AC-11 `RecordFilter` 语义一致);Page/PageSize 分页。
+type RecordListQuery struct {
+	// CoursesID 课程 ID(<=0 不筛)。
+	CoursesID int64
+	// TeacherID 老师 ULID(空不筛)。
+	TeacherID string
+	// Status 上课状态(nil 不筛;0 未开始 / 1 上课中 / 2 已结束)。
+	Status *int32
+	// From 上课时间下限(含;nil 不限)。
+	From *time.Time
+	// To 上课时间上限(含;nil 不限)。
+	To *time.Time
+	// Page 页码(1 基)。
+	Page int
+	// PageSize 每页条数(上限 200)。
+	PageSize int
+}
+
+// ListRecordsByQuery 分页查询排班实例(端点 48)。
+//
+// 入参:ctx;q 查询入参。
+// 出参:([]model.CoursesRecord, total, error);5002。
+//
+// 时间窗口径**沿用 AC-11**(`start_time` 闭区间,两端都含):课表是"看某段时间有哪些课",
+// 与契约 §1.3 的 `from` 闭 / `to` 开**不同**——这里是有意的差异(契约 §1.3 约束的是
+// "区间筛选类查询"的通用规则,课表按"日期区间"展示,前端传 `to=2026-11-30` 期望含 30 号当天)。
+// 该差异已在交付报告登记。
+func (s *RecordService) ListRecordsByQuery(ctx context.Context, q RecordListQuery) ([]model.CoursesRecord, int64, error) {
+	return s.List(ctx, repository.RecordFilter{
+		CoursesID: q.CoursesID,
+		TeacherID: q.TeacherID,
+		Status:    q.Status,
+		From:      q.From,
+		To:        q.To,
+	}, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
+}
+
+// NewRecordRewardQuoter 构造课酬端口适配器(装配层用)。
+//
+// 入参:svc 课酬试算服务(可 nil → 适配器返回 0 课酬)。
+// 出参:TeacherRewardQuoter 实现。
+func NewRecordRewardQuoter(svc *PayrollService) TeacherRewardQuoter {
+	return recordRewardQuoter{svc: svc}
+}
+
+// NewDivideServiceWithRecordRepo 构造分账服务并注入"首次扣费记录"端口(AC-14 装配用)。
+//
+// 入参:db;sc 请求作用域(仅用于构造 RecordRepo;分账域本身不做租户裁剪)。
+// 出参:*DivideService(`firstCharged` 已注入 = `repository.RecordRepo`,结构化满足端口)。
+//
+// 为什么放在 service 包而不是装配层 new 两个对象再拼:`repository.RecordRepo` 必须按
+// **请求作用域**构造(它继承 BaseRepo),把这个约束关在本包内,装配层就不必 import repository
+// (handler 层至今对 repository 零 import,这条依赖方向要保持)。
+func NewDivideServiceWithRecordRepo(db *gorm.DB, sc *appscope.Scope) *DivideService {
+	return NewDivideService(db, repository.NewRecordRepo(db, sc))
+}

+ 196 - 0
internal/academic/service/decimal_format.go

@@ -0,0 +1,196 @@
+// 十进制「定点格式化」纯函数(**不碰数据库、不碰时钟**)——AC-13d 追加。
+//
+// ---------------------------------------------------------------------------
+// 为什么必须有这一层(不是"多此一举的漂亮化")
+// ---------------------------------------------------------------------------
+//
+// 库里三类"小数字符串"列(`decimal(5,1)` 比例 / `decimal(10,3)` 教龄 / `decimal(12,2)` 金额)
+// 都以**字符串**进出 Go,于是同一个值在不同环境里长成不同样子:
+//
+//	MySQL 8        读 decimal(5,1) 的 85   → "85.0"
+//	SQLite(单测) 读 numeric 亲和列       → "85"     ← 尾部 0 被吃掉
+//	前端刚才写入    "85.00"(多打一位)    → "85.00"
+//
+// 三种形态**数值相等、字节不等**。后果不是"不好看",而是三条实打实的故障:
+//
+//  1. **前端 diff 误判**:编辑弹窗把接口回的值原样回填,第二次提交时"85" ≠ "85.0",
+//     被认为用户改过 ⇒ 生成一条脏变更(配置类页面最常见)。
+//  2. **单测在两个方言之间摇摆**:断言 `"85.0"` 在 SQLite 上必红、断言 `"85"` 在 MySQL 上必红 ⇒
+//     要么写 `mustDecimal` 把断言弱化成"数值对就行"(那就丢了"量纲/位数"这条防线),
+//     要么测试骨架随数据库变。**格式化到列声明的位数,两端都稳。**
+//  3. **金额与比例的"尺寸"混淆无人察觉**:`percentage` 如果被按金额格式化(1 位 vs 2 位),
+//     `"85.0" → "85.00"` 恰好数值不变 ⇒ 量纲被污染的 bug 静默通过。**位数本身就是量纲指纹。**
+//
+// 因此:**出口统一按"列声明的 scale"定点化**,入口只做区间校验。本文件是出口的唯一实现点。
+//
+// ---------------------------------------------------------------------------
+// 实现约束
+// ---------------------------------------------------------------------------
+//
+//   - **零 float64**:百分比/教龄虽不是资金,但"用浮点格式化再拼字符串"在 0.1 这种十进制
+//     不可表示值上会产生 `84.99999999999999` 之类的残渣;本文件全程字符串 + int64。
+//   - **纯函数**:无 IO、无全局状态、无时区依赖 ⇒ 可以脱离数据库做单测(`decimal_format_test.go`)。
+//   - **不静默吞错**:非法文本返回错误,由调用方映射(Handler 侧统一 5002「数据不一致」,
+//     因为走到格式化时数据已经出库,格式坏 = 库里写坏了,不是用户参数问题,**不能回 2002**)。
+package service
+
+import "strings"
+
+// 各类列的 scale(小数位数)——**与 DDL 声明一致,不要在这里"顺手统一"**。
+const (
+	// ScalePercentage `t_course_payment_detail.percentage` 是 `decimal(5,1)` ⇒ 1 位。
+	ScalePercentage = 1
+	// ScaleYears `t_course_payment_detail.teacher_exp_min/max` 是 `decimal(10,3)` ⇒ 3 位。
+	ScaleYears = 3
+	// ScaleYuan 金额列统一 `decimal(12,2)` ⇒ 2 位。
+	ScaleYuan = 2
+)
+
+// FormatPercentage 把百分数字符串定点化到 1 位小数("85" → "85.0","85.00" → "85.0")。
+//
+// 入参:text 库里的百分数("85" / "85.0" / "85.00")。
+// 出参:(定点化字符串, error);空串/非数字/超过 int64 量级 → ErrInvalidFormat。
+//
+// ⚠ **只格式化,不改量纲**:`"85"` 是 85%,不是 0.85。需要比率时由 `applyPercentage` 除 1000,
+// 不要在这里动手脚(否则"格式化了"会被误认为"归一化了")。
+func FormatPercentage(text string) (string, error) {
+	return FormatFixed(text, ScalePercentage)
+}
+
+// FormatYears 把教龄字符串定点化到 3 位小数("1" → "1.000","5" → "5.000")。
+//
+// 入参:text 库里的教龄区间端点("0" / "1.000" / "5")。
+// 出参:(定点化字符串, error);非法 → ErrInvalidFormat。
+//
+// 为什么教龄也要固定位数:它是**区间比较的展示值**(前端拿它判断"我这一档是不是到了"),
+// `0` 与 `0.000` 在 JS 里一个是 number 一个是 string,跨端比较会出现 `"0" > "0.000"` 之类的字符串比较坑。
+func FormatYears(text string) (string, error) {
+	return FormatFixed(text, ScaleYears)
+}
+
+// FormatYuan 把金额字符串定点化到 2 位小数("100" → "100.00","12.3" → "12.30")。
+//
+// 入参:text 库里的金额("0" / "100" / "300.00";空串按 0 处理,与 `parseAmount` 一致)。
+// 出参:(定点化字符串, error);非法 → ErrDBError(**不是 2002**:出库数据坏 = 库写坏了)。
+//
+// 内部走 `parseAmount(...).String()`:解析与输出都在定点整数域(`money` 包已禁 float64),
+// 这里不重复实现一遍舍入,**只有一处舍入规则**(第 3 位 ≥5 进位)。
+func FormatYuan(text string) (string, error) {
+	amt, err := parseAmount(text)
+	if err != nil {
+		return "", err
+	}
+	return amt.String(), nil
+}
+
+// FormatFixed 把十进制字符串四舍五入到固定小数位数(本包定点化的唯一实现)。
+//
+// 入参:
+//   - text  十进制字符串,允许前后空格与正负号("85" / "-0.01" / "1.23456");
+//   - scale 目标小数位(0~3,见 Scale* 常量)。
+//
+// 出参:(定点化字符串, error);以下情形返回 ErrInvalidFormat:
+// 空串、只有符号、整数位非数字(含 ".5")、小数位非数字(含 "12.")、整数位超过 12 位(量级异常)。
+//
+// 舍入:第 scale+1 位 ≥5 时进位(远离零方向),与 `money.NewFromYuanString` 同规则。
+// 负零归一:`"-0.0"` → `"0.0"`(不产生 `-0.0`,否则前端 `Number("-0.0")` 得到 -0,diff 又会误判)。
+func FormatFixed(text string, scale int) (string, error) {
+	s := strings.TrimSpace(text)
+	if s == "" {
+		return "", errString("decimal: 空字符串无法定点化")
+	}
+	neg := false
+	switch s[0] {
+	case '-':
+		neg = true
+		s = s[1:]
+	case '+':
+		s = s[1:]
+	}
+	if s == "" {
+		return "", errString("decimal: 只有符号")
+	}
+	intPart, fracPart, hasDot := strings.Cut(s, ".")
+	if intPart == "" || !allDigits(intPart) {
+		return "", errString("decimal: 整数位非法 " + text)
+	}
+	if hasDot && (fracPart == "" || !allDigits(fracPart)) {
+		return "", errString("decimal: 小数位非法 " + text) // "12." 与 "12.a" 都拒(库里的值不该长这样)
+	}
+	// 量级护栏:整数位超过 12 位即超出本项目任何 decimal 列(decimal(12,2) 上限 10 位整数),
+	// 继续算 int64 会静默溢出 ⇒ 明确拒绝(宁可 5002,也不要吐出错误金额)。
+	if len(intPart) > 12 {
+		return "", errString("decimal: 整数位过长(超过 12 位)" + text)
+	}
+	// 保留 scale 位,看下一位决定进位
+	kept := fracPart
+	roundUp := false
+	if len(kept) > scale {
+		roundUp = kept[scale] >= '5'
+		kept = kept[:scale]
+	}
+	for len(kept) < scale {
+		kept += "0"
+	}
+
+	var units int64 // 放大 10^scale 后的整数
+	for _, ch := range intPart {
+		units = units*10 + int64(ch-'0')
+	}
+	for i := 0; i < scale; i++ {
+		units *= 10
+	}
+	var frac int64
+	for i := 0; i < len(kept); i++ {
+		frac = frac*10 + int64(kept[i]-'0')
+	}
+	if roundUp {
+		frac++ // 99…9 + 1 由 int64 相加自然向整数位进位
+	}
+	units += frac
+	return renderFixed(units, scale, neg), nil
+}
+
+// renderFixed 把"放大 10^scale 后的整数"渲染成定点字符串(FormatFixed 的输出侧)。
+//
+// 入参:units 放大值(非负);scale 小数位;neg 是否为负。
+// 出参:定点字符串(`units=850, scale=1` → "85.0";`units=10000, scale=2` → "100.00")。
+func renderFixed(units int64, scale int, neg bool) string {
+	if scale <= 0 {
+		text := formatInt64(units)
+		if neg && units != 0 {
+			return "-" + text
+		}
+		return text
+	}
+	div := int64(1)
+	for i := 0; i < scale; i++ {
+		div *= 10
+	}
+	whole := units / div
+	frac := units % div
+	fracText := formatInt64(frac)
+	for len(fracText) < scale {
+		fracText = "0" + fracText
+	}
+	text := formatInt64(whole) + "." + fracText
+	if neg && units != 0 {
+		return "-" + text
+	}
+	return text
+}
+
+// formatInt64 把非负 int64 转十进制(避免为本文件拉 strconv 进热路径的语感,也便于统一)。
+func formatInt64(v int64) string {
+	if v == 0 {
+		return "0"
+	}
+	var buf [20]byte
+	i := len(buf)
+	for v > 0 {
+		i--
+		buf[i] = byte('0' + v%10)
+		v /= 10
+	}
+	return string(buf[i:])
+}

+ 171 - 0
internal/academic/service/decimal_format_test.go

@@ -0,0 +1,171 @@
+// 定点格式化纯函数单测(**不连库、不起 Fiber、不依赖时区**)。
+//
+// ---------------------------------------------------------------------------
+// 为什么这组用例必须存在(而不是"顺手写的字符串断言")
+// ---------------------------------------------------------------------------
+//
+// 之前的断言都活在 HTTP + SQLite 用例里,于是只能写 `mustDecimal`(比数值不比字节)——
+// 因为 SQLite 的 numeric 亲和性会把 "85.0" 存回 "85"。**这恰好把本层的价值测掉了**:
+// 字节层面的"位数"正是量纲指纹(百分比 1 位 / 金额 2 位 / 教龄 3 位),
+// 数值断言全绿也照样让「比例被按金额格式化」这个 bug 混过去。
+//
+// 所以这里**脱离数据库直接调纯函数**,断言字节:
+//
+//	输入 "85"   → 百分比出口 "85.0"    (不是 "85"、不是 "85.00")
+//	输入 "100"  → 金额出口   "100.00"  (不是 "100"、不是 "100.0")
+//
+// 三条变异(把 FormatPercentage 改成 ScaleYuan / 去掉补零 / 去掉进位)都会让本文件变红,
+// 而在 DB 用例里前两条**不会**红。这就是这组用例存在的唯一理由。
+
+package service
+
+import (
+	"strings"
+	"testing"
+)
+
+// TestFormatPercentageBytes 断言百分数出口恒为 1 位小数(字节级)。
+//
+// 关键用例:`"85"` → `"85.0"`。这条来自团队裁决原文("输入 "85" → 输出 "85.0""),
+// 也是 SQLite 环境下最容易漏掉的一条——库里读出来就是 "85",不补零就原样漏给前端。
+func TestFormatPercentageBytes(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{"整数补零(SQLite 读出的形态)", "85", "85.0"},
+		{"已是一位小数", "85.0", "85.0"},
+		// ⚠ 这条专门钉"补零位置":一位小数必须补在**右侧**("1.5" → "1.5")。
+		// 若实现把补零补在左侧(frac 从 5 变成 "05"),会吐出 "1.05" —— 数值变了 10 倍,
+		// 而 "85"/"100" 这类整数输入**测不出来**(它们的 frac 本来就是 0)。
+		{"一位小数不能被左补零", "1.5", "1.5"},
+		{"两位小数中的有效位", "65.5", "65.5"},
+		{"两位小数截断(不能四舍五入成 85.1)", "85.04", "85.0"},
+		{"第三位 ≥5 进位", "85.05", "85.1"},
+		{"100 的百分数(不是金额 100.00)", "100", "100.0"},
+		{"零", "0", "0.0"},
+		{"带空格", "  85  ", "85.0"},
+		{"负零归一(不产出 -0.0)", "-0.0", "0.0"},
+	}
+	for _, tc := range cases {
+		got, err := FormatPercentage(tc.in)
+		if err != nil {
+			t.Fatalf("%s:FormatPercentage(%q) 意外报错 %v", tc.name, tc.in, err)
+		}
+		if got != tc.want {
+			t.Fatalf("%s:FormatPercentage(%q) = %q,期望 %q(百分比是 decimal(5,1),"+
+				"位数就是量纲指纹:\"85.00\" 说明被当成金额格式化了)", tc.name, tc.in, got, tc.want)
+		}
+	}
+}
+
+// TestFormatYuanBytes 断言金额出口恒为 2 位小数(字节级)。
+//
+// 关键用例:`"100"` → `"100.00"`(团队裁决原文),以及 `"12.3"` → `"12.30"`(一位补成两位)。
+func TestFormatYuanBytes(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{"整数补两位(MySQL/SQLite 都可能读出这个形态)", "100", "100.00"},
+		{"一位小数补零", "12.3", "12.30"},
+		{"已是两位", "300.00", "300.00"},
+		{"第三位 ≥5 进位(与 money 同规则)", "0.005", "0.01"},
+		{"第三位 <5 舍去", "0.004", "0.00"},
+		{"空串按 0(与 parseAmount 一致)", "", "0.00"},
+		{"负数保留符号", "-1.5", "-1.50"},
+		{"负零归一", "-0.00", "0.00"},
+	}
+	for _, tc := range cases {
+		got, err := FormatYuan(tc.in)
+		if err != nil {
+			t.Fatalf("%s:FormatYuan(%q) 意外报错 %v", tc.name, tc.in, err)
+		}
+		if got != tc.want {
+			t.Fatalf("%s:FormatYuan(%q) = %q,期望 %q", tc.name, tc.in, got, tc.want)
+		}
+	}
+}
+
+// TestFormatYearsBytes 断言教龄区间端点恒为 3 位小数(`decimal(10,3)`)。
+func TestFormatYearsBytes(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{"整数补三位", "1", "1.000"},
+		{"零", "0", "0.000"},
+		{"一位补三位", "2.5", "2.500"},
+		{"已三位", "5.000", "5.000"},
+		{"第四位进位", "1.2345", "1.235"},
+	}
+	for _, tc := range cases {
+		got, err := FormatYears(tc.in)
+		if err != nil {
+			t.Fatalf("%s:FormatYears(%q) 意外报错 %v", tc.name, tc.in, err)
+		}
+		if got != tc.want {
+			t.Fatalf("%s:FormatYears(%q) = %q,期望 %q", tc.name, tc.in, got, tc.want)
+		}
+	}
+}
+
+// TestFormatFixedRejectsBadInput 非法输入必须报错(**不静默返回 0**)。
+//
+// 为什么这条重要:格式化发生在**出库之后**,喂进来的都是库里的值。若非法值被静默当成 0,
+// 前端会看到一条"比例 0.0% / 教龄 0 年"的合法规则 ⇒ 课酬被算成 0 却查不出原因。
+// 报错(→ 5002 数据不一致)才能让人去查那条坏数据。
+func TestFormatFixedRejectsBadInput(t *testing.T) {
+	bad := []string{"", "   ", "-", "+", "abc", ".5", "12.", "1.2.3", "1a", "1 ", "0x10", "1e5"}
+	for _, in := range bad {
+		if in == "1 " { // 两侧空格是允许的(TrimSpace 后合法)
+			// "1 " 去掉尾部空格后是 "1",属于合法输入,跳过
+			if got, err := FormatPercentage(in); err != nil || got != "1.0" {
+				t.Fatalf("FormatPercentage(%q) = (%q,%v),期望 (\"1.0\",nil):两侧空白应被容忍", in, got, err)
+			}
+			continue
+		}
+		if got, err := FormatPercentage(in); err == nil {
+			t.Fatalf("FormatPercentage(%q) = %q 且无错,期望报错(非法值静默当 0 会让课酬静默为 0)", in, got)
+		}
+	}
+	// 整数位过长(超出任何 decimal 列的量级)必须拒绝,不能静默溢出
+	long := strings.Repeat("9", 13)
+	if got, err := FormatPercentage(long); err == nil {
+		t.Fatalf("FormatPercentage(%q) = %q 且无错,期望报错(整数位 >12 位视为数据坏)", long, got)
+	}
+}
+
+// TestFormatFixedNoFloat64Residue 定点化不得引入浮点残渣("用 float 拼字符串"的典型翻车形态)。
+//
+// 0.1 / 0.3 在二进制浮点里不可精确表示,若实现走 `fmt.Sprintf("%.1f", x)` 之类路径,
+// 会在某些值上吐出 84.99999999999999 / 0.30000000000000004 这类残渣。
+// 本用例断言"每个结果都只含数字、小数点与一个可选负号",且小数位数恰好等于 scale。
+func TestFormatFixedNoFloat64Residue(t *testing.T) {
+	inputs := []string{"85", "84.999999", "0.1", "0.3", "65.5", "99.95", "33.333333"}
+	for _, in := range inputs {
+		got, err := FormatPercentage(in)
+		if err != nil {
+			t.Fatalf("FormatPercentage(%q) 意外报错 %v", in, err)
+		}
+		dot := strings.IndexByte(got, '.')
+		if dot < 0 {
+			t.Fatalf("FormatPercentage(%q) = %q,缺少小数点(1 位小数必须显式补 0)", in, got)
+		}
+		if len(got)-dot-1 != ScalePercentage {
+			t.Fatalf("FormatPercentage(%q) = %q,小数位 %d 位,期望 %d 位",
+				in, got, len(got)-dot-1, ScalePercentage)
+		}
+		body := strings.TrimPrefix(got, "-")
+		for i := 0; i < len(body); i++ {
+			ch := body[i]
+			if ch == '.' || (ch >= '0' && ch <= '9') {
+				continue
+			}
+			t.Fatalf("FormatPercentage(%q) = %q,出现非数字字符 %q(浮点残渣/科学计数法)", in, got, ch)
+		}
+	}
+}

+ 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
+}

+ 106 - 0
internal/academic/service/payroll_age.go

@@ -0,0 +1,106 @@
+// 教龄换算 + 教师档案读取(课酬试算的 `TeacherExp` 入参来源)——AC-13d 追加。
+//
+// ---------------------------------------------------------------------------
+// 权威口径:PHP `components/Utils.php:924-935` 的 `calculatingAge()`
+// ---------------------------------------------------------------------------
+//
+// 逐行照抄:
+//
+//	if (empty($ageDate)) return 0;
+//	$strtotime = strtotime($ageDate);
+//	if (!$strtotime) return 0;
+//	if ($strtotime < 0) return 0;
+//	$error = time() - $strtotime;
+//	$age = $error / (60*60*24) / 365;     // ← 固定 365 天/年,**不处理闰年**
+//	$age = round($age, 1);                 // ← 四舍五入到 **1 位小数**
+//	return $age;
+//
+// 三条必须照抄的点(任一写错都会**稳定地跨错档且不报错**):
+//
+//  1. **除数固定 365**(不是 365.25、不按日历年份相减)。`time.Year()-exp.Year()` 与
+//     "秒差/365" 在跨年边界会差出整整 1 岁(例:2025-12-31 起算、2026-11-09 试算 ⇒
+//     日历相减 = 1 年,而真实天数 313 ⇒ 0.9 年);
+//  2. **四舍五入到 1 位小数**(`round(0.5479,1)` = 0.5)。教龄要和
+//     `teacher_exp_min/max`(`DECIMAL(6,3)`)做 `BETWEEN` 比较,而**整数年附近最密集**
+//     (此前修过 `0.999` 那种端点)⇒ 精度错一位就可能跨档;
+//  3. **空 / 非法 / 负值 → 0**(也覆盖"老师没录教龄"的存量数据,旧系统同样返回 0)。
+//
+// 为什么单独成文件:`payroll_service.go` 是 AC-10 的交付物,本批**不改既有实现**;
+// 新增能力按团队纪律放新文件(同包、同类型方法,Go 允许跨文件定义)。
+package service
+
+import (
+	"context"
+	"math"
+	"strings"
+	"time"
+)
+
+// AgeDecimalPlaces 教龄的小数位(PHP `round($age, 1)`)。
+const AgeDecimalPlaces = 1
+
+// calculatingAgeYears 按 PHP `calculatingAge()` 口径把"起算日"换算成"年数"。
+//
+// 入参:
+//   - expDate *time.Time:教龄起算日(`t_teacher_information.teacher_exp`,可 nil = 未录);
+//   - now time.Time:试算时刻(单测注入固定值,生产传 `time.Now()`)。
+//
+// 出参:教龄年数(保留 1 位小数);nil / 零值 / 未来日期 → 0。
+//
+// 实现要点:全程用**秒差**(`now.Sub(exp)`),不经 `AddDate` / 年份相减;
+// 负数(未来日期)按 0(PHP 的 `$strtotime < 0` 分支对应"时间戳为负",业务上等价于非法值)。
+func calculatingAgeYears(expDate *time.Time, now time.Time) float64 {
+	if expDate == nil || expDate.IsZero() || now.IsZero() {
+		return 0
+	}
+	seconds := now.Sub(*expDate).Seconds()
+	if seconds <= 0 {
+		return 0
+	}
+	years := seconds / 86400 / 365
+	if math.IsNaN(years) || math.IsInf(years, 0) || years <= 0 {
+		return 0
+	}
+	return roundToDecimal(years, AgeDecimalPlaces)
+}
+
+// roundToDecimal 四舍五入到指定小数位(等价 PHP `round($v, $places)`)。
+//
+// 入参:v 原值;places 小数位(0~6)。出参:舍入结果。
+//
+// 为什么不用 `strconv.FormatFloat` + 再 parse:那会把"舍入"变成"格式化",在 0.5 边界上
+// 依赖二进制表示(0.15 → "0.1" 或 "0.2" 取决于实现),而 `math.Round` 是显式语义。
+func roundToDecimal(v float64, places int) float64 {
+	if places < 0 {
+		places = 0
+	}
+	if places > 6 {
+		places = 6
+	}
+	scale := math.Pow(10, float64(places))
+	return math.Round(v*scale) / scale
+}
+
+// TeacherExpYears 取某老师的教龄(年),供结课侧课酬试算使用。
+//
+// 入参:ctx;teacherID 老师 ULID(空 → 0,不报错:排班规则允许没有老师);
+// at 试算时刻(= 上课时间;零值 → 当前时刻)。
+// 出参:教龄年数(1 位小数);**档案不存在 / 未录教龄 / DB 错误 → 0**(旧系统同口径)。
+//
+// 为什么 DB 错误也返回 0 而不冒泡:教龄只影响"命中哪一档课酬",而**结课是考勤事实的落库**;
+// 让它把整节课打回会变成"某张辅助表抖动 ⇒ 全班结不了课"(与 3112 置零继续同一取舍)。
+// 代价是少发钱——这条已在交付报告登记为"教龄解析失败降级 0",需要监控时看 warn 日志。
+func (s *PayrollService) TeacherExpYears(ctx context.Context, teacherID string, at time.Time) float64 {
+	if s == nil || s.repo == nil || strings.TrimSpace(teacherID) == "" {
+		return 0
+	}
+	expDate, found, err := s.repo.TeacherExpDate(ctx, teacherID)
+	if err != nil || !found {
+		return 0
+	}
+	now := at
+	if now.IsZero() {
+		now = time.Now()
+	}
+	return calculatingAgeYears(expDate, now)
+}

+ 94 - 0
internal/academic/service/payroll_age_test.go

@@ -0,0 +1,94 @@
+// 教龄换算单测(PHP `utils.php:924-935` `calculatingAge()` 口径)。
+//
+// 三条口径各有一组断言,**任一条写错都会让课酬"稳定地跨错档且不报错"**:
+//
+//	① 除数固定 365(不是 365.25、不是年份相减)
+//	② 四舍五入到 1 位小数(不是 3 位、不是截断)
+//	③ 空 / 非法 / 未来日期 → 0
+//
+// 为什么用固定 `now`:真实 `time.Now()` 会让断言依赖运行时刻(跨零点跑两次结果不同),
+// 而教龄恰好要在"整数年边界"上稳定 —— 边界值必须可复现。
+package service
+
+import (
+	"testing"
+	"time"
+)
+
+// dateUTC 构造 UTC 日期(只取年月日,与 `t_teacher_information.teacher_exp` 的 DATE 语义一致)。
+func dateUTC(year int, month time.Month, day int) time.Time {
+	return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
+}
+
+// ptrTime 时间指针(可空列用)。
+func ptrTime(t time.Time) *time.Time { return &t }
+
+// TestCalculatingAgeYearsMatchesPHP 教龄换算逐值断言(固定 now,边界可复现)。
+func TestCalculatingAgeYearsMatchesPHP(t *testing.T) {
+	now := dateUTC(2026, time.November, 9)
+
+	cases := []struct {
+		name string
+		exp  *time.Time
+		want float64
+	}{
+		{"nil 起算日 → 0", nil, 0},
+		{"零值起算日 → 0", ptrTime(time.Time{}), 0},
+		{"恰好 365 天 → 1.0", ptrTime(now.AddDate(0, 0, -365)), 1.0},
+		{"恰好 730 天 → 2.0", ptrTime(now.AddDate(0, 0, -730)), 2.0},
+		// ① 除数口径:313 天 = 0.8575 年 → 0.9。若按"年份相减"会得 **1.0**(2025-12-31 → 2026-11-09)
+		{"313 天(跨年但不足一年)→ 0.9 而非 1.0", ptrTime(dateUTC(2025, time.December, 31)), 0.9},
+		// ② 小数位口径:10 天 = 0.0274 年 → **0.0**(按 3 位会是 0.027,按截断也是 0.0 但 100 天会分叉)
+		{"10 天 → 0.0(1 位小数舍入)", ptrTime(now.AddDate(0, 0, -10)), 0},
+		{"100 天 → 0.3(0.274 → 0.3)", ptrTime(now.AddDate(0, 0, -100)), 0.3},
+		{"400 天 → 1.1(1.0959 → 1.1)", ptrTime(now.AddDate(0, 0, -400)), 1.1},
+		// ③ 未来日期 → 0(PHP 的负时间戳分支)
+		{"未来日期 → 0", ptrTime(now.AddDate(0, 0, 30)), 0},
+	}
+	for _, tc := range cases {
+		got := calculatingAgeYears(tc.exp, now)
+		if got != tc.want {
+			t.Errorf("%s:calculatingAgeYears = %v,期望 %v", tc.name, got, tc.want)
+		}
+	}
+}
+
+// ⚠ **本测试覆盖不到的一处,写在最前面免得被误解为"已覆盖"**:
+// 除数取 `365` 还是 `365.25`,在 `round(x,1)` 之下**对现实教龄区间不可观测**——
+// 两者每天只差 1.88e-6 年,要拉出 0.05 年的差异需 26000+ 天(70 年以上)才可能跨舍入边界。
+// 我们仍写 `365` 的唯一理由是**逐字照抄 PHP**(`Utils.php:930`),不是因为有断言能抓到;
+// 若有人改成 365.25,现有用例**不会红**——记在这里,不假装有防线。
+// 真正可观测、且已被用例钉住的是:① 不得用年份相减(见下一条)、② 必须舍入到 1 位、③ 空/未来 → 0。
+// TestCalculatingAgeYearsUses365NotCalendar 专门钉住"不得用年份相减"。
+//
+// 同一个起算日,两种算法给出**不同档位**(0.9 vs 1.0),而 `teacher_exp_min=1.000` 的规则
+// 只在后者命中 ⇒ 写错口径会**稳定地跨错档**(且课酬算错、无任何报错)。
+func TestCalculatingAgeYearsUses365NotCalendar(t *testing.T) {
+	now := dateUTC(2026, time.November, 9)
+	exp := dateUTC(2025, time.December, 31)
+	// 日历相减:2026-2025 = 1
+	calendarYears := float64(now.Year() - exp.Year())
+	got := calculatingAgeYears(ptrTime(exp), now)
+	if got == calendarYears {
+		t.Fatalf("教龄 = %v,与「年份相减」结果相同:说明没按 365 天口径算(本例应为 0.9,年份相减是 1.0)", got)
+	}
+	if got != 0.9 {
+		t.Fatalf("教龄 = %v,期望 0.9(313 天 / 365,四舍五入 1 位)", got)
+	}
+}
+
+// TestRoundToDecimal 舍入辅助的边界(0.5 进位按 PHP round 的四舍五入)。
+func TestRoundToDecimal(t *testing.T) {
+	cases := []struct{ in, want float64 }{
+		{0.5479, 0.5},
+		{0.55, 0.6},
+		{1.0959, 1.1},
+		{2.04, 2.0},
+		{9.96, 10.0},
+	}
+	for _, tc := range cases {
+		if got := roundToDecimal(tc.in, 1); got != tc.want {
+			t.Errorf("roundToDecimal(%v,1) = %v,期望 %v", tc.in, got, tc.want)
+		}
+	}
+}

+ 622 - 0
internal/academic/service/payroll_plan_service.go

@@ -0,0 +1,622 @@
+// 课酬方案 + 明细服务(端点 58)——AC-13d 追加。
+//
+// 依据:团队裁决 D17(两级形状 / `PUT` 按方案 id 整体替换明细 / 教龄区间两字段)。
+//
+// ---------------------------------------------------------------------------
+// 为什么新增而不是改 AC-10 的 `PayrollService`
+// ---------------------------------------------------------------------------
+//
+// AC-10 交付的 `PayrollService` 是**只读试算**(`QuoteTeacherReward` / `QuotePayroll`),
+// 没有任何写入面。端点 58 是"配置 CRUD",属**新能力**:
+//
+//	新能力 / 为什么 AC-10 没覆盖 / 是否与既有逻辑冲突
+//	ListPlans            / AC-10 只有"按机构+校区+日期取**一条**生效方案",没有列表  / 无(新查询,只读)
+//	GetPlan + details    / AC-10 不暴露方案实体(只回命中的规则)                      / 无(新查询,只读)
+//	CreatePlan           / AC-10 无任何写入                                           / 无(新事务)
+//	UpdatePlan           / 同上                                                       / 无(新事务)
+//	ReplaceDetails       / 同上("整体替换明细"是 D17 新裁的口径)                      / 无(新事务)
+//
+// 三条硬约束(团队批准):不改既有方法签名与行为、新增放新文件、逐条登记(上表)。
+//
+// ---------------------------------------------------------------------------
+// 两条校验口径(**不是可有可无的防呆**)
+// ---------------------------------------------------------------------------
+//
+//  1. **`teacherExpMin <= teacherExpMax`**:区间倒挂会让规则**永不命中**(`BETWEEN` 恒 false)
+//     ⇒ 课酬静默变 0。必须在写入前拒(2002),不能靠"以后发现课酬不对"。
+//  2. **`percentage` 必须是 0~100 的百分数**:它是 `percentage/100` 的基数(PHP `:781`),
+//     传 `0.85` 会被当成 0.85% ⇒ 课酬少 100 倍。写入侧限 0~100 且 ≤1 位小数,
+//     与列 `decimal(5,1)` 一致。
+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"
+)
+
+// MaxPayrollPercentage 课酬比例上限(百分数,含)。列是 `decimal(5,1)`,业务上 0~100。
+const MaxPayrollPercentage = 100
+
+// PayrollPlanService 课酬方案配置服务(写入面 + 列表读面)。
+type PayrollPlanService struct {
+	// repo 课酬方案/明细仓储(复用 AC-10 的 PayrollRepo,新增方法在同包新文件)。
+	repo *repository.PayrollRepo
+	// runner 事务运行器(方案与明细必须同一事务)。
+	runner *repository.TxRunner
+	// sc 请求作用域(机构维度越权断言)。
+	sc *appscope.Scope
+	// now 时钟(effect_date 缺省值用)。
+	now func() time.Time
+}
+
+// NewPayrollPlanService 构造课酬方案服务。
+//
+// 入参:db;sc 请求作用域(nil = 系统任务视角,`assertInstitution` 放行)。
+// 出参:*PayrollPlanService。
+func NewPayrollPlanService(db *gorm.DB, sc *appscope.Scope) *PayrollPlanService {
+	return &PayrollPlanService{
+		repo:   repository.NewPayrollRepo(db, sc),
+		runner: repository.NewTxRunner(db),
+		sc:     sc,
+		now:    time.Now,
+	}
+}
+
+// PayrollPlanQuery 端点 58 GET 的查询入参。
+//
+// 字段:InstitutionID 机构(**必填 >0**;非超管必须等于作用域机构,否则 1004);
+// CampusID 校区(nil 不筛);Actived 启用(nil 不筛);Keyword 名称模糊;Page/PageSize 分页。
+type PayrollPlanQuery struct {
+	InstitutionID int64
+	CampusID      *int64
+	Actived       *bool
+	Keyword       string
+	Page          int
+	PageSize      int
+}
+
+// PayrollPlanDetail 方案下的一条明细(对外视图,**每个字段都能追到列**)。
+//
+// 字段来源:
+//
+//	DetailID       ← t_course_payment_detail.id
+//	TeacherID      ← teacher_id(**可空**;NULL 原样下发 `null`,不是空串)
+//	ClassifyID     ← classify_id
+//	ClassType      ← class_type
+//	GradeID        ← grade_id
+//	SubjectID      ← subject_id
+//	TeacherCert    ← teacher_cert
+//	TeacherExpMin  ← teacher_exp_min(教龄区间下界,**不是单值 teacherExp**;定点化到 3 位)
+//	TeacherExpMax  ← teacher_exp_max(定点化到 3 位)
+//	Percentage     ← percentage(**百分数**,"85.0" = 85%;定点化到 1 位)
+//	Min            ← min_payment(金额,2 位小数)
+//	Max            ← max_payment(金额,2 位小数)
+//	Reward         ← reward(金额,2 位小数)
+//	DisplayName    ← name(**明细的"计算方式名称"**;与方案名分开,见 D17)
+//	DetailActived  ← actived(**明细级**启用;方案的 actived 是另一层)
+//
+// ⚠ **`TeacherID` 用 `*string` 而不是 `string`**:`teacher_id` 可空,"通用规则"就是 NULL。
+// 下发 `""` 会让前端无法区分两件事:
+//
+//	① 这条规则**不限定老师**(NULL,通用档);
+//	② 这条规则限定了老师,但老师 ID 是空串(数据坏了,本不该存在)。
+//
+// 前者是正常业务,后者是脏数据,前端一个 `if (teacherId)` 就把两者混成一支——
+// 脏数据被当成"通用档"静默通过校验。`null` / `"ULID"` / 见不到第三种取值,才有排查价值。
+type PayrollPlanDetail struct {
+	DetailID      int64
+	TeacherID     *string
+	ClassifyID    int64
+	ClassType     int32
+	GradeID       int32
+	SubjectID     int32
+	TeacherCert   bool
+	TeacherExpMin string
+	TeacherExpMax string
+	Percentage    string
+	Min           money.Amount
+	Max           money.Amount
+	Reward        money.Amount
+	DisplayName   string
+	DetailActived bool
+}
+
+// PayrollPlan 方案 + 明细(端点 58 的响应单元)。
+//
+// 字段来源:
+//
+//	ID            ← t_course_payment.id(**方案 id**,`PUT /plans/:id` 的 `:id`)
+//	InstitutionID ← institution_id
+//	CampusID      ← campus_id(0 = 机构级方案)
+//	SchemeName    ← name(**方案名称**;与明细的 displayName 分开)
+//	EffectDate    ← effect_date(可空)
+//	Actived       ← actived(**方案级**启用)
+//	Details       ← t_course_payment_detail(按 id ASC)
+type PayrollPlan struct {
+	ID            int64
+	InstitutionID int64
+	CampusID      int64
+	SchemeName    string
+	EffectDate    *time.Time
+	Actived       bool
+	Details       []PayrollPlanDetail
+}
+
+// ListPlans 分页查询方案(含明细)。
+//
+// 入参:ctx;q 查询入参。
+// 出参:([]PayrollPlan, total, error);空结果非 nil;1004(跨机构)/2001/5002。
+func (s *PayrollPlanService) ListPlans(ctx context.Context, q PayrollPlanQuery) ([]PayrollPlan, int64, error) {
+	if q.InstitutionID <= 0 {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: 课酬方案列表需要 institutionId(>0)"))
+	}
+	plans, total, err := s.repo.ListPlans(ctx, q.InstitutionID, repository.PayrollPlanFilter{
+		CampusID: q.CampusID, Actived: q.Actived, Keyword: q.Keyword,
+	}, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
+	if err != nil {
+		return nil, 0, err
+	}
+	if len(plans) == 0 {
+		return make([]PayrollPlan, 0), total, nil
+	}
+	ids := make([]int64, 0, len(plans))
+	for i := range plans {
+		ids = append(ids, plans[i].ID)
+	}
+	details, err := s.repo.ListDetailsByPlanIDs(ctx, ids)
+	if err != nil {
+		return nil, 0, err
+	}
+	byPlan := make(map[int64][]PayrollPlanDetail, len(ids))
+	for i := range details {
+		d := &details[i]
+		view, perr := toPlanDetail(d)
+		if perr != nil {
+			return nil, 0, perr
+		}
+		byPlan[d.CoursePaymentID] = append(byPlan[d.CoursePaymentID], view)
+	}
+	out := make([]PayrollPlan, 0, len(plans))
+	for i := range plans {
+		p := &plans[i]
+		items := byPlan[p.ID]
+		if items == nil {
+			items = make([]PayrollPlanDetail, 0)
+		}
+		out = append(out, PayrollPlan{
+			ID:            p.ID,
+			InstitutionID: p.InstitutionID,
+			CampusID:      p.CampusID,
+			SchemeName:    strValue(p.Name),
+			EffectDate:    p.EffectDate,
+			Actived:       p.Actived,
+			Details:       items,
+		})
+	}
+	return out, total, nil
+}
+
+// PayrollPlanDetailInput 明细写入入参(新建/整体替换共用)。
+//
+// 全部字段与列一一对应;金额用"元字符串"由 Handler 解析后传入(`money.Amount`)。
+type PayrollPlanDetailInput struct {
+	TeacherID     string
+	ClassifyID    int64
+	ClassType     int32
+	GradeID       int32
+	SubjectID     int32
+	TeacherCert   bool
+	TeacherExpMin string
+	TeacherExpMax string
+	Percentage    string
+	Min           money.Amount
+	Max           money.Amount
+	Reward        money.Amount
+	DisplayName   string
+	Actived       bool
+}
+
+// CreatePlanInput 端点 58 POST 入参(方案 + 明细,一次建完)。
+type CreatePlanInput struct {
+	InstitutionID int64
+	CampusID      int64
+	SchemeName    string
+	EffectDate    *time.Time
+	Actived       bool
+	Details       []PayrollPlanDetailInput
+	OperatorID    string
+}
+
+// UpdatePlanInput 端点 58 PUT 入参(`:id` = **方案 id**,明细整体替换)。
+//
+// 指针语义:nil = 不修改(方案字段);`Details` nil = **不动明细**,
+// 非 nil(含空切片)= **整体替换**(空切片 = 清空明细)。
+type UpdatePlanInput struct {
+	ID            int64
+	InstitutionID int64
+	CampusID      *int64
+	SchemeName    *string
+	EffectDate    *time.Time
+	Actived       *bool
+	Details       []PayrollPlanDetailInput
+	ReplaceDetail bool
+}
+
+// CreatePlan 新建方案 + 明细(**同一事务**)。
+//
+// 入参:ctx;in 入参。
+// 出参:*PayrollPlan(含新 id 与明细);2001/2002(名称/比例/教龄区间/金额非法)/1004/5002。
+func (s *PayrollPlanService) CreatePlan(ctx context.Context, in CreatePlanInput) (*PayrollPlan, error) {
+	name := strings.TrimSpace(in.SchemeName)
+	if name == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 方案名称不能为空"))
+	}
+	if in.InstitutionID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: 课酬方案需要 institutionId(>0)"))
+	}
+	details, err := buildPlanDetails(in.Details)
+	if err != nil {
+		return nil, err
+	}
+	row := &model.CoursePayment{
+		InstitutionID: in.InstitutionID,
+		CampusID:      in.CampusID,
+		Name:          &name,
+		EffectDate:    in.EffectDate,
+		Actived:       in.Actived,
+	}
+	if uid := strings.TrimSpace(in.OperatorID); uid != "" {
+		row.CreateUserID = &uid
+	}
+	err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		if cerr := s.repo.CreatePlan(txCtx, tx, row); cerr != nil {
+			return cerr
+		}
+		return s.repo.ReplaceDetails(txCtx, tx, row.ID, details)
+	})
+	if err != nil {
+		return nil, err
+	}
+	return s.getPlan(ctx, in.InstitutionID, row.ID)
+}
+
+// UpdatePlan 更新方案(可选整体替换明细),**同一事务**。
+//
+// 入参:ctx;in 入参(ID = 方案 id)。
+// 出参:*PayrollPlan(更新后回读);2001(没有任何待更新字段)/2002/3001/1004/5002。
+func (s *PayrollPlanService) UpdatePlan(ctx context.Context, in UpdatePlanInput) (*PayrollPlan, error) {
+	if in.ID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 方案 id 必须 > 0"))
+	}
+	if in.InstitutionID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: 课酬方案需要 institutionId(>0)"))
+	}
+	row := &model.CoursePayment{ID: in.ID, InstitutionID: in.InstitutionID}
+	columns := make([]string, 0, 4)
+	if in.CampusID != nil {
+		row.CampusID = *in.CampusID
+		columns = append(columns, "campus_id")
+	}
+	if in.SchemeName != nil {
+		name := strings.TrimSpace(*in.SchemeName)
+		if name == "" {
+			return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 方案名称不能为空"))
+		}
+		row.Name = &name
+		columns = append(columns, "name")
+	}
+	if in.EffectDate != nil {
+		row.EffectDate = in.EffectDate
+		columns = append(columns, "effect_date")
+	}
+	if in.Actived != nil {
+		row.Actived = *in.Actived
+		columns = append(columns, "actived")
+	}
+	var details []*model.CoursePaymentDetail
+	if in.ReplaceDetail {
+		built, derr := buildPlanDetails(in.Details)
+		if derr != nil {
+			return nil, derr
+		}
+		details = built
+	}
+	if len(columns) == 0 && !in.ReplaceDetail {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: 没有任何待更新字段(方案字段与明细都未提供)"))
+	}
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		if _, gerr := s.repo.GetPlanByID(txCtx, in.InstitutionID, in.ID); gerr != nil {
+			return gerr // 3001(不存在/不在本机构)
+		}
+		if len(columns) > 0 {
+			affected, uerr := s.repo.UpdatePlan(txCtx, tx, row, columns)
+			if uerr != nil {
+				return uerr
+			}
+			if affected == 0 {
+				return infraerrors.WithCause(infraerrors.ErrNotFound,
+					errString("service: 课酬方案不存在或不在本机构"))
+			}
+		}
+		if in.ReplaceDetail {
+			return s.repo.ReplaceDetails(txCtx, tx, in.ID, details)
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return s.getPlan(ctx, in.InstitutionID, in.ID)
+}
+
+// getPlan 回读单个方案 + 明细(Create/Update 的出参装配)。
+//
+// 入参:ctx;institutionID;id。出参:*PayrollPlan;3001/1004/5002。
+func (s *PayrollPlanService) getPlan(ctx context.Context, institutionID, id int64) (*PayrollPlan, error) {
+	row, err := s.repo.GetPlanByID(ctx, institutionID, id)
+	if err != nil {
+		return nil, err
+	}
+	details, err := s.repo.ListDetailsByPlanIDs(ctx, []int64{id})
+	if err != nil {
+		return nil, err
+	}
+	items := make([]PayrollPlanDetail, 0, len(details))
+	for i := range details {
+		view, perr := toPlanDetail(&details[i])
+		if perr != nil {
+			return nil, perr
+		}
+		items = append(items, view)
+	}
+	return &PayrollPlan{
+		ID:            row.ID,
+		InstitutionID: row.InstitutionID,
+		CampusID:      row.CampusID,
+		SchemeName:    strValue(row.Name),
+		EffectDate:    row.EffectDate,
+		Actived:       row.Actived,
+		Details:       items,
+	}, nil
+}
+
+// buildPlanDetails 校验并构造明细行(**唯一校验点**,Create/Update 共用)。
+//
+// 入参:inputs 明细入参。出参:[]*model.CoursePaymentDetail;2001/2002。
+//
+// 校验项(顺序固定):
+//  1. `percentage` 必须是 0~100 的百分数(≤1 位小数)——传 0.85 会让课酬少 100 倍;
+//  2. `teacherExpMin <= teacherExpMax`(区间倒挂 ⇒ 规则永不命中 ⇒ 课酬静默 0);
+//  3. 金额非负(`min/max/reward` 都是支出方向;`min/max` 为 0 = 不设限)。
+func buildPlanDetails(inputs []PayrollPlanDetailInput) ([]*model.CoursePaymentDetail, error) {
+	out := make([]*model.CoursePaymentDetail, 0, len(inputs))
+	for i := range inputs {
+		in := &inputs[i]
+		pct := strings.TrimSpace(in.Percentage)
+		if pct == "" {
+			return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+				errString("service: 课酬比例 percentage 不能为空(0~100 的百分数)"))
+		}
+		if pctValue, perr := parsePercentage(pct); perr != nil {
+			return nil, perr
+		} else if pctValue > MaxPayrollPercentage {
+			return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+				errString("service: 课酬比例不得超过 100(收到 "+pct+";注意是百分数,85% 应传 85 或 85.0)"))
+		}
+		minExp := strings.TrimSpace(in.TeacherExpMin)
+		maxExp := strings.TrimSpace(in.TeacherExpMax)
+		if minExp == "" {
+			minExp = "0.000"
+		}
+		if maxExp == "" {
+			maxExp = "0.000"
+		}
+		minValue, minErr := parseDecimal3(minExp)
+		if minErr != nil {
+			return nil, minErr
+		}
+		maxValue, maxErr := parseDecimal3(maxExp)
+		if maxErr != nil {
+			return nil, maxErr
+		}
+		if minValue > maxValue {
+			return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+				errString("service: 教龄区间倒挂(teacherExpMin "+minExp+" > teacherExpMax "+maxExp+
+					"):该规则将永不命中,课酬会静默变 0"))
+		}
+		for _, amt := range []money.Amount{in.Min, in.Max, in.Reward} {
+			if amt.IsNegative() {
+				return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+					errString("service: 课酬金额不能为负"))
+			}
+			if err := amt.Validate(); err != nil {
+				return nil, infraerrors.WithCause(infraerrors.ErrBadParam, err)
+			}
+		}
+		row := &model.CoursePaymentDetail{
+			GradeID:       in.GradeID,
+			SubjectID:     in.SubjectID,
+			ClassType:     in.ClassType,
+			ClassifyID:    in.ClassifyID,
+			Percentage:    pct,
+			MinPayment:    in.Min.String(),
+			MaxPayment:    in.Max.String(),
+			Reward:        in.Reward.String(),
+			TeacherCert:   in.TeacherCert,
+			TeacherExpMin: minExp,
+			TeacherExpMax: maxExp,
+			Actived:       in.Actived,
+		}
+		if tid := strings.TrimSpace(in.TeacherID); tid != "" {
+			row.TeacherID = &tid
+		}
+		if name := strings.TrimSpace(in.DisplayName); name != "" {
+			row.Name = &name
+		}
+		out = append(out, row)
+	}
+	return out, nil
+}
+
+// toPlanDetail 把明细行转成对外视图(**出口定点化 + 金额定点解析**;列写坏 → 5002)。
+//
+// 入参:d 明细行。出参:PayrollPlanDetail;5002(ErrDBError)。
+//
+// 三类字段的出口处理**必须都做**,且都不能省:
+//
+//	percentage / teacherExp  → `FormatFixed` 到列声明的位数(1 / 3 位)。
+//	                           不定点化 ⇒ SQLite 回 "85"、MySQL 回 "85.0",前端 diff 误判 + 单测随方言摇摆;
+//	                           **也正因为定点化,"比例被当成金额(2 位)"这种量纲污染会立刻显形**。
+//	金额(min/max/reward)  → `money.Amount.String()`,恒 2 位小数(定点整数域,无浮点)。
+//	teacherId              → NULL 保持 `nil`(下发 `null`),**不折叠成空串**(见字段注释)。
+func toPlanDetail(d *model.CoursePaymentDetail) (PayrollPlanDetail, error) {
+	minAmount, err := parseAmount(d.MinPayment)
+	if err != nil {
+		return PayrollPlanDetail{}, err
+	}
+	maxAmount, err := parseAmount(d.MaxPayment)
+	if err != nil {
+		return PayrollPlanDetail{}, err
+	}
+	reward, err := parseAmount(d.Reward)
+	if err != nil {
+		return PayrollPlanDetail{}, err
+	}
+	percentage, err := FormatPercentage(d.Percentage)
+	if err != nil {
+		return PayrollPlanDetail{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	yearMin, err := FormatYears(d.TeacherExpMin)
+	if err != nil {
+		return PayrollPlanDetail{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	yearMax, err := FormatYears(d.TeacherExpMax)
+	if err != nil {
+		return PayrollPlanDetail{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return PayrollPlanDetail{
+		DetailID:      d.ID,
+		TeacherID:     optionalTrimmedPtr(d.TeacherID),
+		ClassifyID:    d.ClassifyID,
+		ClassType:     d.ClassType,
+		GradeID:       d.GradeID,
+		SubjectID:     d.SubjectID,
+		TeacherCert:   d.TeacherCert,
+		TeacherExpMin: yearMin,
+		TeacherExpMax: yearMax,
+		Percentage:    percentage,
+		Min:           minAmount,
+		Max:           maxAmount,
+		Reward:        reward,
+		DisplayName:   strValue(d.Name),
+		DetailActived: d.Actived,
+	}, nil
+}
+
+// optionalTrimmedPtr 把"可空文本列"归一为"有值才有指针"(NULL / 空白 → nil)。
+//
+// 入参:v 列值指针。出参:去空白后非空串的指针;NULL 或纯空白 → nil。
+//
+// 使用点(两处,口径相同):`teacher_id`(课酬明细的"通用档")与 `create_user_id`(收支记账人)。
+//
+// 为什么空白也归 nil:两列都是 `varchar`,历史数据里存在 `''`(旧系统用空串代替 NULL)。
+// 若把 `''` 原样下发,前端 `if (x)` 得到 false —— 行为上等同 NULL,但**字节不同**,
+// 又回到"同一语义两种表示"的老坑。这里统一收口为 `null`,让出口只有三种取值:
+// `null`(不限定/未记录)/ 合法 ULID / 见不到别的。
+func optionalTrimmedPtr(v *string) *string {
+	if v == nil {
+		return nil
+	}
+	trimmed := strings.TrimSpace(*v)
+	if trimmed == "" {
+		return nil
+	}
+	return &trimmed
+}
+
+// parsePercentage 解析百分数字符串为浮点(写入校验用;**不参与金额计算**)。
+//
+// 入参:text 百分数("85" / "85.0")。出参:(数值, error);非数字/负/多于 1 位小数 → 2002。
+//
+// 说明:金额计算侧走 `applyPercentage` 的整数分路径;本函数只做**界内校验**,
+// 用 float 不会污染金额(结果只用于和 100 比较)。
+func parsePercentage(text string) (float64, error) {
+	if strings.HasPrefix(text, "-") {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: 课酬比例不能为负 "+text))
+	}
+	intPart, fracPart, hasDot := strings.Cut(text, ".")
+	if intPart == "" || !allDigits(intPart) {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: 课酬比例格式非法 "+text))
+	}
+	if hasDot {
+		if fracPart == "" || len(fracPart) > 1 || !allDigits(fracPart) {
+			return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+				errString("service: 课酬比例最多 1 位小数 "+text))
+		}
+	}
+	var value float64
+	for _, ch := range intPart {
+		value = value*10 + float64(ch-'0')
+	}
+	if hasDot {
+		value += float64(fracPart[0]-'0') / 10
+	}
+	return value, nil
+}
+
+// parseDecimal3 解析 3 位小数的教龄字符串(只用于比较大小)。
+//
+// 入参:text("1.000")。出参:(数值, error);非法 → 2002。
+func parseDecimal3(text string) (float64, error) {
+	intPart, fracPart, hasDot := strings.Cut(text, ".")
+	if intPart == "" || !allDigits(intPart) {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: 教龄格式非法 "+text))
+	}
+	var value float64
+	for _, ch := range intPart {
+		value = value*10 + float64(ch-'0')
+	}
+	if hasDot && fracPart != "" {
+		if len(fracPart) > 3 || !allDigits(fracPart) {
+			return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+				errString("service: 教龄最多 3 位小数 "+text))
+		}
+		scale := 0.1
+		for _, ch := range fracPart {
+			value += float64(ch-'0') * scale
+			scale /= 10
+		}
+	}
+	return value, nil
+}
+
+// allDigits 判断是否全为数字(非空)。
+func allDigits(s string) bool {
+	if s == "" {
+		return false
+	}
+	for _, ch := range s {
+		if ch < '0' || ch > '9' {
+			return false
+		}
+	}
+	return true
+}

+ 114 - 18
internal/academic/service/record_service.go

@@ -18,7 +18,7 @@
 //	④ spend 累加(t_user_expense(.school).spend += cost,统计口径列,**不动 balance**)
 //	⑤ record 汇总落库(status=2 + actual_end_time + student_number + income + teacher_reward)
 //	⑥ 分账(DivideService.AddLog:从推荐人的上级起逐级入账,两级幂等)
-//	⑦ 课酬(TeacherRewardQuoter 试算,按学生行均分以满足 Σ record_student.teacher_reward = record.teacher_reward)
+//	⑦ 课酬(TeacherRewardQuoter 试算;**按 PHP 口径只分给 type∈{1,2} 的行**,见 `applyTeacherRewards`)
 //
 // **锁序(§8 AC-07 基线)**:record 行锁 → 账户锁(全局 → 校区,由 ExpenseRepo 固定顺序实现)
 // → 报课行 → 分账表。活动/优惠池行锁本流程不涉及;若将来在结束时核销活动赠送,
@@ -65,7 +65,14 @@ type DivideLogGranter interface {
 //   - InstitutionID/CampusID int64:方案两级回退的校区维度(校区 → 机构级 campus_id=0);
 //   - ClassifyID int64:课程分类(决定 grade/subject/class_type 维度);
 //   - ClassType int32:班级类型(1000 集体班…);
-//   - RecordDate time.Time:上课日期(方案 effect_date 生效判定)。
+//   - RecordDate time.Time:上课日期(方案 effect_date 生效判定);
+//   - Income money.Amount:**本次课收入**(= Σ record_student.cost)——按比例提成的计算基数;
+//   - TeacherExp float64:教龄(年)——课酬规则的 `teacher_exp_min..max` 区间命中依据。
+//
+// ⚠ **v0.1.34 增补(D10 裁决):Income / TeacherExp 是后加的两个字段,既有字段语义一个未改**
+// (Go 结构体加字段对既有调用向后兼容)。为什么必须加:没有 Income 就算不出"按比例提成"
+// (旧适配只能返回方案里的 `reward` 固定列 ⇒ 纯比例型方案课酬恒 0 = **少发钱**);
+// 没有 TeacherExp 就永远命中 0 年档。
 type RewardQuoteInput struct {
 	TeacherID     string
 	InstitutionID int64
@@ -73,6 +80,18 @@ type RewardQuoteInput struct {
 	ClassifyID    int64
 	ClassType     int32
 	RecordDate    time.Time
+	// Income 本次课收入(元;Σ 打卡明细 cost)。按比例提成的基数;0 表示无收入(比例部分算 0)。
+	Income money.Amount
+	// TeacherExp 教龄(年;**0 = 未取到**,见 `recordRewardQuoter` 注释里的口径缺口)。
+	TeacherExp float64
+	// StudentCount 本次上课人数(= 打卡行数)。
+	//
+	// 两个用途,都来自 PHP 原文(`CoursesRecordHelper.php:794` / `:806-814`):
+	//   - 计算守卫:`if ($income>0 || $attendClassStudentNumber)` —— 收入 0 但有学生时仍要算(0+reward 再夹);
+	//   - 固定课酬:`teacher_reward_type==2` 时按人数查 `makeupCost` 表(不看比例)。
+	StudentCount int32
+	// RewardType 课酬结算方式(`t_courses_record.teacher_reward_type`:1 正常 / 2 固定课酬)。
+	RewardType int8
 }
 
 // TeacherRewardQuoter 课酬试算端口(AC-10 PayrollService 实现;本服务只声明契约)。
@@ -435,19 +454,25 @@ func (s *RecordService) FinishRecord(ctx context.Context, in FinishRecordInput)
 			return rerr
 		}
 
+		// 先算本次课收入(Σ cost):它是"按比例提成"的基数 ⇒ **必须早于试算**。
+		// 顺序调整(D10):旧代码先试算、后累加 income,导致比例型方案拿不到基数。
+		income := money.Amount(0)
+		for i := range resolved {
+			income = income.Add(resolved[i].Cost)
+		}
+
 		// ⑦ 课酬试算(一次:一节课一份课酬;nil quoter → 0.00)
-		totalReward, qerr := s.quoteReward(txCtx, rec, endTime)
+		totalReward, qerr := s.quoteReward(txCtx, rec, endTime, income, int32(len(resolved)), in.RewardType)
 		if qerr != nil {
 			return qerr
 		}
-		rewards := distributeAmount(totalReward, len(resolved))
-
-		income := money.Amount(0)
-		for i := range resolved {
-			cur := &resolved[i]
-			cur.TeacherReward = rewards[i]
-			income = income.Add(cur.Cost)
-		}
+		// 课酬分发**按 PHP 口径**(`CoursesRecordHelper.php:806-818`):
+		//   除数 = `count(type IN (1,2))`(**不含 type=3 赠送生**)
+		//   写入范围 = **只有 type∈{1,2} 的行**;type=3 行 `teacher_reward` **恒为 0**
+		// ⇒ 恒等式是**条件式**:`stuCount>0` 时 `Σ(type∈{1,2}) == record.teacher_reward`;
+		//   `stuCount==0`(全班赠送)时**一行都不写**(PHP 的 `if($stuCount)` 不进入),
+		//   此时 record 上仍可能有课酬、学生行全 0 —— **恒等式不成立,不得按无条件断言校验**。
+		applyTeacherRewards(resolved, totalReward)
 
 		// ③ 扣课时 + ④ spend 累加 + ② 打卡明细落库(逐学生,顺序固定 → 锁序可复现)
 		for i := range resolved {
@@ -727,9 +752,11 @@ func (s *RecordService) upsertRecordStudent(txCtx context.Context, tx *gorm.DB,
 
 // quoteReward 课酬试算(未注入 quoter → 0.00;未配置方案 3112 → 0.00 且不阻断结课)。
 //
-// 入参:txCtx;rec 实例;at 上课时间(用于方案 effect_date 判定)。
+// 入参:txCtx;rec 实例;at 上课时间(方案 effect_date 判定);income 本次课收入(比例提成基数);
+// studentCount 上课人数(计算守卫 + 固定课酬查表);rewardType 结算方式(1 正常 / 2 固定课酬)。
 // 出参:单节课课酬(money.Amount);非 3112 的错误原样上抛(整体回滚)。
-func (s *RecordService) quoteReward(txCtx context.Context, rec *model.CoursesRecord, at time.Time) (money.Amount, error) {
+func (s *RecordService) quoteReward(txCtx context.Context, rec *model.CoursesRecord, at time.Time,
+	income money.Amount, studentCount int32, rewardType int8) (money.Amount, error) {
 	if s.quoter == nil {
 		return 0, nil
 	}
@@ -753,6 +780,12 @@ func (s *RecordService) quoteReward(txCtx context.Context, rec *model.CoursesRec
 		ClassifyID:    course.ClassifyID,
 		ClassType:     classType,
 		RecordDate:    at,
+		Income:        income,
+		// TeacherExp 置 0 表示"本层不认识教龄":由课酬适配器(`recordRewardQuoter`)回落到教师档案,
+		// 换算按 PHP `calculatingAge()`(固定 365 天/年、round 1 位,见 `payroll_age.go`)。
+		TeacherExp:   0,
+		StudentCount: studentCount,
+		RewardType:   rewardType,
 	}
 	reward, qerr := s.quoter.QuoteRecordReward(txCtx, in)
 	if qerr != nil {
@@ -836,13 +869,76 @@ func sumRecordStudentCost(rows []model.CoursesRecordStudent) (money.Amount, erro
 	return total, nil
 }
 
-// distributeAmount 把总额按行数均分(整数分运算,余数全部给第一行)。
+// applyTeacherRewards 把单节课课酬分发到各打卡行(**按 PHP 口径**,`CoursesRecordHelper.php:806-818`)。
+//
+// 入参:rows 结课名单(会被就地改写 `TeacherReward`);total 单节课课酬合计。
+// 出参:无(就地写)。
+//
+// PHP 原文(逐行照抄):
+//
+//	$stuCount = CoursesRecordStudent::find()->where(['recordid'=>$recordid,'type'=>[1,2]])->count();
+//	if($stuCount){ $reward = round(($teacherReward/$stuCount),3);
+//	              CoursesRecordStudent::updateAll(['teacher_reward'=>$reward],
+//	                  ['recordid'=>$recordid,'type'=>[1,2]]); }
+//
+// 三条照抄要点(**任一写错都会让账与旧系统分叉**):
+//  1. **除数是 `count(type∈{1,2})`**,不是"全部打卡行数"——赠送生(type=3)不参与分摊;
+//  2. **只写 `type∈{1,2}` 的行**,赠送行的 `teacher_reward` **保持 0**;
+//  3. `stuCount == 0`(全班赠送)时 PHP 的 `if($stuCount)` **不进入** ⇒ 一行都不写:
+//     此时 record 上仍写课酬(考勤事实),学生行全 0 —— **这不是 bug,是旧系统语义**。
+//
+// ⚠ 恒等式因此是**条件式**(AC-17 一致性检查必须照此写,勿写成无条件):
+//
+//	stuCount > 0  ⇒  Σ(teacher_reward WHERE type∈{1,2}) == record.teacher_reward
+//	stuCount == 0 ⇒  所有行 teacher_reward == 0(**不要求** Σ == record)
+//
+// 为什么不能无条件:无条件断言在"全赠送班"上恒红,维护者最后一定会把它改成"跳过",
+// 那等于把这条防线删掉(与 AC-14 断言②"不得加过滤"同一条纪律)。
+func applyTeacherRewards(rows []resolvedAttendance, total money.Amount) {
+	eligible := 0
+	for i := range rows {
+		if isRewardEligibleType(rows[i].Type) {
+			eligible++
+		}
+	}
+	if eligible == 0 {
+		// 全班赠送:PHP 不进入 `if($stuCount)` ⇒ 所有行课酬保持 0
+		for i := range rows {
+			rows[i].TeacherReward = 0
+		}
+		return
+	}
+	shares := distributeAmount(total, eligible)
+	next := 0
+	for i := range rows {
+		if !isRewardEligibleType(rows[i].Type) {
+			rows[i].TeacherReward = 0 // 赠送行恒 0(即便入参给了 reward 也不写)
+			continue
+		}
+		rows[i].TeacherReward = shares[next]
+		next++
+	}
+}
+
+// isRewardEligibleType 判断该打卡类型是否参与课酬分摊(PHP `type IN (1,2)`:1 正常 / 2 插班)。
+//
+// 入参:typ 打卡类型。出参:true = 参与分摊。
+func isRewardEligibleType(typ int32) bool {
+	switch typ {
+	case model.CoursesRecordStudentTypeNormal, model.CoursesRecordStudentTypeTransfer:
+		return true
+	default:
+		return false // type=3 赠送(以及任何未知类型):不分摊课酬
+	}
+}
+
+// distributeAmount 把总额按"参与分摊的行数"均分(整数分运算,余数全部给第一行)。
 //
-// 入参:total 总额(分);n 行数(<=0 → 返回空切片)。
-// 出参:[]money.Amount(长度 n;Σ == total,保证 Σ record_student.teacher_reward = record.teacher_reward)。
+// 入参:total 总额(分);n 参与分摊的行数(<=0 → 返回空切片)。
+// 出参:[]money.Amount(长度 n;Σ == total)。
 //
-// 为什么均分:dump 无 finish SP(课酬分摊无权威口径)。选择"均分"的唯一硬理由是对账恒等式
-// (模型注释红线 Σ = record.teacher_reward);余数入首行避免金额丢失(分位不可整除时)。
+// 调用约定:`n` **必须**是 `count(type∈{1,2})`(见 `applyTeacherRewards`),**不是**全部行数。
+// 余数入首行避免金额丢失(分位不可整除时)。
 func distributeAmount(total money.Amount, n int) []money.Amount {
 	out := make([]money.Amount, 0, n)
 	if n <= 0 {

+ 113 - 0
internal/identity/repository/user_name_repo.go

@@ -0,0 +1,113 @@
+// 用户显示名批量只读仓储(identity 侧,实现 academic 的 `port.UserNameReader`)。
+//
+// 为什么实现放在这里而不是 academic:`t_user` 的属主是 identity 域;academic 只声明**接口**
+// (`internal/academic/port/user_adapter.go`),实现与 SQL 留在本包,依赖方向保持
+// `academic → port ← identity`(装配层注入,academic 对 identity 零 import)。
+//
+// 契约细节(与 `port.UserNameReader` 逐条对齐):
+//   - **批量**:一页最多 200 个学生,逐条查会 200 次往返;
+//   - 查不到的 userID **不进 map**(不塞空串)——调用方据此下发 `null` 而不是 `""`,
+//     前端才能区分"没有这个人"与"姓名为空";
+//   - 只读:本文件只有 SELECT,没有任何写路径。
+package repository
+
+import (
+	"context"
+	"strings"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/identity/model"
+)
+
+// userNameBatchLimit 单次批量查询的 userID 上限(防一条 IN 列表过长打爆 SQL)。
+//
+// 200 = academic 分页上限(`repository.MaxPageSize`),与调用方一页行数一致;
+// 超过则分批查询(本方法内部循环,调用方无感)。
+const userNameBatchLimit = 200
+
+// UserNameRepo 用户显示名只读仓储。
+type UserNameRepo struct {
+	// db 连接池句柄(只读用途;不持有事务,展示类查询不需要快照一致性)。
+	db *gorm.DB
+}
+
+// NewUserNameRepo 构造显示名仓储。
+//
+// 入参:db *gorm.DB(必填)。
+// 出参:*UserNameRepo —— 结构化满足 `academic/port.UserNameReader`(装配层做编译期断言)。
+func NewUserNameRepo(db *gorm.DB) *UserNameRepo {
+	return &UserNameRepo{db: db}
+}
+
+// DisplayNames 批量取显示名(`t_user.display_name`)。
+//
+// 入参:ctx;userIDs 用户 ULID 列表(可重复/可空)。
+// 出参:map[ULID]显示名(仅含"查得到且非空"的项);error 为 DB 错误。
+//
+// 取数口径(**这是 D4 的唯一口径点**):
+//   - 列 = `display_name`(identity 域用户表**唯一**的姓名列;不存在 `real_name` 列);
+//   - 值为 NULL 或全空白 ⇒ **不进 map**(让调用方下发 `null`,与"空字符串"区分);
+//   - 不做任何格式化/拼接(拼接出来的"姓名"无法反查,属伪造数据)。
+func (r *UserNameRepo) DisplayNames(ctx context.Context, userIDs []string) (map[string]string, error) {
+	out := make(map[string]string, len(userIDs))
+	ids := dedupNonEmpty(userIDs)
+	if len(ids) == 0 {
+		return out, nil
+	}
+	if r == nil || r.db == nil {
+		return nil, gorm.ErrInvalidDB
+	}
+	db := r.db
+	if ctx != nil {
+		db = db.WithContext(ctx)
+	}
+	for start := 0; start < len(ids); start += userNameBatchLimit {
+		end := start + userNameBatchLimit
+		if end > len(ids) {
+			end = len(ids)
+		}
+		rows := make([]nameRow, 0, end-start)
+		if err := db.Model(&model.User{}).
+			Select("canonical_user_id", "display_name").
+			Where("canonical_user_id IN ?", ids[start:end]).
+			Find(&rows).Error; err != nil {
+			return nil, err
+		}
+		for i := range rows {
+			if rows[i].DisplayName == nil {
+				continue // NULL:不放进 map(调用方下发 null)
+			}
+			name := strings.TrimSpace(*rows[i].DisplayName)
+			if name == "" {
+				continue // 空白:同样按"没有名字"处理
+			}
+			out[strings.TrimSpace(rows[i].CanonicalUserID)] = name
+		}
+	}
+	return out, nil
+}
+
+// nameRow `t_user` 的两列投影(不 SELECT *:用户表可能有凭据类列,越少出库越好)。
+type nameRow struct {
+	CanonicalUserID string  `gorm:"column:canonical_user_id"`
+	DisplayName     *string `gorm:"column:display_name"`
+}
+
+// dedupNonEmpty 去重并剔除空白(IN 列表净化,保持首次出现顺序)。
+func dedupNonEmpty(ids []string) []string {
+	out := make([]string, 0, len(ids))
+	seen := make(map[string]struct{}, len(ids))
+	for _, id := range ids {
+		trimmed := strings.TrimSpace(id)
+		if trimmed == "" {
+			continue
+		}
+		if _, dup := seen[trimmed]; dup {
+			continue
+		}
+		seen[trimmed] = struct{}{}
+		out = append(out, trimmed)
+	}
+	return out
+}

+ 122 - 0
internal/identity/repository/user_name_repo_test.go

@@ -0,0 +1,122 @@
+// 用户显示名批量只读仓储单测(D4:academic 端点 53 的 `realName` 数据源)。
+//
+// 为什么自带 sqlite 库而不走 `setup(t)`:本包的 `setup` 依赖 `UNIFIED_TEST_DSN`(MySQL 集成测试),
+// 未设置时整包 Skip;本用例要证明的是**三条纯逻辑口径**(NULL → 不返回、空白 → 不返回、批量去重),
+// 与方言无关,用 sqlite 才能在默认 CI 环境真跑起来。
+package repository
+
+import (
+	"context"
+	"path/filepath"
+	"testing"
+	"time"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/identity/model"
+)
+
+// newUserNameTestDB 建只含 `t_user` 的 sqlite 库。
+func newUserNameTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "user_name_repo_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(显示名测试库) 失败: %v", err)
+	}
+	sqlDB, err := db.DB()
+	if err != nil {
+		t.Fatalf("取 sql.DB 失败: %v", err)
+	}
+	sqlDB.SetMaxOpenConns(1)
+	t.Cleanup(func() { _ = sqlDB.Close() })
+	if err := db.AutoMigrate(&model.User{}); err != nil {
+		t.Fatalf("AutoMigrate(t_user) 失败: %v", err)
+	}
+	return db
+}
+
+// insertUser 落一个用户(display_name 可 NULL)。
+func insertUser(t *testing.T, db *gorm.DB, uid string, displayName *string) {
+	t.Helper()
+	row := &model.User{CanonicalUserID: uid, DisplayName: displayName}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("插入用户 %s 失败: %v", uid, err)
+	}
+	_ = time.Now()
+}
+
+// TestDisplayNamesContracts 三条口径 + 批量去重(**任一条写错都会让端点 53 的姓名列出现脏值**)。
+//
+//	A 有名字          → 返回 "张三"
+//	B display_name 为 NULL → **不进 map**(调用方下发 null)
+//	C display_name 为空白串 → **不进 map**(不能把 " " 当姓名)
+//	D 有名字但重复传两次  → 只查一次、结果一次
+//	E 库里不存在         → 不进 map
+func TestDisplayNamesContracts(t *testing.T) {
+	db := newUserNameTestDB(t)
+	const (
+		userA = "01HUSERA0000000000000000A"
+		userB = "01HUSERB0000000000000000B"
+		userC = "01HUSERC0000000000000000C"
+		userD = "01HUSERD0000000000000000D"
+		userE = "01HUSERE0000000000000000E"
+	)
+	insertUser(t, db, userA, strPtr("张三"))
+	insertUser(t, db, userB, nil)
+	insertUser(t, db, userC, strPtr("   "))
+	insertUser(t, db, userD, strPtr("李四"))
+
+	repo := NewUserNameRepo(db)
+	names, err := repo.DisplayNames(context.Background(),
+		[]string{userA, userB, userC, userD, userD, userE, "  "})
+	if err != nil {
+		t.Fatalf("DisplayNames 失败: %v", err)
+	}
+
+	if got, ok := names[userA]; !ok || got != "张三" {
+		t.Fatalf("A = %q(在 map 内=%v),期望 张三", got, ok)
+	}
+	if _, ok := names[userB]; ok {
+		t.Fatalf("B 在 map 内:display_name 为 NULL 时**不得**返回(调用方要据此下发 null)")
+	}
+	if _, ok := names[userC]; ok {
+		t.Fatalf("C 在 map 内:display_name 为空白串时**不得**返回(空白不是姓名)")
+	}
+	if got, ok := names[userD]; !ok || got != "李四" {
+		t.Fatalf("D = %q(在 map 内=%v),期望 李四", got, ok)
+	}
+	if _, ok := names[userE]; ok {
+		t.Fatalf("E 在 map 内:库中不存在该用户,不得返回")
+	}
+	if len(names) != 2 {
+		t.Fatalf("返回条数 = %d,期望 2(只有 A/D 有真名字):%v", len(names), names)
+	}
+}
+
+// TestDisplayNamesEmptyInput 空入参不发 SQL、返回空 map(不是 nil,避免调用方 nil 解引用)。
+func TestDisplayNamesEmptyInput(t *testing.T) {
+	db := newUserNameTestDB(t)
+	repo := NewUserNameRepo(db)
+	for _, ids := range [][]string{nil, {}, {"", "  "}} {
+		names, err := repo.DisplayNames(context.Background(), ids)
+		if err != nil {
+			t.Fatalf("空入参不该报错: %v", err)
+		}
+		if names == nil {
+			t.Fatalf("空入参应返回空 map 而非 nil(入参 %v)", ids)
+		}
+		if len(names) != 0 {
+			t.Fatalf("空入参应返回 0 条,实际 %d", len(names))
+		}
+	}
+}
+
+// TestDisplayNamesNilDB 未装配 DB 时返回错误(不得 panic)。
+func TestDisplayNamesNilDB(t *testing.T) {
+	repo := NewUserNameRepo(nil)
+	if _, err := repo.DisplayNames(context.Background(), []string{"01HUSERA0000000000000000A"}); err == nil {
+		t.Fatal("DB 为 nil 时应返回错误,而不是静默返回空 map(那会让\"没名字\"与\"库连不上\"混为一谈)")
+	}
+}

+ 6 - 0
internal/infra/rbac/enforcer.go

@@ -47,6 +47,12 @@ type ruleRow struct {
 	V3    string `gorm:"column:v3"`
 }
 
+// TableName 锁定表名(**必须显式**:GORM 会把 `ruleRow` 复数化为 `rule_rows`,
+// 而目标态 DDL 表名是 `casbin_rule`——不写这一行,LoadPolicy 会去读一张不存在的表
+// `rule_rows`,NewEnforcer 直接报 "no such table",策略永远加载不到;
+// AddPolicy/SavePolicy 同理会写到错误的表。AC-14 接线时首次真库/文件库跑通暴露,属 S1 遗留缺陷)。
+func (ruleRow) TableName() string { return "casbin_rule" }
+
 // 编译期断言:gormAdapter 须满足 casbin persist.Adapter(签名漂移即编译失败)。
 var _ persist.Adapter = (*gormAdapter)(nil)