Просмотр исходного кода

feat(academic): AC-13d 第一批——端点 59 课酬试算 + 61~63 维护型(58/60 待契约裁决)

Q 10 часов назад
Родитель
Сommit
fcc2a30d6f

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

@@ -0,0 +1,104 @@
+// 课酬 / 维护 端点请求 DTO(端点 59、61、62、63)——AC-13d 第一批。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5(端点表)/ §3.4 D 表(字段级);
+// 设计 `academic-domain-design.md` §4.5(端点清单与角色)/ §3.5(SP28/SP29)/ §3.6(SP30/SP31)。
+//
+// ---------------------------------------------------------------------------
+// 本批**只做 59/61/62/63**,58 与 60 因契约形状冲突暂停(已登记偏差,等裁决)
+// ---------------------------------------------------------------------------
+//
+//		58 课酬方案 —— 契约把"方案(t_course_payment)"与"明细(t_course_payment_detail)"**压成一行**:
+//		   `id/schoolId/campusId/effectDate` 是方案列,`teacherId/classifyId/percentage/min/max/reward` 是明细列,
+//		   `actived` 两级都有;且 `teacherExp:number` **一个字段要承载 `teacher_exp_min` + `teacher_exp_max` 两列**。
+//	  ⇒ 需要契约先回答三件事:入参是否嵌套 `details[]`、`id` 指方案还是明细、教龄区间怎么表达。
+//	    在此之前**不落代码**(按"字段准入条件"硬门槛,写不出源列就不进契约)。
+//
+//		60 收支 —— 契约 `GET/POST /admin/expenditures[/types]` 把**字典**(`t_expenditure`)与
+//		   **记录**(`t_expenditure_log`)放在同一组路径下,而行字段 `type/name/direction` 三个里
+//		   `name`/`direction` 在记录表**不存在**(记录表只有 `expenditure_id`,方向要 JOIN 字典的 `type`);
+//		   `bizNo` 只在记录表有。⇒ 需要契约把"字典"与"记录"的响应分别定义(当前一行行结构无法同时满足两者)。
+//
+// 下面四个端点的字段全部可逐列溯源,故先行落地。
+package handler
+
+// ---------------------------------------------------------------- 端点 59:课酬试算
+
+// PayrollQuoteReq 端点 59 课酬试算请求体(`POST /admin/payroll/quote`)。
+//
+// 契约 §2.5 行 59 原文入参:`teacherId,schoolId,campusId,classifyId,teacherExp,date`(六个都必填)。
+//
+// ⚠ **本端点无法给出"最终课酬金额"**:最终金额还需要 `income`(本次课收入)、`studentCount`、
+// `teacherRewardType` 三个入参,契约没给 ⇒ 本端点只返回**命中的规则**(含 percentage/min/max/reward),
+// 由前端自行展示;**最终金额只在结课(端点 51)产生**,两者共用同一 `computeRecordReward`(不重复实现公式)。
+// 已在交付报告登记为偏差(若产品要"试算金额",契约补三个入参即可)。
+type PayrollQuoteReq struct {
+	// TeacherID 老师 ULID(必填)。
+	TeacherID string `json:"teacherId" validate:"required,max=26"`
+	// SchoolID 机构 ID(必填 >0;旧口径 schoolid = 目标态 institution_id)。
+	SchoolID int64 `json:"schoolId" validate:"required,gt=0"`
+	// CampusID 校区 ID(0 = 机构级方案;契约要求必传,0 合法)。
+	CampusID int64 `json:"campusId"`
+	// ClassifyID 课程分类 ID(必填 >0;提供 grade/subject/class_type 三个维度)。
+	ClassifyID int64 `json:"classifyId" validate:"required,gt=0"`
+	// TeacherExp 教龄(年;可含小数;负数按 0,与 dump 763609 一致)。
+	TeacherExp float64 `json:"teacherExp"`
+	// Date 试算日期(`2006-01-02` 或 RFC3339;方案 `effect_date <= date`,**含当日**)。
+	Date string `json:"date" validate:"required"`
+}
+
+// payrollQuoteView 端点 59 响应(契约 §4.6 `PayrollQuote` 的 9 个字段,**不得裁掉溯源字段**)。
+//
+// 字段来源(逐列溯源):
+//
+//	detailId       ← t_course_payment_detail.id
+//	schemeId       ← t_course_payment.id
+//	schemeCampusId ← t_course_payment.campus_id(0 = 机构级方案)
+//	schemeScope    ← 由命中层级推导(campus | institution)——**不是列**,而是"从哪个 campus_id 查到的"
+//	ruleTier       ← 由命中规则类型推导(personal_classify | personal_grade_subject | cert_general | nocert_general)
+//	percentage     ← t_course_payment_detail.percentage(**百分数字符串**,非金额)
+//	min            ← min_payment;max ← max_payment;reward ← reward
+//
+// `schemeScope` / `ruleTier` 属**可推导的溯源标签**(不是新造业务值):它们回答"这条规则为什么被命中",
+// 是排障必需项(§4.6 v0.1.29 明确"不得裁掉")。
+type payrollQuoteView struct {
+	// DetailID 命中的课酬明细规则 ID。
+	DetailID int64 `json:"detailId"`
+	// SchemeID 命中的课酬方案 ID。
+	SchemeID int64 `json:"schemeId"`
+	// SchemeCampusID 命中方案所属校区(0 = 机构级)。
+	SchemeCampusID int64 `json:"schemeCampusId"`
+	// SchemeScope 方案层级(campus / institution)。
+	SchemeScope string `json:"schemeScope"`
+	// RuleTier 规则层级(personal_classify / personal_grade_subject / cert_general / nocert_general)。
+	RuleTier string `json:"ruleTier"`
+	// Percentage 课酬比例(**百分数字符串** "85.0",不是 0.85、不是金额)。
+	Percentage string `json:"percentage"`
+	// Min 最低课酬标准(元字符串;"0.00" = 不限制)。
+	Min string `json:"min"`
+	// Max 最高课酬标准(元字符串;"0.00" = 不限制)。
+	Max string `json:"max"`
+	// Reward 额外课酬(元字符串;在比例基础上追加)。
+	Reward string `json:"reward"`
+}
+
+// ---------------------------------------------------------------- 端点 61 / 62 / 63:维护
+
+// MaintenanceResultResp 维护类端点(61/62/63)的响应(契约 §3.4 D 表:`affected` + `message`)。
+//
+// `affected` = 本次**实际写库**的行数;`message` = 面向运维的一段人话(含"可重跑/已是最新"判据)。
+// 三个端点响应同构,用同一结构体(避免三份一模一样的东西漂移)。
+type MaintenanceResultResp struct {
+	// Affected 本次实际影响(写入)的行数。
+	Affected int64 `json:"affected"`
+	// Message 结果说明(含幂等判据:第二次执行应为 0 / 已是最新)。
+	Message string `json:"message"`
+}
+
+// ActivitySyncReq 端点 63 活动数据同步请求体(`POST /admin/maintenance/activity-sync`)。
+//
+// 契约 §2.5 行 63:`mode` ∈ {`used-log`, `signed`};缺省/非法 → **2001**(契约明确写 2001,
+// 不是 2002——因为"缺参"与"枚举非法"在这里都按"必须指定同步模式"处理)。
+type ActivitySyncReq struct {
+	// Mode 同步模式(必填:`used-log` → SP30 重建 used_log;`signed` → SP31 重放 signed)。
+	Mode string `json:"mode" validate:"required"`
+}

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

