diff --git a/app/api/dingtalk/h5-login/route.ts b/app/api/dingtalk/h5-login/route.ts new file mode 100644 index 0000000..463f652 --- /dev/null +++ b/app/api/dingtalk/h5-login/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server' +import { loginDingtalkUserByH5AuthCode } from '@/lib/dingtalk-login' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})) + const code = String(body?.code || '').trim() + + if (!code) { + return NextResponse.json( + { success: false, message: '缺少钉钉免登授权码' }, + { status: 400 }, + ) + } + + const response = await loginDingtalkUserByH5AuthCode(request, code) + response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + return response + } catch (error: any) { + console.error('钉钉H5免登失败:', error) + return NextResponse.json( + { + success: false, + message: error.message || '钉钉免登失败,请稍后再试', + }, + { status: 500 }, + ) + } +} diff --git a/app/api/dingtalk/login/route.ts b/app/api/dingtalk/login/route.ts index a0638a2..6e3f587 100644 --- a/app/api/dingtalk/login/route.ts +++ b/app/api/dingtalk/login/route.ts @@ -1,498 +1,34 @@ import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { getDingtalkAppAccessToken } from '@/lib/dingtalk-client' -import { ensurePerformanceSurveyEmployeeProfileForUser } from '@/lib/performance-survey-report-sync' -import { - getAuthSessionCookieName, - resolveSecureAuthCookieFromRequest, -} from '@/lib/auth-cookie' -import bcrypt from 'bcryptjs' -import { encode } from 'next-auth/jwt' -import type { Role } from '@prisma/client' +import { loginDingtalkUserByOAuthCode } from '@/lib/dingtalk-login' /** * R25修复:移除硬编码的钉钉密钥,强制使用环境变量 - * + * * 必需的环境变量: * - DINGTALK_CLIENT_ID: 钉钉应用的 AppKey * - DINGTALK_CLIENT_SECRET: 钉钉应用的 AppSecret */ -// 钉钉配置验证标记(只在首次调用时检查一次) -let configValidated = false - -// 验证钉钉配置 -function validateDingtalkConfig(): { clientId: string; clientSecret: string } | null { - const clientId = process.env.DINGTALK_CLIENT_ID - const clientSecret = process.env.DINGTALK_CLIENT_SECRET - - if (!clientId || !clientSecret) { - if (!configValidated) { - console.error('[Dingtalk] ❌ 配置错误:缺少必需的环境变量') - console.error('[Dingtalk] 请配置以下环境变量:') - console.error('[Dingtalk] - DINGTALK_CLIENT_ID: 钉钉应用的 AppKey') - console.error('[Dingtalk] - DINGTALK_CLIENT_SECRET: 钉钉应用的 AppSecret') - configValidated = true - } - return null - } - - if (!configValidated) { - console.log('[Dingtalk] ✅ 钉钉配置已加载') - configValidated = true - } - - return { clientId, clientSecret } -} - -// 钉钉应用配置(从环境变量获取) -function getDingtalkConfig(): { clientId: string; clientSecret: string } { - const config = validateDingtalkConfig() - if (!config) { - throw new Error('钉钉登录未配置,请联系管理员配置 DINGTALK_CLIENT_ID 和 DINGTALK_CLIENT_SECRET') - } - return config -} - -// 获取用户访问令牌 -async function getUserAccessToken(code: string) { - const config = getDingtalkConfig() - const url = 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken' - - const params = { - clientId: config.clientId, - clientSecret: config.clientSecret, - code: code, - grantType: 'authorization_code' - } - - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params) - }) - - 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() - - // 新版API可能返回错误信息 - if (data.code && data.code !== '0') { - console.error('获取用户访问令牌API错误:', data) - throw new Error(`获取用户访问令牌失败: ${data.message || '未知错误'}`) - } - - return data -} - -// 获取应用访问令牌 -async function getAppAccessToken() { - return getDingtalkAppAccessToken() -} - -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 - }, -) { - 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 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: 'lax', - secure: secureCookie, - path: '/', - maxAge, - }) - - return response -} - -// 根据token获取用户unionId -async function getUnionId(token: string) { - const url = 'https://api.dingtalk.com/v1.0/contact/users/me' - - const response = await fetch(url, { - headers: { 'x-acs-dingtalk-access-token': token } - }) - - if (!response.ok) { - throw new Error('获取unionId失败') - } - - const data = await response.json() - - // 检查 unionId 是否存在 - if (!data.unionId) { - console.error('获取unionId返回数据异常:', data) - throw new Error('获取unionId失败: 返回数据中没有unionId') - } - - return data.unionId -} - -// 根据unionId获取userId -async function getUserIdByUnionId(unionId: string, appToken: string) { - const url = `https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=${appToken}` - - const response = await fetch(url, { - 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() - - // 检查钉钉 API 返回的错误码 - if (data.errcode !== 0) { - console.error('钉钉API错误:', data) - throw new Error(`获取userId失败: ${data.errmsg || '未知错误'}`) - } - - // 检查 result 是否存在 - if (!data.result || !data.result.userid) { - console.error('钉钉API返回数据异常:', data) - throw new Error('获取userId失败: 返回数据格式错误') - } - - return data.result.userid -} - -// 根据userId获取用户详细信息 -async function getUserInfo(userId: string, appToken: string) { - const url = `https://oapi.dingtalk.com/topapi/v2/user/get?access_token=${appToken}` - - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userid: userId }) - }) - - if (!response.ok) { - throw new Error('获取用户信息失败') - } - - const data = await response.json() - - // 检查钉钉 API 返回的错误码 - if (data.errcode !== 0) { - console.error('钉钉API错误:', data) - throw new Error(`获取用户信息失败: ${data.errmsg || '未知错误'}`) - } - - // 检查 result 是否存在 - if (!data.result) { - console.error('钉钉API返回数据异常:', data) - throw new Error('获取用户信息失败: 返回数据格式错误') - } - - return data.result -} - -// 生成随机密码 -function generateRandomPassword(length: number = 12): string { - const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*' - let password = '' - for (let i = 0; i < length; i++) { - password += charset.charAt(Math.floor(Math.random() * charset.length)) - } - return password -} - -// 生成随机邮箱 -function generateRandomEmail(): string { - const randomStr = Math.random().toString(36).substring(2, 10) - return `dingtalk_${randomStr}@temp.com` -} - export async function POST(request: NextRequest) { try { - const { code, state } = await request.json() - + const { code } = await request.json() + if (!code) { return NextResponse.json( { success: false, message: '缺少授权码' }, - { status: 400 } + { status: 400 }, ) } - - // state 用于传递前端数据(如origin),不是公司标识 - console.log('开始钉钉登录流程:', { code }) - - // 1. 获取用户访问令牌 - const tokenData = await getUserAccessToken(code) - - // 检查返回的数据 - if (!tokenData || !tokenData.accessToken) { - console.error('获取用户访问令牌返回数据异常:', tokenData) - return NextResponse.json( - { success: false, message: '获取用户访问令牌失败:返回数据格式错误' }, - { status: 500 } - ) - } - - const userAccessToken = tokenData.accessToken - console.log('获取用户访问令牌成功') - - // 2. 获取应用访问令牌 - const appAccessToken = await getAppAccessToken() - console.log('获取应用访问令牌成功') - - // 3. 获取unionId - const unionId = await getUnionId(userAccessToken) - console.log('获取unionId成功:', unionId) - - // 4. 根据unionId获取userId - const userId = await getUserIdByUnionId(unionId, appAccessToken) - console.log('获取userId成功:', userId) - - // 5. 获取用户详细信息 - const userInfo = await getUserInfo(userId, appAccessToken) - console.log('获取用户信息成功:', userInfo.name) - - // 6. 在数据库中查找或创建用户 - let user = await prisma.user.findFirst({ - where: { dingtalkUserId: userId } - }) - - let isNewUser = false - let needCompleteProfile = false - - if (!user) { - // 首先检查是否有用户名为钉钉真实姓名的用户 - const existingUserByName = await prisma.user.findUnique({ - where: { username: userInfo.name } - }) - - // 使用固定密码用于钉钉自动登录 - const hashedPassword = await bcrypt.hash('dingtalk_auto_login', 10) - - if (existingUserByName && !existingUserByName.dingtalkUserId) { - // 找到了同名用户且未绑定钉钉,直接关联 - console.log('找到同名用户,自动关联钉钉账号:', existingUserByName.username) - - user = await prisma.user.update({ - where: { id: existingUserByName.id }, - data: { - dingtalkUserId: userId, - loginType: 'dingtalk', - realName: userInfo.name, - avatar: userInfo.avatar || existingUserByName.avatar, - phone: userInfo.mobile || existingUserByName.phone, - password: hashedPassword, // 更新密码为固定值 - } - }) - - console.log('账号关联成功:', user.username) - } else { - // 创建新用户,直接使用钉钉真实姓名作为用户名 - console.log('创建新用户(使用钉钉姓名):', userInfo.name) - - let emailToUse = userInfo.email || generateRandomEmail() - - // 检查邮箱是否已存在 - let emailExists = await prisma.user.findUnique({ where: { email: emailToUse } }) - if (emailExists) { - emailToUse = generateRandomEmail() - } - - user = await prisma.user.create({ - data: { - username: userInfo.name, // 直接使用钉钉真实姓名 - email: emailToUse, - password: hashedPassword, - realName: userInfo.name, - avatar: userInfo.avatar || null, - phone: userInfo.mobile || null, - department: '', // 待用户选择 - role: 'USER', - isActive: false, // 新用户默认未激活,需要管理员激活(向后兼容) - status: 'pending', // 新用户默认状态为 pending(阶段一新增) - loginType: 'dingtalk', - dingtalkUserId: userId - } - }) - - isNewUser = true - needCompleteProfile = true // 需要选择部门 - console.log('用户创建成功:', user.id) - } - } else { - // 更新现有用户信息 - console.log('更新现有用户:', user.username) - - // 确保密码也更新为固定值,以便前端能够自动登录 - const hashedPassword = await bcrypt.hash('dingtalk_auto_login', 10) - - user = await prisma.user.update({ - where: { id: user.id }, - data: { - realName: userInfo.name, - avatar: userInfo.avatar || user.avatar, - phone: userInfo.mobile || user.phone, - password: hashedPassword, // 更新密码为固定值 - } - }) - } - - // 如果需要完善信息(选择部门) - if (needCompleteProfile) { - warmPerformanceSurveyProfile(user) - return 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 - } - }) - } - - // 检查用户状态(优先使用 status 字段,兼容 isActive) - 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 }) - } - - // 记录登录审计信息(获取客户端IP) - 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 和 isActive 一致 - status: 'active', - isActive: true - } - }).catch(err => { - console.error('记录登录审计信息失败:', err); - // 不阻塞登录流程 - }); - - // 返回用户信息(不包含敏感信息) - 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, - }) - + return loginDingtalkUserByOAuthCode(request, String(code)) } catch (error: any) { console.error('钉钉登录失败:', error) return NextResponse.json( - { - success: false, - message: error.message || '登录失败,请稍后再试' + { + success: false, + message: error.message || '登录失败,请稍后再试', }, - { status: 500 } + { status: 500 }, ) } } - diff --git a/app/dingtalk-entry/route.ts b/app/dingtalk-entry/route.ts index 61579f2..b55a036 100644 --- a/app/dingtalk-entry/route.ts +++ b/app/dingtalk-entry/route.ts @@ -5,6 +5,10 @@ import { resolveSecureAuthCookieFromRequest, } from '@/lib/auth-cookie' import { DINGTALK_DIRECT_LOGIN_SOURCE, buildDingtalkOAuthUrl } from '@/lib/dingtalk-oauth' +import { + getConfiguredDingtalkCorpId, + normalizeDingtalkCorpId, +} from '@/lib/dingtalk-corp' import { getPublicAppOrigin } from '@/lib/public-app-url' import { normalizeInternalRedirectPath } from '@/lib/safe-redirect' @@ -26,6 +30,194 @@ async function resolveCurrentToken(request: NextRequest) { return null } +function isDingtalkBrowser(request: NextRequest) { + return /DingTalk/i.test(request.headers.get('user-agent') || '') +} + +function stringifyForInlineScript(value: string) { + return JSON.stringify(value).replace(/ + + + + + 正在进入素材库 + + + + +
+ +

