13 KiB
13 KiB
多校区切换/隔离 — 设计规格
版本:v1.0 日期:2026-07-05 基于 PRD:
PRD-恭学教育学生管理系统.md§23.7(多校区隔离确认需要,Department.type=campus 预留)+ §1.2(角色数据范围:超管全部 / 教职工指定部门+子部门 / 班主任本班 / 学生本人)
1. 目标
为系统引入多校区(Campus)概念,实现:
- 校区树形组织结构(校区 → 子部门 → 班级)
- 用户-部门绑定 + 默认校区
- 全局数据查询按校区自动隔离
- 前端校区切换器(支持单校区 / 全部校区视图)
2. 数据模型
2.1 部门表 departments
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
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 扩展
// 登录时注入
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)
// 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<number[]> {
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<T extends Record<string, any>>(where: T): Promise<T> {
if (this.isSuperAdmin && !this.currentDepartmentId) return where;
const ids = await this.getEffectiveScopeIds();
return { ...where, departmentId: In(ids) } as any;
}
private async getEffectiveScopeIds(): Promise<number[]> {
// 选择了具体校区 → 该校区 + 所有子部门
// 未选 → 用户所有可访问部门 + 子部门
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 使用模式
@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 注入:
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 填充
新建宿舍时:
async create(dto: CreateRoomDto) {
// department_id 由前端传入(校区选择器当前选中值)
return this.repo.save({ ...dto, departmentId: dto.departmentId });
}
新建学生时关联班级的 department:
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 或/departmentsAPI 获取)- 切换时:
localStorage.setItem('currentCampusId', id)+ 刷新所有页面数据 - 多校区权限用户底部显示「全部校区」选项(value 为空字符串)
- 仅一个校区 → 纯文本展示,不可切换
- 默认选中
localStorage.getItem('currentCampusId')或用户默认校区
4.2 useCampus Hook
function useCampus() {
const [campuses, setCampuses] = useState<Department[]>([]);
const [currentId, setCurrentId] = useState<string>(
() => 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
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);
数据回填
- 创建默认校区 "主校区"(
departmentstype=campus) - 所有现有数据的
department_id回填为默认校区 ID - 现有用户全部关联到默认校区(
user_departments)
7. 钉钉同步集成
钉钉组织架构拉取已有能力(PRD §19.1),同步时将钉钉部门树映射到 departments 表:
- 根部门 →
type = 'campus' - 子部门 →
type = 'department' - 同步时维护
parent_id树结构