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 = {}; // 更新姓名(如果数据库中为空但钉钉有数据) 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 }); } }