增加收藏更新邮件发送

This commit is contained in:
mtvpls
2026-01-16 16:11:31 +08:00
parent 036e909f74
commit 6bce099a8d
14 changed files with 2468 additions and 176 deletions

View File

@@ -49,6 +49,7 @@
"next-pwa": "^5.6.0",
"next-themes": "^0.4.6",
"node-fetch": "^2.7.0",
"nodemailer": "^7.0.12",
"nprogress": "^0.2.0",
"opencc-js": "^1.0.5",
"parse-torrent-name": "^0.5.4",
@@ -79,6 +80,7 @@
"@types/he": "^1.2.3",
"@types/node": "24.0.3",
"@types/node-fetch": "^2.6.13",
"@types/nodemailer": "^7.0.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^19.1.6",
"@types/react-window": "^2.0.0",

1025
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@ import {
ExternalLink,
FileText,
FolderOpen,
Mail,
Palette,
Settings,
Tv,
@@ -8852,6 +8853,386 @@ const XiaoyaConfigComponent = ({
);
};
// 邮件配置组件
const EmailConfigComponent = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [enabled, setEnabled] = useState(false);
const [provider, setProvider] = useState<'smtp' | 'resend'>('smtp');
// SMTP配置
const [smtpHost, setSmtpHost] = useState('');
const [smtpPort, setSmtpPort] = useState(587);
const [smtpSecure, setSmtpSecure] = useState(false);
const [smtpUser, setSmtpUser] = useState('');
const [smtpPassword, setSmtpPassword] = useState('');
const [smtpFrom, setSmtpFrom] = useState('');
// Resend配置
const [resendApiKey, setResendApiKey] = useState('');
const [resendFrom, setResendFrom] = useState('');
// 测试邮件
const [testEmail, setTestEmail] = useState('');
useEffect(() => {
if (config?.EmailConfig) {
setEnabled(config.EmailConfig.enabled || false);
setProvider(config.EmailConfig.provider || 'smtp');
if (config.EmailConfig.smtp) {
setSmtpHost(config.EmailConfig.smtp.host || '');
setSmtpPort(config.EmailConfig.smtp.port || 587);
setSmtpSecure(config.EmailConfig.smtp.secure || false);
setSmtpUser(config.EmailConfig.smtp.user || '');
setSmtpPassword(config.EmailConfig.smtp.password || '');
setSmtpFrom(config.EmailConfig.smtp.from || '');
}
if (config.EmailConfig.resend) {
setResendApiKey(config.EmailConfig.resend.apiKey || '');
setResendFrom(config.EmailConfig.resend.from || '');
}
}
}, [config]);
const handleSave = async () => {
await withLoading('saveEmail', async () => {
try {
const emailConfig: AdminConfig['EmailConfig'] = {
enabled,
provider,
smtp: provider === 'smtp' ? {
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
user: smtpUser,
password: smtpPassword,
from: smtpFrom,
} : undefined,
resend: provider === 'resend' ? {
apiKey: resendApiKey,
from: resendFrom,
} : undefined,
};
const response = await fetch('/api/admin/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'save',
config: emailConfig,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || '保存失败');
}
showSuccess('保存成功', showAlert);
await refreshConfig();
} catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert);
throw error;
}
});
};
const handleTest = async () => {
if (!testEmail) {
showError('请输入测试邮箱地址', showAlert);
return;
}
await withLoading('testEmail', async () => {
try {
const emailConfig: AdminConfig['EmailConfig'] = {
enabled: true,
provider,
smtp: provider === 'smtp' ? {
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
user: smtpUser,
password: smtpPassword,
from: smtpFrom,
} : undefined,
resend: provider === 'resend' ? {
apiKey: resendApiKey,
from: resendFrom,
} : undefined,
};
const response = await fetch('/api/admin/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'test',
config: emailConfig,
testEmail,
}),
});
const data = await response.json();
if (data.success) {
showSuccess('测试邮件发送成功,请检查收件箱', showAlert);
} else {
showError(data.error || '发送失败', showAlert);
}
} catch (error) {
showError(error instanceof Error ? error.message : '发送失败', showAlert);
throw error;
}
});
};
return (
<div className='space-y-6'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
<h3 className='text-sm font-medium text-blue-900 dark:text-blue-100 mb-2'>
</h3>
<div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'>
<p> </p>
<p> SMTP Resend </p>
<p> </p>
</div>
</div>
<div className='space-y-4'>
{/* 启用开关 */}
<div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'>
<div>
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<button
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* 发送方式选择 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<div className='flex gap-4'>
<label className='flex items-center'>
<input
type='radio'
value='smtp'
checked={provider === 'smtp'}
onChange={(e) => setProvider(e.target.value as 'smtp')}
className='mr-2'
/>
<span className='text-sm text-gray-700 dark:text-gray-300'>SMTP</span>
</label>
<label className='flex items-center'>
<input
type='radio'
value='resend'
checked={provider === 'resend'}
onChange={(e) => setProvider(e.target.value as 'resend')}
className='mr-2'
/>
<span className='text-sm text-gray-700 dark:text-gray-300'>Resend</span>
</label>
</div>
</div>
{/* SMTP配置 */}
{provider === 'smtp' && (
<div className='space-y-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700'>
<h4 className='text-sm font-medium text-gray-900 dark:text-white'>SMTP </h4>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
SMTP *
</label>
<input
type='text'
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder='smtp.gmail.com'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
SMTP *
</label>
<input
type='number'
value={smtpPort}
onChange={(e) => setSmtpPort(parseInt(e.target.value))}
placeholder='587'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white'
/>
</div>
</div>
<div className='flex items-center'>
<input
type='checkbox'
checked={smtpSecure}
onChange={(e) => setSmtpSecure(e.target.checked)}
className='mr-2'
/>
<label className='text-sm text-gray-700 dark:text-gray-300'>
使 SSL/TLS 465
</label>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
SMTP *
</label>
<input
type='text'
value={smtpUser}
onChange={(e) => setSmtpUser(e.target.value)}
placeholder='your-email@gmail.com'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
SMTP *
</label>
<input
type='password'
value={smtpPassword}
onChange={(e) => setSmtpPassword(e.target.value)}
placeholder='应用专用密码'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
*
</label>
<input
type='email'
value={smtpFrom}
onChange={(e) => setSmtpFrom(e.target.value)}
placeholder='noreply@yourdomain.com'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white'
/>
</div>
</div>
)}
{/* Resend配置 */}
{provider === 'resend' && (
<div className='space-y-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700'>
<h4 className='text-sm font-medium text-gray-900 dark:text-white'>Resend </h4>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
Resend API Key *
</label>
<input
type='password'
value={resendApiKey}
onChange={(e) => setResendApiKey(e.target.value)}
placeholder='re_xxxxx'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white'
/>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
<a href='https://resend.com/api-keys' target='_blank' rel='noopener noreferrer' className='text-blue-600 hover:underline'>Resend </a>
</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
*
</label>
<input
type='email'
value={resendFrom}
onChange={(e) => setResendFrom(e.target.value)}
placeholder='noreply@yourdomain.com'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white'
/>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
Resend
</p>
</div>
</div>
)}
{/* 测试邮件 */}
<div className='p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg'>
<h4 className='text-sm font-medium text-blue-900 dark:text-blue-100 mb-2'>
</h4>
<div className='flex flex-col sm:flex-row gap-2'>
<input
type='email'
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder='输入测试邮箱地址'
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm'
/>
<button
onClick={handleTest}
disabled={isLoading('testEmail') || !testEmail}
className={`${buttonStyles.primary} whitespace-nowrap`}
>
{isLoading('testEmail') ? '发送中...' : '发送测试'}
</button>
</div>
</div>
{/* 保存按钮 */}
<div className='flex gap-3'>
<button
onClick={handleSave}
disabled={isLoading('saveEmail')}
className={buttonStyles.success}
>
{isLoading('saveEmail') ? '保存中...' : '保存配置'}
</button>
</div>
</div>
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
};
// 求片列表组件
const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
@@ -10322,6 +10703,7 @@ function AdminPageClient() {
dataMigration: false,
customAdFilter: false,
themeConfig: false,
emailConfig: false,
});
// 获取管理员配置
@@ -10712,6 +11094,18 @@ function AdminPageClient() {
<AIConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 邮件配置标签 */}
<CollapsibleTab
title='邮件配置'
icon={
<Mail size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.emailConfig}
onToggle={() => toggleTab('emailConfig')}
>
<EmailConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 分类配置标签 */}
<CollapsibleTab
title='分类配置'

