341 lines
13 KiB
Markdown
341 lines
13 KiB
Markdown
# 多校区切换/隔离 — 设计规格
|
||
|
||
> 版本: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<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 使用模式
|
||
|
||
```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<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
|
||
|
||
```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` 树结构
|