From 38c10d616b95ecd48fc160cbe2bcd97c6c21d48a Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 1 May 2026 01:45:59 +0800 Subject: [PATCH] feat: add dingtalk robot update notifications --- .../settings/admin-settings-client.tsx | 177 +++++++++++++++++- app/api/admin/dingtalk-robot/send/route.ts | 67 +++++++ app/api/admin/settings/route.ts | 14 ++ lib/notifications/dingtalk-robot-config.ts | 37 ++++ lib/notifications/dingtalk-robot.ts | 134 +++++++++++++ 5 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 app/api/admin/dingtalk-robot/send/route.ts create mode 100644 lib/notifications/dingtalk-robot-config.ts create mode 100644 lib/notifications/dingtalk-robot.ts diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 4eff9106d..b5a1dc9ed 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -87,6 +87,12 @@ import { normalizeCanvasImageTaskQueueConfig, type CanvasImageTaskQueueConfig, } from "@/lib/canvas/image-task-config"; +import { + DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY, + defaultDingTalkRobotNotificationConfig, + normalizeDingTalkRobotNotificationConfig, + type DingTalkRobotNotificationConfig, +} from "@/lib/notifications/dingtalk-robot-config"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Separator } from "@/components/ui/separator"; import { HomeAnimationEmbed } from "@/components/home/home-animation-embed"; @@ -1513,6 +1519,13 @@ export function AdminSettingsClient() { `/api/admin/settings?key=${WORLDLABS_KEY_POOL_CONFIG_KEY}`, fetcher ); + const { + data: dingTalkRobotNotificationConfigData, + mutate: mutateDingTalkRobotNotificationConfig, + } = useSWR<{ setting?: { value?: unknown } }>( + `/api/admin/settings?key=${DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY}`, + fetcher + ); const { data: threeDModelAliasConfigData, mutate: mutateThreeDModelAliasConfig, @@ -2830,6 +2843,14 @@ export function AdminSettingsClient() { title: "", content: "", }); + const [dingTalkRobotNotificationConfig, setDingTalkRobotNotificationConfig] = + useState( + defaultDingTalkRobotNotificationConfig, + ); + const [isSavingDingTalkRobotNotificationConfig, setIsSavingDingTalkRobotNotificationConfig] = + useState(false); + const [isSendingDingTalkNotification, setIsSendingDingTalkNotification] = + useState(false); useEffect(() => { fetch("/api/admin/settings?key=login_notification") @@ -2841,6 +2862,13 @@ export function AdminSettingsClient() { }); }, []); + useEffect(() => { + const value = dingTalkRobotNotificationConfigData?.setting?.value; + setDingTalkRobotNotificationConfig( + normalizeDingTalkRobotNotificationConfig(value), + ); + }, [dingTalkRobotNotificationConfigData]); + const handleSaveNotification = async () => { try { await fetch("/api/admin/settings", { @@ -2857,6 +2885,68 @@ export function AdminSettingsClient() { } }; + const handleSaveDingTalkRobotNotificationConfig = async () => { + setIsSavingDingTalkRobotNotificationConfig(true); + try { + const response = await fetch("/api/admin/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key: DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY, + value: dingTalkRobotNotificationConfig, + }), + }); + + if (!response.ok) { + throw new Error("保存失败"); + } + + toast.success("钉钉群机器人配置已保存"); + mutateDingTalkRobotNotificationConfig(); + } catch (error) { + toast.error("保存钉钉群机器人配置失败"); + } finally { + setIsSavingDingTalkRobotNotificationConfig(false); + } + }; + + const handleSendNotificationToDingTalk = async () => { + const title = String(notification.title || "").trim(); + const content = String(notification.content || "").trim(); + + if (!title) { + toast.error("请先填写系统通知标题"); + return; + } + + if (!content) { + toast.error("请先填写系统通知内容"); + return; + } + + setIsSendingDingTalkNotification(true); + try { + const response = await fetch("/api/admin/dingtalk-robot/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, content }), + }); + const payload = await response.json().catch(() => null); + + if (!response.ok || payload?.success === false) { + throw new Error(payload?.error || "发送失败"); + } + + toast.success("当前系统通知已发送到钉钉群"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "发送钉钉群通知失败", + ); + } finally { + setIsSendingDingTalkNotification(false); + } + }; + useEffect(() => { if (questionsData) { setQuestions(questionsData); @@ -13421,7 +13511,7 @@ export function AdminSettingsClient() { {/* 系统通知设置 */} - + 系统通知 @@ -13471,6 +13561,91 @@ export function AdminSettingsClient() { + + + + 钉钉群机器人通知 + + 配置钉钉自定义机器人后,可将当前系统通知一键发送到钉钉群 + + + +
+ + setDingTalkRobotNotificationConfig((current) => ({ + ...current, + enabled: checked, + })) + } + /> + +
+
+ + + setDingTalkRobotNotificationConfig((current) => ({ + ...current, + webhookUrl: e.target.value, + })) + } + placeholder="https://oapi.dingtalk.com/robot/send?access_token=..." + /> +
+
+ + + setDingTalkRobotNotificationConfig((current) => ({ + ...current, + secret: e.target.value, + })) + } + placeholder="如机器人启用了安全加签,请填写" + /> +
+

+ 建议在钉钉群中创建“自定义机器人”,将 Webhook 与加签密钥配置到这里。发送时会复用当前系统通知的标题与正文。 +

+
+ + +
+
+
{/* AI 模型配置 */} diff --git a/app/api/admin/dingtalk-robot/send/route.ts b/app/api/admin/dingtalk-robot/send/route.ts new file mode 100644 index 000000000..ecfb15d40 --- /dev/null +++ b/app/api/admin/dingtalk-robot/send/route.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import { getSystemSetting } from "@/lib/db/queries"; +import { ChatSDKError } from "@/lib/errors"; +import { requireAdmin } from "@/lib/middleware/admin"; +import { + DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY, + defaultDingTalkRobotNotificationConfig, + normalizeDingTalkRobotNotificationConfig, +} from "@/lib/notifications/dingtalk-robot-config"; +import { sendDingTalkRobotNotification } from "@/lib/notifications/dingtalk-robot"; + +const bodySchema = z.object({ + title: z.string().trim().min(1, "通知标题不能为空").max(100, "通知标题过长"), + content: z + .string() + .trim() + .min(1, "通知内容不能为空") + .max(5000, "通知内容过长"), +}); + +export async function POST(request: Request) { + try { + await requireAdmin(); + + const body = bodySchema.parse(await request.json()); + const setting = await getSystemSetting({ + key: DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY, + }).catch(() => null); + + const config = setting?.value + ? normalizeDingTalkRobotNotificationConfig(setting.value) + : defaultDingTalkRobotNotificationConfig; + + const result = await sendDingTalkRobotNotification({ + config, + title: body.title, + content: body.content, + }); + + return Response.json({ + success: true, + result, + }); + } catch (error) { + if (error instanceof ChatSDKError) { + return error.toResponse(); + } + + if (error instanceof z.ZodError) { + return Response.json( + { + success: false, + error: error.issues[0]?.message || "参数校验失败", + }, + { status: 400 }, + ); + } + + return Response.json( + { + success: false, + error: error instanceof Error ? error.message : "发送钉钉通知失败", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index 4ed475670..c7c81bb65 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -51,6 +51,10 @@ import { CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY, normalizeCanvasImageTaskQueueConfig, } from "@/lib/canvas/image-task-config"; +import { + DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY, + normalizeDingTalkRobotNotificationConfig, +} from "@/lib/notifications/dingtalk-robot-config"; const settingSchema = z.object({ key: z.string(), @@ -98,6 +102,12 @@ export async function GET(request: Request) { setting: setting ? { ...setting, value } : { key, value }, }); } + if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) { + const value = normalizeDingTalkRobotNotificationConfig(setting?.value); + return Response.json({ + setting: setting ? { ...setting, value } : { key, value }, + }); + } return Response.json({ setting }); } @@ -232,6 +242,10 @@ export async function POST(request: Request) { nextValue = normalizeCanvasImageTaskQueueConfig(value); } + if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) { + nextValue = normalizeDingTalkRobotNotificationConfig(value); + } + if (key === THIRD_PARTY_SQLSERVER_CONFIG_KEY) { const existingSetting = await getSystemSetting({ key: THIRD_PARTY_SQLSERVER_CONFIG_KEY, diff --git a/lib/notifications/dingtalk-robot-config.ts b/lib/notifications/dingtalk-robot-config.ts new file mode 100644 index 000000000..552003bd0 --- /dev/null +++ b/lib/notifications/dingtalk-robot-config.ts @@ -0,0 +1,37 @@ +export const DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY = + "dingtalk_robot_notification_config"; + +export type DingTalkRobotNotificationConfig = { + enabled: boolean; + webhookUrl: string; + secret: string; +}; + +export const defaultDingTalkRobotNotificationConfig: DingTalkRobotNotificationConfig = + { + enabled: false, + webhookUrl: "", + secret: "", + }; + +function normalizeRobotString(value: unknown) { + return String(value || "") + .replace(/[\u2028\u2029]/g, "") + .replace(/[\u0000-\u001F\u007F]/g, "") + .trim(); +} + +export function normalizeDingTalkRobotNotificationConfig( + value: unknown, +): DingTalkRobotNotificationConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return defaultDingTalkRobotNotificationConfig; + } + + const raw = value as Partial; + return { + enabled: raw.enabled === true, + webhookUrl: normalizeRobotString(raw.webhookUrl), + secret: normalizeRobotString(raw.secret), + }; +} diff --git a/lib/notifications/dingtalk-robot.ts b/lib/notifications/dingtalk-robot.ts new file mode 100644 index 000000000..9fd6947dc --- /dev/null +++ b/lib/notifications/dingtalk-robot.ts @@ -0,0 +1,134 @@ +import "server-only"; + +import { createHmac } from "node:crypto"; +import { + defaultDingTalkRobotNotificationConfig, + normalizeDingTalkRobotNotificationConfig, + type DingTalkRobotNotificationConfig, +} from "./dingtalk-robot-config"; + +function buildSignedWebhookUrl(webhookUrl: string, secret: string) { + const normalizedUrl = String(webhookUrl || "").trim(); + const normalizedSecret = String(secret || "").trim(); + + if (!normalizedSecret) { + return normalizedUrl; + } + + const timestamp = Date.now().toString(); + const stringToSign = `${timestamp}\n${normalizedSecret}`; + const sign = createHmac("sha256", normalizedSecret) + .update(stringToSign, "utf8") + .digest("base64"); + + const url = new URL(normalizedUrl); + url.searchParams.set("timestamp", timestamp); + url.searchParams.set("sign", sign); + return url.toString(); +} + +function buildMarkdownText(params: { title: string; content: string }) { + const normalizedTitle = String(params.title || "").trim(); + const normalizedContent = String(params.content || "").trim(); + const sentAt = new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(new Date()); + + const body = normalizedContent + .split(/\r?\n/) + .map((line) => line.trim()) + .join("\n\n"); + + return [`### ${normalizedTitle}`, "", body, "", `> 发送时间:${sentAt}`].join( + "\n", + ); +} + +export async function sendDingTalkRobotNotification(params: { + config: DingTalkRobotNotificationConfig; + title: string; + content: string; +}) { + const config = normalizeDingTalkRobotNotificationConfig(params.config); + + if (!config.enabled) { + throw new Error("钉钉群机器人通知未启用"); + } + + if (!config.webhookUrl) { + throw new Error("钉钉群机器人 Webhook 未配置"); + } + + const title = String(params.title || "").trim(); + const content = String(params.content || "").trim(); + + if (!title) { + throw new Error("通知标题不能为空"); + } + + if (!content) { + throw new Error("通知内容不能为空"); + } + + const requestUrl = buildSignedWebhookUrl(config.webhookUrl, config.secret); + const response = await fetch(requestUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + msgtype: "markdown", + markdown: { + title, + text: buildMarkdownText({ title, content }), + }, + at: { + isAtAll: false, + }, + }), + signal: AbortSignal.timeout(15_000), + }); + + const responseText = await response.text(); + let responseData: any = null; + + try { + responseData = responseText ? JSON.parse(responseText) : null; + } catch { + responseData = responseText; + } + + if (!response.ok) { + throw new Error( + `钉钉群机器人请求失败:${response.status} ${response.statusText}`, + ); + } + + if ( + responseData && + typeof responseData === "object" && + "errcode" in responseData && + responseData.errcode !== 0 + ) { + throw new Error( + String(responseData.errmsg || "钉钉群机器人返回失败"), + ); + } + + return { + ok: true, + response: responseData, + configUsed: { + ...defaultDingTalkRobotNotificationConfig, + ...config, + secret: config.secret ? "***" : "", + }, + }; +}