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

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
+40 -5
View File
@@ -29,14 +29,18 @@ export async function GET(request: NextRequest) {
// 检查播放记录迁移标识,没有迁移标识时执行迁移 // 检查播放记录迁移标识,没有迁移标识时执行迁移
if (!userInfoV2.playrecord_migrated) { if (!userInfoV2.playrecord_migrated) {
console.log(`用户 ${authInfo.username} 播放记录未迁移,开始执行迁移...`); console.log(
`用户 ${authInfo.username} 播放记录未迁移,开始执行迁移...`
);
await db.migratePlayRecords(authInfo.username); await db.migratePlayRecords(authInfo.username);
} }
} else { } else {
// 站长也需要执行迁移(站长可能不在数据库中,直接尝试迁移) // 站长也需要执行迁移(站长可能不在数据库中,直接尝试迁移)
const userInfoV2 = await db.getUserInfoV2(authInfo.username); const userInfoV2 = await db.getUserInfoV2(authInfo.username);
if (!userInfoV2 || !userInfoV2.playrecord_migrated) { if (!userInfoV2 || !userInfoV2.playrecord_migrated) {
console.log(`站长 ${authInfo.username} 播放记录未迁移,开始执行迁移...`); console.log(
`站长 ${authInfo.username} 播放记录未迁移,开始执行迁移...`
);
await db.migratePlayRecords(authInfo.username); await db.migratePlayRecords(authInfo.username);
} }
} }
@@ -106,9 +110,11 @@ export async function POST(request: NextRequest) {
await db.savePlayRecord(authInfo.username, source, id, finalRecord); await db.savePlayRecord(authInfo.username, source, id, finalRecord);
// 异步清理旧的播放记录(不阻塞响应) // 异步清理旧的播放记录(不阻塞响应)
(db as any).storage.cleanupOldPlayRecords(authInfo.username).catch((err: Error) => { (db as any).storage
console.error('异步清理播放记录失败:', err); .cleanupOldPlayRecords(authInfo.username)
}); .catch((err: Error) => {
console.error('异步清理播放记录失败:', err);
});
return NextResponse.json({ success: true }, { status: 200 }); return NextResponse.json({ success: true }, { status: 200 });
} catch (err) { } catch (err) {
@@ -142,6 +148,23 @@ export async function DELETE(request: NextRequest) {
const username = authInfo.username; const username = authInfo.username;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const key = searchParams.get('key'); const key = searchParams.get('key');
let keys: string[] | undefined;
try {
const text = await request.text();
if (text) {
const body = JSON.parse(text);
if (Array.isArray(body?.keys)) {
keys = Array.from(
new Set(
body.keys.filter((item: unknown) => typeof item === 'string')
)
);
}
}
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (key) { if (key) {
// 如果提供了 key,删除单条播放记录 // 如果提供了 key,删除单条播放记录
@@ -154,6 +177,18 @@ export async function DELETE(request: NextRequest) {
} }
await db.deletePlayRecord(username, source, id); await db.deletePlayRecord(username, source, id);
} else if (keys && keys.length > 0) {
for (const item of keys) {
const [source, id] = item.split('+');
if (!source || !id) {
return NextResponse.json(
{ error: 'Invalid key format' },
{ status: 400 }
);
}
}
await db.deletePlayRecords(username, keys);
} else { } else {
// 未提供 key,则清空全部播放记录 // 未提供 key,则清空全部播放记录
// 目前 DbManager 没有对应方法,这里直接遍历删除 // 目前 DbManager 没有对应方法,这里直接遍历删除
+194 -10
View File
@@ -1,12 +1,13 @@
'use client'; 'use client';
import { AlertTriangle, History, X } from 'lucide-react'; import { AlertTriangle, Check, History, Trash2, X } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { PlayRecord } from '@/lib/db.client'; import type { PlayRecord } from '@/lib/db.client';
import { import {
clearAllPlayRecords, clearAllPlayRecords,
deletePlayRecords,
getAllPlayRecords, getAllPlayRecords,
subscribeToDataUpdates, subscribeToDataUpdates,
} from '@/lib/db.client'; } from '@/lib/db.client';
@@ -39,6 +40,11 @@ export default function PlayRecordsPanel({
const [playRecords, setPlayRecords] = useState<PlayRecordItem[]>([]); const [playRecords, setPlayRecords] = useState<PlayRecordItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [editMode, setEditMode] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [showDeleteSelectedDialog, setShowDeleteSelectedDialog] =
useState(false);
const [deletingSelected, setDeletingSelected] = useState(false);
const loadPlayRecords = async () => { const loadPlayRecords = async () => {
setLoading(true); setLoading(true);
@@ -51,6 +57,13 @@ export default function PlayRecordsPanel({
})) }))
.sort((a, b) => b.save_time - a.save_time); .sort((a, b) => b.save_time - a.save_time);
setPlayRecords(sorted); setPlayRecords(sorted);
setSelectedKeys((prev) => {
if (prev.size === 0) return prev;
const availableKeys = new Set(sorted.map((record) => record.key));
return new Set(
Array.from(prev).filter((key) => availableKeys.has(key))
);
});
} catch (error) { } catch (error) {
console.error('加载播放记录失败:', error); console.error('加载播放记录失败:', error);
setPlayRecords([]); setPlayRecords([]);
@@ -63,12 +76,59 @@ export default function PlayRecordsPanel({
try { try {
await clearAllPlayRecords(); await clearAllPlayRecords();
setPlayRecords([]); setPlayRecords([]);
setSelectedKeys(new Set());
setEditMode(false);
setShowConfirmDialog(false); setShowConfirmDialog(false);
} catch (error) { } catch (error) {
console.error('清空播放记录失败:', error); console.error('清空播放记录失败:', error);
} }
}; };
const toggleEditMode = () => {
setEditMode((prev) => {
if (prev) {
setSelectedKeys(new Set());
}
return !prev;
});
};
const toggleSelected = (key: string) => {
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const selectAll = () => {
setSelectedKeys(new Set(playRecords.map((record) => record.key)));
};
const handleDeleteSelected = async () => {
if (selectedKeys.size === 0) return;
setDeletingSelected(true);
try {
const keysToDelete = Array.from(selectedKeys);
await deletePlayRecords(keysToDelete);
setPlayRecords((prev) =>
prev.filter((record) => !selectedKeys.has(record.key))
);
setSelectedKeys(new Set());
setEditMode(false);
setShowDeleteSelectedDialog(false);
} catch (error) {
console.error('删除选中播放记录失败:', error);
} finally {
setDeletingSelected(false);
}
};
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
loadPlayRecords(); loadPlayRecords();
@@ -86,6 +146,13 @@ export default function PlayRecordsPanel({
})) }))
.sort((a, b) => b.save_time - a.save_time); .sort((a, b) => b.save_time - a.save_time);
setPlayRecords(sorted); setPlayRecords(sorted);
setSelectedKeys((prev) => {
if (prev.size === 0) return prev;
const availableKeys = new Set(sorted.map((record) => record.key));
return new Set(
Array.from(prev).filter((key) => availableKeys.has(key))
);
});
} }
); );
@@ -94,6 +161,10 @@ export default function PlayRecordsPanel({
}; };
}, [isOpen]); }, [isOpen]);
const selectedCount = selectedKeys.size;
const allSelected =
playRecords.length > 0 && selectedCount === playRecords.length;
return ( return (
<> <>
<div <div
@@ -115,14 +186,48 @@ export default function PlayRecordsPanel({
)} )}
</div> </div>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
{playRecords.length > 0 && ( {playRecords.length > 0 &&
<button (editMode ? (
onClick={() => setShowConfirmDialog(true)} <>
className='text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors' <button
> onClick={
allSelected ? () => setSelectedKeys(new Set()) : selectAll
</button> }
)} className='text-xs text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors'
>
{allSelected ? '取消全选' : '全选'}
</button>
<button
onClick={() => setShowDeleteSelectedDialog(true)}
disabled={selectedCount === 0 || deletingSelected}
className='inline-flex items-center gap-1 text-xs text-red-500 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-40 dark:text-red-400 dark:hover:text-red-300 transition-colors'
>
<Trash2 className='w-3.5 h-3.5' />
{selectedCount > 0 ? `(${selectedCount})` : ''}
</button>
<button
onClick={toggleEditMode}
className='text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors'
>
</button>
</>
) : (
<>
<button
onClick={toggleEditMode}
className='text-xs text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300 transition-colors'
>
</button>
<button
onClick={() => setShowConfirmDialog(true)}
className='text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors'
>
</button>
</>
))}
<button <button
onClick={onClose} onClick={onClose}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors' className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
@@ -147,9 +252,10 @@ export default function PlayRecordsPanel({
<div className='grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'> <div className='grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'>
{playRecords.map((record) => { {playRecords.map((record) => {
const { source, id } = parseKey(record.key); const { source, id } = parseKey(record.key);
const checked = selectedKeys.has(record.key);
return ( return (
<div key={record.key} className='w-full'> <div key={record.key} className='relative w-full'>
<VideoCard <VideoCard
id={id} id={id}
title={record.title} title={record.title}
@@ -172,6 +278,34 @@ export default function PlayRecordsPanel({
playTime={record.play_time} playTime={record.play_time}
totalTime={record.total_time} totalTime={record.total_time}
/> />
{editMode && (
<button
type='button'
aria-label={
checked ? '取消选择播放记录' : '选择播放记录'
}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
toggleSelected(record.key);
}}
className={`absolute inset-0 z-20 rounded-lg transition-colors ${
checked
? 'bg-sky-500/15 ring-2 ring-sky-500'
: 'bg-black/5 hover:bg-sky-500/10 dark:bg-black/20'
}`}
>
<span
className={`absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded-full border-2 shadow-md transition-colors ${
checked
? 'border-sky-500 bg-sky-500 text-white'
: 'border-white bg-black/40 text-transparent'
}`}
>
<Check className='h-4 w-4' />
</span>
</button>
)}
</div> </div>
); );
})} })}
@@ -224,6 +358,56 @@ export default function PlayRecordsPanel({
</div>, </div>,
document.body document.body
)} )}
{showDeleteSelectedDialog &&
createPortal(
<div
className='fixed inset-0 bg-black bg-opacity-50 z-[9999] flex items-center justify-center p-4 transition-opacity duration-300'
onClick={() =>
!deletingSelected && setShowDeleteSelectedDialog(false)
}
>
<div
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border border-red-200 dark:border-red-800 transition-all duration-300'
onClick={(e) => e.stopPropagation()}
>
<div className='p-6'>
<div className='flex items-start gap-4 mb-4'>
<div className='flex-shrink-0'>
<AlertTriangle className='w-8 h-8 text-red-500' />
</div>
<div className='flex-1'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
</h3>
<p className='text-sm text-gray-600 dark:text-gray-400'>
{selectedCount}{' '}
</p>
</div>
</div>
<div className='flex gap-3 mt-6'>
<button
onClick={() => setShowDeleteSelectedDialog(false)}
disabled={deletingSelected}
className='flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-60 rounded-lg transition-colors'
>
</button>
<button
onClick={handleDeleteSelected}
disabled={deletingSelected}
className='flex-1 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-60 rounded-lg transition-colors'
>
{deletingSelected ? '删除中...' : '确定删除'}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</> </>
); );
} }
+691 -240
View File
@@ -19,7 +19,11 @@ import { AdminConfig } from './admin.types';
import { MangaReadRecord, MangaShelfItem } from './manga.types'; import { MangaReadRecord, MangaShelfItem } from './manga.types';
import { BookReadRecord, BookShelfItem } from './book.types'; import { BookReadRecord, BookShelfItem } from './book.types';
import { DatabaseAdapter } from './d1-adapter'; import { DatabaseAdapter } from './d1-adapter';
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2'; import {
MusicV2HistoryRecord,
MusicV2PlaylistItem,
MusicV2PlaylistRecord,
} from './music-v2';
import { userInfoCache } from './user-cache'; import { userInfoCache } from './user-cache';
/** /**
@@ -58,8 +62,15 @@ export class D1Storage implements IStorage {
for (const statement of statements) { for (const statement of statements) {
try { try {
const result = await this.db.prepare(statement).run(); const result = await this.db.prepare(statement).run();
if (!result.success && result.error && !/duplicate column|already exists/i.test(result.error)) { if (
console.warn('D1Storage.ensureMangaShelfColumns warning:', result.error); !result.success &&
result.error &&
!/duplicate column|already exists/i.test(result.error)
) {
console.warn(
'D1Storage.ensureMangaShelfColumns warning:',
result.error
);
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
@@ -72,7 +83,10 @@ export class D1Storage implements IStorage {
// ==================== 播放记录 ==================== // ==================== 播放记录 ====================
async getPlayRecord(userName: string, key: string): Promise<PlayRecord | null> { async getPlayRecord(
userName: string,
key: string
): Promise<PlayRecord | null> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT * FROM play_records WHERE username = ? AND key = ?') .prepare('SELECT * FROM play_records WHERE username = ? AND key = ?')
@@ -87,10 +101,15 @@ export class D1Storage implements IStorage {
} }
} }
async setPlayRecord(userName: string, key: string, record: PlayRecord): Promise<void> { async setPlayRecord(
userName: string,
key: string,
record: PlayRecord
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO play_records ( INSERT INTO play_records (
username, key, title, source_name, cover, year, username, key, title, source_name, cover, year,
episode_index, total_episodes, play_time, total_time, episode_index, total_episodes, play_time, total_time,
@@ -109,7 +128,8 @@ export class D1Storage implements IStorage {
save_time = excluded.save_time, save_time = excluded.save_time,
search_title = excluded.search_title, search_title = excluded.search_title,
new_episodes = excluded.new_episodes new_episodes = excluded.new_episodes
`) `
)
.bind( .bind(
userName, userName,
key, key,
@@ -132,10 +152,14 @@ export class D1Storage implements IStorage {
} }
} }
async getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }> { async getAllPlayRecords(
userName: string
): Promise<{ [key: string]: PlayRecord }> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM play_records WHERE username = ? ORDER BY save_time DESC') .prepare(
'SELECT * FROM play_records WHERE username = ? ORDER BY save_time DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -165,14 +189,37 @@ export class D1Storage implements IStorage {
} }
} }
async deletePlayRecords(userName: string, keys: string[]): Promise<void> {
const uniqueKeys = Array.from(new Set(keys)).filter(Boolean);
if (uniqueKeys.length === 0) return;
try {
const placeholders = uniqueKeys.map(() => '?').join(',');
await this.db
.prepare(
`DELETE FROM play_records WHERE username = ? AND key IN (${placeholders})`
)
.bind(userName, ...uniqueKeys)
.run();
} catch (err) {
console.error('D1Storage.deletePlayRecords error:', err);
throw err;
}
}
async cleanupOldPlayRecords(userName: string): Promise<void> { async cleanupOldPlayRecords(userName: string): Promise<void> {
try { try {
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; const threshold = maxRecords + 10;
// 检查记录数量 // 检查记录数量
const countResult = await this.db const countResult = await this.db
.prepare('SELECT COUNT(*) as count FROM play_records WHERE username = ?') .prepare(
'SELECT COUNT(*) as count FROM play_records WHERE username = ?'
)
.bind(userName) .bind(userName)
.first(); .first();
@@ -181,7 +228,8 @@ export class D1Storage implements IStorage {
// 删除超出限制的旧记录 // 删除超出限制的旧记录
await this.db await this.db
.prepare(` .prepare(
`
DELETE FROM play_records DELETE FROM play_records
WHERE username = ? WHERE username = ?
AND key NOT IN ( AND key NOT IN (
@@ -190,11 +238,14 @@ export class D1Storage implements IStorage {
ORDER BY save_time DESC ORDER BY save_time DESC
LIMIT ? LIMIT ?
) )
`) `
)
.bind(userName, userName, maxRecords) .bind(userName, userName, maxRecords)
.run(); .run();
console.log(`D1Storage: Cleaned up old play records for user ${userName}`); console.log(
`D1Storage: Cleaned up old play records for user ${userName}`
);
} catch (err) { } catch (err) {
console.error('D1Storage.cleanupOldPlayRecords error:', err); console.error('D1Storage.cleanupOldPlayRecords error:', err);
throw err; throw err;
@@ -234,10 +285,15 @@ export class D1Storage implements IStorage {
} }
} }
async setFavorite(userName: string, key: string, favorite: Favorite): Promise<void> { async setFavorite(
userName: string,
key: string,
favorite: Favorite
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO favorites ( INSERT INTO favorites (
username, key, source_name, total_episodes, title, username, key, source_name, total_episodes, title,
year, cover, save_time, search_title, origin, year, cover, save_time, search_title, origin,
@@ -255,7 +311,8 @@ export class D1Storage implements IStorage {
origin = excluded.origin, origin = excluded.origin,
is_completed = excluded.is_completed, is_completed = excluded.is_completed,
vod_remarks = excluded.vod_remarks vod_remarks = excluded.vod_remarks
`) `
)
.bind( .bind(
userName, userName,
key, key,
@@ -277,10 +334,14 @@ export class D1Storage implements IStorage {
} }
} }
async getAllFavorites(userName: string): Promise<{ [key: string]: Favorite }> { async getAllFavorites(
userName: string
): Promise<{ [key: string]: Favorite }> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM favorites WHERE username = ? ORDER BY save_time DESC') .prepare(
'SELECT * FROM favorites WHERE username = ? ORDER BY save_time DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -330,7 +391,9 @@ export class D1Storage implements IStorage {
async getMusicPlayRecord(userName: string, key: string): Promise<any | null> { async getMusicPlayRecord(userName: string, key: string): Promise<any | null> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT * FROM music_play_records WHERE username = ? AND key = ?') .prepare(
'SELECT * FROM music_play_records WHERE username = ? AND key = ?'
)
.bind(userName, key) .bind(userName, key)
.first(); .first();
@@ -353,10 +416,15 @@ export class D1Storage implements IStorage {
} }
} }
async setMusicPlayRecord(userName: string, key: string, record: any): Promise<void> { async setMusicPlayRecord(
userName: string,
key: string,
record: any
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time) INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(username, key) DO UPDATE SET ON CONFLICT(username, key) DO UPDATE SET
@@ -367,7 +435,8 @@ export class D1Storage implements IStorage {
play_time = excluded.play_time, play_time = excluded.play_time,
duration = excluded.duration, duration = excluded.duration,
save_time = excluded.save_time save_time = excluded.save_time
`) `
)
.bind( .bind(
userName, userName,
key, key,
@@ -388,15 +457,18 @@ export class D1Storage 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; if (records.length === 0) return;
if (!this.db) return; if (!this.db) return;
try { try {
// 使用批量插入,D1 支持 batch 操作 // 使用批量插入,D1 支持 batch 操作
const statements = records.map(({ key, record }) => const statements = records.map(({ key, record }) =>
this.db! this.db!.prepare(
.prepare(` `
INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time) INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(username, key) DO UPDATE SET ON CONFLICT(username, key) DO UPDATE SET
@@ -409,20 +481,20 @@ export class D1Storage implements IStorage {
play_time = excluded.play_time, play_time = excluded.play_time,
duration = excluded.duration, duration = excluded.duration,
save_time = excluded.save_time save_time = excluded.save_time
`) `
.bind( ).bind(
userName, userName,
key, key,
record.platform, record.platform,
record.id, record.id,
record.name, record.name,
record.artist, record.artist,
record.album || null, record.album || null,
record.pic || null, record.pic || null,
record.play_time, record.play_time,
record.duration, record.duration,
record.save_time record.save_time
) )
); );
if (this.db.batch) { if (this.db.batch) {
@@ -434,10 +506,14 @@ export class D1Storage implements IStorage {
} }
} }
async getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }> { async getAllMusicPlayRecords(
userName: string
): Promise<{ [key: string]: any }> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM music_play_records WHERE username = ? ORDER BY save_time DESC') .prepare(
'SELECT * FROM music_play_records WHERE username = ? ORDER BY save_time DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -467,7 +543,9 @@ export class D1Storage implements IStorage {
async deleteMusicPlayRecord(userName: string, key: string): Promise<void> { async deleteMusicPlayRecord(userName: string, key: string): Promise<void> {
try { try {
await this.db await this.db
.prepare('DELETE FROM music_play_records WHERE username = ? AND key = ?') .prepare(
'DELETE FROM music_play_records WHERE username = ? AND key = ?'
)
.bind(userName, key) .bind(userName, key)
.run(); .run();
} catch (err) { } catch (err) {
@@ -490,19 +568,24 @@ export class D1Storage implements IStorage {
// ==================== 音乐歌单相关 ==================== // ==================== 音乐歌单相关 ====================
async createMusicPlaylist(userName: string, playlist: { async createMusicPlaylist(
id: string; userName: string,
name: string; playlist: {
description?: string; id: string;
cover?: string; name: string;
}): Promise<void> { description?: string;
cover?: string;
}
): Promise<void> {
try { try {
const now = Date.now(); const now = Date.now();
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO music_playlists (id, username, name, description, cover, created_at, updated_at) INSERT INTO music_playlists (id, username, name, description, cover, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`) `
)
.bind( .bind(
playlist.id, playlist.id,
userName, userName,
@@ -546,7 +629,9 @@ export class D1Storage implements IStorage {
async getUserMusicPlaylists(userName: string): Promise<any[]> { async getUserMusicPlaylists(userName: string): Promise<any[]> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM music_playlists WHERE username = ? ORDER BY created_at DESC') .prepare(
'SELECT * FROM music_playlists WHERE username = ? ORDER BY created_at DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -567,11 +652,14 @@ export class D1Storage implements IStorage {
} }
} }
async updateMusicPlaylist(playlistId: string, updates: { async updateMusicPlaylist(
name?: string; playlistId: string,
description?: string; updates: {
cover?: string; name?: string;
}): Promise<void> { description?: string;
cover?: string;
}
): Promise<void> {
try { try {
const fields: string[] = []; const fields: string[] = [];
const values: any[] = []; const values: any[] = [];
@@ -618,28 +706,34 @@ export class D1Storage implements IStorage {
} }
} }
async addSongToPlaylist(playlistId: string, song: { async addSongToPlaylist(
platform: string; playlistId: string,
id: string; song: {
name: string; platform: string;
artist: string; id: string;
album?: string; name: string;
pic?: string; artist: string;
duration: number; album?: string;
}): Promise<void> { pic?: string;
duration: number;
}
): Promise<void> {
try { try {
const now = Date.now(); const now = Date.now();
// 获取当前最大的 sort_order // 获取当前最大的 sort_order
const maxOrderResult = await this.db const maxOrderResult = await this.db
.prepare('SELECT MAX(sort_order) as max_order FROM music_playlist_songs WHERE playlist_id = ?') .prepare(
'SELECT MAX(sort_order) as max_order FROM music_playlist_songs WHERE playlist_id = ?'
)
.bind(playlistId) .bind(playlistId)
.first(); .first();
const nextOrder = (maxOrderResult?.max_order as number || 0) + 1; const nextOrder = ((maxOrderResult?.max_order as number) || 0) + 1;
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO music_playlist_songs ( INSERT INTO music_playlist_songs (
playlist_id, platform, song_id, name, artist, album, pic, duration, added_at, sort_order playlist_id, platform, song_id, name, artist, album, pic, duration, added_at, sort_order
) )
@@ -650,7 +744,8 @@ export class D1Storage implements IStorage {
album = excluded.album, album = excluded.album,
pic = excluded.pic, pic = excluded.pic,
duration = excluded.duration duration = excluded.duration
`) `
)
.bind( .bind(
playlistId, playlistId,
song.platform, song.platform,
@@ -667,7 +762,9 @@ export class D1Storage implements IStorage {
// 更新歌单的 updated_at 和封面(如果是第一首歌) // 更新歌单的 updated_at 和封面(如果是第一首歌)
const songCount = await this.db const songCount = await this.db
.prepare('SELECT COUNT(*) as count FROM music_playlist_songs WHERE playlist_id = ?') .prepare(
'SELECT COUNT(*) as count FROM music_playlist_songs WHERE playlist_id = ?'
)
.bind(playlistId) .bind(playlistId)
.first(); .first();
@@ -685,10 +782,16 @@ export class D1Storage implements IStorage {
} }
} }
async removeSongFromPlaylist(playlistId: string, platform: string, songId: string): Promise<void> { async removeSongFromPlaylist(
playlistId: string,
platform: string,
songId: string
): Promise<void> {
try { try {
await this.db await this.db
.prepare('DELETE FROM music_playlist_songs WHERE playlist_id = ? AND platform = ? AND song_id = ?') .prepare(
'DELETE FROM music_playlist_songs WHERE playlist_id = ? AND platform = ? AND song_id = ?'
)
.bind(playlistId, platform, songId) .bind(playlistId, platform, songId)
.run(); .run();
@@ -706,7 +809,9 @@ export class D1Storage implements IStorage {
async getPlaylistSongs(playlistId: string): Promise<any[]> { async getPlaylistSongs(playlistId: string): Promise<any[]> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM music_playlist_songs WHERE playlist_id = ? ORDER BY sort_order ASC') .prepare(
'SELECT * FROM music_playlist_songs WHERE playlist_id = ? ORDER BY sort_order ASC'
)
.bind(playlistId) .bind(playlistId)
.all(); .all();
@@ -729,10 +834,16 @@ export class D1Storage implements IStorage {
} }
} }
async isSongInPlaylist(playlistId: string, platform: string, songId: string): Promise<boolean> { async isSongInPlaylist(
playlistId: string,
platform: string,
songId: string
): Promise<boolean> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT 1 FROM music_playlist_songs WHERE playlist_id = ? AND platform = ? AND song_id = ? LIMIT 1') .prepare(
'SELECT 1 FROM music_playlist_songs WHERE playlist_id = ? AND platform = ? AND song_id = ? LIMIT 1'
)
.bind(playlistId, platform, songId) .bind(playlistId, platform, songId)
.first(); .first();
@@ -749,7 +860,9 @@ export class D1Storage implements IStorage {
try { try {
const results = await this.db const results = await this.db
// 按队列顺序返回;当前播放项由最大 last_played_at 决定 // 按队列顺序返回;当前播放项由最大 last_played_at 决定
.prepare('SELECT * FROM music_v2_history WHERE username = ? ORDER BY created_at ASC, id ASC') .prepare(
'SELECT * FROM music_v2_history WHERE username = ? ORDER BY created_at ASC, id ASC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -778,10 +891,14 @@ export class D1Storage implements IStorage {
} }
} }
async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise<void> { async upsertMusicV2History(
userName: string,
record: MusicV2HistoryRecord
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO music_v2_history ( INSERT INTO music_v2_history (
username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec, username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec,
play_progress_sec, last_played_at, play_count, last_quality, created_at, updated_at play_progress_sec, last_played_at, play_count, last_quality, created_at, updated_at
@@ -801,7 +918,8 @@ export class D1Storage implements IStorage {
play_count = excluded.play_count, play_count = excluded.play_count,
last_quality = excluded.last_quality, last_quality = excluded.last_quality,
updated_at = excluded.updated_at updated_at = excluded.updated_at
`) `
)
.bind( .bind(
userName, userName,
record.songId, record.songId,
@@ -827,7 +945,10 @@ export class D1Storage implements IStorage {
} }
} }
async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise<void> { async batchUpsertMusicV2History(
userName: string,
records: MusicV2HistoryRecord[]
): Promise<void> {
for (const record of records) { for (const record of records) {
await this.upsertMusicV2History(userName, record); await this.upsertMusicV2History(userName, record);
} }
@@ -835,7 +956,9 @@ export class D1Storage implements IStorage {
async deleteMusicV2History(userName: string, songId: string): Promise<void> { async deleteMusicV2History(userName: string, songId: string): Promise<void> {
await this.db await this.db
.prepare('DELETE FROM music_v2_history WHERE username = ? AND song_id = ?') .prepare(
'DELETE FROM music_v2_history WHERE username = ? AND song_id = ?'
)
.bind(userName, songId) .bind(userName, songId)
.run(); .run();
} }
@@ -849,23 +972,39 @@ export class D1Storage implements IStorage {
// ==================== Music V2 歌单相关 ==================== // ==================== Music V2 歌单相关 ====================
async createMusicV2Playlist(userName: string, playlist: { async createMusicV2Playlist(
id: string; userName: string,
name: string; playlist: {
description?: string; id: string;
cover?: string; name: string;
}): Promise<void> { description?: string;
cover?: string;
}
): Promise<void> {
const now = Date.now(); const now = Date.now();
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO music_v2_playlists (id, username, name, description, cover, song_count, created_at, updated_at) INSERT INTO music_v2_playlists (id, username, name, description, cover, song_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`) `
.bind(playlist.id, userName, playlist.name, playlist.description || null, playlist.cover || null, 0, now, now) )
.bind(
playlist.id,
userName,
playlist.name,
playlist.description || null,
playlist.cover || null,
0,
now,
now
)
.run(); .run();
} }
async getMusicV2Playlist(playlistId: string): Promise<MusicV2PlaylistRecord | null> { async getMusicV2Playlist(
playlistId: string
): Promise<MusicV2PlaylistRecord | null> {
const row: any = await this.db const row: any = await this.db
.prepare('SELECT * FROM music_v2_playlists WHERE id = ?') .prepare('SELECT * FROM music_v2_playlists WHERE id = ?')
.bind(playlistId) .bind(playlistId)
@@ -885,9 +1024,13 @@ export class D1Storage implements IStorage {
}; };
} }
async listMusicV2Playlists(userName: string): Promise<MusicV2PlaylistRecord[]> { async listMusicV2Playlists(
userName: string
): Promise<MusicV2PlaylistRecord[]> {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM music_v2_playlists WHERE username = ? ORDER BY updated_at DESC') .prepare(
'SELECT * FROM music_v2_playlists WHERE username = ? ORDER BY updated_at DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -905,12 +1048,15 @@ export class D1Storage implements IStorage {
})); }));
} }
async updateMusicV2Playlist(playlistId: string, updates: { async updateMusicV2Playlist(
name?: string; playlistId: string,
description?: string; updates: {
cover?: string; name?: string;
song_count?: number; description?: string;
}): Promise<void> { cover?: string;
song_count?: number;
}
): Promise<void> {
const fields: string[] = []; const fields: string[] = [];
const values: any[] = []; const values: any[] = [];
@@ -936,7 +1082,9 @@ export class D1Storage implements IStorage {
values.push(playlistId); values.push(playlistId);
await this.db await this.db
.prepare(`UPDATE music_v2_playlists SET ${fields.join(', ')} WHERE id = ?`) .prepare(
`UPDATE music_v2_playlists SET ${fields.join(', ')} WHERE id = ?`
)
.bind(...values) .bind(...values)
.run(); .run();
} }
@@ -948,21 +1096,30 @@ export class D1Storage implements IStorage {
.run(); .run();
} }
async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise<void> { async addMusicV2PlaylistItem(
playlistId: string,
item: MusicV2PlaylistItem
): Promise<void> {
const playlist = await this.getMusicV2Playlist(playlistId); const playlist = await this.getMusicV2Playlist(playlistId);
if (!playlist) { if (!playlist) {
throw new Error('歌单不存在'); throw new Error('歌单不存在');
} }
const maxOrder: any = await this.db const maxOrder: any = await this.db
.prepare('SELECT MAX(sort_order) as max_order FROM music_v2_playlist_items WHERE playlist_id = ?') .prepare(
'SELECT MAX(sort_order) as max_order FROM music_v2_playlist_items WHERE playlist_id = ?'
)
.bind(playlistId) .bind(playlistId)
.first(); .first();
const nextOrder = Math.max(item.sortOrder || 0, (maxOrder?.max_order as number || 0) + 1); const nextOrder = Math.max(
item.sortOrder || 0,
((maxOrder?.max_order as number) || 0) + 1
);
const now = Date.now(); const now = Date.now();
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO music_v2_playlist_items ( INSERT INTO music_v2_playlist_items (
playlist_id, username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec, sort_order, added_at, updated_at playlist_id, username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec, sort_order, added_at, updated_at
) )
@@ -977,7 +1134,8 @@ export class D1Storage implements IStorage {
duration_text = excluded.duration_text, duration_text = excluded.duration_text,
duration_sec = excluded.duration_sec, duration_sec = excluded.duration_sec,
updated_at = excluded.updated_at updated_at = excluded.updated_at
`) `
)
.bind( .bind(
playlistId, playlistId,
playlist.username, playlist.username,
@@ -1003,9 +1161,14 @@ export class D1Storage implements IStorage {
}); });
} }
async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise<void> { async removeMusicV2PlaylistItem(
playlistId: string,
songId: string
): Promise<void> {
await this.db await this.db
.prepare('DELETE FROM music_v2_playlist_items WHERE playlist_id = ? AND song_id = ?') .prepare(
'DELETE FROM music_v2_playlist_items WHERE playlist_id = ? AND song_id = ?'
)
.bind(playlistId, songId) .bind(playlistId, songId)
.run(); .run();
@@ -1016,9 +1179,13 @@ export class D1Storage implements IStorage {
}); });
} }
async listMusicV2PlaylistItems(playlistId: string): Promise<MusicV2PlaylistItem[]> { async listMusicV2PlaylistItems(
playlistId: string
): Promise<MusicV2PlaylistItem[]> {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM music_v2_playlist_items WHERE playlist_id = ? ORDER BY sort_order ASC, added_at ASC') .prepare(
'SELECT * FROM music_v2_playlist_items WHERE playlist_id = ? ORDER BY sort_order ASC, added_at ASC'
)
.bind(playlistId) .bind(playlistId)
.all(); .all();
@@ -1041,9 +1208,14 @@ export class D1Storage implements IStorage {
})); }));
} }
async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise<boolean> { async hasMusicV2PlaylistItem(
playlistId: string,
songId: string
): Promise<boolean> {
const row = await this.db const row = await this.db
.prepare('SELECT 1 FROM music_v2_playlist_items WHERE playlist_id = ? AND song_id = ? LIMIT 1') .prepare(
'SELECT 1 FROM music_v2_playlist_items WHERE playlist_id = ? AND song_id = ? LIMIT 1'
)
.bind(playlistId, songId) .bind(playlistId, songId)
.first(); .first();
return row !== null; return row !== null;
@@ -1096,12 +1268,17 @@ export class D1Storage implements IStorage {
async verifyUser(userName: string, password: string): Promise<boolean> { async verifyUser(userName: string, password: string): Promise<boolean> {
try { try {
// 检查是否是环境变量中的管理员 // 检查是否是环境变量中的管理员
if (userName === process.env.USERNAME && password === process.env.PASSWORD) { if (
userName === process.env.USERNAME &&
password === process.env.PASSWORD
) {
return true; return true;
} }
const user = await this.db const user = await this.db
.prepare('SELECT password_hash FROM users WHERE username = ? AND banned = 0') .prepare(
'SELECT password_hash FROM users WHERE username = ? AND banned = 0'
)
.bind(userName) .bind(userName)
.first(); .first();
@@ -1196,12 +1373,16 @@ export class D1Storage implements IStorage {
banned: user.banned === 1, banned: user.banned === 1,
tags: user.tags ? JSON.parse(user.tags as string) : undefined, tags: user.tags ? JSON.parse(user.tags as string) : undefined,
oidcSub: user.oidc_sub as string | undefined, oidcSub: user.oidc_sub as string | undefined,
enabledApis: user.enabled_apis ? JSON.parse(user.enabled_apis as string) : undefined, enabledApis: user.enabled_apis
? JSON.parse(user.enabled_apis as string)
: undefined,
created_at: user.created_at as number, created_at: user.created_at as number,
playrecord_migrated: user.playrecord_migrated === 1, playrecord_migrated: user.playrecord_migrated === 1,
favorite_migrated: user.favorite_migrated === 1, favorite_migrated: user.favorite_migrated === 1,
skip_migrated: user.skip_migrated === 1, skip_migrated: user.skip_migrated === 1,
last_movie_request_time: user.last_movie_request_time as number | undefined, last_movie_request_time: user.last_movie_request_time as
| number
| undefined,
email: user.email as string | undefined, email: user.email as string | undefined,
emailNotifications: user.email_notifications === 1, emailNotifications: user.email_notifications === 1,
}; };
@@ -1232,13 +1413,15 @@ export class D1Storage implements IStorage {
// 为站长创建数据库记录 // 为站长创建数据库记录
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO users ( INSERT INTO users (
username, password_hash, role, banned, created_at, username, password_hash, role, banned, created_at,
playrecord_migrated, favorite_migrated, skip_migrated playrecord_migrated, favorite_migrated, skip_migrated
) )
VALUES (?, ?, ?, 0, ?, 1, 1, 1) VALUES (?, ?, ?, 0, ?, 1, 1, 1)
`) `
)
.bind( .bind(
userName, userName,
'', // 站长不需要密码哈希 '', // 站长不需要密码哈希
@@ -1276,14 +1459,16 @@ export class D1Storage implements IStorage {
const passwordHash = await this.hashPassword(password); const passwordHash = await this.hashPassword(password);
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO users ( INSERT INTO users (
username, password_hash, role, banned, tags, oidc_sub, username, password_hash, role, banned, tags, oidc_sub,
enabled_apis, created_at, playrecord_migrated, enabled_apis, created_at, playrecord_migrated,
favorite_migrated, skip_migrated favorite_migrated, skip_migrated
) )
VALUES (?, ?, ?, 0, ?, ?, ?, ?, 1, 1, 1) VALUES (?, ?, ?, 0, ?, ?, ?, ?, 1, 1, 1)
`) `
)
.bind( .bind(
userName, userName,
passwordHash, passwordHash,
@@ -1327,7 +1512,9 @@ export class D1Storage implements IStorage {
// 获取总数 // 获取总数
const countQuery = trimmedSearch const countQuery = trimmedSearch
? this.db ? this.db
.prepare('SELECT COUNT(*) as total FROM users WHERE username LIKE ?') .prepare(
'SELECT COUNT(*) as total FROM users WHERE username LIKE ?'
)
.bind(searchPattern) .bind(searchPattern)
: this.db.prepare('SELECT COUNT(*) as total FROM users'); : this.db.prepare('SELECT COUNT(*) as total FROM users');
const countResult = await countQuery.first(); const countResult = await countQuery.first();
@@ -1370,21 +1557,25 @@ export class D1Storage implements IStorage {
// 获取用户列表(按创建时间降序) // 获取用户列表(按创建时间降序)
const listQuery = trimmedSearch const listQuery = trimmedSearch
? this.db ? this.db
.prepare(` .prepare(
`
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
FROM users FROM users
WHERE username LIKE ? WHERE username LIKE ?
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`) `
)
.bind(searchPattern, actualLimit, actualOffset) .bind(searchPattern, actualLimit, actualOffset)
: this.db : this.db
.prepare(` .prepare(
`
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
FROM users FROM users
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`) `
)
.bind(actualLimit, actualOffset); .bind(actualLimit, actualOffset);
const result = await listQuery.all(); const result = await listQuery.all();
@@ -1421,7 +1612,9 @@ export class D1Storage implements IStorage {
banned: user.banned === 1, banned: user.banned === 1,
tags: user.tags ? JSON.parse(user.tags as string) : undefined, tags: user.tags ? JSON.parse(user.tags as string) : undefined,
oidcSub: user.oidc_sub as string | undefined, oidcSub: user.oidc_sub as string | undefined,
enabledApis: user.enabled_apis ? JSON.parse(user.enabled_apis as string) : undefined, enabledApis: user.enabled_apis
? JSON.parse(user.enabled_apis as string)
: undefined,
created_at: user.created_at as number, created_at: user.created_at as number,
}); });
} }
@@ -1568,10 +1761,12 @@ export class D1Storage implements IStorage {
try { try {
// SQLite 不支持 JSON 查询,需要使用 LIKE // SQLite 不支持 JSON 查询,需要使用 LIKE
const result = await this.db const result = await this.db
.prepare(` .prepare(
`
SELECT username FROM users SELECT username FROM users
WHERE tags LIKE ? WHERE tags LIKE ?
`) `
)
.bind(`%"${tagName}"%`) .bind(`%"${tagName}"%`)
.all(); .all();
@@ -1600,7 +1795,10 @@ export class D1Storage implements IStorage {
} }
// 直接设置用户密码哈希(用于数据导入,不进行二次哈希) // 直接设置用户密码哈希(用于数据导入,不进行二次哈希)
async setUserPasswordHash(userName: string, passwordHash: string): Promise<void> { async setUserPasswordHash(
userName: string,
passwordHash: string
): Promise<void> {
try { try {
await this.db await this.db
.prepare('UPDATE users SET password_hash = ? WHERE username = ?') .prepare('UPDATE users SET password_hash = ? WHERE username = ?')
@@ -1625,14 +1823,16 @@ export class D1Storage implements IStorage {
): Promise<void> { ): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO users ( INSERT INTO users (
username, password_hash, role, banned, tags, oidc_sub, username, password_hash, role, banned, tags, oidc_sub,
enabled_apis, created_at, playrecord_migrated, enabled_apis, created_at, playrecord_migrated,
favorite_migrated, skip_migrated favorite_migrated, skip_migrated
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1, 1) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1, 1)
`) `
)
.bind( .bind(
userName, userName,
passwordHash, passwordHash,
@@ -1693,7 +1893,10 @@ export class D1Storage implements IStorage {
} }
} }
async setEmailNotificationPreference?(userName: string, enabled: boolean): Promise<void> { async setEmailNotificationPreference?(
userName: string,
enabled: boolean
): Promise<void> {
try { try {
await this.db await this.db
.prepare('UPDATE users SET email_notifications = ? WHERE username = ?') .prepare('UPDATE users SET email_notifications = ? WHERE username = ?')
@@ -1724,10 +1927,15 @@ export class D1Storage implements IStorage {
} }
} }
async setTvboxSubscribeToken?(userName: string, token: string): Promise<void> { async setTvboxSubscribeToken?(
userName: string,
token: string
): Promise<void> {
try { try {
await this.db await this.db
.prepare('UPDATE users SET tvbox_subscribe_token = ? WHERE username = ?') .prepare(
'UPDATE users SET tvbox_subscribe_token = ? WHERE username = ?'
)
.bind(token, userName) .bind(token, userName)
.run(); .run();
@@ -1758,7 +1966,9 @@ export class D1Storage implements IStorage {
async getSearchHistory(userName: string): Promise<string[]> { async getSearchHistory(userName: string): Promise<string[]> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT keyword FROM search_history WHERE username = ? ORDER BY timestamp DESC LIMIT 20') .prepare(
'SELECT keyword FROM search_history WHERE username = ? ORDER BY timestamp DESC LIMIT 20'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -1776,24 +1986,29 @@ export class D1Storage implements IStorage {
// 插入或更新时间戳 // 插入或更新时间戳
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO search_history (username, keyword, timestamp) INSERT INTO search_history (username, keyword, timestamp)
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(username, keyword) DO UPDATE SET timestamp = excluded.timestamp ON CONFLICT(username, keyword) DO UPDATE SET timestamp = excluded.timestamp
`) `
)
.bind(userName, keyword, timestamp) .bind(userName, keyword, timestamp)
.run(); .run();
// 保持最多 20 条记录 // 保持最多 20 条记录
const countResult = await this.db const countResult = await this.db
.prepare('SELECT COUNT(*) as count FROM search_history WHERE username = ?') .prepare(
'SELECT COUNT(*) as count FROM search_history WHERE username = ?'
)
.bind(userName) .bind(userName)
.first(); .first();
const count = (countResult?.count as number) || 0; const count = (countResult?.count as number) || 0;
if (count > 20) { if (count > 20) {
await this.db await this.db
.prepare(` .prepare(
`
DELETE FROM search_history DELETE FROM search_history
WHERE username = ? WHERE username = ?
AND id NOT IN ( AND id NOT IN (
@@ -1802,7 +2017,8 @@ export class D1Storage implements IStorage {
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT 20 LIMIT 20
) )
`) `
)
.bind(userName, userName) .bind(userName, userName)
.run(); .run();
} }
@@ -1816,7 +2032,9 @@ export class D1Storage implements IStorage {
try { try {
if (keyword) { if (keyword) {
await this.db await this.db
.prepare('DELETE FROM search_history WHERE username = ? AND keyword = ?') .prepare(
'DELETE FROM search_history WHERE username = ? AND keyword = ?'
)
.bind(userName, keyword) .bind(userName, keyword)
.run(); .run();
} else { } else {
@@ -1833,7 +2051,10 @@ export class D1Storage implements IStorage {
// ==================== 漫画书架 ==================== // ==================== 漫画书架 ====================
async getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null> { async getMangaShelf(
userName: string,
key: string
): Promise<MangaShelfItem | null> {
try { try {
await this.schemaReady; await this.schemaReady;
const result = await this.db const result = await this.db
@@ -1857,11 +2078,13 @@ export class D1Storage implements IStorage {
latestChapterId: (result.latest_chapter_id as string) || undefined, latestChapterId: (result.latest_chapter_id as string) || undefined,
latestChapterName: (result.latest_chapter_name as string) || undefined, latestChapterName: (result.latest_chapter_name as string) || undefined,
latestChapterCount: latestChapterCount:
result.latest_chapter_count === null || result.latest_chapter_count === undefined result.latest_chapter_count === null ||
result.latest_chapter_count === undefined
? undefined ? undefined
: Number(result.latest_chapter_count), : Number(result.latest_chapter_count),
unreadChapterCount: unreadChapterCount:
result.unread_chapter_count === null || result.unread_chapter_count === undefined result.unread_chapter_count === null ||
result.unread_chapter_count === undefined
? undefined ? undefined
: Number(result.unread_chapter_count), : Number(result.unread_chapter_count),
}; };
@@ -1871,11 +2094,16 @@ export class D1Storage implements IStorage {
} }
} }
async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void> { async setMangaShelf(
userName: string,
key: string,
item: MangaShelfItem
): Promise<void> {
try { try {
await this.schemaReady; await this.schemaReady;
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO manga_shelf ( INSERT INTO manga_shelf (
username, key, source_id, source_name, manga_id, title, cover, save_time, username, key, source_id, source_name, manga_id, title, cover, save_time,
description, author, status, last_chapter_id, last_chapter_name, description, author, status, last_chapter_id, last_chapter_name,
@@ -1898,7 +2126,8 @@ export class D1Storage implements IStorage {
latest_chapter_name = excluded.latest_chapter_name, latest_chapter_name = excluded.latest_chapter_name,
latest_chapter_count = excluded.latest_chapter_count, latest_chapter_count = excluded.latest_chapter_count,
unread_chapter_count = excluded.unread_chapter_count unread_chapter_count = excluded.unread_chapter_count
`) `
)
.bind( .bind(
userName, userName,
key, key,
@@ -1925,11 +2154,15 @@ export class D1Storage implements IStorage {
} }
} }
async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> { async getAllMangaShelf(
userName: string
): Promise<{ [key: string]: MangaShelfItem }> {
try { try {
await this.schemaReady; await this.schemaReady;
const results = await this.db const results = await this.db
.prepare('SELECT * FROM manga_shelf WHERE username = ? ORDER BY save_time DESC') .prepare(
'SELECT * FROM manga_shelf WHERE username = ? ORDER BY save_time DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -1952,11 +2185,13 @@ export class D1Storage implements IStorage {
latestChapterId: (row.latest_chapter_id as string) || undefined, latestChapterId: (row.latest_chapter_id as string) || undefined,
latestChapterName: (row.latest_chapter_name as string) || undefined, latestChapterName: (row.latest_chapter_name as string) || undefined,
latestChapterCount: latestChapterCount:
row.latest_chapter_count === null || row.latest_chapter_count === undefined row.latest_chapter_count === null ||
row.latest_chapter_count === undefined
? undefined ? undefined
: Number(row.latest_chapter_count), : Number(row.latest_chapter_count),
unreadChapterCount: unreadChapterCount:
row.unread_chapter_count === null || row.unread_chapter_count === undefined row.unread_chapter_count === null ||
row.unread_chapter_count === undefined
? undefined ? undefined
: Number(row.unread_chapter_count), : Number(row.unread_chapter_count),
}; };
@@ -1983,10 +2218,15 @@ export class D1Storage implements IStorage {
// ==================== 漫画阅读历史 ==================== // ==================== 漫画阅读历史 ====================
async getMangaReadRecord(userName: string, key: string): Promise<MangaReadRecord | null> { async getMangaReadRecord(
userName: string,
key: string
): Promise<MangaReadRecord | null> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT * FROM manga_read_records WHERE username = ? AND key = ?') .prepare(
'SELECT * FROM manga_read_records WHERE username = ? AND key = ?'
)
.bind(userName, key) .bind(userName, key)
.first(); .first();
@@ -2009,10 +2249,15 @@ export class D1Storage implements IStorage {
} }
} }
async setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise<void> { async setMangaReadRecord(
userName: string,
key: string,
record: MangaReadRecord
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO manga_read_records ( INSERT INTO manga_read_records (
username, key, source_id, source_name, manga_id, title, cover, username, key, source_id, source_name, manga_id, title, cover,
chapter_id, chapter_name, page_index, page_count, save_time chapter_id, chapter_name, page_index, page_count, save_time
@@ -2029,7 +2274,8 @@ export class D1Storage implements IStorage {
page_index = excluded.page_index, page_index = excluded.page_index,
page_count = excluded.page_count, page_count = excluded.page_count,
save_time = excluded.save_time save_time = excluded.save_time
`) `
)
.bind( .bind(
userName, userName,
key, key,
@@ -2051,10 +2297,14 @@ export class D1Storage implements IStorage {
} }
} }
async getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }> { async getAllMangaReadRecords(
userName: string
): Promise<{ [key: string]: MangaReadRecord }> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM manga_read_records WHERE username = ? ORDER BY save_time DESC') .prepare(
'SELECT * FROM manga_read_records WHERE username = ? ORDER BY save_time DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -2086,7 +2336,9 @@ export class D1Storage implements IStorage {
async deleteMangaReadRecord(userName: string, key: string): Promise<void> { async deleteMangaReadRecord(userName: string, key: string): Promise<void> {
try { try {
await this.db await this.db
.prepare('DELETE FROM manga_read_records WHERE username = ? AND key = ?') .prepare(
'DELETE FROM manga_read_records WHERE username = ? AND key = ?'
)
.bind(userName, key) .bind(userName, key)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2097,10 +2349,15 @@ export class D1Storage implements IStorage {
async cleanupOldMangaReadRecords(userName: string): Promise<void> { async cleanupOldMangaReadRecords(userName: string): Promise<void> {
try { try {
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; const threshold = maxRecords + 10;
const countResult = await this.db const countResult = await this.db
.prepare('SELECT COUNT(*) as count FROM manga_read_records WHERE username = ?') .prepare(
'SELECT COUNT(*) as count FROM manga_read_records WHERE username = ?'
)
.bind(userName) .bind(userName)
.first(); .first();
@@ -2108,7 +2365,8 @@ export class D1Storage implements IStorage {
if (count <= threshold) return; if (count <= threshold) return;
await this.db await this.db
.prepare(` .prepare(
`
DELETE FROM manga_read_records DELETE FROM manga_read_records
WHERE username = ? WHERE username = ?
AND key NOT IN ( AND key NOT IN (
@@ -2117,7 +2375,8 @@ export class D1Storage implements IStorage {
ORDER BY save_time DESC ORDER BY save_time DESC
LIMIT ? LIMIT ?
) )
`) `
)
.bind(userName, userName, maxRecords) .bind(userName, userName, maxRecords)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2126,12 +2385,17 @@ export class D1Storage implements IStorage {
} }
} }
// ==================== 电子书书架 ==================== // ==================== 电子书书架 ====================
async getBookShelf(userName: string, key: string): Promise<BookShelfItem | null> { async getBookShelf(
userName: string,
key: string
): Promise<BookShelfItem | null> {
try { try {
const result = await this.db.prepare('SELECT * FROM book_shelf WHERE username = ? AND key = ?').bind(userName, key).first(); const result = await this.db
.prepare('SELECT * FROM book_shelf WHERE username = ? AND key = ?')
.bind(userName, key)
.first();
if (!result) return null; if (!result) return null;
return { return {
sourceId: result.source_id as string, sourceId: result.source_id as string,
@@ -2143,9 +2407,18 @@ export class D1Storage implements IStorage {
format: (result.format as 'epub' | 'pdf' | null) || undefined, format: (result.format as 'epub' | 'pdf' | null) || undefined,
detailHref: (result.detail_href as string) || undefined, detailHref: (result.detail_href as string) || undefined,
acquisitionHref: (result.acquisition_href as string) || undefined, acquisitionHref: (result.acquisition_href as string) || undefined,
progressPercent: result.progress_percent === null || result.progress_percent === undefined ? undefined : Number(result.progress_percent), progressPercent:
lastReadTime: result.last_read_time === null || result.last_read_time === undefined ? undefined : Number(result.last_read_time), result.progress_percent === null ||
lastLocatorType: (result.last_locator_type as BookShelfItem['lastLocatorType']) || undefined, result.progress_percent === undefined
? undefined
: Number(result.progress_percent),
lastReadTime:
result.last_read_time === null || result.last_read_time === undefined
? undefined
: Number(result.last_read_time),
lastLocatorType:
(result.last_locator_type as BookShelfItem['lastLocatorType']) ||
undefined,
lastLocatorValue: (result.last_locator_value as string) || undefined, lastLocatorValue: (result.last_locator_value as string) || undefined,
lastChapterTitle: (result.last_chapter_title as string) || undefined, lastChapterTitle: (result.last_chapter_title as string) || undefined,
saveTime: Number(result.save_time || 0), saveTime: Number(result.save_time || 0),
@@ -2156,9 +2429,15 @@ export class D1Storage implements IStorage {
} }
} }
async setBookShelf(userName: string, key: string, item: BookShelfItem): Promise<void> { async setBookShelf(
userName: string,
key: string,
item: BookShelfItem
): Promise<void> {
try { try {
await this.db.prepare(` await this.db
.prepare(
`
INSERT INTO book_shelf ( INSERT INTO book_shelf (
username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href, username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href,
progress_percent, last_read_time, last_locator_type, last_locator_value, last_chapter_title, save_time progress_percent, last_read_time, last_locator_type, last_locator_value, last_chapter_title, save_time
@@ -2180,21 +2459,44 @@ export class D1Storage implements IStorage {
last_locator_value = excluded.last_locator_value, last_locator_value = excluded.last_locator_value,
last_chapter_title = excluded.last_chapter_title, last_chapter_title = excluded.last_chapter_title,
save_time = excluded.save_time save_time = excluded.save_time
`).bind( `
userName, key, item.sourceId, item.sourceName, item.bookId, item.title, item.author || null, )
item.cover || null, item.format || null, item.detailHref || null, item.acquisitionHref || null, item.progressPercent ?? null, .bind(
item.lastReadTime ?? null, item.lastLocatorType || null, item.lastLocatorValue || null, userName,
item.lastChapterTitle || null, item.saveTime key,
).run(); item.sourceId,
item.sourceName,
item.bookId,
item.title,
item.author || null,
item.cover || null,
item.format || null,
item.detailHref || null,
item.acquisitionHref || null,
item.progressPercent ?? null,
item.lastReadTime ?? null,
item.lastLocatorType || null,
item.lastLocatorValue || null,
item.lastChapterTitle || null,
item.saveTime
)
.run();
} catch (err) { } catch (err) {
console.error('D1Storage.setBookShelf error:', err); console.error('D1Storage.setBookShelf error:', err);
throw err; throw err;
} }
} }
async getAllBookShelf(userName: string): Promise<{ [key: string]: BookShelfItem }> { async getAllBookShelf(
userName: string
): Promise<{ [key: string]: BookShelfItem }> {
try { try {
const results = await this.db.prepare('SELECT * FROM book_shelf WHERE username = ? ORDER BY COALESCE(last_read_time, save_time) DESC').bind(userName).all(); const results = await this.db
.prepare(
'SELECT * FROM book_shelf WHERE username = ? ORDER BY COALESCE(last_read_time, save_time) DESC'
)
.bind(userName)
.all();
const shelves: { [key: string]: BookShelfItem } = {}; const shelves: { [key: string]: BookShelfItem } = {};
if (!results.results) return shelves; if (!results.results) return shelves;
for (const row of results.results) { for (const row of results.results) {
@@ -2208,9 +2510,17 @@ export class D1Storage implements IStorage {
format: (row.format as 'epub' | 'pdf' | null) || undefined, format: (row.format as 'epub' | 'pdf' | null) || undefined,
detailHref: (row.detail_href as string) || undefined, detailHref: (row.detail_href as string) || undefined,
acquisitionHref: (row.acquisition_href as string) || undefined, acquisitionHref: (row.acquisition_href as string) || undefined,
progressPercent: row.progress_percent === null || row.progress_percent === undefined ? undefined : Number(row.progress_percent), progressPercent:
lastReadTime: row.last_read_time === null || row.last_read_time === undefined ? undefined : Number(row.last_read_time), row.progress_percent === null || row.progress_percent === undefined
lastLocatorType: (row.last_locator_type as BookShelfItem['lastLocatorType']) || undefined, ? undefined
: Number(row.progress_percent),
lastReadTime:
row.last_read_time === null || row.last_read_time === undefined
? undefined
: Number(row.last_read_time),
lastLocatorType:
(row.last_locator_type as BookShelfItem['lastLocatorType']) ||
undefined,
lastLocatorValue: (row.last_locator_value as string) || undefined, lastLocatorValue: (row.last_locator_value as string) || undefined,
lastChapterTitle: (row.last_chapter_title as string) || undefined, lastChapterTitle: (row.last_chapter_title as string) || undefined,
saveTime: Number(row.save_time || 0), saveTime: Number(row.save_time || 0),
@@ -2225,7 +2535,10 @@ export class D1Storage implements IStorage {
async deleteBookShelf(userName: string, key: string): Promise<void> { async deleteBookShelf(userName: string, key: string): Promise<void> {
try { try {
await this.db.prepare('DELETE FROM book_shelf WHERE username = ? AND key = ?').bind(userName, key).run(); await this.db
.prepare('DELETE FROM book_shelf WHERE username = ? AND key = ?')
.bind(userName, key)
.run();
} catch (err) { } catch (err) {
console.error('D1Storage.deleteBookShelf error:', err); console.error('D1Storage.deleteBookShelf error:', err);
throw err; throw err;
@@ -2234,9 +2547,17 @@ export class D1Storage implements IStorage {
// ==================== 电子书阅读历史 ==================== // ==================== 电子书阅读历史 ====================
async getBookReadRecord(userName: string, key: string): Promise<BookReadRecord | null> { async getBookReadRecord(
userName: string,
key: string
): Promise<BookReadRecord | null> {
try { try {
const result = await this.db.prepare('SELECT * FROM book_read_records WHERE username = ? AND key = ?').bind(userName, key).first(); const result = await this.db
.prepare(
'SELECT * FROM book_read_records WHERE username = ? AND key = ?'
)
.bind(userName, key)
.first();
if (!result) return null; if (!result) return null;
return { return {
sourceId: result.source_id as string, sourceId: result.source_id as string,
@@ -2265,9 +2586,15 @@ export class D1Storage implements IStorage {
} }
} }
async setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise<void> { async setBookReadRecord(
userName: string,
key: string,
record: BookReadRecord
): Promise<void> {
try { try {
await this.db.prepare(` await this.db
.prepare(
`
INSERT INTO book_read_records ( INSERT INTO book_read_records (
username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href, username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href,
locator_type, locator_value, chapter_title, chapter_href, progress_percent, save_time locator_type, locator_value, chapter_title, chapter_href, progress_percent, save_time
@@ -2289,21 +2616,44 @@ export class D1Storage implements IStorage {
chapter_href = excluded.chapter_href, chapter_href = excluded.chapter_href,
progress_percent = excluded.progress_percent, progress_percent = excluded.progress_percent,
save_time = excluded.save_time save_time = excluded.save_time
`).bind( `
userName, key, record.sourceId, record.sourceName, record.bookId, record.title, record.author || null, )
record.cover || null, record.format, record.detailHref || null, record.acquisitionHref || null, record.locator.type, record.locator.value, .bind(
record.chapterTitle || record.locator.chapterTitle || null, record.chapterHref || record.locator.href || null, userName,
record.progressPercent, record.saveTime key,
).run(); record.sourceId,
record.sourceName,
record.bookId,
record.title,
record.author || null,
record.cover || null,
record.format,
record.detailHref || null,
record.acquisitionHref || null,
record.locator.type,
record.locator.value,
record.chapterTitle || record.locator.chapterTitle || null,
record.chapterHref || record.locator.href || null,
record.progressPercent,
record.saveTime
)
.run();
} catch (err) { } catch (err) {
console.error('D1Storage.setBookReadRecord error:', err); console.error('D1Storage.setBookReadRecord error:', err);
throw err; throw err;
} }
} }
async getAllBookReadRecords(userName: string): Promise<{ [key: string]: BookReadRecord }> { async getAllBookReadRecords(
userName: string
): Promise<{ [key: string]: BookReadRecord }> {
try { try {
const results = await this.db.prepare('SELECT * FROM book_read_records WHERE username = ? ORDER BY save_time DESC').bind(userName).all(); const results = await this.db
.prepare(
'SELECT * FROM book_read_records WHERE username = ? ORDER BY save_time DESC'
)
.bind(userName)
.all();
const records: { [key: string]: BookReadRecord } = {}; const records: { [key: string]: BookReadRecord } = {};
if (!results.results) return records; if (!results.results) return records;
for (const row of results.results) { for (const row of results.results) {
@@ -2338,7 +2688,10 @@ export class D1Storage implements IStorage {
async deleteBookReadRecord(userName: string, key: string): Promise<void> { async deleteBookReadRecord(userName: string, key: string): Promise<void> {
try { try {
await this.db.prepare('DELETE FROM book_read_records WHERE username = ? AND key = ?').bind(userName, key).run(); await this.db
.prepare('DELETE FROM book_read_records WHERE username = ? AND key = ?')
.bind(userName, key)
.run();
} catch (err) { } catch (err) {
console.error('D1Storage.deleteBookReadRecord error:', err); console.error('D1Storage.deleteBookReadRecord error:', err);
throw err; throw err;
@@ -2347,12 +2700,22 @@ export class D1Storage implements IStorage {
async cleanupOldBookReadRecords(userName: string): Promise<void> { async cleanupOldBookReadRecords(userName: string): Promise<void> {
try { try {
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; const threshold = maxRecords + 10;
const countResult = await this.db.prepare('SELECT COUNT(*) as count FROM book_read_records WHERE username = ?').bind(userName).first(); const countResult = await this.db
.prepare(
'SELECT COUNT(*) as count FROM book_read_records WHERE username = ?'
)
.bind(userName)
.first();
const count = Number(countResult?.count || 0); const count = Number(countResult?.count || 0);
if (count <= threshold) return; if (count <= threshold) return;
await this.db.prepare(` await this.db
.prepare(
`
DELETE FROM book_read_records DELETE FROM book_read_records
WHERE username = ? WHERE username = ?
AND key NOT IN ( AND key NOT IN (
@@ -2361,7 +2724,10 @@ export class D1Storage implements IStorage {
ORDER BY save_time DESC ORDER BY save_time DESC
LIMIT ? LIMIT ?
) )
`).bind(userName, userName, maxRecords).run(); `
)
.bind(userName, userName, maxRecords)
.run();
} catch (err) { } catch (err) {
console.error('D1Storage.cleanupOldBookReadRecords error:', err); console.error('D1Storage.cleanupOldBookReadRecords error:', err);
throw err; throw err;
@@ -2370,7 +2736,11 @@ export class D1Storage implements IStorage {
// ==================== 跳过配置 ==================== // ==================== 跳过配置 ====================
async getSkipConfig(userName: string, source: string, id: string): Promise<SkipConfig | null> { async getSkipConfig(
userName: string,
source: string,
id: string
): Promise<SkipConfig | null> {
try { try {
const key = `${source}+${id}`; const key = `${source}+${id}`;
const result = await this.db const result = await this.db
@@ -2390,19 +2760,32 @@ export class D1Storage implements IStorage {
} }
} }
async setSkipConfig(userName: string, source: string, id: string, config: SkipConfig): Promise<void> { async setSkipConfig(
userName: string,
source: string,
id: string,
config: SkipConfig
): Promise<void> {
try { try {
const key = `${source}+${id}`; const key = `${source}+${id}`;
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO skip_configs (username, key, enable, intro_time, outro_time) INSERT INTO skip_configs (username, key, enable, intro_time, outro_time)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(username, key) DO UPDATE SET ON CONFLICT(username, key) DO UPDATE SET
enable = excluded.enable, enable = excluded.enable,
intro_time = excluded.intro_time, intro_time = excluded.intro_time,
outro_time = excluded.outro_time outro_time = excluded.outro_time
`) `
.bind(userName, key, config.enable ? 1 : 0, config.intro_time, config.outro_time) )
.bind(
userName,
key,
config.enable ? 1 : 0,
config.intro_time,
config.outro_time
)
.run(); .run();
} catch (err) { } catch (err) {
console.error('D1Storage.setSkipConfig error:', err); console.error('D1Storage.setSkipConfig error:', err);
@@ -2410,7 +2793,11 @@ export class D1Storage implements IStorage {
} }
} }
async deleteSkipConfig(userName: string, source: string, id: string): Promise<void> { async deleteSkipConfig(
userName: string,
source: string,
id: string
): Promise<void> {
try { try {
const key = `${source}+${id}`; const key = `${source}+${id}`;
await this.db await this.db
@@ -2423,7 +2810,9 @@ export class D1Storage implements IStorage {
} }
} }
async getAllSkipConfigs(userName: string): Promise<{ [key: string]: SkipConfig }> { async getAllSkipConfigs(
userName: string
): Promise<{ [key: string]: SkipConfig }> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM skip_configs WHERE username = ?') .prepare('SELECT * FROM skip_configs WHERE username = ?')
@@ -2463,7 +2852,9 @@ export class D1Storage implements IStorage {
// ==================== 弹幕过滤配置 ==================== // ==================== 弹幕过滤配置 ====================
async getDanmakuFilterConfig(userName: string): Promise<DanmakuFilterConfig | null> { async getDanmakuFilterConfig(
userName: string
): Promise<DanmakuFilterConfig | null> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT rules FROM danmaku_filter_configs WHERE username = ?') .prepare('SELECT rules FROM danmaku_filter_configs WHERE username = ?')
@@ -2478,14 +2869,19 @@ export class D1Storage implements IStorage {
} }
} }
async setDanmakuFilterConfig(userName: string, config: DanmakuFilterConfig): Promise<void> { async setDanmakuFilterConfig(
userName: string,
config: DanmakuFilterConfig
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO danmaku_filter_configs (username, rules) INSERT INTO danmaku_filter_configs (username, rules)
VALUES (?, ?) VALUES (?, ?)
ON CONFLICT(username) DO UPDATE SET rules = excluded.rules ON CONFLICT(username) DO UPDATE SET rules = excluded.rules
`) `
)
.bind(userName, JSON.stringify(config)) .bind(userName, JSON.stringify(config))
.run(); .run();
} catch (err) { } catch (err) {
@@ -2511,7 +2907,9 @@ export class D1Storage implements IStorage {
async getNotifications(userName: string): Promise<Notification[]> { async getNotifications(userName: string): Promise<Notification[]> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM notifications WHERE username = ? ORDER BY timestamp DESC') .prepare(
'SELECT * FROM notifications WHERE username = ? ORDER BY timestamp DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -2531,13 +2929,18 @@ export class D1Storage implements IStorage {
} }
} }
async addNotification(userName: string, notification: Notification): Promise<void> { async addNotification(
userName: string,
notification: Notification
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO notifications (id, username, type, title, message, timestamp, read, metadata) INSERT INTO notifications (id, username, type, title, message, timestamp, read, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`) `
)
.bind( .bind(
notification.id, notification.id,
userName, userName,
@@ -2555,10 +2958,15 @@ export class D1Storage implements IStorage {
} }
} }
async markNotificationAsRead(userName: string, notificationId: string): Promise<void> { async markNotificationAsRead(
userName: string,
notificationId: string
): Promise<void> {
try { try {
await this.db await this.db
.prepare('UPDATE notifications SET read = 1 WHERE username = ? AND id = ?') .prepare(
'UPDATE notifications SET read = 1 WHERE username = ? AND id = ?'
)
.bind(userName, notificationId) .bind(userName, notificationId)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2567,7 +2975,10 @@ export class D1Storage implements IStorage {
} }
} }
async deleteNotification(userName: string, notificationId: string): Promise<void> { async deleteNotification(
userName: string,
notificationId: string
): Promise<void> {
try { try {
await this.db await this.db
.prepare('DELETE FROM notifications WHERE username = ? AND id = ?') .prepare('DELETE FROM notifications WHERE username = ? AND id = ?')
@@ -2594,7 +3005,9 @@ export class D1Storage implements IStorage {
async getUnreadNotificationCount(userName: string): Promise<number> { async getUnreadNotificationCount(userName: string): Promise<number> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT COUNT(*) as count FROM notifications WHERE username = ? AND read = 0') .prepare(
'SELECT COUNT(*) as count FROM notifications WHERE username = ? AND read = 0'
)
.bind(userName) .bind(userName)
.first(); .first();
@@ -2639,14 +3052,16 @@ export class D1Storage implements IStorage {
async createMovieRequest(request: MovieRequest): Promise<void> { async createMovieRequest(request: MovieRequest): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO movie_requests ( INSERT INTO movie_requests (
id, tmdb_id, title, year, media_type, season, poster, overview, id, tmdb_id, title, year, media_type, season, poster, overview,
requested_by, request_count, status, created_at, updated_at, requested_by, request_count, status, created_at, updated_at,
fulfilled_at, fulfilled_source, fulfilled_id fulfilled_at, fulfilled_source, fulfilled_id
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`) `
)
.bind( .bind(
request.id, request.id,
request.tmdbId || null, request.tmdbId || null,
@@ -2672,7 +3087,10 @@ export class D1Storage implements IStorage {
} }
} }
async updateMovieRequest(requestId: string, updates: Partial<MovieRequest>): Promise<void> { async updateMovieRequest(
requestId: string,
updates: Partial<MovieRequest>
): Promise<void> {
try { try {
const fields: string[] = []; const fields: string[] = [];
const values: any[] = []; const values: any[] = [];
@@ -2732,7 +3150,9 @@ export class D1Storage implements IStorage {
async getUserMovieRequests(userName: string): Promise<string[]> { async getUserMovieRequests(userName: string): Promise<string[]> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT request_id FROM user_movie_requests WHERE username = ?') .prepare(
'SELECT request_id FROM user_movie_requests WHERE username = ?'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -2744,10 +3164,15 @@ export class D1Storage implements IStorage {
} }
} }
async addUserMovieRequest(userName: string, requestId: string): Promise<void> { async addUserMovieRequest(
userName: string,
requestId: string
): Promise<void> {
try { try {
await this.db await this.db
.prepare('INSERT OR IGNORE INTO user_movie_requests (username, request_id) VALUES (?, ?)') .prepare(
'INSERT OR IGNORE INTO user_movie_requests (username, request_id) VALUES (?, ?)'
)
.bind(userName, requestId) .bind(userName, requestId)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2756,10 +3181,15 @@ export class D1Storage implements IStorage {
} }
} }
async removeUserMovieRequest(userName: string, requestId: string): Promise<void> { async removeUserMovieRequest(
userName: string,
requestId: string
): Promise<void> {
try { try {
await this.db await this.db
.prepare('DELETE FROM user_movie_requests WHERE username = ? AND request_id = ?') .prepare(
'DELETE FROM user_movie_requests WHERE username = ? AND request_id = ?'
)
.bind(userName, requestId) .bind(userName, requestId)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2808,11 +3238,13 @@ export class D1Storage implements IStorage {
async setAdminConfig(config: AdminConfig): Promise<void> { async setAdminConfig(config: AdminConfig): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO admin_config (id, config, updated_at) INSERT INTO admin_config (id, config, updated_at)
VALUES (1, ?, ?) VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET config = excluded.config, updated_at = excluded.updated_at ON CONFLICT(id) DO UPDATE SET config = excluded.config, updated_at = excluded.updated_at
`) `
)
.bind(JSON.stringify(config), Date.now()) .bind(JSON.stringify(config), Date.now())
.run(); .run();
} catch (err) { } catch (err) {
@@ -2849,7 +3281,10 @@ export class D1Storage implements IStorage {
await this.db.prepare(`DELETE FROM ${table}`).run(); await this.db.prepare(`DELETE FROM ${table}`).run();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
if (message.includes('no such table') || message.includes('does not exist')) { if (
message.includes('no such table') ||
message.includes('does not exist')
) {
console.warn('D1Storage.clearAllData warning:', table, message); console.warn('D1Storage.clearAllData warning:', table, message);
continue; continue;
} }
@@ -2879,11 +3314,13 @@ export class D1Storage implements IStorage {
async setGlobalValue(key: string, value: string): Promise<void> { async setGlobalValue(key: string, value: string): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO global_config (key, value, updated_at) INSERT INTO global_config (key, value, updated_at)
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`) `
)
.bind(key, value, Date.now()) .bind(key, value, Date.now())
.run(); .run();
} catch (err) { } catch (err) {
@@ -2907,7 +3344,9 @@ export class D1Storage implements IStorage {
async getLastFavoriteCheckTime(userName: string): Promise<number> { async getLastFavoriteCheckTime(userName: string): Promise<number> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT last_check_time FROM favorite_check_times WHERE username = ?') .prepare(
'SELECT last_check_time FROM favorite_check_times WHERE username = ?'
)
.bind(userName) .bind(userName)
.first(); .first();
@@ -2918,14 +3357,19 @@ export class D1Storage implements IStorage {
} }
} }
async setLastFavoriteCheckTime(userName: string, timestamp: number): Promise<void> { async setLastFavoriteCheckTime(
userName: string,
timestamp: number
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO favorite_check_times (username, last_check_time) INSERT INTO favorite_check_times (username, last_check_time)
VALUES (?, ?) VALUES (?, ?)
ON CONFLICT(username) DO UPDATE SET last_check_time = excluded.last_check_time ON CONFLICT(username) DO UPDATE SET last_check_time = excluded.last_check_time
`) `
)
.bind(userName, timestamp) .bind(userName, timestamp)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2934,10 +3378,15 @@ export class D1Storage implements IStorage {
} }
} }
async updateLastMovieRequestTime(userName: string, timestamp: number): Promise<void> { async updateLastMovieRequestTime(
userName: string,
timestamp: number
): Promise<void> {
try { try {
await this.db await this.db
.prepare('UPDATE users SET last_movie_request_time = ? WHERE username = ?') .prepare(
'UPDATE users SET last_movie_request_time = ? WHERE username = ?'
)
.bind(timestamp, userName) .bind(timestamp, userName)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2965,11 +3414,13 @@ class RedisHashAdapter {
async hSet(hashKey: string, field: string, value: string): Promise<void> { async hSet(hashKey: string, field: string, value: string): Promise<void> {
const key = `${hashKey}:${field}`; const key = `${hashKey}:${field}`;
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO global_config (key, value, updated_at) INSERT INTO global_config (key, value, updated_at)
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`) `
)
.bind(key, value, Date.now()) .bind(key, value, Date.now())
.run(); .run();
} }
+233 -67
View File
@@ -17,7 +17,7 @@
import { getAuthInfoFromBrowserCookie, clearAuthCookie } from './auth'; import { getAuthInfoFromBrowserCookie, clearAuthCookie } from './auth';
import { normalizeEpisodeFilterConfig } from './episode-filter'; import { normalizeEpisodeFilterConfig } from './episode-filter';
import { MangaReadRecord, MangaShelfItem } from './manga.types'; import { MangaReadRecord, MangaShelfItem } from './manga.types';
import { DanmakuFilterConfig, EpisodeFilterConfig,SkipConfig } from './types'; import { DanmakuFilterConfig, EpisodeFilterConfig, SkipConfig } from './types';
// 全局错误触发函数 // 全局错误触发函数
function triggerGlobalError(message: string) { function triggerGlobalError(message: string) {
@@ -97,7 +97,8 @@ const FAVORITES_KEY = 'moontv_favorites';
const MANGA_SHELF_KEY = 'moontv_manga_shelf'; const MANGA_SHELF_KEY = 'moontv_manga_shelf';
const MANGA_HISTORY_KEY = 'moontv_manga_history'; const MANGA_HISTORY_KEY = 'moontv_manga_history';
const DEFAULT_MAX_MANGA_HISTORY_RECORDS = 100; const DEFAULT_MAX_MANGA_HISTORY_RECORDS = 100;
const DEFAULT_MAX_MANGA_HISTORY_THRESHOLD = DEFAULT_MAX_MANGA_HISTORY_RECORDS + 10; const DEFAULT_MAX_MANGA_HISTORY_THRESHOLD =
DEFAULT_MAX_MANGA_HISTORY_RECORDS + 10;
const SEARCH_HISTORY_KEY = 'moontv_search_history'; const SEARCH_HISTORY_KEY = 'moontv_search_history';
const MUSIC_PLAY_RECORDS_KEY = 'moontv_music_play_records'; const MUSIC_PLAY_RECORDS_KEY = 'moontv_music_play_records';
@@ -141,10 +142,7 @@ class HybridCacheManager {
/** /**
* 获取或创建请求 Promise(防止并发重复请求) * 获取或创建请求 Promise(防止并发重复请求)
*/ */
getOrCreateRequest<T>( getOrCreateRequest<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
key: string,
fetcher: () => Promise<T>
): Promise<T> {
// 如果已有正在进行的请求,直接返回 // 如果已有正在进行的请求,直接返回
if (this.pendingRequests.has(key)) { if (this.pendingRequests.has(key)) {
console.log(`[${key}] 复用进行中的请求`); console.log(`[${key}] 复用进行中的请求`);
@@ -153,11 +151,10 @@ class HybridCacheManager {
console.log(`[${key}] 创建新请求`); console.log(`[${key}] 创建新请求`);
// 创建新请求 // 创建新请求
const promise = fetcher() const promise = fetcher().finally(() => {
.finally(() => { // 请求完成后清除缓存
// 请求完成后清除缓存 this.pendingRequests.delete(key);
this.pendingRequests.delete(key); });
});
this.pendingRequests.set(key, promise); this.pendingRequests.set(key, promise);
return promise; return promise;
@@ -249,7 +246,10 @@ class HybridCacheManager {
delete cache.mangaShelf; delete cache.mangaShelf;
} }
if (cache.mangaReadRecords && now - cache.mangaReadRecords.timestamp > maxAge) { if (
cache.mangaReadRecords &&
now - cache.mangaReadRecords.timestamp > maxAge
) {
delete cache.mangaReadRecords; delete cache.mangaReadRecords;
} }
} }
@@ -565,7 +565,12 @@ const cacheManager = HybridCacheManager.getInstance();
* 立即从数据库刷新对应类型的缓存以保持数据一致性 * 立即从数据库刷新对应类型的缓存以保持数据一致性
*/ */
async function handleDatabaseOperationFailure( async function handleDatabaseOperationFailure(
dataType: 'playRecords' | 'favorites' | 'searchHistory' | 'mangaShelf' | 'mangaHistory', dataType:
| 'playRecords'
| 'favorites'
| 'searchHistory'
| 'mangaShelf'
| 'mangaHistory',
error: any error: any
): Promise<void> { ): Promise<void> {
console.error(`数据库操作失败 (${dataType}):`, error); console.error(`数据库操作失败 (${dataType}):`, error);
@@ -598,12 +603,16 @@ async function handleDatabaseOperationFailure(
eventName = 'searchHistoryUpdated'; eventName = 'searchHistoryUpdated';
break; break;
case 'mangaShelf': case 'mangaShelf':
freshData = await fetchFromApi<Record<string, MangaShelfItem>>(`/api/manga/shelf`); freshData = await fetchFromApi<Record<string, MangaShelfItem>>(
`/api/manga/shelf`
);
cacheManager.cacheMangaShelf(freshData); cacheManager.cacheMangaShelf(freshData);
eventName = 'mangaShelfUpdated'; eventName = 'mangaShelfUpdated';
break; break;
case 'mangaHistory': case 'mangaHistory':
freshData = await fetchFromApi<Record<string, MangaReadRecord>>(`/api/manga/history`); freshData = await fetchFromApi<Record<string, MangaReadRecord>>(
`/api/manga/history`
);
cacheManager.cacheMangaReadRecords(freshData); cacheManager.cacheMangaReadRecords(freshData);
eventName = 'mangaHistoryUpdated'; eventName = 'mangaHistoryUpdated';
break; break;
@@ -642,9 +651,16 @@ export async function fetchWithAuth(
const text = await res.clone().text(); const text = await res.clone().text();
// 只有当响应体包含 "Unauthorized" 或 "Refresh token expired" 或 "Access token expired" 时才处理 // 只有当响应体包含 "Unauthorized" 或 "Refresh token expired" 或 "Access token expired" 时才处理
if (text.includes('Unauthorized') || text.includes('Refresh token expired') || text.includes('Access token expired')) { if (
text.includes('Unauthorized') ||
text.includes('Refresh token expired') ||
text.includes('Access token expired')
) {
// 如果在登录页面,跳过刷新逻辑 // 如果在登录页面,跳过刷新逻辑
if (typeof window !== 'undefined' && window.location.pathname === '/login') { if (
typeof window !== 'undefined' &&
window.location.pathname === '/login'
) {
console.log('[fetchWithAuth] On login page, skipping refresh logic'); console.log('[fetchWithAuth] On login page, skipping refresh logic');
return res; return res;
} }
@@ -671,7 +687,9 @@ export async function fetchWithAuth(
} }
} else { } else {
// 不是认证错误的401,直接返回 // 不是认证错误的401,直接返回
console.log('[fetchWithAuth] Received 401 but not an auth error, skipping refresh'); console.log(
'[fetchWithAuth] Received 401 but not an auth error, skipping refresh'
);
return res; return res;
} }
@@ -679,9 +697,16 @@ export async function fetchWithAuth(
if (res.status === 401) { if (res.status === 401) {
const text2 = await res.clone().text(); const text2 = await res.clone().text();
// 再次检查响应体 // 再次检查响应体
if (text2.includes('Unauthorized') || text2.includes('Refresh token expired') || text2.includes('Access token expired')) { if (
text2.includes('Unauthorized') ||
text2.includes('Refresh token expired') ||
text2.includes('Access token expired')
) {
// 检查当前页面是否已经是登录页,避免重复跳转 // 检查当前页面是否已经是登录页,避免重复跳转
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { if (
typeof window !== 'undefined' &&
!window.location.pathname.startsWith('/login')
) {
// 调用 logout 接口 // 调用 logout 接口
try { try {
await fetch('/api/logout', { await fetch('/api/logout', {
@@ -824,7 +849,10 @@ export function getCachedPlayRecordsSnapshot(): Record<string, PlayRecord> {
} }
} }
export function getCachedMangaReadRecordsSnapshot(): Record<string, MangaReadRecord> { export function getCachedMangaReadRecordsSnapshot(): Record<
string,
MangaReadRecord
> {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return {}; return {};
} }
@@ -995,6 +1023,66 @@ export async function deletePlayRecord(
} }
} }
/**
* 批量删除播放记录。
* 数据库存储模式下只发起一次 API 请求,并进行一次缓存/事件更新。
*/
export async function deletePlayRecords(keys: string[]): Promise<void> {
const uniqueKeys = Array.from(new Set(keys)).filter(Boolean);
if (uniqueKeys.length === 0) return;
// 数据库存储模式:一次性乐观更新 + 一次 API 请求
if (STORAGE_TYPE !== 'localstorage') {
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
uniqueKeys.forEach((key) => {
delete cachedRecords[key];
});
cacheManager.cachePlayRecords(cachedRecords);
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: cachedRecords,
})
);
try {
await fetchWithAuth('/api/playrecords', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keys: uniqueKeys }),
});
} catch (err) {
await handleDatabaseOperationFailure('playRecords', err);
triggerGlobalError('删除播放记录失败');
throw err;
}
return;
}
// localstorage 模式:一次性更新本地数据和事件
if (typeof window === 'undefined') {
console.warn('无法在服务端删除播放记录到 localStorage');
return;
}
try {
const allRecords = await getAllPlayRecords();
uniqueKeys.forEach((key) => {
delete allRecords[key];
});
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: allRecords,
})
);
} catch (err) {
console.error('批量删除播放记录失败:', err);
triggerGlobalError('删除播放记录失败');
throw err;
}
}
/** /**
* 迁移播放记录到新的 source/id。 * 迁移播放记录到新的 source/id。
* 用于换源时保留单一记忆点语义:当前进度迁移到新源后,再清理旧源记录。 * 用于换源时保留单一记忆点语义:当前进度迁移到新源后,再清理旧源记录。
@@ -1038,9 +1126,12 @@ export async function migratePlayRecord(
body: JSON.stringify({ key: toKey, record }), body: JSON.stringify({ key: toKey, record }),
}); });
await fetchWithAuth(`/api/playrecords?key=${encodeURIComponent(fromKey)}`, { await fetchWithAuth(
method: 'DELETE', `/api/playrecords?key=${encodeURIComponent(fromKey)}`,
}); {
method: 'DELETE',
}
);
}; };
persistMove().catch((err) => { persistMove().catch((err) => {
@@ -1307,7 +1398,8 @@ export async function deleteSearchHistory(keyword: string): Promise<void> {
// 模块级别的防重复请求机制 // 模块级别的防重复请求机制
let pendingFavoritesBackgroundRequest: Promise<void> | null = null; let pendingFavoritesBackgroundRequest: Promise<void> | null = null;
let pendingFavoritesFetchRequest: Promise<Record<string, Favorite>> | null = null; let pendingFavoritesFetchRequest: Promise<Record<string, Favorite>> | null =
null;
let lastFavoritesBackgroundFetchTime = 0; let lastFavoritesBackgroundFetchTime = 0;
const MIN_BACKGROUND_FETCH_INTERVAL = 3000; // 3秒内不重复后台请求 const MIN_BACKGROUND_FETCH_INTERVAL = 3000; // 3秒内不重复后台请求
@@ -1329,12 +1421,18 @@ export async function getAllFavorites(): Promise<Record<string, Favorite>> {
if (cachedData) { if (cachedData) {
// 有缓存:返回缓存,后台异步刷新(带防抖和防重复) // 有缓存:返回缓存,后台异步刷新(带防抖和防重复)
const now = Date.now(); const now = Date.now();
if (now - lastFavoritesBackgroundFetchTime > MIN_BACKGROUND_FETCH_INTERVAL && !pendingFavoritesBackgroundRequest) { if (
now - lastFavoritesBackgroundFetchTime >
MIN_BACKGROUND_FETCH_INTERVAL &&
!pendingFavoritesBackgroundRequest
) {
lastFavoritesBackgroundFetchTime = now; lastFavoritesBackgroundFetchTime = now;
pendingFavoritesBackgroundRequest = (async () => { pendingFavoritesBackgroundRequest = (async () => {
try { try {
const freshData = await fetchFromApi<Record<string, Favorite>>(`/api/favorites`); const freshData = await fetchFromApi<Record<string, Favorite>>(
`/api/favorites`
);
// 只有数据真正不同时才更新缓存 // 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) { if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheFavorites(freshData); cacheManager.cacheFavorites(freshData);
@@ -1363,7 +1461,9 @@ export async function getAllFavorites(): Promise<Record<string, Favorite>> {
pendingFavoritesFetchRequest = (async () => { pendingFavoritesFetchRequest = (async () => {
try { try {
const freshData = await fetchFromApi<Record<string, Favorite>>(`/api/favorites`); const freshData = await fetchFromApi<Record<string, Favorite>>(
`/api/favorites`
);
cacheManager.cacheFavorites(freshData); cacheManager.cacheFavorites(freshData);
return freshData; return freshData;
} catch (err) { } catch (err) {
@@ -1627,10 +1727,11 @@ export async function clearAllFavorites(): Promise<void> {
); );
} }
// ---------------- 漫画书架 / 历史 API ---------------- // ---------------- 漫画书架 / 历史 API ----------------
export async function getAllMangaShelf(): Promise<Record<string, MangaShelfItem>> { export async function getAllMangaShelf(): Promise<
Record<string, MangaShelfItem>
> {
if (typeof window === 'undefined') return {}; if (typeof window === 'undefined') return {};
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
@@ -1640,7 +1741,9 @@ export async function getAllMangaShelf(): Promise<Record<string, MangaShelfItem>
.then((freshData) => { .then((freshData) => {
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) { if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheMangaShelf(freshData); cacheManager.cacheMangaShelf(freshData);
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: freshData })); window.dispatchEvent(
new CustomEvent('mangaShelfUpdated', { detail: freshData })
);
} }
}) })
.catch((err) => { .catch((err) => {
@@ -1650,7 +1753,9 @@ export async function getAllMangaShelf(): Promise<Record<string, MangaShelfItem>
} }
try { try {
const freshData = await fetchFromApi<Record<string, MangaShelfItem>>('/api/manga/shelf'); const freshData = await fetchFromApi<Record<string, MangaShelfItem>>(
'/api/manga/shelf'
);
cacheManager.cacheMangaShelf(freshData); cacheManager.cacheMangaShelf(freshData);
return freshData; return freshData;
} catch (err) { } catch (err) {
@@ -1671,14 +1776,20 @@ export async function getAllMangaShelf(): Promise<Record<string, MangaShelfItem>
} }
} }
export async function saveMangaShelf(sourceId: string, mangaId: string, item: MangaShelfItem): Promise<void> { export async function saveMangaShelf(
sourceId: string,
mangaId: string,
item: MangaShelfItem
): Promise<void> {
const key = generateStorageKey(sourceId, mangaId); const key = generateStorageKey(sourceId, mangaId);
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
const cached = cacheManager.getCachedMangaShelf() || {}; const cached = cacheManager.getCachedMangaShelf() || {};
cached[key] = item; cached[key] = item;
cacheManager.cacheMangaShelf(cached); cacheManager.cacheMangaShelf(cached);
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: cached })); window.dispatchEvent(
new CustomEvent('mangaShelfUpdated', { detail: cached })
);
try { try {
await fetchWithAuth('/api/manga/shelf', { await fetchWithAuth('/api/manga/shelf', {
@@ -1696,20 +1807,29 @@ export async function saveMangaShelf(sourceId: string, mangaId: string, item: Ma
const allItems = await getAllMangaShelf(); const allItems = await getAllMangaShelf();
allItems[key] = item; allItems[key] = item;
localStorage.setItem(MANGA_SHELF_KEY, JSON.stringify(allItems)); localStorage.setItem(MANGA_SHELF_KEY, JSON.stringify(allItems));
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: allItems })); window.dispatchEvent(
new CustomEvent('mangaShelfUpdated', { detail: allItems })
);
} }
export async function deleteMangaShelf(sourceId: string, mangaId: string): Promise<void> { export async function deleteMangaShelf(
sourceId: string,
mangaId: string
): Promise<void> {
const key = generateStorageKey(sourceId, mangaId); const key = generateStorageKey(sourceId, mangaId);
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
const cached = cacheManager.getCachedMangaShelf() || {}; const cached = cacheManager.getCachedMangaShelf() || {};
delete cached[key]; delete cached[key];
cacheManager.cacheMangaShelf(cached); cacheManager.cacheMangaShelf(cached);
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: cached })); window.dispatchEvent(
new CustomEvent('mangaShelfUpdated', { detail: cached })
);
try { try {
await fetchWithAuth(`/api/manga/shelf?key=${encodeURIComponent(key)}`, { method: 'DELETE' }); await fetchWithAuth(`/api/manga/shelf?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
} catch (err) { } catch (err) {
await handleDatabaseOperationFailure('mangaShelf', err); await handleDatabaseOperationFailure('mangaShelf', err);
throw err; throw err;
@@ -1720,7 +1840,9 @@ export async function deleteMangaShelf(sourceId: string, mangaId: string): Promi
const allItems = await getAllMangaShelf(); const allItems = await getAllMangaShelf();
delete allItems[key]; delete allItems[key];
localStorage.setItem(MANGA_SHELF_KEY, JSON.stringify(allItems)); localStorage.setItem(MANGA_SHELF_KEY, JSON.stringify(allItems));
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: allItems })); window.dispatchEvent(
new CustomEvent('mangaShelfUpdated', { detail: allItems })
);
} }
export async function clearAllMangaShelf(): Promise<void> { export async function clearAllMangaShelf(): Promise<void> {
@@ -1740,7 +1862,9 @@ export async function clearAllMangaShelf(): Promise<void> {
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: {} })); window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: {} }));
} }
function trimMangaReadRecords(records: Record<string, MangaReadRecord>): Record<string, MangaReadRecord> { function trimMangaReadRecords(
records: Record<string, MangaReadRecord>
): Record<string, MangaReadRecord> {
const entries = Object.entries(records); const entries = Object.entries(records);
if (entries.length <= DEFAULT_MAX_MANGA_HISTORY_THRESHOLD) return records; if (entries.length <= DEFAULT_MAX_MANGA_HISTORY_THRESHOLD) return records;
@@ -1751,7 +1875,9 @@ function trimMangaReadRecords(records: Record<string, MangaReadRecord>): Record<
); );
} }
export async function getAllMangaReadRecords(): Promise<Record<string, MangaReadRecord>> { export async function getAllMangaReadRecords(): Promise<
Record<string, MangaReadRecord>
> {
if (typeof window === 'undefined') return {}; if (typeof window === 'undefined') return {};
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
@@ -1761,7 +1887,9 @@ export async function getAllMangaReadRecords(): Promise<Record<string, MangaRead
.then((freshData) => { .then((freshData) => {
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) { if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheMangaReadRecords(freshData); cacheManager.cacheMangaReadRecords(freshData);
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: freshData })); window.dispatchEvent(
new CustomEvent('mangaHistoryUpdated', { detail: freshData })
);
} }
}) })
.catch((err) => { .catch((err) => {
@@ -1771,7 +1899,9 @@ export async function getAllMangaReadRecords(): Promise<Record<string, MangaRead
} }
try { try {
const freshData = await fetchFromApi<Record<string, MangaReadRecord>>('/api/manga/history'); const freshData = await fetchFromApi<Record<string, MangaReadRecord>>(
'/api/manga/history'
);
cacheManager.cacheMangaReadRecords(freshData); cacheManager.cacheMangaReadRecords(freshData);
return freshData; return freshData;
} catch (err) { } catch (err) {
@@ -1792,7 +1922,11 @@ export async function getAllMangaReadRecords(): Promise<Record<string, MangaRead
} }
} }
export async function saveMangaReadRecord(sourceId: string, mangaId: string, record: MangaReadRecord): Promise<void> { export async function saveMangaReadRecord(
sourceId: string,
mangaId: string,
record: MangaReadRecord
): Promise<void> {
const key = generateStorageKey(sourceId, mangaId); const key = generateStorageKey(sourceId, mangaId);
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
@@ -1800,7 +1934,9 @@ export async function saveMangaReadRecord(sourceId: string, mangaId: string, rec
cached[key] = record; cached[key] = record;
const trimmedRecords = trimMangaReadRecords(cached); const trimmedRecords = trimMangaReadRecords(cached);
cacheManager.cacheMangaReadRecords(trimmedRecords); cacheManager.cacheMangaReadRecords(trimmedRecords);
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: trimmedRecords })); window.dispatchEvent(
new CustomEvent('mangaHistoryUpdated', { detail: trimmedRecords })
);
try { try {
await fetchWithAuth('/api/manga/history', { await fetchWithAuth('/api/manga/history', {
@@ -1819,20 +1955,29 @@ export async function saveMangaReadRecord(sourceId: string, mangaId: string, rec
allRecords[key] = record; allRecords[key] = record;
const trimmedRecords = trimMangaReadRecords(allRecords); const trimmedRecords = trimMangaReadRecords(allRecords);
localStorage.setItem(MANGA_HISTORY_KEY, JSON.stringify(trimmedRecords)); localStorage.setItem(MANGA_HISTORY_KEY, JSON.stringify(trimmedRecords));
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: trimmedRecords })); window.dispatchEvent(
new CustomEvent('mangaHistoryUpdated', { detail: trimmedRecords })
);
} }
export async function deleteMangaReadRecord(sourceId: string, mangaId: string): Promise<void> { export async function deleteMangaReadRecord(
sourceId: string,
mangaId: string
): Promise<void> {
const key = generateStorageKey(sourceId, mangaId); const key = generateStorageKey(sourceId, mangaId);
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
const cached = cacheManager.getCachedMangaReadRecords() || {}; const cached = cacheManager.getCachedMangaReadRecords() || {};
delete cached[key]; delete cached[key];
cacheManager.cacheMangaReadRecords(cached); cacheManager.cacheMangaReadRecords(cached);
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: cached })); window.dispatchEvent(
new CustomEvent('mangaHistoryUpdated', { detail: cached })
);
try { try {
await fetchWithAuth(`/api/manga/history?key=${encodeURIComponent(key)}`, { method: 'DELETE' }); await fetchWithAuth(`/api/manga/history?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
} catch (err) { } catch (err) {
await handleDatabaseOperationFailure('mangaHistory', err); await handleDatabaseOperationFailure('mangaHistory', err);
throw err; throw err;
@@ -1843,13 +1988,17 @@ export async function deleteMangaReadRecord(sourceId: string, mangaId: string):
const allRecords = await getAllMangaReadRecords(); const allRecords = await getAllMangaReadRecords();
delete allRecords[key]; delete allRecords[key];
localStorage.setItem(MANGA_HISTORY_KEY, JSON.stringify(allRecords)); localStorage.setItem(MANGA_HISTORY_KEY, JSON.stringify(allRecords));
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: allRecords })); window.dispatchEvent(
new CustomEvent('mangaHistoryUpdated', { detail: allRecords })
);
} }
export async function clearAllMangaReadRecords(): Promise<void> { export async function clearAllMangaReadRecords(): Promise<void> {
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
cacheManager.cacheMangaReadRecords({}); cacheManager.cacheMangaReadRecords({});
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: {} })); window.dispatchEvent(
new CustomEvent('mangaHistoryUpdated', { detail: {} })
);
try { try {
await fetchWithAuth('/api/manga/history', { method: 'DELETE' }); await fetchWithAuth('/api/manga/history', { method: 'DELETE' });
} catch (err) { } catch (err) {
@@ -1886,15 +2035,21 @@ export async function refreshAllCache(): Promise<void> {
// 使用 Promise 缓存防止并发重复刷新 // 使用 Promise 缓存防止并发重复刷新
await cacheManager.getOrCreateRequest('refresh-all-cache', async () => { await cacheManager.getOrCreateRequest('refresh-all-cache', async () => {
// 并行刷新所有数据 // 并行刷新所有数据
const [playRecords, favorites, mangaShelf, mangaHistory, searchHistory, skipConfigs] = const [
await Promise.allSettled([ playRecords,
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`), favorites,
fetchFromApi<Record<string, Favorite>>(`/api/favorites`), mangaShelf,
fetchFromApi<Record<string, MangaShelfItem>>(`/api/manga/shelf`), mangaHistory,
fetchFromApi<Record<string, MangaReadRecord>>(`/api/manga/history`), searchHistory,
fetchFromApi<string[]>(`/api/searchhistory`), skipConfigs,
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`), ] = await Promise.allSettled([
]); fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
fetchFromApi<Record<string, MangaShelfItem>>(`/api/manga/shelf`),
fetchFromApi<Record<string, MangaReadRecord>>(`/api/manga/history`),
fetchFromApi<string[]>(`/api/searchhistory`),
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`),
]);
if (playRecords.status === 'fulfilled') { if (playRecords.status === 'fulfilled') {
cacheManager.cachePlayRecords(playRecords.value); cacheManager.cachePlayRecords(playRecords.value);
@@ -2020,7 +2175,7 @@ export function subscribeToDataUpdates<T>(
callback: (data: T) => void callback: (data: T) => void
): () => void { ): () => void {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return () => { }; return () => {};
} }
const handleUpdate = (event: CustomEvent) => { const handleUpdate = (event: CustomEvent) => {
@@ -2425,7 +2580,10 @@ export async function saveDanmakuFilterConfig(
} }
try { try {
localStorage.setItem('moontv_danmaku_filter_config', JSON.stringify(config)); localStorage.setItem(
'moontv_danmaku_filter_config',
JSON.stringify(config)
);
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('danmakuFilterConfigUpdated', { new CustomEvent('danmakuFilterConfigUpdated', {
detail: config, detail: config,
@@ -2444,7 +2602,9 @@ export async function saveDanmakuFilterConfig(
* 获取全部音乐播放记录。 * 获取全部音乐播放记录。
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。 * 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/ */
export async function getAllMusicPlayRecords(): Promise<Record<string, MusicPlayRecord>> { export async function getAllMusicPlayRecords(): Promise<
Record<string, MusicPlayRecord>
> {
// 服务器端渲染阶段直接返回空 // 服务器端渲染阶段直接返回空
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return {}; return {};
@@ -2594,9 +2754,12 @@ export async function deleteMusicPlayRecord(
// 异步同步到数据库 // 异步同步到数据库
try { try {
await fetchWithAuth(`/api/music/playrecords?key=${encodeURIComponent(key)}`, { await fetchWithAuth(
method: 'DELETE', `/api/music/playrecords?key=${encodeURIComponent(key)}`,
}); {
method: 'DELETE',
}
);
} catch (err) { } catch (err) {
console.error('删除音乐播放记录失败:', err); console.error('删除音乐播放记录失败:', err);
triggerGlobalError('删除音乐播放记录失败'); triggerGlobalError('删除音乐播放记录失败');
@@ -2701,7 +2864,10 @@ export async function saveEpisodeFilterConfig(
try { try {
const normalizedConfig = normalizeEpisodeFilterConfig(config); const normalizedConfig = normalizeEpisodeFilterConfig(config);
localStorage.setItem('moontv_episode_filter_config', JSON.stringify(normalizedConfig)); localStorage.setItem(
'moontv_episode_filter_config',
JSON.stringify(normalizedConfig)
);
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('episodeFilterConfigUpdated', { new CustomEvent('episodeFilterConfigUpdated', {
detail: normalizedConfig, detail: normalizedConfig,
+243 -67
View File
@@ -5,9 +5,19 @@ import { MusicPlayRecord } from './db.client';
import { KvrocksStorage } from './kvrocks.db'; import { KvrocksStorage } from './kvrocks.db';
import { MangaReadRecord, MangaShelfItem } from './manga.types'; import { MangaReadRecord, MangaShelfItem } from './manga.types';
import { BookReadRecord, BookShelfItem } from './book.types'; import { BookReadRecord, BookShelfItem } from './book.types';
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2'; import {
MusicV2HistoryRecord,
MusicV2PlaylistItem,
MusicV2PlaylistRecord,
} from './music-v2';
import { RedisStorage } from './redis.db'; import { RedisStorage } from './redis.db';
import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types'; import {
DanmakuFilterConfig,
Favorite,
IStorage,
PlayRecord,
SkipConfig,
} from './types';
import { UpstashRedisStorage } from './upstash.db'; import { UpstashRedisStorage } from './upstash.db';
// storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1' | 'postgres',默认 'localstorage' // storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1' | 'postgres',默认 'localstorage'
@@ -77,36 +87,44 @@ function getD1Adapter(): any {
const { CloudflareD1Adapter, SQLiteAdapter } = require('./d1-adapter'); const { CloudflareD1Adapter, SQLiteAdapter } = require('./d1-adapter');
// 检查是否为 Cloudflare 构建 // 检查是否为 Cloudflare 构建
const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare'; const isCloudflare =
process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
// 生产环境:Cloudflare Workers/Pages // 生产环境:Cloudflare Workers/Pages
if (isCloudflare) { if (isCloudflare) {
// 创建一个懒加载的适配器,延迟到实际使用时才获取 D1 绑定 // 创建一个懒加载的适配器,延迟到实际使用时才获取 D1 绑定
let cachedAdapter: any = null; let cachedAdapter: any = null;
return new Proxy({}, { return new Proxy(
get(target, prop) { {},
// 懒加载:第一次访问时才获取真实的 D1 适配器 {
if (!cachedAdapter) { get(target, prop) {
try { // 懒加载:第一次访问时才获取真实的 D1 适配器
const { getCloudflareContext } = require('@opennextjs/cloudflare'); if (!cachedAdapter) {
const { env } = getCloudflareContext(); try {
const {
getCloudflareContext,
} = require('@opennextjs/cloudflare');
const { env } = getCloudflareContext();
if (!env.DB) { if (!env.DB) {
throw new Error('D1 database binding (DB) not found in Cloudflare environment'); throw new Error(
'D1 database binding (DB) not found in Cloudflare environment'
);
}
console.log('Using Cloudflare D1 database');
cachedAdapter = new CloudflareD1Adapter(env.DB);
} catch (error) {
console.error('Failed to initialize Cloudflare D1:', error);
throw error;
} }
console.log('Using Cloudflare D1 database');
cachedAdapter = new CloudflareD1Adapter(env.DB);
} catch (error) {
console.error('Failed to initialize Cloudflare D1:', error);
throw error;
} }
}
return cachedAdapter[prop]; return cachedAdapter[prop];
},
} }
}); );
} }
// 开发环境:better-sqlite3 // 开发环境:better-sqlite3
@@ -114,7 +132,8 @@ function getD1Adapter(): any {
const path = require('path'); const path = require('path');
const dbPath = const dbPath =
process.env.SQLITE_DB_PATH || path.join(process.cwd(), '.data', 'moontv.db'); process.env.SQLITE_DB_PATH ||
path.join(process.cwd(), '.data', 'moontv.db');
const db = new Database(dbPath); const db = new Database(dbPath);
db.pragma('journal_mode = WAL'); // 启用 WAL 模式提升性能 db.pragma('journal_mode = WAL'); // 启用 WAL 模式提升性能
@@ -185,6 +204,10 @@ export class DbManager {
await this.storage.deletePlayRecord(userName, key); await this.storage.deletePlayRecord(userName, key);
} }
async deletePlayRecords(userName: string, keys: string[]): Promise<void> {
await this.storage.deletePlayRecords(userName, keys);
}
// 收藏相关方法 // 收藏相关方法
async getFavorite( async getFavorite(
userName: string, userName: string,
@@ -280,13 +303,19 @@ export class DbManager {
return []; return [];
} }
async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise<void> { async upsertMusicV2History(
userName: string,
record: MusicV2HistoryRecord
): Promise<void> {
if (typeof (this.storage as any).upsertMusicV2History === 'function') { if (typeof (this.storage as any).upsertMusicV2History === 'function') {
await (this.storage as any).upsertMusicV2History(userName, record); await (this.storage as any).upsertMusicV2History(userName, record);
} }
} }
async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise<void> { async batchUpsertMusicV2History(
userName: string,
records: MusicV2HistoryRecord[]
): Promise<void> {
if (typeof (this.storage as any).batchUpsertMusicV2History === 'function') { if (typeof (this.storage as any).batchUpsertMusicV2History === 'function') {
await (this.storage as any).batchUpsertMusicV2History(userName, records); await (this.storage as any).batchUpsertMusicV2History(userName, records);
} }
@@ -307,21 +336,25 @@ export class DbManager {
// Music V2 歌单相关 // Music V2 歌单相关
async createMusicV2Playlist( async createMusicV2Playlist(
userName: string, userName: string,
playlist: { id: string; name: string; description?: string; cover?: string; } playlist: { id: string; name: string; description?: string; cover?: string }
): Promise<void> { ): Promise<void> {
if (typeof (this.storage as any).createMusicV2Playlist === 'function') { if (typeof (this.storage as any).createMusicV2Playlist === 'function') {
await (this.storage as any).createMusicV2Playlist(userName, playlist); await (this.storage as any).createMusicV2Playlist(userName, playlist);
} }
} }
async getMusicV2Playlist(playlistId: string): Promise<MusicV2PlaylistRecord | null> { async getMusicV2Playlist(
playlistId: string
): Promise<MusicV2PlaylistRecord | null> {
if (typeof (this.storage as any).getMusicV2Playlist === 'function') { if (typeof (this.storage as any).getMusicV2Playlist === 'function') {
return (this.storage as any).getMusicV2Playlist(playlistId); return (this.storage as any).getMusicV2Playlist(playlistId);
} }
return null; return null;
} }
async listMusicV2Playlists(userName: string): Promise<MusicV2PlaylistRecord[]> { async listMusicV2Playlists(
userName: string
): Promise<MusicV2PlaylistRecord[]> {
if (typeof (this.storage as any).listMusicV2Playlists === 'function') { if (typeof (this.storage as any).listMusicV2Playlists === 'function') {
return (this.storage as any).listMusicV2Playlists(userName); return (this.storage as any).listMusicV2Playlists(userName);
} }
@@ -330,7 +363,12 @@ export class DbManager {
async updateMusicV2Playlist( async updateMusicV2Playlist(
playlistId: string, playlistId: string,
updates: { name?: string; description?: string; cover?: string; song_count?: number; } updates: {
name?: string;
description?: string;
cover?: string;
song_count?: number;
}
): Promise<void> { ): Promise<void> {
if (typeof (this.storage as any).updateMusicV2Playlist === 'function') { if (typeof (this.storage as any).updateMusicV2Playlist === 'function') {
await (this.storage as any).updateMusicV2Playlist(playlistId, updates); await (this.storage as any).updateMusicV2Playlist(playlistId, updates);
@@ -343,26 +381,37 @@ export class DbManager {
} }
} }
async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise<void> { async addMusicV2PlaylistItem(
playlistId: string,
item: MusicV2PlaylistItem
): Promise<void> {
if (typeof (this.storage as any).addMusicV2PlaylistItem === 'function') { if (typeof (this.storage as any).addMusicV2PlaylistItem === 'function') {
await (this.storage as any).addMusicV2PlaylistItem(playlistId, item); await (this.storage as any).addMusicV2PlaylistItem(playlistId, item);
} }
} }
async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise<void> { async removeMusicV2PlaylistItem(
playlistId: string,
songId: string
): Promise<void> {
if (typeof (this.storage as any).removeMusicV2PlaylistItem === 'function') { if (typeof (this.storage as any).removeMusicV2PlaylistItem === 'function') {
await (this.storage as any).removeMusicV2PlaylistItem(playlistId, songId); await (this.storage as any).removeMusicV2PlaylistItem(playlistId, songId);
} }
} }
async listMusicV2PlaylistItems(playlistId: string): Promise<MusicV2PlaylistItem[]> { async listMusicV2PlaylistItems(
playlistId: string
): Promise<MusicV2PlaylistItem[]> {
if (typeof (this.storage as any).listMusicV2PlaylistItems === 'function') { if (typeof (this.storage as any).listMusicV2PlaylistItems === 'function') {
return (this.storage as any).listMusicV2PlaylistItems(playlistId); return (this.storage as any).listMusicV2PlaylistItems(playlistId);
} }
return []; return [];
} }
async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise<boolean> { async hasMusicV2PlaylistItem(
playlistId: string,
songId: string
): Promise<boolean> {
if (typeof (this.storage as any).hasMusicV2PlaylistItem === 'function') { if (typeof (this.storage as any).hasMusicV2PlaylistItem === 'function') {
return (this.storage as any).hasMusicV2PlaylistItem(playlistId, songId); return (this.storage as any).hasMusicV2PlaylistItem(playlistId, songId);
} }
@@ -440,7 +489,11 @@ export class DbManager {
songId: string songId: string
): Promise<void> { ): Promise<void> {
if (typeof (this.storage as any).removeSongFromPlaylist === 'function') { if (typeof (this.storage as any).removeSongFromPlaylist === 'function') {
await (this.storage as any).removeSongFromPlaylist(playlistId, platform, songId); await (this.storage as any).removeSongFromPlaylist(
playlistId,
platform,
songId
);
} }
} }
@@ -457,7 +510,11 @@ export class DbManager {
songId: string songId: string
): Promise<boolean> { ): Promise<boolean> {
if (typeof (this.storage as any).isSongInPlaylist === 'function') { if (typeof (this.storage as any).isSongInPlaylist === 'function') {
return (this.storage as any).isSongInPlaylist(playlistId, platform, songId); return (this.storage as any).isSongInPlaylist(
playlistId,
platform,
songId
);
} }
return false; return false;
} }
@@ -489,7 +546,14 @@ export class DbManager {
enabledApis?: string[] enabledApis?: string[]
): Promise<void> { ): Promise<void> {
if (typeof (this.storage as any).createUserV2 === 'function') { if (typeof (this.storage as any).createUserV2 === 'function') {
await (this.storage as any).createUserV2(userName, password, role, tags, oidcSub, enabledApis); await (this.storage as any).createUserV2(
userName,
password,
role,
tags,
oidcSub,
enabledApis
);
} }
} }
@@ -570,7 +634,12 @@ export class DbManager {
total: number; total: number;
}> { }> {
if (typeof (this.storage as any).getUserListV2 === 'function') { if (typeof (this.storage as any).getUserListV2 === 'function') {
return (this.storage as any).getUserListV2(offset, limit, ownerUsername, search); return (this.storage as any).getUserListV2(
offset,
limit,
ownerUsername,
search
);
} }
return { users: [], total: 0 }; return { users: [], total: 0 };
} }
@@ -670,7 +739,9 @@ export class DbManager {
else { else {
try { try {
if ((this.storage as any).client) { if ((this.storage as any).client) {
const storedPassword = await (this.storage as any).client.get(`u:${user.username}:pwd`); const storedPassword = await (this.storage as any).client.get(
`u:${user.username}:pwd`
);
if (storedPassword) { if (storedPassword) {
password = storedPassword; password = storedPassword;
console.log(`用户 ${user.username} 使用旧密码迁移`); console.log(`用户 ${user.username} 使用旧密码迁移`);
@@ -683,7 +754,10 @@ export class DbManager {
password = 'defaultPassword123'; password = 'defaultPassword123';
} }
} catch (err) { } catch (err) {
console.error(`获取用户 ${user.username} 的密码失败,使用默认密码`, err); console.error(
`获取用户 ${user.username} 的密码失败,使用默认密码`,
err
);
password = 'defaultPassword123'; password = 'defaultPassword123';
} }
} }
@@ -732,71 +806,171 @@ export class DbManager {
} }
// ---------- 漫画书架 ---------- // ---------- 漫画书架 ----------
async getMangaShelf(userName: string, sourceId: string, mangaId: string): Promise<MangaShelfItem | null> { async getMangaShelf(
return this.storage.getMangaShelf(userName, generateStorageKey(sourceId, mangaId)); userName: string,
sourceId: string,
mangaId: string
): Promise<MangaShelfItem | null> {
return this.storage.getMangaShelf(
userName,
generateStorageKey(sourceId, mangaId)
);
} }
async saveMangaShelf(userName: string, sourceId: string, mangaId: string, item: MangaShelfItem): Promise<void> { async saveMangaShelf(
await this.storage.setMangaShelf(userName, generateStorageKey(sourceId, mangaId), item); userName: string,
sourceId: string,
mangaId: string,
item: MangaShelfItem
): Promise<void> {
await this.storage.setMangaShelf(
userName,
generateStorageKey(sourceId, mangaId),
item
);
} }
async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> { async getAllMangaShelf(
userName: string
): Promise<{ [key: string]: MangaShelfItem }> {
return this.storage.getAllMangaShelf(userName); return this.storage.getAllMangaShelf(userName);
} }
async deleteMangaShelf(userName: string, sourceId: string, mangaId: string): Promise<void> { async deleteMangaShelf(
await this.storage.deleteMangaShelf(userName, generateStorageKey(sourceId, mangaId)); userName: string,
sourceId: string,
mangaId: string
): Promise<void> {
await this.storage.deleteMangaShelf(
userName,
generateStorageKey(sourceId, mangaId)
);
} }
// ---------- 漫画阅读历史 ---------- // ---------- 漫画阅读历史 ----------
async getMangaReadRecord(userName: string, sourceId: string, mangaId: string): Promise<MangaReadRecord | null> { async getMangaReadRecord(
return this.storage.getMangaReadRecord(userName, generateStorageKey(sourceId, mangaId)); userName: string,
sourceId: string,
mangaId: string
): Promise<MangaReadRecord | null> {
return this.storage.getMangaReadRecord(
userName,
generateStorageKey(sourceId, mangaId)
);
} }
async saveMangaReadRecord(userName: string, sourceId: string, mangaId: string, record: MangaReadRecord): Promise<void> { async saveMangaReadRecord(
await this.storage.setMangaReadRecord(userName, generateStorageKey(sourceId, mangaId), record); userName: string,
sourceId: string,
mangaId: string,
record: MangaReadRecord
): Promise<void> {
await this.storage.setMangaReadRecord(
userName,
generateStorageKey(sourceId, mangaId),
record
);
} }
async getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }> { async getAllMangaReadRecords(
userName: string
): Promise<{ [key: string]: MangaReadRecord }> {
return this.storage.getAllMangaReadRecords(userName); return this.storage.getAllMangaReadRecords(userName);
} }
async deleteMangaReadRecord(userName: string, sourceId: string, mangaId: string): Promise<void> { async deleteMangaReadRecord(
await this.storage.deleteMangaReadRecord(userName, generateStorageKey(sourceId, mangaId)); userName: string,
sourceId: string,
mangaId: string
): Promise<void> {
await this.storage.deleteMangaReadRecord(
userName,
generateStorageKey(sourceId, mangaId)
);
} }
// ---------- 电子书书架 ---------- // ---------- 电子书书架 ----------
async getBookShelf(userName: string, sourceId: string, bookId: string): Promise<BookShelfItem | null> { async getBookShelf(
return this.storage.getBookShelf(userName, generateStorageKey(sourceId, bookId)); userName: string,
sourceId: string,
bookId: string
): Promise<BookShelfItem | null> {
return this.storage.getBookShelf(
userName,
generateStorageKey(sourceId, bookId)
);
} }
async saveBookShelf(userName: string, sourceId: string, bookId: string, item: BookShelfItem): Promise<void> { async saveBookShelf(
await this.storage.setBookShelf(userName, generateStorageKey(sourceId, bookId), item); userName: string,
sourceId: string,
bookId: string,
item: BookShelfItem
): Promise<void> {
await this.storage.setBookShelf(
userName,
generateStorageKey(sourceId, bookId),
item
);
} }
async getAllBookShelf(userName: string): Promise<{ [key: string]: BookShelfItem }> { async getAllBookShelf(
userName: string
): Promise<{ [key: string]: BookShelfItem }> {
return this.storage.getAllBookShelf(userName); return this.storage.getAllBookShelf(userName);
} }
async deleteBookShelf(userName: string, sourceId: string, bookId: string): Promise<void> { async deleteBookShelf(
await this.storage.deleteBookShelf(userName, generateStorageKey(sourceId, bookId)); userName: string,
sourceId: string,
bookId: string
): Promise<void> {
await this.storage.deleteBookShelf(
userName,
generateStorageKey(sourceId, bookId)
);
} }
// ---------- 电子书阅读历史 ---------- // ---------- 电子书阅读历史 ----------
async getBookReadRecord(userName: string, sourceId: string, bookId: string): Promise<BookReadRecord | null> { async getBookReadRecord(
return this.storage.getBookReadRecord(userName, generateStorageKey(sourceId, bookId)); userName: string,
sourceId: string,
bookId: string
): Promise<BookReadRecord | null> {
return this.storage.getBookReadRecord(
userName,
generateStorageKey(sourceId, bookId)
);
} }
async saveBookReadRecord(userName: string, sourceId: string, bookId: string, record: BookReadRecord): Promise<void> { async saveBookReadRecord(
await this.storage.setBookReadRecord(userName, generateStorageKey(sourceId, bookId), record); userName: string,
sourceId: string,
bookId: string,
record: BookReadRecord
): Promise<void> {
await this.storage.setBookReadRecord(
userName,
generateStorageKey(sourceId, bookId),
record
);
} }
async getAllBookReadRecords(userName: string): Promise<{ [key: string]: BookReadRecord }> { async getAllBookReadRecords(
userName: string
): Promise<{ [key: string]: BookReadRecord }> {
return this.storage.getAllBookReadRecords(userName); return this.storage.getAllBookReadRecords(userName);
} }
async deleteBookReadRecord(userName: string, sourceId: string, bookId: string): Promise<void> { async deleteBookReadRecord(
await this.storage.deleteBookReadRecord(userName, generateStorageKey(sourceId, bookId)); userName: string,
sourceId: string,
bookId: string
): Promise<void> {
await this.storage.deleteBookReadRecord(
userName,
generateStorageKey(sourceId, bookId)
);
} }
// 获取全部用户名 // 获取全部用户名
@@ -864,7 +1038,9 @@ export class DbManager {
} }
// ---------- 弹幕过滤配置 ---------- // ---------- 弹幕过滤配置 ----------
async getDanmakuFilterConfig(userName: string): Promise<DanmakuFilterConfig | null> { async getDanmakuFilterConfig(
userName: string
): Promise<DanmakuFilterConfig | null> {
if (typeof (this.storage as any).getDanmakuFilterConfig === 'function') { if (typeof (this.storage as any).getDanmakuFilterConfig === 'function') {
return (this.storage as any).getDanmakuFilterConfig(userName); return (this.storage as any).getDanmakuFilterConfig(userName);
} }
+682 -229
View File
@@ -21,7 +21,11 @@ import { AdminConfig } from './admin.types';
import { MangaReadRecord, MangaShelfItem } from './manga.types'; import { MangaReadRecord, MangaShelfItem } from './manga.types';
import { BookReadRecord, BookShelfItem } from './book.types'; import { BookReadRecord, BookShelfItem } from './book.types';
import { DatabaseAdapter } from './d1-adapter'; import { DatabaseAdapter } from './d1-adapter';
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2'; import {
MusicV2HistoryRecord,
MusicV2PlaylistItem,
MusicV2PlaylistRecord,
} from './music-v2';
/** /**
* Vercel Postgres 存储实现 * Vercel Postgres 存储实现
@@ -60,7 +64,10 @@ export class PostgresStorage implements IStorage {
try { try {
const result = await this.db.prepare(statement).run(); const result = await this.db.prepare(statement).run();
if (!result.success && result.error) { if (!result.success && result.error) {
console.warn('PostgresStorage.ensureMangaShelfColumns warning:', result.error); console.warn(
'PostgresStorage.ensureMangaShelfColumns warning:',
result.error
);
} }
} catch (err) { } catch (err) {
console.warn('PostgresStorage.ensureMangaShelfColumns warning:', err); console.warn('PostgresStorage.ensureMangaShelfColumns warning:', err);
@@ -70,7 +77,10 @@ export class PostgresStorage implements IStorage {
// ==================== 播放记录 ==================== // ==================== 播放记录 ====================
async getPlayRecord(userName: string, key: string): Promise<PlayRecord | null> { async getPlayRecord(
userName: string,
key: string
): Promise<PlayRecord | null> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT * FROM play_records WHERE username = $1 AND key = $2') .prepare('SELECT * FROM play_records WHERE username = $1 AND key = $2')
@@ -85,10 +95,15 @@ export class PostgresStorage implements IStorage {
} }
} }
async setPlayRecord(userName: string, key: string, record: PlayRecord): Promise<void> { async setPlayRecord(
userName: string,
key: string,
record: PlayRecord
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO play_records ( INSERT INTO play_records (
username, key, title, source_name, cover, year, username, key, title, source_name, cover, year,
episode_index, total_episodes, play_time, total_time, episode_index, total_episodes, play_time, total_time,
@@ -107,7 +122,8 @@ export class PostgresStorage implements IStorage {
save_time = EXCLUDED.save_time, save_time = EXCLUDED.save_time,
search_title = EXCLUDED.search_title, search_title = EXCLUDED.search_title,
new_episodes = EXCLUDED.new_episodes new_episodes = EXCLUDED.new_episodes
`) `
)
.bind( .bind(
userName, userName,
key, key,
@@ -130,10 +146,14 @@ export class PostgresStorage implements IStorage {
} }
} }
async getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }> { async getAllPlayRecords(
userName: string
): Promise<{ [key: string]: PlayRecord }> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM play_records WHERE username = $1 ORDER BY save_time DESC') .prepare(
'SELECT * FROM play_records WHERE username = $1 ORDER BY save_time DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -163,14 +183,39 @@ export class PostgresStorage implements IStorage {
} }
} }
async deletePlayRecords(userName: string, keys: string[]): Promise<void> {
const uniqueKeys = Array.from(new Set(keys)).filter(Boolean);
if (uniqueKeys.length === 0) return;
try {
const placeholders = uniqueKeys
.map((_, index) => `$${index + 2}`)
.join(',');
await this.db
.prepare(
`DELETE FROM play_records WHERE username = $1 AND key IN (${placeholders})`
)
.bind(userName, ...uniqueKeys)
.run();
} catch (err) {
console.error('PostgresStorage.deletePlayRecords error:', err);
throw err;
}
}
async cleanupOldPlayRecords(userName: string): Promise<void> { async cleanupOldPlayRecords(userName: string): Promise<void> {
try { try {
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; const threshold = maxRecords + 10;
// 检查记录数量 // 检查记录数量
const countResult = await this.db const countResult = await this.db
.prepare('SELECT COUNT(*) as count FROM play_records WHERE username = $1') .prepare(
'SELECT COUNT(*) as count FROM play_records WHERE username = $1'
)
.bind(userName) .bind(userName)
.first(); .first();
@@ -179,7 +224,8 @@ export class PostgresStorage implements IStorage {
// 删除超出限制的旧记录 // 删除超出限制的旧记录
await this.db await this.db
.prepare(` .prepare(
`
DELETE FROM play_records DELETE FROM play_records
WHERE username = $1 WHERE username = $1
AND key NOT IN ( AND key NOT IN (
@@ -188,11 +234,14 @@ export class PostgresStorage implements IStorage {
ORDER BY save_time DESC ORDER BY save_time DESC
LIMIT $2 LIMIT $2
) )
`) `
)
.bind(userName, maxRecords) .bind(userName, maxRecords)
.run(); .run();
console.log(`PostgresStorage: Cleaned up old play records for user ${userName}`); console.log(
`PostgresStorage: Cleaned up old play records for user ${userName}`
);
} catch (err) { } catch (err) {
console.error('PostgresStorage.cleanupOldPlayRecords error:', err); console.error('PostgresStorage.cleanupOldPlayRecords error:', err);
throw err; throw err;
@@ -227,10 +276,15 @@ export class PostgresStorage implements IStorage {
} }
} }
async setFavorite(userName: string, key: string, favorite: Favorite): Promise<void> { async setFavorite(
userName: string,
key: string,
favorite: Favorite
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO favorites ( INSERT INTO favorites (
username, key, source_name, total_episodes, title, username, key, source_name, total_episodes, title,
year, cover, save_time, search_title, origin, year, cover, save_time, search_title, origin,
@@ -248,7 +302,8 @@ export class PostgresStorage implements IStorage {
origin = EXCLUDED.origin, origin = EXCLUDED.origin,
is_completed = EXCLUDED.is_completed, is_completed = EXCLUDED.is_completed,
vod_remarks = EXCLUDED.vod_remarks vod_remarks = EXCLUDED.vod_remarks
`) `
)
.bind( .bind(
userName, userName,
key, key,
@@ -270,10 +325,14 @@ export class PostgresStorage implements IStorage {
} }
} }
async getAllFavorites(userName: string): Promise<{ [key: string]: Favorite }> { async getAllFavorites(
userName: string
): Promise<{ [key: string]: Favorite }> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM favorites WHERE username = $1 ORDER BY save_time DESC') .prepare(
'SELECT * FROM favorites WHERE username = $1 ORDER BY save_time DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -360,12 +419,17 @@ export class PostgresStorage implements IStorage {
async verifyUser(userName: string, password: string): Promise<boolean> { async verifyUser(userName: string, password: string): Promise<boolean> {
try { try {
// 检查是否是环境变量中的管理员 // 检查是否是环境变量中的管理员
if (userName === process.env.USERNAME && password === process.env.PASSWORD) { if (
userName === process.env.USERNAME &&
password === process.env.PASSWORD
) {
return true; return true;
} }
const user = await this.db const user = await this.db
.prepare('SELECT password_hash FROM users WHERE username = $1 AND banned = 0') .prepare(
'SELECT password_hash FROM users WHERE username = $1 AND banned = 0'
)
.bind(userName) .bind(userName)
.first(); .first();
@@ -461,12 +525,16 @@ export class PostgresStorage implements IStorage {
banned: user.banned === 1, banned: user.banned === 1,
tags: user.tags ? JSON.parse(user.tags as string) : undefined, tags: user.tags ? JSON.parse(user.tags as string) : undefined,
oidcSub: user.oidc_sub as string | undefined, oidcSub: user.oidc_sub as string | undefined,
enabledApis: user.enabled_apis ? JSON.parse(user.enabled_apis as string) : undefined, enabledApis: user.enabled_apis
? JSON.parse(user.enabled_apis as string)
: undefined,
created_at: user.created_at as number, created_at: user.created_at as number,
playrecord_migrated: user.playrecord_migrated === 1, playrecord_migrated: user.playrecord_migrated === 1,
favorite_migrated: user.favorite_migrated === 1, favorite_migrated: user.favorite_migrated === 1,
skip_migrated: user.skip_migrated === 1, skip_migrated: user.skip_migrated === 1,
last_movie_request_time: user.last_movie_request_time as number | undefined, last_movie_request_time: user.last_movie_request_time as
| number
| undefined,
email: user.email as string | undefined, email: user.email as string | undefined,
emailNotifications: user.email_notifications === 1, emailNotifications: user.email_notifications === 1,
}; };
@@ -491,13 +559,15 @@ export class PostgresStorage implements IStorage {
// 为站长创建数据库记录 // 为站长创建数据库记录
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO users ( INSERT INTO users (
username, password_hash, role, banned, created_at, username, password_hash, role, banned, created_at,
playrecord_migrated, favorite_migrated, skip_migrated playrecord_migrated, favorite_migrated, skip_migrated
) )
VALUES ($1, $2, $3, 0, $4, 1, 1, 1) VALUES ($1, $2, $3, 0, $4, 1, 1, 1)
`) `
)
.bind( .bind(
userName, userName,
'', // 站长不需要密码哈希 '', // 站长不需要密码哈希
@@ -536,14 +606,16 @@ export class PostgresStorage implements IStorage {
const passwordHash = await this.hashPassword(password); const passwordHash = await this.hashPassword(password);
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO users ( INSERT INTO users (
username, password_hash, role, banned, tags, oidc_sub, username, password_hash, role, banned, tags, oidc_sub,
enabled_apis, created_at, playrecord_migrated, enabled_apis, created_at, playrecord_migrated,
favorite_migrated, skip_migrated favorite_migrated, skip_migrated
) )
VALUES ($1, $2, $3, 0, $4, $5, $6, $7, 1, 1, 1) VALUES ($1, $2, $3, 0, $4, $5, $6, $7, 1, 1, 1)
`) `
)
.bind( .bind(
userName, userName,
passwordHash, passwordHash,
@@ -584,7 +656,9 @@ export class PostgresStorage implements IStorage {
// 获取总数 // 获取总数
const countQuery = trimmedSearch const countQuery = trimmedSearch
? this.db ? this.db
.prepare('SELECT COUNT(*) as total FROM users WHERE username LIKE $1') .prepare(
'SELECT COUNT(*) as total FROM users WHERE username LIKE $1'
)
.bind(searchPattern) .bind(searchPattern)
: this.db.prepare('SELECT COUNT(*) as total FROM users'); : this.db.prepare('SELECT COUNT(*) as total FROM users');
const countResult = await countQuery.first(); const countResult = await countQuery.first();
@@ -627,21 +701,25 @@ export class PostgresStorage implements IStorage {
// 获取用户列表(按创建时间降序) // 获取用户列表(按创建时间降序)
const listQuery = trimmedSearch const listQuery = trimmedSearch
? this.db ? this.db
.prepare(` .prepare(
`
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
FROM users FROM users
WHERE username LIKE $1 WHERE username LIKE $1
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $2 OFFSET $3 LIMIT $2 OFFSET $3
`) `
)
.bind(searchPattern, actualLimit, actualOffset) .bind(searchPattern, actualLimit, actualOffset)
: this.db : this.db
.prepare(` .prepare(
`
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
FROM users FROM users
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $1 OFFSET $2 LIMIT $1 OFFSET $2
`) `
)
.bind(actualLimit, actualOffset); .bind(actualLimit, actualOffset);
const result = await listQuery.all(); const result = await listQuery.all();
@@ -678,7 +756,9 @@ export class PostgresStorage implements IStorage {
banned: user.banned === 1, banned: user.banned === 1,
tags: user.tags ? JSON.parse(user.tags as string) : undefined, tags: user.tags ? JSON.parse(user.tags as string) : undefined,
oidcSub: user.oidc_sub as string | undefined, oidcSub: user.oidc_sub as string | undefined,
enabledApis: user.enabled_apis ? JSON.parse(user.enabled_apis as string) : undefined, enabledApis: user.enabled_apis
? JSON.parse(user.enabled_apis as string)
: undefined,
created_at: user.created_at as number, created_at: user.created_at as number,
}); });
} }
@@ -749,7 +829,11 @@ export class PostgresStorage implements IStorage {
values.push(userName); values.push(userName);
await this.db await this.db
.prepare(`UPDATE users SET ${fields.join(', ')} WHERE username = $${paramIndex}`) .prepare(
`UPDATE users SET ${fields.join(
', '
)} WHERE username = $${paramIndex}`
)
.bind(...values) .bind(...values)
.run(); .run();
@@ -825,10 +909,12 @@ export class PostgresStorage implements IStorage {
try { try {
// Postgres 支持 JSON 查询 // Postgres 支持 JSON 查询
const result = await this.db const result = await this.db
.prepare(` .prepare(
`
SELECT username FROM users SELECT username FROM users
WHERE tags::jsonb ? $1 WHERE tags::jsonb ? $1
`) `
)
.bind(tagName) .bind(tagName)
.all(); .all();
@@ -855,7 +941,10 @@ export class PostgresStorage implements IStorage {
} }
} }
async setUserPasswordHash(userName: string, passwordHash: string): Promise<void> { async setUserPasswordHash(
userName: string,
passwordHash: string
): Promise<void> {
try { try {
await this.db await this.db
.prepare('UPDATE users SET password_hash = $1 WHERE username = $2') .prepare('UPDATE users SET password_hash = $1 WHERE username = $2')
@@ -879,14 +968,16 @@ export class PostgresStorage implements IStorage {
): Promise<void> { ): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO users ( INSERT INTO users (
username, password_hash, role, banned, tags, oidc_sub, username, password_hash, role, banned, tags, oidc_sub,
enabled_apis, created_at, playrecord_migrated, enabled_apis, created_at, playrecord_migrated,
favorite_migrated, skip_migrated favorite_migrated, skip_migrated
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, 1, 1) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, 1, 1)
`) `
)
.bind( .bind(
userName, userName,
passwordHash, passwordHash,
@@ -943,15 +1034,23 @@ export class PostgresStorage implements IStorage {
return result?.email_notifications === 1; return result?.email_notifications === 1;
} catch (err) { } catch (err) {
console.error('PostgresStorage.getEmailNotificationPreference error:', err); console.error(
'PostgresStorage.getEmailNotificationPreference error:',
err
);
return true; // 默认开启 return true; // 默认开启
} }
} }
async setEmailNotificationPreference(userName: string, enabled: boolean): Promise<void> { async setEmailNotificationPreference(
userName: string,
enabled: boolean
): Promise<void> {
try { try {
await this.db await this.db
.prepare('UPDATE users SET email_notifications = $1 WHERE username = $2') .prepare(
'UPDATE users SET email_notifications = $1 WHERE username = $2'
)
.bind(enabled ? 1 : 0, userName) .bind(enabled ? 1 : 0, userName)
.run(); .run();
@@ -959,7 +1058,10 @@ export class PostgresStorage implements IStorage {
const { userInfoCache } = await import('./user-cache'); const { userInfoCache } = await import('./user-cache');
userInfoCache.delete(userName); userInfoCache.delete(userName);
} catch (err) { } catch (err) {
console.error('PostgresStorage.setEmailNotificationPreference error:', err); console.error(
'PostgresStorage.setEmailNotificationPreference error:',
err
);
throw err; throw err;
} }
} }
@@ -969,7 +1071,9 @@ export class PostgresStorage implements IStorage {
async getTvboxSubscribeToken(userName: string): Promise<string | null> { async getTvboxSubscribeToken(userName: string): Promise<string | null> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT tvbox_subscribe_token FROM users_v2 WHERE username = $1') .prepare(
'SELECT tvbox_subscribe_token FROM users_v2 WHERE username = $1'
)
.bind(userName) .bind(userName)
.first(); .first();
@@ -983,7 +1087,9 @@ export class PostgresStorage implements IStorage {
async setTvboxSubscribeToken(userName: string, token: string): Promise<void> { async setTvboxSubscribeToken(userName: string, token: string): Promise<void> {
try { try {
await this.db await this.db
.prepare('UPDATE users_v2 SET tvbox_subscribe_token = $1 WHERE username = $2') .prepare(
'UPDATE users_v2 SET tvbox_subscribe_token = $1 WHERE username = $2'
)
.bind(token, userName) .bind(token, userName)
.run(); .run();
@@ -999,7 +1105,9 @@ export class PostgresStorage implements IStorage {
async getUsernameByTvboxToken(token: string): Promise<string | null> { async getUsernameByTvboxToken(token: string): Promise<string | null> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT username FROM users_v2 WHERE tvbox_subscribe_token = $1') .prepare(
'SELECT username FROM users_v2 WHERE tvbox_subscribe_token = $1'
)
.bind(token) .bind(token)
.first(); .first();
@@ -1015,7 +1123,9 @@ export class PostgresStorage implements IStorage {
async getMusicPlayRecord(userName: string, key: string): Promise<any | null> { async getMusicPlayRecord(userName: string, key: string): Promise<any | null> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT * FROM music_play_records WHERE username = $1 AND key = $2') .prepare(
'SELECT * FROM music_play_records WHERE username = $1 AND key = $2'
)
.bind(userName, key) .bind(userName, key)
.first(); .first();
@@ -1038,10 +1148,15 @@ export class PostgresStorage implements IStorage {
} }
} }
async setMusicPlayRecord(userName: string, key: string, record: any): Promise<void> { async setMusicPlayRecord(
userName: string,
key: string,
record: any
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time) INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT(username, key) DO UPDATE SET ON CONFLICT(username, key) DO UPDATE SET
@@ -1052,7 +1167,8 @@ export class PostgresStorage implements IStorage {
play_time = EXCLUDED.play_time, play_time = EXCLUDED.play_time,
duration = EXCLUDED.duration, duration = EXCLUDED.duration,
save_time = EXCLUDED.save_time save_time = EXCLUDED.save_time
`) `
)
.bind( .bind(
userName, userName,
key, key,
@@ -1073,14 +1189,18 @@ export class PostgresStorage 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; if (records.length === 0) return;
try { try {
// 使用批量插入,Postgres 支持 batch 操作 // 使用批量插入,Postgres 支持 batch 操作
const statements = records.map(({ key, record }) => const statements = records.map(({ key, record }) =>
this.db this.db
.prepare(` .prepare(
`
INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time) INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT(username, key) DO UPDATE SET ON CONFLICT(username, key) DO UPDATE SET
@@ -1093,7 +1213,8 @@ export class PostgresStorage implements IStorage {
play_time = EXCLUDED.play_time, play_time = EXCLUDED.play_time,
duration = EXCLUDED.duration, duration = EXCLUDED.duration,
save_time = EXCLUDED.save_time save_time = EXCLUDED.save_time
`) `
)
.bind( .bind(
userName, userName,
key, key,
@@ -1118,10 +1239,14 @@ export class PostgresStorage implements IStorage {
} }
} }
async getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }> { async getAllMusicPlayRecords(
userName: string
): Promise<{ [key: string]: any }> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM music_play_records WHERE username = $1 ORDER BY save_time DESC') .prepare(
'SELECT * FROM music_play_records WHERE username = $1 ORDER BY save_time DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -1151,7 +1276,9 @@ export class PostgresStorage implements IStorage {
async deleteMusicPlayRecord(userName: string, key: string): Promise<void> { async deleteMusicPlayRecord(userName: string, key: string): Promise<void> {
try { try {
await this.db await this.db
.prepare('DELETE FROM music_play_records WHERE username = $1 AND key = $2') .prepare(
'DELETE FROM music_play_records WHERE username = $1 AND key = $2'
)
.bind(userName, key) .bind(userName, key)
.run(); .run();
} catch (err) { } catch (err) {
@@ -1174,19 +1301,24 @@ export class PostgresStorage implements IStorage {
// ==================== 音乐歌单相关 ==================== // ==================== 音乐歌单相关 ====================
async createMusicPlaylist(userName: string, playlist: { async createMusicPlaylist(
id: string; userName: string,
name: string; playlist: {
description?: string; id: string;
cover?: string; name: string;
}): Promise<void> { description?: string;
cover?: string;
}
): Promise<void> {
try { try {
const now = Date.now(); const now = Date.now();
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO music_playlists (id, username, name, description, cover, created_at, updated_at) INSERT INTO music_playlists (id, username, name, description, cover, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7)
`) `
)
.bind( .bind(
playlist.id, playlist.id,
userName, userName,
@@ -1230,7 +1362,9 @@ export class PostgresStorage implements IStorage {
async getUserMusicPlaylists(userName: string): Promise<any[]> { async getUserMusicPlaylists(userName: string): Promise<any[]> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM music_playlists WHERE username = $1 ORDER BY created_at DESC') .prepare(
'SELECT * FROM music_playlists WHERE username = $1 ORDER BY created_at DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -1251,11 +1385,14 @@ export class PostgresStorage implements IStorage {
} }
} }
async updateMusicPlaylist(playlistId: string, updates: { async updateMusicPlaylist(
name?: string; playlistId: string,
description?: string; updates: {
cover?: string; name?: string;
}): Promise<void> { description?: string;
cover?: string;
}
): Promise<void> {
try { try {
const setClauses: string[] = []; const setClauses: string[] = [];
const values: any[] = []; const values: any[] = [];
@@ -1282,7 +1419,11 @@ export class PostgresStorage implements IStorage {
values.push(playlistId); values.push(playlistId);
await this.db await this.db
.prepare(`UPDATE music_playlists SET ${setClauses.join(', ')} WHERE id = $${paramIndex}`) .prepare(
`UPDATE music_playlists SET ${setClauses.join(
', '
)} WHERE id = $${paramIndex}`
)
.bind(...values) .bind(...values)
.run(); .run();
} catch (err) { } catch (err) {
@@ -1303,28 +1444,34 @@ export class PostgresStorage implements IStorage {
} }
} }
async addSongToPlaylist(playlistId: string, song: { async addSongToPlaylist(
platform: string; playlistId: string,
id: string; song: {
name: string; platform: string;
artist: string; id: string;
album?: string; name: string;
pic?: string; artist: string;
duration: number; album?: string;
}): Promise<void> { pic?: string;
duration: number;
}
): Promise<void> {
try { try {
const now = Date.now(); const now = Date.now();
// 获取当前最大的 sort_order // 获取当前最大的 sort_order
const maxSortResult = await this.db const maxSortResult = await this.db
.prepare('SELECT MAX(sort_order) as max_sort FROM music_playlist_songs WHERE playlist_id = $1') .prepare(
'SELECT MAX(sort_order) as max_sort FROM music_playlist_songs WHERE playlist_id = $1'
)
.bind(playlistId) .bind(playlistId)
.first(); .first();
const nextSortOrder = (maxSortResult?.max_sort as number || 0) + 1; const nextSortOrder = ((maxSortResult?.max_sort as number) || 0) + 1;
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO music_playlist_songs (playlist_id, platform, song_id, name, artist, album, pic, duration, added_at, sort_order) INSERT INTO music_playlist_songs (playlist_id, platform, song_id, name, artist, album, pic, duration, added_at, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT(playlist_id, platform, song_id) DO UPDATE SET ON CONFLICT(playlist_id, platform, song_id) DO UPDATE SET
@@ -1333,7 +1480,8 @@ export class PostgresStorage implements IStorage {
album = EXCLUDED.album, album = EXCLUDED.album,
pic = EXCLUDED.pic, pic = EXCLUDED.pic,
duration = EXCLUDED.duration duration = EXCLUDED.duration
`) `
)
.bind( .bind(
playlistId, playlistId,
song.platform, song.platform,
@@ -1359,10 +1507,16 @@ export class PostgresStorage implements IStorage {
} }
} }
async removeSongFromPlaylist(playlistId: string, platform: string, songId: string): Promise<void> { async removeSongFromPlaylist(
playlistId: string,
platform: string,
songId: string
): Promise<void> {
try { try {
await this.db await this.db
.prepare('DELETE FROM music_playlist_songs WHERE playlist_id = $1 AND platform = $2 AND song_id = $3') .prepare(
'DELETE FROM music_playlist_songs WHERE playlist_id = $1 AND platform = $2 AND song_id = $3'
)
.bind(playlistId, platform, songId) .bind(playlistId, platform, songId)
.run(); .run();
@@ -1380,7 +1534,9 @@ export class PostgresStorage implements IStorage {
async getPlaylistSongs(playlistId: string): Promise<any[]> { async getPlaylistSongs(playlistId: string): Promise<any[]> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM music_playlist_songs WHERE playlist_id = $1 ORDER BY sort_order ASC') .prepare(
'SELECT * FROM music_playlist_songs WHERE playlist_id = $1 ORDER BY sort_order ASC'
)
.bind(playlistId) .bind(playlistId)
.all(); .all();
@@ -1403,11 +1559,16 @@ export class PostgresStorage implements IStorage {
} }
} }
async updatePlaylistSongOrder(playlistId: string, songOrders: Array<{ platform: string; songId: string; sortOrder: number }>): Promise<void> { async updatePlaylistSongOrder(
playlistId: string,
songOrders: Array<{ platform: string; songId: string; sortOrder: number }>
): Promise<void> {
try { try {
const statements = songOrders.map(({ platform, songId, sortOrder }) => const statements = songOrders.map(({ platform, songId, sortOrder }) =>
this.db this.db
.prepare('UPDATE music_playlist_songs SET sort_order = $1 WHERE playlist_id = $2 AND platform = $3 AND song_id = $4') .prepare(
'UPDATE music_playlist_songs SET sort_order = $1 WHERE playlist_id = $2 AND platform = $3 AND song_id = $4'
)
.bind(sortOrder, playlistId, platform, songId) .bind(sortOrder, playlistId, platform, songId)
); );
@@ -1432,7 +1593,9 @@ export class PostgresStorage implements IStorage {
try { try {
const results = await this.db const results = await this.db
// 按队列顺序返回;当前播放项由最大 last_played_at 决定 // 按队列顺序返回;当前播放项由最大 last_played_at 决定
.prepare('SELECT * FROM music_v2_history WHERE username = $1 ORDER BY created_at ASC, id ASC') .prepare(
'SELECT * FROM music_v2_history WHERE username = $1 ORDER BY created_at ASC, id ASC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -1461,10 +1624,14 @@ export class PostgresStorage implements IStorage {
} }
} }
async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise<void> { async upsertMusicV2History(
userName: string,
record: MusicV2HistoryRecord
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO music_v2_history ( INSERT INTO music_v2_history (
username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec, username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec,
play_progress_sec, last_played_at, play_count, last_quality, created_at, updated_at play_progress_sec, last_played_at, play_count, last_quality, created_at, updated_at
@@ -1484,7 +1651,8 @@ export class PostgresStorage implements IStorage {
play_count = EXCLUDED.play_count, play_count = EXCLUDED.play_count,
last_quality = EXCLUDED.last_quality, last_quality = EXCLUDED.last_quality,
updated_at = EXCLUDED.updated_at updated_at = EXCLUDED.updated_at
`) `
)
.bind( .bind(
userName, userName,
record.songId, record.songId,
@@ -1510,7 +1678,10 @@ export class PostgresStorage implements IStorage {
} }
} }
async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise<void> { async batchUpsertMusicV2History(
userName: string,
records: MusicV2HistoryRecord[]
): Promise<void> {
for (const record of records) { for (const record of records) {
await this.upsertMusicV2History(userName, record); await this.upsertMusicV2History(userName, record);
} }
@@ -1518,7 +1689,9 @@ export class PostgresStorage implements IStorage {
async deleteMusicV2History(userName: string, songId: string): Promise<void> { async deleteMusicV2History(userName: string, songId: string): Promise<void> {
await this.db await this.db
.prepare('DELETE FROM music_v2_history WHERE username = $1 AND song_id = $2') .prepare(
'DELETE FROM music_v2_history WHERE username = $1 AND song_id = $2'
)
.bind(userName, songId) .bind(userName, songId)
.run(); .run();
} }
@@ -1532,23 +1705,39 @@ export class PostgresStorage implements IStorage {
// ==================== Music V2 歌单相关 ==================== // ==================== Music V2 歌单相关 ====================
async createMusicV2Playlist(userName: string, playlist: { async createMusicV2Playlist(
id: string; userName: string,
name: string; playlist: {
description?: string; id: string;
cover?: string; name: string;
}): Promise<void> { description?: string;
cover?: string;
}
): Promise<void> {
const now = Date.now(); const now = Date.now();
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO music_v2_playlists (id, username, name, description, cover, song_count, created_at, updated_at) INSERT INTO music_v2_playlists (id, username, name, description, cover, song_count, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`) `
.bind(playlist.id, userName, playlist.name, playlist.description || null, playlist.cover || null, 0, now, now) )
.bind(
playlist.id,
userName,
playlist.name,
playlist.description || null,
playlist.cover || null,
0,
now,
now
)
.run(); .run();
} }
async getMusicV2Playlist(playlistId: string): Promise<MusicV2PlaylistRecord | null> { async getMusicV2Playlist(
playlistId: string
): Promise<MusicV2PlaylistRecord | null> {
const row: any = await this.db const row: any = await this.db
.prepare('SELECT * FROM music_v2_playlists WHERE id = $1') .prepare('SELECT * FROM music_v2_playlists WHERE id = $1')
.bind(playlistId) .bind(playlistId)
@@ -1566,9 +1755,13 @@ export class PostgresStorage implements IStorage {
}; };
} }
async listMusicV2Playlists(userName: string): Promise<MusicV2PlaylistRecord[]> { async listMusicV2Playlists(
userName: string
): Promise<MusicV2PlaylistRecord[]> {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM music_v2_playlists WHERE username = $1 ORDER BY updated_at DESC') .prepare(
'SELECT * FROM music_v2_playlists WHERE username = $1 ORDER BY updated_at DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
if (!results.results) return []; if (!results.results) return [];
@@ -1584,12 +1777,15 @@ export class PostgresStorage implements IStorage {
})); }));
} }
async updateMusicV2Playlist(playlistId: string, updates: { async updateMusicV2Playlist(
name?: string; playlistId: string,
description?: string; updates: {
cover?: string; name?: string;
song_count?: number; description?: string;
}): Promise<void> { cover?: string;
song_count?: number;
}
): Promise<void> {
const clauses: string[] = []; const clauses: string[] = [];
const values: any[] = []; const values: any[] = [];
let index = 1; let index = 1;
@@ -1613,7 +1809,11 @@ export class PostgresStorage implements IStorage {
values.push(Date.now()); values.push(Date.now());
values.push(playlistId); values.push(playlistId);
await this.db await this.db
.prepare(`UPDATE music_v2_playlists SET ${clauses.join(', ')} WHERE id = $${index}`) .prepare(
`UPDATE music_v2_playlists SET ${clauses.join(
', '
)} WHERE id = $${index}`
)
.bind(...values) .bind(...values)
.run(); .run();
} }
@@ -1625,20 +1825,29 @@ export class PostgresStorage implements IStorage {
.run(); .run();
} }
async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise<void> { async addMusicV2PlaylistItem(
playlistId: string,
item: MusicV2PlaylistItem
): Promise<void> {
const playlist = await this.getMusicV2Playlist(playlistId); const playlist = await this.getMusicV2Playlist(playlistId);
if (!playlist) { if (!playlist) {
throw new Error('歌单不存在'); throw new Error('歌单不存在');
} }
const maxSort: any = await this.db const maxSort: any = await this.db
.prepare('SELECT MAX(sort_order) as max_sort FROM music_v2_playlist_items WHERE playlist_id = $1') .prepare(
'SELECT MAX(sort_order) as max_sort FROM music_v2_playlist_items WHERE playlist_id = $1'
)
.bind(playlistId) .bind(playlistId)
.first(); .first();
const nextOrder = Math.max(item.sortOrder || 0, (maxSort?.max_sort as number || 0) + 1); const nextOrder = Math.max(
item.sortOrder || 0,
((maxSort?.max_sort as number) || 0) + 1
);
const now = Date.now(); const now = Date.now();
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO music_v2_playlist_items ( INSERT INTO music_v2_playlist_items (
playlist_id, username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec, sort_order, added_at, updated_at playlist_id, username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec, sort_order, added_at, updated_at
) )
@@ -1653,7 +1862,8 @@ export class PostgresStorage implements IStorage {
duration_text = EXCLUDED.duration_text, duration_text = EXCLUDED.duration_text,
duration_sec = EXCLUDED.duration_sec, duration_sec = EXCLUDED.duration_sec,
updated_at = EXCLUDED.updated_at updated_at = EXCLUDED.updated_at
`) `
)
.bind( .bind(
playlistId, playlistId,
playlist.username, playlist.username,
@@ -1679,9 +1889,14 @@ export class PostgresStorage implements IStorage {
}); });
} }
async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise<void> { async removeMusicV2PlaylistItem(
playlistId: string,
songId: string
): Promise<void> {
await this.db await this.db
.prepare('DELETE FROM music_v2_playlist_items WHERE playlist_id = $1 AND song_id = $2') .prepare(
'DELETE FROM music_v2_playlist_items WHERE playlist_id = $1 AND song_id = $2'
)
.bind(playlistId, songId) .bind(playlistId, songId)
.run(); .run();
const items = await this.listMusicV2PlaylistItems(playlistId); const items = await this.listMusicV2PlaylistItems(playlistId);
@@ -1691,9 +1906,13 @@ export class PostgresStorage implements IStorage {
}); });
} }
async listMusicV2PlaylistItems(playlistId: string): Promise<MusicV2PlaylistItem[]> { async listMusicV2PlaylistItems(
playlistId: string
): Promise<MusicV2PlaylistItem[]> {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM music_v2_playlist_items WHERE playlist_id = $1 ORDER BY sort_order ASC, added_at ASC') .prepare(
'SELECT * FROM music_v2_playlist_items WHERE playlist_id = $1 ORDER BY sort_order ASC, added_at ASC'
)
.bind(playlistId) .bind(playlistId)
.all(); .all();
if (!results.results) return []; if (!results.results) return [];
@@ -1714,9 +1933,14 @@ export class PostgresStorage implements IStorage {
})); }));
} }
async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise<boolean> { async hasMusicV2PlaylistItem(
playlistId: string,
songId: string
): Promise<boolean> {
const row = await this.db const row = await this.db
.prepare('SELECT 1 FROM music_v2_playlist_items WHERE playlist_id = $1 AND song_id = $2 LIMIT 1') .prepare(
'SELECT 1 FROM music_v2_playlist_items WHERE playlist_id = $1 AND song_id = $2 LIMIT 1'
)
.bind(playlistId, songId) .bind(playlistId, songId)
.first(); .first();
return row !== null; return row !== null;
@@ -1727,7 +1951,9 @@ export class PostgresStorage implements IStorage {
async getSearchHistory(userName: string): Promise<string[]> { async getSearchHistory(userName: string): Promise<string[]> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT keyword FROM search_history WHERE username = $1 ORDER BY timestamp DESC LIMIT 20') .prepare(
'SELECT keyword FROM search_history WHERE username = $1 ORDER BY timestamp DESC LIMIT 20'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -1745,24 +1971,29 @@ export class PostgresStorage implements IStorage {
// 插入或更新时间戳 // 插入或更新时间戳
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO search_history (username, keyword, timestamp) INSERT INTO search_history (username, keyword, timestamp)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
ON CONFLICT (username, keyword) DO UPDATE SET timestamp = EXCLUDED.timestamp ON CONFLICT (username, keyword) DO UPDATE SET timestamp = EXCLUDED.timestamp
`) `
)
.bind(userName, keyword, timestamp) .bind(userName, keyword, timestamp)
.run(); .run();
// 保持最多 20 条记录 // 保持最多 20 条记录
const countResult = await this.db const countResult = await this.db
.prepare('SELECT COUNT(*) as count FROM search_history WHERE username = $1') .prepare(
'SELECT COUNT(*) as count FROM search_history WHERE username = $1'
)
.bind(userName) .bind(userName)
.first(); .first();
const count = (countResult?.count as number) || 0; const count = (countResult?.count as number) || 0;
if (count > 20) { if (count > 20) {
await this.db await this.db
.prepare(` .prepare(
`
DELETE FROM search_history DELETE FROM search_history
WHERE username = $1 WHERE username = $1
AND id NOT IN ( AND id NOT IN (
@@ -1771,7 +2002,8 @@ export class PostgresStorage implements IStorage {
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT 20 LIMIT 20
) )
`) `
)
.bind(userName) .bind(userName)
.run(); .run();
} }
@@ -1785,7 +2017,9 @@ export class PostgresStorage implements IStorage {
try { try {
if (keyword) { if (keyword) {
await this.db await this.db
.prepare('DELETE FROM search_history WHERE username = $1 AND keyword = $2') .prepare(
'DELETE FROM search_history WHERE username = $1 AND keyword = $2'
)
.bind(userName, keyword) .bind(userName, keyword)
.run(); .run();
} else { } else {
@@ -1802,7 +2036,10 @@ export class PostgresStorage implements IStorage {
// ==================== 漫画书架 ==================== // ==================== 漫画书架 ====================
async getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null> { async getMangaShelf(
userName: string,
key: string
): Promise<MangaShelfItem | null> {
try { try {
await this.schemaReady; await this.schemaReady;
const result = await this.db const result = await this.db
@@ -1826,11 +2063,13 @@ export class PostgresStorage implements IStorage {
latestChapterId: (result.latest_chapter_id as string) || undefined, latestChapterId: (result.latest_chapter_id as string) || undefined,
latestChapterName: (result.latest_chapter_name as string) || undefined, latestChapterName: (result.latest_chapter_name as string) || undefined,
latestChapterCount: latestChapterCount:
result.latest_chapter_count === null || result.latest_chapter_count === undefined result.latest_chapter_count === null ||
result.latest_chapter_count === undefined
? undefined ? undefined
: Number(result.latest_chapter_count), : Number(result.latest_chapter_count),
unreadChapterCount: unreadChapterCount:
result.unread_chapter_count === null || result.unread_chapter_count === undefined result.unread_chapter_count === null ||
result.unread_chapter_count === undefined
? undefined ? undefined
: Number(result.unread_chapter_count), : Number(result.unread_chapter_count),
}; };
@@ -1840,11 +2079,16 @@ export class PostgresStorage implements IStorage {
} }
} }
async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void> { async setMangaShelf(
userName: string,
key: string,
item: MangaShelfItem
): Promise<void> {
try { try {
await this.schemaReady; await this.schemaReady;
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO manga_shelf ( INSERT INTO manga_shelf (
username, key, source_id, source_name, manga_id, title, cover, save_time, username, key, source_id, source_name, manga_id, title, cover, save_time,
description, author, status, last_chapter_id, last_chapter_name, description, author, status, last_chapter_id, last_chapter_name,
@@ -1867,7 +2111,8 @@ export class PostgresStorage implements IStorage {
latest_chapter_name = EXCLUDED.latest_chapter_name, latest_chapter_name = EXCLUDED.latest_chapter_name,
latest_chapter_count = EXCLUDED.latest_chapter_count, latest_chapter_count = EXCLUDED.latest_chapter_count,
unread_chapter_count = EXCLUDED.unread_chapter_count unread_chapter_count = EXCLUDED.unread_chapter_count
`) `
)
.bind( .bind(
userName, userName,
key, key,
@@ -1894,11 +2139,15 @@ export class PostgresStorage implements IStorage {
} }
} }
async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> { async getAllMangaShelf(
userName: string
): Promise<{ [key: string]: MangaShelfItem }> {
try { try {
await this.schemaReady; await this.schemaReady;
const results = await this.db const results = await this.db
.prepare('SELECT * FROM manga_shelf WHERE username = $1 ORDER BY save_time DESC') .prepare(
'SELECT * FROM manga_shelf WHERE username = $1 ORDER BY save_time DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -1921,11 +2170,13 @@ export class PostgresStorage implements IStorage {
latestChapterId: (row.latest_chapter_id as string) || undefined, latestChapterId: (row.latest_chapter_id as string) || undefined,
latestChapterName: (row.latest_chapter_name as string) || undefined, latestChapterName: (row.latest_chapter_name as string) || undefined,
latestChapterCount: latestChapterCount:
row.latest_chapter_count === null || row.latest_chapter_count === undefined row.latest_chapter_count === null ||
row.latest_chapter_count === undefined
? undefined ? undefined
: Number(row.latest_chapter_count), : Number(row.latest_chapter_count),
unreadChapterCount: unreadChapterCount:
row.unread_chapter_count === null || row.unread_chapter_count === undefined row.unread_chapter_count === null ||
row.unread_chapter_count === undefined
? undefined ? undefined
: Number(row.unread_chapter_count), : Number(row.unread_chapter_count),
}; };
@@ -1952,10 +2203,15 @@ export class PostgresStorage implements IStorage {
// ==================== 漫画阅读历史 ==================== // ==================== 漫画阅读历史 ====================
async getMangaReadRecord(userName: string, key: string): Promise<MangaReadRecord | null> { async getMangaReadRecord(
userName: string,
key: string
): Promise<MangaReadRecord | null> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT * FROM manga_read_records WHERE username = $1 AND key = $2') .prepare(
'SELECT * FROM manga_read_records WHERE username = $1 AND key = $2'
)
.bind(userName, key) .bind(userName, key)
.first(); .first();
@@ -1978,10 +2234,15 @@ export class PostgresStorage implements IStorage {
} }
} }
async setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise<void> { async setMangaReadRecord(
userName: string,
key: string,
record: MangaReadRecord
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO manga_read_records ( INSERT INTO manga_read_records (
username, key, source_id, source_name, manga_id, title, cover, username, key, source_id, source_name, manga_id, title, cover,
chapter_id, chapter_name, page_index, page_count, save_time chapter_id, chapter_name, page_index, page_count, save_time
@@ -1998,7 +2259,8 @@ export class PostgresStorage implements IStorage {
page_index = EXCLUDED.page_index, page_index = EXCLUDED.page_index,
page_count = EXCLUDED.page_count, page_count = EXCLUDED.page_count,
save_time = EXCLUDED.save_time save_time = EXCLUDED.save_time
`) `
)
.bind( .bind(
userName, userName,
key, key,
@@ -2020,10 +2282,14 @@ export class PostgresStorage implements IStorage {
} }
} }
async getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }> { async getAllMangaReadRecords(
userName: string
): Promise<{ [key: string]: MangaReadRecord }> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM manga_read_records WHERE username = $1 ORDER BY save_time DESC') .prepare(
'SELECT * FROM manga_read_records WHERE username = $1 ORDER BY save_time DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -2055,7 +2321,9 @@ export class PostgresStorage implements IStorage {
async deleteMangaReadRecord(userName: string, key: string): Promise<void> { async deleteMangaReadRecord(userName: string, key: string): Promise<void> {
try { try {
await this.db await this.db
.prepare('DELETE FROM manga_read_records WHERE username = $1 AND key = $2') .prepare(
'DELETE FROM manga_read_records WHERE username = $1 AND key = $2'
)
.bind(userName, key) .bind(userName, key)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2066,10 +2334,15 @@ export class PostgresStorage implements IStorage {
async cleanupOldMangaReadRecords(userName: string): Promise<void> { async cleanupOldMangaReadRecords(userName: string): Promise<void> {
try { try {
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; const threshold = maxRecords + 10;
const countResult = await this.db const countResult = await this.db
.prepare('SELECT COUNT(*) as count FROM manga_read_records WHERE username = $1') .prepare(
'SELECT COUNT(*) as count FROM manga_read_records WHERE username = $1'
)
.bind(userName) .bind(userName)
.first(); .first();
@@ -2077,7 +2350,8 @@ export class PostgresStorage implements IStorage {
if (count <= threshold) return; if (count <= threshold) return;
await this.db await this.db
.prepare(` .prepare(
`
DELETE FROM manga_read_records DELETE FROM manga_read_records
WHERE username = $1 WHERE username = $1
AND key NOT IN ( AND key NOT IN (
@@ -2086,7 +2360,8 @@ export class PostgresStorage implements IStorage {
ORDER BY save_time DESC ORDER BY save_time DESC
LIMIT $2 LIMIT $2
) )
`) `
)
.bind(userName, maxRecords) .bind(userName, maxRecords)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2095,10 +2370,12 @@ export class PostgresStorage implements IStorage {
} }
} }
// ==================== 电子书书架 ==================== // ==================== 电子书书架 ====================
async getBookShelf(userName: string, key: string): Promise<BookShelfItem | null> { async getBookShelf(
userName: string,
key: string
): Promise<BookShelfItem | null> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT * FROM book_shelf WHERE username = $1 AND key = $2') .prepare('SELECT * FROM book_shelf WHERE username = $1 AND key = $2')
@@ -2116,9 +2393,18 @@ export class PostgresStorage implements IStorage {
format: (result.format as 'epub' | 'pdf' | null) || undefined, format: (result.format as 'epub' | 'pdf' | null) || undefined,
detailHref: (result.detail_href as string) || undefined, detailHref: (result.detail_href as string) || undefined,
acquisitionHref: (result.acquisition_href as string) || undefined, acquisitionHref: (result.acquisition_href as string) || undefined,
progressPercent: result.progress_percent === null || result.progress_percent === undefined ? undefined : Number(result.progress_percent), progressPercent:
lastReadTime: result.last_read_time === null || result.last_read_time === undefined ? undefined : Number(result.last_read_time), result.progress_percent === null ||
lastLocatorType: (result.last_locator_type as BookShelfItem['lastLocatorType']) || undefined, result.progress_percent === undefined
? undefined
: Number(result.progress_percent),
lastReadTime:
result.last_read_time === null || result.last_read_time === undefined
? undefined
: Number(result.last_read_time),
lastLocatorType:
(result.last_locator_type as BookShelfItem['lastLocatorType']) ||
undefined,
lastLocatorValue: (result.last_locator_value as string) || undefined, lastLocatorValue: (result.last_locator_value as string) || undefined,
lastChapterTitle: (result.last_chapter_title as string) || undefined, lastChapterTitle: (result.last_chapter_title as string) || undefined,
saveTime: Number(result.save_time || 0), saveTime: Number(result.save_time || 0),
@@ -2129,10 +2415,15 @@ export class PostgresStorage implements IStorage {
} }
} }
async setBookShelf(userName: string, key: string, item: BookShelfItem): Promise<void> { async setBookShelf(
userName: string,
key: string,
item: BookShelfItem
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO book_shelf ( INSERT INTO book_shelf (
username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href, username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href,
progress_percent, last_read_time, last_locator_type, last_locator_value, last_chapter_title, save_time progress_percent, last_read_time, last_locator_type, last_locator_value, last_chapter_title, save_time
@@ -2154,12 +2445,26 @@ export class PostgresStorage implements IStorage {
last_locator_value = EXCLUDED.last_locator_value, last_locator_value = EXCLUDED.last_locator_value,
last_chapter_title = EXCLUDED.last_chapter_title, last_chapter_title = EXCLUDED.last_chapter_title,
save_time = EXCLUDED.save_time save_time = EXCLUDED.save_time
`) `
)
.bind( .bind(
userName, key, item.sourceId, item.sourceName, item.bookId, item.title, item.author || null, userName,
item.cover || null, item.format || null, item.detailHref || null, item.acquisitionHref || null, item.progressPercent ?? null, key,
item.lastReadTime ?? null, item.lastLocatorType || null, item.lastLocatorValue || null, item.sourceId,
item.lastChapterTitle || null, item.saveTime item.sourceName,
item.bookId,
item.title,
item.author || null,
item.cover || null,
item.format || null,
item.detailHref || null,
item.acquisitionHref || null,
item.progressPercent ?? null,
item.lastReadTime ?? null,
item.lastLocatorType || null,
item.lastLocatorValue || null,
item.lastChapterTitle || null,
item.saveTime
) )
.run(); .run();
} catch (err) { } catch (err) {
@@ -2168,10 +2473,14 @@ export class PostgresStorage implements IStorage {
} }
} }
async getAllBookShelf(userName: string): Promise<{ [key: string]: BookShelfItem }> { async getAllBookShelf(
userName: string
): Promise<{ [key: string]: BookShelfItem }> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM book_shelf WHERE username = $1 ORDER BY COALESCE(last_read_time, save_time) DESC') .prepare(
'SELECT * FROM book_shelf WHERE username = $1 ORDER BY COALESCE(last_read_time, save_time) DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
const shelves: { [key: string]: BookShelfItem } = {}; const shelves: { [key: string]: BookShelfItem } = {};
@@ -2187,9 +2496,17 @@ export class PostgresStorage implements IStorage {
format: (row.format as 'epub' | 'pdf' | null) || undefined, format: (row.format as 'epub' | 'pdf' | null) || undefined,
detailHref: (row.detail_href as string) || undefined, detailHref: (row.detail_href as string) || undefined,
acquisitionHref: (row.acquisition_href as string) || undefined, acquisitionHref: (row.acquisition_href as string) || undefined,
progressPercent: row.progress_percent === null || row.progress_percent === undefined ? undefined : Number(row.progress_percent), progressPercent:
lastReadTime: row.last_read_time === null || row.last_read_time === undefined ? undefined : Number(row.last_read_time), row.progress_percent === null || row.progress_percent === undefined
lastLocatorType: (row.last_locator_type as BookShelfItem['lastLocatorType']) || undefined, ? undefined
: Number(row.progress_percent),
lastReadTime:
row.last_read_time === null || row.last_read_time === undefined
? undefined
: Number(row.last_read_time),
lastLocatorType:
(row.last_locator_type as BookShelfItem['lastLocatorType']) ||
undefined,
lastLocatorValue: (row.last_locator_value as string) || undefined, lastLocatorValue: (row.last_locator_value as string) || undefined,
lastChapterTitle: (row.last_chapter_title as string) || undefined, lastChapterTitle: (row.last_chapter_title as string) || undefined,
saveTime: Number(row.save_time || 0), saveTime: Number(row.save_time || 0),
@@ -2204,7 +2521,10 @@ export class PostgresStorage implements IStorage {
async deleteBookShelf(userName: string, key: string): Promise<void> { async deleteBookShelf(userName: string, key: string): Promise<void> {
try { try {
await this.db.prepare('DELETE FROM book_shelf WHERE username = $1 AND key = $2').bind(userName, key).run(); await this.db
.prepare('DELETE FROM book_shelf WHERE username = $1 AND key = $2')
.bind(userName, key)
.run();
} catch (err) { } catch (err) {
console.error('PostgresStorage.deleteBookShelf error:', err); console.error('PostgresStorage.deleteBookShelf error:', err);
throw err; throw err;
@@ -2213,10 +2533,15 @@ export class PostgresStorage implements IStorage {
// ==================== 电子书阅读历史 ==================== // ==================== 电子书阅读历史 ====================
async getBookReadRecord(userName: string, key: string): Promise<BookReadRecord | null> { async getBookReadRecord(
userName: string,
key: string
): Promise<BookReadRecord | null> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT * FROM book_read_records WHERE username = $1 AND key = $2') .prepare(
'SELECT * FROM book_read_records WHERE username = $1 AND key = $2'
)
.bind(userName, key) .bind(userName, key)
.first(); .first();
if (!result) return null; if (!result) return null;
@@ -2247,10 +2572,15 @@ export class PostgresStorage implements IStorage {
} }
} }
async setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise<void> { async setBookReadRecord(
userName: string,
key: string,
record: BookReadRecord
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO book_read_records ( INSERT INTO book_read_records (
username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href, username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href,
locator_type, locator_value, chapter_title, chapter_href, progress_percent, save_time locator_type, locator_value, chapter_title, chapter_href, progress_percent, save_time
@@ -2272,12 +2602,26 @@ export class PostgresStorage implements IStorage {
chapter_href = EXCLUDED.chapter_href, chapter_href = EXCLUDED.chapter_href,
progress_percent = EXCLUDED.progress_percent, progress_percent = EXCLUDED.progress_percent,
save_time = EXCLUDED.save_time save_time = EXCLUDED.save_time
`) `
)
.bind( .bind(
userName, key, record.sourceId, record.sourceName, record.bookId, record.title, record.author || null, userName,
record.cover || null, record.format, record.detailHref || null, record.acquisitionHref || null, record.locator.type, record.locator.value, key,
record.chapterTitle || record.locator.chapterTitle || null, record.chapterHref || record.locator.href || null, record.sourceId,
record.progressPercent, record.saveTime record.sourceName,
record.bookId,
record.title,
record.author || null,
record.cover || null,
record.format,
record.detailHref || null,
record.acquisitionHref || null,
record.locator.type,
record.locator.value,
record.chapterTitle || record.locator.chapterTitle || null,
record.chapterHref || record.locator.href || null,
record.progressPercent,
record.saveTime
) )
.run(); .run();
} catch (err) { } catch (err) {
@@ -2286,10 +2630,14 @@ export class PostgresStorage implements IStorage {
} }
} }
async getAllBookReadRecords(userName: string): Promise<{ [key: string]: BookReadRecord }> { async getAllBookReadRecords(
userName: string
): Promise<{ [key: string]: BookReadRecord }> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM book_read_records WHERE username = $1 ORDER BY save_time DESC') .prepare(
'SELECT * FROM book_read_records WHERE username = $1 ORDER BY save_time DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
const records: { [key: string]: BookReadRecord } = {}; const records: { [key: string]: BookReadRecord } = {};
@@ -2326,7 +2674,12 @@ export class PostgresStorage implements IStorage {
async deleteBookReadRecord(userName: string, key: string): Promise<void> { async deleteBookReadRecord(userName: string, key: string): Promise<void> {
try { try {
await this.db.prepare('DELETE FROM book_read_records WHERE username = $1 AND key = $2').bind(userName, key).run(); await this.db
.prepare(
'DELETE FROM book_read_records WHERE username = $1 AND key = $2'
)
.bind(userName, key)
.run();
} catch (err) { } catch (err) {
console.error('PostgresStorage.deleteBookReadRecord error:', err); console.error('PostgresStorage.deleteBookReadRecord error:', err);
throw err; throw err;
@@ -2335,16 +2688,22 @@ export class PostgresStorage implements IStorage {
async cleanupOldBookReadRecords(userName: string): Promise<void> { async cleanupOldBookReadRecords(userName: string): Promise<void> {
try { try {
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; const threshold = maxRecords + 10;
const countResult = await this.db const countResult = await this.db
.prepare('SELECT COUNT(*) as count FROM book_read_records WHERE username = $1') .prepare(
'SELECT COUNT(*) as count FROM book_read_records WHERE username = $1'
)
.bind(userName) .bind(userName)
.first(); .first();
const count = Number(countResult?.count || 0); const count = Number(countResult?.count || 0);
if (count <= threshold) return; if (count <= threshold) return;
await this.db await this.db
.prepare(` .prepare(
`
DELETE FROM book_read_records DELETE FROM book_read_records
WHERE username = $1 WHERE username = $1
AND key NOT IN ( AND key NOT IN (
@@ -2353,7 +2712,8 @@ export class PostgresStorage implements IStorage {
ORDER BY save_time DESC ORDER BY save_time DESC
LIMIT $2 LIMIT $2
) )
`) `
)
.bind(userName, maxRecords) .bind(userName, maxRecords)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2364,7 +2724,11 @@ export class PostgresStorage implements IStorage {
// ==================== 跳过配置 ==================== // ==================== 跳过配置 ====================
async getSkipConfig(userName: string, source: string, id: string): Promise<SkipConfig | null> { async getSkipConfig(
userName: string,
source: string,
id: string
): Promise<SkipConfig | null> {
try { try {
const key = `${source}+${id}`; const key = `${source}+${id}`;
const result = await this.db const result = await this.db
@@ -2384,19 +2748,32 @@ export class PostgresStorage implements IStorage {
} }
} }
async setSkipConfig(userName: string, source: string, id: string, config: SkipConfig): Promise<void> { async setSkipConfig(
userName: string,
source: string,
id: string,
config: SkipConfig
): Promise<void> {
try { try {
const key = `${source}+${id}`; const key = `${source}+${id}`;
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO skip_configs (username, key, enable, intro_time, outro_time) INSERT INTO skip_configs (username, key, enable, intro_time, outro_time)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (username, key) DO UPDATE SET ON CONFLICT (username, key) DO UPDATE SET
enable = EXCLUDED.enable, enable = EXCLUDED.enable,
intro_time = EXCLUDED.intro_time, intro_time = EXCLUDED.intro_time,
outro_time = EXCLUDED.outro_time outro_time = EXCLUDED.outro_time
`) `
.bind(userName, key, config.enable ? 1 : 0, config.intro_time, config.outro_time) )
.bind(
userName,
key,
config.enable ? 1 : 0,
config.intro_time,
config.outro_time
)
.run(); .run();
} catch (err) { } catch (err) {
console.error('PostgresStorage.setSkipConfig error:', err); console.error('PostgresStorage.setSkipConfig error:', err);
@@ -2404,7 +2781,11 @@ export class PostgresStorage implements IStorage {
} }
} }
async deleteSkipConfig(userName: string, source: string, id: string): Promise<void> { async deleteSkipConfig(
userName: string,
source: string,
id: string
): Promise<void> {
try { try {
const key = `${source}+${id}`; const key = `${source}+${id}`;
await this.db await this.db
@@ -2417,7 +2798,9 @@ export class PostgresStorage implements IStorage {
} }
} }
async getAllSkipConfigs(userName: string): Promise<{ [key: string]: SkipConfig }> { async getAllSkipConfigs(
userName: string
): Promise<{ [key: string]: SkipConfig }> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM skip_configs WHERE username = $1') .prepare('SELECT * FROM skip_configs WHERE username = $1')
@@ -2454,7 +2837,9 @@ export class PostgresStorage implements IStorage {
// ==================== 弹幕过滤配置 ==================== // ==================== 弹幕过滤配置 ====================
async getDanmakuFilterConfig(userName: string): Promise<DanmakuFilterConfig | null> { async getDanmakuFilterConfig(
userName: string
): Promise<DanmakuFilterConfig | null> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT rules FROM danmaku_filter_configs WHERE username = $1') .prepare('SELECT rules FROM danmaku_filter_configs WHERE username = $1')
@@ -2469,14 +2854,19 @@ export class PostgresStorage implements IStorage {
} }
} }
async setDanmakuFilterConfig(userName: string, config: DanmakuFilterConfig): Promise<void> { async setDanmakuFilterConfig(
userName: string,
config: DanmakuFilterConfig
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO danmaku_filter_configs (username, rules) INSERT INTO danmaku_filter_configs (username, rules)
VALUES ($1, $2) VALUES ($1, $2)
ON CONFLICT (username) DO UPDATE SET rules = EXCLUDED.rules ON CONFLICT (username) DO UPDATE SET rules = EXCLUDED.rules
`) `
)
.bind(userName, JSON.stringify(config)) .bind(userName, JSON.stringify(config))
.run(); .run();
} catch (err) { } catch (err) {
@@ -2502,7 +2892,9 @@ export class PostgresStorage implements IStorage {
async getNotifications(userName: string): Promise<Notification[]> { async getNotifications(userName: string): Promise<Notification[]> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT * FROM notifications WHERE username = $1 ORDER BY timestamp DESC') .prepare(
'SELECT * FROM notifications WHERE username = $1 ORDER BY timestamp DESC'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -2522,13 +2914,18 @@ export class PostgresStorage implements IStorage {
} }
} }
async addNotification(userName: string, notification: Notification): Promise<void> { async addNotification(
userName: string,
notification: Notification
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO notifications (id, username, type, title, message, timestamp, read, metadata) INSERT INTO notifications (id, username, type, title, message, timestamp, read, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`) `
)
.bind( .bind(
notification.id, notification.id,
userName, userName,
@@ -2546,10 +2943,15 @@ export class PostgresStorage implements IStorage {
} }
} }
async markNotificationAsRead(userName: string, notificationId: string): Promise<void> { async markNotificationAsRead(
userName: string,
notificationId: string
): Promise<void> {
try { try {
await this.db await this.db
.prepare('UPDATE notifications SET read = 1 WHERE username = $1 AND id = $2') .prepare(
'UPDATE notifications SET read = 1 WHERE username = $1 AND id = $2'
)
.bind(userName, notificationId) .bind(userName, notificationId)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2558,7 +2960,10 @@ export class PostgresStorage implements IStorage {
} }
} }
async deleteNotification(userName: string, notificationId: string): Promise<void> { async deleteNotification(
userName: string,
notificationId: string
): Promise<void> {
try { try {
await this.db await this.db
.prepare('DELETE FROM notifications WHERE username = $1 AND id = $2') .prepare('DELETE FROM notifications WHERE username = $1 AND id = $2')
@@ -2585,7 +2990,9 @@ export class PostgresStorage implements IStorage {
async getUnreadNotificationCount(userName: string): Promise<number> { async getUnreadNotificationCount(userName: string): Promise<number> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT COUNT(*) as count FROM notifications WHERE username = $1 AND read = 0') .prepare(
'SELECT COUNT(*) as count FROM notifications WHERE username = $1 AND read = 0'
)
.bind(userName) .bind(userName)
.first(); .first();
@@ -2630,14 +3037,16 @@ export class PostgresStorage implements IStorage {
async createMovieRequest(request: MovieRequest): Promise<void> { async createMovieRequest(request: MovieRequest): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO movie_requests ( INSERT INTO movie_requests (
id, tmdb_id, title, year, media_type, season, poster, overview, id, tmdb_id, title, year, media_type, season, poster, overview,
requested_by, request_count, status, created_at, updated_at, requested_by, request_count, status, created_at, updated_at,
fulfilled_at, fulfilled_source, fulfilled_id fulfilled_at, fulfilled_source, fulfilled_id
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
`) `
)
.bind( .bind(
request.id, request.id,
request.tmdbId || null, request.tmdbId || null,
@@ -2663,7 +3072,10 @@ export class PostgresStorage implements IStorage {
} }
} }
async updateMovieRequest(requestId: string, updates: Partial<MovieRequest>): Promise<void> { async updateMovieRequest(
requestId: string,
updates: Partial<MovieRequest>
): Promise<void> {
try { try {
const fields: string[] = []; const fields: string[] = [];
const values: any[] = []; const values: any[] = [];
@@ -2700,7 +3112,11 @@ export class PostgresStorage implements IStorage {
values.push(requestId); values.push(requestId);
await this.db await this.db
.prepare(`UPDATE movie_requests SET ${fields.join(', ')} WHERE id = $${paramIndex}`) .prepare(
`UPDATE movie_requests SET ${fields.join(
', '
)} WHERE id = $${paramIndex}`
)
.bind(...values) .bind(...values)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2724,7 +3140,9 @@ export class PostgresStorage implements IStorage {
async getUserMovieRequests(userName: string): Promise<string[]> { async getUserMovieRequests(userName: string): Promise<string[]> {
try { try {
const results = await this.db const results = await this.db
.prepare('SELECT request_id FROM user_movie_requests WHERE username = $1') .prepare(
'SELECT request_id FROM user_movie_requests WHERE username = $1'
)
.bind(userName) .bind(userName)
.all(); .all();
@@ -2736,10 +3154,15 @@ export class PostgresStorage implements IStorage {
} }
} }
async addUserMovieRequest(userName: string, requestId: string): Promise<void> { async addUserMovieRequest(
userName: string,
requestId: string
): Promise<void> {
try { try {
await this.db await this.db
.prepare('INSERT INTO user_movie_requests (username, request_id) VALUES ($1, $2) ON CONFLICT (username, request_id) DO NOTHING') .prepare(
'INSERT INTO user_movie_requests (username, request_id) VALUES ($1, $2) ON CONFLICT (username, request_id) DO NOTHING'
)
.bind(userName, requestId) .bind(userName, requestId)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2748,10 +3171,15 @@ export class PostgresStorage implements IStorage {
} }
} }
async removeUserMovieRequest(userName: string, requestId: string): Promise<void> { async removeUserMovieRequest(
userName: string,
requestId: string
): Promise<void> {
try { try {
await this.db await this.db
.prepare('DELETE FROM user_movie_requests WHERE username = $1 AND request_id = $2') .prepare(
'DELETE FROM user_movie_requests WHERE username = $1 AND request_id = $2'
)
.bind(userName, requestId) .bind(userName, requestId)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2800,11 +3228,13 @@ export class PostgresStorage implements IStorage {
async setAdminConfig(config: AdminConfig): Promise<void> { async setAdminConfig(config: AdminConfig): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO admin_config (id, config, updated_at) INSERT INTO admin_config (id, config, updated_at)
VALUES (1, $1, $2) VALUES (1, $1, $2)
ON CONFLICT (id) DO UPDATE SET config = EXCLUDED.config, updated_at = EXCLUDED.updated_at ON CONFLICT (id) DO UPDATE SET config = EXCLUDED.config, updated_at = EXCLUDED.updated_at
`) `
)
.bind(JSON.stringify(config), Date.now()) .bind(JSON.stringify(config), Date.now())
.run(); .run();
} catch (err) { } catch (err) {
@@ -2841,8 +3271,15 @@ export class PostgresStorage implements IStorage {
await this.db.prepare(`DELETE FROM ${table}`).run(); await this.db.prepare(`DELETE FROM ${table}`).run();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
if (message.includes('no such table') || message.includes('does not exist')) { if (
console.warn('PostgresStorage.clearAllData warning:', table, message); message.includes('no such table') ||
message.includes('does not exist')
) {
console.warn(
'PostgresStorage.clearAllData warning:',
table,
message
);
continue; continue;
} }
throw err; throw err;
@@ -2871,11 +3308,13 @@ export class PostgresStorage implements IStorage {
async setGlobalValue(key: string, value: string): Promise<void> { async setGlobalValue(key: string, value: string): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO global_config (key, value, updated_at) INSERT INTO global_config (key, value, updated_at)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at
`) `
)
.bind(key, value, Date.now()) .bind(key, value, Date.now())
.run(); .run();
} catch (err) { } catch (err) {
@@ -2899,7 +3338,9 @@ export class PostgresStorage implements IStorage {
async getLastFavoriteCheckTime(userName: string): Promise<number> { async getLastFavoriteCheckTime(userName: string): Promise<number> {
try { try {
const result = await this.db const result = await this.db
.prepare('SELECT last_check_time FROM favorite_check_times WHERE username = $1') .prepare(
'SELECT last_check_time FROM favorite_check_times WHERE username = $1'
)
.bind(userName) .bind(userName)
.first(); .first();
@@ -2910,14 +3351,19 @@ export class PostgresStorage implements IStorage {
} }
} }
async setLastFavoriteCheckTime(userName: string, timestamp: number): Promise<void> { async setLastFavoriteCheckTime(
userName: string,
timestamp: number
): Promise<void> {
try { try {
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO favorite_check_times (username, last_check_time) INSERT INTO favorite_check_times (username, last_check_time)
VALUES ($1, $2) VALUES ($1, $2)
ON CONFLICT (username) DO UPDATE SET last_check_time = EXCLUDED.last_check_time ON CONFLICT (username) DO UPDATE SET last_check_time = EXCLUDED.last_check_time
`) `
)
.bind(userName, timestamp) .bind(userName, timestamp)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2926,10 +3372,15 @@ export class PostgresStorage implements IStorage {
} }
} }
async updateLastMovieRequestTime(userName: string, timestamp: number): Promise<void> { async updateLastMovieRequestTime(
userName: string,
timestamp: number
): Promise<void> {
try { try {
await this.db await this.db
.prepare('UPDATE users SET last_movie_request_time = $1 WHERE username = $2') .prepare(
'UPDATE users SET last_movie_request_time = $1 WHERE username = $2'
)
.bind(timestamp, userName) .bind(timestamp, userName)
.run(); .run();
} catch (err) { } catch (err) {
@@ -2952,11 +3403,13 @@ class PostgresRedisHashAdapter {
async hSet(hashKey: string, field: string, value: string): Promise<void> { async hSet(hashKey: string, field: string, value: string): Promise<void> {
const key = `${hashKey}:${field}`; const key = `${hashKey}:${field}`;
await this.db await this.db
.prepare(` .prepare(
`
INSERT INTO global_config (key, value, updated_at) INSERT INTO global_config (key, value, updated_at)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at
`) `
)
.bind(key, value, Date.now()) .bind(key, value, Date.now())
.run(); .run();
} }
+580 -196
View File
@@ -5,7 +5,11 @@ import { createClient, RedisClientType } from 'redis';
import { AdminConfig } from './admin.types'; import { AdminConfig } from './admin.types';
import { MangaReadRecord, MangaShelfItem } from './manga.types'; import { MangaReadRecord, MangaShelfItem } from './manga.types';
import { BookReadRecord, BookShelfItem } from './book.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 { RedisAdapter } from './redis-adapter';
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
import { userInfoCache } from './user-cache'; import { userInfoCache } from './user-cache';
@@ -32,7 +36,10 @@ export interface RedisConnectionConfig {
} }
// 添加Redis操作重试包装器 // 添加Redis操作重试包装器
export function createRetryWrapper(clientName: string, getClient: () => RedisClientType) { export function createRetryWrapper(
clientName: string,
getClient: () => RedisClientType
) {
return async function withRetry<T>( return async function withRetry<T>(
operation: () => Promise<T>, operation: () => Promise<T>,
maxRetries = 3 maxRetries = 3
@@ -51,7 +58,9 @@ export function createRetryWrapper(clientName: string, getClient: () => RedisCli
if (isConnectionError && !isLastAttempt) { if (isConnectionError && !isLastAttempt) {
console.log( console.log(
`${clientName} operation failed, retrying... (${i + 1}/${maxRetries})` `${clientName} operation failed, retrying... (${
i + 1
}/${maxRetries})`
); );
console.error('Error:', err.message); 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]; let client: RedisClientType | undefined = (global as any)[globalSymbol];
if (!client) { if (!client) {
@@ -94,9 +106,13 @@ export function createRedisClient(config: RedisConnectionConfig, globalSymbol: s
socket: { socket: {
// 重连策略:指数退避,最大30秒 // 重连策略:指数退避,最大30秒
reconnectStrategy: (retries: number) => { reconnectStrategy: (retries: number) => {
console.log(`${config.clientName} reconnection attempt ${retries + 1}`); console.log(
`${config.clientName} reconnection attempt ${retries + 1}`
);
if (retries > 10) { if (retries > 10) {
console.error(`${config.clientName} max reconnection attempts exceeded`); console.error(
`${config.clientName} max reconnection attempts exceeded`
);
return false; // 停止重连 return false; // 停止重连
} }
return Math.min(1000 * Math.pow(2, retries), 30000); // 指数退避,最大30秒 return Math.min(1000 * Math.pow(2, retries), 30000); // 指数退避,最大30秒
@@ -151,11 +167,20 @@ export function createRedisClient(config: RedisConnectionConfig, globalSymbol: s
// 抽象基类,包含所有通用的Redis操作逻辑 // 抽象基类,包含所有通用的Redis操作逻辑
export abstract class BaseRedisStorage implements IStorage { export abstract class BaseRedisStorage implements IStorage {
protected adapter: RedisAdapter; 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 属性用于向后兼容(数据迁移代码使用)
client: any; 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.adapter = adapter;
this.withRetry = withRetryFn; this.withRetry = withRetryFn;
// 创建兼容层,同时支持驼峰和小写命名(用于数据迁移代码) // 创建兼容层,同时支持驼峰和小写命名(用于数据迁移代码)
@@ -176,8 +201,10 @@ export abstract class BaseRedisStorage implements IStorage {
hget: (key: string, field: string) => this.adapter.hGet(key, field), hget: (key: string, field: string) => this.adapter.hGet(key, field),
hGetAll: (key: string) => this.adapter.hGetAll(key), hGetAll: (key: string) => this.adapter.hGetAll(key),
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 }) =>
zadd: (key: string, member: { score: number; value: string }) => this.adapter.zAdd(key, member), 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), set: (key: string, value: string) => this.adapter.set(key, value),
get: (key: string) => this.adapter.get(key), get: (key: string) => this.adapter.get(key),
del: (...keys: string[]) => this.adapter.del(keys), 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> { 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> { private async doCleanup(userName: string): Promise<void> {
try { try {
// 获取配置的最大播放记录数,默认100 // 获取配置的最大播放记录数,默认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时才触发清理 const threshold = maxRecords + 10; // 超过最大值+10时才触发清理
// 获取所有播放记录 // 获取所有播放记录
@@ -272,7 +312,9 @@ export abstract class BaseRedisStorage implements IStorage {
return; return;
} }
console.log(`用户 ${userName} 的播放记录数 ${recordCount} 超过阈值 ${threshold},开始清理...`); console.log(
`用户 ${userName} 的播放记录数 ${recordCount} 超过阈值 ${threshold},开始清理...`
);
// 将记录转换为数组并按 save_time 排序(从旧到新) // 将记录转换为数组并按 save_time 排序(从旧到新)
const sortedRecords = Object.entries(allRecords).sort( const sortedRecords = Object.entries(allRecords).sort(
@@ -330,13 +372,19 @@ export abstract class BaseRedisStorage implements IStorage {
// 2. 获取旧结构的所有播放记录key // 2. 获取旧结构的所有播放记录key
const pattern = `u:${userName}:pr:*`; 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) { if (oldKeys.length === 0) {
console.log(`用户 ${userName} 没有旧的播放记录,标记为已迁移`); console.log(`用户 ${userName} 没有旧的播放记录,标记为已迁移`);
// 即使没有数据也标记为已迁移 // 即使没有数据也标记为已迁移
await this.withRetry(() => 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'); const { userInfoCache } = await import('./user-cache');
@@ -365,7 +413,9 @@ export abstract class BaseRedisStorage implements IStorage {
await this.withRetry(() => await this.withRetry(() =>
this.adapter.hSet(this.prHashKey(userName), hashData) this.adapter.hSet(this.prHashKey(userName), hashData)
); );
console.log(`成功迁移 ${Object.keys(hashData).length} 条播放记录到hash结构`); console.log(
`成功迁移 ${Object.keys(hashData).length} 条播放记录到hash结构`
);
} }
// 6. 删除旧的key // 6. 删除旧的key
@@ -374,7 +424,11 @@ export abstract class BaseRedisStorage implements IStorage {
// 7. 标记迁移完成 // 7. 标记迁移完成
await this.withRetry(() => await this.withRetry(() =>
this.adapter.hSet(this.userInfoKey(userName), 'playrecord_migrated', 'true') this.adapter.hSet(
this.userInfoKey(userName),
'playrecord_migrated',
'true'
)
); );
// 8. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识 // 8. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识
@@ -407,7 +461,11 @@ export abstract class BaseRedisStorage implements IStorage {
favorite: Favorite favorite: Favorite
): Promise<void> { ): Promise<void> {
await this.withRetry(() => 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> { 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结构 // 迁移收藏:从旧的多key结构迁移到新的hash结构
@@ -464,13 +524,19 @@ export abstract class BaseRedisStorage implements IStorage {
// 2. 获取旧结构的所有收藏key // 2. 获取旧结构的所有收藏key
const pattern = `u:${userName}:fav:*`; 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) { if (oldKeys.length === 0) {
console.log(`用户 ${userName} 没有旧的收藏,标记为已迁移`); console.log(`用户 ${userName} 没有旧的收藏,标记为已迁移`);
// 即使没有数据也标记为已迁移 // 即使没有数据也标记为已迁移
await this.withRetry(() => 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'); const { userInfoCache } = await import('./user-cache');
@@ -530,7 +596,11 @@ export abstract class BaseRedisStorage implements IStorage {
return value ? JSON.parse(value) : null; 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(() => await this.withRetry(() =>
this.adapter.hSet( this.adapter.hSet(
this.musicPlayRecordHashKey(userName), 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; if (records.length === 0) return;
const hashKey = this.musicPlayRecordHashKey(userName); const hashKey = this.musicPlayRecordHashKey(userName);
@@ -550,9 +623,7 @@ export abstract class BaseRedisStorage implements IStorage {
data[key] = JSON.stringify(record); data[key] = JSON.stringify(record);
} }
await this.withRetry(() => await this.withRetry(() => this.adapter.hSet(hashKey, data));
this.adapter.hSet(hashKey, data)
);
} }
async getAllMusicPlayRecords(userName: string): Promise<Record<string, any>> { async getAllMusicPlayRecords(userName: string): Promise<Record<string, any>> {
@@ -594,12 +665,15 @@ export abstract class BaseRedisStorage implements IStorage {
return `music_playlist:${playlistId}:songs`; return `music_playlist:${playlistId}:songs`;
} }
async createMusicPlaylist(userName: string, playlist: { async createMusicPlaylist(
id: string; userName: string,
name: string; playlist: {
description?: string; id: string;
cover?: string; name: string;
}): Promise<void> { description?: string;
cover?: string;
}
): Promise<void> {
const now = Date.now(); const now = Date.now();
const playlistData = { const playlistData = {
id: playlist.id, id: playlist.id,
@@ -664,11 +738,14 @@ export abstract class BaseRedisStorage implements IStorage {
return playlists.sort((a, b) => b.created_at - a.created_at); return playlists.sort((a, b) => b.created_at - a.created_at);
} }
async updateMusicPlaylist(playlistId: string, updates: { async updateMusicPlaylist(
name?: string; playlistId: string,
description?: string; updates: {
cover?: string; name?: string;
}): Promise<void> { description?: string;
cover?: string;
}
): Promise<void> {
const updateData: Record<string, string> = { const updateData: Record<string, string> = {
updated_at: Date.now().toString(), updated_at: Date.now().toString(),
}; };
@@ -709,15 +786,18 @@ export abstract class BaseRedisStorage implements IStorage {
); );
} }
async addSongToPlaylist(playlistId: string, song: { async addSongToPlaylist(
platform: string; playlistId: string,
id: string; song: {
name: string; platform: string;
artist: string; id: string;
album?: string; name: string;
pic?: string; artist: string;
duration: number; album?: string;
}): Promise<void> { pic?: string;
duration: number;
}
): Promise<void> {
const now = Date.now(); const now = Date.now();
const songKey = `${song.platform}+${song.id}`; const songKey = `${song.platform}+${song.id}`;
@@ -734,7 +814,11 @@ export abstract class BaseRedisStorage implements IStorage {
// 添加歌曲到歌单(使用 hash 存储歌曲信息) // 添加歌曲到歌单(使用 hash 存储歌曲信息)
await this.withRetry(() => 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 // 更新歌单的 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}`; const songKey = `${platform}+${songId}`;
await this.withRetry(() => await this.withRetry(() =>
@@ -786,7 +874,11 @@ export abstract class BaseRedisStorage implements IStorage {
return songs.sort((a, b) => a.added_at - b.added_at); 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 songKey = `${platform}+${songId}`;
const exists = await this.withRetry(() => const exists = await this.withRetry(() =>
this.adapter.hGet(this.musicPlaylistSongsKey(playlistId), songKey) this.adapter.hGet(this.musicPlaylistSongsKey(playlistId), songKey)
@@ -804,39 +896,59 @@ export abstract class BaseRedisStorage implements IStorage {
this.adapter.hGetAll(this.musicV2HistoryKey(userName)) this.adapter.hGetAll(this.musicV2HistoryKey(userName))
); );
return Object.values(rows || {}) return (
.filter(Boolean) Object.values(rows || {})
.map(value => JSON.parse(value as string) as MusicV2HistoryRecord) .filter(Boolean)
// 按队列顺序返回;当前播放项由最大 lastPlayedAt 决定。 .map((value) => JSON.parse(value as string) as MusicV2HistoryRecord)
// createdAt 相同时使用歌曲标识做稳定兜底,避免最近播放时间把歌曲顶到队尾 // 按队列顺序返回;当前播放项由最大 lastPlayedAt 决定
.sort((a, b) => { // createdAt 相同时使用歌曲标识做稳定兜底,避免最近播放时间把歌曲顶到队尾。
const createdAtDiff = (a.createdAt || 0) - (b.createdAt || 0); .sort((a, b) => {
if (createdAtDiff !== 0) return createdAtDiff; const createdAtDiff = (a.createdAt || 0) - (b.createdAt || 0);
return `${a.source}:${a.songId}`.localeCompare(`${b.source}:${b.songId}`); 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))
); );
} }
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; if (!records.length) return;
const payload: Record<string, string> = {}; const payload: Record<string, string> = {};
for (const record of records) { for (const record of records) {
payload[record.songId] = JSON.stringify(record); 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> { 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> { 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 歌单 ---------- // ---------- Music V2 歌单 ----------
@@ -852,12 +964,15 @@ export abstract class BaseRedisStorage implements IStorage {
return `music:v2:playlist:${playlistId}:items`; return `music:v2:playlist:${playlistId}:items`;
} }
async createMusicV2Playlist(userName: string, playlist: { async createMusicV2Playlist(
id: string; userName: string,
name: string; playlist: {
description?: string; id: string;
cover?: string; name: string;
}): Promise<void> { description?: string;
cover?: string;
}
): Promise<void> {
const now = Date.now(); const now = Date.now();
const payload = { const payload = {
id: playlist.id, id: playlist.id,
@@ -870,14 +985,23 @@ export abstract class BaseRedisStorage implements IStorage {
updated_at: now.toString(), updated_at: now.toString(),
}; };
await this.withRetry(() => this.adapter.hSet(this.musicV2PlaylistKey(playlist.id), payload));
await this.withRetry(() => 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> { async getMusicV2Playlist(
const data = await this.withRetry(() => this.adapter.hGetAll(this.musicV2PlaylistKey(playlistId))); 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; if (!data || Object.keys(data).length === 0) return null;
return { return {
id: data.id, id: data.id,
@@ -891,8 +1015,12 @@ export abstract class BaseRedisStorage implements IStorage {
}; };
} }
async listMusicV2Playlists(userName: string): Promise<MusicV2PlaylistRecord[]> { async listMusicV2Playlists(
const playlistIds = await this.withRetry(() => this.adapter.zRange(this.musicV2PlaylistsKey(userName), 0, -1)); userName: string
): Promise<MusicV2PlaylistRecord[]> {
const playlistIds = await this.withRetry(() =>
this.adapter.zRange(this.musicV2PlaylistsKey(userName), 0, -1)
);
const playlists: MusicV2PlaylistRecord[] = []; const playlists: MusicV2PlaylistRecord[] = [];
for (const playlistId of playlistIds || []) { for (const playlistId of playlistIds || []) {
const playlist = await this.getMusicV2Playlist(ensureString(playlistId)); 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); return playlists.sort((a, b) => b.updated_at - a.updated_at);
} }
async updateMusicV2Playlist(playlistId: string, updates: { async updateMusicV2Playlist(
name?: string; playlistId: string,
description?: string; updates: {
cover?: string; name?: string;
song_count?: number; description?: string;
}): Promise<void> { cover?: string;
song_count?: number;
}
): Promise<void> {
const payload: Record<string, string> = { const payload: Record<string, string> = {
updated_at: Date.now().toString(), updated_at: Date.now().toString(),
}; };
if (updates.name !== undefined) payload.name = updates.name; 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.cover !== undefined) payload.cover = updates.cover || '';
if (updates.song_count !== undefined) payload.song_count = String(updates.song_count); if (updates.song_count !== undefined)
await this.withRetry(() => this.adapter.hSet(this.musicV2PlaylistKey(playlistId), payload)); payload.song_count = String(updates.song_count);
await this.withRetry(() =>
this.adapter.hSet(this.musicV2PlaylistKey(playlistId), payload)
);
} }
async deleteMusicV2Playlist(playlistId: string): Promise<void> { async deleteMusicV2Playlist(playlistId: string): Promise<void> {
const playlist = await this.getMusicV2Playlist(playlistId); const playlist = await this.getMusicV2Playlist(playlistId);
if (!playlist) return; if (!playlist) return;
await this.withRetry(() => this.adapter.zRem(this.musicV2PlaylistsKey(playlist.username), playlistId)); await this.withRetry(() =>
await this.withRetry(() => this.adapter.del(this.musicV2PlaylistKey(playlistId))); this.adapter.zRem(this.musicV2PlaylistsKey(playlist.username), playlistId)
await this.withRetry(() => this.adapter.del(this.musicV2PlaylistItemsKey(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(() => 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 items = await this.listMusicV2PlaylistItems(playlistId);
const playlist = await this.getMusicV2Playlist(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> { async removeMusicV2PlaylistItem(
await this.withRetry(() => this.adapter.hDel(this.musicV2PlaylistItemsKey(playlistId), songId)); playlistId: string,
songId: string
): Promise<void> {
await this.withRetry(() =>
this.adapter.hDel(this.musicV2PlaylistItemsKey(playlistId), songId)
);
const items = await this.listMusicV2PlaylistItems(playlistId); const items = await this.listMusicV2PlaylistItems(playlistId);
await this.updateMusicV2Playlist(playlistId, { await this.updateMusicV2Playlist(playlistId, {
song_count: items.length, song_count: items.length,
@@ -946,16 +1099,25 @@ export abstract class BaseRedisStorage implements IStorage {
}); });
} }
async listMusicV2PlaylistItems(playlistId: string): Promise<MusicV2PlaylistItem[]> { async listMusicV2PlaylistItems(
const rows = await this.withRetry(() => this.adapter.hGetAll(this.musicV2PlaylistItemsKey(playlistId))); playlistId: string
): Promise<MusicV2PlaylistItem[]> {
const rows = await this.withRetry(() =>
this.adapter.hGetAll(this.musicV2PlaylistItemsKey(playlistId))
);
return Object.values(rows || {}) return Object.values(rows || {})
.filter(Boolean) .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); .sort((a, b) => a.sortOrder - b.sortOrder || a.addedAt - b.addedAt);
} }
async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise<boolean> { async hasMusicV2PlaylistItem(
const exists = await this.withRetry(() => this.adapter.hGet(this.musicV2PlaylistItemsKey(playlistId), songId)); playlistId: string,
songId: string
): Promise<boolean> {
const exists = await this.withRetry(() =>
this.adapter.hGet(this.musicV2PlaylistItemsKey(playlistId), songId)
);
return exists !== null; 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.favHashKey(userName)));
// 删除漫画书架与历史 // 删除漫画书架与历史
await this.withRetry(() => this.adapter.del(this.mangaShelfHashKey(userName))); await this.withRetry(() =>
await this.withRetry(() => this.adapter.del(this.mangaReadHashKey(userName))); this.adapter.del(this.mangaShelfHashKey(userName))
);
await this.withRetry(() =>
this.adapter.del(this.mangaReadHashKey(userName))
);
// 删除旧的收藏key(如果有) // 删除旧的收藏key(如果有)
const favoritePattern = `u:${userName}:fav:*`; 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(() => 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.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 播放记录 // 删除音乐 V2 播放记录
await this.withRetry(() => this.adapter.del(this.musicV2HistoryKey(userName))); await this.withRetry(() =>
this.adapter.del(this.musicV2HistoryKey(userName))
);
// 删除音乐 V2 歌单 // 删除音乐 V2 歌单
const musicV2PlaylistIds = await this.withRetry(() => const musicV2PlaylistIds = await this.withRetry(() =>
@@ -1067,11 +1241,17 @@ export abstract class BaseRedisStorage implements IStorage {
if (musicV2PlaylistIds && musicV2PlaylistIds.length > 0) { if (musicV2PlaylistIds && musicV2PlaylistIds.length > 0) {
for (const playlistId of musicV2PlaylistIds) { for (const playlistId of musicV2PlaylistIds) {
const id = ensureString(playlistId); const id = ensureString(playlistId);
await this.withRetry(() => this.adapter.del(this.musicV2PlaylistKey(id))); await this.withRetry(() =>
await this.withRetry(() => this.adapter.del(this.musicV2PlaylistItemsKey(id))); 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 ---------- // ---------- 新版用户存储(使用Hash和Sorted Set ----------
@@ -1093,7 +1273,7 @@ export abstract class BaseRedisStorage implements IStorage {
const data = encoder.encode(password); const data = encoder.encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer)); 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) { if (oidcSub) {
userInfo.oidcSub = oidcSub; userInfo.oidcSub = oidcSub;
// 创建OIDC映射 // 创建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,按注册时间排序) // 添加到用户列表(Sorted Set,按注册时间排序)
await this.withRetry(() => this.adapter.zAdd(this.userListKey(), { await this.withRetry(() =>
score: createdAt, this.adapter.zAdd(this.userListKey(), {
value: userName, score: createdAt,
})); value: userName,
})
);
// 清除用户信息缓存 // 清除用户信息缓存
userInfoCache?.delete(userName); userInfoCache?.delete(userName);
@@ -1211,13 +1397,17 @@ export abstract class BaseRedisStorage implements IStorage {
skip_migrated: 'true', 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,按注册时间排序) // 添加到用户列表(Sorted Set,按注册时间排序)
await this.withRetry(() => this.adapter.zAdd(this.userListKey(), { await this.withRetry(() =>
score: ownerInfo.created_at, this.adapter.zAdd(this.userListKey(), {
value: userName, score: ownerInfo.created_at,
})); value: userName,
})
);
console.log(`Created database record for site owner: ${userName}`); console.log(`Created database record for site owner: ${userName}`);
} catch (insertErr) { } catch (insertErr) {
@@ -1237,12 +1427,16 @@ export abstract class BaseRedisStorage implements IStorage {
banned: userInfoRaw.banned === 'true', banned: userInfoRaw.banned === 'true',
tags: userInfoRaw.tags ? JSON.parse(userInfoRaw.tags) : undefined, tags: userInfoRaw.tags ? JSON.parse(userInfoRaw.tags) : undefined,
oidcSub: userInfoRaw.oidcSub, 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), created_at: parseInt(userInfoRaw.created_at || '0', 10),
playrecord_migrated: userInfoRaw.playrecord_migrated === 'true', playrecord_migrated: userInfoRaw.playrecord_migrated === 'true',
favorite_migrated: userInfoRaw.favorite_migrated === 'true', favorite_migrated: userInfoRaw.favorite_migrated === 'true',
skip_migrated: userInfoRaw.skip_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, email: userInfoRaw.email,
emailNotifications: userInfoRaw.emailNotifications === 'true', emailNotifications: userInfoRaw.emailNotifications === 'true',
}; };
@@ -1284,7 +1478,9 @@ export abstract class BaseRedisStorage implements IStorage {
userInfo.tags = JSON.stringify(updates.tags); userInfo.tags = JSON.stringify(updates.tags);
} else { } else {
// 删除tags字段 // 删除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); userInfo.enabledApis = JSON.stringify(updates.enabledApis);
} else { } else {
// 删除enabledApis字段 // 删除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); const oldInfo = await this.getUserInfoV2(userName);
if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) { if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) {
// 删除旧的OIDC映射 // 删除旧的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; userInfo.oidcSub = updates.oidcSub;
// 创建新的OIDC映射 // 创建新的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) { 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() || ''; 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; let ownerInfo = null;
@@ -1493,7 +1699,9 @@ export abstract class BaseRedisStorage implements IStorage {
// 删除OIDC映射 // 删除OIDC映射
if (userInfo?.oidcSub) { if (userInfo?.oidcSub) {
await this.withRetry(() => this.adapter.del(this.oidcSubKey(userInfo.oidcSub!))); await this.withRetry(() =>
this.adapter.del(this.oidcSubKey(userInfo.oidcSub!))
);
} }
// 删除用户信息Hash // 删除用户信息Hash
@@ -1525,17 +1733,23 @@ export abstract class BaseRedisStorage implements IStorage {
async addSearchHistory(userName: string, keyword: string): Promise<void> { async addSearchHistory(userName: string, keyword: string): Promise<void> {
const key = this.shKey(userName); 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.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> { async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
const key = this.shKey(userName); const key = this.shKey(userName);
if (keyword) { if (keyword) {
await this.withRetry(() => this.adapter.lRem(key, 0, ensureString(keyword))); await this.withRetry(() =>
this.adapter.lRem(key, 0, ensureString(keyword))
);
} else { } else {
await this.withRetry(() => this.adapter.del(key)); await this.withRetry(() => this.adapter.del(key));
} }
@@ -1546,17 +1760,36 @@ export abstract class BaseRedisStorage implements IStorage {
return `u:${user}:manga:shelf`; return `u:${user}:manga:shelf`;
} }
async getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null> { async getMangaShelf(
const val = await this.withRetry(() => this.adapter.hGet(this.mangaShelfHashKey(userName), key)); 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; return val ? (JSON.parse(val) as MangaShelfItem) : null;
} }
async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void> { async setMangaShelf(
await this.withRetry(() => this.adapter.hSet(this.mangaShelfHashKey(userName), key, JSON.stringify(item))); 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>> { async getAllMangaShelf(
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.mangaShelfHashKey(userName))); userName: string
): Promise<Record<string, MangaShelfItem>> {
const hashData = await this.withRetry(() =>
this.adapter.hGetAll(this.mangaShelfHashKey(userName))
);
const result: Record<string, MangaShelfItem> = {}; const result: Record<string, MangaShelfItem> = {};
for (const [key, value] of Object.entries(hashData)) { for (const [key, value] of Object.entries(hashData)) {
if (value) result[key] = JSON.parse(value) as MangaShelfItem; 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> { 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`; return `u:${user}:manga:history`;
} }
async getMangaReadRecord(userName: string, key: string): Promise<MangaReadRecord | null> { async getMangaReadRecord(
const val = await this.withRetry(() => this.adapter.hGet(this.mangaReadHashKey(userName), key)); 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; return val ? (JSON.parse(val) as MangaReadRecord) : null;
} }
async setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise<void> { async setMangaReadRecord(
await this.withRetry(() => this.adapter.hSet(this.mangaReadHashKey(userName), key, JSON.stringify(record))); 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>> { async getAllMangaReadRecords(
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.mangaReadHashKey(userName))); userName: string
): Promise<Record<string, MangaReadRecord>> {
const hashData = await this.withRetry(() =>
this.adapter.hGetAll(this.mangaReadHashKey(userName))
);
const result: Record<string, MangaReadRecord> = {}; const result: Record<string, MangaReadRecord> = {};
for (const [key, value] of Object.entries(hashData)) { for (const [key, value] of Object.entries(hashData)) {
if (value) result[key] = JSON.parse(value) as MangaReadRecord; 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> { 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> { async cleanupOldMangaReadRecords(userName: string): Promise<void> {
const records = await this.getAllMangaReadRecords(userName); 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; const threshold = maxRecords + 10;
if (Object.keys(records).length <= threshold) return; if (Object.keys(records).length <= threshold) return;
const keys = Object.entries(records) const keys = Object.entries(records)
@@ -1606,27 +1865,47 @@ export abstract class BaseRedisStorage implements IStorage {
.map(([key]) => key); .map(([key]) => key);
if (keys.length > 0) { 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) { private bookShelfHashKey(user: string) {
return `u:${user}:book:shelf`; return `u:${user}:book:shelf`;
} }
async getBookShelf(userName: string, key: string): Promise<BookShelfItem | null> { async getBookShelf(
const val = await this.withRetry(() => this.adapter.hGet(this.bookShelfHashKey(userName), key)); 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; return val ? (JSON.parse(val) as BookShelfItem) : null;
} }
async setBookShelf(userName: string, key: string, item: BookShelfItem): Promise<void> { async setBookShelf(
await this.withRetry(() => this.adapter.hSet(this.bookShelfHashKey(userName), key, JSON.stringify(item))); 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>> { async getAllBookShelf(
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.bookShelfHashKey(userName))); userName: string
): Promise<Record<string, BookShelfItem>> {
const hashData = await this.withRetry(() =>
this.adapter.hGetAll(this.bookShelfHashKey(userName))
);
const result: Record<string, BookShelfItem> = {}; const result: Record<string, BookShelfItem> = {};
for (const [key, value] of Object.entries(hashData)) { for (const [key, value] of Object.entries(hashData)) {
if (value) result[key] = JSON.parse(value) as BookShelfItem; 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> { 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`; return `u:${user}:book:history`;
} }
async getBookReadRecord(userName: string, key: string): Promise<BookReadRecord | null> { async getBookReadRecord(
const val = await this.withRetry(() => this.adapter.hGet(this.bookReadHashKey(userName), key)); 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; return val ? (JSON.parse(val) as BookReadRecord) : null;
} }
async setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise<void> { async setBookReadRecord(
await this.withRetry(() => this.adapter.hSet(this.bookReadHashKey(userName), key, JSON.stringify(record))); 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>> { async getAllBookReadRecords(
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.bookReadHashKey(userName))); userName: string
): Promise<Record<string, BookReadRecord>> {
const hashData = await this.withRetry(() =>
this.adapter.hGetAll(this.bookReadHashKey(userName))
);
const result: Record<string, BookReadRecord> = {}; const result: Record<string, BookReadRecord> = {};
for (const [key, value] of Object.entries(hashData)) { for (const [key, value] of Object.entries(hashData)) {
if (value) result[key] = JSON.parse(value) as BookReadRecord; 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> { 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> { async cleanupOldBookReadRecords(userName: string): Promise<void> {
const records = await this.getAllBookReadRecords(userName); 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; const threshold = maxRecords + 10;
if (Object.keys(records).length <= threshold) return; if (Object.keys(records).length <= threshold) return;
const keys = Object.entries(records) const keys = Object.entries(records)
@@ -1676,7 +1981,9 @@ export abstract class BaseRedisStorage implements IStorage {
.map(([key]) => key); .map(([key]) => key);
if (keys.length > 0) { 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(() => const users = await this.withRetry(() =>
this.adapter.zRange(userListKey, 0, -1) 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; const ownerUsername = process.env.USERNAME;
@@ -1704,7 +2011,9 @@ export abstract class BaseRedisStorage implements IStorage {
} }
async getAdminConfig(): Promise<AdminConfig | null> { 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; return val ? (JSON.parse(val) as AdminConfig) : null;
} }
@@ -1803,7 +2112,9 @@ export abstract class BaseRedisStorage implements IStorage {
} }
const pattern = `u:${userName}:skip:*`; 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) { if (oldKeys.length === 0) {
console.log(`用户 ${userName} 没有旧的跳过配置,标记为已迁移`); console.log(`用户 ${userName} 没有旧的跳过配置,标记为已迁移`);
@@ -1833,7 +2144,9 @@ export abstract class BaseRedisStorage implements IStorage {
await this.withRetry(() => await this.withRetry(() =>
this.adapter.hSet(this.skipHashKey(userName), hashData) 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)); await this.withRetry(() => this.adapter.del(oldKeys));
@@ -1855,7 +2168,9 @@ export abstract class BaseRedisStorage implements IStorage {
const val = await this.withRetry(() => const val = await this.withRetry(() =>
this.adapter.get(this.danmakuFilterConfigKey(userName)) 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( async setDanmakuFilterConfig(
@@ -1928,7 +2243,9 @@ export abstract class BaseRedisStorage implements IStorage {
return `u:${userName}:last_fav_check`; 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(() => const val = await this.withRetry(() =>
this.adapter.get(this.notificationsKey(userName)) this.adapter.get(this.notificationsKey(userName))
); );
@@ -1946,7 +2263,10 @@ export abstract class BaseRedisStorage implements IStorage {
notifications.splice(100); notifications.splice(100);
} }
await this.withRetry(() => 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) { if (notification) {
notification.read = true; notification.read = true;
await this.withRetry(() => 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 notifications = await this.getNotifications(userName);
const filtered = notifications.filter((n) => n.id !== notificationId); const filtered = notifications.filter((n) => n.id !== notificationId);
await this.withRetry(() => 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> { 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> { async getUnreadNotificationCount(userName: string): Promise<number> {
@@ -1996,11 +2324,17 @@ export abstract class BaseRedisStorage implements IStorage {
timestamp: number timestamp: number
): Promise<void> { ): Promise<void> {
await this.withRetry(() => 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(() => await this.withRetry(() =>
this.adapter.hSet( this.adapter.hSet(
this.userInfoKey(userName), this.userInfoKey(userName),
@@ -2020,42 +2354,81 @@ export abstract class BaseRedisStorage implements IStorage {
} }
async getAllMovieRequests(): Promise<import('./types').MovieRequest[]> { 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 []; 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> { async getMovieRequest(
const val = await this.withRetry(() => this.adapter.hGet(this.movieRequestsKey(), requestId)); 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; return val ? (JSON.parse(val) as import('./types').MovieRequest) : null;
} }
async createMovieRequest(request: import('./types').MovieRequest): Promise<void> { async createMovieRequest(
await this.withRetry(() => this.adapter.hSet(this.movieRequestsKey(), request.id, JSON.stringify(request))); 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); const existing = await this.getMovieRequest(requestId);
if (!existing) throw new Error('Movie request not found'); if (!existing) throw new Error('Movie request not found');
const updated = { ...existing, ...updates }; 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> { 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[]> { 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) : []; return val ? ensureStringArray(val) : [];
} }
async addUserMovieRequest(userName: string, requestId: string): Promise<void> { async addUserMovieRequest(
await this.withRetry(() => this.adapter.sAdd(this.userMovieRequestsKey(userName), requestId)); userName: string,
requestId: string
): Promise<void> {
await this.withRetry(() =>
this.adapter.sAdd(this.userMovieRequestsKey(userName), requestId)
);
} }
async removeUserMovieRequest(userName: string, requestId: string): Promise<void> { async removeUserMovieRequest(
await this.withRetry(() => this.adapter.sRem(this.userMovieRequestsKey(userName), requestId)); 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; return userInfo?.emailNotifications || false;
} }
async setEmailNotificationPreference(userName: string, enabled: boolean): Promise<void> { async setEmailNotificationPreference(
userName: string,
enabled: boolean
): Promise<void> {
await this.withRetry(() => await this.withRetry(() =>
this.adapter.hSet(this.userInfoKey(userName), 'emailNotifications', enabled.toString()) this.adapter.hSet(
this.userInfoKey(userName),
'emailNotifications',
enabled.toString()
)
); );
// 清除缓存 // 清除缓存
userInfoCache?.delete(userName); userInfoCache?.delete(userName);
@@ -2097,7 +2477,11 @@ export abstract class BaseRedisStorage implements IStorage {
async setTvboxSubscribeToken(userName: string, token: string): Promise<void> { async setTvboxSubscribeToken(userName: string, token: string): Promise<void> {
// 保存token到用户信息 // 保存token到用户信息
await this.withRetry(() => await this.withRetry(() =>
this.adapter.hSet(this.userInfoKey(userName), 'tvboxSubscribeToken', token) this.adapter.hSet(
this.userInfoKey(userName),
'tvboxSubscribeToken',
token
)
); );
// 创建token到用户名的反向索引 // 创建token到用户名的反向索引
+58 -14
View File
@@ -42,6 +42,7 @@ export interface IStorage {
): Promise<void>; ): Promise<void>;
getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }>; getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }>;
deletePlayRecord(userName: string, key: string): Promise<void>; deletePlayRecord(userName: string, key: string): Promise<void>;
deletePlayRecords(userName: string, keys: string[]): Promise<void>;
// 清理超出限制的旧播放记录 // 清理超出限制的旧播放记录
cleanupOldPlayRecords(userName: string): Promise<void>; cleanupOldPlayRecords(userName: string): Promise<void>;
// 迁移播放记录 // 迁移播放记录
@@ -58,7 +59,10 @@ export interface IStorage {
// 音乐播放记录相关 // 音乐播放记录相关
getMusicPlayRecord(userName: string, key: string): Promise<any | null>; getMusicPlayRecord(userName: string, key: string): Promise<any | null>;
setMusicPlayRecord(userName: string, key: string, record: any): Promise<void>; setMusicPlayRecord(userName: string, key: string, record: any): Promise<void>;
batchSetMusicPlayRecords(userName: string, records: { key: string; record: any }[]): Promise<void>; batchSetMusicPlayRecords(
userName: string,
records: { key: string; record: any }[]
): Promise<void>;
getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }>; getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }>;
deleteMusicPlayRecord(userName: string, key: string): Promise<void>; deleteMusicPlayRecord(userName: string, key: string): Promise<void>;
clearAllMusicPlayRecords(userName: string): Promise<void>; clearAllMusicPlayRecords(userName: string): Promise<void>;
@@ -79,27 +83,55 @@ export interface IStorage {
// 漫画书架相关 // 漫画书架相关
getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null>; getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null>;
setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void>; setMangaShelf(
getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }>; userName: string,
key: string,
item: MangaShelfItem
): Promise<void>;
getAllMangaShelf(
userName: string
): Promise<{ [key: string]: MangaShelfItem }>;
deleteMangaShelf(userName: string, key: string): Promise<void>; deleteMangaShelf(userName: string, key: string): Promise<void>;
// 漫画阅读历史相关 // 漫画阅读历史相关
getMangaReadRecord(userName: string, key: string): Promise<MangaReadRecord | null>; getMangaReadRecord(
setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise<void>; userName: string,
getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }>; key: string
): Promise<MangaReadRecord | null>;
setMangaReadRecord(
userName: string,
key: string,
record: MangaReadRecord
): Promise<void>;
getAllMangaReadRecords(
userName: string
): Promise<{ [key: string]: MangaReadRecord }>;
deleteMangaReadRecord(userName: string, key: string): Promise<void>; deleteMangaReadRecord(userName: string, key: string): Promise<void>;
cleanupOldMangaReadRecords?(userName: string): Promise<void>; cleanupOldMangaReadRecords?(userName: string): Promise<void>;
// 电子书书架相关 // 电子书书架相关
getBookShelf(userName: string, key: string): Promise<BookShelfItem | null>; getBookShelf(userName: string, key: string): Promise<BookShelfItem | null>;
setBookShelf(userName: string, key: string, item: BookShelfItem): Promise<void>; setBookShelf(
userName: string,
key: string,
item: BookShelfItem
): Promise<void>;
getAllBookShelf(userName: string): Promise<{ [key: string]: BookShelfItem }>; getAllBookShelf(userName: string): Promise<{ [key: string]: BookShelfItem }>;
deleteBookShelf(userName: string, key: string): Promise<void>; deleteBookShelf(userName: string, key: string): Promise<void>;
// 电子书阅读历史相关 // 电子书阅读历史相关
getBookReadRecord(userName: string, key: string): Promise<BookReadRecord | null>; getBookReadRecord(
setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise<void>; userName: string,
getAllBookReadRecords(userName: string): Promise<{ [key: string]: BookReadRecord }>; key: string
): Promise<BookReadRecord | null>;
setBookReadRecord(
userName: string,
key: string,
record: BookReadRecord
): Promise<void>;
getAllBookReadRecords(
userName: string
): Promise<{ [key: string]: BookReadRecord }>;
deleteBookReadRecord(userName: string, key: string): Promise<void>; deleteBookReadRecord(userName: string, key: string): Promise<void>;
cleanupOldBookReadRecords?(userName: string): Promise<void>; cleanupOldBookReadRecords?(userName: string): Promise<void>;
@@ -146,7 +178,10 @@ export interface IStorage {
// 通知相关 // 通知相关
getNotifications(userName: string): Promise<Notification[]>; getNotifications(userName: string): Promise<Notification[]>;
addNotification(userName: string, notification: Notification): Promise<void>; addNotification(userName: string, notification: Notification): Promise<void>;
markNotificationAsRead(userName: string, notificationId: string): Promise<void>; markNotificationAsRead(
userName: string,
notificationId: string
): Promise<void>;
deleteNotification(userName: string, notificationId: string): Promise<void>; deleteNotification(userName: string, notificationId: string): Promise<void>;
clearAllNotifications(userName: string): Promise<void>; clearAllNotifications(userName: string): Promise<void>;
getUnreadNotificationCount(userName: string): Promise<number>; getUnreadNotificationCount(userName: string): Promise<number>;
@@ -156,13 +191,19 @@ export interface IStorage {
setLastFavoriteCheckTime(userName: string, timestamp: number): Promise<void>; setLastFavoriteCheckTime(userName: string, timestamp: number): Promise<void>;
// 求片冷却时间 // 求片冷却时间
updateLastMovieRequestTime?(userName: string, timestamp: number): Promise<void>; updateLastMovieRequestTime?(
userName: string,
timestamp: number
): Promise<void>;
// 求片相关 // 求片相关
getAllMovieRequests(): Promise<MovieRequest[]>; getAllMovieRequests(): Promise<MovieRequest[]>;
getMovieRequest(requestId: string): Promise<MovieRequest | null>; getMovieRequest(requestId: string): Promise<MovieRequest | null>;
createMovieRequest(request: MovieRequest): Promise<void>; createMovieRequest(request: MovieRequest): Promise<void>;
updateMovieRequest(requestId: string, updates: Partial<MovieRequest>): Promise<void>; updateMovieRequest(
requestId: string,
updates: Partial<MovieRequest>
): Promise<void>;
deleteMovieRequest(requestId: string): Promise<void>; deleteMovieRequest(requestId: string): Promise<void>;
getUserMovieRequests(userName: string): Promise<string[]>; getUserMovieRequests(userName: string): Promise<string[]>;
addUserMovieRequest(userName: string, requestId: string): Promise<void>; addUserMovieRequest(userName: string, requestId: string): Promise<void>;
@@ -188,7 +229,10 @@ export interface IStorage {
getUserEmail?(userName: string): Promise<string | null>; getUserEmail?(userName: string): Promise<string | null>;
setUserEmail?(userName: string, email: string): Promise<void>; setUserEmail?(userName: string, email: string): Promise<void>;
getEmailNotificationPreference?(userName: string): Promise<boolean>; getEmailNotificationPreference?(userName: string): Promise<boolean>;
setEmailNotificationPreference?(userName: string, enabled: boolean): Promise<void>; setEmailNotificationPreference?(
userName: string,
enabled: boolean
): Promise<void>;
// TVBox订阅token相关 // TVBox订阅token相关
getTvboxSubscribeToken?(userName: string): Promise<string | null>; getTvboxSubscribeToken?(userName: string): Promise<string | null>;