Просмотр исходного кода

feat(org): T6 机构/校区仓储 + 强制 Scoping + 越权拦截 + /org 端点

org 域只读落地(架构规格 §3.5 多租户防线):
- infra/scope:scoping→Scoping 导出(跨包组合调用须导出方法,修复 org repo
  "cannot refer to unexported method" 编译错误);BaseRepo 增 CampusScoped 标记,
  机构级表(t_institution 无 campus_id 列)只加 institution_id 过滤,
  避免 campus_id Unknown column。
- org/repository:InstitutionRepo/CampusRepo 继承 BaseRepo 强制 Scoping,
  PagedList + Get(GORM 纪律:Count *int64 / NotFound→ErrNotFound / nil 切片非 nil)。
- org/service:OrgService.assertInScope(请求显式 id 与 token 作用域冲突→code 1004,
  服务层第一道防线)+ List/Get 编排(仓储层 Scoping 为第二道纵深防御)。
- org/handler:/org/institutions[/:id] + /org/campuses[/:id],scope 经中间件注入。
- cmd/api:org 装配 + /org 路由(经同一验签中间件;公开路由铁律 8 不受影响)。
- 测试:assertInScope 纯单测 + 越权 e2e(DB-gated,跨机构/跨校区被拒 1004、
  数据层 Scoping 只给本机构/本校区、超管跨机构全见、无 token 1001)。

build/vet/test 全绿;测试函数 99(org 域 5,其中 4 e2e DB-gated 未设 DSN 则 skip)。
Q 3 дней назад
Родитель
Сommit
b8f4538b68

+ 24 - 1
cmd/api/main.go

@@ -46,7 +46,9 @@ import (
 	"yuansiwei/unified-backend/internal/infra/config"
 	"yuansiwei/unified-backend/internal/infra/db"
 	"yuansiwei/unified-backend/internal/infra/redis"
+	orghandler "yuansiwei/unified-backend/internal/org/handler"
 	orgmodel "yuansiwei/unified-backend/internal/org/model"
+	orgservice "yuansiwei/unified-backend/internal/org/service"
 )
 
 // main 进程入口:装配 → 监听 → 信号驱动优雅退出。
@@ -170,6 +172,18 @@ func main() {
 		os.Exit(1)
 	}
 
