diff --git a/relay/channel/cloudflare/adaptor.go b/relay/channel/cloudflare/adaptor.go index 75400098..5c2eadc2 100644 --- a/relay/channel/cloudflare/adaptor.go +++ b/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 { diff --git a/service/cf_worker.go b/service/cf_worker.go index afe65411..40a1e294 100644 --- a/service/cf_worker.go +++ b/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") + req := &WorkerRequest{ + URL: originUrl, + Key: setting.WorkerValidKey, } - workerUrl := setting.WorkerUrl - if !strings.HasSuffix(workerUrl, "/") { - workerUrl += "/" - } - // 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) diff --git a/service/user_notify.go b/service/user_notify.go index d51bbcec..e01b7aa9 100644 --- a/service/user_notify.go +++ b/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 } diff --git a/service/webhook.go b/service/webhook.go new file mode 100644 index 00000000..ad2967eb --- /dev/null +++ b/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 +} diff --git a/web/src/components/PersonalSetting.js b/web/src/components/PersonalSetting.js index 66e2bb26..777cf042 100644 --- a/web/src/components/PersonalSetting.js +++ b/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')} /> - {t('系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求')} + {t('只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求')} + + +
setShowWebhookDocs(!showWebhookDocs)}> + {t('Webhook请求结构')} {showWebhookDocs ? '▼' : '▶'} +
+ +
+{`{
+    "type": "quota_exceed",      // 通知类型
+    "title": "标题",             // 通知标题
+    "content": "通知内容",       // 通知内容,支持 {{value}} 变量占位符
+    "values": ["值1", "值2"],    // 按顺序替换content中的 {{value}} 占位符
+    "timestamp": 1739950503      // 时间戳
+}
+
+示例:
+{
+    "type": "quota_exceed",
+    "title": "额度预警通知",
+    "content": "您的额度即将用尽,当前剩余额度为 {{value}}",
+    "values": ["$0.99"],
+    "timestamp": 1739950503
+}`}
+                                                    
+