0.2.2: 消息推送插件化重构 + 14通道 + 多用户推送 + 全局推送 + 事件模板自定义 + 每日报告 + 变量说明

This commit is contained in:
2026-05-16 17:25:29 +08:00
parent e38adee8ff
commit ab52ca2e69
30 changed files with 1638 additions and 326 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "cloudsearch-backend",
"version": "0.1.7",
"version": "0.2.2",
"private": true,
"scripts": {
"dev": "tsx watch src/main.ts",

View File

@@ -274,6 +274,8 @@ export async function checkAndRunScheduledCleanup(): Promise<void> {
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');
notifyEvent('cleanup', `🧹 清理完成`, lines.join('\n'), stats.errors.length > 0 ? 'warn' : 'info', {
stats_lines: lines.join('\n'),
});
}
}

View File

@@ -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<string, Promise<SaveResult>>();
@@ -195,27 +195,45 @@ 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');
'info', {
file_name: driverResult.folderName || sourceTitle || shareUrl,
file_size: '',
cloud_type: cloudType,
nickname: nickname || '',
duration: ((Date.now() - startTime) / 1000).toFixed(1),
share_url: shareUrl,
});
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过期`,
notifyConfigEvent(config.id, 'cookie_expire', `⚠️ Cookie过期`,
`**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n请重新登录`,
'error');
'error', {
cloud_type: cloudType,
nickname: config.nickname || '',
share_url: shareUrl,
});
} 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}`,
notifyConfigEvent(config.id, 'save_fail', `❌ 转存连续失败 ${failCount}`,
`**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n错误: ${driverResult.message}`,
'warn');
'warn', {
file_name: sourceTitle || shareUrl,
fail_count: String(failCount),
cloud_type: cloudType,
nickname: config.nickname || '',
error: driverResult.message || '',
share_url: shareUrl,
});
}
}

View File

@@ -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);

View File

@@ -1,265 +1,269 @@
// ============================================================
// 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<void>;
/** 用户级推送配置(存储在 cloud_configs.notify_config */
export interface PerConfigNotify {
// 每个通道的配置 = { channelName: { paramKey: value, ... } }
channels?: Record<string, Record<string, string>>;
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<string, string> }[] | null = null;
let _configHash: string = '';
async send(title: string, content: string, _level: NotifyLevel): Promise<void> {
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<void> {
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<void> {
try {
const iconMap: Record<NotifyLevel, string> = {
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<void> {
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<void> {
try {
const iconMap: Record<NotifyLevel, string> = { 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<string, string> }[] {
// 从 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<string, string> }[] = [];
if (globalConfig.channels) {
for (const [name, params] of Object.entries(globalConfig.channels)) {
if (params && typeof params === 'object') {
channels.push({ name, params: { ...(params as Record<string, string>), 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<string, string> }[],
title: string,
content: string,
level: string
): Promise<void> {
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',
templateVars?: Record<string, string>
): void {
if (!checkEventEnabled(eventName)) return;
// Apply global template if available
const eventKey = 'on_' + eventName;
const templates = getEventTemplates();
const tmpl = templates[eventKey];
if (tmpl && tmpl.content) {
const vars: Record<string, string> = { ...(templateVars || {}) };
if (!vars.content && content) vars.content = content;
if (!vars.title && title) vars.title = title;
title = applyTemplate(tmpl.title || title, vars);
content = applyTemplate(tmpl.content, vars);
}
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 {};
}
function applyTemplate(template: string, vars: Record<string, string>): string {
return template.replace(/\{([^}]+)\}/g, (_, key) => vars[key] || '{' + key + '}');
}
function getEventTemplates(): Record<string, { title: string; content: string }> {
try {
const raw = getSystemConfig('global_notify_config') || '{}';
const cfg = JSON.parse(raw);
return cfg.eventTemplates || {};
} catch { return {}; }
}
export function notifyConfigEvent(
configId: number,
eventName: string,
title: string,
content: string,
level: 'info' | 'warn' | 'error' = 'info',
templateVars?: Record<string, string>
): void {
// Apply global template if available
const eventKey = 'on_' + eventName;
const templates = getEventTemplates();
const tmpl = templates[eventKey];
if (tmpl && tmpl.content) {
const vars: Record<string, string> = { ...(templateVars || {}) };
if (!vars.content && content) vars.content = content;
if (!vars.title && title) vars.title = title;
const appliedTitle = applyTemplate(tmpl.title || title, vars);
const appliedContent = applyTemplate(tmpl.content, vars);
title = appliedTitle;
content = appliedContent;
}
// 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
if (notifyConfig.events && notifyConfig.events[eventKey] === false) return;
// Build channels from push user config
const userChannels: { name: string; params: Record<string, string> }[] = [];
if (notifyConfig.channels) {
for (const [name, params] of Object.entries(notifyConfig.channels)) {
userChannels.push({ name, params: { ...(params as Record<string, string>), 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,
directParams?: Record<string, string>
): Promise<{ success: boolean; message: string }> {
let params: Record<string, string> = {};
// If direct params provided, use them directly (for global panel test without saving first)
if (directParams) {
params = directParams;
} else 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);
}

View File

@@ -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<NotifyResult> {
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 };
}
},
};

View File

@@ -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<NotifyResult> {
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 };
}
},
};

View File

@@ -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<NotifyResult> {
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 };
}
},
};

View File

@@ -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<NotifyResult> {
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 };
}
},
};

View File

@@ -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<string, Notifier>();
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<string, any> {
const result: Record<string, any> = {};
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<string, any>): Promise<{ success: boolean; message: string }> {
const n = getNotifier(name);
if (!n) return { success: false, message: `未知的通知渠道: ${name}` };
return n.notify(params);
}

View File

@@ -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<NotifyResult> {
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 };
}
},
};

View File

@@ -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<NotifyResult>;
}
export interface NotifierParam {
key: string;
label: string;
type: 'text' | 'password' | 'url' | 'switch' | 'number';
placeholder?: string;
default?: any;
required: boolean;
}

View File

@@ -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<NotifyResult> {
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 };
}
},
};

View File

@@ -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<NotifyResult> {
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 };
}
},
};

View File

@@ -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<NotifyResult> {
try {
const body: Record<string, string> = {
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 };
}
},
};

View File

@@ -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<NotifyResult> {
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 };
}
},
};

View File

@@ -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<NotifyResult> {
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 };
}
},
};

View File

@@ -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<NotifyResult> {
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 };
}
},
};

View File

@@ -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<NotifyResult> {
try {
const iconMap: Record<string, string> = { 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 };
}
},
};

View File

@@ -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<NotifyResult> {
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 };
}
},
};

View File

@@ -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<NotifyResult> {
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 };
}
},
};

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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, params } = req.body;
const ctx = account || (configId ? String(configId) : undefined);
const result = await testChannel(channelType as string, ctx, params);
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;

View File

@@ -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";