route.go 24 KB

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