diff --git a/src/app/api/favorites/check-updates/route.ts b/src/app/api/favorites/check-updates/route.ts new file mode 100644 index 0000000..917650c --- /dev/null +++ b/src/app/api/favorites/check-updates/route.ts @@ -0,0 +1,159 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getStorage } from '@/lib/db'; +import { getAvailableApiSites } from '@/lib/config'; +import { getDetailFromApi } from '@/lib/downstream'; +import { Notification } from '@/lib/types'; + +export const runtime = 'nodejs'; + +export async function POST(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const storage = getStorage(); + const username = authInfo.username; + const now = Date.now(); + + console.log(`用户 ${username} 请求检查收藏更新`); + console.log(`当前时间: ${new Date(now).toLocaleString('zh-CN')}`); + console.log(`开始检查收藏更新...`); + + // 获取所有收藏 + const favorites = await storage.getAllFavorites(username); + const favoriteKeys = Object.keys(favorites); + + if (favoriteKeys.length === 0) { + return NextResponse.json({ + message: '没有收藏', + updates: [], + }); + } + + // 获取可用的 API 站点 + const apiSites = await getAvailableApiSites(username); + + // 检查每个收藏的更新 + const updates: Array<{ + source: string; + id: string; + title: string; + old_episodes: number; + new_episodes: number; + }> = []; + + // 限制并发请求数量,避免过载 + const BATCH_SIZE = 5; + for (let i = 0; i < favoriteKeys.length; i += BATCH_SIZE) { + const batch = favoriteKeys.slice(i, i + BATCH_SIZE); + + await Promise.all( + batch.map(async (key) => { + try { + const favorite = favorites[key]; + + // 跳过 live 类型的收藏 + if (favorite.origin === 'live') { + return; + } + + // 跳过已完结的收藏 + if (favorite.is_completed) { + console.log(`跳过已完结的收藏: ${favorite.title}`); + return; + } + + // 解析 source 和 id + const [source, id] = key.split('+'); + if (!source || !id) { + return; + } + + // 查找对应的 API 站点 + const apiSite = apiSites.find((site) => site.key === source); + if (!apiSite) { + return; + } + + // 获取最新详情 + const detail = await getDetailFromApi(apiSite, id); + + // 比较集数 + const oldEpisodes = favorite.total_episodes; + const newEpisodes = detail.episodes.length; + + console.log(`检查收藏: ${favorite.title} (${source}+${id})`); + console.log(` 旧集数: ${oldEpisodes}, 新集数: ${newEpisodes}`); + console.log(` 是否完结: ${favorite.is_completed}, 备注: ${favorite.vod_remarks}`); + + if (newEpisodes > oldEpisodes) { + updates.push({ + source, + id, + title: favorite.title, + old_episodes: oldEpisodes, + new_episodes: newEpisodes, + }); + + // 更新收藏的集数和完结状态 + await storage.setFavorite(username, key, { + ...favorite, + total_episodes: newEpisodes, + is_completed: detail.vod_remarks + ? ['全', '完结', '大结局', 'end', '完'].some((keyword) => + detail.vod_remarks!.toLowerCase().includes(keyword) + ) + : false, + vod_remarks: detail.vod_remarks, + }); + } + } catch (error) { + console.error(`检查收藏更新失败 (${key}):`, error); + // 继续处理其他收藏 + } + }) + ); + } + + console.log(`检查完成,发现 ${updates.length} 个更新`); + + // 如果有更新,创建通知 + if (updates.length > 0) { + for (const update of updates) { + const notification: Notification = { + id: `fav_update_${update.source}_${update.id}_${now}`, + type: 'favorite_update', + title: '收藏更新', + message: `《${update.title}》有新集数更新!从 ${update.old_episodes} 集更新到 ${update.new_episodes} 集`, + timestamp: now, + read: false, + metadata: { + source: update.source, + id: update.id, + title: update.title, + old_episodes: update.old_episodes, + new_episodes: update.new_episodes, + }, + }; + + await storage.addNotification(username, notification); + } + } + + return NextResponse.json({ + message: updates.length > 0 ? `发现 ${updates.length} 个更新` : '没有更新', + updates, + checked: favoriteKeys.length, + }); + } catch (error) { + console.error('检查收藏更新失败:', error); + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..44531ee --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getStorage } from '@/lib/db'; + +export const runtime = 'nodejs'; + +// GET: 获取所有通知 +export async function GET(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const storage = getStorage(); + const notifications = await storage.getNotifications(authInfo.username); + const unreadCount = await storage.getUnreadNotificationCount(authInfo.username); + + return NextResponse.json({ + notifications, + unreadCount, + }); + } catch (error) { + console.error('获取通知失败:', error); + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} + +// POST: 标记通知为已读或删除通知 +export async function POST(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const body = await request.json(); + const { action, notificationId } = body; + + const storage = getStorage(); + + if (action === 'mark_read' && notificationId) { + await storage.markNotificationAsRead(authInfo.username, notificationId); + return NextResponse.json({ message: '已标记为已读' }); + } + + if (action === 'delete' && notificationId) { + await storage.deleteNotification(authInfo.username, notificationId); + return NextResponse.json({ message: '已删除' }); + } + + if (action === 'clear_all') { + await storage.clearAllNotifications(authInfo.username); + return NextResponse.json({ message: '已清空所有通知' }); + } + + return NextResponse.json({ error: '无效的操作' }, { status: 400 }); + } catch (error) { + console.error('操作通知失败:', error); + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index c89965b..a6afaf3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -57,6 +57,55 @@ function HomeClient() { } }, [announcement]); + // 首次进入时检查收藏更新(带前端冷却检查) + useEffect(() => { + const checkFavoriteUpdates = async () => { + try { + // 检查冷却时间(前端 localStorage) + const COOLDOWN_TIME = 30 * 60 * 1000; // 30分钟 + const lastCheckTime = localStorage.getItem('lastFavoriteCheckTime'); + const now = Date.now(); + + if (lastCheckTime) { + const timeSinceLastCheck = now - parseInt(lastCheckTime, 10); + if (timeSinceLastCheck < COOLDOWN_TIME) { + const remainingMinutes = Math.ceil((COOLDOWN_TIME - timeSinceLastCheck) / 1000 / 60); + console.log(`收藏更新检查冷却中,还需等待 ${remainingMinutes} 分钟`); + return; + } + } + + console.log('开始检查收藏更新...'); + const response = await fetch('/api/favorites/check-updates', { + method: 'POST', + }); + + if (response.ok) { + // 更新本地检查时间 + localStorage.setItem('lastFavoriteCheckTime', now.toString()); + + const data = await response.json(); + if (data.updates && data.updates.length > 0) { + console.log(`发现 ${data.updates.length} 个收藏更新`); + // 触发通知更新事件 + window.dispatchEvent(new Event('notificationsUpdated')); + } else { + console.log('没有收藏更新'); + } + } + } catch (error) { + console.error('检查收藏更新失败:', error); + } + }; + + // 延迟3秒后检查,避免影响首页加载 + const timer = setTimeout(() => { + checkFavoriteUpdates(); + }, 3000); + + return () => clearTimeout(timer); + }, []); + // 收藏夹数据 type FavoriteItem = { id: string; diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 639e8bc..a0e0da2 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -602,6 +602,39 @@ function PlayPageClient() { // 工具函数(Utils) // ----------------------------------------------------------------------------- + // 判断剧集是否已完结 + const isSeriesCompleted = (detail: SearchResult | null): boolean => { + if (!detail) return false; + + // 方法1:通过 vod_remarks 判断 + if (detail.vod_remarks) { + const remarks = detail.vod_remarks.toLowerCase(); + // 判定为完结的关键词 + const completedKeywords = ['全', '完结', '大结局', 'end', '完']; + // 判定为连载的关键词 + const ongoingKeywords = ['更新至', '连载', '第', '更新到']; + + // 如果包含连载关键词,则为连载中 + if (ongoingKeywords.some(keyword => remarks.includes(keyword))) { + return false; + } + + // 如果包含完结关键词,则为已完结 + if (completedKeywords.some(keyword => remarks.includes(keyword))) { + return true; + } + } + + // 方法2:通过 vod_total 和实际集数对比判断 + if (detail.vod_total && detail.vod_total > 0 && detail.episodes && detail.episodes.length > 0) { + // 如果实际集数 >= 总集数,则为已完结 + return detail.episodes.length >= detail.vod_total; + } + + // 无法判断,默认返回 false(连载中) + return false; + }; + // 播放源优选函数 const preferBestSource = async ( sources: SearchResult[] @@ -2758,11 +2791,13 @@ function PlayPageClient() { await saveFavorite(currentSourceRef.current, currentIdRef.current, { title: videoTitleRef.current, source_name: detailRef.current?.source_name || '', - year: detailRef.current?.year, + year: detailRef.current?.year || 'unknown', cover: detailRef.current?.poster || '', total_episodes: detailRef.current?.episodes.length || 1, save_time: Date.now(), search_title: searchTitle, + is_completed: isSeriesCompleted(detailRef.current), + vod_remarks: detailRef.current?.vod_remarks, }); setFavorited(true); } @@ -4288,14 +4323,28 @@ function PlayPageClient() {
{/* 第一行:影片标题 */}
-

- {videoTitle || '影片标题'} - {totalEpisodes > 1 && ( - - {` > ${ - detail?.episodes_titles?.[currentEpisodeIndex] || - `第 ${currentEpisodeIndex + 1} 集` +

+ + {videoTitle || '影片标题'} + {totalEpisodes > 1 && ( + + {` > ${ + detail?.episodes_titles?.[currentEpisodeIndex] || + `第 ${currentEpisodeIndex + 1} 集` + }`} + + )} + + {/* 完结状态标识 */} + {detail && totalEpisodes > 1 && ( + + {isSeriesCompleted(detail) ? '已完结' : '连载中'} )}

diff --git a/src/components/NotificationPanel.tsx b/src/components/NotificationPanel.tsx new file mode 100644 index 0000000..996cdba --- /dev/null +++ b/src/components/NotificationPanel.tsx @@ -0,0 +1,243 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +'use client'; + +import { Bell, Check, Trash2, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +import { Notification } from '@/lib/types'; + +interface NotificationPanelProps { + isOpen: boolean; + onClose: () => void; +} + +export const NotificationPanel: React.FC = ({ + isOpen, + onClose, +}) => { + const router = useRouter(); + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(false); + + // 加载通知 + const loadNotifications = async () => { + setLoading(true); + try { + const response = await fetch('/api/notifications'); + if (response.ok) { + const data = await response.json(); + setNotifications(data.notifications || []); + } + } catch (error) { + console.error('加载通知失败:', error); + } finally { + setLoading(false); + } + }; + + // 标记为已读 + const markAsRead = async (notificationId: string) => { + try { + const response = await fetch('/api/notifications', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'mark_read', + notificationId, + }), + }); + + if (response.ok) { + setNotifications((prev) => + prev.map((n) => + n.id === notificationId ? { ...n, read: true } : n + ) + ); + } + } catch (error) { + console.error('标记已读失败:', error); + } + }; + + // 删除通知 + const deleteNotification = async (notificationId: string) => { + try { + const response = await fetch('/api/notifications', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'delete', + notificationId, + }), + }); + + if (response.ok) { + setNotifications((prev) => prev.filter((n) => n.id !== notificationId)); + } + } catch (error) { + console.error('删除通知失败:', error); + } + }; + + // 清空所有通知 + const clearAll = async () => { + try { + const response = await fetch('/api/notifications', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'clear_all', + }), + }); + + if (response.ok) { + setNotifications([]); + } + } catch (error) { + console.error('清空通知失败:', error); + } + }; + + // 处理通知点击 + const handleNotificationClick = (notification: Notification) => { + // 标记为已读 + if (!notification.read) { + markAsRead(notification.id); + } + + // 根据通知类型跳转 + if (notification.type === 'favorite_update' && notification.metadata) { + const { source, id } = notification.metadata; + router.push(`/play?source=${source}&id=${id}`); + onClose(); + } + }; + + // 打开面板时加载通知 + useEffect(() => { + if (isOpen) { + loadNotifications(); + } + }, [isOpen]); + + return ( + <> + {/* 背景遮罩 */} +
+ + {/* 通知面板 */} +
+ {/* 标题栏 */} +
+
+ +

+ 通知中心 +

+ {notifications.length > 0 && ( + + {notifications.filter((n) => !n.read).length} 条未读 + + )} +
+
+ {notifications.length > 0 && ( + + )} + +
+
+ + {/* 通知列表 */} +
+ {loading ? ( +
+
+
+ ) : notifications.length === 0 ? ( +
+ +

暂无通知

+
+ ) : ( +
+ {notifications.map((notification) => ( +
handleNotificationClick(notification)} + > + {/* 未读标识 */} + {!notification.read && ( +
+ )} + + {/* 通知内容 */} +
+
+

+ {notification.title} +

+
+

+ {notification.message} +

+

+ {new Date(notification.timestamp).toLocaleString('zh-CN')} +

+
+ + {/* 操作按钮 */} +
+ {!notification.read && ( + + )} + +
+
+ ))} +
+ )} +
+
+ + ); +}; diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index babbe84..b0af501 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -3,6 +3,7 @@ 'use client'; import { + Bell, Check, ChevronDown, Copy, @@ -28,6 +29,7 @@ import { UpdateStatus } from '@/lib/version_check'; import { useVersionCheck } from './VersionCheckProvider'; import { VersionPanel } from './VersionPanel'; import { OfflineDownloadPanel } from './OfflineDownloadPanel'; +import { NotificationPanel } from './NotificationPanel'; interface AuthInfo { username?: string; @@ -43,9 +45,11 @@ export const UserMenu: React.FC = () => { const [isSubscribeOpen, setIsSubscribeOpen] = useState(false); const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false); const [isOfflineDownloadPanelOpen, setIsOfflineDownloadPanelOpen] = useState(false); + const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false); const [authInfo, setAuthInfo] = useState(null); const [storageType, setStorageType] = useState('localstorage'); const [mounted, setMounted] = useState(false); + const [unreadCount, setUnreadCount] = useState(0); // 订阅相关状态 const [subscribeEnabled, setSubscribeEnabled] = useState(false); @@ -129,6 +133,36 @@ export const UserMenu: React.FC = () => { setMounted(true); }, []); + // 加载未读通知数量 + const loadUnreadCount = async () => { + try { + const response = await fetch('/api/notifications'); + if (response.ok) { + const data = await response.json(); + setUnreadCount(data.unreadCount || 0); + } + } catch (error) { + console.error('加载未读通知数量失败:', error); + } + }; + + // 首次加载时检查未读通知数量 + useEffect(() => { + loadUnreadCount(); + }, []); + + // 监听通知更新事件 + useEffect(() => { + const handleNotificationsUpdated = () => { + loadUnreadCount(); + }; + + window.addEventListener('notificationsUpdated', handleNotificationsUpdated); + return () => { + window.removeEventListener('notificationsUpdated', handleNotificationsUpdated); + }; + }, []); + // 从运行时配置读取订阅是否启用 useEffect(() => { if (typeof window !== 'undefined') { @@ -600,6 +634,23 @@ export const UserMenu: React.FC = () => { {/* 菜单项 */}
+ {/* 通知按钮 */} + + {/* 设置按钮 */} + {/* 版本更新红点 */} {updateStatus === UpdateStatus.HAS_UPDATE && (
)} + {/* 未读通知红点 */} + {unreadCount > 0 && ( +
+ )}
{/* 使用 Portal 将菜单面板渲染到 document.body */} @@ -1390,6 +1446,20 @@ export const UserMenu: React.FC = () => { isOpen={isOfflineDownloadPanelOpen} onClose={() => setIsOfflineDownloadPanelOpen(false)} /> + + {/* 使用 Portal 将通知面板渲染到 document.body */} + {isNotificationPanelOpen && + mounted && + createPortal( + { + setIsNotificationPanelOpen(false); + loadUnreadCount(); // 关闭时刷新未读数量 + }} + />, + document.body + )} ); }; diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index 774c8fa..cf78f10 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -53,6 +53,8 @@ export interface Favorite { save_time: number; search_title?: string; origin?: 'vod' | 'live'; + is_completed?: boolean; // 是否已完结 + vod_remarks?: string; // 视频备注信息 } // ---- 缓存数据结构 ---- diff --git a/src/lib/db.ts b/src/lib/db.ts index e838455..074c4cc 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -33,7 +33,7 @@ function createStorage(): IStorage { // 单例存储实例 let storageInstance: IStorage | null = null; -function getStorage(): IStorage { +export function getStorage(): IStorage { if (!storageInstance) { storageInstance = createStorage(); } diff --git a/src/lib/downstream.ts b/src/lib/downstream.ts index f98f705..348b9e6 100644 --- a/src/lib/downstream.ts +++ b/src/lib/downstream.ts @@ -16,6 +16,7 @@ interface ApiSearchItem { vod_content?: string; vod_douban_id?: number; type_name?: string; + vod_total?: number; } /** @@ -114,6 +115,8 @@ async function searchWithCache( desc: cleanHtmlTags(item.vod_content || ''), type_name: item.type_name, douban_id: item.vod_douban_id, + vod_remarks: item.vod_remarks, + vod_total: item.vod_total, }; }); @@ -283,6 +286,8 @@ export async function getDetailFromApi( desc: cleanHtmlTags(videoDetail.vod_content), type_name: videoDetail.type_name, douban_id: videoDetail.vod_douban_id, + vod_remarks: videoDetail.vod_remarks, + vod_total: videoDetail.vod_total, }; } @@ -363,5 +368,7 @@ async function handleSpecialSourceDetail( desc: descText, type_name: '', douban_id: 0, + vod_remarks: undefined, + vod_total: undefined, }; } diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index 62c797a..98ba2a0 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -517,4 +517,85 @@ export abstract class BaseRedisStorage implements IStorage { async deleteGlobalValue(key: string): Promise { await this.withRetry(() => this.client.del(this.globalValueKey(key))); } + + // ---------- 通知相关 ---------- + private notificationsKey(userName: string) { + return `u:${userName}:notifications`; + } + + private lastFavoriteCheckKey(userName: string) { + return `u:${userName}:last_fav_check`; + } + + async getNotifications(userName: string): Promise { + const val = await this.withRetry(() => + this.client.get(this.notificationsKey(userName)) + ); + return val ? (JSON.parse(val) as import('./types').Notification[]) : []; + } + + async addNotification( + userName: string, + notification: import('./types').Notification + ): Promise { + const notifications = await this.getNotifications(userName); + notifications.unshift(notification); // 新通知放在最前面 + // 限制通知数量,最多保留100条 + if (notifications.length > 100) { + notifications.splice(100); + } + await this.withRetry(() => + this.client.set(this.notificationsKey(userName), JSON.stringify(notifications)) + ); + } + + async markNotificationAsRead( + userName: string, + notificationId: string + ): Promise { + const notifications = await this.getNotifications(userName); + const notification = notifications.find((n) => n.id === notificationId); + if (notification) { + notification.read = true; + await this.withRetry(() => + this.client.set(this.notificationsKey(userName), JSON.stringify(notifications)) + ); + } + } + + async deleteNotification( + userName: string, + notificationId: string + ): Promise { + const notifications = await this.getNotifications(userName); + const filtered = notifications.filter((n) => n.id !== notificationId); + await this.withRetry(() => + this.client.set(this.notificationsKey(userName), JSON.stringify(filtered)) + ); + } + + async clearAllNotifications(userName: string): Promise { + await this.withRetry(() => this.client.del(this.notificationsKey(userName))); + } + + async getUnreadNotificationCount(userName: string): Promise { + const notifications = await this.getNotifications(userName); + return notifications.filter((n) => !n.read).length; + } + + async getLastFavoriteCheckTime(userName: string): Promise { + const val = await this.withRetry(() => + this.client.get(this.lastFavoriteCheckKey(userName)) + ); + return val ? parseInt(val, 10) : 0; + } + + async setLastFavoriteCheckTime( + userName: string, + timestamp: number + ): Promise { + await this.withRetry(() => + this.client.set(this.lastFavoriteCheckKey(userName), timestamp.toString()) + ); + } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 59e35d6..df20898 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -24,6 +24,8 @@ export interface Favorite { save_time: number; // 记录保存时间(时间戳) search_title: string; // 搜索时使用的标题 origin?: 'vod' | 'live'; + is_completed?: boolean; // 是否已完结 + vod_remarks?: string; // 视频备注信息 } // 存储接口 @@ -96,6 +98,18 @@ export interface IStorage { getGlobalValue(key: string): Promise; setGlobalValue(key: string, value: string): Promise; deleteGlobalValue(key: string): Promise; + + // 通知相关 + getNotifications(userName: string): Promise; + addNotification(userName: string, notification: Notification): Promise; + markNotificationAsRead(userName: string, notificationId: string): Promise; + deleteNotification(userName: string, notificationId: string): Promise; + clearAllNotifications(userName: string): Promise; + getUnreadNotificationCount(userName: string): Promise; + + // 收藏更新检查相关 + getLastFavoriteCheckTime(userName: string): Promise; + setLastFavoriteCheckTime(userName: string, timestamp: number): Promise; } // 搜索结果数据结构 @@ -112,6 +126,8 @@ export interface SearchResult { desc?: string; type_name?: string; douban_id?: number; + vod_remarks?: string; // 视频备注信息(如"全80集"、"更新至25集"等) + vod_total?: number; // 总集数 } // 豆瓣数据结构 @@ -161,3 +177,32 @@ export interface EpisodeFilterRule { export interface EpisodeFilterConfig { rules: EpisodeFilterRule[]; // 过滤规则列表 } + +// 通知类型枚举 +export type NotificationType = + | 'favorite_update' // 收藏更新 + | 'system' // 系统通知 + | 'announcement'; // 公告 + +// 通知数据结构 +export interface Notification { + id: string; // 通知ID + type: NotificationType; // 通知类型 + title: string; // 通知标题 + message: string; // 通知内容 + timestamp: number; // 通知时间戳 + read: boolean; // 是否已读 + metadata?: Record; // 额外的元数据(如收藏更新的source、id等) +} + +// 收藏更新检查结果 +export interface FavoriteUpdateCheck { + last_check_time: number; // 上次检查时间戳 + updates: Array<{ + source: string; + id: string; + title: string; + old_episodes: number; + new_episodes: number; + }>; +} diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index 00ed464..a9ab356 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -415,6 +415,87 @@ export class UpstashRedisStorage implements IStorage { async deleteGlobalValue(key: string): Promise { await withRetry(() => this.client.del(this.globalValueKey(key))); } + + // ---------- 通知相关 ---------- + private notificationsKey(userName: string) { + return `u:${userName}:notifications`; + } + + private lastFavoriteCheckKey(userName: string) { + return `u:${userName}:last_fav_check`; + } + + async getNotifications(userName: string): Promise { + const val = await withRetry(() => + this.client.get(this.notificationsKey(userName)) + ); + return val ? (val as import('./types').Notification[]) : []; + } + + async addNotification( + userName: string, + notification: import('./types').Notification + ): Promise { + const notifications = await this.getNotifications(userName); + notifications.unshift(notification); // 新通知放在最前面 + // 限制通知数量,最多保留100条 + if (notifications.length > 100) { + notifications.splice(100); + } + await withRetry(() => + this.client.set(this.notificationsKey(userName), notifications) + ); + } + + async markNotificationAsRead( + userName: string, + notificationId: string + ): Promise { + const notifications = await this.getNotifications(userName); + const notification = notifications.find((n) => n.id === notificationId); + if (notification) { + notification.read = true; + await withRetry(() => + this.client.set(this.notificationsKey(userName), notifications) + ); + } + } + + async deleteNotification( + userName: string, + notificationId: string + ): Promise { + const notifications = await this.getNotifications(userName); + const filtered = notifications.filter((n) => n.id !== notificationId); + await withRetry(() => + this.client.set(this.notificationsKey(userName), filtered) + ); + } + + async clearAllNotifications(userName: string): Promise { + await withRetry(() => this.client.del(this.notificationsKey(userName))); + } + + async getUnreadNotificationCount(userName: string): Promise { + const notifications = await this.getNotifications(userName); + return notifications.filter((n) => !n.read).length; + } + + async getLastFavoriteCheckTime(userName: string): Promise { + const val = await withRetry(() => + this.client.get(this.lastFavoriteCheckKey(userName)) + ); + return val ? (val as number) : 0; + } + + async setLastFavoriteCheckTime( + userName: string, + timestamp: number + ): Promise { + await withRetry(() => + this.client.set(this.lastFavoriteCheckKey(userName), timestamp) + ); + } } // 单例 Upstash Redis 客户端