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

1378 lines
38 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.

# 多校区切换/隔离 — 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现多校区树形组织结构、用户-部门绑定、全局数据查询按校区自动隔离、前端校区切换器。
**Architecture:** 新增 `departments`/`user_departments` 表 + `DepartmentsModule` + 请求级 `CampusScope` Provider各业务实体追加 `department_id` 冗余字段Controller 通过 `scope.filter()` 自动附加校区过滤,超管绕过。
**Tech Stack:** NestJS 11, TypeORM 0.3, React 19, Ant Design 6
## Global Constraints
- 表名使用复数形式:`departments``user_departments`
- Entity 使用 `@Entity('table_name')` + `@Column({ name: 'snake_case' })` 模式
- 所有 entity 在 `apps/server/src/entities/index.ts` 注册导出
- Module 必须 `imports: [TypeOrmModule.forFeature([...])]`
- Controller 所有方法 `@UseGuards(JwtAuthGuard)`
- DTO 使用 class-validator 装饰器
- 前端 axios 实例从 `api/` 导入
- 前端新增路由在 `App.tsx` 注册
- 超管 (super_admin 角色) 绕过所有校区隔离
- `department_id` 采用冗余存储策略,写入时填入,避免查询时多表 JOIN
---
### Task 1: Department + UserDepartment Entity
**Files:**
- Create: `apps/server/src/entities/department.entity.ts`
- Create: `apps/server/src/entities/user-department.entity.ts`
- Modify: `apps/server/src/entities/index.ts`
**Interfaces:**
- Produces: `Department` entity, `UserDepartment` entity
- [ ] **Step 1: 创建 department.entity.ts**
```typescript
// apps/server/src/entities/department.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
export enum DepartmentType {
CAMPUS = 'campus',
DEPARTMENT = 'department',
}
@Entity('departments')
export class Department {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
name: string;
@Column({ name: 'parent_id', type: 'integer', nullable: true })
parentId: number;
@ManyToOne(() => Department, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'parent_id' })
parent: Department;
@OneToMany(() => Department, (d) => d.parent)
children: Department[];
@Column({ length: 20, default: DepartmentType.DEPARTMENT })
type: string;
@Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder: number;
@Column({ length: 20, default: 'active' })
status: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
```
- [ ] **Step 2: 创建 user-department.entity.ts**
```typescript
// apps/server/src/entities/user-department.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { User } from './user.entity';
import { Department } from './department.entity';
@Entity('user_departments')
@Unique(['userId', 'departmentId'])
export class UserDepartment {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'user_id', type: 'integer' })
userId: number;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ name: 'department_id', type: 'integer' })
departmentId: number;
@ManyToOne(() => Department, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'department_id' })
department: Department;
@Column({ name: 'is_default', type: 'boolean', default: false })
isDefault: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
```
- [ ] **Step 3: 在 entities/index.ts 注册导出**
```typescript
export { Department, DepartmentType } from './department.entity';
export { UserDepartment } from './user-department.entity';
```
- [ ] **Step 4: 验证 — 启动后端检查建表**
```bash
cd apps/server && npm run start:dev
```
Expected: 启动成功,`departments``user_departments` 表自动创建。
- [ ] **Step 5: Commit**
```bash
git add apps/server/src/entities/department.entity.ts apps/server/src/entities/user-department.entity.ts apps/server/src/entities/index.ts
git commit -m "feat: add Department and UserDepartment entities"
```
---
### Task 2: 现有实体追加 department_id
**Files:**
- Modify: `apps/server/src/entities/student.entity.ts`
- Modify: `apps/server/src/entities/room.entity.ts`
- Modify: `apps/server/src/entities/classroom.entity.ts`
- Modify: `apps/server/src/entities/class-schedule.entity.ts`
- Modify: `apps/server/src/entities/attendance-record.entity.ts`
- Modify: `apps/server/src/entities/room-expense.entity.ts`
- Modify: `apps/server/src/entities/personal-expense.entity.ts`
- Modify: `apps/server/src/entities/occupancy.entity.ts`
- Modify: `apps/server/src/entities/bill.entity.ts`
- Modify: `apps/server/src/entities/deposit.entity.ts`
- Modify: `apps/server/src/entities/deposit-installment.entity.ts`
- Modify: `apps/server/src/entities/classroom-rental.entity.ts`
**Interfaces:**
- Consumes: `Department` entity from Task 1
- Produces: 所有业务实体新增 `departmentId` 字段 + `@ManyToOne` 关系
- [ ] **Step 1: 批量追加 department_id**
对每个实体,追加以下代码块(以 `student.entity.ts` 为例):
```typescript
// 在 imports 中添加:
import { Department } from './department.entity';
// 在类体中添加:
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
```
追加清单:
| 文件 | 说明 |
|------|------|
| `student.entity.ts` | 学生归属校区 |
| `room.entity.ts` | 宿舍归属校区 |
| `classroom.entity.ts` | 教室归属校区 |
| `class-schedule.entity.ts` | 排课归属校区(冗余) |
| `attendance-record.entity.ts` | 考勤归属校区(冗余) |
| `room-expense.entity.ts` | 宿舍费用归属校区(冗余) |
| `personal-expense.entity.ts` | 个人费用归属校区(冗余) |
| `occupancy.entity.ts` | 入住归属校区(冗余) |
| `bill.entity.ts` | 账单归属校区(冗余) |
| `deposit.entity.ts` | 押金归属校区(冗余) |
| `deposit-installment.entity.ts` | 押金分期归属校区(冗余) |
| `classroom-rental.entity.ts` | 租赁订单归属校区(冗余) |
注:`classes` 实体已有 `departmentId`,跳过。
- [ ] **Step 2: 验证 — TypeORM 自动 ALTER TABLE**
```bash
cd apps/server && npm run start:dev
```
Expected: 启动成功,所有业务表新增 `department_id` 列。
- [ ] **Step 3: Commit**
```bash
git add apps/server/src/entities/
git commit -m "feat: add department_id to all business entities"
```
---
### Task 3: DepartmentsModule — DTO + Service
**Files:**
- Create: `apps/server/src/departments/dto/department.dto.ts`
- Create: `apps/server/src/departments/departments.service.ts`
- Create: `apps/server/src/departments/departments.module.ts`
**Interfaces:**
- Consumes: `Department` + `UserDepartment` entities from Task 1
- Produces: `DepartmentsService` with `findAll`, `findTree`, `create`, `update`, `remove`, `getDescendantIds`, `getUserDepartments`, `assignUser`, `removeUser`
- [ ] **Step 1: 创建 DTO**
```typescript
// apps/server/src/departments/dto/department.dto.ts
import { IsString, IsNotEmpty, IsOptional, IsInt, IsBoolean, IsEnum } from 'class-validator';
export class CreateDepartmentDto {
@IsString() @IsNotEmpty()
name: string;
@IsOptional() @IsInt()
parentId?: number;
@IsOptional() @IsString()
type?: string;
@IsOptional() @IsInt()
sortOrder?: number;
}
export class UpdateDepartmentDto {
@IsOptional() @IsString()
name?: string;
@IsOptional() @IsInt()
parentId?: number;
@IsOptional() @IsString()
type?: string;
@IsOptional() @IsInt()
sortOrder?: number;
}
export class AssignUserDto {
@IsInt()
userId: number;
@IsOptional() @IsBoolean()
isDefault?: boolean;
}
```
- [ ] **Step 2: 创建 Service**
```typescript
// apps/server/src/departments/departments.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { Department } from '../entities/department.entity';
import { UserDepartment } from '../entities/user-department.entity';
import { CreateDepartmentDto, UpdateDepartmentDto, AssignUserDto } from './dto/department.dto';
@Injectable()
export class DepartmentsService {
constructor(
@InjectRepository(Department)
private deptRepo: Repository<Department>,
@InjectRepository(UserDepartment)
private userDeptRepo: Repository<UserDepartment>,
) {}
async findAll(): Promise<Department[]> {
return this.deptRepo.find({
where: { status: 'active' },
order: { sortOrder: 'ASC', name: 'ASC' },
});
}
async findTree(): Promise<Department[]> {
const all = await this.deptRepo.find({
where: { status: 'active' },
order: { sortOrder: 'ASC', name: 'ASC' },
relations: ['children'],
});
// 返回根节点parent_id = null
return all.filter((d) => d.parentId === null);
}
async findOne(id: number): Promise<Department> {
const dept = await this.deptRepo.findOne({ where: { id } });
if (!dept) throw new NotFoundException('部门不存在');
return dept;
}
async create(dto: CreateDepartmentDto): Promise<Department> {
const dept = this.deptRepo.create(dto);
return this.deptRepo.save(dept);
}
async update(id: number, dto: UpdateDepartmentDto): Promise<Department> {
const dept = await this.findOne(id);
Object.assign(dept, dto);
return this.deptRepo.save(dept);
}
async remove(id: number): Promise<void> {
// 检查是否有子部门
const children = await this.deptRepo.count({ where: { parentId: id } });
if (children > 0) throw new ConflictException('该部门下存在子部门,无法删除');
// 检查是否有关联用户
const users = await this.userDeptRepo.count({ where: { departmentId: id } });
if (users > 0) throw new ConflictException('该部门下有用户关联,无法删除');
await this.deptRepo.update(id, { status: 'archived' });
}
/** 获取部门的所有子部门 ID递归含自身 */
async getDescendantIds(departmentId: number): Promise<number[]> {
const ids = [departmentId];
const children = await this.deptRepo.find({
where: { parentId: departmentId, status: 'active' },
});
for (const child of children) {
const childIds = await this.getDescendantIds(child.id);
ids.push(...childIds);
}
return ids;
}
/** 获取用户可访问的部门 ID 列表 */
async getUserDepartments(userId: number): Promise<number[]> {
const records = await this.userDeptRepo.find({
where: { userId },
});
return records.map((r) => r.departmentId);
}
/** 获取用户默认校区 ID */
async getUserDefaultDepartmentId(userId: number): Promise<number | null> {
const record = await this.userDeptRepo.findOne({
where: { userId, isDefault: true },
});
return record?.departmentId ?? null;
}
/** 获取部门下的用户 */
async getUsers(departmentId: number): Promise<UserDepartment[]> {
return this.userDeptRepo.find({
where: { departmentId },
relations: ['user'],
});
}
/** 为用户分配部门 */
async assignUser(departmentId: number, dto: AssignUserDto): Promise<UserDepartment> {
const record = this.userDeptRepo.create({
userId: dto.userId,
departmentId,
isDefault: dto.isDefault ?? false,
});
return this.userDeptRepo.save(record);
}
/** 移除用户-部门关联 */
async removeUser(departmentId: number, userId: number): Promise<void> {
await this.userDeptRepo.delete({ departmentId, userId });
}
}
```
- [ ] **Step 3: 创建 Module**
```typescript
// apps/server/src/departments/departments.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Department } from '../entities/department.entity';
import { UserDepartment } from '../entities/user-department.entity';
import { DepartmentsService } from './departments.service';
import { DepartmentsController } from './departments.controller';
@Module({
imports: [TypeOrmModule.forFeature([Department, UserDepartment])],
controllers: [DepartmentsController],
providers: [DepartmentsService],
exports: [DepartmentsService],
})
export class DepartmentsModule {}
```
- [ ] **Step 4: Commit**
```bash
git add apps/server/src/departments/
git commit -m "feat: add DepartmentsService with tree query and user assignment"
```
---
### Task 4: DepartmentsController
**Files:**
- Create: `apps/server/src/departments/departments.controller.ts`
- Modify: `apps/server/src/app.module.ts` — 注册 DepartmentsModule + 在 TypeORM entities 中添加 Department/UserDepartment
**Interfaces:**
- Consumes: `DepartmentsService` from Task 3
- Produces: REST API
- [ ] **Step 1: 创建 Controller**
```typescript
// apps/server/src/departments/departments.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { DepartmentsService } from './departments.service';
import { CreateDepartmentDto, UpdateDepartmentDto, AssignUserDto } from './dto/department.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@UseGuards(JwtAuthGuard)
@Controller('departments')
export class DepartmentsController {
constructor(private readonly service: DepartmentsService) {}
@Get()
findAll() {
return this.service.findAll();
}
@Get('tree')
findTree() {
return this.service.findTree();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.service.findOne(+id);
}
@Post()
create(@Body() dto: CreateDepartmentDto) {
return this.service.create(dto);
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdateDepartmentDto) {
return this.service.update(+id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.service.remove(+id);
}
@Get(':id/users')
getUsers(@Param('id') id: string) {
return this.service.getUsers(+id);
}
@Post(':id/users')
assignUser(@Param('id') id: string, @Body() dto: AssignUserDto) {
return this.service.assignUser(+id, dto);
}
@Delete(':id/users/:userId')
removeUser(@Param('id') id: string, @Param('userId') userId: string) {
return this.service.removeUser(+id, +userId);
}
}
```
- [ ] **Step 2: 注册到 AppModule**
`apps/server/src/app.module.ts` 中:
1.`TypeOrmModule.forRootAsync``allEntities` 数组中添加:
```typescript
Department,
UserDepartment,
```
2.`@Module imports` 中添加:
```typescript
DepartmentsModule,
```
- [ ] **Step 3: 验证 — 测试 API**
```bash
# 创建校区
curl -X POST http://localhost:3000/api/departments \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name":"鼓楼校区","type":"campus"}'
# 获取树
curl http://localhost:3000/api/departments/tree \
-H "Authorization: Bearer <token>"
```
- [ ] **Step 4: Commit**
```bash
git add apps/server/src/departments/departments.controller.ts apps/server/src/app.module.ts
git commit -m "feat: add DepartmentsController with CRUD + user assignment"
```
---
### Task 5: CampusScope + 请求级注入
**Files:**
- Create: `apps/server/src/common/campus-scope.ts`
- Modify: `apps/server/src/app.module.ts` — 注册 CampusScope provider
**Interfaces:**
- Produces: `CampusScope` 请求级 Provider通过 `scope.filter()` 在查询条件中自动追加 `departmentId`
- [ ] **Step 1: 创建 CampusScope**
```typescript
// apps/server/src/common/campus-scope.ts
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { In } from 'typeorm';
import { DepartmentsService } from '../departments/departments.service';
@Injectable({ scope: Scope.REQUEST })
export class CampusScope {
constructor(
@Inject(REQUEST) private req: any,
private departmentsService: DepartmentsService,
) {}
get userId(): number {
return this.req.user?.id;
}
get isSuperAdmin(): boolean {
return this.req.user?.isSuperAdmin ?? false;
}
get currentDepartmentId(): number | null {
const headerId = parseInt(this.req.headers?.['x-campus-id'] || '0', 10);
return headerId || null;
}
/** 对 TypeORM find where 条件追加校区过滤 */
async filter<T extends Record<string, any>>(where: T): Promise<T> {
// 超管 + 未选校区 → 不过滤
if (this.isSuperAdmin && !this.currentDepartmentId) {
return where;
}
const ids = await this.getEffectiveScopeIds();
if (ids.length === 0) return where;
return { ...where, departmentId: In(ids) } as any;
}
private async getEffectiveScopeIds(): Promise<number[]> {
// 如果用户选了具体校区 → 该校区 + 子部门
if (this.currentDepartmentId) {
return this.departmentsService.getDescendantIds(this.currentDepartmentId);
}
// 未选校区 → 用户关联的所有部门 + 子部门
const userDeptIds = await this.departmentsService.getUserDepartments(this.userId);
const allIds = await Promise.all(
userDeptIds.map((id) => this.departmentsService.getDescendantIds(id)),
);
return [...new Set(allIds.flat())];
}
}
```
- [ ] **Step 2: 创建 CampusScopeMiddleware 将 scope 注入 req**
```typescript
// apps/server/src/common/campus-scope.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { CampusScope } from './campus-scope';
@Injectable()
export class CampusScopeMiddleware implements NestMiddleware {
constructor(private readonly scope: CampusScope) {}
use(req: Request, _res: Response, next: NextFunction) {
(req as any).campusScope = this.scope;
next();
}
}
```
- [ ] **Step 3: 在 AppModule 中注册**
`apps/server/src/app.module.ts` 中:
```typescript
import { CampusScope } from './common/campus-scope';
import { CampusScopeMiddleware } from './common/campus-scope.middleware';
import { MiddlewareConsumer, NestModule } from '@nestjs/common';
// 在 providers 中添加:
CampusScope,
// AppModule 实现 NestModule:
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(CampusScopeMiddleware).forRoutes('*');
}
}
```
- [ ] **Step 4: Commit**
```bash
git add apps/server/src/common/campus-scope.ts apps/server/src/common/campus-scope.middleware.ts apps/server/src/app.module.ts
git commit -m "feat: add CampusScope request-level provider for data isolation"
```
---
### Task 6: 各 Service 接入 CampusScope.filter()
**Files:**
- Modify: `apps/server/src/students/students.service.ts`
- Modify: `apps/server/src/rooms/rooms.service.ts`
- Modify: `apps/server/src/occupancies/occupancies.service.ts`
- Modify: `apps/server/src/bills/bills.service.ts`
- Modify: `apps/server/src/expenses/expenses.service.ts`
- Modify: `apps/server/src/classes/classes.service.ts`
- Modify: `apps/server/src/schedules/schedules.service.ts`
- Modify: `apps/server/src/attendance/attendance.service.ts`
- Modify: `apps/server/src/deposits/deposits.service.ts`
- Modify: `apps/server/src/classrooms/classrooms.service.ts`
- Modify: `apps/server/src/classroom-rentals/classroom-rentals.service.ts`
- Modify: `apps/server/src/dashboard/dashboard.service.ts`
**Interfaces:**
- Consumes: `CampusScope` from Task 5
- Modifies: 所有查询方法通过 `scope.filter()` 追加校区过滤
- [ ] **Step 1: 模式说明**
每个 service 的查询方法改造模式(以 `StudentsService.findAll` 为例):
```typescript
// 修改前:
async findAll(query: any) {
return this.repo.find({ where: { status: 'active' } });
}
// 修改后:
async findAll(query: any) {
const where = await this.scope.filter({ status: 'active' });
return this.repo.find({ where });
}
```
- [ ] **Step 2: 各 Service 注入 CampusScope**
对于每个 Controller/Service 文件,需要:
1. `import { CampusScope } from '../common/campus-scope';`
2. constructor 注入: `private scope: CampusScope`
3. 所有 `find`/`findAndCount`/`createQueryBuilder` 查询用 `await this.scope.filter(where)` 包裹
但不是所有 module 都注入了 `DepartmentsModule`CampusScope 依赖它)。需要在各个需要 CampusScope 的 module 的 imports 中添加 `DepartmentsModule`
```typescript
// 在每个受影响的 module 中添加:
import { DepartmentsModule } from '../departments/departments.module';
@Module({
imports: [
TypeOrmModule.forFeature([...]),
DepartmentsModule, // 新增
],
})
```
- [ ] **Step 3: 改造清单**
逐个 service 改造 `findAll` 类方法(有查询条件的方法):
| Service | 方法 | 操作 |
|---------|------|------|
| `students.service.ts` | `findAll` | `await this.scope.filter(where)` |
| `rooms.service.ts` | `findAll`, `findVisual` | 同上 |
| `occupancies.service.ts` | `findAll` | 同上 |
| `bills.service.ts` | `findAll` | 同上 |
| `expenses.service.ts` | 所有查询 | 同上 |
| `classes.service.ts` | `findAll` | 同上 |
| `schedules.service.ts` | `findAll`, `findWeekly` | 同上 |
| `attendance.service.ts` | 所有查询 | 同上 |
| `deposits.service.ts` | 所有查询 | 同上 |
| `classrooms.service.ts` | `findAll` | 同上 |
| `classroom-rentals.service.ts` | `findAll` | 同上 |
| `dashboard.service.ts` | `getStats` | 同上 |
- [ ] **Step 4: Commit**
```bash
git add apps/server/src/
git commit -m "feat: integrate CampusScope.filter() into all business services"
```
---
### Task 7: 写操作时填充 department_id
**Files:**
- Modify: 各 Service 的 create/update 方法
**Interfaces:**
- Modifies: 创建记录时自动填充 `departmentId`(从关联实体或前端输入获取)
- [ ] **Step 1: 创建 Room 时填充 department_id**
```typescript
// rooms.service.ts create()
async create(dto: CreateRoomDto) {
const room = this.repo.create({
...dto,
departmentId: dto.departmentId, // 前端传入(由校区选择器当前选中值决定)
});
return this.repo.save(room);
}
```
`rooms.dto.ts` 需新增字段:
```typescript
@IsOptional() @IsInt()
departmentId?: number;
```
- [ ] **Step 2: 创建 Student 时从关联 Class 获取 department_id**
```typescript
// students.service.ts create()
async create(dto: CreateStudentDto) {
let departmentId = dto.departmentId;
if (!departmentId && dto.classId) {
const cls = await this.classesRepo.findOne({ where: { id: dto.classId } });
departmentId = cls?.departmentId || null;
}
const student = this.repo.create({ ...dto, departmentId });
return this.repo.save(student);
}
```
- [ ] **Step 3: 其他实体类似处理**
| 实体创建时 | department_id 来源 |
|-----------|-------------------|
| Occupancy | 从关联的 Room.departmentId |
| Bill | 从关联的 Student.departmentId |
| PersonalExpense | 从关联的 Student.departmentId |
| RoomExpense | 从关联的 Room.departmentId |
| AttendanceRecord | 从关联的 Student.departmentId |
| Deposit | 从关联的 Student.departmentId |
| DepositInstallment | 从关联的 Deposit.departmentId |
| ClassSchedule | 从关联的 Class.departmentId 或前端传入 |
| ClassroomRental | 从关联的 Classroom.departmentId |
| Classroom | 前端传入 |
- [ ] **Step 4: Commit**
```bash
git add apps/server/src/
git commit -m "feat: auto-populate department_id on entity creation"
```
---
### Task 8: JWT Payload 扩展 + 登录注入 isSuperAdmin
**Files:**
- Modify: `apps/server/src/auth/auth.service.ts`
- Modify: `apps/server/src/auth/strategies/jwt.strategy.ts`
**Interfaces:**
- Modifies: JWT payload 增加 `isSuperAdmin` 标记
- [ ] **Step 1: auth.service.ts — login() 注入 isSuperAdmin**
`login()` 方法的 payload 构造处:
```typescript
// auth.service.ts
const isSuperAdmin = user.roles?.some(r => r.name === 'super_admin') ?? false;
const payload = {
sub: user.id,
username: user.username,
permissions,
isSuperAdmin,
};
```
- [ ] **Step 2: jwt.strategy.ts — validate() 透传 isSuperAdmin**
```typescript
// jwt.strategy.ts validate()
async validate(payload: any) {
return {
id: payload.sub,
username: payload.username,
permissions: payload.permissions || [],
isSuperAdmin: payload.isSuperAdmin || false,
};
}
```
- [ ] **Step 3: Commit**
```bash
git add apps/server/src/auth/
git commit -m "feat: add isSuperAdmin to JWT payload for campus scope bypass"
```
---
### Task 9: 数据回填 — 默认校区 + 历史数据迁移
**Files:**
- Create: `apps/server/src/departments/seed.service.ts`(或直接用 migration script
- [ ] **Step 1: 创建 seed 脚本**
```typescript
// apps/server/src/departments/seed.ts
// 在 NestJS bootstrap 后执行(或作为独立脚本运行)
import { DataSource } from 'typeorm';
export async function seedDefaultCampus(dataSource: DataSource) {
const deptRepo = dataSource.getRepository('departments');
const userDeptRepo = dataSource.getRepository('user_departments');
// 1. 检查是否已有校区数据
const existing = await deptRepo.count();
if (existing > 0) {
console.log('Departments already exist, skipping seed');
return;
}
// 2. 创建默认校区
const campus = await deptRepo.save({
name: '主校区',
type: 'campus',
sortOrder: 0,
});
// 3. 回填所有业务数据的 department_id
const tables = [
'students', 'rooms', 'classrooms', 'class_schedules',
'attendance_records', 'room_expenses', 'personal_expenses',
'occupancies', 'bills', 'deposits', 'deposit_installments',
'classroom_rentals',
];
for (const table of tables) {
await dataSource.query(
`UPDATE ${table} SET department_id = ? WHERE department_id IS NULL`,
[campus.id],
);
}
// 4. 所有现有用户关联到默认校区
const users = await dataSource.query('SELECT id FROM users');
for (const user of users) {
await userDeptRepo.save({
userId: user.id,
departmentId: campus.id,
isDefault: true,
});
}
console.log('Seed complete: default campus created, data backfilled');
}
```
- [ ] **Step 2: 在 main.ts 中注册 seed**
```typescript
// apps/server/src/main.ts
// 在 app.listen() 之前:
const dataSource = app.get(DataSource);
await seedDefaultCampus(dataSource);
```
- [ ] **Step 3: 运行验证**
```bash
cd apps/server && npm run start:dev
```
Expected: 启动日志显示 "Seed complete: default campus created, data backfilled"。
验证:查询 `SELECT COUNT(*) FROM students WHERE department_id IS NULL` 应为 0。
- [ ] **Step 4: Commit**
```bash
git add apps/server/src/departments/seed.ts apps/server/src/main.ts
git commit -m "feat: add default campus seed with historical data backfill"
```
---
### Task 10: 前端 — useCampus Hook + CampusSwitcher 组件
**Files:**
- Create: `apps/admin/src/hooks/useCampus.ts`
- Create: `apps/admin/src/components/CampusSwitcher.tsx`
**Interfaces:**
- Consumes: `GET /api/departments/tree` + `GET /api/departments` (for flat list)
- Produces: `useCampus` hook, `CampusSwitcher` component
- [ ] **Step 1: 创建 useCampus hook**
```typescript
// apps/admin/src/hooks/useCampus.ts
import { useState, useEffect, useCallback } from 'react';
import api from '../api';
interface Department {
id: number;
name: string;
type: string;
parentId: number | null;
}
export function useCampus() {
const [campuses, setCampuses] = useState<Department[]>([]);
const [currentId, setCurrentId] = useState<string>(
() => localStorage.getItem('currentCampusId') || ''
);
const [loading, setLoading] = useState(true);
const fetchCampuses = useCallback(async () => {
try {
// 只取校区级部门type=campus
const data = await api.get('/departments') as unknown as Department[];
const campusList = data.filter((d) => d.type === 'campus');
setCampuses(campusList);
// 如果没有选中校区,选第一个
if (!currentId && campusList.length > 0) {
setCurrentId(String(campusList[0].id));
localStorage.setItem('currentCampusId', String(campusList[0].id));
}
} catch {
// 静默失败
} finally {
setLoading(false);
}
}, [currentId]);
useEffect(() => {
fetchCampuses();
}, []);
const switchCampus = useCallback((id: string) => {
setCurrentId(id);
localStorage.setItem('currentCampusId', id);
// 触发全局数据刷新
window.dispatchEvent(new CustomEvent('campus-changed', { detail: id }));
}, []);
return { campuses, currentId, switchCampus, loading };
}
```
- [ ] **Step 2: 创建 CampusSwitcher 组件**
```tsx
// apps/admin/src/components/CampusSwitcher.tsx
import React from 'react';
import { Select, Typography } from 'antd';
import { EnvironmentOutlined } from '@ant-design/icons';
import { useCampus } from '../hooks/useCampus';
const CampusSwitcher: React.FC = () => {
const { campuses, currentId, switchCampus, loading } = useCampus();
// 只有一个校区 → 纯文本展示
if (campuses.length <= 1) {
return (
<Typography.Text style={{ color: '#fff', marginRight: 24 }}>
<EnvironmentOutlined style={{ marginRight: 4 }} />
{campuses[0]?.name || '主校区'}
</Typography.Text>
);
}
const options = [
...campuses.map((c) => ({ value: String(c.id), label: c.name })),
{ value: '', label: '全部校区' },
];
return (
<Select
value={currentId || undefined}
onChange={switchCampus}
options={options}
loading={loading}
style={{ minWidth: 140, marginRight: 24 }}
variant="borderless"
popupMatchSelectWidth={false}
/>
);
};
export default CampusSwitcher;
```
- [ ] **Step 3: Commit**
```bash
git add apps/admin/src/hooks/useCampus.ts apps/admin/src/components/CampusSwitcher.tsx
git commit -m "feat: add useCampus hook and CampusSwitcher component"
```
---
### Task 11: 前端 — MainLayout 集成校区选择器 + API interceptor
**Files:**
- Modify: `apps/admin/src/layouts/MainLayout.tsx`
- Modify: `apps/admin/src/api/index.ts`
- [ ] **Step 1: MainLayout 添加 CampusSwitcher**
`MainLayout.tsx` 的 Header 中Logo 旁边添加:
```tsx
import CampusSwitcher from '../components/CampusSwitcher';
// 在 Header 内 Logo 区域后:
<CampusSwitcher />
```
- [ ] **Step 2: API interceptor 注入 X-Campus-Id**
```typescript
// apps/admin/src/api/index.ts
// 在已有的 request interceptor 中添加:
api.interceptors.request.use((config) => {
// ... 已有的 token 注入 ...
const campusId = localStorage.getItem('currentCampusId');
if (campusId) {
config.headers['X-Campus-Id'] = campusId;
}
return config;
});
```
- [ ] **Step 3: Commit**
```bash
git add apps/admin/src/layouts/MainLayout.tsx apps/admin/src/api/index.ts
git commit -m "feat: integrate CampusSwitcher into header and API interceptor"
```
---
### Task 12: 前端 — 部门管理页
**Files:**
- Create: `apps/admin/src/pages/Departments/index.tsx`
- Modify: `apps/admin/src/App.tsx` — 注册路由
- [ ] **Step 1: 创建 Departments 管理页**
```tsx
// apps/admin/src/pages/Departments/index.tsx
import React, { useState, useEffect } from 'react';
import {
Tree, Card, Button, Modal, Form, Input, Select, InputNumber,
Space, Table, Popconfirm, message, Row, Col,
} from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
import api from '../../api';
interface Department {
id: number;
name: string;
parentId: number | null;
type: string;
sortOrder: number;
children?: Department[];
}
const DepartmentsPage: React.FC = () => {
const [tree, setTree] = useState<Department[]>([]);
const [selected, setSelected] = useState<Department | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Department | null>(null);
const [users, setUsers] = useState<any[]>([]);
const [form] = Form.useForm();
const fetchTree = async () => {
try {
const data = await api.get('/departments/tree') as unknown as Department[];
setTree(data);
} catch { /* ignore */ }
};
useEffect(() => { fetchTree(); }, []);
const handleSelect = async (keys: React.Key[]) => {
if (keys.length === 0) return;
try {
const dept = await api.get(`/departments/${keys[0]}`) as unknown as Department;
setSelected(dept);
const userData = await api.get(`/departments/${keys[0]}/users`) as unknown as any[];
setUsers(userData);
} catch { /* ignore */ }
};
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editing) {
await api.put(`/departments/${editing.id}`, values);
message.success('更新成功');
} else {
await api.post('/departments', values);
message.success('创建成功');
}
setModalOpen(false);
fetchTree();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/departments/${id}`);
message.success('已删除');
setSelected(null);
fetchTree();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const openCreate = (parentId?: number) => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ parentId: parentId ?? null, type: 'department', sortOrder: 0 });
setModalOpen(true);
};
const openEdit = () => {
if (!selected) return;
setEditing(selected);
form.setFieldsValue(selected);
setModalOpen(true);
};
const treeData = tree.map((node) => ({
title: `${node.name} (${node.type === 'campus' ? '校区' : '部门'})`,
key: node.id,
children: node.children?.map((child) => ({
title: `${child.name} (${child.type === 'campus' ? '校区' : '部门'})`,
key: child.id,
})),
}));
return (
<Row gutter={24}>
<Col span={8}>
<Card
title="组织架构"
extra={
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => openCreate()}>
新增校区
</Button>
}
>
<Tree
treeData={treeData}
onSelect={handleSelect}
style={{ minHeight: 400 }}
/>
</Card>
</Col>
<Col span={16}>
{selected ? (
<Card
title={selected.name}
extra={
<Space>
<Button size="small" icon={<PlusOutlined />} onClick={() => openCreate(selected.id)}>
添加子部门
</Button>
<Button size="small" icon={<EditOutlined />} onClick={openEdit}>
编辑
</Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(selected.id)}>
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
}
>
<p>类型:{selected.type === 'campus' ? '校区' : '部门'}</p>
<p>排序:{selected.sortOrder}</p>
<h4>部门成员</h4>
<Table
dataSource={users}
rowKey="id"
columns={[
{ title: '用户名', dataIndex: ['user', 'username'] },
{ title: '姓名', dataIndex: ['user', 'name'] },
{ title: '默认校区', dataIndex: 'isDefault', render: (v: boolean) => v ? '是' : '否' },
]}
size="small"
/>
</Card>
) : (
<Card>
<div style={{ textAlign: 'center', color: '#999', padding: 40 }}>
请从左侧选择一个部门
</div>
</Card>
)}
</Col>
<Modal
title={editing ? '编辑部门' : '新增部门'}
open={modalOpen}
onOk={handleSave}
onCancel={() => setModalOpen(false)}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="parentId" label="上级部门">
<Select
allowClear
options={[
...tree.map((d) => ({ value: d.id, label: d.name })),
]}
/>
</Form.Item>
<Form.Item name="type" label="类型">
<Select
options={[
{ value: 'campus', label: '校区' },
{ value: 'department', label: '部门' },
]}
/>
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} />
</Form.Item>
</Form>
</Modal>
</Row>
);
};
export default DepartmentsPage;
```
- [ ] **Step 2: 在 App.tsx 注册路由**
```tsx
import DepartmentsPage from './pages/Departments';
// 在 Routes 内添加:
<Route path="/departments" element={
<PrivateRoute>
<MainLayout />
</PrivateRoute>
}>
<Route index element={<DepartmentsPage />} />
</Route>
```
- [ ] **Step 3: Commit**
```bash
git add apps/admin/src/pages/Departments/index.tsx apps/admin/src/App.tsx
git commit -m "feat: add Departments management page with tree + user list"
```
---
### Task 13: 验证 + 端到端测试
- [ ] **Step 1: 启动完整环境**
```bash
cd apps/server && npm run start:dev &
cd apps/admin && npm run dev &
```
- [ ] **Step 2: 测试流程**
1. 打开 `http://localhost:5173`,用超管登录
2. 访问 `/departments` → 确认默认「主校区」存在
3. 创建第二个校区「江宁校区」
4. 进入「账号管理」→ 编辑某个教职工,将其分配到「江宁校区」
5. 用该教职工登录 → Header 显示校区选择器,可切换
6. 切换到「江宁校区」→ 学生列表/宿舍列表只显示江宁数据
7. 切换到「全部校区」→ 显示两个校区数据
8. 超管不选校区 → 显示全部数据(无隔离)
- [ ] **Step 3: 数据隔离验证**
SQL 验证:
```sql
-- 确认历史数据已回填
SELECT COUNT(*) FROM students WHERE department_id IS NULL; -- 期望 0
SELECT COUNT(*) FROM rooms WHERE department_id IS NULL; -- 期望 0
-- 确认新创建实体自动填充
INSERT INTO students (...) VALUES (...);
-- 应自动填 department_id
```
- [ ] **Step 4: Commit (如有调整)**
```bash
git add -A
git commit -m "fix: campus isolation tweaks and seed adjustments"
```