View File

@@ -0,0 +1,190 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getStorage } from '@/lib/db';
import { getConfig } from '@/lib/config';
import { EmailService } from '@/lib/email.service';
import type { AdminConfig } from '@/lib/admin.types';
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 userInfo = await storage.getUserInfoV2?.(authInfo.username);
// 只有管理员和站长可以访问
if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const adminConfig = await getConfig();
const emailConfig = adminConfig?.EmailConfig || {
enabled: false,
provider: 'smtp' as const,
};
// 不返回敏感信息密码、API Key
const safeConfig = {
enabled: emailConfig.enabled,
provider: emailConfig.provider,
smtp: emailConfig.smtp
? {
host: emailConfig.smtp.host,
port: emailConfig.smtp.port,
secure: emailConfig.smtp.secure,
user: emailConfig.smtp.user,
from: emailConfig.smtp.from,
password: emailConfig.smtp.password ? '******' : '',
}
: undefined,
resend: emailConfig.resend
? {
from: emailConfig.resend.from,
apiKey: emailConfig.resend.apiKey ? '******' : '',
}
: undefined,
};
return NextResponse.json(safeConfig);
} 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 storage = getStorage();
const userInfo = await storage.getUserInfoV2?.(authInfo.username);
// 只有管理员和站长可以访问
if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const { action, config, testEmail } = body;
// 发送测试邮件
if (action === 'test') {
if (!testEmail) {
return NextResponse.json(
{ error: '请提供测试邮箱地址' },
{ status: 400 }
);
}
const emailConfig = config as AdminConfig['EmailConfig'];
if (!emailConfig || !emailConfig.enabled) {
return NextResponse.json(
{ error: '邮件配置未启用' },
{ status: 400 }
);
}
try {
const adminConfig = await getConfig();
const siteName = adminConfig?.SiteConfig?.SiteName || 'MoonTVPlus';
await EmailService.sendTestEmail(emailConfig, testEmail, siteName);
return NextResponse.json({ success: true, message: '测试邮件发送成功' });
} catch (error) {
console.error('发送测试邮件失败:', error);
return NextResponse.json(
{ error: `发送失败: ${(error as Error).message}` },
{ status: 500 }
);
}
}
// 保存邮件配置
if (action === 'save') {
const emailConfig = config as AdminConfig['EmailConfig'];
if (!emailConfig) {
return NextResponse.json(
{ error: '邮件配置不能为空' },
{ status: 400 }
);
}
// 验证配置
if (emailConfig.enabled) {
if (emailConfig.provider === 'smtp') {
if (!emailConfig.smtp?.host || !emailConfig.smtp?.port || !emailConfig.smtp?.user || !emailConfig.smtp?.from) {
return NextResponse.json(
{ error: 'SMTP配置不完整' },
{ status: 400 }
);
}
} else if (emailConfig.provider === 'resend') {
if (!emailConfig.resend?.apiKey || !emailConfig.resend?.from) {
return NextResponse.json(
{ error: 'Resend配置不完整' },
{ status: 400 }
);
}
}
}
// 获取现有配置
const adminConfig = await getConfig();
if (!adminConfig) {
return NextResponse.json(
{ error: '管理员配置不存在' },
{ status: 500 }
);
}
// 如果密码或API Key是占位符保留原有值
if (emailConfig.smtp?.password === '******') {
const oldConfig = adminConfig.EmailConfig;
if (oldConfig?.smtp?.password) {
emailConfig.smtp.password = oldConfig.smtp.password;
}
}
if (emailConfig.resend?.apiKey === '******') {
const oldConfig = adminConfig.EmailConfig;
if (oldConfig?.resend?.apiKey) {
emailConfig.resend.apiKey = oldConfig.resend.apiKey;
}
}
// 更新配置
adminConfig.EmailConfig = emailConfig;
await storage.setAdminConfig(adminConfig);
return NextResponse.json({ success: true, message: '邮件配置保存成功' });
}
return NextResponse.json(
{ error: '无效的操作' },
{ status: 400 }
);
} catch (error) {
console.error('处理邮件配置失败:', error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -8,6 +8,8 @@ import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
import { refreshLiveChannels } from '@/lib/live';
import { startOpenListRefresh } from '@/lib/openlist-refresh';
import { SearchResult } from '@/lib/types';
import { EmailService } from '@/lib/email.service';
import { getBatchFavoriteUpdateEmailTemplate, FavoriteUpdate } from '@/lib/email.templates';
export const runtime = 'nodejs';
@@ -230,6 +232,7 @@ async function refreshRecordAndFavorites() {
const totalFavorites = Object.keys(favorites).length;
let processedFavorites = 0;
const now = Date.now();
const userUpdates: FavoriteUpdate[] = []; // 收集该用户的所有更新
for (const [key, fav] of Object.entries(favorites)) {
try {
@@ -279,6 +282,17 @@ async function refreshRecordAndFavorites() {
await storage.addNotification(user, notification);
console.log(`已为用户 ${user} 创建收藏更新通知: ${fav.title}`);
// 收集更新信息用于邮件
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
const playUrl = `${siteUrl}/play?source=${source}&id=${id}`;
userUpdates.push({
title: fav.title,
oldEpisodes: fav.total_episodes,
newEpisodes: favEpisodeCount,
url: playUrl,
cover: favDetail.poster || fav.cover,
});
}
processedFavorites++;
@@ -289,6 +303,42 @@ async function refreshRecordAndFavorites() {
}
console.log(`收藏处理完成: ${processedFavorites}/${totalFavorites}`);
// 如果有更新,发送汇总邮件
if (userUpdates.length > 0) {
try {
const userEmail = storage.getUserEmail ? await storage.getUserEmail(user) : null;
const emailNotifications = storage.getEmailNotificationPreference
? await storage.getEmailNotificationPreference(user)
: false;
if (userEmail && emailNotifications) {
const config = await getConfig();
const emailConfig = config?.EmailConfig;
if (emailConfig?.enabled) {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
const siteName = config?.SiteConfig?.SiteName || 'MoonTVPlus';
await EmailService.send(emailConfig, {
to: userEmail,
subject: `📺 收藏更新汇总 - ${userUpdates.length} 部影片有更新`,
html: getBatchFavoriteUpdateEmailTemplate(
user,
userUpdates,
siteUrl,
siteName
),
});
console.log(`邮件汇总已发送至: ${userEmail} (${userUpdates.length} 个更新)`);
}
}
} catch (emailError) {
console.error(`发送邮件汇总失败 (${user}):`, emailError);
// 邮件发送失败不影响主流程
}
}
} catch (err) {
console.error(`获取用户收藏失败 (${user}):`, err);
}

View File

@@ -1,159 +0,0 @@
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 }
);
}
}

