增加播放记录批量删除功能
This commit is contained in:
+580
-196
@@ -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到用户名的反向索引
|
||||
|
||||
Reference in New Issue
Block a user