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 };