View File

@@ -0,0 +1,90 @@
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 username = authInfo.username;
const email = storage.getUserEmail
? await storage.getUserEmail(username)
: null;
const emailNotifications = storage.getEmailNotificationPreference
? await storage.getEmailNotificationPreference(username)
: false;
return NextResponse.json({
email: email || '',
emailNotifications,
});
} 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 storage = getStorage();
const username = authInfo.username;
const body = await request.json();
const { email, emailNotifications } = body;
// 验证邮箱格式
if (email && typeof email === 'string') {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: '邮箱格式不正确' },
{ status: 400 }
);
}
if (storage.setUserEmail) {
await storage.setUserEmail(username, email);
}
}
// 保存邮件通知偏好
if (typeof emailNotifications === 'boolean') {
if (storage.setEmailNotificationPreference) {
await storage.setEmailNotificationPreference(username, emailNotifications);
}
}
return NextResponse.json({
success: true,
message: '邮箱设置保存成功',
});
} catch (error) {
console.error('保存用户邮箱设置失败:', error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -15,6 +15,7 @@ import {
Home,
KeyRound,
LogOut,
Mail,
MoveDown,
MoveUp,
Rss,
@@ -55,6 +56,7 @@ export const UserMenu: React.FC = () => {
const [isOfflineDownloadPanelOpen, setIsOfflineDownloadPanelOpen] = useState(false);
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
const [isFavoritesPanelOpen, setIsFavoritesPanelOpen] = useState(false);
const [isEmailSettingsOpen, setIsEmailSettingsOpen] = useState(false);
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
const [storageType, setStorageType] = useState<string>('localstorage');
const [mounted, setMounted] = useState(false);
@@ -68,7 +70,7 @@ export const UserMenu: React.FC = () => {
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
useEffect(() => {
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen) {
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen) {
const body = document.body;
const html = document.documentElement;
@@ -87,7 +89,7 @@ export const UserMenu: React.FC = () => {
html.style.overflow = originalHtmlOverflow;
};
}
}, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen]);
}, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen, isEmailSettingsOpen]);
// 设置相关状态
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
@@ -108,6 +110,10 @@ export const UserMenu: React.FC = () => {
const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true);
const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] = useState(false);
// 邮件通知设置
const [userEmail, setUserEmail] = useState('');
const [emailNotifications, setEmailNotifications] = useState(false);
// 折叠面板状态
const [isDoubanSectionOpen, setIsDoubanSectionOpen] = useState(true);
const [isUsageSectionOpen, setIsUsageSectionOpen] = useState(false);
@@ -390,9 +396,67 @@ export const UserMenu: React.FC = () => {
if (savedSearchTraditionalToSimplified !== null) {
setSearchTraditionalToSimplified(savedSearchTraditionalToSimplified === 'true');
}
// 加载邮件通知设置
loadEmailSettings();
}
}, []);
// 加载邮件通知设置
const loadEmailSettings = async () => {
try {
const response = await fetch('/api/user/email-settings');
if (response.ok) {
const data = await response.json();
setUserEmail(data.email || '');
setEmailNotifications(data.emailNotifications || false);
}
} catch (error) {
console.error('加载邮件设置失败:', error);
}
};
// 保存邮件通知设置
const handleSaveEmailSettings = async () => {
try {
const response = await fetch('/api/user/email-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: userEmail,
emailNotifications,
}),
});
const messageEl = document.getElementById('email-settings-message');
if (response.ok) {
if (messageEl) {
messageEl.textContent = '保存成功!';
messageEl.className = 'text-xs text-center text-green-600 dark:text-green-400';
messageEl.classList.remove('hidden');
setTimeout(() => {
messageEl.classList.add('hidden');
}, 3000);
}
} else {
const data = await response.json();
if (messageEl) {
messageEl.textContent = data.error || '保存失败';
messageEl.className = 'text-xs text-center text-red-600 dark:text-red-400';
messageEl.classList.remove('hidden');
}
}
} catch (error) {
console.error('保存邮件设置失败:', error);
const messageEl = document.getElementById('email-settings-message');
if (messageEl) {
messageEl.textContent = '保存失败,请重试';
messageEl.className = 'text-xs text-center text-red-600 dark:text-red-400';
messageEl.classList.remove('hidden');
}
}
};
// 点击外部区域关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -847,9 +911,23 @@ export const UserMenu: React.FC = () => {
<div className='px-3 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800 dark:to-gray-800/50'>
<div className='space-y-1'>
<div className='flex items-center justify-between'>
<span className='text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</span>
<div className='flex items-center gap-1.5'>
<span className='text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</span>
{/* 邮件设置图标按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
setIsEmailSettingsOpen(true);
}}
className='p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
title='邮件通知设置'
>
<Mail className='w-3 h-3 text-gray-500 dark:text-gray-400' />
</button>
</div>
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${(authInfo?.role || 'user') === 'owner'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
@@ -2007,6 +2085,111 @@ export const UserMenu: React.FC = () => {
</>
);
// 邮件设置面板内容
const emailSettingsPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={() => setIsEmailSettingsOpen(false)}
onTouchMove={(e) => {
e.preventDefault();
}}
onWheel={(e) => {
e.preventDefault();
}}
style={{
touchAction: 'none',
}}
/>
{/* 邮件设置面板 */}
<div
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'
>
<div
className='h-full p-6'
data-panel-content
onTouchMove={(e) => {
e.stopPropagation();
}}
style={{
touchAction: 'auto',
}}
>
{/* 标题栏 */}
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<button
onClick={() => setIsEmailSettingsOpen(false)}
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'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
{/* 表单 */}
<div className='space-y-4'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='email'
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
placeholder='输入您的邮箱地址'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
/>
</div>
<div className='flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<button
onClick={() => setEmailNotifications(!emailNotifications)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
emailNotifications ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
emailNotifications ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<button
onClick={handleSaveEmailSettings}
className='w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors'
>
</button>
<p id='email-settings-message' className='text-xs text-center hidden'></p>
</div>
{/* 提示信息 */}
<div className='mt-6 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg'>
<p className='text-xs text-blue-800 dark:text-blue-200'>
💡
</p>
</div>
</div>
</div>
</>
);
return (
<>
<div className='relative'>
@@ -2079,6 +2262,11 @@ export const UserMenu: React.FC = () => {
/>,
document.body
)}
{/* 使用 Portal 将邮件设置面板渲染到 document.body */}
{isEmailSettingsOpen &&
mounted &&
createPortal(emailSettingsPanel, document.body)}
</>
);
};

