diff --git a/docs/superpowers/plans/2026-07-09-dingtalk-sync-role-selection.md b/docs/superpowers/plans/2026-07-09-dingtalk-sync-role-selection.md new file mode 100644 index 0000000..de60555 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-dingtalk-sync-role-selection.md @@ -0,0 +1,876 @@ +# DingTalk Sync Role Selection — 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:** Move DingTalk user sync from Users page to IntegrationConfig page with a Drawer-based tree UI for selecting which synced users are teachers (with role assignment) vs students. + +**Architecture:** Backend adds two endpoints: one to fetch the DingTalk org tree with users attached, one to import users with role/student assignment. Frontend adds a "同步用户" Tab on IntegrationConfig page with a Drawer tree; removes the old sync button and mark-staff/mark-student buttons from Users page. + +**Tech Stack:** NestJS 11 + TypeORM 0.3 (backend), React 19 + Vite + Ant Design 6 (frontend) + +## Global Constraints + +- MUST inject superpowers:ui-ux-pro-max and superpowers:vercel-react-best-practices during implementation +- Follow existing NestJS module patterns: entity/dto/service/controller +- Frontend pages in `apps/admin/src/pages/` with independent directories +- RBAC permission decorators on all new endpoints +- Use existing `api` axios instance for frontend API calls +- Default teacher role: "班主任" (look up by name from `/rbac/roles`) +- `roleId: null` (not undefined) marks a user as student + +--- + +### Task 1: Backend — New type and fetchOrgTreeWithUsers + +**Files:** +- Modify: `apps/server/src/integration/dingtalk.service.ts` + +**Interfaces:** +- Produces: `DingOrgTreeNodeWithUsers` (exported interface), `fetchOrgTreeWithUsers(rootDeptId?: number): Promise` + +- [ ] **Step 1: Add DingOrgTreeNodeWithUsers type** + +After the existing `DingOrgTreeNode` interface (line ~73), add: + +```typescript +/** 钉钉部门树节点(含用户),供同步用户选择器使用 */ +export interface DingOrgTreeNodeWithUsers { + id: number; + name: string; + parentId: number; + children: DingOrgTreeNodeWithUsers[]; + users: Array<{ userid: string; name: string; mobile: string }>; +} +``` + +- [ ] **Step 2: Add fetchOrgTreeWithUsers method** + +After `fetchOrgTree` method (line ~456), add: + +```typescript + /** + * 获取钉钉组织部门树(含用户),供前端同步用户选择器使用。 + * 返回从指定 rootDeptId 开始的树,每个部门节点含 users 数组。 + */ + async fetchOrgTreeWithUsers(rootDeptId = 1): Promise { + if (!this.configured) { + throw new ServiceUnavailableException('钉钉未配置'); + } + const token = await this.getAccessToken(); + const deptIds = await this.getAllDeptIds(token, rootDeptId); + + // 拉每个部门详情 + const nodes: DingOrgTreeNodeWithUsers[] = []; + for (let i = 0; i < deptIds.length; i++) { + if (i > 0) await this.delay(i); + const detail = await this.getDeptDetail(token, deptIds[i]); + if (!detail) continue; + + // 拉该部门下的用户 + const dingUsers = await this.getDeptUsers(token, deptIds[i]); + + nodes.push({ + id: detail.dept_id, + name: detail.name, + parentId: detail.parent_id, + children: [], + users: dingUsers.map((u) => ({ + userid: u.userid, + name: u.name, + mobile: u.mobile, + })), + }); + } + + // 全局去重:同一个 dingUserId 可能在多个部门出现 + const seenUserIds = new Set(); + for (const node of nodes) { + node.users = node.users.filter((u) => { + if (seenUserIds.has(u.userid)) return false; + seenUserIds.add(u.userid); + return true; + }); + } + + // 组装成树 + const map = new Map(); + nodes.forEach((n) => map.set(n.id, n)); + const roots: DingOrgTreeNodeWithUsers[] = []; + for (const node of nodes) { + const parent = map.get(node.parentId); + if (parent && node.id !== rootDeptId) { + parent.children.push(node); + } else { + roots.push(node); + } + } + return roots; + } +``` + +- [ ] **Step 3: Build check** + +```bash +cd apps/server && npx tsc --noEmit -p tsconfig.build.json +``` + +Expected: no new errors (existing pre-existing errors may remain). + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/src/integration/dingtalk.service.ts +git commit -m "feat: add fetchOrgTreeWithUsers to DingTalkService" +``` + +--- + +### Task 2: Backend — SyncService new methods + +**Files:** +- Modify: `apps/server/src/sync/sync.service.ts` +- Modify: `apps/server/src/sync/sync.module.ts` + +**Interfaces:** +- Consumes: `DingOrgTreeNodeWithUsers` from Task 1 +- Produces: `getDingTalkOrgTreeWithUsers(rootDeptId?: number): Promise`, `importDingTalkUsers(users: ImportUserDto[]): Promise<{ teacherCount: number; studentCount: number; skipped: number }>` + +- [ ] **Step 1: Add ImportUserDto and inject new repos** + +In `sync.service.ts`, after existing imports, add: + +```typescript +import { User } from '../entities/user.entity'; +import { Student } from '../entities/student.entity'; +import { Role } from '../entities/role.entity'; +import * as bcrypt from 'bcryptjs'; +``` + +Add to constructor injection (after existing `mappingRepo`): + +```typescript + @InjectRepository(User) + private readonly userRepo: Repository, + @InjectRepository(Student) + private readonly studentRepo: Repository, + @InjectRepository(Role) + private readonly roleRepo: Repository, +``` + +Add DTO interface at top of file (before class): + +```typescript +export interface ImportUserDto { + dingUserId: string; + name: string; + mobile: string; + roleId: number | null; +} +``` + +- [ ] **Step 2: Add getDingTalkOrgTreeWithUsers method** + +After existing `getDingTalkOrgTree` method (line ~93), add: + +```typescript + /** 获取钉钉组织部门树(含用户),供前端同步用户选择器使用 */ + async getDingTalkOrgTreeWithUsers(rootDeptId = 1) { + return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId); + } +``` + +- [ ] **Step 3: Add importDingTalkUsers method** + +After the new `getDingTalkOrgTreeWithUsers`, add: + +```typescript + /** + * 从钉钉导入用户:roleId 非 null → 老师(User + 指定角色),roleId null → 学生(User + Student)。 + * 已存在 UserDingMapping 的记录跳过。 + */ + async importDingTalkUsers(users: ImportUserDto[]): Promise<{ + teacherCount: number; + studentCount: number; + skipped: number; + }> { + let teacherCount = 0; + let studentCount = 0; + let skipped = 0; + + for (const u of users) { + // 检查是否已存在映射 + const existing = await this.mappingRepo.findOne({ + where: { dingUserId: u.dingUserId }, + }); + if (existing) { + skipped++; + continue; + } + + try { + const username = u.mobile || `dd_${u.dingUserId}`; + const passwordHash = await bcrypt.hash('123456', 10); + + const user = this.userRepo.create({ + username, + name: u.name, + passwordHash, + isActive: true, + }); + await this.userRepo.save(user); + + if (u.roleId != null) { + // 老师:分配角色 + const role = await this.roleRepo.findOne({ where: { id: u.roleId } }); + if (role) { + user.roles = [role]; + await this.userRepo.save(user); + } else { + this.logger.warn(`角色 id=${u.roleId} 不存在,用户 ${u.name} 未分配角色`); + } + teacherCount++; + } else { + // 学生:创建 Student 记录 + const student = this.studentRepo.create({ + name: u.name, + phone: u.mobile || undefined, + userId: user.id, + status: 'active', + }); + await this.studentRepo.save(student); + studentCount++; + } + + // 创建映射 + const mapping = this.mappingRepo.create({ + dingUserId: u.dingUserId, + userId: user.id, + dingName: u.name, + dingMobile: u.mobile, + }); + await this.mappingRepo.save(mapping); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + this.logger.error(`导入用户 ${u.name}(${u.dingUserId}) 失败: ${msg}`); + } + } + + this.logger.log( + `钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${skipped} 跳过`, + ); + return { teacherCount, studentCount, skipped }; + } +``` + +- [ ] **Step 4: Update sync.module.ts — add User, Student, Role entities** + +In `TypeOrmModule.forFeature([...])` array, add `User`, `Student`, `Role` to the imports. Also add the import for them at the top: + +```typescript +import { + SyncLog, + SyncState, + UserDingMapping, + ClassSchedule, + Department, + UserDepartment, + ClassTeacher, + User, + Student, + Role, +} from '../entities'; +``` + +And in the `forFeature` array after `ClassTeacher`: + +```typescript + User, + Student, + Role, +``` + +- [ ] **Step 5: Build check** + +```bash +cd apps/server && npx tsc --noEmit -p tsconfig.build.json +``` + +Expected: no new errors from modified files. + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/sync/sync.service.ts apps/server/src/sync/sync.module.ts +git commit -m "feat: add importDingTalkUsers and org-tree-with-users to SyncService" +``` + +--- + +### Task 3: Backend — SyncController new endpoints + +**Files:** +- Modify: `apps/server/src/sync/sync.controller.ts` + +**Interfaces:** +- Consumes: `getDingTalkOrgTreeWithUsers`, `importDingTalkUsers` from Task 2 + +- [ ] **Step 1: Add org-tree-with-users endpoint** + +After `getDingTalkOrgTree` endpoint (line ~41), add: + +```typescript + /** 获取钉钉组织部门树(含用户),供同步用户选择器使用 */ + @Get('dingtalk/org-tree-with-users') + @RequirePermission('sync:read') + async getDingTalkOrgTreeWithUsers(@Query('rootDeptId') rootDeptId?: string) { + const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1; + const tree = await this.syncService.getDingTalkOrgTreeWithUsers(rootId); + return { success: true, data: tree }; + } +``` + +- [ ] **Step 2: Add import-users endpoint** + +After the new endpoint above, add: + +```typescript + /** 导入钉钉用户:老师分配角色,学生创建 Student */ + @Post('dingtalk/import-users') + @RequirePermission('sync:trigger') + async importDingTalkUsers(@Body() body: { users: Array<{ dingUserId: string; name: string; mobile: string; roleId: number | null }> }) { + const result = await this.syncService.importDingTalkUsers(body.users); + return { success: true, ...result }; + } +``` + +Add `Body` to the imports from `@nestjs/common` at the top if not already present (check line 1 — `BadRequestException` is there, add `Body`): + +```typescript +import { BadRequestException, Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; +``` + +- [ ] **Step 3: Build check** + +```bash +cd apps/server && npx tsc --noEmit -p tsconfig.build.json +``` + +Expected: no new errors. + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/src/sync/sync.controller.ts +git commit -m "feat: add org-tree-with-users and import-users endpoints" +``` + +--- + +### Task 4: Frontend — Users page cleanup + +**Files:** +- Modify: `apps/admin/src/pages/Users/index.tsx` + +- [ ] **Step 1: Remove imports** + +Remove from import line (line 1): `useCallback` (if only used by sync), `CloudDownloadOutlined` (line 15), `TreeSelect` (from antd imports). + +Check: `useCallback` is also used by `fetchData` (line 81), so keep it. Only remove `CloudDownloadOutlined` and `TreeSelect` from imports. + +Change antd import line (line 2-14): +- Remove `TreeSelect` from the destructured import list. + +Change icons import line 15: +- Remove `CloudDownloadOutlined` from the import. + +- [ ] **Step 2: Remove sync-related state and functions** + +Remove these state declarations (lines ~33, 37-38): +- `const [syncing, setSyncing] = useState(false);` +- `const [syncDeptId, setSyncDeptId] = useState(undefined);` +- `const [orgTree, setOrgTree] = useState<...>([]);` + +Remove these functions: +- `loadOrgTree` (lines ~40-53) +- `handleSyncDingTalk` (lines ~96-113) +- `handleMarkStaff` (lines ~187-196) + +- [ ] **Step 3: Remove sync button and TreeSelect from JSX** + +In the toolbar `` (lines ~332-368): +- Remove the `TreeSelect` block (lines ~333-342) +- Remove the `PermissionButton` with `permission="sync:trigger"` (lines ~351-358) + +- [ ] **Step 4: Remove mark-staff/mark-student buttons from columns** + +In the `columns` useMemo (lines ~298-307), remove the two conditional blocks: +- Remove lines ~298-302: `record.studentStatus === 'active'` → "标记教职工" button +- Remove lines ~303-307: `record.studentStatus === 'staff'` → "恢复学员" button + +Adjust the `width` of the 操作 column from `320` to `240` since we're removing two buttons. + +- [ ] **Step 5: Build check** + +```bash +cd apps/admin && npx tsc --noEmit +``` + +Expected: no new errors. + +- [ ] **Step 6: Commit** + +```bash +git add apps/admin/src/pages/Users/index.tsx +git commit -m "refactor: remove sync and mark-staff buttons from Users page" +``` + +--- + +### Task 5: Frontend — IntegrationConfig sync users Tab + Drawer + +**Files:** +- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx` + +**Interfaces:** +- Consumes: `GET /sync/dingtalk/org-tree-with-users`, `POST /sync/dingtalk/import-users`, `GET /rbac/roles` +- Produces: SyncUsersTab component with org tree Drawer, teacher selection, role assignment + +- [ ] **Step 1: Add new imports** + +Add to existing antd imports: `Tabs`, `Drawer`, `Tree`, `Checkbox`, `Select`, `TreeSelect`. + +Add icons: `SyncOutlined`, `ReloadOutlined`. + +Current import block (lines 1-8): + +```typescript +import React, { useEffect, useState } from 'react'; +import { + Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag, +} from 'antd'; +import { + SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, +} from '@ant-design/icons'; +import api from '../../api'; +``` + +Replace with: + +```typescript +import React, { useEffect, useState, useMemo } from 'react'; +import { + Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag, + Tabs, Drawer, Tree, Checkbox, Select, TreeSelect, +} from 'antd'; +import { + SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, + SyncOutlined, ReloadOutlined, +} from '@ant-design/icons'; +import type { DataNode } from 'antd/es/tree'; +import api from '../../api'; +``` + +- [ ] **Step 2: Add types and state for sync tab** + +After the existing `IntegrationConfigPage` component declaration, add new state: + +```typescript + // ── Sync Users Tab ── + const [syncRootDeptId, setSyncRootDeptId] = useState(undefined); + const [orgTree, setOrgTree] = useState([]); + const [drawerOpen, setDrawerOpen] = useState(false); + const [fetchingTree, setFetchingTree] = useState(false); + const [importing, setImporting] = useState(false); + const [roles, setRoles] = useState>([]); + const [defaultTeacherRoleId, setDefaultTeacherRoleId] = useState(null); + // Department tree for the picker (no users) + const [deptPickerTree, setDeptPickerTree] = useState }>>([]); +``` + +Add the extended tree node type before the component: + +```typescript +interface DingOrgTreeNodeExt { + id: number; + name: string; + parentId: number; + children: DingOrgTreeNodeExt[]; + users: Array<{ userid: string; name: string; mobile: string }>; +} +``` + +- [ ] **Step 3: Add fetch roles and fetch org tree handlers** + +```typescript + const fetchRoles = async () => { + try { + const res: any = await api.get('/rbac/roles'); + const activeRoles = res.filter((r: any) => r.status !== 0); + setRoles(activeRoles); + const teacherRole = activeRoles.find((r: any) => r.name === '班主任'); + setDefaultTeacherRoleId(teacherRole?.id || activeRoles[0]?.id || null); + } catch { + // ignore — roles will be empty + } + }; + + const loadDeptTree = async () => { + try { + const res: any = await api.get('/sync/dingtalk/org-tree'); + if (res.success && res.data) { + const toTreeNode = (nodes: any[]): any[] => + nodes.map((n: any) => ({ + title: n.name, + value: n.id, + children: n.children ? toTreeNode(n.children) : undefined, + })); + setDeptPickerTree(toTreeNode(res.data)); + } + } catch { + // ignore + } + }; + + const handleFetchOrgTree = async () => { + setFetchingTree(true); + try { + const params: Record = {}; + if (syncRootDeptId) params.rootDeptId = String(syncRootDeptId); + const res: any = await api.get('/sync/dingtalk/org-tree-with-users', { params }); + if (res.success && res.data) { + setOrgTree(res.data); + setTeacherChecks({}); + setTeacherRoles({}); + setDrawerOpen(true); + } else { + message.error('获取组织架构失败'); + } + } catch (e: any) { + message.error(e?.message || '获取组织架构失败'); + } finally { + setFetchingTree(false); + } + }; +``` + +- [ ] **Step 4: Add import handler** + +```typescript + const handleImportUsers = async () => { + setImporting(true); + try { + // Flatten all users from tree + const allUsers: Array<{ + dingUserId: string; + name: string; + mobile: string; + }> = []; + + const flatten = (nodes: DingOrgTreeNodeExt[]) => { + for (const node of nodes) { + allUsers.push(...node.users); + flatten(node.children); + } + }; + flatten(orgTree); + + const payload = { + users: allUsers.map((u) => ({ + dingUserId: u.userid, + name: u.name, + mobile: u.mobile, + roleId: teacherChecks[u.userid] + ? (teacherRoles[u.userid] || defaultTeacherRoleId) + : null, + })), + }; + + const res: any = await api.post('/sync/dingtalk/import-users', payload); + message.success( + `导入完成:${res.teacherCount} 位老师,${res.studentCount} 位学生` + + (res.skipped > 0 ? `,${res.skipped} 已跳过` : ''), + ); + setDrawerOpen(false); + } catch (e: any) { + message.error(e?.message || '导入失败'); + } finally { + setImporting(false); + } + }; +``` + +- [ ] **Step 5: Build tree data for Drawer** + +```typescript + const buildTreeData = (nodes: DingOrgTreeNodeExt[]): DataNode[] => { + return nodes.map((node) => ({ + title: node.name, + key: `dept-${node.id}`, + children: [ + // Sub-departments + ...buildTreeData(node.children), + // Users in this department + ...node.users.map((u) => ({ + title: ( + + { + setTeacherChecks((prev) => ({ + ...prev, + [u.userid]: e.target.checked, + })); + if (!e.target.checked) { + setTeacherRoles((prev) => { + const next = { ...prev }; + delete next[u.userid]; + return next; + }); + } + }} + > + 老师 + + {u.name} + {u.mobile && ( + {u.mobile} + )} + {teacherChecks[u.userid] && ( + + + + + + + + + + + + + + + + + + ), + }, + ...syncTabItems, + ]; + + return ( + + + + ); +``` + +- [ ] **Step 8: Add fetchRoles to useEffect** + +In the existing `useEffect` (line ~42), add `fetchRoles()` call: + +```typescript + useEffect(() => { + fetchConfig(); + fetchRoles(); + }, []); +``` + +- [ ] **Step 9: Build check** + +```bash +cd apps/admin && npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 10: E2E smoke test** + +Start the dev server and verify: +1. Navigate to IntegrationConfig page +2. "同步用户" Tab only visible when DingTalk is configured +3. Click "获取组织架构" → Drawer opens with department tree +4. Check users as teachers → role select appears (defaults to 班主任) +5. Click "导入" → success message with counts + +```bash +cd apps/admin && npx vite --port 5173 & +cd apps/server && npm run start:dev & +``` + +- [ ] **Step 11: Commit** + +```bash +git add apps/admin/src/pages/IntegrationConfig/index.tsx +git commit -m "feat: add sync users Tab with Drawer tree to IntegrationConfig" +``` + +--- + +### Task 6: Final verification & cleanup + +- [ ] **Step 1: Run full type check** + +```bash +cd apps/server && npx tsc --noEmit -p tsconfig.build.json +cd apps/admin && npx tsc --noEmit +``` + +- [ ] **Step 2: Verify Users page no longer shows sync/mark buttons** + +Smoke test Users page — confirm no "同步钉钉用户" button, no TreeSelect, and no "标记教职工"/"恢复学员" in the actions column. + +- [ ] **Step 3: Verify IntegrationConfig sync flow end-to-end** + +Run through the full flow: +1. Config page → Sync Users tab +2. Fetch org tree → Drawer shows tree +3. Check teachers → role dropdown works +4. Import → correct counts returned +5. Verify in DB: teachers have roles, students have Student records + +- [ ] **Step 4: Commit any remaining changes** + +```bash +git add -A +git commit -m "chore: final verification and cleanup for dingtalk sync role selection" +```