diff --git a/docs/superpowers/specs/2026-07-09-dingtalk-sync-role-selection-design.md b/docs/superpowers/specs/2026-07-09-dingtalk-sync-role-selection-design.md new file mode 100644 index 0000000..dd89dad --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-dingtalk-sync-role-selection-design.md @@ -0,0 +1,201 @@ +# 钉钉同步 — 用户角色选择设计 + +> 2026-07-09 | 状态:待实现 + +## 问题 + +当前 `DingTalkService.syncOneUser()` 将每个从钉钉同步过来的新用户**无条件创建为 Student**。钉钉组织里教职工和学生混在一起,导致每次同步后管理员需要手动去用户管理页面逐人修正角色(「标记为教职工」「标记为学员」按钮)。 + +## 方案 + +将同步入口从 Users 页面移到 IntegrationConfig 页面,新增「同步用户」Tab。同步时前端拉取钉钉组织树(含用户),在 Drawer 内勾选「谁是老师」,导入时后端据此分别创建: + +- **老师**(勾选):User + 指定角色,不建 Student +- **学生**(未勾选):User + Student,不分配角色 + +--- + +## 后端 + +### 1. 新接口:`GET /api/sync/dingtalk/org-tree-with-users` + +权限:`sync:read` + +Query: `rootDeptId` (可选,默认 1) + +返回:部门树,每个节点含 `users` 数组。 + +```json +{ + "success": true, + "data": [ + { + "id": 1, + "name": "恭学教育", + "parentId": 0, + "children": [ + { + "id": 10, + "name": "教务处", + "parentId": 1, + "children": [], + "users": [ + { "userid": "abc123", "name": "张老师", "mobile": "138..." } + ] + } + ], + "users": [] + } + ] +} +``` + +实现层级: +- `DingTalkService` 新增 `fetchOrgTreeWithUsers(rootDeptId)` — BFS 拉部门 + 按部门拉用户,组装树 +- `SyncService` 新增 `getDingTalkOrgTreeWithUsers(rootDeptId)` — 透传 +- `SyncController` 新增 `@Get('dingtalk/org-tree-with-users')` + +### 2. 新接口:`POST /api/sync/dingtalk/import-users` + +权限:`sync:trigger` + +Body: + +```json +{ + "users": [ + { "dingUserId": "abc123", "name": "张老师", "mobile": "138...", "roleId": 2 }, + { "dingUserId": "def456", "name": "李同学", "mobile": "139...", "roleId": null } + ] +} +``` + +- `roleId: number` → 老师,创建 User + 分配角色,不创建 Student +- `roleId: null` → 学生,创建 User + Student(status=active),不分配角色 +> `roleId: null`(前端传 null, 不是 undefined)表示学生。两端统一用 `null` 作为"不是老师"的标记值。 +- users 数组为空时 → 返回 `{ teacherCount: 0, studentCount: 0, skipped: 0 }`,不报错 + +- 已存在 UserDingMapping → 跳过,计入 skipped + +返回: + +```json +{ "teacherCount": 3, "studentCount": 25, "skipped": 2 } +``` + +实现层级: +- `SyncService` 新增 `importDingTalkUsers(users)` — 遍历、去重、创建 User/Student/UserDingMapping +- `SyncController` 新增 `@Post('dingtalk/import-users')` + +### 3. 旧逻辑不删 + +`syncAll()` / `syncOneUser()` 保持不变,保持向后兼容。只是前端入口不再走这个路径。 + +--- + +## 前端 + +### 文件:`apps/admin/src/pages/IntegrationConfig/index.tsx` + +页面加 Tabs,拆两个标签: + +| Tab | key | 内容 | +|-----|-----|------| +| 钉钉配置 | `config` | 现有表单(不变) | +| 同步用户 | `sync-users` | 新增(见下) | + +**钉钉未配置时**:「同步用户」Tab 不显示(`config === null`)。 + +**「同步用户」Tab 结构:** + +``` +Card + ├─ TreeSelect (选起始部门,默认全部) + ├─ Button 「获取组织架构」 + ├─ Drawer (open=hasTree) + │ ├─ Tree 组件 + │ │ ├─ 部门节点 (可展开) + │ │ └─ 用户节点 (叶子,不可展开) + │ │ ├─ 姓名 + 手机号 + │ │ ├─ Checkbox 「老师」(默认不勾) + │ │ └─ 勾选后:Select 角色 (默认「班主任」) + │ └─ Footer + │ ├─ Button 「取消」 + │ └─ Button 「导入」(type=primary, loading) + └─ result message (导入后显示) +``` + +**状态管理:** + +```typescript +// 新增 state +const [orgTree, setOrgTree] = useState([]); +const [drawerOpen, setDrawerOpen] = useState(false); +const [fetchingTree, setFetchingTree] = useState(false); +const [importing, setImporting] = useState(false); +const [teacherChecks, setTeacherChecks] = useState>({}); +const [teacherRoles, setTeacherRoles] = useState>({}); +``` + + +**默认角色解析:** + +> `defaultTeacherRoleId` 在组件挂载时从 `/rbac/roles` 加载所有角色后,查找 `name === '班主任'` 的 id 作为默认值。若角色列表里没有「班主任」,取第一个非系统角色的 id。 + +### 文件:`apps/admin/src/pages/Users/index.tsx` + +删除以下内容: +- 「同步钉钉用户」按钮 (PermissionButton, permission `sync:trigger`) +- 同步部门 TreeSelect +- 「标记为教职工」「标记为学员」操作按钮 +- 相关 state:`syncing`、`syncDeptId`、`orgTree` +- 相关函数:`loadOrgTree`、`handleSyncDingTalk`、`handleMarkStaff` +- 未使用的 import:`CloudDownloadOutlined`、`TreeSelect` + +--- + +## 数据流 + +``` +[同步用户 Tab] + │ + ├─ 选部门 → 点「获取组织架构」 + ├─ GET /sync/dingtalk/org-tree-with-users?rootDeptId=X + │ DingTalkService.fetchOrgTreeWithUsers() + │ 按部门拉用户 → 去重(dingUserId) → 组装树 + │ + ├─ Drawer 展示树,勾选老师+选角色 + ├─ 点「导入」 + ├─ POST /sync/dingtalk/import-users + │ { users: [{ dingUserId, name, mobile, roleId|null }] } + │ │ + │ ├─ 查 UserDingMapping → 已存在则跳过 + │ ├─ roleId != null: User + role assignment + │ └─ roleId == null: User + Student(status=active) + │ └─ UserDingMapping(dingUserId, userId, dingName, dingMobile) + │ + └─ 结果: { teacherCount, studentCount, skipped } +``` + +## 边界情况 + +| 场景 | 处理 | +|------|------| +| 钉钉未配置 | 不显示「同步用户」Tab | +| 已存在 mapping | 跳过,计入 skipped | +| 手机号为空 | username = `dd_` | +| 同用户属多部门 | 去重,仅在首个部门展示 | +| 角色下拉选项 | 从 `/rbac/roles` 获取所有非禁用角色 | +| 导入单个失败 | 独立 try/catch,log 错误但不阻断其余 | + +## 涉及文件 + +### 后端 (4 个文件修改) +- `apps/server/src/integration/dingtalk.service.ts` — 新增 `fetchOrgTreeWithUsers` +- `apps/server/src/sync/sync.service.ts` — 新增 `getDingTalkOrgTreeWithUsers`、`importDingTalkUsers` +- `apps/server/src/sync/sync.controller.ts` — 新增 2 个 endpoint +- `apps/server/src/sync/sync.module.ts` — 注入 User/Student/Role Repository(如需) + +### 前端 (2 个文件修改) +- `apps/admin/src/pages/IntegrationConfig/index.tsx` — 加 Tabs + Drawer +- `apps/admin/src/pages/Users/index.tsx` — 删同步/标记按钮及相关代码