修复了一点bug,然后增加了图层拆分更多的功能
This commit is contained in:
1 parent
1305a9cdd8
commit
ce8372e878
31 files changed
+2567
-391
No files matched your search
@@ -17,6 +17,7 @@ import (
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/layercompositions"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/localstore"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/orchestration"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres"
|
||||
@@ -119,6 +120,7 @@ func New(options Options) (*App, error) {
|
||||
var usageRepository usage.Repository = postgres.NewUsageRepository(database.Store)
|
||||
var templateCatalog templates.Catalog = database.Store
|
||||
var jobStore jobs.Store = database.Store
|
||||
var layerCompositionRepository layercompositions.Repository = database.Store
|
||||
var creationState orchestration.CreationStateWriter = database.Store
|
||||
var jobState orchestration.JobStateWriter = database.Store
|
||||
var settlementState orchestration.SettlementStateWriter = database.Store
|
||||
@@ -134,6 +136,7 @@ func New(options Options) (*App, error) {
|
||||
usageRepository = store
|
||||
templateCatalog = store
|
||||
jobStore = store
|
||||
layerCompositionRepository = store
|
||||
creationState = store
|
||||
jobState = store
|
||||
settlementState = store
|
||||
@@ -277,6 +280,13 @@ func New(options Options) (*App, error) {
|
||||
providerResolver = newRuntimeProviderResolver(getenv, runtimeSettings)
|
||||
}
|
||||
jobService := jobs.NewService(jobStore, nil)
|
||||
layerCompositionService := layercompositions.NewService(layerCompositionRepository, assetCatalog)
|
||||
layerCompositionHandler, err := httpapi.NewLayerCompositionsHandler(platformAuthorizer, jobService, layerCompositionService, httpapi.LayerCompositionsConfig{
|
||||
MaxJSONBytes: positiveInt64Env(getenv, "ZHINIAN_MAX_JSON_BYTES", 1<<20),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobBuilder := runtimeProviderJobBuilder{fallback: getenv, settings: runtimeSettings, enforceAvailability: config.Backend == postgres.BackendPostgres}
|
||||
usageService := usage.Service{
|
||||
Repository: usageRepository,
|
||||
@@ -358,6 +368,7 @@ func New(options Options) (*App, error) {
|
||||
mux.Handle("/api/v1/jobs", jobsHandler)
|
||||
mux.Handle("/api/v1/jobs/", jobsHandler)
|
||||
mux.Handle("/api/internal/worker/tick", jobsHandler)
|
||||
mux.Handle("/api/layer-compositions/", layerCompositionHandler)
|
||||
mux.Handle("/api/image-templates", miscHandler)
|
||||
mux.Handle("/api/image-templates/", miscHandler)
|
||||
mux.Handle("/api/prompt/assemble", miscHandler)
|
||||
|
||||
@@ -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...}"},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Package layercompositions owns durable Seedream layer-editor documents.
|
||||
package layercompositions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConflict = errors.New("layer composition version conflict")
|
||||
ErrInvalid = errors.New("invalid layer composition")
|
||||
ErrAssetNotFound = errors.New("layer composition asset not found")
|
||||
)
|
||||
|
||||
type Box [4]float64
|
||||
|
||||
type Layer struct {
|
||||
ID string `json:"id"`
|
||||
AssetID string `json:"assetId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Visible bool `json:"visible"`
|
||||
Box Box `json:"box"`
|
||||
Rotation float64 `json:"rotation"`
|
||||
Source string `json:"source"`
|
||||
EditJobID string `json:"editJobId,omitempty"`
|
||||
OriginalAssetID string `json:"originalAssetId,omitempty"`
|
||||
}
|
||||
|
||||
type Composition struct {
|
||||
JobID string `json:"jobId"`
|
||||
OwnerID string `json:"-"`
|
||||
BaseAssetID string `json:"baseAssetId"`
|
||||
BaseVisible bool `json:"baseVisible"`
|
||||
Layers []Layer `json:"layers"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
FindLayerComposition(context.Context, string, string) (Composition, bool, error)
|
||||
SaveLayerComposition(context.Context, Composition, int64) (Composition, error)
|
||||
}
|
||||
|
||||
type AssetCatalog interface {
|
||||
GetOwner(context.Context, string, string) (assets.Asset, bool, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
assets AssetCatalog
|
||||
}
|
||||
|
||||
func NewService(repository Repository, catalog AssetCatalog) *Service {
|
||||
return &Service{repository: repository, assets: catalog}
|
||||
}
|
||||
|
||||
func (service *Service) Get(ctx context.Context, ownerID, jobID string) (Composition, bool, error) {
|
||||
if strings.TrimSpace(ownerID) == "" || strings.TrimSpace(jobID) == "" {
|
||||
return Composition{}, false, fmt.Errorf("%w: ownerId and jobId are required", ErrInvalid)
|
||||
}
|
||||
return service.repository.FindLayerComposition(ctx, ownerID, jobID)
|
||||
}
|
||||
|
||||
func (service *Service) Save(ctx context.Context, ownerID, jobID string, expectedVersion int64, document Composition) (Composition, error) {
|
||||
document.OwnerID = strings.TrimSpace(ownerID)
|
||||
document.JobID = strings.TrimSpace(jobID)
|
||||
document.Version = 0
|
||||
if expectedVersion < 0 {
|
||||
return Composition{}, fmt.Errorf("%w: version must not be negative", ErrInvalid)
|
||||
}
|
||||
if err := normalizeAndValidate(&document); err != nil {
|
||||
return Composition{}, err
|
||||
}
|
||||
assetIDs := []string{document.BaseAssetID}
|
||||
for _, layer := range document.Layers {
|
||||
assetIDs = append(assetIDs, layer.AssetID)
|
||||
if layer.OriginalAssetID != "" {
|
||||
assetIDs = append(assetIDs, layer.OriginalAssetID)
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, assetID := range assetIDs {
|
||||
if seen[assetID] {
|
||||
continue
|
||||
}
|
||||
seen[assetID] = true
|
||||
asset, found, err := service.assets.GetOwner(ctx, document.OwnerID, assetID)
|
||||
if err != nil {
|
||||
return Composition{}, fmt.Errorf("load layer asset: %w", err)
|
||||
}
|
||||
if !found || (asset.Kind != assets.KindImage && asset.Kind != assets.KindReference && asset.Kind != assets.KindMask) {
|
||||
return Composition{}, fmt.Errorf("%w: %s", ErrAssetNotFound, assetID)
|
||||
}
|
||||
}
|
||||
return service.repository.SaveLayerComposition(ctx, document, expectedVersion)
|
||||
}
|
||||
|
||||
func normalizeAndValidate(document *Composition) error {
|
||||
if document.OwnerID == "" || document.JobID == "" || strings.TrimSpace(document.BaseAssetID) == "" {
|
||||
return fmt.Errorf("%w: ownerId, jobId, and baseAssetId are required", ErrInvalid)
|
||||
}
|
||||
document.BaseAssetID = strings.TrimSpace(document.BaseAssetID)
|
||||
if len(document.Layers) > 100 {
|
||||
return fmt.Errorf("%w: at most 100 layers are supported", ErrInvalid)
|
||||
}
|
||||
if document.Layers == nil {
|
||||
document.Layers = []Layer{}
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for index := range document.Layers {
|
||||
layer := &document.Layers[index]
|
||||
layer.ID = strings.TrimSpace(layer.ID)
|
||||
layer.AssetID = strings.TrimSpace(layer.AssetID)
|
||||
layer.Name = strings.TrimSpace(layer.Name)
|
||||
layer.Description = strings.TrimSpace(layer.Description)
|
||||
layer.Source = strings.TrimSpace(layer.Source)
|
||||
layer.EditJobID = strings.TrimSpace(layer.EditJobID)
|
||||
layer.OriginalAssetID = strings.TrimSpace(layer.OriginalAssetID)
|
||||
if layer.ID == "" || layer.AssetID == "" || layer.Name == "" {
|
||||
return fmt.Errorf("%w: every layer requires id, assetId, and name", ErrInvalid)
|
||||
}
|
||||
if ids[layer.ID] {
|
||||
return fmt.Errorf("%w: duplicate layer id %s", ErrInvalid, layer.ID)
|
||||
}
|
||||
ids[layer.ID] = true
|
||||
if len([]rune(layer.Name)) > 120 || len([]rune(layer.Description)) > 500 {
|
||||
return fmt.Errorf("%w: layer text is too long", ErrInvalid)
|
||||
}
|
||||
if !validLayerSource(layer.Source) {
|
||||
return fmt.Errorf("%w: unsupported layer source %s", ErrInvalid, layer.Source)
|
||||
}
|
||||
if err := validateBox(layer.Box); err != nil {
|
||||
return fmt.Errorf("%w: layer %s: %v", ErrInvalid, layer.ID, err)
|
||||
}
|
||||
if math.IsNaN(layer.Rotation) || math.IsInf(layer.Rotation, 0) {
|
||||
return fmt.Errorf("%w: layer rotation must be finite", ErrInvalid)
|
||||
}
|
||||
layer.Rotation = normalizeRotation(layer.Rotation)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validLayerSource(value string) bool {
|
||||
switch value {
|
||||
case "provider", "upload", "library", "seedream-edit":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateBox(box Box) error {
|
||||
for _, value := range box {
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value > 1000 {
|
||||
return errors.New("box coordinates must be finite values between 0 and 1000")
|
||||
}
|
||||
}
|
||||
if box[2]-box[0] < 1 || box[3]-box[1] < 1 {
|
||||
return errors.New("box must have a positive width and height")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeRotation(value float64) float64 {
|
||||
value = math.Mod(value, 360)
|
||||
if value > 180 {
|
||||
value -= 360
|
||||
}
|
||||
if value <= -180 {
|
||||
value += 360
|
||||
}
|
||||
return math.Round(value*1000) / 1000
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package layercompositions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets"
|
||||
)
|
||||
|
||||
type compositionRepositoryStub struct {
|
||||
value Composition
|
||||
found bool
|
||||
}
|
||||
|
||||
func (repository *compositionRepositoryStub) FindLayerComposition(_ context.Context, ownerID, jobID string) (Composition, bool, error) {
|
||||
if !repository.found || repository.value.OwnerID != ownerID || repository.value.JobID != jobID {
|
||||
return Composition{}, false, nil
|
||||
}
|
||||
return repository.value, true, nil
|
||||
}
|
||||
|
||||
func (repository *compositionRepositoryStub) SaveLayerComposition(_ context.Context, value Composition, expectedVersion int64) (Composition, error) {
|
||||
if repository.found && repository.value.Version != expectedVersion {
|
||||
return Composition{}, ErrConflict
|
||||
}
|
||||
if !repository.found && expectedVersion != 0 {
|
||||
return Composition{}, ErrConflict
|
||||
}
|
||||
now := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC)
|
||||
value.Version = expectedVersion + 1
|
||||
value.CreatedAt = now
|
||||
value.UpdatedAt = now
|
||||
repository.value = value
|
||||
repository.found = true
|
||||
return value, nil
|
||||
}
|
||||
|
||||
type assetCatalogStub map[string]assets.Asset
|
||||
|
||||
func (catalog assetCatalogStub) GetOwner(_ context.Context, ownerID, assetID string) (assets.Asset, bool, error) {
|
||||
asset, found := catalog[assetID]
|
||||
return asset, found && asset.OwnerID == ownerID, nil
|
||||
}
|
||||
|
||||
func TestServiceSavesOwnedCompositionAndNormalizesRotation(t *testing.T) {
|
||||
repository := &compositionRepositoryStub{}
|
||||
catalog := assetCatalogStub{
|
||||
"base": {ID: "base", OwnerID: "owner", Kind: assets.KindImage},
|
||||
"layer": {ID: "layer", OwnerID: "owner", Kind: assets.KindImage},
|
||||
}
|
||||
service := NewService(repository, catalog)
|
||||
|
||||
saved, err := service.Save(context.Background(), "owner", "job-1", 0, Composition{
|
||||
BaseAssetID: "base", BaseVisible: true,
|
||||
Layers: []Layer{{ID: "layer-1", AssetID: "layer", Name: "主体", Visible: true, Box: Box{100, 100, 900, 900}, Rotation: 450, Source: "upload"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Version != 1 || saved.OwnerID != "owner" || saved.JobID != "job-1" || saved.Layers[0].Rotation != 90 {
|
||||
t.Fatalf("saved=%#v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsForeignOrMalformedLayerAssets(t *testing.T) {
|
||||
service := NewService(&compositionRepositoryStub{}, assetCatalogStub{
|
||||
"base": {ID: "base", OwnerID: "owner", Kind: assets.KindImage},
|
||||
"foreign": {ID: "foreign", OwnerID: "another-owner", Kind: assets.KindImage},
|
||||
})
|
||||
_, err := service.Save(context.Background(), "owner", "job-1", 0, Composition{
|
||||
BaseAssetID: "base", BaseVisible: true,
|
||||
Layers: []Layer{{ID: "layer-1", AssetID: "foreign", Name: "主体", Visible: true, Box: Box{100, 100, 900, 900}, Source: "library"}},
|
||||
})
|
||||
if !errors.Is(err, ErrAssetNotFound) {
|
||||
t.Fatalf("foreign asset err=%v", err)
|
||||
}
|
||||
|
||||
_, err = service.Save(context.Background(), "owner", "job-1", 0, Composition{
|
||||
BaseAssetID: "base", BaseVisible: true,
|
||||
Layers: []Layer{{ID: "layer-1", AssetID: "base", Name: "主体", Visible: true, Box: Box{900, 100, 100, 900}, Source: "provider"}},
|
||||
})
|
||||
if !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("invalid box err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUsesOptimisticVersion(t *testing.T) {
|
||||
repository := &compositionRepositoryStub{found: true, value: Composition{JobID: "job-1", OwnerID: "owner", Version: 3}}
|
||||
service := NewService(repository, assetCatalogStub{"base": {ID: "base", OwnerID: "owner", Kind: assets.KindImage}})
|
||||
_, err := service.Save(context.Background(), "owner", "job-1", 2, Composition{BaseAssetID: "base", BaseVisible: true})
|
||||
if !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/layercompositions"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/templates"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/usage"
|
||||
)
|
||||
@@ -43,6 +44,7 @@ type Store struct {
|
||||
organizations map[string]administration.Organization
|
||||
assets map[string]assets.Asset
|
||||
jobs map[string]jobs.Job
|
||||
compositions map[string]layercompositions.Composition
|
||||
templates map[string]templates.Template
|
||||
usageEvents map[string]usage.Event
|
||||
usageJobIDs map[string]string
|
||||
@@ -58,7 +60,7 @@ type walletRecord struct {
|
||||
}
|
||||
|
||||
func New(options ...Option) *Store {
|
||||
s := &Store{now: time.Now, accounts: map[string]administration.Account{}, organizations: map[string]administration.Organization{}, assets: map[string]assets.Asset{}, jobs: map[string]jobs.Job{}, templates: map[string]templates.Template{}, usageEvents: map[string]usage.Event{}, usageJobIDs: map[string]string{}, priceRules: map[string]billing.PriceRule{}, wallets: map[string]billing.Wallet{}, postings: map[string]walletRecord{}}
|
||||
s := &Store{now: time.Now, accounts: map[string]administration.Account{}, organizations: map[string]administration.Organization{}, assets: map[string]assets.Asset{}, jobs: map[string]jobs.Job{}, compositions: map[string]layercompositions.Composition{}, templates: map[string]templates.Template{}, usageEvents: map[string]usage.Event{}, usageJobIDs: map[string]string{}, priceRules: map[string]billing.PriceRule{}, wallets: map[string]billing.Wallet{}, postings: map[string]walletRecord{}}
|
||||
for _, option := range options {
|
||||
option(s)
|
||||
}
|
||||
@@ -110,6 +112,10 @@ func cloneJob(j jobs.Job) jobs.Job {
|
||||
}
|
||||
return j
|
||||
}
|
||||
func cloneLayerComposition(composition layercompositions.Composition) layercompositions.Composition {
|
||||
composition.Layers = append([]layercompositions.Layer(nil), composition.Layers...)
|
||||
return composition
|
||||
}
|
||||
func cloneMap(m map[string]any) map[string]any {
|
||||
if m == nil {
|
||||
return nil
|
||||
@@ -370,8 +376,47 @@ func (s *Store) DeleteJob(_ context.Context, id string) error {
|
||||
return fmt.Errorf("generation job not found: %s", id)
|
||||
}
|
||||
delete(s.jobs, id)
|
||||
delete(s.compositions, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Seedream layer compositions.
|
||||
func (s *Store) FindLayerComposition(_ context.Context, ownerID, jobID string) (layercompositions.Composition, bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
composition, found := s.compositions[jobID]
|
||||
if !found || composition.OwnerID != ownerID {
|
||||
return layercompositions.Composition{}, false, nil
|
||||
}
|
||||
return cloneLayerComposition(composition), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveLayerComposition(_ context.Context, composition layercompositions.Composition, expectedVersion int64) (layercompositions.Composition, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
existing, found := s.compositions[composition.JobID]
|
||||
if found {
|
||||
if existing.OwnerID != composition.OwnerID || existing.Version != expectedVersion {
|
||||
return layercompositions.Composition{}, layercompositions.ErrConflict
|
||||
}
|
||||
composition.Version = existing.Version + 1
|
||||
composition.CreatedAt = existing.CreatedAt
|
||||
} else {
|
||||
if expectedVersion != 0 {
|
||||
return layercompositions.Composition{}, layercompositions.ErrConflict
|
||||
}
|
||||
job, jobFound := s.jobs[composition.JobID]
|
||||
if !jobFound || job.OwnerID != composition.OwnerID {
|
||||
return layercompositions.Composition{}, fmt.Errorf("generation job not found: %s", composition.JobID)
|
||||
}
|
||||
composition.Version = 1
|
||||
composition.CreatedAt = s.now().UTC()
|
||||
}
|
||||
composition.UpdatedAt = s.now().UTC()
|
||||
s.compositions[composition.JobID] = cloneLayerComposition(composition)
|
||||
return cloneLayerComposition(composition), nil
|
||||
}
|
||||
|
||||
func (s *Store) ClaimJobs(_ context.Context, worker string, limit, timeoutSeconds int) ([]jobs.Job, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -12,6 +12,7 @@ WITH required_table_privileges(table_name, privilege_name) AS (
|
||||
VALUES
|
||||
('assets', 'SELECT'), ('assets', 'INSERT'), ('assets', 'DELETE'),
|
||||
('generation_jobs', 'SELECT'), ('generation_jobs', 'INSERT'), ('generation_jobs', 'UPDATE'), ('generation_jobs', 'DELETE'),
|
||||
('seedream_layer_compositions', 'SELECT'), ('seedream_layer_compositions', 'INSERT'), ('seedream_layer_compositions', 'UPDATE'), ('seedream_layer_compositions', 'DELETE'),
|
||||
('usage_events', 'SELECT'), ('usage_events', 'INSERT'), ('usage_events', 'UPDATE'),
|
||||
('projects', 'SELECT'), ('projects', 'UPDATE'),
|
||||
('image_templates', 'SELECT'), ('image_templates', 'INSERT'), ('image_templates', 'UPDATE'), ('image_templates', 'DELETE'),
|
||||
|
||||
@@ -266,6 +266,8 @@ func (rows *jobRows) Scan(dest ...any) error {
|
||||
*target = value.(int)
|
||||
case *int64:
|
||||
*target = value.(int64)
|
||||
case *bool:
|
||||
*target = value.(bool)
|
||||
case *time.Time:
|
||||
if value != nil {
|
||||
*target = value.(time.Time)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/layercompositions"
|
||||
)
|
||||
|
||||
const FindLayerCompositionSQL = `SELECT job_id, owner_id, base_asset_id, base_visible, layers, version, created_at, updated_at
|
||||
FROM public.seedream_layer_compositions
|
||||
WHERE owner_id = $1::text AND job_id = $2::text
|
||||
LIMIT 1`
|
||||
|
||||
const SaveLayerCompositionSQL = `WITH updated AS (
|
||||
UPDATE public.seedream_layer_compositions SET
|
||||
base_asset_id = $3::text,
|
||||
base_visible = $4::boolean,
|
||||
layers = $5::jsonb,
|
||||
version = seedream_layer_compositions.version + 1,
|
||||
updated_at = now()
|
||||
WHERE job_id = $1::text
|
||||
AND owner_id = $2::text
|
||||
AND version = $6::bigint
|
||||
AND $6::bigint > 0
|
||||
RETURNING job_id, owner_id, base_asset_id, base_visible, layers, version, created_at, updated_at
|
||||
), inserted AS (
|
||||
INSERT INTO public.seedream_layer_compositions (
|
||||
job_id, owner_id, base_asset_id, base_visible, layers, version, created_at, updated_at
|
||||
)
|
||||
SELECT $1::text, $2::text, $3::text, $4::boolean, $5::jsonb, 1, now(), now()
|
||||
WHERE $6::bigint = 0
|
||||
ON CONFLICT (job_id) DO NOTHING
|
||||
RETURNING job_id, owner_id, base_asset_id, base_visible, layers, version, created_at, updated_at
|
||||
)
|
||||
SELECT job_id, owner_id, base_asset_id, base_visible, layers, version, created_at, updated_at FROM updated
|
||||
UNION ALL
|
||||
SELECT job_id, owner_id, base_asset_id, base_visible, layers, version, created_at, updated_at FROM inserted
|
||||
LIMIT 1`
|
||||
|
||||
func (db *Database) FindLayerComposition(ctx context.Context, ownerID, jobID string) (layercompositions.Composition, bool, error) {
|
||||
if err := db.available(); err != nil {
|
||||
return layercompositions.Composition{}, false, err
|
||||
}
|
||||
rows, err := db.querier.Query(ctx, FindLayerCompositionSQL, ownerID, jobID)
|
||||
if err != nil {
|
||||
return layercompositions.Composition{}, false, fmt.Errorf("find layer composition: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
if err := rows.Err(); err != nil {
|
||||
return layercompositions.Composition{}, false, fmt.Errorf("read layer composition: %w", err)
|
||||
}
|
||||
return layercompositions.Composition{}, false, nil
|
||||
}
|
||||
composition, err := scanLayerComposition(rows)
|
||||
if err != nil {
|
||||
return layercompositions.Composition{}, false, err
|
||||
}
|
||||
return composition, true, rows.Err()
|
||||
}
|
||||
|
||||
func (db *Database) SaveLayerComposition(ctx context.Context, composition layercompositions.Composition, expectedVersion int64) (layercompositions.Composition, error) {
|
||||
if err := db.available(); err != nil {
|
||||
return layercompositions.Composition{}, err
|
||||
}
|
||||
layers, err := json.Marshal(composition.Layers)
|
||||
if err != nil {
|
||||
return layercompositions.Composition{}, fmt.Errorf("encode layer composition: %w", err)
|
||||
}
|
||||
rows, err := db.querier.Query(ctx, SaveLayerCompositionSQL,
|
||||
composition.JobID, composition.OwnerID, composition.BaseAssetID,
|
||||
composition.BaseVisible, layers, expectedVersion,
|
||||
)
|
||||
if err != nil {
|
||||
return layercompositions.Composition{}, fmt.Errorf("save layer composition: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
if err := rows.Err(); err != nil {
|
||||
return layercompositions.Composition{}, fmt.Errorf("read saved layer composition: %w", err)
|
||||
}
|
||||
return layercompositions.Composition{}, layercompositions.ErrConflict
|
||||
}
|
||||
saved, err := scanLayerComposition(rows)
|
||||
if err != nil {
|
||||
return layercompositions.Composition{}, err
|
||||
}
|
||||
return saved, rows.Err()
|
||||
}
|
||||
|
||||
func scanLayerComposition(rows Rows) (layercompositions.Composition, error) {
|
||||
var composition layercompositions.Composition
|
||||
var rawLayers []byte
|
||||
if err := rows.Scan(
|
||||
&composition.JobID, &composition.OwnerID, &composition.BaseAssetID,
|
||||
&composition.BaseVisible, &rawLayers, &composition.Version,
|
||||
&composition.CreatedAt, &composition.UpdatedAt,
|
||||
); err != nil {
|
||||
return layercompositions.Composition{}, fmt.Errorf("scan layer composition: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(rawLayers, &composition.Layers); err != nil {
|
||||
return layercompositions.Composition{}, fmt.Errorf("decode layer composition: %w", err)
|
||||
}
|
||||
if composition.Layers == nil {
|
||||
composition.Layers = []layercompositions.Layer{}
|
||||
}
|
||||
return composition, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/layercompositions"
|
||||
)
|
||||
|
||||
func TestLayerCompositionAdapterScopesReadsAndUsesOptimisticSave(t *testing.T) {
|
||||
now := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC)
|
||||
row := []any{"job-1", "owner", "base", true, []byte(`[{"id":"layer-1","assetId":"asset-1","name":"主体","visible":true,"box":[100,100,900,900],"rotation":0,"source":"provider"}]`), int64(2), now, now}
|
||||
querier := &jobQuerier{rows: &jobRows{rows: [][]any{row}}}
|
||||
database := NewDatabase(Config{Backend: BackendPostgres}, querier)
|
||||
|
||||
composition, found, err := database.FindLayerComposition(context.Background(), "owner", "job-1")
|
||||
if err != nil || !found || composition.Version != 2 || len(composition.Layers) != 1 {
|
||||
t.Fatalf("composition=%#v found=%v err=%v", composition, found, err)
|
||||
}
|
||||
if querier.query != FindLayerCompositionSQL || !reflect.DeepEqual(querier.args, []any{"owner", "job-1"}) {
|
||||
t.Fatalf("query=%q args=%#v", querier.query, querier.args)
|
||||
}
|
||||
|
||||
savedRow := append([]any(nil), row...)
|
||||
savedRow[5] = int64(3)
|
||||
querier.rows = &jobRows{rows: [][]any{savedRow}}
|
||||
saved, err := database.SaveLayerComposition(context.Background(), layercompositions.Composition{
|
||||
JobID: "job-1", OwnerID: "owner", BaseAssetID: "base", BaseVisible: true,
|
||||
Layers: []layercompositions.Layer{{ID: "layer-1", AssetID: "asset-1", Name: "主体", Visible: true, Box: layercompositions.Box{100, 100, 900, 900}, Source: "provider"}},
|
||||
}, 2)
|
||||
if err != nil || saved.Version != 3 {
|
||||
t.Fatalf("saved=%#v err=%v", saved, err)
|
||||
}
|
||||
if querier.query != SaveLayerCompositionSQL || len(querier.args) != 6 || querier.args[5] != int64(2) {
|
||||
t.Fatalf("query=%q args=%#v", querier.query, querier.args)
|
||||
}
|
||||
for _, fragment := range []string{"WITH updated AS", "WHERE $6::bigint = 0", "version = seedream_layer_compositions.version + 1", "AND version = $6::bigint", "ON CONFLICT (job_id) DO NOTHING"} {
|
||||
if !strings.Contains(SaveLayerCompositionSQL, fragment) {
|
||||
t.Fatalf("upsert omits %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayerCompositionSaveSQLKeepsExistingVersionedUpdatesReachable(t *testing.T) {
|
||||
// A top-level INSERT ... SELECT ... WHERE expectedVersion = 0 filters the
|
||||
// source row before ON CONFLICT can run. In that shape every normal update
|
||||
// (expectedVersion > 0) returns no row and is reported as a false conflict.
|
||||
if strings.Contains(SaveLayerCompositionSQL, "SELECT $1::text, $2::text, $3::text, $4::boolean, $5::jsonb, 1, now(), now()\nWHERE $6::bigint = 0\nON CONFLICT") {
|
||||
t.Fatal("versioned updates are unreachable because the INSERT source is filtered before ON CONFLICT")
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
"WITH updated AS",
|
||||
"version = $6::bigint",
|
||||
"$6::bigint > 0",
|
||||
"WHERE $6::bigint = 0",
|
||||
"UNION ALL",
|
||||
} {
|
||||
if !strings.Contains(SaveLayerCompositionSQL, fragment) {
|
||||
t.Fatalf("save query does not preserve both update and create paths; missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayerCompositionAdapterReturnsConflictWhenVersionUpdateMatchesNoRow(t *testing.T) {
|
||||
querier := &jobQuerier{rows: &jobRows{}}
|
||||
database := NewDatabase(Config{Backend: BackendPostgres}, querier)
|
||||
_, err := database.SaveLayerComposition(context.Background(), layercompositions.Composition{JobID: "job-1", OwnerID: "owner", BaseAssetID: "base", Layers: []layercompositions.Layer{}}, 4)
|
||||
if !errors.Is(err, layercompositions.ErrConflict) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user