387 lines
14 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 { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getAuthSettings, getUser } from "@/lib/db/queries";
import { loginToken, user } from "@/lib/db/schema";
import { generateUUID } from "@/lib/utils";
import { signIn } from "@/app/(auth)/auth";
import { eq } from "drizzle-orm";
import { createLogger } from "@/lib/observability/logger";
const logger = createLogger("dingtalk-callback");
const normalizeCallbackUrl = (value: unknown) => {
if (typeof value !== "string" || !value) return "/";
try {
const parsed = new URL(value, "https://local.invalid");
if (parsed.origin !== "https://local.invalid") return "/";
return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
} catch {
return "/";
}
};
const parseDingTalkState = (state: string | null) => {
if (!state) {
return { org: undefined as string | undefined, callbackUrl: "/" };
}
if (state.startsWith("v1.")) {
try {
const payload = JSON.parse(
Buffer.from(state.slice(3), "base64url").toString("utf8"),
) as { org?: unknown; callbackUrl?: unknown };
return {
org:
typeof payload.org === "string" && payload.org !== "default"
? payload.org
: undefined,
callbackUrl: normalizeCallbackUrl(payload.callbackUrl),
};
} catch (error) {
logger.warn("[DingTalk Callback] Failed to parse state payload:", error);
}
}
return {
org: state !== "default" ? state : undefined,
callbackUrl: "/",
};
};
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const dingTalkState = parseDingTalkState(state);
logger.debug("[DingTalk Callback] Received callback", {
hasCode: Boolean(code),
hasState: Boolean(state),
hasOrg: Boolean(dingTalkState.org),
});
if (!code) {
return NextResponse.json({ success: false, error: "Missing code" }, { status: 400 });
}
const settings = await getAuthSettings();
if (!settings.isDingTalkEnabled) {
return NextResponse.json(
{ success: false, error: "DingTalk login is not configured" },
{ status: 400 },
);
}
const organizations = settings.dingTalkOrganizations ?? [];
const selectedOrg = dingTalkState.org
? organizations.find((org) => org.name === dingTalkState.org)
: organizations[0];
const appKey = selectedOrg?.appKey ?? settings.dingTalkAppKey;
const appSecret = selectedOrg?.appSecret ?? settings.dingTalkAppSecret;
if (!appKey || !appSecret) {
return NextResponse.json(
{ success: false, error: "DingTalk appKey/appSecret is not configured" },
{ status: 400 },
);
}
try {
// 1. 获取 userAccessToken
logger.debug("[DingTalk Callback] Getting user access token...");
const tokenResponse = await fetch(
"https://api.dingtalk.com/v1.0/oauth2/userAccessToken",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: appKey,
clientSecret: appSecret,
code,
grantType: "authorization_code",
}),
},
);
const tokenData = await tokenResponse.json();
logger.debug("[DingTalk Callback] Token response received", {
ok: Boolean(tokenData.accessToken),
status: tokenResponse.status,
});
if (!tokenData.accessToken) {
logger.error("[DingTalk Callback] Token exchange failed", {
status: tokenResponse.status,
hasMessage: Boolean(tokenData.message),
});
return NextResponse.json({ success: false, error: "Failed to get access token: " + (tokenData.message || JSON.stringify(tokenData)) }, { status: 400 });
}
// 2. 获取用户 unionId
logger.debug("[DingTalk Callback] Getting user unionId...");
const userMeResponse = await fetch("https://api.dingtalk.com/v1.0/contact/users/me", {
headers: {
"x-acs-dingtalk-access-token": tokenData.accessToken,
},
});
const userMeData = await userMeResponse.json();
logger.debug("[DingTalk Callback] User identity response received", {
ok: Boolean(userMeData.unionId),
status: userMeResponse.status,
});
if (!userMeData.unionId) {
logger.error("[DingTalk Callback] User identity lookup failed", {
status: userMeResponse.status,
});
return NextResponse.json({ success: false, error: "Failed to get user unionId" }, { status: 400 });
}
// 3. 获取应用访问令牌 (用于获取更详细的用户信息)
logger.debug("[DingTalk Callback] Getting app access token...");
const appTokenResponse = await fetch(
`https://oapi.dingtalk.com/gettoken?appkey=${encodeURIComponent(
appKey,
)}&appsecret=${encodeURIComponent(appSecret)}`,
);
const appTokenData = await appTokenResponse.json();
if (!appTokenData.access_token) {
logger.error("[DingTalk Callback] App token lookup failed", {
status: appTokenResponse.status,
hasMessage: Boolean(appTokenData.message),
});
return NextResponse.json({ success: false, error: "Failed to get app access token" }, { status: 400 });
}
// 4. 根据 unionId 获取 userId
logger.debug("[DingTalk Callback] Getting userId by unionId...");
const userIdResponse = await fetch(`https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=${appTokenData.access_token}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ unionid: userMeData.unionId }),
});
const userIdData = await userIdResponse.json();
logger.debug("[DingTalk Callback] UserId response received", {
ok: Boolean(userIdData.result?.userid),
status: userIdResponse.status,
});
let userInfo: any = {
unionId: userMeData.unionId,
nick: userMeData.nick,
avatarUrl: userMeData.avatarUrl,
};
// 如果能获取到 userId,则获取更详细的用户信息
if (userIdData.result && userIdData.result.userid) {
logger.debug("[DingTalk Callback] Getting detailed user info...");
const detailResponse = await fetch(`https://oapi.dingtalk.com/topapi/v2/user/get?access_token=${appTokenData.access_token}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userid: userIdData.result.userid }),
});
const detailData = await detailResponse.json();
if (detailData.result) {
// 获取部门名称
let departmentNames: string[] = [];
const deptIdList = detailData.result.dept_id_list || [];
if (deptIdList.length > 0) {
logger.debug("[DingTalk Callback] Getting department info", {
departmentCount: deptIdList.length,
});
try {
// 获取每个部门的详细信息
for (const deptId of deptIdList) {
const deptResponse = await fetch(`https://oapi.dingtalk.com/topapi/v2/department/get?access_token=${appTokenData.access_token}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dept_id: deptId }),
});
const deptData = await deptResponse.json();
if (deptData.result && deptData.result.name) {
departmentNames.push(deptData.result.name);
}
}
logger.debug("[DingTalk Callback] Department info resolved", {
departmentCount: departmentNames.length,
});
} catch (deptError) {
logger.error("[DingTalk Callback] Failed to get department info:", deptError);
}
}
userInfo = {
...userInfo,
userId: userIdData.result.userid,
name: detailData.result.name,
email: detailData.result.email,
mobile: detailData.result.mobile,
avatar: detailData.result.avatar,
jobNumber: detailData.result.job_number,
title: detailData.result.title, // 职位
deptIdList: deptIdList,
departmentNames: departmentNames, // 部门名称列表
};
}
}
logger.debug("[DingTalk Callback] Final user info resolved", {
hasEmail: Boolean(userInfo.email),
hasName: Boolean(userInfo.name || userInfo.nick),
hasMobile: Boolean(userInfo.mobile),
departmentCount: userInfo.departmentNames?.length || 0,
});
// 5. 查找或创建用户
// 使用 unionId 作为唯一标识,email 可能为空
const email = userInfo.email || `dingtalk_${userInfo.unionId}@dingtalk.local`;
logger.debug("[DingTalk Callback] Checking user", {
hasEmail: Boolean(email),
isSyntheticEmail: !userInfo.email,
});
// 检查用户是否存在(直接使用 Drizzle 查询,不通过 getUser)
let existingUsers;
try {
existingUsers = await db
.select()
.from(user)
.where(eq(user.email, email));
logger.debug("[DingTalk Callback] User found:", existingUsers.length > 0);
if (existingUsers.length > 0) {
logger.debug("[DingTalk Callback] User details:", {
id: existingUsers[0].id,
hasEmail: Boolean(existingUsers[0].email),
hasName: Boolean(existingUsers[0].name),
isActive: existingUsers[0].isActive
});
}
} catch (error) {
logger.error("[DingTalk Callback] Error querying user:", error);
throw error;
}
// 组合部门信息为字符串
const departmentStr = userInfo.departmentNames?.length > 0
? userInfo.departmentNames.join(" / ")
: null;
logger.debug("[DingTalk Callback] Department string resolved", {
hasDepartment: Boolean(departmentStr),
});
// 获取钉钉用户ID
const dingtalkUserId = userInfo.userId || null;
logger.debug("[DingTalk Callback] DingTalk user id resolved", {
hasUserId: Boolean(dingtalkUserId),
});
if (existingUsers.length === 0) {
// 创建新用户
logger.debug("[DingTalk Callback] Creating new user", {
hasName: Boolean(userInfo.name || userInfo.nick),
hasDepartment: Boolean(departmentStr),
hasDingTalkUserId: Boolean(dingtalkUserId),
});
try {
await db.insert(user).values({
email,
password: "", // 钉钉用户无密码
name: userInfo.name || userInfo.nick, // 保存真实姓名或昵称
department: departmentStr, // 保存部门信息
dingtalkUserId: dingtalkUserId, // 保存钉钉用户ID
role: "regular",
isActive: true,
});
logger.debug("[DingTalk Callback] User created successfully", {
hasDepartment: Boolean(departmentStr),
hasDingTalkUserId: Boolean(dingtalkUserId),
});
// 重新查询用户
existingUsers = await db
.select()
.from(user)
.where(eq(user.email, email));
} catch (error) {
logger.error("[DingTalk Callback] Error creating user:", error);
throw error;
}
} else {
// 更新现有用户的姓名、部门信息和钉钉用户ID
const existingUser = existingUsers[0];
logger.debug("[DingTalk Callback] Existing user found", {
hasName: Boolean(existingUser.name),
hasDepartment: Boolean((existingUser as any).department),
});
const updateData: Record<string, string | null> = {};
// 更新姓名(如果数据库中为空但钉钉有数据)
if (!existingUser.name && (userInfo.name || userInfo.nick)) {
updateData.name = userInfo.name || userInfo.nick;
}
// 每次登录都更新部门信息(部门可能变化)
if (departmentStr) {
updateData.department = departmentStr;
}
// 更新钉钉用户ID(如果数据库中为空)
if (!(existingUser as any).dingtalkUserId && dingtalkUserId) {
updateData.dingtalkUserId = dingtalkUserId;
}
if (Object.keys(updateData).length > 0) {
logger.debug("[DingTalk Callback] Updating user info", {
fields: Object.keys(updateData),
});
try {
await db.update(user)
.set(updateData)
.where(eq(user.email, email));
logger.debug("[DingTalk Callback] User info updated successfully");
} catch (error) {
logger.error("[DingTalk Callback] Error updating user info:", error);
// 不抛出错误,继续登录流程
}
}
}
// 6. 生成临时登录令牌
const token = generateUUID();
await db.insert(loginToken).values({
token,
email,
expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5分钟有效期
});
logger.debug("[DingTalk Callback] Login successful", {
isSyntheticEmail: !userInfo.email,
});
// 7. 返回成功响应(给 iframe 中的回调页面)
return NextResponse.json({
success: true,
message: "登录成功",
user: {
email,
name: userInfo.name || userInfo.nick,
avatar: userInfo.avatar || userInfo.avatarUrl,
title: userInfo.title,
departments: userInfo.departmentNames || [],
},
token,
callbackUrl: dingTalkState.callbackUrl,
});
} catch (error) {
logger.error("[DingTalk Callback] Error:", error);
return NextResponse.json({ success: false, error: "Internal server error: " + (error as Error).message }, { status: 500 });
}
}