feat: Implement comprehensive webhook notification system
This commit is contained in:
@@ -4,13 +4,14 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/dto"
|
"one-api/dto"
|
||||||
"one-api/relay/channel"
|
"one-api/relay/channel"
|
||||||
relaycommon "one-api/relay/common"
|
relaycommon "one-api/relay/common"
|
||||||
"one-api/relay/constant"
|
"one-api/relay/constant"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Adaptor struct {
|
type Adaptor struct {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
"one-api/common"
|
||||||
@@ -9,19 +10,46 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DoDownloadRequest(originUrl string) (resp *http.Response, err error) {
|
// WorkerRequest Worker请求的数据结构
|
||||||
if setting.EnableWorker() {
|
type WorkerRequest struct {
|
||||||
common.SysLog(fmt.Sprintf("downloading file from worker: %s", originUrl))
|
URL string `json:"url"`
|
||||||
if !strings.HasPrefix(originUrl, "https") {
|
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")
|
return nil, fmt.Errorf("only support https url")
|
||||||
}
|
}
|
||||||
|
|
||||||
workerUrl := setting.WorkerUrl
|
workerUrl := setting.WorkerUrl
|
||||||
if !strings.HasSuffix(workerUrl, "/") {
|
if !strings.HasSuffix(workerUrl, "/") {
|
||||||
workerUrl += "/"
|
workerUrl += "/"
|
||||||
}
|
}
|
||||||
// post request to worker
|
|
||||||
data := []byte(`{"url":"` + originUrl + `","key":"` + setting.WorkerValidKey + `"}`)
|
// 序列化worker请求数据
|
||||||
return http.Post(setting.WorkerUrl, "application/json", bytes.NewBuffer(data))
|
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))
|
||||||
|
req := &WorkerRequest{
|
||||||
|
URL: originUrl,
|
||||||
|
Key: setting.WorkerValidKey,
|
||||||
|
}
|
||||||
|
return DoWorkerRequest(req)
|
||||||
} else {
|
} else {
|
||||||
common.SysLog(fmt.Sprintf("downloading from origin: %s", originUrl))
|
common.SysLog(fmt.Sprintf("downloading from origin: %s", originUrl))
|
||||||
return http.Get(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))
|
common.SysError(fmt.Sprintf("user %d has no webhook url, skip sending webhook", user.Id))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// TODO: 实现webhook通知
|
webhookURLStr, ok := webhookURL.(string)
|
||||||
_ = webhookURL // 临时处理未使用警告,等待webhook实现
|
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
|
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: '',
|
webhookSecret: '',
|
||||||
notificationEmail: ''
|
notificationEmail: ''
|
||||||
});
|
});
|
||||||
|
const [showWebhookDocs, setShowWebhookDocs] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let status = localStorage.getItem('status');
|
let status = localStorage.getItem('status');
|
||||||
@@ -771,7 +772,32 @@ const PersonalSetting = () => {
|
|||||||
placeholder={t('请输入Webhook地址,例如: https://example.com/webhook')}
|
placeholder={t('请输入Webhook地址,例如: https://example.com/webhook')}
|
||||||
/>
|
/>
|
||||||
<Typography.Text type="secondary" style={{marginTop: 8, display: 'block'}}>
|
<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>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user