2025-12-19 00:12:12 +08:00

403 lines
13 KiB
TypeScript
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.

import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { user } from "@/lib/db/schema";
import { eq, isNotNull, isNull, or, sql } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
// 获取钉钉配置
async function getDingtalkConfig() {
const settings = await db.query.authSettings.findFirst();
if (!settings?.isDingTalkEnabled || !settings.dingTalkAppKey || !settings.dingTalkAppSecret) {
throw new Error("钉钉配置未启用或配置不完整");
}
return {
appKey: settings.dingTalkAppKey,
appSecret: settings.dingTalkAppSecret,
};
}
// 获取钉钉 access_token
async function getDingtalkAccessToken(appKey: string, appSecret: string) {
const tokenResponse = await fetch(
`https://oapi.dingtalk.com/gettoken?appkey=${appKey}&appsecret=${appSecret}`
);
const tokenData = await tokenResponse.json();
if (tokenData.errcode !== 0) {
throw new Error(`获取钉钉token失败: ${tokenData.errmsg}`);
}
return tokenData.access_token;
}
// 获取部门用户列表(使用新版 v2 API,带限流重试)
async function getDeptUsers(accessToken: string, deptId: number, cursor: number = 0, retryCount: number = 0): Promise<any[]> {
const response = await fetch(
`https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${accessToken}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dept_id: deptId,
cursor: cursor,
size: 100,
}),
}
);
const data = await response.json();
// 如果是限流错误,等待后重试(最多重试3次)
if (data.errcode === 90002 && retryCount < 3) {
console.log(`[Sync DingTalk] 部门 ${deptId} API限流,等待2秒后重试 (${retryCount + 1}/3)...`);
await new Promise(resolve => setTimeout(resolve, 2000));
return getDeptUsers(accessToken, deptId, cursor, retryCount + 1);
}
if (data.errcode !== 0) {
console.error(`获取部门 ${deptId} 用户列表失败:`, data.errmsg);
return [];
}
const users = data.result?.list || [];
// 如果还有更多数据,递归获取
if (data.result?.has_more && data.result?.next_cursor) {
await new Promise(resolve => setTimeout(resolve, 300)); // 获取下一页前延迟
const moreUsers = await getDeptUsers(accessToken, deptId, data.result.next_cursor);
return [...users, ...moreUsers];
}
return users;
}
// 获取所有子部门ID(使用队列方式避免递归导致的大量并发请求)
async function getAllDeptIds(accessToken: string, rootId: number = 1): Promise<number[]> {
const allDeptIds: number[] = [rootId];
const queue: number[] = [rootId];
while (queue.length > 0) {
const parentId = queue.shift()!;
// 添加延迟避免限流(增加到150ms)
await new Promise(resolve => setTimeout(resolve, 150));
let retryCount = 0;
let data: any = null;
while (retryCount < 3) {
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 }),
}
);
data = await response.json();
// 如果是限流错误,等待后重试
if (data.errcode === 90002) {
console.log(`[Sync DingTalk] 获取子部门API限流,等待2秒后重试...`);
await new Promise(resolve => setTimeout(resolve, 2000));
retryCount++;
continue;
}
break;
}
if (!data || data.errcode !== 0) {
console.error(`获取子部门列表失败:`, data?.errmsg);
continue;
}
const subDepts = data.result || [];
for (const dept of subDepts) {
allDeptIds.push(dept.dept_id);
queue.push(dept.dept_id);
}
}
return allDeptIds;
}
// 获取用户详细信息(包括部门)
async function getUserDetailFromDingtalk(accessToken: string, userId: string) {
const response = await fetch(
`https://oapi.dingtalk.com/topapi/v2/user/get?access_token=${accessToken}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userid: userId }),
}
);
const data = await response.json();
if (data.errcode !== 0) {
console.error(`获取用户 ${userId} 信息失败:`, data.errmsg);
return null;
}
return data.result;
}
// 获取部门名称
async function getDepartmentName(accessToken: string, deptId: number) {
const response = await fetch(
`https://oapi.dingtalk.com/topapi/v2/department/get?access_token=${accessToken}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dept_id: deptId }),
}
);
const data = await response.json();
if (data.errcode !== 0) {
console.error(`获取部门 ${deptId} 信息失败:`, data.errmsg);
return null;
}
return data.result?.name;
}
// POST: 批量同步钉钉用户信息
export async function POST() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 检查是否为管理员
const currentUser = await db.query.user.findFirst({
where: eq(user.id, session.user.id),
});
if (currentUser?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// 获取钉钉配置
const config = await getDingtalkConfig();
const accessToken = await getDingtalkAccessToken(config.appKey, config.appSecret);
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 dingtalkUsersByEmail = new Map<string, any>(); // email -> user info
const dingtalkUsersByName = new Map<string, any>(); // name -> user info
let totalDtUsers = 0;
let usersWithEmail = 0;
for (const deptId of allDeptIds) {
const deptUsers = await getDeptUsers(accessToken, deptId);
// 调试:打印第一个部门的第一个用户结构
if (totalDtUsers === 0 && deptUsers.length > 0) {
console.log(`[Sync DingTalk] Sample user data structure:`, JSON.stringify(deptUsers[0], null, 2));
}
for (const dtUser of deptUsers) {
totalDtUsers++;
// 使用邮箱作为匹配键
if (dtUser.email) {
usersWithEmail++;
dingtalkUsersByEmail.set(dtUser.email.toLowerCase(), dtUser);
}
// 也尝试使用 org_email
if (dtUser.org_email) {
dingtalkUsersByEmail.set(dtUser.org_email.toLowerCase(), dtUser);
}
// 使用姓名作为备选匹配键
if (dtUser.name) {
dingtalkUsersByName.set(dtUser.name, dtUser);
}
}
// 添加延迟避免触发钉钉API限流(增加到300ms)
await new Promise(resolve => setTimeout(resolve, 300));
}
console.log(`[Sync DingTalk] Found ${totalDtUsers} total DingTalk users`);
console.log(`[Sync DingTalk] Found ${usersWithEmail} users with email`);
console.log(`[Sync DingTalk] Found ${dingtalkUsersByName.size} unique names`);
// 获取我们数据库中的所有用户
const dbUsers = await db
.select({
id: user.id,
email: user.email,
name: user.name,
dingtalkUserId: user.dingtalkUserId,
department: user.department,
})
.from(user);
console.log(`[Sync DingTalk] Found ${dbUsers.length} users in database`);
let syncedCount = 0;
let skippedCount = 0;
let errorCount = 0;
let matchedCount = 0;
const results: Array<{ email: string; status: string; department?: string; dingtalkUserId?: string }> = [];
// 遍历数据库用户,匹配钉钉用户
for (const dbUser of dbUsers) {
const emailLower = dbUser.email.toLowerCase();
// 优先通过邮箱匹配
let dtUser = dingtalkUsersByEmail.get(emailLower);
// 如果邮箱匹配不到,尝试通过姓名匹配
if (!dtUser && dbUser.name) {
dtUser = dingtalkUsersByName.get(dbUser.name);
}
if (!dtUser) {
// 没有找到匹配的钉钉用户
skippedCount++;
continue;
}
matchedCount++;
try {
// 获取部门名称列表
const deptIdList = dtUser.dept_id_list || [];
const departmentNames: string[] = [];
for (const deptId of deptIdList) {
const deptName = await getDepartmentName(accessToken, deptId);
if (deptName) {
departmentNames.push(deptName);
}
}
const departmentStr = departmentNames.length > 0 ? departmentNames.join(" / ") : null;
const dingtalkUserId = dtUser.userid;
// 检查是否需要更新
const needUpdate =
departmentStr !== dbUser.department ||
dingtalkUserId !== dbUser.dingtalkUserId ||
(!dbUser.name && dtUser.name);
if (needUpdate) {
const updateData: Record<string, string | null> = {};
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: dbUser.email, status: "already_synced" });
}
// 添加延迟避免触发钉钉API限流
await new Promise(resolve => setTimeout(resolve, 30));
} catch (error) {
console.error(`[Sync DingTalk] Error syncing user ${dbUser.email}:`, error);
errorCount++;
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: dbUsers.length,
dingtalkUsers: totalDtUsers,
dingtalkUsersWithEmail: usersWithEmail,
matched: matchedCount,
synced: syncedCount,
skipped: skippedCount,
errors: errorCount,
},
results: results.slice(0, 50), // 只返回前50条结果
});
} catch (error) {
console.error("[Sync DingTalk API] Error:", error);
return NextResponse.json(
{ error: (error as Error).message || "同步失败" },
{ status: 500 }
);
}
}
// GET: 获取同步状态统计
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 检查是否为管理员
const currentUser = await db.query.user.findFirst({
where: eq(user.id, session.user.id),
});
if (currentUser?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// 统计用户数据
const allUsers = await db
.select({
id: user.id,
dingtalkUserId: user.dingtalkUserId,
department: user.department,
})
.from(user);
const stats = {
total: allUsers.length,
withDingtalkId: allUsers.filter(u => u.dingtalkUserId).length,
withDepartment: allUsers.filter(u => u.department).length,
needSync: allUsers.filter(u => !u.department).length, // 没有部门信息的用户都需要同步
};
return NextResponse.json({ stats });
} catch (error) {
console.error("[Sync DingTalk API] Error:", error);
return NextResponse.json({ error: "获取统计失败" }, { status: 500 });
}
}