wiring_academic.go 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. // academic 域装配(AC-11 交付部分)——跨服务依赖注入点。
  2. //
  3. // 为什么单独成文件:academic 的路由/Handler 由 AC-14 挂载,但**服务间注入关系**必须在
  4. // 本期固定下来,否则会退化成"端口永远没人实现 → 生产永远走降级分支"的静默缺陷。
  5. //
  6. // 本期唯一必须固定的注入(设计 §3.3 #23 / AC-11 硬要点 5):
  7. //
  8. // DivideService.firstCharged ← RecordRepo(FirstChargedRecordID)
  9. //
  10. // 背景:`divide_into_log_delete`(撤销分成)有两条分支——
  11. //
  12. // ① 学生存在"首次扣费上课记录" → **改绑 order_no 保留分成**(dump 口径,D11 已拍板);
  13. // ② 无首扣记录 → 回滚 total_income 并物理删除明细。
  14. //
  15. // 若端口未注入,DivideService 只能走 ②(保守分支)——即"撤销首扣订单"时把真实产生的分成整组冲掉
  16. // (少发钱,且与旧系统双跑必然不一致)。因此本文件的存在本身就是一条**回归防线**:
  17. // 谁把注入去掉,`TestWiringAcademicFirstChargedInjected` 立刻红(cmd/api 侧单测)。
  18. package main
  19. import (
  20. "context"
  21. "os"
  22. "github.com/gofiber/fiber/v2"
  23. "go.uber.org/zap"
  24. "gorm.io/gorm"
  25. academichandler "yuansiwei/unified-backend/internal/academic/handler"
  26. academicport "yuansiwei/unified-backend/internal/academic/port"
  27. academicrepo "yuansiwei/unified-backend/internal/academic/repository"
  28. academicservice "yuansiwei/unified-backend/internal/academic/service"
  29. identityhandler "yuansiwei/unified-backend/internal/identity/handler"
  30. identityrepo "yuansiwei/unified-backend/internal/identity/repository"
  31. )
  32. // 编译期契约断言(签名漂移即编译失败,杜绝"注入了但方法对不上"的隐蔽错误):
  33. //
  34. // ① RecordRepo 必须满足 DivideService 声明的 FirstChargedRecordReader(D11 改绑前提);
  35. // ② *DivideService 必须满足 RecordService 声明的 DivideLogGranter(结课联动分账前提)。
  36. var (
  37. _ academicservice.FirstChargedRecordReader = (*academicrepo.RecordRepo)(nil)
  38. _ academicservice.DivideLogGranter = (*academicservice.DivideService)(nil)
  39. _ academicport.UserNameReader = (*identityrepo.UserNameRepo)(nil)
  40. )
  41. // academicServices academic 域已装配的服务集合(AC-14 接线时在此继续追加 Handler/路由)。
  42. type academicServices struct {
  43. // Divide 分账服务(AC-09;本章节只负责把首次扣费端口注入它)。
  44. Divide *academicservice.DivideService
  45. // FirstChargedInjected 首次扣费记录端口是否已注入(D11 改绑分支生效的判据,供启动日志与单测断言)。
  46. FirstChargedInjected bool
  47. }
  48. // newAcademicServices 装配 academic 域服务。
  49. //
  50. // 入参:gdb *gorm.DB——全局数据库句柄(连接池)。
  51. // 出参:*academicServices(永不返回 nil;装配失败由后续域初始化暴露)。
  52. //
  53. // 关键说明(**评审必看**):
  54. // - `NewRecordRepo(gdb, nil)` 的 nil 是**有意**的:FirstChargedRecordID 的取数口径
  55. // (dump `yuansiwei_finance.sql:763811`)按 student_id 精确过滤、无机构/校区维度,
  56. // 分账撤销场景也没有请求作用域(系统/后台动作)。该端口方法内部**不调用 Scoped**,
  57. // 因此 nil scope 安全;但这个 repo 实例**不得**用于任何带作用域的查询
  58. // (带作用域的方法一调用即 panic fail-fast,属预期行为)。
  59. // - 其余 academic 服务(Course/Record/Maintenance)需要请求作用域,由 Handler 层
  60. // `repository.NewXxxRepo(db, scope.FromFiber(c))` 按请求构造(AC-13/14),不在此处单例化。
  61. func newAcademicServices(gdb *gorm.DB) *academicServices {
  62. firstChargedReader := academicrepo.NewRecordRepo(gdb, nil)
  63. divide := academicservice.NewDivideService(gdb, firstChargedReader)
  64. return &academicServices{
  65. Divide: divide,
  66. FirstChargedInjected: true,
  67. }
  68. }
  69. // registerAcademicRoutes academic 域路由接线(AC-14 第一批:40 个已交付端点)。
  70. //
  71. // 这是 `cmd/api/main.go` 里**唯一**的 academic 挂载调用点(main 不关心端点清单、不关心顺序,
  72. // 全部收在 `internal/academic/handler/route.go` 的注册表里)。
  73. //
  74. // 入参:
  75. // - app *fiber.App:应用实例;
  76. // - gdb *gorm.DB:数据库句柄(academic 的 Service 按请求作用域现场装配,Handler 只持 DB);
  77. // - authMw fiber.Handler:identity 的验签 + 作用域注入中间件(本批 40 端点**全部**需登录);
  78. // - log *zap.Logger:结构化日志器。
  79. //
  80. // 出参:error——Handler 装配失败 / 路由表非法(含 `/api`、空白名单、未知角色码)/
  81. // 断言 ①(`method+path` 全局重复)/ 断言 ②(一级段越界,如误注册 `/auth/*`)。
  82. // **任一失败都必须让进程起不来**:这类缺陷在运行期是"静默失效"(Fiber 后注册者被丢弃、
  83. // 或某角色恒 1003),启动失败是唯一可靠的暴露点。
  84. //
  85. // 副作用(仅 dev):`UNIFIED_AUTO_MIGRATE=1` 时顺带写 RBAC 种子(三张表,`INSERT IGNORE` 幂等)。
  86. // 生产不在此处写权限表——按"DDL 先行"纪律随迁移脚本执行(种子 SQL 由
  87. // `academichandler.RBACSeed.SQL("mysql")` 渲染,与路由表 1:1,不会手抄漏行)。
  88. // 种子写入失败**不阻断启动**(表未建时告警;空策略是 fail-closed,不会放行越权)。
  89. func registerAcademicRoutes(app *fiber.App, gdb *gorm.DB, authMw fiber.Handler, log *zap.Logger) error {
  90. academicH, err := academichandler.New(academichandler.Config{
  91. DB: gdb,
  92. // 成员校验器(Service 层对象级越权预检):academic 域经 port 只读 t_membership,
  93. // 不去 import org 的 repository(依赖方向铁律,见 academic/port/orgadapter.go)。
  94. Member: academicport.NewOrgMembershipChecker(gdb),
  95. // 用户显示名只读端口(端点 53 的 `realName`,D4 裁决):接口在 academic/port,
  96. // 实现放 identity(`t_user` 的属主),此处注入。**academic 对 identity 零 import**。
  97. Users: identityrepo.NewUserNameRepo(gdb),
  98. Log: log,
  99. })
  100. if err != nil {
  101. return err
  102. }
  103. reg, err := academichandler.Register(app, academicH, academichandler.RegisterOptions{
  104. Auth: authMw,
  105. RoleGuard: identityhandler.RequireRole,
  106. })
  107. if err != nil {
  108. return err
  109. }
  110. log.Info("academic 路由已挂载",
  111. zap.Int("routes", len(reg.Routes)),
  112. zap.Strings("firstSegments", reg.Prefixes))
  113. if os.Getenv("UNIFIED_AUTO_MIGRATE") == "1" {
  114. res, serr := academichandler.SeedRBAC(context.Background(), gdb, reg.Routes)
  115. switch {
  116. case serr != nil:
  117. // **不阻断启动**:种子是"运维动作"而不是"启动前提"。
  118. // 三张表里只有 t_permission/t_role_permission 在 AutoMigrate 名单内,`casbin_rule`
  119. // 按"目标态 DDL 先行"纪律**不**由 GORM 建表(见 infra/rbac/enforcer.go 包注释),
  120. // 因此 dev/预发若还没跑 DDL,这里一定报 "no such table"。
  121. // 若此时 os.Exit(1),结果是"没跑 DDL 就完全起不来"(连带登录也不可用),
  122. // 代价远大于"策略表为空"——策略为空是 fail-closed(非超管全 1003),不会放行越权。
  123. log.Warn("academic RBAC 种子未写入(通常是 casbin_rule 等权限表尚未按 DDL 建表)",
  124. zap.Error(serr))
  125. default:
  126. log.Info("academic RBAC 种子已应用(幂等:changed=0 表示已是最新)",
  127. zap.Int("casbinRules", res.CasbinRules),
  128. zap.Int64("insertedCasbinRules", res.InsertedCasbinRules),
  129. zap.Int("permissions", res.Permissions),
  130. zap.Int64("insertedPermissions", res.InsertedPermissions),
  131. zap.Int("rolePermissions", res.RolePermissions),
  132. zap.Int64("insertedRolePermissions", res.InsertedRolePermissions))
  133. }
  134. }
  135. return nil
  136. }