// Package service 组织域业务逻辑(机构/校区只读列表 + 作用域越权拦截,T6)。 // // 分层职责(架构规格 §3.2 三层): // - 本包(Service):编排 org/repository(作用域数据访问)+ 作用域越权判定(assertInScope), // 只返回 **sentinel 业务错误**(不泄漏 gorm 原错); // - Repository(org/repository):t_institution / t_campus 的作用域数据访问(继承 scope.BaseRepo, // 强制 Scoping 过滤——多租户防线的"第二道",数据层收口); // - Handler(T6):把请求 query 绑定为 ListQuery、经 scope.FromFiber 取作用域、调 Service, // 再把 *AppError 映射为统一响应信封(response.Fail / Page)。 // // 作用域越权的双层防线(架构规格 §3.5,本域核心安全属性): // 1. **Service 层显式判定(assertInScope)**:请求 query 显式携带的 institutionId/campusId // 与 token 作用域冲突时,明确返回 code 1004(前端据此提示"作用域越界并校正")—— // 这是给前端的**显式信号**(对齐 PC 契约 code=1004); // 2. **Repository 层强制过滤(Scoping)**:即便绕过 ①(如请求不带 id),数据查询也强制 // 带 institution_id/campus_id 过滤——非超管永远查不到别家机构/校区的数据(**纵深防御**)。 // // 两层缺一不可:只靠 ① 则漏网请求可能裸查;只靠 ② 则前端拿不到"越界"的明确错误码。 // // 契约说明(与 PC 前端 src/api/org/institution.ts 的出入): // // PC 参考样例的 InstitutionDTO 用 {id, name, creditCode, status:number, createdAt}, // 那是按"旧系统 school 概念"假想的出参形状。本后端以**目标态 core-model-ddl + GORM model** // 为权威契约,直接下发 model(json tag 已 camelCase): // {institutionId, institutionName, institutionCode, status:"active"|"disabled", createdAt, ...}。 // PC 端 institution.ts 需按此对齐(前端侧任务,非本后端范围)——见工作区记忆"PC 前端 T4 拍板后果"。 // // 依赖方向:service → repository(具体仓储,同域) + infra/scope + infra/errors(全下层/同域), // 不依赖 handler/main。纯业务,可单测(mock 由 e2e 用真 GORM 仓储覆盖,见 handler 包 e2e)。 package service import ( "context" stderrors "errors" "gorm.io/gorm" apperrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" "yuansiwei/unified-backend/internal/org/model" "yuansiwei/unified-backend/internal/org/repository" ) // OrgService 组织域业务服务(机构/校区只读 + 越权拦截;启动装配一次,全进程共享)。 // // 字段: // - db:数据库句柄(连接池由 infra/db 统一建立)。每次请求据此构造**带作用域**的仓储 // (作用域来自中间件注入的 *scope.Scope,随请求变化,故仓储不进服务字段、按请求现造)。 type OrgService struct { db *gorm.DB } // ListQuery 机构/校区列表查询条件(Handler 从 query 绑定;机构与校区列表共用)。 type ListQuery struct { // InstitutionID 请求显式携带的机构 id(作用域第一级;0 = 未显式指定,由 Repository Scoping 兜底)。 InstitutionID int64 // CampusID 请求显式携带的校区 id(作用域第二级;0 = 机构级/未指定)。 CampusID int64 // Keyword 名称模糊筛选(可空)。 Keyword string // Status 状态精确筛选(可空;取值 active/disabled)。 Status string // Page / PageSize 分页(Handler 经 response.ParsePage 解析后填入)。 Page int PageSize int } // NewOrgService 构造组织域业务服务。 // // 入参:db *gorm.DB:数据库句柄(必填,fail-fast——无 DB 不挂载 /org 端点)。 // 出参:*OrgService:就绪服务。 func NewOrgService(db *gorm.DB) (*OrgService, error) { if db == nil { return nil, apperrors.New(apperrors.CodeInternal, "org service: DB 未注入") } return &OrgService{db: db}, nil } // assertInScope 作用域越权显式判定(Service 层第一道防线,返回 code 1004 给前端)。 // // 入参: // - sc *scope.Scope:当前请求作用域(中间件注入,非 nil——公开路由不会走到本域)。 // - reqInstitutionID int64:请求显式携带的机构 id(0 = 未显式指定)。 // - reqCampusID int64:请求显式携带的校区 id(0 = 未显式指定)。 // // 出参: // - error: // - ErrUnscoped(1004):请求显式指定的机构/校区与 token 作用域冲突(跨机构,或校区锁定 token // 请求了别的校区); // - nil:放行(超管跨机构 / 未显式指定 / 指定值与作用域一致)。 // // 规则(与 scope.Scoping 数据层过滤对齐,但给出**显式错误码**): // 1. SuperAdmin=true:平台超管跨机构,跳过判定(放行); // 2. 机构:reqInstitutionID>0 且 != sc.InstitutionID → 1004(普通用户不能显式指定别家机构); // 3. 校区:仅当 sc 是"校区锁定"(sc.CampusID>0,如老师)时,reqCampusID>0 且 != sc.CampusID // → 1004。机构级 token(sc.CampusID=0,如教务/财务)可下钻查看本机构任一校区,故不拦截。 func (s *OrgService) assertInScope(sc *appscope.Scope, reqInstitutionID, reqCampusID int64) error { if sc == nil { // 理论不会发生(受保护路由经中间件必注入);纵深防御按未授权拒绝 return apperrors.ErrUnauth } if sc.SuperAdmin { return nil // 平台超管跨机构 } if reqInstitutionID > 0 && reqInstitutionID != sc.InstitutionID { return apperrors.ErrUnscoped // 跨机构越权 } if sc.CampusID > 0 && reqCampusID > 0 && reqCampusID != sc.CampusID { return apperrors.ErrUnscoped // 校区锁定 token 请求了别的校区 } return nil } // ListInstitutions 机构分页列表(作用域过滤 + 越权判定)。 // // 出参: // - []model.Institution:当前页(空时非 nil,JSON 序列化为 []); // - int64:总数; // - error:ErrUnscoped(1004) 越界 / ErrDBError(5002) DB 异常。 func (s *OrgService) ListInstitutions(ctx context.Context, sc *appscope.Scope, q ListQuery) ([]model.Institution, int64, error) { if err := s.assertInScope(sc, q.InstitutionID, q.CampusID); err != nil { return nil, 0, err } repo := repository.NewInstitutionRepo(s.db, sc) return repo.PagedList(ctx, repository.InstitutionFilter{Keyword: q.Keyword, Status: q.Status}, q.Page, q.PageSize) } // ListCampuses 校区分页列表(作用域过滤 + 越权判定)。 // // 出参:同 ListInstitutions(返回 []model.Campus)。 func (s *OrgService) ListCampuses(ctx context.Context, sc *appscope.Scope, q ListQuery) ([]model.Campus, int64, error) { if err := s.assertInScope(sc, q.InstitutionID, q.CampusID); err != nil { return nil, 0, err } repo := repository.NewCampusRepo(s.db, sc) return repo.PagedList(ctx, repository.CampusFilter{Keyword: q.Keyword, Status: q.Status}, q.Page, q.PageSize) } // GetInstitution 取单个机构(作用域过滤 + 越权判定)。 // // 出参:*model.Institution;error——ErrUnscoped(1004) 越界 / ErrNotFound(3001) 不存在或作用域外 / ErrDBError(5002)。 func (s *OrgService) GetInstitution(ctx context.Context, sc *appscope.Scope, institutionID int64) (*model.Institution, error) { if institutionID > 0 { if err := s.assertInScope(sc, institutionID, 0); err != nil { return nil, err } } repo := repository.NewInstitutionRepo(s.db, sc) it, err := repo.Get(ctx, institutionID) if stderrors.Is(err, apperrors.ErrNotFound) { return nil, apperrors.ErrNotFound // 作用域外/不存在统一 3001(非超管取别家机构也归此,不泄漏"存在与否") } return it, err } // GetCampus 取单个校区(作用域过滤 + 越权判定)。 // // 出参:*model.Campus;error——ErrUnscoped(1004) / ErrNotFound(3001) / ErrDBError(5002)。 func (s *OrgService) GetCampus(ctx context.Context, sc *appscope.Scope, campusID int64) (*model.Campus, error) { if campusID > 0 { if err := s.assertInScope(sc, 0, campusID); err != nil { return nil, err } } repo := repository.NewCampusRepo(s.db, sc) cp, err := repo.Get(ctx, campusID) if stderrors.Is(err, apperrors.ErrNotFound) { return nil, apperrors.ErrNotFound } return cp, err }