Files
2026-08-25 14:06:04 +08:00

485 lines
15 KiB
Go

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)
}
// ProviderCommandBuilder is the shared seam used by creation, retry, and quote
// paths. Implementations may resolve runtime provider settings per request.
type ProviderCommandBuilder interface {
Build(context.Context, string, string, string, string, map[string]any) (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 ProviderCommandBuilder }
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."
} else if errors.Is(err, billing.ErrInsufficientBalance) {
message = "余额不足,请先充值。"
} else if errors.Is(err, billing.ErrOrganizationUnavailable) {
message = "账号未绑定有效组织,请联系管理员。"
}
writeJSON(w, billingStatus.Status, map[string]string{"error": message})
return
}
if errors.Is(err, billing.ErrProviderUnavailable) {
message := "计费服务暂不可用,请稍后重试。"
if public {
message = "Generation billing is temporarily unavailable."
}
writeJSON(w, http.StatusServiceUnavailable, 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) }