增加播放记录批量删除功能

This commit is contained in:
mtvpls
2026-06-01 17:52:16 +08:00
parent 2f1a763367
commit 0201891efa
8 changed files with 2721 additions and 828 deletions
+580 -196
View File
@@ -5,7 +5,11 @@ import { createClient, RedisClientType } from 'redis';
import { AdminConfig } from './admin.types';
import { MangaReadRecord, MangaShelfItem } from './manga.types';
import { BookReadRecord, BookShelfItem } from './book.types';
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
import {
MusicV2HistoryRecord,
MusicV2PlaylistItem,
MusicV2PlaylistRecord,
} from './music-v2';
import { RedisAdapter } from './redis-adapter';
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
import { userInfoCache } from './user-cache';
@@ -32,7 +36,10 @@ export interface RedisConnectionConfig {
}
// 添加Redis操作重试包装器
export function createRetryWrapper(clientName: string, getClient: () => RedisClientType) {
export function createRetryWrapper(
clientName: string,
getClient: () => RedisClientType
) {
return async function withRetry<T>(
operation: () => Promise<T>,
maxRetries = 3
@@ -51,7 +58,9 @@ export function createRetryWrapper(clientName: string, getClient: () => RedisCli
if (isConnectionError && !isLastAttempt) {
console.log(
`${clientName} operation failed, retrying... (${i + 1}/${maxRetries})`
`${clientName} operation failed, retrying... (${
i + 1
}/${maxRetries})`
);
console.error('Error:', err.message);
@@ -80,7 +89,10 @@ export function createRetryWrapper(clientName: string, getClient: () => RedisCli
}
// 创建客户端的工厂函数
export function createRedisClient(config: RedisConnectionConfig, globalSymbol: symbol): RedisClientType {
export function createRedisClient(
config: RedisConnectionConfig,
globalSymbol: symbol
): RedisClientType {
let client: RedisClientType | undefined = (global as any)[globalSymbol];
if (!client) {
@@ -94,9 +106,13 @@ export function createRedisClient(config: RedisConnectionConfig, globalSymbol: s
socket: {
// 重连策略:指数退避,最大30秒
reconnectStrategy: (retries: number) => {
console.log(`${config.clientName} reconnection attempt ${retries + 1}`);
console.log(
`${config.clientName} reconnection attempt ${retries + 1}`
);
if (retries > 10) {
console.error(`${config.clientName} max reconnection attempts exceeded`);
console.error(
`${config.clientName} max reconnection attempts exceeded`
);
return false; // 停止重连
}
return Math.min(1000 * Math.pow(2, retries), 30000); // 指数退避,最大30秒
@@ -151,11 +167,20 @@ export function createRedisClient(config: RedisConnectionConfig, globalSymbol: s
// 抽象基类,包含所有通用的Redis操作逻辑
export abstract class BaseRedisStorage implements IStorage {
protected adapter: RedisAdapter;
protected withRetry: <T>(operation: () => Promise<T>, maxRetries?: number) => Promise<T>;
protected withRetry: <T>(
operation: () => Promise<T>,
maxRetries?: number
) => Promise<T>;
// 保留 client 属性用于向后兼容(数据迁移代码使用)
client: any;
constructor(adapter: RedisAdapter, withRetryFn: <T>(operation: () => Promise<T>, maxRetries?: number) => Promise<T>) {
constructor(
adapter: RedisAdapter,
withRetryFn: <T>(
operation: () => Promise<T>,
maxRetries?: number
) => Promise<T>
) {
this.adapter = adapter;
this.withRetry = withRetryFn;
// 创建兼容层,同时支持驼峰和小写命名(用于数据迁移代码)
@@ -176,8 +201,10 @@ export abstract class BaseRedisStorage implements IStorage {
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),
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),
@@ -231,7 +258,17 @@ export abstract class BaseRedisStorage implements IStorage {
}
async deletePlayRecord(userName: string, key: string): Promise<void> {
await this.withRetry(() => this.adapter.hDel(this.prHashKey(userName), key));
await this.withRetry(() =>
this.adapter.hDel(this.prHashKey(userName), key)
);
}
async deletePlayRecords(userName: string, keys: string[]): Promise<void> {
const uniqueKeys = Array.from(new Set(keys)).filter(Boolean);
if (uniqueKeys.length === 0) return;
await this.withRetry(() =>
this.adapter.hDel(this.prHashKey(userName), ...uniqueKeys)
);
}
// 清理超出限制的旧播放记录
@@ -260,7 +297,10 @@ export abstract class BaseRedisStorage implements IStorage {
private async doCleanup(userName: string): Promise<void> {
try {
// 获取配置的最大播放记录数,默认100
const maxRecords = parseInt(process.env.MAX_PLAY_RECORDS_PER_USER || '100', 10);
const maxRecords = parseInt(
process.env.MAX_PLAY_RECORDS_PER_USER || '100',
10
);
const threshold = maxRecords + 10; // 超过最大值+10时才触发清理
// 获取所有播放记录
@@ -272,7 +312,9 @@ export abstract class BaseRedisStorage implements IStorage {
return;
}
console.log(`用户 ${userName} 的播放记录数 ${recordCount} 超过阈值 ${threshold},开始清理...`);
console.log(
`用户 ${userName} 的播放记录数 ${recordCount} 超过阈值 ${threshold},开始清理...`
);
// 将记录转换为数组并按 save_time 排序(从旧到新)
const sortedRecords = Object.entries(allRecords).sort(
@@ -330,13 +372,19 @@ export abstract class BaseRedisStorage implements IStorage {
// 2. 获取旧结构的所有播放记录key
const pattern = `u:${userName}:pr:*`;
const oldKeys: string[] = await this.withRetry(() => this.adapter.keys(pattern));
const oldKeys: string[] = await this.withRetry(() =>
this.adapter.keys(pattern)
);
if (oldKeys.length === 0) {
console.log(`用户 ${userName} 没有旧的播放记录,标记为已迁移`);
// 即使没有数据也标记为已迁移
await this.withRetry(() =>
this.adapter.hSet(this.userInfoKey(userName), 'playrecord_migrated', 'true')
this.adapter.hSet(
this.userInfoKey(userName),
'playrecord_migrated',
'true'
)
);
// 清除用户信息缓存
const { userInfoCache } = await import('./user-cache');
@@ -365,7 +413,9 @@ export abstract class BaseRedisStorage implements IStorage {
await this.withRetry(() =>
this.adapter.hSet(this.prHashKey(userName), hashData)
);
console.log(`成功迁移 ${Object.keys(hashData).length} 条播放记录到hash结构`);
console.log(
`成功迁移 ${Object.keys(hashData).length} 条播放记录到hash结构`
);
}
// 6. 删除旧的key
@@ -374,7 +424,11 @@ export abstract class BaseRedisStorage implements IStorage {
// 7. 标记迁移完成
await this.withRetry(() =>
this.adapter.hSet(this.userInfoKey(userName), 'playrecord_migrated', 'true')
this.adapter.hSet(
this.userInfoKey(userName),
'playrecord_migrated',
'true'
)
);
// 8. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识
@@ -407,7 +461,11 @@ export abstract class BaseRedisStorage implements IStorage {
favorite: Favorite
): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(this.favHashKey(userName), key, JSON.stringify(favorite))
this.adapter.hSet(
this.favHashKey(userName),
key,
JSON.stringify(favorite)
)
);
}
@@ -426,7 +484,9 @@ export abstract class BaseRedisStorage implements IStorage {
}
async deleteFavorite(userName: string, key: string): Promise<void> {
await this.withRetry(() => this.adapter.hDel(this.favHashKey(userName), key));
await this.withRetry(() =>
this.adapter.hDel(this.favHashKey(userName), key)
);
}
// 迁移收藏:从旧的多key结构迁移到新的hash结构
@@ -464,13 +524,19 @@ export abstract class BaseRedisStorage implements IStorage {
// 2. 获取旧结构的所有收藏key
const pattern = `u:${userName}:fav:*`;
const oldKeys: string[] = await this.withRetry(() => this.adapter.keys(pattern));
const oldKeys: string[] = await this.withRetry(() =>
this.adapter.keys(pattern)
);
if (oldKeys.length === 0) {
console.log(`用户 ${userName} 没有旧的收藏,标记为已迁移`);
// 即使没有数据也标记为已迁移
await this.withRetry(() =>
this.adapter.hSet(this.userInfoKey(userName), 'favorite_migrated', 'true')
this.adapter.hSet(
this.userInfoKey(userName),
'favorite_migrated',
'true'
)
);
// 清除用户信息缓存
const { userInfoCache } = await import('./user-cache');
@@ -530,7 +596,11 @@ export abstract class BaseRedisStorage implements IStorage {
return value ? JSON.parse(value) : null;
}
async setMusicPlayRecord(userName: string, key: string, record: any): Promise<void> {
async setMusicPlayRecord(
userName: string,
key: string,
record: any
): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(
this.musicPlayRecordHashKey(userName),
@@ -540,7 +610,10 @@ export abstract class BaseRedisStorage implements IStorage {
);
}
async batchSetMusicPlayRecords(userName: string, records: { key: string; record: any }[]): Promise<void> {
async batchSetMusicPlayRecords(
userName: string,
records: { key: string; record: any }[]
): Promise<void> {
if (records.length === 0) return;
const hashKey = this.musicPlayRecordHashKey(userName);
@@ -550,9 +623,7 @@ export abstract class BaseRedisStorage implements IStorage {
data[key] = JSON.stringify(record);
}
await this.withRetry(() =>
this.adapter.hSet(hashKey, data)
);
await this.withRetry(() => this.adapter.hSet(hashKey, data));
}
async getAllMusicPlayRecords(userName: string): Promise<Record<string, any>> {
@@ -594,12 +665,15 @@ export abstract class BaseRedisStorage implements IStorage {
return `music_playlist:${playlistId}:songs`;
}
async createMusicPlaylist(userName: string, playlist: {
id: string;
name: string;
description?: string;
cover?: string;
}): Promise<void> {
async createMusicPlaylist(
userName: string,
playlist: {
id: string;
name: string;
description?: string;
cover?: string;
}
): Promise<void> {
const now = Date.now();
const playlistData = {
id: playlist.id,
@@ -664,11 +738,14 @@ export abstract class BaseRedisStorage implements IStorage {
return playlists.sort((a, b) => b.created_at - a.created_at);
}
async updateMusicPlaylist(playlistId: string, updates: {
name?: string;
description?: string;
cover?: string;
}): Promise<void> {
async updateMusicPlaylist(
playlistId: string,
updates: {
name?: string;
description?: string;
cover?: string;
}
): Promise<void> {
const updateData: Record<string, string> = {
updated_at: Date.now().toString(),
};
@@ -709,15 +786,18 @@ export abstract class BaseRedisStorage implements IStorage {
);
}
async addSongToPlaylist(playlistId: string, song: {
platform: string;
id: string;
name: string;
artist: string;
album?: string;
pic?: string;
duration: number;
}): Promise<void> {
async addSongToPlaylist(
playlistId: string,
song: {
platform: string;
id: string;
name: string;
artist: string;
album?: string;
pic?: string;
duration: number;
}
): Promise<void> {
const now = Date.now();
const songKey = `${song.platform}+${song.id}`;
@@ -734,7 +814,11 @@ export abstract class BaseRedisStorage implements IStorage {
// 添加歌曲到歌单(使用 hash 存储歌曲信息)
await this.withRetry(() =>
this.adapter.hSet(this.musicPlaylistSongsKey(playlistId), songKey, JSON.stringify(songData))
this.adapter.hSet(
this.musicPlaylistSongsKey(playlistId),
songKey,
JSON.stringify(songData)
)
);
// 更新歌单的 updated_at
@@ -747,7 +831,11 @@ export abstract class BaseRedisStorage implements IStorage {
}
}
async removeSongFromPlaylist(playlistId: string, platform: string, songId: string): Promise<void> {
async removeSongFromPlaylist(
playlistId: string,
platform: string,
songId: string
): Promise<void> {
const songKey = `${platform}+${songId}`;
await this.withRetry(() =>
@@ -786,7 +874,11 @@ export abstract class BaseRedisStorage implements IStorage {
return songs.sort((a, b) => a.added_at - b.added_at);
}
async isSongInPlaylist(playlistId: string, platform: string, songId: string): Promise<boolean> {
async isSongInPlaylist(
playlistId: string,
platform: string,
songId: string
): Promise<boolean> {
const songKey = `${platform}+${songId}`;
const exists = await this.withRetry(() =>
this.adapter.hGet(this.musicPlaylistSongsKey(playlistId), songKey)
@@ -804,39 +896,59 @@ export abstract class BaseRedisStorage implements IStorage {
this.adapter.hGetAll(this.musicV2HistoryKey(userName))
);
return Object.values(rows || {})
.filter(Boolean)
.map(value => JSON.parse(value as string) as MusicV2HistoryRecord)
// 按队列顺序返回;当前播放项由最大 lastPlayedAt 决定。
// createdAt 相同时使用歌曲标识做稳定兜底,避免最近播放时间把歌曲顶到队尾
.sort((a, b) => {
const createdAtDiff = (a.createdAt || 0) - (b.createdAt || 0);
if (createdAtDiff !== 0) return createdAtDiff;
return `${a.source}:${a.songId}`.localeCompare(`${b.source}:${b.songId}`);
});
}
async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(this.musicV2HistoryKey(userName), record.songId, JSON.stringify(record))
return (
Object.values(rows || {})
.filter(Boolean)
.map((value) => JSON.parse(value as string) as MusicV2HistoryRecord)
// 按队列顺序返回;当前播放项由最大 lastPlayedAt 决定
// createdAt 相同时使用歌曲标识做稳定兜底,避免最近播放时间把歌曲顶到队尾。
.sort((a, b) => {
const createdAtDiff = (a.createdAt || 0) - (b.createdAt || 0);
if (createdAtDiff !== 0) return createdAtDiff;
return `${a.source}:${a.songId}`.localeCompare(
`${b.source}:${b.songId}`
);
})
);
}
async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise<void> {
async upsertMusicV2History(
userName: string,
record: MusicV2HistoryRecord
): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(
this.musicV2HistoryKey(userName),
record.songId,
JSON.stringify(record)
)
);
}
async batchUpsertMusicV2History(
userName: string,
records: MusicV2HistoryRecord[]
): Promise<void> {
if (!records.length) return;
const payload: Record<string, string> = {};
for (const record of records) {
payload[record.songId] = JSON.stringify(record);
}
await this.withRetry(() => this.adapter.hSet(this.musicV2HistoryKey(userName), payload));
await this.withRetry(() =>
this.adapter.hSet(this.musicV2HistoryKey(userName), payload)
);
}
async deleteMusicV2History(userName: string, songId: string): Promise<void> {
await this.withRetry(() => this.adapter.hDel(this.musicV2HistoryKey(userName), songId));
await this.withRetry(() =>
this.adapter.hDel(this.musicV2HistoryKey(userName), songId)
);
}
async clearMusicV2History(userName: string): Promise<void> {
await this.withRetry(() => this.adapter.del(this.musicV2HistoryKey(userName)));
await this.withRetry(() =>
this.adapter.del(this.musicV2HistoryKey(userName))
);
}
// ---------- Music V2 歌单 ----------
@@ -852,12 +964,15 @@ export abstract class BaseRedisStorage implements IStorage {
return `music:v2:playlist:${playlistId}:items`;
}
async createMusicV2Playlist(userName: string, playlist: {
id: string;
name: string;
description?: string;
cover?: string;
}): Promise<void> {
async createMusicV2Playlist(
userName: string,
playlist: {
id: string;
name: string;
description?: string;
cover?: string;
}
): Promise<void> {
const now = Date.now();
const payload = {
id: playlist.id,
@@ -870,14 +985,23 @@ export abstract class BaseRedisStorage implements IStorage {
updated_at: now.toString(),
};
await this.withRetry(() => this.adapter.hSet(this.musicV2PlaylistKey(playlist.id), payload));
await this.withRetry(() =>
this.adapter.zAdd(this.musicV2PlaylistsKey(userName), { score: now, value: playlist.id })
this.adapter.hSet(this.musicV2PlaylistKey(playlist.id), payload)
);
await this.withRetry(() =>
this.adapter.zAdd(this.musicV2PlaylistsKey(userName), {
score: now,
value: playlist.id,
})
);
}
async getMusicV2Playlist(playlistId: string): Promise<MusicV2PlaylistRecord | null> {
const data = await this.withRetry(() => this.adapter.hGetAll(this.musicV2PlaylistKey(playlistId)));
async getMusicV2Playlist(
playlistId: string
): Promise<MusicV2PlaylistRecord | null> {
const data = await this.withRetry(() =>
this.adapter.hGetAll(this.musicV2PlaylistKey(playlistId))
);
if (!data || Object.keys(data).length === 0) return null;
return {
id: data.id,
@@ -891,8 +1015,12 @@ export abstract class BaseRedisStorage implements IStorage {
};
}
async listMusicV2Playlists(userName: string): Promise<MusicV2PlaylistRecord[]> {
const playlistIds = await this.withRetry(() => this.adapter.zRange(this.musicV2PlaylistsKey(userName), 0, -1));
async listMusicV2Playlists(
userName: string
): Promise<MusicV2PlaylistRecord[]> {
const playlistIds = await this.withRetry(() =>
this.adapter.zRange(this.musicV2PlaylistsKey(userName), 0, -1)
);
const playlists: MusicV2PlaylistRecord[] = [];
for (const playlistId of playlistIds || []) {
const playlist = await this.getMusicV2Playlist(ensureString(playlistId));
@@ -901,33 +1029,53 @@ export abstract class BaseRedisStorage implements IStorage {
return playlists.sort((a, b) => b.updated_at - a.updated_at);
}
async updateMusicV2Playlist(playlistId: string, updates: {
name?: string;
description?: string;
cover?: string;
song_count?: number;
}): Promise<void> {
async updateMusicV2Playlist(
playlistId: string,
updates: {
name?: string;
description?: string;
cover?: string;
song_count?: number;
}
): Promise<void> {
const payload: Record<string, string> = {
updated_at: Date.now().toString(),
};
if (updates.name !== undefined) payload.name = updates.name;
if (updates.description !== undefined) payload.description = updates.description || '';
if (updates.description !== undefined)
payload.description = updates.description || '';
if (updates.cover !== undefined) payload.cover = updates.cover || '';
if (updates.song_count !== undefined) payload.song_count = String(updates.song_count);
await this.withRetry(() => this.adapter.hSet(this.musicV2PlaylistKey(playlistId), payload));
if (updates.song_count !== undefined)
payload.song_count = String(updates.song_count);
await this.withRetry(() =>
this.adapter.hSet(this.musicV2PlaylistKey(playlistId), payload)
);
}
async deleteMusicV2Playlist(playlistId: string): Promise<void> {
const playlist = await this.getMusicV2Playlist(playlistId);
if (!playlist) return;
await this.withRetry(() => this.adapter.zRem(this.musicV2PlaylistsKey(playlist.username), playlistId));
await this.withRetry(() => this.adapter.del(this.musicV2PlaylistKey(playlistId)));
await this.withRetry(() => this.adapter.del(this.musicV2PlaylistItemsKey(playlistId)));
await this.withRetry(() =>
this.adapter.zRem(this.musicV2PlaylistsKey(playlist.username), playlistId)
);
await this.withRetry(() =>
this.adapter.del(this.musicV2PlaylistKey(playlistId))
);
await this.withRetry(() =>
this.adapter.del(this.musicV2PlaylistItemsKey(playlistId))
);
}
async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise<void> {
async addMusicV2PlaylistItem(
playlistId: string,
item: MusicV2PlaylistItem
): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(this.musicV2PlaylistItemsKey(playlistId), item.songId, JSON.stringify(item))
this.adapter.hSet(
this.musicV2PlaylistItemsKey(playlistId),
item.songId,
JSON.stringify(item)
)
);
const items = await this.listMusicV2PlaylistItems(playlistId);
const playlist = await this.getMusicV2Playlist(playlistId);
@@ -937,8 +1085,13 @@ export abstract class BaseRedisStorage implements IStorage {
});
}
async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise<void> {
await this.withRetry(() => this.adapter.hDel(this.musicV2PlaylistItemsKey(playlistId), songId));
async removeMusicV2PlaylistItem(
playlistId: string,
songId: string
): Promise<void> {
await this.withRetry(() =>
this.adapter.hDel(this.musicV2PlaylistItemsKey(playlistId), songId)
);
const items = await this.listMusicV2PlaylistItems(playlistId);
await this.updateMusicV2Playlist(playlistId, {
song_count: items.length,
@@ -946,16 +1099,25 @@ export abstract class BaseRedisStorage implements IStorage {
});
}
async listMusicV2PlaylistItems(playlistId: string): Promise<MusicV2PlaylistItem[]> {
const rows = await this.withRetry(() => this.adapter.hGetAll(this.musicV2PlaylistItemsKey(playlistId)));
async listMusicV2PlaylistItems(
playlistId: string
): Promise<MusicV2PlaylistItem[]> {
const rows = await this.withRetry(() =>
this.adapter.hGetAll(this.musicV2PlaylistItemsKey(playlistId))
);
return Object.values(rows || {})
.filter(Boolean)
.map(value => JSON.parse(value as string) as MusicV2PlaylistItem)
.map((value) => JSON.parse(value as string) as MusicV2PlaylistItem)
.sort((a, b) => a.sortOrder - b.sortOrder || a.addedAt - b.addedAt);
}
async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise<boolean> {
const exists = await this.withRetry(() => this.adapter.hGet(this.musicV2PlaylistItemsKey(playlistId), songId));
async hasMusicV2PlaylistItem(
playlistId: string,
songId: string
): Promise<boolean> {
const exists = await this.withRetry(() =>
this.adapter.hGet(this.musicV2PlaylistItemsKey(playlistId), songId)
);
return exists !== null;
}
@@ -1014,8 +1176,12 @@ export abstract class BaseRedisStorage implements IStorage {
await this.withRetry(() => this.adapter.del(this.favHashKey(userName)));
// 删除漫画书架与历史
await this.withRetry(() => this.adapter.del(this.mangaShelfHashKey(userName)));
await this.withRetry(() => this.adapter.del(this.mangaReadHashKey(userName)));
await this.withRetry(() =>
this.adapter.del(this.mangaShelfHashKey(userName))
);
await this.withRetry(() =>
this.adapter.del(this.mangaReadHashKey(userName))
);
// 删除旧的收藏key(如果有)
const favoritePattern = `u:${userName}:fav:*`;
@@ -1039,7 +1205,9 @@ export abstract class BaseRedisStorage implements IStorage {
}
// 删除音乐播放记录
await this.withRetry(() => this.adapter.del(this.musicPlayRecordHashKey(userName)));
await this.withRetry(() =>
this.adapter.del(this.musicPlayRecordHashKey(userName))
);
// 删除用户的所有歌单
const playlistIds = await this.withRetry(() =>
@@ -1051,14 +1219,20 @@ export abstract class BaseRedisStorage implements IStorage {
// 删除歌单信息
await this.withRetry(() => this.adapter.del(this.musicPlaylistKey(id)));
// 删除歌单的歌曲列表
await this.withRetry(() => this.adapter.del(this.musicPlaylistSongsKey(id)));
await this.withRetry(() =>
this.adapter.del(this.musicPlaylistSongsKey(id))
);
}
}
// 删除用户的歌单列表
await this.withRetry(() => this.adapter.del(this.musicPlaylistsKey(userName)));
await this.withRetry(() =>
this.adapter.del(this.musicPlaylistsKey(userName))
);
// 删除音乐 V2 播放记录
await this.withRetry(() => this.adapter.del(this.musicV2HistoryKey(userName)));
await this.withRetry(() =>
this.adapter.del(this.musicV2HistoryKey(userName))
);
// 删除音乐 V2 歌单
const musicV2PlaylistIds = await this.withRetry(() =>
@@ -1067,11 +1241,17 @@ export abstract class BaseRedisStorage implements IStorage {
if (musicV2PlaylistIds && musicV2PlaylistIds.length > 0) {
for (const playlistId of musicV2PlaylistIds) {
const id = ensureString(playlistId);
await this.withRetry(() => this.adapter.del(this.musicV2PlaylistKey(id)));
await this.withRetry(() => this.adapter.del(this.musicV2PlaylistItemsKey(id)));
await this.withRetry(() =>
this.adapter.del(this.musicV2PlaylistKey(id))
);
await this.withRetry(() =>
this.adapter.del(this.musicV2PlaylistItemsKey(id))
);
}
}
await this.withRetry(() => this.adapter.del(this.musicV2PlaylistsKey(userName)));
await this.withRetry(() =>
this.adapter.del(this.musicV2PlaylistsKey(userName))
);
}
// ---------- 新版用户存储(使用Hash和Sorted Set ----------
@@ -1093,7 +1273,7 @@ export abstract class BaseRedisStorage implements IStorage {
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('');
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}
// 创建新用户(新版本)
@@ -1127,16 +1307,22 @@ export abstract class BaseRedisStorage implements IStorage {
if (oidcSub) {
userInfo.oidcSub = oidcSub;
// 创建OIDC映射
await this.withRetry(() => this.adapter.set(this.oidcSubKey(oidcSub), userName));
await this.withRetry(() =>
this.adapter.set(this.oidcSubKey(oidcSub), userName)
);
}
await this.withRetry(() => this.adapter.hSet(this.userInfoKey(userName), userInfo));
await this.withRetry(() =>
this.adapter.hSet(this.userInfoKey(userName), userInfo)
);
// 添加到用户列表(Sorted Set,按注册时间排序)
await this.withRetry(() => this.adapter.zAdd(this.userListKey(), {
score: createdAt,
value: userName,
}));
await this.withRetry(() =>
this.adapter.zAdd(this.userListKey(), {
score: createdAt,
value: userName,
})
);
// 清除用户信息缓存
userInfoCache?.delete(userName);
@@ -1211,13 +1397,17 @@ export abstract class BaseRedisStorage implements IStorage {
skip_migrated: 'true',
};
await this.withRetry(() => this.adapter.hSet(this.userInfoKey(userName), userInfo));
await this.withRetry(() =>
this.adapter.hSet(this.userInfoKey(userName), userInfo)
);
// 添加到用户列表(Sorted Set,按注册时间排序)
await this.withRetry(() => this.adapter.zAdd(this.userListKey(), {
score: ownerInfo.created_at,
value: userName,
}));
await this.withRetry(() =>
this.adapter.zAdd(this.userListKey(), {
score: ownerInfo.created_at,
value: userName,
})
);
console.log(`Created database record for site owner: ${userName}`);
} catch (insertErr) {
@@ -1237,12 +1427,16 @@ export abstract class BaseRedisStorage implements IStorage {
banned: userInfoRaw.banned === 'true',
tags: userInfoRaw.tags ? JSON.parse(userInfoRaw.tags) : undefined,
oidcSub: userInfoRaw.oidcSub,
enabledApis: userInfoRaw.enabledApis ? JSON.parse(userInfoRaw.enabledApis) : undefined,
enabledApis: userInfoRaw.enabledApis
? JSON.parse(userInfoRaw.enabledApis)
: undefined,
created_at: parseInt(userInfoRaw.created_at || '0', 10),
playrecord_migrated: userInfoRaw.playrecord_migrated === 'true',
favorite_migrated: userInfoRaw.favorite_migrated === 'true',
skip_migrated: userInfoRaw.skip_migrated === 'true',
last_movie_request_time: userInfoRaw.last_movie_request_time ? parseInt(userInfoRaw.last_movie_request_time, 10) : undefined,
last_movie_request_time: userInfoRaw.last_movie_request_time
? parseInt(userInfoRaw.last_movie_request_time, 10)
: undefined,
email: userInfoRaw.email,
emailNotifications: userInfoRaw.emailNotifications === 'true',
};
@@ -1284,7 +1478,9 @@ export abstract class BaseRedisStorage implements IStorage {
userInfo.tags = JSON.stringify(updates.tags);
} else {
// 删除tags字段
await this.withRetry(() => this.adapter.hDel(this.userInfoKey(userName), 'tags'));
await this.withRetry(() =>
this.adapter.hDel(this.userInfoKey(userName), 'tags')
);
}
}
@@ -1293,7 +1489,9 @@ export abstract class BaseRedisStorage implements IStorage {
userInfo.enabledApis = JSON.stringify(updates.enabledApis);
} else {
// 删除enabledApis字段
await this.withRetry(() => this.adapter.hDel(this.userInfoKey(userName), 'enabledApis'));
await this.withRetry(() =>
this.adapter.hDel(this.userInfoKey(userName), 'enabledApis')
);
}
}
@@ -1301,15 +1499,21 @@ export abstract class BaseRedisStorage implements IStorage {
const oldInfo = await this.getUserInfoV2(userName);
if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) {
// 删除旧的OIDC映射
await this.withRetry(() => this.adapter.del(this.oidcSubKey(oldInfo.oidcSub!)));
await this.withRetry(() =>
this.adapter.del(this.oidcSubKey(oldInfo.oidcSub!))
);
}
userInfo.oidcSub = updates.oidcSub;
// 创建新的OIDC映射
await this.withRetry(() => this.adapter.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.adapter.hSet(this.userInfoKey(userName), userInfo));
await this.withRetry(() =>
this.adapter.hSet(this.userInfoKey(userName), userInfo)
);
}
// 清除缓存
@@ -1364,7 +1568,9 @@ export abstract class BaseRedisStorage implements IStorage {
const trimmedSearch = search?.trim() || '';
// 获取总数
let total = await this.withRetry(() => this.adapter.zCard(this.userListKey()));
let total = await this.withRetry(() =>
this.adapter.zCard(this.userListKey())
);
// 检查站长是否在数据库中(使用缓存)
let ownerInfo = null;
@@ -1493,7 +1699,9 @@ export abstract class BaseRedisStorage implements IStorage {
// 删除OIDC映射
if (userInfo?.oidcSub) {
await this.withRetry(() => this.adapter.del(this.oidcSubKey(userInfo.oidcSub!)));
await this.withRetry(() =>
this.adapter.del(this.oidcSubKey(userInfo.oidcSub!))
);
}
// 删除用户信息Hash
@@ -1525,17 +1733,23 @@ export abstract class BaseRedisStorage implements IStorage {
async addSearchHistory(userName: string, keyword: string): Promise<void> {
const key = this.shKey(userName);
// 先去重
await this.withRetry(() => this.adapter.lRem(key, 0, ensureString(keyword)));
await this.withRetry(() =>
this.adapter.lRem(key, 0, ensureString(keyword))
);
// 插入到最前
await this.withRetry(() => this.adapter.lPush(key, ensureString(keyword)));
// 限制最大长度
await this.withRetry(() => this.adapter.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.adapter.lRem(key, 0, ensureString(keyword)));
await this.withRetry(() =>
this.adapter.lRem(key, 0, ensureString(keyword))
);
} else {
await this.withRetry(() => this.adapter.del(key));
}
@@ -1546,17 +1760,36 @@ export abstract class BaseRedisStorage implements IStorage {
return `u:${user}:manga:shelf`;
}
async getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null> {
const val = await this.withRetry(() => this.adapter.hGet(this.mangaShelfHashKey(userName), key));
async getMangaShelf(
userName: string,
key: string
): Promise<MangaShelfItem | null> {
const val = await this.withRetry(() =>
this.adapter.hGet(this.mangaShelfHashKey(userName), key)
);
return val ? (JSON.parse(val) as MangaShelfItem) : null;
}
async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void> {
await this.withRetry(() => this.adapter.hSet(this.mangaShelfHashKey(userName), key, JSON.stringify(item)));
async setMangaShelf(
userName: string,
key: string,
item: MangaShelfItem
): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(
this.mangaShelfHashKey(userName),
key,
JSON.stringify(item)
)
);
}
async getAllMangaShelf(userName: string): Promise<Record<string, MangaShelfItem>> {
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.mangaShelfHashKey(userName)));
async getAllMangaShelf(
userName: string
): Promise<Record<string, MangaShelfItem>> {
const hashData = await this.withRetry(() =>
this.adapter.hGetAll(this.mangaShelfHashKey(userName))
);
const result: Record<string, MangaShelfItem> = {};
for (const [key, value] of Object.entries(hashData)) {
if (value) result[key] = JSON.parse(value) as MangaShelfItem;
@@ -1565,7 +1798,9 @@ export abstract class BaseRedisStorage implements IStorage {
}
async deleteMangaShelf(userName: string, key: string): Promise<void> {
await this.withRetry(() => this.adapter.hDel(this.mangaShelfHashKey(userName), key));
await this.withRetry(() =>
this.adapter.hDel(this.mangaShelfHashKey(userName), key)
);
}
// ---------- 漫画阅读历史 ----------
@@ -1573,17 +1808,36 @@ export abstract class BaseRedisStorage implements IStorage {
return `u:${user}:manga:history`;
}
async getMangaReadRecord(userName: string, key: string): Promise<MangaReadRecord | null> {
const val = await this.withRetry(() => this.adapter.hGet(this.mangaReadHashKey(userName), key));
async getMangaReadRecord(
userName: string,
key: string
): Promise<MangaReadRecord | null> {
const val = await this.withRetry(() =>
this.adapter.hGet(this.mangaReadHashKey(userName), key)
);
return val ? (JSON.parse(val) as MangaReadRecord) : null;
}
async setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise<void> {
await this.withRetry(() => this.adapter.hSet(this.mangaReadHashKey(userName), key, JSON.stringify(record)));
async setMangaReadRecord(
userName: string,
key: string,
record: MangaReadRecord
): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(
this.mangaReadHashKey(userName),
key,
JSON.stringify(record)
)
);
}
async getAllMangaReadRecords(userName: string): Promise<Record<string, MangaReadRecord>> {
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.mangaReadHashKey(userName)));
async getAllMangaReadRecords(
userName: string
): Promise<Record<string, MangaReadRecord>> {
const hashData = await this.withRetry(() =>
this.adapter.hGetAll(this.mangaReadHashKey(userName))
);
const result: Record<string, MangaReadRecord> = {};
for (const [key, value] of Object.entries(hashData)) {
if (value) result[key] = JSON.parse(value) as MangaReadRecord;
@@ -1592,12 +1846,17 @@ export abstract class BaseRedisStorage implements IStorage {
}
async deleteMangaReadRecord(userName: string, key: string): Promise<void> {
await this.withRetry(() => this.adapter.hDel(this.mangaReadHashKey(userName), key));
await this.withRetry(() =>
this.adapter.hDel(this.mangaReadHashKey(userName), key)
);
}
async cleanupOldMangaReadRecords(userName: string): Promise<void> {
const records = await this.getAllMangaReadRecords(userName);
const maxRecords = parseInt(process.env.MAX_MANGA_HISTORY_PER_USER || '100', 10);
const maxRecords = parseInt(
process.env.MAX_MANGA_HISTORY_PER_USER || '100',
10
);
const threshold = maxRecords + 10;
if (Object.keys(records).length <= threshold) return;
const keys = Object.entries(records)
@@ -1606,27 +1865,47 @@ export abstract class BaseRedisStorage implements IStorage {
.map(([key]) => key);
if (keys.length > 0) {
await this.withRetry(() => this.adapter.hDel(this.mangaReadHashKey(userName), ...keys));
await this.withRetry(() =>
this.adapter.hDel(this.mangaReadHashKey(userName), ...keys)
);
}
}
// ---------- 电子书书架 ----------
private bookShelfHashKey(user: string) {
return `u:${user}:book:shelf`;
}
async getBookShelf(userName: string, key: string): Promise<BookShelfItem | null> {
const val = await this.withRetry(() => this.adapter.hGet(this.bookShelfHashKey(userName), key));
async getBookShelf(
userName: string,
key: string
): Promise<BookShelfItem | null> {
const val = await this.withRetry(() =>
this.adapter.hGet(this.bookShelfHashKey(userName), key)
);
return val ? (JSON.parse(val) as BookShelfItem) : null;
}
async setBookShelf(userName: string, key: string, item: BookShelfItem): Promise<void> {
await this.withRetry(() => this.adapter.hSet(this.bookShelfHashKey(userName), key, JSON.stringify(item)));
async setBookShelf(
userName: string,
key: string,
item: BookShelfItem
): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(
this.bookShelfHashKey(userName),
key,
JSON.stringify(item)
)
);
}
async getAllBookShelf(userName: string): Promise<Record<string, BookShelfItem>> {
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.bookShelfHashKey(userName)));
async getAllBookShelf(
userName: string
): Promise<Record<string, BookShelfItem>> {
const hashData = await this.withRetry(() =>
this.adapter.hGetAll(this.bookShelfHashKey(userName))
);
const result: Record<string, BookShelfItem> = {};
for (const [key, value] of Object.entries(hashData)) {
if (value) result[key] = JSON.parse(value) as BookShelfItem;
@@ -1635,7 +1914,9 @@ export abstract class BaseRedisStorage implements IStorage {
}
async deleteBookShelf(userName: string, key: string): Promise<void> {
await this.withRetry(() => this.adapter.hDel(this.bookShelfHashKey(userName), key));
await this.withRetry(() =>
this.adapter.hDel(this.bookShelfHashKey(userName), key)
);
}
// ---------- 电子书阅读历史 ----------
@@ -1643,17 +1924,36 @@ export abstract class BaseRedisStorage implements IStorage {
return `u:${user}:book:history`;
}
async getBookReadRecord(userName: string, key: string): Promise<BookReadRecord | null> {
const val = await this.withRetry(() => this.adapter.hGet(this.bookReadHashKey(userName), key));
async getBookReadRecord(
userName: string,
key: string
): Promise<BookReadRecord | null> {
const val = await this.withRetry(() =>
this.adapter.hGet(this.bookReadHashKey(userName), key)
);
return val ? (JSON.parse(val) as BookReadRecord) : null;
}
async setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise<void> {
await this.withRetry(() => this.adapter.hSet(this.bookReadHashKey(userName), key, JSON.stringify(record)));
async setBookReadRecord(
userName: string,
key: string,
record: BookReadRecord
): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(
this.bookReadHashKey(userName),
key,
JSON.stringify(record)
)
);
}
async getAllBookReadRecords(userName: string): Promise<Record<string, BookReadRecord>> {
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.bookReadHashKey(userName)));
async getAllBookReadRecords(
userName: string
): Promise<Record<string, BookReadRecord>> {
const hashData = await this.withRetry(() =>
this.adapter.hGetAll(this.bookReadHashKey(userName))
);
const result: Record<string, BookReadRecord> = {};
for (const [key, value] of Object.entries(hashData)) {
if (value) result[key] = JSON.parse(value) as BookReadRecord;
@@ -1662,12 +1962,17 @@ export abstract class BaseRedisStorage implements IStorage {
}
async deleteBookReadRecord(userName: string, key: string): Promise<void> {
await this.withRetry(() => this.adapter.hDel(this.bookReadHashKey(userName), key));
await this.withRetry(() =>
this.adapter.hDel(this.bookReadHashKey(userName), key)
);
}
async cleanupOldBookReadRecords(userName: string): Promise<void> {
const records = await this.getAllBookReadRecords(userName);
const maxRecords = parseInt(process.env.MAX_BOOK_HISTORY_PER_USER || '100', 10);
const maxRecords = parseInt(
process.env.MAX_BOOK_HISTORY_PER_USER || '100',
10
);
const threshold = maxRecords + 10;
if (Object.keys(records).length <= threshold) return;
const keys = Object.entries(records)
@@ -1676,7 +1981,9 @@ export abstract class BaseRedisStorage implements IStorage {
.map(([key]) => key);
if (keys.length > 0) {
await this.withRetry(() => this.adapter.hDel(this.bookReadHashKey(userName), ...keys));
await this.withRetry(() =>
this.adapter.hDel(this.bookReadHashKey(userName), ...keys)
);
}
}
@@ -1687,7 +1994,7 @@ export abstract class BaseRedisStorage implements IStorage {
const users = await this.withRetry(() =>
this.adapter.zRange(userListKey, 0, -1)
);
const userList = users.map(u => ensureString(u));
const userList = users.map((u) => ensureString(u));
// 确保站长在列表中(站长可能不在数据库中,使用环境变量认证)
const ownerUsername = process.env.USERNAME;
@@ -1704,7 +2011,9 @@ export abstract class BaseRedisStorage implements IStorage {
}
async getAdminConfig(): Promise<AdminConfig | null> {
const val = await this.withRetry(() => this.adapter.get(this.adminConfigKey()));
const val = await this.withRetry(() =>
this.adapter.get(this.adminConfigKey())
);
return val ? (JSON.parse(val) as AdminConfig) : null;
}
@@ -1803,7 +2112,9 @@ export abstract class BaseRedisStorage implements IStorage {
}
const pattern = `u:${userName}:skip:*`;
const oldKeys: string[] = await this.withRetry(() => this.adapter.keys(pattern));
const oldKeys: string[] = await this.withRetry(() =>
this.adapter.keys(pattern)
);
if (oldKeys.length === 0) {
console.log(`用户 ${userName} 没有旧的跳过配置,标记为已迁移`);
@@ -1833,7 +2144,9 @@ export abstract class BaseRedisStorage implements IStorage {
await this.withRetry(() =>
this.adapter.hSet(this.skipHashKey(userName), hashData)
);
console.log(`成功迁移 ${Object.keys(hashData).length} 条跳过配置到hash结构`);
console.log(
`成功迁移 ${Object.keys(hashData).length} 条跳过配置到hash结构`
);
}
await this.withRetry(() => this.adapter.del(oldKeys));
@@ -1855,7 +2168,9 @@ export abstract class BaseRedisStorage implements IStorage {
const val = await this.withRetry(() =>
this.adapter.get(this.danmakuFilterConfigKey(userName))
);
return val ? (JSON.parse(val) as import('./types').DanmakuFilterConfig) : null;
return val
? (JSON.parse(val) as import('./types').DanmakuFilterConfig)
: null;
}
async setDanmakuFilterConfig(
@@ -1928,7 +2243,9 @@ export abstract class BaseRedisStorage implements IStorage {
return `u:${userName}:last_fav_check`;
}
async getNotifications(userName: string): Promise<import('./types').Notification[]> {
async getNotifications(
userName: string
): Promise<import('./types').Notification[]> {
const val = await this.withRetry(() =>
this.adapter.get(this.notificationsKey(userName))
);
@@ -1946,7 +2263,10 @@ export abstract class BaseRedisStorage implements IStorage {
notifications.splice(100);
}
await this.withRetry(() =>
this.adapter.set(this.notificationsKey(userName), JSON.stringify(notifications))
this.adapter.set(
this.notificationsKey(userName),
JSON.stringify(notifications)
)
);
}
@@ -1959,7 +2279,10 @@ export abstract class BaseRedisStorage implements IStorage {
if (notification) {
notification.read = true;
await this.withRetry(() =>
this.adapter.set(this.notificationsKey(userName), JSON.stringify(notifications))
this.adapter.set(
this.notificationsKey(userName),
JSON.stringify(notifications)
)
);
}
}
@@ -1971,12 +2294,17 @@ export abstract class BaseRedisStorage implements IStorage {
const notifications = await this.getNotifications(userName);
const filtered = notifications.filter((n) => n.id !== notificationId);
await this.withRetry(() =>
this.adapter.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.adapter.del(this.notificationsKey(userName)));
await this.withRetry(() =>
this.adapter.del(this.notificationsKey(userName))
);
}
async getUnreadNotificationCount(userName: string): Promise<number> {
@@ -1996,11 +2324,17 @@ export abstract class BaseRedisStorage implements IStorage {
timestamp: number
): Promise<void> {
await this.withRetry(() =>
this.adapter.set(this.lastFavoriteCheckKey(userName), timestamp.toString())
this.adapter.set(
this.lastFavoriteCheckKey(userName),
timestamp.toString()
)
);
}
async updateLastMovieRequestTime(userName: string, timestamp: number): Promise<void> {
async updateLastMovieRequestTime(
userName: string,
timestamp: number
): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(
this.userInfoKey(userName),
@@ -2020,42 +2354,81 @@ export abstract class BaseRedisStorage implements IStorage {
}
async getAllMovieRequests(): Promise<import('./types').MovieRequest[]> {
const data = await this.withRetry(() => this.adapter.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);
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.adapter.hGet(this.movieRequestsKey(), requestId));
async getMovieRequest(
requestId: string
): Promise<import('./types').MovieRequest | null> {
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.adapter.hSet(this.movieRequestsKey(), request.id, JSON.stringify(request)));
async createMovieRequest(
request: import('./types').MovieRequest
): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(
this.movieRequestsKey(),
request.id,
JSON.stringify(request)
)
);
}
async updateMovieRequest(requestId: string, updates: Partial<import('./types').MovieRequest>): Promise<void> {
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.adapter.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.adapter.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.adapter.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.adapter.sAdd(this.userMovieRequestsKey(userName), requestId));
async addUserMovieRequest(
userName: string,
requestId: string
): Promise<void> {
await this.withRetry(() =>
this.adapter.sAdd(this.userMovieRequestsKey(userName), requestId)
);
}
async removeUserMovieRequest(userName: string, requestId: string): Promise<void> {
await this.withRetry(() => this.adapter.sRem(this.userMovieRequestsKey(userName), requestId));
async removeUserMovieRequest(
userName: string,
requestId: string
): Promise<void> {
await this.withRetry(() =>
this.adapter.sRem(this.userMovieRequestsKey(userName), requestId)
);
}
// ---------- 用户邮箱相关 ----------
@@ -2077,9 +2450,16 @@ export abstract class BaseRedisStorage implements IStorage {
return userInfo?.emailNotifications || false;
}
async setEmailNotificationPreference(userName: string, enabled: boolean): Promise<void> {
async setEmailNotificationPreference(
userName: string,
enabled: boolean
): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(this.userInfoKey(userName), 'emailNotifications', enabled.toString())
this.adapter.hSet(
this.userInfoKey(userName),
'emailNotifications',
enabled.toString()
)
);
// 清除缓存
userInfoCache?.delete(userName);
@@ -2097,7 +2477,11 @@ export abstract class BaseRedisStorage implements IStorage {
async setTvboxSubscribeToken(userName: string, token: string): Promise<void> {
// 保存token到用户信息
await this.withRetry(() =>
this.adapter.hSet(this.userInfoKey(userName), 'tvboxSubscribeToken', token)
this.adapter.hSet(
this.userInfoKey(userName),
'tvboxSubscribeToken',
token
)
);
// 创建token到用户名的反向索引