206 lines
5.1 KiB
TypeScript
206 lines
5.1 KiB
TypeScript
import { PrismaClient } from '.prisma/main-client';
|
||
import type { Project } from '.prisma/main-client';
|
||
import * as crypto from 'crypto';
|
||
|
||
// 主库客户端(单例)
|
||
let mainClient: PrismaClient | null = null;
|
||
|
||
// 项目库客户端缓存(LRU,最多缓存 10 个连接)
|
||
const projectClients = new Map<string, { client: PrismaClient; lastUsed: number }>();
|
||
const MAX_CACHED_CLIENTS = 10;
|
||
const CLIENT_EXPIRY_MS = 30 * 60 * 1000; // 30 分钟未使用则释放
|
||
|
||
// 加密密钥(从环境变量获取)
|
||
const getEncryptionKey = () => {
|
||
const key = process.env.MAIN_DB_ENCRYPTION_KEY;
|
||
if (!key || key.length < 32) {
|
||
throw new Error('MAIN_DB_ENCRYPTION_KEY 环境变量未设置或长度不足 32 字节');
|
||
}
|
||
return key.substring(0, 32);
|
||
};
|
||
|
||
/**
|
||
* 加密数据库密码
|
||
*/
|
||
export function encryptPassword(password: string): string {
|
||
const key = getEncryptionKey();
|
||
const iv = crypto.randomBytes(16);
|
||
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
||
|
||
let encrypted = cipher.update(password, 'utf8', 'hex');
|
||
encrypted += cipher.final('hex');
|
||
|
||
return iv.toString('hex') + ':' + encrypted;
|
||
}
|
||
|
||
/**
|
||
* 解密数据库密码
|
||
*/
|
||
export function decryptPassword(encryptedPassword: string): string {
|
||
const key = getEncryptionKey();
|
||
const [ivHex, encrypted] = encryptedPassword.split(':');
|
||
|
||
if (!ivHex || !encrypted) {
|
||
throw new Error('密码格式无效');
|
||
}
|
||
|
||
const iv = Buffer.from(ivHex, 'hex');
|
||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||
|
||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||
decrypted += decipher.final('utf8');
|
||
|
||
return decrypted;
|
||
}
|
||
|
||
/**
|
||
* 获取主库客户端
|
||
*/
|
||
export function getMainClient(): PrismaClient {
|
||
if (!mainClient) {
|
||
mainClient = new PrismaClient();
|
||
}
|
||
return mainClient;
|
||
}
|
||
|
||
/**
|
||
* 构建项目数据库连接 URL
|
||
*/
|
||
function buildProjectDbUrl(project: Project): string {
|
||
const password = decryptPassword(project.dbPassword);
|
||
return `postgresql://${project.dbUser}:${encodeURIComponent(password)}@${project.dbHost}:${project.dbPort}/${project.dbName}?schema=${project.dbSchema}`;
|
||
}
|
||
|
||
/**
|
||
* 获取项目库客户端(动态创建,带缓存)
|
||
*/
|
||
export async function getProjectClient(projectCode: string): Promise<PrismaClient> {
|
||
// 清理过期连接
|
||
cleanupExpiredClients();
|
||
|
||
// 检查缓存
|
||
const cached = projectClients.get(projectCode);
|
||
if (cached) {
|
||
cached.lastUsed = Date.now();
|
||
return cached.client;
|
||
}
|
||
|
||
// 从主库获取项目配置
|
||
const mainPrisma = getMainClient();
|
||
const project = await mainPrisma.project.findUnique({
|
||
where: { code: projectCode },
|
||
});
|
||
|
||
if (!project) {
|
||
throw new Error(`项目不存在: ${projectCode}`);
|
||
}
|
||
|
||
if (!project.isActive) {
|
||
throw new Error(`项目已禁用: ${projectCode}`);
|
||
}
|
||
|
||
// 创建新的项目库客户端
|
||
const dbUrl = buildProjectDbUrl(project);
|
||
const client = new PrismaClient({
|
||
datasourceUrl: dbUrl,
|
||
});
|
||
|
||
// 缓存客户端
|
||
if (projectClients.size >= MAX_CACHED_CLIENTS) {
|
||
// 移除最久未使用的客户端
|
||
let oldestKey: string | null = null;
|
||
let oldestTime = Infinity;
|
||
|
||
projectClients.forEach((value, key) => {
|
||
if (value.lastUsed < oldestTime) {
|
||
oldestTime = value.lastUsed;
|
||
oldestKey = key;
|
||
}
|
||
});
|
||
|
||
if (oldestKey) {
|
||
const oldClient = projectClients.get(oldestKey);
|
||
if (oldClient) {
|
||
await oldClient.client.$disconnect();
|
||
}
|
||
projectClients.delete(oldestKey);
|
||
}
|
||
}
|
||
|
||
projectClients.set(projectCode, { client, lastUsed: Date.now() });
|
||
|
||
return client;
|
||
}
|
||
|
||
/**
|
||
* 根据项目配置直接创建客户端(用于测试连接)
|
||
*/
|
||
export async function createProjectClient(project: Project): Promise<PrismaClient> {
|
||
const dbUrl = buildProjectDbUrl(project);
|
||
const client = new PrismaClient({
|
||
datasourceUrl: dbUrl,
|
||
});
|
||
return client;
|
||
}
|
||
|
||
/**
|
||
* 测试项目数据库连接
|
||
*/
|
||
export async function testProjectConnection(project: Project): Promise<{ success: boolean; error?: string }> {
|
||
let client: PrismaClient | null = null;
|
||
|
||
try {
|
||
const dbUrl = buildProjectDbUrl(project);
|
||
client = new PrismaClient({
|
||
datasourceUrl: dbUrl,
|
||
});
|
||
|
||
await client.$connect();
|
||
await client.$queryRaw`SELECT 1`;
|
||
|
||
return { success: true };
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : '连接失败',
|
||
};
|
||
} finally {
|
||
if (client) {
|
||
await client.$disconnect();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理过期的客户端连接
|
||
*/
|
||
function cleanupExpiredClients(): void {
|
||
const now = Date.now();
|
||
|
||
projectClients.forEach((value, key) => {
|
||
if (now - value.lastUsed > CLIENT_EXPIRY_MS) {
|
||
value.client.$disconnect().catch(console.error);
|
||
projectClients.delete(key);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 断开所有连接(应用关闭时调用)
|
||
*/
|
||
export async function disconnectAll(): Promise<void> {
|
||
if (mainClient) {
|
||
await mainClient.$disconnect();
|
||
mainClient = null;
|
||
}
|
||
|
||
for (const value of Array.from(projectClients.values())) {
|
||
await value.client.$disconnect();
|
||
}
|
||
|
||
projectClients.clear();
|
||
}
|
||
|
||
// 导出类型
|
||
export type { Project } from '.prisma/main-client';
|