diff --git a/docs/superpowers/plans/2026-08-11-wecom-notification.md b/docs/superpowers/plans/2026-08-11-wecom-notification.md index 414bc99..411f7e6 100644 --- a/docs/superpowers/plans/2026-08-11-wecom-notification.md +++ b/docs/superpowers/plans/2026-08-11-wecom-notification.md @@ -362,19 +362,19 @@ const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{ 预期:所有修改的 Go 文件采用项目标准格式。 -- [ ] **步骤 2:运行后端回归测试** - -运行:`go test ./internal/server ./internal/store` - -预期:所有目标包通过,无失败测试。 - -- [ ] **步骤 3:重新运行前端生产构建** +- [ ] **步骤 2:运行前端生产构建** 运行:`npm run build` 工作目录:`web` -预期:退出码 0。 +预期:退出码 0,并生成 `web/dist` 供 Go 的嵌入资源使用。 + +- [ ] **步骤 3:运行后端回归测试** + +运行:`go test ./...` + +预期:所有目标包通过,无失败测试;`cmd/vocat` 和 `web` 包从步骤 2 生成的 `web/dist` 读取嵌入资源。 - [ ] **步骤 4:检查最终变更** diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go index bbd5101..e95b039 100644 --- a/internal/server/settings_api.go +++ b/internal/server/settings_api.go @@ -517,9 +517,7 @@ func (s *Server) resolveNotificationTestConfig( } for key, value := range overlay { if _, secret := sensitive[key]; secret { - if text, ok := value.(string); !ok || text == "" || text == store.SecretMask { - continue - } + value = mergeNotificationTestSecretValue(value, resolved[key]) } resolved[key] = value } @@ -545,6 +543,37 @@ func (s *Server) resolveNotificationTestConfig( return resolved, provider, nil } +// mergeNotificationTestSecretValue preserves masked values submitted by the +// settings form while allowing newly entered sensitive values in the same +// request. WeCom URLs are a sensitive list, unlike the string-based secrets +// used by the other notification channels. +func mergeNotificationTestSecretValue(incoming, existing any) any { + if incoming == nil { + return existing + } + switch next := incoming.(type) { + case string: + if next == "" || next == store.SecretMask { + return existing + } + case []any: + previous, ok := existing.([]any) + if !ok { + return incoming + } + merged := make([]any, len(next)) + for index, value := range next { + if index < len(previous) { + merged[index] = mergeNotificationTestSecretValue(value, previous[index]) + } else { + merged[index] = value + } + } + return merged + } + return incoming +} + func validateNotificationTestConfig(channel string, config map[string]any) error { switch channel { case "webhook": diff --git a/internal/server/settings_api_test.go b/internal/server/settings_api_test.go index 4c02eb0..6fc2fcc 100644 --- a/internal/server/settings_api_test.go +++ b/internal/server/settings_api_test.go @@ -179,6 +179,59 @@ func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) { } } +func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) { + test := newSettingsAPITest(t) + raw, err := json.Marshal(map[string]any{ + "urls": []string{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved"}, + "payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`, + }) + if err != nil { + t.Fatal(err) + } + resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "wecom", raw) + if err != nil { + t.Fatal(err) + } + urls, ok := resolved["urls"].([]any) + if !ok || len(urls) != 1 || urls[0] != "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved" { + t.Fatalf("resolved URLs = %#v", resolved["urls"]) + } +} + +func TestResolveWecomNotificationTestConfigMergesMaskedAndUnsavedWebhookURLs(t *testing.T) { + test := newSettingsAPITest(t) + storedURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=stored" + unsavedURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved" + storedConfig, err := json.Marshal(map[string]any{ + "urls": []string{storedURL}, + "payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`, + }) + if err != nil { + t.Fatal(err) + } + if err := test.database.UpsertNotificationSetting(context.Background(), store.NotificationSetting{ + Channel: "wecom", + Config: storedConfig, + }); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(map[string]any{ + "urls": []string{store.SecretMask, unsavedURL}, + "payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`, + }) + if err != nil { + t.Fatal(err) + } + resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "wecom", raw) + if err != nil { + t.Fatal(err) + } + urls, ok := resolved["urls"].([]any) + if !ok || len(urls) != 2 || urls[0] != storedURL || urls[1] != unsavedURL { + t.Fatalf("resolved URLs = %#v", resolved["urls"]) + } +} + func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) { test := newSettingsAPITest(t) cases := []struct { diff --git a/web/src/components/settings/PushTabs.tsx b/web/src/components/settings/PushTabs.tsx index 0a1dae7..760b290 100644 --- a/web/src/components/settings/PushTabs.tsx +++ b/web/src/components/settings/PushTabs.tsx @@ -269,13 +269,13 @@ export function WebhookTab({ value, onChange, testing, onTest }: PushChannelProp } export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps) { - const { t } = useI18n(); + const { t, lang } = useI18n(); const off = !value.enabled; const complete = hasAnyUrl(value.urls) && !!value.payloadTemplate.trim(); return (
onChange({ enabled })} actions={ @@ -294,13 +294,13 @@ export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps< onChange={(urls) => onChange({ urls })} enabled={value.enabled} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." - emptyText={t("尚未配置任何企业微信 Webhook URL,点击右侧添加按钮。")} + emptyText={t("尚未配置任何企业微信消息推送 Webhook URL,点击右侧添加按钮。")} /> - {t("支持完整企业微信消息 JSON。变量必须作为 JSON 值使用,例如")} {"{{message}}"}。 + {t("支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如")} {"{{message}}"}{lang === "zh" ? "。" : "."} {t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")} } diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index 3d593bd..ed5c967 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -229,6 +229,7 @@ export const EN_DICT: Record = { "Webhook 测试失败": "Webhook test failed", "Bark 测试失败": "Bark test failed", "Email 测试失败": "Email test failed", + "企业微信消息推送测试失败": "WeCom message push test failed", // ---- 设置页:安全卡 ---- 安全: "Security", @@ -326,10 +327,20 @@ export const EN_DICT: Record = { "启用 Bark 推送": "Enable Bark", "启用 Email 推送": "Enable Email", "启用 Webhook 推送": "Enable Webhook", + "企业微信消息推送": "WeCom Message Push", + "启用企业微信消息推送": "Enable WeCom Message Push", + "Telegram / Bark / Email / Pushplus / Webhook / 企业微信消息推送": "Telegram / Bark / Email / Pushplus / Webhook / WeCom Message Push", "目标 URLs": "Target URLs", "添加 URL": "Add URL", "尚未配置任何 Bark URL,点击右侧添加按钮。": "No Bark URLs yet. Click the add button on the right.", "尚未配置任何 Webhook URL,点击右侧添加按钮。": "No Webhook URLs yet. Click the add button on the right.", + "每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。": + "Enter one WeCom message push Webhook URL per line. Use Add URL to add another row; do not separate URLs with commas, spaces, or line breaks.", + "尚未配置任何企业微信消息推送 Webhook URL,点击右侧添加按钮。": "No WeCom message push Webhook URLs yet. Click the add button on the right.", + "JSON 请求体模板": "JSON Request Body Template", + "支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如": "Supports a complete WeCom message push JSON payload. Use variables as JSON values, for example", + "可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。": + "Available variables: {{event}}, {{title}}, {{message}}, {{timestamp}}, {{content}}, {{number}}, {{device_id}}, {{device_name}}, {{device_label}}, {{time}}.", "分组 (Group)": "Group", "例如 vocat": "e.g. vocat", "iOS 设备上的通知分组。": "Notification group on iOS devices.", diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index e4f66d0..94ae6bb 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -35,7 +35,7 @@ const NOTIFY_TABS = [ { key: "email", label: "Email" }, { key: "pushplus", label: "Pushplus" }, { key: "webhook", label: "Webhook" }, - { key: "wecom", label: "企业微信" }, + { key: "wecom", label: "企业微信消息推送" }, ]; const EMPTY_SYSTEM_INFO: SystemInfo = { version: "", buildTime: "", config: "" }; @@ -332,7 +332,7 @@ export default function SettingsPage() { }); message.success(t("测试通知已发送")); } catch (error) { - message.error(apiMessage(error) || t("企业微信测试失败")); + message.error(apiMessage(error) || t("企业微信消息推送测试失败")); } finally { setTestingWecom(false); } @@ -466,7 +466,7 @@ export default function SettingsPage() { - +