import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { getDingtalkAppAccessToken, getDingtalkWorkNoticeAppAccessToken, } from '@/lib/dingtalk-client' import { ensurePerformanceSurveyEmployeeProfileForUser } from '@/lib/performance-survey-report-sync' import { getAuthSessionCookieCandidates, getAuthSessionCookieName, resolveSecureAuthCookieFromRequest, } from '@/lib/auth-cookie' import bcrypt from 'bcryptjs' import { encode } from 'next-auth/jwt' import type { Role } from '@prisma/client' import { randomBytes } from 'crypto' import { createDingtalkProfileToken, DINGTALK_PROFILE_TOKEN_MAX_AGE_SECONDS, getDingtalkProfileCookieName, getDingtalkProfileCookiePath, } from '@/lib/dingtalk-profile-token' type DingtalkUserInfo = { name?: string | null avatar?: string | null mobile?: string | null email?: string | null } type DingtalkLoginAppSource = 'login' | 'workNotice' type DingtalkNextAuthUser = { id: string email: string username: string realName?: string | null role: Role department: string managedDepartment?: string | null avatar?: string | null isSuperAdmin?: boolean | null } let configValidated = false async function createDingtalkOnlyPasswordHash() { return bcrypt.hash(randomBytes(32).toString('hex'), 10) } function validateDingtalkConfig( appSource: DingtalkLoginAppSource = 'login', ): { clientId: string; clientSecret: string } | null { const clientId = appSource === 'workNotice' ? process.env.DINGTALK_WORK_NOTICE_CLIENT_ID || process.env.DINGTALK_WORK_NOTICE_APP_KEY || process.env.DINGTALK_CLIENT_ID : process.env.DINGTALK_CLIENT_ID const clientSecret = appSource === 'workNotice' ? process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET || process.env.DINGTALK_WORK_NOTICE_APP_SECRET || process.env.DINGTALK_CLIENT_SECRET : process.env.DINGTALK_CLIENT_SECRET if (!clientId || !clientSecret) { if (!configValidated) { console.error('[Dingtalk] 配置错误:缺少必需的环境变量') console.error( appSource === 'workNotice' ? '[Dingtalk] 请配置 DINGTALK_WORK_NOTICE_CLIENT_ID 和 DINGTALK_WORK_NOTICE_CLIENT_SECRET' : '[Dingtalk] 请配置 DINGTALK_CLIENT_ID 和 DINGTALK_CLIENT_SECRET', ) configValidated = true } return null } if (!configValidated) { console.log('[Dingtalk] 钉钉配置已加载') configValidated = true } return { clientId, clientSecret } } function getDingtalkConfig( appSource: DingtalkLoginAppSource = 'login', ): { clientId: string; clientSecret: string } { const config = validateDingtalkConfig(appSource) if (!config) { throw new Error( appSource === 'workNotice' ? '钉钉工作通知免登未配置,请联系管理员配置 DINGTALK_WORK_NOTICE_CLIENT_ID 和 DINGTALK_WORK_NOTICE_CLIENT_SECRET' : '钉钉登录未配置,请联系管理员配置 DINGTALK_CLIENT_ID 和 DINGTALK_CLIENT_SECRET', ) } return config } async function getUserAccessToken(code: string, appSource: DingtalkLoginAppSource = 'login') { const config = getDingtalkConfig(appSource) const response = await fetch('https://api.dingtalk.com/v1.0/oauth2/userAccessToken', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: config.clientId, clientSecret: config.clientSecret, code, grantType: 'authorization_code', }), }) if (!response.ok) { const errorText = await response.text() console.error('获取用户访问令牌HTTP错误:', response.status, errorText) throw new Error(`获取用户访问令牌失败: HTTP ${response.status}`) } const data = await response.json() if (data.code && data.code !== '0') { console.error('获取用户访问令牌API错误:', data) throw new Error(`获取用户访问令牌失败: ${data.message || '未知错误'}`) } return data } async function getUnionId(token: string) { const response = await fetch('https://api.dingtalk.com/v1.0/contact/users/me', { headers: { 'x-acs-dingtalk-access-token': token }, }) if (!response.ok) { throw new Error('获取unionId失败') } const data = await response.json() if (!data.unionId) { console.error('获取unionId返回数据异常:', data) throw new Error('获取unionId失败: 返回数据中没有unionId') } return data.unionId } async function getUserIdByUnionId(unionId: string, appToken: string) { const response = await fetch( `https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=${appToken}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ unionid: unionId }), }, ) if (!response.ok) { throw new Error('根据unionId获取userId失败') } const data = await response.json() if (data.errcode !== 0) { console.error('钉钉API错误:', data) throw new Error(`获取userId失败: ${data.errmsg || '未知错误'}`) } if (!data.result?.userid) { console.error('钉钉API返回数据异常:', data) throw new Error('获取userId失败: 返回数据格式错误') } return data.result.userid } async function getUserIdByH5AuthCode(code: string, appToken: string) { const response = await fetch( `https://oapi.dingtalk.com/topapi/v2/user/getuserinfo?access_token=${appToken}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), }, ) if (!response.ok) { throw new Error(`通过免登码获取用户失败: HTTP ${response.status}`) } const data = await response.json() if (data.errcode !== 0) { console.error('钉钉免登API错误:', data) throw new Error(`通过免登码获取用户失败: ${data.errmsg || '未知错误'}`) } const userId = data.result?.userid || data.result?.userId || data.userid || data.userId if (!userId) { console.error('钉钉免登API返回数据异常:', data) throw new Error('通过免登码获取用户失败: 返回数据中没有userId') } return String(userId) } async function getUserInfo(userId: string, appToken: string): Promise { const response = await fetch( `https://oapi.dingtalk.com/topapi/v2/user/get?access_token=${appToken}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userid: userId }), }, ) if (!response.ok) { throw new Error('获取用户信息失败') } const data = await response.json() if (data.errcode !== 0) { console.error('钉钉API错误:', data) throw new Error(`获取用户信息失败: ${data.errmsg || '未知错误'}`) } if (!data.result) { console.error('钉钉API返回数据异常:', data) throw new Error('获取用户信息失败: 返回数据格式错误') } return data.result } function generateRandomEmail(): string { const randomStr = Math.random().toString(36).substring(2, 10) return `dingtalk_${randomStr}@temp.com` } function warmPerformanceSurveyProfile(user: { id: string dingtalkUserId: string | null email: string username: string realName: string | null }) { void ensurePerformanceSurveyEmployeeProfileForUser({ userId: user.id, dingtalkUserId: user.dingtalkUserId, email: user.email, username: user.username, realName: user.realName, displayName: user.realName || user.username, }).catch((error) => { console.warn('[performance-survey] 钉钉登录后预热人员档案失败:', error) }) } async function createAuthenticatedResponse( request: NextRequest, user: { id: string email: string username: string realName?: string | null role: Role department: string managedDepartment?: string | null avatar?: string | null isSuperAdmin?: boolean | null }, options?: { cookieSameSite?: 'lax' | 'none' }, ) { const secret = process.env.NEXTAUTH_SECRET if (!secret) { throw new Error('登录会话未配置 NEXTAUTH_SECRET') } const maxAge = 7 * 24 * 60 * 60 const secureCookie = resolveSecureAuthCookieFromRequest(request) const cookieName = getAuthSessionCookieName(secureCookie) const cookieNames = secureCookie ? getAuthSessionCookieCandidates(true) : [cookieName] const sameSite = options?.cookieSameSite === 'none' && secureCookie ? 'none' : 'lax' const sessionToken = await encode({ secret, maxAge, token: { sub: user.id, id: user.id, email: user.email, name: user.username, role: user.role, avatar: user.avatar || null, isSuperAdmin: user.isSuperAdmin ?? false, department: user.department || '', managedDepartment: user.managedDepartment || null, }, }) const response = NextResponse.json({ success: true, needCompleteProfile: false, message: '登录成功', sessionCreated: true, user: { id: user.id, username: user.username, email: user.email, realName: user.realName, avatar: user.avatar, role: user.role, department: user.department, }, }) response.cookies.set(cookieName, sessionToken, { httpOnly: true, sameSite, secure: secureCookie, path: '/', maxAge, }) for (const name of cookieNames) { if (name === cookieName) { continue } response.cookies.set(name, sessionToken, { httpOnly: true, sameSite, secure: secureCookie, path: '/', maxAge, }) } return response } async function createOrUpdateUserSession( request: NextRequest, userId: string, userInfo: DingtalkUserInfo, options?: { cookieSameSite?: 'lax' | 'none' }, ) { const displayName = String(userInfo.name || '').trim() if (!displayName) { throw new Error('钉钉返回的用户名称为空') } let user = await prisma.user.findFirst({ where: { dingtalkUserId: userId }, }) let needCompleteProfile = false if (!user) { const existingUserByName = await prisma.user.findUnique({ where: { username: displayName }, }) if (existingUserByName && !existingUserByName.dingtalkUserId) { console.log('找到同名用户,自动关联钉钉账号:', existingUserByName.username) user = await prisma.user.update({ where: { id: existingUserByName.id }, data: { dingtalkUserId: userId, loginType: 'dingtalk', realName: displayName, avatar: userInfo.avatar || existingUserByName.avatar, phone: userInfo.mobile || existingUserByName.phone, }, }) console.log('账号关联成功:', user.username) } else { console.log('创建新用户(使用钉钉姓名):', displayName) let emailToUse = userInfo.email || generateRandomEmail() const emailExists = await prisma.user.findUnique({ where: { email: emailToUse } }) if (emailExists) { emailToUse = generateRandomEmail() } user = await prisma.user.create({ data: { username: displayName, email: emailToUse, password: await createDingtalkOnlyPasswordHash(), realName: displayName, avatar: userInfo.avatar || null, phone: userInfo.mobile || null, department: '', role: 'USER', isActive: false, status: 'pending', loginType: 'dingtalk', dingtalkUserId: userId, }, }) needCompleteProfile = true console.log('用户创建成功:', user.id) } } else { console.log('更新现有用户:', user.username) user = await prisma.user.update({ where: { id: user.id }, data: { realName: displayName, avatar: userInfo.avatar || user.avatar, phone: userInfo.mobile || user.phone, }, }) } if (needCompleteProfile) { warmPerformanceSurveyProfile(user) const response = NextResponse.json({ success: true, needCompleteProfile: true, message: '请选择您的部门', user: { id: user.id, username: user.username, email: user.email, realName: user.realName, avatar: user.avatar, role: user.role, department: user.department, isActive: user.isActive, }, }) const secureCookie = resolveSecureAuthCookieFromRequest(request) response.cookies.set( getDingtalkProfileCookieName(request), await createDingtalkProfileToken(user.id), { httpOnly: true, sameSite: options?.cookieSameSite === 'none' && secureCookie ? 'none' : 'lax', secure: secureCookie, path: getDingtalkProfileCookiePath(), maxAge: DINGTALK_PROFILE_TOKEN_MAX_AGE_SECONDS, }, ) return response } const userStatus = (user as any).status || (user.isActive ? 'active' : 'pending') const isUserActive = userStatus === 'active' if (!isUserActive) { warmPerformanceSurveyProfile(user) return NextResponse.json( { success: false, message: '您的账号尚未激活,请联系管理员激活后再登录', }, { status: 403 }, ) } if (userStatus === 'suspended' || userStatus === 'archived') { return NextResponse.json( { success: false, message: '您的账号已被暂停或归档,无法登录', }, { status: 403 }, ) } const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || null await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date(), lastLoginIp: clientIp, loginCount: { increment: 1 }, status: 'active', isActive: true, }, }).catch((error) => { console.error('记录登录审计信息失败:', error) }) warmPerformanceSurveyProfile(user) return createAuthenticatedResponse(request, { id: user.id, email: user.email, username: user.username, realName: user.realName, role: user.role, department: user.department, managedDepartment: user.managedDepartment, avatar: user.avatar, isSuperAdmin: (user as any).isSuperAdmin ?? false, }, options) } function getAppAccessToken(appSource: DingtalkLoginAppSource) { return appSource === 'workNotice' ? getDingtalkWorkNoticeAppAccessToken() : getDingtalkAppAccessToken() } async function resolveDingtalkUserForSession( userId: string, userInfo: DingtalkUserInfo, clientIp?: string | null, ): Promise { const displayName = String(userInfo.name || '').trim() if (!displayName) { throw new Error('钉钉返回的用户名称为空') } let user = await prisma.user.findFirst({ where: { dingtalkUserId: userId }, }) if (!user) { const existingUserByName = await prisma.user.findUnique({ where: { username: displayName }, }) if (existingUserByName && !existingUserByName.dingtalkUserId) { console.log('找到同名用户,自动关联钉钉账号:', existingUserByName.username) user = await prisma.user.update({ where: { id: existingUserByName.id }, data: { dingtalkUserId: userId, loginType: 'dingtalk', realName: displayName, avatar: userInfo.avatar || existingUserByName.avatar, phone: userInfo.mobile || existingUserByName.phone, }, }) console.log('账号关联成功:', user.username) } else { console.log('创建新用户(使用钉钉姓名):', displayName) let emailToUse = userInfo.email || generateRandomEmail() const emailExists = await prisma.user.findUnique({ where: { email: emailToUse } }) if (emailExists) { emailToUse = generateRandomEmail() } user = await prisma.user.create({ data: { username: displayName, email: emailToUse, password: await createDingtalkOnlyPasswordHash(), realName: displayName, avatar: userInfo.avatar || null, phone: userInfo.mobile || null, department: '', role: 'USER', isActive: false, status: 'pending', loginType: 'dingtalk', dingtalkUserId: userId, }, }) warmPerformanceSurveyProfile(user) throw new Error('您的账号尚未激活,请联系管理员激活后再登录') } } else { console.log('更新现有用户:', user.username) user = await prisma.user.update({ where: { id: user.id }, data: { realName: displayName, avatar: userInfo.avatar || user.avatar, phone: userInfo.mobile || user.phone, }, }) } const userStatus = (user as any).status || (user.isActive ? 'active' : 'pending') const isUserActive = userStatus === 'active' if (!isUserActive) { warmPerformanceSurveyProfile(user) throw new Error('您的账号尚未激活,请联系管理员激活后再登录') } if (userStatus === 'suspended' || userStatus === 'archived') { throw new Error('您的账号已被暂停或归档,无法登录') } await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date(), lastLoginIp: clientIp || null, loginCount: { increment: 1 }, status: 'active', isActive: true, }, }).catch((error) => { console.error('记录登录审计信息失败:', error) }) warmPerformanceSurveyProfile(user) return { id: user.id, email: user.email, username: user.username, realName: user.realName, role: user.role, department: user.department, managedDepartment: user.managedDepartment, avatar: user.avatar, isSuperAdmin: (user as any).isSuperAdmin ?? false, } } export async function authorizeDingtalkH5UserForNextAuth( code: string, options?: { appSource?: DingtalkLoginAppSource; clientIp?: string | null }, ) { console.log('开始钉钉H5免登NextAuth登录流程') const appSource = options?.appSource || 'login' const appAccessToken = await getAppAccessToken(appSource) console.log('获取应用访问令牌成功') const userId = await getUserIdByH5AuthCode(code, appAccessToken) console.log('通过免登码获取userId成功:', userId) const userInfo = await getUserInfo(userId, appAccessToken) console.log('获取用户信息成功:', userInfo.name) return resolveDingtalkUserForSession(userId, userInfo, options?.clientIp || null) } export async function loginDingtalkUserByOAuthCode( request: NextRequest, code: string, options?: { appSource?: DingtalkLoginAppSource }, ) { console.log('开始钉钉OAuth登录流程') const appSource = options?.appSource || 'login' const tokenData = await getUserAccessToken(code, appSource) if (!tokenData?.accessToken) { console.error('获取用户访问令牌返回数据异常:', tokenData) return NextResponse.json( { success: false, message: '获取用户访问令牌失败:返回数据格式错误' }, { status: 500 }, ) } console.log('获取用户访问令牌成功') const appAccessToken = await getAppAccessToken(appSource) console.log('获取应用访问令牌成功') const unionId = await getUnionId(tokenData.accessToken) console.log('获取unionId成功:', unionId) const userId = await getUserIdByUnionId(unionId, appAccessToken) console.log('获取userId成功:', userId) const userInfo = await getUserInfo(userId, appAccessToken) console.log('获取用户信息成功:', userInfo.name) return createOrUpdateUserSession(request, userId, userInfo) } export async function loginDingtalkUserByH5AuthCode( request: NextRequest, code: string, options?: { appSource?: DingtalkLoginAppSource }, ) { console.log('开始钉钉H5免登流程') const appSource = options?.appSource || 'login' const appAccessToken = await getAppAccessToken(appSource) console.log('获取应用访问令牌成功') const userId = await getUserIdByH5AuthCode(code, appAccessToken) console.log('通过免登码获取userId成功:', userId) const userInfo = await getUserInfo(userId, appAccessToken) console.log('获取用户信息成功:', userInfo.name) return createOrUpdateUserSession(request, userId, userInfo, { cookieSameSite: 'none', }) }