@@ -172,8 +172,10 @@ type scopedServices struct {
 	record *service.RecordService
 	record *service.RecordService
 	// recordView 结课响应溯源装配(端点 51 的 `divide[]` / `payroll.detailId`;**只读、事务之后**)。
 	// recordView 结课响应溯源装配(端点 51 的 `divide[]` / `payroll.detailId`;**只读、事务之后**)。
 	recordView *service.RecordViewService
 	recordView *service.RecordViewService
-	// maintenance 欠费核销(端点 56;SP27,AC-11 `CounteractArrears`)。
+	// maintenance 欠费核销(端点 56;SP27)与维护型修复(端点 61~63;SP28~SP31)。
 	maintenance *service.MaintenanceService
 	maintenance *service.MaintenanceService
+	// payroll 课酬试算(端点 59;SP26)。**与结课共用同一算法规格**(见 service.computeRecordReward)。
+	payroll *service.PayrollService
 }
 }
 
 
 // servicesFor 按请求作用域装配一套服务。
 // servicesFor 按请求作用域装配一套服务。
@@ -209,6 +211,7 @@ func (h *Handler) servicesFor(sc *appscope.Scope) *scopedServices {
 		record:           record,
 		record:           record,
 		recordView:       service.NewRecordViewService(h.db, sc, payroll),
 		recordView:       service.NewRecordViewService(h.db, sc, payroll),
 		maintenance:      service.NewMaintenanceService(h.db, sc),
 		maintenance:      service.NewMaintenanceService(h.db, sc),
+		payroll:          payroll,
 	}
 	}
 }
 }
 
 

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

