| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205 |
- // 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) // 作用域绑定到仓储
- // list, total, err := repo.List(c.Context(), Filter{...})
- // if err != nil { return response.Fail(c, err) }
- // return response.Page(c, list, 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 int64:登录用户 ID(内部 int;对外暴露的 canonical_user_id 是 ULID,见 identity/id)。
- // - Role string:角色码(7 角色之一:"1"超管/"2"教务/"3"财务/"4"老师/"5"学生/"6"家长/"8"市场;市场登录入口禁用,正常不会出现 "9")。
- // - SuperAdmin bool:是否平台超管(跨机构操作,scoping 跳过机构过滤)。
- type Scope struct {
- InstitutionID int64
- CampusID int64
- UserID int64
- 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
- }
- // 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}
- }
- // scoping 把作用域过滤条件拼进查询(BaseRepo 的受保护入口,业务 Repo 通过组合调用)。
- //
- // 入参:
- // - 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;CampusID>0 时再强制 campus_id。
- //
- // 异常场景:
- // - 表没有 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.Sc.CampusID > 0 {
- q = q.Where("campus_id = ?", r.Sc.CampusID) // 强制校区
- }
- 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) }
|