diff --git a/src/lib/config.ts b/src/lib/config.ts index 61a5644..6a3b4e8 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -152,7 +152,7 @@ async function initConfig() { SiteConfig: { SiteName: process.env.SITE_NAME || 'MoonTV', Announcement: - process.env.NEXT_PUBLIC_ANNOUNCEMENT || + process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。', SearchDownstreamMaxPage: Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5, @@ -192,7 +192,7 @@ async function initConfig() { SiteConfig: { SiteName: process.env.SITE_NAME || 'MoonTV', Announcement: - process.env.NEXT_PUBLIC_ANNOUNCEMENT || + process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。', SearchDownstreamMaxPage: Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5, @@ -252,7 +252,7 @@ export async function resetConfig() { SiteConfig: { SiteName: process.env.SITE_NAME || 'MoonTV', Announcement: - process.env.NEXT_PUBLIC_ANNOUNCEMENT || + process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。', SearchDownstreamMaxPage: Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5, diff --git a/src/lib/db.ts b/src/lib/db.ts index 00c0939..88d0b89 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,6 +1,10 @@ /* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ -import { AdminConfig } from './admin.types'; +import { RedisStorage } from './redis.db'; +import { AdminConfig, Favorite, IStorage, PlayRecord } from './types'; + +// 重新导出类型,保持向后兼容 +export type { AdminConfig, Favorite, IStorage, PlayRecord }; // storage type 常量: 'localstorage' | 'database',默认 'localstorage' const STORAGE_TYPE = @@ -9,240 +13,6 @@ const STORAGE_TYPE = | 'redis' | undefined) || 'localstorage'; -// 播放记录数据结构 -export interface PlayRecord { - title: string; - source_name: string; - cover: string; - index: number; // 第几集 - total_episodes: number; // 总集数 - play_time: number; // 播放进度(秒) - total_time: number; // 总进度(秒) - save_time: number; // 记录保存时间(时间戳) - user_id: number; // 用户ID,localStorage情况下全部为0 -} - -// 收藏数据结构 -export interface Favorite { - source_name: string; - total_episodes: number; // 总集数 - title: string; - cover: string; - user_id: number; // 用户ID,localStorage情况下全部为0 - save_time: number; // 记录保存时间(时间戳) -} - -// 搜索历史最大条数 -const SEARCH_HISTORY_LIMIT = 20; - -// 存储接口 -export interface IStorage { - // 播放记录相关 - getPlayRecord(userName: string, key: string): Promise; - setPlayRecord( - userName: string, - key: string, - record: PlayRecord - ): Promise; - getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }>; - deletePlayRecord(userName: string, key: string): Promise; - - // 收藏相关 - getFavorite(userName: string, key: string): Promise; - setFavorite(userName: string, key: string, favorite: Favorite): Promise; - getAllFavorites(userName: string): Promise<{ [key: string]: Favorite }>; - deleteFavorite(userName: string, key: string): Promise; - - // 用户相关 - registerUser(userName: string, password: string): Promise; - verifyUser(userName: string, password: string): Promise; - // 检查用户是否存在(无需密码) - checkUserExist(userName: string): Promise; - - // 搜索历史相关 - getSearchHistory(userName: string): Promise; - addSearchHistory(userName: string, keyword: string): Promise; - deleteSearchHistory(userName: string, keyword?: string): Promise; - - // 用户列表 - getAllUsers(): Promise; - - // 管理员配置相关 - getAdminConfig(): Promise; - setAdminConfig(config: AdminConfig): Promise; -} - -// ---------------- Redis 实现 ---------------- -import { createClient, RedisClientType } from 'redis'; - -class RedisStorage implements IStorage { - private client: RedisClientType; - - 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 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.client.set(this.prKey(userName, key), JSON.stringify(record)); - } - - async getAllPlayRecords( - userName: string - ): Promise> { - const pattern = `u:${userName}:pr:*`; - const keys: string[] = await this.client.keys(pattern); - if (keys.length === 0) return {}; - const values = await 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 = fullKey.replace(`u:${userName}:pr:`, ''); - result[keyPart] = rec; - } - }); - return result; - } - - async deletePlayRecord(userName: string, key: string): Promise { - await 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.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.client.set(this.favKey(userName, key), JSON.stringify(favorite)); - } - - async getAllFavorites(userName: string): Promise> { - const pattern = `u:${userName}:fav:*`; - const keys: string[] = await this.client.keys(pattern); - if (keys.length === 0) return {}; - const values = await 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 = fullKey.replace(`u:${userName}:fav:`, ''); - result[keyPart] = fav; - } - }); - return result; - } - - async deleteFavorite(userName: string, key: string): Promise { - await this.client.del(this.favKey(userName, key)); - } - - // ---------- 用户注册 / 登录 ---------- - private userPwdKey(user: string) { - return `u:${user}:pwd`; - } - - async registerUser(userName: string, password: string): Promise { - // 简单存储明文密码,生产环境应加密 - await this.client.set(this.userPwdKey(userName), password); - } - - async verifyUser(userName: string, password: string): Promise { - const stored = await this.client.get(this.userPwdKey(userName)); - if (stored === null) return false; - return stored === password; - } - - // 检查用户是否存在 - async checkUserExist(userName: string): Promise { - // 使用 EXISTS 判断 key 是否存在 - const exists = await this.client.exists(this.userPwdKey(userName)); - return exists === 1; - } - - // ---------- 搜索历史 ---------- - private shKey(user: string) { - return `u:${user}:sh`; // u:username:sh - } - - async getSearchHistory(userName: string): Promise { - return (await this.client.lRange(this.shKey(userName), 0, -1)) as string[]; - } - - async addSearchHistory(userName: string, keyword: string): Promise { - const key = this.shKey(userName); - // 先去重 - await this.client.lRem(key, 0, keyword); - // 插入到最前 - await this.client.lPush(key, keyword); - // 限制最大长度 - await 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.client.lRem(key, 0, keyword); - } else { - await this.client.del(key); - } - } - - // ---------- 获取全部用户 ---------- - async getAllUsers(): Promise { - const keys = await this.client.keys('u:*:pwd'); - return keys - .map((k) => { - const match = k.match(/^u:(.+?):pwd$/); - return match ? match[1] : undefined; - }) - .filter((u): u is string => typeof u === 'string'); - } - - // ---------- 管理员配置 ---------- - private adminConfigKey() { - return 'admin:config'; - } - - async getAdminConfig(): Promise { - const val = await this.client.get(this.adminConfigKey()); - return val ? (JSON.parse(val) as AdminConfig) : null; - } - - async setAdminConfig(config: AdminConfig): Promise { - await this.client.set(this.adminConfigKey(), JSON.stringify(config)); - } -} - // 创建存储实例 function createStorage(): IStorage { switch (STORAGE_TYPE) { @@ -432,47 +202,3 @@ export class DbManager { // 导出默认实例 export const db = new DbManager(); - -// 单例 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: { - // 5秒重连间隔 - reconnectStrategy: (retries: number) => { - console.log(`Redis reconnection attempt ${retries + 1}`); - return 5000; // 5秒后重试 - }, - connectTimeout: 10000, // 10秒连接超时 - }, - }); - - // 初始连接,带重试机制 - 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); - } - }; - - connectWithRetry(); - - (global as any)[globalKey] = client; - } - - return client; -} diff --git a/src/lib/redis.db.ts b/src/lib/redis.db.ts new file mode 100644 index 0000000..ddb936f --- /dev/null +++ b/src/lib/redis.db.ts @@ -0,0 +1,307 @@ +/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +import { createClient, RedisClientType } from 'redis'; + +import { AdminConfig, Favorite, IStorage, PlayRecord } from './types'; + +// 搜索历史最大条数 +const SEARCH_HISTORY_LIMIT = 20; + +// 添加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; + + 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 = 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 = 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 stored === password; + } + + // 检查用户是否存在 + async checkUserExist(userName: string): Promise { + // 使用 EXISTS 判断 key 是否存在 + const exists = await withRetry(() => + this.client.exists(this.userPwdKey(userName)) + ); + return exists === 1; + } + + // ---------- 搜索历史 ---------- + private shKey(user: string) { + return `u:${user}:sh`; // u:username:sh + } + + async getSearchHistory(userName: string): Promise { + return withRetry( + () => this.client.lRange(this.shKey(userName), 0, -1) as Promise + ); + } + + async addSearchHistory(userName: string, keyword: string): Promise { + const key = this.shKey(userName); + // 先去重 + await withRetry(() => this.client.lRem(key, 0, keyword)); + // 插入到最前 + await withRetry(() => this.client.lPush(key, 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, 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 ? 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)) + ); + } +} + +// 单例 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); + } + }; + + connectWithRetry(); + + (global as any)[globalKey] = client; + } + + return client; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 8a2a3a8..6e40d21 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,3 +1,66 @@ +import { AdminConfig } from './admin.types'; + +// 播放记录数据结构 +export interface PlayRecord { + title: string; + source_name: string; + cover: string; + index: number; // 第几集 + total_episodes: number; // 总集数 + play_time: number; // 播放进度(秒) + total_time: number; // 总进度(秒) + save_time: number; // 记录保存时间(时间戳) + user_id: number; // 用户ID,localStorage情况下全部为0 +} + +// 收藏数据结构 +export interface Favorite { + source_name: string; + total_episodes: number; // 总集数 + title: string; + cover: string; + user_id: number; // 用户ID,localStorage情况下全部为0 + save_time: number; // 记录保存时间(时间戳) +} + +// 存储接口 +export interface IStorage { + // 播放记录相关 + getPlayRecord(userName: string, key: string): Promise; + setPlayRecord( + userName: string, + key: string, + record: PlayRecord + ): Promise; + getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }>; + deletePlayRecord(userName: string, key: string): Promise; + + // 收藏相关 + getFavorite(userName: string, key: string): Promise; + setFavorite(userName: string, key: string, favorite: Favorite): Promise; + getAllFavorites(userName: string): Promise<{ [key: string]: Favorite }>; + deleteFavorite(userName: string, key: string): Promise; + + // 用户相关 + registerUser(userName: string, password: string): Promise; + verifyUser(userName: string, password: string): Promise; + // 检查用户是否存在(无需密码) + checkUserExist(userName: string): Promise; + + // 搜索历史相关 + getSearchHistory(userName: string): Promise; + addSearchHistory(userName: string, keyword: string): Promise; + deleteSearchHistory(userName: string, keyword?: string): Promise; + + // 用户列表 + getAllUsers(): Promise; + + // 管理员配置相关 + getAdminConfig(): Promise; + setAdminConfig(config: AdminConfig): Promise; +} + +// 视频详情数据结构 export interface VideoDetail { code: number; episodes: string[]; @@ -18,6 +81,7 @@ export interface VideoDetail { }; } +// 搜索结果数据结构 export interface SearchResult { id: string; title: string; @@ -32,6 +96,7 @@ export interface SearchResult { douban_id?: number; } +// 豆瓣数据结构 export interface DoubanItem { id: string; title: string; @@ -44,3 +109,6 @@ export interface DoubanResult { message: string; list: DoubanItem[]; } + +// 导出AdminConfig类型 +export { AdminConfig };