修复钉钉工作通知端内免登

This commit is contained in:
chenpei 2026-07-06 14:54:16 +08:00
parent 06b9678fe1
commit 1a00ca9030
6 changed files with 735 additions and 476 deletions

View File

@ -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 },
)
}
}

View File

@ -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 },
)
}
}

View File

@ -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(/</g, '\\u003c')
}
function createH5LoginPage({
redirectPath,
corpId,
fallbackOAuthUrl,
}: {
redirectPath: string
corpId: string
fallbackOAuthUrl: string
}) {
return new NextResponse(
`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>正在进入素材库</title>
<meta http-equiv="Cache-Control" content="no-store" />
<style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0f172a;color:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{width:min(420px,calc(100vw - 48px));padding:32px 24px;text-align:center}
h1{margin:0 0 12px;font-size:22px}
p{margin:0;color:#cbd5e1;line-height:1.7}
.spinner{width:38px;height:38px;margin:0 auto 20px;border:4px solid rgba(255,255,255,.24);border-top-color:#60a5fa;border-radius:999px;animation:spin .85s linear infinite}
a{color:#93c5fd}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<main>
<div class="spinner" aria-hidden="true"></div>
<h1>正在进入素材库</h1>
<p id="status">正在通过钉钉确认身份...</p>
</main>
<script src="https://g.alicdn.com/dingding/dingtalk-jsapi/2.15.6/dingtalk.open.js" onload="window.__startDingtalkLogin && window.__startDingtalkLogin()" onerror="window.__failDingtalkLogin && window.__failDingtalkLogin('钉钉 JSAPI 加载失败')"></script>
<script>
(function () {
var target = ${stringifyForInlineScript(redirectPath)};
var corpId = ${stringifyForInlineScript(corpId)};
var fallbackOAuthUrl = ${stringifyForInlineScript(fallbackOAuthUrl)};
var started = false;
var status = document.getElementById('status');
function setStatus(text) {
if (status) status.textContent = text;
}
function showError(message) {
if (status) {
status.textContent = message + '。请返回钉钉重新打开通知,或联系管理员检查钉钉免登配置。';
status.appendChild(document.createElement('br'));
status.appendChild(document.createElement('br'));
var link = document.createElement('a');
link.href = fallbackOAuthUrl;
link.textContent = '改用网页授权登录';
status.appendChild(link);
}
}
window.__failDingtalkLogin = showError;
function completeProfile(user) {
var userInfo = encodeURIComponent(JSON.stringify(user || {}));
window.location.replace('/complete-profile?userInfo=' + userInfo);
}
function submitCode(code) {
setStatus('已获取钉钉身份,正在创建登录状态...');
fetch('/api/dingtalk/h5-login', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code })
})
.then(function (response) {
return response.json().catch(function () { return {}; }).then(function (data) {
return { ok: response.ok, data: data };
});
})
.then(function (result) {
if (!result.ok || !result.data || !result.data.success) {
throw new Error((result.data && result.data.message) || '登录失败');
}
if (result.data.needCompleteProfile) {
setStatus('首次登录,需要完善账号信息...');
completeProfile(result.data.user);
return;
}
setStatus('登录成功,正在打开考核调研...');
setTimeout(function () {
window.location.replace(target);
}, 180);
})
.catch(function (error) {
showError(error && error.message ? error.message : '钉钉免登失败');
});
}
window.__startDingtalkLogin = function () {
if (started) return;
started = true;
var dd = window.dd;
if (!dd || !dd.ready || !dd.runtime || !dd.runtime.permission) {
showError('当前页面未检测到钉钉客户端环境');
return;
}
if (dd.error) {
dd.error(function (error) {
console.log('DingTalk JSAPI error:', error);
showError('钉钉免登初始化失败');
});
}
dd.ready(function () {
setStatus('正在向钉钉获取免登授权码...');
dd.runtime.permission.requestAuthCode({
corpId: corpId,
onSuccess: function (result) {
if (!result || !result.code) {
showError('钉钉未返回免登授权码');
return;
}
submitCode(result.code);
},
onFail: function (error) {
console.log('requestAuthCode failed:', error);
showError('钉钉免登授权失败');
}
});
});
};
})();
</script>
</body>
</html>`,
{
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
},
},
)
}
function createMissingCorpIdPage() {
return new NextResponse(
`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>钉钉免登配置缺失</title>
<style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{width:min(440px,calc(100vw - 48px));padding:28px}
h1{font-size:22px;margin:0 0 12px}
p{margin:0;color:#475569;line-height:1.8}
code{background:#e2e8f0;border-radius:6px;padding:2px 6px}
</style>
</head>
<body>
<main>
<h1>钉钉免登配置缺失</h1>
<p>当前缺少企业 <code>CorpId</code>,无法在钉钉内自动确认身份。请在服务端配置 <code>DINGTALK_CORP_ID</code>,或让入口链接携带 <code>corpId</code> 参数后重试。</p>
</main>
</body>
</html>`,
{
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,
})
}

12
lib/dingtalk-corp.ts Normal file
View File

@ -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,
)
}

466
lib/dingtalk-login.ts Normal file
View File

@ -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<DingtalkUserInfo> {
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)
}

View File

@ -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',