Forráskód Böngészése

feat: Implement comprehensive webhook notification system

[email protected] 10 hónapja
szülő
commit
4e871507cf

+ 2 - 1
relay/channel/cloudflare/adaptor.go

@@ -4,13 +4,14 @@ import (
 	"bytes"
 	"errors"
 	"fmt"
-	"github.com/gin-gonic/gin"
 	"io"
 	"net/http"
 	"one-api/dto"
 	"one-api/relay/channel"
 	relaycommon "one-api/relay/common"
 	"one-api/relay/constant"
+
+	"github.com/gin-gonic/gin"
 )
 
 type Adaptor struct {

+ 37 - 9
service/cf_worker.go

@@ -2,6 +2,7 @@ package service
 
 import (
 	"bytes"
+	"encoding/json"
 	"fmt"
 	"net/http"
 	"one-api/common"
@@ -9,19 +10,46 @@ import (
 	"strings"
 )
 
+// WorkerRequest Worker请求的数据结构
+type WorkerRequest struct {
+	URL     string            `json:"url"`
+	Key     string            `json:"key"`
+	Method  string            `json:"method,omitempty"`
+	Headers map[string]string `json:"headers,omitempty"`
+	Body    json.RawMessage   `json:"body,omitempty"`
+}
+
+// DoWorkerRequest 通过Worker发送请求
+func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
+	if !setting.EnableWorker() {
+		return nil, fmt.Errorf("worker not enabled")
+	}
+	if !strings.HasPrefix(req.URL, "https") {
+		return nil, fmt.Errorf("only support https url")
+	}
+
+	workerUrl := setting.WorkerUrl
+	if !strings.HasSuffix(workerUrl, "/") {
+		workerUrl += "/"
+	}
+
+	// 序列化worker请求数据
+	workerPayload, err := json.Marshal(req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal worker payload: %v", err)
+	}
+
+	return http.Post(workerUrl, "application/json", bytes.NewBuffer(workerPayload))
+}
+
 func DoDownloadRequest(originUrl string) (resp *http.Response, err error) {
 	if setting.EnableWorker() {
 		common.SysLog(fmt.Sprintf("downloading file from worker: %s", originUrl))
-		if !strings.HasPrefix(originUrl, "https") {
-			return nil, fmt.Errorf("only support https url")
-		}
-		workerUrl := setting.WorkerUrl
-		if !strings.HasSuffix(workerUrl, "/") {
-			workerUrl += "/"
+		req := &WorkerRequest{
+			URL: originUrl,
+			Key: setting.WorkerValidKey,
 		}
-		// post request to worker
-		data := []byte(`{"url":"` + originUrl + `","key":"` + setting.WorkerValidKey + `"}`)
-		return http.Post(setting.WorkerUrl, "application/json", bytes.NewBuffer(data))
+		return DoWorkerRequest(req)
 	} else {
 		common.SysLog(fmt.Sprintf("downloading from origin: %s", originUrl))
 		return http.Get(originUrl)

+ 13 - 2
service/user_notify.go

@@ -49,8 +49,19 @@ func NotifyUser(user *model.UserBase, data dto.Notify) error {
 			common.SysError(fmt.Sprintf("user %d has no webhook url, skip sending webhook", user.Id))
 			return nil
 		}
-		// TODO: 实现webhook通知
-		_ = webhookURL // 临时处理未使用警告,等待webhook实现
+		webhookURLStr, ok := webhookURL.(string)
+		if !ok {
+			common.SysError(fmt.Sprintf("user %d webhook url is not string type", user.Id))
+			return nil
+		}
+
+		// 获取 webhook secret
+		var webhookSecret string
+		if secret, ok := userSetting[constant.UserSettingWebhookSecret]; ok {
+			webhookSecret, _ = secret.(string)
+		}
+
+		return SendWebhookNotify(webhookURLStr, webhookSecret, data)
 	}
 	return nil
 }

+ 118 - 0
service/webhook.go

@@ -0,0 +1,118 @@
+package service
+
+import (
+	"bytes"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"one-api/dto"
+	"one-api/setting"
+	"time"
+)
+
+// WebhookPayload webhook 通知的负载数据
+type WebhookPayload struct {
+	Type      string        `json:"type"`
+	Title     string        `json:"title"`
+	Content   string        `json:"content"`
+	Values    []interface{} `json:"values,omitempty"`
+	Timestamp int64         `json:"timestamp"`
+}
+
+// generateSignature 生成 webhook 签名
+func generateSignature(secret string, payload []byte) string {
+	h := hmac.New(sha256.New, []byte(secret))
+	h.Write(payload)
+	return hex.EncodeToString(h.Sum(nil))
+}
+
+// SendWebhookNotify 发送 webhook 通知
+func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error {
+	// 处理占位符
+	content := data.Content
+	for _, value := range data.Values {
+		content = fmt.Sprintf(content, value)
+	}
+
+	// 构建 webhook 负载
+	payload := WebhookPayload{
+		Type:      data.Type,
+		Title:     data.Title,
+		Content:   content,
+		Values:    data.Values,
+		Timestamp: time.Now().Unix(),
+	}
+
+	// 序列化负载
+	payloadBytes, err := json.Marshal(payload)
+	if err != nil {
+		return fmt.Errorf("failed to marshal webhook payload: %v", err)
+	}
+
+	// 创建 HTTP 请求
+	var req *http.Request
+	var resp *http.Response
+
+	if setting.EnableWorker() {
+		// 构建worker请求数据
+		workerReq := &WorkerRequest{
+			URL:    webhookURL,
+			Key:    setting.WorkerValidKey,
+			Method: http.MethodPost,
+			Headers: map[string]string{
+				"Content-Type": "application/json",
+			},
+			Body: payloadBytes,
+		}
+
+		// 如果有secret,添加签名到headers
+		if secret != "" {
+			signature := generateSignature(secret, payloadBytes)
+			workerReq.Headers["X-Webhook-Signature"] = signature
+			workerReq.Headers["Authorization"] = "Bearer " + secret
+		}
+
+		resp, err = DoWorkerRequest(workerReq)
+		if err != nil {
+			return fmt.Errorf("failed to send webhook request through worker: %v", err)
+		}
+		defer resp.Body.Close()
+
+		// 检查响应状态
+		if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+			return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode)
+		}
+	} else {
+		req, err = http.NewRequest(http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes))
+		if err != nil {
+			return fmt.Errorf("failed to create webhook request: %v", err)
+		}
+
+		// 设置请求头
+		req.Header.Set("Content-Type", "application/json")
+
+		// 如果有 secret,生成签名
+		if secret != "" {
+			signature := generateSignature(secret, payloadBytes)
+			req.Header.Set("X-Webhook-Signature", signature)
+		}
+
+		// 发送请求
+		client := GetImpatientHttpClient()
+		resp, err = client.Do(req)
+		if err != nil {
+			return fmt.Errorf("failed to send webhook request: %v", err)
+		}
+		defer resp.Body.Close()
+
+		// 检查响应状态
+		if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+			return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode)
+		}
+	}
+
+	return nil
+}