@@ -0,0 +1,228 @@
+// 课酬试算 + 维护型端点处理器(端点 59、61、62、63)——AC-13d 第一批。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 / §3.4 D 表;
+// 设计 `academic-domain-design.md` §4.5(角色白名单)/ §3.5(SP28/SP29)/ §3.6(SP30/SP31)/
+// §4.6(`PayrollQuote` 9 字段,**不得裁掉溯源字段**)。
+//
+// ---------------------------------------------------------------------------
+// 端点 → 服务方法对照
+// ---------------------------------------------------------------------------
+//
+//	59 POST /admin/payroll/quote                    PayrollService.QuoteTeacherReward(AC-10,SP26)
+//	61 POST /admin/maintenance/courses-max-people   MaintenanceService.BackfillCoursesMaxPeople(SP28)
+//	62 POST /admin/maintenance/courses-campus       MaintenanceService.BackfillCoursesCampus(SP29)
+//	63 POST /admin/maintenance/activity-sync        RebuildActivityUsedLog(SP30)/ ReplayActivitySigned(SP31)
+//
+// 本批**不含** 58(课酬方案 CRUD)与 60(收支字典/记录):契约把"方案+明细"、"字典+记录"
+// 各自压成一行,字段无法逐列溯源(详见 `dto_payroll.go` 文件头),已报偏差等裁决。
+//
+// ---------------------------------------------------------------------------
+// 三条纪律(与 course_handler.go 同源)
+// ---------------------------------------------------------------------------
+//
+//  1. **61~63 仅 `super_admin`**:路由白名单是第一道(契约 §2.5),Service 层 `assertSuperAdmin`
+//     是第二道(AC-11 已交付)。**两层都要有**——白名单写错时第二层仍拦得住。
+//  2. **维护端点是"全表级"动作**(SP30 会清 `used_log` 整表):Handler 不提供任何"部分执行"参数,
+//     也没有 dry-run;要重跑就再调一次(三个 SP 都幂等)。
+//  3. 维护类不做作用域过滤(跨机构修数据是**目的**),因此**越权防线只有白名单 + 超管断言**两条。
+package handler
+
+import (
+	"strconv"
+	"strings"
+
+	"github.com/gofiber/fiber/v2"
+
+	"yuansiwei/unified-backend/internal/academic/service"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/infra/response"
+)
+
+// ---------------------------------------------------------------- 路径常量(**不含 `/api`**)
+
+const (
+	// PathPayrollQuote 端点 59:课酬试算(`POST /admin/payroll/quote`)。
+	PathPayrollQuote = "/admin/payroll/quote"
+	// PathMaintenanceCoursesMaxPeople 端点 61:修复课程 max_people(SP28)。
+	PathMaintenanceCoursesMaxPeople = "/admin/maintenance/courses-max-people"
+	// PathMaintenanceCoursesCampus 端点 62:修复课程 campus_id(SP29)。
+	PathMaintenanceCoursesCampus = "/admin/maintenance/courses-campus"
+	// PathMaintenanceActivitySync 端点 63:活动数据同步(SP30/SP31)。
+	PathMaintenanceActivitySync = "/admin/maintenance/activity-sync"
+)
+
+// activitySyncModeUsedLog 端点 63 的 mode 取值:重建 `t_courses_activity_used_log`(SP30)。
+const activitySyncModeUsedLog = "used-log"
+
+// activitySyncModeSigned 端点 63 的 mode 取值:重放 `t_courses_activity_signed`(SP31)。
+const activitySyncModeSigned = "signed"
+
+// QuotePayroll 课酬试算(端点 59 `POST /admin/payroll/quote`,SP26 dump 763600~763670)。
+//
+// 入参(body):见 `PayrollQuoteReq`(六个字段全必填)。
+// 出参(成功):`payrollQuoteView`(9 字段:4 业务列 + 4 溯源字段 + detailId)。
+// 出参(失败):1001/2001(缺参/日期格式非法)/1004(schoolId 与作用域机构不一致)/
+// **3112**(两级方案都没有 or 四级规则一条都没命中)/5002。
+//
+// **与端点 51 的关系(重要)**:本端点返回的是**规则**(percentage/min/max/reward),
+// **不是最终课酬金额**——最终金额还需要 `income`/`studentCount`/`teacherRewardType`,
+// 契约没给这三个入参。二者共用同一 `computeRecordReward`(结课侧),本端点**不重复实现公式**,
+// 因此不会出现"试算一套、结课另一套"。
+func (h *Handler) QuotePayroll(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req PayrollQuoteReq
+	if !validateReq(c, &req) {
+		return nil // body code=2001 已写出
+	}
+	date, hasDate, derr := parseTimeParam(req.Date, "date")
+	if derr != nil {
+		return response.Fail(c, derr)
+	}
+	if !hasDate {
+		// `validate:"required"` 已挡住空串;这里只兜"传了空白串被 trim 掉"的情形
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("date 不能为空")))
+	}
+	if req.TeacherExp < 0 {
+		// dump 763609:负数按 0 兜底(不报错)。此处**不拒绝**,只归一,保持与旧系统一致。
+		req.TeacherExp = 0
+	}
+	svc := h.servicesFor(sc)
+	quote, err := svc.payroll.QuoteTeacherReward(c.Context(), service.QuotePayrollInput{
+		TeacherID:     strings.TrimSpace(req.TeacherID),
+		TeacherExp:    req.TeacherExp,
+		InstitutionID: req.SchoolID,
+		CampusID:      req.CampusID,
+		ClassifyID:    req.ClassifyID,
+		Date:          date,
+	})
+	if err != nil {
+		return response.Fail(c, err) // 2001/1004/3112/5002
+	}
+	return response.OK(c, payrollQuoteView{
+		DetailID:       quote.DetailID,
+		SchemeID:       quote.SchemeID,
+		SchemeCampusID: quote.SchemeCampusID,
+		SchemeScope:    quote.SchemeScope,
+		RuleTier:       quote.RuleTier,
+		Percentage:     quote.Percentage,
+		Min:            quote.Min.String(),
+		Max:            quote.Max.String(),
+		Reward:         quote.Reward.String(),
+	})
+}
+
+// BackfillCoursesMaxPeople 修复课程 `max_people`(端点 61 `POST /admin/maintenance/courses-max-people`,SP28)。
+//
+// 入参:无 body。
+// 出参(成功):`{affected, message}`(**可重跑**:第二次 `affected=0`)。
+// 出参(失败):1001/**1004**(非超管)/5002。
+//
+// 幂等判据(AC-11 已实现):只处理 `max_people = 0` 的行;分类也没有值时**跳过不写 0**
+// (写 0 等于不改,却会让运维以为"已修复")。
+func (h *Handler) BackfillCoursesMaxPeople(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.maintenance.BackfillCoursesMaxPeople(c.Context())
+	if err != nil {
+		return response.Fail(c, err) // 1004 非超管 / 5002
+	}
+	msg := "已回填 " + strconv.FormatInt(int64(res.Filled), 10) + " 门课程的 max_people"
+	if res.Filled == 0 {
+		msg = "无待回填课程(可重跑:已是最新)"
+	}
+	if res.Skipped > 0 {
+		msg += ";跳过 " + strconv.FormatInt(int64(res.Skipped), 10) + " 门(分类未配置,未写 0)"
+	}
+	return response.OK(c, MaintenanceResultResp{Affected: res.Affected, Message: msg})
+}
+
+// BackfillCoursesCampus 修复课程 `campus_id`(端点 62 `POST /admin/maintenance/courses-campus`,SP29)。
+//
+// 入参:无 body。
+// 出参(成功):`{affected, message}`(**可重跑**)。
+// 出参(失败):1001/**1004**(非超管)/5002。
+//
+// **反直觉口径(必须保留)**:多校区课程(去重校区数 ≥ 2)**不回填、保持 0**("机构级课程");
+// 无排班规则的课程(计数 = 0)同样不回填(0 ≠ 1)。
+func (h *Handler) BackfillCoursesCampus(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	svc := h.servicesFor(sc)
+	res, err := svc.maintenance.BackfillCoursesCampus(c.Context())
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	msg := "已回填 " + strconv.FormatInt(int64(res.Updated), 10) + " 门课程的 campus_id"
+	if res.Updated == 0 {
+		msg = "无待回填课程(可重跑:已是最新)"
+	}
+	if res.SkippedMultiCampus > 0 {
+		msg += ";多校区课程 " + strconv.FormatInt(int64(res.SkippedMultiCampus), 10) + " 门保持 0(机构级,不填)"
+	}
+	if res.SkippedNoRule > 0 {
+		msg += ";无排班规则 " + strconv.FormatInt(int64(res.SkippedNoRule), 10) + " 门未动"
+	}
+	return response.OK(c, MaintenanceResultResp{Affected: int64(res.Updated), Message: msg})
+}
+
+// SyncActivityData 活动数据同步(端点 63 `POST /admin/maintenance/activity-sync`,SP30 / SP31)。
+//
+// 入参(body):`mode` ∈ {`used-log`, `signed`};**缺省/非法 → 2001**(契约 §3.4 D 表原文)。
+// 出参(成功):`{affected, message}`。
+// 出参(失败):1001/2001(mode 缺失或非法)/**1004**(非超管)/5002。
+//
+// 两个模式各自幂等:
+//
+//	used-log → `RebuildActivityUsedLog`:**同事务 DELETE + 批量 INSERT**(不用 TRUNCATE —— DDL 隐式提交
+//	           会"清空成功、重建失败"时数据全丢,见 §3.8-BUG#3);重跑结果一致。
+//	signed   → `ReplayActivitySigned`:只更新 `signed.status > 0` 的行(已取消报名不得被历史工单"复活")。
+func (h *Handler) SyncActivityData(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req ActivitySyncReq
+	if !validateReq(c, &req) {
+		return nil // body code=2001 已写出
+	}
+	mode := strings.TrimSpace(req.Mode)
+	svc := h.servicesFor(sc)
+	switch mode {
+	case activitySyncModeUsedLog:
+		res, serr := svc.maintenance.RebuildActivityUsedLog(c.Context())
+		if serr != nil {
+			return response.Fail(c, serr)
+		}
+		msg := "used_log 已重建:" + strconv.FormatInt(res.Inserted, 10) + " 行(扫描 " + strconv.FormatInt(int64(res.Scanned), 10) +
+			",删除旧行 " + strconv.FormatInt(res.Deleted, 10)
+		if res.Skipped > 0 {
+			msg += ";跳过 " + strconv.FormatInt(int64(res.Skipped), 10) + " 行(活动/用户为空,目标态 NOT NULL 无法表达)"
+		}
+		return response.OK(c, MaintenanceResultResp{Affected: res.Inserted, Message: msg + ")"})
+	case activitySyncModeSigned:
+		res, serr := svc.maintenance.ReplayActivitySigned(c.Context())
+		if serr != nil {
+			return response.Fail(c, serr)
+		}
+		msg := "signed 已重放:更新 " + strconv.FormatInt(res.Updated, 10) + " 行(扫描 " + strconv.FormatInt(int64(res.Scanned), 10) + ")"
+		if res.SkippedCanceled > 0 {
+			msg += ";跳过 " + strconv.FormatInt(res.SkippedCanceled, 10) + " 行(已取消报名,不得复活)"
+		}
+		return response.OK(c, MaintenanceResultResp{Affected: res.Updated, Message: msg})
+	default:
+		// 契约 §3.4 D 表:mode 缺省/非法 → **2001**(不是 2002)。
+		// 理由:这里的语义是"必须指定同步哪种数据",属**未提供必要参数**,与"提供了非法值"在
+		// 前端提示上是同一动作(去选一个模式),而契约明文写了 2001 ⇒ 按契约,不按 2002 通则。
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("mode 必须是 used-log 或 signed(收到 "+mode+")")))
+	}
+}

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

