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 {