gongxiangp2/lib/external-api.ts

367 lines
7.6 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';
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}`;
}
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,
},
});
// 已有明文 Key 在首次成功调用时原地迁移为哈希,不改变调用方使用方式。
if (!keyRecord) {
keyRecord = await prisma.apiKey.findUnique({
where: { key: apiKey },
select: {
id: true,
name: true,
isActive: true,
createdBy: true,
},
});
}
if (!keyRecord || !keyRecord.isActive) {
return null;
}
// 更新最后使用时间
await prisma.apiKey.update({
where: { id: keyRecord.id },
data: {
lastUsedAt: new Date(),
key: hashedKey,
},
});
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 apiKey = await prisma.apiKey.create({
data: {
name,
key: hashApiKey(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;
}