diff --git a/docs/superpowers/plans/2026-07-09-student-role-separation.md b/docs/superpowers/plans/2026-07-09-student-role-separation.md new file mode 100644 index 0000000..3ad0397 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-student-role-separation.md @@ -0,0 +1,334 @@ +# 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 的记录: + +```typescript +// 在创建 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)也加相同检查: + +```typescript +// 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: 编译检查** + +```bash +cd apps/server && npx tsc --noEmit -p tsconfig.build.json +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +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`: + +```typescript +import { Student } from '../entities'; + +// constructor 中新增: +@InjectRepository(Student) +private readonly studentRepo: Repository, +``` + +在 `apps/server/src/rbac/rbac.module.ts` 中 `TypeOrmModule.forFeature` 加入 `Student`。 + +- [ ] **Step 2: 添加 markAsStaff 方法** + +```typescript +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` 中的用户管理区域添加: + +```typescript +@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-staff` 在 `users/:id/password` 之后,`users/:id/archive` 之前,无冲突。 + +- [ ] **Step 4: 更新 findAllUsers 返回 Student.status** + +在 `findAllUsers` 的返回映射中加入 student status: + +```typescript +// 在 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: 编译检查** + +```bash +cd apps/server && npx tsc --noEmit -p tsconfig.build.json +``` + +- [ ] **Step 6: Commit** + +```bash +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.ts` 的 `findAll` 方法中: + +```typescript +// 在 where 条件中默认排除 staff,除非明确传了 status=staff +if (!query.status) { + where.status = Not('staff'); +} +// 如果明确传了 status=staff,则按传入值查询 +``` + +如果 `query.status` 传了 `'staff'`,则按 `staff` 过滤,否则默认排除。 + +- [ ] **Step 2: 编译检查 + Commit** + +```bash +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 函数** + +```typescript +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: 在表格操作列添加按钮** + +在操作列中,归档按钮之后添加: + +```tsx +{record.studentStatus === 'active' && ( + handleMarkStaff(record.id, true)}> + + 标记教职工 + + +)} +{record.studentStatus === 'staff' && ( + handleMarkStaff(record.id, false)}> + + 恢复学员 + + +)} +``` + +- [ ] **Step 3: 编译检查 + Commit** + +```bash +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** + +```typescript +const statusMap: Record = { + 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** + +```bash +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: 启动服务** + +```bash +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(如有修复)**