1240 lines
33 KiB
TypeScript
1240 lines
33 KiB
TypeScript
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function */
|
|
'use client';
|
|
|
|
/**
|
|
* 仅在浏览器端使用的数据库工具,目前基于 localStorage 实现。
|
|
* 之所以单独拆分文件,是为了避免在客户端 bundle 中引入 `fs`, `path` 等 Node.js 内置模块,
|
|
* 从而解决诸如 "Module not found: Can't resolve 'fs'" 的问题。
|
|
*
|
|
* 功能:
|
|
* 1. 获取全部播放记录(getAllPlayRecords)。
|
|
* 2. 保存播放记录(savePlayRecord)。
|
|
* 3. D1 存储模式下的混合缓存策略,提升用户体验。
|
|
*
|
|
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
|
|
*/
|
|
|
|
import { getAuthInfoFromBrowserCookie } from './auth';
|
|
|
|
// ---- 类型 ----
|
|
export interface PlayRecord {
|
|
title: string;
|
|
source_name: string;
|
|
year: string;
|
|
cover: string;
|
|
index: number; // 第几集
|
|
total_episodes: number; // 总集数
|
|
play_time: number; // 播放进度(秒)
|
|
total_time: number; // 总进度(秒)
|
|
save_time: number; // 记录保存时间(时间戳)
|
|
search_title?: string; // 搜索时使用的标题
|
|
}
|
|
|
|
// ---- 收藏类型 ----
|
|
export interface Favorite {
|
|
title: string;
|
|
source_name: string;
|
|
year: string;
|
|
cover: string;
|
|
total_episodes: number;
|
|
save_time: number;
|
|
search_title?: string;
|
|
}
|
|
|
|
// ---- 缓存数据结构 ----
|
|
interface CacheData<T> {
|
|
data: T;
|
|
timestamp: number;
|
|
version: string;
|
|
}
|
|
|
|
interface UserCacheStore {
|
|
playRecords?: CacheData<Record<string, PlayRecord>>;
|
|
favorites?: CacheData<Record<string, Favorite>>;
|
|
searchHistory?: CacheData<string[]>;
|
|
}
|
|
|
|
// ---- 常量 ----
|
|
const PLAY_RECORDS_KEY = 'moontv_play_records';
|
|
const FAVORITES_KEY = 'moontv_favorites';
|
|
const SEARCH_HISTORY_KEY = 'moontv_search_history';
|
|
|
|
// 缓存相关常量
|
|
const CACHE_PREFIX = 'moontv_cache_';
|
|
const CACHE_VERSION = '1.0.0';
|
|
const CACHE_EXPIRE_TIME = 60 * 60 * 1000; // 一小时缓存过期
|
|
|
|
// ---- 环境变量 ----
|
|
const STORAGE_TYPE = (() => {
|
|
const raw =
|
|
(typeof window !== 'undefined' &&
|
|
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE) ||
|
|
(process.env.STORAGE_TYPE as 'localstorage' | 'redis' | 'd1' | undefined) ||
|
|
'localstorage';
|
|
return raw;
|
|
})();
|
|
|
|
// ---------------- 搜索历史相关常量 ----------------
|
|
// 搜索历史最大保存条数
|
|
const SEARCH_HISTORY_LIMIT = 20;
|
|
|
|
// ---- 缓存管理器 ----
|
|
class HybridCacheManager {
|
|
private static instance: HybridCacheManager;
|
|
|
|
static getInstance(): HybridCacheManager {
|
|
if (!HybridCacheManager.instance) {
|
|
HybridCacheManager.instance = new HybridCacheManager();
|
|
}
|
|
return HybridCacheManager.instance;
|
|
}
|
|
|
|
/**
|
|
* 获取当前用户名
|
|
*/
|
|
private getCurrentUsername(): string | null {
|
|
const authInfo = getAuthInfoFromBrowserCookie();
|
|
return authInfo?.username || null;
|
|
}
|
|
|
|
/**
|
|
* 生成用户专属的缓存key
|
|
*/
|
|
private getUserCacheKey(username: string): string {
|
|
return `${CACHE_PREFIX}${username}`;
|
|
}
|
|
|
|
/**
|
|
* 获取用户缓存数据
|
|
*/
|
|
private getUserCache(username: string): UserCacheStore {
|
|
if (typeof window === 'undefined') return {};
|
|
|
|
try {
|
|
const cacheKey = this.getUserCacheKey(username);
|
|
const cached = localStorage.getItem(cacheKey);
|
|
return cached ? JSON.parse(cached) : {};
|
|
} catch (error) {
|
|
console.warn('获取用户缓存失败:', error);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 保存用户缓存数据
|
|
*/
|
|
private saveUserCache(username: string, cache: UserCacheStore): void {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
try {
|
|
const cacheKey = this.getUserCacheKey(username);
|
|
localStorage.setItem(cacheKey, JSON.stringify(cache));
|
|
} catch (error) {
|
|
console.warn('保存用户缓存失败:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 检查缓存是否有效
|
|
*/
|
|
private isCacheValid<T>(cache: CacheData<T>): boolean {
|
|
const now = Date.now();
|
|
return (
|
|
cache.version === CACHE_VERSION &&
|
|
now - cache.timestamp < CACHE_EXPIRE_TIME
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 创建缓存数据
|
|
*/
|
|
private createCacheData<T>(data: T): CacheData<T> {
|
|
return {
|
|
data,
|
|
timestamp: Date.now(),
|
|
version: CACHE_VERSION,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 获取缓存的播放记录
|
|
*/
|
|
getCachedPlayRecords(): Record<string, PlayRecord> | null {
|
|
const username = this.getCurrentUsername();
|
|
if (!username) return null;
|
|
|
|
const userCache = this.getUserCache(username);
|
|
const cached = userCache.playRecords;
|
|
|
|
if (cached && this.isCacheValid(cached)) {
|
|
return cached.data;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 缓存播放记录
|
|
*/
|
|
cachePlayRecords(data: Record<string, PlayRecord>): void {
|
|
const username = this.getCurrentUsername();
|
|
if (!username) return;
|
|
|
|
const userCache = this.getUserCache(username);
|
|
userCache.playRecords = this.createCacheData(data);
|
|
this.saveUserCache(username, userCache);
|
|
}
|
|
|
|
/**
|
|
* 获取缓存的收藏
|
|
*/
|
|
getCachedFavorites(): Record<string, Favorite> | null {
|
|
const username = this.getCurrentUsername();
|
|
if (!username) return null;
|
|
|
|
const userCache = this.getUserCache(username);
|
|
const cached = userCache.favorites;
|
|
|
|
if (cached && this.isCacheValid(cached)) {
|
|
return cached.data;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 缓存收藏
|
|
*/
|
|
cacheFavorites(data: Record<string, Favorite>): void {
|
|
const username = this.getCurrentUsername();
|
|
if (!username) return;
|
|
|
|
const userCache = this.getUserCache(username);
|
|
userCache.favorites = this.createCacheData(data);
|
|
this.saveUserCache(username, userCache);
|
|
}
|
|
|
|
/**
|
|
* 获取缓存的搜索历史
|
|
*/
|
|
getCachedSearchHistory(): string[] | null {
|
|
const username = this.getCurrentUsername();
|
|
if (!username) return null;
|
|
|
|
const userCache = this.getUserCache(username);
|
|
const cached = userCache.searchHistory;
|
|
|
|
if (cached && this.isCacheValid(cached)) {
|
|
return cached.data;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 缓存搜索历史
|
|
*/
|
|
cacheSearchHistory(data: string[]): void {
|
|
const username = this.getCurrentUsername();
|
|
if (!username) return;
|
|
|
|
const userCache = this.getUserCache(username);
|
|
userCache.searchHistory = this.createCacheData(data);
|
|
this.saveUserCache(username, userCache);
|
|
}
|
|
|
|
/**
|
|
* 清除指定用户的所有缓存
|
|
*/
|
|
clearUserCache(username?: string): void {
|
|
const targetUsername = username || this.getCurrentUsername();
|
|
if (!targetUsername) return;
|
|
|
|
try {
|
|
const cacheKey = this.getUserCacheKey(targetUsername);
|
|
localStorage.removeItem(cacheKey);
|
|
} catch (error) {
|
|
console.warn('清除用户缓存失败:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清除所有过期缓存
|
|
*/
|
|
clearExpiredCaches(): void {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
try {
|
|
const keysToRemove: string[] = [];
|
|
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
const key = localStorage.key(i);
|
|
if (key?.startsWith(CACHE_PREFIX)) {
|
|
try {
|
|
const cache = JSON.parse(localStorage.getItem(key) || '{}');
|
|
// 检查是否有任何缓存数据过期
|
|
let hasValidData = false;
|
|
for (const [, cacheData] of Object.entries(cache)) {
|
|
if (cacheData && this.isCacheValid(cacheData as CacheData<any>)) {
|
|
hasValidData = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!hasValidData) {
|
|
keysToRemove.push(key);
|
|
}
|
|
} catch {
|
|
// 解析失败的缓存也删除
|
|
keysToRemove.push(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
|
} catch (error) {
|
|
console.warn('清除过期缓存失败:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 获取缓存管理器实例
|
|
const cacheManager = HybridCacheManager.getInstance();
|
|
|
|
// ---- 错误处理辅助函数 ----
|
|
/**
|
|
* 数据库操作失败时的通用错误处理
|
|
* 立即从数据库刷新对应类型的缓存以保持数据一致性
|
|
*/
|
|
async function handleDatabaseOperationFailure(
|
|
dataType: 'playRecords' | 'favorites' | 'searchHistory',
|
|
error: any
|
|
): Promise<void> {
|
|
console.error(`数据库操作失败 (${dataType}):`, error);
|
|
|
|
try {
|
|
let freshData: any;
|
|
let eventName: string;
|
|
|
|
switch (dataType) {
|
|
case 'playRecords':
|
|
freshData = await fetchFromApi<Record<string, PlayRecord>>(
|
|
`/api/playrecords`
|
|
);
|
|
cacheManager.cachePlayRecords(freshData);
|
|
eventName = 'playRecordsUpdated';
|
|
break;
|
|
case 'favorites':
|
|
freshData = await fetchFromApi<Record<string, Favorite>>(
|
|
`/api/favorites`
|
|
);
|
|
cacheManager.cacheFavorites(freshData);
|
|
eventName = 'favoritesUpdated';
|
|
break;
|
|
case 'searchHistory':
|
|
freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
|
|
cacheManager.cacheSearchHistory(freshData);
|
|
eventName = 'searchHistoryUpdated';
|
|
break;
|
|
}
|
|
|
|
// 触发更新事件通知组件
|
|
window.dispatchEvent(
|
|
new CustomEvent(eventName, {
|
|
detail: freshData,
|
|
})
|
|
);
|
|
} catch (refreshErr) {
|
|
console.error(`刷新${dataType}缓存失败:`, refreshErr);
|
|
}
|
|
}
|
|
|
|
// 页面加载时清理过期缓存
|
|
if (typeof window !== 'undefined') {
|
|
setTimeout(() => cacheManager.clearExpiredCaches(), 1000);
|
|
}
|
|
|
|
// ---- 工具函数 ----
|
|
async function fetchFromApi<T>(path: string): Promise<T> {
|
|
const res = await fetch(path);
|
|
if (!res.ok) throw new Error(`请求 ${path} 失败: ${res.status}`);
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
/**
|
|
* 生成存储key
|
|
*/
|
|
export function generateStorageKey(source: string, id: string): string {
|
|
return `${source}+${id}`;
|
|
}
|
|
|
|
// ---- API ----
|
|
/**
|
|
* 读取全部播放记录。
|
|
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
|
* 在服务端渲染阶段 (window === undefined) 时返回空对象,避免报错。
|
|
*/
|
|
export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
|
|
// 服务器端渲染阶段直接返回空,交由客户端 useEffect 再行请求
|
|
if (typeof window === 'undefined') {
|
|
return {};
|
|
}
|
|
|
|
// D1 存储模式:使用混合缓存策略
|
|
if (STORAGE_TYPE !== 'localstorage') {
|
|
// 优先从缓存获取数据
|
|
const cachedData = cacheManager.getCachedPlayRecords();
|
|
|
|
if (cachedData) {
|
|
// 返回缓存数据,同时后台异步更新
|
|
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`)
|
|
.then((freshData) => {
|
|
// 只有数据真正不同时才更新缓存
|
|
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
|
cacheManager.cachePlayRecords(freshData);
|
|
// 触发数据更新事件,供组件监听
|
|
window.dispatchEvent(
|
|
new CustomEvent('playRecordsUpdated', {
|
|
detail: freshData,
|
|
})
|
|
);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.warn('后台同步播放记录失败:', err);
|
|
});
|
|
|
|
return cachedData;
|
|
} else {
|
|
// 缓存为空,直接从 API 获取并缓存
|
|
try {
|
|
const freshData = await fetchFromApi<Record<string, PlayRecord>>(
|
|
`/api/playrecords`
|
|
);
|
|
cacheManager.cachePlayRecords(freshData);
|
|
return freshData;
|
|
} catch (err) {
|
|
console.error('获取播放记录失败:', err);
|
|
return {};
|
|
}
|
|
}
|
|
}
|
|
|
|
// localstorage 模式
|
|
try {
|
|
const raw = localStorage.getItem(PLAY_RECORDS_KEY);
|
|
if (!raw) return {};
|
|
return JSON.parse(raw) as Record<string, PlayRecord>;
|
|
} catch (err) {
|
|
console.error('读取播放记录失败:', err);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 保存播放记录。
|
|
* D1 存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
|
|
*/
|
|
export async function savePlayRecord(
|
|
source: string,
|
|
id: string,
|
|
record: PlayRecord
|
|
): Promise<void> {
|
|
const key = generateStorageKey(source, id);
|
|
|
|
// D1 存储模式:乐观更新策略
|
|
if (STORAGE_TYPE !== 'localstorage') {
|
|
// 立即更新缓存
|
|
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
|
|
cachedRecords[key] = record;
|
|
cacheManager.cachePlayRecords(cachedRecords);
|
|
|
|
// 触发立即更新事件
|
|
window.dispatchEvent(
|
|
new CustomEvent('playRecordsUpdated', {
|
|
detail: cachedRecords,
|
|
})
|
|
);
|
|
|
|
// 异步同步到数据库
|
|
try {
|
|
const res = await fetch('/api/playrecords', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ key, record }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`保存播放记录失败: ${res.status}`);
|
|
}
|
|
} catch (err) {
|
|
await handleDatabaseOperationFailure('playRecords', err);
|
|
throw err;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// localstorage 模式
|
|
if (typeof window === 'undefined') {
|
|
console.warn('无法在服务端保存播放记录到 localStorage');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const allRecords = await getAllPlayRecords();
|
|
allRecords[key] = record;
|
|
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
|
|
window.dispatchEvent(
|
|
new CustomEvent('playRecordsUpdated', {
|
|
detail: allRecords,
|
|
})
|
|
);
|
|
} catch (err) {
|
|
console.error('保存播放记录失败:', err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 删除播放记录。
|
|
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
|
*/
|
|
export async function deletePlayRecord(
|
|
source: string,
|
|
id: string
|
|
): Promise<void> {
|
|
const key = generateStorageKey(source, id);
|
|
|
|
// D1 存储模式:乐观更新策略
|
|
if (STORAGE_TYPE !== 'localstorage') {
|
|
// 立即更新缓存
|
|
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
|
|
delete cachedRecords[key];
|
|
cacheManager.cachePlayRecords(cachedRecords);
|
|
|
|
// 触发立即更新事件
|
|
window.dispatchEvent(
|
|
new CustomEvent('playRecordsUpdated', {
|
|
detail: cachedRecords,
|
|
})
|
|
);
|
|
|
|
// 异步同步到数据库
|
|
try {
|
|
const res = await fetch(
|
|
`/api/playrecords?key=${encodeURIComponent(key)}`,
|
|
{
|
|
method: 'DELETE',
|
|
}
|
|
);
|
|
if (!res.ok) throw new Error(`删除播放记录失败: ${res.status}`);
|
|
} catch (err) {
|
|
await handleDatabaseOperationFailure('playRecords', err);
|
|
throw err;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// localstorage 模式
|
|
if (typeof window === 'undefined') {
|
|
console.warn('无法在服务端删除播放记录到 localStorage');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const allRecords = await getAllPlayRecords();
|
|
delete allRecords[key];
|
|
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
|
|
window.dispatchEvent(
|
|
new CustomEvent('playRecordsUpdated', {
|
|
detail: allRecords,
|
|
})
|
|
);
|
|
} catch (err) {
|
|
console.error('删除播放记录失败:', err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/* ---------------- 搜索历史相关 API ---------------- */
|
|
|
|
/**
|
|
* 获取搜索历史。
|
|
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
|
*/
|
|
export async function getSearchHistory(): Promise<string[]> {
|
|
// 服务器端渲染阶段直接返回空
|
|
if (typeof window === 'undefined') {
|
|
return [];
|
|
}
|
|
|
|
// D1 存储模式:使用混合缓存策略
|
|
if (STORAGE_TYPE !== 'localstorage') {
|
|
// 优先从缓存获取数据
|
|
const cachedData = cacheManager.getCachedSearchHistory();
|
|
|
|
if (cachedData) {
|
|
// 返回缓存数据,同时后台异步更新
|
|
fetchFromApi<string[]>(`/api/searchhistory`)
|
|
.then((freshData) => {
|
|
// 只有数据真正不同时才更新缓存
|
|
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
|
cacheManager.cacheSearchHistory(freshData);
|
|
// 触发数据更新事件
|
|
window.dispatchEvent(
|
|
new CustomEvent('searchHistoryUpdated', {
|
|
detail: freshData,
|
|
})
|
|
);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.warn('后台同步搜索历史失败:', err);
|
|
});
|
|
|
|
return cachedData;
|
|
} else {
|
|
// 缓存为空,直接从 API 获取并缓存
|
|
try {
|
|
const freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
|
|
cacheManager.cacheSearchHistory(freshData);
|
|
return freshData;
|
|
} catch (err) {
|
|
console.error('获取搜索历史失败:', err);
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
// localStorage 模式
|
|
try {
|
|
const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
|
|
if (!raw) return [];
|
|
const arr = JSON.parse(raw) as string[];
|
|
// 仅返回字符串数组
|
|
return Array.isArray(arr) ? arr : [];
|
|
} catch (err) {
|
|
console.error('读取搜索历史失败:', err);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 将关键字添加到搜索历史。
|
|
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
|
*/
|
|
export async function addSearchHistory(keyword: string): Promise<void> {
|
|
const trimmed = keyword.trim();
|
|
if (!trimmed) return;
|
|
|
|
// D1 存储模式:乐观更新策略
|
|
if (STORAGE_TYPE !== 'localstorage') {
|
|
// 立即更新缓存
|
|
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
|
|
const newHistory = [trimmed, ...cachedHistory.filter((k) => k !== trimmed)];
|
|
// 限制长度
|
|
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
|
|
newHistory.length = SEARCH_HISTORY_LIMIT;
|
|
}
|
|
cacheManager.cacheSearchHistory(newHistory);
|
|
|
|
// 触发立即更新事件
|
|
window.dispatchEvent(
|
|
new CustomEvent('searchHistoryUpdated', {
|
|
detail: newHistory,
|
|
})
|
|
);
|
|
|
|
// 异步同步到数据库
|
|
try {
|
|
const res = await fetch('/api/searchhistory', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ keyword: trimmed }),
|
|
});
|
|
if (!res.ok) throw new Error(`保存搜索历史失败: ${res.status}`);
|
|
} catch (err) {
|
|
await handleDatabaseOperationFailure('searchHistory', err);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// localStorage 模式
|
|
if (typeof window === 'undefined') return;
|
|
|
|
try {
|
|
const history = await getSearchHistory();
|
|
const newHistory = [trimmed, ...history.filter((k) => k !== trimmed)];
|
|
// 限制长度
|
|
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
|
|
newHistory.length = SEARCH_HISTORY_LIMIT;
|
|
}
|
|
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory));
|
|
window.dispatchEvent(
|
|
new CustomEvent('searchHistoryUpdated', {
|
|
detail: newHistory,
|
|
})
|
|
);
|
|
} catch (err) {
|
|
console.error('保存搜索历史失败:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清空搜索历史。
|
|
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
|
*/
|
|
export async function clearSearchHistory(): Promise<void> {
|
|
// D1 存储模式:乐观更新策略
|
|
if (STORAGE_TYPE !== 'localstorage') {
|
|
// 立即更新缓存
|
|
cacheManager.cacheSearchHistory([]);
|
|
|
|
// 触发立即更新事件
|
|
window.dispatchEvent(
|
|
new CustomEvent('searchHistoryUpdated', {
|
|
detail: [],
|
|
})
|
|
);
|
|
|
|
// 异步同步到数据库
|
|
try {
|
|
const res = await fetch(`/api/searchhistory`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (!res.ok) throw new Error(`清空搜索历史失败: ${res.status}`);
|
|
} catch (err) {
|
|
await handleDatabaseOperationFailure('searchHistory', err);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// localStorage 模式
|
|
if (typeof window === 'undefined') return;
|
|
localStorage.removeItem(SEARCH_HISTORY_KEY);
|
|
window.dispatchEvent(
|
|
new CustomEvent('searchHistoryUpdated', {
|
|
detail: [],
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 删除单条搜索历史。
|
|
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
|
*/
|
|
export async function deleteSearchHistory(keyword: string): Promise<void> {
|
|
const trimmed = keyword.trim();
|
|
if (!trimmed) return;
|
|
|
|
// D1 存储模式:乐观更新策略
|
|
if (STORAGE_TYPE !== 'localstorage') {
|
|
// 立即更新缓存
|
|
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
|
|
const newHistory = cachedHistory.filter((k) => k !== trimmed);
|
|
cacheManager.cacheSearchHistory(newHistory);
|
|
|
|
// 触发立即更新事件
|
|
window.dispatchEvent(
|
|
new CustomEvent('searchHistoryUpdated', {
|
|
detail: newHistory,
|
|
})
|
|
);
|
|
|
|
// 异步同步到数据库
|
|
try {
|
|
const res = await fetch(
|
|
`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`,
|
|
{
|
|
method: 'DELETE',
|
|
}
|
|
);
|
|
if (!res.ok) throw new Error(`删除搜索历史失败: ${res.status}`);
|
|
} catch (err) {
|
|
await handleDatabaseOperationFailure('searchHistory', err);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// localStorage 模式
|
|
if (typeof window === 'undefined') return;
|
|
|
|
try {
|
|
const history = await getSearchHistory();
|
|
const newHistory = history.filter((k) => k !== trimmed);
|
|
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory));
|
|
window.dispatchEvent(
|
|
new CustomEvent('searchHistoryUpdated', {
|
|
detail: newHistory,
|
|
})
|
|
);
|
|
} catch (err) {
|
|
console.error('删除搜索历史失败:', err);
|
|
}
|
|
}
|
|
|
|
// ---------------- 收藏相关 API ----------------
|
|
|
|
/**
|
|
* 获取全部收藏。
|
|
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
|
*/
|
|
export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
|
// 服务器端渲染阶段直接返回空
|
|
if (typeof window === 'undefined') {
|
|
return {};
|
|
}
|
|
|
|
// D1 存储模式:使用混合缓存策略
|
|
if (STORAGE_TYPE !== 'localstorage') {
|
|
// 优先从缓存获取数据
|
|
const cachedData = cacheManager.getCachedFavorites();
|
|
|
|
if (cachedData) {
|
|
// 返回缓存数据,同时后台异步更新
|
|
fetchFromApi<Record<string, Favorite>>(`/api/favorites`)
|
|
.then((freshData) => {
|
|
// 只有数据真正不同时才更新缓存
|
|
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
|
cacheManager.cacheFavorites(freshData);
|
|
// 触发数据更新事件
|
|
window.dispatchEvent(
|
|
new CustomEvent('favoritesUpdated', {
|
|
detail: freshData,
|
|
})
|
|
);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.warn('后台同步收藏失败:', err);
|
|
});
|
|
|
|
return cachedData;
|
|
} else {
|
|
// 缓存为空,直接从 API 获取并缓存
|
|
try {
|
|
const freshData = await fetchFromApi<Record<string, Favorite>>(
|
|
`/api/favorites`
|
|
);
|
|
cacheManager.cacheFavorites(freshData);
|
|
return freshData;
|
|
} catch (err) {
|
|
console.error('获取收藏失败:', err);
|
|
return {};
|
|
}
|
|
}
|
|
}
|
|
|
|
// localStorage 模式
|
|
try {
|
|
const raw = localStorage.getItem(FAVORITES_KEY);
|
|
if (!raw) return {};
|
|
return JSON.parse(raw) as Record<string, Favorite>;
|
|
} catch (err) {
|
|
console.error('读取收藏失败:', err);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 保存收藏。
|
|
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
|
*/
|
|
export async function saveFavorite(
|
|
source: string,
|
|
id: string,
|
|
favorite: Favorite
|
|
): Promise<void> {
|
|
const key = generateStorageKey(source, id);
|
|
|
|
// D1 存储模式:乐观更新策略
|
|
if (STORAGE_TYPE !== 'localstorage') {
|
|
// 立即更新缓存
|
|
const cachedFavorites = cacheManager.getCachedFavorites() || {};
|
|
cachedFavorites[key] = favorite;
|
|
cacheManager.cacheFavorites(cachedFavorites);
|
|
|
|
// 触发立即更新事件
|
|
window.dispatchEvent(
|
|
new CustomEvent('favoritesUpdated', {
|
|
detail: cachedFavorites,
|
|
})
|
|
);
|
|
|
|
// 异步同步到数据库
|
|
try {
|
|
const res = await fetch('/api/favorites', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ key, favorite }),
|
|
});
|
|
if (!res.ok) throw new Error(`保存收藏失败: ${res.status}`);
|
|
} catch (err) {
|
|
await handleDatabaseOperationFailure('favorites', err);
|
|
throw err;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// localStorage 模式
|
|
if (typeof window === 'undefined') {
|
|
console.warn('无法在服务端保存收藏到 localStorage');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const allFavorites = await getAllFavorites();
|
|
allFavorites[key] = favorite;
|
|
localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites));
|
|
window.dispatchEvent(
|
|
new CustomEvent('favoritesUpdated', {
|
|
detail: allFavorites,
|
|
})
|
|
);
|
|
} catch (err) {
|
|
console.error('保存收藏失败:', err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 删除收藏。
|
|
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
|
*/
|
|
export async function deleteFavorite(
|
|
source: string,
|
|
id: string
|
|
): Promise<void> {
|
|
const key = generateStorageKey(source, id);
|
|
|
|
// D1 存储模式:乐观更新策略
|
|
if (STORAGE_TYPE !== 'localstorage') {
|
|
// 立即更新缓存
|
|
const cachedFavorites = cacheManager.getCachedFavorites() || {};
|
|
delete cachedFavorites[key];
|
|
cacheManager.cacheFavorites(cachedFavorites);
|
|
|
|
// 触发立即更新事件
|
|
window.dispatchEvent(
|
|
new CustomEvent('favoritesUpdated', {
|
|
detail: cachedFavorites,
|
|
})
|
|
);
|
|
|
|
// 异步同步到数据库
|
|
try {
|
|
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (!res.ok) throw new Error(`删除收藏失败: ${res.status}`);
|
|
} catch (err) {
|
|
await handleDatabaseOperationFailure('favorites', err);
|
|
throw err;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// localStorage 模式
|
|
if (typeof window === 'undefined') {
|
|
console.warn('无法在服务端删除收藏到 localStorage');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const allFavorites = await getAllFavorites();
|
|
delete allFavorites[key];
|
|
localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites));
|
|
window.dispatchEvent(
|
|
new CustomEvent('favoritesUpdated', {
|
|
detail: allFavorites,
|
|
})
|
|
);
|
|
} catch (err) {
|
|
console.error('删除收藏失败:', err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 判断是否已收藏。
|
|
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
|
*/
|
|
export async function isFavorited(
|
|
source: string,
|
|
id: string
|
|
): Promise<boolean> {
|
|
const key = generateStorageKey(source, id);
|
|
|
|
// D1 存储模式:使用混合缓存策略
|
|
if (STORAGE_TYPE !== 'localstorage') {
|
|
const cachedFavorites = cacheManager.getCachedFavorites();
|
|
|
|
if (cachedFavorites) {
|
|
// 返回缓存数据,同时后台异步更新
|
|
fetchFromApi<Record<string, Favorite>>(`/api/favorites`)
|
|
.then((freshData) => {
|
|
// 只有数据真正不同时才更新缓存
|
|
if (JSON.stringify(cachedFavorites) !== JSON.stringify(freshData)) {
|
|
cacheManager.cacheFavorites(freshData);
|
|
// 触发数据更新事件
|
|
window.dispatchEvent(
|
|
new CustomEvent('favoritesUpdated', {
|
|
detail: freshData,
|
|
})
|
|
);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.warn('后台同步收藏失败:', err);
|
|
});
|
|
|
|
return !!cachedFavorites[key];
|
|
} else {
|
|
// 缓存为空,直接从 API 获取并缓存
|
|
try {
|
|
const freshData = await fetchFromApi<Record<string, Favorite>>(
|
|
`/api/favorites`
|
|
);
|
|
cacheManager.cacheFavorites(freshData);
|
|
return !!freshData[key];
|
|
} catch (err) {
|
|
console.error('检查收藏状态失败:', err);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// localStorage 模式
|
|
const allFavorites = await getAllFavorites();
|
|
return !!allFavorites[key];
|
|
}
|
|
|
|
/**
|
|
* 清空全部播放记录
|
|
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
|
*/
|
|
export async function clearAllPlayRecords(): Promise<void> {
|
|
// D1 存储模式:乐观更新策略
|
|
if (STORAGE_TYPE !== 'localstorage') {
|
|
// 立即更新缓存
|
|
cacheManager.cachePlayRecords({});
|
|
|
|
// 触发立即更新事件
|
|
window.dispatchEvent(
|
|
new CustomEvent('playRecordsUpdated', {
|
|
detail: {},
|
|
})
|
|
);
|
|
|
|
// 异步同步到数据库
|
|
try {
|
|
const res = await fetch(`/api/playrecords`, {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
if (!res.ok) throw new Error(`清空播放记录失败: ${res.status}`);
|
|
} catch (err) {
|
|
await handleDatabaseOperationFailure('playRecords', err);
|
|
throw err;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// localStorage 模式
|
|
if (typeof window === 'undefined') return;
|
|
localStorage.removeItem(PLAY_RECORDS_KEY);
|
|
window.dispatchEvent(
|
|
new CustomEvent('playRecordsUpdated', {
|
|
detail: {},
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 清空全部收藏
|
|
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
|
*/
|
|
export async function clearAllFavorites(): Promise<void> {
|
|
// D1 存储模式:乐观更新策略
|
|
if (STORAGE_TYPE !== 'localstorage') {
|
|
// 立即更新缓存
|
|
cacheManager.cacheFavorites({});
|
|
|
|
// 触发立即更新事件
|
|
window.dispatchEvent(
|
|
new CustomEvent('favoritesUpdated', {
|
|
detail: {},
|
|
})
|
|
);
|
|
|
|
// 异步同步到数据库
|
|
try {
|
|
const res = await fetch(`/api/favorites`, {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
if (!res.ok) throw new Error(`清空收藏失败: ${res.status}`);
|
|
} catch (err) {
|
|
await handleDatabaseOperationFailure('favorites', err);
|
|
throw err;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// localStorage 模式
|
|
if (typeof window === 'undefined') return;
|
|
localStorage.removeItem(FAVORITES_KEY);
|
|
window.dispatchEvent(
|
|
new CustomEvent('favoritesUpdated', {
|
|
detail: {},
|
|
})
|
|
);
|
|
}
|
|
|
|
// ---------------- 混合缓存辅助函数 ----------------
|
|
|
|
/**
|
|
* 清除当前用户的所有缓存数据
|
|
* 用于用户登出时清理缓存
|
|
*/
|
|
export function clearUserCache(): void {
|
|
if (STORAGE_TYPE === 'd1') {
|
|
cacheManager.clearUserCache();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 手动刷新所有缓存数据
|
|
* 强制从服务器重新获取数据并更新缓存
|
|
*/
|
|
export async function refreshAllCache(): Promise<void> {
|
|
if (STORAGE_TYPE !== 'd1') return;
|
|
|
|
try {
|
|
// 并行刷新所有数据
|
|
const [playRecords, favorites, searchHistory] = await Promise.allSettled([
|
|
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
|
|
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
|
|
fetchFromApi<string[]>(`/api/searchhistory`),
|
|
]);
|
|
|
|
if (playRecords.status === 'fulfilled') {
|
|
cacheManager.cachePlayRecords(playRecords.value);
|
|
window.dispatchEvent(
|
|
new CustomEvent('playRecordsUpdated', {
|
|
detail: playRecords.value,
|
|
})
|
|
);
|
|
}
|
|
|
|
if (favorites.status === 'fulfilled') {
|
|
cacheManager.cacheFavorites(favorites.value);
|
|
window.dispatchEvent(
|
|
new CustomEvent('favoritesUpdated', {
|
|
detail: favorites.value,
|
|
})
|
|
);
|
|
}
|
|
|
|
if (searchHistory.status === 'fulfilled') {
|
|
cacheManager.cacheSearchHistory(searchHistory.value);
|
|
window.dispatchEvent(
|
|
new CustomEvent('searchHistoryUpdated', {
|
|
detail: searchHistory.value,
|
|
})
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.error('刷新缓存失败:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取缓存状态信息
|
|
* 用于调试和监控缓存健康状态
|
|
*/
|
|
export function getCacheStatus(): {
|
|
hasPlayRecords: boolean;
|
|
hasFavorites: boolean;
|
|
hasSearchHistory: boolean;
|
|
username: string | null;
|
|
} {
|
|
if (STORAGE_TYPE !== 'd1') {
|
|
return {
|
|
hasPlayRecords: false,
|
|
hasFavorites: false,
|
|
hasSearchHistory: false,
|
|
username: null,
|
|
};
|
|
}
|
|
|
|
const authInfo = getAuthInfoFromBrowserCookie();
|
|
return {
|
|
hasPlayRecords: !!cacheManager.getCachedPlayRecords(),
|
|
hasFavorites: !!cacheManager.getCachedFavorites(),
|
|
hasSearchHistory: !!cacheManager.getCachedSearchHistory(),
|
|
username: authInfo?.username || null,
|
|
};
|
|
}
|
|
|
|
// ---------------- React Hook 辅助类型 ----------------
|
|
|
|
export type CacheUpdateEvent =
|
|
| 'playRecordsUpdated'
|
|
| 'favoritesUpdated'
|
|
| 'searchHistoryUpdated';
|
|
|
|
/**
|
|
* 用于 React 组件监听数据更新的事件监听器
|
|
* 使用方法:
|
|
*
|
|
* useEffect(() => {
|
|
* const unsubscribe = subscribeToDataUpdates('playRecordsUpdated', (data) => {
|
|
* setPlayRecords(data);
|
|
* });
|
|
* return unsubscribe;
|
|
* }, []);
|
|
*/
|
|
export function subscribeToDataUpdates<T>(
|
|
eventType: CacheUpdateEvent,
|
|
callback: (data: T) => void
|
|
): () => void {
|
|
if (typeof window === 'undefined') {
|
|
return () => {};
|
|
}
|
|
|
|
const handleUpdate = (event: CustomEvent) => {
|
|
callback(event.detail);
|
|
};
|
|
|
|
window.addEventListener(eventType, handleUpdate as EventListener);
|
|
|
|
return () => {
|
|
window.removeEventListener(eventType, handleUpdate as EventListener);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 预加载所有用户数据到缓存
|
|
* 适合在应用启动时调用,提升后续访问速度
|
|
*/
|
|
export async function preloadUserData(): Promise<void> {
|
|
if (STORAGE_TYPE !== 'd1') return;
|
|
|
|
// 检查是否已有有效缓存,避免重复请求
|
|
const status = getCacheStatus();
|
|
if (status.hasPlayRecords && status.hasFavorites && status.hasSearchHistory) {
|
|
return;
|
|
}
|
|
|
|
// 后台静默预加载,不阻塞界面
|
|
refreshAllCache().catch((err) => {
|
|
console.warn('预加载用户数据失败:', err);
|
|
});
|
|
}
|