# 多校区切换/隔离 — 设计规格

> 版本：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` 树结构
