Files
gongxue-base/docs/superpowers/plans/2026-07-09-student-role-separation.md

10 KiB
Raw Blame History

Student 角色分离 Implementation Plan

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: 允许管理员将钉钉同步的用户标记为"教职工"(摘掉 Student使其不在学员管理中出现且下次同步不恢复。

Architecture: 利用 Student.status 新增值 'staff'rbac 模块加两个端点syncOneUser 加防护逻辑,前端加操作按钮。

Tech Stack: NestJS + TypeORM + React 19 + Ant Design 6

Global Constraints

  • Student.status 是 varchar'staff' 是新增有效值,无需数据库迁移
  • 物理删除 User 已禁止(必须先归档),本方案不涉及 User CRUD 修改
  • 同步时不覆盖 status != 'active' 的 Student

Task 1: syncOneUser 防护 — 不覆盖非 active 的 Student

Files:

  • Modify: apps/server/src/integration/dingtalk.service.ts (syncOneUser 方法内)

Interfaces:

  • Consumes: Student.status (现有字段)

  • Produces: 无新增接口,行为变更

  • Step 1: 定位 syncOneUser 中创建/更新 Student 的代码

当前逻辑(约 line 497-520新用户创建 Student已有用户 backfill Student。

  • Step 2: 在创建/更新 Student 前加防护

syncOneUser 中,创建新 Student 和 backfill 已有 Student 之前,都先检查是否已有 status 为非 active 的记录:

// 在创建 Student 之前(约 line 497:
// 检查是否已被手动标记为教职工/毕业/退训
const existingStudent = await this.studentRepo.findOne({ where: { userId: user.id } });
if (existingStudent && existingStudent.status !== 'active') {
  // 用户已被标记为非学员状态,不覆盖
  return;
}

// 创建新 Student原有逻辑在防护之后
const student = this.studentRepo.create({
  name: du.name,
  phone: du.mobile || undefined,
  userId: user.id,
  status: 'active',
});
await this.studentRepo.save(student);

同样在 backfill 分支(约 line 510也加相同检查

// backfill 分支:已有 User 但没有 Student
const existingStudent = await this.studentRepo.findOne({ where: { userId: user.id } });
if (!existingStudent || existingStudent.status === 'active') {
  // 只在没有 Student 或 Student 为 active 时才 backfill
  if (!existingStudent) {
    const student = this.studentRepo.create({ ... });
    await this.studentRepo.save(student);
  }
}
  • Step 3: 编译检查
cd apps/server && npx tsc --noEmit -p tsconfig.build.json

Expected: no errors.

  • Step 4: Commit
git add apps/server/src/integration/dingtalk.service.ts
git commit -m "feat: syncOneUser skips Students with non-active status"

Task 2: RBAC mark-staff / mark-student 端点

Files:

  • Modify: apps/server/src/rbac/rbac.service.ts
  • Modify: apps/server/src/rbac/rbac.controller.ts

Interfaces:

  • Produces: PUT /rbac/users/:id/mark-staff{ message: string }

  • Produces: PUT /rbac/users/:id/mark-student{ message: string }

  • Step 1: 注入 Student repo 到 RbacService

apps/server/src/rbac/rbac.service.ts:

import { Student } from '../entities';

// constructor 中新增:
@InjectRepository(Student)
private readonly studentRepo: Repository<Student>,

apps/server/src/rbac/rbac.module.tsTypeOrmModule.forFeature 加入 Student

  • Step 2: 添加 markAsStaff 方法
async markAsStaff(userId: number) {
  const student = await this.studentRepo.findOne({ where: { userId } });
  if (!student) throw new Error('该用户没有学员记录');
  await this.studentRepo.update(student.id, { status: 'staff' });
  return { message: '已标记为教职工' };
}

async markAsStudent(userId: number) {
  const student = await this.studentRepo.findOne({ where: { userId } });
  if (!student) throw new Error('该用户没有学员记录');
  await this.studentRepo.update(student.id, { status: 'active' });
  return { message: '已恢复为学员' };
}
  • Step 3: 添加 Controller 端点

apps/server/src/rbac/rbac.controller.ts 中的用户管理区域添加:

@Put('users/:id/mark-staff')
@RequirePermission('user:edit')
async markAsStaff(@Param('id') id: string) {
  try {
    return await this.rbacService.markAsStaff(+id);
  } catch (e: unknown) {
    const err = e as { message?: string };
    throw new BadRequestException(err?.message);
  }
}

@Put('users/:id/mark-student')
@RequirePermission('user:edit')
async markAsStudent(@Param('id') id: string) {
  try {
    return await this.rbacService.markAsStudent(+id);
  } catch (e: unknown) {
    const err = e as { message?: string };
    throw new BadRequestException(err?.message);
  }
}

路由顺序检查:users/:id/mark-staffusers/:id/password 之后,users/:id/archive 之前,无冲突。

  • Step 4: 更新 findAllUsers 返回 Student.status

findAllUsers 的返回映射中加入 student status

// 在 findAllUsers 方法中,先批量查 Student
const userIds = users.map((u) => u.id);
const students = await this.studentRepo.find({
  where: { userId: In(userIds) },
  select: ['userId', 'status'],
});
const statusMap = new Map(students.map((s) => [s.userId, s.status]));

return users.map((u) => ({
  // ...原有字段...
  studentStatus: statusMap.get(u.id) || null,
}));
  • Step 5: 编译检查
cd apps/server && npx tsc --noEmit -p tsconfig.build.json
  • Step 6: Commit
git add apps/server/src/rbac/
git commit -m "feat: add mark-staff/mark-student endpoints for role separation"

Task 3: 学员列表默认隐藏 staff

Files:

  • Modify: apps/server/src/students/students.service.ts

Interfaces:

  • Consumes: Student.status (现有字段)

  • Produces: GET /students 默认排除 status='staff'

  • Step 1: 修改 findAll 默认过滤条件

apps/server/src/students/students.service.tsfindAll 方法中:

// 在 where 条件中默认排除 staff除非明确传了 status=staff
if (!query.status) {
  where.status = Not('staff');
}
// 如果明确传了 status=staff则按传入值查询

如果 query.status 传了 'staff',则按 staff 过滤,否则默认排除。

  • Step 2: 编译检查 + Commit
cd apps/server && npx tsc --noEmit -p tsconfig.build.json
git add apps/server/src/students/students.service.ts
git commit -m "feat: Students list excludes staff by default"

Task 4: 前端 — 用户管理页面加标记/恢复按钮

Files:

  • Modify: apps/admin/src/pages/Users/index.tsx

Interfaces:

  • Consumes: GET /rbac/users?isArchived= 返回 studentStatus 字段

  • Consumes: PUT /rbac/users/:id/mark-staff, PUT /rbac/users/:id/mark-student

  • Step 1: 添加 handleMarkStaff 函数

const handleMarkStaff = async (id: number, toStaff: boolean) => {
  try {
    await api.put(`/rbac/users/${id}/${toStaff ? 'mark-staff' : 'mark-student'}`);
    message.success(toStaff ? '已标记为教职工' : '已恢复为学员');
    fetchData();
  } catch (e: unknown) {
    const err = e as { message?: string };
    message.error(err?.message || '操作失败');
  }
};
  • Step 2: 在表格操作列添加按钮

在操作列中,归档按钮之后添加:

{record.studentStatus === 'active' && (
  <Popconfirm title="确认标记为教职工?" onConfirm={() => handleMarkStaff(record.id, true)}>
    <PermissionButton permission="user:edit" type="link" size="small">
      标记教职工
    </PermissionButton>
  </Popconfirm>
)}
{record.studentStatus === 'staff' && (
  <Popconfirm title="确认恢复为学员?" onConfirm={() => handleMarkStaff(record.id, false)}>
    <PermissionButton permission="user:edit" type="link" size="small">
      恢复学员
    </PermissionButton>
  </Popconfirm>
)}
  • Step 3: 编译检查 + Commit
cd apps/admin && npx tsc --noEmit
git add apps/admin/src/pages/Users/index.tsx
git commit -m "feat: add mark-staff/restore-student buttons in user management"

Task 5: 前端 — 学员管理页面加 staff 筛选

Files:

  • Modify: apps/admin/src/pages/Students/index.tsx

  • Step 1: 检查 Students 页面现有筛选

apps/admin/src/pages/Students/index.tsx 已有 statusMap 包含 active/graduated/withdrawn/archived。

  • Step 2: 添加 staff 状态到 statusMap
const statusMap: Record<string, { text: string; color: string }> = {
  active: { text: '在读', color: 'green' },
  graduated: { text: '已毕业', color: 'blue' },
  withdrawn: { text: '已退训', color: 'red' },
  archived: { text: '已归档', color: '#999' },
  staff: { text: '教职工', color: 'purple' },
};
  • Step 3: 确认可搜索 staff 状态

因为 Student findAll 已经支持 status 查询参数(传 staff 即可),前端只需把 staff 加入 Select options 即可。staff 不在默认显示中,需要主动切换状态筛选才能看到。

  • Step 4: 编译检查 + Commit
cd apps/admin && npx tsc --noEmit
git add apps/admin/src/pages/Students/index.tsx
git commit -m "feat: add staff status filter in student management"

Task 6: 端到端验证

  • Step 1: 启动服务
cd apps/server && npm run start:dev
cd apps/admin && npm run dev
  • Step 2: 验证流程
  1. 打开「账号管理」→ 确认列表中有 studentStatus 显示
  2. 找到一个 studentStatus='active' 的用户 → 点「标记教职工」
  3. 确认提示 "已标记为教职工",刷新后状态变为 staff
  4. 打开「学员管理」→ 默认列表不再显示该用户
  5. 切换状态筛选到"教职工"→ 能看到该用户
  6. 回到「账号管理」→ 点「恢复学员」→ 确认恢复
  7. 模拟同步:触发 POST /api/sync/trigger → 确认 staff 状态的学员不被覆盖
  • Step 3: Commit如有修复