+	// ---- org 模块装配(T6:机构/校区只读 + 作用域越权拦截)----
+	orgSvc, err := orgservice.NewOrgService(gdb)
+	if err != nil {
+		logger.Error("OrgService 装配失败", zap.Error(err))
+		os.Exit(1)
+	}
+	orgH, err := orghandler.New(orghandler.Config{Svc: orgSvc})
+	if err != nil {
+		logger.Error("org Handler 装配失败", zap.Error(err))
+		os.Exit(1)
+	}
+
 	app := fiber.New(fiber.Config{
 		AppName:               "yuansiwei-unified-backend",
 		ReadTimeout:           time.Duration(cfg.Server.ReadTimeoutSec) * time.Second,
@@ -205,7 +219,16 @@ func main() {
 	auth.Get("/me", authH.Me)
 	auth.Post("/logout", authH.Logout)
 
-	// 其余域(org/academic/teaching/ai)路由挂载点(域模块实现后在此注册):
+	// org 域(机构/校区只读 + 作用域越权拦截,经同一验签中间件;作用域由中间件注入,
+	// Repository Scoping 强制机构/校区过滤——跨校区越权防线,架构规格 §3.5)
+	org := app.Group("/org")
+	org.Use(authMw)
+	org.Get("/institutions", orgH.Institutions)
+	org.Get("/institutions/:institutionId", orgH.Institution)
+	org.Get("/campuses", orgH.Campuses)
+	org.Get("/campuses/:campusId", orgH.Campus)
+
+	// 其余域(academic/teaching/ai)路由挂载点(域模块实现后在此注册):
 	//
 	//	app.Get("/auth/jwks", identityHandler.JWKS)            // 公开
 	//	auth := app.Group("/", middleware.Auth(logger))        // 以下全部需登录

+ 24 - 9
internal/infra/scope/scope.go

@@ -6,7 +6,7 @@
 //  2. Handler:调 FromFiber(c) 读回 *Scope,连同 DB 句柄一起构造业务 Repository
 //     (NewBaseRepo(db, sc))——**作用域不进 context、不靠隐式全局**,
 //     构造 Repository 即"绑定本次请求的租户",后续所有查询强制带机构/校区过滤;
-//  3. Repository:BaseRepo.scoping 把 institution_id / campus_id 条件拼进每条查询,
+//  3. Repository:BaseRepo.Scoping 把 institution_id / campus_id 条件拼进每条查询,
 //     **禁止裸查**(风险登记册"跨校区越权")。
 //
 // 为什么不走 context.Value:Fiber v2 的 *fiber.Ctx 不实现标准 context.Context
@@ -37,7 +37,7 @@
 //	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{})) // 强制带机构/校区
+//	    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").
@@ -128,6 +128,14 @@ func FromFiber(c *fiber.Ctx) *Scope {
 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 后调用)。
@@ -143,10 +151,15 @@ type BaseRepo struct {
 // 说明:不在此处拒绝 nil——部分平台级操作(超管跨机构、定时任务、割接)确实无作用域;
 // 是否允许"无作用域"由具体域评审决定,scoping 的 panic 是最后一道兜底防线。
 func NewBaseRepo(db *gorm.DB, sc *Scope) BaseRepo {
-	return BaseRepo{DB: db, Sc: sc}
+	return BaseRepo{DB: db, Sc: sc, CampusScoped: true} // 默认表带 campus_id(校区级及以下资源)
 }
 
-// scoping 把作用域过滤条件拼进查询(BaseRepo 的受保护入口,业务 Repo 通过组合调用)。
+// Scoping 把作用域过滤条件拼进查询(BaseRepo 的作用域强制过滤入口,业务 Repo 通过组合调用)。
+//
+// 导出理由:业务域仓储(org/academic/teaching 等)以组合方式内嵌 BaseRepo,跨包调用
+// 受 Go 可见性规则约束——方法必须导出才能在外部包经组合调用(`r.Scoping(...)`)。
+// 这是"多租户防线唯一收口点"的设计前提:所有业务查询必须经此显式注入机构/校区过滤,
+// 禁止绕过(裸 r.DB.Where(...))。命名大写 Scoping 同时强化"必须走作用域"的纪律。
 //
 // 入参:
 //   - q *gorm.DB:基础查询(通常 r.DB.WithContext(ctx).Model(&model.T{}))。
@@ -160,12 +173,14 @@ func NewBaseRepo(db *gorm.DB, sc *Scope) BaseRepo {
 //      静默放行裸查;panic 由 HTTP 接入层全局 Recover 兜底为 5001 + 结构化日志,
 //      不影响生产可用性(单测须覆盖该分支);
 //   2. SuperAdmin=true:不加机构/校区过滤(平台超管跨机构);
-//   3. 普通用户:强制 institution_id = Sc.InstitutionID;CampusID>0 时再强制 campus_id。
+//   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 {
+func (r BaseRepo) Scoping(q *gorm.DB) *gorm.DB {
 	if r.Sc == nil {
 		// 纵深防御:无作用域上下文进入数据访问层,fail-fast(全局 Recover 兜底 5001)
 		panic(errNoScope.Error())
@@ -174,8 +189,8 @@ func (r BaseRepo) scoping(q *gorm.DB) *gorm.DB {
 		return q // 超管跨机构,不加作用域
 	}
 	q = q.Where("institution_id = ?", r.Sc.InstitutionID) // 强制机构
-	if r.Sc.CampusID > 0 {
-		q = q.Where("campus_id = ?", r.Sc.CampusID) // 强制校区
+	if r.CampusScoped && r.Sc.CampusID > 0 {
+		q = q.Where("campus_id = ?", r.Sc.CampusID) // 强制校区(仅表带 campus_id 列时)
 	}
 	return q
 }
@@ -187,7 +202,7 @@ func (r BaseRepo) scoping(q *gorm.DB) *gorm.DB {
 //   - ctx context.Context:标准请求上下文(GORM 用其传递取消/超时信号,与 fiber 解耦)。
 //
 // 出参:
-//   - *gorm.DB:WithContext 后的查询句柄(**未**加作用域——仍需调用方追加 r.scoping(...),
+//   - *gorm.DB:WithContext 后的查询句柄(**未**加作用域——仍需调用方追加 r.Scoping(...),
 //     防止"以为 WithContext 带了租户"的误用;作用域永远显式经 scoping 注入)。
 //
 // 说明:此方法只处理"context 传递",**不**处理作用域——两段式显式注入是本包的核心纪律。

+ 173 - 0
internal/org/handler/handler.go

@@ -0,0 +1,173 @@
+// Package handler 组织域 HTTP 接入层(/org/* 端点,架构规格 §3.2 三层之"Handler",T6)。
+//
+// 分层职责:
+//   - 本包(Handler):从 query 绑定 ListQuery → 经 scope.FromFiber 取中间件注入的作用域 →
+//     调 org/service.OrgService(纯业务 + 越权判定)→ 把 *AppError 映射为统一响应信封。
+//     **不写业务逻辑、不直接碰 DB**;作用域不进 query 兜底(query 显式 id 仅作"越权判定"输入,
+//     真正的数据过滤由 Repository Scoping 完成——query 缺 id 也安全)。
+//   - Service(org/service):机构/校区只读编排 + assertInScope 越权判定(code 1004)。
+//   - Repository(org/repository):t_institution / t_campus 作用域数据访问(强制 Scoping)。
+//
+// 端点清单(路由挂载在 T6 main 完成,经 Auth 验签中间件前置——见 cmd/api/main.go):
+//   - GET /org/institutions   机构分页列表(keyword/status/page/pageSize 可空;institutionId/campusId 为越权判定输入)
+//   - GET /org/institutions/:institutionId   单个机构
+//   - GET /org/campuses       校区分页列表(同上)
+//   - GET /org/campuses/:campusId           单个校区
+//
+// 契约要点:
+//   - HTTP 恒 200,真实状态在 body code(铁律 6;见 infra/response)。
+//   - 作用域越界(query 显式 id 与 token 作用域冲突)→ code 1004(前端据此校正 scope)。
+//   - 空列表:data.list 恒为非 nil 的 [](response.Page 要求)。
+package handler
+
+import (
+	"strconv"
+
+	"github.com/gofiber/fiber/v2"
+
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/infra/response"
+	"yuansiwei/unified-backend/internal/infra/scope"
+	"yuansiwei/unified-backend/internal/org/service"
+)
+
+// Handler 组织域 HTTP 处理器(启动装配一次,全进程共享)。
+type Handler struct {
+	svc *service.OrgService
+}
+
+// Config Handler 装配参数(T6 main 按此构造)。
+type Config struct {
+	// Svc 组织域业务服务(必填)。
+	Svc *service.OrgService
+}
+
+// New 构造组织域 HTTP 处理器。
+//
+// 入参:cfg Config。
+// 出参:
+//   - *Handler:就绪处理器。
+//   - error:必填依赖缺失(Svc 为 nil,fail-fast——无业务服务不允许挂载端点)。
+func New(cfg Config) (*Handler, error) {
+	if cfg.Svc == nil {
+		return nil, apperrors.New(apperrors.CodeInternal, "org handler: OrgService 未注入")
+	}
+	return &Handler{svc: cfg.Svc}, nil
+}
+
+// scopeFromCtx 取中间件注入的多租户作用域;nil 时按未授权拒绝(受保护路由中间件必注入)。
+func scopeFromCtx(c *fiber.Ctx) (*scope.Scope, error) {
+	sc := scope.FromFiber(c)
+	if sc == nil {
+		return nil, apperrors.ErrUnauth
+	}
+	return sc, nil
+}
+
+// bindListQuery 从 query 绑定机构/校区列表查询条件。
+//
+// 入参:c *fiber.Ctx。
+// 出参:service.ListQuery:
+//   - institutionId/campusId:query 显式携带(int64,缺失/非法为 0——0 表示未显式指定,交由 Scoping 兜底);
+//   - keyword/status:可空字符串;
+//   - page/pageSize:经 response.ParsePage 解析(默认 1/10,上限 200)。
+func bindListQuery(c *fiber.Ctx) service.ListQuery {
+	page, pageSize := response.ParsePage(c)
+	return service.ListQuery{
+		InstitutionID: parseID64(c.Query("institutionId")),
+		CampusID:      parseID64(c.Query("campusId")),
+		Keyword:       c.Query("keyword"),
+		Status:        c.Query("status"),
+		Page:          page,
+		PageSize:      pageSize,
+	}
+}
+
+// parseID64 把 query 的机构/校区 id 解析为 int64(缺失/非法 → 0,表示"未显式指定",
+// 交由 Repository Scoping 兜底——0 值本身不触发越权判定,也不构成裸查)。
+func parseID64(s string) int64 {
+	if s == "" {
+		return 0
+	}
+	v, err := strconv.ParseInt(s, 10, 64)
+	if err != nil {
+		return 0 // 非法 id 不报错(作用域判定对 0 放行,数据过滤由 Scoping 保证安全)
+	}
+	return v
+}
+
+// Institutions 机构分页列表(GET /org/institutions,受保护)。
+//
+// 出参(成功):code 200,data 为 PageData(list 为 []model.Institution)。
+// 出参(失败):code 1004(作用域越界)/ 1001(未登录)/ 5002(DB)。
+func (h *Handler) Institutions(c *fiber.Ctx) error {
+	sc, err := scopeFromCtx(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	q := bindListQuery(c)
+	page, pageSize := q.Page, q.PageSize
+	list, total, err := h.svc.ListInstitutions(c.Context(), sc, q)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.Page(c, list, page, pageSize, total)
+}
+
+// Institution 单个机构(GET /org/institutions/:institutionId,受保护)。
+//
+// 出参(成功):code 200,data 为 model.Institution。
+// 出参(失败):code 1004(越界)/ 3001(不存在或作用域外)/ 1001 / 5002。
+func (h *Handler) Institution(c *fiber.Ctx) error {
+	sc, err := scopeFromCtx(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	id, perr := strconv.ParseInt(c.Params("institutionId"), 10, 64)
+	if perr != nil {
+		return response.Fail(c, apperrors.New(apperrors.CodeBadFormat, "institutionId 格式错误"))
+	}
+	it, err := h.svc.GetInstitution(c.Context(), sc, id)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, it)
+}
+
+// Campuses 校区分页列表(GET /org/campuses,受保护)。
+//
+// 出参(成功):code 200,data 为 PageData(list 为 []model.Campus)。
+// 出参(失败):code 1004 / 1001 / 5002。
+func (h *Handler) Campuses(c *fiber.Ctx) error {
+	sc, err := scopeFromCtx(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	q := bindListQuery(c)
+	page, pageSize := q.Page, q.PageSize
+	list, total, err := h.svc.ListCampuses(c.Context(), sc, q)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.Page(c, list, page, pageSize, total)
+}
+
+// Campus 单个校区(GET /org/campuses/:campusId,受保护)。
+//
+// 出参(成功):code 200,data 为 model.Campus。
+// 出参(失败):code 1004 / 3001 / 1001 / 5002。
+func (h *Handler) Campus(c *fiber.Ctx) error {
+	sc, err := scopeFromCtx(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	id, perr := strconv.ParseInt(c.Params("campusId"), 10, 64)
+	if perr != nil {
+		return response.Fail(c, apperrors.New(apperrors.CodeBadFormat, "campusId 格式错误"))
+	}
+	cp, err := h.svc.GetCampus(c.Context(), sc, id)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, cp)
+}

+ 325 - 0
internal/org/handler/org_e2e_test.go

@@ -0,0 +1,325 @@
+// org 域越权端到端测试(T6 验收核心:跨校区/跨机构访问被拒)。
+//
+// 用真 GORM 仓储 + 真 Issuer + miniredis + NewAuth 验签中间件,走 HTTP 全链路,
+// 同时覆盖多租户防线的**两道**(架构规格 §3.5):
+//  1. Service 层 assertInScope(显式 1004):请求 query 显式携带的 institutionId/campusId
+//     与 token 作用域冲突 → code 1004(前端据此校正 scope);
+//  2. Repository 层 Scoping(数据层强制过滤):即便请求不带显式 id,查询也强制带
+//     institution_id/campus_id 过滤——非超管永远查不到别家机构/别校校区的数据。
+//
+// 场景矩阵:
+//   - 老师(inst=11, campus=3,校区锁定):
+//     GET /org/institutions   → 只见机构 11(机构 22 被 Scoping 排除,数据层)
+//     GET /org/campuses       → 只见校区 3(校区 4 被机构+校区双重 scoping 排除)
+//     GET /org/institutions?institutionId=22 → 1004(跨机构,Service 显式)
+//     GET /org/campuses?campusId=4           → 1004(跨校区,Service 显式)
+//     GET /org/institutions/22               → 1004(跨机构单查,Service 显式)
+//   - 平台超管(super_admin):GET /org/institutions → 见全部机构(11 + 22)
+//   - 无 token:/org/* → 1001(中间件未登录)
+//
+// 运行前提(同身份域 e2e 纪律:DSN 走环境变量,不入库):
+//
+//	set UNIFIED_TEST_DSN=dev:xxxx@tcp(127.0.0.1:3306)/e2e_test?charset=utf8mb4&parseTime=True&loc=Local
+//	go test ./internal/org/handler/ -run TestOrgE2E -v
+//
+// 未设置 UNIFIED_TEST_DSN 时自动跳过(CI / 无库环境 go test ./... 不受影响)。
+package handler
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/json"
+	"encoding/pem"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+	"time"
+
+	miniredis "github.com/alicebob/miniredis/v2"
+	"github.com/gofiber/fiber/v2"
+	"gorm.io/gorm"
+
+	idhandler "yuansiwei/unified-backend/internal/identity/handler"
+	"yuansiwei/unified-backend/internal/identity/token"
+	"yuansiwei/unified-backend/internal/infra/config"
+	infra "yuansiwei/unified-backend/internal/infra/db"
+	appredis "yuansiwei/unified-backend/internal/infra/redis"
+	"yuansiwei/unified-backend/internal/org/model"
+	orgservice "yuansiwei/unified-backend/internal/org/service"
+)
+
+// orgEnv 端到端测试句柄(真库 + 真仓储 + miniredis + 真 Issuer + 验签中间件 + /org 路由)。
+type orgEnv struct {
+	t          *testing.T
+	app        *fiber.App
+	pubKeys    []string
+	revocation *appredis.RevocationStore
+	gdb        *gorm.DB
+	issuer     *token.Issuer
+}
+
+// orgE2eSetup 建真库 + AutoMigrate(t_institution/t_campus) + miniredis + 真 Issuer + OrgService + /org 路由。
+func orgE2eSetup(t *testing.T) *orgEnv {
+	t.Helper()
+	dsn := os.Getenv("UNIFIED_TEST_DSN")
+	if dsn == "" {
+		t.Skip("未设置 UNIFIED_TEST_DSN,跳过 org 越权端到端测试")
+	}
+	gdb, err := infra.NewDB(config.DBCfg{DSN: dsn, MaxOpenConns: 5, MaxIdleConns: 2, ConnMaxLifetimeSec: 60})
+	if err != nil {
+		t.Fatalf("NewDB: %v", err)
+	}
+	t.Cleanup(func() { _ = infra.Close(gdb) })
+	if err := infra.Migrate(gdb, &model.Institution{}, &model.Campus{}); err != nil {
+		t.Fatalf("Migrate: %v", err)
+	}
+	t.Cleanup(func() { _ = gdb.Migrator().DropTable("t_institution", "t_campus") })
+
+	mr, err := miniredis.Run()
+	if err != nil {
+		t.Fatalf("miniredis.Run: %v", err)
+	}
+	t.Cleanup(mr.Close)
+	rc, err := appredis.NewClient(config.RedisCfg{Addr: mr.Addr()})
+	if err != nil {
+		t.Fatalf("NewClient: %v", err)
+	}
+	t.Cleanup(func() { _ = rc.Close() })
+	refreshStore := appredis.NewRefreshStore(rc)
+	revocation := appredis.NewRevocationStore(rc, "revoked")
+
+	privPEM, pubKeys := genOrgRSAKey(t)
+	issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation)
+	if err != nil {
+		t.Fatalf("NewIssuer: %v", err)
+	}
+	svc, err := orgservice.NewOrgService(gdb)
+	if err != nil {
+		t.Fatalf("NewOrgService: %v", err)
+	}
+	h, err := New(Config{Svc: svc})
+	if err != nil {
+		t.Fatalf("org handler New: %v", err)
+	}
+	mw, err := idhandler.NewAuth(idhandler.AuthConfig{PublicKeys: pubKeys, Revocation: revocation, Issuer: "https://unified.test"})
+	if err != nil {
+		t.Fatalf("NewAuth: %v", err)
+	}
+
+	app := fiber.New(fiber.Config{DisableStartupMessage: true})
+	org := app.Group("/org")
+	org.Use(mw)
+	org.Get("/institutions", h.Institutions)
+	org.Get("/institutions/:institutionId", h.Institution)
+	org.Get("/campuses", h.Campuses)
+	org.Get("/campuses/:campusId", h.Campus)
+
+	return &orgEnv{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb, issuer: issuer}
+}
+
+// genOrgRSAKey 生成 RS2048 私钥 + 公钥 PEM(本包私有,避免与身份域测试 helper 重名冲突)。
+func genOrgRSAKey(t *testing.T) (string, []string) {
+	t.Helper()
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("生成 RSA 私钥: %v", err)
+	}
+	privPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
+	pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
+	if err != nil {
+		t.Fatalf("序列化公钥: %v", err)
+	}
+	pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
+	return privPEM, []string{pubPEM}
+}
+
+// issue 用真 Issuer 签一个 access(指定作用域 claims)。
+func (e *orgEnv) issue(claims token.Claims) string {
+	e.t.Helper()
+	access, _, err := e.issuer.Issue(context.Background(), claims)
+	if err != nil {
+		e.t.Fatalf("Issue: %v", err)
+	}
+	return access
+}
+
+// seedOrg 落两所机构 + 两校区(inst11/campus3、inst22/campus4,raw SQL 保证 id 确定)。
+func (e *orgEnv) seedOrg() {
+	e.t.Helper()
+	ctx := context.Background()
+	now := time.Now()
+	must := func(err error, what string) {
+		if err != nil {
+			e.t.Fatalf("%s: %v", what, err)
+		}
+	}
+	must(e.gdb.WithContext(ctx).Create(&model.Institution{
+		InstitutionID: 11, InstitutionName: "实验一中", Status: model.StatusActive, CreatedAt: now, UpdatedAt: now,
+	}).Error, "插入机构 11")
+	must(e.gdb.WithContext(ctx).Create(&model.Institution{
+		InstitutionID: 22, InstitutionName: "实验二中", Status: model.StatusActive, CreatedAt: now, UpdatedAt: now,
+	}).Error, "插入机构 22")
+	must(e.gdb.WithContext(ctx).Create(&model.Campus{
+		CampusID: 3, InstitutionID: 11, CampusName: "一中本部", Status: model.StatusActive, CreatedAt: now, UpdatedAt: now,
+	}).Error, "插入校区 3")
+	must(e.gdb.WithContext(ctx).Create(&model.Campus{
+		CampusID: 4, InstitutionID: 22, CampusName: "二中本部", Status: model.StatusActive, CreatedAt: now, UpdatedAt: now,
+	}).Error, "插入校区 4")
+}
+
+// orgEnv envelope(统一信封;Data 留原始 JSON 供按需解析)。
+type env struct {
+	Code    int             `json:"code"`
+	Message string          `json:"message"`
+	Data    json.RawMessage `json:"data"`
+	Success bool            `json:"success"`
+}
+
+// get 带 Bearer 头 GET 并解信封(HTTP 恒 200,断 body.code)。
+func (e *orgEnv) get(path, bearer string) *env {
+	e.t.Helper()
+	req := httptest.NewRequest(http.MethodGet, path, nil)
+	if bearer != "" {
+		req.Header.Set("Authorization", "Bearer "+bearer)
+	}
+	resp, err := e.app.Test(req, -1)
+	if err != nil {
+		e.t.Fatalf("GET %s 请求失败: %v", path, err)
+	}
+	var en env
+	if err := json.NewDecoder(resp.Body).Decode(&en); err != nil {
+		e.t.Fatalf("GET %s 解析响应: %v", path, err)
+	}
+	return &en
+}
+
+// instList 从 PageData 解机构列表(institutionId 集合)。
+func instList(t *testing.T, data json.RawMessage) []int64 {
+	t.Helper()
+	var pd struct {
+		List []struct {
+			InstitutionID int64 `json:"institutionId"`
+		} `json:"list"`
+		Total int64 `json:"total"`
+	}
+	if err := json.Unmarshal(data, &pd); err != nil {
+		t.Fatalf("解析机构 PageData: %v", err)
+	}
+	out := make([]int64, 0, len(pd.List))
+	for _, it := range pd.List {
+		out = append(out, it.InstitutionID)
+	}
+	return out
+}
+
+// TestOrgE2E_CampusLocked 老师(校区锁定 inst=11/campus=3):
+// 数据层 Scoping 只给本机构机构 + 本校区;显式跨机构/跨校区 id → 1004。
+func TestOrgE2E_CampusLocked(t *testing.T) {
+	e := orgE2eSetup(t)
+	e.seedOrg()
+	teacher := e.issue(token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3,
+	})
+
+	// 数据层:机构列表只见本机构(11),机构 22 被 Scoping 排除
+	inst := e.get("/org/institutions", teacher)
+	if inst.Code != 200 {
+		t.Fatalf("机构列表 code=%d msg=%s(期望 200)", inst.Code, inst.Message)
+	}
+	got := instList(t, inst.Data)
+	if len(got) != 1 || got[0] != 11 {
+		t.Fatalf("机构列表=%v(期望仅 [11],机构 22 须被 scoping 排除)", got)
+	}
+
+	// 数据层:校区列表只见本校区(3),校区 4(别家机构+别校)被双重 scoping 排除
+	camp := e.get("/org/campuses", teacher)
+	if camp.Code != 200 {
+		t.Fatalf("校区列表 code=%d msg=%s(期望 200)", camp.Code, camp.Message)
+	}
+	var cpd struct {
+		List []struct {
+			CampusID      int64 `json:"campusId"`
+			InstitutionID int64 `json:"institutionId"`
+		} `json:"list"`
+	}
+	if err := json.Unmarshal(camp.Data, &cpd); err != nil {
+		t.Fatalf("解析校区 PageData: %v", err)
+	}
+	if len(cpd.List) != 1 || cpd.List[0].CampusID != 3 {
+		t.Fatalf("校区列表=%+v(期望仅 [campus 3])", cpd.List)
+	}
+
+	// Service 显式 1004:跨机构 institutionId=22
+	if r := e.get("/org/institutions?institutionId=22", teacher); r.Code != 1004 {
+		t.Fatalf("跨机构机构列表 code=%d(期望 1004 作用域越界)", r.Code)
+	}
+	// Service 显式 1004:跨校区 campusId=4(校区锁定 token)
+	if r := e.get("/org/campuses?campusId=4", teacher); r.Code != 1004 {
+		t.Fatalf("跨校区校区列表 code=%d(期望 1004 作用域越界)", r.Code)
+	}
+	// Service 显式 1004:跨机构单查
+	if r := e.get("/org/institutions/22", teacher); r.Code != 1004 {
+		t.Fatalf("跨机构单查 code=%d(期望 1004)", r.Code)
+	}
+}
+
+// TestOrgE2E_SuperAdmin 平台超管:跨机构可见全部机构(Scoping 跳过机构过滤)。
+func TestOrgE2E_SuperAdmin(t *testing.T) {
+	e := orgE2eSetup(t)
+	e.seedOrg()
+	super := e.issue(token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0,
+	})
+	inst := e.get("/org/institutions", super)
+	if inst.Code != 200 {
+		t.Fatalf("超管机构列表 code=%d msg=%s(期望 200)", inst.Code, inst.Message)
+	}
+	got := instList(t, inst.Data)
+	// 超管不加作用域:应见两所机构(11 + 22)
+	seen := map[int64]bool{}
+	for _, v := range got {
+		seen[v] = true
+	}
+	if len(got) != 2 || !seen[11] || !seen[22] {
+		t.Fatalf("超管机构列表=%v(期望 [11 22] 全见)", got)
+	}
+}
+
+// TestOrgE2E_Unauth 无 token:/org/* 一律 1001(中间件未登录拦截)。
+func TestOrgE2E_Unauth(t *testing.T) {
+	e := orgE2eSetup(t)
+	e.seedOrg()
+	for _, path := range []string{"/org/institutions", "/org/campuses", "/org/institutions/11"} {
+		if r := e.get(path, ""); r.Code != 1001 {
+			t.Fatalf("%s 无 token code=%d(期望 1001 未登录)", path, r.Code)
+		}
+	}
+}
+
+// TestOrgE2E_ScopeMissingID 老师不带任何显式 id:仍只返回本机构/本校区(Scoping 兜底,非裸查)。
+func TestOrgE2E_ScopeMissingID(t *testing.T) {
+	e := orgE2eSetup(t)
+	e.seedOrg()
+	teacher := e.issue(token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3,
+	})
+	// 不带 institutionId/campusId:数据层 scoping 仍强制 inst=11 + campus=3
+	inst := e.get("/org/institutions", teacher)
+	if inst.Code != 200 {
+		t.Fatalf("code=%d(期望 200)", inst.Code)
+	}
+	got := instList(t, inst.Data)
+	if len(got) != 1 || got[0] != 11 {
+		t.Fatalf("缺 id 时机构列表=%v(期望仅 [11],scoping 兜底)", got)
+	}
+	// 带别家机构 id 但角色是机构级(教务):campus=0 可下钻,但跨机构仍 1004
+	academic := e.issue(token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"academic"}, InstitutionID: 11, CampusID: 0,
+	})
+	if r := e.get("/org/institutions?institutionId=22", academic); r.Code != 1004 {
+		t.Fatalf("教务跨机构 code=%d(期望 1004)", r.Code)
+	}
+}

+ 101 - 0
internal/org/repository/campus_repo.go

@@ -0,0 +1,101 @@
+// 校区仓储(T6):t_campus 的**作用域**数据访问。
+//
+// 与 identity 平台级仓储的关键差异(架构规格 §3.5):
+//   - **继承 scope.BaseRepo**:t_campus 隶属机构(institution_id 列),是"租户内的校区"维度资源,
+//     每次查询必须经 Scoping 强制过滤——多租户防线的唯一收口点在 BaseRepo.Scoping,
+//     本包禁止裸查(r.DB.Where(...) 不经过 Scoping = 跨校区/跨机构越权,风险登记册项)。
+//   - scoping 行为(由 scope 包保证):
+//     Sc 为 nil → panic(fail-fast,正常请求链路 Auth 中间件保证作用域已注入);
+//     SuperAdmin=true → 不加机构/校区过滤(平台超管跨机构视角);
+//     普通用户 → 强制 institution_id = Sc.InstitutionID;CampusID>0 时再强制 campus_id
+//     (老师角色锁定本校;教务/财务 campus=0 时可看本机构全部校区)。
+//
+// 依赖方向铁律:org repository → infra/scope + org/model + infra/errors(全下层/同域),
+// 不依赖 handler/service。
+package repository
+
+import (
+	"context"
+	stderrors "errors"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+	"yuansiwei/unified-backend/internal/org/model"
+)
+
+// CampusRepo 校区仓储(作用域强制过滤)。
+type CampusRepo struct {
+	appscope.BaseRepo
+}
+
+// NewCampusRepo 构造校区仓储(Handler 在拿到 Scope 后调用;db 必填、sc 可为 nil——
+// nil 进入 Scoping 会 panic,属预期 fail-fast,见 scope.BaseRepo 注释)。
+func NewCampusRepo(db *gorm.DB, sc *appscope.Scope) *CampusRepo {
+	return &CampusRepo{BaseRepo: appscope.NewBaseRepo(db, sc)}
+}
+
+// CampusFilter 校区列表筛选条件(keyword 模糊匹配校区名 / 状态精确匹配;均可空)。
+type CampusFilter struct {
+	Keyword string
+	Status  string
+}
+
+// PagedList 分页取校区列表(强制作用域过滤:普通用户仅见本机构;CampusID>0 时仅见本校区)。
+//
+// 入参:
+//   - ctx context.Context:请求上下文(GORM WithContext 传递取消/超时)。
+//   - f CampusFilter:筛选条件(Keyword/Status 为空则不过滤该维度)。
+//   - page int:页码(从 1 开始;<1 按 1 处理)。
+//   - pageSize int:每页条数(0/负数按 10 兜底——与 response.ParsePage 同语义)。
+//
+// 出参:
+//   - []model.Campus:当前页记录(无记录时返回非 nil 空切片,JSON 序列化为 [] 而非 null)。
+//   - int64:符合筛选条件的总记录数(分页器"共 N 条")。
+//   - error:*AppError——ErrDBError(5002) 数据库异常;无作用域由 Scoping panic 兜底(全局 Recover)。
+func (r *CampusRepo) PagedList(ctx context.Context, f CampusFilter, page, pageSize int) ([]model.Campus, int64, error) {
+	if page < 1 {
+		page = 1
+	}
+	if pageSize < 1 {
+		pageSize = 10
+	}
+	q := r.Scoping(r.WithContext(ctx).Model(&model.Campus{}))
+	if f.Keyword != "" {
+		q = q.Where("campus_name LIKE ?", "%"+f.Keyword+"%")
+	}
+	if f.Status != "" {
+		q = q.Where("status = ?", f.Status)
+	}
+	var total int64
+	if err := q.Count(&total).Error; err != nil { // GORM 纪律:Count 收 *int64
+		return nil, 0, errors.WithCause(errors.ErrDBError, err)
+	}
+	list := make([]model.Campus, 0, pageSize)
+	if err := q.Order("campus_id ASC").
+		Offset((page - 1) * pageSize).
+		Limit(pageSize).
+		Find(&list).Error; err != nil {
+		return nil, 0, errors.WithCause(errors.ErrDBError, err)
+	}
+	return list, total, nil
+}
+
+// Get 按校区 ID 取单个校区(强制作用域过滤:非超管取别家/别校校区 → 查不到 → ErrNotFound)。
+//
+// 入参:ctx 请求上下文;campusID 校区 ID(>0)。
+// 出参:*model.Campus(命中);error——ErrNotFound(3001) 不存在或作用域外 / ErrDBError(5002)。
+func (r *CampusRepo) Get(ctx context.Context, campusID int64) (*model.Campus, error) {
+	var cp model.Campus
+	err := r.Scoping(r.WithContext(ctx)).
+		Where("campus_id = ?", campusID).
+		First(&cp).Error
+	if stderrors.Is(err, gorm.ErrRecordNotFound) {
+		return nil, errors.ErrNotFound
+	}
+	if err != nil {
+		return nil, errors.WithCause(errors.ErrDBError, err)
+	}
+	return &cp, nil
+}

+ 112 - 0
internal/org/repository/institution_repo.go

@@ -0,0 +1,112 @@
+// 机构仓储(T6):t_institution 的**作用域**数据访问。
+//
+// 与 identity 平台级仓储的关键差异(架构规格 §3.5):
+//   - **继承 scope.BaseRepo**:t_institution 是"租户内的机构"维度资源,每次查询必须经
+//     Scoping 强制过滤——多租户防线的唯一收口点在 BaseRepo.Scoping,本包禁止裸查
+//     (r.DB.Where(...) 不经过 Scoping = 跨校区/跨机构越权,风险登记册项)。
+//   - scoping 行为(由 scope 包保证):
+//     Sc 为 nil → panic(fail-fast,正常请求链路 Auth 中间件保证作用域已注入);
+//     SuperAdmin=true → 不加机构过滤(平台超管跨机构视角);
+//     普通用户 → 强制 institution_id = Sc.InstitutionID(CampusID>0 时再强制 campus_id,
+//     对 t_institution 无 campus_id 列,该校验在 service 作用域校验层完成)。
+//
+// 依赖方向铁律:org repository → infra/scope + org/model + infra/errors(全下层/同域),
+// 不依赖 handler/service。
+package repository
+
+import (
+	"context"
+	stderrors "errors"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+	"yuansiwei/unified-backend/internal/org/model"
+)
+
+// InstitutionRepo 机构仓储(作用域强制过滤)。
+type InstitutionRepo struct {
+	appscope.BaseRepo
+}
+
+// NewInstitutionRepo 构造机构仓储(Handler 在拿到 Scope 后调用;db 必填、sc 可为 nil——
+// nil 进入 Scoping 会 panic,属预期 fail-fast,见 scope.BaseRepo 注释)。
+//
+// 注:t_institution 无 campus_id 列,故 CampusScoped=false——Scoping 只加 institution_id
+// 过滤(校区级用户查机构列表时,机构按其机构归属展示,不下钻到校区维度)。
+func NewInstitutionRepo(db *gorm.DB, sc *appscope.Scope) *InstitutionRepo {
+	r := &InstitutionRepo{BaseRepo: appscope.NewBaseRepo(db, sc)}
+	r.CampusScoped = false // 机构级资源:无 campus_id 列
+	return r
+}
+
+// InstitutionFilter 机构列表筛选条件(keyword 模糊匹配机构名/状态精确匹配;均可空)。
+type InstitutionFilter struct {
+	Keyword string
+	Status  string
+}
+
+// PagedList 分页取机构列表(强制作用域过滤,防 N+1 的标准两段式由 service 决定——
+// 机构表数据量小(23 所业务机构),直接单查询 Count+Find)。
+//
+// 入参:
+//   - ctx context.Context:请求上下文(GORM WithContext 传递取消/超时)。
+//   - f InstitutionFilter:筛选条件(Keyword/Status 为空则不过滤该维度)。
+//   - page int:页码(从 1 开始;<1 按 1 处理)。
+//   - pageSize int:每页条数(0/负数按 10 兜底——与 response.ParsePage 同语义)。
+//
+// 出参:
+//   - []model.Institution:当前页记录(无记录时返回非 nil 空切片,JSON 序列化为 [] 而非 null)。
+//   - int64:符合筛选条件的总记录数(分页器"共 N 条")。
+//   - error:*AppError——ErrDBError(5002) 数据库异常;ErrUnscoped 由 scoping panic 兜底(全局 Recover)。
+//
+// 样例:
+//
+//	list, total, err := repo.PagedList(ctx, InstitutionFilter{Keyword: "实验"}, 1, 10)
+func (r *InstitutionRepo) PagedList(ctx context.Context, f InstitutionFilter, page, pageSize int) ([]model.Institution, int64, error) {
+	if page < 1 {
+		page = 1
+	}
+	if pageSize < 1 {
+		pageSize = 10
+	}
+	q := r.Scoping(r.WithContext(ctx).Model(&model.Institution{}))
+	if f.Keyword != "" {
+		q = q.Where("institution_name LIKE ?", "%"+f.Keyword+"%")
+	}
+	if f.Status != "" {
+		q = q.Where("status = ?", f.Status)
+	}
+	var total int64
+	if err := q.Count(&total).Error; err != nil { // GORM 纪律:Count 收 *int64
+		return nil, 0, errors.WithCause(errors.ErrDBError, err)
+	}
+	list := make([]model.Institution, 0, pageSize)
+	if err := q.Order("institution_id ASC").
+		Offset((page - 1) * pageSize).
+		Limit(pageSize).
+		Find(&list).Error; err != nil {
+		return nil, 0, errors.WithCause(errors.ErrDBError, err)
+	}
+	return list, total, nil
+}
+
+// Get 按机构 ID 取单个机构(强制作用域过滤:非超管取别家机构 → 查不到 → ErrNotFound,
+// 由 service 层进一步判定"跨机构"语义,见 OrgService.assertInScope)。
+//
+// 入参:ctx 请求上下文;institutionID 机构 ID(>0)。
+// 出参:*model.Institution(命中);error——ErrNotFound(3001) 不存在或作用域外 / ErrDBError(5002)。
+func (r *InstitutionRepo) Get(ctx context.Context, institutionID int64) (*model.Institution, error) {
+	var it model.Institution
+	err := r.Scoping(r.WithContext(ctx)).
+		Where("institution_id = ?", institutionID).
+		First(&it).Error
+	if stderrors.Is(err, gorm.ErrRecordNotFound) {
+		return nil, errors.ErrNotFound
+	}
+	if err != nil {
+		return nil, errors.WithCause(errors.ErrDBError, err)
+	}
+	return &it, nil
+}

+ 171 - 0
internal/org/service/org_service.go

@@ -0,0 +1,171 @@
+// 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
+}

+ 55 - 0
internal/org/service/org_service_test.go

@@ -0,0 +1,55 @@
+// OrgService 作用域越权判定单测(assertInScope 纯逻辑,不连 DB)。
+//
+// assertInScope 是 Service 层"第一道防线"——请求显式 id 与 token 作用域冲突时返回 1004。
+// 数据层的第二道防线(Repository Scoping 强制过滤)由 handler 包 e2e 覆盖。
+package service
+
+import (
+	"errors"
+	"testing"
+
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// newScope 快捷构造作用域(单测用)。
+func newScope(inst, campus int64, super bool, role string) *appscope.Scope {
+	return &appscope.Scope{InstitutionID: inst, CampusID: campus, SuperAdmin: super, Role: role, UserID: "01TESTUID000000000000000"}
+}
+
+// TestAssertInScope 覆盖 assertInScope 各分支。
+func TestAssertInScope(t *testing.T) {
+	svc := &OrgService{} // 零值;assertInScope 不触碰 db/仓储
+	cases := []struct {
+		name     string
+		sc       *appscope.Scope
+		reqInst  int64
+		reqCamp  int64
+		wantErr  bool
+		wantCode int
+	}{
+		{"nil_作用域_按未授权拒绝", nil, 0, 0, true, apperrors.CodeUnauth},
+		{"超管_跨机构放行", newScope(0, 0, true, "super_admin"), 99, 99, false, 0},
+		{"未显式指定_放行", newScope(11, 3, false, "teacher"), 0, 0, false, 0},
+		{"机构一致_放行", newScope(11, 3, false, "teacher"), 11, 0, false, 0},
+		{"校区一致_放行", newScope(11, 3, false, "teacher"), 11, 3, false, 0},
+		{"跨机构_越权1004", newScope(11, 3, false, "teacher"), 22, 0, true, apperrors.CodeScopeViolation},
+		{"校区锁定_token请求别的校区_越权1004", newScope(11, 3, false, "teacher"), 0, 4, true, apperrors.CodeScopeViolation},
+		{"机构级token_下钻别的校区_放行", newScope(11, 0, false, "academic"), 0, 4, false, 0},
+		{"机构级token_跨机构_越权1004", newScope(11, 0, false, "academic"), 22, 0, true, apperrors.CodeScopeViolation},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			err := svc.assertInScope(tc.sc, tc.reqInst, tc.reqCamp)
+			if tc.wantErr {
+				if !errors.Is(err, apperrors.ErrUnscoped) && !(tc.wantCode == apperrors.CodeUnauth && errors.Is(err, apperrors.ErrUnauth)) {
+					t.Fatalf("期望 code=%d 错误,实际 %v", tc.wantCode, err)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("期望放行,实际 %v", err)
+			}
+		})
+	}
+}