View File

@@ -206,6 +206,24 @@ export interface AdminConfig {
Password?: string; // 密码认证(备选)
DisableVideoPreview?: boolean; // 禁用预览视频,直接返回直连链接
};
EmailConfig?: {
enabled: boolean; // 是否启用邮件通知
provider: 'smtp' | 'resend'; // 邮件发送方式
// SMTP配置
smtp?: {
host: string; // SMTP服务器地址
port: number; // SMTP端口25/465/587
secure: boolean; // 是否使用SSL/TLS
user: string; // SMTP用户名
password: string; // SMTP密码
from: string; // 发件人邮箱
};
// Resend配置
resend?: {
apiKey: string; // Resend API Key
from: string; // 发件人邮箱
};
};
}
export interface AdminConfigResult {

182
src/lib/email.service.ts Normal file
View File

@@ -0,0 +1,182 @@
import nodemailer from 'nodemailer';
import type { AdminConfig } from './admin.types';
export interface EmailOptions {
to: string;
subject: string;
html: string;
}
export class EmailService {
/**
* 通过SMTP发送邮件
*/
static async sendViaSMTP(
config: NonNullable<AdminConfig['EmailConfig']>['smtp'],
options: EmailOptions
): Promise<void> {
if (!config) {
throw new Error('SMTP配置不存在');
}
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: {
user: config.user,
pass: config.password,
},
});
await transporter.sendMail({
from: config.from,
to: options.to,
subject: options.subject,
html: options.html,
});
}
/**
* 通过Resend API发送邮件
*/
static async sendViaResend(
config: NonNullable<AdminConfig['EmailConfig']>['resend'],
options: EmailOptions
): Promise<void> {
if (!config) {
throw new Error('Resend配置不存在');
}
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: config.from,
to: options.to,
subject: options.subject,
html: options.html,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Resend API错误: ${response.statusText} - ${errorText}`);
}
}
/**
* 统一发送接口
*/
static async send(
emailConfig: AdminConfig['EmailConfig'],
options: EmailOptions
): Promise<void> {
if (!emailConfig || !emailConfig.enabled) {
console.log('邮件通知未启用,跳过发送');
return;
}
try {
if (emailConfig.provider === 'smtp' && emailConfig.smtp) {
await this.sendViaSMTP(emailConfig.smtp, options);
console.log(`邮件已通过SMTP发送至: ${options.to}`);
} else if (emailConfig.provider === 'resend' && emailConfig.resend) {
await this.sendViaResend(emailConfig.resend, options);
console.log(`邮件已通过Resend发送至: ${options.to}`);
} else {
throw new Error('邮件配置不完整');
}
} catch (error) {
console.error('邮件发送失败:', error);
throw error;
}
}
/**
* 发送测试邮件
*/
static async sendTestEmail(
emailConfig: AdminConfig['EmailConfig'],
toEmail: string,
siteName?: string
): Promise<void> {
const displayName = siteName || 'MoonTVPlus';
await this.send(emailConfig, {
to: toEmail,
subject: `测试邮件 - ${displayName}`,
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.container {
max-width: 600px;
margin: 20px auto;
background: white;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header {
background: white;
color: #333;
padding: 30px 20px;
text-align: center;
border-bottom: 2px solid #f0f0f0;
}
.header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.content {
padding: 30px 20px;
background: white;
}
.content p {
color: #333;
margin: 10px 0;
}
.footer {
padding: 20px;
text-align: center;
color: #999;
font-size: 12px;
background: white;
border-top: 1px solid #eee;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📧 测试邮件</h1>
</div>
<div class="content">
<p>这是一封来自 ${displayName} 的测试邮件。</p>
<p>如果您收到这封邮件,说明邮件配置正确!</p>
<p style="color: #666;">发送时间: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}</p>
</div>
<div class="footer">
<p>此邮件由 ${displayName} 自动发送</p>
</div>
</div>
</body>
</html>
`,
});
}
}

