367 lines
7.6 KiB
TypeScript
367 lines
7.6 KiB
TypeScript
/**
|
||
* 外部 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;
|
||
}
|