route.go 32 KB

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