// Package scope 多租户作用域注入(institution/campus 强制过滤防线,架构规格 §3.5)。 // // 数据流(三段式,载体明确): // 1. Auth 中间件:验签成功后从 token claims 解析出 *Scope,调 Set(c, sc) 写入 // fiber.Ctx 的请求级局部值(c.Locals); // 2. Handler:调 FromFiber(c) 读回 *Scope,连同 DB 句柄一起构造业务 Repository // (NewBaseRepo(db, sc))——**作用域不进 context、不靠隐式全局**, // 构造 Repository 即"绑定本次请求的租户",后续所有查询强制带机构/校区过滤; // 3. Repository:BaseRepo.Scoping 把 institution_id / campus_id 条件拼进每条查询, // **禁止裸查**(风险登记册"跨校区越权")。 // // 为什么不走 context.Value:Fiber v2 的 *fiber.Ctx 不实现标准 context.Context // (缺 Deadline/Value 透传),若把 c 当 context 传递下游取不到;显式"Handler 取 Scope // → 构造 Repo"最直白、可单测(无需 mock fiber 请求),且杜绝"忘带 context 导致作用域丢失"。 // // 依赖方向铁律:本包属 infra,只被业务域依赖,不依赖任何业务域; // 作用域的"解析"(token → user/campus/role)由 identity/org 域完成,本包只负责"承载与过滤"。 // // 典型使用: // // // Handler(业务域内) // func List(c *fiber.Ctx) error { // sc := scope.FromFiber(c) // Auth 中间件已写入 // if sc == nil { return response.Fail(c, errors.ErrUnauth) } // repo := newCourseRepo(db, sc) // 作用域绑定到仓储 // page, size := response.ParsePage(c) // 统一分页参数(默认 1/10,上限 200) // list, total, err := repo.List(c.Context(), Filter{...}, page, size) // if err != nil { return response.Fail(c, err) } // return response.Page(c, list, page, size, total) // } // // // Repository(业务域内) // type CourseRepo struct{ scope.BaseRepo } // func newCourseRepo(db *gorm.DB, sc *scope.Scope) *CourseRepo { // return &CourseRepo{scope.BaseRepo{DB: db, Sc: sc}} // } // func (r *CourseRepo) List(ctx context.Context, f Filter) ([]model.Course, int64, error) { // var list []model.Course // var total int64 // q := r.Scoping(r.DB.WithContext(ctx).Model(&model.Course{})) // 强制带机构/校区 // if err := q.Where("status = ?", f.Status). // Count(&total). // Count 收 *int64(GORM 纪律) // Order("created_at DESC"). // Find(&list).Error; err != nil { // return nil, 0, errors.WithCause(errors.ErrDBError, err) // } // return list, total, nil // } package scope import ( "context" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) // Scope 多租户作用域,由 Auth 中间件从 token claims 解析后注入。 // // 字段: // - InstitutionID int64:当前请求所属机构 ID(必填;0 表示缺失,视为异常请求应拒绝)。 // - CampusID int64:当前请求所属校区 ID(0 表示不限校区,如机构级超管视角)。 // - UserID string:登录用户 ID(canonical_user_id,ULID 26 位,对齐 ADR-013/D1; // 2026-11-06 由 int64 修正为 string——新域外键一律 ULID,见 identity/model.User.CanonicalUserID)。 // - Role string:角色码(7 角色之一:super_admin/academic/finance/teacher/student/parent/market;市场登录入口禁用)。 // - SuperAdmin bool:是否平台超管(跨机构操作,scoping 跳过机构过滤)。 type Scope struct { InstitutionID int64 CampusID int64 UserID string Role string SuperAdmin bool } // CtxKey 作用域在 fiber.Ctx 局部值(c.Locals)中的键。 // 导出以便"中间件写入"与"Handler 读取"两端使用同一常量,避免字符串散落。 const CtxKey = "scope" // Set 把作用域写入 fiber.Ctx 的请求级局部值(Auth 中间件在验签成功后调用)。 // // 入参: // - c *fiber.Ctx:Fiber 请求上下文。 // - sc *Scope:已解析好的作用域;字段合法性由调用方(identity 域验签逻辑)保证。 // // 出参: // - *fiber.Ctx:同一个 c(便于中间件链书写:scope.Set(c, sc) 后 c.Next())。 // // 异常场景: // - sc 为 nil:不写入(后续 FromFiber 返回 nil,Handler 应拒绝请求或 scoping 纵深防御拦截)。 func Set(c *fiber.Ctx, sc *Scope) *fiber.Ctx { if sc != nil { _ = c.Locals(CtxKey, sc) // 写入请求级局部值(返回写入前的旧值,本处忽略) } return c } // FromFiber 从 fiber.Ctx 局部值读取作用域(Handler 侧入口,替代 context 通道)。 // // 入参: // - c *fiber.Ctx:当前请求上下文。 // // 出参: // - *Scope:写入的作用域;未写入或类型不符时返回 nil(调用方必须判 nil—— // nil 代表"公开路由/未认证/中间件链配置错误",公开接口的 Repository 不走 BaseRepo)。 // // 说明:c.Locals(key) 单参调用为"读",返回 interface{};未设置时为 nil。 func FromFiber(c *fiber.Ctx) *Scope { if c == nil { return nil } if sc, ok := c.Locals(CtxKey).(*Scope); ok { return sc } return nil } // BaseRepo 所有业务域 Repository 的基类:持有数据库句柄与本次请求的作用域, // 提供 scoping 强制过滤——多租户防线的唯一收口点。 // // 字段: // - DB *gorm.DB:数据库句柄(连接池由 infra/db 统一建立)。 // - Sc *Scope:当前请求作用域;由 Repository 构造时从 Handler 传入(见 FromFiber 链路)。 // // 使用约定: // - 业务域 Repo 通过组合 BaseRepo 获得 scoping 能力(见包注释样例); // - 所有 List/Get/Update/Delete 必须先过 scoping,禁止直接 r.DB.Where(...); // - 构造入口应显式传入 *Scope;Sc 为 nil 时 scoping 纵深防御 panic(见下)。 type BaseRepo struct { DB *gorm.DB Sc *Scope // CampusScoped 该仓储查询的表是否含 campus_id 列(作用域第二级)。 // - true(默认):表带 campus_id(t_campus / t_course / t_student 等校区级资源)—— // Sc.CampusID>0 时 Scoping 追加 campus_id 过滤; // - false:机构级资源(t_institution 无 campus_id 列)——只加 institution_id 过滤, // 跳过 campus_id(否则 SQL 报 Unknown column,见 Scoping 注释规则 3)。 // 由具体仓储构造函数显式设置(机构仓储 false / 校区及校区以下仓储 true), // 避免"对所有表无差别加 campus_id"导致无 campus_id 的表查询报错。 CampusScoped bool } // NewBaseRepo 构造作用域仓储基类(Handler 在拿到 Scope 后调用)。 // // 入参: // - db *gorm.DB:数据库句柄(必填)。 // - sc *Scope:当前请求作用域(可能为 nil——系统任务/割接脚本等无用户作用域场景, // 由具体 Repository 决定是否允许;nil 进入 scoping 会 panic,属预期 fail-fast)。 // // 出参: // - BaseRepo:基类实例(值类型;业务 Repo 通常内嵌 `scope.BaseRepo`)。 // // 说明:不在此处拒绝 nil——部分平台级操作(超管跨机构、定时任务、割接)确实无作用域; // 是否允许"无作用域"由具体域评审决定,scoping 的 panic 是最后一道兜底防线。 func NewBaseRepo(db *gorm.DB, sc *Scope) BaseRepo { return BaseRepo{DB: db, Sc: sc, CampusScoped: true} // 默认表带 campus_id(校区级及以下资源) } // Scoping 把作用域过滤条件拼进查询(BaseRepo 的作用域强制过滤入口,业务 Repo 通过组合调用)。 // // 导出理由:业务域仓储(org/academic/teaching 等)以组合方式内嵌 BaseRepo,跨包调用 // 受 Go 可见性规则约束——方法必须导出才能在外部包经组合调用(`r.Scoping(...)`)。 // 这是"多租户防线唯一收口点"的设计前提:所有业务查询必须经此显式注入机构/校区过滤, // 禁止绕过(裸 r.DB.Where(...))。命名大写 Scoping 同时强化"必须走作用域"的纪律。 // // 入参: // - q *gorm.DB:基础查询(通常 r.DB.WithContext(ctx).Model(&model.T{}))。 // // 出参: // - *gorm.DB:追加过滤条件后的查询(原 q 不被就地修改,链式安全)。 // // 过滤规则(与架构规格 §3.5 对齐): // 1. Sc 为 nil:**panic**(errNoScope 文案)——无作用域上下文进入数据访问层属 // 配置错误(正常请求链路 Auth 中间件保证作用域已注入),fail-fast 优于 // 静默放行裸查;panic 由 HTTP 接入层全局 Recover 兜底为 5001 + 结构化日志, // 不影响生产可用性(单测须覆盖该分支); // 2. SuperAdmin=true:不加机构/校区过滤(平台超管跨机构); // 3. 普通用户:强制 institution_id = Sc.InstitutionID;**当且仅当 CampusScoped=true 且 // Sc.CampusID>0** 时再强制 campus_id。机构级资源(CampusScoped=false,如 t_institution // 无 campus_id 列)跳过 campus_id 过滤——否则 SQL 报 Unknown column。 // // 异常场景: // - 表没有 institution_id 列(如平台级字典表):该表的 Repository 不应继承 BaseRepo, // 直接使用裸 *gorm.DB(代码评审中显式标注"平台级数据")。 func (r BaseRepo) Scoping(q *gorm.DB) *gorm.DB { if r.Sc == nil { // 纵深防御:无作用域上下文进入数据访问层,fail-fast(全局 Recover 兜底 5001) panic(errNoScope.Error()) } if r.Sc.SuperAdmin { return q // 超管跨机构,不加作用域 } q = q.Where("institution_id = ?", r.Sc.InstitutionID) // 强制机构 if r.CampusScoped && r.Sc.CampusID > 0 { q = q.Where("campus_id = ?", r.Sc.CampusID) // 强制校区(仅表带 campus_id 列时) } return q } // WithContext 便捷方法:把标准 context.Context 与 BaseRepo 作用域一起绑定, // 供业务 Repo 在方法内 `r.WithContext(ctx)` 起步(避免每个方法手写 r.DB.WithContext(ctx))。 // // 入参: // - ctx context.Context:标准请求上下文(GORM 用其传递取消/超时信号,与 fiber 解耦)。 // // 出参: // - *gorm.DB:WithContext 后的查询句柄(**未**加作用域——仍需调用方追加 r.Scoping(...), // 防止"以为 WithContext 带了租户"的误用;作用域永远显式经 scoping 注入)。 // // 说明:此方法只处理"context 传递",**不**处理作用域——两段式显式注入是本包的核心纪律。 func (r BaseRepo) WithContext(ctx context.Context) *gorm.DB { return r.DB.WithContext(ctx) } // errNoScope 无作用域上下文的哨兵错误(scoping panic 文案,Recover 日志中可检索)。 // 包级私有;不对外暴露 error 值(panic 场景由全局 Recover 统一处理为 5001)。 var errNoScope = errString("scope: missing tenant scope, refusing unsoped query") // errString 实现 error 的最小字符串错误类型(避免引入 pkg/errors 依赖)。 type errString string // Error 实现 error 接口。 // 入参:无。出参:string——固定文案 "scope: missing tenant scope, refusing unsoped query"。 func (e errString) Error() string { return string(e) }