From fa689aedbcda120e04f30599d2cc120f037fdd19 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 16 Aug 2026 11:06:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(sync):=20=E5=90=8C=E6=AD=A5=20Wavelet=20?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E5=AE=A1=E8=AE=A1=E3=80=81OTel=20schema=20?= =?UTF-8?q?=E4=B8=8E=E5=89=8D=E7=AB=AF=E5=AD=97=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自定义 Webhook 在 HTTP 200 但业务 errcode 非零时记为失败,任务日志记录上游响应。 OTel Resource 改为 NewSchemaless,避免 semconv 与 SDK 版本冲突。 前端用 next/font 自托管 Inter,并忽略浏览器扩展改写 body 引起的 hydration 警告。 --- docs/changelog/index.md | 9 +++ frontend/app/globals.css | 2 +- frontend/app/layout.tsx | 14 ++++- internal/apps/admin/push/push_test.go | 4 +- internal/apps/admin/push/routers.go | 2 +- internal/apps/admin/push/tasks.go | 8 ++- pkg/push/custom.go | 33 +++++++--- pkg/push/custom_test.go | 91 +++++++++++++++++++++++++++ pkg/push/email.go | 12 ++-- pkg/push/lark.go | 20 +++--- pkg/push/push.go | 3 +- pkg/push/telegram.go | 10 +-- pkg/push/telegram_test.go | 4 +- pkg/trace/trace_provider.go | 17 +++-- 14 files changed, 181 insertions(+), 48 deletions(-) create mode 100644 pkg/push/custom_test.go diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 713e6eb2..0b6b9960 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -8,6 +8,15 @@ sidebar: false 格式基于 [Keep a Changelog](http://keepachangelog.com/),版本号遵循 [语义化版本](http://semver.org/)。 +## [Unreleased] + +### 🛠 修复 +- 修复自定义 Webhook 推送在企业微信/钉钉返回 HTTP 200 但 `errcode` 非零时仍记为成功的问题;任务日志会记录上游响应体。 +- 修复 OpenTelemetry Resource 绑定 semconv schema 版本导致 SDK 升级后可能无法启动的问题。 + +### 💄 其他/体验 +- 前端使用 `next/font` 自托管 Inter,并忽略浏览器扩展改写 `body` 属性引起的 hydration 警告。 + ## 重大变更 > [!IMPORTANT] diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 5521bd24..5e12cdc2 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -7,7 +7,7 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: "Inter", "PingFang SC", "Microsoft YaHei", sans-serif; + --font-sans: var(--font-inter), "PingFang SC", "Microsoft YaHei", sans-serif; --font-mono: var(--font-geist-mono), "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "PingFang SC", "Microsoft YaHei", monospace; diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index a9a9b9a4..2011b399 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; import { Toaster } from '@/components/ui/sonner'; import { ThemeProvider } from '@/components/layout/theme-provider'; import { CustomThemeProvider } from '@/lib/theme'; @@ -10,6 +11,12 @@ import { SiteTitleUpdater } from '@/components/providers/title-updater'; import { RobotsMeta } from '@/components/layout/robots-meta'; import './globals.css'; +const inter = Inter({ + subsets: ['latin'], + variable: '--font-inter', + display: 'swap', +}); + export const metadata: Metadata = { title: 'OpenFlare', description: 'OpenFlare 边缘节点与反向代理管理平台', @@ -23,10 +30,13 @@ export default function RootLayout({ return ( - + = 300 { - return fmt.Errorf("custom: http status %s", resp.Status) + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, maxCustomResponseBytes)) + upstreamResp := strings.TrimSpace(string(bodyBytes)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { //nolint:mnd + return upstreamResp, fmt.Errorf("custom: http status %s", resp.Status) } - return nil + // 部分 Webhook(如企业微信、钉钉)即使业务失败也返回 HTTP 200, + // 仅当响应体包含非零 errcode 时才判定为发送失败,避免审计记录误报成功。 + var apiResp struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.Unmarshal(bodyBytes, &apiResp); err == nil && apiResp.ErrCode != 0 { + return upstreamResp, fmt.Errorf("custom: webhook rejected: errcode=%d errmsg=%q", apiResp.ErrCode, apiResp.ErrMsg) + } + + return upstreamResp, nil } // ValidateConfig 校验自定义配置 diff --git a/pkg/push/custom_test.go b/pkg/push/custom_test.go new file mode 100644 index 00000000..fb622e65 --- /dev/null +++ b/pkg/push/custom_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCustomPusherSend_ResponseBodyErrcode(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + wantErr bool + wantErrMsg string + }{ + { + name: "wechat business error returns HTTP 200 with non-zero errcode", + statusCode: http.StatusOK, + body: `{"errcode":93000,"errmsg":"invalid request data"}`, + wantErr: true, + wantErrMsg: "errcode=93000", + }, + { + name: "wechat success returns errcode 0", + statusCode: http.StatusOK, + body: `{"errcode":0,"errmsg":"ok"}`, + wantErr: false, + }, + { + name: "json response without errcode is tolerated", + statusCode: http.StatusOK, + body: `{"success":true}`, + wantErr: false, + }, + { + name: "non-json response body is tolerated", + statusCode: http.StatusOK, + body: "ok", + wantErr: false, + }, + { + name: "empty response body is tolerated", + statusCode: http.StatusNoContent, + body: "", + wantErr: false, + }, + { + name: "http error status still fails", + statusCode: http.StatusInternalServerError, + body: `{"errcode":0,"errmsg":"ok"}`, + wantErr: true, + wantErrMsg: "http status", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.statusCode) + _, _ = w.Write([]byte(tt.body)) + })) + defer srv.Close() + + pusher := &CustomPusher{} + upstreamResp, err := pusher.Send(context.Background(), + Config{Channel: "custom", URL: srv.URL}, + "", + map[string]any{"title": "t", "content": "c"}, + `{"title":"$title","content":"$content"}`, + nil, + ) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrMsg) + return + } + assert.NoError(t, err) + if tt.body != "" { + assert.Contains(t, upstreamResp, tt.body) + } + }) + } +} diff --git a/pkg/push/email.go b/pkg/push/email.go index 62c748a5..50635aa0 100644 --- a/pkg/push/email.go +++ b/pkg/push/email.go @@ -28,12 +28,12 @@ func sanitizeEmailHeader(v string) string { } // Send 发送邮件 -func (p *EmailPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, ext map[string]any) error { +func (p *EmailPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, ext map[string]any) (string, error) { if cfg.URL == "" || cfg.Key == "" || cfg.Secret == "" { - return errors.New("email: SMTP configuration (url, key, secret) is incomplete") + return "", errors.New("email: SMTP configuration (url, key, secret) is incomplete") } if target == "" { - return errors.New("email: target email address is required") + return "", errors.New("email: target email address is required") } title := defaultTitle @@ -92,14 +92,14 @@ func (p *EmailPusher) Send(ctx context.Context, cfg Config, target string, body select { case <-ctx.Done(): - return ctx.Err() + return "", ctx.Err() case err := <-errChan: if err != nil { - return fmt.Errorf("email: send smtp mail failed: %w", err) + return "", fmt.Errorf("email: send smtp mail failed: %w", err) } } - return nil + return "", nil } // ValidateConfig 校验邮件 SMTP 配置 diff --git a/pkg/push/lark.go b/pkg/push/lark.go index a29ea7aa..e97a8b1a 100644 --- a/pkg/push/lark.go +++ b/pkg/push/lark.go @@ -76,9 +76,9 @@ type larkMessageResponse struct { // Send 执行飞书消息发送 // //nolint:nestif,cyclop -func (p *LarkPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, template string, _ map[string]any) error { +func (p *LarkPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, template string, _ map[string]any) (string, error) { if cfg.URL == "" { - return errors.New("lark: URL is required") + return "", errors.New("lark: URL is required") } var req larkMessageRequest @@ -212,7 +212,7 @@ func (p *LarkPusher) Send(ctx context.Context, cfg Config, _ string, body map[st timestamp := time.Now().Unix() sign, err := larkSign(cfg.Secret, timestamp) if err != nil { - return fmt.Errorf("lark: sign failed: %w", err) + return "", fmt.Errorf("lark: sign failed: %w", err) } req.Timestamp = strconv.FormatInt(timestamp, 10) req.Sign = sign @@ -220,37 +220,37 @@ func (p *LarkPusher) Send(ctx context.Context, cfg Config, _ string, body map[st jsonData, err := json.Marshal(req) if err != nil { - return fmt.Errorf("lark: marshal request failed: %w", err) + return "", fmt.Errorf("lark: marshal request failed: %w", err) } // 4. 发送 POST 请求 httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewBuffer(jsonData)) if err != nil { - return fmt.Errorf("lark: create http request failed: %w", err) + return "", fmt.Errorf("lark: create http request failed: %w", err) } httpReq.Header.Set("Content-Type", "application/json") client := httppool.NewClient(defaultHTTPClientTimeout) resp, err := client.Do(httpReq) if err != nil { - return fmt.Errorf("lark: http request failed: %w", err) + return "", fmt.Errorf("lark: http request failed: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("lark: http status %s", resp.Status) + return "", fmt.Errorf("lark: http status %s", resp.Status) } var res larkMessageResponse if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - return fmt.Errorf("lark: decode response failed: %w", err) + return "", fmt.Errorf("lark: decode response failed: %w", err) } if res.Code != 0 { - return fmt.Errorf("lark: send message failed, code %d: %s", res.Code, res.Msg) + return "", fmt.Errorf("lark: send message failed, code %d: %s", res.Code, res.Msg) } - return nil + return "", nil } // ValidateConfig 校验飞书配置 diff --git a/pkg/push/push.go b/pkg/push/push.go index cacdbba7..e035d62a 100644 --- a/pkg/push/push.go +++ b/pkg/push/push.go @@ -33,7 +33,8 @@ type Pusher interface { // body: 消息体数据 (含默认字段如 title, content, level) // template: 消息卡片/模板 JSON (可选) // ext: 预留的单次发送拓展数据 - Send(ctx context.Context, cfg Config, target string, body map[string]any, template string, ext map[string]any) error + // 返回 upstreamResp: 上游服务返回的响应内容(如 Webhook 响应体),用于任务日志审计;无响应时为空字符串 + Send(ctx context.Context, cfg Config, target string, body map[string]any, template string, ext map[string]any) (upstreamResp string, err error) // ValidateConfig 校验渠道配置合法性 ValidateConfig(cfg Config) error diff --git a/pkg/push/telegram.go b/pkg/push/telegram.go index a3956c3f..54f49e76 100644 --- a/pkg/push/telegram.go +++ b/pkg/push/telegram.go @@ -37,9 +37,9 @@ type telegramErrorResponse struct { // Send 执行 Telegram 消息发送 // //nolint:cyclop -func (p *TelegramPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, template string, _ map[string]any) error { +func (p *TelegramPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, template string, _ map[string]any) (string, error) { if cfg.Secret == "" { - return errors.New("telegram: Bot Token (Secret) is required") + return "", errors.New("telegram: Bot Token (Secret) is required") } chatID := target @@ -47,7 +47,7 @@ func (p *TelegramPusher) Send(ctx context.Context, cfg Config, target string, bo chatID = cfg.Key // Use default chat ID (Key) if target is blank } if chatID == "" { - return errors.New("telegram: chat_id (target or default Key) is required") + return "", errors.New("telegram: chat_id (target or default Key) is required") } baseURL := cfg.URL @@ -92,11 +92,11 @@ func (p *TelegramPusher) Send(ctx context.Context, cfg Config, target string, bo } fallbackErr := p.sendMessage(ctx, baseURL, cfg.Secret, chatID, plainText, "") if fallbackErr != nil { - return fmt.Errorf("telegram: send message failed (fallback also failed): %w (original HTML error: %v)", fallbackErr, err) + return "", fmt.Errorf("telegram: send message failed (fallback also failed): %w (original HTML error: %v)", fallbackErr, err) } } - return nil + return "", nil } // ValidateConfig 校验 Telegram 配置 diff --git a/pkg/push/telegram_test.go b/pkg/push/telegram_test.go index f018cadd..74fcf728 100644 --- a/pkg/push/telegram_test.go +++ b/pkg/push/telegram_test.go @@ -41,7 +41,7 @@ func TestTelegramPusher_Send(t *testing.T) { "content": "Host down", "level": "CRITICAL", } - err := pusher.Send(context.Background(), cfg, "123456", body, "", nil) + _, err := pusher.Send(context.Background(), cfg, "123456", body, "", nil) require.NoError(t, err) assert.Equal(t, "123456", receivedReq.ChatID) @@ -79,7 +79,7 @@ func TestTelegramPusher_Send(t *testing.T) { "content": "A < B comparison", "level": "INFO", } - err := pusher.Send(context.Background(), cfg, "123456", body, "", nil) + _, err := pusher.Send(context.Background(), cfg, "123456", body, "", nil) require.NoError(t, err) require.Len(t, requests, 2) diff --git a/pkg/trace/trace_provider.go b/pkg/trace/trace_provider.go index 558ad23a..8051f2b8 100644 --- a/pkg/trace/trace_provider.go +++ b/pkg/trace/trace_provider.go @@ -8,10 +8,10 @@ import ( "context" "os" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" - semconv "go.opentelemetry.io/otel/semconv/v1.43.0" ) func newTracerProvider(cfg Config) (*sdktrace.TracerProvider, error) { @@ -21,16 +21,15 @@ func newTracerProvider(cfg Config) (*sdktrace.TracerProvider, error) { return nil, err } - // 初始化 Resource + // 业务属性不绑定 schema URL,合并时继承 resource.Default() 的 SDK 内置版本,避免 semconv 与 otel/sdk 升级不同步。 r, err := resource.Merge( resource.Default(), - resource.NewWithAttributes( - semconv.SchemaURL, - semconv.ServiceName(cfg.AppName), - semconv.HostName(hostname), - semconv.K8SNamespaceName(os.Getenv("KUBERNETES_NAMESPACE")), - semconv.K8SPodName(os.Getenv("KUBERNETES_POD_NAME")), - semconv.K8SPodUID(os.Getenv("KUBERNETES_POD_UID")), + resource.NewSchemaless( + attribute.String("service.name", cfg.AppName), + attribute.String("host.name", hostname), + attribute.String("k8s.namespace.name", os.Getenv("KUBERNETES_NAMESPACE")), + attribute.String("k8s.pod.name", os.Getenv("KUBERNETES_POD_NAME")), + attribute.String("k8s.pod.uid", os.Getenv("KUBERNETES_POD_UID")), ), ) if err != nil {