修复了一点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

@@ -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 {
+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...}"},
}
@@ -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)
}
}
+46 -1
View File
@@ -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()
+1
View File
@@ -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'),
+2
View File
@@ -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)
}
}