diff --git a/README.md b/README.md
index 8c18abd..1ba02c2 100644
--- a/README.md
+++ b/README.md
@@ -448,6 +448,8 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
| DANMAKU_API_TOKEN | 弹幕 API Token | 任意字符串 | 87654321 |
| DATA_MIGRATION_CHUNK_SIZE | 数据迁移批处理大小(控制导入导出时每批处理的用户数量和数据条数) | 正整数 | 10 |
| QR_LOGIN_STORE_MODE | 电视端扫码登录状态存储模式;serverless环境下多节点内存状态不可靠。 | auto、memory、hybrid、shared | auto |
+| WEB_PUSH_PROXY | Web Push 服务端发送代理地址,用于服务器访问 FCM 等 Push endpoint | HTTP/HTTPS 代理 URL | (空) |
+| WEB_PUSH_BASEURL | Web Push endpoint 反向代理 Base URL;支持 `{endpoint}`(URL编码)和 `{raw_endpoint}`(不编码)占位符 | URL | (空) |
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
diff --git a/migrations/008_web_push_notifications.sql b/migrations/008_web_push_notifications.sql
new file mode 100644
index 0000000..44818cf
--- /dev/null
+++ b/migrations/008_web_push_notifications.sql
@@ -0,0 +1,52 @@
+-- ============================================
+-- Web Push notifications
+-- 版本: 008
+-- 说明:
+-- - SQLite/D1 不支持 ALTER TABLE ADD COLUMN IF NOT EXISTS。
+-- - init-sqlite 使用 schema_migrations 保证该迁移只执行一次。
+-- - 本地 init-sqlite 会逐条执行并忽略 duplicate column name。
+-- ============================================
+
+CREATE TABLE IF NOT EXISTS notification_push_subscriptions (
+ id TEXT PRIMARY KEY,
+ username TEXT NOT NULL,
+ token_id TEXT,
+ endpoint TEXT NOT NULL UNIQUE,
+ p256dh TEXT NOT NULL,
+ auth TEXT NOT NULL,
+ user_agent TEXT,
+ enabled INTEGER DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ last_success_at INTEGER,
+ last_failure_at INTEGER,
+ failure_count INTEGER DEFAULT 0,
+ FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_push_subscriptions_username ON notification_push_subscriptions(username);
+CREATE INDEX IF NOT EXISTS idx_push_subscriptions_token ON notification_push_subscriptions(username, token_id);
+CREATE INDEX IF NOT EXISTS idx_push_subscriptions_enabled ON notification_push_subscriptions(username, enabled);
+
+-- Rebuild notifications without type CHECK so TS NotificationType is the source of truth.
+CREATE TABLE IF NOT EXISTS notifications_new (
+ id TEXT PRIMARY KEY,
+ username TEXT NOT NULL,
+ type TEXT NOT NULL,
+ title TEXT NOT NULL,
+ message TEXT NOT NULL,
+ timestamp INTEGER NOT NULL,
+ read INTEGER DEFAULT 0,
+ metadata TEXT,
+ FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
+);
+
+INSERT OR IGNORE INTO notifications_new (id, username, type, title, message, timestamp, read, metadata)
+SELECT id, username, type, title, message, timestamp, read, metadata FROM notifications;
+
+DROP TABLE notifications;
+ALTER TABLE notifications_new RENAME TO notifications;
+
+CREATE INDEX IF NOT EXISTS idx_notifications_user_time ON notifications(username, timestamp DESC);
+CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(username, read, timestamp DESC);
+
diff --git a/migrations/postgres/008_web_push_notifications.sql b/migrations/postgres/008_web_push_notifications.sql
new file mode 100644
index 0000000..44d7d4c
--- /dev/null
+++ b/migrations/postgres/008_web_push_notifications.sql
@@ -0,0 +1,28 @@
+-- ============================================
+-- Web Push notifications for Postgres
+-- 版本: 008
+-- ============================================
+
+
+CREATE TABLE IF NOT EXISTS notification_push_subscriptions (
+ id TEXT PRIMARY KEY,
+ username TEXT NOT NULL,
+ token_id TEXT,
+ endpoint TEXT NOT NULL UNIQUE,
+ p256dh TEXT NOT NULL,
+ auth TEXT NOT NULL,
+ user_agent TEXT,
+ enabled INTEGER DEFAULT 1,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ last_success_at BIGINT,
+ last_failure_at BIGINT,
+ failure_count INTEGER DEFAULT 0,
+ FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_push_subscriptions_username ON notification_push_subscriptions(username);
+CREATE INDEX IF NOT EXISTS idx_push_subscriptions_token ON notification_push_subscriptions(username, token_id);
+CREATE INDEX IF NOT EXISTS idx_push_subscriptions_enabled ON notification_push_subscriptions(username, enabled);
+
+ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_type_check;
diff --git a/next.config.js b/next.config.js
index 9e8f8b9..39dec99 100644
--- a/next.config.js
+++ b/next.config.js
@@ -183,6 +183,7 @@ const createNextConfig = (phase) => {
dest: 'public',
register: true,
skipWaiting: true,
+ importScripts: ['/push-sw.js'],
});
return withPWA(nextConfig);
diff --git a/public/push-sw.js b/public/push-sw.js
new file mode 100644
index 0000000..855cf60
--- /dev/null
+++ b/public/push-sw.js
@@ -0,0 +1,58 @@
+/* MoonTVPlus Web Push handlers */
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(self.skipWaiting());
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(self.clients.claim());
+});
+
+self.addEventListener('push', (event) => {
+ if (!event.data) return;
+
+ let payload = {};
+ try {
+ payload = event.data.json();
+ } catch (error) {
+ payload = { title: 'MoonTVPlus', body: event.data.text() };
+ }
+
+ const title = payload.title || 'MoonTVPlus';
+ const options = {
+ body: payload.body || payload.message || '',
+ icon: '/icons/icon-192x192.png',
+ badge: '/icons/icon-192x192.png',
+ tag: payload.notificationId || undefined,
+ data: {
+ url: payload.url || '/',
+ notificationId: payload.notificationId,
+ },
+ };
+
+ event.waitUntil(self.registration.showNotification(title, options));
+});
+
+self.addEventListener('notificationclick', (event) => {
+ event.notification.close();
+
+ const targetUrl = new URL(event.notification.data?.url || '/', self.location.origin).href;
+
+ event.waitUntil((async () => {
+ const windowClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+
+ for (const client of windowClients) {
+ if ('focus' in client) {
+ await client.focus();
+ if ('navigate' in client) {
+ return client.navigate(targetUrl);
+ }
+ return;
+ }
+ }
+
+ if (self.clients.openWindow) {
+ return self.clients.openWindow(targetUrl);
+ }
+ })());
+});
diff --git a/scripts/init-postgres.js b/scripts/init-postgres.js
index b52a71a..dd7d5d9 100644
--- a/scripts/init-postgres.js
+++ b/scripts/init-postgres.js
@@ -37,27 +37,86 @@ if (migrationFiles.length === 0) {
console.log(`📄 Found ${migrationFiles.length} migration file(s):`, migrationFiles.join(', '));
+const MIGRATION_BASELINE_CUTOFF = '008_web_push_notifications.sql';
+
+function splitSqlStatements(schemaSql) {
+ const withoutLineComments = schemaSql
+ .split('\n')
+ .filter((line) => !line.trim().startsWith('--'))
+ .join('\n');
+
+ return withoutLineComments
+ .split(';')
+ .map((statement) => statement.trim())
+ .filter((statement) => statement.length > 0);
+}
+
+async function tableExists(tableName) {
+ const result = await sql.query(
+ "SELECT to_regclass($1) AS table_name",
+ [`public.${tableName}`]
+ );
+ return Boolean(result.rows?.[0]?.table_name);
+}
+
+async function ensureMigrationTable() {
+ await sql.query(`
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ filename TEXT PRIMARY KEY,
+ applied_at BIGINT NOT NULL
+ )
+ `);
+}
+
+async function getAppliedMigrations() {
+ const result = await sql.query('SELECT filename FROM schema_migrations');
+ return new Set((result.rows || []).map((row) => row.filename));
+}
+
+async function markMigrationApplied(filename) {
+ await sql.query(
+ 'INSERT INTO schema_migrations (filename, applied_at) VALUES ($1, $2) ON CONFLICT (filename) DO NOTHING',
+ [filename, Date.now()]
+ );
+}
+
+async function seedExistingMigrationBaseline(hadExistingSchema) {
+ const applied = await getAppliedMigrations();
+ if (!hadExistingSchema || applied.size > 0) return;
+
+ for (const file of migrationFiles) {
+ if (file.localeCompare(MIGRATION_BASELINE_CUTOFF) < 0) {
+ await markMigrationApplied(file);
+ }
+ }
+}
+
async function init() {
try {
// 执行所有迁移脚本
console.log('🔧 Running database migrations...');
+ const hadExistingSchema = await tableExists('users');
+ await ensureMigrationTable();
+ await seedExistingMigrationBaseline(hadExistingSchema);
for (const migrationFile of migrationFiles) {
+ const applied = await getAppliedMigrations();
+ if (applied.has(migrationFile)) {
+ console.log(` ⏭️ ${migrationFile} already applied`);
+ continue;
+ }
+
const sqlPath = path.join(migrationsDir, migrationFile);
console.log(` ⏳ Executing ${migrationFile}...`);
const schemaSql = fs.readFileSync(sqlPath, 'utf8');
-
- // 将 SQL 脚本按语句分割并逐个执行
- const statements = schemaSql
- .split(';')
- .map(s => s.trim())
- .filter(s => s.length > 0);
+ const statements = splitSqlStatements(schemaSql);
for (const statement of statements) {
await sql.query(statement);
}
+ await markMigrationApplied(migrationFile);
console.log(` ✅ ${migrationFile} executed successfully`);
}
diff --git a/scripts/init-sqlite.js b/scripts/init-sqlite.js
index 4c5ac50..49627f0 100644
--- a/scripts/init-sqlite.js
+++ b/scripts/init-sqlite.js
@@ -4,6 +4,7 @@ const path = require('path');
const crypto = require('crypto');
const MIGRATIONS_DIR = path.join(__dirname, '../migrations');
+const MIGRATION_BASELINE_CUTOFF = '008_web_push_notifications.sql';
function hashPassword(password) {
return crypto.createHash('sha256').update(password).digest('hex');
@@ -46,24 +47,88 @@ function isIgnorableMigrationError(error) {
);
}
-function runMigrations(db) {
- const migrationFiles = getMigrationFiles();
+function splitSqlStatements(sql) {
+ const withoutLineComments = sql
+ .split('\n')
+ .filter((line) => !line.trim().startsWith('--'))
+ .join('\n');
+
+ return withoutLineComments
+ .split(';')
+ .map((statement) => statement.trim())
+ .filter((statement) => statement.length > 0);
+}
+
+function tableExists(db, tableName) {
+ const row = db
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
+ .get(tableName);
+ return Boolean(row);
+}
+
+function ensureMigrationTable(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ filename TEXT PRIMARY KEY,
+ applied_at INTEGER NOT NULL
+ )
+ `);
+}
+
+function getAppliedMigrations(db) {
+ return new Set(
+ db.prepare('SELECT filename FROM schema_migrations').all().map((row) => row.filename)
+ );
+}
+
+function markMigrationApplied(db, filename) {
+ db.prepare(
+ 'INSERT OR IGNORE INTO schema_migrations (filename, applied_at) VALUES (?, ?)'
+ ).run(filename, Date.now());
+}
+
+function seedExistingMigrationBaseline(db, migrationFiles, hadExistingSchema) {
+ const applied = getAppliedMigrations(db);
+ if (!hadExistingSchema || applied.size > 0) return;
for (const file of migrationFiles) {
+ if (file.localeCompare(MIGRATION_BASELINE_CUTOFF) < 0) {
+ markMigrationApplied(db, file);
+ }
+ }
+}
+
+function runMigrations(db) {
+ const migrationFiles = getMigrationFiles();
+ const hadExistingSchema = tableExists(db, 'users');
+ ensureMigrationTable(db);
+ seedExistingMigrationBaseline(db, migrationFiles, hadExistingSchema);
+
+ for (const file of migrationFiles) {
+ const applied = getAppliedMigrations(db);
+ if (applied.has(file)) {
+ console.log(`⏭️ Migration already applied: ${file}`);
+ continue;
+ }
+
const migrationPath = path.join(MIGRATIONS_DIR, file);
const sql = fs.readFileSync(migrationPath, 'utf8');
+ const statements = splitSqlStatements(sql);
console.log(`▶️ Applying migration: ${file}`);
- try {
- db.exec(sql);
- console.log(`✅ Migration applied: ${file}`);
- } catch (error) {
- if (isIgnorableMigrationError(error)) {
- console.log(`⏭️ Migration skipped: ${file}`);
- continue;
+ for (const statement of statements) {
+ try {
+ db.exec(statement);
+ } catch (error) {
+ if (isIgnorableMigrationError(error)) {
+ console.log(`⏭️ Statement skipped in ${file}: ${error.message}`);
+ continue;
+ }
+ throw error;
}
- throw error;
}
+ markMigrationApplied(db, file);
+ console.log(`✅ Migration applied: ${file}`);
}
}
diff --git a/src/app/api/auth/devices/route.ts b/src/app/api/auth/devices/route.ts
index 863b9c8..a4e966f 100644
--- a/src/app/api/auth/devices/route.ts
+++ b/src/app/api/auth/devices/route.ts
@@ -3,6 +3,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getStorage } from '@/lib/db';
import {
getUserDevices,
revokeAllRefreshTokens,
@@ -51,6 +52,8 @@ export async function DELETE(request: NextRequest) {
}
await revokeRefreshToken(authInfo.username, tokenId);
+ const storage = getStorage();
+ await storage.deletePushSubscriptionsByTokenId?.(authInfo.username, tokenId);
return NextResponse.json({ ok: true });
} catch (error) {
@@ -69,6 +72,8 @@ export async function POST(request: NextRequest) {
try {
await revokeAllRefreshTokens(authInfo.username);
+ const storage = getStorage();
+ await storage.deleteAllPushSubscriptions?.(authInfo.username);
const response = NextResponse.json({ ok: true });
diff --git a/src/app/api/change-password/route.ts b/src/app/api/change-password/route.ts
index 95e0dc9..82baf50 100644
--- a/src/app/api/change-password/route.ts
+++ b/src/app/api/change-password/route.ts
@@ -3,6 +3,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getStorage } from '@/lib/db';
import { db } from '@/lib/db';
import { getUserDevices, revokeRefreshToken } from '@/lib/refresh-token';
@@ -53,11 +54,13 @@ export async function POST(request: NextRequest) {
try {
const currentTokenId = authInfo.tokenId;
const devices = await getUserDevices(username);
+ const storage = getStorage();
// 撤销所有非当前设备的 token
for (const device of devices) {
if (device.tokenId !== currentTokenId) {
await revokeRefreshToken(username, device.tokenId);
+ await storage.deletePushSubscriptionsByTokenId?.(username, device.tokenId);
console.log(`Revoked token ${device.tokenId} for ${username} after password change`);
}
}
diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts
index 87e37f9..0d46d18 100644
--- a/src/app/api/logout/route.ts
+++ b/src/app/api/logout/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getStorage } from '@/lib/db';
import { revokeRefreshToken } from '@/lib/refresh-token';
export const runtime = 'nodejs';
@@ -12,6 +13,8 @@ export async function POST(request: NextRequest) {
if (authInfo && authInfo.username && authInfo.tokenId) {
try {
await revokeRefreshToken(authInfo.username, authInfo.tokenId);
+ const storage = getStorage();
+ await storage.deletePushSubscriptionsByTokenId?.(authInfo.username, authInfo.tokenId);
} catch (error) {
console.error('Failed to revoke refresh token:', error);
}
diff --git a/src/app/api/notifications/push/route.ts b/src/app/api/notifications/push/route.ts
new file mode 100644
index 0000000..16112fa
--- /dev/null
+++ b/src/app/api/notifications/push/route.ts
@@ -0,0 +1,111 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getStorage } from '@/lib/db';
+import {
+ createPushSubscriptionRecord,
+ getVapidPublicKey,
+ isWebPushConfigured,
+} from '@/lib/web-push';
+
+export const runtime = 'nodejs';
+
+function extractSubscriptionKeys(subscription: any) {
+ const p256dh = subscription?.keys?.p256dh || subscription?.toJSON?.()?.keys?.p256dh;
+ const auth = subscription?.keys?.auth || subscription?.toJSON?.()?.keys?.auth;
+ return { p256dh, auth };
+}
+
+export async function GET(request: NextRequest) {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo?.username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const storage = getStorage();
+
+ const [configured, publicKey, subscriptions] = await Promise.all([
+ isWebPushConfigured(storage),
+ getVapidPublicKey(storage),
+ storage.getEnabledPushSubscriptions
+ ? storage.getEnabledPushSubscriptions(authInfo.username)
+ : Promise.resolve([]),
+ ]);
+
+ const currentDeviceSubscriptions = authInfo.tokenId
+ ? subscriptions.filter((item) => item.tokenId === authInfo.tokenId)
+ : [];
+
+ return NextResponse.json({
+ configured,
+ publicKey,
+ pushNotifications: currentDeviceSubscriptions.length > 0,
+ hasDeviceToken: Boolean(authInfo.tokenId),
+ subscriptionCount: subscriptions.length,
+ currentDeviceSubscriptionCount: currentDeviceSubscriptions.length,
+ });
+}
+
+export async function POST(request: NextRequest) {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo?.username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const { subscription, enabled } = body;
+ const storage = getStorage();
+
+ if (enabled === false) {
+ if (authInfo.tokenId) {
+ await storage.deletePushSubscriptionsByTokenId?.(authInfo.username, authInfo.tokenId);
+ }
+ return NextResponse.json({ ok: true });
+ }
+
+ if (subscription) {
+ const { p256dh, auth } = extractSubscriptionKeys(subscription);
+ if (!subscription.endpoint || !p256dh || !auth) {
+ return NextResponse.json({ error: 'Invalid push subscription' }, { status: 400 });
+ }
+
+ if (!authInfo.tokenId) {
+ return NextResponse.json(
+ { error: '当前登录模式不支持设备级浏览器通知订阅' },
+ { status: 400 }
+ );
+ }
+
+ await storage.upsertPushSubscription?.(
+ authInfo.username,
+ createPushSubscriptionRecord({
+ username: authInfo.username,
+ tokenId: authInfo.tokenId,
+ endpoint: subscription.endpoint,
+ p256dh,
+ auth,
+ userAgent: request.headers.get('user-agent'),
+ })
+ );
+ }
+
+ return NextResponse.json({ ok: true });
+}
+
+export async function DELETE(request: NextRequest) {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo?.username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const body = await request.json().catch(() => ({}));
+ const storage = getStorage();
+
+ if (body?.endpoint) {
+ await storage.deletePushSubscriptionByEndpoint?.(authInfo.username, body.endpoint);
+ } else if (authInfo.tokenId) {
+ await storage.deletePushSubscriptionsByTokenId?.(authInfo.username, authInfo.tokenId);
+ }
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/user/email-settings/route.ts b/src/app/api/user/email-settings/route.ts
index 299b279..0d40f24 100644
--- a/src/app/api/user/email-settings/route.ts
+++ b/src/app/api/user/email-settings/route.ts
@@ -6,7 +6,7 @@ import { getStorage } from '@/lib/db';
export const runtime = 'nodejs';
/**
- * GET - 获取用户邮箱设置
+ * GET - 获取用户通知设置
*/
export async function GET(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
@@ -31,7 +31,7 @@ export async function GET(request: NextRequest) {
emailNotifications,
});
} catch (error) {
- console.error('获取用户邮箱设置失败:', error);
+ console.error('获取用户通知设置失败:', error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
@@ -40,7 +40,7 @@ export async function GET(request: NextRequest) {
}
/**
- * POST - 保存用户邮箱设置
+ * POST - 保存用户通知设置
*/
export async function POST(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
@@ -76,12 +76,14 @@ export async function POST(request: NextRequest) {
}
}
+
+
return NextResponse.json({
success: true,
- message: '邮箱设置保存成功',
+ message: '通知设置保存成功',
});
} catch (error) {
- console.error('保存用户邮箱设置失败:', error);
+ console.error('保存用户通知设置失败:', error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
diff --git a/src/components/EmailSettingsPanel.tsx b/src/components/EmailSettingsPanel.tsx
index 499d11a..1ea4dc8 100644
--- a/src/components/EmailSettingsPanel.tsx
+++ b/src/components/EmailSettingsPanel.tsx
@@ -1,6 +1,6 @@
'use client';
-import { X } from 'lucide-react';
+import { Bell, Info, Mail, MonitorSmartphone, X } from 'lucide-react';
import { createPortal } from 'react-dom';
interface EmailSettingsPanelProps {
@@ -11,6 +11,11 @@ interface EmailSettingsPanelProps {
onUserEmailChange: (value: string) => void;
emailNotifications: boolean;
onEmailNotificationsChange: (value: boolean) => void;
+ pushNotifications: boolean;
+ onPushNotificationsChange: (value: boolean) => void;
+ pushNotificationsSupported: boolean;
+ pushNotificationsConfigured: boolean;
+ pushNotificationsBusy: boolean;
emailSettingsLoading: boolean;
emailSettingsSaving: boolean;
onSave: () => void;
@@ -18,6 +23,44 @@ interface EmailSettingsPanelProps {
statusType?: 'success' | 'error' | null;
}
+function Toggle({
+ checked,
+ disabled,
+ busy,
+ label,
+ onChange,
+}: {
+ checked: boolean;
+ disabled?: boolean;
+ busy?: boolean;
+ label: string;
+ onChange: () => void;
+}) {
+ return (
+
+ );
+}
+
export function EmailSettingsPanel({
isOpen,
mounted,
@@ -26,6 +69,11 @@ export function EmailSettingsPanel({
onUserEmailChange,
emailNotifications,
onEmailNotificationsChange,
+ pushNotifications,
+ onPushNotificationsChange,
+ pushNotificationsSupported,
+ pushNotificationsConfigured,
+ pushNotificationsBusy,
emailSettingsLoading,
emailSettingsSaving,
onSave,
@@ -34,56 +82,81 @@ export function EmailSettingsPanel({
}: EmailSettingsPanelProps) {
if (!isOpen || !mounted) return null;
+ const pushDisabled =
+ emailSettingsSaving ||
+ pushNotificationsBusy ||
+ (!pushNotifications && (!pushNotificationsConfigured || !pushNotificationsSupported));
+
return createPortal(
<>
e.preventDefault()}
onWheel={(e) => e.preventDefault()}
style={{ touchAction: 'none' }}
/>
-
+
e.stopPropagation()}
style={{ touchAction: 'auto' }}
>
-
-
- 邮件通知设置
-
+
+
+
+
+
+
+ 通知设置
+
+
+ 管理邮件通知和当前设备浏览器系统通知。
+
+
{emailSettingsLoading ? (
-
-
-
-
+
+
-
) : (
-
-
-
-
-
- 接收收藏更新通知
-
-
- 当收藏的影片有更新时发送邮件通知
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ 当前设备浏览器系统通知
+
+
+ 当前设备收到站内通知时,通过浏览器推送到系统通知中心。
+
+
+
onPushNotificationsChange(!pushNotifications)}
+ />
+
+
+
+
+ 当前设备
+
+ {pushNotificationsSupported ? '可用' : '需支持或授权'}
+
+
+ {!pushNotificationsConfigured && (
+
+ 系统正在初始化 Web Push 密钥,请稍后重试。
+
+ )}
+ {pushNotificationsConfigured && !pushNotificationsSupported && (
+
+ 当前浏览器、系统权限或登录模式暂不支持系统通知。
+
+ )}
+
+
{statusMessage ? (
)}
-
-
- 💡 提示:需要管理员先在管理面板中配置邮件服务
+
+
+
+ 邮件通知需要管理员配置邮件服务;当前设备浏览器系统通知需要当前浏览器授权通知权限。
diff --git a/src/components/PersonalCenterPanel.tsx b/src/components/PersonalCenterPanel.tsx
index 3c5af6e..b0408b1 100644
--- a/src/components/PersonalCenterPanel.tsx
+++ b/src/components/PersonalCenterPanel.tsx
@@ -1,6 +1,6 @@
'use client';
-import { KeyRound, Mail, Monitor, X } from 'lucide-react';
+import { Bell, KeyRound, Monitor, X } from 'lucide-react';
import { createPortal } from 'react-dom';
interface PersonalCenterPanelProps {
@@ -88,14 +88,14 @@ export function PersonalCenterPanel({
className='flex w-full items-center gap-3 rounded-2xl border border-gray-200 bg-gray-50 px-4 py-4 text-left transition-colors hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-750'
>
-
+
- 邮件通知设置
+ 通知设置
- 管理接收收藏更新通知的邮箱和开关
+ 管理邮件通知和浏览器系统通知
diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx
index dcab099..7bbd5c0 100644
--- a/src/components/UserMenu.tsx
+++ b/src/components/UserMenu.tsx
@@ -213,9 +213,13 @@ export const UserMenu: React.FC = () => {
);
const [filesystemSavePath, setFilesystemSavePath] = useState
('');
- // 邮件通知设置
+ // 通知设置
const [userEmail, setUserEmail] = useState('');
const [emailNotifications, setEmailNotifications] = useState(false);
+ const [pushNotifications, setPushNotifications] = useState(false);
+ const [pushNotificationsConfigured, setPushNotificationsConfigured] = useState(false);
+ const [pushNotificationsSupported, setPushNotificationsSupported] = useState(false);
+ const [pushNotificationsBusy, setPushNotificationsBusy] = useState(false);
const [emailSettingsLoading, setEmailSettingsLoading] = useState(false);
const [emailSettingsSaving, setEmailSettingsSaving] = useState(false);
const [emailSettingsMessage, setEmailSettingsMessage] = useState('');
@@ -838,7 +842,7 @@ export const UserMenu: React.FC = () => {
}
}, []);
- // 加载邮件通知设置
+ // 加载通知设置
const loadEmailSettings = async () => {
setEmailSettingsLoading(true);
setEmailSettingsMessage('');
@@ -850,14 +854,207 @@ export const UserMenu: React.FC = () => {
setUserEmail(data.email || '');
setEmailNotifications(data.emailNotifications || false);
}
+
+ const pushResponse = await fetch('/api/notifications/push');
+ if (pushResponse.ok) {
+ const pushData = await pushResponse.json();
+ setPushNotificationsConfigured(Boolean(pushData.configured && pushData.publicKey));
+ setPushNotificationsSupported(
+ Boolean(
+ pushData.configured &&
+ pushData.publicKey &&
+ pushData.hasDeviceToken &&
+ typeof window !== 'undefined' &&
+ 'Notification' in window &&
+ 'serviceWorker' in navigator &&
+ 'PushManager' in window
+ )
+ );
+ setPushNotifications(Boolean(pushData.pushNotifications));
+ }
} catch (error) {
- console.error('加载邮件设置失败:', error);
+ console.error('加载通知设置失败:', error);
} finally {
setEmailSettingsLoading(false);
}
};
- // 保存邮件通知设置
+ const urlBase64ToUint8Array = (base64String: string) => {
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
+ const base64 = (base64String + padding)
+ .replace(/-/g, '+')
+ .replace(/_/g, '/');
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+ for (let i = 0; i < rawData.length; i++) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+ };
+
+ const arrayBufferToBase64Url = (buffer: ArrayBuffer | null) => {
+ if (!buffer) return '';
+ const bytes = new Uint8Array(buffer);
+ let binary = '';
+ for (let i = 0; i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return window
+ .btoa(binary)
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/g, '');
+ };
+
+ const isSubscriptionUsingPublicKey = (
+ subscription: PushSubscription,
+ publicKey: string
+ ) => {
+ const subscriptionKey = arrayBufferToBase64Url(
+ subscription.options?.applicationServerKey || null
+ );
+ return subscriptionKey === publicKey;
+ };
+
+ const waitForServiceWorkerActivation = async (
+ registration: ServiceWorkerRegistration
+ ) => {
+ let pendingWorker = registration.installing || registration.waiting;
+
+ if (!pendingWorker) {
+ await registration.update();
+ pendingWorker = registration.installing || registration.waiting;
+ }
+
+ // 没有新的 installing/waiting worker 时,说明当前 active registration 可直接使用。
+ if (!pendingWorker) {
+ if (registration.active) return registration;
+ throw new Error('Service Worker 注册失败,请刷新页面后重试');
+ }
+
+ const activatingWorker = pendingWorker;
+ if (activatingWorker.state === 'activated') return registration;
+
+ await new Promise((resolve, reject) => {
+ const handleStateChange = () => {
+ if (activatingWorker.state === 'activated') {
+ activatingWorker.removeEventListener('statechange', handleStateChange);
+ resolve();
+ } else if (activatingWorker.state === 'redundant') {
+ activatingWorker.removeEventListener('statechange', handleStateChange);
+ reject(new Error('Service Worker 激活失败,请刷新页面后重试'));
+ }
+ };
+
+ activatingWorker.addEventListener('statechange', handleStateChange);
+ handleStateChange();
+ });
+
+ return registration;
+ };
+
+ const getReadyServiceWorkerRegistration = async () => {
+ if (!('serviceWorker' in navigator)) {
+ throw new Error('当前浏览器不支持 Service Worker');
+ }
+
+ // 开启系统通知时明确使用带 push 事件处理器的 Service Worker。
+ // 如果浏览器里已有旧 /sw.js 注册,重新注册同一 scope 的 /push-sw.js 会更新该注册;
+ // push-sw.js 内部会 skipWaiting + clients.claim,激活后再订阅,确保 Push 到达能展示通知。
+ const registration = await navigator.serviceWorker.register('/push-sw.js', {
+ scope: '/',
+ updateViaCache: 'none',
+ });
+
+ return waitForServiceWorkerActivation(registration);
+ };
+
+ const handlePushNotificationsChange = async (enabled: boolean) => {
+ if (!enabled) {
+ setPushNotificationsBusy(true);
+ try {
+ let endpoint: string | undefined;
+ const registration =
+ 'serviceWorker' in navigator
+ ? await navigator.serviceWorker.getRegistration()
+ : undefined;
+ const subscription = await registration?.pushManager.getSubscription();
+ endpoint = subscription?.endpoint;
+ await subscription?.unsubscribe();
+ await fetch('/api/notifications/push', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ endpoint }),
+ });
+ setPushNotifications(false);
+ } catch (error) {
+ console.error('关闭浏览器通知失败:', error);
+ setEmailSettingsMessage('关闭浏览器通知失败,请重试');
+ setEmailSettingsMessageType('error');
+ } finally {
+ setPushNotificationsBusy(false);
+ }
+ return;
+ }
+
+ setPushNotificationsBusy(true);
+ setEmailSettingsMessage('');
+ setEmailSettingsMessageType(null);
+ try {
+ const statusResponse = await fetch('/api/notifications/push');
+ const status = statusResponse.ok ? await statusResponse.json() : null;
+ if (!status?.configured || !status?.publicKey) {
+ throw new Error('管理员尚未配置 Web Push VAPID 密钥');
+ }
+
+ const permission = await Notification.requestPermission();
+ if (permission !== 'granted') {
+ throw new Error('浏览器通知权限未授权');
+ }
+
+ const registration = await getReadyServiceWorkerRegistration();
+
+ let subscription = await registration.pushManager.getSubscription();
+ if (subscription && !isSubscriptionUsingPublicKey(subscription, status.publicKey)) {
+ await subscription.unsubscribe();
+ subscription = null;
+ }
+
+ if (!subscription) {
+ subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(status.publicKey),
+ });
+ }
+
+ const response = await fetch('/api/notifications/push', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ enabled: true,
+ subscription: subscription.toJSON(),
+ }),
+ });
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}));
+ throw new Error(data.error || '保存浏览器通知订阅失败');
+ }
+
+ setPushNotifications(true);
+ setEmailSettingsMessage('浏览器系统通知已开启');
+ setEmailSettingsMessageType('success');
+ } catch (error) {
+ console.error('开启浏览器通知失败:', error);
+ setPushNotifications(false);
+ setEmailSettingsMessage(error instanceof Error ? error.message : '开启浏览器通知失败');
+ setEmailSettingsMessageType('error');
+ } finally {
+ setPushNotificationsBusy(false);
+ }
+ };
+
+ // 保存通知设置
const handleSaveEmailSettings = async () => {
setEmailSettingsSaving(true);
setEmailSettingsMessage('');
@@ -885,7 +1082,7 @@ export const UserMenu: React.FC = () => {
setEmailSettingsMessageType('error');
}
} catch (error) {
- console.error('保存邮件设置失败:', error);
+ console.error('保存通知设置失败:', error);
setEmailSettingsMessage('保存失败,请重试');
setEmailSettingsMessageType('error');
} finally {
@@ -4866,6 +5063,11 @@ export const UserMenu: React.FC = () => {
onUserEmailChange={setUserEmail}
emailNotifications={emailNotifications}
onEmailNotificationsChange={setEmailNotifications}
+ pushNotifications={pushNotifications}
+ onPushNotificationsChange={handlePushNotificationsChange}
+ pushNotificationsSupported={pushNotificationsSupported}
+ pushNotificationsConfigured={pushNotificationsConfigured}
+ pushNotificationsBusy={pushNotificationsBusy}
emailSettingsLoading={emailSettingsLoading}
emailSettingsSaving={emailSettingsSaving}
onSave={handleSaveEmailSettings}
diff --git a/src/lib/d1.db.ts b/src/lib/d1.db.ts
index 8937edb..5702777 100644
--- a/src/lib/d1.db.ts
+++ b/src/lib/d1.db.ts
@@ -14,6 +14,7 @@ import {
DanmakuFilterConfig,
Notification,
MovieRequest,
+ PushSubscriptionRecord,
} from './types';
import { AdminConfig } from './admin.types';
import { MangaReadRecord, MangaShelfItem } from './manga.types';
@@ -25,6 +26,7 @@ import {
MusicV2PlaylistRecord,
} from './music-v2';
import { userInfoCache } from './user-cache';
+import { dispatchWebPushNotification } from './web-push';
/**
* Cloudflare D1 存储实现
@@ -81,6 +83,7 @@ export class D1Storage implements IStorage {
}
}
+
// ==================== 播放记录 ====================
async getPlayRecord(
@@ -1911,6 +1914,133 @@ export class D1Storage implements IStorage {
}
}
+
+ async upsertPushSubscription(
+ userName: string,
+ subscription: PushSubscriptionRecord
+ ): Promise {
+ try {
+ const result = await this.db
+ .prepare(`
+ INSERT INTO notification_push_subscriptions (
+ id, username, token_id, endpoint, p256dh, auth, user_agent, enabled,
+ created_at, updated_at, last_success_at, last_failure_at, failure_count
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0)
+ ON CONFLICT(endpoint) DO UPDATE SET
+ username = excluded.username,
+ token_id = excluded.token_id,
+ p256dh = excluded.p256dh,
+ auth = excluded.auth,
+ user_agent = excluded.user_agent,
+ enabled = 1,
+ updated_at = excluded.updated_at
+ `)
+ .bind(
+ subscription.id,
+ userName,
+ subscription.tokenId || null,
+ subscription.endpoint,
+ subscription.p256dh,
+ subscription.auth,
+ subscription.userAgent || null,
+ subscription.enabled ? 1 : 0,
+ subscription.createdAt,
+ subscription.updatedAt
+ )
+ .run();
+ if (!result.success) {
+ throw new Error(result.error || '保存浏览器通知订阅失败');
+ }
+ } catch (err) {
+ console.error('D1Storage.upsertPushSubscription error:', err);
+ throw err;
+ }
+ }
+
+ async getEnabledPushSubscriptions(userName: string): Promise {
+ try {
+ const results = await this.db
+ .prepare('SELECT * FROM notification_push_subscriptions WHERE username = ? AND enabled = 1')
+ .bind(userName)
+ .all();
+
+ return (results.results || []).map((row: any) => ({
+ id: row.id as string,
+ username: row.username as string,
+ tokenId: (row.token_id as string | null) || null,
+ endpoint: row.endpoint as string,
+ p256dh: row.p256dh as string,
+ auth: row.auth as string,
+ userAgent: (row.user_agent as string | null) || null,
+ enabled: row.enabled === 1,
+ createdAt: Number(row.created_at),
+ updatedAt: Number(row.updated_at),
+ lastSuccessAt: row.last_success_at ? Number(row.last_success_at) : null,
+ lastFailureAt: row.last_failure_at ? Number(row.last_failure_at) : null,
+ failureCount: Number(row.failure_count || 0),
+ }));
+ } catch (err) {
+ console.error('D1Storage.getEnabledPushSubscriptions error:', err);
+ return [];
+ }
+ }
+
+ async deletePushSubscriptionByEndpoint(userName: string, endpoint: string): Promise {
+ try {
+ await this.db
+ .prepare('DELETE FROM notification_push_subscriptions WHERE username = ? AND endpoint = ?')
+ .bind(userName, endpoint)
+ .run();
+ } catch (err) {
+ console.error('D1Storage.deletePushSubscriptionByEndpoint error:', err);
+ }
+ }
+
+ async deletePushSubscriptionsByTokenId(userName: string, tokenId: string): Promise {
+ try {
+ await this.db
+ .prepare('DELETE FROM notification_push_subscriptions WHERE username = ? AND token_id = ?')
+ .bind(userName, tokenId)
+ .run();
+ } catch (err) {
+ console.error('D1Storage.deletePushSubscriptionsByTokenId error:', err);
+ }
+ }
+
+ async deleteAllPushSubscriptions(userName: string): Promise {
+ try {
+ await this.db
+ .prepare('DELETE FROM notification_push_subscriptions WHERE username = ?')
+ .bind(userName)
+ .run();
+ } catch (err) {
+ console.error('D1Storage.deleteAllPushSubscriptions error:', err);
+ }
+ }
+
+ async updatePushSubscriptionDeliveryStats(
+ userName: string,
+ endpoint: string,
+ success: boolean
+ ): Promise {
+ try {
+ const now = Date.now();
+ if (success) {
+ await this.db
+ .prepare('UPDATE notification_push_subscriptions SET last_success_at = ?, failure_count = 0, updated_at = ? WHERE username = ? AND endpoint = ?')
+ .bind(now, now, userName, endpoint)
+ .run();
+ } else {
+ await this.db
+ .prepare('UPDATE notification_push_subscriptions SET last_failure_at = ?, failure_count = failure_count + 1, updated_at = ? WHERE username = ? AND endpoint = ?')
+ .bind(now, now, userName, endpoint)
+ .run();
+ }
+ } catch (err) {
+ console.error('D1Storage.updatePushSubscriptionDeliveryStats error:', err);
+ }
+ }
+
// ==================== TVBox订阅token ====================
async getTvboxSubscribeToken?(userName: string): Promise {
@@ -2952,6 +3082,8 @@ export class D1Storage implements IStorage {
notification.metadata ? JSON.stringify(notification.metadata) : null
)
.run();
+
+ await dispatchWebPushNotification(this, userName, notification);
} catch (err) {
console.error('D1Storage.addNotification error:', err);
throw err;
@@ -3059,7 +3191,7 @@ export class D1Storage implements IStorage {
requested_by, request_count, status, created_at, updated_at,
fulfilled_at, fulfilled_source, fulfilled_id
)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
)
.bind(
diff --git a/src/lib/postgres.db.ts b/src/lib/postgres.db.ts
index ccedd59..1ea5951 100644
--- a/src/lib/postgres.db.ts
+++ b/src/lib/postgres.db.ts
@@ -16,6 +16,7 @@ import {
DanmakuFilterConfig,
Notification,
MovieRequest,
+ PushSubscriptionRecord,
} from './types';
import { AdminConfig } from './admin.types';
import { MangaReadRecord, MangaShelfItem } from './manga.types';
@@ -26,6 +27,7 @@ import {
MusicV2PlaylistItem,
MusicV2PlaylistRecord,
} from './music-v2';
+import { dispatchWebPushNotification } from './web-push';
/**
* Vercel Postgres 存储实现
@@ -1066,13 +1068,137 @@ export class PostgresStorage implements IStorage {
}
}
+
+ async upsertPushSubscription(
+ userName: string,
+ subscription: PushSubscriptionRecord
+ ): Promise {
+ try {
+ await this.db
+ .prepare(`
+ INSERT INTO notification_push_subscriptions (
+ id, username, token_id, endpoint, p256dh, auth, user_agent, enabled,
+ created_at, updated_at, last_success_at, last_failure_at, failure_count
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NULL, NULL, 0)
+ ON CONFLICT(endpoint) DO UPDATE SET
+ username = excluded.username,
+ token_id = excluded.token_id,
+ p256dh = excluded.p256dh,
+ auth = excluded.auth,
+ user_agent = excluded.user_agent,
+ enabled = 1,
+ updated_at = excluded.updated_at
+ `)
+ .bind(
+ subscription.id,
+ userName,
+ subscription.tokenId || null,
+ subscription.endpoint,
+ subscription.p256dh,
+ subscription.auth,
+ subscription.userAgent || null,
+ subscription.enabled ? 1 : 0,
+ subscription.createdAt,
+ subscription.updatedAt
+ )
+ .run();
+ } catch (err) {
+ console.error('PostgresStorage.upsertPushSubscription error:', err);
+ throw err;
+ }
+ }
+
+ async getEnabledPushSubscriptions(userName: string): Promise {
+ try {
+ const results = await this.db
+ .prepare('SELECT * FROM notification_push_subscriptions WHERE username = $1 AND enabled = 1')
+ .bind(userName)
+ .all();
+
+ return (results.results || []).map((row: any) => ({
+ id: row.id as string,
+ username: row.username as string,
+ tokenId: (row.token_id as string | null) || null,
+ endpoint: row.endpoint as string,
+ p256dh: row.p256dh as string,
+ auth: row.auth as string,
+ userAgent: (row.user_agent as string | null) || null,
+ enabled: row.enabled === 1,
+ createdAt: Number(row.created_at),
+ updatedAt: Number(row.updated_at),
+ lastSuccessAt: row.last_success_at ? Number(row.last_success_at) : null,
+ lastFailureAt: row.last_failure_at ? Number(row.last_failure_at) : null,
+ failureCount: Number(row.failure_count || 0),
+ }));
+ } catch (err) {
+ console.error('PostgresStorage.getEnabledPushSubscriptions error:', err);
+ return [];
+ }
+ }
+
+ async deletePushSubscriptionByEndpoint(userName: string, endpoint: string): Promise {
+ try {
+ await this.db
+ .prepare('DELETE FROM notification_push_subscriptions WHERE username = $1 AND endpoint = $2')
+ .bind(userName, endpoint)
+ .run();
+ } catch (err) {
+ console.error('PostgresStorage.deletePushSubscriptionByEndpoint error:', err);
+ }
+ }
+
+ async deletePushSubscriptionsByTokenId(userName: string, tokenId: string): Promise {
+ try {
+ await this.db
+ .prepare('DELETE FROM notification_push_subscriptions WHERE username = $1 AND token_id = $2')
+ .bind(userName, tokenId)
+ .run();
+ } catch (err) {
+ console.error('PostgresStorage.deletePushSubscriptionsByTokenId error:', err);
+ }
+ }
+
+ async deleteAllPushSubscriptions(userName: string): Promise {
+ try {
+ await this.db
+ .prepare('DELETE FROM notification_push_subscriptions WHERE username = $1')
+ .bind(userName)
+ .run();
+ } catch (err) {
+ console.error('PostgresStorage.deleteAllPushSubscriptions error:', err);
+ }
+ }
+
+ async updatePushSubscriptionDeliveryStats(
+ userName: string,
+ endpoint: string,
+ success: boolean
+ ): Promise {
+ try {
+ const now = Date.now();
+ if (success) {
+ await this.db
+ .prepare('UPDATE notification_push_subscriptions SET last_success_at = $1, failure_count = 0, updated_at = $2 WHERE username = $3 AND endpoint = $4')
+ .bind(now, now, userName, endpoint)
+ .run();
+ } else {
+ await this.db
+ .prepare('UPDATE notification_push_subscriptions SET last_failure_at = $1, failure_count = failure_count + 1, updated_at = $2 WHERE username = $3 AND endpoint = $4')
+ .bind(now, now, userName, endpoint)
+ .run();
+ }
+ } catch (err) {
+ console.error('PostgresStorage.updatePushSubscriptionDeliveryStats error:', err);
+ }
+ }
+
// ==================== TVBox订阅token ====================
async getTvboxSubscribeToken(userName: string): Promise {
try {
const result = await this.db
.prepare(
- 'SELECT tvbox_subscribe_token FROM users_v2 WHERE username = $1'
+ 'SELECT tvbox_subscribe_token FROM users WHERE username = $1'
)
.bind(userName)
.first();
@@ -1088,7 +1214,7 @@ export class PostgresStorage implements IStorage {
try {
await this.db
.prepare(
- 'UPDATE users_v2 SET tvbox_subscribe_token = $1 WHERE username = $2'
+ 'UPDATE users SET tvbox_subscribe_token = $1 WHERE username = $2'
)
.bind(token, userName)
.run();
@@ -1106,7 +1232,7 @@ export class PostgresStorage implements IStorage {
try {
const result = await this.db
.prepare(
- 'SELECT username FROM users_v2 WHERE tvbox_subscribe_token = $1'
+ 'SELECT username FROM users WHERE tvbox_subscribe_token = $1'
)
.bind(token)
.first();
@@ -2937,6 +3063,8 @@ export class PostgresStorage implements IStorage {
notification.metadata ? JSON.stringify(notification.metadata) : null
)
.run();
+
+ await dispatchWebPushNotification(this, userName, notification);
} catch (err) {
console.error('PostgresStorage.addNotification error:', err);
throw err;
@@ -3044,7 +3172,7 @@ export class PostgresStorage implements IStorage {
requested_by, request_count, status, created_at, updated_at,
fulfilled_at, fulfilled_source, fulfilled_id
)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
`
)
.bind(
diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts
index 33dd528..e123048 100644
--- a/src/lib/redis-base.db.ts
+++ b/src/lib/redis-base.db.ts
@@ -11,8 +11,9 @@ import {
MusicV2PlaylistRecord,
} from './music-v2';
import { RedisAdapter } from './redis-adapter';
-import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
+import { Favorite, IStorage, Notification, PlayRecord, PushSubscriptionRecord, SkipConfig } from './types';
import { userInfoCache } from './user-cache';
+import { dispatchWebPushNotification } from './web-push';
// 搜索历史最大条数
const SEARCH_HISTORY_LIMIT = 20;
@@ -2268,6 +2269,8 @@ export abstract class BaseRedisStorage implements IStorage {
JSON.stringify(notifications)
)
);
+
+ await dispatchWebPushNotification(this, userName, notification);
}
async markNotificationAsRead(
@@ -2465,6 +2468,106 @@ export abstract class BaseRedisStorage implements IStorage {
userInfoCache?.delete(userName);
}
+
+ private pushSubscriptionsKey(userName: string): string {
+ return `u:${userName}:push_subscriptions`;
+ }
+
+ async upsertPushSubscription(
+ userName: string,
+ subscription: PushSubscriptionRecord
+ ): Promise {
+ await this.withRetry(() =>
+ this.adapter.hSet(
+ this.pushSubscriptionsKey(userName),
+ subscription.id,
+ JSON.stringify({ ...subscription, username: userName, updatedAt: Date.now() })
+ )
+ );
+ }
+
+ async getEnabledPushSubscriptions(userName: string): Promise {
+ const all = await this.withRetry(() =>
+ this.adapter.hGetAll(this.pushSubscriptionsKey(userName))
+ );
+ if (!all || typeof all !== 'object') return [];
+
+ return Object.values(all)
+ .map((raw) => {
+ try {
+ return JSON.parse(raw as string) as PushSubscriptionRecord;
+ } catch {
+ return null;
+ }
+ })
+ .filter((item): item is PushSubscriptionRecord => Boolean(item?.enabled));
+ }
+
+ async deletePushSubscriptionByEndpoint(userName: string, endpoint: string): Promise {
+ const subscriptions = await this.getEnabledPushSubscriptions(userName);
+ const target = subscriptions.find((item) => item.endpoint === endpoint);
+ if (!target) return;
+ await this.withRetry(() =>
+ this.adapter.hDel(this.pushSubscriptionsKey(userName), target.id)
+ );
+ }
+
+ async deletePushSubscriptionsByTokenId(userName: string, tokenId: string): Promise {
+ const all = await this.withRetry(() =>
+ this.adapter.hGetAll(this.pushSubscriptionsKey(userName))
+ );
+ if (!all || typeof all !== 'object') return;
+
+ for (const [id, raw] of Object.entries(all)) {
+ try {
+ const subscription = JSON.parse(raw as string) as PushSubscriptionRecord;
+ if (subscription.tokenId === tokenId) {
+ await this.withRetry(() =>
+ this.adapter.hDel(this.pushSubscriptionsKey(userName), id)
+ );
+ }
+ } catch {
+ // ignore malformed record
+ }
+ }
+ }
+
+ async deleteAllPushSubscriptions(userName: string): Promise {
+ await this.withRetry(() => this.adapter.del(this.pushSubscriptionsKey(userName)));
+ }
+
+ async updatePushSubscriptionDeliveryStats(
+ userName: string,
+ endpoint: string,
+ success: boolean
+ ): Promise {
+ const all = await this.withRetry(() =>
+ this.adapter.hGetAll(this.pushSubscriptionsKey(userName))
+ );
+ if (!all || typeof all !== 'object') return;
+
+ for (const [id, raw] of Object.entries(all)) {
+ try {
+ const subscription = JSON.parse(raw as string) as PushSubscriptionRecord;
+ if (subscription.endpoint !== endpoint) continue;
+ const now = Date.now();
+ const next = {
+ ...subscription,
+ updatedAt: now,
+ lastSuccessAt: success ? now : subscription.lastSuccessAt || null,
+ lastFailureAt: success ? subscription.lastFailureAt || null : now,
+ failureCount: success ? 0 : (subscription.failureCount || 0) + 1,
+ };
+ await this.withRetry(() =>
+ this.adapter.hSet(this.pushSubscriptionsKey(userName), id, JSON.stringify(next))
+ );
+ return;
+ } catch {
+ // ignore malformed record
+ }
+ }
+ }
+
// ---------- TVBox订阅token相关 ----------
async getTvboxSubscribeToken(userName: string): Promise {
// 直接从数据库读取,不使用缓存
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 846d5d9..de358d9 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -233,6 +233,26 @@ export interface IStorage {
userName: string,
enabled: boolean
): Promise;
+ // Web Push订阅相关
+ upsertPushSubscription?(
+ userName: string,
+ subscription: PushSubscriptionRecord
+ ): Promise;
+ getEnabledPushSubscriptions?(userName: string): Promise;
+ deletePushSubscriptionByEndpoint?(
+ userName: string,
+ endpoint: string
+ ): Promise;
+ deletePushSubscriptionsByTokenId?(
+ userName: string,
+ tokenId: string
+ ): Promise;
+ deleteAllPushSubscriptions?(userName: string): Promise;
+ updatePushSubscriptionDeliveryStats?(
+ userName: string,
+ endpoint: string,
+ success: boolean
+ ): Promise;
// TVBox订阅token相关
getTvboxSubscribeToken?(userName: string): Promise;
@@ -314,6 +334,23 @@ export interface EpisodeFilterConfig {
reverseMode?: boolean; // 反向模式:开启后仅显示符合规则的集数
}
+
+export interface PushSubscriptionRecord {
+ id: string;
+ username?: string;
+ tokenId?: string | null;
+ endpoint: string;
+ p256dh: string;
+ auth: string;
+ userAgent?: string | null;
+ enabled: boolean;
+ createdAt: number;
+ updatedAt: number;
+ lastSuccessAt?: number | null;
+ lastFailureAt?: number | null;
+ failureCount?: number;
+}
+
// 通知类型枚举
export type NotificationType =
| 'favorite_update' // 收藏更新
diff --git a/src/lib/web-push.ts b/src/lib/web-push.ts
new file mode 100644
index 0000000..f0d4840
--- /dev/null
+++ b/src/lib/web-push.ts
@@ -0,0 +1,547 @@
+/* eslint-disable no-console */
+
+import crypto from 'crypto';
+
+import { HttpsProxyAgent } from 'https-proxy-agent';
+import nodeFetch from 'node-fetch';
+
+import { lockManager } from './lock';
+import { IStorage, Notification, PushSubscriptionRecord } from './types';
+
+const DEFAULT_TTL_SECONDS = 60 * 60 * 24;
+const WEB_PUSH_MAX_RETRIES = 2;
+const SUBJECT_ENV = 'WEB_PUSH_SUBJECT';
+const PROXY_ENV = 'WEB_PUSH_PROXY';
+const BASE_URL_ENV = 'WEB_PUSH_BASEURL';
+const VAPID_KEYS_GLOBAL_CONFIG_KEY = 'web_push_vapid_keys';
+
+interface VapidKeys {
+ publicKey: string;
+ privateKey: string;
+}
+
+export interface WebPushDeliveryResult {
+ endpointHost: string;
+ ok: boolean;
+ status?: number;
+ error?: string;
+ removed?: boolean;
+}
+
+export interface WebPushDispatchResult {
+ configured: boolean;
+ preferenceEnabled: boolean;
+ subscriptionCount: number;
+ deliveries: WebPushDeliveryResult[];
+}
+
+const globalVapidCacheKey = Symbol.for('__MOONTV_WEB_PUSH_VAPID_KEYS__');
+const globalVapidPromiseKey = Symbol.for('__MOONTV_WEB_PUSH_VAPID_KEYS_PROMISE__');
+
+function getCachedVapidKeys(): VapidKeys | null {
+ return ((globalThis as any)[globalVapidCacheKey] as VapidKeys | undefined) || null;
+}
+
+function setCachedVapidKeys(keys: VapidKeys): void {
+ (globalThis as any)[globalVapidCacheKey] = keys;
+}
+
+function getCachedVapidKeysPromise(): Promise | null {
+ return ((globalThis as any)[globalVapidPromiseKey] as Promise | undefined) || null;
+}
+
+function setCachedVapidKeysPromise(promise: Promise | null): void {
+ if (promise) {
+ (globalThis as any)[globalVapidPromiseKey] = promise;
+ } else {
+ delete (globalThis as any)[globalVapidPromiseKey];
+ }
+}
+
+function base64UrlEncode(input: Buffer | Uint8Array | string): string {
+ const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
+ return buffer
+ .toString('base64')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/g, '');
+}
+
+function base64UrlDecode(input: string): Buffer {
+ const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
+ const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
+ return Buffer.from(normalized + padding, 'base64');
+}
+
+function isCloudflareEnvironment(): boolean {
+ return process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
+}
+
+function getWebPushProxy(): string | null {
+ const proxy = process.env[PROXY_ENV]?.trim();
+ return proxy || null;
+}
+
+function normalizeBaseUrl(input: string): string {
+ return input.trim().replace(/\/+$/, '');
+}
+
+function getWebPushRequestUrl(endpoint: string): string {
+ const baseUrl = process.env[BASE_URL_ENV]?.trim();
+ if (!baseUrl) return endpoint;
+
+ const endpointUrl = new URL(endpoint);
+ const normalizedBase = normalizeBaseUrl(baseUrl);
+
+ // 支持自定义转发服务格式:
+ // - {endpoint}: URL 编码后的完整原始 endpoint,适合放在 query 参数里
+ // - {raw_endpoint}: 未编码的完整原始 endpoint,适合路径重写或代理服务自行解析
+ if (normalizedBase.includes('{raw_endpoint}')) {
+ return normalizedBase.replace('{raw_endpoint}', endpoint);
+ }
+ if (normalizedBase.includes('{endpoint}')) {
+ return normalizedBase.replace('{endpoint}', encodeURIComponent(endpoint));
+ }
+
+ const base = new URL(normalizedBase);
+ const basePath = base.pathname.replace(/\/+$/, '');
+ const endpointPath = endpointUrl.pathname.startsWith('/')
+ ? endpointUrl.pathname
+ : `/${endpointUrl.pathname}`;
+
+ base.pathname = `${basePath}${endpointPath}`.replace(/\/+/g, '/');
+ base.search = endpointUrl.search;
+ return base.toString();
+}
+
+async function fetchWebPushEndpoint(
+ endpoint: string,
+ init: {
+ method: string;
+ headers: Record;
+ body: Buffer;
+ }
+): Promise {
+ const requestUrl = getWebPushRequestUrl(endpoint);
+ const proxy = getWebPushProxy();
+
+ if (isCloudflareEnvironment()) {
+ if (proxy) {
+ console.warn('WEB_PUSH_PROXY is ignored in Cloudflare runtime; use WEB_PUSH_BASEURL instead.');
+ }
+ return fetch(requestUrl, init) as Promise;
+ }
+
+ const fetchOptions: any = {
+ method: init.method,
+ headers: init.headers,
+ body: init.body,
+ };
+
+ if (proxy) {
+ fetchOptions.agent = new HttpsProxyAgent(proxy, {
+ timeout: 30000,
+ keepAlive: false,
+ });
+ }
+
+ return nodeFetch(requestUrl, fetchOptions) as unknown as Response;
+}
+
+function generateVapidKeys(): VapidKeys {
+ const ecdh = crypto.createECDH('prime256v1');
+ ecdh.generateKeys();
+
+ return {
+ publicKey: base64UrlEncode(ecdh.getPublicKey(undefined, 'uncompressed')),
+ privateKey: base64UrlEncode(ecdh.getPrivateKey()),
+ };
+}
+
+function parseStoredVapidKeys(raw: string | null): VapidKeys | null {
+ if (!raw) return null;
+
+ try {
+ const parsed = JSON.parse(raw) as Partial;
+ if (parsed.publicKey && parsed.privateKey) {
+ return { publicKey: parsed.publicKey, privateKey: parsed.privateKey };
+ }
+ } catch (error) {
+ console.error('Failed to parse stored Web Push VAPID keys:', error);
+ }
+
+ return null;
+}
+
+async function loadOrCreateDatabaseVapidKeys(storage: IStorage): Promise {
+ if (!storage?.getGlobalValue || !storage?.setGlobalValue) {
+ throw new Error('当前存储类型不支持保存 Web Push VAPID 密钥');
+ }
+
+ const existing = parseStoredVapidKeys(
+ await storage.getGlobalValue(VAPID_KEYS_GLOBAL_CONFIG_KEY)
+ );
+ if (existing) return existing;
+
+ const release = await lockManager.acquire('web-push-vapid-keys');
+ try {
+ const latest = parseStoredVapidKeys(
+ await storage.getGlobalValue(VAPID_KEYS_GLOBAL_CONFIG_KEY)
+ );
+ if (latest) return latest;
+
+ const keys = generateVapidKeys();
+ await storage.setGlobalValue(
+ VAPID_KEYS_GLOBAL_CONFIG_KEY,
+ JSON.stringify(keys)
+ );
+ return keys;
+ } finally {
+ release();
+ }
+}
+
+export async function getVapidKeys(storage: IStorage): Promise {
+ const cached = getCachedVapidKeys();
+ if (cached) return cached;
+
+ const cachedPromise = getCachedVapidKeysPromise();
+ if (cachedPromise) return cachedPromise;
+
+ const promise = loadOrCreateDatabaseVapidKeys(storage)
+ .then((keys) => {
+ setCachedVapidKeys(keys);
+ return keys;
+ })
+ .finally(() => setCachedVapidKeysPromise(null));
+
+ setCachedVapidKeysPromise(promise);
+ return promise;
+}
+
+export async function getVapidPublicKey(storage: IStorage): Promise {
+ try {
+ return (await getVapidKeys(storage)).publicKey;
+ } catch (error) {
+ console.error('Failed to get Web Push VAPID public key:', error);
+ return null;
+ }
+}
+
+export async function isWebPushConfigured(storage: IStorage): Promise {
+ try {
+ await getVapidKeys(storage);
+ return true;
+ } catch (error) {
+ console.error('Web Push VAPID keys are not configured:', error);
+ return false;
+ }
+}
+
+function getVapidSubject(): string {
+ return process.env[SUBJECT_ENV] || process.env.NEXT_PUBLIC_SITE_URL || 'mailto:admin@example.com';
+}
+
+function getPublicKeyFromPrivate(privateKeyBase64Url: string): Buffer {
+ const ecdh = crypto.createECDH('prime256v1');
+ ecdh.setPrivateKey(base64UrlDecode(privateKeyBase64Url));
+ return ecdh.getPublicKey(undefined, 'uncompressed');
+}
+
+function createVapidJwt(endpoint: string, privateKeyBase64Url: string): string {
+ const audience = new URL(endpoint).origin;
+ const publicKey = getPublicKeyFromPrivate(privateKeyBase64Url);
+ const x = publicKey.subarray(1, 33);
+ const y = publicKey.subarray(33, 65);
+ const d = base64UrlDecode(privateKeyBase64Url);
+
+ const header = { typ: 'JWT', alg: 'ES256' };
+ const payload = {
+ aud: audience,
+ exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60,
+ sub: getVapidSubject(),
+ };
+
+ const signingInput = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`;
+ const key = crypto.createPrivateKey({
+ key: {
+ kty: 'EC',
+ crv: 'P-256',
+ x: base64UrlEncode(x),
+ y: base64UrlEncode(y),
+ d: base64UrlEncode(d),
+ },
+ format: 'jwk',
+ });
+
+ const signature = crypto.sign('sha256', Buffer.from(signingInput), {
+ key,
+ dsaEncoding: 'ieee-p1363',
+ });
+
+ return `${signingInput}.${base64UrlEncode(signature)}`;
+}
+
+function hkdf(secret: Buffer, salt: Buffer, info: Buffer | string, length: number): Buffer {
+ const prk = crypto.createHmac('sha256', salt).update(secret).digest();
+ const infoBuffer = Buffer.isBuffer(info) ? info : Buffer.from(info);
+ const blocks: Buffer[] = [];
+ let previous = Buffer.alloc(0);
+ let counter = 1;
+
+ while (Buffer.concat(blocks).length < length) {
+ previous = crypto
+ .createHmac('sha256', prk)
+ .update(Buffer.concat([previous, infoBuffer, Buffer.from([counter])]))
+ .digest();
+ blocks.push(previous);
+ counter += 1;
+ }
+
+ return Buffer.concat(blocks).subarray(0, length);
+}
+
+function encryptPayload(payload: string, subscription: PushSubscriptionRecord) {
+ const receiverPublicKey = base64UrlDecode(subscription.p256dh);
+ const authSecret = base64UrlDecode(subscription.auth);
+ const salt = crypto.randomBytes(16);
+ const localEcdh = crypto.createECDH('prime256v1');
+ localEcdh.generateKeys();
+ const senderPublicKey = localEcdh.getPublicKey(undefined, 'uncompressed');
+ const sharedSecret = localEcdh.computeSecret(receiverPublicKey);
+
+ const keyInfo = Buffer.concat([
+ Buffer.from('WebPush: info\0'),
+ receiverPublicKey,
+ senderPublicKey,
+ ]);
+ const ikm = hkdf(sharedSecret, authSecret, keyInfo, 32);
+ const cek = hkdf(ikm, salt, 'Content-Encoding: aes128gcm\0', 16);
+ const nonce = hkdf(ikm, salt, 'Content-Encoding: nonce\0', 12);
+
+ const plaintext = Buffer.concat([Buffer.from(payload), Buffer.from([0x02])]);
+ const cipher = crypto.createCipheriv('aes-128-gcm', cek, nonce);
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
+ const tag = cipher.getAuthTag();
+ const ciphertext = Buffer.concat([encrypted, tag]);
+
+ const recordSize = Buffer.alloc(4);
+ recordSize.writeUInt32BE(4096, 0);
+
+ const body = Buffer.concat([
+ salt,
+ recordSize,
+ Buffer.from([senderPublicKey.length]),
+ senderPublicKey,
+ ciphertext,
+ ]);
+
+ return body;
+}
+
+export function getNotificationClickUrl(notification: Notification): string {
+ const metadata = notification.metadata || {};
+
+ if (notification.type === 'favorite_update' && metadata.source && metadata.id) {
+ const title = encodeURIComponent(String(metadata.title || ''));
+ return `/play?source=${encodeURIComponent(String(metadata.source))}&id=${encodeURIComponent(String(metadata.id))}&title=${title}`;
+ }
+
+ if (notification.type === 'manga_update' && metadata.sourceId && metadata.mangaId) {
+ const params = new URLSearchParams({
+ sourceId: String(metadata.sourceId),
+ mangaId: String(metadata.mangaId),
+ title: String(metadata.title || ''),
+ cover: String(metadata.cover || ''),
+ sourceName: String(metadata.sourceName || ''),
+ });
+ return `/manga/detail?${params.toString()}`;
+ }
+
+ if (notification.type === 'movie_request') {
+ return '/admin';
+ }
+
+ if (notification.type === 'request_fulfilled') {
+ if (metadata.source && metadata.id) {
+ return `/play?source=${encodeURIComponent(String(metadata.source))}&id=${encodeURIComponent(String(metadata.id))}&title=${encodeURIComponent(notification.title)}`;
+ }
+ return '/movie-request';
+ }
+
+ if (notification.type === 'anime_subscription_update') {
+ return '/private-library';
+ }
+
+ return '/';
+}
+
+function buildPayload(notification: Notification): string {
+ return JSON.stringify({
+ notificationId: notification.id,
+ type: notification.type,
+ title: notification.title,
+ message: notification.message,
+ body: notification.message,
+ url: getNotificationClickUrl(notification),
+ timestamp: notification.timestamp,
+ });
+}
+
+async function sendToSubscription(storage: IStorage, subscription: PushSubscriptionRecord, payload: string): Promise {
+ const keys = await getVapidKeys(storage);
+
+ const jwt = createVapidJwt(subscription.endpoint, keys.privateKey);
+ const encryptedBody = encryptPayload(payload, subscription);
+
+ return fetchWebPushEndpoint(subscription.endpoint, {
+ method: 'POST',
+ headers: {
+ Authorization: `vapid t=${jwt}, k=${keys.publicKey}`,
+ 'Content-Encoding': 'aes128gcm',
+ 'Content-Type': 'application/octet-stream',
+ TTL: String(DEFAULT_TTL_SECONDS),
+ Urgency: 'normal',
+ },
+ body: encryptedBody,
+ });
+}
+
+function shouldRetryWebPushResponse(response: Response): boolean {
+ if (response.status === 404 || response.status === 410) return false;
+ return !response.ok;
+}
+
+async function sendToSubscriptionWithRetry(
+ storage: IStorage,
+ subscription: PushSubscriptionRecord,
+ payload: string
+): Promise {
+ let lastError: unknown = null;
+
+ for (let attempt = 0; attempt <= WEB_PUSH_MAX_RETRIES; attempt++) {
+ try {
+ const response = await sendToSubscription(storage, subscription, payload);
+ if (!shouldRetryWebPushResponse(response) || attempt === WEB_PUSH_MAX_RETRIES) {
+ return response;
+ }
+
+ console.warn(
+ `Web Push send failed with ${response.status}, retrying (${attempt + 1}/${WEB_PUSH_MAX_RETRIES})...`
+ );
+ } catch (error) {
+ lastError = error;
+ if (attempt === WEB_PUSH_MAX_RETRIES) break;
+ console.warn(
+ `Web Push send error, retrying (${attempt + 1}/${WEB_PUSH_MAX_RETRIES}):`,
+ error
+ );
+ }
+ }
+
+ throw lastError instanceof Error ? lastError : new Error(String(lastError || 'Web Push send failed'));
+}
+
+export async function dispatchWebPushNotificationWithResult(
+ storage: IStorage,
+ userName: string,
+ notification: Notification
+): Promise {
+ const configured = await isWebPushConfigured(storage);
+ if (!configured || !storage.getEnabledPushSubscriptions) {
+ return {
+ configured,
+ preferenceEnabled: false,
+ subscriptionCount: 0,
+ deliveries: [],
+ };
+ }
+
+ const subscriptions = await storage.getEnabledPushSubscriptions(userName);
+ if (!subscriptions.length) {
+ return {
+ configured,
+ preferenceEnabled: true,
+ subscriptionCount: 0,
+ deliveries: [],
+ };
+ }
+
+ const payload = buildPayload(notification);
+ const settled = await Promise.allSettled(
+ subscriptions.map(async (subscription): Promise => {
+ const endpointHost = new URL(subscription.endpoint).host;
+ try {
+ const response = await sendToSubscriptionWithRetry(storage, subscription, payload);
+
+ if (response.ok) {
+ await storage.updatePushSubscriptionDeliveryStats?.(userName, subscription.endpoint, true);
+ return { endpointHost, ok: true, status: response.status };
+ }
+
+ if (response.status === 404 || response.status === 410) {
+ await storage.deletePushSubscriptionByEndpoint?.(userName, subscription.endpoint);
+ return { endpointHost, ok: false, status: response.status, removed: true };
+ }
+
+ await storage.updatePushSubscriptionDeliveryStats?.(userName, subscription.endpoint, false);
+ const errorText = await response.text().catch(() => '');
+ console.warn(`Web Push failed (${response.status}) for ${userName}: ${errorText}`);
+ return { endpointHost, ok: false, status: response.status, error: errorText || response.statusText };
+ } catch (error) {
+ await storage.updatePushSubscriptionDeliveryStats?.(userName, subscription.endpoint, false);
+ console.error('Web Push delivery error:', error);
+ return {
+ endpointHost,
+ ok: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ })
+ );
+
+ return {
+ configured,
+ preferenceEnabled: true,
+ subscriptionCount: subscriptions.length,
+ deliveries: settled.map((item) =>
+ item.status === 'fulfilled'
+ ? item.value
+ : { endpointHost: 'unknown', ok: false, error: item.reason instanceof Error ? item.reason.message : String(item.reason) }
+ ),
+ };
+}
+
+export async function dispatchWebPushNotification(
+ storage: IStorage,
+ userName: string,
+ notification: Notification
+): Promise {
+ await dispatchWebPushNotificationWithResult(storage, userName, notification);
+}
+
+export function createPushSubscriptionRecord(input: {
+ username: string;
+ tokenId?: string | null;
+ endpoint: string;
+ p256dh: string;
+ auth: string;
+ userAgent?: string | null;
+}): PushSubscriptionRecord {
+ const now = Date.now();
+ return {
+ id: base64UrlEncode(crypto.createHash('sha256').update(input.endpoint).digest()),
+ username: input.username,
+ tokenId: input.tokenId || null,
+ endpoint: input.endpoint,
+ p256dh: input.p256dh,
+ auth: input.auth,
+ userAgent: input.userAgent || null,
+ enabled: true,
+ createdAt: now,
+ updatedAt: now,
+ lastSuccessAt: null,
+ lastFailureAt: null,
+ failureCount: 0,
+ };
+}