@@ -0,0 +1,415 @@
+// 课酬试算 + 维护型端点单测(端点 59、61、62、63)——AC-13d 第一批。
+//
+// 断言纪律同前几批:只断 body 契约,**每个端点至少一条"写错会变红"的断言**,尽量落在库上。
+//
+// 覆盖:
+//
+//	59 试算     命中规则 → 9 字段(percentage 是**百分数字符串**不是金额);无方案 → 3112;
+//	            跨机构 → 1004;日期格式非法 → 2002
+//	61 修 max_people  回填后库值 = 分类值;可重跑(第二次 affected=0);分类也 0 的行**不写**
+//	62 修 campus_id   单校区课程回填;**多校区课程保持 0**;无规则课程不动
+//	63 活动同步     mode=used-log 重建 used_log(DELETE+INSERT);mode=signed 只回写 status>0;
+//	            非法 mode → 2001;非超管 → 1004
+package handler
+
+import (
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/apperr"
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// newPayrollTestApp 挂 59/61/62/63 四条路由(**不做角色白名单**:白名单由 e2e 验,
+// 但 61~63 的"仅超管"还有 Service 层 `assertSuperAdmin` 第二道,本文件**要验第二道**)。
+//
+// 作用域:`X-Test-Scope: super` 超管;`academic` 教务(非超管,用于验 1004)。
+func newPayrollTestApp(t *testing.T, db *gorm.DB) *fiber.App {
+	t.Helper()
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	app := fiber.New(fiber.Config{DisableStartupMessage: true})
+	app.Use(func(c *fiber.Ctx) error {
+		switch c.Get("X-Test-Scope") {
+		case "none":
+			// 无作用域 → 1001
+		case "academic":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 10, UserID: "01HACADEMIC0000000000001", Role: RoleAcademic,
+			})
+		default:
+			appscope.Set(c, testSuperScope())
+		}
+		return c.Next()
+	})
+	app.Post(PathPayrollQuote, h.QuotePayroll)
+	app.Post(PathMaintenanceCoursesMaxPeople, h.BackfillCoursesMaxPeople)
+	app.Post(PathMaintenanceCoursesCampus, h.BackfillCoursesCampus)
+	app.Post(PathMaintenanceActivitySync, h.SyncActivityData)
+	return app
+}
+
+// ---------------------------------------------------------------- 端点 59:课酬试算
+
+// TestPayrollQuoteReturnsRuleTraceableFields 端点 59 必须回 9 个**可溯源**字段(不能裁掉 4 个溯源字段)。
+//
+// 变红条件:
+//   - 漏 `schemeScope`/`ruleTier` → 前端无法定位"命中哪一档",且契约 §4.6 明确不得裁掉;
+//   - 把 `percentage` 当金额下发("85.0" → "85.00")→ 量纲污染断言红。
+func TestPayrollQuoteReturnsRuleTraceableFields(t *testing.T) {
+	db := newCourseTestDB(t)
+	classify := seedPayrollClassify(t, db, 1000)
+	scheme, detail := seedPayrollScheme(t, db, classify.ClassType, "85.0", "0.00", "0.00", "0.00")
+	app := newPayrollTestApp(t, db)
+
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathPayrollQuote, map[string]any{
+		"teacherId":  courseTestTeacher,
+		"schoolId":   1,
+		"campusId":   10,
+		"classifyId": classify.ID,
+		"teacherExp": 3.5,
+		"date":       "2026-11-09",
+	}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("课酬试算 = %d(%s),期望 200", code, msg)
+	}
+	payload := jsonMap(t, data)
+
+	if got, _ := payload["detailId"].(float64); got != float64(detail.ID) {
+		t.Fatalf("detailId = %v,期望 %d", payload["detailId"], detail.ID)
+	}
+	if got, _ := payload["schemeId"].(float64); got != float64(scheme.ID) {
+		t.Fatalf("schemeId = %v,期望 %d", payload["schemeId"], scheme.ID)
+	}
+	if got, _ := payload["schemeCampusId"].(float64); got != 10 {
+		t.Fatalf("schemeCampusId = %v,期望 10(校区级方案)", payload["schemeCampusId"])
+	}
+	if got, _ := payload["schemeScope"].(string); got != "campus" {
+		t.Fatalf("schemeScope = %v,期望 campus(命中校区级方案)", payload["schemeScope"])
+	}
+	if got, _ := payload["ruleTier"].(string); got == "" {
+		t.Fatalf("ruleTier 为空:溯源字段不得裁掉(契约 §4.6 v0.1.29)")
+	}
+	// 量纲红线:percentage 是**百分数字符串**,不是金额(不能被 money 格式化)。
+	// ⚠ 不比较字面量:SQLite 无 DECIMAL 类型,"85.0" 读回来是 "85";生产 MySQL 是 "85.0"。
+	// 断言"数值等于 85 且**不是 0.85 / 8500**"——这才是"被当 0~1 比率或被 ×100"的真实失败条件。
+	pctText, _ := payload["percentage"].(string)
+	pctValue, pctErr := strconv.ParseFloat(strings.TrimSpace(pctText), 64)
+	if pctErr != nil {
+		t.Fatalf("percentage = %q 不是合法百分数字符串: %v", pctText, pctErr)
+	}
+	if pctValue != 85 {
+		t.Fatalf("percentage = %q(数值 %v),期望 85(百分数);0.85 说明被当比率、8500 说明被 ×100",
+			pctText, pctValue)
+	}
+	if got, _ := payload["min"].(string); got != "0.00" {
+		t.Fatalf("min = %v,期望 \"0.00\"", payload["min"])
+	}
+}
+
+// TestPayrollQuoteErrors 端点 59 的错误分流:无方案 → 3112;跨机构 → 1004;日期非法 → 2002。
+func TestPayrollQuoteErrors(t *testing.T) {
+	db := newCourseTestDB(t)
+	classify := seedPayrollClassify(t, db, 1000) // 只建分类,**不建方案**
+	app := newPayrollTestApp(t, db)
+	base := map[string]any{
+		"teacherId": courseTestTeacher, "schoolId": 1, "campusId": 10,
+		"classifyId": classify.ID, "teacherExp": 1, "date": "2026-11-09",
+	}
+
+	// 无方案 → 3112(结课侧会把它降级为 0 课酬,但试算入口必须暴露)
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathPayrollQuote, base, "X-Test-Token", "t"); code != apperr.CodePayrollPlanMissing {
+		t.Fatalf("无方案试算 = %d(%s),期望 3112", code, msg)
+	}
+	// 跨机构(作用域机构 1,传入 2)→ 1004。
+	// **必须用非超管作用域**:超管按设计可查任意机构(`assertInstitution` 放行),
+	// 用超管测 1004 会得到 3112(无方案),把"越权没拦"误判成"业务错"。
+	cross := map[string]any{
+		"teacherId": courseTestTeacher, "schoolId": 2, "campusId": 10,
+		"classifyId": classify.ID, "teacherExp": 1, "date": "2026-11-09",
+	}
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathPayrollQuote, cross,
+		"X-Test-Token", "t", "X-Test-Scope", "academic"); code != infraerrors.ErrUnscoped.Code {
+		t.Fatalf("跨机构试算 = %d(%s),期望 1004", code, msg)
+	}
+	// 日期格式非法 → 2002
+	bad := map[string]any{
+		"teacherId": courseTestTeacher, "schoolId": 1, "campusId": 10,
+		"classifyId": classify.ID, "teacherExp": 1, "date": "2026/11/09",
+	}
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathPayrollQuote, bad, "X-Test-Token", "t"); code != infraerrors.ErrBadFormat.Code {
+		t.Fatalf("非法日期 = %d(%s),期望 2002", code, msg)
+	}
+}
+
+// seedPayrollClassify 建课程分类(class_type 决定课酬规则维度)。
+func seedPayrollClassify(t *testing.T, db *gorm.DB, classType int32) *model.CoursesClassify {
+	t.Helper()
+	row := &model.CoursesClassify{
+		InstitutionID: 1, CampusID: 10, ClassType: classType, GradeID: 0, SubjectID: 0,
+		Name: "课酬测试班", UnitPrice: "100.00", MinPeople: 1, MaxPeople: 10,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("建分类失败: %v", err)
+	}
+	return row
+}
+
+// seedPayrollScheme 建一套"校区级方案 + 无证通用规则"(命中确定,便于逐字段断言)。
+func seedPayrollScheme(t *testing.T, db *gorm.DB, classType int32, percentage, minPayment, maxPayment, reward string) (*model.CoursePayment, *model.CoursePaymentDetail) {
+	t.Helper()
+	effect := time.Now().AddDate(0, 0, -30)
+	scheme := &model.CoursePayment{
+		InstitutionID: 1, CampusID: 10, Actived: true, EffectDate: &effect,
+	}
+	if err := db.Create(scheme).Error; err != nil {
+		t.Fatalf("建课酬方案失败: %v", err)
+	}
+	detail := &model.CoursePaymentDetail{
+		CoursePaymentID: scheme.ID, GradeID: -1, SubjectID: -1, ClassType: classType, ClassifyID: 0,
+		Percentage: percentage, MinPayment: minPayment, MaxPayment: maxPayment, Reward: reward,
+		TeacherCert: false, TeacherExpMin: "0.000", TeacherExpMax: "100.000", Actived: true,
+	}
+	if err := db.Create(detail).Error; err != nil {
+		t.Fatalf("建课酬规则失败: %v", err)
+	}
+	return scheme, detail
+}
+
+// ---------------------------------------------------------------- 端点 61:修 max_people
+
+// TestBackfillMaxPeopleIdempotent 端点 61:按分类回填 + 分类未配置的行**不写 0** + 可重跑。
+//
+// 变红条件:
+//   - 分类未配置(max_people=0)的课程被写成 0 → 该断言行"必须保持 NULL/0 且计入 skipped"红;
+//   - 第二次执行 affected≠0 → 幂等红。
+func TestBackfillMaxPeopleIdempotent(t *testing.T) {
+	db := newCourseTestDB(t)
+	// 分类 A:max_people=25;分类 B:max_people=0(未配置)
+	classifyA := seedPayrollClassify(t, db, 1000)
+	classifyA.MaxPeople = 25
+	if err := db.Save(classifyA).Error; err != nil {
+		t.Fatalf("更新分类失败: %v", err)
+	}
+	classifyB := seedPayrollClassify(t, db, 1001) // MaxPeople 默认 1000 → 显式压成 0
+	if err := db.Model(&model.CoursesClassify{}).Where("id = ?", classifyB.ID).
+		UpdateColumn("max_people", 0).Error; err != nil {
+		t.Fatalf("压零分类 B 失败: %v", err)
+	}
+	courseA := seedCourse(t, db, "待回填A", 10)
+	courseA.ClassifyID = classifyA.ID
+	db.Save(courseA)
+	courseB := seedCourse(t, db, "分类未配置B", 10)
+	courseB.ClassifyID = classifyB.ID
+	db.Save(courseB)
+
+	app := newPayrollTestApp(t, db)
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathMaintenanceCoursesMaxPeople, nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("修 max_people = %d(%s),期望 200", code, msg)
+	}
+	if got, _ := jsonMap(t, data)["affected"].(float64); got != 1 {
+		t.Fatalf("affected = %v,期望 1(只有 A 被回填;分类为 0 的行不得写 0)", jsonMap(t, data)["affected"])
+	}
+	var gotA, gotB int32
+	db.Raw("SELECT max_people FROM t_courses WHERE id = ?", courseA.ID).Scan(&gotA)
+	db.Raw("SELECT max_people FROM t_courses WHERE id = ?", courseB.ID).Scan(&gotB)
+	if gotA != 25 {
+		t.Fatalf("课程 A max_people = %d,期望 25(继承分类)", gotA)
+	}
+	if gotB != 0 {
+		t.Fatalf("课程 B max_people = %d,期望 0(分类未配置,不得写别的值)", gotB)
+	}
+	// 可重跑:第二次 affected = 0
+	code, msg, data = doJSON(t, app, fiber.MethodPost, PathMaintenanceCoursesMaxPeople, nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("二次修 max_people = %d(%s),期望 200", code, msg)
+	}
+	if got, _ := jsonMap(t, data)["affected"].(float64); got != 0 {
+		t.Fatalf("二次 affected = %v,期望 0(幂等)", jsonMap(t, data)["affected"])
+	}
+}
+
+// ---------------------------------------------------------------- 端点 62:修 campus_id
+
+// TestBackfillCoursesCampusSkipsMultiCampus 端点 62:**多校区课程必须保持 0**(机构级),单校区回填。
+//
+// 变红条件:把多校区课程也回填 → 课程被锁进某一个校区、其它校区看不到(越权/丢数据);
+//
+//	无排班规则的课程被回填(0≠1)→ affected 变 2。
+func TestBackfillCoursesCampusSkipsMultiCampus(t *testing.T) {
+	db := newCourseTestDB(t)
+	single := seedCourse(t, db, "单校区课", 0) // campus_id=0 待回填
+	multi := seedCourse(t, db, "多校区课", 0)  // 两条规则、两个校区 → 保持 0
+	noRule := seedCourse(t, db, "无规则课", 0) // 无规则 → 不回填
+	// 单校区:1 条规则(campus 10)
+	seedClassRule(t, db, single.ID, 9101, courseTestTeacher, "1", model.CoursesClassStatusOngoing, "09:00", "10:00")
+	// 多校区:2 条规则,其中一条改到 campus 20
+	seedClassRule(t, db, multi.ID, 9201, courseTestTeacher, "1", model.CoursesClassStatusOngoing, "09:00", "10:00")
+	otherCampus := seedClassRule(t, db, multi.ID, 9201, courseTestTeacher, "3", model.CoursesClassStatusOngoing, "11:00", "12:00")
+	otherCampus.CampusID = 20
+	if err := db.Save(otherCampus).Error; err != nil {
+		t.Fatalf("改规则校区失败: %v", err)
+	}
+	app := newPayrollTestApp(t, db)
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathMaintenanceCoursesCampus, nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("修 campus_id = %d(%s),期望 200", code, msg)
+	}
+	if got, _ := jsonMap(t, data)["affected"].(float64); got != 1 {
+		t.Fatalf("affected = %v,期望 1(只有单校区课程被回填)", jsonMap(t, data)["affected"])
+	}
+	var gotSingle, gotMulti, gotNoRule int64
+	db.Raw("SELECT campus_id FROM t_courses WHERE id = ?", single.ID).Scan(&gotSingle)
+	db.Raw("SELECT campus_id FROM t_courses WHERE id = ?", multi.ID).Scan(&gotMulti)
+	db.Raw("SELECT campus_id FROM t_courses WHERE id = ?", noRule.ID).Scan(&gotNoRule)
+	if gotSingle != 10 {
+		t.Fatalf("单校区课程 campus_id = %d,期望 10", gotSingle)
+	}
+	if gotMulti != 0 {
+		t.Fatalf("多校区课程 campus_id = %d,期望 0(机构级课程不得被锁进单校区)", gotMulti)
+	}
+	if gotNoRule != 0 {
+		t.Fatalf("无规则课程 campus_id = %d,期望 0(计数 0 ≠ 1,不回填)", gotNoRule)
+	}
+}
+
+// ---------------------------------------------------------------- 端点 63:活动数据同步
+
+// TestActivitySyncModes 端点 63:两种 mode 各走各的 SP;非法 mode → 2001;非超管 → 1004。
+//
+// 变红条件:
+//   - 非法 mode 落进某个分支(会被当成 used-log 而清表!)→ 2001 断言红;
+//   - mode=signed 把已取消报名(status=0)的行走回写 → 数据断言红。
+func TestActivitySyncModes(t *testing.T) {
+	db := newCourseTestDB(t)
+	// ---- used-log 模式:造 1 条 discount_log,重建后 used_log 应有 1 行
+	activity := &model.CoursesActivity{
+		InstitutionID: 1, CampusID: 10, Type: 2, Title: ptr("充值赠送活动"), Status: 1,
+		Money: "100.00",
+	}
+	if err := db.Create(activity).Error; err != nil {
+		t.Fatalf("建活动失败: %v", err)
+	}
+	const studentID = courseTestStudent
+	// SP30 的数据形状:**流水只指向池行**(`t_courses_activity_user_discount_log` 没有
+	// activity_id/user_id 列)⇒ 必须"池行 + 流水行"两条,重建才能 JOIN 出 activity/user。
+	pool := &model.CoursesActivityUserDiscount{
+		ActivityID: activity.ID, InstitutionID: 1, CampusID: 10, UserID: studentID,
+		Income: "100.00", DiscountAmount: "100.00", DiscountAmountUsed: "50.00",
+	}
+	if err := db.Create(pool).Error; err != nil {
+		t.Fatalf("建赠送池行失败: %v", err)
+	}
+	logRow := &model.CoursesActivityUserDiscountLog{
+		ActivityUserDiscountID: pool.ID,
+		DiscountAmountUsed:     "-50.00", Memo: ptr("使用赠送"),
+	}
+	if err := db.Create(logRow).Error; err != nil {
+		t.Fatalf("建赠送流水失败: %v", err)
+	}
+	app := newPayrollTestApp(t, db)
+
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathMaintenanceActivitySync,
+		map[string]any{"mode": "used-log"}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("used-log 同步 = %d(%s),期望 200", code, msg)
+	}
+	if got, _ := jsonMap(t, data)["affected"].(float64); got != 1 {
+		t.Fatalf("used-log affected = %v,期望 1(重建出 1 行)", jsonMap(t, data)["affected"])
+	}
+	var usedCount int64
+	db.Model(&model.CoursesActivityUsedLog{}).Count(&usedCount)
+	if usedCount != 1 {
+		t.Fatalf("used_log 行数 = %d,期望 1", usedCount)
+	}
+
+	// ---- signed 模式:status=0(已取消)的行不得被历史工单"复活"
+	signed := &model.CoursesActivitySigned{
+		ActivityID: activity.ID, UserID: studentID, Status: 0, RechargeAmount: "0.00",
+	}
+	if err := db.Create(signed).Error; err != nil {
+		t.Fatalf("建报名行失败: %v", err)
+	}
+	request := &model.UserBalanceRequest{
+		BizNo: "REQ-SYNC-1", InstitutionID: 1, CampusID: 10, UserID: studentID,
+		Type: 100, Money: "100.00", Approved: 1,
+	}
+	if err := db.Create(request).Error; err != nil {
+		t.Fatalf("建充值工单失败: %v", err)
+	}
+	pool2 := &model.CoursesActivityUserDiscount{
+		ActivityID: activity.ID, InstitutionID: 1, CampusID: 10, UserID: studentID,
+		BalanceRequestID: &request.ID, Income: "100.00", DiscountAmount: "100.00",
+	}
+	if err := db.Create(pool2).Error; err != nil {
+		t.Fatalf("建第二池行失败: %v", err)
+	}
+	code, msg, _ = doJSON(t, app, fiber.MethodPost, PathMaintenanceActivitySync,
+		map[string]any{"mode": "signed"}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("signed 同步 = %d(%s),期望 200", code, msg)
+	}
+	var signedStatus int8
+	db.Raw("SELECT status FROM t_courses_activity_signed WHERE id = ?", signed.ID).Scan(&signedStatus)
+	if signedStatus != 0 {
+		t.Fatalf("已取消报名的 status = %d,期望仍为 0(重放不得复活已取消报名)", signedStatus)
+	}
+
+	// ---- 非法 / 缺失 mode → 2001(**绝不能落进任何分支**:落进 used-log 会清表)
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathMaintenanceActivitySync,
+		map[string]any{"mode": "drop-all"}, "X-Test-Token", "t"); code != infraerrors.ErrBadParam.Code {
+		t.Fatalf("非法 mode = %d(%s),期望 2001", code, msg)
+	}
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathMaintenanceActivitySync,
+		map[string]any{}, "X-Test-Token", "t"); code != infraerrors.ErrBadParam.Code {
+		t.Fatalf("缺失 mode = %d(%s),期望 2001", code, msg)
+	}
+	// 非法 mode 之后 used_log 必须还在(证明没有误清表)
+	db.Model(&model.CoursesActivityUsedLog{}).Count(&usedCount)
+	if usedCount != 1 {
+		t.Fatalf("非法 mode 后 used_log 行数 = %d,期望仍为 1(**误清表**)", usedCount)
+	}
+}
+
+// TestMaintenanceRequiresSuperAdmin 61~63 的**第二道防线**:非超管即使绕过路由白名单也要 1004。
+//
+// 为什么必须测第二道:路由白名单和种子是**配置**,配错只会让"该拦的没拦";
+// Service 层 `assertSuperAdmin` 是代码里的硬断言,两者独立,缺一不可。
+func TestMaintenanceRequiresSuperAdmin(t *testing.T) {
+	db := newCourseTestDB(t)
+	app := newPayrollTestApp(t, db)
+	cases := []struct {
+		name string
+		path string
+		body any
+	}{
+		{"61 修 max_people", PathMaintenanceCoursesMaxPeople, nil},
+		{"62 修 campus", PathMaintenanceCoursesCampus, nil},
+		{"63 活动同步", PathMaintenanceActivitySync, map[string]any{"mode": "used-log"}},
+	}
+	for _, tc := range cases {
+		code, msg, _ := doJSON(t, app, fiber.MethodPost, tc.path, tc.body,
+			"X-Test-Token", "t", "X-Test-Scope", "academic")
+		if code != infraerrors.ErrUnscoped.Code {
+			t.Errorf("%s 非超管 = %d(%s),期望 1004(Service 层 assertSuperAdmin)", tc.name, code, msg)
+		}
+	}
+	// 对照:超管通过(61 在空库上 affected=0,不是 1004)
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathMaintenanceCoursesMaxPeople, nil,
+		"X-Test-Token", "t"); code != 200 {
+		t.Errorf("61 超管 = %d(%s),期望 200", code, msg)
+	}
+}
+
+// ptr 返回字符串指针(测试夹具的可空列用)。
+func ptr(v string) *string { return &v }

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

