feat(sync): 同步 Wavelet 推送审计、OTel schema 与前端字体

自定义 Webhook 在 HTTP 200 但业务 errcode 非零时记为失败,任务日志记录上游响应。
OTel Resource 改为 NewSchemaless,避免 semconv 与 SDK 版本冲突。
前端用 next/font 自托管 Inter,并忽略浏览器扩展改写 body 引起的 hydration 警告。
This commit is contained in:
ryan
2026-08-16 11:06:54 +08:00
parent f960511cc0
commit fa689aedbc
14 changed files with 181 additions and 48 deletions
+9
View File
@@ -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]
+1 -1
View File
@@ -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;
+12 -2
View File
@@ -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 (
<html
lang='zh-CN'
className='hide-scrollbar font-sans'
className={`hide-scrollbar font-sans ${inter.variable}`}
suppressHydrationWarning
>
<body className='hide-scrollbar font-sans antialiased'>
<body
className='hide-scrollbar font-sans antialiased'
suppressHydrationWarning
>
<ThemeProvider
attribute='class'
defaultTheme='system'
+2 -2
View File
@@ -52,12 +52,12 @@ type mockPusher struct {
sentTgt string
}
func (m *mockPusher) Send(ctx context.Context, cfg pkgpush.Config, target string, body map[string]any, template string, ext map[string]any) error {
func (m *mockPusher) Send(ctx context.Context, cfg pkgpush.Config, target string, body map[string]any, template string, ext map[string]any) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.sentBody = body
m.sentTgt = target
return nil
return "", nil
}
func (m *mockPusher) ValidateConfig(cfg pkgpush.Config) error {
+1 -1
View File
@@ -284,7 +284,7 @@ func TestPush(c *gin.Context) {
keyContent: "当您收到这条消息,说明当前渠道连通性测试通过。",
keyLevel: defaultLevelInfo,
}
if err := pusher.Send(c.Request.Context(), req.Config, req.Target, testBody, "", nil); err != nil {
if _, err := pusher.Send(c.Request.Context(), req.Config, req.Target, testBody, "", nil); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
+7 -1
View File
@@ -93,13 +93,16 @@ func (h *PushHandler) Execute(ctx context.Context, payload []byte) (*task.TaskRe
// 执行真正的消息推送,扁平化为原始 json 格式
flatBody := req.Body.Flatten()
err = pusher.Send(ctx, req.Config, req.Target, flatBody, req.Template, nil)
upstreamResp, err := pusher.Send(ctx, req.Config, req.Target, flatBody, req.Template, nil)
title := req.Body.Title
content := req.Body.Content
if err != nil {
task.AppendLog(ctx, "消息推送失败 (标题: %s): %v", title, err)
if upstreamResp != "" {
task.AppendLog(ctx, "上游返回: %s", upstreamResp)
}
if task.IsFinalAttempt(ctx) {
h.recordHistory(ctx, req, "failed", err.Error())
}
@@ -107,6 +110,9 @@ func (h *PushHandler) Execute(ctx context.Context, payload []byte) (*task.TaskRe
}
task.AppendLog(ctx, "消息推送成功 (标题: %s, 内容摘要: %s)", title, content)
if upstreamResp != "" {
task.AppendLog(ctx, "上游返回: %s", upstreamResp)
}
h.recordHistory(ctx, req, "success", "")
return &task.TaskResult{
+25 -8
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
@@ -19,13 +20,16 @@ func init() {
Register("custom", &CustomPusher{})
}
// maxCustomResponseBytes 限制读取 Webhook 响应体的最大字节数,防止无界读取。
const maxCustomResponseBytes = 4096
// CustomPusher 自定义 Webhook 发送实现
type CustomPusher struct{}
// Send 发送自定义 webhook
func (p *CustomPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, template string, _ map[string]any) error {
func (p *CustomPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, template string, _ map[string]any) (string, error) {
if cfg.URL == "" {
return errors.New("custom: URL is required")
return "", errors.New("custom: URL is required")
}
var reqBody []byte
@@ -39,13 +43,13 @@ func (p *CustomPusher) Send(ctx context.Context, cfg Config, _ string, body map[
var err error
reqBody, err = json.Marshal(body)
if err != nil {
return fmt.Errorf("custom: marshal body failed: %w", err)
return "", fmt.Errorf("custom: marshal body failed: %w", err)
}
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("custom: create http request failed: %w", err)
return "", fmt.Errorf("custom: create http request failed: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
@@ -58,15 +62,28 @@ func (p *CustomPusher) Send(ctx context.Context, cfg Config, _ string, body map[
client := httppool.NewClient(defaultHTTPClientTimeout)
resp, err := client.Do(httpReq)
if err != nil {
return fmt.Errorf("custom: http request failed: %w", err)
return "", fmt.Errorf("custom: http request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 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 校验自定义配置
+91
View File
@@ -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)
}
})
}
}
+6 -6
View File
@@ -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 配置
+10 -10
View File
@@ -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 校验飞书配置
+2 -1
View File
@@ -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
+5 -5
View File
@@ -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 配置
+2 -2
View File
@@ -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)
+8 -9
View File
@@ -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 {