正在进入素材库

+

正在通过钉钉确认身份...

+
+ + + +`, + { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + }, + }, + ) +} + +function createMissingCorpIdPage() { + return new NextResponse( + ` + + + + + 钉钉免登配置缺失 + + + +
+

钉钉免登配置缺失

+

当前缺少企业 CorpId,无法在钉钉内自动确认身份。请在服务端配置 DINGTALK_CORP_ID,或让入口链接携带 corpId 参数后重试。

+
+ +`, + { + status: 503, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + }, + }, + ) +} + export async function GET(request: NextRequest) { const redirectPath = normalizeInternalRedirectPath(request.nextUrl.searchParams.get('redirect')) const token = await resolveCurrentToken(request) @@ -33,9 +225,28 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL(redirectPath, getPublicAppOrigin())) } - return NextResponse.redirect(buildDingtalkOAuthUrl({ + const fallbackOAuthUrl = buildDingtalkOAuthUrl({ redirectPath, callbackPath: '/dingtalk-callback', source: DINGTALK_DIRECT_LOGIN_SOURCE, - })) + }) + + if (!isDingtalkBrowser(request)) { + return NextResponse.redirect(fallbackOAuthUrl) + } + + const queryCorpId = + request.nextUrl.searchParams.get('corpId') || + request.nextUrl.searchParams.get('corpid') + const corpId = normalizeDingtalkCorpId(queryCorpId) || getConfiguredDingtalkCorpId() + + if (!corpId) { + return createMissingCorpIdPage() + } + + return createH5LoginPage({ + redirectPath, + corpId, + fallbackOAuthUrl, + }) } diff --git a/lib/dingtalk-corp.ts b/lib/dingtalk-corp.ts new file mode 100644 index 0000000..26a57b8 --- /dev/null +++ b/lib/dingtalk-corp.ts @@ -0,0 +1,12 @@ +export function normalizeDingtalkCorpId(value: unknown) { + const corpId = String(value ?? '').trim() + return corpId || '' +} + +export function getConfiguredDingtalkCorpId() { + return normalizeDingtalkCorpId( + process.env.DINGTALK_CORP_ID || + process.env.DINGTALK_CORPID || + process.env.NEXT_PUBLIC_DINGTALK_CORP_ID, + ) +} diff --git a/lib/dingtalk-login.ts b/lib/dingtalk-login.ts new file mode 100644 index 0000000..48bea7b --- /dev/null +++ b/lib/dingtalk-login.ts @@ -0,0 +1,466 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { getDingtalkAppAccessToken } from '@/lib/dingtalk-client' +import { ensurePerformanceSurveyEmployeeProfileForUser } from '@/lib/performance-survey-report-sync' +import { + getAuthSessionCookieName, + resolveSecureAuthCookieFromRequest, +} from '@/lib/auth-cookie' +import bcrypt from 'bcryptjs' +import { encode } from 'next-auth/jwt' +import type { Role } from '@prisma/client' + +type DingtalkUserInfo = { + name?: string | null + avatar?: string | null + mobile?: string | null + email?: string | null +} + +let configValidated = false + +function validateDingtalkConfig(): { clientId: string; clientSecret: string } | null { + const clientId = process.env.DINGTALK_CLIENT_ID + const clientSecret = process.env.DINGTALK_CLIENT_SECRET + + if (!clientId || !clientSecret) { + if (!configValidated) { + console.error('[Dingtalk] 配置错误:缺少必需的环境变量') + console.error('[Dingtalk] 请配置 DINGTALK_CLIENT_ID 和 DINGTALK_CLIENT_SECRET') + configValidated = true + } + return null + } + + if (!configValidated) { + console.log('[Dingtalk] 钉钉配置已加载') + configValidated = true + } + + return { clientId, clientSecret } +} + +function getDingtalkConfig(): { clientId: string; clientSecret: string } { + const config = validateDingtalkConfig() + if (!config) { + throw new Error('钉钉登录未配置,请联系管理员配置 DINGTALK_CLIENT_ID 和 DINGTALK_CLIENT_SECRET') + } + return config +} + +async function getUserAccessToken(code: string) { + const config = getDingtalkConfig() + 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 + }, +) { + 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 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: 'lax', + secure: secureCookie, + path: '/', + maxAge, + }) + + return response +} + +async function createOrUpdateUserSession( + request: NextRequest, + userId: string, + userInfo: DingtalkUserInfo, +) { + const displayName = String(userInfo.name || '').trim() + if (!displayName) { + throw new Error('钉钉返回的用户名称为空') + } + + let user = await prisma.user.findFirst({ + where: { dingtalkUserId: userId }, + }) + + let needCompleteProfile = false + const hashedPassword = await bcrypt.hash('dingtalk_auto_login', 10) + + 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, + password: hashedPassword, + }, + }) + 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: hashedPassword, + 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, + password: hashedPassword, + }, + }) + } + + if (needCompleteProfile) { + warmPerformanceSurveyProfile(user) + return 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 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, + }) +} + +export async function loginDingtalkUserByOAuthCode(request: NextRequest, code: string) { + console.log('开始钉钉OAuth登录流程') + + const tokenData = await getUserAccessToken(code) + if (!tokenData?.accessToken) { + console.error('获取用户访问令牌返回数据异常:', tokenData) + return NextResponse.json( + { success: false, message: '获取用户访问令牌失败:返回数据格式错误' }, + { status: 500 }, + ) + } + + console.log('获取用户访问令牌成功') + const appAccessToken = await getDingtalkAppAccessToken() + 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) { + console.log('开始钉钉H5免登流程') + + const appAccessToken = await getDingtalkAppAccessToken() + 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) +} diff --git a/lib/env-validation.ts b/lib/env-validation.ts index dad586b..57789fb 100644 --- a/lib/env-validation.ts +++ b/lib/env-validation.ts @@ -66,6 +66,8 @@ const OPTIONAL_ENV_VARS = [ 'DINGTALK_APP_SECRET', 'DINGTALK_CLIENT_ID', 'DINGTALK_CLIENT_SECRET', + 'DINGTALK_CORP_ID', + 'DINGTALK_CORPID', 'DINGTALK_WORK_NOTICE_CLIENT_ID', 'DINGTALK_WORK_NOTICE_CLIENT_SECRET', 'DINGTALK_WORK_NOTICE_APP_KEY',