若干更新迭代
This commit is contained in:
1 parent
deb9cc5bcf
commit
4ac786aafb
64 files changed
+3251
-291
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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...}"},
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user