feat: add jobs worker and webhook core
This commit is contained in:
1 parent
7847c95538
commit
065dcd955a
12 files changed
+1664
No files matched your search
@@ -0,0 +1,125 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs"
|
||||
)
|
||||
|
||||
func TestBodyAndSignatureMatchSharedFixture(t *testing.T) {
|
||||
fixture := loadWebhookFixture(t)
|
||||
updatedAt, err := time.Parse(time.RFC3339Nano, fixture.Job.UpdatedAt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := jobs.Job{ID: fixture.Job.ID, Status: jobs.Status(fixture.Job.Status), Capability: fixture.Job.Capability, OutputAssetIDs: fixture.Job.OutputAssetIDs, Error: fixture.Job.Error, UpdatedAt: updatedAt}
|
||||
body, err := Body(job)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(body) != fixture.Body {
|
||||
t.Fatalf("body = %s, want %s", body, fixture.Body)
|
||||
}
|
||||
if got := Sign(body, fixture.Secret); got != fixture.Signature {
|
||||
t.Fatalf("signature = %q, want %q", got, fixture.Signature)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverRetriesUntilSuccessAndPreservesExactRequest(t *testing.T) {
|
||||
clock := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
sender := &senderStub{responses: []sendResult{{response: Response{Status: 503}}, {err: errors.New("network")}, {response: Response{Status: 204}}}}
|
||||
deliverer := NewDeliverer(sender, " secret ", func() time.Time { return clock })
|
||||
job := jobs.Job{ID: "job", Status: jobs.StatusSucceeded, Capability: "image.generate", OutputAssetIDs: []string{}, UpdatedAt: clock, WebhookURL: "https://hooks.example.test/job"}
|
||||
|
||||
result, err := deliverer.Deliver(context.Background(), job)
|
||||
if err != nil || result.Attempts != 3 || result.LastStatus == nil || !result.LastStatus.OK || result.LastStatus.Status != 204 {
|
||||
t.Fatalf("Deliver = %#v, %v", result, err)
|
||||
}
|
||||
if len(sender.requests) != 3 || sender.requests[0].Headers["Content-Type"] != "application/json" || sender.requests[0].Headers["User-Agent"] != UserAgent || sender.requests[0].Headers["X-Zhinian-Signature"] == "" {
|
||||
t.Fatalf("requests = %#v", sender.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverRespectsPersistedAttemptCeiling(t *testing.T) {
|
||||
sender := &senderStub{}
|
||||
deliverer := NewDeliverer(sender, "", nil)
|
||||
result, err := deliverer.Deliver(context.Background(), jobs.Job{WebhookURL: "https://hooks.example.test", WebhookAttempts: 3})
|
||||
if err != nil || result.Attempts != 3 || len(sender.requests) != 0 {
|
||||
t.Fatalf("Deliver = %#v, %v requests=%d", result, err, len(sender.requests))
|
||||
}
|
||||
}
|
||||
|
||||
type sendResult struct {
|
||||
response Response
|
||||
err error
|
||||
}
|
||||
type senderStub struct {
|
||||
responses []sendResult
|
||||
requests []Request
|
||||
}
|
||||
|
||||
func (stub *senderStub) Send(_ context.Context, request Request) (Response, error) {
|
||||
stub.requests = append(stub.requests, request)
|
||||
result := stub.responses[len(stub.requests)-1]
|
||||
return result.response, result.err
|
||||
}
|
||||
|
||||
type webhookFixture struct {
|
||||
Secret string `json:"secret"`
|
||||
Body string `json:"body"`
|
||||
Signature string `json:"signature"`
|
||||
Job struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Capability string `json:"capability"`
|
||||
OutputAssetIDs []string `json:"outputAssetIds"`
|
||||
Error *jobs.JobError `json:"error"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
} `json:"job"`
|
||||
}
|
||||
|
||||
func loadWebhookFixture(t *testing.T) webhookFixture {
|
||||
t.Helper()
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate fixture")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(filepath.Dir(filename), "..", "..", "..", "contracts", "webhook", "job-webhook-v1.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixture webhookFixture
|
||||
if err := json.Unmarshal(raw, &fixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
Reference in new issue
Block a user