diff --git a/.env.example b/.env.example index a12cbf1..b8c059c 100644 --- a/.env.example +++ b/.env.example @@ -36,10 +36,11 @@ ZHINIAN_WORKER_LOCK_TIMEOUT_MS=300000 ZHINIAN_WORKER_RETRY_BASE_MS=10000 ZHINIAN_WORKER_RETRY_MAX_MS=300000 ZHINIAN_WORKER_REQUEST_TIMEOUT_MS=120000 +ZHINIAN_GO_EMBEDDED_WORKER=true -# Data layer. Use local only for development/single-instance operation. -# Production must explicitly select postgres; it never falls back to container-local JSON. -ZHINIAN_DATA_BACKEND=local +# Data layer. Production and Docker Compose use PostgreSQL; set local only for +# an explicitly non-production single-process development run. +ZHINIAN_DATA_BACKEND=postgres DATABASE_URL= # Migration runner only: PostgreSQL role used by the Go API DATABASE_URL. DATABASE_APP_ROLE= @@ -66,16 +67,12 @@ VOLCENGINE_REGION=cn-north-1 VOLCENGINE_SERVICE=cv VOLCENGINE_VISUAL_ENDPOINT=https://visual.volcengineapi.com JIMENG_IMAGE_GENERATE_46_REQ_KEY=jimeng_seedream46_cvtob -# auto mocks image jobs when Volcengine credentials are missing. -JIMENG_VISUAL_MOCK=auto # EvoLink GPT Image 2 relay for image generation. EVOLINK_API_KEY= EVOLINK_BASE_URL=https://api.evolink.ai EVOLINK_IMAGE_MODEL=gpt-image-2 EVOLINK_IMAGE_QUALITY=medium -# auto mocks EvoLink image jobs when EVOLINK_API_KEY is missing. -EVOLINK_MOCK=auto # Seedance / Volcengine Ark SEEDANCE_API_KEY= @@ -85,8 +82,6 @@ SEEDANCE_RATIO=9:16 # Seedance 2.0 duration supports integer seconds from 4 to 15, or -1 for model auto. SEEDANCE_DURATION=5 SEEDANCE_RESOLUTION=720p -# auto mocks video jobs when Seedance credentials are missing. -SEEDANCE_MOCK=auto # Aliyun OSS for uploaded/reference assets that Seedance can read. ALI_OSS_ENDPOINT= diff --git a/README.md b/README.md index c79b039..a7eef6e 100644 --- a/README.md +++ b/README.md @@ -118,11 +118,11 @@ https://你的域名/api/auth/callback ## 账号、组织用量与计费 -平台用量页统计登录用户使用真实服务商后成功完成的任务;失败、取消、过期、Mock 和开放 API 客户端任务不会计入。普通用户点击页头账号 ID 查看快捷周期和最近记录;管理员通过 `/usage` 按日期、组织、账号、功能类型和服务商查看汇总、趋势及明细。 +平台用量页统计登录用户使用真实服务商后成功完成的任务;失败、取消、过期和开放 API 客户端任务不会计入。普通用户点击页头账号 ID 查看快捷周期和最近记录;管理员通过 `/usage` 按日期、组织、账号、功能类型和服务商查看汇总、趋势及明细。 计费目录由平台维护各接口的标准成本与参数档案,超级管理员只调整上浮倍率。真实生成任务提交时按“基础标准成本 × 参数档位系数 × 任务数量 × 组合倍率”报价并从组织余额冻结,单价、参数、倍率、数量和最终金额会快照到任务;每个任务在创作结果和历史任务中显示扣费状态。普通用户余额不足时会被拒绝提交并提示“余额不足,请先充值”,不会提交服务商;超级管理员仍计算并记录生成费用,但不检查或扣减组织额度,也不产生钱包扣费、退款流水。Seedance 成功后按上游 `usage.completion_tokens` 重新结算,多退少补;没有返回用量时保留冻结金额。组织成员通过 `/billing` 查看组织余额、自己的消耗和账务流水,余额属于组织而不是个人;充值和人工余额调整也只记入组织账本,不设置个人上账归属。 -当前组织余额由超级管理员直接上账,充值和人工余额调整不选择个人归属;未来接入用户自主支付时,支付成功回调自动入账,不设置人工审核队列。余额不足或未配置对应计费规则时,真实生成任务不会提交给服务商;本地 Mock 任务免计费。未绑定组织的开放 API 任务暂保持兼容,不纳入组织余额扣费。 +当前组织余额由超级管理员直接上账,充值和人工余额调整不选择个人归属;未来接入用户自主支付时,支付成功回调自动入账,不设置人工审核队列。余额不足或未配置对应计费规则时,真实生成任务不会提交给服务商。未绑定组织的开放 API 任务暂保持兼容,不纳入组织余额扣费。 系统首次打开超管计费中心或提交真实任务时会自动补齐平台标准价格目录,不覆盖已有目录。当前默认目录为:百炼 `wan2.7-image-pro` ¥0.50/张,百炼 `wan2.7-i2v-2026-04-25` 720P ¥0.60/秒、1080P ¥1.00/秒;火山方舟 `doubao-seedance-2-0-260128` 480P/720P/1080P/4K 分别为 ¥0.46/秒、¥0.99/秒、¥2.48/秒、¥5.05/秒;EvoLink `gpt-image-2` medium/1K/1:1/无参考图基础估算 ¥0.34/张(固定汇率 1 USD = 7.20 CNY),并列出质量、分辨率、画面比例和参考图数量档位;即梦 `jimeng_seedream46_cvtob` 暂按公开资源包折算参考 ¥0.20/张,官方实时计费以控制台为准。参数化报价按基础成本乘以所选参数档位系数,组合倍率取所选档位中的最高倍率;超管只在 `/billing` 调整倍率,标准成本和参数档案由平台维护。 @@ -147,7 +147,7 @@ V1 接入图片生成能力: - 提交:`CVSync2AsyncSubmitTask` - 查询:`CVSync2AsyncGetResult` -未配置火山密钥时,`JIMENG_VISUAL_MOCK=auto` 会自动使用 mock 图,方便先跑通产品流。 +未配置火山密钥时,服务会明确报告凭据未配置,不会生成占位图片。 ## EvoLink 图片能力 @@ -157,7 +157,7 @@ V1 接入图片生成能力: - 查询:`GET /v1/tasks/{task_id}` - 默认模型:`gpt-image-2` -未配置 `EVOLINK_API_KEY` 且 `EVOLINK_MOCK=auto` 时,会自动使用 mock 图。 +未配置 `EVOLINK_API_KEY` 时,服务会明确报告凭据未配置,不会生成占位图片。 ## AI 生成台与提示词编排 @@ -171,7 +171,7 @@ V1 接入图片生成能力: - 任务模块:创作页右侧直接展示任务列表,点击任务可查看完整提示词、输入要素、生成参数、状态和结果。 - 结果保存:图片和视频生成结果会写入资产记录,并在任务详情和任务卡中提供预览与下载。 -未配置 `SEEDANCE_API_KEY` 时,`SEEDANCE_MOCK=auto` 会自动使用旧模板样片作为 mock 成品,方便先验收工作流。 +未配置 `SEEDANCE_API_KEY` 时,服务会明确报告凭据未配置,不会生成占位视频。 ## 环境变量 @@ -214,20 +214,18 @@ cp .env.example .env.local - `EVOLINK_BASE_URL=https://api.evolink.ai` - `EVOLINK_IMAGE_MODEL=gpt-image-2` - `EVOLINK_IMAGE_QUALITY=medium` -- `EVOLINK_MOCK=auto` - `SEEDANCE_API_KEY` - `SEEDANCE_BASE_URL` - `SEEDANCE_MODEL` - `SEEDANCE_RATIO`:支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive` - `SEEDANCE_DURATION`:Seedance 2.0 支持 `4` 到 `15` 的整数秒,或 `-1` 让模型自动选择 - `SEEDANCE_RESOLUTION`:支持 `480p`、`720p`、`1080p`、`4k`;Seedance 2.0 fast 不支持 `1080p` -- `SEEDANCE_MOCK` - `ALI_OSS_*`:用于上传素材和生成结果转存 - `ZHINIAN_DATA_BACKEND`:生产使用 `postgres`,开发可使用 `local` - `DATABASE_URL`:仅服务端读取的 PostgreSQL 连接串 - PostgreSQL 客户端强制使用 `sslmode=disable` 且不读取 CA。ACK 到 RDS 的数据库链路为明文,只应使用 RDS 内网地址,并通过 VPC、安全组和白名单限制访问。 -当 `ZHINIAN_DATA_BACKEND=local` 时,应用使用 `.runtime/data/web-app-state.json` 作为单实例开发数据层。生产 `postgres` 模式缺少连接配置会直接失败,不会静默写入本地 JSON。如果 OSS 未配置,上传和 mock 结果会保存到 `.runtime/uploads` 和 `.runtime/generated-results`,并通过 Go 路由提供访问。 +当 `ZHINIAN_DATA_BACKEND=local` 时,应用使用进程内单实例开发数据层。生产 `postgres` 模式缺少连接配置或真实服务商凭据会在启动时直接失败,不会静默写入本地数据。如果 OSS 未配置,上传和生成结果会保存到 `.runtime/uploads` 和 `.runtime/generated-results`,并通过 Go 路由提供访问。 ## 数据库 diff --git a/README.zh-CN.md b/README.zh-CN.md index 772b0ba..56c8718 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -25,7 +25,7 @@ - 图片生成:即梦图片生成 4.6 或 EvoLink GPT Image 2 - 视频生成:Seedance 2.0 - 素材引用:上传后可在提示词中使用 `@图片1`、`@视频1`、`@音频1` -- 本地开发兜底:未配置真实接口时,可使用 mock 流程完成产品验收 +- 生产链路:未配置真实接口时明确报错,不生成占位结果 ## 技术栈 @@ -63,6 +63,17 @@ npm run build 构建结果位于 `out/`。生产不运行 Node Web 进程;仓库 Dockerfile 使用非 root Nginx 托管静态文件,浏览器通过同域 `/api` 调用 Go。 +完整容器联调(Web + Go API + 内嵌 Worker): + +```bash +cp .env.example .env.local +# 在 .env.local 配置 DATABASE_URL 及真实服务商凭据 +./scripts/deploy.sh +``` + +Compose 会启动 `zhinian-web` 和 `zhinian-go-api`,并由 Nginx 将 `/api`、`/uploads`、 +`/generated-results` 转发给 Go;不再启动独立 Node Worker。 + ## 生产部署 生产推荐使用 ACK 路径分流: @@ -136,7 +147,7 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码 - 仅统计平台内登录用户调用真实服务商后成功完成的任务。 - 成功任务会进入用量记录;一次返回多张图片的计费数量以超级管理员配置的标准计费单位为准。 -- 失败、取消、过期、Mock 和开放 API 客户端任务不计入。 +- 失败、取消、过期和开放 API 客户端任务不计入。 - 任务创建时会快照账号、租户和组织归属;无法匹配的记录进入“未归属组织”。 - 用户删除任务、素材或账号不会删除计量记录;用量明细不保存或展示提示词、素材与生成结果。 - 统计日和自然月统一使用 `Asia/Shanghai`。 @@ -260,7 +271,8 @@ cp .env.example .env.local | `ZHINIAN_INTERNAL_WORKER_TOKEN` | 内部 Worker tick 接口令牌 | | `ZHINIAN_WEBHOOK_SECRET` | Webhook 签名密钥,可选 | | `ZHINIAN_WORKER_*` | Worker 间隔、批量、锁超时、重试配置 | -| `IMAGE_GENERATE_ENGINE` | 图片生成引擎:`jimeng` 或 `evolink` | +| `ZHINIAN_GO_EMBEDDED_WORKER` | Go 内嵌 Worker 开关,生产设为 `true` | +| `IMAGE_GENERATE_ENGINE` | 图片生成引擎:`jimeng`、`evolink` 或 `bailian` | | `BAILIAN_API_KEY` | 阿里云百炼 API Key | | `BAILIAN_BASE_URL` | 百炼业务空间兼容地址;系统自动派生原生异步接口 | | `BAILIAN_IMAGE_MODEL` | 图片模型,默认 `wan2.7-image-pro` | @@ -279,7 +291,7 @@ cp .env.example .env.local | `DATABASE_URL` | PostgreSQL 连接串(仅放 Secret) | | PostgreSQL 传输 | 客户端强制 `sslmode=disable` 且不读取 CA;ACK 到 RDS 的链路为明文,只应走内网并通过 VPC、安全组和白名单限制访问 | -`ZHINIAN_DATA_BACKEND=local` 时,应用使用 `.runtime/data/web-app-state.json` 作为单实例开发数据层;生产 `postgres` 模式配置错误会直接失败。未配置 OSS 时,上传和生成结果会写入 `.runtime/uploads` 与 `.runtime/generated-results`。 +`ZHINIAN_DATA_BACKEND=local` 时,应用使用进程内单实例开发数据层;生产 `postgres` 模式配置错误或缺少真实服务商凭据会直接失败。未配置 OSS 时,上传和生成结果会写入 `.runtime/uploads` 与 `.runtime/generated-results`。 ## 项目结构 diff --git a/app/globals.css b/app/globals.css index b898008..eeb7003 100644 --- a/app/globals.css +++ b/app/globals.css @@ -5150,7 +5150,8 @@ button:active:not(:disabled), } .status.running, -.status.queued { +.status.queued, +.status.syncing { background: #fff6dc; color: var(--warning); } @@ -8335,6 +8336,22 @@ button:active:not(:disabled), color: var(--muted); } +.billing-estimate.has-error { + border-color: #e6c5be; + background: #fff8f6; +} + +.billing-estimate.has-error .billing-estimate-head, +.billing-estimate.has-error .billing-estimate-icon { + color: #a8483b; +} + +.billing-estimate-error { + color: #a8483b; + font-size: 11px; + line-height: 1.35; +} + .main.create-main .create-main-column { container: create-main-column / inline-size; } diff --git a/backend/internal/application/application.go b/backend/internal/application/application.go index 38a1666..c332246 100644 --- a/backend/internal/application/application.go +++ b/backend/internal/application/application.go @@ -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 diff --git a/backend/internal/application/application_test.go b/backend/internal/application/application_test.go index c4be03d..db650fb 100644 --- a/backend/internal/application/application_test.go +++ b/backend/internal/application/application_test.go @@ -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{ diff --git a/backend/internal/application/runtime.go b/backend/internal/application/runtime.go index db4c8d7..52cc730 100644 --- a/backend/internal/application/runtime.go +++ b/backend/internal/application/runtime.go @@ -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) } diff --git a/backend/internal/application/runtime_test.go b/backend/internal/application/runtime_test.go index e1e723a..9ce4f7b 100644 --- a/backend/internal/application/runtime_test.go +++ b/backend/internal/application/runtime_test.go @@ -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 diff --git a/backend/internal/assets/assets.go b/backend/internal/assets/assets.go index 5f5fde1..646d99a 100644 --- a/backend/internal/assets/assets.go +++ b/backend/internal/assets/assets.go @@ -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 := `` - 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 { diff --git a/backend/internal/assets/service_test.go b/backend/internal/assets/service_test.go index 7e39515..33522c2 100644 --- a/backend/internal/assets/service_test.go +++ b/backend/internal/assets/service_test.go @@ -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")} diff --git a/backend/internal/billing/catalog.go b/backend/internal/billing/catalog.go index 97ae9eb..defbecc 100644 --- a/backend/internal/billing/catalog.go +++ b/backend/internal/billing/catalog.go @@ -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 diff --git a/backend/internal/billing/catalog_test.go b/backend/internal/billing/catalog_test.go index d287de5..dd28281 100644 --- a/backend/internal/billing/catalog_test.go +++ b/backend/internal/billing/catalog_test.go @@ -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) } diff --git a/backend/internal/billing/ledger.go b/backend/internal/billing/ledger.go index f0fa1f6..1c5ec7e 100644 --- a/backend/internal/billing/ledger.go +++ b/backend/internal/billing/ledger.go @@ -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 } diff --git a/backend/internal/httpapi/billing.go b/backend/internal/httpapi/billing.go index fc0ce2f..5178b68 100644 --- a/backend/internal/httpapi/billing.go +++ b/backend/internal/httpapi/billing.go @@ -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 diff --git a/backend/internal/httpapi/billing_test.go b/backend/internal/httpapi/billing_test.go index 89379a5..1b7a52b 100644 --- a/backend/internal/httpapi/billing_test.go +++ b/backend/internal/httpapi/billing_test.go @@ -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) diff --git a/backend/internal/httpapi/httpapi_test.go b/backend/internal/httpapi/httpapi_test.go index b74b246..384c1fa 100644 --- a/backend/internal/httpapi/httpapi_test.go +++ b/backend/internal/httpapi/httpapi_test.go @@ -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" { diff --git a/backend/internal/httpapi/openapi.go b/backend/internal/httpapi/openapi.go index 5b7a17c..a92f358 100644 --- a/backend/internal/httpapi/openapi.go +++ b/backend/internal/httpapi/openapi.go @@ -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"}, diff --git a/backend/internal/jobs/provider.go b/backend/internal/jobs/provider.go index 61b8953..e774465 100644 --- a/backend/internal/jobs/provider.go +++ b/backend/internal/jobs/provider.go @@ -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 != "" { diff --git a/backend/internal/jobs/provider_test.go b/backend/internal/jobs/provider_test.go index 150676a..76e3400 100644 --- a/backend/internal/jobs/provider_test.go +++ b/backend/internal/jobs/provider_test.go @@ -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"}`)} diff --git a/backend/internal/orchestration/orchestration.go b/backend/internal/orchestration/orchestration.go index 675bd60..579d44f 100644 --- a/backend/internal/orchestration/orchestration.go +++ b/backend/internal/orchestration/orchestration.go @@ -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") diff --git a/backend/internal/orchestration/orchestration_test.go b/backend/internal/orchestration/orchestration_test.go index 8c44682..975339e 100644 --- a/backend/internal/orchestration/orchestration_test.go +++ b/backend/internal/orchestration/orchestration_test.go @@ -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) { diff --git a/backend/internal/settings/service.go b/backend/internal/settings/service.go index f386a59..6bfd79e 100644 --- a/backend/internal/settings/service.go +++ b/backend/internal/settings/service.go @@ -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{ diff --git a/components/asset-manager.tsx b/components/asset-manager.tsx index 6929831..23bcfb8 100644 --- a/components/asset-manager.tsx +++ b/components/asset-manager.tsx @@ -52,6 +52,7 @@ export function AssetManager({ initialView = "assets", initialTaskId }: AssetMan } return map; }, [jobs]); + const outputAssetIds = useMemo(() => new Set(assets.map((asset) => asset.id)), [assets]); const visibleAssets = pageItems(assets, assetPage, ASSET_PAGE_SIZE); const visibleJobs = pageItems(jobs, jobPage, TASK_PAGE_SIZE); @@ -262,10 +263,10 @@ export function AssetManager({ initialView = "assets", initialTaskId }: AssetMan
{capabilityLabel(job.capability)} / {job.reqKey}
-{durationLabel(job, durationNow)} / 输入 {job.inputAssetIds.length || job.inputUrls.length} 个,输出 {job.outputAssetIds.length} 个 / {billingLabel(job)}
+{durationLabel(job, durationNow)} / 输入 {job.inputAssetIds.length || job.inputUrls.length} 个,输出 {resolvedOutputCount(job, outputAssetIds)} 个 / {billingLabel(job)}