修复了一点bug,然后增加了图层拆分更多的功能

This commit is contained in:
andy committed 2026-09-02 16:06:45 +08:00
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 {
+1 -1
View File
@@ -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...}"},
}