From ec90fafc390f3b4a70d982cb53f7f1b11c053248 Mon Sep 17 00:00:00 2001 From: shinya Date: Fri, 15 Aug 2025 19:26:13 +0800 Subject: [PATCH] feat: support kvrocks --- src/app/api/login/route.ts | 1 + src/app/api/register/route.ts | 1 + src/lib/db.ts | 4 + src/lib/kvrocks.db.ts | 14 + src/lib/redis-base.db.ts | 466 ++++++++++++++++++++++++++++++++++ src/lib/redis.db.ts | 457 +-------------------------------- 6 files changed, 494 insertions(+), 449 deletions(-) create mode 100644 src/lib/kvrocks.db.ts create mode 100644 src/lib/redis-base.db.ts diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts index 4eed3fa..b407e37 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -12,6 +12,7 @@ const STORAGE_TYPE = | 'localstorage' | 'redis' | 'upstash' + | 'kvrocks' | undefined) || 'localstorage'; // 生成签名 diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index bd88274..d0ca129 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -12,6 +12,7 @@ const STORAGE_TYPE = | 'localstorage' | 'redis' | 'upstash' + | 'kvrocks' | undefined) || 'localstorage'; // 生成签名 diff --git a/src/lib/db.ts b/src/lib/db.ts index d12bac2..9f129b5 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,6 +1,7 @@ /* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ import { AdminConfig } from './admin.types'; +import { KvrocksStorage } from './kvrocks.db'; import { RedisStorage } from './redis.db'; import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; import { UpstashRedisStorage } from './upstash.db'; @@ -11,6 +12,7 @@ const STORAGE_TYPE = | 'localstorage' | 'redis' | 'upstash' + | 'kvrocks' | undefined) || 'localstorage'; // 创建存储实例 @@ -20,6 +22,8 @@ function createStorage(): IStorage { return new RedisStorage(); case 'upstash': return new UpstashRedisStorage(); + case 'kvrocks': + return new KvrocksStorage(); case 'localstorage': default: return null as unknown as IStorage; diff --git a/src/lib/kvrocks.db.ts b/src/lib/kvrocks.db.ts new file mode 100644 index 0000000..87cac06 --- /dev/null +++ b/src/lib/kvrocks.db.ts @@ -0,0 +1,14 @@ +/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +import { BaseRedisStorage } from './redis-base.db'; + +export class KvrocksStorage extends BaseRedisStorage { + constructor() { + const config = { + url: process.env.KVROCKS_URL!, + clientName: 'Kvrocks' + }; + const globalSymbol = Symbol.for('__MOONTV_KVROCKS_CLIENT__'); + super(config, globalSymbol); + } +} \ No newline at end of file diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts new file mode 100644 index 0000000..caad222 --- /dev/null +++ b/src/lib/redis-base.db.ts @@ -0,0 +1,466 @@ +/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +import { createClient, RedisClientType } from 'redis'; + +import { AdminConfig } from './admin.types'; +import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; + +// 搜索历史最大条数 +const SEARCH_HISTORY_LIMIT = 20; + +// 数据类型转换辅助函数 +function ensureString(value: any): string { + return String(value); +} + +function ensureStringArray(value: any[]): string[] { + return value.map((item) => String(item)); +} + +// 连接配置接口 +export interface RedisConnectionConfig { + url: string; + clientName: string; // 用于日志显示,如 "Redis" 或 "Pika" +} + +// 添加Redis操作重试包装器 +function createRetryWrapper(clientName: string, getClient: () => RedisClientType) { + return async function withRetry( + operation: () => Promise, + maxRetries = 3 + ): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + return await operation(); + } catch (err: any) { + const isLastAttempt = i === maxRetries - 1; + const isConnectionError = + err.message?.includes('Connection') || + err.message?.includes('ECONNREFUSED') || + err.message?.includes('ENOTFOUND') || + err.code === 'ECONNRESET' || + err.code === 'EPIPE'; + + if (isConnectionError && !isLastAttempt) { + console.log( + `${clientName} operation failed, retrying... (${i + 1}/${maxRetries})` + ); + console.error('Error:', err.message); + + // 等待一段时间后重试 + await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1))); + + // 尝试重新连接 + try { + const client = getClient(); + if (!client.isOpen) { + await client.connect(); + } + } catch (reconnectErr) { + console.error('Failed to reconnect:', reconnectErr); + } + + continue; + } + + throw err; + } + } + + throw new Error('Max retries exceeded'); + }; +} + +// 创建客户端的工厂函数 +export function createRedisClient(config: RedisConnectionConfig, globalSymbol: symbol): RedisClientType { + let client: RedisClientType | undefined = (global as any)[globalSymbol]; + + if (!client) { + if (!config.url) { + throw new Error(`${config.clientName}_URL env variable not set`); + } + + // 创建客户端配置 + const clientConfig: any = { + url: config.url, + socket: { + // 重连策略:指数退避,最大30秒 + reconnectStrategy: (retries: number) => { + console.log(`${config.clientName} reconnection attempt ${retries + 1}`); + if (retries > 10) { + console.error(`${config.clientName} max reconnection attempts exceeded`); + return false; // 停止重连 + } + return Math.min(1000 * Math.pow(2, retries), 30000); // 指数退避,最大30秒 + }, + connectTimeout: 10000, // 10秒连接超时 + // 设置no delay,减少延迟 + noDelay: true, + }, + // 添加其他配置 + pingInterval: 30000, // 30秒ping一次,保持连接活跃 + }; + + client = createClient(clientConfig); + + // 添加错误事件监听 + client.on('error', (err) => { + console.error(`${config.clientName} client error:`, err); + }); + + client.on('connect', () => { + console.log(`${config.clientName} connected`); + }); + + client.on('reconnecting', () => { + console.log(`${config.clientName} reconnecting...`); + }); + + client.on('ready', () => { + console.log(`${config.clientName} ready`); + }); + + // 初始连接,带重试机制 + const connectWithRetry = async () => { + try { + await client!.connect(); + console.log(`${config.clientName} connected successfully`); + } catch (err) { + console.error(`${config.clientName} initial connection failed:`, err); + console.log('Will retry in 5 seconds...'); + setTimeout(connectWithRetry, 5000); + } + }; + + connectWithRetry(); + + (global as any)[globalSymbol] = client; + } + + return client; +} + +// 抽象基类,包含所有通用的Redis操作逻辑 +export abstract class BaseRedisStorage implements IStorage { + protected client: RedisClientType; + protected withRetry: (operation: () => Promise, maxRetries?: number) => Promise; + + constructor(config: RedisConnectionConfig, globalSymbol: symbol) { + this.client = createRedisClient(config, globalSymbol); + this.withRetry = createRetryWrapper(config.clientName, () => this.client); + } + + // ---------- 播放记录 ---------- + private prKey(user: string, key: string) { + return `u:${user}:pr:${key}`; // u:username:pr:source+id + } + + async getPlayRecord( + userName: string, + key: string + ): Promise { + const val = await this.withRetry(() => + this.client.get(this.prKey(userName, key)) + ); + return val ? (JSON.parse(val) as PlayRecord) : null; + } + + async setPlayRecord( + userName: string, + key: string, + record: PlayRecord + ): Promise { + await this.withRetry(() => + this.client.set(this.prKey(userName, key), JSON.stringify(record)) + ); + } + + async getAllPlayRecords( + userName: string + ): Promise> { + const pattern = `u:${userName}:pr:*`; + const keys: string[] = await this.withRetry(() => this.client.keys(pattern)); + if (keys.length === 0) return {}; + const values = await this.withRetry(() => this.client.mGet(keys)); + const result: Record = {}; + keys.forEach((fullKey: string, idx: number) => { + const raw = values[idx]; + if (raw) { + const rec = JSON.parse(raw) as PlayRecord; + // 截取 source+id 部分 + const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, '')); + result[keyPart] = rec; + } + }); + return result; + } + + async deletePlayRecord(userName: string, key: string): Promise { + await this.withRetry(() => this.client.del(this.prKey(userName, key))); + } + + // ---------- 收藏 ---------- + private favKey(user: string, key: string) { + return `u:${user}:fav:${key}`; + } + + async getFavorite(userName: string, key: string): Promise { + const val = await this.withRetry(() => + this.client.get(this.favKey(userName, key)) + ); + return val ? (JSON.parse(val) as Favorite) : null; + } + + async setFavorite( + userName: string, + key: string, + favorite: Favorite + ): Promise { + await this.withRetry(() => + this.client.set(this.favKey(userName, key), JSON.stringify(favorite)) + ); + } + + async getAllFavorites(userName: string): Promise> { + const pattern = `u:${userName}:fav:*`; + const keys: string[] = await this.withRetry(() => this.client.keys(pattern)); + if (keys.length === 0) return {}; + const values = await this.withRetry(() => this.client.mGet(keys)); + const result: Record = {}; + keys.forEach((fullKey: string, idx: number) => { + const raw = values[idx]; + if (raw) { + const fav = JSON.parse(raw) as Favorite; + const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, '')); + result[keyPart] = fav; + } + }); + return result; + } + + async deleteFavorite(userName: string, key: string): Promise { + await this.withRetry(() => this.client.del(this.favKey(userName, key))); + } + + // ---------- 用户注册 / 登录 ---------- + private userPwdKey(user: string) { + return `u:${user}:pwd`; + } + + async registerUser(userName: string, password: string): Promise { + // 简单存储明文密码,生产环境应加密 + await this.withRetry(() => this.client.set(this.userPwdKey(userName), password)); + } + + async verifyUser(userName: string, password: string): Promise { + const stored = await this.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 this.withRetry(() => + this.client.exists(this.userPwdKey(userName)) + ); + return exists === 1; + } + + // 修改用户密码 + async changePassword(userName: string, newPassword: string): Promise { + // 简单存储明文密码,生产环境应加密 + await this.withRetry(() => + this.client.set(this.userPwdKey(userName), newPassword) + ); + } + + // 删除用户及其所有数据 + async deleteUser(userName: string): Promise { + // 删除用户密码 + await this.withRetry(() => this.client.del(this.userPwdKey(userName))); + + // 删除搜索历史 + await this.withRetry(() => this.client.del(this.shKey(userName))); + + // 删除播放记录 + const playRecordPattern = `u:${userName}:pr:*`; + const playRecordKeys = await this.withRetry(() => + this.client.keys(playRecordPattern) + ); + if (playRecordKeys.length > 0) { + await this.withRetry(() => this.client.del(playRecordKeys)); + } + + // 删除收藏夹 + const favoritePattern = `u:${userName}:fav:*`; + const favoriteKeys = await this.withRetry(() => + this.client.keys(favoritePattern) + ); + if (favoriteKeys.length > 0) { + await this.withRetry(() => this.client.del(favoriteKeys)); + } + + // 删除跳过片头片尾配置 + const skipConfigPattern = `u:${userName}:skip:*`; + const skipConfigKeys = await this.withRetry(() => + this.client.keys(skipConfigPattern) + ); + if (skipConfigKeys.length > 0) { + await this.withRetry(() => this.client.del(skipConfigKeys)); + } + } + + // ---------- 搜索历史 ---------- + private shKey(user: string) { + return `u:${user}:sh`; // u:username:sh + } + + async getSearchHistory(userName: string): Promise { + const result = await this.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 this.withRetry(() => this.client.lRem(key, 0, ensureString(keyword))); + // 插入到最前 + await this.withRetry(() => this.client.lPush(key, ensureString(keyword))); + // 限制最大长度 + await this.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 this.withRetry(() => this.client.lRem(key, 0, ensureString(keyword))); + } else { + await this.withRetry(() => this.client.del(key)); + } + } + + // ---------- 获取全部用户 ---------- + async getAllUsers(): Promise { + const keys = await this.withRetry(() => this.client.keys('u:*:pwd')); + return keys + .map((k) => { + const match = k.match(/^u:(.+?):pwd$/); + return match ? ensureString(match[1]) : undefined; + }) + .filter((u): u is string => typeof u === 'string'); + } + + // ---------- 管理员配置 ---------- + private adminConfigKey() { + return 'admin:config'; + } + + async getAdminConfig(): Promise { + const val = await this.withRetry(() => this.client.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)) + ); + } + + // ---------- 跳过片头片尾配置 ---------- + private skipConfigKey(user: string, source: string, id: string) { + return `u:${user}:skip:${source}+${id}`; + } + + async getSkipConfig( + userName: string, + source: string, + id: string + ): Promise { + const val = await this.withRetry(() => + this.client.get(this.skipConfigKey(userName, source, id)) + ); + return val ? (JSON.parse(val) as SkipConfig) : null; + } + + async setSkipConfig( + userName: string, + source: string, + id: string, + config: SkipConfig + ): Promise { + await this.withRetry(() => + this.client.set( + this.skipConfigKey(userName, source, id), + JSON.stringify(config) + ) + ); + } + + async deleteSkipConfig( + userName: string, + source: string, + id: string + ): Promise { + await this.withRetry(() => + this.client.del(this.skipConfigKey(userName, source, id)) + ); + } + + async getAllSkipConfigs( + userName: string + ): Promise<{ [key: string]: SkipConfig }> { + const pattern = `u:${userName}:skip:*`; + const keys = await this.withRetry(() => this.client.keys(pattern)); + + if (keys.length === 0) { + return {}; + } + + const configs: { [key: string]: SkipConfig } = {}; + + // 批量获取所有配置 + const values = await this.withRetry(() => this.client.mGet(keys)); + + keys.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; + } + } + }); + + return configs; + } + + // 清空所有数据 + async clearAllData(): Promise { + try { + // 获取所有用户 + const allUsers = await this.getAllUsers(); + + // 删除所有用户及其数据 + for (const username of allUsers) { + await this.deleteUser(username); + } + + // 删除管理员配置 + await this.withRetry(() => this.client.del(this.adminConfigKey())); + + console.log('所有数据已清空'); + } catch (error) { + console.error('清空数据失败:', error); + throw new Error('清空数据失败'); + } + } +} diff --git a/src/lib/redis.db.ts b/src/lib/redis.db.ts index 0e704fc..9ab7607 100644 --- a/src/lib/redis.db.ts +++ b/src/lib/redis.db.ts @@ -1,455 +1,14 @@ /* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ -import { createClient, RedisClientType } from 'redis'; - -import { AdminConfig } from './admin.types'; -import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; - -// 搜索历史最大条数 -const SEARCH_HISTORY_LIMIT = 20; - -// 数据类型转换辅助函数 -function ensureString(value: any): string { - return String(value); -} - -function ensureStringArray(value: any[]): string[] { - return value.map((item) => String(item)); -} - -// 添加Redis操作重试包装器 -async function withRetry( - operation: () => Promise, - maxRetries = 3 -): Promise { - for (let i = 0; i < maxRetries; i++) { - try { - return await operation(); - } catch (err: any) { - const isLastAttempt = i === maxRetries - 1; - const isConnectionError = - err.message?.includes('Connection') || - err.message?.includes('ECONNREFUSED') || - err.message?.includes('ENOTFOUND') || - err.code === 'ECONNRESET' || - err.code === 'EPIPE'; - - if (isConnectionError && !isLastAttempt) { - console.log( - `Redis operation failed, retrying... (${i + 1}/${maxRetries})` - ); - console.error('Error:', err.message); - - // 等待一段时间后重试 - await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1))); - - // 尝试重新连接 - try { - const client = getRedisClient(); - if (!client.isOpen) { - await client.connect(); - } - } catch (reconnectErr) { - console.error('Failed to reconnect:', reconnectErr); - } - - continue; - } - - throw err; - } - } - - throw new Error('Max retries exceeded'); -} - -export class RedisStorage implements IStorage { - private client: RedisClientType; +import { BaseRedisStorage } from './redis-base.db'; +export class RedisStorage extends BaseRedisStorage { constructor() { - this.client = getRedisClient(); - } - - // ---------- 播放记录 ---------- - private prKey(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.get(this.prKey(userName, key)) - ); - return val ? (JSON.parse(val) as PlayRecord) : null; - } - - async setPlayRecord( - userName: string, - key: string, - record: PlayRecord - ): Promise { - await withRetry(() => - this.client.set(this.prKey(userName, key), JSON.stringify(record)) - ); - } - - async getAllPlayRecords( - userName: string - ): Promise> { - const pattern = `u:${userName}:pr:*`; - const keys: string[] = await withRetry(() => this.client.keys(pattern)); - if (keys.length === 0) return {}; - const values = await withRetry(() => this.client.mGet(keys)); - const result: Record = {}; - keys.forEach((fullKey: string, idx: number) => { - const raw = values[idx]; - if (raw) { - const rec = JSON.parse(raw) as PlayRecord; - // 截取 source+id 部分 - const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, '')); - result[keyPart] = rec; - } - }); - return result; - } - - async deletePlayRecord(userName: string, key: string): Promise { - await withRetry(() => this.client.del(this.prKey(userName, key))); - } - - // ---------- 收藏 ---------- - private favKey(user: string, key: string) { - return `u:${user}:fav:${key}`; - } - - async getFavorite(userName: string, key: string): Promise { - const val = await withRetry(() => - this.client.get(this.favKey(userName, key)) - ); - return val ? (JSON.parse(val) as Favorite) : null; - } - - async setFavorite( - userName: string, - key: string, - favorite: Favorite - ): Promise { - await withRetry(() => - this.client.set(this.favKey(userName, key), JSON.stringify(favorite)) - ); - } - - async getAllFavorites(userName: string): Promise> { - const pattern = `u:${userName}:fav:*`; - const keys: string[] = await withRetry(() => this.client.keys(pattern)); - if (keys.length === 0) return {}; - const values = await withRetry(() => this.client.mGet(keys)); - const result: Record = {}; - keys.forEach((fullKey: string, idx: number) => { - const raw = values[idx]; - if (raw) { - const fav = JSON.parse(raw) as Favorite; - const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, '')); - result[keyPart] = fav; - } - }); - return result; - } - - async deleteFavorite(userName: string, key: string): Promise { - await withRetry(() => this.client.del(this.favKey(userName, key))); - } - - // ---------- 用户注册 / 登录 ---------- - private userPwdKey(user: string) { - return `u:${user}:pwd`; - } - - async registerUser(userName: string, password: string): Promise { - // 简单存储明文密码,生产环境应加密 - await withRetry(() => this.client.set(this.userPwdKey(userName), password)); - } - - 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))); - - // 删除播放记录 - const playRecordPattern = `u:${userName}:pr:*`; - const playRecordKeys = await withRetry(() => - this.client.keys(playRecordPattern) - ); - if (playRecordKeys.length > 0) { - await withRetry(() => this.client.del(playRecordKeys)); - } - - // 删除收藏夹 - const favoritePattern = `u:${userName}:fav:*`; - const favoriteKeys = await withRetry(() => - this.client.keys(favoritePattern) - ); - if (favoriteKeys.length > 0) { - await withRetry(() => this.client.del(favoriteKeys)); - } - - // 删除跳过片头片尾配置 - const skipConfigPattern = `u:${userName}:skip:*`; - const skipConfigKeys = await withRetry(() => - this.client.keys(skipConfigPattern) - ); - if (skipConfigKeys.length > 0) { - await withRetry(() => this.client.del(skipConfigKeys)); - } - } - - // ---------- 搜索历史 ---------- - 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 keys = await withRetry(() => this.client.keys('u:*:pwd')); - return keys - .map((k) => { - const match = k.match(/^u:(.+?):pwd$/); - return match ? ensureString(match[1]) : undefined; - }) - .filter((u): u is string => typeof u === 'string'); - } - - // ---------- 管理员配置 ---------- - private adminConfigKey() { - return 'admin:config'; - } - - async getAdminConfig(): Promise { - const val = await withRetry(() => this.client.get(this.adminConfigKey())); - return val ? (JSON.parse(val) as AdminConfig) : null; - } - - async setAdminConfig(config: AdminConfig): Promise { - await withRetry(() => - this.client.set(this.adminConfigKey(), JSON.stringify(config)) - ); - } - - // ---------- 跳过片头片尾配置 ---------- - private skipConfigKey(user: string, source: string, id: string) { - return `u:${user}:skip:${source}+${id}`; - } - - async getSkipConfig( - userName: string, - source: string, - id: string - ): Promise { - const val = await withRetry(() => - this.client.get(this.skipConfigKey(userName, source, id)) - ); - return val ? (JSON.parse(val) as SkipConfig) : null; - } - - async setSkipConfig( - userName: string, - source: string, - id: string, - config: SkipConfig - ): Promise { - await withRetry(() => - this.client.set( - this.skipConfigKey(userName, source, id), - JSON.stringify(config) - ) - ); - } - - async deleteSkipConfig( - userName: string, - source: string, - id: string - ): Promise { - await withRetry(() => - this.client.del(this.skipConfigKey(userName, source, id)) - ); - } - - async getAllSkipConfigs( - userName: string - ): Promise<{ [key: string]: SkipConfig }> { - const pattern = `u:${userName}:skip:*`; - const keys = await withRetry(() => this.client.keys(pattern)); - - if (keys.length === 0) { - return {}; - } - - const configs: { [key: string]: SkipConfig } = {}; - - // 批量获取所有配置 - const values = await withRetry(() => this.client.mGet(keys)); - - keys.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; - } - } - }); - - return configs; - } - - // 清空所有数据 - 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('清空数据失败'); - } - } -} - -// 单例 Redis 客户端 -function getRedisClient(): RedisClientType { - const globalKey = Symbol.for('__MOONTV_REDIS_CLIENT__'); - let client: RedisClientType | undefined = (global as any)[globalKey]; - - if (!client) { - const url = process.env.REDIS_URL; - if (!url) { - throw new Error('REDIS_URL env variable not set'); - } - - // 创建客户端,配置重连策略 - client = createClient({ - url, - socket: { - // 重连策略:指数退避,最大30秒 - reconnectStrategy: (retries: number) => { - console.log(`Redis reconnection attempt ${retries + 1}`); - if (retries > 10) { - console.error('Redis max reconnection attempts exceeded'); - return false; // 停止重连 - } - return Math.min(1000 * Math.pow(2, retries), 30000); // 指数退避,最大30秒 - }, - connectTimeout: 10000, // 10秒连接超时 - // 设置no delay,减少延迟 - noDelay: true, - }, - // 添加其他配置 - pingInterval: 30000, // 30秒ping一次,保持连接活跃 - }); - - // 添加错误事件监听 - client.on('error', (err) => { - console.error('Redis client error:', err); - }); - - client.on('connect', () => { - console.log('Redis connected'); - }); - - client.on('reconnecting', () => { - console.log('Redis reconnecting...'); - }); - - client.on('ready', () => { - console.log('Redis ready'); - }); - - // 初始连接,带重试机制 - const connectWithRetry = async () => { - try { - await client!.connect(); - console.log('Redis connected successfully'); - } catch (err) { - console.error('Redis initial connection failed:', err); - console.log('Will retry in 5 seconds...'); - setTimeout(connectWithRetry, 5000); - } + const config = { + url: process.env.REDIS_URL!, + clientName: 'Redis' }; - - connectWithRetry(); - - (global as any)[globalKey] = client; + const globalSymbol = Symbol.for('__MOONTV_REDIS_CLIENT__'); + super(config, globalSymbol); } - - return client; -} +} \ No newline at end of file