package httpapi import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "net/http" "net/url" "strconv" "strings" "time" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/orchestration" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/publicapi" ) type JobsPublicAuthenticator interface { Authenticate(*http.Request) (publicapi.PublicClient, string, error) AssertInternalWorker(*http.Request) error } type JobBuildInput struct { Scope jobs.Scope Capability string Body map[string]any IdempotencyKey string Origin string } type JobBuilder interface { Build(context.Context, JobBuildInput) (jobs.CreateCommand, error) } type JobCreationCoordinator interface { CreatePlatform(context.Context, identity.Session, orchestration.CreationInput) (jobs.Job, bool, error) CreatePublic(context.Context, orchestration.CreationInput) (jobs.Job, bool, error) } type PlatformJobRetryCoordinator interface { RetryPlatform(context.Context, identity.Session, jobs.Job) (jobs.Job, error) } type JobTickLimiter interface { TickLimit(context.Context, string, int) (jobs.TickResult, error) } // ProviderBuilderAdapter exposes jobs.ProviderJobBuilder at the HTTP seam. type ProviderBuilderAdapter struct{ Builder jobs.ProviderJobBuilder } func (a ProviderBuilderAdapter) Build(ctx context.Context, in JobBuildInput) (jobs.CreateCommand, error) { return a.Builder.Build(ctx, in.Scope.OwnerID, in.Scope.ExternalClientID, in.Capability, in.IdempotencyKey, in.Body) } type JobsDependencies struct { Service *jobs.Service Platform *PlatformAuthorizer Public JobsPublicAuthenticator Builder JobBuilder Creation JobCreationCoordinator Refunds jobs.RefundPort Artifacts jobs.ArtifactDeleter Worker JobTickLimiter } type JobsConfig struct { MaxJSONBytes int64 NewID func() string } type jobsHandler struct { dependencies JobsDependencies config JobsConfig } func NewJobsHandler(d JobsDependencies, c JobsConfig) (http.Handler, error) { if d.Service == nil || d.Platform == nil || d.Public == nil || d.Builder == nil { return nil, errors.New("jobs HTTP dependencies are not configured") } if c.MaxJSONBytes <= 0 { c.MaxJSONBytes = 1 << 20 } if c.NewID == nil { c.NewID = randomJobID } return &jobsHandler{d, c}, nil } func (h *jobsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { route, id := matchJobRoute(r.URL.Path) if route == "" { http.NotFound(w, r) return } allow := jobAllow(route) if r.Method == http.MethodOptions { w.Header().Set("Allow", allow) w.WriteHeader(http.StatusNoContent) return } if !methodAllowed(allow, r.Method) { w.Header().Set("Allow", allow) w.WriteHeader(http.StatusMethodNotAllowed) return } switch route { case "platform-image-list": h.platformCollection(w, r, "image.generate") case "platform-video-list": h.platformCollection(w, r, "video.generate") case "platform-image-item": h.platformItem(w, r, id, false) case "platform-video-item": h.platformItem(w, r, id, true) case "platform-retry": h.platformRetry(w, r, id) case "public-list": h.publicCollection(w, r) case "public-item": h.publicGet(w, r, id) case "public-cancel": h.publicCancel(w, r, id) case "worker": h.workerTick(w, r) } } func (h *jobsHandler) platformCollection(w http.ResponseWriter, r *http.Request, capability string) { session, err := h.dependencies.Platform.Authorize(r, PlatformApp) if err != nil { writeJobError(w, err, false) return } scope := jobs.Scope{OwnerID: session.User.ID} if r.Method == http.MethodGet { items, err := h.dependencies.Service.List(r.Context(), jobs.ListFilter{Scope: scope, Capability: capability, Limit: 200}) if err != nil { writeJobError(w, err, false) return } if items == nil { items = []jobs.Job{} } writeJSON(w, 200, map[string]any{"jobs": items}) return } h.create(w, r, scope, capability, false, &session) } func (h *jobsHandler) platformItem(w http.ResponseWriter, r *http.Request, id string, video bool) { session, err := h.dependencies.Platform.Authorize(r, PlatformApp) if err != nil { writeJobError(w, err, false) return } scope := jobs.Scope{OwnerID: session.User.ID} if r.Method == http.MethodGet { j, err := h.dependencies.Service.Get(r.Context(), scope, id) if err != nil { writeJobError(w, err, false) return } writeJSON(w, 200, map[string]any{"job": j}) return } j, err := h.dependencies.Service.Get(r.Context(), scope, id) if err != nil || ((j.Capability == "video.generate") != video) { writeJSON(w, 404, map[string]string{"error": "任务不存在"}) return } if !j.Status.Terminal() { j, err = h.dependencies.Service.Cancel(r.Context(), scope, id, h.dependencies.Refunds) if err != nil { writeJobError(w, err, false) return } } deletedAssets := []string{} if h.dependencies.Artifacts != nil { deletedAssets, err = h.dependencies.Artifacts.DeleteOutputs(r.Context(), j) if err != nil { writeJobError(w, err, false) return } } _, err = h.dependencies.Service.Delete(r.Context(), scope, id, nil) if err != nil { writeJobError(w, err, false) return } writeJSON(w, 200, map[string]any{"ok": true, "deletedJobId": id, "deletedAssetIds": deletedAssets}) } func (h *jobsHandler) platformRetry(w http.ResponseWriter, r *http.Request, id string) { session, err := h.dependencies.Platform.Authorize(r, PlatformApp) if err != nil { writeJobError(w, err, false) return } original, err := h.dependencies.Service.Get(r.Context(), jobs.Scope{OwnerID: session.User.ID}, id) if err != nil || original.Capability != "image.generate" { writeJSON(w, 404, map[string]string{"error": "任务不存在"}) return } retryCoordinator, ok := h.dependencies.Creation.(PlatformJobRetryCoordinator) if !ok { writeJSON(w, 500, map[string]string{"error": "Internal server error."}) return } j, err := retryCoordinator.RetryPlatform(r.Context(), session, original) if err != nil { writeJobError(w, err, false) return } writeJSON(w, 202, map[string]any{"job": j}) } func (h *jobsHandler) publicCollection(w http.ResponseWriter, r *http.Request) { client, owner, err := h.dependencies.Public.Authenticate(r) if err != nil { writeJobError(w, err, true) return } scope := jobs.Scope{OwnerID: owner, ExternalClientID: client.ID} if r.Method == http.MethodPost { h.create(w, r, scope, "", true, nil) return } q := r.URL.Query() limit, _ := strconv.Atoi(q.Get("limit")) var before *time.Time if raw := q.Get("before"); raw != "" { if parsed, e := time.Parse(time.RFC3339, raw); e == nil { before = &parsed } } items, err := h.dependencies.Service.List(r.Context(), jobs.ListFilter{Scope: scope, Status: jobs.Status(q.Get("status")), Capability: q.Get("capability"), Limit: limit, Before: before}) if err != nil { writeJobError(w, err, true) return } if items == nil { items = []jobs.Job{} } writeJSON(w, 200, map[string]any{"jobs": items}) } func (h *jobsHandler) publicGet(w http.ResponseWriter, r *http.Request, id string) { _, scope, ok := h.publicScope(w, r) if !ok { return } j, err := h.dependencies.Service.Get(r.Context(), scope, id) if err != nil { writeJobError(w, err, true) return } writeJSON(w, 200, map[string]any{"job": j}) } func (h *jobsHandler) publicCancel(w http.ResponseWriter, r *http.Request, id string) { _, scope, ok := h.publicScope(w, r) if !ok { return } j, err := h.dependencies.Service.Cancel(r.Context(), scope, id, h.dependencies.Refunds) if err != nil { writeJobError(w, err, true) return } writeJSON(w, 200, map[string]any{"job": j}) } func (h *jobsHandler) publicScope(w http.ResponseWriter, r *http.Request) (publicapi.PublicClient, jobs.Scope, bool) { c, o, e := h.dependencies.Public.Authenticate(r) if e != nil { writeJobError(w, e, true) return publicapi.PublicClient{}, jobs.Scope{}, false } return c, jobs.Scope{OwnerID: o, ExternalClientID: c.ID}, true } func (h *jobsHandler) create(w http.ResponseWriter, r *http.Request, scope jobs.Scope, capability string, public bool, session *identity.Session) { body := map[string]any{} if !decodeJobJSON(w, r, h.config.MaxJSONBytes, &body, public) { return } if capability == "" { capability = stringValueHTTP(body["capability"]) if capability == "" { capability = "image.generate" } } if priority, exists := body["priority"]; exists { body["priority"] = float64(jobs.NormalizePriority(jobHTTPInt(priority))) } if public { if webhook := strings.TrimSpace(stringValueHTTP(body["webhookUrl"])); webhook != "" { parsed, parseErr := url.Parse(webhook) if parseErr != nil || !parsed.IsAbs() || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "webhookUrl must be an HTTP or HTTPS URL."}) return } body["webhookUrl"] = webhook } } key := strings.TrimSpace(r.Header.Get("Idempotency-Key")) if key == "" { key = stringValueHTTP(body["idempotencyKey"]) } var created jobs.Job var reused bool var err error if h.dependencies.Creation != nil { input := orchestration.CreationInput{OwnerID: scope.OwnerID, ExternalClientID: scope.ExternalClientID, Capability: capability, Body: body, IdempotencyKey: key} if public { created, reused, err = h.dependencies.Creation.CreatePublic(r.Context(), input) } else if session != nil { created, reused, err = h.dependencies.Creation.CreatePlatform(r.Context(), *session, input) } else { err = errors.New("platform creation session is unavailable") } } else { cmd, buildErr := h.dependencies.Builder.Build(r.Context(), JobBuildInput{Scope: scope, Capability: capability, Body: body, IdempotencyKey: key, Origin: absoluteRequestURL(r)}) if buildErr != nil { writeJobError(w, buildErr, public) return } created, reused, err = h.dependencies.Service.Create(r.Context(), cmd) } if err != nil { writeJobError(w, err, public) return } status := 202 if reused { status = 200 } response := map[string]any{"job": created} if public { response["reused"] = reused } writeJSON(w, status, response) } func jobHTTPInt(value any) int { switch typed := value.(type) { case float64: return int(typed) case int: return typed case json.Number: parsed, _ := strconv.Atoi(typed.String()) return parsed default: return 0 } } func (h *jobsHandler) workerTick(w http.ResponseWriter, r *http.Request) { if err := h.dependencies.Public.AssertInternalWorker(r); err != nil { writeJobError(w, err, true) return } if h.dependencies.Worker == nil { writeJSON(w, 500, map[string]string{"error": "Internal server error."}) return } input := struct { WorkerID string `json:"workerId"` Limit int `json:"limit"` }{} if !decodeJobJSON(w, r, h.config.MaxJSONBytes, &input, true) { return } if input.WorkerID == "" { input.WorkerID = h.config.NewID() } result, err := h.dependencies.Worker.TickLimit(r.Context(), input.WorkerID, input.Limit) if err != nil { writeJobError(w, err, true) return } writeJSON(w, 200, result) } func matchJobRoute(p string) (string, string) { switch p { case "/api/generations/image": return "platform-image-list", "" case "/api/generations/video": return "platform-video-list", "" case "/api/v1/jobs": return "public-list", "" case "/api/internal/worker/tick": return "worker", "" } parts := strings.Split(strings.Trim(p, "/"), "/") if len(parts) == 4 && parts[0] == "api" && parts[1] == "generations" && (parts[2] == "image" || parts[2] == "video") { return "platform-" + parts[2] + "-item", parts[3] } if len(parts) == 5 && parts[0] == "api" && parts[1] == "generations" && parts[2] == "image" && parts[4] == "retry" { return "platform-retry", parts[3] } if len(parts) == 5 && parts[0] == "api" && parts[1] == "v1" && parts[2] == "jobs" && parts[4] == "cancel" { return "public-cancel", parts[3] } if len(parts) == 4 && parts[0] == "api" && parts[1] == "v1" && parts[2] == "jobs" { return "public-item", parts[3] } return "", "" } func jobAllow(route string) string { switch route { case "platform-image-list", "platform-video-list", "public-list": return "GET, POST" case "platform-image-item", "platform-video-item": return "GET, DELETE" case "public-item": return "GET" default: return "POST" } } func decodeJobJSON(w http.ResponseWriter, r *http.Request, limit int64, target any, public bool) bool { r.Body = http.MaxBytesReader(w, r.Body, limit) if err := json.NewDecoder(r.Body).Decode(target); err != nil { message := "Invalid request body." if !public { message = "请求参数无效" } writeJSON(w, 400, map[string]string{"error": message}) return false } return true } func writeJobError(w http.ResponseWriter, err error, public bool) { var domain *jobs.Error if errors.As(err, &domain) { message := domain.Message if !public && domain.Kind == jobs.ErrorNotFound { message = "Generation job not found." } writeJSON(w, domain.Status, map[string]string{"error": message}) return } var auth *publicapi.AuthError if errors.As(err, &auth) { writeJSON(w, auth.Status, map[string]string{"error": auth.Message}) return } var platform *PlatformAuthError if errors.As(err, &platform) { writeJSON(w, platform.Status, map[string]string{"error": platform.Message}) return } var billingStatus *billing.StatusError if errors.As(err, &billingStatus) && (billingStatus.Status == 402 || billingStatus.Status == 409) { message := "生成任务计费失败。" if public { message = "Generation job billing failed." } writeJSON(w, billingStatus.Status, map[string]string{"error": message}) return } writeJSON(w, 500, map[string]string{"error": "Internal server error."}) } func randomJobID() string { b := make([]byte, 16) _, _ = rand.Read(b) return "job-" + hex.EncodeToString(b) } func stringValueHTTP(v any) string { s, _ := v.(string); return strings.TrimSpace(s) }