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