Quellcode durchsuchen

feat(academic): AC-13c 课程 Handler(端点 44~57)

Q vor 10 Stunden
Ursprung
Commit
fec4cbecf5

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

@@ -0,0 +1,1048 @@
+// 课程 / 排班 / 打卡 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 (
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+
+	"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)
+	}
+	views := make([]studentRowView, 0, len(rows))
+	for i := range rows {
+		row := &rows[i]
+		views = append(views, studentRowView{
+			UserID:              row.UserID,
+			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 跨校区)/
+// **3140**(该学生已在本课程在读)/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,
+			Quota:  d.Quota.String(),
+			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
+}
+
+// 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]
+}

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

@@ -0,0 +1,1334 @@
+// 课程 / 排班 / 打卡端点单测(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 (
+	"encoding/json"
+	"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"
+	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 表级隔离,不受影响)。
+	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()
+	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 "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_AC11Defect:
+	// 本批实测发现 AC-11 的 `CreateCourse` 落库后 status = 1(而非 0),成因与处置见该用例注释。
+	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_AC11Defect **复现 AC-11 缺陷(本批只报不修,待裁决)**。
+//
+// 现象(实测,非推测):`CourseService.CreateCourse` 里显式写了
+// `Status: model.CoursesStatusUnpublished`(0),但 `model.Courses.Status` 的 GORM tag 是
+// `not null;default:1` —— GORM 对"带 default tag 且值为零值"的字段**不把该列写进 INSERT**,
+// 改由数据库默认值填充 ⇒ 新建课程实际落 **status=1(发布未开始)**。
+//
+// 为什么不是测试环境假象:MySQL 与 SQLite 走的是同一段 GORM 回调(`Create` 前对 default 列
+// 置 `DefaultValueInterface`),因此生产同样会落 1。后果是"新建即发布"——课程直接出现在
+// 前台可报名列表里,与 `CreateCourse` 注释"新建恒落未发布、发布走状态机"直接矛盾。
+//
+// 为什么不本批直接修:修复点落在 **AC-11 的 `repository.CreateCourse`**(`tx.Create(row)`
+// 需改为 `tx.Select("*").Create(row)` 或显式列出含 `status` 的列),属"改 AC-11 既有实现",
+// 按本批纪律**先报不改**。修复落地后,删掉下面这行 `t.Skip` 即可作为回归门禁。
+func TestCreateCourseStatusIsUnpublished_AC11Defect(t *testing.T) {
+	t.Skip("AC-11 缺陷待裁决:新建课程 status 被 GORM default tag 吞掉,实际落 1(发布未开始)")
+	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))
+	}
+	if _, hasName := row["realName"]; hasName {
+		t.Fatalf("响应下发了 realName(%s):本域无 t_user,不得凭空编姓名", string(data))
+	}
+}
+
+// ---------------------------------------------------------------- 端点 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.CodeBusinessParam {
+		t.Fatalf("重复报名 = %d(%s),期望 3140(已在读报课)", 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)
+	}
+}
+
+// ---------------------------------------------------------------- 端点 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\"(上级 quota)", d0["amount"])
+	}
+	if got, _ := d0["userId"].(string); got != fx.Upline {
+		t.Fatalf("divide[0].userId = %v,期望 %s", d0["userId"], fx.Upline)
+	}
+
+	// ① 打卡行三列
+	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)
+	}
+}
+
+// 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)
+		}
+	}
+}

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

@@ -0,0 +1,495 @@
+// 课程域请求 / 响应 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[]` 的元素(契约 §3.4 C 行 51 去掉无列字段 `level`)。
+type divideGrantView struct {
+	// UserID 获得分成的用户(源列 `t_user_divide_into_log.user_id`;契约未列,用于区分元素)。
+	UserID string `json:"userId"`
+	// Quota 本次分成额度(源列 `t_user_divide_into_log.money`,入账当时即节点 quota)。
+	Quota string `json:"quota"`
+	// Amount 本次入账金额(同上列)。
+	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`**:姓名在 identity 域(`t_user`),academic 域不持有、跨域只读要经 port,
+// 本批未注入姓名解析器 ⇒ 不下发(空字符串会让前端显示空白且无法区分"未填"与"查不到")。
+type studentRowView struct {
+	// UserID 学生 ULID(源列 `student_id`)。
+	UserID string `json:"userId"`
+	// 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"`
+}

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

@@ -162,6 +162,14 @@ 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,AC-11 `CounteractArrears`)。
+	maintenance *service.MaintenanceService
 }
 
 // servicesFor 按请求作用域装配一套服务。
@@ -175,6 +183,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 +201,10 @@ 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),
 	}
 }
 

+ 72 - 5
internal/academic/handler/route.go

