production runtime and mock removal

This commit is contained in:
inman committed 2026-08-17 23:44:14 +08:00
1 parent 2ef3da7af5
commit 6480e503eb
55 files changed
+581 -540

No files matched your search

@@ -79,6 +79,11 @@ func New(options Options) (*App, error) {
if err != nil {
return nil, err
}
if config.Backend == postgres.BackendPostgres && options.ProviderRegistry == nil {
if err := validateProductionProviderConfiguration(getenv); err != nil {
return nil, err
}
}
database, err := postgres.Open(ctx, config)
if err != nil {
return nil, err
@@ -3,6 +3,7 @@ package application_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
@@ -11,6 +12,7 @@ import (
"time"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/application"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs"
@@ -335,7 +337,7 @@ func TestLocalApplicationBusinessModulesDoNotUseUnavailablePostgresShell(t *test
{http.MethodGet, "/api/assets", "", http.StatusOK},
{http.MethodGet, "/api/image-templates", "", http.StatusOK},
{http.MethodGet, "/api/usage", "", http.StatusOK},
{http.MethodPost, "/api/generations/image", `{"prompt":"local mock"}`, http.StatusAccepted},
{http.MethodPost, "/api/generations/image", `{"prompt":"local production path"}`, http.StatusAccepted},
} {
req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body))
if test.body != "" {
@@ -349,20 +351,17 @@ func TestLocalApplicationBusinessModulesDoNotUseUnavailablePostgresShell(t *test
}
}
func TestLocalApplicationMockJobReachesSucceededWithStoredOutput(t *testing.T) {
func TestLocalApplicationRealProviderJobReachesSucceededWithStoredOutput(t *testing.T) {
app, err := application.New(application.Options{
Getenv: applicationEnv(map[string]string{
"ZHINIAN_DATA_BACKEND": "local", "ZHINIAN_BILLING_REQUIRED": "0",
"ZHINIAN_DATA_BACKEND": "local", "ZHINIAN_BILLING_REQUIRED": "0", "ZHINIAN_RUNTIME_DIR": t.TempDir(),
"ZHINIAN_INTERNAL_WORKER_TOKEN": "worker-secret",
"ZHINIAN_WORKER_POLL_INTERVAL_MS": "1",
}),
ProviderRegistry: jobs.ProviderRegistry{
"volcengine-visual": providers.NewMock("local-e2e"),
"evolink": providers.NewMock("local-e2e"),
"bailian": providers.NewMock("local-e2e"),
"seedance": providers.NewMock("local-e2e"),
"mock": providers.NewMock("local-e2e"),
"volcengine-visual": applicationTestProvider{},
},
RemoteFetcher: applicationTestRemoteFetcher{},
})
if err != nil {
t.Fatalf("New() error = %v", err)
@@ -419,6 +418,23 @@ func TestLocalApplicationMockJobReachesSucceededWithStoredOutput(t *testing.T) {
}
}
type applicationTestProvider struct{}
func (applicationTestProvider) Submit(context.Context, providers.Request) (providers.Result, error) {
return providers.Result{TaskID: "provider-task-1", Status: providers.StatusQueued}, nil
}
func (applicationTestProvider) Query(_ context.Context, taskID string) (providers.Result, error) {
return providers.Result{TaskID: taskID, Status: providers.StatusSucceeded, OutputURLs: []string{"https://provider.test/generated.png"}}, nil
}
type applicationTestRemoteFetcher struct{}
func (applicationTestRemoteFetcher) Fetch(context.Context, string) (assets.Blob, error) {
content := "real provider output fixture"
return assets.Blob{Body: io.NopCloser(strings.NewReader(content)), ContentType: "image/png", Size: int64(len(content))}, nil
}
func TestApplicationComposesPasswordLoginAndLogoutHandlers(t *testing.T) {
secret := "application-password-login-secret-with-enough-entropy"
authenticator := &applicationCredentialAuthenticator{account: identity.LoginAccount{
+29 -60
View File
@@ -86,48 +86,21 @@ func videoEngine(getenv postgres.Getenv) string {
return "bailian"
}
func mockEnabled(getenv postgres.Getenv, flag string, configured bool) bool {
switch strings.ToLower(strings.TrimSpace(getenv(flag))) {
case "1", "true":
return true
case "0", "false":
return false
default:
return !configured
}
}
func imageProvider(getenv postgres.Getenv) string {
switch imageEngine(getenv) {
case "evolink":
if mockEnabled(getenv, "EVOLINK_MOCK", strings.TrimSpace(getenv("EVOLINK_API_KEY")) != "") {
return "mock"
}
return "evolink"
case "bailian":
if mockEnabled(getenv, "BAILIAN_MOCK", bailianAPIKey(getenv) != "") {
return "mock"
}
return "bailian"
default:
configured := strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) != "" && strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) != ""
if mockEnabled(getenv, "JIMENG_VISUAL_MOCK", configured) {
return "mock"
}
return "volcengine-visual"
}
}
func videoProvider(getenv postgres.Getenv) string {
if videoEngine(getenv) == "seedance" {
if mockEnabled(getenv, "SEEDANCE_MOCK", strings.TrimSpace(getenv("SEEDANCE_API_KEY")) != "") {
return "mock"
}
return "seedance"
}
if mockEnabled(getenv, "BAILIAN_MOCK", bailianAPIKey(getenv) != "") {
return "mock"
}
return "bailian"
}
@@ -167,7 +140,6 @@ func buildProviderRegistry(getenv postgres.Getenv) jobs.ProviderRegistry {
client := &http.Client{Timeout: durationEnv(getenv, "ZHINIAN_PROVIDER_TIMEOUT_MS", 30*time.Second)}
maximum := positiveInt64Env(getenv, "ZHINIAN_PROVIDER_MAX_RESPONSE_BYTES", 2<<20)
return jobs.ProviderRegistry{
"mock": providers.NewMock(firstNonEmpty(getenv("ZHINIAN_MOCK_SEED"), "zhinian")),
"volcengine-visual": providers.NewVolcengine(providers.Config{
BaseURL: firstNonEmpty(getenv("VOLCENGINE_VISUAL_ENDPOINT"), "https://visual.volcengineapi.com"),
Model: imageModel(getenv), AccessKeyID: getenv("VOLCENGINE_ACCESS_KEY_ID"), SecretAccessKey: getenv("VOLCENGINE_SECRET_ACCESS_KEY"),
@@ -180,18 +152,17 @@ func buildProviderRegistry(getenv postgres.Getenv) jobs.ProviderRegistry {
}
func providerImageTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget {
jimengConfigured := strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) != "" && strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) != ""
return map[string]jobs.ProviderTarget{
"jimeng": {
Provider: providerOrMock("volcengine-visual", mockEnabled(getenv, "JIMENG_VISUAL_MOCK", jimengConfigured)),
Provider: "volcengine-visual",
Model: firstNonEmpty(getenv("JIMENG_IMAGE_GENERATE_46_REQ_KEY"), "jimeng_seedream46_cvtob"),
},
"evolink": {
Provider: providerOrMock("evolink", mockEnabled(getenv, "EVOLINK_MOCK", strings.TrimSpace(getenv("EVOLINK_API_KEY")) != "")),
Provider: "evolink",
Model: firstNonEmpty(getenv("EVOLINK_IMAGE_MODEL"), "gpt-image-2"),
},
"bailian": {
Provider: providerOrMock("bailian", mockEnabled(getenv, "BAILIAN_MOCK", bailianAPIKey(getenv) != "")),
Provider: "bailian",
Model: firstNonEmpty(getenv("BAILIAN_IMAGE_MODEL"), "wan2.7-image-pro"),
},
}
@@ -200,7 +171,7 @@ func providerImageTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget
func providerVideoTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget {
return map[string]jobs.ProviderTarget{
"seedance": {
Provider: providerOrMock("seedance", mockEnabled(getenv, "SEEDANCE_MOCK", strings.TrimSpace(getenv("SEEDANCE_API_KEY")) != "")),
Provider: "seedance",
Model: firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128"),
Settings: map[string]any{
"ratio": firstNonEmpty(getenv("SEEDANCE_DEFAULT_RATIO"), "9:16"),
@@ -209,17 +180,30 @@ func providerVideoTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget
},
},
"bailian": {
Provider: providerOrMock("bailian", mockEnabled(getenv, "BAILIAN_MOCK", bailianAPIKey(getenv) != "")),
Provider: "bailian",
Model: firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25"),
},
}
}
func providerOrMock(provider string, mocked bool) string {
if mocked {
return "mock"
func validateProductionProviderConfiguration(getenv postgres.Getenv) error {
missing := make([]string, 0, 4)
if strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) == "" || strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) == "" {
missing = append(missing, "即梦 VOLCENGINE_ACCESS_KEY_ID/VOLCENGINE_SECRET_ACCESS_KEY")
}
return provider
if strings.TrimSpace(getenv("EVOLINK_API_KEY")) == "" {
missing = append(missing, "EvoLink EVOLINK_API_KEY")
}
if bailianAPIKey(getenv) == "" {
missing = append(missing, "百炼 BAILIAN_API_KEY/DASHSCOPE_API_KEY")
}
if strings.TrimSpace(getenv("SEEDANCE_API_KEY")) == "" {
missing = append(missing, "Seedance SEEDANCE_API_KEY")
}
if len(missing) == 0 {
return nil
}
return fmt.Errorf("生产环境缺少真实服务商凭据:%s", strings.Join(missing, ";"))
}
func defaultWebhookSender(getenv postgres.Getenv) (*webhook.HTTPSender, error) {
@@ -267,12 +251,6 @@ func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails {
visualConfigured := strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) != "" && strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) != ""
auth, _ := ParseAuthConfig(getenv)
bailianKey := bailianAPIKey(getenv)
bailianMode := "missing"
if mockFlagEnabled(getenv("BAILIAN_MOCK")) {
bailianMode = "mock"
} else if bailianKey != "" {
bailianMode = "bailian"
}
authMode := "disabled"
if auth.Required {
authMode = "missing"
@@ -281,10 +259,10 @@ func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails {
}
}
return httpapi.HealthDetails{
VisualAPIMode: providerMode(mockEnabled(getenv, "JIMENG_VISUAL_MOCK", visualConfigured), "volcengine"),
EvolinkMode: providerMode(mockEnabled(getenv, "EVOLINK_MOCK", strings.TrimSpace(getenv("EVOLINK_API_KEY")) != ""), "evolink"),
SeedanceMode: providerMode(mockEnabled(getenv, "SEEDANCE_MOCK", strings.TrimSpace(getenv("SEEDANCE_API_KEY")) != ""), "seedance"),
BailianMode: bailianMode,
VisualAPIMode: providerMode(visualConfigured, "volcengine"),
EvolinkMode: providerMode(strings.TrimSpace(getenv("EVOLINK_API_KEY")) != "", "evolink"),
SeedanceMode: providerMode(strings.TrimSpace(getenv("SEEDANCE_API_KEY")) != "", "seedance"),
BailianMode: providerMode(bailianKey != "", "bailian"),
AuthMode: authMode,
Capabilities: []any{
map[string]any{"id": "image.generate", "label": "图片生成 4.6", "engine": image, "engineLabel": imageLabel, "reqKey": imageModel(getenv)},
@@ -293,22 +271,13 @@ func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails {
}
}
func providerMode(mock bool, live string) string {
if mock {
return "mock"
func providerMode(configured bool, live string) string {
if !configured {
return "missing"
}
return live
}
func mockFlagEnabled(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true":
return true
default:
return false
}
}
func remoteAssetMaxBytes(getenv postgres.Getenv) int64 {
return positiveInt64Env(getenv, "ZHINIAN_REMOTE_ASSET_MAX_BYTES", 20<<20)
}
+36 -7
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets"
@@ -81,7 +82,7 @@ func TestRuntimeHealthDetailsMatchTypeScriptDefaultsAndConfiguredModes(t *testin
"ZHINIAN_AUTH_SESSION_SECRET": "session-secret",
}
details := runtimeHealthDetails(func(name string) string { return values[name] })
if details.VisualAPIMode != "volcengine" || details.EvolinkMode != "mock" || details.SeedanceMode != "seedance" || details.BailianMode != "missing" || details.AuthMode != "configured" {
if details.VisualAPIMode != "volcengine" || details.EvolinkMode != "missing" || details.SeedanceMode != "seedance" || details.BailianMode != "missing" || details.AuthMode != "configured" {
t.Fatalf("details = %+v", details)
}
if len(details.Capabilities) != 2 {
@@ -94,17 +95,13 @@ func TestRuntimeHealthDetailsMatchTypeScriptDefaultsAndConfiguredModes(t *testin
}
}
func TestRuntimeHealthDetailsHonorExplicitMockFlagsAndImageEngine(t *testing.T) {
func TestRuntimeHealthDetailsReportConfiguredProvidersAndImageEngine(t *testing.T) {
values := map[string]string{
"IMAGE_GENERATE_ENGINE": "bailian",
"BAILIAN_API_KEY": "bailian-key",
"BAILIAN_MOCK": "true",
"JIMENG_VISUAL_MOCK": "true",
"EVOLINK_MOCK": "false",
"SEEDANCE_MOCK": "true",
}
details := runtimeHealthDetails(func(name string) string { return values[name] })
if details.VisualAPIMode != "mock" || details.EvolinkMode != "evolink" || details.SeedanceMode != "mock" || details.BailianMode != "mock" || details.AuthMode != "disabled" {
if details.VisualAPIMode != "missing" || details.EvolinkMode != "missing" || details.SeedanceMode != "missing" || details.BailianMode != "bailian" || details.AuthMode != "disabled" {
t.Fatalf("details = %+v", details)
}
image := details.Capabilities[0].(map[string]any)
@@ -113,6 +110,38 @@ func TestRuntimeHealthDetailsHonorExplicitMockFlagsAndImageEngine(t *testing.T)
}
}
func TestValidateProductionProviderConfigurationRequiresAllRealCredentials(t *testing.T) {
if err := validateProductionProviderConfiguration(func(string) string { return "" }); err == nil {
t.Fatal("validateProductionProviderConfiguration() unexpectedly succeeded")
} else if !strings.Contains(err.Error(), "EVOLINK_API_KEY") || !strings.Contains(err.Error(), "SEEDANCE_API_KEY") {
t.Fatalf("error = %v", err)
}
values := map[string]string{
"VOLCENGINE_ACCESS_KEY_ID": "access",
"VOLCENGINE_SECRET_ACCESS_KEY": "secret",
"EVOLINK_API_KEY": "evolink",
"BAILIAN_API_KEY": "bailian",
"SEEDANCE_API_KEY": "seedance",
}
if err := validateProductionProviderConfiguration(func(name string) string { return values[name] }); err != nil {
t.Fatalf("validateProductionProviderConfiguration() error = %v", err)
}
}
func TestProviderTargetsNeverSelectRemovedProvider(t *testing.T) {
getenv := func(string) string { return "" }
for engine, target := range providerImageTargets(getenv) {
if target.Provider == "mock" {
t.Fatalf("image engine %s selected removed provider", engine)
}
}
for engine, target := range providerVideoTargets(getenv) {
if target.Provider == "mock" {
t.Fatalf("video engine %s selected removed provider", engine)
}
}
}
func TestCapabilitySummaryMatchesConfiguredDefaultVideoEngine(t *testing.T) {
for _, test := range []struct {
name, engine, wantEngine, wantProvider, wantModel string
-58
View File
@@ -7,7 +7,6 @@ import (
"encoding/json"
"errors"
"fmt"
"html"
"io"
"path"
"regexp"
@@ -236,16 +235,6 @@ type ImportGeneratedCommand struct {
Metadata map[string]any
}
// ImportMockCommand describes a locally generated development result. Unlike
// ImportGeneratedCommand it carries no URL, so mock output never enters the
// remote-fetch/SSRF boundary.
type ImportMockCommand struct {
Name, Capability, JobID string
Kind Kind
Tags []string
Metadata map[string]any
}
func (s *Service) Upload(ctx context.Context, scope Scope, cmd UploadCommand) (Asset, error) {
if err := validScope(scope); err != nil {
return Asset{}, err
@@ -336,53 +325,6 @@ func (s *Service) ImportGenerated(ctx context.Context, scope Scope, cmd ImportGe
}
return created, nil
}
// ImportMock stores a small deterministic placeholder in the configured blob
// store and registers it as a generated asset. It is intentionally a separate
// path from remote imports: relative mock provider URLs are never interpreted
// as fetch destinations.
func (s *Service) ImportMock(ctx context.Context, scope Scope, cmd ImportMockCommand) (Asset, error) {
if err := validScope(scope); err != nil {
return Asset{}, err
}
if s.blobs == nil {
return Asset{}, errors.New("blob store is unavailable")
}
kind := cmd.Kind
if kind == "" {
kind = KindImage
}
name, contentType, content := mockOutput(cmd.Name, kind, cmd.JobID)
key := path.Join("generated-results", s.now().UTC().Format("2006-01-02"), s.id("file")+"-"+sanitizeFileName(name))
stored, err := s.blobs.Put(ctx, key, bytes.NewReader(content), int64(len(content)), contentType)
if err != nil {
return Asset{}, err
}
now := s.now().UTC()
metadata := cloneMetadata(cmd.Metadata)
metadata["contentType"] = contentType
metadata["size"] = len(content)
metadata["capability"] = cmd.Capability
metadata["jobId"] = cmd.JobID
metadata["mock"] = true
asset := Asset{ID: s.id("asset"), OwnerID: scope.ownerID, Kind: kind, Name: name, URL: stored.URL, StoragePath: stored.Key, Source: SourceGenerated, Tags: cloneStrings(cmd.Tags), Metadata: metadata, CreatedAt: now, UpdatedAt: now}
created, err := s.catalog.Create(ctx, asset)
if err != nil {
_ = s.blobs.Delete(context.WithoutCancel(ctx), stored.Key)
return Asset{}, err
}
return created, nil
}
func mockOutput(name string, kind Kind, jobID string) (string, string, []byte) {
if kind == KindVideo {
return defaultString(name, "mock-video.mp4"), "video/mp4", []byte("mock video result for " + jobID + "\n")
}
name = defaultString(name, "mock-image.svg")
content := `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" fill="#111827"/><text x="512" y="492" text-anchor="middle" fill="#f9fafb" font-family="sans-serif" font-size="64">Mock image</text><text x="512" y="558" text-anchor="middle" fill="#9ca3af" font-family="sans-serif" font-size="28">` + html.EscapeString(jobID) + `</text></svg>`
return name, "image/svg+xml", []byte(content)
}
func (s *Service) Delete(ctx context.Context, scope Scope, id string) (Asset, error) {
a, err := s.Get(ctx, scope, id)
if err != nil {
-22
View File
@@ -104,28 +104,6 @@ func TestImportGeneratedCompensatesBlobWhenCatalogFails(t *testing.T) {
}
}
func TestImportMockStoresAccessibleGeneratedAssetWithoutRemoteFetch(t *testing.T) {
cat := &memoryCatalog{}
blobs := &memoryBlobs{}
remote := &memoryRemote{}
ids := []string{"file-1", "asset-1"}
svc := NewService(cat, blobs, remote, func() time.Time { return time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC) }, func(string) string {
id := ids[0]
ids = ids[1:]
return id
})
created, err := svc.ImportMock(context.Background(), PlatformScope("owner-a"), ImportMockCommand{Capability: "image.generate", JobID: "job-1", Kind: KindImage, Tags: []string{"generated", "job:job-1"}})
if err != nil {
t.Fatal(err)
}
if created.ID != "asset-1" || created.StoragePath != "generated-results/2026-08-13/file-1-mock-image.svg" || created.URL != "https://app.test/generated-results/2026-08-13/file-1-mock-image.svg" || created.Metadata["mock"] != true || remote.called {
t.Fatalf("created = %#v remoteCalled=%v", created, remote.called)
}
if blobs.putContentType != "image/svg+xml" || !bytes.Contains(blobs.putBody, []byte("job-1")) {
t.Fatalf("stored contentType=%q body=%q", blobs.putContentType, blobs.putBody)
}
}
func TestDeleteLeavesCatalogWhenBlobDeletionFails(t *testing.T) {
cat := &memoryCatalog{assets: []Asset{{ID: "a", OwnerID: "o", StoragePath: "uploads/a"}}}
blobs := &memoryBlobs{deleteErr: errors.New("storage down")}
+8 -4
View File
@@ -12,9 +12,10 @@ import (
const CurrencyCNY = "CNY"
var (
ErrAmbiguousPriceRule = errors.New("billing price rules are ambiguous")
ErrPriceRuleNotFound = errors.New("billing price rule not found")
ErrParameterTier = errors.New("billing parameter tier not found")
ErrAmbiguousPriceRule = errors.New("billing price rules are ambiguous")
ErrPriceRuleNotFound = errors.New("billing price rule not found")
ErrParameterTier = errors.New("billing parameter tier not found")
ErrProviderUnavailable = errors.New("billing provider is unavailable")
)
type Unit string
@@ -103,9 +104,12 @@ type Quote struct {
type Catalog struct{ Rules []PriceRule }
func (c Catalog) Quote(input QuoteInput) (*Quote, error) {
if input.BillingDisabled || input.Provider == "mock" {
if input.BillingDisabled {
return nil, nil
}
if input.Provider == "mock" {
return nil, ErrProviderUnavailable
}
type candidate struct {
rule PriceRule
req, conditions, priority int
+9 -10
View File
@@ -60,18 +60,17 @@ func TestCatalogRejectsAmbiguousWinners(t *testing.T) {
}
}
func TestCatalogExemptsDisabledMockAndSuperAdmin(t *testing.T) {
func TestCatalogExemptsDisabledBillingAndRejectsRemovedMockProvider(t *testing.T) {
rule := PriceRule{ID: "r", Provider: "bailian", Capability: "image.generate", Unit: UnitRequest, StandardUnitPriceFen: 10, MarkupMultiplier: 1, Enabled: true}
for _, input := range []QuoteInput{
{BillingDisabled: true, Provider: "bailian", Capability: "image.generate"},
{Provider: "mock", Capability: "image.generate"},
} {
quote, err := (Catalog{Rules: []PriceRule{rule}}).Quote(input)
if err != nil || quote != nil {
t.Fatalf("Quote(%#v) = %#v, %v", input, quote, err)
}
quote, err := (Catalog{Rules: []PriceRule{rule}}).Quote(QuoteInput{BillingDisabled: true, Provider: "bailian", Capability: "image.generate"})
if err != nil || quote != nil {
t.Fatalf("disabled Quote = %#v, %v", quote, err)
}
quote, err := (Catalog{Rules: []PriceRule{rule}}).Quote(QuoteInput{Provider: "bailian", Capability: "image.generate", Source: "platform", Role: "super_admin"})
quote, err = (Catalog{Rules: []PriceRule{rule}}).Quote(QuoteInput{Provider: "mock", Capability: "image.generate"})
if quote != nil || !errors.Is(err, ErrProviderUnavailable) {
t.Fatalf("removed provider Quote = %#v, %v", quote, err)
}
quote, err = (Catalog{Rules: []PriceRule{rule}}).Quote(QuoteInput{Provider: "bailian", Capability: "image.generate", Source: "platform", Role: "super_admin"})
if err != nil || quote == nil || !quote.QuotaExempt {
t.Fatalf("quote = %#v, %v", quote, err)
}
+3
View File
@@ -51,6 +51,9 @@ func HTTPStatus(err error) int {
if errors.As(err, &status) {
return status.Status
}
if errors.Is(err, ErrProviderUnavailable) {
return 503
}
return 500
}
+17 -1
View File
@@ -5,6 +5,7 @@ import (
"errors"
"math"
"net/http"
"strconv"
"strings"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing"
@@ -301,7 +302,18 @@ func (h *billingHandler) authorize(w http.ResponseWriter, r *http.Request, requi
}
func decodeJSON(r *http.Request, value any) { _ = json.NewDecoder(r.Body).Decode(value) }
func billingString(value any) string { text, _ := value.(string); return strings.TrimSpace(text) }
func numberValue(value any) float64 { number, _ := value.(float64); return number }
func numberValue(value any) float64 {
switch number := value.(type) {
case float64:
return number
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(number), 64)
if err == nil && !math.IsNaN(parsed) && !math.IsInf(parsed, 0) {
return parsed
}
}
return 0
}
func first(value, fallback string) string {
if value != "" {
return value
@@ -326,6 +338,10 @@ func writeAuthError(w http.ResponseWriter, err error) {
}
func writeDomainError(w http.ResponseWriter, err error) {
status := billing.HTTPStatus(err)
if status == http.StatusServiceUnavailable {
writeAPIError(w, status, "计费服务暂不可用,请联系管理员。")
return
}
if status >= 500 {
writeAPIError(w, 500, "服务器内部错误。")
return
+4
View File
@@ -87,6 +87,10 @@ func TestBillingAdminRoutesRequireSuperAdminAndValidateWrites(t *testing.T) {
if got.Code != 200 || service.adjustment.AmountFen != 124 || service.adjustment.DeltaFen != -124 || service.adjustment.OperatorID != "root" {
t.Fatalf("adjustment status=%d got=%+v body=%s", got.Code, service.adjustment, got.Body.String())
}
got = serveJSON(t, super, http.MethodPost, "/api/admin/billing/adjustments", map[string]any{"organizationId": "org", "amountYuan": "1.235", "direction": "credit", "note": " string amount "})
if got.Code != 200 || service.adjustment.AmountFen != 124 || service.adjustment.DeltaFen != 124 {
t.Fatalf("string adjustment status=%d got=%+v body=%s", got.Code, service.adjustment, got.Body.String())
}
var adjustmentResponse map[string]json.RawMessage
if err := json.Unmarshal(got.Body.Bytes(), &adjustmentResponse); err != nil {
t.Fatalf("decode adjustment response: %v", err)
+2 -2
View File
@@ -46,7 +46,7 @@ func TestHealthReportsProcessAndDatabaseConfigurationWithoutReadinessProbe(t *te
func TestHealthIncludesInjectedRuntimeCompatibilityDetails(t *testing.T) {
details := httpapi.HealthDetails{
VisualAPIMode: "volcengine",
EvolinkMode: "mock",
EvolinkMode: "missing",
SeedanceMode: "seedance",
BailianMode: "missing",
AuthMode: "configured",
@@ -73,7 +73,7 @@ func TestHealthIncludesInjectedRuntimeCompatibilityDetails(t *testing.T) {
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.VisualAPIMode != "volcengine" || response.EvolinkMode != "mock" || response.SeedanceMode != "seedance" || response.BailianMode != "missing" || response.AuthMode != "configured" {
if response.VisualAPIMode != "volcengine" || response.EvolinkMode != "missing" || response.SeedanceMode != "seedance" || response.BailianMode != "missing" || response.AuthMode != "configured" {
t.Fatalf("runtime response = %+v", response)
}
if len(response.Capabilities) != 2 || response.Capabilities[0]["id"] != "image.generate" || response.Capabilities[1]["id"] != "video.generate" {
+1 -1
View File
@@ -55,7 +55,7 @@ func openAPISchemas() map[string]any {
"ownerId": map[string]any{"type": "string", "example": "api:partner-a"},
"externalClientId": map[string]any{"type": "string"},
"capability": openAPIRef("GenerationCapability"),
"provider": map[string]any{"type": "string", "enum": []string{"volcengine-visual", "evolink", "seedance", "bailian", "mock"}},
"provider": map[string]any{"type": "string", "enum": []string{"volcengine-visual", "evolink", "seedance", "bailian"}},
"reqKey": map[string]any{"type": "string"},
"status": openAPIRef("GenerationStatus"),
"prompt": map[string]any{"type": "string"},
+18
View File
@@ -77,6 +77,24 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) {
if err != nil {
return Job{}, errors.New("encode provider result")
}
if result.Status == providers.StatusSucceeded && len(result.OutputURLs) == 0 {
failed := StatusFailed
failure := &JobError{Message: "provider reported success without an output URL", Retryable: false}
if p.Store == nil {
job.Status, job.Error, job.ResponsePayload = failed, failure, encoded
return job, nil
}
patch := workerPatch(job, Patch{
Status: &failed,
Error: failure,
ResponsePayload: encoded,
SetResponsePayload: true,
})
if result.TaskID != "" {
patch.ProviderTaskID = &result.TaskID
}
return p.Store.UpdateJob(ctx, job.ID, patch)
}
job.ResponsePayload = encoded
job.Status = Status(result.Status)
if result.ErrorMessage != "" {
+16
View File
@@ -213,6 +213,22 @@ func TestProviderProcessorClearsTransientErrorAfterSuccessfulPoll(t *testing.T)
}
}
func TestProviderProcessorRejectsSuccessWithoutOutput(t *testing.T) {
store := newMemoryJobStore()
job := Job{ID: "job-no-output", OwnerID: "owner", Provider: "fixture", ReqKey: "model-a", Capability: "image.generate", Status: StatusRunning, LockedBy: "worker", ProviderTaskID: "provider-task", RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"model-a","prompt":"hello"}`)}
store.jobs[job.ID] = job
adapter := &countingProvider{result: providers.Result{TaskID: "provider-task", Status: providers.StatusSucceeded}}
processor := ProviderProcessor{Providers: ProviderRegistry{"fixture": adapter}, Store: store}
got, err := processor.Advance(context.Background(), job)
if err != nil || got.Status != StatusFailed || got.Error == nil || got.Error.Retryable || !strings.Contains(got.Error.Message, "without an output URL") {
t.Fatalf("got=%#v err=%v", got, err)
}
if store.jobs[job.ID].Status != StatusFailed {
t.Fatalf("persisted status = %s, want failed", store.jobs[job.ID].Status)
}
}
func TestProviderProcessorQueriesWithPersistedRequestModel(t *testing.T) {
store := newMemoryJobStore()
job := Job{ID: "job-model", OwnerID: "owner", Provider: "fixture", ReqKey: "persisted-model-a", Capability: "image.generate", Status: StatusQueued, LockedBy: "worker", ProviderTaskID: "provider-task", RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"persisted-model-a","prompt":"hello"}`)}
@@ -217,7 +217,6 @@ func writeOutputState(ctx context.Context, state JobStateWriter, job jobs.Job, i
type GeneratedAssetImporter interface {
List(context.Context, assets.Scope) ([]assets.Asset, error)
ImportGenerated(context.Context, assets.Scope, assets.ImportGeneratedCommand) (assets.Asset, error)
ImportMock(context.Context, assets.Scope, assets.ImportMockCommand) (assets.Asset, error)
}
type OutputURLResolver func(jobs.Job) ([]string, error)
@@ -240,24 +239,6 @@ func (r *AssetOutputRegistrar) Register(ctx context.Context, job jobs.Job) ([]st
if err != nil {
return nil, errors.New("register generation outputs")
}
if job.Provider == "mock" {
if id := existingOutputID(existing, job.ID, "output:0"); id != "" {
return []string{id}, nil
}
kind := assets.KindImage
if job.Capability == "video.generate" {
kind = assets.KindVideo
}
created, createErr := r.assets.ImportMock(ctx, scope, assets.ImportMockCommand{
Capability: job.Capability, JobID: job.ID, Kind: kind,
Tags: []string{"generated", job.Capability, "job:" + job.ID, "output:0"},
Metadata: map[string]any{"capability": job.Capability, "jobId": job.ID, "index": 0},
})
if createErr != nil {
return nil, errors.New("register generation outputs")
}
return []string{created.ID}, nil
}
urls, err := r.resolve(job)
if err != nil || len(urls) == 0 {
return nil, errors.New("register generation outputs")
@@ -128,33 +128,6 @@ func TestAssetOutputRegistrarUsesGeneratedOwnerScopedAssets(t *testing.T) {
}
}
func TestOutputRegisteringProcessorCreatesAndPersistsMockAssetWithoutRemoteFetch(t *testing.T) {
for _, capability := range []string{"image.generate", "video.generate"} {
t.Run(capability, func(t *testing.T) {
creator := &assetCreatorStub{}
registrar := NewAssetOutputRegistrar(creator, ResolveProviderOutputURLs)
completed := jobs.Job{
ID: "job-mock", OwnerID: "owner", Provider: "mock", Capability: capability,
Prompt: "mock output", Status: jobs.StatusSucceeded,
ResponsePayload: json.RawMessage(`{"status":"succeeded","outputUrls":["/generated-results/mock-task"]}`),
}
writer := &stateWriterStub{}
processor := NewOutputRegisteringProcessor(&processorStub{job: completed}, registrar, writer)
got, err := processor.Advance(context.Background(), jobs.Job{ID: "job-mock"})
if err != nil || !reflect.DeepEqual(got.OutputAssetIDs, []string{"asset-1"}) || !reflect.DeepEqual(writer.outputIDs, []string{"asset-1"}) || len(creator.mockCommands) != 1 || creator.mockCommands[0].JobID != "job-mock" {
t.Fatalf("Advance = %#v, %v mock=%#v persisted=%#v", got, err, creator.mockCommands, writer.outputIDs)
}
wantKind := assets.KindImage
if capability == "video.generate" {
wantKind = assets.KindVideo
}
if creator.mockCommands[0].Kind != wantKind || len(creator.commands) != 0 {
t.Fatalf("mock command=%#v remote imports=%#v", creator.mockCommands[0], creator.commands)
}
})
}
}
func TestResolveProviderOutputURLsAcceptsCurrentShapesAndDeduplicates(t *testing.T) {
job := jobs.Job{ResponsePayload: json.RawMessage(`{"data":{"image_urls":["https://cdn.test/a.png","javascript:alert(1)"],"results":[{"url":"https://cdn.test/a.png"},{"url":"http://cdn.test/b.png"}]},"unrelated":"https://secret.test/not-output"}`)}
got, err := ResolveProviderOutputURLs(job)
@@ -401,10 +374,9 @@ func (s *outputRegistrarStub) Register(context.Context, jobs.Job) ([]string, err
}
type assetCreatorStub struct {
scopes []assets.Scope
commands []assets.ImportGeneratedCommand
mockCommands []assets.ImportMockCommand
existing []assets.Asset
scopes []assets.Scope
commands []assets.ImportGeneratedCommand
existing []assets.Asset
}
func (s *assetCreatorStub) List(_ context.Context, _ assets.Scope) ([]assets.Asset, error) {
@@ -417,12 +389,6 @@ func (s *assetCreatorStub) ImportGenerated(_ context.Context, scope assets.Scope
return assets.Asset{ID: "asset-" + string(rune('0'+len(s.commands)))}, nil
}
func (s *assetCreatorStub) ImportMock(_ context.Context, scope assets.Scope, command assets.ImportMockCommand) (assets.Asset, error) {
s.scopes = append(s.scopes, scope)
s.mockCommands = append(s.mockCommands, command)
return assets.Asset{ID: "asset-" + string(rune('0'+len(s.mockCommands)))}, nil
}
type platformBuilderStub struct{}
func (platformBuilderStub) Build(_ context.Context, owner, client, capability, idempotency string, body map[string]any) (jobs.CreateCommand, error) {
+4 -15
View File
@@ -450,26 +450,15 @@ func connection(ok bool) string {
func connected(engine string, environment, file map[string]string) bool {
switch engine {
case "evolink":
return mock(lookup(environment, file, "EVOLINK_MOCK", "auto"), lookup(environment, file, "EVOLINK_API_KEY", "") != "")
return strings.TrimSpace(lookup(environment, file, "EVOLINK_API_KEY", "")) != ""
case "seedance":
return mock(lookup(environment, file, "SEEDANCE_MOCK", "auto"), lookup(environment, file, "SEEDANCE_API_KEY", "") != "")
return strings.TrimSpace(lookup(environment, file, "SEEDANCE_API_KEY", "")) != ""
case "bailian":
flag := strings.ToLower(strings.TrimSpace(lookup(environment, file, "BAILIAN_MOCK", "auto")))
return flag != "1" && flag != "true" && lookup(environment, file, "BAILIAN_API_KEY", "") != ""
return strings.TrimSpace(lookup(environment, file, "BAILIAN_API_KEY", "")) != "" || strings.TrimSpace(lookup(environment, file, "DASHSCOPE_API_KEY", "")) != ""
default:
return mock(lookup(environment, file, "JIMENG_VISUAL_MOCK", "auto"), lookup(environment, file, "VOLCENGINE_ACCESS_KEY_ID", "") != "" && lookup(environment, file, "VOLCENGINE_SECRET_ACCESS_KEY", "") != "")
return strings.TrimSpace(lookup(environment, file, "VOLCENGINE_ACCESS_KEY_ID", "")) != "" && strings.TrimSpace(lookup(environment, file, "VOLCENGINE_SECRET_ACCESS_KEY", "")) != ""
}
}
func mock(flag string, configured bool) bool {
flag = strings.ToLower(strings.TrimSpace(flag))
if flag == "1" || flag == "true" {
return false
}
if flag == "0" || flag == "false" {
return true
}
return configured
}
func definitions() []Group {
return []Group{