feat: complete remaining Go backend modules
This commit is contained in:
1 parent
cea2751dc5
commit
aef5a97165
145 files changed
+18376
-199
No files matched your search
@@ -0,0 +1,466 @@
|
||||
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) }
|
||||
Reference in new issue
Block a user