| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565 |
- // 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 对齐 + 一级段白名单断言对象 = 本装配单元)。
- //
- // ---------------------------------------------------------------------------
- // 一、本批范围(**只挂已交付的端点**: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)。**不挂空壳/占位路由**——空壳会让"未实现"看起来像"已实现但返回怪码",
- // 比 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 返回全部已交付路由(55 条:AC-14 40 条 + AC-13c 15 条;**顺序即注册顺序,静态段全部在 `: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.CreateExpenditure},
- {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(课酬方案 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},
- }
- }
- // 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
- }
|