/** * 外部 API 鉴权和日志 */ import { prisma } from './prisma'; import { NextRequest, NextResponse } from 'next/server'; import crypto from 'crypto'; const API_KEY_HASH_PREFIX = 'v1'; const API_KEY_ENCRYPTION_ENV = 'API_KEY_ENCRYPTION_KEY'; const API_KEY_ENCRYPTION_ALGORITHM = 'aes-256-gcm'; export class ApiKeyEncryptionConfigError extends Error { constructor() { super(`${API_KEY_ENCRYPTION_ENV} 未配置或长度不足 32 个字符`); this.name = 'ApiKeyEncryptionConfigError'; } } export class ApiKeyNotRecoverableError extends Error { constructor() { super('该历史 API Key 只保存了哈希,无法恢复原文,请创建新 Key 后逐步替换'); this.name = 'ApiKeyNotRecoverableError'; } } function hashApiKey(apiKey: string): string { const preview = apiKey.slice(0, 8); const digest = crypto.createHash('sha256').update(apiKey).digest('hex'); return `${API_KEY_HASH_PREFIX}:${preview}:${digest}`; } function isHashedApiKey(storedKey: string): boolean { return /^v1:[^:]{1,16}:[a-f0-9]{64}$/i.test(storedKey); } function getApiKeyEncryptionKey(): Buffer { const secret = process.env[API_KEY_ENCRYPTION_ENV]?.trim(); if (!secret || Buffer.byteLength(secret, 'utf8') < 32) { throw new ApiKeyEncryptionConfigError(); } return crypto.createHash('sha256').update(secret, 'utf8').digest(); } function isApiKeyEncryptionConfigured(): boolean { const secret = process.env[API_KEY_ENCRYPTION_ENV]?.trim(); return Boolean(secret && Buffer.byteLength(secret, 'utf8') >= 32); } function encryptApiKeySecret(apiKey: string) { const nonce = crypto.randomBytes(12); const cipher = crypto.createCipheriv( API_KEY_ENCRYPTION_ALGORITHM, getApiKeyEncryptionKey(), nonce, ); const ciphertext = Buffer.concat([cipher.update(apiKey, 'utf8'), cipher.final()]); return { secretCiphertext: ciphertext.toString('base64'), secretNonce: nonce.toString('base64'), secretAuthTag: cipher.getAuthTag().toString('base64'), }; } function decryptApiKeySecret(input: { secretCiphertext: string; secretNonce: string; secretAuthTag: string; }) { const decipher = crypto.createDecipheriv( API_KEY_ENCRYPTION_ALGORITHM, getApiKeyEncryptionKey(), Buffer.from(input.secretNonce, 'base64'), ); decipher.setAuthTag(Buffer.from(input.secretAuthTag, 'base64')); return Buffer.concat([ decipher.update(Buffer.from(input.secretCiphertext, 'base64')), decipher.final(), ]).toString('utf8'); } export function getApiKeyPreview(storedKey: string): string { const match = storedKey.match(/^v1:([^:]{1,16}):[a-f0-9]{64}$/i); return `${match?.[1] || storedKey.slice(0, 8)}...`; } // 错误码 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_BARCODE: { code: 'INVALID_BARCODE', message: '产品条码不能为空', status: 400, }, INVALID_MATERIAL_ID: { code: 'INVALID_MATERIAL_ID', message: '素材 ID 格式错误', status: 400, }, MATERIAL_NOT_FOUND: { code: 'MATERIAL_NOT_FOUND', message: '素材不存在', status: 404, }, BARCODE_NOT_FOUND: { code: 'BARCODE_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 hashedKey = hashApiKey(apiKey); let keyRecord = await prisma.apiKey.findUnique({ where: { key: hashedKey }, select: { id: true, name: true, isActive: true, createdBy: true, }, }); let matchedLegacyPlaintext = false; // 已有明文 Key 在加密配置就绪后,于首次成功调用时原地迁移,不改变调用方使用方式。 if (!keyRecord) { keyRecord = await prisma.apiKey.findUnique({ where: { key: apiKey }, select: { id: true, name: true, isActive: true, createdBy: true, }, }); matchedLegacyPlaintext = Boolean(keyRecord); } if (!keyRecord || !keyRecord.isActive) { return null; } const legacySecretUpdate = matchedLegacyPlaintext && isApiKeyEncryptionConfigured() ? { key: hashedKey, ...encryptApiKeySecret(apiKey), } : {}; // 更新最后使用时间;历史明文只有在可同时加密留存时才迁移为哈希。 await prisma.apiKey.update({ where: { id: keyRecord.id }, data: { lastUsedAt: new Date(), ...legacySecretUpdate, }, }); return keyRecord; } /** * 从请求中获取 API Key。 * * 默认只接受请求头;少数低代码平台不方便配置固定 Header 时, * 可由具体路由显式开启 URL 参数兼容。 */ export function getApiKeyFromRequest( request: NextRequest, options?: { allowQuery?: boolean }, ): string | null { const headerKey = request.headers.get('X-API-Key')?.trim(); if (headerKey) { return headerKey; } if (!options?.allowQuery) { return null; } const queryKey = request.nextUrl.searchParams.get('apiKey') || request.nextUrl.searchParams.get('xApiKey') || request.nextUrl.searchParams.get('api_key'); return queryKey?.trim() || null; } /** * 记录 API 调用日志 */ export async function logApiCall(data: { apiKeyId: string; endpoint: string; method: string; taskNo?: string; queryType?: string; queryValue?: 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, queryType: data.queryType, queryValue: data.queryValue, 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(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 encryptedSecret = encryptApiKeySecret(key); const apiKey = await prisma.apiKey.create({ data: { name, key: hashApiKey(key), ...encryptedSecret, createdBy, }, }); return { id: apiKey.id, name: apiKey.name, 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() { const apiKeys = await prisma.apiKey.findMany({ select: { id: true, name: true, // 不返回完整 key,只返回前8位 key: true, secretCiphertext: true, secretNonce: true, secretAuthTag: true, isActive: true, lastUsedAt: true, createdAt: true, updatedAt: true, }, orderBy: { createdAt: 'desc' }, }); return apiKeys.map(({ secretCiphertext, secretNonce, secretAuthTag, ...apiKey }) => ({ ...apiKey, canReveal: Boolean(secretCiphertext && secretNonce && secretAuthTag) || !isHashedApiKey(apiKey.key), })); } /** * 按需读取完整 API Key。新 Key 从加密列解密;历史明文在首次查看时原地加密迁移。 */ export async function revealApiKey(id: string, viewerUserId: string): Promise { const apiKey = await prisma.apiKey.findUnique({ where: { id }, select: { id: true, key: true, secretCiphertext: true, secretNonce: true, secretAuthTag: true, }, }); if (!apiKey) { return null; } if (apiKey.secretCiphertext && apiKey.secretNonce && apiKey.secretAuthTag) { const secret = decryptApiKeySecret({ secretCiphertext: apiKey.secretCiphertext, secretNonce: apiKey.secretNonce, secretAuthTag: apiKey.secretAuthTag, }); if (isHashedApiKey(apiKey.key) && hashApiKey(secret) !== apiKey.key) { throw new Error('API Key 加密原文与鉴权哈希不一致'); } await prisma.apiKeyRevealAudit.create({ data: { apiKeyId: id, viewerUserId }, }); return secret; } if (isHashedApiKey(apiKey.key)) { throw new ApiKeyNotRecoverableError(); } const legacyPlaintext = apiKey.key; await prisma.$transaction([ prisma.apiKey.update({ where: { id }, data: { key: hashApiKey(legacyPlaintext), ...encryptApiKeySecret(legacyPlaintext), }, }), prisma.apiKeyRevealAudit.create({ data: { apiKeyId: id, viewerUserId }, }), ]); return legacyPlaintext; } /** * 获取 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; }