@@ -357,6 +357,24 @@ func Routes(h *Handler) []Route {
 		{No: 57, Method: fiber.MethodGet, Path: PathStudentSchedule, Name: "学生课表",
 		{No: 57, Method: fiber.MethodGet, Path: PathStudentSchedule, Name: "学生课表",
 			Roles:   []string{RoleStudent, RoleParent, RoleTeacher},
 			Roles:   []string{RoleStudent, RoleParent, RoleTeacher},
 			Handler: h.GetStudentSchedule},
 			Handler: h.GetStudentSchedule},
+
+		// ---------------------------------------------------------------- 课酬 / 维护(端点 59、61~63)
+		//
+		// 端点 58(课酬方案 CRUD)与 60(收支字典/记录)**本批不挂**:契约把它们各自压成
+		// 一行("方案+明细" / "字典+记录"),字段无法逐列溯源,已报偏差等裁决(见 dto_payroll.go 文件头)。
+		{No: 59, Method: fiber.MethodPost, Path: PathPayrollQuote, Name: "课酬试算",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.QuotePayroll},
+		// 61~63 维护型:**仅 super_admin**(Service 层 `assertSuperAdmin` 是第二道防线)。
+		{No: 61, Method: fiber.MethodPost, Path: PathMaintenanceCoursesMaxPeople, Name: "修复课程最多人数",
+			Roles:   []string{RoleSuperAdmin},
+			Handler: h.BackfillCoursesMaxPeople},
+		{No: 62, Method: fiber.MethodPost, Path: PathMaintenanceCoursesCampus, Name: "修复课程校区",
+			Roles:   []string{RoleSuperAdmin},
+			Handler: h.BackfillCoursesCampus},
+		{No: 63, Method: fiber.MethodPost, Path: PathMaintenanceActivitySync, Name: "活动数据同步",
+			Roles:   []string{RoleSuperAdmin},
+			Handler: h.SyncActivityData},
 	}
 	}
 }
 }
 
 

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

