| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171 |
- // 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
- }
|