增加redis适配器统一upstash和redis数据层
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number>;
|
||||
hSet(key: string, data: Record<string, string>): Promise<number>;
|
||||
hGet(key: string, field: string): Promise<string | null>;
|
||||
hGetAll(key: string): Promise<Record<string, string>>;
|
||||
hDel(key: string, ...fields: string[]): Promise<number>;
|
||||
|
||||
// String 操作
|
||||
set(key: string, value: string): Promise<void>;
|
||||
get(key: string): Promise<string | null>;
|
||||
del(keys: string | string[]): Promise<number>;
|
||||
exists(...keys: string[]): Promise<number>;
|
||||
keys(pattern: string): Promise<string[]>;
|
||||
mGet(keys: string[]): Promise<(string | null)[]>;
|
||||
|
||||
// List 操作
|
||||
lPush(key: string, ...values: string[]): Promise<number>;
|
||||
lRange(key: string, start: number, stop: number): Promise<string[]>;
|
||||
lRem(key: string, count: number, value: string): Promise<number>;
|
||||
lTrim(key: string, start: number, stop: number): Promise<void>;
|
||||
|
||||
// Set 操作
|
||||
sAdd(key: string, ...members: string[]): Promise<number>;
|
||||
sMembers(key: string): Promise<string[]>;
|
||||
sRem(key: string, ...members: string[]): Promise<number>;
|
||||
|
||||
// Sorted Set 操作
|
||||
zAdd(key: string, member: { score: number; value: string }): Promise<number>;
|
||||
zRange(key: string, start: number, stop: number): Promise<string[]>;
|
||||
zCard(key: string): Promise<number>;
|
||||
zRem(key: string, ...members: string[]): Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准 Redis 客户端适配器(用于 Redis 和 Kvrocks)
|
||||
* 只处理 API 命名,不处理序列化
|
||||
*/
|
||||
export class StandardRedisAdapter implements RedisAdapter {
|
||||
constructor(private client: RedisClientType) {}
|
||||
|
||||
// Hash 操作
|
||||
async hSet(key: string, fieldOrData: string | Record<string, string>, value?: string): Promise<number> {
|
||||
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<string | null> {
|
||||
const val = await this.client.hGet(key, field);
|
||||
return val ?? null;
|
||||
}
|
||||
|
||||
async hGetAll(key: string): Promise<Record<string, string>> {
|
||||
return this.client.hGetAll(key);
|
||||
}
|
||||
|
||||
async hDel(key: string, ...fields: string[]): Promise<number> {
|
||||
return this.client.hDel(key, fields);
|
||||
}
|
||||
|
||||
// String 操作
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
await this.client.set(key, value);
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.client.get(key);
|
||||
}
|
||||
|
||||
async del(keys: string | string[]): Promise<number> {
|
||||
const keyArray = Array.isArray(keys) ? keys : [keys];
|
||||
if (keyArray.length === 0) return 0;
|
||||
return this.client.del(keyArray);
|
||||
}
|
||||
|
||||
async exists(...keys: string[]): Promise<number> {
|
||||
return this.client.exists(keys);
|
||||
}
|
||||
|
||||
async keys(pattern: string): Promise<string[]> {
|
||||
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<number> {
|
||||
return this.client.lPush(key, values);
|
||||
}
|
||||
|
||||
async lRange(key: string, start: number, stop: number): Promise<string[]> {
|
||||
return this.client.lRange(key, start, stop);
|
||||
}
|
||||
|
||||
async lRem(key: string, count: number, value: string): Promise<number> {
|
||||
return this.client.lRem(key, count, value);
|
||||
}
|
||||
|
||||
async lTrim(key: string, start: number, stop: number): Promise<void> {
|
||||
await this.client.lTrim(key, start, stop);
|
||||
}
|
||||
|
||||
// Set 操作
|
||||
async sAdd(key: string, ...members: string[]): Promise<number> {
|
||||
return this.client.sAdd(key, members);
|
||||
}
|
||||
|
||||
async sMembers(key: string): Promise<string[]> {
|
||||
return Array.from(await this.client.sMembers(key));
|
||||
}
|
||||
|
||||
async sRem(key: string, ...members: string[]): Promise<number> {
|
||||
return this.client.sRem(key, members);
|
||||
}
|
||||
|
||||
// Sorted Set 操作
|
||||
async zAdd(key: string, member: { score: number; value: string }): Promise<number> {
|
||||
return this.client.zAdd(key, member);
|
||||
}
|
||||
|
||||
async zRange(key: string, start: number, stop: number): Promise<string[]> {
|
||||
return this.client.zRange(key, start, stop);
|
||||
}
|
||||
|
||||
async zCard(key: string): Promise<number> {
|
||||
return this.client.zCard(key);
|
||||
}
|
||||
|
||||
async zRem(key: string, ...members: string[]): Promise<number> {
|
||||
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<string, string>, value?: string): Promise<number> {
|
||||
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<string | null> {
|
||||
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<Record<string, string>> {
|
||||
const hashData = await this.client.hgetall(key);
|
||||
if (!hashData) return {};
|
||||
// 确保所有值都是字符串
|
||||
const result: Record<string, string> = {};
|
||||
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<number> {
|
||||
return this.client.hdel(key, ...fields);
|
||||
}
|
||||
|
||||
// String 操作
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
await this.client.set(key, value);
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
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<number> {
|
||||
const keyArray = Array.isArray(keys) ? keys : [keys];
|
||||
if (keyArray.length === 0) return 0;
|
||||
return this.client.del(...keyArray);
|
||||
}
|
||||
|
||||
async exists(...keys: string[]): Promise<number> {
|
||||
return this.client.exists(...keys);
|
||||
}
|
||||
|
||||
async keys(pattern: string): Promise<string[]> {
|
||||
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<number> {
|
||||
return this.client.lpush(key, ...values);
|
||||
}
|
||||
|
||||
async lRange(key: string, start: number, stop: number): Promise<string[]> {
|
||||
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<number> {
|
||||
return this.client.lrem(key, count, value);
|
||||
}
|
||||
|
||||
async lTrim(key: string, start: number, stop: number): Promise<void> {
|
||||
await this.client.ltrim(key, start, stop);
|
||||
}
|
||||
|
||||
// Set 操作
|
||||
async sAdd(key: string, ...members: string[]): Promise<number> {
|
||||
if (members.length === 0) return 0;
|
||||
return this.client.sadd(key, members[0], ...members.slice(1));
|
||||
}
|
||||
|
||||
async sMembers(key: string): Promise<string[]> {
|
||||
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<number> {
|
||||
return this.client.srem(key, ...members);
|
||||
}
|
||||
|
||||
// Sorted Set 操作
|
||||
async zAdd(key: string, member: { score: number; value: string }): Promise<number> {
|
||||
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<string[]> {
|
||||
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<number> {
|
||||
return this.client.zcard(key);
|
||||
}
|
||||
|
||||
async zRem(key: string, ...members: string[]): Promise<number> {
|
||||
return this.client.zrem(key, ...members);
|
||||
}
|
||||
}
|
||||
+127
-100
@@ -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<T>(
|
||||
operation: () => Promise<T>,
|
||||
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: <T>(operation: () => Promise<T>, maxRetries?: number) => Promise<T>;
|
||||
// 保留 client 属性用于向后兼容(数据迁移代码使用)
|
||||
client: any;
|
||||
|
||||
constructor(config: RedisConnectionConfig, globalSymbol: symbol) {
|
||||
this.client = createRedisClient(config, globalSymbol);
|
||||
this.withRetry = createRetryWrapper(config.clientName, () => this.client);
|
||||
constructor(adapter: RedisAdapter, withRetryFn: <T>(operation: () => Promise<T>, maxRetries?: number) => Promise<T>) {
|
||||
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<PlayRecord | null> {
|
||||
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<void> {
|
||||
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<Record<string, PlayRecord>> {
|
||||
const hashData = await this.withRetry(() =>
|
||||
this.client.hGetAll(this.prHashKey(userName))
|
||||
this.adapter.hGetAll(this.prHashKey(userName))
|
||||
);
|
||||
|
||||
const result: Record<string, PlayRecord> = {};
|
||||
@@ -201,7 +228,7 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
async deletePlayRecord(userName: string, key: string): Promise<void> {
|
||||
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<string, string> = {};
|
||||
@@ -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<Favorite | null> {
|
||||
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<void> {
|
||||
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<Record<string, Favorite>> {
|
||||
const hashData = await this.withRetry(() =>
|
||||
this.client.hGetAll(this.favHashKey(userName))
|
||||
this.adapter.hGetAll(this.favHashKey(userName))
|
||||
);
|
||||
|
||||
const result: Record<string, Favorite> = {};
|
||||
@@ -396,7 +423,7 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
async deleteFavorite(userName: string, key: string): Promise<void> {
|
||||
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<string, string> = {};
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
// 使用 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<void> {
|
||||
// 简单存储明文密码,生产环境应加密
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.userPwdKey(userName), newPassword)
|
||||
this.adapter.set(this.userPwdKey(userName), newPassword)
|
||||
);
|
||||
}
|
||||
|
||||
// 删除用户及其所有数据
|
||||
async deleteUser(userName: string): Promise<void> {
|
||||
// 删除用户密码
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<AdminConfig | null> {
|
||||
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<void> {
|
||||
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<SkipConfig | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, SkipConfig> = {};
|
||||
@@ -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<string, string> = {};
|
||||
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<import('./types').DanmakuFilterConfig | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.globalValueKey(key), ensureString(value))
|
||||
this.adapter.set(this.globalValueKey(key), ensureString(value))
|
||||
);
|
||||
}
|
||||
|
||||
async deleteGlobalValue(key: string): Promise<void> {
|
||||
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<import('./types').Notification[]> {
|
||||
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<void> {
|
||||
await this.withRetry(() => this.client.del(this.notificationsKey(userName)));
|
||||
await this.withRetry(() => this.adapter.del(this.notificationsKey(userName)));
|
||||
}
|
||||
|
||||
async getUnreadNotificationCount(userName: string): Promise<number> {
|
||||
@@ -1234,7 +1261,7 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
|
||||
async getLastFavoriteCheckTime(userName: string): Promise<number> {
|
||||
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<void> {
|
||||
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<import('./types').MovieRequest[]> {
|
||||
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<import('./types').MovieRequest | null> {
|
||||
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<void> {
|
||||
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<import('./types').MovieRequest>): Promise<void> {
|
||||
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<void> {
|
||||
await this.withRetry(() => this.client.hDel(this.movieRequestsKey(), requestId));
|
||||
await this.withRetry(() => this.adapter.hDel(this.movieRequestsKey(), requestId));
|
||||
}
|
||||
|
||||
async getUserMovieRequests(userName: string): Promise<string[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.withRetry(() =>
|
||||
this.client.hSet(this.userInfoKey(userName), 'emailNotifications', enabled.toString())
|
||||
this.adapter.hSet(this.userInfoKey(userName), 'emailNotifications', enabled.toString())
|
||||
);
|
||||
// 清除缓存
|
||||
userInfoCache?.delete(userName);
|
||||
|
||||
+7
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1325
@@ -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<string, Promise<void>>();
|
||||
import { BaseRedisStorage } from './redis-base.db';
|
||||
import { UpstashRedisAdapter } from './redis-adapter';
|
||||
|
||||
// 添加Upstash Redis操作重试包装器
|
||||
async function withRetry<T>(
|
||||
@@ -57,1314 +41,11 @@ async function withRetry<T>(
|
||||
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<string, any>, value?: string) => {
|
||||
if (typeof field === 'string' && value !== undefined) {
|
||||
return this._client.hset(key, { [field]: value });
|
||||
}
|
||||
return this._client.hset(key, field as Record<string, any>);
|
||||
},
|
||||
hset: (key: string, data: Record<string, any>) => 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<T>(operation: () => Promise<T>, maxRetries = 3): Promise<T> {
|
||||
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<PlayRecord | null> {
|
||||
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<void> {
|
||||
await withRetry(() => this._client.hset(this.prHashKey(userName), { [key]: record }));
|
||||
}
|
||||
|
||||
async getAllPlayRecords(
|
||||
userName: string
|
||||
): Promise<Record<string, PlayRecord>> {
|
||||
const hashData = await withRetry(() =>
|
||||
this._client.hgetall(this.prHashKey(userName))
|
||||
);
|
||||
|
||||
if (!hashData || Object.keys(hashData).length === 0) return {};
|
||||
|
||||
const result: Record<string, PlayRecord> = {};
|
||||
for (const [key, value] of Object.entries(hashData)) {
|
||||
if (value) {
|
||||
result[key] = value as PlayRecord;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deletePlayRecord(userName: string, key: string): Promise<void> {
|
||||
await withRetry(() => this._client.hdel(this.prHashKey(userName), key));
|
||||
}
|
||||
|
||||
// 清理超出限制的旧播放记录
|
||||
async cleanupOldPlayRecords(userName: string): Promise<void> {
|
||||
// 检查是否已有正在进行的操作
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 检查是否已有正在进行的迁移
|
||||
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<void> {
|
||||
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<string, any> = {};
|
||||
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<Favorite | null> {
|
||||
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<void> {
|
||||
await withRetry(() => this._client.hset(this.favHashKey(userName), { [key]: favorite }));
|
||||
}
|
||||
|
||||
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
|
||||
const hashData = await withRetry(() =>
|
||||
this._client.hgetall(this.favHashKey(userName))
|
||||
);
|
||||
|
||||
if (!hashData || Object.keys(hashData).length === 0) return {};
|
||||
|
||||
const result: Record<string, Favorite> = {};
|
||||
for (const [key, value] of Object.entries(hashData)) {
|
||||
if (value) {
|
||||
result[key] = value as Favorite;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteFavorite(userName: string, key: string): Promise<void> {
|
||||
await withRetry(() => this._client.hdel(this.favHashKey(userName), key));
|
||||
}
|
||||
|
||||
// 迁移收藏:从旧的多key结构迁移到新的hash结构
|
||||
async migrateFavorites(userName: string): Promise<void> {
|
||||
// 检查是否已有正在进行的迁移
|
||||
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<void> {
|
||||
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<string, any> = {};
|
||||
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<boolean> {
|
||||
const stored = await withRetry(() =>
|
||||
this._client.get(this.userPwdKey(userName))
|
||||
);
|
||||
if (stored === null) return false;
|
||||
// 确保比较时都是字符串类型
|
||||
return ensureString(stored) === password;
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
async checkUserExist(userName: string): Promise<boolean> {
|
||||
// 使用 EXISTS 判断 key 是否存在
|
||||
const exists = await withRetry(() =>
|
||||
this._client.exists(this.userPwdKey(userName))
|
||||
);
|
||||
return exists === 1;
|
||||
}
|
||||
|
||||
// 修改用户密码
|
||||
async changePassword(userName: string, newPassword: string): Promise<void> {
|
||||
// 简单存储明文密码,生产环境应加密
|
||||
await withRetry(() =>
|
||||
this._client.set(this.userPwdKey(userName), newPassword)
|
||||
);
|
||||
}
|
||||
|
||||
// 删除用户及其所有数据
|
||||
async deleteUser(userName: string): Promise<void> {
|
||||
// 删除用户密码
|
||||
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<string> {
|
||||
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<void> {
|
||||
// 先检查用户是否已存在(原子性检查)
|
||||
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<string, any> = {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
const userInfo: Record<string, any> = {};
|
||||
|
||||
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<void> {
|
||||
const hashedPassword = await this.hashPassword(newPassword);
|
||||
await withRetry(() =>
|
||||
this._client.hset(this.userInfoKey(userName), { password: hashedPassword })
|
||||
);
|
||||
|
||||
// 清除缓存
|
||||
userInfoCache?.delete(userName);
|
||||
}
|
||||
|
||||
// 检查用户是否存在(新版本)
|
||||
async checkUserExistV2(userName: string): Promise<boolean> {
|
||||
const exists = await withRetry(() =>
|
||||
this._client.exists(this.userInfoKey(userName))
|
||||
);
|
||||
return exists === 1;
|
||||
}
|
||||
|
||||
// 通过OIDC Sub查找用户名
|
||||
async getUserByOidcSub(oidcSub: string): Promise<string | null> {
|
||||
const userName = await withRetry(() =>
|
||||
this._client.get(this.oidcSubKey(oidcSub))
|
||||
);
|
||||
return userName ? ensureString(userName) : null;
|
||||
}
|
||||
|
||||
// 获取使用特定用户组的用户列表
|
||||
async getUsersByTag(tagName: string): Promise<string[]> {
|
||||
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<void> {
|
||||
// 获取用户信息
|
||||
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<string[]> {
|
||||
const result = await withRetry(() =>
|
||||
this._client.lrange(this.shKey(userName), 0, -1)
|
||||
);
|
||||
// 确保返回的都是字符串类型
|
||||
return ensureStringArray(result as any[]);
|
||||
}
|
||||
|
||||
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
// 从新版用户列表获取
|
||||
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<AdminConfig | null> {
|
||||
const val = await withRetry(() => this._client.get(this.adminConfigKey()));
|
||||
return val ? (val as AdminConfig) : null;
|
||||
}
|
||||
|
||||
async setAdminConfig(config: AdminConfig): Promise<void> {
|
||||
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<SkipConfig | null> {
|
||||
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<void> {
|
||||
const key = `${source}+${id}`;
|
||||
await withRetry(() =>
|
||||
this._client.hset(this.skipHashKey(userName), { [key]: config })
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
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<Record<string, SkipConfig>>(this.skipHashKey(userName))
|
||||
);
|
||||
|
||||
return hashData || {};
|
||||
}
|
||||
|
||||
// 迁移跳过配置:从旧的多key结构迁移到新的hash结构
|
||||
async migrateSkipConfigs(userName: string): Promise<void> {
|
||||
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<void> {
|
||||
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<string, SkipConfig> = {};
|
||||
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<import('./types').DanmakuFilterConfig | null> {
|
||||
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<void> {
|
||||
await withRetry(() =>
|
||||
this._client.set(this.danmakuFilterConfigKey(userName), config)
|
||||
);
|
||||
}
|
||||
|
||||
async deleteDanmakuFilterConfig(userName: string): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this._client.del(this.danmakuFilterConfigKey(userName))
|
||||
);
|
||||
}
|
||||
|
||||
// 清空所有数据
|
||||
async clearAllData(): Promise<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
await withRetry(() =>
|
||||
this._client.set(this.globalValueKey(key), value)
|
||||
);
|
||||
}
|
||||
|
||||
async deleteGlobalValue(key: string): Promise<void> {
|
||||
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<import('./types').Notification[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await withRetry(() => this._client.del(this.notificationsKey(userName)));
|
||||
}
|
||||
|
||||
async getUnreadNotificationCount(userName: string): Promise<number> {
|
||||
const notifications = await this.getNotifications(userName);
|
||||
return notifications.filter((n) => !n.read).length;
|
||||
}
|
||||
|
||||
async getLastFavoriteCheckTime(userName: string): Promise<number> {
|
||||
const val = await withRetry(() =>
|
||||
this._client.get(this.lastFavoriteCheckKey(userName))
|
||||
);
|
||||
return val ? (val as number) : 0;
|
||||
}
|
||||
|
||||
async setLastFavoriteCheckTime(
|
||||
userName: string,
|
||||
timestamp: number
|
||||
): Promise<void> {
|
||||
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<import('./types').MovieRequest[]> {
|
||||
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<import('./types').MovieRequest | null> {
|
||||
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<void> {
|
||||
await withRetry(() => this._client.hset(this.movieRequestsKey(), { [request.id]: request }));
|
||||
}
|
||||
|
||||
async updateMovieRequest(requestId: string, updates: Partial<import('./types').MovieRequest>): Promise<void> {
|
||||
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<void> {
|
||||
await withRetry(() => this._client.hdel(this.movieRequestsKey(), requestId));
|
||||
}
|
||||
|
||||
async getUserMovieRequests(userName: string): Promise<string[]> {
|
||||
const val = await withRetry(() => this._client.smembers(this.userMovieRequestsKey(userName)));
|
||||
return val ? ensureStringArray(val) : [];
|
||||
}
|
||||
|
||||
async addUserMovieRequest(userName: string, requestId: string): Promise<void> {
|
||||
await withRetry(() => this._client.sadd(this.userMovieRequestsKey(userName), requestId));
|
||||
}
|
||||
|
||||
async removeUserMovieRequest(userName: string, requestId: string): Promise<void> {
|
||||
await withRetry(() => this._client.srem(this.userMovieRequestsKey(userName), requestId));
|
||||
}
|
||||
|
||||
// ---------- 用户邮箱相关 ----------
|
||||
async getUserEmail(userName: string): Promise<string | null> {
|
||||
const userInfo = await this.getUserInfoV2(userName);
|
||||
return userInfo?.email || null;
|
||||
}
|
||||
|
||||
async setUserEmail(userName: string, email: string): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this._client.hset(this.userInfoKey(userName), { email })
|
||||
);
|
||||
// 清除缓存
|
||||
userInfoCache?.delete(userName);
|
||||
}
|
||||
|
||||
async getEmailNotificationPreference(userName: string): Promise<boolean> {
|
||||
const userInfo = await this.getUserInfoV2(userName);
|
||||
return userInfo?.emailNotifications || false;
|
||||
}
|
||||
|
||||
async setEmailNotificationPreference(userName: string, enabled: boolean): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user