org_service.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. // Package service 组织域业务逻辑(机构/校区只读列表 + 作用域越权拦截,T6)。
  2. //
  3. // 分层职责(架构规格 §3.2 三层):
  4. // - 本包(Service):编排 org/repository(作用域数据访问)+ 作用域越权判定(assertInScope),
  5. // 只返回 **sentinel 业务错误**(不泄漏 gorm 原错);
  6. // - Repository(org/repository):t_institution / t_campus 的作用域数据访问(继承 scope.BaseRepo,
  7. // 强制 Scoping 过滤——多租户防线的"第二道",数据层收口);
  8. // - Handler(T6):把请求 query 绑定为 ListQuery、经 scope.FromFiber 取作用域、调 Service,
  9. // 再把 *AppError 映射为统一响应信封(response.Fail / Page)。
  10. //
  11. // 作用域越权的双层防线(架构规格 §3.5,本域核心安全属性):
  12. // 1. **Service 层显式判定(assertInScope)**:请求 query 显式携带的 institutionId/campusId
  13. // 与 token 作用域冲突时,明确返回 code 1004(前端据此提示"作用域越界并校正")——
  14. // 这是给前端的**显式信号**(对齐 PC 契约 code=1004);
  15. // 2. **Repository 层强制过滤(Scoping)**:即便绕过 ①(如请求不带 id),数据查询也强制
  16. // 带 institution_id/campus_id 过滤——非超管永远查不到别家机构/校区的数据(**纵深防御**)。
  17. //
  18. // 两层缺一不可:只靠 ① 则漏网请求可能裸查;只靠 ② 则前端拿不到"越界"的明确错误码。
  19. //
  20. // 契约说明(与 PC 前端 src/api/org/institution.ts 的出入):
  21. //
  22. // PC 参考样例的 InstitutionDTO 用 {id, name, creditCode, status:number, createdAt},
  23. // 那是按"旧系统 school 概念"假想的出参形状。本后端以**目标态 core-model-ddl + GORM model**
  24. // 为权威契约,直接下发 model(json tag 已 camelCase):
  25. // {institutionId, institutionName, institutionCode, status:"active"|"disabled", createdAt, ...}。
  26. // PC 端 institution.ts 需按此对齐(前端侧任务,非本后端范围)——见工作区记忆"PC 前端 T4 拍板后果"。
  27. //
  28. // 依赖方向:service → repository(具体仓储,同域) + infra/scope + infra/errors(全下层/同域),
  29. // 不依赖 handler/main。纯业务,可单测(mock 由 e2e 用真 GORM 仓储覆盖,见 handler 包 e2e)。
  30. package service
  31. import (
  32. "context"
  33. stderrors "errors"
  34. "gorm.io/gorm"
  35. apperrors "yuansiwei/unified-backend/internal/infra/errors"
  36. appscope "yuansiwei/unified-backend/internal/infra/scope"
  37. "yuansiwei/unified-backend/internal/org/model"
  38. "yuansiwei/unified-backend/internal/org/repository"
  39. )
  40. // OrgService 组织域业务服务(机构/校区只读 + 越权拦截;启动装配一次,全进程共享)。
  41. //
  42. // 字段:
  43. // - db:数据库句柄(连接池由 infra/db 统一建立)。每次请求据此构造**带作用域**的仓储
  44. // (作用域来自中间件注入的 *scope.Scope,随请求变化,故仓储不进服务字段、按请求现造)。
  45. type OrgService struct {
  46. db *gorm.DB
  47. }
  48. // ListQuery 机构/校区列表查询条件(Handler 从 query 绑定;机构与校区列表共用)。
  49. type ListQuery struct {
  50. // InstitutionID 请求显式携带的机构 id(作用域第一级;0 = 未显式指定,由 Repository Scoping 兜底)。
  51. InstitutionID int64
  52. // CampusID 请求显式携带的校区 id(作用域第二级;0 = 机构级/未指定)。
  53. CampusID int64
  54. // Keyword 名称模糊筛选(可空)。
  55. Keyword string
  56. // Status 状态精确筛选(可空;取值 active/disabled)。
  57. Status string
  58. // Page / PageSize 分页(Handler 经 response.ParsePage 解析后填入)。
  59. Page int
  60. PageSize int
  61. }
  62. // NewOrgService 构造组织域业务服务。
  63. //
  64. // 入参:db *gorm.DB:数据库句柄(必填,fail-fast——无 DB 不挂载 /org 端点)。
  65. // 出参:*OrgService:就绪服务。
  66. func NewOrgService(db *gorm.DB) (*OrgService, error) {
  67. if db == nil {
  68. return nil, apperrors.New(apperrors.CodeInternal, "org service: DB 未注入")
  69. }
  70. return &OrgService{db: db}, nil
  71. }
  72. // assertInScope 作用域越权显式判定(Service 层第一道防线,返回 code 1004 给前端)。
  73. //
  74. // 入参:
  75. // - sc *scope.Scope:当前请求作用域(中间件注入,非 nil——公开路由不会走到本域)。
  76. // - reqInstitutionID int64:请求显式携带的机构 id(0 = 未显式指定)。
  77. // - reqCampusID int64:请求显式携带的校区 id(0 = 未显式指定)。
  78. //
  79. // 出参:
  80. // - error:
  81. // - ErrUnscoped(1004):请求显式指定的机构/校区与 token 作用域冲突(跨机构,或校区锁定 token
  82. // 请求了别的校区);
  83. // - nil:放行(超管跨机构 / 未显式指定 / 指定值与作用域一致)。
  84. //
  85. // 规则(与 scope.Scoping 数据层过滤对齐,但给出**显式错误码**):
  86. // 1. SuperAdmin=true:平台超管跨机构,跳过判定(放行);
  87. // 2. 机构:reqInstitutionID>0 且 != sc.InstitutionID → 1004(普通用户不能显式指定别家机构);
  88. // 3. 校区:仅当 sc 是"校区锁定"(sc.CampusID>0,如老师)时,reqCampusID>0 且 != sc.CampusID
  89. // → 1004。机构级 token(sc.CampusID=0,如教务/财务)可下钻查看本机构任一校区,故不拦截。
  90. func (s *OrgService) assertInScope(sc *appscope.Scope, reqInstitutionID, reqCampusID int64) error {
  91. if sc == nil {
  92. // 理论不会发生(受保护路由经中间件必注入);纵深防御按未授权拒绝
  93. return apperrors.ErrUnauth
  94. }
  95. if sc.SuperAdmin {
  96. return nil // 平台超管跨机构
  97. }
  98. if reqInstitutionID > 0 && reqInstitutionID != sc.InstitutionID {
  99. return apperrors.ErrUnscoped // 跨机构越权
  100. }
  101. if sc.CampusID > 0 && reqCampusID > 0 && reqCampusID != sc.CampusID {
  102. return apperrors.ErrUnscoped // 校区锁定 token 请求了别的校区
  103. }
  104. return nil
  105. }
  106. // ListInstitutions 机构分页列表(作用域过滤 + 越权判定)。
  107. //
  108. // 出参:
  109. // - []model.Institution:当前页(空时非 nil,JSON 序列化为 []);
  110. // - int64:总数;
  111. // - error:ErrUnscoped(1004) 越界 / ErrDBError(5002) DB 异常。
  112. func (s *OrgService) ListInstitutions(ctx context.Context, sc *appscope.Scope, q ListQuery) ([]model.Institution, int64, error) {
  113. if err := s.assertInScope(sc, q.InstitutionID, q.CampusID); err != nil {
  114. return nil, 0, err
  115. }
  116. repo := repository.NewInstitutionRepo(s.db, sc)
  117. return repo.PagedList(ctx, repository.InstitutionFilter{Keyword: q.Keyword, Status: q.Status}, q.Page, q.PageSize)
  118. }
  119. // ListCampuses 校区分页列表(作用域过滤 + 越权判定)。
  120. //
  121. // 出参:同 ListInstitutions(返回 []model.Campus)。
  122. func (s *OrgService) ListCampuses(ctx context.Context, sc *appscope.Scope, q ListQuery) ([]model.Campus, int64, error) {
  123. if err := s.assertInScope(sc, q.InstitutionID, q.CampusID); err != nil {
  124. return nil, 0, err
  125. }
  126. repo := repository.NewCampusRepo(s.db, sc)
  127. return repo.PagedList(ctx, repository.CampusFilter{Keyword: q.Keyword, Status: q.Status}, q.Page, q.PageSize)
  128. }
  129. // GetInstitution 取单个机构(作用域过滤 + 越权判定)。
  130. //
  131. // 出参:*model.Institution;error——ErrUnscoped(1004) 越界 / ErrNotFound(3001) 不存在或作用域外 / ErrDBError(5002)。
  132. func (s *OrgService) GetInstitution(ctx context.Context, sc *appscope.Scope, institutionID int64) (*model.Institution, error) {
  133. if institutionID > 0 {
  134. if err := s.assertInScope(sc, institutionID, 0); err != nil {
  135. return nil, err
  136. }
  137. }
  138. repo := repository.NewInstitutionRepo(s.db, sc)
  139. it, err := repo.Get(ctx, institutionID)
  140. if stderrors.Is(err, apperrors.ErrNotFound) {
  141. return nil, apperrors.ErrNotFound // 作用域外/不存在统一 3001(非超管取别家机构也归此,不泄漏"存在与否")
  142. }
  143. return it, err
  144. }
  145. // GetCampus 取单个校区(作用域过滤 + 越权判定)。
  146. //
  147. // 出参:*model.Campus;error——ErrUnscoped(1004) / ErrNotFound(3001) / ErrDBError(5002)。
  148. func (s *OrgService) GetCampus(ctx context.Context, sc *appscope.Scope, campusID int64) (*model.Campus, error) {
  149. if campusID > 0 {
  150. if err := s.assertInScope(sc, 0, campusID); err != nil {
  151. return nil, err
  152. }
  153. }
  154. repo := repository.NewCampusRepo(s.db, sc)
  155. cp, err := repo.Get(ctx, campusID)
  156. if stderrors.Is(err, apperrors.ErrNotFound) {
  157. return nil, apperrors.ErrNotFound
  158. }
  159. return cp, err
  160. }