@@ -183,10 +183,15 @@ func TestRouteRegistryMatchesContract(t *testing.T) {
 		{55, "GET", "/admin/courses/arrears", "super_admin,academic,finance"},
 		{55, "GET", "/admin/courses/arrears", "super_admin,academic,finance"},
 		{56, "POST", "/admin/courses/arrears/counteract", "super_admin,academic,finance"},
 		{56, "POST", "/admin/courses/arrears/counteract", "super_admin,academic,finance"},
 		{57, "GET", "/student/courses/schedule", "student,parent,teacher"},
 		{57, "GET", "/student/courses/schedule", "student,parent,teacher"},
+		// ---- AC-13d 第一批:课酬试算 + 维护(端点 59、61~63;58/60 待契约裁决,本批不挂)
+		{59, "POST", "/admin/payroll/quote", "super_admin,academic,finance"},
+		{61, "POST", "/admin/maintenance/courses-max-people", "super_admin"},
+		{62, "POST", "/admin/maintenance/courses-campus", "super_admin"},
+		{63, "POST", "/admin/maintenance/activity-sync", "super_admin"},
 	}
 	}
 
 
 	if len(routes) != len(want) {
 	if len(routes) != len(want) {
-		t.Fatalf("路由条数 = %d,期望 %d(AC-14 第一批 40 + AC-13c 15 = 55 条 method+path)", len(routes), len(want))
+		t.Fatalf("路由条数 = %d,期望 %d(AC-14 40 + AC-13c 15 + AC-13d 4 = 59 条 method+path)", len(routes), len(want))
 	}
 	}
 
 
 	// 逐条对齐**用集合比**而不是按下标比:注册顺序是"静态段优先"(≠ 契约端点号顺序),
 	// 逐条对齐**用集合比**而不是按下标比:注册顺序是"静态段优先"(≠ 契约端点号顺序),
