From 4901e2c5691be18fe4a1003ec54e6ad15838ab4a Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 8 Feb 2026 09:23:37 +0800 Subject: [PATCH] =?UTF-8?q?postgres=E9=80=82=E9=85=8Dmusic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/postgres/002_add_music.sql | 65 ++++ scripts/init-postgres.js | 48 ++- src/lib/postgres.db.ts | 451 +++++++++++++++++++++++++- 3 files changed, 548 insertions(+), 16 deletions(-) create mode 100644 migrations/postgres/002_add_music.sql diff --git a/migrations/postgres/002_add_music.sql b/migrations/postgres/002_add_music.sql new file mode 100644 index 0000000..9a16083 --- /dev/null +++ b/migrations/postgres/002_add_music.sql @@ -0,0 +1,65 @@ +-- ============================================ +-- MoonTV Plus - 音乐模块数据表 (PostgreSQL) +-- 版本: 1.2.0 +-- 创建时间: 2026-02-08 +-- ============================================ + +-- 音乐播放记录表 +CREATE TABLE IF NOT EXISTS music_play_records ( + username TEXT NOT NULL, + key TEXT NOT NULL, -- format: "platform+id" (e.g., "netease+12345") + platform TEXT NOT NULL CHECK(platform IN ('netease', 'qq', 'kuwo')), -- 音乐平台 + song_id TEXT NOT NULL, -- 歌曲ID + name TEXT NOT NULL, -- 歌曲名 + artist TEXT NOT NULL, -- 艺术家 + album TEXT, -- 专辑(可选) + pic TEXT, -- 封面图URL(可选) + play_time REAL NOT NULL DEFAULT 0, -- 播放进度(秒) + duration REAL NOT NULL DEFAULT 0, -- 总时长(秒) + save_time BIGINT NOT NULL, -- 保存时间戳 + PRIMARY KEY (username, key), + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); + +-- 创建索引以提高查询性能 +CREATE INDEX IF NOT EXISTS idx_music_play_records_username ON music_play_records(username); +CREATE INDEX IF NOT EXISTS idx_music_play_records_save_time ON music_play_records(username, save_time DESC); +CREATE INDEX IF NOT EXISTS idx_music_play_records_platform ON music_play_records(username, platform); + +-- ============================================ +-- 音乐歌单表 +-- ============================================ + +-- 音乐歌单表 +CREATE TABLE IF NOT EXISTS music_playlists ( + id TEXT NOT NULL, -- 歌单ID (UUID) + username TEXT NOT NULL, -- 用户名 + name TEXT NOT NULL, -- 歌单名称 + description TEXT, -- 歌单描述(可选) + cover TEXT, -- 歌单封面(可选,使用第一首歌的封面) + created_at BIGINT NOT NULL, -- 创建时间戳 + updated_at BIGINT NOT NULL, -- 更新时间戳 + PRIMARY KEY (id), + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); + +-- 音乐歌单歌曲关联表 +CREATE TABLE IF NOT EXISTS music_playlist_songs ( + playlist_id TEXT NOT NULL, -- 歌单ID + platform TEXT NOT NULL CHECK(platform IN ('netease', 'qq', 'kuwo')), -- 音乐平台 + song_id TEXT NOT NULL, -- 歌曲ID + name TEXT NOT NULL, -- 歌曲名 + artist TEXT NOT NULL, -- 艺术家 + album TEXT, -- 专辑(可选) + pic TEXT, -- 封面图URL(可选) + duration REAL NOT NULL DEFAULT 0, -- 总时长(秒) + added_at BIGINT NOT NULL, -- 添加时间戳 + sort_order INTEGER NOT NULL DEFAULT 0, -- 排序顺序 + PRIMARY KEY (playlist_id, platform, song_id), + FOREIGN KEY (playlist_id) REFERENCES music_playlists(id) ON DELETE CASCADE +); + +-- 创建索引以提高查询性能 +CREATE INDEX IF NOT EXISTS idx_music_playlists_username ON music_playlists(username, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_music_playlist_songs_playlist ON music_playlist_songs(playlist_id, sort_order ASC); +CREATE INDEX IF NOT EXISTS idx_music_playlist_songs_added_at ON music_playlist_songs(playlist_id, added_at DESC); diff --git a/scripts/init-postgres.js b/scripts/init-postgres.js index 7ae0735..b52a71a 100644 --- a/scripts/init-postgres.js +++ b/scripts/init-postgres.js @@ -18,30 +18,50 @@ console.log('📦 Initializing Vercel Postgres database...'); const fs = require('fs'); const path = require('path'); -const sqlPath = path.join(__dirname, '../migrations/postgres/001_initial_schema.sql'); -if (!fs.existsSync(sqlPath)) { - console.error('❌ Migration file not found:', sqlPath); +// 获取所有迁移文件 +const migrationsDir = path.join(__dirname, '../migrations/postgres'); +if (!fs.existsSync(migrationsDir)) { + console.error('❌ Migrations directory not found:', migrationsDir); process.exit(1); } -const schemaSql = fs.readFileSync(sqlPath, 'utf8'); +// 读取并排序所有 .sql 文件 +const migrationFiles = fs.readdirSync(migrationsDir) + .filter(file => file.endsWith('.sql')) + .sort(); // 按文件名排序,确保按顺序执行 + +if (migrationFiles.length === 0) { + console.error('❌ No migration files found in:', migrationsDir); + process.exit(1); +} + +console.log(`📄 Found ${migrationFiles.length} migration file(s):`, migrationFiles.join(', ')); async function init() { try { - // 执行 schema 创建 - console.log('🔧 Creating database schema...'); + // 执行所有迁移脚本 + console.log('🔧 Running database migrations...'); - // 将 SQL 脚本按语句分割并逐个执行 - const statements = schemaSql - .split(';') - .map(s => s.trim()) - .filter(s => s.length > 0); + for (const migrationFile of migrationFiles) { + const sqlPath = path.join(migrationsDir, migrationFile); + console.log(` ⏳ Executing ${migrationFile}...`); - for (const statement of statements) { - await sql.query(statement); + const schemaSql = fs.readFileSync(sqlPath, 'utf8'); + + // 将 SQL 脚本按语句分割并逐个执行 + const statements = schemaSql + .split(';') + .map(s => s.trim()) + .filter(s => s.length > 0); + + for (const statement of statements) { + await sql.query(statement); + } + + console.log(` ✅ ${migrationFile} executed successfully`); } - console.log('✅ Database schema created successfully!'); + console.log('✅ All migrations completed successfully!'); // 创建默认管理员用户 const username = process.env.USERNAME || 'admin'; diff --git a/src/lib/postgres.db.ts b/src/lib/postgres.db.ts index a14efbb..979dd52 100644 --- a/src/lib/postgres.db.ts +++ b/src/lib/postgres.db.ts @@ -414,14 +414,21 @@ export class PostgresStorage implements IStorage { async getUserInfoV2(userName: string): Promise { try { - // 先尝试从数据库获取用户信息 + // 先尝试从缓存获取用户信息 + const { userInfoCache } = await import('./user-cache'); + const cached = userInfoCache.get(userName); + if (cached) { + return cached; + } + + // 从数据库获取用户信息 const user = await this.db .prepare('SELECT * FROM users WHERE username = $1') .bind(userName) .first(); if (user) { - return { + const userInfo = { role: user.role as 'owner' | 'admin' | 'user', banned: user.banned === 1, tags: user.tags ? JSON.parse(user.tags as string) : undefined, @@ -435,6 +442,11 @@ export class PostgresStorage implements IStorage { email: user.email as string | undefined, emailNotifications: user.email_notifications === 1, }; + + // 缓存用户信息 + userInfoCache.set(userName, userInfo); + + return userInfo; } // 如果数据库中没有,检查是否是环境变量中的站长 @@ -471,6 +483,9 @@ export class PostgresStorage implements IStorage { // 即使插入失败,仍然返回默认信息 } + // 缓存站长信息 + userInfoCache.set(userName, ownerInfo); + return ownerInfo; } @@ -681,6 +696,10 @@ export class PostgresStorage implements IStorage { .prepare(`UPDATE users SET ${fields.join(', ')} WHERE username = $${paramIndex}`) .bind(...values) .run(); + + // 清除用户信息缓存 + const { userInfoCache } = await import('./user-cache'); + userInfoCache.delete(userName); } catch (err) { console.error('PostgresStorage.updateUserInfoV2 error:', err); throw err; @@ -736,6 +755,10 @@ export class PostgresStorage implements IStorage { .prepare('DELETE FROM users WHERE username = $1') .bind(userName) .run(); + + // 清除用户信息缓存 + const { userInfoCache } = await import('./user-cache'); + userInfoCache.delete(userName); } catch (err) { console.error('PostgresStorage.deleteUserV2 error:', err); throw err; @@ -845,6 +868,10 @@ export class PostgresStorage implements IStorage { .prepare('UPDATE users SET email = $1 WHERE username = $2') .bind(email, userName) .run(); + + // 清除用户信息缓存 + const { userInfoCache } = await import('./user-cache'); + userInfoCache.delete(userName); } catch (err) { console.error('PostgresStorage.setUserEmail error:', err); throw err; @@ -871,12 +898,432 @@ export class PostgresStorage implements IStorage { .prepare('UPDATE users SET email_notifications = $1 WHERE username = $2') .bind(enabled ? 1 : 0, userName) .run(); + + // 清除用户信息缓存 + const { userInfoCache } = await import('./user-cache'); + userInfoCache.delete(userName); } catch (err) { console.error('PostgresStorage.setEmailNotificationPreference error:', err); throw err; } } + // ==================== 音乐播放记录 ==================== + + async getMusicPlayRecord(userName: string, key: string): Promise { + try { + const result = await this.db + .prepare('SELECT * FROM music_play_records WHERE username = $1 AND key = $2') + .bind(userName, key) + .first(); + + if (!result) return null; + + return { + platform: result.platform, + id: result.song_id, + name: result.name, + artist: result.artist, + album: result.album || undefined, + pic: result.pic || undefined, + play_time: result.play_time, + duration: result.duration, + save_time: result.save_time, + }; + } catch (err) { + console.error('PostgresStorage.getMusicPlayRecord error:', err); + return null; + } + } + + async setMusicPlayRecord(userName: string, key: string, record: any): Promise { + try { + await this.db + .prepare(` + INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT(username, key) DO UPDATE SET + name = EXCLUDED.name, + artist = EXCLUDED.artist, + album = EXCLUDED.album, + pic = EXCLUDED.pic, + play_time = EXCLUDED.play_time, + duration = EXCLUDED.duration, + save_time = EXCLUDED.save_time + `) + .bind( + userName, + key, + record.platform, + record.id, + record.name, + record.artist, + record.album || null, + record.pic || null, + record.play_time, + record.duration, + record.save_time + ) + .run(); + } catch (err) { + console.error('PostgresStorage.setMusicPlayRecord error:', err); + throw err; + } + } + + async batchSetMusicPlayRecords(userName: string, records: { key: string; record: any }[]): Promise { + if (records.length === 0) return; + + try { + // 使用批量插入,Postgres 支持 batch 操作 + const statements = records.map(({ key, record }) => + this.db + .prepare(` + INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT(username, key) DO UPDATE SET + platform = EXCLUDED.platform, + song_id = EXCLUDED.song_id, + name = EXCLUDED.name, + artist = EXCLUDED.artist, + album = EXCLUDED.album, + pic = EXCLUDED.pic, + play_time = EXCLUDED.play_time, + duration = EXCLUDED.duration, + save_time = EXCLUDED.save_time + `) + .bind( + userName, + key, + record.platform, + record.id, + record.name, + record.artist, + record.album || null, + record.pic || null, + record.play_time, + record.duration, + record.save_time + ) + ); + + if (this.db.batch) { + await this.db.batch(statements); + } + } catch (err) { + console.error('PostgresStorage.batchSetMusicPlayRecords error:', err); + throw err; + } + } + + async getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }> { + try { + const results = await this.db + .prepare('SELECT * FROM music_play_records WHERE username = $1 ORDER BY save_time DESC') + .bind(userName) + .all(); + + const records: { [key: string]: any } = {}; + if (results.results) { + for (const row of results.results) { + records[row.key as string] = { + platform: row.platform, + id: row.song_id, + name: row.name, + artist: row.artist, + album: row.album || undefined, + pic: row.pic || undefined, + play_time: row.play_time, + duration: row.duration, + save_time: row.save_time, + }; + } + } + return records; + } catch (err) { + console.error('PostgresStorage.getAllMusicPlayRecords error:', err); + throw err; + } + } + + async deleteMusicPlayRecord(userName: string, key: string): Promise { + try { + await this.db + .prepare('DELETE FROM music_play_records WHERE username = $1 AND key = $2') + .bind(userName, key) + .run(); + } catch (err) { + console.error('PostgresStorage.deleteMusicPlayRecord error:', err); + throw err; + } + } + + async clearAllMusicPlayRecords(userName: string): Promise { + try { + await this.db + .prepare('DELETE FROM music_play_records WHERE username = $1') + .bind(userName) + .run(); + } catch (err) { + console.error('PostgresStorage.clearAllMusicPlayRecords error:', err); + throw err; + } + } + + // ==================== 音乐歌单相关 ==================== + + async createMusicPlaylist(userName: string, playlist: { + id: string; + name: string; + description?: string; + cover?: string; + }): Promise { + try { + const now = Date.now(); + await this.db + .prepare(` + INSERT INTO music_playlists (id, username, name, description, cover, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `) + .bind( + playlist.id, + userName, + playlist.name, + playlist.description || null, + playlist.cover || null, + now, + now + ) + .run(); + } catch (err) { + console.error('PostgresStorage.createMusicPlaylist error:', err); + throw err; + } + } + + async getMusicPlaylist(playlistId: string): Promise { + try { + const result = await this.db + .prepare('SELECT * FROM music_playlists WHERE id = $1') + .bind(playlistId) + .first(); + + if (!result) return null; + + return { + id: result.id, + username: result.username, + name: result.name, + description: result.description || undefined, + cover: result.cover || undefined, + created_at: result.created_at, + updated_at: result.updated_at, + }; + } catch (err) { + console.error('PostgresStorage.getMusicPlaylist error:', err); + return null; + } + } + + async getUserMusicPlaylists(userName: string): Promise { + try { + const results = await this.db + .prepare('SELECT * FROM music_playlists WHERE username = $1 ORDER BY created_at DESC') + .bind(userName) + .all(); + + if (!results.results) return []; + + return results.results.map((row) => ({ + id: row.id, + username: row.username, + name: row.name, + description: row.description || undefined, + cover: row.cover || undefined, + created_at: row.created_at, + updated_at: row.updated_at, + })); + } catch (err) { + console.error('PostgresStorage.getUserMusicPlaylists error:', err); + return []; + } + } + + async updateMusicPlaylist(playlistId: string, updates: { + name?: string; + description?: string; + cover?: string; + }): Promise { + try { + const setClauses: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (updates.name !== undefined) { + setClauses.push(`name = $${paramIndex++}`); + values.push(updates.name); + } + if (updates.description !== undefined) { + setClauses.push(`description = $${paramIndex++}`); + values.push(updates.description); + } + if (updates.cover !== undefined) { + setClauses.push(`cover = $${paramIndex++}`); + values.push(updates.cover); + } + + if (setClauses.length === 0) return; + + setClauses.push(`updated_at = $${paramIndex++}`); + values.push(Date.now()); + + values.push(playlistId); + + await this.db + .prepare(`UPDATE music_playlists SET ${setClauses.join(', ')} WHERE id = $${paramIndex}`) + .bind(...values) + .run(); + } catch (err) { + console.error('PostgresStorage.updateMusicPlaylist error:', err); + throw err; + } + } + + async deleteMusicPlaylist(playlistId: string): Promise { + try { + await this.db + .prepare('DELETE FROM music_playlists WHERE id = $1') + .bind(playlistId) + .run(); + } catch (err) { + console.error('PostgresStorage.deleteMusicPlaylist error:', err); + throw err; + } + } + + async addSongToPlaylist(playlistId: string, song: { + platform: string; + id: string; + name: string; + artist: string; + album?: string; + pic?: string; + duration: number; + }): Promise { + try { + const now = Date.now(); + + // 获取当前最大的 sort_order + const maxSortResult = await this.db + .prepare('SELECT MAX(sort_order) as max_sort FROM music_playlist_songs WHERE playlist_id = $1') + .bind(playlistId) + .first(); + + const nextSortOrder = (maxSortResult?.max_sort as number || 0) + 1; + + await this.db + .prepare(` + INSERT INTO music_playlist_songs (playlist_id, platform, song_id, name, artist, album, pic, duration, added_at, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT(playlist_id, platform, song_id) DO UPDATE SET + name = EXCLUDED.name, + artist = EXCLUDED.artist, + album = EXCLUDED.album, + pic = EXCLUDED.pic, + duration = EXCLUDED.duration + `) + .bind( + playlistId, + song.platform, + song.id, + song.name, + song.artist, + song.album || null, + song.pic || null, + song.duration, + now, + nextSortOrder + ) + .run(); + + // 更新歌单的 updated_at + await this.db + .prepare('UPDATE music_playlists SET updated_at = $1 WHERE id = $2') + .bind(now, playlistId) + .run(); + } catch (err) { + console.error('PostgresStorage.addSongToPlaylist error:', err); + throw err; + } + } + + async removeSongFromPlaylist(playlistId: string, platform: string, songId: string): Promise { + try { + await this.db + .prepare('DELETE FROM music_playlist_songs WHERE playlist_id = $1 AND platform = $2 AND song_id = $3') + .bind(playlistId, platform, songId) + .run(); + + // 更新歌单的 updated_at + await this.db + .prepare('UPDATE music_playlists SET updated_at = $1 WHERE id = $2') + .bind(Date.now(), playlistId) + .run(); + } catch (err) { + console.error('PostgresStorage.removeSongFromPlaylist error:', err); + throw err; + } + } + + async getPlaylistSongs(playlistId: string): Promise { + try { + const results = await this.db + .prepare('SELECT * FROM music_playlist_songs WHERE playlist_id = $1 ORDER BY sort_order ASC') + .bind(playlistId) + .all(); + + if (!results.results) return []; + + return results.results.map((row) => ({ + platform: row.platform, + id: row.song_id, + name: row.name, + artist: row.artist, + album: row.album || undefined, + pic: row.pic || undefined, + duration: row.duration, + added_at: row.added_at, + sort_order: row.sort_order, + })); + } catch (err) { + console.error('PostgresStorage.getPlaylistSongs error:', err); + return []; + } + } + + async updatePlaylistSongOrder(playlistId: string, songOrders: Array<{ platform: string; songId: string; sortOrder: number }>): Promise { + try { + const statements = songOrders.map(({ platform, songId, sortOrder }) => + this.db + .prepare('UPDATE music_playlist_songs SET sort_order = $1 WHERE playlist_id = $2 AND platform = $3 AND song_id = $4') + .bind(sortOrder, playlistId, platform, songId) + ); + + if (this.db.batch) { + await this.db.batch(statements); + } + + // 更新歌单的 updated_at + await this.db + .prepare('UPDATE music_playlists SET updated_at = $1 WHERE id = $2') + .bind(Date.now(), playlistId) + .run(); + } catch (err) { + console.error('PostgresStorage.updatePlaylistSongOrder error:', err); + throw err; + } + } + // ==================== 搜索历史 ==================== async getSearchHistory(userName: string): Promise {