route.go 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. // academic 域**路由装配单元**(AC-14 第一批 40 条 + AC-13c 15 条 = 55 条 method+path 的接线 + RBAC 种子来源)。
  2. //
  3. // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.1/§2.2/§2.3(method/path/角色白名单,
  4. // **唯一权威**)/ §1.6(后端路由不含 `/api`)/ §2(63 端点清单);设计 `academic-domain-design.md`
  5. // AC-14 行(四条运行期断言 + 种子 1:1 对齐 + 一级段白名单断言对象 = 本装配单元)。
  6. //
  7. // ---------------------------------------------------------------------------
  8. // 一、本批范围(**只挂已交付的端点**:AC-14 第一批 40 条 + AC-13c 15 条)
  9. // ---------------------------------------------------------------------------
  10. //
  11. // 财务 16 端点 1~16 fund_handler.go
  12. // 账单 2 端点 17、21 bill_handler.go
  13. // 活动 16 端点 22~37 activity_handler.go
  14. // 分账 6 端点 38~43 divide_handler.go
  15. // 课程 15 端点 44~57 course_handler.go(AC-13c;端点 45 = POST + PUT 两条 method+path)
  16. //
  17. // **总数 55 条 method+path**(40 + 15),对应**契约 54 个已实现端点**
  18. // (63 个端点里未实现的 9 个:18/19/20 支付端点 + 58~63 课酬/收支/维护;端点 45 因 POST+PUT 占两条路由)。
  19. //
  20. // **端点 18/19/20(`/pay/*`)本批不挂**:handler 尚未实现(不属 AC-12 的 1~17/21,
  21. // 也不属 AC-13 的 22~63)。**不挂空壳/占位路由**——空壳会让"未实现"看起来像"已实现但返回怪码",
  22. // 比 404 更难查(前端拿到的不是 404 而是业务错误码,会去查业务逻辑)。
  23. //
  24. // ---------------------------------------------------------------------------
  25. // 二、四条运行期断言(本文件是唯一实现点,e2e 复用同一函数,不另写一套)
  26. // ---------------------------------------------------------------------------
  27. //
  28. // ① AssertUniqueMethodPath:遍历 `app.GetRoutes()` 断言 `method+path` 全局唯一。
  29. // **为什么必须运行期**:Fiber 对重复 `method+path` 是"**先注册优先、后者静默失效**",
  30. // 症状是"改了没生效";静态 grep 抓不到这种语义重叠,人眼也抓不到(40 条路由里有一对
  31. // 重复,review 时几乎不可能看出来)。
  32. //
  33. // ② AssertFirstSegments:断言**本装配单元注册出的**一级段集合 ⊆ {`/admin`,`/student`,`/pay`}。
  34. // ⚠ **断言对象是 `Registration.Prefixes`(本域注册出来的段),不是全局 `app.GetRoutes()`**:
  35. // 全局表天然含 identity 的 `/auth/*` 与 org 的 `/org/*`,对全局表断言**必红**,
  36. // 维护者最后只会做两件事之一——删断言,或把白名单放宽成 `{admin,student,pay,auth,org}`,
  37. // 两种都把"academic 不得重复注册他域路径"的意图彻底丢掉。**作用域故意不同,不得合并**。
  38. //
  39. // ③ 变异反例(证明 ② 不是"恒过"):`RegisterOptions.Routes` 允许注入自定义路由表,
  40. // e2e 用它注册一条 `/auth/xxx` → ② 必须变红。若把 ② 误写成"先过滤掉 /auth 再断言",
  41. // 则该用例恒绿——这正是"守卫本身要能被证伪"的意义(设计 §10.3)。
  42. //
  43. // ④ 静态段优先:见 `Routes` 的注册顺序(静态段全部在 `:id` 之前)。
  44. // AC-13c 已逐个核对:`courses/arrears`、`courses/records/*` 与 `courses/:id*` **不存在**
  45. // 同 depth 真冲突(反证见 course_handler.go 文件头),如实登记、不硬凑变红用例。
  46. // 行为断言在 e2e:`GET /admin/activities/discounts/usable` 必须 200,
  47. // 被 `:id` 吃掉会变成 2002(`:id` 解析 "discounts" 失败)。
  48. //
  49. // ---------------------------------------------------------------------------
  50. // 三、为什么中间件按"每条路由一条链"挂,而不是按 Group 挂
  51. // ---------------------------------------------------------------------------
  52. //
  53. // 40 条端点的角色白名单**只有 3 种组合**,但静态段/`:id` 段的注册顺序**跨组合交错**
  54. // (如 `discounts/*` 是 finance 组、`/admin/activities/:id` 是 academic 组,前者必须先注册)。
  55. // 用 Group 挂就必须在多个 group 之间来回跳,注册顺序变成"跟着 group 走",
  56. // 一旦新增端点很容易把静态段放到 `:id` 之后。因此改为**单层平铺 + 每行自带
  57. // `Auth → RoleGuard(白名单) → handler`**:注册顺序 = 表顺序,一眼可审;
  58. // 白名单与 `SeedRBAC` 的 casbin 策略同源(都取 `Route.Roles`),不会打字不一致。
  59. package handler
  60. import (
  61. "fmt"
  62. "sort"
  63. "strings"
  64. "github.com/gofiber/fiber/v2"
  65. )
  66. // 角色码常量(契约 §1.7 的 6 个 + `market`;与 org 域 `t_role` 字典一致)。
  67. const (
  68. // RoleSuperAdmin 超级管理员(代码层恒放行,见 identity/handler.RequireRole)。
  69. RoleSuperAdmin = "super_admin"
  70. // RoleAcademic 教务。
  71. RoleAcademic = "academic"
  72. // RoleFinance 财务。
  73. RoleFinance = "finance"
  74. // RoleTeacher 老师。
  75. RoleTeacher = "teacher"
  76. // RoleStudent 学生(移动端)。
  77. RoleStudent = "student"
  78. // RoleParent 家长(移动端)。
  79. RoleParent = "parent"
  80. // RoleMarket 市场(推广/分账候选角色;本批 40 端点未用到,保留以支持白名单校验)。
  81. RoleMarket = "market"
  82. )
  83. // knownRoleCodes 合法角色码集合(`Route.Roles` 的**拼写校验**用)。
  84. //
  85. // 为什么要在注册期校验角色码:白名单写错(如 `"finance "` 带尾空格、`"Finance"` 大小写错)
  86. // **不会让任何用例变红**——它只会让该角色在生产上永远 1003,而 1003 看起来像"权限没配",
  87. // 排查会先怀疑 RBAC 种子,最后才发现是路由表里的一个空格。注册期 fail-fast 一次即可。
  88. var knownRoleCodes = map[string]struct{}{
  89. RoleSuperAdmin: {}, RoleAcademic: {}, RoleFinance: {},
  90. RoleTeacher: {}, RoleStudent: {}, RoleParent: {}, RoleMarket: {},
  91. }
  92. // AcademicFirstSegments academic 域**允许**出现的一级段白名单(契约 §1.6/§2)。
  93. //
  94. // `/auth` 属 identity、`/org` 属 org,academic **不得**注册(重复注册同一 method+path 时
  95. // Fiber 先注册优先,identity 的 handler 会被 academic 的悄悄顶掉,症状是"登录接口返回 1003")。
  96. var AcademicFirstSegments = []string{"/admin", "/student", "/pay"}
  97. // Route 一条 academic 路由(**注册表行**:既是注册输入,也是 RBAC 种子的唯一来源)。
  98. //
  99. // 字段:
  100. // - No int:契约端点号(§2 表内编号 1~63);用于种子排序与交付报告逐条对账。
  101. // - Method string:HTTP method(大写,`GET`/`POST`/`PUT`/`DELETE`)。
  102. // - Path string:**不含 `/api`** 的后端路径(契约 §1.6),参数段用 Fiber 的 `:name` 写法。
  103. // - Name string:中文名(日志/种子的权限点名称用;不参与路由语义)。
  104. // - Roles []string:契约 §2 该行给出的角色白名单(含 `super_admin`,便于审计与种子的可读性)。
  105. // - Handler fiber.Handler:已交付的处理函数(**本批不改 handler 实现**)。
  106. type Route struct {
  107. No int
  108. Method string
  109. Path string
  110. Name string
  111. Roles []string
  112. Handler fiber.Handler
  113. }
  114. // Key `method + path` 唯一键(断言 ① 与种子去重共用同一口径)。
  115. func (r Route) Key() string { return r.Method + " " + r.Path }
  116. // RoleGuard 角色白名单中间件**工厂**(由装配层注入 `identity/handler.RequireRole`)。
  117. //
  118. // 为什么不直接 import identity 的 handler:academic → identity 是跨域依赖,
  119. // 本域与 org 的耦合一律经 `port` 包或装配层注入(见 `academic/port` 包注释的依赖方向铁律)。
  120. // 注入后 `academic/handler` 对 identity **零 import**,签名漂移在 `cmd/api` 装配处即编译失败。
  121. type RoleGuard func(roles ...string) fiber.Handler
  122. // RegisterOptions 路由注册参数。
  123. //
  124. // 字段:
  125. // - Auth fiber.Handler:验签 + 作用域注入中间件(**必填**;本批 40 端点**全部**需登录,
  126. // 公开端点将来在别的装配单元里挂,不得混进这里)。
  127. // - RoleGuard RoleGuard:角色白名单中间件工厂(**必填**)。
  128. // - Routes []Route:注册表(**nil = 用 `Routes(h)` 的全部路由**)。
  129. // 非 nil 只给"能自证失败"的变异用例用(e2e 注册一条 `/auth/xxx` 验证断言 ② 会红),
  130. // 生产装配恒传 nil。
  131. type RegisterOptions struct {
  132. Auth fiber.Handler
  133. RoleGuard RoleGuard
  134. Routes []Route
  135. }
  136. // Registration 注册结果(断言 ② 的断言对象 + 种子生成输入 + 交付报告的对账表)。
  137. //
  138. // 字段:
  139. // - Routes []Route:实际注册的路由(顺序 = 注册顺序)。
  140. // - Prefixes []string:本装配单元注册出的**一级段**集合(去重、升序,如 `["/admin","/student"]`)。
  141. type Registration struct {
  142. Routes []Route
  143. Prefixes []string
  144. }
  145. // Routes 返回全部已交付路由(55 条:AC-14 40 条 + AC-13c 15 条;**顺序即注册顺序,静态段全部在 `:id` 之前**)。
  146. //
  147. // ⚠ 顺序规则(Fiber 按注册顺序匹配;违反即"静态段被 `:id` 吃掉"):
  148. //
  149. // ① 同深度下 **字面段必须先于 `:param` 段**:
  150. // `/admin/activities/discounts/*`(深度 4)必须先于 `/admin/activities/:id`(深度 3)——
  151. // 深度不同时不会互相吃掉,但同深度的 `discounts|recharge-gifts` 与 `:id` 必须字面优先;
  152. // ② `/admin/finance/refunds/custom` 先于 `/admin/finance/refunds`(深度不同,仍按"具体优先"排);
  153. // ③ 本函数把**整个 40 条按"静态优先"统一排序**,而不是分段排——分段排最容易在
  154. // "以后再加一条"时破坏顺序,整表排序只有一处可错。
  155. func Routes(h *Handler) []Route {
  156. return []Route{
  157. // ---------------------------------------------------------------- 财务(端点 1~16、21)
  158. {No: 2, Method: fiber.MethodGet, Path: PathFinanceRefundable, Name: "可退金额",
  159. Roles: []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
  160. Handler: h.GetRefundable},
  161. {No: 1, Method: fiber.MethodGet, Path: PathFinanceAccounts, Name: "账户快照",
  162. Roles: []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
  163. Handler: h.GetAccount},
  164. {No: 6, Method: fiber.MethodPost, Path: PathFinanceRefundsCustom, Name: "自定义退费(含坏账)",
  165. Roles: []string{RoleSuperAdmin, RoleFinance},
  166. Handler: h.CreateCustomRefund},
  167. {No: 5, Method: fiber.MethodPost, Path: PathFinanceRefunds, Name: "退费",
  168. Roles: []string{RoleSuperAdmin, RoleFinance},
  169. Handler: h.CreateRefund},
  170. {No: 3, Method: fiber.MethodPost, Path: PathFinanceRecharges, Name: "充值",
  171. Roles: []string{RoleSuperAdmin, RoleFinance},
  172. Handler: h.CreateRecharge},
  173. {No: 4, Method: fiber.MethodPost, Path: PathFinanceExpenditures, Name: "消费",
  174. Roles: []string{RoleSuperAdmin, RoleFinance},
  175. Handler: h.CreateExpenditure},
  176. {No: 7, Method: fiber.MethodPost, Path: PathFinanceTransfers, Name: "课费转结",
  177. Roles: []string{RoleSuperAdmin, RoleFinance},
  178. Handler: h.CreateTransfer},
  179. {No: 8, Method: fiber.MethodPost, Path: PathFinanceBadDebts, Name: "坏账",
  180. Roles: []string{RoleSuperAdmin, RoleFinance},
  181. Handler: h.CreateBadDebt},
  182. {No: 9, Method: fiber.MethodPost, Path: PathFinanceClearedBalances, Name: "余额清零",
  183. Roles: []string{RoleSuperAdmin, RoleFinance},
  184. Handler: h.ClearBalance},
  185. {No: 10, Method: fiber.MethodPost, Path: PathFinanceSpends, Name: "累计消费",
  186. Roles: []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
  187. Handler: h.AddSpend},
  188. {No: 11, Method: fiber.MethodGet, Path: PathFinanceLogs, Name: "流水列表",
  189. Roles: []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
  190. Handler: h.ListLogs},
  191. {No: 14, Method: fiber.MethodPost, Path: PathFinanceRequestApprove, Name: "工单通过",
  192. Roles: []string{RoleSuperAdmin, RoleFinance},
  193. Handler: h.ApproveRequest},
  194. {No: 15, Method: fiber.MethodPost, Path: PathFinanceRequestReject, Name: "工单驳回",
  195. Roles: []string{RoleSuperAdmin, RoleFinance},
  196. Handler: h.RejectRequest},
  197. // 端点 16:契约角色列写的是"申请人本人 或 super_admin/finance"。
  198. // 路由白名单是**粗粒度**门禁,必须 ⊇ 可能成为申请人的角色(端点 13 允许 academic 建单),
  199. // 否则"教务本人撤自己的单"会被路由层 1003 挡住、走不到 Service 的归属判定;
  200. // **"本人"这一层由 Service 判**(`BalanceRequestService.Cancel` isOwner/isManager,越权 → 1003)。
  201. {No: 16, Method: fiber.MethodPost, Path: PathFinanceRequestCancel, Name: "工单撤销",
  202. Roles: []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
  203. Handler: h.CancelRequest},
  204. {No: 13, Method: fiber.MethodPost, Path: PathFinanceRequests, Name: "创建工单",
  205. Roles: []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
  206. Handler: h.CreateRequest},
  207. {No: 12, Method: fiber.MethodGet, Path: PathFinanceRequests, Name: "工单列表",
  208. Roles: []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
  209. Handler: h.ListRequests},
  210. {No: 21, Method: fiber.MethodGet, Path: PathFinanceDashboard, Name: "仪表盘",
  211. Roles: []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
  212. Handler: h.GetDashboard},
  213. // 端点 17 是 `/student` 段(移动端账单)。
  214. {No: 17, Method: fiber.MethodGet, Path: PathStudentBill, Name: "学生账单",
  215. Roles: []string{RoleStudent, RoleParent, RoleSuperAdmin},
  216. Handler: h.GetBill},
  217. // ---------------------------------------------------------------- 活动(端点 22~37)
  218. {No: 22, Method: fiber.MethodGet, Path: PathActivities, Name: "活动列表",
  219. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleFinance, RoleTeacher},
  220. Handler: h.ListActivities},
  221. {No: 23, Method: fiber.MethodPost, Path: PathActivities, Name: "新建活动",
  222. Roles: []string{RoleSuperAdmin, RoleAcademic},
  223. Handler: h.CreateActivity},
  224. // ↓ 静态段(`discounts/*`、`recharge-gifts/*`)全部先于 `:id` 段注册。
  225. {No: 31, Method: fiber.MethodGet, Path: PathActivityDiscountsUsable, Name: "可用赠送余额",
  226. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
  227. Handler: h.GetActivityUsableDiscount},
  228. {No: 32, Method: fiber.MethodPost, Path: PathActivityDiscountsUse, Name: "使用赠送",
  229. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
  230. Handler: h.UseActivityDiscount},
  231. {No: 33, Method: fiber.MethodPost, Path: PathActivityDiscountsRefund, Name: "赠送退回",
  232. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
  233. Handler: h.RefundActivityDiscount},
  234. {No: 34, Method: fiber.MethodPost, Path: PathActivityDiscountsRefundByActivity, Name: "按活动退回赠送",
  235. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
  236. Handler: h.RefundDiscountByActivity},
  237. {No: 35, Method: fiber.MethodPost, Path: PathActivityRechargeGiftsUse, Name: "充值赠送使用",
  238. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
  239. Handler: h.UseRechargeGift},
  240. {No: 36, Method: fiber.MethodPost, Path: PathActivityRechargeGiftsRefund, Name: "充值赠送退回",
  241. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
  242. Handler: h.RefundRechargeGift},
  243. {No: 24, Method: fiber.MethodGet, Path: PathActivityDetail, Name: "活动详情",
  244. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleTeacher},
  245. Handler: h.GetActivity},
  246. {No: 25, Method: fiber.MethodPut, Path: PathActivityDetail, Name: "更新活动",
  247. Roles: []string{RoleSuperAdmin, RoleAcademic},
  248. Handler: h.UpdateActivity},
  249. {No: 26, Method: fiber.MethodPost, Path: PathActivitySign, Name: "活动报名",
  250. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleStudent, RoleParent},
  251. Handler: h.SignActivity},
  252. {No: 27, Method: fiber.MethodPost, Path: PathActivityCancelSign, Name: "取消报名",
  253. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleStudent, RoleParent},
  254. Handler: h.CancelActivitySign},
  255. {No: 28, Method: fiber.MethodGet, Path: PathActivitySigneds, Name: "报名列表",
  256. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
  257. Handler: h.ListActivitySigneds},
  258. {No: 29, Method: fiber.MethodPost, Path: PathActivityRecommendSettle, Name: "老带新结算",
  259. Roles: []string{RoleSuperAdmin, RoleAcademic},
  260. Handler: h.SettleActivityRecommend},
  261. {No: 30, Method: fiber.MethodPost, Path: PathActivityCancelRecharge, Name: "取消充值活动",
  262. Roles: []string{RoleSuperAdmin, RoleFinance},
  263. Handler: h.CancelActivityRecharge},
  264. {No: 37, Method: fiber.MethodPost, Path: PathActivityVisits, Name: "回访登记",
  265. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleTeacher},
  266. Handler: h.CreateActivityVisit},
  267. // ---------------------------------------------------------------- 分账(端点 38~43)
  268. {No: 38, Method: fiber.MethodGet, Path: PathDivideTree, Name: "分账树",
  269. Roles: []string{RoleSuperAdmin, RoleAcademic},
  270. Handler: h.GetDivideTree},
  271. {No: 39, Method: fiber.MethodPost, Path: PathDivideMembers, Name: "加入下线",
  272. Roles: []string{RoleSuperAdmin, RoleAcademic},
  273. Handler: h.AddDivideMember},
  274. {No: 40, Method: fiber.MethodPut, Path: PathDivideMemberQuota, Name: "设置分成额度",
  275. Roles: []string{RoleSuperAdmin, RoleAcademic},
  276. Handler: h.SetDivideQuota},
  277. {No: 41, Method: fiber.MethodPost, Path: PathDivideLogs, Name: "记分账流水",
  278. Roles: []string{RoleSuperAdmin, RoleAcademic},
  279. Handler: h.CreateDivideLogRecord},
  280. {No: 42, Method: fiber.MethodDelete, Path: PathDivideLogs, Name: "删分账流水",
  281. Roles: []string{RoleSuperAdmin, RoleAcademic},
  282. Handler: h.DeleteDivideLogRecord},
  283. {No: 43, Method: fiber.MethodGet, Path: PathDivideLogs, Name: "分账流水列表",
  284. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
  285. Handler: h.ListDivideLogs},
  286. // ---------------------------------------------------------------- 课程 / 排班 / 打卡(端点 44~57)
  287. //
  288. // 顺序规则同活动域:**静态段全部先于 `:id` 段**。本批 15 条的静态段是
  289. // `courses`(2 段)/ `courses/arrears`(3 段)/ `courses/arrears/counteract`(4 段)/
  290. // `courses/records/...`(5 段);`:id` 段是 `courses/:id`(3 段)与
  291. // `courses/:id/classes|records|students`(4 段、5 段)。
  292. //
  293. // **已逐个核对:本批不存在"会被 `:id` 吃掉"的同 depth 真冲突**(详见 course_handler.go
  294. // 文件头「路由注册顺序」的三条反证)——如实登记,不硬凑变红用例。
  295. //
  296. // 端点 45 是 `POST /admin/courses` + `PUT /admin/courses/:id` **两条** method+path
  297. // (契约 §2.4 行 45 写 "POST/PUT"),因此 14 个契约端点 = 15 条路由。
  298. {No: 44, Method: fiber.MethodGet, Path: PathCourses, Name: "课程列表",
  299. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleFinance, RoleTeacher},
  300. Handler: h.ListCourses},
  301. {No: 45, Method: fiber.MethodPost, Path: PathCourses, Name: "新建课程",
  302. Roles: []string{RoleSuperAdmin, RoleAcademic},
  303. Handler: h.CreateCourse},
  304. // ↓ 静态段(arrears、records/*)先于 `:id`
  305. {No: 55, Method: fiber.MethodGet, Path: PathCourseArrears, Name: "欠费列表",
  306. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
  307. Handler: h.ListCourseArrears},
  308. {No: 56, Method: fiber.MethodPost, Path: PathCourseArrearsCounteract, Name: "欠费核销",
  309. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
  310. Handler: h.CounteractCourseArrears},
  311. {No: 50, Method: fiber.MethodPost, Path: PathCourseRecordStart, Name: "开始上课",
  312. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleTeacher},
  313. Handler: h.StartCourseRecord},
  314. {No: 51, Method: fiber.MethodPost, Path: PathCourseRecordFinish, Name: "结束上课",
  315. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleTeacher},
  316. Handler: h.FinishCourseRecord},
  317. {No: 52, Method: fiber.MethodPost, Path: PathCourseRecordAttendance, Name: "打卡",
  318. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleTeacher},
  319. Handler: h.MarkCourseRecordAttendance},
  320. // ↓ 以下是 `:id` 段(排在静态段之后)
  321. {No: 45, Method: fiber.MethodPut, Path: PathCourseDetail, Name: "更新课程",
  322. Roles: []string{RoleSuperAdmin, RoleAcademic},
  323. Handler: h.UpdateCourse},
  324. {No: 46, Method: fiber.MethodGet, Path: PathCourseClasses, Name: "排班规则列表",
  325. Roles: []string{RoleSuperAdmin, RoleAcademic},
  326. Handler: h.ListCourseClasses},
  327. {No: 47, Method: fiber.MethodPost, Path: PathCourseClasses, Name: "新建排班规则",
  328. Roles: []string{RoleSuperAdmin, RoleAcademic},
  329. Handler: h.CreateCourseClass},
  330. {No: 48, Method: fiber.MethodGet, Path: PathCourseRecords, Name: "课表实例",
  331. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleTeacher},
  332. Handler: h.ListCourseRecords},
  333. {No: 49, Method: fiber.MethodPost, Path: PathCourseRecordsGenerate, Name: "生成课表",
  334. Roles: []string{RoleSuperAdmin, RoleAcademic},
  335. Handler: h.GenerateCourseRecords},
  336. {No: 53, Method: fiber.MethodGet, Path: PathCourseStudents, Name: "报课学生列表",
  337. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
  338. Handler: h.ListCourseStudents},
  339. {No: 54, Method: fiber.MethodPost, Path: PathCourseStudents, Name: "报名",
  340. Roles: []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
  341. Handler: h.EnrollCourseStudent},
  342. // 端点 57 是 `/student` 段(移动端课表)。
  343. {No: 57, Method: fiber.MethodGet, Path: PathStudentSchedule, Name: "学生课表",
  344. Roles: []string{RoleStudent, RoleParent, RoleTeacher},
  345. Handler: h.GetStudentSchedule},
  346. }
  347. }
  348. // Register 把 academic 域路由挂到 app 上,并**当场**跑断言 ①②(生产路径与 e2e 同一函数)。
  349. //
  350. // 入参:
  351. // - app *fiber.App:应用实例(非 nil);
  352. // - h *Handler:academic Handler(非 nil;本批不改 handler 实现,只接线);
  353. // - opt RegisterOptions:中间件 + 可选注册表覆盖(生产恒 nil,变异用例才用)。
  354. //
  355. // 出参:
  356. // - *Registration:注册结果(路由表 + 一级段集合);
  357. // - error:参数缺失 / 路由表非法(空 handler、空白名单、未知角色码、含 `/api` 前缀)/
  358. // 断言 ①(`method+path` 重复)/ 断言 ②(一级段越界)。**任一失败都必须让进程起不来**——
  359. // 这类错误在生产上是"静默失效",启动失败是唯一可靠的暴露方式。
  360. func Register(app *fiber.App, h *Handler, opt RegisterOptions) (*Registration, error) {
  361. if app == nil {
  362. return nil, fmt.Errorf("academic route: app 未注入")
  363. }
  364. if opt.Auth == nil {
  365. return nil, fmt.Errorf("academic route: Auth 中间件未注入(本域端点全部需登录)")
  366. }
  367. if opt.RoleGuard == nil {
  368. return nil, fmt.Errorf("academic route: RoleGuard(角色白名单工厂)未注入")
  369. }
  370. routes := opt.Routes
  371. if routes == nil {
  372. if h == nil {
  373. return nil, fmt.Errorf("academic route: Handler 未注入")
  374. }
  375. routes = Routes(h)
  376. }
  377. if err := validateRoutes(routes); err != nil {
  378. return nil, err
  379. }
  380. for _, r := range routes {
  381. // 单层平铺:`Auth → RoleGuard(本行白名单) → handler`。
  382. // 顺序即匹配顺序,与 `Routes` 的"静态优先"排序一一对应(见文件头 §三)。
  383. app.Add(r.Method, r.Path, opt.Auth, opt.RoleGuard(r.Roles...), r.Handler)
  384. }
  385. // 断言 ①:**全局**路由表 `method+path` 唯一(跨域重复也算错,故不只看本域 40 条)。
  386. if err := AssertUniqueMethodPath(app.GetRoutes()); err != nil {
  387. return nil, err
  388. }
  389. // 断言 ②:**本装配单元**的一级段白名单(不碰全局表,理由见文件头 §二)。
  390. prefixes := FirstSegments(routes)
  391. if err := AssertFirstSegments(prefixes); err != nil {
  392. return nil, err
  393. }
  394. return &Registration{Routes: routes, Prefixes: prefixes}, nil
  395. }
  396. // validateRoutes 注册期校验路由表本身(拼写类错误在这里暴露,不留给运行期)。
  397. //
  398. // 入参:routes []Route。
  399. // 出参:nil 合法;否则描述第一条问题(fail-fast,一次报一条便于修)。
  400. func validateRoutes(routes []Route) error {
  401. if len(routes) == 0 {
  402. return fmt.Errorf("academic route: 路由表为空")
  403. }
  404. for _, r := range routes {
  405. if r.Handler == nil {
  406. return fmt.Errorf("academic route: 端点 %d(%s %s)未绑定 handler", r.No, r.Method, r.Path)
  407. }
  408. if r.No <= 0 {
  409. return fmt.Errorf("academic route: %s %s 缺少契约端点号", r.Method, r.Path)
  410. }
  411. if !strings.HasPrefix(r.Path, "/") {
  412. return fmt.Errorf("academic route: 端点 %d 路径必须以 / 开头(当前 %q)", r.No, r.Path)
  413. }
  414. // `/api` 是代理层外部前缀(契约 §1.6),**不得**进后端路由表:
  415. // 一旦混入,对外就变成 `/api/api/...`(vite/nginx 已去掉一层),症状是"一类环境能通、另一类 404"。
  416. if r.Path == "/api" || strings.HasPrefix(r.Path, "/api/") {
  417. return fmt.Errorf("academic route: 端点 %d 路径不得含 /api 前缀(当前 %q,见契约 §1.6)", r.No, r.Path)
  418. }
  419. if len(r.Roles) == 0 {
  420. // 空白名单在 `RequireRole` 里是 fail-closed(除超管外全 1003),
  421. // 但那种"配置错误"在运行期只表现为 1003,看起来像 RBAC 种子没配 —— 注册期直接拒。
  422. return fmt.Errorf("academic route: 端点 %d(%s %s)角色白名单为空", r.No, r.Method, r.Path)
  423. }
  424. for _, role := range r.Roles {
  425. if _, ok := knownRoleCodes[role]; !ok {
  426. return fmt.Errorf("academic route: 端点 %d 角色码 %q 非法(合法值:super_admin/academic/finance/teacher/student/parent/market)",
  427. r.No, role)
  428. }
  429. }
  430. switch r.Method {
  431. case fiber.MethodGet, fiber.MethodPost, fiber.MethodPut, fiber.MethodDelete:
  432. default:
  433. return fmt.Errorf("academic route: 端点 %d method %q 非法", r.No, r.Method)
  434. }
  435. }
  436. return nil
  437. }
  438. // FirstSegments 取路由表的一级段集合(去重、升序)。
  439. //
  440. // 入参:routes []Route(或任意带 Path 的路由行)。
  441. // 出参:[]string,如 `["/admin","/student"]`。
  442. //
  443. // 这是断言 ② 的数据来源:**集合必须是"本装配单元注册了什么",而不是"全局表里有什么"**
  444. // (见文件头 §二)。空路径段(如 `/`)会得到空串,断言会因"不在白名单"而报错——符合预期。
  445. func FirstSegments(routes []Route) []string {
  446. seen := make(map[string]struct{}, len(routes))
  447. for _, r := range routes {
  448. seg := firstSegment(r.Path)
  449. seen[seg] = struct{}{}
  450. }
  451. out := make([]string, 0, len(seen))
  452. for seg := range seen {
  453. out = append(out, seg)
  454. }
  455. sort.Strings(out)
  456. return out
  457. }
  458. // firstSegment 取路径第一段(含前导 `/`;`/admin/finance` → `/admin`)。
  459. func firstSegment(path string) string {
  460. trimmed := strings.TrimPrefix(path, "/")
  461. if trimmed == "" {
  462. return "/"
  463. }
  464. if idx := strings.IndexByte(trimmed, '/'); idx >= 0 {
  465. return "/" + trimmed[:idx]
  466. }
  467. return "/" + trimmed
  468. }
  469. // AssertUniqueMethodPath 断言路由表 `method+path` **唯一**(断言 ①)。
  470. //
  471. // 入参:routes []fiber.Route(生产路径传 `app.GetRoutes()`,e2e 传同一份)。
  472. // 出参:nil 唯一;否则 error 列出**全部**重复项(一次报全,避免改一个跑一遍)。
  473. //
  474. // 为什么必须运行期断言:Fiber 的 `app.Add` 对重复 `method+path` 不报错、不 panic,
  475. // 后注册者**静默失效**。症状是"改了没生效/新接口返回旧逻辑",且:
  476. // - 静态 grep 抓不到(两条注册语句长得不一样,只有 method+path 相同);
  477. // - 单端点用例抓不到(打的还是先注册那个 handler,行为正确)。
  478. //
  479. // 注意:同一 path 不同 method 是**合法**的(如端点 12/13 共用 `/admin/finance/requests`),
  480. // 故键是 `method + " " + path`,不是 path。
  481. func AssertUniqueMethodPath(routes []fiber.Route) error {
  482. seen := make(map[string]int, len(routes))
  483. dups := make([]string, 0, 4)
  484. for _, r := range routes {
  485. key := r.Method + " " + r.Path
  486. if n, ok := seen[key]; ok {
  487. dups = append(dups, fmt.Sprintf("%s(重复 %d 次)", key, n+1))
  488. seen[key] = n + 1
  489. continue
  490. }
  491. seen[key] = 1
  492. }
  493. if len(dups) > 0 {
  494. sort.Strings(dups)
  495. return fmt.Errorf("academic route: 路由表存在重复 method+path(Fiber 会静默丢弃后注册者):%s",
  496. strings.Join(dups, ";"))
  497. }
  498. return nil
  499. }
  500. // AssertFirstSegments 断言一级段集合 ⊆ {`/admin`,`/student`,`/pay`}(断言 ②)。
  501. //
  502. // 入参:prefixes []string(**必须是本装配单元注册出的段集合**,见 `FirstSegments`)。
  503. // 出参:nil 合法;否则 error 指出越界段。
  504. //
  505. // ⚠ 本函数**不做任何过滤**(不跳过 /auth、不跳过 /org):一旦加了过滤,断言就恒过,
  506. // 与"没写断言"等价,但看起来像有防线——比没有更危险。
  507. func AssertFirstSegments(prefixes []string) error {
  508. allowed := make(map[string]struct{}, len(AcademicFirstSegments))
  509. for _, seg := range AcademicFirstSegments {
  510. allowed[seg] = struct{}{}
  511. }
  512. bad := make([]string, 0, 2)
  513. for _, seg := range prefixes {
  514. if _, ok := allowed[seg]; !ok {
  515. bad = append(bad, seg)
  516. }
  517. }
  518. if len(bad) > 0 {
  519. sort.Strings(bad)
  520. return fmt.Errorf(
  521. "academic route: 一级段越界 %v(academic 只允许 %v;/auth 属 identity、/org 属 org,重复注册会让先注册者静默失效)",
  522. bad, AcademicFirstSegments)
  523. }
  524. return nil
  525. }