@@ -218,7 +223,7 @@ func TestRouteRegistryMatchesContract(t *testing.T) {
 		delete(gotByKey, key)
 		delete(gotByKey, key)
 	}
 	}
 	for key := range gotByKey {
 	for key := range gotByKey {
-		t.Errorf("注册了契约外的路由 %s(当前只挂已交付的 55 条)", key)
+		t.Errorf("注册了契约外的路由 %s(当前只挂已交付的 59 条)", key)
 	}
 	}
 
 
 	// 反向断言:18/19/20(支付)**本批不得挂**——handler 尚未实现,挂空壳会让"未实现"
 	// 反向断言:18/19/20(支付)**本批不得挂**——handler 尚未实现,挂空壳会让"未实现"
@@ -326,8 +331,8 @@ func TestAssertUniqueMethodPath(t *testing.T) {
 	if err := AssertUniqueMethodPath(app.GetRoutes()); err != nil {
 	if err := AssertUniqueMethodPath(app.GetRoutes()); err != nil {
 		t.Fatalf("40 条路由不该有重复:%v", err)
 		t.Fatalf("40 条路由不该有重复:%v", err)
 	}
 	}
-	if len(reg.Routes) != 55 {
-		t.Fatalf("注册条数 = %d,期望 55(40 + 15)", len(reg.Routes))
+	if len(reg.Routes) != 59 {
+		t.Fatalf("注册条数 = %d,期望 59(40 + 15 + 4)", len(reg.Routes))
 	}
 	}
 
 
 	// 正例:同 path 不同 method 是**合法**的(端点 12/13 共用 /admin/finance/requests)
 	// 正例:同 path 不同 method 是**合法**的(端点 12/13 共用 /admin/finance/requests)