diff --git a/src/lib/kvrocks.db.ts b/src/lib/kvrocks.db.ts index 87cac06..8c07b9c 100644 --- a/src/lib/kvrocks.db.ts +++ b/src/lib/kvrocks.db.ts @@ -1,6 +1,7 @@ /* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ -import { BaseRedisStorage } from './redis-base.db'; +import { BaseRedisStorage, createRedisClient, createRetryWrapper } from './redis-base.db'; +import { StandardRedisAdapter } from './redis-adapter'; export class KvrocksStorage extends BaseRedisStorage { constructor() { @@ -9,6 +10,9 @@ export class KvrocksStorage extends BaseRedisStorage { clientName: 'Kvrocks' }; const globalSymbol = Symbol.for('__MOONTV_KVROCKS_CLIENT__'); - super(config, globalSymbol); + const client = createRedisClient(config, globalSymbol); + const adapter = new StandardRedisAdapter(client); + const withRetry = createRetryWrapper(config.clientName, () => client); + super(adapter, withRetry); } -} \ No newline at end of file +} diff --git a/src/lib/redis-adapter.ts b/src/lib/redis-adapter.ts new file mode 100644 index 0000000..96fbdc5 --- /dev/null +++ b/src/lib/redis-adapter.ts @@ -0,0 +1,297 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { RedisClientType } from 'redis'; +import { Redis } from '@upstash/redis'; + +/** + * 统一的 Redis 适配器接口 + * 只抽象 API 命名差异,不处理序列化(由调用者负责) + */ +export interface RedisAdapter { + // Hash 操作 + hSet(key: string, field: string, value: string): Promise; + hSet(key: string, data: Record): Promise; + hGet(key: string, field: string): Promise; + hGetAll(key: string): Promise>; + hDel(key: string, ...fields: string[]): Promise; + + // String 操作 + set(key: string, value: string): Promise; + get(key: string): Promise; + del(keys: string | string[]): Promise; + exists(...keys: string[]): Promise; + keys(pattern: string): Promise; + mGet(keys: string[]): Promise<(string | null)[]>; + + // List 操作 + lPush(key: string, ...values: string[]): Promise; + lRange(key: string, start: number, stop: number): Promise; + lRem(key: string, count: number, value: string): Promise; + lTrim(key: string, start: number, stop: number): Promise; + + // Set 操作 + sAdd(key: string, ...members: string[]): Promise; + sMembers(key: string): Promise; + sRem(key: string, ...members: string[]): Promise; + + // Sorted Set 操作 + zAdd(key: string, member: { score: number; value: string }): Promise; + zRange(key: string, start: number, stop: number): Promise; + zCard(key: string): Promise; + zRem(key: string, ...members: string[]): Promise; +} + +/** + * 标准 Redis 客户端适配器(用于 Redis 和 Kvrocks) + * 只处理 API 命名,不处理序列化 + */ +export class StandardRedisAdapter implements RedisAdapter { + constructor(private client: RedisClientType) {} + + // Hash 操作 + async hSet(key: string, fieldOrData: string | Record, value?: string): Promise { + if (typeof fieldOrData === 'string') { + return this.client.hSet(key, fieldOrData, value!); + } else { + return this.client.hSet(key, fieldOrData); + } + } + + async hGet(key: string, field: string): Promise { + const val = await this.client.hGet(key, field); + return val ?? null; + } + + async hGetAll(key: string): Promise> { + return this.client.hGetAll(key); + } + + async hDel(key: string, ...fields: string[]): Promise { + return this.client.hDel(key, fields); + } + + // String 操作 + async set(key: string, value: string): Promise { + await this.client.set(key, value); + } + + async get(key: string): Promise { + return this.client.get(key); + } + + async del(keys: string | string[]): Promise { + const keyArray = Array.isArray(keys) ? keys : [keys]; + if (keyArray.length === 0) return 0; + return this.client.del(keyArray); + } + + async exists(...keys: string[]): Promise { + return this.client.exists(keys); + } + + async keys(pattern: string): Promise { + return this.client.keys(pattern); + } + + async mGet(keys: string[]): Promise<(string | null)[]> { + return this.client.mGet(keys); + } + + // List 操作 + async lPush(key: string, ...values: string[]): Promise { + return this.client.lPush(key, values); + } + + async lRange(key: string, start: number, stop: number): Promise { + return this.client.lRange(key, start, stop); + } + + async lRem(key: string, count: number, value: string): Promise { + return this.client.lRem(key, count, value); + } + + async lTrim(key: string, start: number, stop: number): Promise { + await this.client.lTrim(key, start, stop); + } + + // Set 操作 + async sAdd(key: string, ...members: string[]): Promise { + return this.client.sAdd(key, members); + } + + async sMembers(key: string): Promise { + return Array.from(await this.client.sMembers(key)); + } + + async sRem(key: string, ...members: string[]): Promise { + return this.client.sRem(key, members); + } + + // Sorted Set 操作 + async zAdd(key: string, member: { score: number; value: string }): Promise { + return this.client.zAdd(key, member); + } + + async zRange(key: string, start: number, stop: number): Promise { + return this.client.zRange(key, start, stop); + } + + async zCard(key: string): Promise { + return this.client.zCard(key); + } + + async zRem(key: string, ...members: string[]): Promise { + return this.client.zRem(key, members); + } +} + +/** + * Upstash Redis 客户端适配器(用于 Upstash REST API) + * 处理 API 命名差异和 Upstash 的自动序列化 + */ +export class UpstashRedisAdapter implements RedisAdapter { + constructor(private client: Redis) {} + + // Hash 操作 + async hSet(key: string, fieldOrData: string | Record, value?: string): Promise { + if (typeof fieldOrData === 'string') { + // Upstash 会自动序列化,但我们传入的已经是字符串,所以直接存储 + return this.client.hset(key, { [fieldOrData]: value! }); + } else { + return this.client.hset(key, fieldOrData); + } + } + + async hGet(key: string, field: string): Promise { + const val = await this.client.hget(key, field); + if (val === null || val === undefined) return null; + // Upstash 可能返回对象、字符串或其他类型 + // 需要统一转换为字符串 + if (typeof val === 'string') return val; + if (typeof val === 'object') return JSON.stringify(val); + return String(val); + } + + async hGetAll(key: string): Promise> { + const hashData = await this.client.hgetall(key); + if (!hashData) return {}; + // 确保所有值都是字符串 + const result: Record = {}; + for (const [k, v] of Object.entries(hashData)) { + if (typeof v === 'string') { + result[k] = v; + } else if (typeof v === 'object') { + result[k] = JSON.stringify(v); + } else { + result[k] = String(v); + } + } + return result; + } + + async hDel(key: string, ...fields: string[]): Promise { + return this.client.hdel(key, ...fields); + } + + // String 操作 + async set(key: string, value: string): Promise { + await this.client.set(key, value); + } + + async get(key: string): Promise { + const val = await this.client.get(key); + if (val === null || val === undefined) return null; + // Upstash 可能返回对象、字符串或其他类型 + if (typeof val === 'string') return val; + if (typeof val === 'object') return JSON.stringify(val); + return String(val); + } + + async del(keys: string | string[]): Promise { + const keyArray = Array.isArray(keys) ? keys : [keys]; + if (keyArray.length === 0) return 0; + return this.client.del(...keyArray); + } + + async exists(...keys: string[]): Promise { + return this.client.exists(...keys); + } + + async keys(pattern: string): Promise { + return this.client.keys(pattern); + } + + async mGet(keys: string[]): Promise<(string | null)[]> { + const values = await this.client.mget(...keys); + return values.map(v => { + if (v === null || v === undefined) return null; + if (typeof v === 'string') return v; + if (typeof v === 'object') return JSON.stringify(v); + return String(v); + }); + } + + // List 操作 + async lPush(key: string, ...values: string[]): Promise { + return this.client.lpush(key, ...values); + } + + async lRange(key: string, start: number, stop: number): Promise { + const values = await this.client.lrange(key, start, stop); + return values.map(v => { + if (typeof v === 'string') return v; + if (typeof v === 'object') return JSON.stringify(v); + return String(v); + }); + } + + async lRem(key: string, count: number, value: string): Promise { + return this.client.lrem(key, count, value); + } + + async lTrim(key: string, start: number, stop: number): Promise { + await this.client.ltrim(key, start, stop); + } + + // Set 操作 + async sAdd(key: string, ...members: string[]): Promise { + if (members.length === 0) return 0; + return this.client.sadd(key, members[0], ...members.slice(1)); + } + + async sMembers(key: string): Promise { + const members = await this.client.smembers(key); + return members.map(m => { + if (typeof m === 'string') return m; + if (typeof m === 'object') return JSON.stringify(m); + return String(m); + }); + } + + async sRem(key: string, ...members: string[]): Promise { + return this.client.srem(key, ...members); + } + + // Sorted Set 操作 + async zAdd(key: string, member: { score: number; value: string }): Promise { + const result = await this.client.zadd(key, { score: member.score, member: member.value }); + return result || 0; + } + + async zRange(key: string, start: number, stop: number): Promise { + const values = await this.client.zrange(key, start, stop); + return values.map(v => { + if (typeof v === 'string') return v; + if (typeof v === 'object') return JSON.stringify(v); + return String(v); + }); + } + + async zCard(key: string): Promise { + return this.client.zcard(key); + } + + async zRem(key: string, ...members: string[]): Promise { + return this.client.zrem(key, ...members); + } +} diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index 23610cc..e03b14c 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -3,6 +3,7 @@ import { createClient, RedisClientType } from 'redis'; import { AdminConfig } from './admin.types'; +import { RedisAdapter } from './redis-adapter'; import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; import { userInfoCache } from './user-cache'; @@ -28,7 +29,7 @@ export interface RedisConnectionConfig { } // 添加Redis操作重试包装器 -function createRetryWrapper(clientName: string, getClient: () => RedisClientType) { +export function createRetryWrapper(clientName: string, getClient: () => RedisClientType) { return async function withRetry( operation: () => Promise, maxRetries = 3 @@ -146,12 +147,38 @@ export function createRedisClient(config: RedisConnectionConfig, globalSymbol: s // 抽象基类,包含所有通用的Redis操作逻辑 export abstract class BaseRedisStorage implements IStorage { - protected client: RedisClientType; + protected adapter: RedisAdapter; protected withRetry: (operation: () => Promise, maxRetries?: number) => Promise; + // 保留 client 属性用于向后兼容(数据迁移代码使用) + client: any; - constructor(config: RedisConnectionConfig, globalSymbol: symbol) { - this.client = createRedisClient(config, globalSymbol); - this.withRetry = createRetryWrapper(config.clientName, () => this.client); + constructor(adapter: RedisAdapter, withRetryFn: (operation: () => Promise, maxRetries?: number) => Promise) { + this.adapter = adapter; + this.withRetry = withRetryFn; + // 创建兼容层,同时支持驼峰和小写命名(用于数据迁移代码) + this.client = { + hSet: (key: string, ...args: any[]) => { + if (args.length === 1) { + return this.adapter.hSet(key, args[0]); + } + return this.adapter.hSet(key, args[0], args[1]); + }, + hset: (key: string, ...args: any[]) => { + if (args.length === 1) { + return this.adapter.hSet(key, args[0]); + } + return this.adapter.hSet(key, args[0], args[1]); + }, + hGet: (key: string, field: string) => this.adapter.hGet(key, field), + hget: (key: string, field: string) => this.adapter.hGet(key, field), + hGetAll: (key: string) => this.adapter.hGetAll(key), + hgetall: (key: string) => this.adapter.hGetAll(key), + zAdd: (key: string, member: { score: number; value: string }) => this.adapter.zAdd(key, member), + zadd: (key: string, member: { score: number; value: string }) => this.adapter.zAdd(key, member), + set: (key: string, value: string) => this.adapter.set(key, value), + get: (key: string) => this.adapter.get(key), + del: (...keys: string[]) => this.adapter.del(keys), + }; } // ---------- 播放记录 ---------- @@ -169,7 +196,7 @@ export abstract class BaseRedisStorage implements IStorage { key: string ): Promise { const val = await this.withRetry(() => - this.client.hGet(this.prHashKey(userName), key) + this.adapter.hGet(this.prHashKey(userName), key) ); return val ? (JSON.parse(val) as PlayRecord) : null; } @@ -180,7 +207,7 @@ export abstract class BaseRedisStorage implements IStorage { record: PlayRecord ): Promise { await this.withRetry(() => - this.client.hSet(this.prHashKey(userName), key, JSON.stringify(record)) + this.adapter.hSet(this.prHashKey(userName), key, JSON.stringify(record)) ); } @@ -188,7 +215,7 @@ export abstract class BaseRedisStorage implements IStorage { userName: string ): Promise> { const hashData = await this.withRetry(() => - this.client.hGetAll(this.prHashKey(userName)) + this.adapter.hGetAll(this.prHashKey(userName)) ); const result: Record = {}; @@ -201,7 +228,7 @@ export abstract class BaseRedisStorage implements IStorage { } async deletePlayRecord(userName: string, key: string): Promise { - await this.withRetry(() => this.client.hDel(this.prHashKey(userName), key)); + await this.withRetry(() => this.adapter.hDel(this.prHashKey(userName), key)); } // 清理超出限制的旧播放记录 @@ -300,13 +327,13 @@ export abstract class BaseRedisStorage implements IStorage { // 2. 获取旧结构的所有播放记录key const pattern = `u:${userName}:pr:*`; - const oldKeys: string[] = await this.withRetry(() => this.client.keys(pattern)); + const oldKeys: string[] = await this.withRetry(() => this.adapter.keys(pattern)); if (oldKeys.length === 0) { console.log(`用户 ${userName} 没有旧的播放记录,标记为已迁移`); // 即使没有数据也标记为已迁移 await this.withRetry(() => - this.client.hSet(this.userInfoKey(userName), 'playrecord_migrated', 'true') + this.adapter.hSet(this.userInfoKey(userName), 'playrecord_migrated', 'true') ); // 清除用户信息缓存 const { userInfoCache } = await import('./user-cache'); @@ -317,7 +344,7 @@ export abstract class BaseRedisStorage implements IStorage { console.log(`找到 ${oldKeys.length} 条旧播放记录,开始迁移...`); // 3. 批量获取旧数据 - const oldValues = await this.withRetry(() => this.client.mGet(oldKeys)); + const oldValues = await this.withRetry(() => this.adapter.mGet(oldKeys)); // 4. 转换为hash格式 const hashData: Record = {}; @@ -333,18 +360,18 @@ export abstract class BaseRedisStorage implements IStorage { // 5. 写入新的hash结构 if (Object.keys(hashData).length > 0) { await this.withRetry(() => - this.client.hSet(this.prHashKey(userName), hashData) + this.adapter.hSet(this.prHashKey(userName), hashData) ); console.log(`成功迁移 ${Object.keys(hashData).length} 条播放记录到hash结构`); } // 6. 删除旧的key - await this.withRetry(() => this.client.del(oldKeys)); + await this.withRetry(() => this.adapter.del(oldKeys)); console.log(`删除了 ${oldKeys.length} 个旧的播放记录key`); // 7. 标记迁移完成 await this.withRetry(() => - this.client.hSet(this.userInfoKey(userName), 'playrecord_migrated', 'true') + this.adapter.hSet(this.userInfoKey(userName), 'playrecord_migrated', 'true') ); // 8. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识 @@ -366,7 +393,7 @@ export abstract class BaseRedisStorage implements IStorage { async getFavorite(userName: string, key: string): Promise { const val = await this.withRetry(() => - this.client.hGet(this.favHashKey(userName), key) + this.adapter.hGet(this.favHashKey(userName), key) ); return val ? (JSON.parse(val) as Favorite) : null; } @@ -377,13 +404,13 @@ export abstract class BaseRedisStorage implements IStorage { favorite: Favorite ): Promise { await this.withRetry(() => - this.client.hSet(this.favHashKey(userName), key, JSON.stringify(favorite)) + this.adapter.hSet(this.favHashKey(userName), key, JSON.stringify(favorite)) ); } async getAllFavorites(userName: string): Promise> { const hashData = await this.withRetry(() => - this.client.hGetAll(this.favHashKey(userName)) + this.adapter.hGetAll(this.favHashKey(userName)) ); const result: Record = {}; @@ -396,7 +423,7 @@ export abstract class BaseRedisStorage implements IStorage { } async deleteFavorite(userName: string, key: string): Promise { - await this.withRetry(() => this.client.hDel(this.favHashKey(userName), key)); + await this.withRetry(() => this.adapter.hDel(this.favHashKey(userName), key)); } // 迁移收藏:从旧的多key结构迁移到新的hash结构 @@ -434,13 +461,13 @@ export abstract class BaseRedisStorage implements IStorage { // 2. 获取旧结构的所有收藏key const pattern = `u:${userName}:fav:*`; - const oldKeys: string[] = await this.withRetry(() => this.client.keys(pattern)); + const oldKeys: string[] = await this.withRetry(() => this.adapter.keys(pattern)); if (oldKeys.length === 0) { console.log(`用户 ${userName} 没有旧的收藏,标记为已迁移`); // 即使没有数据也标记为已迁移 await this.withRetry(() => - this.client.hSet(this.userInfoKey(userName), 'favorite_migrated', 'true') + this.adapter.hSet(this.userInfoKey(userName), 'favorite_migrated', 'true') ); // 清除用户信息缓存 const { userInfoCache } = await import('./user-cache'); @@ -451,7 +478,7 @@ export abstract class BaseRedisStorage implements IStorage { console.log(`找到 ${oldKeys.length} 条旧收藏,开始迁移...`); // 3. 批量获取旧数据 - const oldValues = await this.withRetry(() => this.client.mGet(oldKeys)); + const oldValues = await this.withRetry(() => this.adapter.mGet(oldKeys)); // 4. 转换为hash格式 const hashData: Record = {}; @@ -467,18 +494,18 @@ export abstract class BaseRedisStorage implements IStorage { // 5. 写入新的hash结构 if (Object.keys(hashData).length > 0) { await this.withRetry(() => - this.client.hSet(this.favHashKey(userName), hashData) + this.adapter.hSet(this.favHashKey(userName), hashData) ); console.log(`成功迁移 ${Object.keys(hashData).length} 条收藏到hash结构`); } // 6. 删除旧的key - await this.withRetry(() => this.client.del(oldKeys)); + await this.withRetry(() => this.adapter.del(oldKeys)); console.log(`删除了 ${oldKeys.length} 个旧的收藏key`); // 7. 标记迁移完成 await this.withRetry(() => - this.client.hSet(this.userInfoKey(userName), 'favorite_migrated', 'true') + this.adapter.hSet(this.userInfoKey(userName), 'favorite_migrated', 'true') ); // 8. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识 @@ -495,7 +522,7 @@ export abstract class BaseRedisStorage implements IStorage { async verifyUser(userName: string, password: string): Promise { const stored = await this.withRetry(() => - this.client.get(this.userPwdKey(userName)) + this.adapter.get(this.userPwdKey(userName)) ); if (stored === null) return false; // 确保比较时都是字符串类型 @@ -506,7 +533,7 @@ export abstract class BaseRedisStorage implements IStorage { async checkUserExist(userName: string): Promise { // 使用 EXISTS 判断 key 是否存在 const exists = await this.withRetry(() => - this.client.exists(this.userPwdKey(userName)) + this.adapter.exists(this.userPwdKey(userName)) ); return exists === 1; } @@ -515,52 +542,52 @@ export abstract class BaseRedisStorage implements IStorage { async changePassword(userName: string, newPassword: string): Promise { // 简单存储明文密码,生产环境应加密 await this.withRetry(() => - this.client.set(this.userPwdKey(userName), newPassword) + this.adapter.set(this.userPwdKey(userName), newPassword) ); } // 删除用户及其所有数据 async deleteUser(userName: string): Promise { // 删除用户密码 - await this.withRetry(() => this.client.del(this.userPwdKey(userName))); + await this.withRetry(() => this.adapter.del(this.userPwdKey(userName))); // 删除搜索历史 - await this.withRetry(() => this.client.del(this.shKey(userName))); + await this.withRetry(() => this.adapter.del(this.shKey(userName))); // 删除播放记录(新hash结构) - await this.withRetry(() => this.client.del(this.prHashKey(userName))); + await this.withRetry(() => this.adapter.del(this.prHashKey(userName))); // 删除旧的播放记录key(如果有) const playRecordPattern = `u:${userName}:pr:*`; const playRecordKeys = await this.withRetry(() => - this.client.keys(playRecordPattern) + this.adapter.keys(playRecordPattern) ); if (playRecordKeys.length > 0) { - await this.withRetry(() => this.client.del(playRecordKeys)); + await this.withRetry(() => this.adapter.del(playRecordKeys)); } // 删除收藏夹(新hash结构) - await this.withRetry(() => this.client.del(this.favHashKey(userName))); + await this.withRetry(() => this.adapter.del(this.favHashKey(userName))); // 删除旧的收藏key(如果有) const favoritePattern = `u:${userName}:fav:*`; const favoriteKeys = await this.withRetry(() => - this.client.keys(favoritePattern) + this.adapter.keys(favoritePattern) ); if (favoriteKeys.length > 0) { - await this.withRetry(() => this.client.del(favoriteKeys)); + await this.withRetry(() => this.adapter.del(favoriteKeys)); } // 删除跳过片头片尾配置(新hash结构) - await this.withRetry(() => this.client.del(this.skipHashKey(userName))); + await this.withRetry(() => this.adapter.del(this.skipHashKey(userName))); // 删除旧的跳过配置key(如果有) const skipConfigPattern = `u:${userName}:skip:*`; const skipConfigKeys = await this.withRetry(() => - this.client.keys(skipConfigPattern) + this.adapter.keys(skipConfigPattern) ); if (skipConfigKeys.length > 0) { - await this.withRetry(() => this.client.del(skipConfigKeys)); + await this.withRetry(() => this.adapter.del(skipConfigKeys)); } } @@ -617,13 +644,13 @@ export abstract class BaseRedisStorage implements IStorage { if (oidcSub) { userInfo.oidcSub = oidcSub; // 创建OIDC映射 - await this.withRetry(() => this.client.set(this.oidcSubKey(oidcSub), userName)); + await this.withRetry(() => this.adapter.set(this.oidcSubKey(oidcSub), userName)); } - await this.withRetry(() => this.client.hSet(this.userInfoKey(userName), userInfo)); + await this.withRetry(() => this.adapter.hSet(this.userInfoKey(userName), userInfo)); // 添加到用户列表(Sorted Set,按注册时间排序) - await this.withRetry(() => this.client.zAdd(this.userListKey(), { + await this.withRetry(() => this.adapter.zAdd(this.userListKey(), { score: createdAt, value: userName, })); @@ -638,7 +665,7 @@ export abstract class BaseRedisStorage implements IStorage { // 验证用户密码(新版本) async verifyUserV2(userName: string, password: string): Promise { const userInfo = await this.withRetry(() => - this.client.hGetAll(this.userInfoKey(userName)) + this.adapter.hGetAll(this.userInfoKey(userName)) ); if (!userInfo || !userInfo.password) { @@ -665,7 +692,7 @@ export abstract class BaseRedisStorage implements IStorage { emailNotifications?: boolean; } | null> { const userInfo = await this.withRetry(() => - this.client.hGetAll(this.userInfoKey(userName)) + this.adapter.hGetAll(this.userInfoKey(userName)) ); if (!userInfo || Object.keys(userInfo).length === 0) { @@ -714,7 +741,7 @@ export abstract class BaseRedisStorage implements IStorage { userInfo.tags = JSON.stringify(updates.tags); } else { // 删除tags字段 - await this.withRetry(() => this.client.hDel(this.userInfoKey(userName), 'tags')); + await this.withRetry(() => this.adapter.hDel(this.userInfoKey(userName), 'tags')); } } @@ -723,7 +750,7 @@ export abstract class BaseRedisStorage implements IStorage { userInfo.enabledApis = JSON.stringify(updates.enabledApis); } else { // 删除enabledApis字段 - await this.withRetry(() => this.client.hDel(this.userInfoKey(userName), 'enabledApis')); + await this.withRetry(() => this.adapter.hDel(this.userInfoKey(userName), 'enabledApis')); } } @@ -731,15 +758,15 @@ export abstract class BaseRedisStorage implements IStorage { const oldInfo = await this.getUserInfoV2(userName); if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) { // 删除旧的OIDC映射 - await this.withRetry(() => this.client.del(this.oidcSubKey(oldInfo.oidcSub!))); + await this.withRetry(() => this.adapter.del(this.oidcSubKey(oldInfo.oidcSub!))); } userInfo.oidcSub = updates.oidcSub; // 创建新的OIDC映射 - await this.withRetry(() => this.client.set(this.oidcSubKey(updates.oidcSub!), userName)); + await this.withRetry(() => this.adapter.set(this.oidcSubKey(updates.oidcSub!), userName)); } if (Object.keys(userInfo).length > 0) { - await this.withRetry(() => this.client.hSet(this.userInfoKey(userName), userInfo)); + await this.withRetry(() => this.adapter.hSet(this.userInfoKey(userName), userInfo)); } } @@ -747,14 +774,14 @@ export abstract class BaseRedisStorage implements IStorage { async changePasswordV2(userName: string, newPassword: string): Promise { const hashedPassword = await this.hashPassword(newPassword); await this.withRetry(() => - this.client.hSet(this.userInfoKey(userName), 'password', hashedPassword) + this.adapter.hSet(this.userInfoKey(userName), 'password', hashedPassword) ); } // 检查用户是否存在(新版本) async checkUserExistV2(userName: string): Promise { const exists = await this.withRetry(() => - this.client.exists(this.userInfoKey(userName)) + this.adapter.exists(this.userInfoKey(userName)) ); return exists === 1; } @@ -762,7 +789,7 @@ export abstract class BaseRedisStorage implements IStorage { // 通过OIDC Sub查找用户名 async getUserByOidcSub(oidcSub: string): Promise { const userName = await this.withRetry(() => - this.client.get(this.oidcSubKey(oidcSub)) + this.adapter.get(this.oidcSubKey(oidcSub)) ); return userName ? ensureString(userName) : null; } @@ -785,7 +812,7 @@ export abstract class BaseRedisStorage implements IStorage { total: number; }> { // 获取总数 - let total = await this.withRetry(() => this.client.zCard(this.userListKey())); + let total = await this.withRetry(() => this.adapter.zCard(this.userListKey())); // 检查站长是否在数据库中(使用缓存) let ownerInfo = null; @@ -832,7 +859,7 @@ export abstract class BaseRedisStorage implements IStorage { // 获取用户列表(按注册时间升序) const usernames = await this.withRetry(() => - this.client.zRange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1) + this.adapter.zRange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1) ); const users = []; @@ -883,14 +910,14 @@ export abstract class BaseRedisStorage implements IStorage { // 删除OIDC映射 if (userInfo?.oidcSub) { - await this.withRetry(() => this.client.del(this.oidcSubKey(userInfo.oidcSub!))); + await this.withRetry(() => this.adapter.del(this.oidcSubKey(userInfo.oidcSub!))); } // 删除用户信息Hash - await this.withRetry(() => this.client.del(this.userInfoKey(userName))); + await this.withRetry(() => this.adapter.del(this.userInfoKey(userName))); // 从用���列表中移除 - await this.withRetry(() => this.client.zRem(this.userListKey(), userName)); + await this.withRetry(() => this.adapter.zRem(this.userListKey(), userName)); // 删除用户的其他数据(播放记录、收藏等) await this.deleteUser(userName); @@ -903,7 +930,7 @@ export abstract class BaseRedisStorage implements IStorage { async getSearchHistory(userName: string): Promise { const result = await this.withRetry(() => - this.client.lRange(this.shKey(userName), 0, -1) + this.adapter.lRange(this.shKey(userName), 0, -1) ); // 确保返回的都是字符串类型 return ensureStringArray(result as any[]); @@ -912,19 +939,19 @@ export abstract class BaseRedisStorage implements IStorage { async addSearchHistory(userName: string, keyword: string): Promise { const key = this.shKey(userName); // 先去重 - await this.withRetry(() => this.client.lRem(key, 0, ensureString(keyword))); + await this.withRetry(() => this.adapter.lRem(key, 0, ensureString(keyword))); // 插入到最前 - await this.withRetry(() => this.client.lPush(key, ensureString(keyword))); + await this.withRetry(() => this.adapter.lPush(key, ensureString(keyword))); // 限制最大长度 - await this.withRetry(() => this.client.lTrim(key, 0, SEARCH_HISTORY_LIMIT - 1)); + await this.withRetry(() => this.adapter.lTrim(key, 0, SEARCH_HISTORY_LIMIT - 1)); } async deleteSearchHistory(userName: string, keyword?: string): Promise { const key = this.shKey(userName); if (keyword) { - await this.withRetry(() => this.client.lRem(key, 0, ensureString(keyword))); + await this.withRetry(() => this.adapter.lRem(key, 0, ensureString(keyword))); } else { - await this.withRetry(() => this.client.del(key)); + await this.withRetry(() => this.adapter.del(key)); } } @@ -933,7 +960,7 @@ export abstract class BaseRedisStorage implements IStorage { // 从新版用户列表获取 const userListKey = this.userListKey(); const users = await this.withRetry(() => - this.client.zRange(userListKey, 0, -1) + this.adapter.zRange(userListKey, 0, -1) ); const userList = users.map(u => ensureString(u)); @@ -952,13 +979,13 @@ export abstract class BaseRedisStorage implements IStorage { } async getAdminConfig(): Promise { - const val = await this.withRetry(() => this.client.get(this.adminConfigKey())); + const val = await this.withRetry(() => this.adapter.get(this.adminConfigKey())); return val ? (JSON.parse(val) as AdminConfig) : null; } async setAdminConfig(config: AdminConfig): Promise { await this.withRetry(() => - this.client.set(this.adminConfigKey(), JSON.stringify(config)) + this.adapter.set(this.adminConfigKey(), JSON.stringify(config)) ); } @@ -978,7 +1005,7 @@ export abstract class BaseRedisStorage implements IStorage { ): Promise { const key = `${source}+${id}`; const val = await this.withRetry(() => - this.client.hGet(this.skipHashKey(userName), key) + this.adapter.hGet(this.skipHashKey(userName), key) ); return val ? (JSON.parse(val) as SkipConfig) : null; } @@ -991,7 +1018,7 @@ export abstract class BaseRedisStorage implements IStorage { ): Promise { const key = `${source}+${id}`; await this.withRetry(() => - this.client.hSet(this.skipHashKey(userName), key, JSON.stringify(config)) + this.adapter.hSet(this.skipHashKey(userName), key, JSON.stringify(config)) ); } @@ -1002,7 +1029,7 @@ export abstract class BaseRedisStorage implements IStorage { ): Promise { const key = `${source}+${id}`; await this.withRetry(() => - this.client.hDel(this.skipHashKey(userName), key) + this.adapter.hDel(this.skipHashKey(userName), key) ); } @@ -1010,7 +1037,7 @@ export abstract class BaseRedisStorage implements IStorage { userName: string ): Promise<{ [key: string]: SkipConfig }> { const hashData = await this.withRetry(() => - this.client.hGetAll(this.skipHashKey(userName)) + this.adapter.hGetAll(this.skipHashKey(userName)) ); const result: Record = {}; @@ -1051,19 +1078,19 @@ export abstract class BaseRedisStorage implements IStorage { } const pattern = `u:${userName}:skip:*`; - const oldKeys: string[] = await this.withRetry(() => this.client.keys(pattern)); + const oldKeys: string[] = await this.withRetry(() => this.adapter.keys(pattern)); if (oldKeys.length === 0) { console.log(`用户 ${userName} 没有旧的跳过配置,标记为已迁移`); await this.withRetry(() => - this.client.hSet(this.userInfoKey(userName), 'skip_migrated', 'true') + this.adapter.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 values = await this.withRetry(() => this.adapter.mGet(oldKeys)); const hashData: Record = {}; oldKeys.forEach((key, index) => { @@ -1079,16 +1106,16 @@ export abstract class BaseRedisStorage implements IStorage { if (Object.keys(hashData).length > 0) { await this.withRetry(() => - this.client.hSet(this.skipHashKey(userName), hashData) + this.adapter.hSet(this.skipHashKey(userName), hashData) ); console.log(`成功迁移 ${Object.keys(hashData).length} 条跳过配置到hash结构`); } - await this.withRetry(() => this.client.del(oldKeys)); + await this.withRetry(() => this.adapter.del(oldKeys)); console.log(`删除了 ${oldKeys.length} 个旧的跳过配置key`); await this.withRetry(() => - this.client.hSet(this.userInfoKey(userName), 'skip_migrated', 'true') + this.adapter.hSet(this.userInfoKey(userName), 'skip_migrated', 'true') ); const { userInfoCache } = await import('./user-cache'); userInfoCache?.delete(userName); @@ -1101,7 +1128,7 @@ export abstract class BaseRedisStorage implements IStorage { userName: string ): Promise { const val = await this.withRetry(() => - this.client.get(this.danmakuFilterConfigKey(userName)) + this.adapter.get(this.danmakuFilterConfigKey(userName)) ); return val ? (JSON.parse(val) as import('./types').DanmakuFilterConfig) : null; } @@ -1111,7 +1138,7 @@ export abstract class BaseRedisStorage implements IStorage { config: import('./types').DanmakuFilterConfig ): Promise { await this.withRetry(() => - this.client.set( + this.adapter.set( this.danmakuFilterConfigKey(userName), JSON.stringify(config) ) @@ -1120,7 +1147,7 @@ export abstract class BaseRedisStorage implements IStorage { async deleteDanmakuFilterConfig(userName: string): Promise { await this.withRetry(() => - this.client.del(this.danmakuFilterConfigKey(userName)) + this.adapter.del(this.danmakuFilterConfigKey(userName)) ); } @@ -1136,7 +1163,7 @@ export abstract class BaseRedisStorage implements IStorage { } // 删除管理员配置 - await this.withRetry(() => this.client.del(this.adminConfigKey())); + await this.withRetry(() => this.adapter.del(this.adminConfigKey())); console.log('所有数据已清空'); } catch (error) { @@ -1152,19 +1179,19 @@ export abstract class BaseRedisStorage implements IStorage { async getGlobalValue(key: string): Promise { const val = await this.withRetry(() => - this.client.get(this.globalValueKey(key)) + this.adapter.get(this.globalValueKey(key)) ); return val ? ensureString(val) : null; } async setGlobalValue(key: string, value: string): Promise { await this.withRetry(() => - this.client.set(this.globalValueKey(key), ensureString(value)) + this.adapter.set(this.globalValueKey(key), ensureString(value)) ); } async deleteGlobalValue(key: string): Promise { - await this.withRetry(() => this.client.del(this.globalValueKey(key))); + await this.withRetry(() => this.adapter.del(this.globalValueKey(key))); } // ---------- 通知相关 ---------- @@ -1178,7 +1205,7 @@ export abstract class BaseRedisStorage implements IStorage { async getNotifications(userName: string): Promise { const val = await this.withRetry(() => - this.client.get(this.notificationsKey(userName)) + this.adapter.get(this.notificationsKey(userName)) ); return val ? (JSON.parse(val) as import('./types').Notification[]) : []; } @@ -1194,7 +1221,7 @@ export abstract class BaseRedisStorage implements IStorage { notifications.splice(100); } await this.withRetry(() => - this.client.set(this.notificationsKey(userName), JSON.stringify(notifications)) + this.adapter.set(this.notificationsKey(userName), JSON.stringify(notifications)) ); } @@ -1207,7 +1234,7 @@ export abstract class BaseRedisStorage implements IStorage { if (notification) { notification.read = true; await this.withRetry(() => - this.client.set(this.notificationsKey(userName), JSON.stringify(notifications)) + this.adapter.set(this.notificationsKey(userName), JSON.stringify(notifications)) ); } } @@ -1219,12 +1246,12 @@ export abstract class BaseRedisStorage implements IStorage { const notifications = await this.getNotifications(userName); const filtered = notifications.filter((n) => n.id !== notificationId); await this.withRetry(() => - this.client.set(this.notificationsKey(userName), JSON.stringify(filtered)) + this.adapter.set(this.notificationsKey(userName), JSON.stringify(filtered)) ); } async clearAllNotifications(userName: string): Promise { - await this.withRetry(() => this.client.del(this.notificationsKey(userName))); + await this.withRetry(() => this.adapter.del(this.notificationsKey(userName))); } async getUnreadNotificationCount(userName: string): Promise { @@ -1234,7 +1261,7 @@ export abstract class BaseRedisStorage implements IStorage { async getLastFavoriteCheckTime(userName: string): Promise { const val = await this.withRetry(() => - this.client.get(this.lastFavoriteCheckKey(userName)) + this.adapter.get(this.lastFavoriteCheckKey(userName)) ); return val ? parseInt(val, 10) : 0; } @@ -1244,7 +1271,7 @@ export abstract class BaseRedisStorage implements IStorage { timestamp: number ): Promise { await this.withRetry(() => - this.client.set(this.lastFavoriteCheckKey(userName), timestamp.toString()) + this.adapter.set(this.lastFavoriteCheckKey(userName), timestamp.toString()) ); } @@ -1258,42 +1285,42 @@ export abstract class BaseRedisStorage implements IStorage { } async getAllMovieRequests(): Promise { - const data = await this.withRetry(() => this.client.hGetAll(this.movieRequestsKey())); + const data = await this.withRetry(() => this.adapter.hGetAll(this.movieRequestsKey())); if (!data || Object.keys(data).length === 0) return []; return Object.values(data).map(v => JSON.parse(v) as import('./types').MovieRequest); } async getMovieRequest(requestId: string): Promise { - const val = await this.withRetry(() => this.client.hGet(this.movieRequestsKey(), requestId)); + const val = await this.withRetry(() => this.adapter.hGet(this.movieRequestsKey(), requestId)); return val ? (JSON.parse(val) as import('./types').MovieRequest) : null; } async createMovieRequest(request: import('./types').MovieRequest): Promise { - await this.withRetry(() => this.client.hSet(this.movieRequestsKey(), request.id, JSON.stringify(request))); + await this.withRetry(() => this.adapter.hSet(this.movieRequestsKey(), request.id, JSON.stringify(request))); } async updateMovieRequest(requestId: string, updates: Partial): Promise { const existing = await this.getMovieRequest(requestId); if (!existing) throw new Error('Movie request not found'); const updated = { ...existing, ...updates }; - await this.withRetry(() => this.client.hSet(this.movieRequestsKey(), requestId, JSON.stringify(updated))); + await this.withRetry(() => this.adapter.hSet(this.movieRequestsKey(), requestId, JSON.stringify(updated))); } async deleteMovieRequest(requestId: string): Promise { - await this.withRetry(() => this.client.hDel(this.movieRequestsKey(), requestId)); + await this.withRetry(() => this.adapter.hDel(this.movieRequestsKey(), requestId)); } async getUserMovieRequests(userName: string): Promise { - const val = await this.withRetry(() => this.client.sMembers(this.userMovieRequestsKey(userName))); + const val = await this.withRetry(() => this.adapter.sMembers(this.userMovieRequestsKey(userName))); return val ? ensureStringArray(val) : []; } async addUserMovieRequest(userName: string, requestId: string): Promise { - await this.withRetry(() => this.client.sAdd(this.userMovieRequestsKey(userName), requestId)); + await this.withRetry(() => this.adapter.sAdd(this.userMovieRequestsKey(userName), requestId)); } async removeUserMovieRequest(userName: string, requestId: string): Promise { - await this.withRetry(() => this.client.sRem(this.userMovieRequestsKey(userName), requestId)); + await this.withRetry(() => this.adapter.sRem(this.userMovieRequestsKey(userName), requestId)); } // ---------- 用户邮箱相关 ---------- @@ -1304,7 +1331,7 @@ export abstract class BaseRedisStorage implements IStorage { async setUserEmail(userName: string, email: string): Promise { await this.withRetry(() => - this.client.hSet(this.userInfoKey(userName), 'email', email) + this.adapter.hSet(this.userInfoKey(userName), 'email', email) ); // 清除缓存 userInfoCache?.delete(userName); @@ -1317,7 +1344,7 @@ export abstract class BaseRedisStorage implements IStorage { async setEmailNotificationPreference(userName: string, enabled: boolean): Promise { await this.withRetry(() => - this.client.hSet(this.userInfoKey(userName), 'emailNotifications', enabled.toString()) + this.adapter.hSet(this.userInfoKey(userName), 'emailNotifications', enabled.toString()) ); // 清除缓存 userInfoCache?.delete(userName); diff --git a/src/lib/redis.db.ts b/src/lib/redis.db.ts index 9ab7607..1876743 100644 --- a/src/lib/redis.db.ts +++ b/src/lib/redis.db.ts @@ -1,6 +1,7 @@ /* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ -import { BaseRedisStorage } from './redis-base.db'; +import { BaseRedisStorage, createRedisClient, createRetryWrapper } from './redis-base.db'; +import { StandardRedisAdapter } from './redis-adapter'; export class RedisStorage extends BaseRedisStorage { constructor() { @@ -9,6 +10,9 @@ export class RedisStorage extends BaseRedisStorage { clientName: 'Redis' }; const globalSymbol = Symbol.for('__MOONTV_REDIS_CLIENT__'); - super(config, globalSymbol); + const client = createRedisClient(config, globalSymbol); + const adapter = new StandardRedisAdapter(client); + const withRetry = createRetryWrapper(config.clientName, () => client); + super(adapter, withRetry); } -} \ No newline at end of file +} diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index 3f4f2e4..163b312 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -2,24 +2,8 @@ import { Redis } from '@upstash/redis'; -import { AdminConfig } from './admin.types'; -import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; -import { userInfoCache } from './user-cache'; - -// 搜索历史最大条数 -const SEARCH_HISTORY_LIMIT = 20; - -// 数据类型转换辅助函数 -function ensureString(value: any): string { - return String(value); -} - -function ensureStringArray(value: any[]): string[] { - return value.map((item) => String(item)); -} - -// 内存锁:用于防止同一用户的并发播放记录操作(迁移、清理等) -const playRecordLocks = new Map>(); +import { BaseRedisStorage } from './redis-base.db'; +import { UpstashRedisAdapter } from './redis-adapter'; // 添加Upstash Redis操作重试包装器 async function withRetry( @@ -57,1314 +41,11 @@ async function withRetry( throw new Error('Max retries exceeded'); } -export class UpstashRedisStorage implements IStorage { - private _client: Redis; - client: any; - +export class UpstashRedisStorage extends BaseRedisStorage { constructor() { - this._client = getUpstashRedisClient(); - // 创建兼容Redis API的client包装器(支持camelCase和lowercase) - this.client = { - hSet: (key: string, field: string | Record, value?: string) => { - if (typeof field === 'string' && value !== undefined) { - return this._client.hset(key, { [field]: value }); - } - return this._client.hset(key, field as Record); - }, - hset: (key: string, data: Record) => this._client.hset(key, data), - zAdd: (key: string, member: { score: number; value: string }) => this._client.zadd(key, { score: member.score, member: member.value }), - zadd: (key: string, member: { score: number; value: string }) => this._client.zadd(key, { score: member.score, member: member.value }), - set: (key: string, value: string) => this._client.set(key, value), - hGetAll: (key: string) => this._client.hgetall(key), - hgetall: (key: string) => this._client.hgetall(key), - }; - } - - // 公开withRetry方法供外部使用 - withRetry(operation: () => Promise, maxRetries = 3): Promise { - return withRetry(operation, maxRetries); - } - - // ---------- 播放记录 ---------- - private prHashKey(user: string) { - return `u:${user}:pr`; // u:username:pr (hash结构) - } - - // 旧版播放记录key(用于迁移) - private prOldKey(user: string, key: string) { - return `u:${user}:pr:${key}`; // u:username:pr:source+id - } - - async getPlayRecord( - userName: string, - key: string - ): Promise { - const val = await withRetry(() => - this._client.hget(this.prHashKey(userName), key) - ); - return val ? (val as PlayRecord) : null; - } - - async setPlayRecord( - userName: string, - key: string, - record: PlayRecord - ): Promise { - await withRetry(() => this._client.hset(this.prHashKey(userName), { [key]: record })); - } - - async getAllPlayRecords( - userName: string - ): Promise> { - const hashData = await withRetry(() => - this._client.hgetall(this.prHashKey(userName)) - ); - - if (!hashData || Object.keys(hashData).length === 0) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(hashData)) { - if (value) { - result[key] = value as PlayRecord; - } - } - return result; - } - - async deletePlayRecord(userName: string, key: string): Promise { - await withRetry(() => this._client.hdel(this.prHashKey(userName), key)); - } - - // 清理超出限制的旧播放记录 - async cleanupOldPlayRecords(userName: string): Promise { - // 检查是否已有正在进行的操作 - const existingLock = playRecordLocks.get(userName); - if (existingLock) { - console.log(`用户 ${userName} 的播放记录操作正在进行中,跳过清理`); - await existingLock; - return; - } - - // 创建新的操作Promise - const cleanupPromise = this.doCleanup(userName); - playRecordLocks.set(userName, cleanupPromise); - - try { - await cleanupPromise; - } finally { - // 操作完成后清除锁 - playRecordLocks.delete(userName); - } - } - - // 实际执行清理的方法 - private async doCleanup(userName: string): Promise { - try { - // 获取配置的最大播放记录数,默认100 - const maxRecords = parseInt(process.env.MAX_PLAY_RECORDS_PER_USER || '100', 10); - const threshold = maxRecords + 10; // 超过最大值+10时才触发清理 - - // 获取所有播放记录 - const allRecords = await this.getAllPlayRecords(userName); - const recordCount = Object.keys(allRecords).length; - - // 如果记录数未超过阈值,不需要清理 - if (recordCount <= threshold) { - return; - } - - console.log(`用户 ${userName} 的播放记录数 ${recordCount} 超过阈值 ${threshold},开始清理...`); - - // 将记录转换为数组并按 save_time 排序(从旧到新) - const sortedRecords = Object.entries(allRecords).sort( - ([, a], [, b]) => a.save_time - b.save_time - ); - - // 计算需要删除的记录数 - const deleteCount = recordCount - maxRecords; - - // 删除最旧的记录 - const recordsToDelete = sortedRecords.slice(0, deleteCount); - for (const [key] of recordsToDelete) { - await this.deletePlayRecord(userName, key); - } - - console.log(`已删除用户 ${userName} 的 ${deleteCount} 条最旧播放记录`); - } catch (error) { - console.error(`清理用户 ${userName} 播放记录失败:`, error); - // 清理失败不影响主流程,只记录错误 - } - } - - // 迁移播放记录:从旧的多key结构迁移到新的hash结构 - async migratePlayRecords(userName: string): Promise { - // 检查是否已有正在进行的迁移 - const existingMigration = playRecordLocks.get(userName); - if (existingMigration) { - console.log(`用户 ${userName} 的播放记录正在迁移中,等待完成...`); - await existingMigration; - return; - } - - // 创建新的迁移Promise - const migrationPromise = this.doMigration(userName); - playRecordLocks.set(userName, migrationPromise); - - try { - await migrationPromise; - } finally { - // 迁移完成后清除锁 - playRecordLocks.delete(userName); - } - } - - // 实际执行迁移的方法 - private async doMigration(userName: string): Promise { - console.log(`开始迁移用户 ${userName} 的播放记录...`); - - // 1. 检查是否已经迁移过 - const userInfo = await this.getUserInfoV2(userName); - if (userInfo?.playrecord_migrated) { - console.log(`用户 ${userName} 的播放记录已经迁移过,跳过`); - return; - } - - // 2. 获取旧结构的所有播放记录key - const pattern = `u:${userName}:pr:*`; - const oldKeys: string[] = await withRetry(() => this._client.keys(pattern)); - - if (oldKeys.length === 0) { - console.log(`用户 ${userName} 没有旧的播放记录,标记为已迁移`); - // 即使没有数据也标记为已迁移 - await withRetry(() => - this._client.hset(this.userInfoKey(userName), { playrecord_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}:pr:`, '')); - hashData[keyPart] = value; - } - } - - // 4. 写入新的hash结构 - if (Object.keys(hashData).length > 0) { - await withRetry(() => - this._client.hset(this.prHashKey(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), { playrecord_migrated: true }) - ); - - // 7. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识 - userInfoCache?.delete(userName); - - console.log(`用户 ${userName} 的播放记录迁移完成`); - } - - // ---------- 收藏 ---------- - 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.hget(this.favHashKey(userName), key) - ); - return val ? (val as Favorite) : null; - } - - async setFavorite( - userName: string, - key: string, - favorite: Favorite - ): Promise { - await withRetry(() => this._client.hset(this.favHashKey(userName), { [key]: favorite })); - } - - async getAllFavorites(userName: string): Promise> { - const hashData = await withRetry(() => - this._client.hgetall(this.favHashKey(userName)) - ); - - if (!hashData || Object.keys(hashData).length === 0) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(hashData)) { - if (value) { - result[key] = value as Favorite; - } - } - return result; - } - - async deleteFavorite(userName: string, key: string): Promise { - 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} 的收藏迁移完成`); - } - - // ---------- 用户注册 / 登录 ---------- - private userPwdKey(user: string) { - return `u:${user}:pwd`; - } - - async verifyUser(userName: string, password: string): Promise { - const stored = await withRetry(() => - this._client.get(this.userPwdKey(userName)) - ); - if (stored === null) return false; - // 确保比较时都是字符串类型 - return ensureString(stored) === password; - } - - // 检查用户是否存在 - async checkUserExist(userName: string): Promise { - // 使用 EXISTS 判断 key 是否存在 - const exists = await withRetry(() => - this._client.exists(this.userPwdKey(userName)) - ); - return exists === 1; - } - - // 修改用户密码 - async changePassword(userName: string, newPassword: string): Promise { - // 简单存储明文密码,生产环境应加密 - await withRetry(() => - this._client.set(this.userPwdKey(userName), newPassword) - ); - } - - // 删除用户及其所有数据 - async deleteUser(userName: string): Promise { - // 删除用户密码 - await withRetry(() => this._client.del(this.userPwdKey(userName))); - - // 删除搜索历史 - 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) - ); - if (playRecordKeys.length > 0) { - 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) - ); - if (favoriteKeys.length > 0) { - 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) - ); - if (skipConfigKeys.length > 0) { - await withRetry(() => this._client.del(...skipConfigKeys)); - } - } - - // ---------- 新版用户存储(使用Hash和Sorted Set) ---------- - private userInfoKey(userName: string) { - return `user:${userName}:info`; - } - - private userListKey() { - return 'user:list'; - } - - private oidcSubKey(oidcSub: string) { - return `oidc:sub:${oidcSub}`; - } - - // SHA256加密密码 - private async hashPassword(password: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(password); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - } - - // 创建新用户(新版本) - async createUserV2( - userName: string, - password: string, - role: 'owner' | 'admin' | 'user' = 'user', - tags?: string[], - oidcSub?: string, - enabledApis?: string[] - ): Promise { - // 先检查用户是否已存在(原子性检查) - const exists = await withRetry(() => - this._client.exists(this.userInfoKey(userName)) - ); - if (exists === 1) { - throw new Error('用户已存在'); - } - - const hashedPassword = await this.hashPassword(password); - const createdAt = Date.now(); - - // 存储用户信息到Hash - const userInfo: Record = { - role, - banned: false, // 直接使用布尔值 - password: hashedPassword, - created_at: createdAt.toString(), - }; - - if (tags && tags.length > 0) { - userInfo.tags = JSON.stringify(tags); - } - - if (oidcSub) { - userInfo.oidcSub = oidcSub; - // 创建OIDC映射 - await withRetry(() => this._client.set(this.oidcSubKey(oidcSub), userName)); - } - - if (enabledApis && enabledApis.length > 0) { - userInfo.enabledApis = JSON.stringify(enabledApis); - } - - await withRetry(() => this._client.hset(this.userInfoKey(userName), userInfo)); - - // 添加到用户列表(Sorted Set,按注册时间排序) - await withRetry(() => this._client.zadd(this.userListKey(), { - score: createdAt, - member: userName, - })); - - // 如果创建的是站长用户,清除站长存在状态缓存 - if (userName === process.env.USERNAME) { - const { ownerExistenceCache } = await import('./user-cache'); - ownerExistenceCache.delete(userName); - } - } - - // 验证用户密码(新版本) - async verifyUserV2(userName: string, password: string): Promise { - const userInfo = await withRetry(() => - this._client.hgetall(this.userInfoKey(userName)) - ); - - if (!userInfo || !userInfo.password) { - return false; - } - - const hashedPassword = await this.hashPassword(password); - return userInfo.password === hashedPassword; - } - - // 获取用户信息(新版本) - async getUserInfoV2(userName: string): Promise<{ - role: 'owner' | 'admin' | 'user'; - banned: boolean; - tags?: string[]; - oidcSub?: string; - enabledApis?: string[]; - created_at: number; - playrecord_migrated?: boolean; - favorite_migrated?: boolean; - skip_migrated?: boolean; - last_movie_request_time?: number; - email?: string; - emailNotifications?: boolean; - } | null> { - // 先从缓存获取 - const cached = userInfoCache?.get(userName); - if (cached) { - return cached; - } - - const userInfo = await withRetry(() => - this._client.hgetall(this.userInfoKey(userName)) - ); - - if (!userInfo || Object.keys(userInfo).length === 0) { - return null; - } - - // 处理 banned 字段:可能是字符串 'true'/'false' 或布尔值 true/false - let banned = false; - if (typeof userInfo.banned === 'boolean') { - banned = userInfo.banned; - } else if (typeof userInfo.banned === 'string') { - banned = userInfo.banned === 'true'; - } - - // 处理 playrecord_migrated 字段 - let playrecord_migrated: boolean | undefined = undefined; - if (userInfo.playrecord_migrated !== undefined) { - if (typeof userInfo.playrecord_migrated === 'boolean') { - playrecord_migrated = userInfo.playrecord_migrated; - } else if (typeof userInfo.playrecord_migrated === 'string') { - playrecord_migrated = userInfo.playrecord_migrated === 'true'; - } - } - - // 处理 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'; - } - } - - // 处理 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) { - if (Array.isArray(userInfo.tags)) { - tags = userInfo.tags; - } else if (typeof userInfo.tags === 'string') { - try { - tags = JSON.parse(userInfo.tags); - } catch { - // 如果解析失败,可能是单个字符串,转换为数组 - tags = [userInfo.tags]; - } - } - } - - // 安全解析 enabledApis 字段 - let enabledApis: string[] | undefined = undefined; - if (userInfo.enabledApis) { - if (Array.isArray(userInfo.enabledApis)) { - enabledApis = userInfo.enabledApis; - } else if (typeof userInfo.enabledApis === 'string') { - try { - enabledApis = JSON.parse(userInfo.enabledApis); - } catch { - // 如果解析失败,可能是单个字符串,转换为数组 - enabledApis = [userInfo.enabledApis]; - } - } - } - - const result = { - role: (userInfo.role as 'owner' | 'admin' | 'user') || 'user', - banned, - tags, - oidcSub: userInfo.oidcSub as string | undefined, - enabledApis, - created_at: parseInt((userInfo.created_at as string) || '0', 10), - playrecord_migrated, - favorite_migrated, - skip_migrated, - last_movie_request_time: userInfo.last_movie_request_time - ? (typeof userInfo.last_movie_request_time === 'number' - ? userInfo.last_movie_request_time - : parseInt(userInfo.last_movie_request_time as string, 10)) - : undefined, - email: userInfo.email as string | undefined, - emailNotifications: userInfo.emailNotifications === 'true' || userInfo.emailNotifications === true, - }; - - // 存入缓存 - userInfoCache?.set(userName, result); - - return result; - } - - // 更新用户信息(新版本) - async updateUserInfoV2( - userName: string, - updates: { - role?: 'owner' | 'admin' | 'user'; - banned?: boolean; - tags?: string[]; - oidcSub?: string; - enabledApis?: string[]; - } - ): Promise { - const userInfo: Record = {}; - - if (updates.role !== undefined) { - userInfo.role = updates.role; - } - - if (updates.banned !== undefined) { - // 直接存储布尔值,让 Upstash 自动处理序列化 - userInfo.banned = updates.banned; - } - - if (updates.tags !== undefined) { - if (updates.tags.length > 0) { - userInfo.tags = JSON.stringify(updates.tags); - } else { - // 删除tags字段 - await withRetry(() => this._client.hdel(this.userInfoKey(userName), 'tags')); - } - } - - if (updates.enabledApis !== undefined) { - if (updates.enabledApis.length > 0) { - userInfo.enabledApis = JSON.stringify(updates.enabledApis); - } else { - // 删除enabledApis字段 - await withRetry(() => this._client.hdel(this.userInfoKey(userName), 'enabledApis')); - } - } - - if (updates.oidcSub !== undefined) { - const oldInfo = await this.getUserInfoV2(userName); - if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) { - // 删除旧的OIDC映射 - await withRetry(() => this._client.del(this.oidcSubKey(oldInfo.oidcSub!))); - } - userInfo.oidcSub = updates.oidcSub; - // 创建新的OIDC映射 - await withRetry(() => this._client.set(this.oidcSubKey(updates.oidcSub!), userName)); - } - - if (Object.keys(userInfo).length > 0) { - await withRetry(() => this._client.hset(this.userInfoKey(userName), userInfo)); - } - - // 清除缓存 - userInfoCache?.delete(userName); - } - - // 修改用户密码(新版本) - async changePasswordV2(userName: string, newPassword: string): Promise { - const hashedPassword = await this.hashPassword(newPassword); - await withRetry(() => - this._client.hset(this.userInfoKey(userName), { password: hashedPassword }) - ); - - // 清除缓存 - userInfoCache?.delete(userName); - } - - // 检查用户是否存在(新版本) - async checkUserExistV2(userName: string): Promise { - const exists = await withRetry(() => - this._client.exists(this.userInfoKey(userName)) - ); - return exists === 1; - } - - // 通过OIDC Sub查找用户名 - async getUserByOidcSub(oidcSub: string): Promise { - const userName = await withRetry(() => - this._client.get(this.oidcSubKey(oidcSub)) - ); - return userName ? ensureString(userName) : null; - } - - // 获取使用特定用户组的用户列表 - async getUsersByTag(tagName: string): Promise { - const affectedUsers: string[] = []; - - // 使用 SCAN 遍历所有用户信息的 key - let cursor: number | string = 0; - do { - const result = await withRetry(() => - this._client.scan(cursor as number, { match: 'user:*:info', count: 100 }) - ); - - cursor = result[0]; - const keys = result[1]; - - // 检查每个用户的 tags - for (const key of keys) { - const userInfo = await withRetry(() => this._client.hgetall(key)); - if (userInfo && userInfo.tags) { - const tags = JSON.parse(userInfo.tags as string); - if (tags.includes(tagName)) { - // 从 key 中提取用户名: user:username:info -> username - const username = key.replace('user:', '').replace(':info', ''); - affectedUsers.push(username); - } - } - } - } while (typeof cursor === 'number' ? cursor !== 0 : cursor !== '0'); - - return affectedUsers; - } - - // 获取用户列表(分页,新版本) - async getUserListV2( - offset: number = 0, - limit: number = 20, - ownerUsername?: string - ): Promise<{ - users: Array<{ - username: string; - role: 'owner' | 'admin' | 'user'; - banned: boolean; - tags?: string[]; - oidcSub?: string; - enabledApis?: string[]; - created_at: number; - }>; - total: number; - }> { - // 获取总数 - let total = await withRetry(() => this._client.zcard(this.userListKey())); - - // 检查站长是否在数据库中(使用缓存) - let ownerInfo = null; - let ownerInDatabase = false; - if (ownerUsername) { - // 先检查缓存 - const { ownerExistenceCache } = await import('./user-cache'); - const cachedExists = ownerExistenceCache.get(ownerUsername); - - if (cachedExists !== null) { - // 使用缓存的结果 - ownerInDatabase = cachedExists; - if (ownerInDatabase) { - // 如果站长在数据库中,获取详细信息 - ownerInfo = await this.getUserInfoV2(ownerUsername); - } - } else { - // 缓存未命中,查询数据库 - ownerInfo = await this.getUserInfoV2(ownerUsername); - ownerInDatabase = !!ownerInfo; - // 更���缓存 - ownerExistenceCache.set(ownerUsername, ownerInDatabase); - } - - // 如果站长不在数据库中,总数+1(无论在哪一页都要加) - if (!ownerInDatabase) { - total += 1; - } - } - - // 如果站长不在数据库中且在第一页,需要调整获取的用户数量和偏移量 - let actualOffset = offset; - let actualLimit = limit; - - if (ownerUsername && !ownerInDatabase) { - if (offset === 0) { - // 第一页:只获取 limit-1 个用户,为站长留出位置 - actualLimit = limit - 1; - } else { - // 其他页:偏移量需要减1,因为站长占据了第一页的一个位置 - actualOffset = offset - 1; - } - } - - // 获取用户列表(按注册时间升序) - const usernames = await withRetry(() => - this._client.zrange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1) - ); - - const users = []; - - // 如果有站长且在第一页,确保站长始终在第一位 - if (ownerUsername && offset === 0) { - // 即使站长不在数据库中,也要添加站长(站长使用环境变量认证) - users.push({ - username: ownerUsername, - role: 'owner' as const, - banned: ownerInfo?.banned || false, - tags: ownerInfo?.tags, - oidcSub: ownerInfo?.oidcSub, - enabledApis: ownerInfo?.enabledApis, - created_at: ownerInfo?.created_at || 0, - }); - } - - // 获取其他用户信息 - for (const username of usernames) { - const usernameStr = ensureString(username); - // 跳过站长(已经添加) - if (ownerUsername && usernameStr === ownerUsername) { - continue; - } - - const userInfo = await this.getUserInfoV2(usernameStr); - if (userInfo) { - users.push({ - username: usernameStr, - role: userInfo.role, - banned: userInfo.banned, - tags: userInfo.tags, - oidcSub: userInfo.oidcSub, - enabledApis: userInfo.enabledApis, - created_at: userInfo.created_at, - }); - } - } - - return { users, total }; - } - - // 删除用户(新版本) - async deleteUserV2(userName: string): Promise { - // 获取用户信息 - const userInfo = await this.getUserInfoV2(userName); - - // 删除OIDC映射 - if (userInfo?.oidcSub) { - await withRetry(() => this._client.del(this.oidcSubKey(userInfo.oidcSub!))); - } - - // 删除用户信息Hash - await withRetry(() => this._client.del(this.userInfoKey(userName))); - - // 从用户列表中移除 - await withRetry(() => this._client.zrem(this.userListKey(), userName)); - - // 删除用户的其他数据(播放记录、收藏等) - await this.deleteUser(userName); - - // 清除缓存 - userInfoCache?.delete(userName); - } - - // ---------- 搜索历史 ---------- - private shKey(user: string) { - return `u:${user}:sh`; // u:username:sh - } - - async getSearchHistory(userName: string): Promise { - const result = await withRetry(() => - this._client.lrange(this.shKey(userName), 0, -1) - ); - // 确保返回的都是字符串类型 - return ensureStringArray(result as any[]); - } - - async addSearchHistory(userName: string, keyword: string): Promise { - const key = this.shKey(userName); - // 先去重 - await withRetry(() => this._client.lrem(key, 0, ensureString(keyword))); - // 插入到最前 - await withRetry(() => this._client.lpush(key, ensureString(keyword))); - // 限制最大长度 - await withRetry(() => this._client.ltrim(key, 0, SEARCH_HISTORY_LIMIT - 1)); - } - - async deleteSearchHistory(userName: string, keyword?: string): Promise { - const key = this.shKey(userName); - if (keyword) { - await withRetry(() => this._client.lrem(key, 0, ensureString(keyword))); - } else { - await withRetry(() => this._client.del(key)); - } - } - - // ---------- 获取全部用户 ---------- - async getAllUsers(): Promise { - // 从新版用户列表获取 - const userListKey = this.userListKey(); - const users = await withRetry(() => - this._client.zrange(userListKey, 0, -1) - ); - const userList = users.map(u => ensureString(u)); - - // 确保站长在列表中(站长可能不在数据库中,使用环境变量认证) - const ownerUsername = process.env.USERNAME; - if (ownerUsername && !userList.includes(ownerUsername)) { - userList.unshift(ownerUsername); - } - - return userList; - } - - // ---------- 管理员配置 ---------- - private adminConfigKey() { - return 'admin:config'; - } - - async getAdminConfig(): Promise { - const val = await withRetry(() => this._client.get(this.adminConfigKey())); - return val ? (val as AdminConfig) : null; - } - - async setAdminConfig(config: AdminConfig): Promise { - await withRetry(() => this._client.set(this.adminConfigKey(), config)); - } - - // ---------- 跳过片头片尾配置 ---------- - private skipHashKey(user: string) { - return `u:${user}:skip`; // u:username:skip (hash结构) - } - - private danmakuFilterConfigKey(user: string) { - return `u:${user}:danmaku_filter`; - } - - async getSkipConfig( - userName: string, - source: string, - id: string - ): Promise { - const key = `${source}+${id}`; - const val = await withRetry(() => - this._client.hget(this.skipHashKey(userName), key) - ); - return val ? (val as SkipConfig) : null; - } - - async setSkipConfig( - userName: string, - source: string, - id: string, - config: SkipConfig - ): Promise { - const key = `${source}+${id}`; - await withRetry(() => - this._client.hset(this.skipHashKey(userName), { [key]: config }) - ); - } - - async deleteSkipConfig( - userName: string, - source: string, - id: string - ): Promise { - const key = `${source}+${id}`; - await withRetry(() => - this._client.hdel(this.skipHashKey(userName), key) - ); - } - - async getAllSkipConfigs( - userName: string - ): Promise<{ [key: string]: SkipConfig }> { - const hashData = await withRetry(() => - this._client.hgetall>(this.skipHashKey(userName)) - ); - - return hashData || {}; - } - - // 迁移跳过配置:从旧的多key结构迁移到新的hash结构 - async migrateSkipConfigs(userName: string): Promise { - const existingMigration = playRecordLocks.get(`${userName}:skip`); - if (existingMigration) { - console.log(`用户 ${userName} 的跳过配置正在迁移中,等待完成...`); - await existingMigration; - return; - } - - const migrationPromise = this.doSkipConfigMigration(userName); - playRecordLocks.set(`${userName}:skip`, migrationPromise); - - try { - await migrationPromise; - } finally { - playRecordLocks.delete(`${userName}:skip`); - } - } - - 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) { - const match = key.match(/^u:.+?:skip:(.+)$/); - if (match) { - const sourceAndId = match[1]; - hashData[sourceAndId] = value as SkipConfig; - } - } - }); - - 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} 的跳过配置迁移完成`); - } - - // ---------- 弹幕过滤配置 ---------- - async getDanmakuFilterConfig( - userName: string - ): Promise { - const val = await withRetry(() => - this._client.get(this.danmakuFilterConfigKey(userName)) - ); - return val ? (val as import('./types').DanmakuFilterConfig) : null; - } - - async setDanmakuFilterConfig( - userName: string, - config: import('./types').DanmakuFilterConfig - ): Promise { - await withRetry(() => - this._client.set(this.danmakuFilterConfigKey(userName), config) - ); - } - - async deleteDanmakuFilterConfig(userName: string): Promise { - await withRetry(() => - this._client.del(this.danmakuFilterConfigKey(userName)) - ); - } - - // 清空所有数据 - async clearAllData(): Promise { - try { - // 获取所有用户 - const allUsers = await this.getAllUsers(); - - // 删除所有用户及其数据 - for (const username of allUsers) { - await this.deleteUser(username); - } - - // 删除管理员配置 - await withRetry(() => this._client.del(this.adminConfigKey())); - - console.log('所有数据已清空'); - } catch (error) { - console.error('清空数据失败:', error); - throw new Error('清空数据失败'); - } - } - - // ---------- 通用键值存储 ---------- - private globalValueKey(key: string) { - return `global:${key}`; - } - - async getGlobalValue(key: string): Promise { - const val = await withRetry(() => - this._client.get(this.globalValueKey(key)) - ); - // Upstash 会自动反序列化 JSON,如果值是对象,需要重新序列化为字符串 - if (val === null) return null; - if (typeof val === 'string') return val; - // 如果是对象(Upstash 自动反序列化的结果),重新序列化 - return JSON.stringify(val); - } - - async setGlobalValue(key: string, value: string): Promise { - await withRetry(() => - this._client.set(this.globalValueKey(key), value) - ); - } - - async deleteGlobalValue(key: string): Promise { - await withRetry(() => this._client.del(this.globalValueKey(key))); - } - - // ---------- 通知相关 ---------- - private notificationsKey(userName: string) { - return `u:${userName}:notifications`; - } - - private lastFavoriteCheckKey(userName: string) { - return `u:${userName}:last_fav_check`; - } - - async getNotifications(userName: string): Promise { - const val = await withRetry(() => - this._client.get(this.notificationsKey(userName)) - ); - return val ? (val as import('./types').Notification[]) : []; - } - - async addNotification( - userName: string, - notification: import('./types').Notification - ): Promise { - const notifications = await this.getNotifications(userName); - notifications.unshift(notification); // 新通知放在最前面 - // 限制通知数量,最多保留100条 - if (notifications.length > 100) { - notifications.splice(100); - } - await withRetry(() => - this._client.set(this.notificationsKey(userName), notifications) - ); - } - - async markNotificationAsRead( - userName: string, - notificationId: string - ): Promise { - const notifications = await this.getNotifications(userName); - const notification = notifications.find((n) => n.id === notificationId); - if (notification) { - notification.read = true; - await withRetry(() => - this._client.set(this.notificationsKey(userName), notifications) - ); - } - } - - async deleteNotification( - userName: string, - notificationId: string - ): Promise { - const notifications = await this.getNotifications(userName); - const filtered = notifications.filter((n) => n.id !== notificationId); - await withRetry(() => - this._client.set(this.notificationsKey(userName), filtered) - ); - } - - async clearAllNotifications(userName: string): Promise { - await withRetry(() => this._client.del(this.notificationsKey(userName))); - } - - async getUnreadNotificationCount(userName: string): Promise { - const notifications = await this.getNotifications(userName); - return notifications.filter((n) => !n.read).length; - } - - async getLastFavoriteCheckTime(userName: string): Promise { - const val = await withRetry(() => - this._client.get(this.lastFavoriteCheckKey(userName)) - ); - return val ? (val as number) : 0; - } - - async setLastFavoriteCheckTime( - userName: string, - timestamp: number - ): Promise { - await withRetry(() => - this._client.set(this.lastFavoriteCheckKey(userName), timestamp) - ); - } - - // ---------- 求片相关 ---------- - private movieRequestsKey() { - return 'movie_requests:all'; - } - - private userMovieRequestsKey(userName: string) { - return `u:${userName}:mr`; - } - - async getAllMovieRequests(): Promise { - const data = await withRetry(() => this._client.hgetall(this.movieRequestsKey())); - if (!data) return []; - return Object.values(data) as import('./types').MovieRequest[]; - } - - async getMovieRequest(requestId: string): Promise { - const val = await withRetry(() => this._client.hget(this.movieRequestsKey(), requestId)); - return val ? (val as import('./types').MovieRequest) : null; - } - - async createMovieRequest(request: import('./types').MovieRequest): Promise { - await withRetry(() => this._client.hset(this.movieRequestsKey(), { [request.id]: request })); - } - - async updateMovieRequest(requestId: string, updates: Partial): Promise { - const existing = await this.getMovieRequest(requestId); - if (!existing) throw new Error('Movie request not found'); - const updated = { ...existing, ...updates }; - await withRetry(() => this._client.hset(this.movieRequestsKey(), { [requestId]: updated })); - } - - async deleteMovieRequest(requestId: string): Promise { - await withRetry(() => this._client.hdel(this.movieRequestsKey(), requestId)); - } - - async getUserMovieRequests(userName: string): Promise { - const val = await withRetry(() => this._client.smembers(this.userMovieRequestsKey(userName))); - return val ? ensureStringArray(val) : []; - } - - async addUserMovieRequest(userName: string, requestId: string): Promise { - await withRetry(() => this._client.sadd(this.userMovieRequestsKey(userName), requestId)); - } - - async removeUserMovieRequest(userName: string, requestId: string): Promise { - await withRetry(() => this._client.srem(this.userMovieRequestsKey(userName), requestId)); - } - - // ---------- 用户邮箱相关 ---------- - async getUserEmail(userName: string): Promise { - const userInfo = await this.getUserInfoV2(userName); - return userInfo?.email || null; - } - - async setUserEmail(userName: string, email: string): Promise { - await withRetry(() => - this._client.hset(this.userInfoKey(userName), { email }) - ); - // 清除缓存 - userInfoCache?.delete(userName); - } - - async getEmailNotificationPreference(userName: string): Promise { - const userInfo = await this.getUserInfoV2(userName); - return userInfo?.emailNotifications || false; - } - - async setEmailNotificationPreference(userName: string, enabled: boolean): Promise { - await withRetry(() => - this._client.hset(this.userInfoKey(userName), { emailNotifications: enabled.toString() }) - ); - // 清除缓存 - userInfoCache?.delete(userName); + const client = getUpstashRedisClient(); + const adapter = new UpstashRedisAdapter(client); + super(adapter, withRetry); } }