From 39f2bb3450f123be4e4daf5c7bf2d08951664451 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 30 Dec 2025 22:00:36 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E6=94=B6=E8=97=8F=E5=88=B0?= =?UTF-8?q?=E6=96=B0=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/favorites/route.ts | 14 ++++ src/app/api/playrecords/route.ts | 8 ++ src/lib/db.ts | 7 ++ src/lib/redis-base.db.ts | 134 +++++++++++++++++++++++++----- src/lib/types.ts | 4 + src/lib/upstash.db.ts | 135 +++++++++++++++++++++++++++---- 6 files changed, 268 insertions(+), 34 deletions(-) diff --git a/src/app/api/favorites/route.ts b/src/app/api/favorites/route.ts index e28e637..d69633b 100644 --- a/src/app/api/favorites/route.ts +++ b/src/app/api/favorites/route.ts @@ -24,6 +24,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + // 检查用户状态和执行迁移 if (authInfo.username !== process.env.USERNAME) { // 非站长,检查用户存在或被封禁 const userInfoV2 = await db.getUserInfoV2(authInfo.username); @@ -33,6 +34,19 @@ export async function GET(request: NextRequest) { if (userInfoV2.banned) { return NextResponse.json({ error: '用户已被封禁' }, { status: 401 }); } + + // 检查收藏迁移标识,没有迁移标识时执行迁移 + if (!userInfoV2.favorite_migrated) { + console.log(`用户 ${authInfo.username} 收藏未迁移,开始执行迁移...`); + await db.migrateFavorites(authInfo.username); + } + } else { + // 站长也需要执行迁移(站长可能不在数据库中,直接尝试迁移) + const userInfoV2 = await db.getUserInfoV2(authInfo.username); + if (!userInfoV2 || !userInfoV2.favorite_migrated) { + console.log(`站长 ${authInfo.username} 收藏未迁移,开始执行迁移...`); + await db.migrateFavorites(authInfo.username); + } } const { searchParams } = new URL(request.url); diff --git a/src/app/api/playrecords/route.ts b/src/app/api/playrecords/route.ts index 73aa085..e44ed53 100644 --- a/src/app/api/playrecords/route.ts +++ b/src/app/api/playrecords/route.ts @@ -17,6 +17,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + // 检查用户状态和执行迁移 if (authInfo.username !== process.env.USERNAME) { // 非站长,检查用户存在或被封禁 const userInfoV2 = await db.getUserInfoV2(authInfo.username); @@ -32,6 +33,13 @@ export async function GET(request: NextRequest) { console.log(`用户 ${authInfo.username} 播放记录未迁移,开始执行迁移...`); await db.migratePlayRecords(authInfo.username); } + } else { + // 站长也需要执行迁移(站长可能不在数据库中,直接尝试迁移) + const userInfoV2 = await db.getUserInfoV2(authInfo.username); + if (!userInfoV2 || !userInfoV2.playrecord_migrated) { + console.log(`站长 ${authInfo.username} 播放记录未迁移,开始执行迁移...`); + await db.migratePlayRecords(authInfo.username); + } } const records = await db.getAllPlayRecords(authInfo.username); diff --git a/src/lib/db.ts b/src/lib/db.ts index 44c7776..f4df91f 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -267,6 +267,13 @@ export class DbManager { } } + // ---------- 收藏迁移 ---------- + async migrateFavorites(userName: string): Promise { + if (typeof (this.storage as any).migrateFavorites === 'function') { + await (this.storage as any).migrateFavorites(userName); + } + } + // ---------- 数据迁移 ---------- async migrateUsersFromConfig(adminConfig: AdminConfig): Promise { if (typeof (this.storage as any).createUserV2 !== 'function') { diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index e9bdd1a..9742cc5 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -267,7 +267,7 @@ export abstract class BaseRedisStorage implements IStorage { // 迁移播放记录:从旧的多key结构迁移到新的hash结构 async migratePlayRecords(userName: string): Promise { // 检查是否已有正在进行的迁移 - const existingMigration = migrationLocks.get(userName); + const existingMigration = playRecordLocks.get(userName); if (existingMigration) { console.log(`用户 ${userName} 的播放记录正在迁移中,等待完成...`); await existingMigration; @@ -276,13 +276,13 @@ export abstract class BaseRedisStorage implements IStorage { // 创建新的迁移Promise const migrationPromise = this.doMigration(userName); - migrationLocks.set(userName, migrationPromise); + playRecordLocks.set(userName, migrationPromise); try { await migrationPromise; } finally { // 迁移完成后清除锁 - migrationLocks.delete(userName); + playRecordLocks.delete(userName); } } @@ -354,13 +354,18 @@ export abstract class BaseRedisStorage implements IStorage { } // ---------- 收藏 ---------- - private favKey(user: string, key: string) { + private favHashKey(user: string) { + return `u:${user}:fav`; // u:username:fav (hash结构) + } + + // 旧版收藏key(用于迁移) + private favOldKey(user: string, key: string) { return `u:${user}:fav:${key}`; } 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; } @@ -371,29 +376,115 @@ 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 hashData = await this.withRetry(() => + this.client.hGetAll(this.favHashKey(userName)) + ); + const result: Record = {}; - keys.forEach((fullKey: string, idx: number) => { - const raw = values[idx]; - if (raw) { - const fav = JSON.parse(raw) as Favorite; - const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, '')); - result[keyPart] = fav; + for (const [key, value] of Object.entries(hashData)) { + if (value) { + result[key] = JSON.parse(value) 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)); + } + + // 迁移收藏:从旧的多key结构迁移到新的hash结构 + async migrateFavorites(userName: string): Promise { + // 检查是否已有正在进行的迁移 + const existingMigration = playRecordLocks.get(userName); + if (existingMigration) { + console.log(`用户 ${userName} 的收藏正在迁移中,等待完成...`); + await existingMigration; + return; + } + + // 创建新的迁移Promise + const migrationPromise = this.doFavoriteMigration(userName); + playRecordLocks.set(userName, migrationPromise); + + try { + await migrationPromise; + } finally { + // 迁移完成后清除锁 + playRecordLocks.delete(userName); + } + } + + // 实际执行收藏迁移的方法 + private async doFavoriteMigration(userName: string): Promise { + console.log(`开始迁移用户 ${userName} 的收藏...`); + + // 1. 检查是否已经迁移过 + const userInfo = await this.getUserInfoV2(userName); + if (userInfo?.favorite_migrated) { + console.log(`用户 ${userName} 的收藏已经迁移过,跳过`); + return; + } + + // 2. 获取旧结构的所有收藏key + const pattern = `u:${userName}:fav:*`; + const oldKeys: string[] = await this.withRetry(() => this.client.keys(pattern)); + + if (oldKeys.length === 0) { + console.log(`用户 ${userName} 没有旧的收藏,标记为已迁移`); + // 即使没有数据也标记为已迁移 + await this.withRetry(() => + this.client.hSet(this.userInfoKey(userName), 'favorite_migrated', 'true') + ); + // 清除用户信息缓存 + const { userInfoCache } = await import('./user-cache'); + userInfoCache?.delete(userName); + return; + } + + console.log(`找到 ${oldKeys.length} 条旧收藏,开始迁移...`); + + // 3. 批量获取旧数据 + const oldValues = await this.withRetry(() => this.client.mGet(oldKeys)); + + // 4. 转换为hash格式 + const hashData: Record = {}; + oldKeys.forEach((fullKey: string, idx: number) => { + const raw = oldValues[idx]; + if (raw) { + // 提取 source+id 部分作为hash的field + const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, '')); + hashData[keyPart] = raw; + } + }); + + // 5. 写入新的hash结构 + if (Object.keys(hashData).length > 0) { + await this.withRetry(() => + this.client.hSet(this.favHashKey(userName), hashData) + ); + console.log(`成功迁移 ${Object.keys(hashData).length} 条收藏到hash结构`); + } + + // 6. 删除旧的key + await this.withRetry(() => this.client.del(oldKeys)); + console.log(`删除了 ${oldKeys.length} 个旧的收藏key`); + + // 7. 标记迁移完成 + await this.withRetry(() => + this.client.hSet(this.userInfoKey(userName), 'favorite_migrated', 'true') + ); + + // 8. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识 + const { userInfoCache } = await import('./user-cache'); + userInfoCache?.delete(userName); + + console.log(`用户 ${userName} 的收藏迁移完成`); } // ---------- 用户注册 / 登录(旧版本,保持兼容) ---------- @@ -452,7 +543,10 @@ export abstract class BaseRedisStorage implements IStorage { await this.withRetry(() => this.client.del(playRecordKeys)); } - // 删除收藏夹 + // 删除收藏夹(新hash结构) + await this.withRetry(() => this.client.del(this.favHashKey(userName))); + + // 删除旧的收藏key(如果有) const favoritePattern = `u:${userName}:fav:*`; const favoriteKeys = await this.withRetry(() => this.client.keys(favoritePattern) @@ -565,6 +659,7 @@ export abstract class BaseRedisStorage implements IStorage { enabledApis?: string[]; created_at: number; playrecord_migrated?: boolean; + favorite_migrated?: boolean; } | null> { const userInfo = await this.withRetry(() => this.client.hGetAll(this.userInfoKey(userName)) @@ -582,6 +677,7 @@ export abstract class BaseRedisStorage implements IStorage { enabledApis: userInfo.enabledApis ? JSON.parse(userInfo.enabledApis) : undefined, created_at: parseInt(userInfo.created_at || '0', 10), playrecord_migrated: userInfo.playrecord_migrated === 'true', + favorite_migrated: userInfo.favorite_migrated === 'true', }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 0179452..96a7040 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -41,12 +41,16 @@ export interface IStorage { deletePlayRecord(userName: string, key: string): Promise; // 清理超出限制的旧播放记录 cleanupOldPlayRecords(userName: string): Promise; + // 迁移播放记录 + migratePlayRecords(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; + // 迁移收藏 + migrateFavorites(userName: string): Promise; // 用户相关 registerUser(userName: string, password: string): Promise; diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index 7e5d680..4a727f5 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -260,13 +260,18 @@ export class UpstashRedisStorage implements IStorage { } // ---------- 收藏 ---------- - private favKey(user: string, key: string) { + private favHashKey(user: string) { + return `u:${user}:fav`; // u:username:fav (hash结构) + } + + // 旧版收藏key(用于迁移) + private favOldKey(user: string, key: string) { return `u:${user}:fav:${key}`; } 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; } @@ -276,29 +281,111 @@ export class UpstashRedisStorage implements IStorage { key: string, favorite: Favorite ): Promise { - await withRetry(() => - this.client.set(this.favKey(userName, key), favorite) - ); + await withRetry(() => 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 hashData = await withRetry(() => + this.client.hgetall(this.favHashKey(userName)) + ); + + if (!hashData || Object.keys(hashData).length === 0) return {}; const result: Record = {}; - for (const fullKey of keys) { - const value = await withRetry(() => this.client.get(fullKey)); + for (const [key, value] of Object.entries(hashData)) { if (value) { - const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, '')); - result[keyPart] = value as Favorite; + result[key] = 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)); + } + + // 迁移收藏:从旧的多key结构迁移到新的hash结构 + async migrateFavorites(userName: string): Promise { + // 检查是否已有正在进行的迁移 + const existingMigration = playRecordLocks.get(userName); + if (existingMigration) { + console.log(`用户 ${userName} 的收藏正在迁移中,等待完成...`); + await existingMigration; + return; + } + + // 创建新的迁移Promise + const migrationPromise = this.doFavoriteMigration(userName); + playRecordLocks.set(userName, migrationPromise); + + try { + await migrationPromise; + } finally { + // 迁移完成后清除锁 + playRecordLocks.delete(userName); + } + } + + // 实际执行收藏迁移的方法 + private async doFavoriteMigration(userName: string): Promise { + console.log(`开始迁移用户 ${userName} 的收藏...`); + + // 1. 检查是否已经迁移过 + const userInfo = await this.getUserInfoV2(userName); + if (userInfo?.favorite_migrated) { + console.log(`用户 ${userName} 的收藏已经迁移过,跳过`); + return; + } + + // 2. 获取旧结构的所有收藏key + const pattern = `u:${userName}:fav:*`; + const oldKeys: string[] = await withRetry(() => this.client.keys(pattern)); + + if (oldKeys.length === 0) { + console.log(`用户 ${userName} 没有旧的收藏,标记为已迁移`); + // 即使没有数据也标记为已迁移 + await withRetry(() => + this.client.hset(this.userInfoKey(userName), { favorite_migrated: true }) + ); + // 清除用户信息缓存 + userInfoCache?.delete(userName); + return; + } + + console.log(`找到 ${oldKeys.length} 条旧收藏,开始迁移...`); + + // 3. 批量获取旧数据并转换为hash格式 + const hashData: Record = {}; + for (const fullKey of oldKeys) { + const value = await withRetry(() => this.client.get(fullKey)); + if (value) { + // 提取 source+id 部分作为hash的field + const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, '')); + hashData[keyPart] = value; + } + } + + // 4. 写入新的hash结构 + if (Object.keys(hashData).length > 0) { + await withRetry(() => + this.client.hset(this.favHashKey(userName), hashData) + ); + console.log(`成功迁移 ${Object.keys(hashData).length} 条收藏到hash结构`); + } + + // 5. 删除旧的key + await withRetry(() => this.client.del(...oldKeys)); + console.log(`删除了 ${oldKeys.length} 个旧的收藏key`); + + // 6. 标记迁移完成 + await withRetry(() => + this.client.hset(this.userInfoKey(userName), { favorite_migrated: true }) + ); + + // 7. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识 + userInfoCache?.delete(userName); + + console.log(`用户 ${userName} 的收藏迁移完成`); } // ---------- 用户注册 / 登录 ---------- @@ -345,7 +432,10 @@ export class UpstashRedisStorage implements IStorage { // 删除搜索历史 await withRetry(() => this.client.del(this.shKey(userName))); - // 删除播放记录 + // 删除播放记录(新hash结构) + await withRetry(() => this.client.del(this.prHashKey(userName))); + + // 删除旧的播放记录key(如果有) const playRecordPattern = `u:${userName}:pr:*`; const playRecordKeys = await withRetry(() => this.client.keys(playRecordPattern) @@ -354,7 +444,10 @@ export class UpstashRedisStorage implements IStorage { await withRetry(() => this.client.del(...playRecordKeys)); } - // 删除收藏夹 + // 删除收藏夹(新hash结构) + await withRetry(() => this.client.del(this.favHashKey(userName))); + + // 删除旧的收藏key(如果有) const favoritePattern = `u:${userName}:fav:*`; const favoriteKeys = await withRetry(() => this.client.keys(favoritePattern) @@ -475,6 +568,7 @@ export class UpstashRedisStorage implements IStorage { enabledApis?: string[]; created_at: number; playrecord_migrated?: boolean; + favorite_migrated?: boolean; } | null> { // 先从缓存获取 const cached = userInfoCache?.get(userName); @@ -508,6 +602,16 @@ export class UpstashRedisStorage implements IStorage { } } + // 处理 favorite_migrated 字段 + let favorite_migrated: boolean | undefined = undefined; + if (userInfo.favorite_migrated !== undefined) { + if (typeof userInfo.favorite_migrated === 'boolean') { + favorite_migrated = userInfo.favorite_migrated; + } else if (typeof userInfo.favorite_migrated === 'string') { + favorite_migrated = userInfo.favorite_migrated === 'true'; + } + } + // 安全解析 tags 字段 let tags: string[] | undefined = undefined; if (userInfo.tags) { @@ -546,6 +650,7 @@ export class UpstashRedisStorage implements IStorage { enabledApis, created_at: parseInt((userInfo.created_at as string) || '0', 10), playrecord_migrated, + favorite_migrated, }; // 存入缓存