214 lines
5.5 KiB
TypeScript
214 lines
5.5 KiB
TypeScript
/**
|
||
* 数据库访问层
|
||
*
|
||
* 支持多项目架构:
|
||
* - 主库:管理项目和用户
|
||
* - 项目库:每个项目独立的许可证数据库
|
||
*/
|
||
|
||
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();
|
||
}
|