From 761ac4eaf32499bede72009b38ce8561ea1ab96d Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 1 Jan 2026 10:01:35 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E8=B7=B3=E8=BF=87=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=88=B0=E6=96=B0=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG | 5 +- VERSION.txt | 2 +- src/app/api/cron/route.ts | 1 + src/app/api/skipconfigs/route.ts | 11 ++++ src/app/warning/page.tsx | 2 +- src/lib/changelog.ts | 11 ++++ src/lib/db.ts | 8 +++ src/lib/redis-base.db.ts | 105 +++++++++++++++++++++++++------ src/lib/types.ts | 2 + src/lib/upstash.db.ts | 104 +++++++++++++++++++++++++----- src/lib/version.ts | 2 +- 11 files changed, 212 insertions(+), 41 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index cd73d95..f19a1e7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,8 @@ -## [205.0.0] - 2026-01-01 +## [205.0.1] - 2026-01-01 +### Changed +- 迁移跳过配置到新数据结构 +## [205.0.0] - 2026-01-01 ### Added - oidc注册支持设置linuxdo信任等级 - 播放页增加更多推荐 diff --git a/VERSION.txt b/VERSION.txt index b66feb2..194b052 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -205.0.0 \ No newline at end of file +205.0.1 \ No newline at end of file diff --git a/src/app/api/cron/route.ts b/src/app/api/cron/route.ts index e3922f6..6659055 100644 --- a/src/app/api/cron/route.ts +++ b/src/app/api/cron/route.ts @@ -338,3 +338,4 @@ async function refreshOpenList() { console.error('OpenList 定时扫描失败:', err); } } + diff --git a/src/app/api/skipconfigs/route.ts b/src/app/api/skipconfigs/route.ts index a011761..ee032a6 100644 --- a/src/app/api/skipconfigs/route.ts +++ b/src/app/api/skipconfigs/route.ts @@ -25,6 +25,17 @@ export async function GET(request: NextRequest) { if (userInfoV2.banned) { return NextResponse.json({ error: '用户已被封禁' }, { status: 401 }); } + + // 检查是否需要迁移跳过配置 + if (!userInfoV2.skip_migrated) { + await db.migrateSkipConfigs(authInfo.username); + } + } else { + // 站长也需要检查迁移 + const userInfoV2 = await db.getUserInfoV2(authInfo.username); + if (!userInfoV2?.skip_migrated) { + await db.migrateSkipConfigs(authInfo.username); + } } const { searchParams } = new URL(request.url); diff --git a/src/app/warning/page.tsx b/src/app/warning/page.tsx index 4efb2a0..92e570e 100644 --- a/src/app/warning/page.tsx +++ b/src/app/warning/page.tsx @@ -52,7 +52,7 @@ export default function WarningPage() { 📢 重要更新说明

- 自 v2.0.5 版本起,已不再支持无数据库部署方式,请配置数据库相关环境变量。 + 自 v200.5.0 版本起,已不再支持无数据库部署方式,请配置数据库相关环境变量。

diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index e0e9114..4b99de5 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -11,6 +11,17 @@ export interface ChangelogEntry { export const changelog: ChangelogEntry[] = [ { + version: '205.0.1', + date: '2026-01-01', + added: [ + ], + changed: [ + "迁移跳过配置到新数据结构" + ], + fixed: [ + ] + }, + { version: '205.0.0', date: '2026-01-01', added: [ diff --git a/src/lib/db.ts b/src/lib/db.ts index 6584fb3..ba1a38b 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -180,6 +180,7 @@ export class DbManager { created_at: number; playrecord_migrated?: boolean; favorite_migrated?: boolean; + skip_migrated?: boolean; } | null> { if (typeof (this.storage as any).getUserInfoV2 === 'function') { return (this.storage as any).getUserInfoV2(userName); @@ -271,6 +272,13 @@ export class DbManager { } } + // ---------- 跳过配置迁移 ---------- + async migrateSkipConfigs(userName: string): Promise { + if (typeof (this.storage as any).migrateSkipConfigs === 'function') { + await (this.storage as any).migrateSkipConfigs(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 f8a4384..89b702a 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -550,7 +550,10 @@ export abstract class BaseRedisStorage implements IStorage { await this.withRetry(() => this.client.del(favoriteKeys)); } - // 删除跳过片头片尾配置 + // 删除跳过片头片尾配置(新hash结构) + await this.withRetry(() => this.client.del(this.skipHashKey(userName))); + + // 删除旧的跳过配置key(如果有) const skipConfigPattern = `u:${userName}:skip:*`; const skipConfigKeys = await this.withRetry(() => this.client.keys(skipConfigPattern) @@ -655,6 +658,7 @@ export abstract class BaseRedisStorage implements IStorage { created_at: number; playrecord_migrated?: boolean; favorite_migrated?: boolean; + skip_migrated?: boolean; } | null> { const userInfo = await this.withRetry(() => this.client.hGetAll(this.userInfoKey(userName)) @@ -673,6 +677,7 @@ export abstract class BaseRedisStorage implements IStorage { created_at: parseInt(userInfo.created_at || '0', 10), playrecord_migrated: userInfo.playrecord_migrated === 'true', favorite_migrated: userInfo.favorite_migrated === 'true', + skip_migrated: userInfo.skip_migrated === 'true', }; } @@ -947,8 +952,8 @@ export abstract class BaseRedisStorage implements IStorage { } // ---------- 跳过片头片尾配置 ---------- - private skipConfigKey(user: string, source: string, id: string) { - return `u:${user}:skip:${source}+${id}`; + private skipHashKey(user: string) { + return `u:${user}:skip`; // u:username:skip (hash结构) } private danmakuFilterConfigKey(user: string) { @@ -960,8 +965,9 @@ export abstract class BaseRedisStorage implements IStorage { source: string, id: string ): Promise { + const key = `${source}+${id}`; const val = await this.withRetry(() => - this.client.get(this.skipConfigKey(userName, source, id)) + this.client.hGet(this.skipHashKey(userName), key) ); return val ? (JSON.parse(val) as SkipConfig) : null; } @@ -972,11 +978,9 @@ export abstract class BaseRedisStorage implements IStorage { id: string, config: SkipConfig ): Promise { + const key = `${source}+${id}`; await this.withRetry(() => - this.client.set( - this.skipConfigKey(userName, source, id), - JSON.stringify(config) - ) + this.client.hSet(this.skipHashKey(userName), key, JSON.stringify(config)) ); } @@ -985,39 +989,100 @@ export abstract class BaseRedisStorage implements IStorage { source: string, id: string ): Promise { + const key = `${source}+${id}`; await this.withRetry(() => - this.client.del(this.skipConfigKey(userName, source, id)) + this.client.hDel(this.skipHashKey(userName), key) ); } async getAllSkipConfigs( userName: string ): Promise<{ [key: string]: SkipConfig }> { - const pattern = `u:${userName}:skip:*`; - const keys = await this.withRetry(() => this.client.keys(pattern)); + const hashData = await this.withRetry(() => + this.client.hGetAll(this.skipHashKey(userName)) + ); - if (keys.length === 0) { - return {}; + const result: Record = {}; + for (const [key, value] of Object.entries(hashData)) { + if (value) { + result[key] = JSON.parse(value) as SkipConfig; + } + } + return result; + } + + // 迁移跳过配置:从旧的多key结构迁移到新的hash结构 + async migrateSkipConfigs(userName: string): Promise { + const existingMigration = playRecordLocks.get(`${userName}:skip`); + if (existingMigration) { + console.log(`用户 ${userName} 的跳过配置正在迁移中,等待完成...`); + await existingMigration; + return; } - const configs: { [key: string]: SkipConfig } = {}; + const migrationPromise = this.doSkipConfigMigration(userName); + playRecordLocks.set(`${userName}:skip`, migrationPromise); - // 批量获取所有配置 - const values = await this.withRetry(() => this.client.mGet(keys)); + try { + await migrationPromise; + } finally { + playRecordLocks.delete(`${userName}:skip`); + } + } - keys.forEach((key, index) => { + private async doSkipConfigMigration(userName: string): Promise { + console.log(`开始迁移用户 ${userName} 的跳过配置...`); + + const userInfo = await this.getUserInfoV2(userName); + if (userInfo?.skip_migrated) { + console.log(`用户 ${userName} 的跳过配置已经迁移过,跳过`); + return; + } + + const pattern = `u:${userName}:skip:*`; + 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), 'skip_migrated', 'true') + ); + const { userInfoCache } = await import('./user-cache'); + userInfoCache?.delete(userName); + return; + } + + const values = await this.withRetry(() => this.client.mGet(oldKeys)); + + const hashData: Record = {}; + oldKeys.forEach((key, index) => { const value = values[index]; if (value) { - // 从key中提取source+id const match = key.match(/^u:.+?:skip:(.+)$/); if (match) { const sourceAndId = match[1]; - configs[sourceAndId] = JSON.parse(value as string) as SkipConfig; + hashData[sourceAndId] = value as string; } } }); - return configs; + if (Object.keys(hashData).length > 0) { + await this.withRetry(() => + this.client.hSet(this.skipHashKey(userName), hashData) + ); + console.log(`成功迁移 ${Object.keys(hashData).length} 条跳过配置到hash结构`); + } + + await this.withRetry(() => this.client.del(oldKeys)); + console.log(`删除了 ${oldKeys.length} 个旧的跳过配置key`); + + await this.withRetry(() => + this.client.hSet(this.userInfoKey(userName), 'skip_migrated', 'true') + ); + const { userInfoCache } = await import('./user-cache'); + userInfoCache?.delete(userName); + + console.log(`用户 ${userName} 的跳过配置迁移完成`); } // ---------- 弹幕过滤配置 ---------- diff --git a/src/lib/types.ts b/src/lib/types.ts index 3159881..175a217 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -87,6 +87,8 @@ export interface IStorage { ): Promise; deleteSkipConfig(userName: string, source: string, id: string): Promise; getAllSkipConfigs(userName: string): Promise<{ [key: string]: SkipConfig }>; + // 迁移跳过配置 + migrateSkipConfigs(userName: string): Promise; // 弹幕过滤配置相关 getDanmakuFilterConfig(userName: string): Promise; diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index fb75710..4c0fa2a 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -451,7 +451,10 @@ export class UpstashRedisStorage implements IStorage { await withRetry(() => this.client.del(...favoriteKeys)); } - // 删除跳过片头片尾配置 + // 删除跳过片头片尾配置(新hash结构) + await withRetry(() => this.client.del(this.skipHashKey(userName))); + + // 删除旧的跳过配置key(如果有) const skipConfigPattern = `u:${userName}:skip:*`; const skipConfigKeys = await withRetry(() => this.client.keys(skipConfigPattern) @@ -564,6 +567,7 @@ export class UpstashRedisStorage implements IStorage { created_at: number; playrecord_migrated?: boolean; favorite_migrated?: boolean; + skip_migrated?: boolean; } | null> { // 先从缓存获取 const cached = userInfoCache?.get(userName); @@ -607,6 +611,16 @@ export class UpstashRedisStorage implements IStorage { } } + // 处理 skip_migrated 字段 + let skip_migrated: boolean | undefined = undefined; + if (userInfo.skip_migrated !== undefined) { + if (typeof userInfo.skip_migrated === 'boolean') { + skip_migrated = userInfo.skip_migrated; + } else if (typeof userInfo.skip_migrated === 'string') { + skip_migrated = userInfo.skip_migrated === 'true'; + } + } + // 安全解析 tags 字段 let tags: string[] | undefined = undefined; if (userInfo.tags) { @@ -646,6 +660,7 @@ export class UpstashRedisStorage implements IStorage { created_at: parseInt((userInfo.created_at as string) || '0', 10), playrecord_migrated, favorite_migrated, + skip_migrated, }; // 存入缓存 @@ -968,8 +983,8 @@ export class UpstashRedisStorage implements IStorage { } // ---------- 跳过片头片尾配置 ---------- - private skipConfigKey(user: string, source: string, id: string) { - return `u:${user}:skip:${source}+${id}`; + private skipHashKey(user: string) { + return `u:${user}:skip`; // u:username:skip (hash结构) } private danmakuFilterConfigKey(user: string) { @@ -981,8 +996,9 @@ export class UpstashRedisStorage implements IStorage { source: string, id: string ): Promise { + const key = `${source}+${id}`; const val = await withRetry(() => - this.client.get(this.skipConfigKey(userName, source, id)) + this.client.hget(this.skipHashKey(userName), key) ); return val ? (val as SkipConfig) : null; } @@ -993,8 +1009,9 @@ export class UpstashRedisStorage implements IStorage { id: string, config: SkipConfig ): Promise { + const key = `${source}+${id}`; await withRetry(() => - this.client.set(this.skipConfigKey(userName, source, id), config) + this.client.hset(this.skipHashKey(userName), { [key]: config }) ); } @@ -1003,39 +1020,92 @@ export class UpstashRedisStorage implements IStorage { source: string, id: string ): Promise { + const key = `${source}+${id}`; await withRetry(() => - this.client.del(this.skipConfigKey(userName, source, id)) + this.client.hdel(this.skipHashKey(userName), key) ); } async getAllSkipConfigs( userName: string ): Promise<{ [key: string]: SkipConfig }> { - const pattern = `u:${userName}:skip:*`; - const keys = await withRetry(() => this.client.keys(pattern)); + const hashData = await withRetry(() => + this.client.hgetall>(this.skipHashKey(userName)) + ); - if (keys.length === 0) { - return {}; + return hashData || {}; + } + + // 迁移跳过配置:从旧的多key结构迁移到新的hash结构 + async migrateSkipConfigs(userName: string): Promise { + const existingMigration = playRecordLocks.get(`${userName}:skip`); + if (existingMigration) { + console.log(`用户 ${userName} 的跳过配置正在迁移中,等待完成...`); + await existingMigration; + return; } - const configs: { [key: string]: SkipConfig } = {}; + const migrationPromise = this.doSkipConfigMigration(userName); + playRecordLocks.set(`${userName}:skip`, migrationPromise); - // 批量获取所有配置 - const values = await withRetry(() => this.client.mget(keys)); + try { + await migrationPromise; + } finally { + playRecordLocks.delete(`${userName}:skip`); + } + } - keys.forEach((key, index) => { + private async doSkipConfigMigration(userName: string): Promise { + console.log(`开始迁移用户 ${userName} 的跳过配置...`); + + const userInfo = await this.getUserInfoV2(userName); + if (userInfo?.skip_migrated) { + console.log(`用户 ${userName} 的跳过配置已经迁移过,跳过`); + return; + } + + const pattern = `u:${userName}:skip:*`; + const oldKeys: string[] = await withRetry(() => this.client.keys(pattern)); + + if (oldKeys.length === 0) { + console.log(`用户 ${userName} 没有旧的跳过配置,标记为已迁移`); + await withRetry(() => + this.client.hset(this.userInfoKey(userName), { skip_migrated: 'true' }) + ); + userInfoCache?.delete(userName); + return; + } + + const values = await withRetry(() => this.client.mget(oldKeys)); + + const hashData: Record = {}; + oldKeys.forEach((key, index) => { const value = values[index]; if (value) { - // 从key中提取source+id const match = key.match(/^u:.+?:skip:(.+)$/); if (match) { const sourceAndId = match[1]; - configs[sourceAndId] = value as SkipConfig; + hashData[sourceAndId] = value as SkipConfig; } } }); - return configs; + if (Object.keys(hashData).length > 0) { + await withRetry(() => + this.client.hset(this.skipHashKey(userName), hashData) + ); + console.log(`成功迁移 ${Object.keys(hashData).length} 条跳过配置到hash结构`); + } + + await withRetry(() => this.client.del(...oldKeys)); + console.log(`删除了 ${oldKeys.length} 个旧的跳过配置key`); + + await withRetry(() => + this.client.hset(this.userInfoKey(userName), { skip_migrated: 'true' }) + ); + userInfoCache?.delete(userName); + + console.log(`用户 ${userName} 的跳过配置迁移完成`); } // ---------- 弹幕过滤配置 ---------- diff --git a/src/lib/version.ts b/src/lib/version.ts index 05d2337..04bf19d 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,6 +1,6 @@ /* eslint-disable no-console */ -const CURRENT_VERSION = '205.0.0'; +const CURRENT_VERSION = '205.0.1'; // 导出当前版本号供其他地方使用 export { CURRENT_VERSION };