diff --git a/messages/en/settings.json b/messages/en/settings.json index 5b3044f2d..9c664ba0f 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -379,7 +379,7 @@ }, "notifications": { "title": "Push Notifications", - "description": "Configure WeChat Work robot push notifications", + "description": "Configure Webhook push notifications", "global": { "title": "Notification Master Switch", "description": "Enable or disable all push notification features", @@ -426,7 +426,7 @@ "saveError": "Failed to save settings", "loadError": "Failed to load notification settings", "webhookRequired": "Please fill in Webhook URL first", - "testSuccess": "Test message sent, please check WeChat Work", + "testSuccess": "Test message sent", "testFailed": "Test failed", "testFailedRetry": "Test failed, please retry", "testError": "Test connection failed", diff --git a/messages/ja/settings.json b/messages/ja/settings.json index 58d493575..d5fc22d5c 100644 --- a/messages/ja/settings.json +++ b/messages/ja/settings.json @@ -370,7 +370,7 @@ }, "notifications": { "title": "プッシュ通知", - "description": "WeCom(企業微信)ロボットのプッシュ通知を設定", + "description": "Webhook プッシュ通知を設定", "global": { "title": "通知マスタースイッチ", "description": "すべてのプッシュ通知機能を有効または無効にする", @@ -417,7 +417,7 @@ "saveError": "設定の保存に失敗しました", "loadError": "通知設定の読み込みに失敗しました", "webhookRequired": "まずWebhook URLを入力してください", - "testSuccess": "テストメッセージを送信しました。WeComを確認してください", + "testSuccess": "テストメッセージを送信しました", "testFailed": "テストに失敗しました", "testFailedRetry": "テストに失敗しました。再試行してください", "testError": "接続テストに失敗しました", diff --git a/messages/ru/settings.json b/messages/ru/settings.json index 97ed8c673..85d4a29d0 100644 --- a/messages/ru/settings.json +++ b/messages/ru/settings.json @@ -370,7 +370,7 @@ }, "notifications": { "title": "Push-уведомления", - "description": "Настройка push-уведомлений робота WeChat Work", + "description": "Настройка push-уведомлений Webhook", "global": { "title": "Главный переключатель уведомлений", "description": "Включить или отключить все функции push-уведомлений", @@ -417,7 +417,7 @@ "saveError": "Не удалось сохранить настройки", "loadError": "Не удалось загрузить настройки уведомлений", "webhookRequired": "Сначала заполните Webhook URL", - "testSuccess": "Тестовое сообщение отправлено, проверьте WeChat Work", + "testSuccess": "Тестовое сообщение отправлено", "testFailed": "Тест не пройден", "testFailedRetry": "Тест не пройден, попробуйте снова", "testError": "Ошибка тестирования подключения", diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index 1e806e995..a72f5fcb2 100644 --- a/messages/zh-CN/settings.json +++ b/messages/zh-CN/settings.json @@ -1560,7 +1560,7 @@ }, "notifications": { "title": "消息推送", - "description": "配置企业微信机器人消息推送", + "description": "配置 Webhook 消息推送", "global": { "title": "通知总开关", "description": "启用或禁用所有消息推送功能", @@ -1607,7 +1607,7 @@ "saveError": "保存设置失败", "loadError": "加载通知设置失败", "webhookRequired": "请先填写 Webhook URL", - "testSuccess": "测试消息已发送,请检查企业微信", + "testSuccess": "测试消息已发送", "testFailed": "测试失败", "testFailedRetry": "测试失败,请重试", "testError": "测试连接失败", diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index 1edfdb61c..61b0c2b13 100644 --- a/messages/zh-TW/settings.json +++ b/messages/zh-TW/settings.json @@ -370,7 +370,7 @@ }, "notifications": { "title": "訊息推送", - "description": "設定企業微信機器人訊息推送", + "description": "設定 Webhook 訊息推送", "global": { "title": "通知總開關", "description": "啟用或停用所有訊息推送功能", @@ -417,7 +417,7 @@ "saveError": "儲存設定失敗", "loadError": "載入通知設定失敗", "webhookRequired": "請先填寫 Webhook URL", - "testSuccess": "測試訊息已發送,請檢查企業微信", + "testSuccess": "測試訊息已發送", "testFailed": "測試失敗", "testFailedRetry": "測試失敗,請重試", "testError": "測試連線失敗", diff --git a/src/actions/notifications.ts b/src/actions/notifications.ts index 48654b5ef..6587116ea 100644 --- a/src/actions/notifications.ts +++ b/src/actions/notifications.ts @@ -1,7 +1,9 @@ "use server"; +import type { NotificationJobType } from "@/lib/constants/notification.constants"; import { logger } from "@/lib/logger"; -import { WeChatBot } from "@/lib/wechat/bot"; +import { WebhookNotifier } from "@/lib/webhook"; +import { buildTestMessage } from "@/lib/webhook/templates/test-messages"; import { getNotificationSettings, type NotificationSettings, @@ -116,7 +118,8 @@ export async function updateNotificationSettingsAction( * 测试 Webhook 连通性 */ export async function testWebhookAction( - webhookUrl: string + webhookUrl: string, + type: NotificationJobType ): Promise<{ success: boolean; error?: string }> { if (!webhookUrl || !webhookUrl.trim()) { return { success: false, error: "Webhook URL 不能为空" }; @@ -135,10 +138,9 @@ export async function testWebhookAction( } try { - const bot = new WeChatBot(trimmedUrl); - const result = await bot.testConnection(); - - return result; + const notifier = new WebhookNotifier(trimmedUrl, { maxRetries: 1 }); + const testMessage = buildTestMessage(type); + return notifier.send(testMessage); } catch (error) { return { success: false, diff --git a/src/app/[locale]/settings/notifications/page.tsx b/src/app/[locale]/settings/notifications/page.tsx index b13d6dcbf..02d8e4bae 100644 --- a/src/app/[locale]/settings/notifications/page.tsx +++ b/src/app/[locale]/settings/notifications/page.tsx @@ -20,6 +20,7 @@ import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; +import type { NotificationJobType } from "@/lib/constants/notification.constants"; /** * 通知设置表单 Schema @@ -50,7 +51,7 @@ export default function NotificationsPage() { const t = useTranslations("settings"); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); - const [testingWebhook, setTestingWebhook] = useState(null); + const [testingWebhook, setTestingWebhook] = useState(null); const { register, @@ -129,7 +130,7 @@ export default function NotificationsPage() { } }; - const handleTestWebhook = async (webhookUrl: string, type: string) => { + const handleTestWebhook = async (webhookUrl: string, type: NotificationJobType) => { if (!webhookUrl || !webhookUrl.trim()) { toast.error(t("notifications.form.webhookRequired")); return; @@ -138,7 +139,7 @@ export default function NotificationsPage() { setTestingWebhook(type); try { - const result = await testWebhookAction(webhookUrl); + const result = await testWebhookAction(webhookUrl, type); if (result.success) { toast.success(t("notifications.form.testSuccess")); @@ -229,12 +230,12 @@ export default function NotificationsPage() { type="button" variant="outline" size="sm" - disabled={!enabled || testingWebhook === "circuitBreaker"} + disabled={!enabled || testingWebhook === "circuit-breaker"} onClick={() => - handleTestWebhook(watch("circuitBreakerWebhook") || "", "circuitBreaker") + handleTestWebhook(watch("circuitBreakerWebhook") || "", "circuit-breaker") } > - {testingWebhook === "circuitBreaker" ? ( + {testingWebhook === "circuit-breaker" ? ( <> {t("common.testing")} @@ -325,12 +326,12 @@ export default function NotificationsPage() { type="button" variant="outline" size="sm" - disabled={!enabled || testingWebhook === "dailyLeaderboard"} + disabled={!enabled || testingWebhook === "daily-leaderboard"} onClick={() => - handleTestWebhook(watch("dailyLeaderboardWebhook") || "", "dailyLeaderboard") + handleTestWebhook(watch("dailyLeaderboardWebhook") || "", "daily-leaderboard") } > - {testingWebhook === "dailyLeaderboard" ? ( + {testingWebhook === "daily-leaderboard" ? ( <> {t("common.testing")} @@ -419,10 +420,10 @@ export default function NotificationsPage() { type="button" variant="outline" size="sm" - disabled={!enabled || testingWebhook === "costAlert"} - onClick={() => handleTestWebhook(watch("costAlertWebhook") || "", "costAlert")} + disabled={!enabled || testingWebhook === "cost-alert"} + onClick={() => handleTestWebhook(watch("costAlertWebhook") || "", "cost-alert")} > - {testingWebhook === "costAlert" ? ( + {testingWebhook === "cost-alert" ? ( <> {t("common.testing")} diff --git a/src/app/api/actions/[...route]/route.ts b/src/app/api/actions/[...route]/route.ts index e12011987..641b9313e 100644 --- a/src/app/api/actions/[...route]/route.ts +++ b/src/app/api/actions/[...route]/route.ts @@ -29,6 +29,7 @@ import * as usageLogActions from "@/actions/usage-logs"; // 导入 actions import * as userActions from "@/actions/users"; import { createActionRoute } from "@/lib/api/action-adapter-openapi"; +import { NOTIFICATION_JOB_TYPES } from "@/lib/constants/notification.constants"; // 导入 validation schemas import { CreateProviderSchema, @@ -809,6 +810,7 @@ const { route: testWebhookRoute, handler: testWebhookHandler } = createActionRou { requestSchema: z.object({ webhookUrl: z.string().url(), + type: z.enum(NOTIFICATION_JOB_TYPES), }), description: "测试 Webhook 配置", summary: "测试 Webhook 配置", diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index bb05a137a..3d273ee64 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -447,7 +447,7 @@ export const systemSettings = pgTable('system_settings', { updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), }); -// Notification Settings table - 企业微信机器人通知配置 +// Notification Settings table - Webhook 通知配置 export const notificationSettings = pgTable('notification_settings', { id: serial('id').primaryKey(), diff --git a/src/lib/constants/notification.constants.ts b/src/lib/constants/notification.constants.ts new file mode 100644 index 000000000..3cdde0aa5 --- /dev/null +++ b/src/lib/constants/notification.constants.ts @@ -0,0 +1,10 @@ +/** + * 通知相关常量 + */ +export const NOTIFICATION_JOB_TYPES = [ + "circuit-breaker", + "cost-alert", + "daily-leaderboard", +] as const; + +export type NotificationJobType = (typeof NOTIFICATION_JOB_TYPES)[number]; diff --git a/src/lib/notification/notification-queue.ts b/src/lib/notification/notification-queue.ts index ae51139ca..441c0e9bf 100644 --- a/src/lib/notification/notification-queue.ts +++ b/src/lib/notification/notification-queue.ts @@ -1,23 +1,20 @@ import type { Job } from "bull"; import Queue from "bull"; +import type { NotificationJobType } from "@/lib/constants/notification.constants"; import { logger } from "@/lib/logger"; -import { sendWeChatNotification } from "@/lib/wechat/bot"; import { - buildCircuitBreakerAlert, - buildCostAlert, - buildDailyLeaderboard, + buildCircuitBreakerMessage, + buildCostAlertMessage, + buildDailyLeaderboardMessage, type CircuitBreakerAlertData, type CostAlertData, type DailyLeaderboardData, -} from "@/lib/wechat/message-templates"; + type StructuredMessage, + sendWebhookMessage, +} from "@/lib/webhook"; import { generateCostAlerts } from "./tasks/cost-alert"; import { generateDailyLeaderboard } from "./tasks/daily-leaderboard"; -/** - * 通知任务类型 - */ -export type NotificationJobType = "circuit-breaker" | "daily-leaderboard" | "cost-alert"; - /** * 通知任务数据 */ @@ -124,11 +121,11 @@ function setupQueueProcessor(queue: Queue.Queue): void { }); try { - // 构建消息内容 - let content: string; + // 构建结构化消息 + let message: StructuredMessage; switch (type) { case "circuit-breaker": - content = buildCircuitBreakerAlert(data as CircuitBreakerAlertData); + message = buildCircuitBreakerMessage(data as CircuitBreakerAlertData); break; case "daily-leaderboard": { // 动态生成排行榜数据 @@ -146,7 +143,7 @@ function setupQueueProcessor(queue: Queue.Queue): void { return { success: true, skipped: true }; } - content = buildDailyLeaderboard(leaderboardData); + message = buildDailyLeaderboardMessage(leaderboardData); break; } case "cost-alert": { @@ -166,7 +163,7 @@ function setupQueueProcessor(queue: Queue.Queue): void { } // 发送第一个告警(后续可扩展为批量发送) - content = buildCostAlert(alerts[0]); + message = buildCostAlertMessage(alerts[0]); break; } default: @@ -174,7 +171,7 @@ function setupQueueProcessor(queue: Queue.Queue): void { } // 发送通知 - const result = await sendWeChatNotification(webhookUrl, content); + const result = await sendWebhookMessage(webhookUrl, message); if (!result.success) { throw new Error(result.error || "Failed to send notification"); diff --git a/src/lib/notification/notifier.ts b/src/lib/notification/notifier.ts index e6f9de604..97a391a50 100644 --- a/src/lib/notification/notifier.ts +++ b/src/lib/notification/notifier.ts @@ -1,6 +1,6 @@ import { logger } from "@/lib/logger"; import { getRedisClient } from "@/lib/redis/client"; -import type { CircuitBreakerAlertData } from "@/lib/wechat/message-templates"; +import type { CircuitBreakerAlertData } from "@/lib/webhook"; import { generateCostAlerts } from "./tasks/cost-alert"; import { generateDailyLeaderboard } from "./tasks/daily-leaderboard"; diff --git a/src/lib/notification/tasks/cost-alert.ts b/src/lib/notification/tasks/cost-alert.ts index 1327c5bb7..69e4a4985 100644 --- a/src/lib/notification/tasks/cost-alert.ts +++ b/src/lib/notification/tasks/cost-alert.ts @@ -2,7 +2,7 @@ import { and, eq, gte, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys, messageRequest, providers } from "@/drizzle/schema"; import { logger } from "@/lib/logger"; -import type { CostAlertData } from "@/lib/wechat/message-templates"; +import type { CostAlertData } from "@/lib/webhook"; /** * 生成成本预警数据 diff --git a/src/lib/notification/tasks/daily-leaderboard.ts b/src/lib/notification/tasks/daily-leaderboard.ts index 72ae4ac27..2f06abdd7 100644 --- a/src/lib/notification/tasks/daily-leaderboard.ts +++ b/src/lib/notification/tasks/daily-leaderboard.ts @@ -1,5 +1,5 @@ import { logger } from "@/lib/logger"; -import type { DailyLeaderboardData } from "@/lib/wechat/message-templates"; +import type { DailyLeaderboardData } from "@/lib/webhook"; import { findLast24HoursLeaderboard } from "@/repository/leaderboard"; /** diff --git a/src/lib/webhook/index.ts b/src/lib/webhook/index.ts new file mode 100644 index 000000000..96b739480 --- /dev/null +++ b/src/lib/webhook/index.ts @@ -0,0 +1,25 @@ +// Types + +// Notifier +export { sendWebhookMessage, WebhookNotifier } from "./notifier"; +// Renderers (for advanced usage) +export { createRenderer, type Renderer } from "./renderers"; +// Templates +export { + buildCircuitBreakerMessage, + buildCostAlertMessage, + buildDailyLeaderboardMessage, +} from "./templates"; +export type { + CircuitBreakerAlertData, + CostAlertData, + DailyLeaderboardData, + DailyLeaderboardEntry, + MessageLevel, + ProviderType, + Section, + SectionContent, + StructuredMessage, + WebhookPayload, + WebhookResult, +} from "./types"; diff --git a/src/lib/webhook/notifier.ts b/src/lib/webhook/notifier.ts new file mode 100644 index 000000000..69499f331 --- /dev/null +++ b/src/lib/webhook/notifier.ts @@ -0,0 +1,103 @@ +import { logger } from "@/lib/logger"; +import { createRenderer, type Renderer } from "./renderers"; +import type { ProviderType, StructuredMessage, WebhookPayload, WebhookResult } from "./types"; +import { withRetry } from "./utils/retry"; + +export interface WebhookNotifierOptions { + maxRetries?: number; +} + +export class WebhookNotifier { + private readonly webhookUrl: string; + private readonly maxRetries: number; + private readonly renderer: Renderer; + private readonly providerType: ProviderType; + + constructor(webhookUrl: string, options?: WebhookNotifierOptions) { + this.webhookUrl = webhookUrl; + this.maxRetries = options?.maxRetries ?? 3; + this.providerType = this.detectProvider(); + this.renderer = createRenderer(this.providerType); + } + + async send(message: StructuredMessage): Promise { + const payload = this.renderer.render(message); + + try { + return await withRetry(() => this.doSend(payload), { + maxRetries: this.maxRetries, + baseDelay: 1000, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ + action: "webhook_send_failed", + provider: this.providerType, + error: errorMessage, + }); + return { success: false, error: errorMessage }; + } + } + + private detectProvider(): ProviderType { + const url = new URL(this.webhookUrl); + if (url.hostname === "qyapi.weixin.qq.com") return "wechat"; + if (url.hostname === "open.feishu.cn") return "feishu"; + throw new Error(`Unsupported webhook hostname: ${url.hostname}`); + } + + private async doSend(payload: WebhookPayload): Promise { + logger.info({ + action: "webhook_send", + provider: this.providerType, + bodyLength: payload.body.length, + }); + + const response = await fetch(this.webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...payload.headers, + }, + body: payload.body, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + return this.checkResponse(result); + } + + private checkResponse(response: Record): WebhookResult { + switch (this.providerType) { + case "wechat": + if (response.errcode === 0) { + return { success: true }; + } + throw new Error(`WeChat API Error ${response.errcode}: ${response.errmsg}`); + + case "feishu": + if (response.code === 0) { + return { success: true }; + } + throw new Error(`Feishu API Error ${response.code}: ${response.msg}`); + } + } +} + +/** + * 便捷函数:发送结构化消息到 webhook + */ +export async function sendWebhookMessage( + webhookUrl: string, + message: StructuredMessage +): Promise { + if (!webhookUrl) { + return { success: false, error: "Webhook URL is empty" }; + } + + const notifier = new WebhookNotifier(webhookUrl); + return notifier.send(message); +} diff --git a/src/lib/webhook/renderers/feishu.ts b/src/lib/webhook/renderers/feishu.ts new file mode 100644 index 000000000..8375d3c8b --- /dev/null +++ b/src/lib/webhook/renderers/feishu.ts @@ -0,0 +1,151 @@ +import type { + ListItem, + MessageLevel, + Section, + SectionContent, + StructuredMessage, + WebhookPayload, +} from "../types"; +import { formatDateTime } from "../utils/date"; +import type { Renderer } from "./index"; + +interface CardElement { + tag: string; + [key: string]: unknown; +} + +export class FeishuCardRenderer implements Renderer { + render(message: StructuredMessage): WebhookPayload { + const elements: CardElement[] = []; + + // Sections + for (const section of message.sections) { + elements.push(...this.renderSection(section)); + } + + // Footer + if (message.footer) { + elements.push({ tag: "hr" }); + for (const section of message.footer) { + elements.push(...this.renderSection(section)); + } + } + + // Timestamp + elements.push({ + tag: "markdown", + content: formatDateTime(message.timestamp), + text_size: "notation", + }); + + const card = { + msg_type: "interactive", + card: { + schema: "2.0", + header: this.renderHeader(message), + body: { + elements, + }, + }, + }; + + return { body: JSON.stringify(card) }; + } + + private renderHeader(message: StructuredMessage): object { + const { title, icon, level } = message.header; + const displayTitle = icon ? `${icon} ${title}` : title; + + return { + title: { tag: "plain_text", content: displayTitle }, + template: this.levelToTemplate(level), + }; + } + + private levelToTemplate(level: MessageLevel): string { + switch (level) { + case "error": + return "red"; + case "warning": + return "orange"; + default: + return "blue"; + } + } + + private renderSection(section: Section): CardElement[] { + const elements: CardElement[] = []; + + if (section.title) { + elements.push({ + tag: "markdown", + content: `**${section.title}**`, + }); + } + + for (const content of section.content) { + elements.push(...this.renderContent(content)); + } + + return elements; + } + + private renderContent(content: SectionContent): CardElement[] { + switch (content.type) { + case "text": + return [{ tag: "markdown", content: content.value }]; + + case "quote": + return [{ tag: "markdown", content: `> ${content.value}` }]; + + case "fields": + return this.renderFields(content.items); + + case "list": + return this.renderList(content.items); + + case "divider": + return [{ tag: "hr" }]; + } + } + + private renderFields(items: { label: string; value: string }[]): CardElement[] { + const columns = items.map((item) => ({ + tag: "column", + width: "weighted", + weight: 1, + elements: [ + { + tag: "markdown", + content: `**${item.label}**\n${item.value}`, + }, + ], + })); + + const rows: CardElement[] = []; + for (let i = 0; i < columns.length; i += 2) { + rows.push({ + tag: "column_set", + flex_mode: "bisect", + columns: columns.slice(i, i + 2), + }); + } + + return rows; + } + + private renderList(items: ListItem[]): CardElement[] { + const lines: string[] = []; + + for (const item of items) { + const icon = item.icon ? `${item.icon} ` : ""; + let line = `${icon}**${item.primary}**`; + if (item.secondary) { + line += `\n${item.secondary}`; + } + lines.push(line); + } + + return [{ tag: "markdown", content: lines.join("\n\n") }]; + } +} diff --git a/src/lib/webhook/renderers/index.ts b/src/lib/webhook/renderers/index.ts new file mode 100644 index 000000000..6c7c5462e --- /dev/null +++ b/src/lib/webhook/renderers/index.ts @@ -0,0 +1,16 @@ +import type { ProviderType, StructuredMessage, WebhookPayload } from "../types"; +import { FeishuCardRenderer } from "./feishu"; +import { WeChatRenderer } from "./wechat"; + +export interface Renderer { + render(message: StructuredMessage): WebhookPayload; +} + +export function createRenderer(provider: ProviderType): Renderer { + switch (provider) { + case "wechat": + return new WeChatRenderer(); + case "feishu": + return new FeishuCardRenderer(); + } +} diff --git a/src/lib/webhook/renderers/wechat.ts b/src/lib/webhook/renderers/wechat.ts new file mode 100644 index 000000000..9948b249b --- /dev/null +++ b/src/lib/webhook/renderers/wechat.ts @@ -0,0 +1,124 @@ +import type { + ListItem, + Section, + SectionContent, + StructuredMessage, + WebhookPayload, +} from "../types"; +import type { Renderer } from "./index"; + +export class WeChatRenderer implements Renderer { + render(message: StructuredMessage): WebhookPayload { + const lines: string[] = []; + + // Header + lines.push(this.renderHeader(message)); + lines.push(""); + + // Sections + for (const section of message.sections) { + lines.push(...this.renderSection(section)); + lines.push(""); + } + + // Footer + if (message.footer) { + lines.push("---"); + for (const section of message.footer) { + lines.push(...this.renderSection(section)); + } + lines.push(""); + } + + // Timestamp + lines.push(this.formatTimestamp(message.timestamp)); + + const content = lines.join("\n"); + + return { + body: JSON.stringify({ + msgtype: "markdown", + markdown: { content }, + }), + }; + } + + private renderHeader(message: StructuredMessage): string { + const { title, icon, level } = message.header; + const levelIcon = this.getLevelIcon(level); + const displayIcon = icon || levelIcon; + return `## ${displayIcon} ${title}`; + } + + private getLevelIcon(level: string): string { + switch (level) { + case "error": + return "🚨"; + case "warning": + return "⚠️"; + case "info": + default: + return "📊"; + } + } + + private renderSection(section: Section): string[] { + const lines: string[] = []; + + if (section.title) { + lines.push(`**${section.title}**`); + } + + for (const content of section.content) { + lines.push(...this.renderContent(content)); + } + + return lines; + } + + private renderContent(content: SectionContent): string[] { + switch (content.type) { + case "text": + return [content.value]; + + case "quote": + return [`> ${content.value}`]; + + case "fields": + return content.items.map((item) => `${item.label}: ${item.value}`); + + case "list": + return this.renderList(content.items); + + case "divider": + return ["---"]; + } + } + + private renderList(items: ListItem[]): string[] { + const lines: string[] = []; + for (const item of items) { + const icon = item.icon ? `${item.icon} ` : ""; + let line = `${icon}**${item.primary}**`; + if (item.secondary) { + line += `\n${item.secondary}`; + } + lines.push(line); + lines.push(""); + } + return lines; + } + + private formatTimestamp(date: Date): string { + return date.toLocaleString("zh-CN", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + } +} diff --git a/src/lib/webhook/templates/circuit-breaker.ts b/src/lib/webhook/templates/circuit-breaker.ts new file mode 100644 index 000000000..251e546c7 --- /dev/null +++ b/src/lib/webhook/templates/circuit-breaker.ts @@ -0,0 +1,41 @@ +import type { CircuitBreakerAlertData, StructuredMessage } from "../types"; +import { formatDateTime } from "../utils/date"; + +export function buildCircuitBreakerMessage(data: CircuitBreakerAlertData): StructuredMessage { + const fields = [ + { label: "失败次数", value: `${data.failureCount} 次` }, + { label: "预计恢复", value: formatDateTime(data.retryAt) }, + ]; + + if (data.lastError) { + fields.push({ label: "最后错误", value: data.lastError }); + } + + return { + header: { + title: "供应商熔断告警", + icon: "🔌", + level: "error", + }, + sections: [ + { + content: [ + { + type: "quote", + value: `供应商 ${data.providerName} (ID: ${data.providerId}) 已触发熔断保护`, + }, + ], + }, + { + title: "详细信息", + content: [{ type: "fields", items: fields }], + }, + ], + footer: [ + { + content: [{ type: "text", value: "熔断器将在预计时间后自动恢复" }], + }, + ], + timestamp: new Date(), + }; +} diff --git a/src/lib/webhook/templates/cost-alert.ts b/src/lib/webhook/templates/cost-alert.ts new file mode 100644 index 000000000..7ad2c66a5 --- /dev/null +++ b/src/lib/webhook/templates/cost-alert.ts @@ -0,0 +1,55 @@ +import type { CostAlertData, StructuredMessage } from "../types"; + +function getUsageIndicator(percent: number): string { + if (percent >= 90) return "🔴"; + if (percent >= 80) return "🟡"; + return "🟢"; +} + +export function buildCostAlertMessage(data: CostAlertData): StructuredMessage { + const usagePercent = (data.currentCost / data.quotaLimit) * 100; + const remaining = data.quotaLimit - data.currentCost; + const targetTypeText = data.targetType === "user" ? "用户" : "供应商"; + + return { + header: { + title: "成本预警提醒", + icon: "💰", + level: "warning", + }, + sections: [ + { + content: [ + { + type: "quote", + value: `${targetTypeText} ${data.targetName} 的消费已达到预警阈值`, + }, + ], + }, + { + title: "消费详情", + content: [ + { + type: "fields", + items: [ + { label: "当前消费", value: `$${data.currentCost.toFixed(4)}` }, + { label: "配额限制", value: `$${data.quotaLimit.toFixed(4)}` }, + { + label: "使用比例", + value: `${usagePercent.toFixed(1)}% ${getUsageIndicator(usagePercent)}`, + }, + { label: "剩余额度", value: `$${remaining.toFixed(4)}` }, + { label: "统计周期", value: data.period }, + ], + }, + ], + }, + ], + footer: [ + { + content: [{ type: "text", value: "请注意控制消费" }], + }, + ], + timestamp: new Date(), + }; +} diff --git a/src/lib/webhook/templates/daily-leaderboard.ts b/src/lib/webhook/templates/daily-leaderboard.ts new file mode 100644 index 000000000..46b6e1670 --- /dev/null +++ b/src/lib/webhook/templates/daily-leaderboard.ts @@ -0,0 +1,73 @@ +import type { DailyLeaderboardData, StructuredMessage } from "../types"; + +function getMedal(index: number): string { + const medals = ["🥇", "🥈", "🥉"]; + return medals[index] || `${index + 1}.`; +} + +function formatTokens(tokens: number): string { + if (tokens >= 1_000_000) { + return `${(tokens / 1_000_000).toFixed(2)}M`; + } + if (tokens >= 1_000) { + return `${(tokens / 1_000).toFixed(2)}K`; + } + return tokens.toLocaleString(); +} + +export function buildDailyLeaderboardMessage(data: DailyLeaderboardData): StructuredMessage { + if (data.entries.length === 0) { + return { + header: { + title: "过去24小时用户消费排行榜", + icon: "📊", + level: "info", + }, + sections: [ + { + content: [ + { type: "quote", value: `统计时间: ${data.date}` }, + { type: "text", value: "暂无数据" }, + ], + }, + ], + timestamp: new Date(), + }; + } + + const listItems = data.entries.map((entry, index) => ({ + icon: getMedal(index), + primary: `${entry.userName} (ID: ${entry.userId})`, + secondary: `消费 $${entry.totalCost.toFixed(4)} · 请求 ${entry.totalRequests.toLocaleString()} 次 · Token ${formatTokens(entry.totalTokens)}`, + })); + + return { + header: { + title: "过去24小时用户消费排行榜", + icon: "📊", + level: "info", + }, + sections: [ + { + content: [{ type: "quote", value: `统计时间: ${data.date}` }], + }, + { + title: "排名情况", + content: [{ type: "list", style: "ordered", items: listItems }], + }, + { + content: [{ type: "divider" }], + }, + { + title: "总览", + content: [ + { + type: "text", + value: `总请求 ${data.totalRequests.toLocaleString()} 次 · 总消费 $${data.totalCost.toFixed(4)}`, + }, + ], + }, + ], + timestamp: new Date(), + }; +} diff --git a/src/lib/webhook/templates/index.ts b/src/lib/webhook/templates/index.ts new file mode 100644 index 000000000..ccf3ebe7a --- /dev/null +++ b/src/lib/webhook/templates/index.ts @@ -0,0 +1,4 @@ +export { buildCircuitBreakerMessage } from "./circuit-breaker"; +export { buildCostAlertMessage } from "./cost-alert"; +export { buildDailyLeaderboardMessage } from "./daily-leaderboard"; +export { buildTestMessage } from "./test-messages"; diff --git a/src/lib/webhook/templates/test-messages.ts b/src/lib/webhook/templates/test-messages.ts new file mode 100644 index 000000000..d06b751f4 --- /dev/null +++ b/src/lib/webhook/templates/test-messages.ts @@ -0,0 +1,44 @@ +import type { NotificationJobType } from "@/lib/constants/notification.constants"; +import type { StructuredMessage } from "../types"; +import { buildCircuitBreakerMessage } from "./circuit-breaker"; +import { buildCostAlertMessage } from "./cost-alert"; +import { buildDailyLeaderboardMessage } from "./daily-leaderboard"; + +/** + * 根据通知类型构建测试消息 + * 使用模拟数据,完整展示真实消息格式 + */ +export function buildTestMessage(type: NotificationJobType): StructuredMessage { + switch (type) { + case "circuit-breaker": + return buildCircuitBreakerMessage({ + providerName: "测试供应商", + providerId: 0, + failureCount: 3, + retryAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + lastError: "Connection timeout (示例错误)", + }); + + case "cost-alert": + return buildCostAlertMessage({ + targetType: "user", + targetName: "测试用户", + targetId: 0, + currentCost: 80, + quotaLimit: 100, + threshold: 0.8, + period: "本月", + }); + + case "daily-leaderboard": + return buildDailyLeaderboardMessage({ + date: new Date().toISOString().split("T")[0], + entries: [ + { userId: 1, userName: "用户A", totalRequests: 150, totalCost: 12.5, totalTokens: 50000 }, + { userId: 2, userName: "用户B", totalRequests: 120, totalCost: 10.2, totalTokens: 40000 }, + ], + totalRequests: 270, + totalCost: 22.7, + }); + } +} diff --git a/src/lib/webhook/types.ts b/src/lib/webhook/types.ts new file mode 100644 index 000000000..a46759aa8 --- /dev/null +++ b/src/lib/webhook/types.ts @@ -0,0 +1,89 @@ +/** + * 平台无关的结构化消息类型 + */ + +export type MessageLevel = "info" | "warning" | "error"; + +export interface MessageHeader { + title: string; + icon?: string; + level: MessageLevel; +} + +export interface ListItem { + icon?: string; + primary: string; + secondary?: string; +} + +export type SectionContent = + | { type: "text"; value: string } + | { type: "quote"; value: string } + | { type: "fields"; items: { label: string; value: string }[] } + | { type: "list"; style: "ordered" | "bullet"; items: ListItem[] } + | { type: "divider" }; + +export interface Section { + title?: string; + content: SectionContent[]; +} + +export interface StructuredMessage { + header: MessageHeader; + sections: Section[]; + footer?: Section[]; + timestamp: Date; +} + +/** + * 业务数据类型 + */ + +export interface CircuitBreakerAlertData { + providerName: string; + providerId: number; + failureCount: number; + retryAt: string; + lastError?: string; +} + +export interface DailyLeaderboardEntry { + userId: number; + userName: string; + totalRequests: number; + totalCost: number; + totalTokens: number; +} + +export interface DailyLeaderboardData { + date: string; + entries: DailyLeaderboardEntry[]; + totalRequests: number; + totalCost: number; +} + +export interface CostAlertData { + targetType: "user" | "provider"; + targetName: string; + targetId: number; + currentCost: number; + quotaLimit: number; + threshold: number; + period: string; +} + +/** + * Webhook 相关类型 + */ + +export type ProviderType = "wechat" | "feishu"; + +export interface WebhookPayload { + body: string; + headers?: Record; +} + +export interface WebhookResult { + success: boolean; + error?: string; +} diff --git a/src/lib/webhook/utils/date.ts b/src/lib/webhook/utils/date.ts new file mode 100644 index 000000000..5c1bf518f --- /dev/null +++ b/src/lib/webhook/utils/date.ts @@ -0,0 +1,16 @@ +/** + * 格式化日期时间为中国时区的本地化字符串 + */ +export function formatDateTime(date: Date | string): string { + const d = typeof date === "string" ? new Date(date) : date; + return d.toLocaleString("zh-CN", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); +} diff --git a/src/lib/webhook/utils/retry.ts b/src/lib/webhook/utils/retry.ts new file mode 100644 index 000000000..c4f0d4562 --- /dev/null +++ b/src/lib/webhook/utils/retry.ts @@ -0,0 +1,31 @@ +export interface RetryOptions { + maxRetries: number; + baseDelay?: number; + backoff?: (attempt: number, baseDelay: number) => number; +} + +const defaultBackoff = (attempt: number, baseDelay: number): number => { + return baseDelay * 2 ** (attempt - 1); +}; + +const delay = (ms: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; + +export async function withRetry(fn: () => Promise, options: RetryOptions): Promise { + const { maxRetries, baseDelay = 1000, backoff = defaultBackoff } = options; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + if (attempt === maxRetries) { + throw error; + } + await delay(backoff(attempt, baseDelay)); + } + } + + // TypeScript 需要这个,实际不会执行到 + throw new Error("Unreachable"); +} diff --git a/src/lib/wechat/bot.ts b/src/lib/wechat/bot.ts deleted file mode 100644 index 835d29be5..000000000 --- a/src/lib/wechat/bot.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { logger } from "@/lib/logger"; - -/** - * 企业微信机器人消息类型 - */ -export interface WeChatMarkdownMessage { - msgtype: "markdown"; - markdown: { - content: string; - }; -} - -/** - * 企业微信机器人发送结果 - */ -export interface WeChatBotResponse { - errcode: number; - errmsg: string; -} - -/** - * 企业微信机器人 SDK - * 文档: https://developer.work.weixin.qq.com/document/path/91770 - */ -export class WeChatBot { - private webhookUrl: string; - private maxRetries: number; - - constructor(webhookUrl: string, maxRetries = 3) { - this.webhookUrl = webhookUrl; - this.maxRetries = maxRetries; - } - - /** - * 发送 Markdown 格式消息 - * @param content Markdown 格式的消息内容 - * @returns 发送结果 - */ - async sendMarkdown(content: string): Promise<{ success: boolean; error?: string }> { - const message: WeChatMarkdownMessage = { - msgtype: "markdown", - markdown: { - content, - }, - }; - - for (let attempt = 1; attempt <= this.maxRetries; attempt++) { - try { - logger.info({ - action: "wechat_bot_send", - attempt, - contentLength: content.length, - }); - - const response = await fetch(this.webhookUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(message), - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const result: WeChatBotResponse = await response.json(); - - if (result.errcode === 0) { - logger.info({ - action: "wechat_bot_success", - attempt, - }); - return { success: true }; - } else { - throw new Error(`WeChat API Error ${result.errcode}: ${result.errmsg}`); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - - logger.error({ - action: "wechat_bot_error", - attempt, - error: errorMessage, - willRetry: attempt < this.maxRetries, - }); - - // 最后一次尝试失败 - if (attempt === this.maxRetries) { - return { success: false, error: errorMessage }; - } - - // 重试延迟: 1秒 -> 2秒 -> 4秒 - const delay = 2 ** (attempt - 1) * 1000; - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - - return { success: false, error: "Max retries exceeded" }; - } - - /** - * 测试 Webhook 连通性 - */ - async testConnection(): Promise<{ success: boolean; error?: string }> { - return this.sendMarkdown("**测试消息**\n\n企业微信机器人连接成功!"); - } -} - -/** - * 发送企业微信通知(工厂函数) - * @param webhookUrl Webhook 地址 - * @param content Markdown 格式内容 - * @returns 发送结果 - */ -export async function sendWeChatNotification( - webhookUrl: string, - content: string -): Promise<{ success: boolean; error?: string }> { - if (!webhookUrl) { - return { success: false, error: "Webhook URL is empty" }; - } - - const bot = new WeChatBot(webhookUrl); - return bot.sendMarkdown(content); -} diff --git a/src/lib/wechat/message-templates.ts b/src/lib/wechat/message-templates.ts deleted file mode 100644 index 9a28f9693..000000000 --- a/src/lib/wechat/message-templates.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * 企业微信消息模板 - * 使用 Markdown 格式,确保美观清晰 - */ - -/** - * 熔断器打开告警消息 - */ -export interface CircuitBreakerAlertData { - providerName: string; - providerId: number; - failureCount: number; - retryAt: string; // ISO 格式时间 - lastError?: string; -} - -export function buildCircuitBreakerAlert(data: CircuitBreakerAlertData): string { - const lines = [ - "## 🚨 供应商熔断告警", - "", - `> 供应商 **${data.providerName}** (ID: ${data.providerId}) 已触发熔断保护`, - "", - "**详细信息**", - `失败次数: ${data.failureCount} 次`, - `预计恢复: ${formatDateTime(data.retryAt)}`, - ]; - - if (data.lastError) { - lines.push(`最后错误: \`${truncate(data.lastError, 100)}\``); - } - - lines.push( - "", - "---", - `${formatDateTime(new Date().toISOString())} · 熔断器将在预计时间后自动恢复` - ); - - return lines.join("\n"); -} - -/** - * 每日用户消费排行榜消息 - */ -export interface DailyLeaderboardEntry { - userId: number; - userName: string; - totalRequests: number; - totalCost: number; - totalTokens: number; -} - -export interface DailyLeaderboardData { - date: string; // YYYY-MM-DD - entries: DailyLeaderboardEntry[]; - totalRequests: number; - totalCost: number; -} - -export function buildDailyLeaderboard(data: DailyLeaderboardData): string { - const lines = ["## 📊 过去24小时用户消费排行榜", "", `> 统计时间: **${data.date}**`, ""]; - - if (data.entries.length === 0) { - lines.push("暂无数据"); - } else { - lines.push("**排名情况**"); - lines.push(""); - - data.entries.forEach((entry, index) => { - const medal = getMedal(index); - lines.push( - `${medal} **${entry.userName}** (ID: ${entry.userId})`, - `消费 $${entry.totalCost.toFixed(4)} · 请求 ${entry.totalRequests.toLocaleString()} 次 · Token ${formatTokens(entry.totalTokens)}`, - "" - ); - }); - - lines.push( - "---", - "**总览**", - `总请求 ${data.totalRequests.toLocaleString()} 次 · 总消费 $${data.totalCost.toFixed(4)}`, - "", - formatDateTime(new Date().toISOString()) - ); - } - - return lines.join("\n"); -} - -/** - * 成本预警消息 - */ -export interface CostAlertData { - targetType: "user" | "provider"; - targetName: string; - targetId: number; - currentCost: number; - quotaLimit: number; - threshold: number; // 0-1 - period: string; // "5小时" | "本周" | "本月" -} - -export function buildCostAlert(data: CostAlertData): string { - const usagePercent = (data.currentCost / data.quotaLimit) * 100; - const remaining = data.quotaLimit - data.currentCost; - const targetTypeText = data.targetType === "user" ? "用户" : "供应商"; - - const lines = [ - "## ⚠️ 成本预警提醒", - "", - `> ${targetTypeText} **${data.targetName}** 的消费已达到预警阈值`, - "", - "**消费详情**", - `当前消费: $${data.currentCost.toFixed(4)}`, - `配额限制: $${data.quotaLimit.toFixed(4)}`, - `使用比例: **${usagePercent.toFixed(1)}%** ${getUsageBar(usagePercent)}`, - `剩余额度: $${remaining.toFixed(4)}`, - `统计周期: ${data.period}`, - "", - "---", - `${formatDateTime(new Date().toISOString())} · 请注意控制消费`, - ]; - - return lines.join("\n"); -} - -/** - * 辅助函数: 格式化日期时间 - */ -function formatDateTime(isoString: string): string { - const date = new Date(isoString); - return date.toLocaleString("zh-CN", { - timeZone: "Asia/Shanghai", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); -} - -/** - * 辅助函数: 截断字符串 - */ -function truncate(str: string, maxLength: number): string { - if (str.length <= maxLength) return str; - return `${str.substring(0, maxLength)}...`; -} - -/** - * 辅助函数: 获取排名奖牌 - */ -function getMedal(index: number): string { - const medals = ["🥇", "🥈", "🥉"]; - return medals[index] || `${index + 1}.`; -} - -/** - * 辅助函数: 格式化 Token 数量 - */ -function formatTokens(tokens: number): string { - if (tokens >= 1_000_000) { - return `${(tokens / 1_000_000).toFixed(2)}M`; - } else if (tokens >= 1_000) { - return `${(tokens / 1_000).toFixed(2)}K`; - } - return tokens.toLocaleString(); -} - -/** - * 辅助函数: 生成使用率进度条 - */ -function getUsageBar(percent: number): string { - if (percent >= 90) return "🔴"; // 红色 - 危险 - if (percent >= 80) return "🟡"; // 黄色 - 警告 - return "🟢"; // 绿色 - 正常 -} diff --git a/tests/unit/webhook/notifier.test.ts b/tests/unit/webhook/notifier.test.ts new file mode 100644 index 000000000..41cb85fde --- /dev/null +++ b/tests/unit/webhook/notifier.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WebhookNotifier } from "@/lib/webhook/notifier"; +import type { StructuredMessage } from "@/lib/webhook/types"; + +describe("WebhookNotifier", () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + const createMessage = (): StructuredMessage => ({ + header: { title: "测试", level: "info" }, + sections: [], + timestamp: new Date(), + }); + + describe("provider detection", () => { + it("should detect wechat provider", () => { + const notifier = new WebhookNotifier( + "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx" + ); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ errcode: 0, errmsg: "ok" }), + }); + + expect(() => notifier.send(createMessage())).not.toThrow(); + }); + + it("should detect feishu provider", () => { + const notifier = new WebhookNotifier("https://open.feishu.cn/open-apis/bot/v2/hook/xxx"); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ code: 0, msg: "success" }), + }); + + expect(() => notifier.send(createMessage())).not.toThrow(); + }); + + it("should throw for unsupported provider", () => { + expect(() => new WebhookNotifier("https://unknown.com/webhook")).toThrow( + "Unsupported webhook hostname: unknown.com" + ); + }); + }); + + describe("send", () => { + it("should send message and return success", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ errcode: 0, errmsg: "ok" }), + }); + + const notifier = new WebhookNotifier( + "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx" + ); + const result = await notifier.send(createMessage()); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "Content-Type": "application/json" }), + }) + ); + }); + + it("should return error on API failure", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ errcode: 40001, errmsg: "invalid token" }), + }); + + const notifier = new WebhookNotifier( + "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx", + { maxRetries: 1 } + ); + const result = await notifier.send(createMessage()); + + expect(result.success).toBe(false); + expect(result.error).toContain("40001"); + }); + + it("should retry on network failure", async () => { + mockFetch.mockRejectedValueOnce(new Error("network error")).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ errcode: 0, errmsg: "ok" }), + }); + + const notifier = new WebhookNotifier( + "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx", + { maxRetries: 2 } + ); + const result = await notifier.send(createMessage()); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + + describe("feishu response handling", () => { + it("should handle feishu success response", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ code: 0, msg: "success", data: {} }), + }); + + const notifier = new WebhookNotifier("https://open.feishu.cn/open-apis/bot/v2/hook/xxx"); + const result = await notifier.send(createMessage()); + + expect(result.success).toBe(true); + }); + + it("should handle feishu error response", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ code: 19024, msg: "Key Words Not Found" }), + }); + + const notifier = new WebhookNotifier("https://open.feishu.cn/open-apis/bot/v2/hook/xxx", { + maxRetries: 1, + }); + const result = await notifier.send(createMessage()); + + expect(result.success).toBe(false); + expect(result.error).toContain("19024"); + }); + }); +}); diff --git a/tests/unit/webhook/renderers/feishu.test.ts b/tests/unit/webhook/renderers/feishu.test.ts new file mode 100644 index 000000000..52662aaba --- /dev/null +++ b/tests/unit/webhook/renderers/feishu.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import { FeishuCardRenderer } from "@/lib/webhook/renderers/feishu"; +import type { StructuredMessage } from "@/lib/webhook/types"; + +describe("FeishuCardRenderer", () => { + const renderer = new FeishuCardRenderer(); + + it("should render interactive card with correct structure", () => { + const message: StructuredMessage = { + header: { title: "测试标题", icon: "🔔", level: "info" }, + sections: [], + timestamp: new Date("2025-01-02T12:00:00Z"), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + expect(body.msg_type).toBe("interactive"); + expect(body.card.schema).toBe("2.0"); + expect(body.card.header.title.content).toContain("🔔 测试标题"); + expect(body.card.header.template).toBe("blue"); + }); + + it("should map level to correct template color", () => { + const levels = [ + { level: "info" as const, template: "blue" }, + { level: "warning" as const, template: "orange" }, + { level: "error" as const, template: "red" }, + ]; + + for (const { level, template } of levels) { + const message: StructuredMessage = { + header: { title: "标题", level }, + sections: [], + timestamp: new Date(), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + expect(body.card.header.template).toBe(template); + } + }); + + it("should render text section as markdown element", () => { + const message: StructuredMessage = { + header: { title: "标题", level: "info" }, + sections: [{ content: [{ type: "text", value: "普通文本内容" }] }], + timestamp: new Date(), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + const elements = body.card.body.elements; + expect( + elements.some((e: any) => e.tag === "markdown" && e.content.includes("普通文本内容")) + ).toBe(true); + }); + + it("should render quote as markdown with quote syntax", () => { + const message: StructuredMessage = { + header: { title: "标题", level: "warning" }, + sections: [{ content: [{ type: "quote", value: "引用内容" }] }], + timestamp: new Date(), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + const elements = body.card.body.elements; + expect( + elements.some((e: any) => e.tag === "markdown" && e.content.includes("> 引用内容")) + ).toBe(true); + }); + + it("should render fields as column set", () => { + const message: StructuredMessage = { + header: { title: "标题", level: "error" }, + sections: [ + { + title: "详细信息", + content: [ + { + type: "fields", + items: [ + { label: "名称", value: "测试" }, + { label: "状态", value: "正常" }, + ], + }, + ], + }, + ], + timestamp: new Date(), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + const bodyStr = JSON.stringify(body.card.body); + expect(bodyStr).toContain("名称"); + expect(bodyStr).toContain("测试"); + }); + + it("should render list items", () => { + const message: StructuredMessage = { + header: { title: "排行榜", level: "info" }, + sections: [ + { + content: [ + { + type: "list", + style: "ordered", + items: [ + { icon: "🥇", primary: "用户A", secondary: "消费 $10" }, + { icon: "🥈", primary: "用户B", secondary: "消费 $5" }, + ], + }, + ], + }, + ], + timestamp: new Date(), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + const bodyStr = JSON.stringify(body.card.body); + expect(bodyStr).toContain("🥇"); + expect(bodyStr).toContain("用户A"); + }); + + it("should render divider element", () => { + const message: StructuredMessage = { + header: { title: "标题", level: "info" }, + sections: [{ content: [{ type: "divider" }] }], + timestamp: new Date(), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + const elements = body.card.body.elements; + expect(elements.some((e: any) => e.tag === "hr")).toBe(true); + }); + + it("should include timestamp in footer", () => { + const message: StructuredMessage = { + header: { title: "标题", level: "info" }, + sections: [], + timestamp: new Date("2025-01-02T12:00:00Z"), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + const bodyStr = JSON.stringify(body.card.body); + expect(bodyStr).toContain("2025"); + }); +}); diff --git a/tests/unit/webhook/renderers/wechat.test.ts b/tests/unit/webhook/renderers/wechat.test.ts new file mode 100644 index 000000000..f74b4cbec --- /dev/null +++ b/tests/unit/webhook/renderers/wechat.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { WeChatRenderer } from "@/lib/webhook/renderers/wechat"; +import type { StructuredMessage } from "@/lib/webhook/types"; + +describe("WeChatRenderer", () => { + const renderer = new WeChatRenderer(); + + it("should render basic message with header", () => { + const message: StructuredMessage = { + header: { title: "测试标题", icon: "🔔", level: "info" }, + sections: [], + timestamp: new Date("2025-01-02T12:00:00Z"), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + expect(body.msgtype).toBe("markdown"); + expect(body.markdown.content).toContain("🔔 测试标题"); + }); + + it("should render text section", () => { + const message: StructuredMessage = { + header: { title: "标题", level: "info" }, + sections: [{ content: [{ type: "text", value: "普通文本内容" }] }], + timestamp: new Date(), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + expect(body.markdown.content).toContain("普通文本内容"); + }); + + it("should render quote section", () => { + const message: StructuredMessage = { + header: { title: "标题", level: "warning" }, + sections: [{ content: [{ type: "quote", value: "引用内容" }] }], + timestamp: new Date(), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + expect(body.markdown.content).toContain("> 引用内容"); + }); + + it("should render fields section", () => { + const message: StructuredMessage = { + header: { title: "标题", level: "error" }, + sections: [ + { + title: "详细信息", + content: [ + { + type: "fields", + items: [ + { label: "名称", value: "测试" }, + { label: "状态", value: "正常" }, + ], + }, + ], + }, + ], + timestamp: new Date(), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + expect(body.markdown.content).toContain("**详细信息**"); + expect(body.markdown.content).toContain("名称: 测试"); + expect(body.markdown.content).toContain("状态: 正常"); + }); + + it("should render list section with icons", () => { + const message: StructuredMessage = { + header: { title: "排行榜", level: "info" }, + sections: [ + { + content: [ + { + type: "list", + style: "ordered", + items: [ + { icon: "🥇", primary: "用户A", secondary: "消费 $10" }, + { icon: "🥈", primary: "用户B", secondary: "消费 $5" }, + ], + }, + ], + }, + ], + timestamp: new Date(), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + expect(body.markdown.content).toContain("🥇 **用户A**"); + expect(body.markdown.content).toContain("消费 $10"); + }); + + it("should render divider", () => { + const message: StructuredMessage = { + header: { title: "标题", level: "info" }, + sections: [{ content: [{ type: "divider" }] }], + timestamp: new Date(), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + expect(body.markdown.content).toContain("---"); + }); + + it("should render footer sections", () => { + const message: StructuredMessage = { + header: { title: "标题", level: "info" }, + sections: [], + footer: [{ content: [{ type: "text", value: "底部提示信息" }] }], + timestamp: new Date(), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + expect(body.markdown.content).toContain("底部提示信息"); + }); + + it("should include timestamp", () => { + const message: StructuredMessage = { + header: { title: "标题", level: "info" }, + sections: [], + timestamp: new Date("2025-01-02T12:00:00Z"), + }; + + const result = renderer.render(message); + const body = JSON.parse(result.body); + + expect(body.markdown.content).toContain("2025"); + }); +}); diff --git a/tests/unit/webhook/templates/templates.test.ts b/tests/unit/webhook/templates/templates.test.ts new file mode 100644 index 000000000..2dcd1d294 --- /dev/null +++ b/tests/unit/webhook/templates/templates.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { buildCircuitBreakerMessage } from "@/lib/webhook/templates/circuit-breaker"; +import { buildCostAlertMessage } from "@/lib/webhook/templates/cost-alert"; +import { buildDailyLeaderboardMessage } from "@/lib/webhook/templates/daily-leaderboard"; +import type { + CircuitBreakerAlertData, + CostAlertData, + DailyLeaderboardData, +} from "@/lib/webhook/types"; + +describe("Message Templates", () => { + describe("buildCircuitBreakerMessage", () => { + it("should create structured message for circuit breaker alert", () => { + const data: CircuitBreakerAlertData = { + providerName: "OpenAI", + providerId: 1, + failureCount: 5, + retryAt: "2025-01-02T12:30:00Z", + lastError: "Connection timeout", + }; + + const message = buildCircuitBreakerMessage(data); + + expect(message.header.level).toBe("error"); + expect(message.header.icon).toBe("🔌"); + expect(message.header.title).toContain("熔断"); + expect(message.timestamp).toBeInstanceOf(Date); + + const sectionsStr = JSON.stringify(message.sections); + expect(sectionsStr).toContain("OpenAI"); + expect(sectionsStr).toContain("5"); + }); + + it("should handle missing lastError", () => { + const data: CircuitBreakerAlertData = { + providerName: "Anthropic", + providerId: 2, + failureCount: 3, + retryAt: "2025-01-02T13:00:00Z", + }; + + const message = buildCircuitBreakerMessage(data); + expect(message.header.level).toBe("error"); + }); + }); + + describe("buildCostAlertMessage", () => { + it("should create structured message for user cost alert", () => { + const data: CostAlertData = { + targetType: "user", + targetName: "张三", + targetId: 100, + currentCost: 8.5, + quotaLimit: 10, + threshold: 0.8, + period: "本周", + }; + + const message = buildCostAlertMessage(data); + + expect(message.header.level).toBe("warning"); + expect(message.header.icon).toBe("💰"); + expect(message.header.title).toContain("成本预警"); + + const sectionsStr = JSON.stringify(message.sections); + expect(sectionsStr).toContain("张三"); + expect(sectionsStr).toContain("8.5"); + expect(sectionsStr).toContain("本周"); + }); + + it("should create structured message for provider cost alert", () => { + const data: CostAlertData = { + targetType: "provider", + targetName: "GPT-4", + targetId: 1, + currentCost: 950, + quotaLimit: 1000, + threshold: 0.9, + period: "本月", + }; + + const message = buildCostAlertMessage(data); + + expect(message.header.level).toBe("warning"); + const sectionsStr = JSON.stringify(message.sections); + expect(sectionsStr).toContain("供应商"); + }); + }); + + describe("buildDailyLeaderboardMessage", () => { + it("should create structured message for leaderboard", () => { + const data: DailyLeaderboardData = { + date: "2025-01-02", + entries: [ + { userId: 1, userName: "用户A", totalRequests: 100, totalCost: 5.0, totalTokens: 50000 }, + { userId: 2, userName: "用户B", totalRequests: 80, totalCost: 4.0, totalTokens: 40000 }, + ], + totalRequests: 180, + totalCost: 9.0, + }; + + const message = buildDailyLeaderboardMessage(data); + + expect(message.header.level).toBe("info"); + expect(message.header.icon).toBe("📊"); + expect(message.header.title).toContain("排行榜"); + + const sectionsStr = JSON.stringify(message.sections); + expect(sectionsStr).toContain("用户A"); + expect(sectionsStr).toContain("🥇"); + }); + + it("should handle empty entries", () => { + const data: DailyLeaderboardData = { + date: "2025-01-02", + entries: [], + totalRequests: 0, + totalCost: 0, + }; + + const message = buildDailyLeaderboardMessage(data); + + expect(message.header.level).toBe("info"); + const sectionsStr = JSON.stringify(message.sections); + expect(sectionsStr).toContain("暂无数据"); + }); + }); +}); diff --git a/tests/unit/webhook/utils/retry.test.ts b/tests/unit/webhook/utils/retry.test.ts new file mode 100644 index 000000000..5155325f2 --- /dev/null +++ b/tests/unit/webhook/utils/retry.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { withRetry } from "@/lib/webhook/utils/retry"; + +describe("withRetry", () => { + it("should return result on first success", async () => { + const fn = vi.fn().mockResolvedValue("success"); + + const result = await withRetry(fn, { maxRetries: 3 }); + + expect(result).toBe("success"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("should retry on failure and succeed", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("fail 1")) + .mockRejectedValueOnce(new Error("fail 2")) + .mockResolvedValue("success"); + + const result = await withRetry(fn, { maxRetries: 3, baseDelay: 1 }); + + expect(result).toBe("success"); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it("should throw after max retries", async () => { + const fn = vi.fn().mockRejectedValue(new Error("always fail")); + + await expect(withRetry(fn, { maxRetries: 3, baseDelay: 1 })).rejects.toThrow("always fail"); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it("should use exponential backoff", async () => { + const delays: number[] = []; + vi.spyOn(globalThis, "setTimeout").mockImplementation((fn: any, delay: number) => { + delays.push(delay); + fn(); + return 0 as any; + }); + + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("fail")) + .mockRejectedValueOnce(new Error("fail")) + .mockResolvedValue("success"); + + await withRetry(fn, { maxRetries: 3, baseDelay: 100 }); + + expect(delays).toEqual([100, 200]); // 100 * 2^0, 100 * 2^1 + + vi.restoreAllMocks(); + }); +});