From 12a383ccebebe8bcbaac7af07d2a577163d00756 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 18 Dec 2025 15:19:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=92=89=E9=92=89=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/admin-settings-client.tsx | 8 +- app/api/admin/sync-dingtalk/route.ts | 196 ++++++++++++++---- 2 files changed, 161 insertions(+), 43 deletions(-) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 9388edd2c..23c221202 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -914,11 +914,15 @@ export function AdminSettingsClient() { size="sm" onClick={async () => { try { - toast.info("正在同步钉钉用户部门信息..."); + toast.info("正在从钉钉同步用户信息,可能需要几分钟..."); const res = await fetch("/api/admin/sync-dingtalk", { method: "POST" }); const data = await res.json(); if (res.ok) { - toast.success(`同步完成:${data.stats.synced} 个用户已更新,${data.stats.skipped} 个跳过,${data.stats.errors} 个失败`); + toast.success( + `同步完成!匹配 ${data.stats.matched}/${data.stats.total} 个用户,` + + `更新 ${data.stats.synced} 个,跳过 ${data.stats.skipped} 个` + + (data.stats.errors > 0 ? `,失败 ${data.stats.errors} 个` : "") + ); mutateUsers(); // 刷新用户列表 } else { toast.error(data.error || "同步失败"); diff --git a/app/api/admin/sync-dingtalk/route.ts b/app/api/admin/sync-dingtalk/route.ts index 41753582d..d5af9e177 100644 --- a/app/api/admin/sync-dingtalk/route.ts +++ b/app/api/admin/sync-dingtalk/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { db } from "@/lib/db"; import { user } from "@/lib/db/schema"; -import { eq, isNotNull, isNull, or } from "drizzle-orm"; +import { eq, isNotNull, isNull, or, sql } from "drizzle-orm"; import { auth } from "@/app/(auth)/auth"; // 获取钉钉配置 @@ -30,6 +30,67 @@ async function getDingtalkAccessToken(appKey: string, appSecret: string) { return tokenData.access_token; } +// 获取部门用户列表(分页) +async function getDeptUsers(accessToken: string, deptId: number, cursor: number = 0): Promise { + const response = await fetch( + `https://oapi.dingtalk.com/topapi/user/listbypage?access_token=${accessToken}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + dept_id: deptId, + cursor: cursor, + size: 100, + order_field: "modify_desc" + }), + } + ); + const data = await response.json(); + + if (data.errcode !== 0) { + console.error(`获取部门 ${deptId} 用户列表失败:`, data.errmsg); + return []; + } + + const users = data.result?.list || []; + + // 如果还有更多数据,递归获取 + if (data.result?.has_more && data.result?.next_cursor) { + const moreUsers = await getDeptUsers(accessToken, deptId, data.result.next_cursor); + return [...users, ...moreUsers]; + } + + return users; +} + +// 获取所有子部门ID +async function getAllDeptIds(accessToken: string, parentId: number = 1): Promise { + const response = await fetch( + `https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=${accessToken}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dept_id: parentId }), + } + ); + const data = await response.json(); + + if (data.errcode !== 0) { + console.error(`获取子部门列表失败:`, data.errmsg); + return [parentId]; + } + + const subDepts = data.result || []; + let allDeptIds = [parentId]; + + for (const dept of subDepts) { + const childDeptIds = await getAllDeptIds(accessToken, dept.dept_id); + allDeptIds = [...allDeptIds, ...childDeptIds]; + } + + return allDeptIds; +} + // 获取用户详细信息(包括部门) async function getUserDetailFromDingtalk(accessToken: string, userId: string) { const response = await fetch( @@ -92,45 +153,69 @@ export async function POST() { const config = await getDingtalkConfig(); const accessToken = await getDingtalkAccessToken(config.appKey, config.appSecret); - // 获取需要同步的用户(有钉钉userId但没有部门信息的用户) - const usersToSync = await db + console.log("[Sync DingTalk] Starting full sync from DingTalk..."); + + // 获取所有部门ID + console.log("[Sync DingTalk] Fetching all department IDs..."); + const allDeptIds = await getAllDeptIds(accessToken, 1); + console.log(`[Sync DingTalk] Found ${allDeptIds.length} departments`); + + // 获取所有钉钉用户(按部门获取) + const dingtalkUsersMap = new Map(); // email -> user info + + for (const deptId of allDeptIds) { + const deptUsers = await getDeptUsers(accessToken, deptId); + for (const dtUser of deptUsers) { + // 使用邮箱作为匹配键 + if (dtUser.email) { + dingtalkUsersMap.set(dtUser.email.toLowerCase(), dtUser); + } + // 也尝试使用 org_email + if (dtUser.org_email) { + dingtalkUsersMap.set(dtUser.org_email.toLowerCase(), dtUser); + } + } + // 添加延迟避免触发钉钉API限流 + await new Promise(resolve => setTimeout(resolve, 50)); + } + + console.log(`[Sync DingTalk] Found ${dingtalkUsersMap.size} DingTalk users with email`); + + // 获取我们数据库中的所有用户 + const dbUsers = await db .select({ id: user.id, email: user.email, + name: user.name, dingtalkUserId: user.dingtalkUserId, department: user.department, }) - .from(user) - .where( - // 有钉钉userId但没有部门信息 - isNotNull(user.dingtalkUserId) - ); + .from(user); - console.log(`[Sync DingTalk] Found ${usersToSync.length} users to check`); + console.log(`[Sync DingTalk] Found ${dbUsers.length} users in database`); let syncedCount = 0; let skippedCount = 0; let errorCount = 0; - const results: Array<{ email: string; status: string; department?: string }> = []; + let matchedCount = 0; + const results: Array<{ email: string; status: string; department?: string; dingtalkUserId?: string }> = []; - for (const u of usersToSync) { - if (!u.dingtalkUserId) { + // 遍历数据库用户,匹配钉钉用户 + for (const dbUser of dbUsers) { + const emailLower = dbUser.email.toLowerCase(); + const dtUser = dingtalkUsersMap.get(emailLower); + + if (!dtUser) { + // 没有找到匹配的钉钉用户 skippedCount++; continue; } - try { - // 获取用户详细信息 - const userDetail = await getUserDetailFromDingtalk(accessToken, u.dingtalkUserId); - - if (!userDetail) { - errorCount++; - results.push({ email: u.email, status: "failed", department: undefined }); - continue; - } + matchedCount++; + try { // 获取部门名称列表 - const deptIdList = userDetail.dept_id_list || []; + const deptIdList = dtUser.dept_id_list || []; const departmentNames: string[] = []; for (const deptId of deptIdList) { @@ -141,45 +226,74 @@ export async function POST() { } const departmentStr = departmentNames.length > 0 ? departmentNames.join(" / ") : null; + const dingtalkUserId = dtUser.userid; - // 只有部门信息变化时才更新 - if (departmentStr && departmentStr !== u.department) { - await db - .update(user) - .set({ - department: departmentStr, - name: userDetail.name || undefined, // 同时更新姓名 - }) - .where(eq(user.id, u.id)); + // 检查是否需要更新 + const needUpdate = + departmentStr !== dbUser.department || + dingtalkUserId !== dbUser.dingtalkUserId || + (!dbUser.name && dtUser.name); + + if (needUpdate) { + const updateData: Record = {}; - syncedCount++; - results.push({ email: u.email, status: "synced", department: departmentStr }); - console.log(`[Sync DingTalk] Updated user ${u.email}: ${departmentStr}`); + if (departmentStr && departmentStr !== dbUser.department) { + updateData.department = departmentStr; + } + if (dingtalkUserId && dingtalkUserId !== dbUser.dingtalkUserId) { + updateData.dingtalkUserId = dingtalkUserId; + } + if (!dbUser.name && dtUser.name) { + updateData.name = dtUser.name; + } + + if (Object.keys(updateData).length > 0) { + await db + .update(user) + .set(updateData) + .where(eq(user.id, dbUser.id)); + + syncedCount++; + results.push({ + email: dbUser.email, + status: "synced", + department: departmentStr || undefined, + dingtalkUserId: dingtalkUserId + }); + console.log(`[Sync DingTalk] Updated user ${dbUser.email}: dept=${departmentStr}, dtUserId=${dingtalkUserId}`); + } else { + skippedCount++; + results.push({ email: dbUser.email, status: "no_change" }); + } } else { skippedCount++; - results.push({ email: u.email, status: "skipped", department: u.department || undefined }); + results.push({ email: dbUser.email, status: "already_synced" }); } // 添加延迟避免触发钉钉API限流 - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise(resolve => setTimeout(resolve, 30)); } catch (error) { - console.error(`[Sync DingTalk] Error syncing user ${u.email}:`, error); + console.error(`[Sync DingTalk] Error syncing user ${dbUser.email}:`, error); errorCount++; - results.push({ email: u.email, status: "error" }); + results.push({ email: dbUser.email, status: "error" }); } } + console.log(`[Sync DingTalk] Sync completed. Synced: ${syncedCount}, Matched: ${matchedCount}, Skipped: ${skippedCount}, Errors: ${errorCount}`); + return NextResponse.json({ success: true, message: `同步完成`, stats: { - total: usersToSync.length, + total: dbUsers.length, + dingtalkUsers: dingtalkUsersMap.size, + matched: matchedCount, synced: syncedCount, skipped: skippedCount, errors: errorCount, }, - results, + results: results.slice(0, 50), // 只返回前50条结果 }); } catch (error) { console.error("[Sync DingTalk API] Error:", error); @@ -221,7 +335,7 @@ export async function GET() { total: allUsers.length, withDingtalkId: allUsers.filter(u => u.dingtalkUserId).length, withDepartment: allUsers.filter(u => u.department).length, - needSync: allUsers.filter(u => u.dingtalkUserId && !u.department).length, + needSync: allUsers.filter(u => !u.department).length, // 没有部门信息的用户都需要同步 }; return NextResponse.json({ stats });