diff --git a/packages/backend/package.json b/packages/backend/package.json index 2715f32..cca7ee4 100755 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "cloudsearch-backend", - "version": "0.1.6", + "version": "0.1.7", "private": true, "scripts": { "dev": "tsx watch src/main.ts", diff --git a/packages/backend/src/cloud/cleanup.service.ts b/packages/backend/src/cloud/cleanup.service.ts index e8be01d..8bf67f4 100755 --- a/packages/backend/src/cloud/cleanup.service.ts +++ b/packages/backend/src/cloud/cleanup.service.ts @@ -3,6 +3,7 @@ import { getSystemConfig, updateSystemConfig } from '../admin/system-config.serv import { formatLocalDate, formatLocalDateTime } from '../utils/time'; import { QuarkDriver } from './drivers/quark.driver'; import { BaiduDriver } from './drivers/baidu.driver'; +import { notifyEvent } from './notification.service'; // ═══════════════════════════════════════════════════════════════════════════ // CloudCleanupDriver — contract that each cloud driver must fulfill @@ -265,4 +266,14 @@ export async function checkAndRunScheduledCleanup(): Promise { console.log(`[Cleanup] Scheduled cleanup starting at ${new Date().toISOString()}...`); const stats = await runFullCleanup(); console.log(`[Cleanup] Done: trashed ${stats.filesTrashed} folders, deleted ${stats.logsDeleted} logs, emptied trash: ${stats.trashEmptied}, errors: ${stats.errors.length}`); + + // ── Notify ── + const lines: string[] = []; + if (stats.filesTrashed > 0) lines.push(`移入回收站 ${stats.filesTrashed} 个文件夹`); + if (stats.logsDeleted > 0) lines.push(`删除 ${stats.logsDeleted} 条日志`); + if (stats.trashEmptied) lines.push('已清空回收站'); + if (stats.errors.length > 0) lines.push(`⚠️ ${stats.errors.length} 个错误(${stats.errors.slice(0, 3).join('; ')}${stats.errors.length > 3 ? `...` : ''})`); + if (lines.length > 0) { + notifyEvent('cleanup', `🧹 清理完成`, lines.join('\n'), stats.errors.length > 0 ? 'warn' : 'info'); + } } diff --git a/packages/backend/src/cloud/cloud.service.ts b/packages/backend/src/cloud/cloud.service.ts index 356aec0..cfc1655 100644 --- a/packages/backend/src/cloud/cloud.service.ts +++ b/packages/backend/src/cloud/cloud.service.ts @@ -6,6 +6,7 @@ import { QuarkDriver } from './drivers/quark.driver'; import { BaiduDriver } from './drivers/baidu.driver'; import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service'; import { lookupIpLocation } from './ip-lookup'; +import { notify, notifyError, notifyInfo, notifyWarn, notifyEvent } from './notification.service'; /** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */ const inFlightSaves = new Map>(); @@ -193,15 +194,29 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle? const durationMs = Date.now() - startTime; if (driverResult.success) { + const nickname = config.nickname || cloudType; + notifyEvent('save_success', `✅ 转存成功`, + `**${cloudType}** · ${nickname}\n文件: ${driverResult.folderName || sourceTitle || shareUrl}\n耗时: ${((Date.now() - startTime) / 1000).toFixed(1)}s`, + 'info'); + db.prepare( `UPDATE cloud_configs SET last_used_at = datetime('now','localtime'), total_saves = total_saves + 1, consecutive_failures = 0 WHERE id = ?` ).run(config.id); } else if ((driverResult as any).cookieExpired) { // Cookie expired — don't count as failure, user needs to re-login + notifyEvent('cookie_expire', `⚠️ Cookie过期`, + `**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n请重新登录`, + 'error'); } else { db.prepare( `UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?` ).run(config.id); + const failCount = (db.prepare(`SELECT consecutive_failures FROM cloud_configs WHERE id = ?`).get(config.id) as any)?.consecutive_failures || 0; + if (failCount >= 3) { + notifyEvent('save_fail', `❌ 转存连续失败 ${failCount} 次`, + `**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n错误: ${driverResult.message}`, + 'warn'); + } } db.prepare( diff --git a/packages/backend/src/cloud/notification.service.ts b/packages/backend/src/cloud/notification.service.ts index f60114c..1ca866c 100644 --- a/packages/backend/src/cloud/notification.service.ts +++ b/packages/backend/src/cloud/notification.service.ts @@ -1,87 +1,248 @@ -// Native fetch available in Node 20+ +// ============================================================ +// notification.service.ts — Multi-channel message notification +// Channels: Feishu Webhook / Server酱 / Bark / Custom Webhook / Telegram +// ============================================================ + import { getSystemConfig } from '../admin/system-config.service'; -type NotifyLevel = 'info' | 'warn' | 'error'; +export type NotifyLevel = 'info' | 'warn' | 'error'; -interface NotifyChannel { +export interface NotifyChannel { send(title: string, content: string, level: NotifyLevel): Promise; } -// ---- Feishu Webhook Channel ---- +// ======================== Channel Implementations ======================== + +/** Feishu/Lark webhook — interactive card message */ class FeishuChannel implements NotifyChannel { private webhookUrl: string; - - constructor(webhookUrl: string) { - this.webhookUrl = webhookUrl; - } + constructor(webhookUrl: string) { this.webhookUrl = webhookUrl; } async send(title: string, content: string, _level: NotifyLevel): Promise { try { - const body = JSON.stringify({ - msg_type: 'interactive', - card: { - header: { - title: { tag: 'plain_text', content: title }, - template: _level === 'error' ? 'red' : _level === 'warn' ? 'orange' : 'blue', - }, - elements: [ - { tag: 'div', text: { tag: 'lark_md', content } }, - { - tag: 'note', - elements: [ - { tag: 'plain_text', content: `CloudSearch · ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` }, - ], - }, - ], - }, - }); - const resp = await fetch(this.webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body, + body: JSON.stringify({ + msg_type: 'interactive', + card: { + header: { + title: { tag: 'plain_text', content: title }, + template: _level === 'error' ? 'red' : _level === 'warn' ? 'orange' : 'blue', + }, + elements: [ + { tag: 'div', text: { tag: 'lark_md', content } }, + { + tag: 'note', + elements: [ + { tag: 'plain_text', content: `CloudSearch · ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` }, + ], + }, + ], + }, + }), }); - - if (!resp.ok) { - console.error(`[Notify] Feishu send failed: ${resp.status}`); - } + if (!resp.ok) console.error(`[Notify] Feishu send failed: ${resp.status}`); } catch (err: any) { console.error('[Notify] Feishu send error:', err.message); } } } -// ---- Notification Manager ---- -let _channel: NotifyChannel | null = null; +/** Server酱 — push to WeChat via https://sct.ftqq.com */ +class ServerChanChannel implements NotifyChannel { + private sendKey: string; + constructor(sendKey: string) { this.sendKey = sendKey; } -function getChannel(): NotifyChannel | null { - const feishuUrl = process.env.FEISHU_WEBHOOK || getSystemConfig('feishu_webhook_url'); - if (!feishuUrl) return null; - - if (!_channel) { - _channel = new FeishuChannel(feishuUrl); - console.log('[Notify] Feishu webhook configured'); + async send(title: string, content: string, _level: NotifyLevel): Promise { + try { + const resp = await fetch(`https://sctapi.ftqq.com/${this.sendKey}.send`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ title, desp: content }).toString(), + }); + if (!resp.ok) console.error(`[Notify] ServerChan send failed: ${resp.status}`); + } catch (err: any) { + console.error('[Notify] ServerChan send error:', err.message); + } } - return _channel; } +/** Bark — push to iOS devices via https://api.day.app */ +class BarkChannel implements NotifyChannel { + private key: string; + private server: string; + constructor(key: string, server: string = 'https://api.day.app') { + this.key = key; + this.server = server.replace(/\/+$/, ''); + } + + async send(title: string, content: string, level: NotifyLevel): Promise { + try { + const iconMap: Record = { + error: '⚠️', warn: '🔔', info: 'ℹ️', + }; + const body = JSON.stringify({ + title: `${iconMap[level]} ${title}`, + body: content, + group: 'CloudSearch', + level: level === 'error' ? 'timeSensitive' : 'active', + icon: level === 'error' ? 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/26a0.png' : undefined, + }); + const resp = await fetch(`${this.server}/${this.key}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + if (!resp.ok) console.error(`[Notify] Bark send failed: ${resp.status}`); + } catch (err: any) { + console.error('[Notify] Bark send error:', err.message); + } + } +} + +/** Custom Webhook — generic HTTP POST */ +class WebhookChannel implements NotifyChannel { + private url: string; + constructor(url: string) { this.url = url; } + + async send(title: string, content: string, level: NotifyLevel): Promise { + try { + const resp = await fetch(this.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, content, level, source: 'CloudSearch', timestamp: new Date().toISOString() }), + }); + if (!resp.ok) console.error(`[Notify] Webhook send failed: ${resp.status}`); + } catch (err: any) { + console.error('[Notify] Webhook send error:', err.message); + } + } +} + +/** Telegram Bot — send via Bot API */ +class TelegramChannel implements NotifyChannel { + private botToken: string; + private chatId: string; + constructor(botToken: string, chatId: string) { + this.botToken = botToken; + this.chatId = chatId; + } + + async send(title: string, content: string, level: NotifyLevel): Promise { + try { + const iconMap: Record = { error: '🚨', warn: '⚠️', info: 'ℹ️' }; + const text = `${iconMap[level]} *${title}*\n\n${content}`; + const resp = await fetch(`https://api.telegram.org/bot${this.botToken}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: this.chatId, + text, + parse_mode: 'Markdown', + disable_web_page_preview: true, + }), + }); + if (!resp.ok) { + const err = await resp.text(); + console.error(`[Notify] Telegram send failed: ${resp.status} — ${err}`); + } + } catch (err: any) { + console.error('[Notify] Telegram send error:', err.message); + } + } +} + +// ======================== Notification Manager ======================== + +interface ChannelConfig { + id: string; + create: () => NotifyChannel | null; +} + +let _channels: NotifyChannel[] | null = null; +let _channelConfigs: ChannelConfig[] | null = null; +let _lastConfigHash: string = ''; +let _debugLogged: boolean = false; + +function buildConfigHash(): string { + const keys = ['feishu_webhook_url', 'serverchan_key', 'bark_key', 'bark_server', 'webhook_url', 'telegram_bot_token', 'telegram_chat_id']; + let hash = ''; + for (const key of keys) { + try { hash += String(getSystemConfig(key) || ''); } catch { hash += ''; } + } + return hash; +} + +function buildChannels(): NotifyChannel[] { + const channels: NotifyChannel[] = []; + + // 1. Feishu + const feishuUrl = process.env.FEISHU_WEBHOOK || getSystemConfig('feishu_webhook_url') || ''; + if (feishuUrl) channels.push(new FeishuChannel(feishuUrl)); + + // 2. Server酱 + const serverchanKey = getSystemConfig('serverchan_key') || ''; + if (serverchanKey) channels.push(new ServerChanChannel(serverchanKey)); + + // 3. Bark + const barkKey = getSystemConfig('bark_key') || ''; + if (barkKey) { + const barkServer = getSystemConfig('bark_server') || 'https://api.day.app'; + channels.push(new BarkChannel(barkKey, barkServer)); + } + + // 4. Custom Webhook + const webhookUrl = getSystemConfig('webhook_url') || ''; + if (webhookUrl) channels.push(new WebhookChannel(webhookUrl)); + + // 5. Telegram + const tgToken = getSystemConfig('telegram_bot_token') || ''; + const tgChatId = getSystemConfig('telegram_chat_id') || ''; + if (tgToken && tgChatId) channels.push(new TelegramChannel(tgToken, tgChatId)); + + if (!_debugLogged && channels.length > 0) { + _debugLogged = true; + console.log(`[Notify] ${channels.length} channel(s) configured: ${channels.map(c => c.constructor.name.replace('Channel','')).join(', ')}`); + } + return channels; +} + +function getChannels(): NotifyChannel[] { + const hash = buildConfigHash(); + if (hash !== _lastConfigHash) { + _channels = buildChannels(); + _lastConfigHash = hash; + } + return _channels || []; +} + +// ======================== Event Trigger Checks ======================== + +function shouldNotify(eventName: string): boolean { + try { + const val = getSystemConfig(`notify_on_${eventName}`); + return val !== 'false'; // default to true + } catch { + return true; + } +} + +// ======================== Public API ======================== + /** - * Send a notification through configured channels. - * Returns immediately — failures are logged silently. + * Send a notification through all configured channels. + * Fire-and-forget — failures are logged silently. */ export function notify(title: string, content: string, level: NotifyLevel = 'info'): void { - const ch = getChannel(); - if (!ch) return; - // Fire-and-forget — don't block the caller - ch.send(title, content, level).catch(() => {}); + const channels = getChannels(); + if (channels.length === 0) return; + for (const ch of channels) { + ch.send(title, content, level).catch(() => {}); + } } -/** - * Notify on critical events: - * - Cookie expired / login failed - * - Save/transfer failed repeatedly - * - Storage below threshold - */ +/** Notify on critical errors (Cookie expired, save failure, etc.) */ export function notifyError(title: string, detail: string): void { notify(`⚠️ ${title}`, detail, 'error'); } @@ -93,3 +254,12 @@ export function notifyWarn(title: string, detail: string): void { export function notifyInfo(title: string, detail: string): void { notify(`ℹ️ ${title}`, detail, 'info'); } + +/** + * Event-aware notification: checks if this event type is enabled, + * then sends the notification. + */ +export function notifyEvent(eventName: string, title: string, content: string, level: NotifyLevel = 'info'): void { + if (!shouldNotify(eventName)) return; + notify(title, content, level); +} diff --git a/packages/backend/src/database/database.ts b/packages/backend/src/database/database.ts index be0e6ee..4d7db2e 100755 --- a/packages/backend/src/database/database.ts +++ b/packages/backend/src/database/database.ts @@ -316,6 +316,11 @@ function seedSystemConfigs(db: Database.Database): void { { key: 'cleanup_space_threshold_percent', value: '90', description: '空间使用阈值百分比(超过此值时触发强制清理)' }, { key: 'cleanup_space_threshold_delete_percent', value: '10', description: '触发阈值清理时释放总空间的百分比(如 10 表示累计删除最旧文件直到达到总空间的 10%,6TB 总空间 → 释放 ~600GB)' }, { key: 'save_reuse_enabled', value: 'true', description: '启用分享链接复用(相同原始链接不再重复转存,直接复用之前的分享链接)' }, + { key: 'cleanup_whitelist_dirs', value: '[]', description: '清理白名单目录名称列表(JSON数组),这些目录不会被自动清理' }, + { key: 'storage_refresh_interval', value: '60', description: '存储空间刷新间隔(分钟),0=不自动刷新' }, + { key: 'cleanup_auto_refresh_storage', value: 'true', description: '启用自动刷新存储空间信息' }, + { key: 'cleanup_verify_enabled', value: 'true', description: '定期验证网盘 Cookie 有效性(随存储刷新一起执行)' }, + { key: 'cleanup_verify_interval', value: '30', description: 'Cookie 有效性检测间隔(分钟)' }, { key: 'cleanup_last_run', value: '', description: '上次自动清理时间' }, { key: 'cleanup_last_stats', value: '', description: '上次清理结果统计(JSON)' }, { key: 'quark_ad_keywords', value: '广告,推广,福利,加V,加微,联系,客服,赚钱,兼职', description: '夸克转存广告关键词(一行一个,匹配文件名/文件夹名即删除)' }, @@ -323,6 +328,18 @@ function seedSystemConfigs(db: Database.Database): void { { key: 'quark_sus_extensions', value: 'bat\nexe\nvbs\nscr\ncmd\ncom\npif\njs\njar\nmsi\nreg\ninf\nps1', description: '夸克转存可疑文件后缀(一行一个,不写点号,匹配即删除)' }, { key: 'link_valid_keywords', value: '链接有效', description: 'PanSou 链接有效关键词(一行一条)' }, { key: 'link_invalid_keywords', value: '链接失效', description: 'PanSou 链接失效关键词和本地验证失效关键词(一行一条)' }, + // ── 消息推送 ── + { key: 'feishu_webhook_url', value: '', description: '飞书机器人 Webhook URL(如 https://open.feishu.cn/open-apis/bot/v2/hook/xxx)' }, + { key: 'serverchan_key', value: '', description: 'Server酱 SendKey(https://sct.ftqq.com,推送到微信)' }, + { key: 'bark_key', value: '', description: 'Bark 推送 Key(https://api.day.app,推送到 iOS 设备)' }, + { key: 'bark_server', value: 'https://api.day.app', description: 'Bark 自定义服务器地址(默认 https://api.day.app)' }, + { key: 'webhook_url', value: '', description: '自定义 Webhook URL(POST JSON: {title, content, level, source})' }, + { key: 'telegram_bot_token', value: '', description: 'Telegram Bot Token(如 123456:ABC-DEF,用于 TG 推送)' }, + { key: 'telegram_chat_id', value: '', description: 'Telegram 接收消息的 Chat ID(数字或 @频道名)' }, + { key: 'notify_on_save_success', value: 'true', description: '转存成功时推送通知' }, + { key: 'notify_on_save_fail', value: 'true', description: '转存失败时推送通知(连续失败 3 次后推送)' }, + { key: 'notify_on_cookie_expire', value: 'true', description: 'Cookie 过期时推送通知' }, + { key: 'notify_on_cleanup', value: 'true', description: '自动清理完成时推送通知' }, ]; const insert = db.prepare( 'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)' diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index cb9d0ee..93ec7de 100755 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -4,7 +4,7 @@ import cors from 'cors'; import helmet from 'helmet'; import morgan from 'morgan'; import config from './config'; -import { APP_VERSION } from "./version"; +import { VERSION as APP_VERSION } from "./version"; import { getDb } from './database/database'; import { connectRedis, disconnectRedis, reconnectRedis, testRedisConnection } from './middleware/cache'; import rateLimiter from './middleware/rate-limit'; diff --git a/packages/backend/src/version.ts b/packages/backend/src/version.ts index 696dac3..62e93c2 100644 --- a/packages/backend/src/version.ts +++ b/packages/backend/src/version.ts @@ -9,4 +9,4 @@ * 修改此文件的同时请同步更新后端 package.json 中的 version 字段。 */ -export const APP_VERSION = "0.1.6"; +export const VERSION = '0.1.7'; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 332da6d..ff79711 100755 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,6 +1,6 @@ { "name": "cloudsearch-frontend", - "version": "0.1.6", + "version": "0.1.7", "private": true, "type": "module", "scripts": { diff --git a/packages/frontend/src/pages/admin/SystemConfig.vue b/packages/frontend/src/pages/admin/SystemConfig.vue index faef602..7bc162e 100755 --- a/packages/frontend/src/pages/admin/SystemConfig.vue +++ b/packages/frontend/src/pages/admin/SystemConfig.vue @@ -490,7 +490,94 @@ -
启用后 CloudSearch 将自动检测并更新到最新镜像版本
当前需手动在服务器执行:docker-compose -f /opt/CloudSearch/docker-compose.yml pull && docker-compose -f /opt/CloudSearch/docker-compose.yml up -d
+ + + + + +
+ 推送通道配置 + + + + +
飞书机器人 Webhook URL,配置后发送卡片消息到群聊。
+
+ 优先从环境变量 FEISHU_WEBHOOK 读取,其次读取此配置 +
+
+ + + + +
通过 Server酱 推送到微信,只需填写 SendKey
+
+ + + + +
通过 Bark 推送到 iOS 设备,填写 API Key
+
+ 自定义服务器 + +
+
+ + + +
+ + Bot Token + + Chat ID +
+
通过 TG Bot 推送消息,需先创建 Bot 并获取 Token
+
+ + + + +
POST JSON 到指定 URL,格式:{title, content, level, source: "CloudSearch"}
+
+ + 推送事件开关 + +
+
+
+ ✅ 转存成功 + +
+
转存成功时推送通知
+
+
+
+ ❌ 转存连续失败 + +
+
连续失败 3 次后推送通知
+
+
+
+ ⚠️ Cookie 过期 + +
+
Cookie 过期时推送提醒
+
+
+
+ 🧹 清理完成 + +
+
每日自动清理完成时推送
+
+
+
+
+ +
启用后 CloudSearch 将自动检测并更新到最新镜像版本
当前需手动在服务器执行:docker-compose -f /opt/CloudSearch/docker-compose.yml pull && docker-compose -f /opt/CloudSearch/docker-compose.yml up -d