feat: Implement comprehensive webhook notification system
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
service/webhook.go
Normal file
118
service/webhook.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user