Files

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
}