- style(theme): 修复浅色模式不生效问题,为 ClassicLight 和 ModernLight 增加独立差异化 CSS 变量定义(主色/圆角/阴影/背景对比度) - feat(blacklist): 后端放开 LANGUAGE/GENRE 类型校验;check API 新增语种(CJK/拉丁/假名/谚文/西里尔/阿拉伯)和曲风关键词匹配逻辑;前端 BlacklistManager UI 增加 语种/曲风 四类型切换、类型筛选、图标与标签颜色映射、输入提示 - fix(DataAnalysisPanel): 活跃用户列表容器加 max-h-[400px] + overflow-y-auto + 底部渐变遮罩;tooltip 加 max-height + truncate 防止超高;头像失败回退首字母 - feat(admin): 新增 user-avatar.ts 工具(OAuth→Gravatar→DiceBear 三级兜底);active-users/realtime/users 列表返回 avatarUrl;DataAnalysisPanel/UserManager 展示圆形头像;权限分级 ROLE_LEVELS,SUPER_ADMIN 全权限,ADMIN 仅可管理 ≤ USER 级用户的密码与角色,前端按钮联动禁用 - fix+style(schedule): ScheduleManager 标签页 sticky min-h 防抖、间距调优、表单分组、移动端 padding 统一 - feat(schedule): 自动排期新增「按投稿人数优先覆盖」策略(算法:requester + collaborators 总数降序),支持 CustomSelect 切换;新增 localStorage voicehub:schedule:auto-config 持久化 7 项配置(策略/时段/年级/排序/标签/搜索/学期)500ms debounce - fix(schedule): songs/index.get 新增 usePool 参数,从 schedule_song_pool(status=PENDING,targetPlayDate IS NULL) 取歌并去重,排除已排期 songs.played=false;schedule.post 和 draft-bulk 分配后更新池状态为 ASSIGNED 避免重复分配 - feat(player): ScheduleManager 新增「构建播放队列」按钮,按 playDate→时段→sequence 排序后经 useAudioPlayer.playSong 整列载入并 toast 反馈 - fix(db): db.ts 检测 NETLIFY/VERCEL serverless 环境使用最小池 max=1 idle_timeout=1 keep_alive=false,Node Server 保持 max=10 - feat(site-config): site-config.get.ts 与 useSiteConfig.js 导出 statisticsCode 和 statisticsCodeEnabled;index.vue 挂载后解析 HTML 动态重插 script 元素确保第三方统计执行 - fix(schedule-printer): ScheduleItemPrint 封面 aspect-square object-cover;print-fix.css 打印时强制 48px×48px contain、去除圆角阴影 - fix(songRequest): 新增 server/utils/url.ts normalizeCoverUrl(trim/去零宽空格/相对协议补 https/非 http 丢弃/URL.parse 兜底);request.post/add.post/import.post 三处写入 cover/coverUrl 前统一规范化
178 lines
6.2 KiB
TypeScript
178 lines
6.2 KiB
TypeScript
import {drizzle} from 'drizzle-orm/postgres-js';
|
||
import {and, asc, count, desc, eq, exists, gt, gte, lt, lte, or, sql} from 'drizzle-orm';
|
||
import postgres from 'postgres';
|
||
import * as schema from './schema.ts';
|
||
import {config} from 'dotenv';
|
||
import path from 'path';
|
||
import {fileURLToPath} from 'url';
|
||
|
||
// 加载环境变量(优先使用工作目录的 .env,确保构建后运行时能正确加载)
|
||
config({ path: path.resolve(process.cwd(), '.env') });
|
||
|
||
// 检查环境变量
|
||
if (!process.env.DATABASE_URL) {
|
||
throw new Error('DATABASE_URL environment variable is not set');
|
||
}
|
||
|
||
// 创建PostgreSQL连接
|
||
const connectionString = process.env.DATABASE_URL;
|
||
|
||
// 检测数据库类型
|
||
const isNeonDatabase = connectionString.includes('neon.tech') || connectionString.includes('neon.database.com');
|
||
|
||
// 检测 Serverless 部署环境
|
||
const isServerlessEnv = process.env.NETLIFY === 'true' || !!process.env.VERCEL;
|
||
|
||
// 根据数据库类型和部署环境选择配置
|
||
const getDatabaseConfig = () => {
|
||
if (isNeonDatabase || isServerlessEnv) {
|
||
// Neon Database / Serverless 环境 (Netlify/Vercel) 优化配置
|
||
return {
|
||
max: 1, // Serverless 环境下每个实例保持最小连接数,利用平台自身的连接池
|
||
idle_timeout: 1, // 尽快释放空闲连接,适应 Serverless 的快速冻结特性
|
||
connect_timeout: 10, // 减少连接超时时间
|
||
max_lifetime: 3600, // 连接最大生命周期(1小时)
|
||
ssl: isNeonDatabase ? 'require' : (connectionString.includes('sslmode=require') || connectionString.includes('ssl=true') ? 'require' : false),
|
||
prepare: false, // 禁用预处理语句以提高兼容性
|
||
keep_alive: false, // 禁用 TCP keep-alive,避免 Serverless 环境下连接残留
|
||
transform: {
|
||
undefined: null, // 将undefined转换为null
|
||
},
|
||
connection: {
|
||
application_name: 'voicehub-app'
|
||
},
|
||
onnotice: process.env.NODE_ENV === 'development' ? console.log : undefined,
|
||
debug: process.env.NODE_ENV === 'development' && process.env.DEBUG_SQL === 'true'
|
||
};
|
||
} else {
|
||
// 标准 PostgreSQL 数据库配置(本地开发 / 常规 Node Server)
|
||
return {
|
||
max: 10, // 普通PostgreSQL可以支持更多连接
|
||
idle_timeout: 20, // 空闲超时时间
|
||
connect_timeout: 30, // 连接超时时间以适应网络延迟
|
||
max_lifetime: 3600, // 连接最大生命周期(1小时)
|
||
ssl: connectionString.includes('sslmode=require') || connectionString.includes('ssl=true') ? 'require' : false,
|
||
prepare: false, // 禁用预处理语句以提高兼容性
|
||
transform: {
|
||
undefined: null, // 将undefined转换为null
|
||
},
|
||
connection: {
|
||
application_name: 'voicehub-app'
|
||
},
|
||
onnotice: process.env.NODE_ENV === 'development' ? console.log : undefined,
|
||
debug: process.env.NODE_ENV === 'development' && process.env.DEBUG_SQL === 'true'
|
||
};
|
||
}
|
||
};
|
||
|
||
const client = postgres(connectionString, getDatabaseConfig());
|
||
|
||
// 创建Drizzle数据库实例
|
||
export const db = drizzle(client, { schema });
|
||
|
||
// 导出连接客户端(用于手动查询或关闭连接)
|
||
export { client };
|
||
|
||
// 导出schema以便在其他地方使用
|
||
export * from './schema.ts';
|
||
|
||
// 导出drizzle-orm函数
|
||
export {eq, and, gt, gte, lt, lte, count, exists, desc, asc, or, sql};
|
||
|
||
// 数据库连接测试函数
|
||
export async function testConnection() {
|
||
try {
|
||
await client`SELECT 1`;
|
||
console.log('✅ Database connection successful');
|
||
return true;
|
||
} catch (error) {
|
||
console.error('❌ Database connection failed:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 获取数据库连接状态
|
||
export function getConnectionStatus() {
|
||
return {
|
||
isConnected: !client.ended,
|
||
maxConnections: client.options.max,
|
||
idleTimeout: client.options.idle_timeout,
|
||
connectTimeout: client.options.connect_timeout
|
||
};
|
||
}
|
||
|
||
// 连接管理 - 根据数据库类型自适应
|
||
let idleTimer: NodeJS.Timeout | null = null;
|
||
// Neon 数据库使用更短的空闲时间以支持自动启停,普通 PostgreSQL 使用更长的空闲时间
|
||
const IDLE_TIMEOUT = isNeonDatabase ? 5 * 60 * 1000 : 10 * 60 * 1000; // Neon: 5分钟,PostgreSQL: 10分钟
|
||
|
||
// 重置空闲计时器
|
||
function resetIdleTimer() {
|
||
if (idleTimer) {
|
||
clearTimeout(idleTimer);
|
||
}
|
||
|
||
// 只在生产环境启用自动断开
|
||
if (process.env.NODE_ENV === 'production') {
|
||
idleTimer = setTimeout(async () => {
|
||
try {
|
||
if (!client.ended) {
|
||
const dbType = isNeonDatabase ? 'Neon' : 'PostgreSQL';
|
||
console.log(`🔄 Auto-closing idle ${dbType} database connections${isNeonDatabase ? ' for Serverless optimization' : ''}`);
|
||
await client.end({ timeout: isNeonDatabase ? 5 : 10 });
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ Error during auto-close:', error);
|
||
}
|
||
}, IDLE_TIMEOUT);
|
||
}
|
||
}
|
||
|
||
// 包装数据库操作以支持自动启停
|
||
export function withAutoReconnect<T extends any[], R>(
|
||
operation: (...args: T) => Promise<R>
|
||
) {
|
||
return async (...args: T): Promise<R> => {
|
||
resetIdleTimer();
|
||
|
||
try {
|
||
return await operation(...args);
|
||
} catch (error: any) {
|
||
// 如果连接已关闭,记录信息
|
||
if (error?.code === 'CONNECTION_ENDED' || client.ended) {
|
||
const dbType = isNeonDatabase ? 'Neon' : 'PostgreSQL';
|
||
console.log(`🔄 ${dbType} database connection ended${isNeonDatabase ? ', Neon will auto-reconnect on next query' : ', will reconnect on next query'}`);
|
||
}
|
||
throw error;
|
||
}
|
||
};
|
||
}
|
||
|
||
// 优雅关闭数据库连接
|
||
export async function closeConnection() {
|
||
try {
|
||
if (idleTimer) {
|
||
clearTimeout(idleTimer);
|
||
idleTimer = null;
|
||
}
|
||
|
||
if (!client.ended) {
|
||
await client.end({ timeout: 10 });
|
||
console.log('✅ Database connection closed gracefully');
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ Error closing database connection:', error);
|
||
}
|
||
}
|
||
|
||
// 设置优雅关闭处理
|
||
if (typeof process !== 'undefined') {
|
||
const gracefulShutdown = async () => {
|
||
console.log('🔄 Shutting down database connections...');
|
||
await closeConnection();
|
||
};
|
||
|
||
process.on('SIGINT', gracefulShutdown);
|
||
process.on('SIGTERM', gracefulShutdown);
|
||
process.on('beforeExit', gracefulShutdown);
|
||
} |