126 lines
3.2 KiB
Go
126 lines
3.2 KiB
Go
// Package webhook owns the generation-job callback byte and retry contract.
|
|
package webhook
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs"
|
|
)
|
|
|
|
const (
|
|
MaximumAttempts = 3
|
|
UserAgent = "zhinian-aigc-webhook/1.0"
|
|
)
|
|
|
|
type Payload struct {
|
|
JobID string `json:"jobId"`
|
|
Status jobs.Status `json:"status"`
|
|
Capability string `json:"capability"`
|
|
OutputAssetIDs []string `json:"outputAssetIds"`
|
|
Error *jobs.JobError `json:"error,omitempty"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
func Body(job jobs.Job) ([]byte, error) {
|
|
outputIDs := job.OutputAssetIDs
|
|
if outputIDs == nil {
|
|
outputIDs = []string{}
|
|
}
|
|
payload := Payload{
|
|
JobID: job.ID, Status: job.Status, Capability: job.Capability,
|
|
OutputAssetIDs: outputIDs, Error: job.Error,
|
|
// JavaScript Date#toISOString always emits exactly three fractional
|
|
// digits. Keep those bytes stable because the HMAC covers them.
|
|
UpdatedAt: job.UpdatedAt.UTC().Format("2006-01-02T15:04:05.000Z"),
|
|
}
|
|
return json.Marshal(payload)
|
|
}
|
|
|
|
func Sign(body []byte, secret string) string {
|
|
secret = strings.TrimSpace(secret)
|
|
if secret == "" {
|
|
return ""
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
_, _ = mac.Write(body)
|
|
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
type Request struct {
|
|
URL string
|
|
Body []byte
|
|
Headers map[string]string
|
|
}
|
|
|
|
type Response struct {
|
|
Status int
|
|
}
|
|
|
|
type Sender interface {
|
|
Send(context.Context, Request) (Response, error)
|
|
}
|
|
|
|
type LastStatus struct {
|
|
OK bool `json:"ok"`
|
|
Status int `json:"status,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
AttemptedAt string `json:"attemptedAt"`
|
|
}
|
|
|
|
type Result struct {
|
|
Attempts int
|
|
LastStatus *LastStatus
|
|
}
|
|
|
|
type Deliverer struct {
|
|
sender Sender
|
|
secret string
|
|
now func() time.Time
|
|
}
|
|
|
|
func NewDeliverer(sender Sender, secret string, now func() time.Time) *Deliverer {
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
return &Deliverer{sender: sender, secret: secret, now: now}
|
|
}
|
|
|
|
func (deliverer *Deliverer) Deliver(ctx context.Context, job jobs.Job) (Result, error) {
|
|
if job.WebhookURL == "" {
|
|
return Result{Attempts: job.WebhookAttempts}, nil
|
|
}
|
|
if deliverer == nil || deliverer.sender == nil {
|
|
return Result{}, fmt.Errorf("webhook sender is not configured")
|
|
}
|
|
body, err := Body(job)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
headers := map[string]string{"Content-Type": "application/json", "User-Agent": UserAgent}
|
|
if signature := Sign(body, deliverer.secret); signature != "" {
|
|
headers["X-Zhinian-Signature"] = signature
|
|
}
|
|
result := Result{Attempts: job.WebhookAttempts}
|
|
for result.Attempts < MaximumAttempts {
|
|
result.Attempts++
|
|
attemptedAt := deliverer.now().UTC().Format(time.RFC3339Nano)
|
|
response, sendErr := deliverer.sender.Send(ctx, Request{URL: job.WebhookURL, Body: body, Headers: headers})
|
|
if sendErr != nil {
|
|
result.LastStatus = &LastStatus{OK: false, Error: sendErr.Error(), AttemptedAt: attemptedAt}
|
|
continue
|
|
}
|
|
result.LastStatus = &LastStatus{OK: response.Status >= 200 && response.Status < 300, Status: response.Status, AttemptedAt: attemptedAt}
|
|
if result.LastStatus.OK {
|
|
break
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|