修复了一点bug,然后增加了图层拆分更多的功能
This commit is contained in:
1 parent
1305a9cdd8
commit
ce8372e878
31 files changed
+2567
-391
No files matched your search
@@ -0,0 +1,124 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/layercompositions"
|
||||
)
|
||||
|
||||
type LayerCompositionsConfig struct {
|
||||
MaxJSONBytes int64
|
||||
}
|
||||
|
||||
type layerCompositionsHandler struct {
|
||||
platform *PlatformAuthorizer
|
||||
jobs *jobs.Service
|
||||
compositions *layercompositions.Service
|
||||
maxJSONBytes int64
|
||||
}
|
||||
|
||||
func NewLayerCompositionsHandler(platform *PlatformAuthorizer, jobService *jobs.Service, compositions *layercompositions.Service, config LayerCompositionsConfig) (http.Handler, error) {
|
||||
if platform == nil || jobService == nil || compositions == nil {
|
||||
return nil, errors.New("layer composition HTTP dependencies are not configured")
|
||||
}
|
||||
if config.MaxJSONBytes <= 0 {
|
||||
config.MaxJSONBytes = 1 << 20
|
||||
}
|
||||
return &layerCompositionsHandler{platform: platform, jobs: jobService, compositions: compositions, maxJSONBytes: config.MaxJSONBytes}, nil
|
||||
}
|
||||
|
||||
func (handler *layerCompositionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
jobID, matched := matchLayerCompositionRoute(r.URL.Path)
|
||||
if !matched {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Allow", "GET, PUT")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPut {
|
||||
w.Header().Set("Allow", "GET, PUT")
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
session, err := handler.platform.Authorize(r, PlatformApp)
|
||||
if err != nil {
|
||||
writeLayerCompositionError(w, err)
|
||||
return
|
||||
}
|
||||
job, err := handler.jobs.Get(r.Context(), jobs.Scope{OwnerID: session.User.ID}, jobID)
|
||||
if err != nil || job.Capability != "image.generate" {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "图层任务不存在"})
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
composition, found, err := handler.compositions.Get(r.Context(), session.User.ID, jobID)
|
||||
if err != nil {
|
||||
writeLayerCompositionError(w, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"composition": nil})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"composition": composition})
|
||||
return
|
||||
}
|
||||
input := struct {
|
||||
Version int64 `json:"version"`
|
||||
BaseAssetID string `json:"baseAssetId"`
|
||||
BaseVisible bool `json:"baseVisible"`
|
||||
Layers []layercompositions.Layer `json:"layers"`
|
||||
}{}
|
||||
if !decodeJobJSON(w, r, handler.maxJSONBytes, &input, false) {
|
||||
return
|
||||
}
|
||||
if !containsString(job.OutputAssetIDs, input.BaseAssetID) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "底图必须来自当前图层拆分任务"})
|
||||
return
|
||||
}
|
||||
saved, err := handler.compositions.Save(r.Context(), session.User.ID, jobID, input.Version, layercompositions.Composition{
|
||||
BaseAssetID: input.BaseAssetID,
|
||||
BaseVisible: input.BaseVisible,
|
||||
Layers: input.Layers,
|
||||
})
|
||||
if err != nil {
|
||||
writeLayerCompositionError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"composition": saved})
|
||||
}
|
||||
|
||||
func matchLayerCompositionRoute(path string) (string, bool) {
|
||||
const prefix = "/api/layer-compositions/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", false
|
||||
}
|
||||
jobID := strings.TrimPrefix(path, prefix)
|
||||
return jobID, jobID != "" && !strings.Contains(jobID, "/")
|
||||
}
|
||||
|
||||
func writeLayerCompositionError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, layercompositions.ErrConflict):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "图层工程已在其他页面更新,请重试"})
|
||||
case errors.Is(err, layercompositions.ErrInvalid), errors.Is(err, layercompositions.ErrAssetNotFound):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
default:
|
||||
writeJobError(w, err, false)
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if value == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/layercompositions"
|
||||
)
|
||||
|
||||
type layerCompositionRepositoryStub struct {
|
||||
value layercompositions.Composition
|
||||
found bool
|
||||
}
|
||||
|
||||
func (repository *layerCompositionRepositoryStub) FindLayerComposition(_ context.Context, ownerID, jobID string) (layercompositions.Composition, bool, error) {
|
||||
if !repository.found || repository.value.OwnerID != ownerID || repository.value.JobID != jobID {
|
||||
return layercompositions.Composition{}, false, nil
|
||||
}
|
||||
return repository.value, true, nil
|
||||
}
|
||||
|
||||
func (repository *layerCompositionRepositoryStub) SaveLayerComposition(_ context.Context, value layercompositions.Composition, version int64) (layercompositions.Composition, error) {
|
||||
if repository.found && repository.value.Version != version {
|
||||
return layercompositions.Composition{}, layercompositions.ErrConflict
|
||||
}
|
||||
if !repository.found && version != 0 {
|
||||
return layercompositions.Composition{}, layercompositions.ErrConflict
|
||||
}
|
||||
value.Version = version + 1
|
||||
value.CreatedAt = time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC)
|
||||
value.UpdatedAt = value.CreatedAt
|
||||
repository.value = value
|
||||
repository.found = true
|
||||
return value, nil
|
||||
}
|
||||
|
||||
type layerCompositionAssetCatalog map[string]assets.Asset
|
||||
|
||||
func (catalog layerCompositionAssetCatalog) GetOwner(_ context.Context, ownerID, assetID string) (assets.Asset, bool, error) {
|
||||
asset, found := catalog[assetID]
|
||||
return asset, found && asset.OwnerID == ownerID, nil
|
||||
}
|
||||
|
||||
func TestLayerCompositionsHTTPPersistsOnlyOwnedJobOutputsAndAssets(t *testing.T) {
|
||||
jobStore := &httpJobStore{values: map[string]jobs.Job{
|
||||
"job-1": {ID: "job-1", OwnerID: "owner", Capability: "image.generate", OutputAssetIDs: []string{"base"}},
|
||||
"job-2": {ID: "job-2", OwnerID: "another-owner", Capability: "image.generate", OutputAssetIDs: []string{"foreign-base"}},
|
||||
}}
|
||||
jobService := jobs.NewService(jobStore, nil)
|
||||
repository := &layerCompositionRepositoryStub{}
|
||||
compositionService := layercompositions.NewService(repository, layerCompositionAssetCatalog{
|
||||
"base": {ID: "base", OwnerID: "owner", Kind: assets.KindImage},
|
||||
"layer": {ID: "layer", OwnerID: "owner", Kind: assets.KindImage},
|
||||
})
|
||||
authorizer, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &fixedSessionResolver{session: identity.Session{User: identity.User{ID: "owner", ClientID: "platform", Role: "user"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := NewLayerCompositionsHandler(authorizer, jobService, compositionService, LayerCompositionsConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
missing := serveJSON(t, handler, http.MethodGet, "/api/layer-compositions/job-1", nil)
|
||||
if missing.Code != http.StatusOK || strings.TrimSpace(missing.Body.String()) != `{"composition":null}` {
|
||||
t.Fatalf("missing=%d %s", missing.Code, missing.Body.String())
|
||||
}
|
||||
|
||||
saved := serveJSON(t, handler, http.MethodPut, "/api/layer-compositions/job-1", map[string]any{
|
||||
"version": 0, "baseAssetId": "base", "baseVisible": true,
|
||||
"layers": []any{map[string]any{"id": "layer-1", "assetId": "layer", "name": "主体", "visible": true, "box": []any{100, 100, 900, 900}, "rotation": 15, "source": "provider"}},
|
||||
})
|
||||
if saved.Code != http.StatusOK || repository.value.Version != 1 || repository.value.Layers[0].Rotation != 15 {
|
||||
t.Fatalf("saved=%d %s value=%#v", saved.Code, saved.Body.String(), repository.value)
|
||||
}
|
||||
|
||||
foreignJob := serveJSON(t, handler, http.MethodGet, "/api/layer-compositions/job-2", nil)
|
||||
if foreignJob.Code != http.StatusNotFound {
|
||||
t.Fatalf("foreign job=%d %s", foreignJob.Code, foreignJob.Body.String())
|
||||
}
|
||||
|
||||
invalidBase := serveJSON(t, handler, http.MethodPut, "/api/layer-compositions/job-1", map[string]any{
|
||||
"version": 1, "baseAssetId": "layer", "baseVisible": true, "layers": []any{},
|
||||
})
|
||||
if invalidBase.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid base=%d %s", invalidBase.Code, invalidBase.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
|
||||
func TestRouteMethodCompatibilityDerivesEverySurfacePath(t *testing.T) {
|
||||
patterns := routeMethodPatterns(GoRouteSurface())
|
||||
if len(patterns) != 47 {
|
||||
t.Fatalf("route patterns=%d want 47", len(patterns))
|
||||
if len(patterns) != 48 {
|
||||
t.Fatalf("route patterns=%d want 48", len(patterns))
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if _, ok := pattern.methods[http.MethodOptions]; !ok {
|
||||
|
||||
@@ -17,7 +17,7 @@ var goRouteSurface = []RouteSurface{
|
||||
{"GET", "/api/assets"}, {"POST", "/api/assets"}, {"POST", "/api/assets/upload"}, {"DELETE", "/api/assets/{id}"}, {"GET", "/api/assets/{id}/download"},
|
||||
{"GET", "/api/auth/callback"}, {"GET", "/api/auth/captcha"}, {"GET", "/api/auth/login"}, {"GET", "/api/auth/logout"}, {"POST", "/api/auth/logout"}, {"GET", "/api/auth/me"}, {"POST", "/api/auth/password"}, {"POST", "/api/auth/password/change"},
|
||||
{"GET", "/api/billing"}, {"POST", "/api/billing/quote"}, {"GET", "/api/generations/image"}, {"POST", "/api/generations/image"}, {"DELETE", "/api/generations/image/{id}"}, {"GET", "/api/generations/image/{id}"}, {"POST", "/api/generations/image/{id}/retry"}, {"GET", "/api/generations/video"}, {"POST", "/api/generations/video"}, {"DELETE", "/api/generations/video/{id}"}, {"GET", "/api/generations/video/{id}"},
|
||||
{"GET", "/api/health"}, {"GET", "/api/image-templates"}, {"POST", "/api/image-templates"}, {"DELETE", "/api/image-templates/{id}"}, {"PATCH", "/api/image-templates/{id}"}, {"POST", "/api/internal/worker/tick"}, {"DELETE", "/api/logs"}, {"GET", "/api/logs"}, {"POST", "/api/prompt/assemble"}, {"GET", "/api/ready"}, {"GET", "/api/settings"}, {"POST", "/api/settings"}, {"GET", "/api/usage"},
|
||||
{"GET", "/api/health"}, {"GET", "/api/image-templates"}, {"POST", "/api/image-templates"}, {"DELETE", "/api/image-templates/{id}"}, {"PATCH", "/api/image-templates/{id}"}, {"POST", "/api/internal/worker/tick"}, {"GET", "/api/layer-compositions/{id}"}, {"PUT", "/api/layer-compositions/{id}"}, {"DELETE", "/api/logs"}, {"GET", "/api/logs"}, {"POST", "/api/prompt/assemble"}, {"GET", "/api/ready"}, {"GET", "/api/settings"}, {"POST", "/api/settings"}, {"GET", "/api/usage"},
|
||||
{"GET", "/api/v1/assets"}, {"POST", "/api/v1/assets"}, {"GET", "/api/v1/assets/{id}"}, {"GET", "/api/v1/assets/{id}/download"}, {"GET", "/api/v1/capabilities"}, {"GET", "/api/v1/jobs"}, {"POST", "/api/v1/jobs"}, {"GET", "/api/v1/jobs/{id}"}, {"POST", "/api/v1/jobs/{id}/cancel"}, {"GET", "/api/v1/openapi.json"}, {"GET", "/generated-results/{path...}"}, {"GET", "/uploads/{path...}"},
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user