chenpei 4e89b57798 feat(upload): optimize upload UI and performance
- 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
2026-01-28 15:07:50 +08:00

429 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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