chenpei ea9076df45 feat: 实现统一的下载错误处理系统并修复产品全案数据库Schema
- 创建统一的下载错误处理模块 (lib/download-errors.ts)
- 实现13种错误类型分类和中文错误消息
- 后端统一使用 createDownloadError 创建标准化错误响应
- 前端统一使用 parseDownloadError 解析错误并显示
- 修复正式系统数据库Schema,添加产品全案相关字段和表
- 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段
- 创建 material_assets, api_keys, api_call_logs 表
2026-01-05 17:52:42 +08:00

396 lines
12 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'
// 钉钉应用配置
const DINGTALK_CONFIG: Record<string, { clientId: string; clientSecret: string }> = {
default: {
clientId: process.env.DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f',
clientSecret: process.env.DINGTALK_CLIENT_SECRET || 'qUmj_gBDzpPEYR3RA3hRqKzwu11a986O-wKfP8V5lDHXn7XJrXxW79KIeP5G-mcY',
}
}
// 获取用户访问令牌
async function getUserAccessToken(code: string, company: string = 'default') {
const config = DINGTALK_CONFIG[company]
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(company: string = 'default') {
const config = DINGTALK_CONFIG[company]
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),不是公司标识
// 始终使用 default 配置
const company = 'default'
console.log('开始钉钉登录流程:', { code, company })
// 1. 获取用户访问令牌
const tokenData = await getUserAccessToken(code, company)
// 检查返回的数据
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(company)
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.findUnique({
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 }
)
}
}