2026-05-25 14:31:58 +08:00

214 lines
5.5 KiB
TypeScript
Raw 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.

/**
* 数据库访问层
*
* 支持多项目架构:
* - 主库:管理项目和用户
* - 项目库:每个项目独立的许可证数据库
*/
import { PrismaClient as MainPrismaClient } from '.prisma/main-client';
import { PrismaClient as ProjectPrismaClient } from '.prisma/project-client';
// 主库客户端(单例)
const globalForMainPrisma = globalThis as unknown as {
mainPrisma: MainPrismaClient | undefined;
};
export const mainPrisma =
globalForMainPrisma.mainPrisma ??
new MainPrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForMainPrisma.mainPrisma = mainPrisma;
// 项目库客户端缓存
const projectClients = new Map<string, { client: ProjectPrismaClient; lastUsed: number }>();
const MAX_CACHED_CLIENTS = 10;
const CLIENT_EXPIRY_MS = 30 * 60 * 1000;
// 加密密钥
const getEncryptionKey = (): string => {
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 crypto = require('crypto');
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 crypto = require('crypto');
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;
}
// 项目配置类型
type ProjectConfig = {
dbHost: string;
dbPort: number;
dbUser: string;
dbPassword: string;
dbName: string;
dbSchema: string;
};
/**
* 构建项目数据库连接 URL(使用加密密码,用于已保存的项目)
*/
function buildProjectDbUrl(project: ProjectConfig): string {
const password = decryptPassword(project.dbPassword);
return `postgresql://${project.dbUser}:${encodeURIComponent(password)}@${project.dbHost}:${project.dbPort}/${project.dbName}?schema=${project.dbSchema}`;
}
/**
* 清理过期的客户端连接
*/
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 getProjectDb(projectCode: string): Promise<ProjectPrismaClient> {
cleanupExpiredClients();
// 检查缓存
const cached = projectClients.get(projectCode);
if (cached) {
cached.lastUsed = Date.now();
return cached.client;
}
// 从主库获取项目配置
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 ProjectPrismaClient({
datasourceUrl: dbUrl,
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
// 缓存管理
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 testProjectConnection(config: {
dbHost: string;
dbPort: number;
dbUser: string;
dbPassword: string;
dbName: string;
dbSchema: string;
}): Promise<{ success: boolean; error?: string }> {
let client: ProjectPrismaClient | null = null;
try {
// 直接使用明文密码构建连接 URL(仅用于测试)
const dbUrl = `postgresql://${config.dbUser}:${encodeURIComponent(config.dbPassword)}@${config.dbHost}:${config.dbPort}/${config.dbName}?schema=${config.dbSchema}`;
client = new ProjectPrismaClient({
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();
}
}
}
/**
* 断开所有连接
*/
export async function disconnectAll(): Promise<void> {
await mainPrisma.$disconnect();
for (const value of Array.from(projectClients.values())) {
await value.client.$disconnect();
}
projectClients.clear();
}