gongxiangp2/lib/external-api.ts
chenpei 02ebaabd0e feat(外部接口): 新增自动上架成套通知能力
- 新增任务-详情全套入库后的自动上架通知与重试补发能力

- 扩展 product-assets 支持按 materialId 拉取正式资源

- 补充后台通知目标管理、投递记录和对接文档
2026-03-25 20:39:56 +08:00

302 lines
5.9 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,
},
INVALID_QUERY: {
code: 'INVALID_QUERY',
message: '请提供 taskNo 或 materialId',
status: 400,
},
INVALID_MATERIAL_ID: {
code: 'INVALID_MATERIAL_ID',
message: '素材 ID 格式错误',
status: 400,
},
MATERIAL_NOT_FOUND: {
code: 'MATERIAL_NOT_FOUND',
message: '素材不存在',
status: 404,
},
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;
}