书架更新通知
This commit is contained in:
+61
-4
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +258,7 @@ export interface EpisodeFilterConfig {
|
||||
// 通知类型枚举
|
||||
export type NotificationType =
|
||||
| 'favorite_update' // 收藏更新
|
||||
| 'manga_update' // 漫画更新
|
||||
| 'system' // 系统通知
|
||||
| 'announcement' // 公告
|
||||
| 'movie_request' // 新求片通知(给管理员)
|
||||
|
||||
Reference in New Issue
Block a user