书架更新通知

This commit is contained in:
mtvpls
2026-04-18 00:48:17 +08:00
parent 7fb11f0ff7
commit 308650f771
16 changed files with 797 additions and 22 deletions
+61 -4
View File
@@ -36,14 +36,39 @@ import { userInfoCache } from './user-cache';
*/
export class D1Storage implements IStorage {
private db: DatabaseAdapter;
private schemaReady: Promise<void>;
public adapter: RedisHashAdapter;
constructor(adapter: DatabaseAdapter) {
this.db = adapter;
this.schemaReady = this.ensureMangaShelfColumns();
// 创建 Redis Hash 兼容适配器用于设备管理
this.adapter = new RedisHashAdapter(adapter);
}
private async ensureMangaShelfColumns(): Promise<void> {
const statements = [
'ALTER TABLE manga_shelf ADD COLUMN latest_chapter_id TEXT',
'ALTER TABLE manga_shelf ADD COLUMN latest_chapter_name TEXT',
'ALTER TABLE manga_shelf ADD COLUMN latest_chapter_count INTEGER',
'ALTER TABLE manga_shelf ADD COLUMN unread_chapter_count INTEGER',
];
for (const statement of statements) {
try {
const result = await this.db.prepare(statement).run();
if (!result.success && result.error && !/duplicate column|already exists/i.test(result.error)) {
console.warn('D1Storage.ensureMangaShelfColumns warning:', result.error);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!/duplicate column|already exists|no such table/i.test(message)) {
console.warn('D1Storage.ensureMangaShelfColumns warning:', err);
}
}
}
}
// ==================== 播放记录 ====================
async getPlayRecord(userName: string, key: string): Promise<PlayRecord | null> {
@@ -1780,6 +1805,7 @@ export class D1Storage implements IStorage {
async getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null> {
try {
await this.schemaReady;
const result = await this.db
.prepare('SELECT * FROM manga_shelf WHERE username = ? AND key = ?')
.bind(userName, key)
@@ -1798,6 +1824,16 @@ export class D1Storage implements IStorage {
status: (result.status as string) || undefined,
lastChapterId: (result.last_chapter_id as string) || undefined,
lastChapterName: (result.last_chapter_name as string) || undefined,
latestChapterId: (result.latest_chapter_id as string) || undefined,
latestChapterName: (result.latest_chapter_name as string) || undefined,
latestChapterCount:
result.latest_chapter_count === null || result.latest_chapter_count === undefined
? undefined
: Number(result.latest_chapter_count),
unreadChapterCount:
result.unread_chapter_count === null || result.unread_chapter_count === undefined
? undefined
: Number(result.unread_chapter_count),
};
} catch (err) {
console.error('D1Storage.getMangaShelf error:', err);
@@ -1807,13 +1843,15 @@ export class D1Storage implements IStorage {
async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void> {
try {
await this.schemaReady;
await this.db
.prepare(`
INSERT INTO manga_shelf (
username, key, source_id, source_name, manga_id, title, cover, save_time,
description, author, status, last_chapter_id, last_chapter_name
description, author, status, last_chapter_id, last_chapter_name,
latest_chapter_id, latest_chapter_name, latest_chapter_count, unread_chapter_count
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(username, key) DO UPDATE SET
source_id = excluded.source_id,
source_name = excluded.source_name,
@@ -1825,7 +1863,11 @@ export class D1Storage implements IStorage {
author = excluded.author,
status = excluded.status,
last_chapter_id = excluded.last_chapter_id,
last_chapter_name = excluded.last_chapter_name
last_chapter_name = excluded.last_chapter_name,
latest_chapter_id = excluded.latest_chapter_id,
latest_chapter_name = excluded.latest_chapter_name,
latest_chapter_count = excluded.latest_chapter_count,
unread_chapter_count = excluded.unread_chapter_count
`)
.bind(
userName,
@@ -1840,7 +1882,11 @@ export class D1Storage implements IStorage {
item.author || null,
item.status || null,
item.lastChapterId || null,
item.lastChapterName || null
item.lastChapterName || null,
item.latestChapterId || null,
item.latestChapterName || null,
item.latestChapterCount ?? null,
item.unreadChapterCount ?? null
)
.run();
} catch (err) {
@@ -1851,6 +1897,7 @@ export class D1Storage implements IStorage {
async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> {
try {
await this.schemaReady;
const results = await this.db
.prepare('SELECT * FROM manga_shelf WHERE username = ? ORDER BY save_time DESC')
.bind(userName)
@@ -1872,6 +1919,16 @@ export class D1Storage implements IStorage {
status: (row.status as string) || undefined,
lastChapterId: (row.last_chapter_id as string) || undefined,
lastChapterName: (row.last_chapter_name as string) || undefined,
latestChapterId: (row.latest_chapter_id as string) || undefined,
latestChapterName: (row.latest_chapter_name as string) || undefined,
latestChapterCount:
row.latest_chapter_count === null || row.latest_chapter_count === undefined
? undefined
: Number(row.latest_chapter_count),
unreadChapterCount:
row.unread_chapter_count === null || row.unread_chapter_count === undefined
? undefined
: Number(row.unread_chapter_count),
};
}
+136
View File
@@ -10,6 +10,15 @@ export interface FavoriteUpdate {
cover?: string;
}
export interface MangaShelfUpdate {
title: string;
previousChapterCount: number;
latestChapterCount: number;
latestChapterName?: string;
url: string;
cover?: string;
}
/**
* 收藏更新邮件模板
*/
@@ -263,3 +272,130 @@ export function getBatchFavoriteUpdateEmailTemplate(
</html>
`;
}
export function getBatchMangaUpdateEmailTemplate(
userName: string,
updates: MangaShelfUpdate[],
siteUrl: string,
siteName?: string
): string {
const totalUpdates = updates.length;
const totalNewChapters = updates.reduce(
(sum, item) => sum + Math.max(item.latestChapterCount - item.previousChapterCount, 0),
0
);
const updatesList = updates
.map(
(item) => `
<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;">
${
item.cover
? `<img src="${item.cover}" alt="${item.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;">${item.title}</div>
<div style="color: #666; margin-bottom: 6px;">
${item.previousChapterCount} 话 → <span style="color: #2563eb; font-weight: bold;">${item.latestChapterCount} 话</span>
<span style="color: #10b981; font-weight: bold;">(+${Math.max(item.latestChapterCount - item.previousChapterCount, 0)})</span>
</div>
${
item.latestChapterName
? `<div style="color: #666; margin-bottom: 10px;">最新章节:${item.latestChapterName}</div>`
: ''
}
<a href="${item.url}" style="display: inline-block; padding: 6px 12px; background: #2563eb; 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: #2563eb;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>漫画书架更新汇总</h1>
<div class="stats">
${totalUpdates} 部漫画更新 · 共 ${totalNewChapters} 话新内容
</div>
</div>
<div class="content">
<div class="greeting">
Hi <strong>${userName}</strong>,
</div>
<p style="color: #666; margin-bottom: 20px;">您书架中的漫画有以下更新:</p>
${updatesList}
</div>
<div class="footer">
<p>此邮件由 <a href="${siteUrl}">${siteName || 'MoonTVPlus'}</a> 自动发送</p>
<p>如不想接收此类邮件,请在用户设置中关闭邮件通知</p>
</div>
</div>
</body>
</html>
`;
}
+4
View File
@@ -53,6 +53,10 @@ export interface MangaShelfItem {
status?: string;
lastChapterId?: string;
lastChapterName?: string;
latestChapterId?: string;
latestChapterName?: string;
latestChapterCount?: number;
unreadChapterCount?: number;
}
export interface MangaReadRecord {
+58 -4
View File
@@ -37,14 +37,36 @@ import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from
*/
export class PostgresStorage implements IStorage {
private db: DatabaseAdapter;
private schemaReady: Promise<void>;
public adapter: any; // 用于兼容
constructor(adapter: DatabaseAdapter) {
this.db = adapter;
this.schemaReady = this.ensureMangaShelfColumns();
// 创建一个简单的适配器用于设备管理
this.adapter = new PostgresRedisHashAdapter(adapter);
}
private async ensureMangaShelfColumns(): Promise<void> {
const statements = [
'ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS latest_chapter_id TEXT',
'ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS latest_chapter_name TEXT',
'ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS latest_chapter_count INTEGER',
'ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS unread_chapter_count INTEGER',
];
for (const statement of statements) {
try {
const result = await this.db.prepare(statement).run();
if (!result.success && result.error) {
console.warn('PostgresStorage.ensureMangaShelfColumns warning:', result.error);
}
} catch (err) {
console.warn('PostgresStorage.ensureMangaShelfColumns warning:', err);
}
}
}
// ==================== 播放记录 ====================
async getPlayRecord(userName: string, key: string): Promise<PlayRecord | null> {
@@ -1752,6 +1774,7 @@ export class PostgresStorage implements IStorage {
async getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null> {
try {
await this.schemaReady;
const result = await this.db
.prepare('SELECT * FROM manga_shelf WHERE username = $1 AND key = $2')
.bind(userName, key)
@@ -1770,6 +1793,16 @@ export class PostgresStorage implements IStorage {
status: (result.status as string) || undefined,
lastChapterId: (result.last_chapter_id as string) || undefined,
lastChapterName: (result.last_chapter_name as string) || undefined,
latestChapterId: (result.latest_chapter_id as string) || undefined,
latestChapterName: (result.latest_chapter_name as string) || undefined,
latestChapterCount:
result.latest_chapter_count === null || result.latest_chapter_count === undefined
? undefined
: Number(result.latest_chapter_count),
unreadChapterCount:
result.unread_chapter_count === null || result.unread_chapter_count === undefined
? undefined
: Number(result.unread_chapter_count),
};
} catch (err) {
console.error('PostgresStorage.getMangaShelf error:', err);
@@ -1779,13 +1812,15 @@ export class PostgresStorage implements IStorage {
async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void> {
try {
await this.schemaReady;
await this.db
.prepare(`
INSERT INTO manga_shelf (
username, key, source_id, source_name, manga_id, title, cover, save_time,
description, author, status, last_chapter_id, last_chapter_name
description, author, status, last_chapter_id, last_chapter_name,
latest_chapter_id, latest_chapter_name, latest_chapter_count, unread_chapter_count
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
ON CONFLICT (username, key) DO UPDATE SET
source_id = EXCLUDED.source_id,
source_name = EXCLUDED.source_name,
@@ -1797,7 +1832,11 @@ export class PostgresStorage implements IStorage {
author = EXCLUDED.author,
status = EXCLUDED.status,
last_chapter_id = EXCLUDED.last_chapter_id,
last_chapter_name = EXCLUDED.last_chapter_name
last_chapter_name = EXCLUDED.last_chapter_name,
latest_chapter_id = EXCLUDED.latest_chapter_id,
latest_chapter_name = EXCLUDED.latest_chapter_name,
latest_chapter_count = EXCLUDED.latest_chapter_count,
unread_chapter_count = EXCLUDED.unread_chapter_count
`)
.bind(
userName,
@@ -1812,7 +1851,11 @@ export class PostgresStorage implements IStorage {
item.author || null,
item.status || null,
item.lastChapterId || null,
item.lastChapterName || null
item.lastChapterName || null,
item.latestChapterId || null,
item.latestChapterName || null,
item.latestChapterCount ?? null,
item.unreadChapterCount ?? null
)
.run();
} catch (err) {
@@ -1823,6 +1866,7 @@ export class PostgresStorage implements IStorage {
async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> {
try {
await this.schemaReady;
const results = await this.db
.prepare('SELECT * FROM manga_shelf WHERE username = $1 ORDER BY save_time DESC')
.bind(userName)
@@ -1844,6 +1888,16 @@ export class PostgresStorage implements IStorage {
status: (row.status as string) || undefined,
lastChapterId: (row.last_chapter_id as string) || undefined,
lastChapterName: (row.last_chapter_name as string) || undefined,
latestChapterId: (row.latest_chapter_id as string) || undefined,
latestChapterName: (row.latest_chapter_name as string) || undefined,
latestChapterCount:
row.latest_chapter_count === null || row.latest_chapter_count === undefined
? undefined
: Number(row.latest_chapter_count),
unreadChapterCount:
row.unread_chapter_count === null || row.unread_chapter_count === undefined
? undefined
: Number(row.unread_chapter_count),
};
}
+1
View File
@@ -258,6 +258,7 @@ export interface EpisodeFilterConfig {
// 通知类型枚举
export type NotificationType =
| 'favorite_update' // 收藏更新
| 'manga_update' // 漫画更新
| 'system' // 系统通知
| 'announcement' // 公告
| 'movie_request' // 新求片通知(给管理员)