gongxiangp2/lib/external-api.ts
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

287 lines
5.6 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.

/**
* 外部 API 鉴权和日志
*/
import { prisma } from './prisma';
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
// 错误码
export const API_ERROR = {
INVALID_API_KEY: {
code: 'INVALID_API_KEY',
message: 'API Key 无效或缺失',
status: 401,
},
TASK_NOT_FOUND: {
code: 'TASK_NOT_FOUND',
message: '任务号不存在',
status: 404,
},
MATERIAL_DELETED: {
code: 'MATERIAL_DELETED',
message: '素材已被删除',
status: 410,
},
TOKEN_EXPIRED: {
code: 'TOKEN_EXPIRED',
message: '下载链接已过期,请重新查询获取新链接',
status: 401,
},
INVALID_TOKEN: {
code: 'INVALID_TOKEN',
message: '下载令牌无效',
status: 401,
},
INVALID_TASK_NO: {
code: 'INVALID_TASK_NO',
message: '任务号格式错误',
status: 400,
},
SERVER_ERROR: {
code: 'SERVER_ERROR',
message: '服务器内部错误',
status: 500,
},
} as const;
export type ApiErrorCode = keyof typeof API_ERROR;
/**
* 验证 API Key
* @returns API Key 记录,验证失败返回 null
*/
export async function validateApiKey(apiKey: string | null) {
if (!apiKey) {
return null;
}
const keyRecord = await prisma.apiKey.findUnique({
where: { key: apiKey },
select: {
id: true,
name: true,
isActive: true,
},
});
if (!keyRecord || !keyRecord.isActive) {
return null;
}
// 更新最后使用时间
await prisma.apiKey.update({
where: { id: keyRecord.id },
data: { lastUsedAt: new Date() },
});
return keyRecord;
}
/**
* 从请求头获取 API Key
*/
export function getApiKeyFromRequest(request: NextRequest): string | null {
return request.headers.get('X-API-Key');
}
/**
* 记录 API 调用日志
*/
export async function logApiCall(data: {
apiKeyId: string;
endpoint: string;
method: string;
taskNo?: string;
statusCode: number;
duration: number;
ipAddress?: string;
userAgent?: string;
}) {
try {
await prisma.apiCallLog.create({
data: {
apiKeyId: data.apiKeyId,
endpoint: data.endpoint,
method: data.method,
taskNo: data.taskNo,
statusCode: data.statusCode,
duration: data.duration,
ipAddress: data.ipAddress,
userAgent: data.userAgent,
},
});
} catch (error) {
console.error('[logApiCall] 记录日志失败:', error);
}
}
/**
* 生成错误响应
*/
export function errorResponse(error: typeof API_ERROR[ApiErrorCode]) {
return NextResponse.json(
{
success: false,
error: {
code: error.code,
message: error.message,
},
},
{ status: error.status }
);
}
/**
* 生成成功响应
*/
export function successResponse<T>(data: T, message?: string) {
return NextResponse.json({
success: true,
...(message && { message }),
data,
});
}
/**
* 生成 API Key(32位随机字符串)
*/
export function generateApiKey(): string {
return crypto.randomBytes(16).toString('hex');
}
/**
* 创建 API Key
*/
export async function createApiKey(name: string, createdBy: string) {
const key = generateApiKey();
const apiKey = await prisma.apiKey.create({
data: {
name,
key,
createdBy,
},
});
return {
id: apiKey.id,
name: apiKey.name,
key, // 仅在创建时返回完整 key
createdAt: apiKey.createdAt,
};
}
/**
* 禁用 API Key
*/
export async function disableApiKey(id: string) {
return prisma.apiKey.update({
where: { id },
data: { isActive: false },
});
}
/**
* 启用 API Key
*/
export async function enableApiKey(id: string) {
return prisma.apiKey.update({
where: { id },
data: { isActive: true },
});
}
/**
* 获取 API Key 列表
*/
export async function getApiKeys() {
return prisma.apiKey.findMany({
select: {
id: true,
name: true,
// 不返回完整 key,只返回前8位
key: true,
isActive: true,
lastUsedAt: true,
createdAt: true,
updatedAt: true,
},
orderBy: { createdAt: 'desc' },
});
}
/**
* 获取 API 调用日志
*/
export async function getApiCallLogs(options?: {
apiKeyId?: string;
taskNo?: string;
startDate?: Date;
endDate?: Date;
limit?: number;
offset?: number;
}) {
const where: any = {};
if (options?.apiKeyId) {
where.apiKeyId = options.apiKeyId;
}
if (options?.taskNo) {
where.taskNo = options.taskNo;
}
if (options?.startDate || options?.endDate) {
where.createdAt = {};
if (options.startDate) {
where.createdAt.gte = options.startDate;
}
if (options.endDate) {
where.createdAt.lte = options.endDate;
}
}
const [logs, total] = await Promise.all([
prisma.apiCallLog.findMany({
where,
include: {
apiKey: {
select: { name: true },
},
},
orderBy: { createdAt: 'desc' },
take: options?.limit || 50,
skip: options?.offset || 0,
}),
prisma.apiCallLog.count({ where }),
]);
return { logs, total };
}
/**
* 清理过期日志(90天前)
*/
export async function cleanupOldLogs() {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 90);
const result = await prisma.apiCallLog.deleteMany({
where: {
createdAt: { lt: cutoffDate },
},
});
return result.count;
}
/**
* 获取客户端 IP
*/
export function getClientIp(request: NextRequest): string | undefined {
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
return forwarded.split(',')[0].trim();
}
return request.headers.get('x-real-ip') || undefined;
}