# 多校区切换/隔离 — 设计规格 > 版本:v1.0 > 日期:2026-07-05 > 基于 PRD:`PRD-恭学教育学生管理系统.md` §23.7(多校区隔离确认需要,Department.type=campus 预留)+ > §1.2(角色数据范围:超管全部 / 教职工指定部门+子部门 / 班主任本班 / 学生本人) ## 1. 目标 为系统引入多校区(Campus)概念,实现: - 校区树形组织结构(校区 → 子部门 → 班级) - 用户-部门绑定 + 默认校区 - 全局数据查询按校区自动隔离 - 前端校区切换器(支持单校区 / 全部校区视图) ## 2. 数据模型 ### 2.1 部门表 `departments` ```sql departments ├── id INTEGER PK AUTOINCREMENT ├── name VARCHAR(100) NOT NULL -- 部门名称,如 "鼓楼校区" ├── parent_id INTEGER NULLABLE FK → self -- 上级部门,NULL = 顶层校区 ├── type VARCHAR(20) DEFAULT 'department' -- 'campus' = 校区(顶层,parent_id=NULL) -- 'department' = 子部门(教学部、后勤部等) ├── sort_order INTEGER DEFAULT 0 ├── status VARCHAR(20) DEFAULT 'active' -- active / archived ├── created_at DATETIME DEFAULT CURRENT_TIMESTAMP ├── updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ``` ### 2.2 用户-部门关联 `user_departments` ```sql user_departments ├── id INTEGER PK AUTOINCREMENT ├── user_id INTEGER NOT NULL FK → users.id ├── department_id INTEGER NOT NULL FK → departments.id ├── is_default BOOLEAN DEFAULT false ├── created_at DATETIME DEFAULT CURRENT_TIMESTAMP UNIQUE(user_id, department_id) ``` 超管不在此表有记录 → 默认全部数据可见。学生不发此关联,通过 `students.department_id` 表所属部门。 ### 2.3 现有实体追加 `department_id` 采用**冗余存储**策略(写入时填入,避免查询时多表 JOIN): | 实体 | 操作 | 说明 | |------|:---:|------| | `students` | 新增 | 学生所属部门 | | `classes` | 保留 | 已有 `department_id` 字段 | | `rooms` | 新增 | 宿舍归属校区 | | `classrooms` | 新增 | 教室归属校区 | | `class_schedules` | 新增 | 冗余加速(也可从 class→department 间接获取) | | `attendance_records` | 新增 | 冗余加速(也可从 student→department 间接获取) | | `room_expenses` | 新增 | 冗余加速(也可从 room→department 间接获取) | | `personal_expenses` | 新增 | 冗余加速(也可从 student→department 间接获取) | | `occupancies` | 新增 | 冗余加速(也可从 room→department 间接获取) | | `bills` | 新增 | 冗余加速(也可从 student→department 间接获取) | | `deposits` | 新增 | 冗余加速(也可从 student→department 间接获取) | | `deposit_installments` | 新增 | 冗余加速 | | `classroom_rentals` | 新增 | 冗余加速(也可从 classroom→department 间接获取) | **全局共享不隔离**:`users`、`roles`、`permissions`、`tenants`、`operation_logs`、`notifications`、`sync_logs`、`sync_states`、`ding_attendance_raw`、`expense_types`。 ## 3. 后端隔离机制 ### 3.1 JWT Payload 扩展 ```typescript // 登录时注入 const payload = { sub: user.id, username: user.username, permissions, isSuperAdmin: user.roles?.some(r => r.name === 'super_admin'), // 不再注入 departmentIds,改为请求级 CampusScope 实时查询 }; ``` 不将 `departmentIds` 写入 JWT,避免校区分配变更后需重新登录。 ### 3.2 CampusScope(请求级 Provider) ```typescript // apps/server/src/common/campus-scope.ts @Injectable({ scope: Scope.REQUEST }) export class CampusScope { private _departmentIds: number[] | null = null; currentDepartmentId: number | null; constructor( @Inject(REQUEST) private req: any, private departmentsService: DepartmentsService, ) { this.currentDepartmentId = parseInt( req.headers['x-campus-id'] || '0' ) || null; } get isSuperAdmin(): boolean { return this.req.user?.isSuperAdmin ?? false; } /** 获取当前用户可访问的所有部门 ID(含子部门) */ async getDepartmentIds(): Promise { if (this._departmentIds) return this._departmentIds; const userDepts = await this.departmentsService.getUserDepartments( this.req.user.id ); this._departmentIds = userDepts; return this._departmentIds; } /** 对 TypeORM find 条件追加校区过滤 */ async filter>(where: T): Promise { if (this.isSuperAdmin && !this.currentDepartmentId) return where; const ids = await this.getEffectiveScopeIds(); return { ...where, departmentId: In(ids) } as any; } private async getEffectiveScopeIds(): Promise { // 选择了具体校区 → 该校区 + 所有子部门 // 未选 → 用户所有可访问部门 + 子部门 const baseId = this.currentDepartmentId; if (baseId) { return this.departmentsService.getDescendantIds(baseId); } const allIds = await this.getDepartmentIds(); const expanded = await Promise.all( allIds.map(id => this.departmentsService.getDescendantIds(id)) ); return [...new Set(expanded.flat())]; } } ``` ### 3.3 Controller 使用模式 ```typescript @Get() async findAll(@Req() req: Request) { const scope = req.campusScope; // 由 middleware 注入 const where = await scope.filter({ status: 'active' }); return this.repo.find({ where, order: { createdAt: 'DESC' } }); } ``` 超管不传 `X-Campus-Id` → `filter()` 原样返回 → 不做隔离。 ### 3.4 校区选择 Header 前端 axios interceptor 注入: ```typescript api.interceptors.request.use((config) => { const campusId = localStorage.getItem('currentCampusId'); if (campusId) { config.headers['X-Campus-Id'] = campusId; } return config; }); ``` ### 3.5 部门管理 CRUD | 方法 | 路径 | 认证 | 说明 | |------|------|:---:|------| | GET | `/departments` | JWT | 部门列表(树形结构,按 sort_order + name) | | GET | `/departments/:id` | JWT | 部门详情 | | POST | `/departments` | JWT | 创建部门(指定 parent_id + type) | | PUT | `/departments/:id` | JWT | 编辑部门 | | DELETE | `/departments/:id` | JWT | 删除部门(检查无子部门+无关联用户) | | GET | `/departments/:id/users` | JWT | 部门下关联的用户列表 | | POST | `/departments/:id/users` | JWT | 为用户分配部门 `{ userId, isDefault? }` | | DELETE | `/departments/:id/users/:userId` | JWT | 移除用户-部门关联 | | GET | `/departments/tree` | JWT | 树形数据(前端级联选择器用) | ### 3.6 写操作时 department_id 填充 新建宿舍时: ```typescript async create(dto: CreateRoomDto) { // department_id 由前端传入(校区选择器当前选中值) return this.repo.save({ ...dto, departmentId: dto.departmentId }); } ``` 新建学生时关联班级的 department: ```typescript async create(dto: CreateStudentDto) { const cls = await this.classesRepo.findOne({ where: { id: dto.classId } }); return this.studentRepo.save({ ...dto, departmentId: cls?.departmentId, }); } ``` ## 4. 前端 ### 4.1 校区选择器 `CampusSwitcher` Header 左侧,Logo 旁边: ``` [ 恭学教育 ] [ 鼓楼校区 ▾ ] ``` - `Select` 组件,列出当前用户可访问的校区(从 JWT payload 或 `/departments` API 获取) - 切换时:`localStorage.setItem('currentCampusId', id)` + 刷新所有页面数据 - 多校区权限用户底部显示「全部校区」选项(value 为空字符串) - 仅一个校区 → 纯文本展示,不可切换 - 默认选中 `localStorage.getItem('currentCampusId')` 或用户默认校区 ### 4.2 `useCampus` Hook ```typescript function useCampus() { const [campuses, setCampuses] = useState([]); const [currentId, setCurrentId] = useState( () => localStorage.getItem('currentCampusId') || '' ); const switchCampus = (id: string) => { setCurrentId(id); localStorage.setItem('currentCampusId', id); // 触发全局数据刷新(通过 event 或 context) window.dispatchEvent(new CustomEvent('campus-changed', { detail: id })); }; return { campuses, currentId, switchCampus }; } ``` ### 4.3 部门管理页面 `Departments/index.tsx` - 左侧 `Tree` 组件展示部门树 - 点击节点 → 右侧表单编辑部门信息 - 右键/操作按钮:添加子部门、编辑、删除 - 「部门成员」Tab:用户列表 + 分配/移除 ### 4.4 数据面板适配 选中「全部校区」时: - 统计卡片数值为各校区汇总 - 图表按校区分组(图例标注校区名) - 不选全部校区 → 仅显示当前校区数据 ## 5. 文件结构 ``` apps/server/src/ ├── entities/ │ ├── department.entity.ts 🆕 │ ├── user-department.entity.ts 🆕 │ ├── student.entity.ts ✏️ +department_id │ ├── room.entity.ts ✏️ +department_id │ ├── classroom.entity.ts ✏️ +department_id │ ├── class-schedule.entity.ts ✏️ +department_id │ ├── attendance-record.entity.ts ✏️ +department_id │ ├── room-expense.entity.ts ✏️ +department_id │ ├── personal-expense.entity.ts ✏️ +department_id │ ├── occupancy.entity.ts ✏️ +department_id │ ├── bill.entity.ts ✏️ +department_id │ ├── deposit.entity.ts ✏️ +department_id │ ├── deposit-installment.entity.ts ✏️ +department_id │ ├── classroom-rental.entity.ts ✏️ +department_id │ └── index.ts ✏️ ├── departments/ 🆕 │ ├── departments.module.ts │ ├── departments.controller.ts │ ├── departments.service.ts │ └── dto/ │ └── department.dto.ts ├── common/ │ └── campus-scope.ts 🆕 ├── auth/ │ ├── auth.service.ts ✏️ 登录注入 isSuperAdmin │ └── strategies/jwt.strategy.ts ✏️ payload 扩展 ├── students/ │ └── students.service.ts ✏️ create 时填充 department_id ├── rooms/ │ └── rooms.service.ts ✏️ create 时填充 department_id ├── ...(各 service 使用 scope.filter()) └── app.module.ts ✏️ 注册 DepartmentsModule + CampusScope apps/admin/src/ ├── pages/ │ └── Departments/ │ └── index.tsx 🆕 部门管理页 ├── components/ │ └── CampusSwitcher.tsx 🆕 ├── api/index.ts ✏️ interceptor 加 X-Campus-Id ├── hooks/ │ └── useCampus.ts 🆕 ├── layouts/ │ └── MainLayout.tsx ✏️ 挂载 CampusSwitcher └── App.tsx ✏️ 注册 /departments 路由 ``` ## 6. 数据库迁移 ### 新增表 `departments`、`user_departments` — TypeORM `synchronize: true` 自动建表。 ### 现有表 ALTER ```sql ALTER TABLE students ADD COLUMN department_id INTEGER REFERENCES departments(id); ALTER TABLE rooms ADD COLUMN department_id INTEGER REFERENCES departments(id); ALTER TABLE classrooms ADD COLUMN department_id INTEGER REFERENCES departments(id); ALTER TABLE class_schedules ADD COLUMN department_id INTEGER REFERENCES departments(id); ALTER TABLE attendance_records ADD COLUMN department_id INTEGER REFERENCES departments(id); ALTER TABLE room_expenses ADD COLUMN department_id INTEGER REFERENCES departments(id); ALTER TABLE personal_expenses ADD COLUMN department_id INTEGER REFERENCES departments(id); ALTER TABLE occupancies ADD COLUMN department_id INTEGER REFERENCES departments(id); ALTER TABLE bills ADD COLUMN department_id INTEGER REFERENCES departments(id); ALTER TABLE deposits ADD COLUMN department_id INTEGER REFERENCES departments(id); ALTER TABLE deposit_installments ADD COLUMN department_id INTEGER REFERENCES departments(id); ALTER TABLE classroom_rentals ADD COLUMN department_id INTEGER REFERENCES departments(id); ``` ### 数据回填 1. 创建默认校区 "主校区"(`departments` type=campus) 2. 所有现有数据的 `department_id` 回填为默认校区 ID 3. 现有用户全部关联到默认校区(`user_departments`) ## 7. 钉钉同步集成 钉钉组织架构拉取已有能力(PRD §19.1),同步时将钉钉部门树映射到 `departments` 表: - 根部门 → `type = 'campus'` - 子部门 → `type = 'department'` - 同步时维护 `parent_id` 树结构