- Expand batch upload list area and optimize preview image layout - Improve upload speed tracking and display (server-side & client-side) - Reduce network detection log noise - Optimize Docker build process (caching, env validation) - Add shadow environment deployment support
429 lines
13 KiB
TypeScript
429 lines
13 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
||
import { prisma } from '@/lib/prisma'
|
||
import bcrypt from 'bcryptjs'
|
||
|
||
/**
|
||
* 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() {
|
||
const config = getDingtalkConfig()
|
||
const url = 'https://oapi.dingtalk.com/gettoken'
|
||
|
||
const response = await fetch(`${url}?appkey=${config.clientId}&appsecret=${config.clientSecret}`)
|
||
|
||
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 || '未知错误'}`)
|
||
}
|
||
|
||
// 检查 access_token 是否存在
|
||
if (!data.access_token) {
|
||
console.error('获取access_token返回数据异常:', data)
|
||
throw new Error('获取应用访问令牌失败: 返回数据中没有access_token')
|
||
}
|
||
|
||
return data.access_token
|
||
}
|
||
|
||
// 根据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()
|
||
|
||
if (!code) {
|
||
return NextResponse.json(
|
||
{ success: false, message: '缺少授权码' },
|
||
{ 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) {
|
||
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) {
|
||
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);
|
||
// 不阻塞登录流程
|
||
});
|
||
|
||
// 返回用户信息(不包含敏感信息)
|
||
return NextResponse.json({
|
||
success: true,
|
||
needCompleteProfile: false,
|
||
message: '登录成功',
|
||
user: {
|
||
id: user.id,
|
||
username: user.username,
|
||
email: user.email,
|
||
realName: user.realName,
|
||
avatar: user.avatar,
|
||
role: user.role,
|
||
department: user.department
|
||
}
|
||
})
|
||
|
||
} catch (error: any) {
|
||
console.error('钉钉登录失败:', error)
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
message: error.message || '登录失败,请稍后再试'
|
||
},
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
}
|
||
|