265
src/lib/email.templates.ts Normal file
View File

@@ -0,0 +1,265 @@
/**
* 邮件模板
*/
export interface FavoriteUpdate {
title: string;
oldEpisodes: number;
newEpisodes: number;
url: string;
cover?: string;
}
/**
* 收藏更新邮件模板
*/
export function getFavoriteUpdateEmailTemplate(
userName: string,
updates: FavoriteUpdate[],
siteUrl: string,
siteName?: string
): string {
const updatesList = updates
.map(
(u) => `
<div style="margin: 15px 0; padding: 15px; background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
${
u.cover
? `<img src="${u.cover}" alt="${u.title}" style="width: 100%; max-width: 200px; border-radius: 5px; margin-bottom: 10px;" />`
: ''
}
<div style="font-size: 16px; font-weight: bold; margin-bottom: 8px;">${u.title}</div>
<div style="color: #666; margin-bottom: 10px;">
更新:第 ${u.oldEpisodes} 集 → <span style="color: #4F46E5; font-weight: bold;">第 ${u.newEpisodes} 集</span>
</div>
<a href="${u.url}" style="display: inline-block; padding: 8px 16px; background: #4F46E5; color: white; text-decoration: none; border-radius: 5px; font-size: 14px;">立即观看</a>
</div>
`
)
.join('');
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.container {
max-width: 600px;
margin: 20px auto;
background: white;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header {
background: white;
color: #333;
padding: 30px 20px;
text-align: center;
border-bottom: 2px solid #f0f0f0;
}
.header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.content {
padding: 30px 20px;
background: white;
}
.greeting {
font-size: 16px;
color: #333;
margin-bottom: 20px;
}
.footer {
padding: 20px;
text-align: center;
color: #999;
font-size: 12px;
background: white;
border-top: 1px solid #eee;
}
.footer a {
color: #4F46E5;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📺 收藏更新通知</h1>
</div>
<div class="content">
<div class="greeting">
Hi <strong>${userName}</strong>
</div>
<p style="color: #666; margin-bottom: 20px;">您收藏的以下影片有更新:</p>
${updatesList}
<p style="color: #666; margin-top: 20px;">快去观看吧!</p>
</div>
<div class="footer">
<p>此邮件由 <a href="${siteUrl}">${siteName || 'MoonTVPlus'}</a> 自动发送</p>
<p>如不想接收此类邮件,请在用户设置中关闭邮件通知</p>
</div>
</div>
</body>
</html>
`;
}
/**
* 单个收藏更新邮件模板(简化版)
*/
export function getSingleFavoriteUpdateEmailTemplate(
userName: string,
title: string,
oldEpisodes: number,
newEpisodes: number,
url: string,
cover?: string,
siteName?: string
): string {
return getFavoriteUpdateEmailTemplate(
userName,
[{ title, oldEpisodes, newEpisodes, url, cover }],
url.split('/play')[0] || 'http://localhost:3000',
siteName
);
}
/**
* 批量收藏更新邮件模板(每日汇总)
*/
export function getBatchFavoriteUpdateEmailTemplate(
userName: string,
updates: FavoriteUpdate[],
siteUrl: string,
siteName?: string
): string {
const totalUpdates = updates.length;
const totalNewEpisodes = updates.reduce(
(sum, u) => sum + (u.newEpisodes - u.oldEpisodes),
0
);
const updatesList = updates
.map(
(u) => `
<div style="margin: 15px 0; padding: 15px; background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<div style="display: flex; align-items: center; gap: 15px;">
${
u.cover
? `<img src="${u.cover}" alt="${u.title}" style="width: 80px; height: 120px; object-fit: cover; border-radius: 5px;" />`
: ''
}
<div style="flex: 1;">
<div style="font-size: 16px; font-weight: bold; margin-bottom: 8px;">${u.title}</div>
<div style="color: #666; margin-bottom: 10px;">
${u.oldEpisodes} 集 → <span style="color: #4F46E5; font-weight: bold;">第 ${u.newEpisodes} 集</span>
<span style="color: #10b981; font-weight: bold;">(+${u.newEpisodes - u.oldEpisodes})</span>
</div>
<a href="${u.url}" style="display: inline-block; padding: 6px 12px; background: #4F46E5; color: white; text-decoration: none; border-radius: 5px; font-size: 13px;">立即观看</a>
</div>
</div>
</div>
`
)
.join('');
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.container {
max-width: 600px;
margin: 20px auto;
background: white;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header {
background: white;
color: #333;
padding: 30px 20px;
text-align: center;
border-bottom: 2px solid #f0f0f0;
}
.header h1 {
margin: 0 0 10px 0;
font-size: 24px;
font-weight: 600;
}
.header .stats {
font-size: 14px;
color: #666;
}
.content {
padding: 30px 20px;
background: white;
}
.greeting {
font-size: 16px;
color: #333;
margin-bottom: 20px;
}
.footer {
padding: 20px;
text-align: center;
color: #999;
font-size: 12px;
background: white;
border-top: 1px solid #eee;
}
.footer a {
color: #4F46E5;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📺 收藏更新汇总</h1>
<div class="stats">
${totalUpdates} 部影片更新 · 共 ${totalNewEpisodes} 集新内容
</div>
</div>
<div class="content">
<div class="greeting">
Hi <strong>${userName}</strong>
</div>
<p style="color: #666; margin-bottom: 20px;">您收藏的影片有以下更新:</p>
${updatesList}
<p style="color: #666; margin-top: 20px;">快去观看吧!</p>
</div>
<div class="footer">
<p>此邮件由 <a href="${siteUrl}">${siteName || 'MoonTVPlus'}</a> 自动发送</p>
<p>如不想接收此类邮件,请在用户设置中关闭邮件通知</p>
</div>
</div>
</body>
</html>
`;
}

View File

@@ -4,6 +4,7 @@ import { createClient, RedisClientType } from 'redis';
import { AdminConfig } from './admin.types';
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
import { userInfoCache } from './user-cache';
// 搜索历史最大条数
const SEARCH_HISTORY_LIMIT = 20;
@@ -660,6 +661,8 @@ export abstract class BaseRedisStorage implements IStorage {
favorite_migrated?: boolean;
skip_migrated?: boolean;
last_movie_request_time?: number;
email?: string;
emailNotifications?: boolean;
} | null> {
const userInfo = await this.withRetry(() =>
this.client.hGetAll(this.userInfoKey(userName))
@@ -680,6 +683,8 @@ export abstract class BaseRedisStorage implements IStorage {
favorite_migrated: userInfo.favorite_migrated === 'true',
skip_migrated: userInfo.skip_migrated === 'true',
last_movie_request_time: userInfo.last_movie_request_time ? parseInt(userInfo.last_movie_request_time, 10) : undefined,
email: userInfo.email,
emailNotifications: userInfo.emailNotifications === 'true',
};
}
@@ -1290,4 +1295,31 @@ export abstract class BaseRedisStorage implements IStorage {
async removeUserMovieRequest(userName: string, requestId: string): Promise<void> {
await this.withRetry(() => this.client.sRem(this.userMovieRequestsKey(userName), requestId));
}
// ---------- 用户邮箱相关 ----------
async getUserEmail(userName: string): Promise<string | null> {
const userInfo = await this.getUserInfoV2(userName);
return userInfo?.email || null;
}
async setUserEmail(userName: string, email: string): Promise<void> {
await this.withRetry(() =>
this.client.hSet(this.userInfoKey(userName), 'email', email)
);
// 清除缓存
userInfoCache?.delete(userName);
}
async getEmailNotificationPreference(userName: string): Promise<boolean> {
const userInfo = await this.getUserInfoV2(userName);
return userInfo?.emailNotifications || false;
}
async setEmailNotificationPreference(userName: string, enabled: boolean): Promise<void> {
await this.withRetry(() =>
this.client.hSet(this.userInfoKey(userName), 'emailNotifications', enabled.toString())
);
// 清除缓存
userInfoCache?.delete(userName);
}
}

View File

@@ -140,7 +140,15 @@ export interface IStorage {
favorite_migrated?: boolean;
skip_migrated?: boolean;
last_movie_request_time?: number;
email?: string; // 用户邮箱
emailNotifications?: boolean; // 是否接收邮件通知
} | null>;
// 用户邮箱相关
getUserEmail?(userName: string): Promise<string | null>;
setUserEmail?(userName: string, email: string): Promise<void>;
getEmailNotificationPreference?(userName: string): Promise<boolean>;
setEmailNotificationPreference?(userName: string, enabled: boolean): Promise<void>;
}
// 搜索结果数据结构

View File

@@ -590,6 +590,8 @@ export class UpstashRedisStorage implements IStorage {
favorite_migrated?: boolean;
skip_migrated?: boolean;
last_movie_request_time?: number;
email?: string;
emailNotifications?: boolean;
} | null> {
// 先从缓存获取
const cached = userInfoCache?.get(userName);
@@ -688,6 +690,8 @@ export class UpstashRedisStorage implements IStorage {
? userInfo.last_movie_request_time
: parseInt(userInfo.last_movie_request_time as string, 10))
: undefined,
email: userInfo.email as string | undefined,
emailNotifications: userInfo.emailNotifications === 'true' || userInfo.emailNotifications === true,
};
// 存入缓存
@@ -1335,6 +1339,33 @@ export class UpstashRedisStorage implements IStorage {
async removeUserMovieRequest(userName: string, requestId: string): Promise<void> {
await withRetry(() => this._client.srem(this.userMovieRequestsKey(userName), requestId));
}
// ---------- 用户邮箱相关 ----------
async getUserEmail(userName: string): Promise<string | null> {
const userInfo = await this.getUserInfoV2(userName);
return userInfo?.email || null;
}
async setUserEmail(userName: string, email: string): Promise<void> {
await withRetry(() =>
this._client.hset(this.userInfoKey(userName), { email })
);
// 清除缓存
userInfoCache?.delete(userName);
}
async getEmailNotificationPreference(userName: string): Promise<boolean> {
const userInfo = await this.getUserInfoV2(userName);
return userInfo?.emailNotifications || false;
}
async setEmailNotificationPreference(userName: string, enabled: boolean): Promise<void> {
await withRetry(() =>
this._client.hset(this.userInfoKey(userName), { emailNotifications: enabled.toString() })
);
// 清除缓存
userInfoCache?.delete(userName);
}
}
// 单例 Upstash Redis 客户端