gongxiangp2/lib/external-api.ts

509 lines
12 KiB
TypeScript
Raw Permalink 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';
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<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 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<string | null> {
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;
}