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

13 KiB
Raw Blame History

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

版本v1.0 日期2026-07-05 基于 PRDPRD-恭学教育学生管理系统.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 间接获取)

全局共享不隔离usersrolespermissionstenantsoperation_logsnotificationssync_logssync_statesding_attendance_rawexpense_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-Idfilter() 原样返回 → 不做隔离。

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 或 /departments API 获取)
  • 切换时: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. 数据库迁移

新增表

departmentsuser_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);

数据回填

  1. 创建默认校区 "主校区"departments type=campus
  2. 所有现有数据的 department_id 回填为默认校区 ID
  3. 现有用户全部关联到默认校区(user_departments

7. 钉钉同步集成

钉钉组织架构拉取已有能力PRD §19.1),同步时将钉钉部门树映射到 departments 表:

  • 根部门 → type = 'campus'
  • 子部门 → type = 'department'
  • 同步时维护 parent_id 树结构