Files
gongxue-base/docs/superpowers/specs/2026-07-05-multi-campus-design.md

341 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 多校区切换/隔离 — 设计规格
> 版本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` 树结构