@@ -1,17 +1,21 @@
-// academic 域**路由装配单元**(AC-14 第一批:40 个已交付端点的接线 + RBAC 种子来源)。
+// academic 域**路由装配单元**(AC-14 第一批 40 条 + AC-13c 15 条 = 55 条 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 对齐 + 一级段白名单断言对象 = 本装配单元)。
 //
 // ---------------------------------------------------------------------------
-// 一、本批范围(**只挂已交付的 40 个端点**)
+// 一、本批范围(**只挂已交付的端点**:AC-14 第一批 40 条 + AC-13c 15 条)
 // ---------------------------------------------------------------------------
 //
 //	财务 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)
+//
+// **总数 55 条 method+path**(40 + 15),对应**契约 54 个已实现端点**
+// (63 个端点里未实现的 9 个:18/19/20 支付端点 + 58~63 课酬/收支/维护;端点 45 因 POST+PUT 占两条路由)。
 //
 // **端点 18/19/20(`/pay/*`)本批不挂**:handler 尚未实现(不属 AC-12 的 1~17/21,
 // 也不属 AC-13 的 22~63)。**不挂空壳/占位路由**——空壳会让"未实现"看起来像"已实现但返回怪码",
@@ -37,6 +41,8 @@
 //	   则该用例恒绿——这正是"守卫本身要能被证伪"的意义(设计 §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" 失败)。
 //
@@ -128,7 +134,7 @@ type RoleGuard func(roles ...string) fiber.Handler
 //   - Auth fiber.Handler:验签 + 作用域注入中间件(**必填**;本批 40 端点**全部**需登录,
 //     公开端点将来在别的装配单元里挂,不得混进这里)。
 //   - RoleGuard RoleGuard:角色白名单中间件工厂(**必填**)。
-//   - Routes []Route:注册表(**nil = 用 `Routes(h)` 的 40 条**)。
+//   - Routes []Route:注册表(**nil = 用 `Routes(h)` 的全部路由**)。
 //     非 nil 只给"能自证失败"的变异用例用(e2e 注册一条 `/auth/xxx` 验证断言 ② 会红),
 //     生产装配恒传 nil。
 type RegisterOptions struct {
@@ -147,7 +153,7 @@ type Registration struct {
 	Prefixes []string
 }
 
-// Routes 返回本批 40 条路由(**顺序即注册顺序,静态段全部在 `:id` 之前**)。
+// Routes 返回全部已交付路由(55 条:AC-14 40 条 + AC-13c 15 条;**顺序即注册顺序,静态段全部在 `:id` 之前**)。
 //
 // ⚠ 顺序规则(Fiber 按注册顺序匹配;违反即"静态段被 `:id` 吃掉"):
 //
@@ -290,6 +296,67 @@ func Routes(h *Handler) []Route {
 		{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},
 	}
 }
 
