403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
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 });
|
||
}
|
||
}
|
||
|