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

335 lines
10 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.

# 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<Student>,
```
`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' && (
<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**
```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<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**
```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如有修复**