feat: add dingtalk robot update notifications
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-01 01:45:59 +08:00
parent 94a22f0cc6
commit 38c10d616b
5 changed files with 428 additions and 1 deletions

View File

@ -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<DingTalkRobotNotificationConfig>(
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() {
</TabsContent>
{/* 系统通知设置 */}
<TabsContent value="notification" className="mt-6">
<TabsContent value="notification" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-xl">系统通知</CardTitle>
@ -13471,6 +13561,91 @@ export function AdminSettingsClient() {
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl">钉钉群机器人通知</CardTitle>
<CardDescription>
配置钉钉自定义机器人后,可将当前系统通知一键发送到钉钉群
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center gap-2">
<Switch
id="dingtalk-robot-enabled"
checked={dingTalkRobotNotificationConfig.enabled}
onCheckedChange={(checked) =>
setDingTalkRobotNotificationConfig((current) => ({
...current,
enabled: checked,
}))
}
/>
<Label htmlFor="dingtalk-robot-enabled">
启用钉钉群机器人通知
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="dingtalk-robot-webhook">Webhook 地址</Label>
<Input
id="dingtalk-robot-webhook"
value={dingTalkRobotNotificationConfig.webhookUrl}
onChange={(e) =>
setDingTalkRobotNotificationConfig((current) => ({
...current,
webhookUrl: e.target.value,
}))
}
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="dingtalk-robot-secret">
加签密钥(可选)
</Label>
<Input
id="dingtalk-robot-secret"
type="password"
value={dingTalkRobotNotificationConfig.secret}
onChange={(e) =>
setDingTalkRobotNotificationConfig((current) => ({
...current,
secret: e.target.value,
}))
}
placeholder="如机器人启用了安全加签,请填写"
/>
</div>
<p className="text-xs text-muted-foreground">
建议在钉钉群中创建“自定义机器人”,将 Webhook 与加签密钥配置到这里。发送时会复用当前系统通知的标题与正文。
</p>
<div className="flex flex-wrap gap-3">
<Button
onClick={handleSaveDingTalkRobotNotificationConfig}
disabled={isSavingDingTalkRobotNotificationConfig}
>
{isSavingDingTalkRobotNotificationConfig ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
保存机器人配置
</Button>
<Button
variant="outline"
onClick={handleSendNotificationToDingTalk}
disabled={isSendingDingTalkNotification}
>
{isSendingDingTalkNotification ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Bell className="mr-2 h-4 w-4" />
)}
发送当前系统通知到钉钉群
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
{/* AI 模型配置 */}

View File

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

View File

@ -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,

View File

@ -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<DingTalkRobotNotificationConfig>;
return {
enabled: raw.enabled === true,
webhookUrl: normalizeRobotString(raw.webhookUrl),
secret: normalizeRobotString(raw.secret),
};
}

View File

@ -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 ? "***" : "",
},
};
}