+ 27 - 1
web/src/components/PersonalSetting.js

@@ -78,6 +78,7 @@ const PersonalSetting = () => {
         webhookSecret: '',
         notificationEmail: ''
     });
+    const [showWebhookDocs, setShowWebhookDocs] = useState(false);
 
     useEffect(() => {
         let status = localStorage.getItem('status');
@@ -771,7 +772,32 @@ const PersonalSetting = () => {
                                                 placeholder={t('请输入Webhook地址,例如: https://example.com/webhook')}
                                             />
                                             <Typography.Text type="secondary" style={{marginTop: 8, display: 'block'}}>
-                                                {t('系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求')}
+                                                {t('只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求')}
+                                            </Typography.Text>
+                                            <Typography.Text type="secondary" style={{marginTop: 8, display: 'block'}}>
+                                                <div style={{cursor: 'pointer'}} onClick={() => setShowWebhookDocs(!showWebhookDocs)}>
+                                                    {t('Webhook请求结构')} {showWebhookDocs ? '▼' : '▶'}
+                                                </div>
+                                                <Collapsible isOpen={showWebhookDocs}>
+                                                    <pre style={{marginTop: 4, background: 'var(--semi-color-fill-0)', padding: 8, borderRadius: 4}}>
+{`{
+    "type": "quota_exceed",      // 通知类型
+    "title": "标题",             // 通知标题
+    "content": "通知内容",       // 通知内容,支持 {{value}} 变量占位符
+    "values": ["值1", "值2"],    // 按顺序替换content中的 {{value}} 占位符
+    "timestamp": 1739950503      // 时间戳
+}
+
+示例:
+{
+    "type": "quota_exceed",
+    "title": "额度预警通知",
+    "content": "您的额度即将用尽,当前剩余额度为 {{value}}",
+    "values": ["$0.99"],
+    "timestamp": 1739950503
+}`}
+                                                    </pre>
+                                                </Collapsible>
                                             </Typography.Text>
                                         </div>
                                     </div>