From e5b4e11f76e6be4f6ee811c8287b8561195e1e58 Mon Sep 17 00:00:00 2001 From: shunli Date: Fri, 13 Mar 2026 21:50:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(P23IM2-4):=20=E9=80=9A=E7=9F=A5=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E8=BF=90=E7=BB=B4=E6=80=81=E5=89=8D=E7=AB=AF(secret?= =?UTF-8?q?=E4=BF=9D=E7=95=99/=E6=9B=BF=E6=8D=A2/=E6=B8=85=E7=A9=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/errorMessages.ts | 4 + client/src/api/notificationChannels.ts | 25 +++- client/src/pages/NotificationChannelsPage.tsx | 140 +++++++++++++++--- 3 files changed, 145 insertions(+), 24 deletions(-) diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index 5f07740b..612f4648 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -235,6 +235,10 @@ export function mapApiErrorMessage( if (details?.integration === "travel_sync_disabled") { return "差旅同步功能未开启。"; } + + if (details?.secret === "field_encryption_key_missing") { + return "当前环境未配置 FIELD_ENCRYPTION_KEY,无法保存或更新 Webhook Secret。"; + } } return fallbackMessage || "请求失败,请稍后重试。"; diff --git a/client/src/api/notificationChannels.ts b/client/src/api/notificationChannels.ts index ceec4b5f..5f2bb511 100644 --- a/client/src/api/notificationChannels.ts +++ b/client/src/api/notificationChannels.ts @@ -20,7 +20,17 @@ export type NotificationChannelRouting = { export type NotificationChannelConfig = { mode: NotificationChannelMode; webhookUrl: string | null; - secret: string | null; + timeoutMs: number; + routing: NotificationChannelRouting; + secretConfigured: boolean; + secretMasked: string | null; +}; + +export type NotificationChannelWriteConfig = { + mode: NotificationChannelMode; + webhookUrl: string | null; + secret?: string | null; + clearSecret?: boolean; timeoutMs: number; routing: NotificationChannelRouting; }; @@ -51,15 +61,24 @@ export type NotificationChannelPayload = { channelType: "wecom"; name: string; enabled: boolean; - configJson: NotificationChannelConfig; + configJson: NotificationChannelWriteConfig; templateJson: NotificationChannelTemplate; }; +export type NotificationChannelUpdatePayload = { + name?: string; + enabled?: boolean; + configJson?: NotificationChannelWriteConfig; + templateJson?: NotificationChannelTemplate; +}; + export type NotificationChannelTestResponse = { channel: NotificationChannel; testResult: { ok: boolean; code: string; + message: string | null; + retryable: boolean | null; previewMessage: string | null; response: unknown; skipped: boolean; @@ -77,7 +96,7 @@ export async function createNotificationChannel(payload: NotificationChannelPayl }); } -export async function updateNotificationChannel(id: string, payload: Omit) { +export async function updateNotificationChannel(id: string, payload: NotificationChannelUpdatePayload) { return apiRequestData<{ channel: NotificationChannel }>(`/api/notifications/channels/${id}`, { method: "PATCH", body: JSON.stringify(payload), diff --git a/client/src/pages/NotificationChannelsPage.tsx b/client/src/pages/NotificationChannelsPage.tsx index e99799a3..562ac730 100644 --- a/client/src/pages/NotificationChannelsPage.tsx +++ b/client/src/pages/NotificationChannelsPage.tsx @@ -11,6 +11,7 @@ import { type NotificationChannelConfig, type NotificationChannelPayload, type NotificationChannelTemplate, + type NotificationChannelWriteConfig, } from "../api/notificationChannels"; import { isApiError } from "../api/types"; import { PageHeader } from "../components/common/PageHeader"; @@ -34,6 +35,9 @@ type ChannelFormState = { enabled: boolean; mode: NotificationChannelConfig["mode"]; webhookUrl: string; + secretConfigured: boolean; + secretMasked: string | null; + secretAction: "keep" | "replace" | "clear"; secret: string; timeoutMs: string; roleCodes: string[]; @@ -50,6 +54,8 @@ type TestResultState = { channelName: string; ok: boolean; code: string; + message: string | null; + retryable: boolean | null; skipped: boolean; previewMessage: string | null; response: unknown; @@ -111,6 +117,9 @@ function buildInitialForm(): ChannelFormState { enabled: true, mode: "mock", webhookUrl: "", + secretConfigured: false, + secretMasked: null, + secretAction: "replace", secret: "", timeoutMs: "5000", roleCodes: [], @@ -130,7 +139,10 @@ function mapChannelToForm(channel: NotificationChannel): ChannelFormState { enabled: channel.enabled, mode: channel.configJson.mode, webhookUrl: channel.configJson.webhookUrl ?? "", - secret: channel.configJson.secret ?? "", + secretConfigured: channel.configJson.secretConfigured, + secretMasked: channel.configJson.secretMasked, + secretAction: channel.configJson.secretConfigured ? "keep" : "replace", + secret: "", timeoutMs: String(channel.configJson.timeoutMs ?? 5000), roleCodes: channel.configJson.routing.roleCodes ?? [], domainScopes: channel.configJson.routing.domainScopes ?? [], @@ -172,21 +184,28 @@ function buildPayload(form: ChannelFormState): NotificationChannelPayload { deepLinkLabel: form.deepLinkLabel.trim() || "查看详情", }; + const configJson: NotificationChannelWriteConfig = { + mode: form.mode, + webhookUrl: form.webhookUrl.trim() || null, + timeoutMs: Math.max(100, Number(form.timeoutMs || 5000)), + routing: { + roleCodes: form.roleCodes, + domainScopes: form.domainScopes, + allowedTypes: form.allowedTypes, + }, + }; + + if (form.secretAction === "replace") { + configJson.secret = form.secret.trim() || null; + } else if (form.secretAction === "clear") { + configJson.clearSecret = true; + } + return { channelType: form.channelType, name: form.name.trim(), enabled: form.enabled, - configJson: { - mode: form.mode, - webhookUrl: form.webhookUrl.trim() || null, - secret: form.secret.trim() || null, - timeoutMs: Math.max(100, Number(form.timeoutMs || 5000)), - routing: { - roleCodes: form.roleCodes, - domainScopes: form.domainScopes, - allowedTypes: form.allowedTypes, - }, - }, + configJson, templateJson, }; } @@ -208,8 +227,12 @@ export function NotificationChannelsPage() { const saveMutation = useMutation({ mutationFn: async () => { + if (editingId && form.secretAction === "replace" && !form.secret.trim()) { + throw new Error("请输入新的 Webhook Secret,或切换为保留现有密钥。"); + } + + const payload = buildPayload(form); if (editingId) { - const payload = buildPayload(form); return updateNotificationChannel(editingId, { name: payload.name, enabled: payload.enabled, @@ -217,7 +240,7 @@ export function NotificationChannelsPage() { templateJson: payload.templateJson, }); } - return createNotificationChannel(buildPayload(form)); + return createNotificationChannel(payload); }, onSuccess: async ({ channel }) => { await queryClient.invalidateQueries({ queryKey: ["notification-channels"] }); @@ -247,6 +270,8 @@ export function NotificationChannelsPage() { channelName: result.channelName, ok: result.testResult.ok, code: result.testResult.code, + message: result.testResult.message, + retryable: result.testResult.retryable, skipped: result.testResult.skipped, previewMessage: result.testResult.previewMessage, response: result.testResult.response, @@ -461,13 +486,78 @@ export function NotificationChannelsPage() {
- - setForm((prev) => ({ ...prev, secret: event.target.value }))} - /> + + {editingId && form.secretConfigured ? ( +
+

+ 当前已配置密钥:{form.secretMasked ?? "******"}。默认保留现有密钥,不会回显明文。 +

+
+ + + +
+
+ ) : ( +

+ {editingId ? "当前未配置密钥,保存时可新增。" : "可选,配置后将参与签名。"} +

+ )} + + {form.secretAction === "replace" ? ( + setForm((prev) => ({ ...prev, secret: event.target.value }))} + /> + ) : null} + + {form.secretAction === "clear" ? ( +

保存后将清空当前 Webhook Secret。

+ ) : null}
@@ -617,8 +707,16 @@ export function NotificationChannelsPage() { {lastTestResult.ok ? "成功" : "失败"} code={lastTestResult.code} + {lastTestResult.retryable !== null ? ( + retryable={lastTestResult.retryable ? "true" : "false"} + ) : null} {lastTestResult.skipped ? (已跳过) : null} + {lastTestResult.message ? ( +

+ {lastTestResult.message} +

+ ) : null}

预览消息