production runtime and mock removal
This commit is contained in:
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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in new issue
Block a user