// 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 }