@@ -310,7 +377,7 @@ func Register(app *fiber.App, h *Handler, opt RegisterOptions) (*Registration, e
 		return nil, fmt.Errorf("academic route: app 未注入")
 	}
 	if opt.Auth == nil {
-		return nil, fmt.Errorf("academic route: Auth 中间件未注入(本批 40 端点全部需登录)")
+		return nil, fmt.Errorf("academic route: Auth 中间件未注入(本域端点全部需登录)")
 	}
 	if opt.RoleGuard == nil {
 		return nil, fmt.Errorf("academic route: RoleGuard(角色白名单工厂)未注入")

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

@@ -107,7 +107,7 @@ func requestPath(tmpl string) string {
 
 // ---------------------------------------------------------------- ① 注册表 ↔ 契约
 
-// TestRouteRegistryMatchesContract 40 条端点与契约 §2.1/§2.2/§2.3 逐条对齐。
+// TestRouteRegistryMatchesContract 55 条路由与契约 §2.1/§2.2/§2.3/§2.4 逐条对齐。
 //
 // 期望值**手写**(测试即契约快照):与 `route.go` 的生成逻辑不同源,才有对齐价值——
 // 若也写成"从路由表推导",这条用例就只能证明"路由表等于自己"。
@@ -167,10 +167,26 @@ func TestRouteRegistryMatchesContract(t *testing.T) {
 		{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"},
 	}
 
 	if len(routes) != len(want) {
-		t.Fatalf("路由条数 = %d,期望 %d(本批只挂已交付的 40 个端点)", len(routes), len(want))
+		t.Fatalf("路由条数 = %d,期望 %d(AC-14 第一批 40 + AC-13c 15 = 55 条 method+path)", len(routes), len(want))
 	}
 
 	// 逐条对齐**用集合比**而不是按下标比:注册顺序是"静态段优先"(≠ 契约端点号顺序),
@@ -202,7 +218,7 @@ func TestRouteRegistryMatchesContract(t *testing.T) {
 		delete(gotByKey, key)
 	}
 	for key := range gotByKey {
-		t.Errorf("注册了契约外的路由 %s(本批只挂已交付的 40 个端点)", key)
+		t.Errorf("注册了契约外的路由 %s(当前只挂已交付的 55 条)", key)
 	}
 
 	// 反向断言:18/19/20(支付)**本批不得挂**——handler 尚未实现,挂空壳会让"未实现"
@@ -310,8 +326,8 @@ func TestAssertUniqueMethodPath(t *testing.T) {
 	if err := AssertUniqueMethodPath(app.GetRoutes()); err != nil {
 		t.Fatalf("40 条路由不该有重复:%v", err)
 	}
-	if len(reg.Routes) != 40 {
-		t.Fatalf("注册条数 = %d,期望 40", len(reg.Routes))
+	if len(reg.Routes) != 55 {
+		t.Fatalf("注册条数 = %d,期望 55(40 + 15)", len(reg.Routes))
 	}
 
 	// 正例:同 path 不同 method 是**合法**的(端点 12/13 共用 /admin/finance/requests)

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

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

@@ -0,0 +1,1126 @@
+// 课程域**补充服务**(端点 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(跨校区)/3140(该学生已在本课程在读)/
+// 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.ErrBusinessParam,
+				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[]`)。
+//
+// 字段来源:
+//
+//	UserID ← t_user_divide_into_log.user_id(获得分成的用户)
+//	Quota  ← t_user_divide_into_log.money(入账金额;**该值就是入账当时的节点 quota**,
+//	         见 model.UserDivideIntoLog.Money 注释"写入:上行分成时取节点 quota")
+//	Amount ← t_user_divide_into_log.money(同上)
+//
+// ⚠ **契约的 `divide[].level` 不下发**:`t_user_divide_into_log` **没有层级列**
+// (只有 `type` = 分成类型 1 一次付清 / 2 按课次);层级 `t_user_divide_into.layer` 是**当前值**,
+// 不是"入账当时"的值,用它顶替会出现"历史明细显示今天的层级"(时间维度错位,比字段恒空更难查)。
+// 按契约 §3.4 ③「找不到列的一律不写」⇒ 删除该字段,并补 `userId` 以免数组元素无法区分。
+// 已登记偏差,待契约升版。
+type DivideGrantRow struct {
+	// UserID 获得分成的用户(ULID)。
+	UserID string
+	// Quota 本次分成额度(= 入账金额)。
+	Quota money.Amount
+	// Amount 本次入账金额。
+	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 结课响应装配服务(**只读**;不参与结账事务、不写任何表)。
+//
+// 为什么单独一个服务:`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,
+				Quota:  amount,
+				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. 这里的试算**不带教龄**(见 `recordRewardQuoter` 的偏差注释),只用于定位规则 id。
+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
+	}
+	quote, qerr := s.payroll.QuoteTeacherReward(ctx, QuotePayrollInput{
+		TeacherID:     teacherID,
+		TeacherExp:    0, // ⚠ AC-11 端口不携带教龄,统一按 0(偏差已登记)
+		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`)。
+//
+// ⚠ **两处能力缺口(AC-11 端口签名所致,已登记偏差,不在本批改端口)**:
+//
+//  1. `RewardQuoteInput` **没有"本次课收入"字段** ⇒ 无法计算"按比例提成"部分
+//     (真实口径是 `percentage × 课收入`,再按 `min/max` 截断后加 `reward`)。
+//     本适配返回方案里的 **`reward`(额外课酬)列**,即"固定课酬"型方案可用、纯比例型方案算不出。
+//  2. `RewardQuoteInput` **没有"教龄"字段** ⇒ 统一按 0 年(dump 对负数也是按 0 兜底,
+//     因此只影响"教龄 > 0 才命中的档位",不影响 0 年档)。
+//
+// 结论:本适配是**占位接线**,让端点 51 的课酬链路(试算 → 分发 → 落库 → 汇总校验)
+// 真正跑通并可断言;口径补齐需 AC-11 扩端口(加 `Income` / `TeacherExp`),
+// 那属于服务层改造,本批按纪律**不改**。
+type recordRewardQuoter struct {
+	svc *PayrollService
+}
+
+// QuoteRecordReward 实现 `service.TeacherRewardQuoter`。
+//
+// 入参:ctx;in 端口入参。出参:本课课酬(元);3112 原样上抛(由 RecordService 降级为 0 课酬)。
+func (q recordRewardQuoter) QuoteRecordReward(ctx context.Context, in RewardQuoteInput) (money.Amount, error) {
+	if q.svc == nil {
+		return 0, nil
+	}
+	quote, err := q.svc.QuoteTeacherReward(ctx, QuotePayrollInput{
+		TeacherID:     in.TeacherID,
+		TeacherExp:    0, // 端口无教龄,见类型注释
+		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 quote.Reward, 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))
+}