diff --git a/src/app/api/favorites/route.ts b/src/app/api/favorites/route.ts index 4e64310..6aa9860 100644 --- a/src/app/api/favorites/route.ts +++ b/src/app/api/favorites/route.ts @@ -179,13 +179,7 @@ export async function DELETE(request: NextRequest) { await db.deleteFavorite(username, source, id); } else { // 清空全部 - const all = await db.getAllFavorites(username); - await Promise.all( - Object.keys(all).map(async (k) => { - const [s, i] = k.split('+'); - if (s && i) await db.deleteFavorite(username, s, i); - }) - ); + await db.deleteAllFavorites(username); } return NextResponse.json({ success: true }, { status: 200 }); diff --git a/src/app/api/playrecords/route.ts b/src/app/api/playrecords/route.ts index 95cd8b6..da2082d 100644 --- a/src/app/api/playrecords/route.ts +++ b/src/app/api/playrecords/route.ts @@ -147,14 +147,7 @@ export async function DELETE(request: NextRequest) { await db.deletePlayRecord(username, source, id); } else { // 未提供 key,则清空全部播放记录 - // 目前 DbManager 没有对应方法,这里直接遍历删除 - const all = await db.getAllPlayRecords(username); - await Promise.all( - Object.keys(all).map(async (k) => { - const [s, i] = k.split('+'); - if (s && i) await db.deletePlayRecord(username, s, i); - }) - ); + await db.deleteAllPlayRecords(username); } return NextResponse.json({ success: true }, { status: 200 }); diff --git a/src/lib/db.ts b/src/lib/db.ts index 9f129b5..7c3e743 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -48,9 +48,24 @@ export function generateStorageKey(source: string, id: string): string { // 导出便捷方法 export class DbManager { private storage: IStorage; + private migrationPromise: Promise | null = null; constructor() { this.storage = getStorage(); + // 启动时自动触发数据迁移(异步,不阻塞构造) + if (this.storage && typeof this.storage.migrateData === 'function') { + this.migrationPromise = this.storage.migrateData().catch((err) => { + console.error('数据迁移异常:', err); + }); + } + } + + /** 等待迁移完成(内部方法,首次调用后 migrationPromise 会被置空) */ + private async ensureMigrated(): Promise { + if (this.migrationPromise) { + await this.migrationPromise; + this.migrationPromise = null; + } } // 播放记录相关方法 @@ -76,6 +91,7 @@ export class DbManager { async getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord; }> { + await this.ensureMigrated(); return this.storage.getAllPlayRecords(userName); } @@ -88,6 +104,10 @@ export class DbManager { await this.storage.deletePlayRecord(userName, key); } + async deleteAllPlayRecords(userName: string): Promise { + await this.storage.deleteAllPlayRecords(userName); + } + // 收藏相关方法 async getFavorite( userName: string, @@ -111,6 +131,7 @@ export class DbManager { async getAllFavorites( userName: string ): Promise<{ [key: string]: Favorite }> { + await this.ensureMigrated(); return this.storage.getAllFavorites(userName); } @@ -123,6 +144,10 @@ export class DbManager { await this.storage.deleteFavorite(userName, key); } + async deleteAllFavorites(userName: string): Promise { + await this.storage.deleteAllFavorites(userName); + } + async isFavorited( userName: string, source: string, diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index caad222..c87f9c2 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -151,8 +151,8 @@ export abstract class BaseRedisStorage implements IStorage { } // ---------- 播放记录 ---------- - private prKey(user: string, key: string) { - return `u:${user}:pr:${key}`; // u:username:pr:source+id + private prHashKey(user: string) { + return `u:${user}:pr`; // 一个用户的所有播放记录存在一个 Hash 中 } async getPlayRecord( @@ -160,7 +160,7 @@ export abstract class BaseRedisStorage implements IStorage { key: string ): Promise { const val = await this.withRetry(() => - this.client.get(this.prKey(userName, key)) + this.client.hGet(this.prHashKey(userName), key) ); return val ? (JSON.parse(val) as PlayRecord) : null; } @@ -171,42 +171,43 @@ export abstract class BaseRedisStorage implements IStorage { record: PlayRecord ): Promise { await this.withRetry(() => - this.client.set(this.prKey(userName, key), JSON.stringify(record)) + this.client.hSet(this.prHashKey(userName), key, JSON.stringify(record)) ); } async getAllPlayRecords( userName: string ): Promise> { - const pattern = `u:${userName}:pr:*`; - const keys: string[] = await this.withRetry(() => this.client.keys(pattern)); - if (keys.length === 0) return {}; - const values = await this.withRetry(() => this.client.mGet(keys)); + const all = await this.withRetry(() => + this.client.hGetAll(this.prHashKey(userName)) + ); const result: Record = {}; - keys.forEach((fullKey: string, idx: number) => { - const raw = values[idx]; + for (const [field, raw] of Object.entries(all)) { if (raw) { - const rec = JSON.parse(raw) as PlayRecord; - // 截取 source+id 部分 - const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, '')); - result[keyPart] = rec; + result[field] = JSON.parse(raw) as PlayRecord; } - }); + } return result; } async deletePlayRecord(userName: string, key: string): Promise { - await this.withRetry(() => this.client.del(this.prKey(userName, key))); + await this.withRetry(() => + this.client.hDel(this.prHashKey(userName), key) + ); + } + + async deleteAllPlayRecords(userName: string): Promise { + await this.withRetry(() => this.client.del(this.prHashKey(userName))); } // ---------- 收藏 ---------- - private favKey(user: string, key: string) { - return `u:${user}:fav:${key}`; + private favHashKey(user: string) { + return `u:${user}:fav`; // 一个用户的所有收藏存在一个 Hash 中 } async getFavorite(userName: string, key: string): Promise { const val = await this.withRetry(() => - this.client.get(this.favKey(userName, key)) + this.client.hGet(this.favHashKey(userName), key) ); return val ? (JSON.parse(val) as Favorite) : null; } @@ -217,29 +218,31 @@ export abstract class BaseRedisStorage implements IStorage { favorite: Favorite ): Promise { await this.withRetry(() => - this.client.set(this.favKey(userName, key), JSON.stringify(favorite)) + this.client.hSet(this.favHashKey(userName), key, JSON.stringify(favorite)) ); } async getAllFavorites(userName: string): Promise> { - const pattern = `u:${userName}:fav:*`; - const keys: string[] = await this.withRetry(() => this.client.keys(pattern)); - if (keys.length === 0) return {}; - const values = await this.withRetry(() => this.client.mGet(keys)); + const all = await this.withRetry(() => + this.client.hGetAll(this.favHashKey(userName)) + ); const result: Record = {}; - keys.forEach((fullKey: string, idx: number) => { - const raw = values[idx]; + for (const [field, raw] of Object.entries(all)) { if (raw) { - const fav = JSON.parse(raw) as Favorite; - const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, '')); - result[keyPart] = fav; + result[field] = JSON.parse(raw) as Favorite; } - }); + } return result; } async deleteFavorite(userName: string, key: string): Promise { - await this.withRetry(() => this.client.del(this.favKey(userName, key))); + await this.withRetry(() => + this.client.hDel(this.favHashKey(userName), key) + ); + } + + async deleteAllFavorites(userName: string): Promise { + await this.withRetry(() => this.client.del(this.favHashKey(userName))); } // ---------- 用户注册 / 登录 ---------- @@ -286,23 +289,11 @@ export abstract class BaseRedisStorage implements IStorage { // 删除搜索历史 await this.withRetry(() => this.client.del(this.shKey(userName))); - // 删除播放记录 - const playRecordPattern = `u:${userName}:pr:*`; - const playRecordKeys = await this.withRetry(() => - this.client.keys(playRecordPattern) - ); - if (playRecordKeys.length > 0) { - await this.withRetry(() => this.client.del(playRecordKeys)); - } + // 删除播放记录(Hash key 直接删除) + await this.withRetry(() => this.client.del(this.prHashKey(userName))); - // 删除收藏夹 - const favoritePattern = `u:${userName}:fav:*`; - const favoriteKeys = await this.withRetry(() => - this.client.keys(favoritePattern) - ); - if (favoriteKeys.length > 0) { - await this.withRetry(() => this.client.del(favoriteKeys)); - } + // 删除收藏夹(Hash key 直接删除) + await this.withRetry(() => this.client.del(this.favHashKey(userName))); // 删除跳过片头片尾配置 const skipConfigPattern = `u:${userName}:skip:*`; @@ -443,6 +434,81 @@ export abstract class BaseRedisStorage implements IStorage { return configs; } + // ---------- 数据迁移:旧扁平 key → Hash 结构 ---------- + private migrationKey() { + return 'sys:migration:hash_v1'; + } + + async migrateData(): Promise { + // 检查是否已迁移 + const migrated = await this.withRetry(() => this.client.get(this.migrationKey())); + if (migrated === 'done') return; + + console.log('开始数据迁移:扁平 key → Hash 结构...'); + + try { + // 迁移播放记录:u:*:pr:* → u:username:pr (Hash) + const prKeys = await this.withRetry(() => this.client.keys('u:*:pr:*')); + if (prKeys.length > 0) { + // 过滤掉新 Hash key(没有第四段的就是 Hash key 本身) + const oldPrKeys = prKeys.filter((k) => { + const parts = k.split(':'); + return parts.length >= 4 && parts[2] === 'pr' && parts[3] !== ''; + }); + + if (oldPrKeys.length > 0) { + const values = await this.withRetry(() => this.client.mGet(oldPrKeys)); + for (let i = 0; i < oldPrKeys.length; i++) { + const raw = values[i]; + if (!raw) continue; + const match = oldPrKeys[i].match(/^u:(.+?):pr:(.+)$/); + if (!match) continue; + const [, userName, field] = match; + await this.withRetry(() => + this.client.hSet(this.prHashKey(userName), field, raw) + ); + } + // 删除旧 key + await this.withRetry(() => this.client.del(oldPrKeys)); + console.log(`迁移了 ${oldPrKeys.length} 条播放记录`); + } + } + + // 迁移收藏:u:*:fav:* → u:username:fav (Hash) + const favKeys = await this.withRetry(() => this.client.keys('u:*:fav:*')); + if (favKeys.length > 0) { + const oldFavKeys = favKeys.filter((k) => { + const parts = k.split(':'); + return parts.length >= 4 && parts[2] === 'fav' && parts[3] !== ''; + }); + + if (oldFavKeys.length > 0) { + const values = await this.withRetry(() => this.client.mGet(oldFavKeys)); + for (let i = 0; i < oldFavKeys.length; i++) { + const raw = values[i]; + if (!raw) continue; + const match = oldFavKeys[i].match(/^u:(.+?):fav:(.+)$/); + if (!match) continue; + const [, userName, field] = match; + await this.withRetry(() => + this.client.hSet(this.favHashKey(userName), field, raw) + ); + } + // 删除旧 key + await this.withRetry(() => this.client.del(oldFavKeys)); + console.log(`迁移了 ${oldFavKeys.length} 条收藏`); + } + } + + // 标记迁移完成 + await this.withRetry(() => this.client.set(this.migrationKey(), 'done')); + console.log('数据迁移完成'); + } catch (error) { + console.error('数据迁移失败:', error); + // 不抛出异常,允许服务继续运行 + } + } + // 清空所有数据 async clearAllData(): Promise { try { diff --git a/src/lib/types.ts b/src/lib/types.ts index 329b275..ba7efae 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -37,12 +37,14 @@ export interface IStorage { ): Promise; getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }>; deletePlayRecord(userName: string, key: string): Promise; + deleteAllPlayRecords(userName: string): Promise; // 收藏相关 getFavorite(userName: string, key: string): Promise; setFavorite(userName: string, key: string, favorite: Favorite): Promise; getAllFavorites(userName: string): Promise<{ [key: string]: Favorite }>; deleteFavorite(userName: string, key: string): Promise; + deleteAllFavorites(userName: string): Promise; // 用户相关 registerUser(userName: string, password: string): Promise; @@ -81,6 +83,9 @@ export interface IStorage { deleteSkipConfig(userName: string, source: string, id: string): Promise; getAllSkipConfigs(userName: string): Promise<{ [key: string]: SkipConfig }>; + // 数据迁移(旧扁平 key → Hash 结构) + migrateData?(): Promise; + // 数据清理相关 clearAllData(): Promise; } diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index bfd1d32..8e4e425 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -61,8 +61,8 @@ export class UpstashRedisStorage implements IStorage { } // ---------- 播放记录 ---------- - private prKey(user: string, key: string) { - return `u:${user}:pr:${key}`; // u:username:pr:source+id + private prHashKey(user: string) { + return `u:${user}:pr`; // 一个用户的所有播放记录存在一个 Hash 中 } async getPlayRecord( @@ -70,7 +70,7 @@ export class UpstashRedisStorage implements IStorage { key: string ): Promise { const val = await withRetry(() => - this.client.get(this.prKey(userName, key)) + this.client.hget(this.prHashKey(userName), key) ); return val ? (val as PlayRecord) : null; } @@ -80,40 +80,43 @@ export class UpstashRedisStorage implements IStorage { key: string, record: PlayRecord ): Promise { - await withRetry(() => this.client.set(this.prKey(userName, key), record)); + await withRetry(() => + this.client.hset(this.prHashKey(userName), { [key]: record }) + ); } async getAllPlayRecords( userName: string ): Promise> { - const pattern = `u:${userName}:pr:*`; - const keys: string[] = await withRetry(() => this.client.keys(pattern)); - if (keys.length === 0) return {}; - + const all = await withRetry(() => + this.client.hgetall(this.prHashKey(userName)) + ); + if (!all || Object.keys(all).length === 0) return {}; const result: Record = {}; - for (const fullKey of keys) { - const value = await withRetry(() => this.client.get(fullKey)); + for (const [field, value] of Object.entries(all)) { if (value) { - // 截取 source+id 部分 - const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, '')); - result[keyPart] = value as PlayRecord; + result[field] = value as PlayRecord; } } return result; } async deletePlayRecord(userName: string, key: string): Promise { - await withRetry(() => this.client.del(this.prKey(userName, key))); + await withRetry(() => this.client.hdel(this.prHashKey(userName), key)); + } + + async deleteAllPlayRecords(userName: string): Promise { + await withRetry(() => this.client.del(this.prHashKey(userName))); } // ---------- 收藏 ---------- - private favKey(user: string, key: string) { - return `u:${user}:fav:${key}`; + private favHashKey(user: string) { + return `u:${user}:fav`; // 一个用户的所有收藏存在一个 Hash 中 } async getFavorite(userName: string, key: string): Promise { const val = await withRetry(() => - this.client.get(this.favKey(userName, key)) + this.client.hget(this.favHashKey(userName), key) ); return val ? (val as Favorite) : null; } @@ -124,28 +127,30 @@ export class UpstashRedisStorage implements IStorage { favorite: Favorite ): Promise { await withRetry(() => - this.client.set(this.favKey(userName, key), favorite) + this.client.hset(this.favHashKey(userName), { [key]: favorite }) ); } async getAllFavorites(userName: string): Promise> { - const pattern = `u:${userName}:fav:*`; - const keys: string[] = await withRetry(() => this.client.keys(pattern)); - if (keys.length === 0) return {}; - + const all = await withRetry(() => + this.client.hgetall(this.favHashKey(userName)) + ); + if (!all || Object.keys(all).length === 0) return {}; const result: Record = {}; - for (const fullKey of keys) { - const value = await withRetry(() => this.client.get(fullKey)); + for (const [field, value] of Object.entries(all)) { if (value) { - const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, '')); - result[keyPart] = value as Favorite; + result[field] = value as Favorite; } } return result; } async deleteFavorite(userName: string, key: string): Promise { - await withRetry(() => this.client.del(this.favKey(userName, key))); + await withRetry(() => this.client.hdel(this.favHashKey(userName), key)); + } + + async deleteAllFavorites(userName: string): Promise { + await withRetry(() => this.client.del(this.favHashKey(userName))); } // ---------- 用户注册 / 登录 ---------- @@ -192,23 +197,11 @@ export class UpstashRedisStorage implements IStorage { // 删除搜索历史 await withRetry(() => this.client.del(this.shKey(userName))); - // 删除播放记录 - const playRecordPattern = `u:${userName}:pr:*`; - const playRecordKeys = await withRetry(() => - this.client.keys(playRecordPattern) - ); - if (playRecordKeys.length > 0) { - await withRetry(() => this.client.del(...playRecordKeys)); - } + // 删除播放记录(Hash key 直接删除) + await withRetry(() => this.client.del(this.prHashKey(userName))); - // 删除收藏夹 - const favoritePattern = `u:${userName}:fav:*`; - const favoriteKeys = await withRetry(() => - this.client.keys(favoritePattern) - ); - if (favoriteKeys.length > 0) { - await withRetry(() => this.client.del(...favoriteKeys)); - } + // 删除收藏夹(Hash key 直接删除) + await withRetry(() => this.client.del(this.favHashKey(userName))); // 删除跳过片头片尾配置 const skipConfigPattern = `u:${userName}:skip:*`; @@ -344,6 +337,77 @@ export class UpstashRedisStorage implements IStorage { return configs; } + // ---------- 数据迁移:旧扁平 key → Hash 结构 ---------- + private migrationKey() { + return 'sys:migration:hash_v1'; + } + + async migrateData(): Promise { + // 检查是否已迁移 + const migrated = await withRetry(() => this.client.get(this.migrationKey())); + if (migrated === 'done') return; + + console.log('开始数据迁移:扁平 key → Hash 结构...'); + + try { + // 迁移播放记录:u:*:pr:* → u:username:pr (Hash) + const prKeys: string[] = await withRetry(() => this.client.keys('u:*:pr:*')); + if (prKeys.length > 0) { + const oldPrKeys = prKeys.filter((k) => { + const parts = k.split(':'); + return parts.length >= 4 && parts[2] === 'pr' && parts[3] !== ''; + }); + + for (const oldKey of oldPrKeys) { + const match = oldKey.match(/^u:(.+?):pr:(.+)$/); + if (!match) continue; + const [, userName, field] = match; + const value = await withRetry(() => this.client.get(oldKey)); + if (value) { + await withRetry(() => + this.client.hset(this.prHashKey(userName), { [field]: value }) + ); + await withRetry(() => this.client.del(oldKey)); + } + } + if (oldPrKeys.length > 0) { + console.log(`迁移了 ${oldPrKeys.length} 条播放记录`); + } + } + + // 迁移收藏:u:*:fav:* → u:username:fav (Hash) + const favKeys: string[] = await withRetry(() => this.client.keys('u:*:fav:*')); + if (favKeys.length > 0) { + const oldFavKeys = favKeys.filter((k) => { + const parts = k.split(':'); + return parts.length >= 4 && parts[2] === 'fav' && parts[3] !== ''; + }); + + for (const oldKey of oldFavKeys) { + const match = oldKey.match(/^u:(.+?):fav:(.+)$/); + if (!match) continue; + const [, userName, field] = match; + const value = await withRetry(() => this.client.get(oldKey)); + if (value) { + await withRetry(() => + this.client.hset(this.favHashKey(userName), { [field]: value }) + ); + await withRetry(() => this.client.del(oldKey)); + } + } + if (oldFavKeys.length > 0) { + console.log(`迁移了 ${oldFavKeys.length} 条收藏`); + } + } + + // 标记迁移完成 + await withRetry(() => this.client.set(this.migrationKey(), 'done')); + console.log('数据迁移完成'); + } catch (error) { + console.error('数据迁移失败:', error); + } + } + // 清空所有数据 async clearAllData(): Promise { try {