diff --git a/packages/backend/package.json b/packages/backend/package.json index cca7ee4..f2a6924 100755 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "cloudsearch-backend", - "version": "0.1.7", + "version": "0.2.2", "private": true, "scripts": { "dev": "tsx watch src/main.ts", diff --git a/packages/backend/src/cloud/cloud.service.ts b/packages/backend/src/cloud/cloud.service.ts index cfc1655..7d44335 100644 --- a/packages/backend/src/cloud/cloud.service.ts +++ b/packages/backend/src/cloud/cloud.service.ts @@ -6,7 +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'; +import { notify, notifyError, notifyInfo, notifyWarn, notifyEvent, notifyConfigEvent } from './notification.service'; /** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */ const inFlightSaves = new Map>(); @@ -195,7 +195,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle? if (driverResult.success) { const nickname = config.nickname || cloudType; - notifyEvent('save_success', `✅ 转存成功`, + notifyConfigEvent(config.id, 'save_success', `✅ 转存成功`, `**${cloudType}** · ${nickname}\n文件: ${driverResult.folderName || sourceTitle || shareUrl}\n耗时: ${((Date.now() - startTime) / 1000).toFixed(1)}s`, 'info'); @@ -204,7 +204,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle? ).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过期`, + notifyConfigEvent(config.id, 'cookie_expire', `⚠️ Cookie过期`, `**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n请重新登录`, 'error'); } else { @@ -213,7 +213,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle? ).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} 次`, + notifyConfigEvent(config.id, 'save_fail', `❌ 转存连续失败 ${failCount} 次`, `**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n错误: ${driverResult.message}`, 'warn'); } diff --git a/packages/backend/src/cloud/database.ts b/packages/backend/src/cloud/database.ts index 460d6d0..15836d5 100755 --- a/packages/backend/src/cloud/database.ts +++ b/packages/backend/src/cloud/database.ts @@ -245,6 +245,30 @@ function migrateCloudConfigs(db: Database.Database): void { console.log('[DB] cloud_configs migration: is_transfer_enabled column added'); } } + // Migration 6: Add notify_config JSON column for per-config notification settings + const row6 = db!.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%notify_config%'").get(); + if (!row6) { + db!.exec("ALTER TABLE cloud_configs ADD COLUMN notify_config TEXT DEFAULT '{}'"); + console.log('[DB] cloud_configs migration: notify_config column added'); + } + + // Migration 7: Add push_users table for multi-user notification settings + const hasPushUsers = db!.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='push_users'" + ).get(); + if (!hasPushUsers) { + db!.exec( + `CREATE TABLE IF NOT EXISTS push_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account TEXT NOT NULL UNIQUE, + notify_config TEXT DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + )` + ); + console.log('[DB] push_users table created'); + } + function seedAdmin(db: Database.Database): void { const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername); diff --git a/packages/backend/src/cloud/drivers/quark-ad-cleanup.ts b/packages/backend/src/cloud/drivers/quark-ad-cleanup.ts index 19068ea..c2cf47d 100644 --- a/packages/backend/src/cloud/drivers/quark-ad-cleanup.ts +++ b/packages/backend/src/cloud/drivers/quark-ad-cleanup.ts @@ -17,6 +17,7 @@ export function getAdKeywords(): string[] { const raw = getSystemConfig("quark_ad_keywords") || ""; return raw .split("\n") + .flatMap((line) => line.split(",")) .map((s) => s.trim()) .filter(Boolean); } @@ -26,6 +27,7 @@ export function getWarningFolderNames(): string[] { const raw = getSystemConfig("quark_warning_folder_names") || ""; return raw .split("\n") + .flatMap((line) => line.split(",")) .map((s) => s.trim()) .filter(Boolean); } @@ -66,7 +68,8 @@ export async function deleteAdFiles( dirFid: string, keywords: string[], ): Promise { - if (!keywords.length) return 0; + const extensions = getSusExtensions(); + if (!keywords.length && !extensions.length) return 0; let deletedCount = 0; const stack: string[] = [dirFid]; @@ -125,6 +128,7 @@ async function batchDeleteFiles( cookie: string, fids: string[], ): Promise { + if (!fids.length) return true; try { const resp = await fetch( `https://drive-pc.quark.cn/1/clouddrive/file/trash?${makeQuery()}`, @@ -135,13 +139,17 @@ async function batchDeleteFiles( "Content-Type": "application/json", }, body: JSON.stringify({ - action_type: 2, // 2 = 移入回收站 - file_list: fids.map((fid) => ({ fid })), - exclude_fids: [], + action_type: 1, + filelist: fids, + exclude_filelist: [], }), - signal: AbortSignal.timeout(15000), + signal: AbortSignal.timeout(30000), }, ); + if (!resp.ok) { + console.log(`[Quark-AdCleanup] batchDelete HTTP ${resp.status}`); + return false; + } const data = (await resp.json()) as any; if (data.status === 200) { return true; @@ -156,6 +164,7 @@ async function batchDeleteFiles( } } + // ==================== 警示文件夹创建 ==================== /** @@ -255,22 +264,23 @@ export async function runAdCleanup( savedDirFid: string, ): Promise<{ adDeleted: number; warningDirs: number }> { const keywords = getAdKeywords(); + const susExtensions = getSusExtensions(); const warningNames = getWarningFolderNames(); let adDeleted = 0; let warningDirs = 0; - // 1. 广告关键词清理 - if (keywords.length > 0) { + // 1. 广告关键词 + 可疑后缀清理 + if (keywords.length > 0 || susExtensions.length > 0) { console.log( - `[Quark-AdCleanup] 开始广告关键词清理: ${keywords.length} 个关键词`, + `[Quark-AdCleanup] 开始文件清理: ${keywords.length} 个关键词, ${susExtensions.length} 个可疑后缀`, ); adDeleted = await deleteAdFiles(cookie, savedDirFid, keywords); console.log( - `[Quark-AdCleanup] 广告清理完成,共删除 ${adDeleted} 个文件/文件夹`, + `[Quark-AdCleanup] 清理完成,共删除 ${adDeleted} 个文件/文件夹`, ); } else { - console.log("[Quark-AdCleanup] 无广告关键词配置,跳过清理"); + console.log("[Quark-AdCleanup] 无关键词/可疑后缀配置,跳过清理"); } // 2. 创建警示文件夹 diff --git a/packages/backend/src/cloud/notification.service.ts b/packages/backend/src/cloud/notification.service.ts index 1ca866c..9196a29 100644 --- a/packages/backend/src/cloud/notification.service.ts +++ b/packages/backend/src/cloud/notification.service.ts @@ -1,265 +1,227 @@ // ============================================================ -// notification.service.ts — Multi-channel message notification -// Channels: Feishu Webhook / Server酱 / Bark / Custom Webhook / Telegram +// notification.service.ts — 插件化消息推送 +// 基于 notifiers/ 注册器,支持 14+ 个推送通道 +// 支持:全局推送(系统配置)和按网盘配置推送 // ============================================================ import { getSystemConfig } from '../admin/system-config.service'; +import { findPushUserForConfig } from './push-user.service'; +import { getAllNotifiers, getNotifier, notifyWith } from './notifiers'; -export type NotifyLevel = 'info' | 'warn' | 'error'; +export { getAllNotifiers, getNotifier, getAllNotifierParams } from './notifiers'; +export type { NotifyLevel, Notifier, NotifierParam, NotifyParams, NotifyResult } from './notifiers/notifier.types'; -export interface NotifyChannel { - send(title: string, content: string, level: NotifyLevel): Promise; +/** 用户级推送配置(存储在 cloud_configs.notify_config) */ +export interface PerConfigNotify { + // 每个通道的配置 = { channelName: { paramKey: value, ... } } + channels?: Record>; + events?: { + on_save_success?: boolean; + on_save_fail?: boolean; + on_cookie_expire?: boolean; + on_cleanup?: boolean; + }; } -// ======================== Channel Implementations ======================== +// ======================== 全局(系统级)通道管理 ======================== -/** Feishu/Lark webhook — interactive card message */ -class FeishuChannel implements NotifyChannel { - private webhookUrl: string; - constructor(webhookUrl: string) { this.webhookUrl = webhookUrl; } +let _globalChannelsCache: { name: string; params: Record }[] | null = null; +let _configHash: string = ''; - async send(title: string, content: string, _level: NotifyLevel): Promise { - try { - const resp = await fetch(this.webhookUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - 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}`); - } catch (err: any) { - console.error('[Notify] Feishu send error:', err.message); - } - } -} - -/** Server酱 — push to WeChat via https://sct.ftqq.com */ -class ServerChanChannel implements NotifyChannel { - private sendKey: string; - constructor(sendKey: string) { this.sendKey = sendKey; } - - 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); - } - } -} - -/** 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}`); +function getGlobalNotifyConfigs(): { name: string; params: Record }[] { + // 从 global_notify_config JSON 读取(格式同 push user 的 notify_config) + const raw = getSystemConfig('global_notify_config') || '{}'; + let globalConfig: any = {}; + try { globalConfig = JSON.parse(raw); } catch {} + + const channels: { name: string; params: Record }[] = []; + if (globalConfig.channels) { + for (const [name, params] of Object.entries(globalConfig.channels)) { + if (params && typeof params === 'object') { + channels.push({ name, params: { ...(params as Record), title: 'CloudSearch' } }); } - } 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 { +function checkEventEnabled(eventName: string): boolean { try { + // 优先读 global_notify_config.events + const raw = getSystemConfig('global_notify_config') || '{}'; + let globalConfig: any = {}; + try { globalConfig = JSON.parse(raw); } catch {} + if (globalConfig.events && globalConfig.events[`on_${eventName}`] !== undefined) { + return globalConfig.events[`on_${eventName}`] !== false; + } + // 降级到旧字段 const val = getSystemConfig(`notify_on_${eventName}`); - return val !== 'false'; // default to true + return val !== 'false' && val !== '0'; } catch { return true; } } -// ======================== Public API ======================== +// ======================== 核心发送函数 ======================== -/** - * 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 channels = getChannels(); - if (channels.length === 0) return; +async function sendToChannels( + channels: { name: string; params: Record }[], + title: string, + content: string, + level: string +): Promise { for (const ch of channels) { - ch.send(title, content, level).catch(() => {}); + try { + await notifyWith(ch.name, { + ...ch.params, + title, + content, + level, + }); + } catch (err: any) { + console.error(`[Notify] ${ch.name} error:`, err.message); + } } } -/** Notify on critical errors (Cookie expired, save failure, etc.) */ +// ======================== 导出 API ======================== + +/** 通过全局通道发送通知(不检查事件开关) */ +export function notify(title: string, content: string, level: 'info' | 'warn' | 'error' = 'info'): void { + const channels = getGlobalNotifyConfigs(); + if (channels.length === 0) return; + sendToChannels(channels, title, content, level).catch(() => {}); +} + export function notifyError(title: string, detail: string): void { - notify(`⚠️ ${title}`, detail, 'error'); + notify(title, detail, 'error'); } export function notifyWarn(title: string, detail: string): void { - notify(`🔔 ${title}`, detail, 'warn'); + notify(title, detail, 'warn'); } export function notifyInfo(title: string, detail: string): void { - notify(`ℹ️ ${title}`, detail, 'info'); + 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; +/** 事件通知(检查全局事件开关) */ +export function notifyEvent( + eventName: string, + title: string, + content: string, + level: 'info' | 'warn' | 'error' = 'info' +): void { + if (!checkEventEnabled(eventName)) return; notify(title, content, level); } + +/** 按网盘配置发送通知(检查用户级推送配置,无配置则降级到全局) */ +const DB_PATH = '/app/dist/database/database'; + +function getConfigNotifySettings(configId: number): PerConfigNotify { + try { + const { getDb } = require(DB_PATH); + const db = getDb(); + const row = db.prepare('SELECT notify_config FROM cloud_configs WHERE id = ?').get(configId) as any; + if (row && row.notify_config) { + return JSON.parse(row.notify_config); + } + } catch {} + return {}; +} + +export function notifyConfigEvent( + configId: number, + eventName: string, + title: string, + content: string, + level: 'info' | 'warn' | 'error' = 'info' +): void { + // Find matching push user by cloud_configs.promotion_account + const pushUser = findPushUserForConfig(configId); + if (!pushUser) { + // No matching push user, fallback to global + notifyEvent(eventName, title, content, level); + return; + } + + let notifyConfig: any = {}; + try { notifyConfig = JSON.parse(pushUser.notify_config); } catch {} + + // Check event switch + const eventKey = 'on_' + eventName; + if (notifyConfig.events && notifyConfig.events[eventKey] === false) return; + + // Build channels from push user config + const userChannels: { name: string; params: Record }[] = []; + if (notifyConfig.channels) { + for (const [name, params] of Object.entries(notifyConfig.channels)) { + userChannels.push({ name, params: { ...(params as Record), title } }); + } + } + + if (userChannels.length > 0) { + sendToChannels(userChannels, title, content, level).catch(() => {}); + } else { + // Fallback to global + notifyEvent(eventName, title, content, level); + } +} + +/** 测试某个通道 */ +export async function testChannel( + channelName: string, + account?: string +): Promise<{ success: boolean; message: string }> { + let params: Record = {}; + + if (account) { + const pushUser = findPushUserForConfig(undefined); + // Use pushUser lookup by account instead + const { getPushUserByAccount } = require('./push-user.service'); + const user = getPushUserByAccount(account); + if (user) { + let notifyConfig: any = {}; + try { notifyConfig = JSON.parse(user.notify_config); } catch {} + const chParams = notifyConfig.channels?.[channelName]; + if (!chParams) return { success: false, message: '该用户未配置此渠道' }; + params = chParams; + } else { + return { success: false, message: '未找到该推送用户' }; + } + } else { + const channels = getGlobalNotifyConfigs(); + const ch = channels.find(c => c.name === channelName); + if (!ch) return { success: false, message: '全局未配置此渠道' }; + params = ch.params; + } + + const result = await notifyWith(channelName, { + ...params, + title: '\ud83d\udd14 CloudSearch \u6d4b\u8bd5', + content: `\u8fd9\u662f\u4e00\u6761\u6d4b\u8bd5\u6d88\u606f\n\u901a\u9053: ${channelName}\n\u65f6\u95f4: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`, + level: 'info', + }); + return result; +} + +/** 保存用户级推送配置到数据库 */ +export function saveConfigNotifySettings(configId: number, notify: PerConfigNotify): void { + try { + const { getDb } = require(DB_PATH); + const db = getDb(); + db.prepare('UPDATE cloud_configs SET notify_config = ? WHERE id = ?').run( + JSON.stringify(notify), + configId + ); + } catch (err: any) { + console.error('[Notify] Failed to save config notify settings:', err.message); + } +} + +/** 获取用户级推送配置 */ +export function getConfigNotifySettingsJSON(configId: number): PerConfigNotify { + return getConfigNotifySettings(configId); +} \ No newline at end of file diff --git a/packages/backend/src/cloud/notifiers/bark.notifier.ts b/packages/backend/src/cloud/notifiers/bark.notifier.ts new file mode 100644 index 0000000..696d496 --- /dev/null +++ b/packages/backend/src/cloud/notifiers/bark.notifier.ts @@ -0,0 +1,44 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'key', label: 'Bark Key', type: 'text', required: true, placeholder: 'xxxxxxxxxxxxxxxxx' }, + { key: 'server', label: '服务器', type: 'url', default: 'https://api.day.app', required: false, placeholder: 'https://api.day.app' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch 通知', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'level', label: '级别', type: 'text', default: 'info', required: false }, +]; + +export const barkNotifier: Notifier = { + name: 'bark', + label: 'Bark', + params, + async notify(params: NotifyParams): Promise { + try { + const key = params.key; + const server = (params.server || 'https://api.day.app').replace(/\/+$/, ''); + const title = params.title || 'CloudSearch'; + const content = params.content || ''; + const level: string = params.level || 'info'; + const icon = level === 'error' ? '⚠️' : level === 'warn' ? '🔔' : 'ℹ️'; + + const resp = await fetch(`${server}/${key}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: `${icon} ${title}`, + body: content, + group: 'CloudSearch', + level: level === 'error' ? 'timeSensitive' : 'active', + icon: '', + }), + }); + if (!resp.ok) { + const text = await resp.text(); + return { success: false, message: `HTTP ${resp.status}: ${text.slice(0, 100)}` }; + } + return { success: true, message: 'Bark 推送成功' }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/packages/backend/src/cloud/notifiers/dingtalk.notifier.ts b/packages/backend/src/cloud/notifiers/dingtalk.notifier.ts new file mode 100644 index 0000000..867ca79 --- /dev/null +++ b/packages/backend/src/cloud/notifiers/dingtalk.notifier.ts @@ -0,0 +1,34 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=xxx' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'level', label: '级别', type: 'text', default: 'info', required: false }, +]; + +export const dingtalkNotifier: Notifier = { + name: 'dingtalk', + label: '钉钉机器人', + params, + async notify(params: NotifyParams): Promise { + try { + const level: string = params.level || 'info'; + const text = `## ${params.title || 'CloudSearch'}\n${params.content || ''}`; + const resp = await fetch(params.webhook_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + msgtype: 'markdown', + markdown: { title: params.title || 'CloudSearch', text }, + at: { isAtAll: false }, + }), + }); + const data: any = await resp.json(); + if (data.errcode === 0) return { success: true, message: '钉钉推送成功' }; + return { success: false, message: data.errmsg || `HTTP ${resp.status}` }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/packages/backend/src/cloud/notifiers/discord.notifier.ts b/packages/backend/src/cloud/notifiers/discord.notifier.ts new file mode 100644 index 0000000..244e61d --- /dev/null +++ b/packages/backend/src/cloud/notifiers/discord.notifier.ts @@ -0,0 +1,36 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://discord.com/api/webhooks/...' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'level', label: '级别', type: 'text', default: 'info', required: false }, +]; + +export const discordNotifier: Notifier = { + name: 'discord', + label: 'Discord', + params, + async notify(params: NotifyParams): Promise { + try { + const level: string = params.level || 'info'; + const color = level === 'error' ? 0xff0000 : level === 'warn' ? 0xffa500 : 0x3498db; + const resp = await fetch(params.webhook_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + embeds: [{ + title: params.title || 'CloudSearch', + description: params.content || '', + color, + footer: { text: 'CloudSearch \u00b7 ' + new Date().toLocaleString('zh-CN') }, + }], + }), + }); + if (resp.ok) return { success: true, message: 'Discord 推送成功' }; + return { success: false, message: `HTTP ${resp.status}` }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/packages/backend/src/cloud/notifiers/gotify.notifier.ts b/packages/backend/src/cloud/notifiers/gotify.notifier.ts new file mode 100644 index 0000000..67d74d2 --- /dev/null +++ b/packages/backend/src/cloud/notifiers/gotify.notifier.ts @@ -0,0 +1,33 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'server', label: '服务器地址', type: 'url', required: true, placeholder: 'https://gotify.example.com' }, + { key: 'token', label: 'App Token', type: 'password', required: true, placeholder: 'Gotify App Token' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'priority', label: '优先级', type: 'number', default: 5, required: false }, +]; + +export const gotifyNotifier: Notifier = { + name: 'gotify', + label: 'Gotify', + params, + async notify(params: NotifyParams): Promise { + try { + const server = params.server.replace(/\/+$/, ''); + const resp = await fetch(`${server}/message?token=${params.token}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: params.title || 'CloudSearch', + message: params.content || '', + priority: params.priority || 5, + }), + }); + if (resp.ok) return { success: true, message: 'Gotify 推送成功' }; + return { success: false, message: `HTTP ${resp.status}` }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/packages/backend/src/cloud/notifiers/index.ts b/packages/backend/src/cloud/notifiers/index.ts new file mode 100644 index 0000000..cb9883e --- /dev/null +++ b/packages/backend/src/cloud/notifiers/index.ts @@ -0,0 +1,72 @@ +/** + * notifiers/index.ts — 注册器 + * 统一管理所有通知渠道,支持按 name 查找和列表获取 + */ + +import { Notifier } from './notifier.types'; + +import { barkNotifier } from './bark.notifier'; +import { serverchanNotifier } from './serverchan.notifier'; +import { serverchanturboNotifier } from './serverchanturbo.notifier'; +import { telegramNotifier } from './telegram.notifier'; +import { larkNotifier } from './lark.notifier'; +import { webhookNotifier } from './webhook.notifier'; +import { wechatWorkBotNotifier } from './wechat_work_bot.notifier'; +import { pushplusNotifier } from './pushplus.notifier'; +import { dingtalkNotifier } from './dingtalk.notifier'; +import { gotifyNotifier } from './gotify.notifier'; +import { ntfyNotifier } from './ntfy.notifier'; +import { discordNotifier } from './discord.notifier'; +import { smtpNotifier } from './smtp.notifier'; +import { qmsgNotifier } from './qmsg.notifier'; + +const registry = new Map(); + +function register(n: Notifier): void { + registry.set(n.name, n); +} + +// ==================== 注册所有内置通知器 ==================== + +register(barkNotifier); +register(serverchanNotifier); +register(serverchanturboNotifier); +register(telegramNotifier); +register(larkNotifier); +register(webhookNotifier); +register(wechatWorkBotNotifier); +register(pushplusNotifier); +register(dingtalkNotifier); +register(gotifyNotifier); +register(ntfyNotifier); +register(discordNotifier); +register(smtpNotifier); +register(qmsgNotifier); + +// ==================== API ==================== + +/** 根据名称获取通知器 */ +export function getNotifier(name: string): Notifier | undefined { + return registry.get(name); +} + +/** 获取所有已注册的通知器 */ +export function getAllNotifiers(): Notifier[] { + return Array.from(registry.values()); +} + +/** 获取所有通知器的参数定义(用于前端动态生成表单) */ +export function getAllNotifierParams(): Record { + const result: Record = {}; + for (const [name, n] of registry) { + result[name] = { name: n.name, label: n.label, params: n.params }; + } + return result; +} + +/** 向指定通知器发送通知 */ +export async function notifyWith(name: string, params: Record): Promise<{ success: boolean; message: string }> { + const n = getNotifier(name); + if (!n) return { success: false, message: `未知的通知渠道: ${name}` }; + return n.notify(params); +} diff --git a/packages/backend/src/cloud/notifiers/lark.notifier.ts b/packages/backend/src/cloud/notifiers/lark.notifier.ts new file mode 100644 index 0000000..fc2d977 --- /dev/null +++ b/packages/backend/src/cloud/notifiers/lark.notifier.ts @@ -0,0 +1,39 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'level', label: '级别', type: 'text', default: 'info', required: false }, +]; + +export const larkNotifier: Notifier = { + name: 'lark', + label: '飞书/Lark', + params, + async notify(params: NotifyParams): Promise { + try { + const level: string = params.level || 'info'; + const template = level === 'error' ? 'red' : level === 'warn' ? 'orange' : 'blue'; + const resp = await fetch(params.webhook_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + msg_type: 'interactive', + card: { + header: { title: { tag: 'plain_text', content: params.title || 'CloudSearch' }, template }, + elements: [ + { tag: 'div', text: { tag: 'lark_md', content: params.content || '' } }, + { tag: 'note', elements: [{ tag: 'plain_text', content: 'CloudSearch \u00b7 ' + new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }) }] }, + ], + }, + }), + }); + if (resp.ok) return { success: true, message: '飞书推送成功' }; + const text = await resp.text(); + return { success: false, message: text.slice(0, 150) }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/packages/backend/src/cloud/notifiers/notifier.types.ts b/packages/backend/src/cloud/notifiers/notifier.types.ts new file mode 100644 index 0000000..7fe7223 --- /dev/null +++ b/packages/backend/src/cloud/notifiers/notifier.types.ts @@ -0,0 +1,34 @@ +/** + * 通知器类型定义 —— 参考 onepush 插件化设计 + * 每个 provider 注册为一个 Notifier,统一 notify(params) 接口 + * 新增通道只需在这里加一个文件 + 在 index.ts 注册 + */ + +export type NotifyLevel = 'info' | 'warn' | 'error'; + +export interface NotifyParams { + [key: string]: any; +} + +export interface NotifyResult { + success: boolean; + message: string; +} + +/** 每个 provider 必须实现这个接口 */ +export interface Notifier { + name: string; + label: string; + /** 参数描述(用于前端动态生成表单) */ + params: NotifierParam[]; + notify(params: NotifyParams): Promise; +} + +export interface NotifierParam { + key: string; + label: string; + type: 'text' | 'password' | 'url' | 'switch' | 'number'; + placeholder?: string; + default?: any; + required: boolean; +} diff --git a/packages/backend/src/cloud/notifiers/ntfy.notifier.ts b/packages/backend/src/cloud/notifiers/ntfy.notifier.ts new file mode 100644 index 0000000..08905a2 --- /dev/null +++ b/packages/backend/src/cloud/notifiers/ntfy.notifier.ts @@ -0,0 +1,34 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'topic', label: 'Topic', type: 'text', required: true, placeholder: 'my-notification-topic' }, + { key: 'server', label: '服务器', type: 'url', default: 'https://ntfy.sh', required: false, placeholder: 'https://ntfy.sh' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'priority', label: '优先级(1-5)', type: 'number', default: 3, required: false }, +]; + +export const ntfyNotifier: Notifier = { + name: 'ntfy', + label: 'ntfy', + params, + async notify(params: NotifyParams): Promise { + try { + const server = (params.server || 'https://ntfy.sh').replace(/\/+$/, ''); + const resp = await fetch(`${server}/${params.topic}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: params.title || 'CloudSearch', + message: params.content || '', + priority: params.priority || 3, + tags: ['cloudsearch'], + }), + }); + if (resp.ok) return { success: true, message: 'ntfy 推送成功' }; + return { success: false, message: `HTTP ${resp.status}` }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/packages/backend/src/cloud/notifiers/pushplus.notifier.ts b/packages/backend/src/cloud/notifiers/pushplus.notifier.ts new file mode 100644 index 0000000..22fad6f --- /dev/null +++ b/packages/backend/src/cloud/notifiers/pushplus.notifier.ts @@ -0,0 +1,31 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'token', label: 'Token', type: 'password', required: true, placeholder: 'pushplus token' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, +]; + +export const pushplusNotifier: Notifier = { + name: 'pushplus', + label: 'PushPlus (微信)', + params, + async notify(params: NotifyParams): Promise { + try { + const resp = await fetch('https://www.pushplus.plus/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: params.token, + title: params.title || 'CloudSearch', + content: params.content || '', + }), + }); + const data: any = await resp.json(); + if (data.code === 200) return { success: true, message: 'PushPlus 推送成功' }; + return { success: false, message: data.msg || `HTTP ${resp.status}` }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/packages/backend/src/cloud/notifiers/qmsg.notifier.ts b/packages/backend/src/cloud/notifiers/qmsg.notifier.ts new file mode 100644 index 0000000..8254649 --- /dev/null +++ b/packages/backend/src/cloud/notifiers/qmsg.notifier.ts @@ -0,0 +1,33 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'key', label: 'API Key', type: 'password', required: true, placeholder: 'Qmsg API Key' }, + { key: 'qq', label: 'QQ 号', type: 'text', required: false, placeholder: '留空则推送到所有绑定QQ' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, +]; + +export const qmsgNotifier: Notifier = { + name: 'qmsg', + label: 'Qmsg (QQ)', + params, + async notify(params: NotifyParams): Promise { + try { + const body: Record = { + msg: `[${params.title || 'CloudSearch'}]\n${params.content || ''}`, + type: 'send', + }; + if (params.qq) body.qq = params.qq; + const resp = await fetch(`https://qmsg.zendee.cn/api/${params.key}`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(body).toString(), + }); + const data: any = await resp.json(); + if (data.code === 0) return { success: true, message: 'Qmsg 推送成功' }; + return { success: false, message: data.reason || `HTTP ${resp.status}` }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/packages/backend/src/cloud/notifiers/serverchan.notifier.ts b/packages/backend/src/cloud/notifiers/serverchan.notifier.ts new file mode 100644 index 0000000..6123d1c --- /dev/null +++ b/packages/backend/src/cloud/notifiers/serverchan.notifier.ts @@ -0,0 +1,28 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'sendkey', label: 'SendKey', type: 'password', required: true, placeholder: 'Server酱 SendKey' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, +]; + +export const serverchanNotifier: Notifier = { + name: 'serverchan', + label: 'Server酱', + params, + async notify(params: NotifyParams): Promise { + try { + const sendkey = params.sendkey; + const resp = await fetch(`https://sctapi.ftqq.com/${sendkey}.send`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ title: params.title || 'CloudSearch', desp: params.content || '' }).toString(), + }); + const data: any = await resp.json(); + if (data.code === 0) return { success: true, message: 'Server酱 推送成功' }; + return { success: false, message: data.message || `HTTP ${resp.status}` }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/packages/backend/src/cloud/notifiers/serverchanturbo.notifier.ts b/packages/backend/src/cloud/notifiers/serverchanturbo.notifier.ts new file mode 100644 index 0000000..3f896a3 --- /dev/null +++ b/packages/backend/src/cloud/notifiers/serverchanturbo.notifier.ts @@ -0,0 +1,27 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'sendkey', label: 'SendKey', type: 'password', required: true, placeholder: 'Server酱 Turbo SendKey' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, +]; + +export const serverchanturboNotifier: Notifier = { + name: 'serverchanturbo', + label: 'Server酱 Turbo', + params, + async notify(params: NotifyParams): Promise { + try { + const resp = await fetch(`https://sctapi.ftqq.com/${params.sendkey}.send`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ title: params.title || 'CloudSearch', desp: params.content || '' }).toString(), + }); + const data: any = await resp.json(); + if (data.code === 0) return { success: true, message: 'Server酱 Turbo 推送成功' }; + return { success: false, message: data.message || `HTTP ${resp.status}` }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/packages/backend/src/cloud/notifiers/smtp.notifier.ts b/packages/backend/src/cloud/notifiers/smtp.notifier.ts new file mode 100644 index 0000000..b83970d --- /dev/null +++ b/packages/backend/src/cloud/notifiers/smtp.notifier.ts @@ -0,0 +1,40 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +/** SMTP 邮件推送 — 使用 nodemailer(需要安装) */ +const params: NotifierParam[] = [ + { key: 'host', label: 'SMTP 服务器', type: 'text', required: true, placeholder: 'smtp.qq.com' }, + { key: 'port', label: '端口', type: 'number', default: 465, required: false }, + { key: 'secure', label: 'SSL', type: 'switch', default: true, required: false }, + { key: 'user', label: '用户名', type: 'text', required: true, placeholder: 'user@example.com' }, + { key: 'pass', label: '密码/授权码', type: 'password', required: true, placeholder: 'SMTP 授权码' }, + { key: 'from', label: '发件人', type: 'text', required: true, placeholder: 'sender@example.com' }, + { key: 'to', label: '收件人', type: 'text', required: true, placeholder: 'receiver@example.com' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, +]; + +export const smtpNotifier: Notifier = { + name: 'smtp', + label: 'SMTP 邮件', + params, + async notify(params: NotifyParams): Promise { + try { + const nodemailer = require('nodemailer'); + const transporter = nodemailer.createTransport({ + host: params.host, + port: params.port || 465, + secure: params.secure !== false, + auth: { user: params.user, pass: params.pass }, + }); + await transporter.sendMail({ + from: params.from, + to: params.to, + subject: `[CloudSearch] ${params.title || ''}`, + text: params.content || '', + }); + return { success: true, message: 'SMTP 邮件发送成功' }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/packages/backend/src/cloud/notifiers/telegram.notifier.ts b/packages/backend/src/cloud/notifiers/telegram.notifier.ts new file mode 100644 index 0000000..75fa701 --- /dev/null +++ b/packages/backend/src/cloud/notifiers/telegram.notifier.ts @@ -0,0 +1,38 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'token', label: 'Bot Token', type: 'password', required: true, placeholder: '123456:ABC-def' }, + { key: 'chat_id', label: 'Chat ID', type: 'text', required: true, placeholder: '@频道 或 -1001234567890' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'level', label: '级别', type: 'text', default: 'info', required: false }, +]; + +export const telegramNotifier: Notifier = { + name: 'telegram', + label: 'Telegram', + params, + async notify(params: NotifyParams): Promise { + try { + const iconMap: Record = { error: '\ud83d\udea8', warn: '\u26a0\ufe0f', info: '\u2139\ufe0f' }; + const level: string = params.level || 'info'; + const text = `${iconMap[level] || iconMap.info} *${params.title || 'CloudSearch'}*\n\n${params.content || ''}`; + + const resp = await fetch(`https://api.telegram.org/bot${params.token}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: params.chat_id, + text, + parse_mode: 'Markdown', + disable_web_page_preview: true, + }), + }); + const data: any = await resp.json(); + if (data.ok) return { success: true, message: 'Telegram 推送成功' }; + return { success: false, message: data.description || `HTTP ${resp.status}` }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/packages/backend/src/cloud/notifiers/webhook.notifier.ts b/packages/backend/src/cloud/notifiers/webhook.notifier.ts new file mode 100644 index 0000000..c5bbbe6 --- /dev/null +++ b/packages/backend/src/cloud/notifiers/webhook.notifier.ts @@ -0,0 +1,33 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://example.com/webhook' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'level', label: '级别', type: 'text', default: 'info', required: false }, +]; + +export const webhookNotifier: Notifier = { + name: 'webhook', + label: '自定义 Webhook', + params, + async notify(params: NotifyParams): Promise { + try { + const resp = await fetch(params.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: params.title || 'CloudSearch', + content: params.content || '', + level: params.level || 'info', + source: 'CloudSearch', + timestamp: new Date().toISOString(), + }), + }); + if (resp.ok) return { success: true, message: 'Webhook 推送成功' }; + return { success: false, message: `HTTP ${resp.status}` }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/packages/backend/src/cloud/notifiers/wechat_work_bot.notifier.ts b/packages/backend/src/cloud/notifiers/wechat_work_bot.notifier.ts new file mode 100644 index 0000000..cd9cacb --- /dev/null +++ b/packages/backend/src/cloud/notifiers/wechat_work_bot.notifier.ts @@ -0,0 +1,32 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'level', label: '级别', type: 'text', default: 'info', required: false }, +]; + +export const wechatWorkBotNotifier: Notifier = { + name: 'wechat_work_bot', + label: '企业微信机器人', + params, + async notify(params: NotifyParams): Promise { + try { + const content = `## ${params.title || 'CloudSearch'}\n${params.content || ''}`; + const resp = await fetch(params.webhook_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + msgtype: 'markdown', + markdown: { content }, + }), + }); + const data: any = await resp.json(); + if (data.errcode === 0) return { success: true, message: '企业微信推送成功' }; + return { success: false, message: data.errmsg || `HTTP ${resp.status}` }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/packages/backend/src/cloud/push-user.service.ts b/packages/backend/src/cloud/push-user.service.ts new file mode 100644 index 0000000..780aaa0 --- /dev/null +++ b/packages/backend/src/cloud/push-user.service.ts @@ -0,0 +1,83 @@ +// ============================================================ +// push-user.service.ts — Multi-user notification settings +// Each "push user" has: account (linked to cloud_configs.promotion_account) +// + notify_config (channels + events) +// ============================================================ + +import { getDb } from '../database/database'; + +export interface PushUser { + id?: number; + account: string; + notify_config: string; + created_at?: string; + updated_at?: string; +} + +interface PushUserRow { + id: number; + account: string; + notify_config: string; + created_at: string; + updated_at: string; +} + +/** Get all push users */ +export function getAllPushUsers(): PushUserRow[] { + const db = getDb(); + return db.prepare('SELECT * FROM push_users ORDER BY account ASC').all() as PushUserRow[]; +} + +/** Get a single push user by id */ +export function getPushUserById(id: number): PushUserRow | undefined { + const db = getDb(); + return db.prepare('SELECT * FROM push_users WHERE id = ?').get(id) as PushUserRow | undefined; +} + +/** Get a push user by account */ +export function getPushUserByAccount(account: string): PushUserRow | undefined { + const db = getDb(); + return db.prepare('SELECT * FROM push_users WHERE account = ?').get(account) as PushUserRow | undefined; +} + +/** Create or update a push user */ +export function upsertPushUser(account: string, notifyConfig: string): PushUserRow { + const db = getDb(); + const existing = getPushUserByAccount(account); + if (existing) { + db.prepare('UPDATE push_users SET notify_config = ?, updated_at = datetime(\'now\', \'localtime\') WHERE id = ?') + .run(notifyConfig, existing.id); + return getPushUserById(existing.id)!; + } else { + const result = db.prepare('INSERT INTO push_users (account, notify_config) VALUES (?, ?)') + .run(account, notifyConfig); + return getPushUserById(result.lastInsertRowid as number)!; + } +} + +/** Update a push user by id */ +export function updatePushUser(id: number, account: string, notifyConfig: string): PushUserRow { + const db = getDb(); + db.prepare('UPDATE push_users SET account = ?, notify_config = ?, updated_at = datetime(\'now\', \'localtime\') WHERE id = ?') + .run(account, notifyConfig, id); + return getPushUserById(id)!; +} + +/** Delete a push user */ +export function deletePushUser(id: number): boolean { + const db = getDb(); + const result = db.prepare('DELETE FROM push_users WHERE id = ?').run(id); + return result.changes > 0; +} + +/** + * Find matching push user config for a given cloud config + * Match based on: cloud_configs.promotion_account == push_users.account + */ +export function findPushUserForConfig(configId?: number): PushUserRow | undefined { + if (!configId) return undefined; + const db = getDb(); + const config = db.prepare('SELECT promotion_account FROM cloud_configs WHERE id = ?').get(configId) as any; + if (!config || !config.promotion_account) return undefined; + return getPushUserByAccount(config.promotion_account); +} diff --git a/packages/backend/src/database/database.ts b/packages/backend/src/database/database.ts index 4d7db2e..f8b3a35 100755 --- a/packages/backend/src/database/database.ts +++ b/packages/backend/src/database/database.ts @@ -129,6 +129,14 @@ function runMigrations(db: Database.Database): void { save_count INTEGER NOT NULL DEFAULT 0, UNIQUE(ip_address, date, cloud_type, config_id) ); + + CREATE TABLE IF NOT EXISTS push_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account TEXT UNIQUE NOT NULL, + notify_config TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); `); seedSystemConfigs(db); migrateSaveRecords(db); diff --git a/packages/backend/src/routes/admin.routes.ts b/packages/backend/src/routes/admin.routes.ts index 5ff548c..2f93360 100644 --- a/packages/backend/src/routes/admin.routes.ts +++ b/packages/backend/src/routes/admin.routes.ts @@ -5,6 +5,8 @@ import { execSync } from 'child_process'; import { adminLimiter, loginLimiter } from '../middleware/rate-limit'; import { getSaveRecords } from '../cloud/cloud.service'; import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie, togglePrimary } from '../cloud/credential.service'; +import { testChannel, saveConfigNotifySettings, getConfigNotifySettingsJSON, getAllNotifierParams } from '../cloud/notification.service'; +import { getAllPushUsers, getPushUserById, upsertPushUser, updatePushUser, deletePushUser } from '../cloud/push-user.service'; // Note: check-in routes were removed (sign-in feature removed) import { getAllCloudTypes } from '../cloud/cloud-types.service'; import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service'; @@ -134,6 +136,12 @@ router.post("/admin/baidu/qr-login/:sessionId/cancel", async (req: Request, res: // Auth wall — all routes below require JWT // ═══════════════════════════════════════ router.use('/admin', authMiddleware); +router.use('/admin', (_req: Request, res: Response, next) => { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + next(); +}); // ═══════════════════════════════════════ // Cloud Configs CRUD @@ -626,4 +634,104 @@ router.post('/admin/update-pansou', async (_req: Request, res: Response) => { } }); -export default router; +/** GET /api/admin/cloud-configs/:id/notify — get per-config notification settings */ +router.get('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const settings = getConfigNotifySettingsJSON(id); + res.json(settings); + } catch (err: any) { + res.status(400).json({ error: err.message || 'Failed to get notification settings' }); + } +}); + +/** PUT /api/admin/cloud-configs/:id/notify — update per-config notification settings */ +router.put('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const settings = req.body; + saveConfigNotifySettings(id, settings); + res.json({ success: true, message: '推送配置已保存' }); + } catch (err: any) { + res.status(400).json({ error: err.message || 'Failed to save notification settings' }); + } +}); + +/** POST /api/admin/notify/test — test a notification channel */ +router.post('/admin/notify/test', async (req: Request, res: Response) => { + try { + const { channelType, account, configId } = req.body; + const ctx = account || (configId ? String(configId) : undefined); + const result = await testChannel(channelType as string, ctx); + res.json(result); + } catch (err: any) { + res.json({ success: false, message: err.message || '测试发送失败' }); + } +}); + + +/** GET /api/admin/notify/providers — get all available notifier providers */ +router.get('/admin/notify/providers', (_req: Request, res: Response) => { + try { + const providers = getAllNotifierParams(); + res.json(providers); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get providers' }); + } +}); + +/** GET /api/admin/push-users — list all push users */ +router.get('/admin/push-users', (_req: Request, res: Response) => { + try { + const users = getAllPushUsers(); + // Parse notify_config for each user for frontend display + const parsed = users.map(u => ({ + ...u, + notify_config: (() => { try { return JSON.parse(u.notify_config); } catch { return {}; } })(), + })); + res.json(parsed); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to list push users' }); + } +}); + +/** POST /api/admin/push-users — create or update a push user */ +router.post('/admin/push-users', (req: Request, res: Response) => { + try { + const { account, notify_config } = req.body; + if (!account) return res.status(400).json({ error: 'account is required' }); + const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {}); + const user = upsertPushUser(account, configStr); + res.json({ ...user, notify_config: JSON.parse(user.notify_config) }); + } catch (err: any) { + res.status(400).json({ error: err.message || 'Failed to save push user' }); + } +}); + +/** PUT /api/admin/push-users/:id — update push user */ +router.put('/admin/push-users/:id', (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const { account, notify_config } = req.body; + if (!account) return res.status(400).json({ error: 'account is required' }); + const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {}); + const user = updatePushUser(id, account, configStr); + res.json({ ...user, notify_config: JSON.parse(user.notify_config) }); + } catch (err: any) { + res.status(400).json({ error: err.message || 'Failed to update push user' }); + } +}); + +/** DELETE /api/admin/push-users/:id — delete push user */ +router.delete('/admin/push-users/:id', (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const ok = deletePushUser(id); + if (ok) res.json({ success: true }); + else res.status(404).json({ error: 'Push user not found' }); + } catch (err: any) { + res.status(400).json({ error: err.message || 'Failed to delete push user' }); + } +}); + +export default router; \ No newline at end of file diff --git a/packages/backend/src/version.ts b/packages/backend/src/version.ts index 62e93c2..4437be1 100644 --- a/packages/backend/src/version.ts +++ b/packages/backend/src/version.ts @@ -1,12 +1 @@ -/** - * CloudSearch 应用版本号 - * - * 版本管理规则: - * - 每次小优化/修复:patch +1 (0.0.1 → 0.0.2) - * - 20 次 patch 后:minor +1, patch 归零 (0.1.0) - * - 10 次 minor 后:major +1, minor 归零 (1.0.0) - * - * 修改此文件的同时请同步更新后端 package.json 中的 version 字段。 - */ - -export const VERSION = '0.1.7'; +export const VERSION = "0.2.2"; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index ff79711..d7a4a38 100755 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,6 +1,6 @@ { "name": "cloudsearch-frontend", - "version": "0.1.7", + "version": "0.2.2", "private": true, "type": "module", "scripts": { @@ -26,4 +26,4 @@ "vite": "^5.4.0", "vue-tsc": "^2.1.0" } -} +} \ No newline at end of file diff --git a/packages/frontend/src/api/index.ts b/packages/frontend/src/api/index.ts index 0d464e9..cf68cde 100755 --- a/packages/frontend/src/api/index.ts +++ b/packages/frontend/src/api/index.ts @@ -320,6 +320,38 @@ export interface SaveRecord { } // ===== 系统配置 ===== + +/** Save/update per-config notification settings */ +export async function saveConfigNotify( + configId: number, + settings: Record +): Promise<{ success: boolean; message: string }> { + const { data } = await api.put(`/admin/cloud-configs/${configId}/notify`, settings) + return data +} + +/** Get per-config notification settings */ +export async function getConfigNotify( + configId: number +): Promise> { + const { data } = await api.get(`/admin/cloud-configs/${configId}/notify`) + return data +} + +/** Test a notification channel (global or per-config) */ +export async function getAllNotifierProviders(): Promise> { + const { data } = await api.get('/admin/notify/providers') + return data +} + +export async function testNotifyChannel( + channelType: string, + configId?: number +): Promise<{ success: boolean; message: string }> { + const { data } = await api.post('/admin/notify/test', { channelType, configId }) + return data +} + export async function getSystemConfigs(): Promise<{ key: string; value: string; description: string }[]> { const { data } = await api.get('/admin/system-configs') return data @@ -438,4 +470,4 @@ export async function emptyAllTrash(): Promise<{ }> { const { data } = await api.post('/admin/cleanup/empty-trash') return data -} +} \ No newline at end of file diff --git a/packages/frontend/src/pages/admin/AdminDashboard.vue b/packages/frontend/src/pages/admin/AdminDashboard.vue index 91cd628..49aabdd 100644 --- a/packages/frontend/src/pages/admin/AdminDashboard.vue +++ b/packages/frontend/src/pages/admin/AdminDashboard.vue @@ -225,6 +225,8 @@ const siteName = ref('') const cloudTypes = ref([]) // ── ECharts trend chart (via composable) ── +/** ── Trend summary (MUST be declared before useTrendChart callback) ── */ + const { chartRef: _chartRef, render: renderTrendChart, initResize: initTrendChart } = useTrendChart(computed(() => stats.value.trendTrend), (s: any) => { trendSummary.value = s }) const chartRef = _chartRef as any diff --git a/packages/frontend/src/pages/admin/AdminLayout.vue b/packages/frontend/src/pages/admin/AdminLayout.vue index 1642a78..fd0af15 100644 --- a/packages/frontend/src/pages/admin/AdminLayout.vue +++ b/packages/frontend/src/pages/admin/AdminLayout.vue @@ -37,6 +37,7 @@ 🔗 外部服务 & 缓存 ⚡ 性能配置 🔑 修改密码 + 📬 消息推送 @@ -94,6 +95,7 @@ const pageTitles: Record = { 'sys-services': '外部服务 & 缓存', 'sys-strategy': '性能配置', 'sys-password': '修改管理员密码', + 'sys-notify': '消息推送', 'save-records': '转存日志', } @@ -307,4 +309,4 @@ onMounted(async () => { box-shadow: 0 6px 24px rgba(0,0,0,0.18); transform: translateY(-2px); } - + \ No newline at end of file diff --git a/packages/frontend/src/pages/admin/SystemConfig.vue b/packages/frontend/src/pages/admin/SystemConfig.vue index 7bc162e..35764bd 100755 --- a/packages/frontend/src/pages/admin/SystemConfig.vue +++ b/packages/frontend/src/pages/admin/SystemConfig.vue @@ -494,87 +494,122 @@ -
- 推送通道配置 - - - - -
飞书机器人 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"}
-
- - 推送事件开关 - -
-
-
- ✅ 转存成功 - + + + +
+ +
+
+
+ + {{ np.label }} + 测试 +
+
+ + + + + + +
+
-
转存成功时推送通知
-
-
-
- ❌ 转存连续失败 - -
-
连续失败 3 次后推送通知
-
-
-
- ⚠️ Cookie 过期 - -
-
Cookie 过期时推送提醒
-
-
-
- 🧹 清理完成 - -
-
每日自动清理完成时推送
+ 全局事件开关 +
+ + + + +
+
全局推送作为兜底通道。设置了推送用户的网盘配置走用户推送,未设置的走全局推送。
+
+
+
添加推送用户 + + +
+
+ + + + + + + + + + + + + + {{ pushUserForm.id ? '更新' : '确认添加' }} + 取消编辑
+ + 推送用户列表 + + + + + + + + + + + + + + + + + + + + + +
启用后 CloudSearch 将自动检测并更新到最新镜像版本
当前需手动在服务器执行:docker-compose -f /opt/CloudSearch/docker-compose.yml pull && docker-compose -f /opt/CloudSearch/docker-compose.yml up -d
@@ -589,11 +624,11 @@