343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
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";
|
||
|
||
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) {
|
||
console.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);
|
||
|
||
console.log(
|
||
"[DingTalk Callback] Received code:",
|
||
code?.substring(0, 10) + "...",
|
||
"state:",
|
||
state,
|
||
);
|
||
|
||
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
|
||
console.log("[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();
|
||
console.log("[DingTalk Callback] Token response:", tokenData.accessToken ? "success" : "failed");
|
||
|
||
if (!tokenData.accessToken) {
|
||
console.error("[DingTalk Callback] Token Error:", tokenData);
|
||
return NextResponse.json({ success: false, error: "Failed to get access token: " + (tokenData.message || JSON.stringify(tokenData)) }, { status: 400 });
|
||
}
|
||
|
||
// 2. 获取用户 unionId
|
||
console.log("[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();
|
||
console.log("[DingTalk Callback] User me response:", userMeData.unionId ? "success" : "failed");
|
||
|
||
if (!userMeData.unionId) {
|
||
console.error("[DingTalk Callback] User Me Error:", userMeData);
|
||
return NextResponse.json({ success: false, error: "Failed to get user unionId" }, { status: 400 });
|
||
}
|
||
|
||
// 3. 获取应用访问令牌 (用于获取更详细的用户信息)
|
||
console.log("[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) {
|
||
console.error("[DingTalk Callback] App Token Error:", appTokenData);
|
||
return NextResponse.json({ success: false, error: "Failed to get app access token" }, { status: 400 });
|
||
}
|
||
|
||
// 4. 根据 unionId 获取 userId
|
||
console.log("[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();
|
||
console.log("[DingTalk Callback] UserId response:", userIdData);
|
||
|
||
let userInfo: any = {
|
||
unionId: userMeData.unionId,
|
||
nick: userMeData.nick,
|
||
avatarUrl: userMeData.avatarUrl,
|
||
};
|
||
|
||
// 如果能获取到 userId,则获取更详细的用户信息
|
||
if (userIdData.result && userIdData.result.userid) {
|
||
console.log("[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) {
|
||
console.log("[DingTalk Callback] Getting department info for dept_ids:", deptIdList);
|
||
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);
|
||
}
|
||
}
|
||
console.log("[DingTalk Callback] Department names:", departmentNames);
|
||
} catch (deptError) {
|
||
console.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, // 部门名称列表
|
||
};
|
||
}
|
||
}
|
||
|
||
console.log("[DingTalk Callback] Final user info:", {
|
||
...userInfo,
|
||
mobile: "***",
|
||
departments: userInfo.departmentNames?.join(", ") || "无部门信息"
|
||
});
|
||
|
||
// 5. 查找或创建用户
|
||
// 使用 unionId 作为唯一标识,email 可能为空
|
||
const email = userInfo.email || `dingtalk_${userInfo.unionId}@dingtalk.local`;
|
||
console.log("[DingTalk Callback] Checking user:", email);
|
||
|
||
// 检查用户是否存在(直接使用 Drizzle 查询,不通过 getUser)
|
||
let existingUsers;
|
||
try {
|
||
existingUsers = await db
|
||
.select()
|
||
.from(user)
|
||
.where(eq(user.email, email));
|
||
console.log("[DingTalk Callback] User found:", existingUsers.length > 0);
|
||
if (existingUsers.length > 0) {
|
||
console.log("[DingTalk Callback] User details:", {
|
||
id: existingUsers[0].id,
|
||
email: existingUsers[0].email,
|
||
name: existingUsers[0].name,
|
||
isActive: existingUsers[0].isActive
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("[DingTalk Callback] Error querying user:", error);
|
||
throw error;
|
||
}
|
||
|
||
// 组合部门信息为字符串
|
||
const departmentStr = userInfo.departmentNames?.length > 0
|
||
? userInfo.departmentNames.join(" / ")
|
||
: null;
|
||
console.log("[DingTalk Callback] Department string:", departmentStr);
|
||
|
||
// 获取钉钉用户ID
|
||
const dingtalkUserId = userInfo.userId || null;
|
||
console.log("[DingTalk Callback] DingTalk userId:", dingtalkUserId);
|
||
|
||
if (existingUsers.length === 0) {
|
||
// 创建新用户
|
||
console.log("[DingTalk Callback] Creating new user:", email, "with name:", userInfo.name || userInfo.nick);
|
||
try {
|
||
await db.insert(user).values({
|
||
email,
|
||
password: "", // 钉钉用户无密码
|
||
name: userInfo.name || userInfo.nick, // 保存真实姓名或昵称
|
||
department: departmentStr, // 保存部门信息
|
||
dingtalkUserId: dingtalkUserId, // 保存钉钉用户ID
|
||
role: "regular",
|
||
isActive: true,
|
||
});
|
||
console.log("[DingTalk Callback] User created successfully with department:", departmentStr, ", dingtalkUserId:", dingtalkUserId);
|
||
// 重新查询用户
|
||
existingUsers = await db
|
||
.select()
|
||
.from(user)
|
||
.where(eq(user.email, email));
|
||
} catch (error) {
|
||
console.error("[DingTalk Callback] Error creating user:", error);
|
||
throw error;
|
||
}
|
||
} else {
|
||
// 更新现有用户的姓名、部门信息和钉钉用户ID
|
||
const existingUser = existingUsers[0];
|
||
console.log("[DingTalk Callback] Existing user found, current name:", existingUser.name, ", department:", (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) {
|
||
console.log("[DingTalk Callback] Updating user info:", updateData);
|
||
try {
|
||
await db.update(user)
|
||
.set(updateData)
|
||
.where(eq(user.email, email));
|
||
console.log("[DingTalk Callback] User info updated successfully");
|
||
} catch (error) {
|
||
console.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分钟有效期
|
||
});
|
||
|
||
console.log("[DingTalk Callback] Login successful for:", 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) {
|
||
console.error("[DingTalk Callback] Error:", error);
|
||
return NextResponse.json({ success: false, error: "Internal server error: " + (error as Error).message }, { status: 500 });
|
||
}
|
||
}
|