# 钉钉同步 — 用户角色选择设计

> 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<TreeNode[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchingTree, setFetchingTree] = useState(false);
const [importing, setImporting] = useState(false);
const [teacherChecks, setTeacherChecks] = useState<Record<string, boolean>>({});
const [teacherRoles, setTeacherRoles] = useState<Record<string, number>>({});
```


**默认角色解析：**

> `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_<dingUserId>` |
| 同用户属多部门 | 去重，仅在首个部门展示 |
| 角色下拉选项 | 从 `/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` — 删同步/标记按钮及相关代码
