若干更新迭代

This commit is contained in:
andy committed 2026-10-02 19:56:32 +08:00
1 parent deb9cc5bcf
commit 4ac786aafb
64 files changed
+3251 -291

No files matched your search

+34
View File
@@ -36,6 +36,8 @@ func (h *billingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/billing":
h.billing(w, r)
case r.URL.Path == "/api/billing/balance":
h.balance(w, r)
case r.URL.Path == "/api/billing/quote":
h.quote(w, r)
case r.URL.Path == "/api/admin/billing":
@@ -53,6 +55,38 @@ func (h *billingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
func (h *billingHandler) balance(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if !allow(w, r, http.MethodGet) {
return
}
session, ok := h.authorize(w, r, PlatformApp)
if !ok {
return
}
organizationID := session.User.OrganizationID
if organizationID == "" {
writeAPIError(w, 422, "当前账号未绑定组织。")
return
}
if h.service == nil {
writeAPIError(w, 500, "服务器内部错误。")
return
}
wallet, err := h.service.Wallet(r.Context(), organizationID)
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, 200, struct {
Organization map[string]string `json:"organization"`
Wallet billing.Wallet `json:"wallet"`
}{
Organization: map[string]string{"id": organizationID, "name": first(session.User.OrganizationName, organizationID)},
Wallet: wallet,
})
}
func (h *billingHandler) billing(w http.ResponseWriter, r *http.Request) {
if !allow(w, r, http.MethodGet) {
return
+96
View File
@@ -28,6 +28,96 @@ func TestBillingMemberRoutesUseRefreshedSessionScope(t *testing.T) {
}
}
func TestBillingBalanceUsesOnlyRefreshedSessionOrganization(t *testing.T) {
service := &billingHTTPServiceStub{}
h := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "member", ClientID: "platform", OrganizationID: "db-org", OrganizationName: "DB Org", Role: "user"}}, service, nil)
response := serveJSON(t, h, http.MethodGet, "/api/billing/balance?organizationId=attacker&orgID=attacker", nil)
if response.Code != 200 || response.Header().Get("Cache-Control") != "no-store" || service.walletOrganization != "db-org" {
t.Fatalf("status=%d cache=%q scope=%q body=%s", response.Code, response.Header().Get("Cache-Control"), service.walletOrganization, response.Body.String())
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload) != 2 || string(payload["organization"]) != `{"id":"db-org","name":"DB Org"}` {
t.Fatalf("unexpected organization or extra fields: %s", response.Body.String())
}
var wallet billing.Wallet
if err := json.Unmarshal(payload["wallet"], &wallet); err != nil || wallet.OrganizationID != "db-org" || wallet.BalanceFen != 0 {
t.Fatalf("zero wallet=%+v err=%v body=%s", wallet, err, response.Body.String())
}
}
func TestBillingBalanceRejectsUnboundAndUnauthenticatedSessions(t *testing.T) {
service := &billingHTTPServiceStub{}
h := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "member", ClientID: "platform", Role: "user"}}, service, nil)
if got := serveJSON(t, h, http.MethodGet, "/api/billing/balance?organizationId=other", nil); got.Code != 422 || service.walletOrganization != "" {
t.Fatalf("unbound status=%d scope=%q body=%s", got.Code, service.walletOrganization, got.Body.String())
}
for _, credential := range []struct{ name, header, value string }{
{"missing", "", ""}, {"bearer", "Authorization", "Bearer other"}, {"api-key", "X-API-Key", "other"},
} {
t.Run(credential.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/api/billing/balance", nil)
if credential.header != "" {
request.Header.Set(credential.header, credential.value)
}
response := httptest.NewRecorder()
h.ServeHTTP(response, request)
if response.Code != 401 || service.walletOrganization != "" {
t.Fatalf("status=%d scope=%q body=%s", response.Code, service.walletOrganization, response.Body.String())
}
})
}
authorizer, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &platformSessionResolverStub{outcome: "unauthenticated"})
if err != nil {
t.Fatal(err)
}
invalidSession := NewBillingHandler(authorizer, service, nil)
if got := serveJSON(t, invalidSession, http.MethodGet, "/api/billing/balance", nil); got.Code != 401 || service.walletOrganization != "" {
t.Fatalf("invalid session status=%d scope=%q body=%s", got.Code, service.walletOrganization, got.Body.String())
}
}
func TestBillingBalanceHidesStoreErrors(t *testing.T) {
service := &billingHTTPServiceStub{err: errors.New("database secret")}
h := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "member", ClientID: "platform", OrganizationID: "org", Role: "user"}}, service, nil)
got := serveJSON(t, h, http.MethodGet, "/api/billing/balance", nil)
if got.Code != 500 || bytes.Contains(got.Body.Bytes(), []byte("database secret")) || got.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("status=%d body=%s", got.Code, got.Body.String())
}
}
func TestBillingBalanceReadsOnlyWallet(t *testing.T) {
store := &balanceOnlyStore{}
h := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "member", ClientID: "platform", OrganizationID: "org", Role: "user"}}, billing.NewService(store, nil), nil)
got := serveJSON(t, h, http.MethodGet, "/api/billing/balance", nil)
if got.Code != 200 || store.walletCalls != 1 || store.organizationID != "org" {
t.Fatalf("status=%d walletCalls=%d org=%q body=%s", got.Code, store.walletCalls, store.organizationID, got.Body.String())
}
}
type balanceOnlyStore struct {
emptyBillingStore
walletCalls int
organizationID string
}
func (s *balanceOnlyStore) BillingWallet(_ context.Context, organizationID string) (billing.Wallet, error) {
s.walletCalls++
s.organizationID = organizationID
return billing.Wallet{OrganizationID: organizationID, BalanceFen: 0, Currency: billing.CurrencyCNY}, nil
}
func (*balanceOnlyStore) BillingLedger(context.Context, string, string, int) ([]billing.LedgerEntry, error) {
panic("balance endpoint accessed ledger")
}
func (*balanceOnlyStore) ListBillingPriceRules(context.Context, bool) ([]billing.PriceRule, error) {
panic("balance endpoint accessed catalog")
}
func (*balanceOnlyStore) BillingWallets(context.Context) ([]billing.Wallet, error) {
panic("balance endpoint accessed all wallets")
}
func TestBillingOverviewResponsesEncodeEmptyCollectionsAsArrays(t *testing.T) {
service := billing.NewService(&emptyBillingStore{}, nil)
member := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "user", ClientID: "platform", OrganizationID: "org", Role: "user"}}, service, nil)
@@ -135,6 +225,7 @@ func TestBillingHandlerRejectsWrongMethods(t *testing.T) {
type billingHTTPServiceStub struct {
overviewOrganization, overviewAccount string
walletOrganization string
quote billing.QuoteCommand
adjustment billing.AdjustmentCommand
pricePatch billing.PricePatch
@@ -143,6 +234,11 @@ type billingHTTPServiceStub struct {
err error
}
func (s *billingHTTPServiceStub) Wallet(_ context.Context, organizationID string) (billing.Wallet, error) {
s.walletOrganization = organizationID
return billing.Wallet{OrganizationID: organizationID, Currency: billing.CurrencyCNY}, s.err
}
func (s *billingHTTPServiceStub) Overview(_ context.Context, organizationID, accountID string) (billing.Overview, error) {
s.overviewOrganization, s.overviewAccount = organizationID, accountID
return billing.Overview{Wallet: billing.Wallet{OrganizationID: organizationID, Currency: billing.CurrencyCNY}}, s.err
@@ -0,0 +1,60 @@
package httpapi
import (
"encoding/json"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs"
)
// The diagnostics stored with a job are intended for platform administrators.
// Project a copy at the HTTP boundary so neither the persisted job nor a
// caller's shared RawMessage buffer is changed by a response to another role.
func projectJobForViewer(job jobs.Job, diagnosticsAllowed bool) jobs.Job {
if diagnosticsAllowed || len(job.ResponsePayload) == 0 {
return job
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(job.ResponsePayload, &payload); err != nil || payload == nil {
job.ResponsePayload = nil
return job
}
for _, key := range []string{"providerError", "errorCode", "errorDetail", "requestId"} {
delete(payload, key)
}
if job.Status == jobs.StatusFailed || job.Status == jobs.StatusCancelled || job.Status == jobs.StatusExpired {
// Historical failures may contain an unsanitized provider body. The
// user-facing explanation is job.Error.Message, never this raw body.
delete(payload, "raw")
delete(payload, "errorMessage")
}
if len(payload) == 0 {
job.ResponsePayload = nil
return job
}
projected, err := json.Marshal(payload)
if err != nil {
job.ResponsePayload = nil
return job
}
job.ResponsePayload = projected
return job
}
func projectJobsForViewer(items []jobs.Job, diagnosticsAllowed bool) []jobs.Job {
if diagnosticsAllowed || len(items) == 0 {
return items
}
projected := make([]jobs.Job, len(items))
for index := range items {
projected[index] = projectJobForViewer(items[index], false)
}
return projected
}
func canViewJobDiagnostics(session identity.Session) bool {
if session.AuthMode != identity.AuthModeAdmin {
return false
}
return session.User.Role == "super_admin" || session.User.Role == "organization_admin"
}
@@ -0,0 +1,151 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/publicapi"
)
const jobWithDiagnosticsPayload = `{"providerError":{"status":400,"code":"PrivateCode","requestId":"private-request","detail":"private diagnostic","phase":"submit"},"errorCode":"PrivateCode","errorDetail":"private diagnostic","requestId":"private-request","errorMessage":"private diagnostic","raw":{"providerMessage":"private diagnostic"},"outputUrls":["https://example.test/result.png"],"usage":{"images":2},"layers":[{"type":"image","url":"https://example.test/layer.png"}]}`
func TestProjectJobForViewerKeepsSuccessMetadataWithoutMutatingStoredPayload(t *testing.T) {
original := jobs.Job{Status: jobs.StatusFailed, ResponsePayload: json.RawMessage(jobWithDiagnosticsPayload)}
viewer := projectJobForViewer(original, false)
if got := string(original.ResponsePayload); got != jobWithDiagnosticsPayload {
t.Fatalf("original responsePayload was mutated: %s", got)
}
if strings.Contains(string(viewer.ResponsePayload), "PrivateCode") || strings.Contains(string(viewer.ResponsePayload), "private diagnostic") || strings.Contains(string(viewer.ResponsePayload), "private-request") {
t.Fatalf("viewer received diagnostic: %s", viewer.ResponsePayload)
}
for _, field := range []string{"outputUrls", "usage", "layers"} {
var decoded map[string]json.RawMessage
if err := json.Unmarshal(viewer.ResponsePayload, &decoded); err != nil || len(decoded[field]) == 0 {
t.Fatalf("successful %s missing after projection: %s (%v)", field, viewer.ResponsePayload, err)
}
}
for _, status := range []jobs.Status{jobs.StatusFailed, jobs.StatusCancelled, jobs.StatusExpired} {
legacy := original
legacy.Status = status
got := projectJobForViewer(legacy, false)
var decoded map[string]json.RawMessage
if err := json.Unmarshal(got.ResponsePayload, &decoded); err != nil {
t.Fatalf("status %s has invalid projected payload: %v", status, err)
}
for _, key := range []string{"raw", "errorMessage"} {
if _, exists := decoded[key]; exists {
t.Fatalf("status %s exposed historical %s: %s", status, key, got.ResponsePayload)
}
}
}
if got := projectJobForViewer(original, true); string(got.ResponsePayload) != jobWithDiagnosticsPayload {
t.Fatalf("administrator response changed: %s", got.ResponsePayload)
}
succeeded := original
succeeded.Status = jobs.StatusSucceeded
if got := projectJobForViewer(succeeded, false); !bytes.Contains(got.ResponsePayload, []byte(`"raw"`)) {
t.Fatalf("successful provider metadata was lost: %s", got.ResponsePayload)
}
if got := projectJobForViewer(jobs.Job{ResponsePayload: []byte(`{"providerError":{"detail":"private"}}`)}, false); got.ResponsePayload != nil {
t.Fatalf("diagnostics-only payload should be omitted: %s", got.ResponsePayload)
}
}
func TestJobsHTTPProjectsProviderDiagnosticsByRoleAndForPublicAPI(t *testing.T) {
for _, testCase := range []struct {
name string
role string
authMode identity.AuthMode
wantDetail bool
}{
{name: "ordinary user", role: "user", authMode: identity.AuthModeUser},
{name: "organization administrator", role: "organization_admin", authMode: identity.AuthModeAdmin, wantDetail: true},
{name: "platform administrator", role: "super_admin", authMode: identity.AuthModeAdmin, wantDetail: true},
} {
t.Run(testCase.name, func(t *testing.T) {
store := &httpJobStore{values: map[string]jobs.Job{
"failed": {ID: "failed", OwnerID: "owner", Capability: "image.generate", Status: jobs.StatusFailed, ResponsePayload: json.RawMessage(jobWithDiagnosticsPayload), Error: &jobs.JobError{Code: "PrivateCode", Message: "请求被服务商拒绝"}},
}}
session := identity.Session{AuthMode: testCase.authMode, User: identity.User{ID: "owner", Role: testCase.role}}
platform, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &fixedSessionResolver{session: session})
if err != nil {
t.Fatal(err)
}
h, err := NewJobsHandler(JobsDependencies{Service: jobs.NewService(store, time.Now), Platform: platform, Public: publicapi.NewAuthenticator(publicapi.Config{APIKeys: "agent-a:secret"}), Builder: &jobBuilderStub{}}, JobsConfig{})
if err != nil {
t.Fatal(err)
}
for _, path := range []string{"/api/generations/image", "/api/generations/image/failed"} {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "session-cookie"})
response := httptest.NewRecorder()
h.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("%s status=%d body=%s", path, response.Code, response.Body.String())
}
assertJobResponseProjection(t, response.Body.Bytes(), testCase.wantDetail)
}
if got := string(store.values["failed"].ResponsePayload); got != jobWithDiagnosticsPayload {
t.Fatalf("store was mutated: %s", got)
}
})
}
store := &httpJobStore{values: map[string]jobs.Job{
"external": {ID: "external", OwnerID: "api:agent-a", ExternalClientID: "agent-a", Capability: "image.generate", Status: jobs.StatusFailed, ResponsePayload: json.RawMessage(jobWithDiagnosticsPayload), Error: &jobs.JobError{Message: "请求被服务商拒绝"}},
}}
platform, _ := NewPlatformAuthorizer(AuthState{}, nil)
h, err := NewJobsHandler(JobsDependencies{Service: jobs.NewService(store, time.Now), Platform: platform, Public: publicapi.NewAuthenticator(publicapi.Config{APIKeys: "agent-a:secret"}), Builder: &jobBuilderStub{}}, JobsConfig{})
if err != nil {
t.Fatal(err)
}
for _, path := range []string{"/api/v1/jobs", "/api/v1/jobs/external"} {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("Authorization", "Bearer secret")
response := httptest.NewRecorder()
h.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("%s status=%d body=%s", path, response.Code, response.Body.String())
}
assertJobResponseProjection(t, response.Body.Bytes(), false)
}
if got := string(store.values["external"].ResponsePayload); got != jobWithDiagnosticsPayload {
t.Fatalf("public response mutated stored job: %s", got)
}
}
func TestJobsHTTPProjectsDiagnosticsFromIdempotentCreate(t *testing.T) {
h, store := newJobsHTTP(t)
fingerprint, err := jobs.Fingerprint(map[string]any{"capability": "image.generate", "prompt": "hello"})
if err != nil {
t.Fatal(err)
}
store.values["existing"] = jobs.Job{ID: "existing", OwnerID: "api:agent-a", ExternalClientID: "agent-a", Capability: "image.generate", Status: jobs.StatusFailed, IdempotencyKey: "same", IdempotencyFingerprint: fingerprint, ResponsePayload: json.RawMessage(jobWithDiagnosticsPayload)}
request := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", bytes.NewBufferString(`{"capability":"image.generate","prompt":"hello"}`))
request.Header.Set("Authorization", "Bearer secret")
request.Header.Set("Idempotency-Key", "same")
response := httptest.NewRecorder()
h.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
assertJobResponseProjection(t, response.Body.Bytes(), false)
}
func assertJobResponseProjection(t *testing.T, body []byte, wantDetail bool) {
t.Helper()
hasDetail := bytes.Contains(body, []byte("private diagnostic")) || bytes.Contains(body, []byte("private-request"))
if hasDetail != wantDetail {
t.Fatalf("diagnostic present=%t want=%t body=%s", hasDetail, wantDetail, body)
}
if !bytes.Contains(body, []byte("outputUrls")) || !bytes.Contains(body, []byte("请求被服务商拒绝")) && !bytes.Contains(body, []byte("existing")) {
t.Fatalf("job payload missing after projection: %s", body)
}
}
+8 -7
View File
@@ -147,7 +147,7 @@ func (h *jobsHandler) platformCollection(w http.ResponseWriter, r *http.Request,
if items == nil {
items = []jobs.Job{}
}
writeJSON(w, 200, map[string]any{"jobs": items})
writeJSON(w, 200, map[string]any{"jobs": projectJobsForViewer(items, canViewJobDiagnostics(session))})
return
}
h.create(w, r, scope, capability, false, &session)
@@ -165,7 +165,7 @@ func (h *jobsHandler) platformItem(w http.ResponseWriter, r *http.Request, id st
writeJobError(w, err, false)
return
}
writeJSON(w, 200, map[string]any{"job": j})
writeJSON(w, 200, map[string]any{"job": projectJobForViewer(j, canViewJobDiagnostics(session))})
return
}
j, err := h.dependencies.Service.Get(r.Context(), scope, id)
@@ -216,7 +216,7 @@ func (h *jobsHandler) platformRetry(w http.ResponseWriter, r *http.Request, id s
writeJobError(w, err, false)
return
}
writeJSON(w, 202, map[string]any{"job": j})
writeJSON(w, 202, map[string]any{"job": projectJobForViewer(j, canViewJobDiagnostics(session))})
}
func (h *jobsHandler) publicCollection(w http.ResponseWriter, r *http.Request) {
@@ -246,7 +246,7 @@ func (h *jobsHandler) publicCollection(w http.ResponseWriter, r *http.Request) {
if items == nil {
items = []jobs.Job{}
}
writeJSON(w, 200, map[string]any{"jobs": items})
writeJSON(w, 200, map[string]any{"jobs": projectJobsForViewer(items, false)})
}
func (h *jobsHandler) publicGet(w http.ResponseWriter, r *http.Request, id string) {
_, scope, ok := h.publicScope(w, r)
@@ -258,7 +258,7 @@ func (h *jobsHandler) publicGet(w http.ResponseWriter, r *http.Request, id strin
writeJobError(w, err, true)
return
}
writeJSON(w, 200, map[string]any{"job": j})
writeJSON(w, 200, map[string]any{"job": projectJobForViewer(j, false)})
}
func (h *jobsHandler) publicCancel(w http.ResponseWriter, r *http.Request, id string) {
_, scope, ok := h.publicScope(w, r)
@@ -270,7 +270,7 @@ func (h *jobsHandler) publicCancel(w http.ResponseWriter, r *http.Request, id st
writeJobError(w, err, true)
return
}
writeJSON(w, 200, map[string]any{"job": j})
writeJSON(w, 200, map[string]any{"job": projectJobForViewer(j, false)})
}
func (h *jobsHandler) publicScope(w http.ResponseWriter, r *http.Request) (publicapi.PublicClient, jobs.Scope, bool) {
c, o, e := h.dependencies.Public.Authenticate(r)
@@ -336,7 +336,8 @@ func (h *jobsHandler) create(w http.ResponseWriter, r *http.Request, scope jobs.
if reused {
status = 200
}
response := map[string]any{"job": created}
canViewDiagnostics := !public && session != nil && canViewJobDiagnostics(*session)
response := map[string]any{"job": projectJobForViewer(created, canViewDiagnostics)}
if public {
response["reused"] = reused
}
@@ -12,8 +12,8 @@ import (
func TestRouteMethodCompatibilityDerivesEverySurfacePath(t *testing.T) {
patterns := routeMethodPatterns(GoRouteSurface())
if len(patterns) != 49 {
t.Fatalf("route patterns=%d want 49", len(patterns))
if len(patterns) != 50 {
t.Fatalf("route patterns=%d want 50", len(patterns))
}
for _, pattern := range patterns {
if _, ok := pattern.methods[http.MethodOptions]; !ok {
+3 -2
View File
@@ -95,7 +95,7 @@ func openAPISchemas() map[string]any {
"properties": map[string]any{
"capability": openAPIRef("GenerationCapability"),
"engine": map[string]any{"type": "string", "enum": []string{"jimeng", "seedream", "evolink", "bailian", "seedance", "minimax"}},
"model": map[string]any{"type": "string", "description": "Optional allow-listed model. For image.generate with engine=evolink: gpt-image-2, gpt-image-2.5-flare, gpt-image-2.5-sunburst, or the configured EVOLINK_IMAGE_MODEL. For video.generate with engine=seedance: doubao-seedance-2-0-260128 or doubao-seedance-2-5-260628."},
"model": map[string]any{"type": "string", "description": "Optional allow-listed model, scoped to the selected engine. Image/evolink: gpt-image-2, gpt-image-2.5-flare, gpt-image-2.5-sunburst, or configured EVOLINK_IMAGE_MODEL. Video/seedance: doubao-seedance-2-0-260128 or doubao-seedance-2-5-260628. Video/bailian: wan2.7-i2v-2026-04-25 or wan3.0-video. Video/minimax: MiniMax-H3."},
"prompt": map[string]any{"type": "string", "description": "Prompt text. Required for generation except Seedream layer decomposition, where an empty prompt triggers automatic decomposition."},
"inputUrls": map[string]any{"type": "array", "items": map[string]any{"type": "string", "format": "uri"}, "description": "Reference image URLs for image capabilities."},
"imageUrls": map[string]any{"type": "array", "items": map[string]any{"type": "string", "format": "uri"}, "description": "Alias for image input URLs."},
@@ -104,8 +104,9 @@ func openAPISchemas() map[string]any {
"settings": map[string]any{
"type": "object", "description": "Provider-specific image or video settings.",
"properties": map[string]any{
"inputMode": map[string]any{"type": "string", "enum": []string{"reference", "frames"}, "description": "Video material mode. reference: up to 9 images for MiniMax H3 or 10 for Wan 3.0. frames: one first-frame image for MiniMax, or 1–2 first/last-frame images for Bailian. Omission preserves legacy frame behavior except Wan 3.0, which defaults to reference. Seedance uses reference materials."},
"ratio": map[string]any{"type": "string", "enum": []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"}},
"duration": map[string]any{"type": "integer", "minimum": 2, "maximum": 30, "description": "Limits depend on engine: MiniMax H3 supports 4–15 seconds."},
"duration": map[string]any{"type": "integer", "description": "MiniMax H3: 4–15 seconds. Wan 2.7: 2–15 seconds. Wan 3.0: 2–30 seconds. Seedance 2.0: 4–15 seconds or -1 (automatic); Seedance 2.5: 4–30 seconds or -1."},
"resolution": map[string]any{"type": "string", "enum": []string{"480p", "720p", "1080p", "768P", "2K"}},
"size": map[string]any{"type": "string", "enum": []string{"auto", "1K", "1.5K", "2K"}, "description": "Seedream 5.0 Pro output size. auto is available for layer decomposition only."},
"outputFormat": map[string]any{"type": "string", "enum": []string{"png", "jpeg"}},
+1 -1
View File
@@ -16,7 +16,7 @@ var goRouteSurface = []RouteSurface{
{"DELETE", "/api/admin/accounts"}, {"GET", "/api/admin/accounts"}, {"PATCH", "/api/admin/accounts"}, {"POST", "/api/admin/accounts"}, {"PUT", "/api/admin/accounts"}, {"POST", "/api/admin/accounts/groups"}, {"POST", "/api/admin/accounts/password"}, {"GET", "/api/admin/billing"}, {"PATCH", "/api/admin/billing/account"}, {"POST", "/api/admin/billing/adjustments"}, {"GET", "/api/admin/billing/prices"}, {"PATCH", "/api/admin/billing/prices"}, {"PATCH", "/api/admin/billing/prices/{id}"}, {"DELETE", "/api/admin/organizations"}, {"GET", "/api/admin/organizations"}, {"PATCH", "/api/admin/organizations"}, {"POST", "/api/admin/organizations"}, {"GET", "/api/admin/usage"},
{"GET", "/api/assets"}, {"POST", "/api/assets"}, {"POST", "/api/assets/upload"}, {"POST", "/api/assets/import-image"}, {"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/billing"}, {"GET", "/api/billing/balance"}, {"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"}, {"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...}"},
}