From 4ac786aafb461119e5fac7c1ba05606c7d4d8272 Mon Sep 17 00:00:00 2001 From: andy Date: Fri, 2 Oct 2026 19:56:32 +0800 Subject: [PATCH] =?UTF-8?q?=E8=8B=A5=E5=B9=B2=E6=9B=B4=E6=96=B0=E8=BF=AD?= =?UTF-8?q?=E4=BB=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 + README.md | 14 +- app/globals.css | 358 +++++++++++++++++- backend/internal/application/runtime.go | 43 ++- backend/internal/application/runtime_test.go | 32 ++ backend/internal/billing/defaults.go | 7 + backend/internal/billing/defaults_test.go | 3 + backend/internal/billing/http_service.go | 1 + backend/internal/billing/minimax.go | 3 +- backend/internal/billing/service.go | 19 + backend/internal/billing/service_test.go | 35 ++ backend/internal/billing/wan30.go | 20 + backend/internal/httpapi/billing.go | 34 ++ backend/internal/httpapi/billing_test.go | 96 +++++ backend/internal/httpapi/job_projection.go | 60 +++ .../internal/httpapi/job_projection_test.go | 151 ++++++++ backend/internal/httpapi/jobs.go | 15 +- .../internal/httpapi/method_compat_test.go | 4 +- backend/internal/httpapi/openapi.go | 5 +- backend/internal/httpapi/route_surface.go | 2 +- backend/internal/jobs/provider.go | 219 ++++++++--- .../internal/jobs/provider_failures_test.go | 107 ++++++ backend/internal/jobs/provider_test.go | 83 +++- backend/internal/orchestration/settlement.go | 128 ++++++- .../internal/orchestration/settlement_test.go | 98 +++++ backend/internal/providers/adapters.go | 39 +- .../internal/providers/failure_diagnostics.go | 150 ++++++++ .../providers/failure_diagnostics_test.go | 185 +++++++++ .../internal/providers/failure_messages.go | 197 ++++++++++ .../providers/failure_messages_test.go | 80 ++++ backend/internal/providers/minimax.go | 19 +- backend/internal/providers/provider.go | 61 ++- backend/internal/providers/providers_test.go | 27 ++ backend/internal/providers/volcengine.go | 18 +- backend/internal/settings/service.go | 6 +- components/account-manager.tsx | 97 +++-- components/asset-manager.tsx | 14 +- components/create-studio.tsx | 232 +++++++++--- components/creation-balance.tsx | 88 +++++ components/generation-error.tsx | 38 ++ contracts/billing/http-v1.json | 1 + contracts/http/route-surface.v1.json | 1 + contracts/providers/http-v1.json | 6 +- docs/API.md | 40 +- lib/bailian/client.ts | 44 ++- lib/client/organization-balance.ts | 24 ++ lib/client/provider-error.ts | 52 +++ lib/minimax/client.ts | 23 +- lib/seedance/client.ts | 18 +- lib/server/app-settings.ts | 6 +- lib/server/billing-catalog.ts | 19 + lib/server/billing-service.ts | 78 +++- lib/server/video-generation-service.ts | 32 +- lib/video-input.ts | 88 +++++ tests/bailian-client.test.ts | 14 + tests/billing-catalog-seed.test.ts | 47 ++- tests/billing-http-contract.test.ts | 1 + tests/create-studio-paste.test.ts | 7 +- tests/generation-error.test.ts | 99 +++++ tests/minimax-client.test.ts | 12 + tests/organization-balance.test.ts | 52 +++ tests/providers-contract.test.ts | 6 +- tests/seedance-client.test.ts | 41 +- tests/video-input.test.ts | 34 ++ 64 files changed, 3251 insertions(+), 291 deletions(-) create mode 100644 backend/internal/billing/wan30.go create mode 100644 backend/internal/httpapi/job_projection.go create mode 100644 backend/internal/httpapi/job_projection_test.go create mode 100644 backend/internal/jobs/provider_failures_test.go create mode 100644 backend/internal/providers/failure_diagnostics.go create mode 100644 backend/internal/providers/failure_diagnostics_test.go create mode 100644 backend/internal/providers/failure_messages.go create mode 100644 backend/internal/providers/failure_messages_test.go create mode 100644 components/creation-balance.tsx create mode 100644 components/generation-error.tsx create mode 100644 lib/client/organization-balance.ts create mode 100644 lib/client/provider-error.ts create mode 100644 lib/video-input.ts create mode 100644 tests/generation-error.test.ts create mode 100644 tests/organization-balance.test.ts create mode 100644 tests/video-input.test.ts diff --git a/.env.example b/.env.example index cdd9541..785ce24 100644 --- a/.env.example +++ b/.env.example @@ -94,8 +94,17 @@ SEEDANCE_RESOLUTION=720p # Default video engine; the creation page can still select another engine per task. VIDEO_GENERATE_ENGINE=bailian +# Alibaba Cloud Model Studio. Match the API key and model's region. +# A workspace-specific MaaS Base URL is also supported. +BAILIAN_API_KEY= +BAILIAN_BASE_URL=https://dashscope.aliyuncs.com +BAILIAN_IMAGE_MODEL=wan2.7-image-pro +# Default only; the video page can select Wan 2.7 or Wan 3.0 per task. +BAILIAN_VIDEO_MODEL=wan2.7-i2v-2026-04-25 + # MiniMax H3 video generation. The API key can also be saved from Settings and # is then persisted in PostgreSQL with precedence over this environment value. +# Supports text, one first-frame image, or up to 9 reference images. MINIMAX_API_KEY= MINIMAX_BASE_URL=https://api.minimax.cn diff --git a/README.md b/README.md index e2618b8..c8de0cf 100644 --- a/README.md +++ b/README.md @@ -120,11 +120,11 @@ https://你的域名/api/auth/callback 平台用量页统计登录用户使用真实服务商后成功完成的任务;失败、取消、过期和开放 API 客户端任务不会计入。普通用户点击页头账号 ID 查看快捷周期和最近记录;管理员通过 `/usage` 按日期、组织、账号、功能类型和服务商查看汇总、趋势及明细。 -计费目录由平台维护各接口的标准成本与参数档案,超级管理员只调整上浮倍率。真实生成任务提交时按“基础标准成本 × 参数档位系数 × 任务数量 × 组合倍率”报价并从组织余额冻结,单价、参数、倍率、数量和最终金额会快照到任务;每个任务在创作结果和历史任务中显示扣费状态。普通用户余额不足时会被拒绝提交并提示“余额不足,请先充值”,不会提交服务商;超级管理员仍计算并记录生成费用,但不检查或扣减组织额度,也不产生钱包扣费、退款流水。Seedance 成功后按上游 `usage.completion_tokens` 重新结算,MiniMax H3 成功后按上游实际输出秒数重新结算,多退少补;没有返回用量时保留冻结金额。组织成员通过 `/billing` 查看组织余额、自己的消耗和账务流水,余额属于组织而不是个人;充值和人工余额调整也只记入组织账本,不设置个人上账归属。 +计费目录由平台维护各接口的标准成本与参数档案,超级管理员只调整上浮倍率。真实生成任务提交时按“基础标准成本 × 参数档位系数 × 任务数量 × 组合倍率”报价并从组织余额冻结,单价、参数、倍率、数量和最终金额会快照到任务;每个任务在创作结果和历史任务中显示扣费状态。普通用户余额不足时会被拒绝提交并提示“余额不足,请先充值”,不会提交服务商;超级管理员仍计算并记录生成费用,但不检查或扣减组织额度,也不产生钱包扣费、退款流水。Seedance 成功后按上游 `usage.completion_tokens` 重新结算,MiniMax H3 成功后按上游实际输出秒数和输入图数量重新结算,Wan 3.0 按实际输出时长重新结算,多退少补;没有返回用量时保留冻结金额并标记为估算。组织成员通过 `/billing` 查看组织余额、自己的消耗和账务流水,余额属于组织而不是个人;充值和人工余额调整也只记入组织账本,不设置个人上账归属。 当前组织余额由超级管理员直接上账,充值和人工余额调整不选择个人归属;未来接入用户自主支付时,支付成功回调自动入账,不设置人工审核队列。余额不足或未配置对应计费规则时,真实生成任务不会提交给服务商。未绑定组织的开放 API 任务暂保持兼容,不纳入组织余额扣费。 -系统首次打开超管计费中心或提交真实任务时会自动补齐平台标准价格目录,不覆盖已有目录。当前默认目录为:百炼 `wan2.7-image-pro` ¥0.50/张,百炼 `wan2.7-i2v-2026-04-25` 720P ¥0.60/秒、1080P ¥1.00/秒;MiniMax H3 的 768P/2K 分别为 ¥0.50/秒、¥0.80/秒;火山方舟 `doubao-seedance-2-0-260128` 480P/720P/1080P/4K 分别为 ¥0.46/秒、¥0.99/秒、¥2.48/秒、¥5.05/秒,`doubao-seedance-2-5-260628` 480P/720P/1080P 分别为 ¥0.67/秒、¥1.51/秒、¥3.74/秒;EvoLink `gpt-image-2` medium/1K/1:1/无参考图基础估算 ¥0.34/张(固定汇率 1 USD = 7.20 CNY),并列出质量、分辨率、画面比例和参考图数量档位;即梦 `jimeng_seedream46_cvtob` 暂按公开资源包折算参考 ¥0.20/张,官方实时计费以控制台为准;Seedream 5.0 Pro 的 1K/1.5K 基础生图为 ¥0.30/张、2K 为 ¥0.60/张,首张输入图免费,从第 2 张起 ¥0.02/张。各标准成本均按平台 `1.2×` 上浮并向上取整到分。参数化报价按基础成本乘以所选参数档位系数,组合倍率取所选档位中的最高倍率;超管只在 `/billing` 调整倍率,标准成本和参数档案由平台维护。 +系统首次打开超管计费中心或提交真实任务时会自动补齐平台标准价格目录,不覆盖已有目录。当前默认目录为:百炼 `wan2.7-image-pro` ¥0.50/张,百炼 `wan2.7-i2v-2026-04-25` 720P ¥0.60/秒、1080P ¥1.00/秒;百炼 `wan3.0-video` 480P/720P/1080P 标准价分别为 ¥0.30/¥0.60/¥1.20 每秒(独立规则,按官网原价维护,不计限时折扣);MiniMax H3 的 768P/2K 分别为 ¥0.50/秒、¥0.80/秒,前 5 张参考图免费、第 6 张起每张标准成本 ¥0.20,并计入预估与预扣;火山方舟 `doubao-seedance-2-0-260128` 480P/720P/1080P/4K 分别为 ¥0.46/秒、¥0.99/秒、¥2.48/秒、¥5.05/秒,`doubao-seedance-2-5-260628` 480P/720P/1080P 分别为 ¥0.67/秒、¥1.51/秒、¥3.74/秒;EvoLink `gpt-image-2` medium/1K/1:1/无参考图基础估算 ¥0.34/张(固定汇率 1 USD = 7.20 CNY),并列出质量、分辨率、画面比例和参考图数量档位;即梦 `jimeng_seedream46_cvtob` 暂按公开资源包折算参考 ¥0.20/张,官方实时计费以控制台为准;Seedream 5.0 Pro 的 1K/1.5K 基础生图为 ¥0.30/张、2K 为 ¥0.60/张,首张输入图免费,从第 2 张起 ¥0.02/张。各标准成本均按平台 `1.2×` 上浮并向上取整到分。参数化报价按基础成本乘以所选参数档位系数,组合倍率取所选档位中的最高倍率;超管只在 `/billing` 调整倍率,标准成本和参数档案由平台维护。 任务会快照账号、租户和组织归属,用量记录不会随任务、素材或账号删除。统计统一采用 `Asia/Shanghai`,明细不展示提示词、素材或生成结果。 @@ -139,9 +139,11 @@ https://你的域名/api/auth/callback ## 视频创作引擎 -- `seedance`:保留 Seedance 2.0/2.5 多素材视频生成。 -- `bailian`:阿里云百炼 1–2 张首尾帧图生视频。 -- `minimax`:MiniMax H3 第一版,支持文生视频或单张首帧图生视频、768P/2K、4–15 秒;生成结果由任务 Worker 查询后立即转存 OSS。 +- `seedance`:Seedance 2.0 支持最多 9 张图片、3 个视频、3 个音频,音频须搭配图片或视频;Seedance 2.5 支持最多 30 张图片、10 个视频、10 个音频,合计最多 50 个。 +- `bailian`:保留 Wan 2.7 的 1–2 张首尾帧视频;新增 `wan3.0-video`,可使用最多 10 张参考图,或切换到 1–2 张首尾帧模式。Wan 3.0 多图参考模式不传图片时可文生视频,支持 480P/720P/1080P、2–30 秒。本次 Wan 3.0 接入仅开放图片素材。 +- `minimax`:MiniMax H3 支持文生视频、单张首帧图生视频,或最多 9 张图片的多图参考视频;支持 768P/2K、4–15 秒。首帧模式根据图片自动确定画幅,多图参考可选画幅;不传图片时须选择固定画幅。生成结果由任务 Worker 查询后立即转存 OSS。 + +视频页面按模型和模式显示素材数量、支持类型和图片要求。切换模型或模式不会删除已有素材;不兼容时显示原因和调整建议,修正后才请求报价和提交。多图参考按图片顺序在提示词中引用 `@图片1`、`@图片2`,首尾帧则分别控制视频开头与结尾。API 的 `settings.inputMode`、模型和素材会随任务保存;具体参数见 [视频任务示例](./docs/API.md#视频任务示例)。 ## 即梦图片能力 @@ -245,6 +247,8 @@ cp .env.example .env.local - `SEEDANCE_DURATION`:Seedance 2.0 支持 `4` 到 `15` 的整数秒,或 `-1` 让模型自动选择 - `SEEDANCE_RESOLUTION`:支持 `480p`、`720p`、`1080p`;Seedance 2.0 fast 不支持 `1080p` - `VIDEO_GENERATE_ENGINE`:支持 `seedance`、`bailian` 或 `minimax` +- `BAILIAN_API_KEY`(或 `DASHSCOPE_API_KEY`)、`BAILIAN_BASE_URL`:Wan 2.7/3.0 共用;API Key、地址和模型开通地域须一致,支持百炼共享域名或业务空间专属 MaaS 域名 +- `BAILIAN_VIDEO_MODEL`:默认 `wan2.7-i2v-2026-04-25`;创作页可逐任务选择 `wan3.0-video`,无需替换旧模型 - `MINIMAX_API_KEY`:MiniMax 开放平台 API Key,可在设置页保存到 PostgreSQL 后立即生效 - `MINIMAX_BASE_URL=https://api.minimax.cn` - `ALI_OSS_*`:用于上传素材和生成结果转存 diff --git a/app/globals.css b/app/globals.css index d2db898..44e6fbf 100644 --- a/app/globals.css +++ b/app/globals.css @@ -492,6 +492,88 @@ h3 { line-height: 1.3; } +.create-center-title-row { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; +} + +.creation-balance { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + max-width: 100%; + padding: 4px 8px; + border: 1px solid var(--line); + border-radius: 10px; + background: #f5faf9; + color: #17645e; + font-size: 12px; + line-height: 1.5; + text-decoration: none; +} + +.creation-balance-summary { + display: inline-flex; + align-items: center; + gap: 6px; + color: inherit; + text-decoration: none; +} + +.creation-balance-summary strong { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.creation-balance-summary:hover, +.creation-balance-exempt:hover { + text-decoration: underline; +} + +.creation-balance-refresh { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + cursor: pointer; +} + +.creation-balance-refresh:hover:not(:disabled) { + background: #e4f0ed; +} + +.creation-balance-refresh:disabled { + cursor: wait; + opacity: .6; +} + +.creation-balance-refresh:focus-visible, +.creation-balance-summary:focus-visible, +.creation-balance-exempt:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.creation-balance-warning { + background: #fff8ed; + color: #87500d; +} + +.creation-balance-note { + flex-basis: 100%; + font-size: 11px; +} + .create-center-head .create-mode-bar { padding: 0; border-bottom: 0; @@ -1031,6 +1113,65 @@ h3 { line-height: 1.5; } +.generation-error { + min-width: 0; + display: grid; + gap: 5px; + padding: 12px 14px; + border: 1px solid #f1c5bd; + border-radius: 10px; + background: #fff7f5; + color: #9f3e30; + font-size: 12px; + line-height: 1.5; +} + +.generation-error-info { + border-color: #e8d7a6; + background: #fffbef; + color: #775d1e; +} + +.generation-error p { + margin: 0; + overflow-wrap: anywhere; +} + +.generation-error-details { + margin-top: 6px; + border-top: 1px solid currentColor; + padding-top: 8px; +} + +.generation-error-details summary { + width: fit-content; + cursor: pointer; + font-weight: 700; +} + +.generation-error-details dl { + display: grid; + gap: 5px; + margin: 9px 0 0; +} + +.generation-error-details dl > div { + display: grid; + grid-template-columns: minmax(68px, 92px) minmax(0, 1fr); + gap: 8px; +} + +.generation-error-details dt { + opacity: 0.8; +} + +.generation-error-details dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + .image-template-rail-head { min-height: 34px; display: flex; @@ -9412,7 +9553,7 @@ button:active:not(:disabled), } .account-members-panel .account-row { - grid-template-columns: minmax(190px, 1.35fr) minmax(140px, 0.9fr) 78px auto; + grid-template-columns: minmax(190px, 1.35fr) minmax(140px, 0.9fr) 78px 112px; min-height: 64px; padding: 11px 0; border-bottom-color: #e6ecee; @@ -9723,6 +9864,112 @@ button:active:not(:disabled), margin-top: 18px; } +.account-org-item { + flex-wrap: wrap; +} + +.account-org-view { + padding: 6px 8px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--surface); + color: var(--green-dark); + font-size: 12px; + white-space: nowrap; + cursor: pointer; +} + +.account-org-view:hover { + background: var(--surface-soft); +} + +.account-org-view:focus-visible { + outline: 2px solid var(--green-dark); + outline-offset: 2px; +} + +.account-members-panel { + scroll-margin-top: 90px; +} + +.account-members-description { + margin: 8px 0 0; + color: var(--muted); + font-size: 12px; +} + +.account-member-groups { + display: grid; + gap: 20px; + margin-top: 22px; +} + +.account-member-group { + min-width: 0; + padding: 16px; + border: 1px solid var(--line); + border-radius: 12px; +} + +.account-member-group-heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.account-member-group-heading h3 { + display: flex; + align-items: center; + gap: 7px; + margin: 0; + font-size: 15px; + overflow-wrap: anywhere; +} + +.account-member-group-heading h3 svg { + flex: 0 0 auto; + color: var(--green-dark); +} + +.account-member-count { + margin-left: auto; + padding: 3px 8px; + border-radius: 6px; + background: var(--surface-soft); + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.account-member-group-empty { + margin: 18px 0 2px; + color: var(--muted); + font-size: 13px; +} + +@media (max-width: 920px) { + .account-management-grid { + grid-template-columns: minmax(0, 1fr); + } + + .account-management-form + .account-management-form { + padding: 20px 0 0; + border-left: 0; + border-top: 1px solid var(--line); + } + + .account-members-heading, + .account-members-tools { + flex-wrap: wrap; + align-items: flex-start; + } + + .account-members-panel .account-row { + grid-template-columns: minmax(0, 1fr); + } +} + /* Create controls: keep the parameter choices and the resulting price in one visual row. */ .generation-settings-row { display: grid; @@ -9749,6 +9996,11 @@ button:active:not(:disabled), flex: 0 1 auto; } +.generation-settings-row.video-settings-row .inline-settings, +.main.create-main .generation-settings-row.video-settings-row .inline-settings { + grid-template-columns: repeat(auto-fit, minmax(105px, 1fr)); +} + .generation-settings-row .inline-settings .inline-field, .generation-settings-row .inline-settings .inline-field:nth-child(2) { width: auto; @@ -9880,3 +10132,107 @@ button:active:not(:disabled), gap: 8px; } } + +.video-input-guidance { + margin-top: 10px; + padding: 10px 12px; + border: 1px solid #c8ddd5; + border-radius: 10px; + background: #f5faf8; + color: #28564b; + font-size: 12px; + line-height: 1.6; +} + +.video-input-guidance p { + margin: 0; +} + +.video-input-guidance > p:first-child { + display: flex; + align-items: flex-start; + gap: 7px; +} + +.video-input-guidance > p:first-child svg { + flex: 0 0 auto; + margin-top: 1px; +} + +.video-input-guidance .video-input-problem { + margin-top: 7px; + color: #a63535; + font-weight: 600; +} + +.video-input-guidance details { + margin-top: 5px; + color: #52665f; +} + +.video-input-guidance summary { + width: fit-content; + cursor: pointer; +} + +.video-material-number-hint { + display: block; + margin-top: 4px; +} + +.video-submit-reason { + max-width: 280px; + color: #8f4b35; + font-size: 12px; + line-height: 1.4; +} + +.video-quote-retry { + margin-top: 5px; + padding: 0; + border: 0; + background: none; + color: #146c5e; + font: inherit; + font-weight: 600; + text-decoration: underline; + cursor: pointer; +} + +/* Extra video controls must not collapse the prompt editor in short viewports. */ +.main.create-main .video-create-center .create-main-column { + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + padding-right: 6px; +} + +.main.create-main .video-create-center .prompt-field { + flex: 1 0 auto; + min-height: 260px; + overflow: visible; +} + +.main.create-main .video-create-center .prompt-field .prompt-editor-wrap { + flex: 1 0 150px; + min-height: 150px; +} + +.main.create-main .video-create-center .prompt-field textarea { + min-height: 150px; +} + +@container create-main-column (max-width: 680px) { + .generation-settings-row.video-settings-row { + grid-template-columns: minmax(0, 1fr); + } + + .generation-settings-row.video-settings-row .billing-estimate { + min-width: 0; + } + + .generation-settings-row.video-settings-row .billing-estimate-head { + white-space: normal; + } +} diff --git a/backend/internal/application/runtime.go b/backend/internal/application/runtime.go index 3844ba4..9daa6e2 100644 --- a/backend/internal/application/runtime.go +++ b/backend/internal/application/runtime.go @@ -236,6 +236,11 @@ func providerVideoModels(getenv postgres.Getenv) map[string]jobs.ProviderTarget for _, model := range models { targets[model] = jobs.ProviderTarget{Provider: "seedance", Model: model, Settings: seedanceDefaultSettings(getenv, model)} } + bailianConfigured := firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25") + for _, model := range []string{"wan2.7-i2v-2026-04-25", "wan3.0-video", bailianConfigured} { + targets[model] = jobs.ProviderTarget{Provider: "bailian", Model: model} + } + targets[providers.MinimaxH3Model] = jobs.ProviderTarget{Provider: "minimax", Model: providers.MinimaxH3Model, Settings: map[string]any{"ratio": "16:9", "duration": float64(5), "resolution": "768P"}} return targets } @@ -303,7 +308,8 @@ func capabilitySummary(getenv postgres.Getenv) func(context.Context) (any, error primaryVideo["limits"] = minimaxVideoCapabilityLimits() } else { primaryVideo["label"] = "百炼图生视频" - primaryVideo["limits"] = bailianVideoCapabilityLimits() + primaryVideo["limits"] = bailianVideoCapabilityLimitsForModel(videoModel(getenv)) + primaryVideo["models"] = bailianVideoModelOptions() } imageCapability := map[string]any{"id": "image.generate", "label": imageCapabilityLabel(engine), "kind": "image", "engine": engine, "provider": provider, "reqKey": imageModel(getenv)} imageCapability["evolink"] = evolinkImageCapability(getenv) @@ -313,7 +319,7 @@ func capabilitySummary(getenv postgres.Getenv) func(context.Context) (any, error return []any{ imageCapability, primaryVideo, - map[string]any{"id": "video.generate.bailian", "label": "百炼图生视频", "kind": "video", "engine": "bailian", "provider": "bailian", "reqKey": firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25"), "limits": bailianVideoCapabilityLimits()}, + map[string]any{"id": "video.generate.bailian", "label": "百炼图生视频", "kind": "video", "engine": "bailian", "provider": "bailian", "reqKey": firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25"), "limits": bailianVideoCapabilityLimitsForModel(firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25")), "models": bailianVideoModelOptions()}, map[string]any{"id": "video.generate.minimax", "label": "MiniMax H3 视频生成", "kind": "video", "engine": "minimax", "provider": "minimax", "reqKey": providers.MinimaxH3Model, "limits": minimaxVideoCapabilityLimits()}, }, nil } @@ -344,7 +350,13 @@ func seedanceCapabilityLimits(model string) map[string]any { if model == "doubao-seedance-2-5-260628" { maximum = 30 } - return map[string]any{"durationSeconds": map[string]int{"min": 4, "max": maximum}, "ratios": []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"}, "resolutions": []string{"480p", "720p", "1080p"}} + limits := map[string]any{"durationSeconds": map[string]int{"min": 4, "max": maximum}, "ratios": []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"}, "resolutions": []string{"480p", "720p", "1080p"}} + if model == "doubao-seedance-2-5-260628" { + limits["materials"] = map[string]int{"images": 30, "videos": 10, "audios": 10, "total": 50} + } else { + limits["materials"] = map[string]int{"images": 9, "videos": 3, "audios": 3} + } + return limits } func seedanceModelOptions() []any { @@ -358,9 +370,30 @@ func bailianVideoCapabilityLimits() map[string]any { return map[string]any{"inputImages": map[string]int{"min": 1, "max": 2}, "durationSeconds": map[string]int{"min": 2, "max": 15}, "resolutions": []string{"720P", "1080P"}} } +func bailianVideoCapabilityLimitsForModel(model string) map[string]any { + if model != "wan3.0-video" { + return bailianVideoCapabilityLimits() + } + return map[string]any{ + "inputImages": map[string]int{"min": 0, "max": 10}, + "inputModes": map[string]any{"reference": map[string]int{"min": 0, "max": 10}, "frames": map[string]int{"min": 1, "max": 2}}, + "durationSeconds": map[string]int{"min": 2, "max": 30}, + "resolutions": []string{"480P", "720P", "1080P"}, + "ratios": []string{"adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}, + } +} + +func bailianVideoModelOptions() []any { + return []any{ + map[string]any{"id": "wan2.7-i2v-2026-04-25", "label": "Wan 2.7 首尾帧", "limits": bailianVideoCapabilityLimits()}, + map[string]any{"id": "wan3.0-video", "label": "Wan 3.0 多图参考", "limits": bailianVideoCapabilityLimitsForModel("wan3.0-video")}, + } +} + func minimaxVideoCapabilityLimits() map[string]any { return map[string]any{ - "inputImages": map[string]int{"min": 0, "max": 1}, + "inputImages": map[string]int{"min": 0, "max": 9}, + "inputModes": map[string]any{"reference": map[string]int{"min": 0, "max": 9}, "frames": map[string]int{"min": 0, "max": 1}}, "durationSeconds": map[string]int{"min": 4, "max": 15}, "ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "adaptive"}, "resolutions": []string{"768P", "2K"}, @@ -390,6 +423,8 @@ func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails { } if video == "seedance" { videoCapability["models"] = seedanceModelOptions() + } else if video == "bailian" { + videoCapability["models"] = bailianVideoModelOptions() } return httpapi.HealthDetails{ VisualAPIMode: providerMode(visualConfigured, "volcengine"), diff --git a/backend/internal/application/runtime_test.go b/backend/internal/application/runtime_test.go index e60f7f4..a5c84d5 100644 --- a/backend/internal/application/runtime_test.go +++ b/backend/internal/application/runtime_test.go @@ -432,6 +432,38 @@ func TestProviderVideoModelsKeepSeedance20And25Available(t *testing.T) { } } +func TestProviderVideoModelsIncludeWan30AndScopedReferenceLimits(t *testing.T) { + targets := providerVideoModels(func(string) string { return "" }) + if target := targets["wan3.0-video"]; target.Provider != "bailian" || target.Model != "wan3.0-video" { + t.Fatalf("Wan 3.0 target=%#v", target) + } + if target := targets[providers.MinimaxH3Model]; target.Provider != "minimax" { + t.Fatalf("MiniMax target=%#v", target) + } + if got := bailianVideoCapabilityLimitsForModel("wan3.0-video")["inputImages"].(map[string]int)["max"]; got != 10 { + t.Fatalf("Wan 3.0 max images=%d", got) + } + if got := minimaxVideoCapabilityLimits()["inputImages"].(map[string]int)["max"]; got != 9 { + t.Fatalf("MiniMax max images=%d", got) + } + if got := seedanceCapabilityLimits("doubao-seedance-2-0-260128")["materials"].(map[string]int)["images"]; got != 9 { + t.Fatalf("Seedance 2.0 max images=%d", got) + } +} + +func TestBailianCapabilityLimitsUseItsConfiguredModelWhenAnotherEngineIsDefault(t *testing.T) { + values := map[string]string{"VIDEO_GENERATE_ENGINE": "seedance", "BAILIAN_VIDEO_MODEL": "wan3.0-video"} + capabilities, err := capabilitySummary(func(name string) string { return values[name] })(context.Background()) + if err != nil { + t.Fatal(err) + } + bailian := capabilities.([]any)[2].(map[string]any) + limits := bailian["limits"].(map[string]any) + if bailian["reqKey"] != "wan3.0-video" || limits["inputImages"].(map[string]int)["max"] != 10 || limits["durationSeconds"].(map[string]int)["max"] != 30 { + t.Fatalf("Bailian capability=%#v", bailian) + } +} + func TestCapabilitySummaryMatchesConfiguredDefaultVideoEngine(t *testing.T) { for _, test := range []struct { name, engine, wantEngine, wantProvider, wantModel string diff --git a/backend/internal/billing/defaults.go b/backend/internal/billing/defaults.go index 2160642..9cb7773 100644 --- a/backend/internal/billing/defaults.go +++ b/backend/internal/billing/defaults.go @@ -30,6 +30,9 @@ func DefaultBillingPriceRules() []PriceRule { {ID: "base-seedream-5-0-pro-layers", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 15, Conditions: Conditions{"layerDecomposition": true}, Priority: 10, Note: "图层拆分:≤261万像素 ¥0.15/张,>261万像素 ¥0.30/张;最多预留底图加16个图层,完成后按每张实际像素结算。", Source: catalogSource("https://docs.volcengine.com/docs/82379/1544106?lang=zh"), Dimensions: seedreamDimensions()}, {ID: "base-bailian-wan27-i2v-720p", Provider: "bailian", Capability: "video.generate", ReqKey: "wan2.7-i2v-2026-04-25", VariantKey: "resolution=720p", Unit: UnitVideoSecond, StandardUnitPriceFen: 60, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-i2v")}, {ID: "base-bailian-wan27-i2v-1080p", Provider: "bailian", Capability: "video.generate", ReqKey: "wan2.7-i2v-2026-04-25", VariantKey: "resolution=1080p", Unit: UnitVideoSecond, StandardUnitPriceFen: 100, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-i2v")}, + {ID: "base-bailian-wan30-video-480p", Provider: "bailian", Capability: "video.generate", ReqKey: BailianWan30VideoModel, VariantKey: "resolution=480p", Unit: UnitVideoSecond, StandardUnitPriceFen: 30, Note: "北京地域官方原价 ¥0.30/秒;官网限时折扣未计入标准成本,图片输入不另收费。", Source: wan30CatalogSource()}, + {ID: "base-bailian-wan30-video-720p", Provider: "bailian", Capability: "video.generate", ReqKey: BailianWan30VideoModel, VariantKey: "resolution=720p", Unit: UnitVideoSecond, StandardUnitPriceFen: 60, Note: "北京地域官方原价 ¥0.60/秒;官网限时折扣未计入标准成本,图片输入不另收费。", Source: wan30CatalogSource()}, + {ID: "base-bailian-wan30-video-1080p", Provider: "bailian", Capability: "video.generate", ReqKey: BailianWan30VideoModel, VariantKey: "resolution=1080p", Unit: UnitVideoSecond, StandardUnitPriceFen: 120, Note: "北京地域官方原价 ¥1.20/秒;官网限时折扣未计入标准成本,图片输入不另收费。", Source: wan30CatalogSource()}, {ID: "base-seedance-2-0-480p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=480p", Unit: UnitVideoSecond, StandardUnitPriceFen: 46, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")}, {ID: "base-seedance-2-0-720p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=720p", Unit: UnitVideoSecond, StandardUnitPriceFen: 99, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")}, {ID: "base-seedance-2-0-1080p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=1080p", Unit: UnitVideoSecond, StandardUnitPriceFen: 248, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")}, @@ -51,6 +54,10 @@ func minimaxCatalogSource() map[string]any { return map[string]any{"url": "https://platform.minimaxi.com/docs/guides/pricing-paygo#视频", "observedAt": "2026-09-04"} } +func wan30CatalogSource() map[string]any { + return map[string]any{"url": "https://help.aliyun.com/zh/model-studio/model-pricing", "observedAt": "2026-10-01", "basis": "北京地域官方原价;限时7折未计入标准成本;仅输出视频按实际秒数计费(本接入仅支持图片输入)"} +} + func seedreamDimensions() []ParameterDimension { return []ParameterDimension{{ Key: "size", Label: "分辨率", BaselineValue: "1.5K", DefaultValue: "1.5K", diff --git a/backend/internal/billing/defaults_test.go b/backend/internal/billing/defaults_test.go index 22b92d8..5e9e2da 100644 --- a/backend/internal/billing/defaults_test.go +++ b/backend/internal/billing/defaults_test.go @@ -14,6 +14,9 @@ func TestDefaultBillingPriceRulesFreezeOfficialCatalogMatchKeys(t *testing.T) { "base-seedream-5-0-pro-layers": false, "base-bailian-wan27-i2v-720p": false, "base-bailian-wan27-i2v-1080p": false, + "base-bailian-wan30-video-480p": false, + "base-bailian-wan30-video-720p": false, + "base-bailian-wan30-video-1080p": false, "base-seedance-2-0-480p": false, "base-seedance-2-0-720p": false, "base-seedance-2-0-1080p": false, diff --git a/backend/internal/billing/http_service.go b/backend/internal/billing/http_service.go index 3bd4825..5ab6c90 100644 --- a/backend/internal/billing/http_service.go +++ b/backend/internal/billing/http_service.go @@ -112,6 +112,7 @@ type AccountConfigStore interface { // Billing HTTP module. Implementations can execute overview reads concurrently. type ReadService interface { Overview(context.Context, string, string) (Overview, error) + Wallet(context.Context, string) (Wallet, error) AdminOverview(context.Context) (AdminOverview, error) ListPrices(context.Context) ([]PriceRule, error) GetPrice(context.Context, string) (*PriceRule, error) diff --git a/backend/internal/billing/minimax.go b/backend/internal/billing/minimax.go index 63ebb2d..f827be6 100644 --- a/backend/internal/billing/minimax.go +++ b/backend/internal/billing/minimax.go @@ -24,8 +24,7 @@ func MinimaxH3VideoPriceFenPerSecond(resolution string) int64 { } // CalculateMinimaxH3ActualAmountFen follows the official pay-as-you-go H3 -// formula. The first five input images are free; this first product release -// accepts at most one, while keeping the calculator correct for future modes. +// formula. The first five input images are free. func CalculateMinimaxH3ActualAmountFen(input MinimaxH3ActualAmountInput) (int64, error) { if input.OutputSeconds <= 0 { return 0, errors.New("MiniMax H3 output seconds must be positive") diff --git a/backend/internal/billing/service.go b/backend/internal/billing/service.go index 570547c..0c40a6c 100644 --- a/backend/internal/billing/service.go +++ b/backend/internal/billing/service.go @@ -69,6 +69,9 @@ func (s *Service) Overview(ctx context.Context, organizationID, accountID string } return Overview{Wallet: wallet, Ledger: ledger, Summary: Summarize(ledger), Personal: Summarize(personal)}, nil } +func (s *Service) Wallet(ctx context.Context, organizationID string) (Wallet, error) { + return s.store.BillingWallet(ctx, organizationID) +} func (s *Service) Quote(ctx context.Context, command QuoteCommand) (*Quote, error) { if !s.enabled { return nil, nil @@ -114,6 +117,22 @@ func (s *Service) Quote(ctx context.Context, command QuoteCommand) (*Quote, erro return quote, nil } if command.Provider == "minimax" && IsMinimaxH3BillingModel(command.ReqKey) { + inputImageCount := 0 + if count, ok := number(parameters["referenceImageCount"]); ok { + inputImageCount = int(math.Ceil(count)) + } + extraInputPriceFen := int64(max(0, inputImageCount-5) * 20) + quote.AmountFen = int64(math.Ceil((float64(quote.StandardUnitPriceFen)*quote.Quantity + float64(extraInputPriceFen)) * quote.MarkupMultiplier)) + if quote.Parameters == nil { + quote.Parameters = Parameters{} + } + quote.Parameters["inputImageCount"] = float64(inputImageCount) + quote.Parameters["inputImagePriceFen"] = float64(extraInputPriceFen) + quote.ReservedAmountFen = quote.AmountFen + quote.SettlementStatus = "pending" + return quote, nil + } + if command.Provider == "bailian" && command.ReqKey == BailianWan30VideoModel { quote.ReservedAmountFen = quote.AmountFen quote.SettlementStatus = "pending" return quote, nil diff --git a/backend/internal/billing/service_test.go b/backend/internal/billing/service_test.go index 6cadb67..af09a6a 100644 --- a/backend/internal/billing/service_test.go +++ b/backend/internal/billing/service_test.go @@ -157,6 +157,41 @@ func TestServiceQuoteReservesMinimaxH3OfficialRateWithMarkup(t *testing.T) { } } +func TestServiceQuoteReservesMinimaxH3ExtraReferenceImages(t *testing.T) { + store := "eStoreStub{rules: []PriceRule{{ + ID: "minimax-h3-2k", Provider: "minimax", Capability: "video.generate", ReqKey: MinimaxH3Model, + VariantKey: "resolution=2k", Unit: UnitVideoSecond, StandardUnitPriceFen: 80, MarkupMultiplier: 1.2, Enabled: true, + }}} + materials := make([]any, 9) + for i := range materials { + materials[i] = map[string]any{"type": "image", "url": "https://cdn.test/ref.png"} + } + quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{ + Provider: "minimax", Capability: "video.generate", ReqKey: MinimaxH3Model, + Payload: map[string]any{"settings": map[string]any{"duration": 5, "resolution": "2K"}, "materials": materials}, + }) + if err != nil { + t.Fatal(err) + } + if quote.AmountFen != 576 || quote.ReservedAmountFen != 576 || quote.Parameters["inputImageCount"] != float64(9) || quote.Parameters["inputImagePriceFen"] != float64(80) { + t.Fatalf("quote=%#v", quote) + } +} + +func TestServiceQuoteReservesIndependentWan30Price(t *testing.T) { + store := "eStoreStub{rules: DefaultBillingPriceRules()} + quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{ + Provider: "bailian", Capability: "video.generate", ReqKey: BailianWan30VideoModel, + Payload: map[string]any{"settings": map[string]any{"duration": 5, "resolution": "1080P"}}, + }) + if err != nil { + t.Fatal(err) + } + if quote.PriceRuleID != "base-bailian-wan30-video-1080p" || quote.AmountFen != 720 || quote.ReservedAmountFen != 720 || quote.SettlementStatus != "pending" { + t.Fatalf("quote=%#v", quote) + } +} + func TestServiceQuoteAppliesSeedreamSizeAndReferenceInputPricing(t *testing.T) { store := "eStoreStub{rules: []PriceRule{{ ID: "seedream-5", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, diff --git a/backend/internal/billing/wan30.go b/backend/internal/billing/wan30.go new file mode 100644 index 0000000..a268845 --- /dev/null +++ b/backend/internal/billing/wan30.go @@ -0,0 +1,20 @@ +package billing + +import ( + "errors" + "math" +) + +const BailianWan30VideoModel = "wan3.0-video" + +// CalculateBailianWan30ActualAmountFen bills successful output duration. This +// integration accepts image inputs only, so there are no input video seconds. +func CalculateBailianWan30ActualAmountFen(outputSeconds float64, standardUnitPriceFen int64, markupMultiplier float64) (int64, error) { + if math.IsNaN(outputSeconds) || math.IsInf(outputSeconds, 0) || outputSeconds <= 0 { + return 0, errors.New("Wan 3.0 output seconds must be positive") + } + if standardUnitPriceFen <= 0 || math.IsNaN(markupMultiplier) || math.IsInf(markupMultiplier, 0) || markupMultiplier < 1 { + return 0, errors.New("Wan 3.0 price or markup is invalid") + } + return int64(math.Ceil(outputSeconds * float64(standardUnitPriceFen) * markupMultiplier)), nil +} diff --git a/backend/internal/httpapi/billing.go b/backend/internal/httpapi/billing.go index 1f85a17..cb1e072 100644 --- a/backend/internal/httpapi/billing.go +++ b/backend/internal/httpapi/billing.go @@ -36,6 +36,8 @@ func (h *billingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/api/billing": h.billing(w, r) + case r.URL.Path == "/api/billing/balance": + h.balance(w, r) case r.URL.Path == "/api/billing/quote": h.quote(w, r) case r.URL.Path == "/api/admin/billing": @@ -53,6 +55,38 @@ func (h *billingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +func (h *billingHandler) balance(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if !allow(w, r, http.MethodGet) { + return + } + session, ok := h.authorize(w, r, PlatformApp) + if !ok { + return + } + organizationID := session.User.OrganizationID + if organizationID == "" { + writeAPIError(w, 422, "当前账号未绑定组织。") + return + } + if h.service == nil { + writeAPIError(w, 500, "服务器内部错误。") + return + } + wallet, err := h.service.Wallet(r.Context(), organizationID) + if err != nil { + writeDomainError(w, err) + return + } + writeJSON(w, 200, struct { + Organization map[string]string `json:"organization"` + Wallet billing.Wallet `json:"wallet"` + }{ + Organization: map[string]string{"id": organizationID, "name": first(session.User.OrganizationName, organizationID)}, + Wallet: wallet, + }) +} + func (h *billingHandler) billing(w http.ResponseWriter, r *http.Request) { if !allow(w, r, http.MethodGet) { return diff --git a/backend/internal/httpapi/billing_test.go b/backend/internal/httpapi/billing_test.go index f7991c4..95fa84f 100644 --- a/backend/internal/httpapi/billing_test.go +++ b/backend/internal/httpapi/billing_test.go @@ -28,6 +28,96 @@ func TestBillingMemberRoutesUseRefreshedSessionScope(t *testing.T) { } } +func TestBillingBalanceUsesOnlyRefreshedSessionOrganization(t *testing.T) { + service := &billingHTTPServiceStub{} + h := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "member", ClientID: "platform", OrganizationID: "db-org", OrganizationName: "DB Org", Role: "user"}}, service, nil) + response := serveJSON(t, h, http.MethodGet, "/api/billing/balance?organizationId=attacker&orgID=attacker", nil) + if response.Code != 200 || response.Header().Get("Cache-Control") != "no-store" || service.walletOrganization != "db-org" { + t.Fatalf("status=%d cache=%q scope=%q body=%s", response.Code, response.Header().Get("Cache-Control"), service.walletOrganization, response.Body.String()) + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if len(payload) != 2 || string(payload["organization"]) != `{"id":"db-org","name":"DB Org"}` { + t.Fatalf("unexpected organization or extra fields: %s", response.Body.String()) + } + var wallet billing.Wallet + if err := json.Unmarshal(payload["wallet"], &wallet); err != nil || wallet.OrganizationID != "db-org" || wallet.BalanceFen != 0 { + t.Fatalf("zero wallet=%+v err=%v body=%s", wallet, err, response.Body.String()) + } +} + +func TestBillingBalanceRejectsUnboundAndUnauthenticatedSessions(t *testing.T) { + service := &billingHTTPServiceStub{} + h := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "member", ClientID: "platform", Role: "user"}}, service, nil) + if got := serveJSON(t, h, http.MethodGet, "/api/billing/balance?organizationId=other", nil); got.Code != 422 || service.walletOrganization != "" { + t.Fatalf("unbound status=%d scope=%q body=%s", got.Code, service.walletOrganization, got.Body.String()) + } + for _, credential := range []struct{ name, header, value string }{ + {"missing", "", ""}, {"bearer", "Authorization", "Bearer other"}, {"api-key", "X-API-Key", "other"}, + } { + t.Run(credential.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/api/billing/balance", nil) + if credential.header != "" { + request.Header.Set(credential.header, credential.value) + } + response := httptest.NewRecorder() + h.ServeHTTP(response, request) + if response.Code != 401 || service.walletOrganization != "" { + t.Fatalf("status=%d scope=%q body=%s", response.Code, service.walletOrganization, response.Body.String()) + } + }) + } + authorizer, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &platformSessionResolverStub{outcome: "unauthenticated"}) + if err != nil { + t.Fatal(err) + } + invalidSession := NewBillingHandler(authorizer, service, nil) + if got := serveJSON(t, invalidSession, http.MethodGet, "/api/billing/balance", nil); got.Code != 401 || service.walletOrganization != "" { + t.Fatalf("invalid session status=%d scope=%q body=%s", got.Code, service.walletOrganization, got.Body.String()) + } +} + +func TestBillingBalanceHidesStoreErrors(t *testing.T) { + service := &billingHTTPServiceStub{err: errors.New("database secret")} + h := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "member", ClientID: "platform", OrganizationID: "org", Role: "user"}}, service, nil) + got := serveJSON(t, h, http.MethodGet, "/api/billing/balance", nil) + if got.Code != 500 || bytes.Contains(got.Body.Bytes(), []byte("database secret")) || got.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("status=%d body=%s", got.Code, got.Body.String()) + } +} + +func TestBillingBalanceReadsOnlyWallet(t *testing.T) { + store := &balanceOnlyStore{} + h := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "member", ClientID: "platform", OrganizationID: "org", Role: "user"}}, billing.NewService(store, nil), nil) + got := serveJSON(t, h, http.MethodGet, "/api/billing/balance", nil) + if got.Code != 200 || store.walletCalls != 1 || store.organizationID != "org" { + t.Fatalf("status=%d walletCalls=%d org=%q body=%s", got.Code, store.walletCalls, store.organizationID, got.Body.String()) + } +} + +type balanceOnlyStore struct { + emptyBillingStore + walletCalls int + organizationID string +} + +func (s *balanceOnlyStore) BillingWallet(_ context.Context, organizationID string) (billing.Wallet, error) { + s.walletCalls++ + s.organizationID = organizationID + return billing.Wallet{OrganizationID: organizationID, BalanceFen: 0, Currency: billing.CurrencyCNY}, nil +} +func (*balanceOnlyStore) BillingLedger(context.Context, string, string, int) ([]billing.LedgerEntry, error) { + panic("balance endpoint accessed ledger") +} +func (*balanceOnlyStore) ListBillingPriceRules(context.Context, bool) ([]billing.PriceRule, error) { + panic("balance endpoint accessed catalog") +} +func (*balanceOnlyStore) BillingWallets(context.Context) ([]billing.Wallet, error) { + panic("balance endpoint accessed all wallets") +} + func TestBillingOverviewResponsesEncodeEmptyCollectionsAsArrays(t *testing.T) { service := billing.NewService(&emptyBillingStore{}, nil) member := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "user", ClientID: "platform", OrganizationID: "org", Role: "user"}}, service, nil) @@ -135,6 +225,7 @@ func TestBillingHandlerRejectsWrongMethods(t *testing.T) { type billingHTTPServiceStub struct { overviewOrganization, overviewAccount string + walletOrganization string quote billing.QuoteCommand adjustment billing.AdjustmentCommand pricePatch billing.PricePatch @@ -143,6 +234,11 @@ type billingHTTPServiceStub struct { err error } +func (s *billingHTTPServiceStub) Wallet(_ context.Context, organizationID string) (billing.Wallet, error) { + s.walletOrganization = organizationID + return billing.Wallet{OrganizationID: organizationID, Currency: billing.CurrencyCNY}, s.err +} + func (s *billingHTTPServiceStub) Overview(_ context.Context, organizationID, accountID string) (billing.Overview, error) { s.overviewOrganization, s.overviewAccount = organizationID, accountID return billing.Overview{Wallet: billing.Wallet{OrganizationID: organizationID, Currency: billing.CurrencyCNY}}, s.err diff --git a/backend/internal/httpapi/job_projection.go b/backend/internal/httpapi/job_projection.go new file mode 100644 index 0000000..7b1f21e --- /dev/null +++ b/backend/internal/httpapi/job_projection.go @@ -0,0 +1,60 @@ +package httpapi + +import ( + "encoding/json" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs" +) + +// The diagnostics stored with a job are intended for platform administrators. +// Project a copy at the HTTP boundary so neither the persisted job nor a +// caller's shared RawMessage buffer is changed by a response to another role. +func projectJobForViewer(job jobs.Job, diagnosticsAllowed bool) jobs.Job { + if diagnosticsAllowed || len(job.ResponsePayload) == 0 { + return job + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(job.ResponsePayload, &payload); err != nil || payload == nil { + job.ResponsePayload = nil + return job + } + for _, key := range []string{"providerError", "errorCode", "errorDetail", "requestId"} { + delete(payload, key) + } + if job.Status == jobs.StatusFailed || job.Status == jobs.StatusCancelled || job.Status == jobs.StatusExpired { + // Historical failures may contain an unsanitized provider body. The + // user-facing explanation is job.Error.Message, never this raw body. + delete(payload, "raw") + delete(payload, "errorMessage") + } + if len(payload) == 0 { + job.ResponsePayload = nil + return job + } + projected, err := json.Marshal(payload) + if err != nil { + job.ResponsePayload = nil + return job + } + job.ResponsePayload = projected + return job +} + +func projectJobsForViewer(items []jobs.Job, diagnosticsAllowed bool) []jobs.Job { + if diagnosticsAllowed || len(items) == 0 { + return items + } + projected := make([]jobs.Job, len(items)) + for index := range items { + projected[index] = projectJobForViewer(items[index], false) + } + return projected +} + +func canViewJobDiagnostics(session identity.Session) bool { + if session.AuthMode != identity.AuthModeAdmin { + return false + } + return session.User.Role == "super_admin" || session.User.Role == "organization_admin" +} diff --git a/backend/internal/httpapi/job_projection_test.go b/backend/internal/httpapi/job_projection_test.go new file mode 100644 index 0000000..3cd949d --- /dev/null +++ b/backend/internal/httpapi/job_projection_test.go @@ -0,0 +1,151 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/publicapi" +) + +const jobWithDiagnosticsPayload = `{"providerError":{"status":400,"code":"PrivateCode","requestId":"private-request","detail":"private diagnostic","phase":"submit"},"errorCode":"PrivateCode","errorDetail":"private diagnostic","requestId":"private-request","errorMessage":"private diagnostic","raw":{"providerMessage":"private diagnostic"},"outputUrls":["https://example.test/result.png"],"usage":{"images":2},"layers":[{"type":"image","url":"https://example.test/layer.png"}]}` + +func TestProjectJobForViewerKeepsSuccessMetadataWithoutMutatingStoredPayload(t *testing.T) { + original := jobs.Job{Status: jobs.StatusFailed, ResponsePayload: json.RawMessage(jobWithDiagnosticsPayload)} + viewer := projectJobForViewer(original, false) + if got := string(original.ResponsePayload); got != jobWithDiagnosticsPayload { + t.Fatalf("original responsePayload was mutated: %s", got) + } + if strings.Contains(string(viewer.ResponsePayload), "PrivateCode") || strings.Contains(string(viewer.ResponsePayload), "private diagnostic") || strings.Contains(string(viewer.ResponsePayload), "private-request") { + t.Fatalf("viewer received diagnostic: %s", viewer.ResponsePayload) + } + for _, field := range []string{"outputUrls", "usage", "layers"} { + var decoded map[string]json.RawMessage + if err := json.Unmarshal(viewer.ResponsePayload, &decoded); err != nil || len(decoded[field]) == 0 { + t.Fatalf("successful %s missing after projection: %s (%v)", field, viewer.ResponsePayload, err) + } + } + for _, status := range []jobs.Status{jobs.StatusFailed, jobs.StatusCancelled, jobs.StatusExpired} { + legacy := original + legacy.Status = status + got := projectJobForViewer(legacy, false) + var decoded map[string]json.RawMessage + if err := json.Unmarshal(got.ResponsePayload, &decoded); err != nil { + t.Fatalf("status %s has invalid projected payload: %v", status, err) + } + for _, key := range []string{"raw", "errorMessage"} { + if _, exists := decoded[key]; exists { + t.Fatalf("status %s exposed historical %s: %s", status, key, got.ResponsePayload) + } + } + } + if got := projectJobForViewer(original, true); string(got.ResponsePayload) != jobWithDiagnosticsPayload { + t.Fatalf("administrator response changed: %s", got.ResponsePayload) + } + succeeded := original + succeeded.Status = jobs.StatusSucceeded + if got := projectJobForViewer(succeeded, false); !bytes.Contains(got.ResponsePayload, []byte(`"raw"`)) { + t.Fatalf("successful provider metadata was lost: %s", got.ResponsePayload) + } + if got := projectJobForViewer(jobs.Job{ResponsePayload: []byte(`{"providerError":{"detail":"private"}}`)}, false); got.ResponsePayload != nil { + t.Fatalf("diagnostics-only payload should be omitted: %s", got.ResponsePayload) + } +} + +func TestJobsHTTPProjectsProviderDiagnosticsByRoleAndForPublicAPI(t *testing.T) { + for _, testCase := range []struct { + name string + role string + authMode identity.AuthMode + wantDetail bool + }{ + {name: "ordinary user", role: "user", authMode: identity.AuthModeUser}, + {name: "organization administrator", role: "organization_admin", authMode: identity.AuthModeAdmin, wantDetail: true}, + {name: "platform administrator", role: "super_admin", authMode: identity.AuthModeAdmin, wantDetail: true}, + } { + t.Run(testCase.name, func(t *testing.T) { + store := &httpJobStore{values: map[string]jobs.Job{ + "failed": {ID: "failed", OwnerID: "owner", Capability: "image.generate", Status: jobs.StatusFailed, ResponsePayload: json.RawMessage(jobWithDiagnosticsPayload), Error: &jobs.JobError{Code: "PrivateCode", Message: "请求被服务商拒绝"}}, + }} + session := identity.Session{AuthMode: testCase.authMode, User: identity.User{ID: "owner", Role: testCase.role}} + platform, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &fixedSessionResolver{session: session}) + if err != nil { + t.Fatal(err) + } + h, err := NewJobsHandler(JobsDependencies{Service: jobs.NewService(store, time.Now), Platform: platform, Public: publicapi.NewAuthenticator(publicapi.Config{APIKeys: "agent-a:secret"}), Builder: &jobBuilderStub{}}, JobsConfig{}) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"/api/generations/image", "/api/generations/image/failed"} { + request := httptest.NewRequest(http.MethodGet, path, nil) + request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "session-cookie"}) + response := httptest.NewRecorder() + h.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("%s status=%d body=%s", path, response.Code, response.Body.String()) + } + assertJobResponseProjection(t, response.Body.Bytes(), testCase.wantDetail) + } + if got := string(store.values["failed"].ResponsePayload); got != jobWithDiagnosticsPayload { + t.Fatalf("store was mutated: %s", got) + } + }) + } + + store := &httpJobStore{values: map[string]jobs.Job{ + "external": {ID: "external", OwnerID: "api:agent-a", ExternalClientID: "agent-a", Capability: "image.generate", Status: jobs.StatusFailed, ResponsePayload: json.RawMessage(jobWithDiagnosticsPayload), Error: &jobs.JobError{Message: "请求被服务商拒绝"}}, + }} + platform, _ := NewPlatformAuthorizer(AuthState{}, nil) + h, err := NewJobsHandler(JobsDependencies{Service: jobs.NewService(store, time.Now), Platform: platform, Public: publicapi.NewAuthenticator(publicapi.Config{APIKeys: "agent-a:secret"}), Builder: &jobBuilderStub{}}, JobsConfig{}) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"/api/v1/jobs", "/api/v1/jobs/external"} { + request := httptest.NewRequest(http.MethodGet, path, nil) + request.Header.Set("Authorization", "Bearer secret") + response := httptest.NewRecorder() + h.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("%s status=%d body=%s", path, response.Code, response.Body.String()) + } + assertJobResponseProjection(t, response.Body.Bytes(), false) + } + if got := string(store.values["external"].ResponsePayload); got != jobWithDiagnosticsPayload { + t.Fatalf("public response mutated stored job: %s", got) + } +} + +func TestJobsHTTPProjectsDiagnosticsFromIdempotentCreate(t *testing.T) { + h, store := newJobsHTTP(t) + fingerprint, err := jobs.Fingerprint(map[string]any{"capability": "image.generate", "prompt": "hello"}) + if err != nil { + t.Fatal(err) + } + store.values["existing"] = jobs.Job{ID: "existing", OwnerID: "api:agent-a", ExternalClientID: "agent-a", Capability: "image.generate", Status: jobs.StatusFailed, IdempotencyKey: "same", IdempotencyFingerprint: fingerprint, ResponsePayload: json.RawMessage(jobWithDiagnosticsPayload)} + request := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", bytes.NewBufferString(`{"capability":"image.generate","prompt":"hello"}`)) + request.Header.Set("Authorization", "Bearer secret") + request.Header.Set("Idempotency-Key", "same") + response := httptest.NewRecorder() + h.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + assertJobResponseProjection(t, response.Body.Bytes(), false) +} + +func assertJobResponseProjection(t *testing.T, body []byte, wantDetail bool) { + t.Helper() + hasDetail := bytes.Contains(body, []byte("private diagnostic")) || bytes.Contains(body, []byte("private-request")) + if hasDetail != wantDetail { + t.Fatalf("diagnostic present=%t want=%t body=%s", hasDetail, wantDetail, body) + } + if !bytes.Contains(body, []byte("outputUrls")) || !bytes.Contains(body, []byte("请求被服务商拒绝")) && !bytes.Contains(body, []byte("existing")) { + t.Fatalf("job payload missing after projection: %s", body) + } +} diff --git a/backend/internal/httpapi/jobs.go b/backend/internal/httpapi/jobs.go index 5744ff8..f90c671 100644 --- a/backend/internal/httpapi/jobs.go +++ b/backend/internal/httpapi/jobs.go @@ -147,7 +147,7 @@ func (h *jobsHandler) platformCollection(w http.ResponseWriter, r *http.Request, if items == nil { items = []jobs.Job{} } - writeJSON(w, 200, map[string]any{"jobs": items}) + writeJSON(w, 200, map[string]any{"jobs": projectJobsForViewer(items, canViewJobDiagnostics(session))}) return } h.create(w, r, scope, capability, false, &session) @@ -165,7 +165,7 @@ func (h *jobsHandler) platformItem(w http.ResponseWriter, r *http.Request, id st writeJobError(w, err, false) return } - writeJSON(w, 200, map[string]any{"job": j}) + writeJSON(w, 200, map[string]any{"job": projectJobForViewer(j, canViewJobDiagnostics(session))}) return } j, err := h.dependencies.Service.Get(r.Context(), scope, id) @@ -216,7 +216,7 @@ func (h *jobsHandler) platformRetry(w http.ResponseWriter, r *http.Request, id s writeJobError(w, err, false) return } - writeJSON(w, 202, map[string]any{"job": j}) + writeJSON(w, 202, map[string]any{"job": projectJobForViewer(j, canViewJobDiagnostics(session))}) } func (h *jobsHandler) publicCollection(w http.ResponseWriter, r *http.Request) { @@ -246,7 +246,7 @@ func (h *jobsHandler) publicCollection(w http.ResponseWriter, r *http.Request) { if items == nil { items = []jobs.Job{} } - writeJSON(w, 200, map[string]any{"jobs": items}) + writeJSON(w, 200, map[string]any{"jobs": projectJobsForViewer(items, false)}) } func (h *jobsHandler) publicGet(w http.ResponseWriter, r *http.Request, id string) { _, scope, ok := h.publicScope(w, r) @@ -258,7 +258,7 @@ func (h *jobsHandler) publicGet(w http.ResponseWriter, r *http.Request, id strin writeJobError(w, err, true) return } - writeJSON(w, 200, map[string]any{"job": j}) + writeJSON(w, 200, map[string]any{"job": projectJobForViewer(j, false)}) } func (h *jobsHandler) publicCancel(w http.ResponseWriter, r *http.Request, id string) { _, scope, ok := h.publicScope(w, r) @@ -270,7 +270,7 @@ func (h *jobsHandler) publicCancel(w http.ResponseWriter, r *http.Request, id st writeJobError(w, err, true) return } - writeJSON(w, 200, map[string]any{"job": j}) + writeJSON(w, 200, map[string]any{"job": projectJobForViewer(j, false)}) } func (h *jobsHandler) publicScope(w http.ResponseWriter, r *http.Request) (publicapi.PublicClient, jobs.Scope, bool) { c, o, e := h.dependencies.Public.Authenticate(r) @@ -336,7 +336,8 @@ func (h *jobsHandler) create(w http.ResponseWriter, r *http.Request, scope jobs. if reused { status = 200 } - response := map[string]any{"job": created} + canViewDiagnostics := !public && session != nil && canViewJobDiagnostics(*session) + response := map[string]any{"job": projectJobForViewer(created, canViewDiagnostics)} if public { response["reused"] = reused } diff --git a/backend/internal/httpapi/method_compat_test.go b/backend/internal/httpapi/method_compat_test.go index 7077440..875fd99 100644 --- a/backend/internal/httpapi/method_compat_test.go +++ b/backend/internal/httpapi/method_compat_test.go @@ -12,8 +12,8 @@ import ( func TestRouteMethodCompatibilityDerivesEverySurfacePath(t *testing.T) { patterns := routeMethodPatterns(GoRouteSurface()) - if len(patterns) != 49 { - t.Fatalf("route patterns=%d want 49", len(patterns)) + if len(patterns) != 50 { + t.Fatalf("route patterns=%d want 50", len(patterns)) } for _, pattern := range patterns { if _, ok := pattern.methods[http.MethodOptions]; !ok { diff --git a/backend/internal/httpapi/openapi.go b/backend/internal/httpapi/openapi.go index 12e5ec0..0223d9b 100644 --- a/backend/internal/httpapi/openapi.go +++ b/backend/internal/httpapi/openapi.go @@ -95,7 +95,7 @@ func openAPISchemas() map[string]any { "properties": map[string]any{ "capability": openAPIRef("GenerationCapability"), "engine": map[string]any{"type": "string", "enum": []string{"jimeng", "seedream", "evolink", "bailian", "seedance", "minimax"}}, - "model": map[string]any{"type": "string", "description": "Optional allow-listed model. For image.generate with engine=evolink: gpt-image-2, gpt-image-2.5-flare, gpt-image-2.5-sunburst, or the configured EVOLINK_IMAGE_MODEL. For video.generate with engine=seedance: doubao-seedance-2-0-260128 or doubao-seedance-2-5-260628."}, + "model": map[string]any{"type": "string", "description": "Optional allow-listed model, scoped to the selected engine. Image/evolink: gpt-image-2, gpt-image-2.5-flare, gpt-image-2.5-sunburst, or configured EVOLINK_IMAGE_MODEL. Video/seedance: doubao-seedance-2-0-260128 or doubao-seedance-2-5-260628. Video/bailian: wan2.7-i2v-2026-04-25 or wan3.0-video. Video/minimax: MiniMax-H3."}, "prompt": map[string]any{"type": "string", "description": "Prompt text. Required for generation except Seedream layer decomposition, where an empty prompt triggers automatic decomposition."}, "inputUrls": map[string]any{"type": "array", "items": map[string]any{"type": "string", "format": "uri"}, "description": "Reference image URLs for image capabilities."}, "imageUrls": map[string]any{"type": "array", "items": map[string]any{"type": "string", "format": "uri"}, "description": "Alias for image input URLs."}, @@ -104,8 +104,9 @@ func openAPISchemas() map[string]any { "settings": map[string]any{ "type": "object", "description": "Provider-specific image or video settings.", "properties": map[string]any{ + "inputMode": map[string]any{"type": "string", "enum": []string{"reference", "frames"}, "description": "Video material mode. reference: up to 9 images for MiniMax H3 or 10 for Wan 3.0. frames: one first-frame image for MiniMax, or 1–2 first/last-frame images for Bailian. Omission preserves legacy frame behavior except Wan 3.0, which defaults to reference. Seedance uses reference materials."}, "ratio": map[string]any{"type": "string", "enum": []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"}}, - "duration": map[string]any{"type": "integer", "minimum": 2, "maximum": 30, "description": "Limits depend on engine: MiniMax H3 supports 4–15 seconds."}, + "duration": map[string]any{"type": "integer", "description": "MiniMax H3: 4–15 seconds. Wan 2.7: 2–15 seconds. Wan 3.0: 2–30 seconds. Seedance 2.0: 4–15 seconds or -1 (automatic); Seedance 2.5: 4–30 seconds or -1."}, "resolution": map[string]any{"type": "string", "enum": []string{"480p", "720p", "1080p", "768P", "2K"}}, "size": map[string]any{"type": "string", "enum": []string{"auto", "1K", "1.5K", "2K"}, "description": "Seedream 5.0 Pro output size. auto is available for layer decomposition only."}, "outputFormat": map[string]any{"type": "string", "enum": []string{"png", "jpeg"}}, diff --git a/backend/internal/httpapi/route_surface.go b/backend/internal/httpapi/route_surface.go index b5e5961..7ebf8e8 100644 --- a/backend/internal/httpapi/route_surface.go +++ b/backend/internal/httpapi/route_surface.go @@ -16,7 +16,7 @@ var goRouteSurface = []RouteSurface{ {"DELETE", "/api/admin/accounts"}, {"GET", "/api/admin/accounts"}, {"PATCH", "/api/admin/accounts"}, {"POST", "/api/admin/accounts"}, {"PUT", "/api/admin/accounts"}, {"POST", "/api/admin/accounts/groups"}, {"POST", "/api/admin/accounts/password"}, {"GET", "/api/admin/billing"}, {"PATCH", "/api/admin/billing/account"}, {"POST", "/api/admin/billing/adjustments"}, {"GET", "/api/admin/billing/prices"}, {"PATCH", "/api/admin/billing/prices"}, {"PATCH", "/api/admin/billing/prices/{id}"}, {"DELETE", "/api/admin/organizations"}, {"GET", "/api/admin/organizations"}, {"PATCH", "/api/admin/organizations"}, {"POST", "/api/admin/organizations"}, {"GET", "/api/admin/usage"}, {"GET", "/api/assets"}, {"POST", "/api/assets"}, {"POST", "/api/assets/upload"}, {"POST", "/api/assets/import-image"}, {"DELETE", "/api/assets/{id}"}, {"GET", "/api/assets/{id}/download"}, {"GET", "/api/auth/callback"}, {"GET", "/api/auth/captcha"}, {"GET", "/api/auth/login"}, {"GET", "/api/auth/logout"}, {"POST", "/api/auth/logout"}, {"GET", "/api/auth/me"}, {"POST", "/api/auth/password"}, {"POST", "/api/auth/password/change"}, - {"GET", "/api/billing"}, {"POST", "/api/billing/quote"}, {"GET", "/api/generations/image"}, {"POST", "/api/generations/image"}, {"DELETE", "/api/generations/image/{id}"}, {"GET", "/api/generations/image/{id}"}, {"POST", "/api/generations/image/{id}/retry"}, {"GET", "/api/generations/video"}, {"POST", "/api/generations/video"}, {"DELETE", "/api/generations/video/{id}"}, {"GET", "/api/generations/video/{id}"}, + {"GET", "/api/billing"}, {"GET", "/api/billing/balance"}, {"POST", "/api/billing/quote"}, {"GET", "/api/generations/image"}, {"POST", "/api/generations/image"}, {"DELETE", "/api/generations/image/{id}"}, {"GET", "/api/generations/image/{id}"}, {"POST", "/api/generations/image/{id}/retry"}, {"GET", "/api/generations/video"}, {"POST", "/api/generations/video"}, {"DELETE", "/api/generations/video/{id}"}, {"GET", "/api/generations/video/{id}"}, {"GET", "/api/health"}, {"GET", "/api/image-templates"}, {"POST", "/api/image-templates"}, {"DELETE", "/api/image-templates/{id}"}, {"PATCH", "/api/image-templates/{id}"}, {"POST", "/api/internal/worker/tick"}, {"GET", "/api/layer-compositions/{id}"}, {"PUT", "/api/layer-compositions/{id}"}, {"DELETE", "/api/logs"}, {"GET", "/api/logs"}, {"POST", "/api/prompt/assemble"}, {"GET", "/api/ready"}, {"GET", "/api/settings"}, {"POST", "/api/settings"}, {"GET", "/api/usage"}, {"GET", "/api/v1/assets"}, {"POST", "/api/v1/assets"}, {"GET", "/api/v1/assets/{id}"}, {"GET", "/api/v1/assets/{id}/download"}, {"GET", "/api/v1/capabilities"}, {"GET", "/api/v1/jobs"}, {"POST", "/api/v1/jobs"}, {"GET", "/api/v1/jobs/{id}"}, {"POST", "/api/v1/jobs/{id}/cancel"}, {"GET", "/api/v1/openapi.json"}, {"GET", "/generated-results/{path...}"}, {"GET", "/uploads/{path...}"}, } diff --git a/backend/internal/jobs/provider.go b/backend/internal/jobs/provider.go index cb66044..9ef9ba3 100644 --- a/backend/internal/jobs/provider.go +++ b/backend/internal/jobs/provider.go @@ -102,33 +102,69 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) { if err != nil { logProviderFailure(job, phase, err) } - if err != nil && job.ProviderTaskID == "" && p.Store != nil { + if err != nil { failed := StatusFailed failure := providerSubmissionFailure(err) + if phase == "query" { + failure = providerQueryFailure(job.Provider, err) + } patch := Patch{Status: &failed, Error: failure} var providerError *providers.ProviderError - if job.Provider == "minimax" && errors.As(err, &providerError) && providerError.Status > 0 { - // Keep only the bounded, redacted diagnostic, never the raw response. - patch.ResponsePayload, _ = json.Marshal(map[string]any{"providerError": map[string]any{ - "status": providerError.Status, "code": providerError.Code, - "requestId": providerError.RequestID, "detail": providerError.Detail, - }}) + if errors.As(err, &providerError) { + if phase == "submit" && providerError.Status > 0 { + failure.Message = providers.FailureMessage(job.Provider, providerError.Status, providerError.Code) + } + failure.Code = safeFailureToken(providerError.Code, 128) + patch.ResponsePayload = providerFailurePayload(job, phase, providerError.Status, providerError.Code, providerError.RequestID, providerError.Detail) patch.SetResponsePayload = true - failure.Code = providerError.Code + } + if p.Store == nil { + job.Status, job.Error = failed, failure + if patch.SetResponsePayload { + job.ResponsePayload = patch.ResponsePayload + } + return job, nil } return p.Store.UpdateJob(ctx, job.ID, workerPatch(job, patch)) } - if err != nil { - return Job{}, err + if result.TaskID == "" { + result.TaskID = job.ProviderTaskID } job.ProviderTaskID = result.TaskID + terminalFailure := result.Status == providers.StatusFailed || result.Status == providers.StatusCancelled || result.Status == providers.StatusExpired + if terminalFailure { + detail := result.ErrorDetail + if detail == "" { + detail = result.ErrorMessage + } + job.Error = &JobError{Code: safeFailureToken(result.ErrorCode, 128), Message: providers.FailureMessage(job.Provider, 0, result.ErrorCode)} + if result.ErrorCode == "" && result.Status == providers.StatusCancelled { + job.Error.Message = "服务商已取消本次生成任务,请确认后重新创建任务。" + } else if result.ErrorCode == "" && result.Status == providers.StatusExpired { + job.Error.Message = "服务商生成任务已过期,请确认任务状态后重新创建。" + } + // Failed raw responses may echo prompts, signed asset URLs or credentials. + // Persist only the user explanation and bounded, redacted diagnostics. + job.ResponsePayload = providerFailurePayload(job, phase, 0, result.ErrorCode, result.RequestID, detail) + job.Status = Status(result.Status) + if p.Store == nil { + return job, nil + } + patch := workerPatch(job, Patch{ + Status: &job.Status, ProviderTaskID: &job.ProviderTaskID, Error: job.Error, + ResponsePayload: job.ResponsePayload, SetResponsePayload: true, + }) + patch.ExpectedStatuses = []Status{expectedStatus} + return p.Store.UpdateJob(ctx, job.ID, patch) + } encoded, err := providers.EncodeResult(result) 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} + failure := &JobError{Message: "服务商返回的生成结果缺少可下载文件,请联系管理员核查。", Retryable: false} + encoded = providerFailurePayload(job, phase, 0, "", result.RequestID, "服务商返回成功状态,但没有可下载文件。") if p.Store == nil { job.Status, job.Error, job.ResponsePayload = failed, failure, encoded return job, nil @@ -146,11 +182,9 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) { } job.ResponsePayload = encoded job.Status = Status(result.Status) - if result.ErrorMessage != "" { - job.Error = &JobError{Message: result.ErrorMessage} - } + job.Error = nil if p.Store != nil { - patch := Patch{Status: &job.Status, ProviderTaskID: &job.ProviderTaskID, ResponsePayload: job.ResponsePayload, SetResponsePayload: true, ClearError: result.ErrorMessage == ""} + patch := Patch{Status: &job.Status, ProviderTaskID: &job.ProviderTaskID, ResponsePayload: job.ResponsePayload, SetResponsePayload: true, ClearError: true} patch.ExpectedStatuses = []Status{expectedStatus} if job.LockedBy != "" { worker := job.LockedBy @@ -165,44 +199,56 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) { } const ( - unknownProviderSubmissionMessage = "provider submission outcome is unknown; refusing duplicate submission" - outputImageSafetyMessage = "生成结果触发内容安全审核,请更换素材或调整内容后重试。" + unknownProviderSubmissionMessage = "暂时无法确认服务商是否已接收本次请求,为避免重复生成和扣费,平台已停止自动重试。请联系管理员核查任务状态。" ) func providerSubmissionFailure(err error) *JobError { message := unknownProviderSubmissionMessage var providerError *providers.ProviderError if errors.As(err, &providerError) { - if providerError.Code == "OutputImageSensitiveContentDetected" || strings.HasPrefix(providerError.Code, "OutputImageSensitiveContentDetected.") { - message = outputImageSafetyMessage - } else if strings.HasPrefix(providerError.Operation, "minimax ") { - message = minimaxSubmissionFailureMessage(providerError) + if providerError.Status > 0 { + provider := strings.SplitN(providerError.Operation, " ", 2)[0] + if provider == "volcengine" { + provider = "volcengine-visual" + } + message = providers.FailureMessage(provider, providerError.Status, providerError.Code) } } return &JobError{Message: message, Retryable: false} } -func minimaxSubmissionFailureMessage(providerError *providers.ProviderError) string { - switch providerError.Status { - case http.StatusBadRequest: - return "MiniMax 请求参数不符合要求,请检查提示词、素材和生成参数。" - case http.StatusUnauthorized: - return "MiniMax 鉴权失败,请检查 API Key 是否正确且已开通 H3。" - case http.StatusForbidden: - return "MiniMax API Key 无 H3 调用权限,请确认已购买并开通该模型。" - case http.StatusPaymentRequired: - return "MiniMax API 账户余额不足,请充值后重试。" - case http.StatusNotFound: - return "MiniMax H3 接口或模型不可用,请检查 Base URL 和账号模型权限。" - case http.StatusUnprocessableEntity: - return "视频描述或输入素材触发内容安全审核,请调整后重试。" - case http.StatusTooManyRequests: - return "MiniMax 请求频率过高,请稍后重试。" - case http.StatusInternalServerError: - return "MiniMax 服务暂时不可用,请稍后重试。" - default: - return unknownProviderSubmissionMessage +func providerQueryFailure(provider string, err error) *JobError { + failure := &JobError{Message: "暂时无法获取服务商的生成结果,请联系管理员核查任务状态。", Retryable: true} + var upstream *providers.ProviderError + if errors.As(err, &upstream) && upstream.Status > 0 { + failure.Code = safeFailureToken(upstream.Code, 128) + failure.Message = providers.FailureMessage(provider, upstream.Status, upstream.Code) + failure.Retryable = upstream.Status == http.StatusRequestTimeout || upstream.Status == http.StatusTooManyRequests || upstream.Status >= 500 } + return failure +} + +func providerFailurePayload(job Job, phase string, status int, code, requestID, detail string) json.RawMessage { + payload, _ := json.Marshal(map[string]any{"providerError": map[string]any{ + "phase": phase, "status": status, "code": safeFailureToken(code, 128), + "requestId": safeFailureToken(requestID, 128), + "detail": providers.SanitizeFailureDetail(detail, job.RequestPayload), + }}) + return payload +} + +func safeFailureToken(value string, maximum int) string { + value = strings.TrimSpace(value) + if len(value) > maximum { + return "" + } + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || strings.ContainsRune("-_.:", char) { + continue + } + return "" + } + return value } type providerFailureDiagnostic struct { @@ -412,11 +458,11 @@ func (b ProviderJobBuilder) target(capability string, rawEngine, rawModel any) ( } if rawModel != nil { model := stringValue(rawModel) - if model == "" || engine != "seedance" { + if model == "" || (engine != "seedance" && engine != "bailian" && engine != "minimax") { return ProviderTarget{}, "", invalidPreparation("unsupported video model") } target, ok := b.VideoModels[model] - if !ok || target.Provider != "seedance" || target.Model == "" { + if !ok || target.Provider != engine || target.Model == "" { return ProviderTarget{}, "", invalidPreparation("unsupported video model") } return target, engine, nil @@ -915,6 +961,7 @@ func validateBailianImage(urls []string, settings map[string]any) error { const ( seedance20Model = "doubao-seedance-2-0-260128" seedance25Model = "doubao-seedance-2-5-260628" + wan30VideoModel = "wan3.0-video" ) func videoSettings(engine, model string, defaults map[string]any, raw any, materials []prompt.Material) (map[string]any, error) { @@ -932,11 +979,21 @@ func videoSettings(engine, model string, defaults map[string]any, raw any, mater if model != providers.MinimaxH3Model { return nil, invalidPreparation("unsupported MiniMax video model") } - if len(materials) > 1 { - return nil, invalidPreparation("MiniMax H3 first release supports at most one first-frame image") + mode := stringValue(input["inputMode"]) + if mode != "" && mode != "frames" && mode != "reference" { + return nil, invalidPreparation("MiniMax 请选择首帧或多图参考模式") } - if len(materials) == 1 && materials[0].Type != "image" { - return nil, invalidPreparation("MiniMax H3 first release supports image material only") + if mode == "reference" { + if len(materials) > 9 { + return nil, invalidPreparation("MiniMax 多图参考模式最多支持 9 张图片") + } + } else if len(materials) > 1 { + return nil, invalidPreparation("MiniMax 首帧模式最多只能上传 1 张图片;多张图片请切换到多图参考模式") + } + for _, material := range materials { + if material.Type != "image" { + return nil, invalidPreparation("MiniMax 当前仅支持图片素材,请移除视频或音频") + } } duration := float64(5) if input["duration"] != nil { @@ -954,43 +1011,78 @@ func videoSettings(engine, model string, defaults map[string]any, raw any, mater return nil, invalidPreparation("MiniMax H3 video resolution must be 768P or 2K") } ratio := stringValue(input["ratio"]) - if len(materials) == 1 { + if mode != "reference" && len(materials) == 1 { ratio = "adaptive" } else { if ratio == "" { ratio = "16:9" } - if ratio == "adaptive" || !oneOf(ratio, "21:9", "16:9", "4:3", "1:1", "3:4", "9:16") { + if (len(materials) == 0 && ratio == "adaptive") || !oneOf(ratio, "21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "adaptive") { return nil, invalidPreparation("MiniMax H3 text-to-video requires a fixed supported ratio") } } - return map[string]any{"duration": duration, "resolution": resolution, "ratio": ratio}, nil + settings := map[string]any{"duration": duration, "resolution": resolution, "ratio": ratio} + if mode != "" { + settings["inputMode"] = mode + } + return settings, nil } if engine == "bailian" { - if len(materials) < 1 || len(materials) > 2 { + wan30 := model == wan30VideoModel + mode := stringValue(input["inputMode"]) + if mode == "" && wan30 { + mode = "reference" + } + if mode != "" && mode != "frames" && mode != "reference" { + return nil, invalidPreparation("百炼请选择首尾帧或多图参考模式") + } + if !wan30 && mode == "reference" { + return nil, invalidPreparation("Wan 2.7 仅支持首尾帧;多图参考请选择 Wan 3.0") + } + if mode == "reference" && len(materials) > 10 { + return nil, invalidPreparation("Wan 3.0 多图参考模式最多支持 10 张图片") + } + if mode != "reference" && (len(materials) < 1 || len(materials) > 2) { return nil, invalidPreparation("bailian video requires 1 or 2 image materials") } for _, material := range materials { if material.Type != "image" { - return nil, invalidPreparation("bailian video requires 1 or 2 image materials") + return nil, invalidPreparation("百炼当前视频模式仅支持图片素材,请移除视频或音频") } } duration := float64(10) if input["duration"] != nil { var ok bool duration, ok = finiteNumber(input["duration"]) - if !ok || math.Trunc(duration) != duration || duration < 2 || duration > 15 { - return nil, invalidPreparation("bailian video duration must be an integer between 2 and 15 seconds") + maximum := float64(15) + if wan30 { + maximum = 30 + } + if !ok || math.Trunc(duration) != duration || duration < 2 || duration > maximum { + return nil, invalidPreparation(fmt.Sprintf("百炼视频时长需为 2–%.0f 秒整数", maximum)) } } resolution := strings.ToUpper(stringValue(input["resolution"])) if resolution == "" { resolution = "720P" } - if resolution != "720P" && resolution != "1080P" { - return nil, invalidPreparation("bailian video resolution must be 720P or 1080P") + if resolution != "720P" && resolution != "1080P" && !(wan30 && resolution == "480P") { + return nil, invalidPreparation("百炼视频分辨率不支持当前设置") } - return map[string]any{"duration": duration, "resolution": resolution}, nil + settings := map[string]any{"duration": duration, "resolution": resolution} + if wan30 { + ratio := stringValue(input["ratio"]) + if ratio != "" { + if !oneOf(ratio, "adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16") { + return nil, invalidPreparation("Wan 3.0 画面比例支持自适应、21:9、16:9、4:3、1:1、3:4 或 9:16") + } + settings["ratio"] = ratio + } + } + if mode != "" { + settings["inputMode"] = mode + } + return settings, nil } if engine != "" && engine != "seedance" { return nil, invalidPreparation("unsupported video engine") @@ -1003,8 +1095,17 @@ func videoSettings(engine, model string, defaults map[string]any, raw any, mater if len(materials) > 50 || counts["image"] > 30 || counts["video"] > 10 || counts["audio"] > 10 { return nil, invalidPreparation("seedance 2.5 supports at most 30 image, 10 video, and 10 audio materials") } - } else if len(materials) > 4 { - return nil, invalidPreparation("seedance 2.0 supports at most 4 materials") + } else { + counts := map[string]int{"image": 0, "video": 0, "audio": 0} + for _, material := range materials { + counts[material.Type]++ + } + if counts["image"] > 9 || counts["video"] > 3 || counts["audio"] > 3 { + return nil, invalidPreparation("Seedance 2.0 最多支持 9 张图片、3 个视频、3 个音频") + } + if counts["audio"] > 0 && counts["image"]+counts["video"] == 0 { + return nil, invalidPreparation("Seedance 2.0 的音频不能单独使用,请添加图片或视频素材") + } } ratio := stringValue(input["ratio"]) if ratio != "" && !oneOf(ratio, "9:16", "16:9", "1:1", "4:3", "3:4", "21:9", "adaptive") { diff --git a/backend/internal/jobs/provider_failures_test.go b/backend/internal/jobs/provider_failures_test.go new file mode 100644 index 0000000..f7a7c4a --- /dev/null +++ b/backend/internal/jobs/provider_failures_test.go @@ -0,0 +1,107 @@ +package jobs + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/providers" +) + +func TestAllProviderRejectionsPersistSafeReasonAndNeverResubmit(t *testing.T) { + for _, provider := range []string{"seedance", "seedream", "bailian", "minimax", "evolink", "volcengine-visual"} { + t.Run(provider, func(t *testing.T) { + store := newMemoryJobStore() + job := failureFixture(provider) + store.jobs[job.ID] = job + adapter := &countingProvider{err: &providers.ProviderError{Operation: provider + " submit", Status: 400, + Code: "InputImageSensitiveContentDetected.PrivacyInformation", RequestID: "request-123", + Detail: "private prompt rejected https://private.test/input.png?token=secret Bearer secret-key"}} + processor := ProviderProcessor{Providers: ProviderRegistry{provider: adapter}, Store: store} + got, err := processor.Advance(context.Background(), job) + if err != nil || got.Status != StatusFailed || got.Error == nil || got.Error.Retryable || got.Error.Message == unknownProviderSubmissionMessage { + t.Fatalf("rejection=%#v err=%v", got, err) + } + if provider == "seedance" && !strings.Contains(got.Error.Message, "真人") { + t.Fatalf("missing portrait reason: %s", got.Error.Message) + } + if !strings.Contains(string(got.ResponsePayload), "request-123") || !strings.Contains(string(got.ResponsePayload), "PrivacyInformation") { + t.Fatalf("missing diagnostic: %s", got.ResponsePayload) + } + for _, private := range []string{"private prompt", "https://private.test", "secret-key", "token=secret"} { + if strings.Contains(string(got.ResponsePayload), private) || strings.Contains(got.Error.Message, private) { + t.Fatalf("leaked %s in failure", private) + } + } + if _, err := processor.Advance(context.Background(), got); err != nil || adapter.submits != 1 { + t.Fatalf("refused request resubmitted: %d, %v", adapter.submits, err) + } + }) + } +} + +func TestTerminalProviderFailureRetainsTaskAndDiscardsRawPayload(t *testing.T) { + store := newMemoryJobStore() + job := failureFixture("seedance") + job.ProviderTaskID = "existing-task" + store.jobs[job.ID] = job + adapter := &countingProvider{result: providers.Result{Status: providers.StatusFailed, + ErrorCode: "InputImageSensitiveContentDetected.PrivacyInformation", RequestID: "request-terminal", + ErrorMessage: "private prompt rejected", Raw: json.RawMessage(`{"error":{"message":"private prompt rejected","secret":"raw-secret"}}`)}} + processor := ProviderProcessor{Providers: ProviderRegistry{"seedance": adapter}, Store: store} + got, err := processor.Advance(context.Background(), job) + if err != nil || got.ProviderTaskID != "existing-task" || got.Error == nil || !strings.Contains(got.Error.Message, "真人") || got.Error.Retryable || adapter.submits != 0 { + t.Fatalf("terminal failure=%#v err=%v", got, err) + } + if strings.Contains(string(got.ResponsePayload), "private prompt") || strings.Contains(string(got.ResponsePayload), "raw-secret") || strings.Contains(string(got.ResponsePayload), `"raw"`) { + t.Fatalf("persisted unsafe raw error: %s", got.ResponsePayload) + } +} + +func TestUnknownSubmitRemainsNonRetryable(t *testing.T) { + store := newMemoryJobStore() + job := failureFixture("seedance") + store.jobs[job.ID] = job + adapter := &countingProvider{err: &providers.ProviderError{Operation: "seedance submit", Cause: context.DeadlineExceeded}} + processor := ProviderProcessor{Providers: ProviderRegistry{"seedance": adapter}, Store: store} + got, err := processor.Advance(context.Background(), job) + if err != nil || got.Error == nil || got.Error.Message != unknownProviderSubmissionMessage || got.Error.Retryable { + t.Fatalf("uncertain submission=%#v err=%v", got, err) + } + if _, err := processor.Advance(context.Background(), got); err != nil || adapter.submits != 1 { + t.Fatalf("uncertain request resubmitted: %d, %v", adapter.submits, err) + } +} + +func TestTransientQueryFailurePreservesTaskAndRecoversWithoutSubmit(t *testing.T) { + store := newMemoryJobStore() + job := failureFixture("bailian") + job.ProviderTaskID = "existing-task" + store.jobs[job.ID] = job + adapter := &countingProvider{err: &providers.ProviderError{Operation: "bailian query", Status: 503, RequestID: "query-123"}} + processor := ProviderProcessor{Providers: ProviderRegistry{"bailian": adapter}, Store: store} + failed, err := processor.Advance(context.Background(), job) + if err != nil || failed.Error == nil || !failed.Error.Retryable || failed.ProviderTaskID != job.ProviderTaskID { + t.Fatalf("query failure=%#v err=%v", failed, err) + } + worker := NewWorker(store, processor, nil, nil, nil, WorkerConfig{}, nil) + retry, action, err := worker.settle(context.Background(), failed) + if err != nil || action != "retry_scheduled" || retry.ProviderTaskID != "existing-task" { + t.Fatalf("retry=%#v action=%s err=%v", retry, action, err) + } + adapter.err = nil + adapter.result = providers.Result{Status: providers.StatusSucceeded, OutputURLs: []string{"https://result.test/image.png"}} + done, err := processor.Advance(context.Background(), retry) + if err != nil || done.Status != StatusSucceeded || done.Error != nil || done.ProviderTaskID != "existing-task" || adapter.submits != 0 { + t.Fatalf("recovery=%#v err=%v submits=%d", done, err, adapter.submits) + } + if strings.Contains(string(done.ResponsePayload), "providerError") { + t.Fatalf("stale failure after success: %s", done.ResponsePayload) + } +} + +func failureFixture(provider string) Job { + return Job{ID: "job-failure", OwnerID: "owner", Provider: provider, Capability: "video.generate", Status: StatusQueued, + RequestPayload: json.RawMessage(`{"capability":"video.generate","prompt":"private prompt","inputUrls":["https://private.test/input.png?token=secret"]}`)} +} diff --git a/backend/internal/jobs/provider_test.go b/backend/internal/jobs/provider_test.go index dd6a316..4ec0cce 100644 --- a/backend/internal/jobs/provider_test.go +++ b/backend/internal/jobs/provider_test.go @@ -380,6 +380,52 @@ func TestProviderJobBuilderPreparesMinimaxH3TextAndFirstFrameVideo(t *testing.T) } } +func TestProviderJobBuilderPreparesMultiReferenceVideoAndPreservesMode(t *testing.T) { + for _, test := range []struct { + engine, model string + count int + }{ + {engine: "minimax", model: providers.MinimaxH3Model, count: 9}, + {engine: "bailian", model: wan30VideoModel, count: 10}, + {engine: "minimax", model: providers.MinimaxH3Model, count: 0}, + {engine: "bailian", model: wan30VideoModel, count: 0}, + } { + t.Run(test.engine, func(t *testing.T) { + materials := make([]any, 0, test.count) + for index := 0; index < test.count; index++ { + materials = append(materials, map[string]any{"url": fmt.Sprintf("https://in.test/%d.png", index), "type": "image"}) + } + command, err := testProviderBuilder().Build(context.Background(), "owner", "", "video.generate", "", map[string]any{ + "engine": test.engine, "model": test.model, "prompt": "combine", "materials": materials, + "settings": map[string]any{"inputMode": "reference", "duration": float64(10)}, + }) + if err != nil { + t.Fatal(err) + } + var request providers.Request + if err := json.Unmarshal(command.Job.RequestPayload, &request); err != nil { + t.Fatal(err) + } + if request.Model != test.model || request.Settings["inputMode"] != "reference" || len(request.Materials) != test.count { + t.Fatalf("request=%#v", request) + } + }) + } +} + +func TestProviderJobBuilderKeepsVideoModelsWithinTheirEngine(t *testing.T) { + for _, test := range []struct{ engine, model string }{ + {"seedance", wan30VideoModel}, + {"minimax", wan30VideoModel}, + {"bailian", providers.MinimaxH3Model}, + } { + _, err := testProviderBuilder().Build(context.Background(), "owner", "", "video.generate", "", map[string]any{"engine": test.engine, "model": test.model, "prompt": "x"}) + if err == nil || !strings.Contains(err.Error(), "unsupported video model") { + t.Fatalf("%#v: %v", test, err) + } + } +} + func TestProviderJobBuilderRejectsInvalidPreparation(t *testing.T) { tests := []struct { name, capability, message string @@ -394,26 +440,19 @@ func TestProviderJobBuilderRejectsInvalidPreparation(t *testing.T) { {name: "seedream rejects invalid format", capability: "image.generate", body: map[string]any{"prompt": "p", "engine": "seedream", "settings": map[string]any{"outputFormat": "webp"}}, message: "png or jpeg"}, {name: "seedance missing materials referenced by prompt", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "use @图片2"}, message: "requires @图片2"}, {name: "bailian requires frame", capability: "video.generate", body: map[string]any{"engine": "bailian", "prompt": "p", "materials": []any{}}, message: "1 or 2 image materials"}, - {name: "bailian rejects video material", capability: "video.generate", body: map[string]any{"engine": "bailian", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/a.mp4", "type": "video"}}}, message: "1 or 2 image materials"}, + {name: "bailian rejects video material", capability: "video.generate", body: map[string]any{"engine": "bailian", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/a.mp4", "type": "video"}}}, message: "仅支持图片素材"}, {name: "bad seedance settings", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "p", "settings": map[string]any{"duration": 99.0}}, message: "video duration"}, {name: "unknown seedance model", capability: "video.generate", body: map[string]any{"engine": "seedance", "model": "attacker-model", "prompt": "p"}, message: "unsupported video model"}, {name: "seedance 2.0 rejects 30 seconds", capability: "video.generate", body: map[string]any{"engine": "seedance", "model": seedance20Model, "prompt": "p", "settings": map[string]any{"duration": 30.0}}, message: "between 4 and 15"}, {name: "seedance 2.5 rejects over 30 seconds", capability: "video.generate", body: map[string]any{"engine": "seedance", "model": seedance25Model, "prompt": "p", "settings": map[string]any{"duration": 31.0}}, message: "between 4 and 30"}, {name: "seedance unsupported 4k output", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "p", "settings": map[string]any{"resolution": "4k"}}, message: "unsupported video resolution"}, {name: "minimax rejects adaptive text ratio", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "settings": map[string]any{"ratio": "adaptive"}}, message: "requires a fixed"}, - {name: "minimax rejects two images", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/1.png", "type": "image"}, map[string]any{"url": "https://in.test/2.png", "type": "image"}}}, message: "at most one"}, - {name: "minimax rejects video", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/1.mp4", "type": "video"}}}, message: "image material only"}, + {name: "minimax reference text rejects adaptive ratio", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "settings": map[string]any{"inputMode": "reference", "ratio": "adaptive"}}, message: "requires a fixed"}, + {name: "minimax rejects two images", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/1.png", "type": "image"}, map[string]any{"url": "https://in.test/2.png", "type": "image"}}}, message: "多图参考模式"}, + {name: "minimax rejects video", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/1.mp4", "type": "video"}}}, message: "仅支持图片素材"}, {name: "minimax rejects short duration", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "settings": map[string]any{"duration": 3.0}}, message: "between 4 and 15"}, {name: "minimax rejects resolution", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "settings": map[string]any{"resolution": "1080p"}}, message: "768P or 2K"}, - {name: "seedance too many materials", capability: "video.generate", body: map[string]any{ - "engine": "seedance", "prompt": "p", "materials": []any{ - map[string]any{"url": "https://in.test/1.png", "type": "image"}, - map[string]any{"url": "https://in.test/2.png", "type": "image"}, - map[string]any{"url": "https://in.test/3.png", "type": "image"}, - map[string]any{"url": "https://in.test/4.png", "type": "image"}, - map[string]any{"url": "https://in.test/5.png", "type": "image"}, - }, - }, message: "at most 4 materials"}, + {name: "seedance 2.0 audio needs visual", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/a.mp3", "type": "audio"}}}, message: "音频不能单独使用"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -555,7 +594,7 @@ func TestProviderProcessorLogsSafeJobCorrelationWhenSubmitFails(t *testing.T) { processor := ProviderProcessor{Providers: ProviderRegistry{"volcengine-visual": adapter}, Store: store} got, err := processor.Advance(context.Background(), job) - if err != nil || got.Status != StatusFailed || got.Error == nil || got.Error.Message != unknownProviderSubmissionMessage { + if err != nil || got.Status != StatusFailed || got.Error == nil || got.Error.Message == unknownProviderSubmissionMessage || !strings.Contains(got.Error.Message, "管理员") { t.Fatalf("got=%#v err=%v", got, err) } logged := output.String() @@ -607,7 +646,7 @@ func TestProviderProcessorPersistsFriendlySeedreamOutputSafetyMessage(t *testing processor := ProviderProcessor{Providers: ProviderRegistry{"seedream": adapter}, Store: store} got, err := processor.Advance(context.Background(), job) - const want = "生成结果触发内容安全审核,请更换素材或调整内容后重试。" + const want = "生成的图片可能涉及版权限制,请调整内容后重试。" if err != nil || got.Status != StatusFailed || got.Error == nil || got.Error.Message != want || got.Error.Retryable { t.Fatalf("got=%#v err=%v", got, err) } @@ -623,10 +662,10 @@ func TestMinimaxSubmissionFailuresUseActionableSafeMessages(t *testing.T) { want string }{ {http.StatusUnauthorized, "鉴权失败"}, - {http.StatusForbidden, "调用权限"}, - {http.StatusPaymentRequired, "余额不足"}, - {http.StatusNotFound, "接口或模型不可用"}, - {http.StatusUnprocessableEntity, "内容安全审核"}, + {http.StatusForbidden, "管理员"}, + {http.StatusPaymentRequired, "额度不足"}, + {http.StatusNotFound, "不存在"}, + {http.StatusUnprocessableEntity, "拒绝"}, {http.StatusTooManyRequests, "频率过高"}, } for _, test := range tests { @@ -691,7 +730,7 @@ func TestProviderProcessorRejectsSuccessWithoutOutput(t *testing.T) { 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") { + if err != nil || got.Status != StatusFailed || got.Error == nil || got.Error.Retryable || !strings.Contains(got.Error.Message, "缺少可下载文件") { t.Fatalf("got=%#v err=%v", got, err) } if store.jobs[job.ID].Status != StatusFailed { @@ -845,8 +884,10 @@ func testProviderBuilder() ProviderJobBuilder { "minimax": {Provider: "minimax", Model: providers.MinimaxH3Model, Settings: map[string]any{"ratio": "16:9", "duration": 5, "resolution": "768P"}}, }, VideoModels: map[string]ProviderTarget{ - seedance20Model: {Provider: "seedance", Model: seedance20Model, Settings: map[string]any{"ratio": "9:16", "duration": 5, "resolution": "720p"}}, - seedance25Model: {Provider: "seedance", Model: seedance25Model, Settings: map[string]any{"ratio": "9:16", "duration": 5, "resolution": "720p"}}, + seedance20Model: {Provider: "seedance", Model: seedance20Model, Settings: map[string]any{"ratio": "9:16", "duration": 5, "resolution": "720p"}}, + seedance25Model: {Provider: "seedance", Model: seedance25Model, Settings: map[string]any{"ratio": "9:16", "duration": 5, "resolution": "720p"}}, + wan30VideoModel: {Provider: "bailian", Model: wan30VideoModel}, + providers.MinimaxH3Model: {Provider: "minimax", Model: providers.MinimaxH3Model}, }, NewID: func() string { return "job-1" }, } diff --git a/backend/internal/orchestration/settlement.go b/backend/internal/orchestration/settlement.go index c9c0e8a..65b847a 100644 --- a/backend/internal/orchestration/settlement.go +++ b/backend/internal/orchestration/settlement.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "math" "strconv" "strings" "time" @@ -55,6 +56,9 @@ func (p *SettlementProcessor) Advance(ctx context.Context, job jobs.Job) (jobs.J if advanced.Provider == "minimax" && billing.IsMinimaxH3BillingModel(advanced.ReqKey) { return p.settleMinimaxH3(ctx, advanced) } + if advanced.Provider == "bailian" && advanced.ReqKey == billing.BailianWan30VideoModel { + return p.settleBailianWan30(ctx, advanced) + } if advanced.Provider != "seedance" { return advanced, nil } @@ -168,6 +172,9 @@ func (p *SettlementProcessor) settleMinimaxH3(ctx context.Context, job jobs.Job) charge.raw["settledAt"] = p.now().UTC().Format(time.RFC3339Nano) return p.write(ctx, job, charge.raw) } + if !usage.hasInputImageCount { + usage.inputImageCount = minimaxQuotedInputImageCount(charge.raw, job.RequestPayload) + } resolution := minimaxResolution(charge.raw) actualAmount, err := billing.CalculateMinimaxH3ActualAmountFen(billing.MinimaxH3ActualAmountInput{ Resolution: resolution, OutputSeconds: usage.outputSeconds, InputVideoSeconds: usage.inputSeconds, @@ -221,6 +228,100 @@ func (p *SettlementProcessor) settleMinimaxH3(ctx context.Context, job jobs.Job) return p.write(ctx, job, charge.raw) } +func (p *SettlementProcessor) settleBailianWan30(ctx context.Context, job jobs.Job) (jobs.Job, error) { + var charge billingState + if json.Unmarshal(job.Billing, &charge) != nil { + return jobs.Job{}, errors.New("settle Wan 3.0 generation charge") + } + status, _ := charge.raw["settlementStatus"].(string) + if status == "settled" || status == "estimated" { + return job, nil + } + var use usageContext + if len(job.UsageContext) != 0 && json.Unmarshal(job.UsageContext, &use) != nil { + return jobs.Job{}, errors.New("settle Wan 3.0 generation charge") + } + quotaExempt := charge.QuotaExempt || use.Source == "platform" && use.Role == "super_admin" + chargeReady := charge.Status == "charged" || quotaExempt && charge.Status == "not_charged" + if !chargeReady || !quotaExempt && use.OrganizationID == "" { + return job, nil + } + + outputSeconds := bailianWan30OutputSeconds(job.ResponsePayload) + if outputSeconds <= 0 { + charge.raw["settlementStatus"] = "estimated" + charge.raw["settlementReason"] = "provider_usage_unavailable" + charge.raw["settledAt"] = p.now().UTC().Format(time.RFC3339Nano) + return p.write(ctx, job, charge.raw) + } + unitPrice, ok := integer(charge.raw["standardUnitPriceFen"]) + if !ok { + return jobs.Job{}, errors.New("settle Wan 3.0 generation charge") + } + actualAmount, err := billing.CalculateBailianWan30ActualAmountFen(outputSeconds, unitPrice, numberOrZero(charge.raw["markupMultiplier"])) + if err != nil { + return jobs.Job{}, errors.New("settle Wan 3.0 generation charge") + } + delta := actualAmount - charge.AmountFen + settledAt := p.now().UTC() + var settlementLedgerID string + if delta != 0 && !quotaExempt { + if p.ledger == nil { + return jobs.Job{}, errors.New("settle Wan 3.0 generation charge") + } + description := capabilityLabel(job.Capability) + "实际用量差额退回" + if delta > 0 { + description = capabilityLabel(job.Capability) + "实际用量补扣" + } + posting, postErr := p.ledger.Settle(ctx, billing.SettlementRequest{ + OrganizationID: use.OrganizationID, AccountID: use.AccountID, JobID: job.ID, + DeltaFen: delta, Description: description, + Metadata: map[string]any{ + "operation": "bailian_wan30_actual_settlement", "reservedAmountFen": charge.AmountFen, + "actualAmountFen": actualAmount, "outputSeconds": outputSeconds, + "resolution": seedanceResolution(charge.raw), "model": job.ReqKey, + }, + }) + if postErr != nil { + return jobs.Job{}, errors.New("settle Wan 3.0 generation charge") + } + settlementLedgerID = posting.LedgerID + if !posting.CreatedAt.IsZero() { + settledAt = posting.CreatedAt.UTC() + } + } + charge.raw["amountFen"] = actualAmount + charge.raw["settlementStatus"] = "settled" + charge.raw["settledAt"] = settledAt.Format(time.RFC3339Nano) + if settlementLedgerID != "" { + charge.raw["settlementLedgerEntryId"] = settlementLedgerID + } else { + delete(charge.raw, "settlementLedgerEntryId") + } + charge.raw["providerUsage"] = map[string]any{ + "model": job.ReqKey, "resolution": seedanceResolution(charge.raw), "outputSeconds": outputSeconds, + "videoPriceFenPerSecond": unitPrice, + } + return p.write(ctx, job, charge.raw) +} + +func bailianWan30OutputSeconds(payload json.RawMessage) float64 { + var response struct { + Usage map[string]any `json:"usage"` + } + if json.Unmarshal(payload, &response) != nil { + return 0 + } + if milliseconds := numberOrZero(response.Usage["outputMilliseconds"]); milliseconds > 0 { + return milliseconds / 1000 + } + seconds := numberOrZero(response.Usage["outputSeconds"]) + if math.IsNaN(seconds) || math.IsInf(seconds, 0) || seconds <= 0 { + return 0 + } + return seconds +} + func (p *SettlementProcessor) settleSeedreamLayers(ctx context.Context, job jobs.Job) (jobs.Job, error) { var charge billingState if json.Unmarshal(job.Billing, &charge) != nil { @@ -325,9 +426,10 @@ func seedanceCompletionTokens(payload json.RawMessage) int64 { } type minimaxUsage struct { - outputSeconds int64 - inputSeconds int64 - inputImageCount int64 + outputSeconds int64 + inputSeconds int64 + inputImageCount int64 + hasInputImageCount bool } func minimaxH3Usage(payload json.RawMessage) minimaxUsage { @@ -339,8 +441,24 @@ func minimaxH3Usage(payload json.RawMessage) minimaxUsage { } outputSeconds, _ := nonNegativeInteger(response.Usage["outputSeconds"]) inputSeconds, _ := nonNegativeInteger(response.Usage["inputSeconds"]) - inputImageCount, _ := nonNegativeInteger(response.Usage["inputImageCount"]) - return minimaxUsage{outputSeconds: outputSeconds, inputSeconds: inputSeconds, inputImageCount: inputImageCount} + inputImageCount, hasInputImageCount := nonNegativeInteger(response.Usage["inputImageCount"]) + return minimaxUsage{outputSeconds: outputSeconds, inputSeconds: inputSeconds, inputImageCount: inputImageCount, hasInputImageCount: hasInputImageCount} +} + +func minimaxQuotedInputImageCount(snapshot map[string]any, requestPayload json.RawMessage) int64 { + parameters, _ := snapshot["parameters"].(map[string]any) + for _, key := range []string{"inputImageCount", "referenceImageCount"} { + if count, ok := nonNegativeInteger(parameters[key]); ok { + return count + } + } + var request map[string]any + if json.Unmarshal(requestPayload, &request) == nil { + if count, ok := nonNegativeInteger(billing.NormalizeBillingParameters(request, nil)["referenceImageCount"]); ok { + return count + } + } + return 0 } func minimaxResolution(snapshot map[string]any) string { diff --git a/backend/internal/orchestration/settlement_test.go b/backend/internal/orchestration/settlement_test.go index 91fb90e..fe90cdb 100644 --- a/backend/internal/orchestration/settlement_test.go +++ b/backend/internal/orchestration/settlement_test.go @@ -124,6 +124,104 @@ func TestSettlementProcessorSettlesSuccessfulMinimaxH3Usage(t *testing.T) { } } +func TestSettlementProcessorDistinguishesMissingMinimaxImageUsageFromExplicitZero(t *testing.T) { + for _, test := range []struct { + name, usageJSON, billingJSON, requestJSON string + wantAmount, wantDelta int64 + wantImageCount float64 + }{ + { + name: "missing usage falls back to quoted count", usageJSON: `{"outputSeconds":5}`, + billingJSON: `{"status":"charged","amountFen":576,"standardUnitPriceFen":80,"markupMultiplier":1.2,"settlementStatus":"pending","parameters":{"resolution":"2k","inputImageCount":9}}`, + wantAmount: 576, wantImageCount: 9, + }, + { + name: "missing usage falls back to request materials", usageJSON: `{"outputSeconds":5}`, + billingJSON: `{"status":"charged","amountFen":576,"standardUnitPriceFen":80,"markupMultiplier":1.2,"settlementStatus":"pending","parameters":{"resolution":"2k"}}`, + requestJSON: `{"materials":[{"type":"image"},{"type":"image"},{"type":"image"},{"type":"image"},{"type":"image"},{"type":"image"},{"type":"image"},{"type":"image"},{"type":"image"}]}`, + wantAmount: 576, wantImageCount: 9, + }, + { + name: "explicit zero overrides quote", usageJSON: `{"outputSeconds":5,"inputImageCount":0}`, + billingJSON: `{"status":"charged","amountFen":576,"standardUnitPriceFen":80,"markupMultiplier":1.2,"settlementStatus":"pending","parameters":{"resolution":"2k","inputImageCount":9}}`, + wantAmount: 480, wantDelta: -96, wantImageCount: 0, + }, + } { + t.Run(test.name, func(t *testing.T) { + state := &settlementStateStub{} + ledger := &settlementLedgerStub{} + job := jobs.Job{ + ID: "minimax-multiref", Provider: "minimax", ReqKey: billing.MinimaxH3Model, Capability: "video.generate", Status: jobs.StatusSucceeded, + ResponsePayload: json.RawMessage(`{"usage":` + test.usageJSON + `}`), + RequestPayload: json.RawMessage(test.requestJSON), Billing: json.RawMessage(test.billingJSON), + UsageContext: json.RawMessage(`{"organizationId":"org-1","accountId":"account-1"}`), + } + got, err := NewSettlementProcessor(settlementProcessorStub{job: job}, ledger, state, time.Now).Advance(context.Background(), jobs.Job{ID: job.ID}) + if err != nil { + t.Fatal(err) + } + var snapshot map[string]any + _ = json.Unmarshal(got.Billing, &snapshot) + usage := snapshot["providerUsage"].(map[string]any) + if snapshot["amountFen"] != float64(test.wantAmount) || usage["inputImageCount"] != test.wantImageCount || state.calls != 1 { + t.Fatalf("billing=%#v state.calls=%d", snapshot, state.calls) + } + if test.wantDelta == 0 && ledger.calls != 0 || test.wantDelta != 0 && (ledger.calls != 1 || ledger.request.DeltaFen != test.wantDelta) { + t.Fatalf("ledger=%#v calls=%d", ledger.request, ledger.calls) + } + }) + } +} + +func TestSettlementProcessorSettlesWan30FromFractionalOutputDuration(t *testing.T) { + state := &settlementStateStub{} + ledger := &settlementLedgerStub{posting: billing.WalletPosting{LedgerID: "wan30-settlement"}} + advanced := jobs.Job{ + ID: "wan30-job", Provider: "bailian", ReqKey: billing.BailianWan30VideoModel, Capability: "video.generate", Status: jobs.StatusSucceeded, + ResponsePayload: json.RawMessage(`{"usage":{"outputMilliseconds":4201,"outputSeconds":5}}`), + Billing: json.RawMessage(`{"status":"charged","amountFen":360,"reservedAmountFen":360,"standardUnitPriceFen":60,"markupMultiplier":1.2,"settlementStatus":"pending","parameters":{"resolution":"720p"}}`), + UsageContext: json.RawMessage(`{"organizationId":"org-1","accountId":"account-1"}`), + } + processor := NewSettlementProcessor(settlementProcessorStub{job: advanced}, ledger, state, time.Now) + got, err := processor.Advance(context.Background(), jobs.Job{ID: advanced.ID}) + if err != nil { + t.Fatal(err) + } + // 4.201 seconds * 60 fen * 1.2 = 302.472 fen, rounded once to 303. + if ledger.calls != 1 || ledger.request.DeltaFen != -57 || ledger.request.Metadata["outputSeconds"] != 4.201 { + t.Fatalf("ledger=%#v calls=%d", ledger.request, ledger.calls) + } + var snapshot map[string]any + _ = json.Unmarshal(got.Billing, &snapshot) + if snapshot["amountFen"] != float64(303) || snapshot["settlementStatus"] != "settled" || snapshot["settlementLedgerEntryId"] != "wan30-settlement" { + t.Fatalf("billing=%#v", snapshot) + } + processor.inner = settlementProcessorStub{job: got} + _, err = processor.Advance(context.Background(), got) + if err != nil || ledger.calls != 1 || state.calls != 1 { + t.Fatalf("repeat calls ledger=%d state=%d err=%v", ledger.calls, state.calls, err) + } +} + +func TestSettlementProcessorKeepsWan30EstimateWhenUsageMissing(t *testing.T) { + state := &settlementStateStub{} + advanced := jobs.Job{ + ID: "wan30-job", Provider: "bailian", ReqKey: billing.BailianWan30VideoModel, Capability: "video.generate", Status: jobs.StatusSucceeded, + ResponsePayload: json.RawMessage(`{"usage":{}}`), + Billing: json.RawMessage(`{"status":"charged","amountFen":360,"standardUnitPriceFen":60,"markupMultiplier":1.2,"settlementStatus":"pending"}`), + UsageContext: json.RawMessage(`{"organizationId":"org-1"}`), + } + got, err := NewSettlementProcessor(settlementProcessorStub{job: advanced}, &settlementLedgerStub{}, state, time.Now).Advance(context.Background(), jobs.Job{ID: advanced.ID}) + if err != nil { + t.Fatal(err) + } + var snapshot map[string]any + _ = json.Unmarshal(got.Billing, &snapshot) + if snapshot["amountFen"] != float64(360) || snapshot["settlementStatus"] != "estimated" || snapshot["settlementReason"] != "provider_usage_unavailable" { + t.Fatalf("billing=%#v", snapshot) + } +} + func TestSettlementProcessorRefundsUnusedSeedreamLayerReservation(t *testing.T) { state := &settlementStateStub{} ledger := &settlementLedgerStub{posting: billing.WalletPosting{LedgerID: "layer-settlement", CreatedAt: time.Date(2026, 8, 21, 8, 0, 0, 0, time.UTC)}} diff --git a/backend/internal/providers/adapters.go b/backend/internal/providers/adapters.go index 736a9b7..f784237 100644 --- a/backend/internal/providers/adapters.go +++ b/backend/internal/providers/adapters.go @@ -3,6 +3,7 @@ package providers import ( "context" "fmt" + "math" "net/http" "strings" ) @@ -64,10 +65,17 @@ func NewBailian(c Config, client HTTPClient) *Bailian { return "/api/v1/services/aigc/image-generation/generation" }, queryPath: func(id string) string { return "/api/v1/tasks/" + id }, payload: func(r Request) any { if r.Capability == "video.generate" { + model := requestModel(r, c.Model) + mode := stringValue(r.Settings["inputMode"]) + if mode == "" && model == "wan3.0-video" { + mode = "reference" + } media := make([]any, 0, len(r.InputURLs)) for index, inputURL := range r.InputURLs { frameType := "first_frame" - if index > 0 { + if mode == "reference" { + frameType = "reference_image" + } else if index > 0 { frameType = "last_frame" } media = append(media, map[string]any{"type": frameType, "url": inputURL}) @@ -84,7 +92,16 @@ func NewBailian(c Config, client HTTPClient) *Bailian { if parameters["duration"] == nil { parameters["duration"] = 10 } - return map[string]any{"model": requestModel(r, c.Model), "input": map[string]any{"prompt": r.Prompt, "media": media}, "parameters": parameters} + if model == "wan3.0-video" { + if ratio := stringValue(r.Settings["ratio"]); ratio != "" { + parameters["ratio"] = ratio + } + } + input := map[string]any{"prompt": r.Prompt} + if len(media) > 0 { + input["media"] = media + } + return map[string]any{"model": model, "input": input, "parameters": parameters} } content := make([]any, 0, len(r.InputURLs)+1) for _, inputURL := range r.InputURLs { @@ -126,7 +143,23 @@ func decodeBailian(raw []byte) Result { } } collectURLs(o["video_url"], &out) - return Result{TaskID: stringValue(o["task_id"], r["task_id"]), Status: status(o["task_status"]), OutputURLs: out, ErrorMessage: stringValue(r["message"])} + usage := map[string]int{} + for _, value := range []any{object(r["usage"])["output_video_duration"], object(r["usage"])["duration"], object(o["usage"])["output_video_duration"], object(o["usage"])["duration"]} { + if milliseconds, ok := positiveDurationMilliseconds(value); ok { + usage["outputMilliseconds"] = milliseconds + usage["outputSeconds"] = int(math.Ceil(float64(milliseconds) / 1000)) + break + } + } + return Result{TaskID: stringValue(o["task_id"], r["task_id"]), Status: status(o["task_status"]), OutputURLs: out, ErrorMessage: stringValue(r["message"]), Usage: usage} +} + +func positiveDurationMilliseconds(value any) (int, bool) { + seconds, ok := value.(float64) + if !ok || math.IsNaN(seconds) || math.IsInf(seconds, 0) || seconds <= 0 || seconds > math.MaxInt32/1000 { + return 0, false + } + return int(math.Ceil(seconds * 1000)), true } type Seedance struct{ *httpAdapter } diff --git a/backend/internal/providers/failure_diagnostics.go b/backend/internal/providers/failure_diagnostics.go new file mode 100644 index 0000000..544a4ae --- /dev/null +++ b/backend/internal/providers/failure_diagnostics.go @@ -0,0 +1,150 @@ +package providers + +import ( + "encoding/json" + "net/http" + "regexp" + "strings" +) + +var providerRequestIDInMessage = regexp.MustCompile(`(?i)\brequest[ _-]?id\s*[:=]\s*([A-Za-z0-9_.:-]{3,128})\b`) + +// SanitizeFailureDetail retains a short provider explanation while removing +// submitted text, media links, credentials, and unrecognized quoted values. +// It is safe to call again after the original request has been discarded. +func SanitizeFailureDetail(message string, requestBody []byte, secrets ...string) string { + if strings.TrimSpace(message) == "" { + return "" + } + privateValues := append([]string(nil), secrets...) + var request any + if json.Unmarshal(requestBody, &request) == nil { + collectPrivateRequestValues(request, "", &privateValues) + } + for _, value := range privateValues { + if value == "" { + continue + } + message = strings.ReplaceAll(message, value, "[redacted]") + encoded, _ := json.Marshal(value) + if len(encoded) > 2 { + message = strings.ReplaceAll(message, string(encoded[1:len(encoded)-1]), "[redacted]") + } + } + message = minimaxDiagnosticURL.ReplaceAllString(message, "[redacted]") + message = minimaxDiagnosticCredential.ReplaceAllString(message, "[redacted]") + message = minimaxDiagnosticQuoted.ReplaceAllStringFunc(message, func(quoted string) string { + value := quoted[1 : len(quoted)-1] + if minimaxDiagnosticValue(value) != "unrecognized" { + return quoted + } + return "[redacted]" + }) + message = minimaxDiagnosticLongToken.ReplaceAllString(message, "[redacted]") + message = strings.Join(strings.Fields(message), " ") + runes := []rune(message) + if len(runes) > 512 { + return string(runes[:512]) + "...[truncated]" + } + return message +} + +func collectPrivateRequestValues(value any, key string, values *[]string) { + switch typed := value.(type) { + case map[string]any: + for childKey, childValue := range typed { + collectPrivateRequestValues(childValue, strings.ToLower(childKey), values) + } + case []any: + for _, child := range typed { + collectPrivateRequestValues(child, key, values) + } + case string: + if strings.Contains(key, "prompt") || strings.Contains(key, "text") || strings.Contains(key, "url") || strings.Contains(key, "image") || strings.Contains(key, "video") || strings.Contains(key, "audio") || strings.Contains(key, "token") || strings.Contains(key, "secret") || strings.Contains(key, "key") || strings.Contains(key, "signature") || strings.Contains(key, "password") || strings.Contains(key, "credential") { + *values = append(*values, typed) + } + } +} + +func requestIDFromMessage(message string) string { + match := providerRequestIDInMessage.FindStringSubmatch(message) + if len(match) < 2 { + return "" + } + return safeHTTPProviderDiagnosticToken(match[1], 128) +} + +func safeProviderResponseDetail(raw, requestBody []byte, secrets ...string) string { + root := record(raw) + providerError := object(first(root["error"], root["Error"])) + output := object(root["output"]) + data := object(root["data"]) + task := object(first(root["task"], data["task"])) + nestedError := object(first(data["error"], task["error"], output["error"])) + metadataError := object(first(object(first(root["ResponseMetadata"], root["response_metadata"]))["Error"], object(first(root["ResponseMetadata"], root["response_metadata"]))["error"])) + message := stringValue(providerError["message"], providerError["Message"], nestedError["message"], nestedError["Message"], task["message"], output["message"], data["message"], metadataError["Message"], metadataError["message"], root["message"], root["Message"]) + return SanitizeFailureDetail(message, requestBody, secrets...) +} + +func successBusinessCode(code string) bool { + switch strings.ToLower(strings.TrimSpace(code)) { + case "", "0", "200", "ok", "success", "10000": + return true + } + return false +} + +// Only explicit top-level or output error indicators count as a business +// rejection. A successful task can carry a normal informational message. +func inspectProviderBusinessRejection(raw []byte, headers http.Header, requestBody []byte, secrets ...string) (code, requestID, detail string, rejected bool) { + root := record(raw) + providerError := object(first(root["error"], root["Error"])) + output := object(root["output"]) + data := object(root["data"]) + task := object(first(root["task"], data["task"])) + nestedError := object(first(data["error"], task["error"], output["error"])) + code, requestID, _ = inspectHTTPProviderFailure(raw, headers) + message := stringValue(providerError["message"], providerError["Message"], nestedError["message"], nestedError["Message"], output["message"], data["message"], root["message"]) + if (len(providerError) > 0 || len(nestedError) > 0) && (code != "" || message != "") { + rejected = true + } else if rootCode := safeHTTPProviderDiagnosticToken(first(root["code"], root["Code"]), 64); rootCode != "" && !successBusinessCode(rootCode) { + rejected = true + } else if outputCode := safeHTTPProviderDiagnosticToken(first(output["code"], output["Code"]), 64); outputCode != "" && !successBusinessCode(outputCode) { + rejected = true + } else if strings.EqualFold(stringValue(root["type"]), "error") && message != "" { + rejected = true + } + if !rejected { + return "", "", "", false + } + return code, requestID, SanitizeFailureDetail(message, requestBody, secrets...), true +} + +func annotateTerminalFailure(result Result, raw []byte, headers http.Header, requestBody []byte, secrets ...string) Result { + if result.Status != StatusFailed && result.Status != StatusExpired && result.Status != StatusCancelled { + return result + } + root := record(raw) + business := object(first(root["Result"], root["result"])) + data := object(root["data"]) + businessData := object(first(business["data"], business["Data"])) + output := object(root["output"]) + task := object(root["task"]) + providerError := object(first(task["error"], data["error"], businessData["error"], businessData["Error"], business["error"], business["Error"], output["error"], root["error"], root["Error"])) + code, requestID, _ := inspectHTTPProviderFailure(raw, headers) + if nestedCode := firstSafeHTTPProviderDiagnosticToken(64, providerError["code"], providerError["Code"], task["error_code"], data["error_code"], businessData["error_code"], business["error_code"]); nestedCode != "" { + code = nestedCode + } + if nestedID := firstSafeHTTPProviderDiagnosticToken(128, providerError["request_id"], providerError["requestId"], providerError["RequestId"], task["request_id"], data["request_id"], businessData["request_id"], business["request_id"]); nestedID != "" { + requestID = nestedID + } + message := stringValue(providerError["message"], providerError["Message"], task["error_message"], task["message"], output["message"], data["error_message"], data["message"], businessData["error_message"], businessData["message"], business["message"], root["message"], result.ErrorMessage) + if requestID == "" { + requestID = requestIDFromMessage(message) + } + result.ErrorCode = code + result.RequestID = requestID + result.ErrorDetail = SanitizeFailureDetail(message, requestBody, secrets...) + result.ErrorMessage = SanitizeFailureDetail(result.ErrorMessage, requestBody, secrets...) + return result +} diff --git a/backend/internal/providers/failure_diagnostics_test.go b/backend/internal/providers/failure_diagnostics_test.go new file mode 100644 index 0000000..2c99228 --- /dev/null +++ b/backend/internal/providers/failure_diagnostics_test.go @@ -0,0 +1,185 @@ +package providers + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" +) + +func TestAdaptersPreserveExplicitProviderRejections(t *testing.T) { + tests := []struct { + name, body, wantCode, wantDetail string + newAdapter func(HTTPClient) Adapter + }{ + {"seedance", `{"error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"Input image may contain a real person. Request ID: req-safe-1"}}`, "InputImageSensitiveContentDetected.PrivacyInformation", "real person", func(c HTTPClient) Adapter { + return NewSeedance(Config{BaseURL: "https://test.invalid", APIKey: "private-key"}, c) + }}, + {"seedream", `{"error":{"code":"InvalidImage","message":"Image format is invalid"}}`, "InvalidImage", "Image format", func(c HTTPClient) Adapter { + return NewSeedream(Config{BaseURL: "https://test.invalid", APIKey: "private-key"}, c) + }}, + {"evolink", `{"data":{"error":{"code":"InvalidPrompt","message":"Prompt is invalid"}}}`, "InvalidPrompt", "Prompt is invalid", func(c HTTPClient) Adapter { + return NewEvoLink(Config{BaseURL: "https://test.invalid", APIKey: "private-key"}, c) + }}, + {"bailian", `{"code":"InvalidParameter","message":"Invalid duration"}`, "InvalidParameter", "Invalid duration", func(c HTTPClient) Adapter { + return NewBailian(Config{BaseURL: "https://test.invalid", APIKey: "private-key"}, c) + }}, + {"minimax", `{"type":"error","error":{"code":"1008","message":"Account balance is insufficient"}}`, "1008", "balance", func(c HTTPClient) Adapter { + return NewMinimax(Config{BaseURL: "https://test.invalid", APIKey: "private-key"}, c) + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + for _, httpStatus := range []int{http.StatusBadRequest, http.StatusOK} { + client := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: httpStatus, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(tc.body))}, nil + }) + result, err := tc.newAdapter(client).Submit(context.Background(), Request{Prompt: "private prompt"}) + if tc.name == "seedream" && httpStatus == http.StatusOK { + if err != nil || result.Status != StatusFailed || result.ErrorCode != tc.wantCode || !strings.Contains(result.ErrorDetail, tc.wantDetail) { + t.Fatalf("status=%d result=%#v err=%v", httpStatus, result, err) + } + continue + } + var failure *ProviderError + if !errors.As(err, &failure) || failure.Status != httpStatus || failure.Code != tc.wantCode || !strings.Contains(failure.Detail, tc.wantDetail) { + t.Fatalf("status=%d failure=%#v err=%v", httpStatus, failure, err) + } + if tc.name == "seedance" && failure.RequestID != "req-safe-1" { + t.Fatalf("request id=%q", failure.RequestID) + } + } + }) + } +} + +func TestSuccessfulBusinessCodesAndInformationalMessagesDoNotReject(t *testing.T) { + tests := []struct { + name, response, taskID string + wantURL bool + adapter func(HTTPClient) Adapter + }{ + {"evolink", `{"code":0,"message":"request accepted","data":{"id":"task-ev","status":"queued"}}`, "task-ev", false, func(c HTTPClient) Adapter { return NewEvoLink(Config{BaseURL: "https://test.invalid"}, c) }}, + {"bailian", `{"code":"200","message":"task submitted","output":{"task_id":"task-ba","task_status":"PENDING"}}`, "task-ba", false, func(c HTTPClient) Adapter { return NewBailian(Config{BaseURL: "https://test.invalid"}, c) }}, + {"seedance", `{"code":"success","message":"task completed","id":"task-se","status":"succeeded","content":{"video_url":"https://cdn.example/video.mp4"}}`, "task-se", true, func(c HTTPClient) Adapter { return NewSeedance(Config{BaseURL: "https://test.invalid"}, c) }}, + {"minimax", `{"code":0,"message":"request accepted","task_id":"task-mi"}`, "task-mi", false, func(c HTTPClient) Adapter { return NewMinimax(Config{BaseURL: "https://test.invalid"}, c) }}, + {"seedream", `{"code":0,"message":"generation complete","data":[{"url":"https://cdn.example/image.png"}]}`, "", true, func(c HTTPClient) Adapter { return NewSeedream(Config{BaseURL: "https://test.invalid"}, c) }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(tc.response))}, nil + }) + result, err := tc.adapter(client).Submit(context.Background(), Request{Prompt: "safe prompt"}) + if err != nil || result.TaskID != tc.taskID || (tc.wantURL && len(result.OutputURLs) == 0) || (!tc.wantURL && len(result.OutputURLs) != 0) { + t.Fatalf("result=%#v err=%v", result, err) + } + }) + } +} + +func TestAsyncTerminalFailurePersistsSafeDiagnostics(t *testing.T) { + client := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"output":{"task_id":"task-1","task_status":"FAILED","code":"InvalidImage","message":"Input image was rejected"},"request_id":"req-bailian-1"}`))}, nil + }) + result, err := NewBailian(Config{BaseURL: "https://test.invalid", APIKey: "secret"}, client).Query(context.Background(), "task-1") + if err != nil || result.Status != StatusFailed || result.ErrorCode != "InvalidImage" || result.RequestID != "req-bailian-1" || result.ErrorDetail != "Input image was rejected" { + t.Fatalf("result=%#v err=%v", result, err) + } + encoded, err := EncodeResult(result) + if err != nil { + t.Fatal(err) + } + var persisted HTTPResult + if err := json.Unmarshal(encoded, &persisted); err != nil || persisted.ErrorCode != result.ErrorCode || persisted.RequestID != result.RequestID || persisted.ErrorDetail != result.ErrorDetail { + t.Fatalf("persisted=%#v err=%v", persisted, err) + } +} + +func TestAsyncAdaptersReadTerminalFailureDetails(t *testing.T) { + tests := []struct { + name, response, code, detail string + adapter func(HTTPClient) Adapter + }{ + {"evolink", `{"id":"task-1","status":"failed","data":{"error":{"code":"ContentRejected","message":"Reference image rejected"}}}`, "ContentRejected", "Reference image rejected", func(c HTTPClient) Adapter { return NewEvoLink(Config{BaseURL: "https://test.invalid"}, c) }}, + {"seedance", `{"id":"task-1","status":"failed","error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"Input image may contain a real person"}}`, "InputImageSensitiveContentDetected.PrivacyInformation", "real person", func(c HTTPClient) Adapter { return NewSeedance(Config{BaseURL: "https://test.invalid"}, c) }}, + {"minimax", `{"task":{"id":"task-1","status":"failed","error":{"code":"1026","message":"Input content rejected"}}}`, "1026", "Input content rejected", func(c HTTPClient) Adapter { return NewMinimax(Config{BaseURL: "https://test.invalid"}, c) }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(tc.response))}, nil + }) + result, err := tc.adapter(client).Query(context.Background(), "task-1") + if err != nil || result.Status != StatusFailed || result.ErrorCode != tc.code || !strings.Contains(result.ErrorDetail, tc.detail) { + t.Fatalf("result=%#v err=%v", result, err) + } + }) + } +} + +func TestSanitizeFailureDetailRemovesSubmittedMaterialsCredentialsAndUnknownQuotes(t *testing.T) { + request := []byte(`{"prompt":"private portrait","content":[{"text":"private portrait"},{"image_url":{"url":"https://private.example/photo.png"}}],"materials":[{"url":"https://private.example/video.mp4"}],"settings":{"token":"very-secret-token"}}`) + message := `invalid "private portrait" from https://private.example/photo.png; video https://private.example/video.mp4; token=very-secret-token; Bearer other-secret; field "person's private name" rejected` + got := SanitizeFailureDetail(message, request, "api-secret") + for _, private := range []string{"private portrait", "private.example", "very-secret-token", "other-secret", "person's private name"} { + if strings.Contains(got, private) { + t.Fatalf("detail leaks %q: %q", private, got) + } + } + if !strings.Contains(got, "invalid") || !strings.Contains(got, "rejected") { + t.Fatalf("detail lost context: %q", got) + } + if got := SanitizeFailureDetail(strings.Repeat("x", 1000), nil); len([]rune(got)) > 527 { + t.Fatalf("detail unbounded: %d", len([]rune(got))) + } +} + +func TestUnknownSubmitOutcomeKeepsStatusZero(t *testing.T) { + for name, client := range map[string]HTTPClient{ + "transport": roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, io.ErrUnexpectedEOF }), + "unreadable-response": roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 400, Header: http.Header{}, Body: &failingBody{}}, nil + }), + } { + t.Run(name, func(t *testing.T) { + _, err := NewSeedance(Config{BaseURL: "https://test.invalid"}, client).Submit(context.Background(), Request{Prompt: "test"}) + var failure *ProviderError + if !errors.As(err, &failure) || failure.Status != 0 { + t.Fatalf("failure=%#v err=%v", failure, err) + } + }) + } +} + +func TestVolcengineRejectionAndTerminalFailurePreserveSafeDiagnostics(t *testing.T) { + responses := []string{ + `{"ResponseMetadata":{"RequestId":"req-volc-1","Error":{"Code":"50413","Message":"Input image rejected"}}}`, + `{"code":10000,"Result":{"data":{"task_id":"task-volc","status":"failed","error":{"code":"InvalidImage","message":"Input image rejected"}}}}`, + } + call := 0 + client := roundTripFunc(func(*http.Request) (*http.Response, error) { + body := responses[call] + call++ + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body))}, nil + }) + adapter := NewVolcengine(Config{BaseURL: "https://test.invalid", AccessKeyID: "private-ak", SecretAccessKey: "private-sk"}, client, func() time.Time { return time.Unix(0, 0) }) + _, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"}) + var failure *ProviderError + if !errors.As(err, &failure) || failure.Status != 200 || failure.Code != "50413" || failure.RequestID != "req-volc-1" || failure.Detail != "Input image rejected" { + t.Fatalf("failure=%#v err=%v", failure, err) + } + result, err := adapter.Query(context.Background(), "task-volc") + if err != nil || result.Status != StatusFailed || result.ErrorCode != "InvalidImage" || result.ErrorDetail != "Input image rejected" { + t.Fatalf("terminal result=%#v err=%v", result, err) + } +} + +type failingBody struct{} + +func (*failingBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } +func (*failingBody) Close() error { return nil } diff --git a/backend/internal/providers/failure_messages.go b/backend/internal/providers/failure_messages.go new file mode 100644 index 0000000..f882a3b --- /dev/null +++ b/backend/internal/providers/failure_messages.go @@ -0,0 +1,197 @@ +package providers + +import "strings" + +// FailureMessage returns a user-facing reason for a confirmed provider rejection +// or a failed asynchronous task. The code must come from the named provider; +// provider response prose must never be passed through to the user. +// +// Error-code references: +// Volcengine Ark: https://docs.volcengine.com/docs/82379/1299023 +// Bailian: https://help.aliyun.com/zh/model-studio/error-code/ +// EvoLink API codes: https://evolink.ai/zh/blog/wan-2-7-api-guide +// Jimeng visual codes: https://docs.volcengine.com/docs/AIGCAIzhongtaigongyongwendang/69728 +// and https://www.volcengine.com/docs/85621/1791184 +func FailureMessage(provider string, status int, code string) string { + code = strings.TrimSpace(code) + switch strings.ToLower(strings.TrimSpace(provider)) { + case "seedance", "seedream": + if message := arkFailureMessage(code); message != "" { + return message + } + case "minimax": + if message := minimaxFailureMessage(code); message != "" { + return message + } + case "bailian": + if message := bailianFailureMessage(code); message != "" { + return message + } + case "evolink": + if message := evolinkFailureMessage(code); message != "" { + return message + } + case "volcengine-visual": + if message := jimengFailureMessage(code); message != "" { + return message + } + } + return failureMessageForStatus(status) +} + +func arkFailureMessage(code string) string { + if code == "InputImageSensitiveContentDetected.PrivacyInformation" { + return "参考图片可能包含真人,未通过服务商审核;请更换符合要求的图片后重试。" + } + if code == "InputVideoSensitiveContentDetected.PrivacyInformation" { + return "参考视频可能包含真人,未通过服务商审核;请更换符合要求的视频后重试。" + } + for _, item := range []struct{ code, material string }{ + {"InputTextSensitiveContentDetected", "提示词"}, + {"InputImageSensitiveContentDetected", "参考图片"}, + {"InputVideoSensitiveContentDetected", "参考视频"}, + {"InputAudioSensitiveContentDetected", "参考音频"}, + {"OutputTextSensitiveContentDetected", "生成的文字"}, + {"OutputImageSensitiveContentDetected", "生成的图片"}, + {"OutputVideoSensitiveContentDetected", "生成的视频"}, + {"OutputAudioSensitiveContentDetected", "生成的音频"}, + } { + if code == item.code+".PolicyViolation" { + return item.material + "可能涉及版权限制,请调整内容后重试。" + } + if code == item.code || strings.HasPrefix(code, item.code+".") { + return item.material + "未通过服务商内容审核,请调整内容后重试。" + } + } + switch code { + case "InvalidImageURL.EmptyURL", "InvalidImageURL.InvalidFormat", "InvalidParameter.TosURLInvalid": + return "参考图片无法读取,请重新上传图片后重试。" + case "OperationDenied.ServiceNotOpen", "OperationDenied.PermissionDenied": + return "当前模型服务尚未开通或无权使用,请联系管理员处理。" + case "OperationDenied.ServiceOverdue": + return "生成服务账户已逾期,请联系管理员处理。" + case "InvalidAccountStatus": + return "生成服务账户状态异常,请联系管理员处理。" + case "ContentSecurityDetectionError": + return "服务商内容审核暂时不可用,请稍后重试。" + } + if code == "MissingParameter" || strings.HasPrefix(code, "MissingParameter.") || code == "InvalidParameter" || strings.HasPrefix(code, "InvalidParameter.") { + return "当前生成参数不符合模型要求,请调整设置或切换模型后重试。" + } + return "" +} + +func minimaxFailureMessage(code string) string { + switch code { + case "1002": + return "MiniMax 请求过于频繁,请稍后重试。" + case "1004", "2049": + return "MiniMax 服务凭证无效,请联系管理员处理。" + case "1008": + return "MiniMax 生成服务账户余额不足,请联系管理员处理。" + case "1026": + return "视频描述或参考素材未通过 MiniMax 内容审核,请调整后重试。" + case "2013": + return "当前生成参数不符合 MiniMax 模型要求,请调整设置后重试。" + } + return "" +} + +func bailianFailureMessage(code string) string { + switch code { + case "Arrearage", "isv.OUT_OF_SERVICE": + return "百炼生成服务账户余额不足或逾期,请联系管理员处理。" + case "DataInspectionFailed", "data_inspection_failed": + return "输入或生成内容未通过百炼审核,请调整提示词或素材后重试。" + case "IPInfringementSuspect": + return "输入内容可能涉及知识产权限制,请调整提示词或素材后重试。" + case "InvalidParameter", "InternalError.Algo.InvalidParameter": + return "当前生成参数不符合百炼模型要求,请调整设置后重试。" + case "InvalidURL", "InvalidURL.ConnectionRefused", "InvalidURL.Timeout", "InvalidFile.DownloadFailed", "InvalidParameter.FileDownload": + return "参考素材无法下载,请重新上传可访问的素材后重试。" + case "InvalidParameter.DataInspection": + return "参考素材在安全审核时无法访问,请重新上传素材;若仍失败请联系管理员。" + case "InvalidFile.Size", "InvalidFile.Format", "InvalidFile.Resolution", "InvalidFile.FPS", "InvalidFile.Duration", "InvalidFile.ImageSize", "InvalidFile.Openerror": + return "参考素材的格式、大小或时长不符合模型要求,请更换素材后重试。" + case "InvalidApiKey", "AccessDenied", "Resource.AccessDenied", "Workspace.AccessDenied": + return "百炼生成服务鉴权或使用权限异常,请联系管理员处理。" + case "Throttling": + return "百炼请求过于频繁,请稍后重试。" + case "InternalError", "InternalError.Algo": + return "百炼生成服务暂时异常,请稍后重试。" + } + return "" +} + +func evolinkFailureMessage(code string) string { + switch code { + case "invalid_request": + return "当前生成参数不符合模型要求,请检查提示词、素材和设置后重试。" + case "unauthorized": + return "EvoLink 生成服务鉴权失败,请联系管理员处理。" + case "insufficient_quota": + return "EvoLink 生成服务账户额度不足,请联系管理员处理。" + case "model_access_denied": + return "当前 EvoLink 模型无调用权限,请联系管理员处理。" + case "rate_limit_exceeded": + return "EvoLink 请求频率过高,请稍后重试。" + case "internal_error": + return "EvoLink 生成服务暂时异常,请稍后重试。" + } + return "" +} + +func jimengFailureMessage(code string) string { + switch code { + case "50200", "50201", "50204": + return "当前生成参数不符合即梦模型要求,请调整设置后重试。" + case "50205", "50206", "50207": + return "参考图片过大、为空或无法读取,请更换图片后重试。" + case "50209", "50210", "50211", "50214": + return "参考视频的内容、大小或时长不符合要求,请更换视频后重试。" + case "50213": + return "提交的素材总大小超过即梦限制,请减少或压缩素材后重试。" + case "60102": + return "参考图片中未检测到人脸,请更换清晰的人像图片后重试。" + case "60208", "50411": + return "参考图片未通过即梦内容审核,请更换图片后重试。" + case "50511": + return "生成的图片未通过即梦内容审核,请调整提示词或素材后重试。" + case "50412", "50413": + return "提示词未通过即梦内容审核,请调整内容后重试。" + case "50512": + return "生成的文字未通过即梦内容审核,请调整提示词或素材后重试。" + case "50400": + return "即梦服务鉴权或调用权限异常,请联系管理员处理。" + case "50402": + return "即梦模型接口不可用,请联系管理员检查配置。" + case "50429", "50430": + return "即梦请求频率或并发达到上限,请稍后重试。" + case "50500", "50501": + return "即梦生成服务暂时异常,请稍后重试。" + } + return "" +} + +func failureMessageForStatus(status int) string { + switch status { + case 0: + return "服务商未能完成生成,请联系管理员查看错误详情。" + case 401: + return "生成服务鉴权失败,请联系管理员处理。" + case 402: + return "生成服务账户额度不足,请联系管理员处理。" + case 403: + return "当前生成服务无权使用,请联系管理员处理。" + case 404: + return "模型或生成任务不存在,请联系管理员检查配置。" + case 408, 504: + return "服务商请求超时,无法确认是否已接收,请联系管理员核查任务状态。" + case 429: + return "生成服务请求频率过高,请稍后重试。" + case 500, 502, 503: + return "生成服务暂时异常,请稍后重试。" + default: + return "服务商拒绝了本次请求,请联系管理员查看错误详情。" + } +} diff --git a/backend/internal/providers/failure_messages_test.go b/backend/internal/providers/failure_messages_test.go new file mode 100644 index 0000000..254723e --- /dev/null +++ b/backend/internal/providers/failure_messages_test.go @@ -0,0 +1,80 @@ +package providers + +import ( + "strings" + "testing" +) + +func TestFailureMessageUsesProviderSpecificCodes(t *testing.T) { + cases := []struct { + name, provider, code, want string + status int + }{ + {"seedance real-person image", "seedance", "InputImageSensitiveContentDetected.PrivacyInformation", "参考图片可能包含真人,未通过服务商审核;请更换符合要求的图片后重试。", 400}, + {"seedream real-person image", "seedream", "InputImageSensitiveContentDetected.PrivacyInformation", "参考图片可能包含真人,未通过服务商审核;请更换符合要求的图片后重试。", 400}, + {"seedance real-person video", "seedance", "InputVideoSensitiveContentDetected.PrivacyInformation", "参考视频可能包含真人,未通过服务商审核;请更换符合要求的视频后重试。", 400}, + {"seedance image copyright", "seedance", "InputImageSensitiveContentDetected.PolicyViolation", "参考图片可能涉及版权限制,请调整内容后重试。", 400}, + {"seedance image subcode", "seedance", "InputImageSensitiveContentDetected.OtherRestriction", "参考图片未通过服务商内容审核,请调整内容后重试。", 400}, + {"seedance parameter subcode", "seedance", "InvalidParameter.NewConstraint", "当前生成参数不符合模型要求,请调整设置或切换模型后重试。", 400}, + {"seedance missing parameter subcode", "seedance", "MissingParameter.ImageURL", "当前生成参数不符合模型要求,请调整设置或切换模型后重试。", 400}, + {"seedance audio moderation", "seedance", "InputAudioSensitiveContentDetected", "参考音频未通过服务商内容审核,请调整内容后重试。", 400}, + {"seedance output video moderation", "seedance", "OutputVideoSensitiveContentDetected", "生成的视频未通过服务商内容审核,请调整内容后重试。", 400}, + {"seedream old output image moderation", "seedream", "OutputImageSensitiveContentDetected", "生成的图片未通过服务商内容审核,请调整内容后重试。", 400}, + {"seedance account overdue", "seedance", "OperationDenied.ServiceOverdue", "生成服务账户已逾期,请联系管理员处理。", 403}, + {"seedance service unopened", "seedance", "OperationDenied.ServiceNotOpen", "当前模型服务尚未开通或无权使用,请联系管理员处理。", 403}, + {"minimax balance", "minimax", "1008", "MiniMax 生成服务账户余额不足,请联系管理员处理。", 400}, + {"minimax moderation", "minimax", "1026", "视频描述或参考素材未通过 MiniMax 内容审核,请调整后重试。", 400}, + {"minimax rate limit", "minimax", "1002", "MiniMax 请求过于频繁,请稍后重试。", 400}, + {"minimax invalid parameters", "minimax", "2013", "当前生成参数不符合 MiniMax 模型要求,请调整设置后重试。", 400}, + {"bailian arrearage", "bailian", "Arrearage", "百炼生成服务账户余额不足或逾期,请联系管理员处理。", 400}, + {"bailian copyright", "bailian", "IPInfringementSuspect", "输入内容可能涉及知识产权限制,请调整提示词或素材后重试。", 400}, + {"bailian inspection", "bailian", "DataInspectionFailed", "输入或生成内容未通过百炼审核,请调整提示词或素材后重试。", 400}, + {"bailian reference download", "bailian", "InvalidFile.DownloadFailed", "参考素材无法下载,请重新上传可访问的素材后重试。", 400}, + {"bailian inspection access", "bailian", "InvalidParameter.DataInspection", "参考素材在安全审核时无法访问,请重新上传素材;若仍失败请联系管理员。", 400}, + {"evolink balance by status", "evolink", "unrecognized", "生成服务账户额度不足,请联系管理员处理。", 402}, + {"evolink invalid request by code", "evolink", "invalid_request", "当前生成参数不符合模型要求,请检查提示词、素材和设置后重试。", 400}, + {"evolink quota by code", "evolink", "insufficient_quota", "EvoLink 生成服务账户额度不足,请联系管理员处理。", 400}, + {"evolink model access by code", "evolink", "model_access_denied", "当前 EvoLink 模型无调用权限,请联系管理员处理。", 400}, + {"jimeng text audit by business code", "volcengine-visual", "50413", "提示词未通过即梦内容审核,请调整内容后重试。", 200}, + {"jimeng image audit by business code", "volcengine-visual", "50411", "参考图片未通过即梦内容审核,请更换图片后重试。", 200}, + {"jimeng image too large by business code", "volcengine-visual", "50205", "参考图片过大、为空或无法读取,请更换图片后重试。", 200}, + {"jimeng concurrency by business code", "volcengine-visual", "50430", "即梦请求频率或并发达到上限,请稍后重试。", 200}, + {"jimeng forbidden by status", "volcengine-visual", "unrecognized", "当前生成服务无权使用,请联系管理员处理。", 403}, + {"unknown authorization", "evolink", "unrecognized", "生成服务鉴权失败,请联系管理员处理。", 401}, + {"unknown model", "evolink", "unrecognized", "模型或生成任务不存在,请联系管理员检查配置。", 404}, + {"unknown validation", "evolink", "unrecognized", "服务商拒绝了本次请求,请联系管理员查看错误详情。", 422}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := FailureMessage(tc.provider, tc.status, tc.code); got != tc.want { + t.Errorf("FailureMessage(%q, %d, %q) = %q; want %q", tc.provider, tc.status, tc.code, got, tc.want) + } + }) + } +} + +func TestFailureMessageUnknownCodesStayGenericAndDoNotCrossProvider(t *testing.T) { + cases := []struct { + provider, code string + status int + want string + }{ + {"seedance", "InputImageSensitiveContentDetectedEvil.PrivacyInformation", 400, "服务商拒绝了本次请求,请联系管理员查看错误详情。"}, + {"evolink", "InputImageSensitiveContentDetected.PrivacyInformation", 400, "服务商拒绝了本次请求,请联系管理员查看错误详情。"}, + {"minimax", "OperationDenied.ServiceOverdue", 400, "服务商拒绝了本次请求,请联系管理员查看错误详情。"}, + {"unknown", "1008", 400, "服务商拒绝了本次请求,请联系管理员查看错误详情。"}, + {"bailian", "unknown", 0, "服务商未能完成生成,请联系管理员查看错误详情。"}, + {"seedream", "unknown", 429, "生成服务请求频率过高,请稍后重试。"}, + {"seedream", "unknown", 504, "服务商请求超时,无法确认是否已接收,请联系管理员核查任务状态。"}, + {"seedream", "unknown", 500, "生成服务暂时异常,请稍后重试。"}, + } + for _, tc := range cases { + got := FailureMessage(tc.provider, tc.status, tc.code) + if got != tc.want { + t.Errorf("FailureMessage(%q, %d, %q) = %q; want %q", tc.provider, tc.status, tc.code, got, tc.want) + } + if strings.Contains(got, tc.code) && tc.code != "unknown" { + t.Errorf("user message leaked provider code %q: %q", tc.code, got) + } + } +} diff --git a/backend/internal/providers/minimax.go b/backend/internal/providers/minimax.go index 9abf982..828a027 100644 --- a/backend/internal/providers/minimax.go +++ b/backend/internal/providers/minimax.go @@ -9,8 +9,8 @@ import ( const MinimaxH3Model = "MiniMax-H3" // Minimax adapts the MiniMax H3 V2 asynchronous video API to the common -// provider contract. The first release intentionally supports text-to-video -// and a single first-frame image only; request validation lives in jobs. +// provider contract. Request validation for first-frame and multi-reference +// modes lives in jobs. type Minimax struct{ *httpAdapter } func NewMinimax(c Config, client HTTPClient) *Minimax { @@ -30,13 +30,20 @@ func NewMinimax(c Config, client HTTPClient) *Minimax { content := []any{map[string]any{"type": "text", "text": r.Prompt}} materials := r.Materials if len(materials) == 0 && len(r.InputURLs) > 0 { - materials = []Material{{URL: r.InputURLs[0], Type: MaterialImage}} + materials = make([]Material, 0, len(r.InputURLs)) + for _, inputURL := range r.InputURLs { + materials = append(materials, Material{URL: inputURL, Type: MaterialImage}) + } } - if len(materials) > 0 { + role := "first_frame" + if r.Settings["inputMode"] == "reference" { + role = "reference_image" + } + for _, material := range materials { content = append(content, map[string]any{ "type": "image_url", - "image_url": map[string]any{"url": materials[0].URL}, - "role": "first_frame", + "image_url": map[string]any{"url": material.URL}, + "role": role, }) } return map[string]any{ diff --git a/backend/internal/providers/provider.go b/backend/internal/providers/provider.go index 95a1df8..064aeb1 100644 --- a/backend/internal/providers/provider.go +++ b/backend/internal/providers/provider.go @@ -66,6 +66,9 @@ type Result struct { OutputURLs []string Raw json.RawMessage ErrorMessage string + ErrorCode string + RequestID string + ErrorDetail string Usage map[string]int } @@ -78,6 +81,9 @@ type HTTPResult struct { OutputURLs []string `json:"outputUrls"` Raw json.RawMessage `json:"raw,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + RequestID string `json:"requestId,omitempty"` + ErrorDetail string `json:"errorDetail,omitempty"` Usage map[string]int `json:"usage,omitempty"` } @@ -86,7 +92,7 @@ func EncodeResult(result Result) (json.RawMessage, error) { if urls == nil { urls = []string{} } - return json.Marshal(HTTPResult{TaskID: result.TaskID, Status: result.Status, OutputURLs: urls, Raw: result.Raw, ErrorMessage: result.ErrorMessage, Usage: result.Usage}) + return json.Marshal(HTTPResult{TaskID: result.TaskID, Status: result.Status, OutputURLs: urls, Raw: result.Raw, ErrorMessage: result.ErrorMessage, ErrorCode: result.ErrorCode, RequestID: result.RequestID, ErrorDetail: SanitizeFailureDetail(result.ErrorDetail, nil), Usage: result.Usage}) } type Adapter interface { @@ -211,10 +217,15 @@ func (a *httpAdapter) call(ctx context.Context, method, path string, body []byte } if resp.StatusCode < 200 || resp.StatusCode >= 300 { code, requestID, errorType := inspectHTTPProviderFailure(raw, resp.Header) - var detail string - // Only a submission carries the original content needed for redaction. + detail := safeProviderResponseDetail(raw, body, a.config.APIKey) if operation == "submit" && a.failureDetail != nil { - detail = a.failureDetail(raw, body) + if adapterDetail := a.failureDetail(raw, body); adapterDetail != "" { + detail = SanitizeFailureDetail(adapterDetail, body, a.config.APIKey) + } + } + loggedDetail := "" + if a.name == "minimax" { + loggedDetail = detail } logHTTPProviderFailure(httpProviderDiagnostic{ Provider: a.name, @@ -225,7 +236,7 @@ func (a *httpAdapter) call(ctx context.Context, method, path string, body []byte ErrorType: errorType, ErrorClass: "service", ElapsedMS: elapsedMilliseconds(startedAt), - Detail: detail, + Detail: loggedDetail, }) return Result{}, &ProviderError{Operation: a.name + " " + operation, Status: resp.StatusCode, Code: code, RequestID: requestID, Detail: detail} } @@ -236,6 +247,13 @@ func (a *httpAdapter) call(ctx context.Context, method, path string, body []byte return Result{}, &ProviderError{Operation: a.name + " " + operation} } result := a.decode(raw) + if result.Status != StatusFailed && result.Status != StatusExpired && result.Status != StatusCancelled { + if code, requestID, detail, rejected := inspectProviderBusinessRejection(raw, resp.Header, body, a.config.APIKey); rejected { + logHTTPProviderFailure(httpProviderDiagnostic{Provider: a.name, Operation: operation, Status: resp.StatusCode, Code: code, RequestID: requestID, ErrorClass: "service", ElapsedMS: elapsedMilliseconds(startedAt)}) + return Result{}, &ProviderError{Operation: a.name + " " + operation, Status: resp.StatusCode, Code: code, RequestID: requestID, Detail: detail} + } + } + result = annotateTerminalFailure(result, raw, resp.Header, body, a.config.APIKey) result.Raw = append(json.RawMessage(nil), raw...) return result, nil } @@ -283,21 +301,35 @@ func inspectHTTPProviderFailure(raw []byte, headers http.Header) (code, requestI } metadata := object(first(root["ResponseMetadata"], root["response_metadata"])) metadataError := object(first(metadata["Error"], metadata["error"])) + output := object(root["output"]) + data := object(root["data"]) + task := object(first(root["task"], data["task"])) + nestedError := object(first(data["error"], task["error"], output["error"])) - code = safeHTTPProviderDiagnosticToken(first( + code = firstSafeHTTPProviderDiagnosticToken(64, providerError["code"], providerError["Code"], + nestedError["code"], nestedError["Code"], metadataError["code"], metadataError["Code"], + output["code"], output["Code"], + data["code"], data["Code"], root["code"], root["Code"], - ), 64) + ) if code == "" { - code = parenthesizedNumericCode(stringValue(providerError["message"], root["message"])) + code = parenthesizedNumericCode(stringValue(providerError["message"], nestedError["message"], output["message"], data["message"], root["message"])) } - requestID = safeHTTPProviderDiagnosticToken(first( + requestID = firstSafeHTTPProviderDiagnosticToken(128, providerError["request_id"], providerError["requestId"], providerError["RequestId"], providerError["RequestID"], + nestedError["request_id"], nestedError["requestId"], nestedError["RequestId"], nestedError["RequestID"], root["request_id"], root["requestId"], root["RequestId"], root["RequestID"], + data["request_id"], data["requestId"], data["RequestId"], data["RequestID"], + task["request_id"], task["requestId"], task["RequestId"], task["RequestID"], metadata["request_id"], metadata["requestId"], metadata["RequestId"], metadata["RequestID"], + output["request_id"], output["requestId"], output["RequestId"], output["RequestID"], headers.Get("X-Tt-Logid"), headers.Get("X-Request-Id"), - ), 128) + ) + if requestID == "" { + requestID = requestIDFromMessage(stringValue(providerError["message"], nestedError["message"], output["message"], data["message"], root["message"])) + } errorType = safeHTTPProviderDiagnosticToken(first( providerError["type"], providerError["Type"], metadataError["type"], metadataError["Type"], @@ -354,6 +386,15 @@ func safeHTTPProviderDiagnosticToken(value any, maxLength int) string { return token } +func firstSafeHTTPProviderDiagnosticToken(maxLength int, values ...any) string { + for _, value := range values { + if token := safeHTTPProviderDiagnosticToken(value, maxLength); token != "" { + return token + } + } + return "" +} + func classifyHTTPProviderTransportError(err error) string { if errors.Is(err, context.Canceled) { return "canceled" diff --git a/backend/internal/providers/providers_test.go b/backend/internal/providers/providers_test.go index 64b2630..90281aa 100644 --- a/backend/internal/providers/providers_test.go +++ b/backend/internal/providers/providers_test.go @@ -738,6 +738,33 @@ func TestBailianDecodesCompatibleModeChoiceImages(t *testing.T) { } } +func TestBailianWan30ReferencePayloadAndActualDuration(t *testing.T) { + adapter := NewBailian(Config{Model: "wan2.7-i2v-2026-04-25"}, nil) + request := Request{Capability: "video.generate", Model: "wan3.0-video", Prompt: "combine", InputURLs: []string{"https://cdn.test/a.png", "https://cdn.test/b.png"}, Settings: map[string]any{"resolution": "480P", "duration": 20, "ratio": "9:16"}} + payload := adapter.payload(request).(map[string]any) + media := payload["input"].(map[string]any)["media"].([]any) + if payload["model"] != "wan3.0-video" || len(media) != 2 || media[0].(map[string]any)["type"] != "reference_image" || media[1].(map[string]any)["type"] != "reference_image" { + t.Fatalf("payload=%#v", payload) + } + if payload["parameters"].(map[string]any)["ratio"] != "9:16" { + t.Fatalf("parameters=%#v", payload["parameters"]) + } + request.Settings["inputMode"] = "frames" + media = adapter.payload(request).(map[string]any)["input"].(map[string]any)["media"].([]any) + if media[0].(map[string]any)["type"] != "first_frame" || media[1].(map[string]any)["type"] != "last_frame" { + t.Fatalf("media=%#v", media) + } + request.InputURLs = nil + request.Settings["inputMode"] = "reference" + if _, exists := adapter.payload(request).(map[string]any)["input"].(map[string]any)["media"]; exists { + t.Fatal("Wan 3.0 text-only request should omit media") + } + result := decodeBailian([]byte(`{"output":{"task_id":"task-1","task_status":"SUCCEEDED","video_url":"https://cdn.test/v.mp4"},"usage":{"output_video_duration":5.25,"duration":8}}`)) + if result.Usage["outputMilliseconds"] != 5250 || result.Usage["outputSeconds"] != 6 { + t.Fatalf("usage=%#v", result.Usage) + } +} + func TestSeedance20PayloadMatchesOfficialMultimodalContract(t *testing.T) { client := roundTripFunc(func(request *http.Request) (*http.Response, error) { var body map[string]any diff --git a/backend/internal/providers/volcengine.go b/backend/internal/providers/volcengine.go index d4f3923..26a47d1 100644 --- a/backend/internal/providers/volcengine.go +++ b/backend/internal/providers/volcengine.go @@ -146,11 +146,11 @@ func (v *Volcengine) call(ctx context.Context, action, version string, payload a raw, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) if err != nil { logVolcengineFailure(volcengineDiagnostic{Operation: operation, Status: resp.StatusCode, ErrorClass: "response_read", ElapsedMS: elapsedMilliseconds(startedAt)}) - return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode} + return Result{}, &ProviderError{Operation: "volcengine request", Cause: err} } if int64(len(raw)) > limit { logVolcengineFailure(volcengineDiagnostic{Operation: operation, Status: resp.StatusCode, ErrorClass: "response_too_large", ElapsedMS: elapsedMilliseconds(startedAt)}) - return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode} + return Result{}, &ProviderError{Operation: "volcengine request"} } validJSON := json.Valid(raw) r := map[string]any{} @@ -169,7 +169,7 @@ func (v *Volcengine) call(ctx context.Context, action, version string, payload a if resp.StatusCode < 200 || resp.StatusCode >= 300 { diagnostic.ErrorClass = "service" logVolcengineFailure(diagnostic) - return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode, Code: diagnostic.Code} + return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode, Code: diagnostic.Code, RequestID: diagnostic.RequestID, Detail: safeProviderResponseDetail(raw, body, v.config.AccessKeyID, v.config.SecretAccessKey)} } if !validJSON { diagnostic.ErrorClass = "invalid_json" @@ -179,14 +179,19 @@ func (v *Volcengine) call(ctx context.Context, action, version string, payload a if response.code != nil && !volcengineRequestSucceeded(response.code) { diagnostic.ErrorClass = "service" logVolcengineFailure(diagnostic) - return Result{}, &ProviderError{Operation: "volcengine request", Code: diagnostic.Code} + return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode, Code: diagnostic.Code, RequestID: diagnostic.RequestID, Detail: safeProviderResponseDetail(raw, body, v.config.AccessKeyID, v.config.SecretAccessKey)} } d := object(first(response.business["data"], response.business["Data"])) out := []string{} for _, value := range []any{d["image_urls"], d["image_url"], d["url"], d["result_url"], d["output"], d["outputs"]} { collectURLs(value, &out) } - return Result{TaskID: stringValue(response.business["task_id"], response.business["TaskId"], d["task_id"], d["TaskId"]), Status: status(first(d["status"], d["Status"], response.business["status"], response.business["Status"])), OutputURLs: out, Raw: raw}, nil + result := Result{TaskID: stringValue(response.business["task_id"], response.business["TaskId"], d["task_id"], d["TaskId"]), Status: status(first(d["status"], d["Status"], response.business["status"], response.business["Status"])), OutputURLs: out, Raw: raw} + result = annotateTerminalFailure(result, raw, resp.Header, body, v.config.AccessKeyID, v.config.SecretAccessKey) + if result.Status == StatusFailed && result.ErrorDetail == "" { + result.ErrorDetail = SanitizeFailureDetail(stringValue(d["message"], response.business["message"]), body, v.config.AccessKeyID, v.config.SecretAccessKey) + } + return result, nil } type volcengineResponse struct { @@ -215,11 +220,12 @@ func inspectVolcengineResponse(root map[string]any, headers http.Header) volceng business["code_n"], business["codeN"], business["CodeN"], root["code_n"], root["codeN"], root["CodeN"], ), - requestID: first( + requestID: firstSafeHTTPProviderDiagnosticToken(128, business["request_id"], business["requestId"], business["RequestId"], business["RequestID"], root["request_id"], root["requestId"], root["RequestId"], root["RequestID"], metadata["RequestId"], metadata["RequestID"], metadata["request_id"], metadata["requestId"], headers.Get("X-Tt-Logid"), headers.Get("X-Request-Id"), + requestIDFromMessage(stringValue(gatewayError["Message"], gatewayError["message"], business["message"], root["message"])), ), } } diff --git a/backend/internal/settings/service.go b/backend/internal/settings/service.go index daab92d..4ecf899 100644 --- a/backend/internal/settings/service.go +++ b/backend/internal/settings/service.go @@ -604,8 +604,8 @@ func definitions() []Group { {ID: "visual", Title: "即梦图片 API", Description: "火山 AK/SK。", Fields: []Field{{Key: "VOLCENGINE_ACCESS_KEY_ID", Label: "Access Key ID", Secret: true, Type: "password"}, {Key: "VOLCENGINE_SECRET_ACCESS_KEY", Label: "Secret Access Key", Secret: true, Type: "password"}}}, {ID: "evolink", Title: "EvoLink 图片 API", Description: "GPT Image 2 / 2.5 图片生成。", Fields: []Field{{Key: "EVOLINK_API_KEY", Label: "EvoLink API Key", Secret: true, Type: "password"}, {Key: "EVOLINK_BASE_URL", Label: "Base URL", DefaultValue: "https://api.evolink.ai"}, {Key: "EVOLINK_IMAGE_MODEL", Label: "默认图片模型", DefaultValue: "gpt-image-2"}, {Key: "EVOLINK_IMAGE_QUALITY", Label: "默认质量", DefaultValue: "medium"}}}, {ID: "seedance", Title: "火山方舟 API", Description: "Seedance 视频与 Seedream 5.0 Pro 图片共用此 API Key。", Fields: []Field{{Key: "SEEDANCE_API_KEY", Label: "方舟 API Key", Secret: true, Type: "password"}}}, - {ID: "bailian", Title: "阿里云百炼 API", Description: "万相图片与视频。", Fields: []Field{{Key: "BAILIAN_API_KEY", Label: "百炼 API Key", Secret: true, Type: "password"}, {Key: "BAILIAN_BASE_URL", Label: "Base URL", DefaultValue: "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"}, {Key: "BAILIAN_IMAGE_MODEL", Label: "图片模型", DefaultValue: "wan2.7-image-pro"}, {Key: "BAILIAN_VIDEO_MODEL", Label: "视频模型", DefaultValue: "wan2.7-i2v-2026-04-25"}}}, - {ID: "minimax", Title: "MiniMax API", Description: "MiniMax H3 视频生成。", Fields: []Field{{Key: "MINIMAX_API_KEY", Label: "MiniMax API Key", Secret: true, Type: "password"}, {Key: "MINIMAX_BASE_URL", Label: "Base URL", DefaultValue: "https://api.minimax.cn"}}}, + {ID: "bailian", Title: "阿里云百炼 API", Description: "万相 2.7 图片与首尾帧视频、万相 3.0 多图参考视频;API Key 和地址须与模型开通地域一致。", Fields: []Field{{Key: "BAILIAN_API_KEY", Label: "百炼 API Key", Secret: true, Type: "password"}, {Key: "BAILIAN_BASE_URL", Label: "Base URL", DefaultValue: "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"}, {Key: "BAILIAN_IMAGE_MODEL", Label: "图片模型", DefaultValue: "wan2.7-image-pro"}, {Key: "BAILIAN_VIDEO_MODEL", Label: "视频模型", DefaultValue: "wan2.7-i2v-2026-04-25"}}}, + {ID: "minimax", Title: "MiniMax API", Description: "MiniMax H3 文生视频、单首帧视频和最多 9 张图片的多图参考视频。", Fields: []Field{{Key: "MINIMAX_API_KEY", Label: "MiniMax API Key", Secret: true, Type: "password"}, {Key: "MINIMAX_BASE_URL", Label: "Base URL", DefaultValue: "https://api.minimax.cn"}}}, {ID: "oss", Title: "OSS 资产存储", Description: "私有桶通过后端签名访问。", Fields: []Field{{Key: "ALI_OSS_ENDPOINT", Label: "Endpoint"}, {Key: "ALI_OSS_BUCKET", Label: "Bucket"}, {Key: "ALI_OSS_ACCESS_KEY_ID", Label: "Access Key ID", Secret: true, Type: "password"}, {Key: "ALI_OSS_ACCESS_KEY_SECRET", Label: "Access Key Secret", Secret: true, Type: "password"}, {Key: "ALI_OSS_PUBLIC_BASE_URL", Label: "Bucket Base URL(无需公共读)"}}}, } } @@ -618,7 +618,7 @@ var fieldIndex = func() map[string]Field { } } result["IMAGE_GENERATE_ENGINE"] = Field{Key: "IMAGE_GENERATE_ENGINE", Label: "图片生成", Type: "select", DefaultValue: "jimeng", Options: []Option{{Label: "即梦 4.6 / 火山视觉", Value: "jimeng"}, {Label: "Seedream 5.0 Pro / 火山方舟", Value: "seedream"}, {Label: "EvoLink GPT Image 2", Value: "evolink"}, {Label: "阿里云百炼 Wan 2.7", Value: "bailian"}}} - result["VIDEO_GENERATE_ENGINE"] = Field{Key: "VIDEO_GENERATE_ENGINE", Label: "视频生成", Type: "select", DefaultValue: "bailian", Options: []Option{{Label: "Seedance", Value: "seedance"}, {Label: "MiniMax H3", Value: "minimax"}, {Label: "阿里云百炼 Wan 2.7", Value: "bailian"}}} + result["VIDEO_GENERATE_ENGINE"] = Field{Key: "VIDEO_GENERATE_ENGINE", Label: "视频生成", Type: "select", DefaultValue: "bailian", Options: []Option{{Label: "Seedance", Value: "seedance"}, {Label: "MiniMax H3", Value: "minimax"}, {Label: "阿里云百炼 Wan 2.7 / 3.0", Value: "bailian"}}} return result }() diff --git a/components/account-manager.tsx b/components/account-manager.tsx index 4688785..44d31bd 100644 --- a/components/account-manager.tsx +++ b/components/account-manager.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { Building2, KeyRound, Loader2, Pencil, Plus, RefreshCw, ShieldCheck, Trash2, UserPlus } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Building2, KeyRound, Pencil, Plus, RefreshCw, ShieldCheck, Trash2, UserPlus } from "lucide-react"; import { AccountSecurityPanel } from "@/components/account-security-panel"; type Role = "super_admin" | "organization_admin" | "user"; @@ -32,6 +32,8 @@ type AccountManagerProps = { canManageAccounts: boolean; }; +type MemberGroup = { id: string; name: string; status?: Status; members: Member[] }; + const roleLabels: Record = { super_admin: "超级管理员", organization_admin: "组织管理员", @@ -47,36 +49,68 @@ export function AccountManager({ canManageAccounts }: AccountManagerProps) { const [selectedOrganizationId, setSelectedOrganizationId] = useState(""); const [form, setForm] = useState({ phone: "", displayName: "", password: "", role: "user" as Role }); const [organizationName, setOrganizationName] = useState(""); + const [accountsLoadFailed, setAccountsLoadFailed] = useState(false); + const accountsRequestVersion = useRef(0); + const membersPanelRef = useRef(null); const selectedOrganization = useMemo( () => payload?.organizations.find((organization) => organization.id === selectedOrganizationId), [payload?.organizations, selectedOrganizationId] ); - const members = payload?.members || []; + const memberGroups = useMemo(() => { + if (!payload) return []; + const groups: MemberGroup[] = payload.organizations + .filter((organization) => !payload.currentOrganizationId || organization.id === payload.currentOrganizationId) + .map((organization) => ({ ...organization, members: [] })); + const byOrganization = new Map(groups.map((group) => [group.id, group])); + for (const member of payload.members) { + const organizationId = member.organizationId || ""; + let group = byOrganization.get(organizationId); + if (!group) { + group = { id: organizationId, name: organizationId ? `未知组织(${organizationId})` : "未归属组织", members: [] }; + byOrganization.set(organizationId, group); + groups.push(group); + } + group.members.push(member); + } + return groups; + }, [payload]); async function loadAccounts(organizationId = selectedOrganizationId) { if (!canManageAccounts) return; + const requestVersion = ++accountsRequestVersion.current; setLoading(true); + setAccountsLoadFailed(false); setError(null); try { const query = organizationId ? `?organizationId=${encodeURIComponent(organizationId)}` : ""; const response = await fetch(`/api/admin/accounts${query}`, { cache: "no-store" }); const next = await response.json() as AccountPayload & { error?: string }; + if (requestVersion !== accountsRequestVersion.current) return; if (!response.ok) throw new Error(next.error || "账号列表加载失败。"); setPayload(next); setSelectedOrganizationId(next.currentOrganizationId || ""); } catch (requestError) { + if (requestVersion !== accountsRequestVersion.current) return; + setAccountsLoadFailed(true); setError(requestError instanceof Error ? requestError.message : String(requestError)); } finally { - setLoading(false); + if (requestVersion === accountsRequestVersion.current) setLoading(false); } } useEffect(() => { if (canManageAccounts) void loadAccounts(); else setLoading(false); + return () => { accountsRequestVersion.current += 1; }; }, [canManageAccounts]); + function viewOrganizationAccounts(organizationId: string) { + setSelectedOrganizationId(organizationId); + void loadAccounts(organizationId); + membersPanelRef.current?.scrollIntoView({ block: "start", behavior: "smooth" }); + } + async function createUser(event: React.FormEvent) { event.preventDefault(); setSaving(true); @@ -259,6 +293,7 @@ export function AccountManager({ canManageAccounts }: AccountManagerProps) {
{organization.name}{organization.status === "active" ? "启用" : "停用"}
+ @@ -270,10 +305,11 @@ export function AccountManager({ canManageAccounts }: AccountManagerProps) {
-
+
-

{selectedOrganization?.name || "全平台账号"}

+

{selectedOrganization ? `${selectedOrganization.name}的账号` : "按组织查看账号"}

+

{payload?.canManageOrganizations ? "按组织展示账号,包含已停用账号。" : "显示本组织内可管理的普通用户账号。"}

- {loading ? : members.length ? ( -
-
账号角色状态操作
- {members.map((member) => ( -
-
- -
{member.displayName}{member.phone}
-
-
{payload?.canAssignOrganizationAdmin ? : {roleLabels[member.role]}}
-
{member.status === "active" ? "启用" : "停用"}
-
- - - -
-
+ {loading ? : accountsLoadFailed ? ( +
账号列表加载失败。
+ ) : memberGroups.length ? ( +
+ {memberGroups.map((group) => ( +
+
+

+ {group.status === "disabled" ? 组织已停用 : null} + {group.members.length} 个{payload?.canManageOrganizations ? "账号" : "普通账号"} +
+ {group.members.length ?
+
账号角色状态操作
+ {group.members.map((member) => ( +
+
+ +
{member.displayName}{member.phone}
+
+
{payload?.canAssignOrganizationAdmin ? : {roleLabels[member.role]}}
+
{member.status === "active" ? "启用" : "停用"}
+
+ + + +
+
+ ))} +
:

{payload?.canManageOrganizations ? "该组织暂无账号。" : "本组织暂无可管理的普通用户账号。"}

} +
))}
- ) :
当前组织还没有账号。
} + ) :
暂无组织或账号。
}
) : null} diff --git a/components/asset-manager.tsx b/components/asset-manager.tsx index 69c9c90..4e9a0b4 100644 --- a/components/asset-manager.tsx +++ b/components/asset-manager.tsx @@ -6,6 +6,8 @@ import { clampPage, pageItems, Pagination } from "@/components/pagination"; import { modalEnter, modalExit, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; import { formatBillingAmount } from "@/lib/billing"; import { assetPreviewUrl } from "@/lib/client/asset-urls"; +import { GenerationError } from "@/components/generation-error"; +import { useBrowserAuth } from "@/components/browser-auth"; import type { Asset, GenerationJob } from "@/lib/types"; type AssetView = "assets" | "tasks"; @@ -18,6 +20,7 @@ type AssetManagerProps = { }; export function AssetManager({ initialView = "assets", initialTaskId }: AssetManagerProps = {}) { + const { isAdmin } = useBrowserAuth(); const [assets, setAssets] = useState([]); const [jobs, setJobs] = useState([]); const [view, setView] = useState(initialView === "tasks" ? "tasks" : "assets"); @@ -280,7 +283,7 @@ export function AssetManager({ initialView = "assets", initialTaskId }: AssetMan
- {expandedJobId === job.id ? : null} + {expandedJobId === job.id ? : null} ))} {!jobs.length ?
暂无生成任务。提交图片或视频生成后会保留任务历史。
: null} @@ -316,7 +319,7 @@ export function AssetManager({ initialView = "assets", initialTaskId }: AssetMan ); } -function JobDetails({ job, now, outputAssetIds }: { job: GenerationJob; now: number; outputAssetIds: Set }) { +function JobDetails({ job, now, outputAssetIds, isAdmin }: { job: GenerationJob; now: number; outputAssetIds: Set; isAdmin: boolean }) { return (
@@ -360,13 +363,8 @@ function JobDetails({ job, now, outputAssetIds }: { job: GenerationJob; now: num
扣费
{billingLabel(job)}
- {job.error ? ( -
-
错误
-
{job.error.message}
-
- ) : null} + {job.prompt ? (
提示词 diff --git a/components/create-studio.tsx b/components/create-studio.tsx index b227d1b..61f61bd 100644 --- a/components/create-studio.tsx +++ b/components/create-studio.tsx @@ -5,6 +5,9 @@ import { Check, CircleDollarSign, Download, Film, ImageIcon, ImagePlus, Info, La import clsx from "clsx"; import { clampPage, pageItems, Pagination } from "@/components/pagination"; import { SeedreamWorkspace } from "@/components/seedream-workspace"; +import { CreationBalance } from "@/components/creation-balance"; +import { GenerationError } from "@/components/generation-error"; +import { useBrowserAuth } from "@/components/browser-auth"; import { imageSizePresets, imageDimensionsForEngine, imageSizePresetFromDimensions, type ImageSizePreset } from "@/lib/image-size-presets"; import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, configuredImageModel, evolinkModelCapability, imageModelAfterHealth, imageModelFromJob, imageModelLabel, templateImageModel } from "@/lib/image-models"; import { SeedreamLayerResultViewer, type SeedreamLayerOutput } from "@/components/seedream-layer-result-viewer"; @@ -44,6 +47,7 @@ import { type SeedreamCreationMode } from "@/lib/seedream/creation"; import { hasCompleteSeedreamLayerMetadata } from "@/lib/seedream/layer-composition"; +import { BAILIAN_27_VIDEO_MODEL, BAILIAN_30_VIDEO_MODEL, BAILIAN_VIDEO_MODELS, videoImageFileError, videoInputHint, videoMaterialError, type VideoInputMode } from "@/lib/video-input"; import type { BillingQuote } from "@/lib/types"; type GenerateMode = "image" | "video"; @@ -191,6 +195,7 @@ const defaultTemplateForm: TemplateForm = { }; export function CreateStudio({ initialMode = "image" }: { initialMode?: GenerateMode }) { + const { isAdmin } = useBrowserAuth(); const [mode, setMode] = useState(initialMode); const [promptByMode, setPromptByMode] = useState>({ image: "", @@ -218,8 +223,15 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate const [templateRestoreState, setTemplateRestoreState] = useState(null); const [busy, setBusy] = useState(false); const [billingQuote, setBillingQuote] = useState(null); + const [billingQuoteResolved, setBillingQuoteResolved] = useState(false); const [billingQuoteLoading, setBillingQuoteLoading] = useState(false); const [billingQuoteError, setBillingQuoteError] = useState(null); + const [billingQuoteRetry, setBillingQuoteRetry] = useState(0); + const [balanceRefreshVersion, setBalanceRefreshVersion] = useState(0); + const balanceRefreshKey = useMemo(() => JSON.stringify([ + balanceRefreshVersion, + recentJobs.map((job) => [job.id, job.status, job.updatedAt, job.billing?.status, job.billing?.settlementStatus, job.billing?.amountFen]) + ]), [recentJobs, balanceRefreshVersion]); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -241,6 +253,8 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate const [seedreamLayerAnnotations, setSeedreamLayerAnnotations] = useState([]); const [videoEngine, setVideoEngine] = useState("bailian"); const [videoModel, setVideoModel] = useState(SEEDANCE_20_MODEL); + const [bailianVideoModel, setBailianVideoModel] = useState(BAILIAN_27_VIDEO_MODEL); + const [videoInputMode, setVideoInputMode] = useState("frames"); const [videoRatio, setVideoRatio] = useState("9:16"); const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT); const [videoResolution, setVideoResolution] = useState("720p"); @@ -260,7 +274,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate const evolinkModelChosenRef = useRef(false); const generateMode: GenerateMode = mode === "video" ? "video" : "image"; - const pasteContextKey = [generateMode, imageCreationMode, imageEngine, evolinkModel, videoEngine, videoModel, activeTemplateId, templateEditorOpen, previewTemplate?.id, taskDetailJobId].join("|"); + const pasteContextKey = [generateMode, imageCreationMode, imageEngine, evolinkModel, videoEngine, videoModel, bailianVideoModel, videoInputMode, activeTemplateId, templateEditorOpen, previewTemplate?.id, taskDetailJobId].join("|"); const pasteContextRef = useRef(pasteContextKey); pasteContextRef.current = pasteContextKey; const prompt = promptByMode[generateMode]; @@ -302,8 +316,12 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate ); const specializedImageMode = generateMode === "image" && imageCreationMode !== "basic"; const minimaxVideoMode = generateMode === "video" && videoEngine === "minimax"; - const minimaxFirstFrameSelected = minimaxVideoMode && materials.length === 1 && materials[0]?.type === "image"; - const minimaxMaterialsValid = !minimaxVideoMode || materials.length <= 1 && materials.every((material) => material.type === "image"); + const effectiveVideoInputMode: VideoInputMode = videoEngine === "seedance" + ? "reference" + : videoEngine === "bailian" && bailianVideoModel === BAILIAN_27_VIDEO_MODEL ? "frames" : videoInputMode; + const videoSelection = { engine: videoEngine, model: videoEngine === "seedance" ? videoModel : videoEngine === "bailian" ? bailianVideoModel : "MiniMax-H3", inputMode: effectiveVideoInputMode }; + const videoInputError = generateMode === "video" ? videoMaterialError(videoSelection, materials) : null; + const minimaxFirstFrameSelected = minimaxVideoMode && effectiveVideoInputMode === "frames" && materials.length === 1 && materials[0]?.type === "image"; const unsupportedSpecializedPlaceholders = useMemo( () => specializedImageMode ? materialPlaceholders.filter((placeholder) => ( placeholder.type !== "image" @@ -336,11 +354,20 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate && unsupportedSpecializedPlaceholders.length === 0 && missingMaterialPlaceholders.length === 0 && missingAnnotationPlaceholders.length === 0 - && minimaxMaterialsValid; - const submitDisabled = busy || uploading || !generationInputReady; - const submitTitle = uploading ? "素材上传或导入中,请稍候" - : !minimaxMaterialsValid - ? "MiniMax H3 第一版仅支持 0 或 1 张图片素材" + && !videoInputError; + const videoSubmitBlockReason = generateMode !== "video" ? null + : uploading ? "素材正在上传或导入,请稍候。" + : busy ? "任务正在提交,请稍候。" + : videoInputError + ? videoInputError + : !prompt.trim() ? "请先填写视频内容提示词。" + : missingMaterialPlaceholders.length ? `请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}。` + : billingQuoteError ? `暂时无法生成:${billingQuoteError}` + : billingQuoteLoading || !billingQuoteResolved ? "正在计算费用,请稍候。" : null; + const submitDisabled = busy || uploading || !generationInputReady || Boolean(videoSubmitBlockReason); + const submitTitle = videoSubmitBlockReason || (uploading ? "素材上传或导入中,请稍候" + : videoInputError + ? videoInputError : unsupportedSpecializedPlaceholders.length ? `交互编辑和图层拆分不支持 ${unsupportedSpecializedPlaceholders.map((placeholder) => placeholder.token).join("、")}` : missingMaterialPlaceholders.length @@ -351,7 +378,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate ? "请先选择一张底图" : annotationRequired ? "请先框选或点选编辑位置" - : submitActionLabel(generateMode, imageCreationMode); + : submitActionLabel(generateMode, imageCreationMode)); const visibleMaterials = pageItems(materials, materialPage, MATERIAL_PAGE_SIZE); const materialPageOffset = (clampPage(materialPage, materials.length, MATERIAL_PAGE_SIZE) - 1) * MATERIAL_PAGE_SIZE; const mentionSuggestions = useMemo(() => { @@ -448,10 +475,17 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate if (videoCapability.engine === "seedance") { const nextModel = normalizeSeedanceModel(videoCapability.reqKey); setVideoModel(nextModel); + setVideoInputMode("reference"); setVideoDuration((duration) => clampVideoDuration(duration, VIDEO_DURATION_DEFAULT, { allowAuto: false, model: nextModel })); } else if (videoCapability.engine === "minimax") { + setVideoInputMode("frames"); setVideoDuration((duration) => Math.min(15, Math.max(4, duration))); setVideoResolution("768P"); + } else { + const nextModel = videoCapability.reqKey === BAILIAN_30_VIDEO_MODEL ? BAILIAN_30_VIDEO_MODEL : BAILIAN_27_VIDEO_MODEL; + setBailianVideoModel(nextModel); + setVideoInputMode(nextModel === BAILIAN_30_VIDEO_MODEL ? "reference" : "frames"); + setVideoDuration((duration) => Math.min(nextModel === BAILIAN_30_VIDEO_MODEL ? 30 : 15, Math.max(2, duration))); } } }) @@ -503,9 +537,16 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate useEffect(() => { let active = true; + if (generateMode === "video" && generationInputReady) { + setBillingQuote(null); + setBillingQuoteResolved(false); + setBillingQuoteLoading(true); + setBillingQuoteError(null); + } const timer = window.setTimeout(async () => { if (!generationInputReady) { if (active) setBillingQuote(null); + if (active) setBillingQuoteResolved(false); if (active) setBillingQuoteError(null); if (active) setBillingQuoteLoading(false); return; @@ -520,18 +561,22 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate }); const payload = await response.json().catch(() => ({})); if (active) { - if (response.ok && payload.quote) { - setBillingQuote(payload.quote as BillingQuote); + if (response.ok && (payload.quote === null || payload.quote)) { + setBillingQuote(payload.quote as BillingQuote | null); + setBillingQuoteResolved(true); setBillingQuoteError(null); } else { setBillingQuote(null); - setBillingQuoteError(typeof payload.error === "string" ? payload.error : "计费估算暂不可用,请检查服务配置"); + setBillingQuoteResolved(false); + const reason = typeof payload.error === "string" ? payload.error : "该模型暂不可用,请联系管理员配置或切换模型。"; + setBillingQuoteError(generateMode === "video" ? friendlyVideoServiceError(reason) : reason); } } } catch { if (active) { setBillingQuote(null); - setBillingQuoteError("计费估算暂不可用,请检查后端服务配置"); + setBillingQuoteResolved(false); + setBillingQuoteError("暂时无法连接服务,请稍后重试;若持续发生,请联系管理员。"); } } finally { if (active) setBillingQuoteLoading(false); @@ -541,7 +586,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate active = false; window.clearTimeout(timer); }; - }, [generateMode, imageCreationMode, imageEngine, evolinkModel, imageSize.height, imageSize.width, seedreamInteractiveSourceKeys, seedreamRequestAnnotations, seedreamLayerSize, seedreamOptimizeMode, seedreamOutputFormat, seedreamSize, seedreamSourceKey, selectedEvolinkQuality.quality, selectedJimengInfluence.scale, videoDuration, videoEngine, videoModel, videoRatio, videoResolution, materials, generationInputReady, prompt]); + }, [generateMode, imageCreationMode, imageEngine, evolinkModel, imageSize.height, imageSize.width, seedreamInteractiveSourceKeys, seedreamRequestAnnotations, seedreamLayerSize, seedreamOptimizeMode, seedreamOutputFormat, seedreamSize, seedreamSourceKey, selectedEvolinkQuality.quality, selectedJimengInfluence.scale, videoDuration, videoEngine, videoModel, bailianVideoModel, videoInputMode, videoRatio, videoResolution, materials, generationInputReady, prompt, billingQuoteRetry]); async function loadImageTemplates(isActive: () => boolean = () => true) { setTemplatesLoading(true); @@ -679,7 +724,10 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate method: "POST", body: formData }); - const payload = await response.json(); + const payload = await response.json().catch(() => ({})); + if (response.status === 413) { + throw new Error("本次素材总大小超出平台单次上传限制;请分批上传,或压缩文件后重试。"); + } if (!response.ok) throw new Error(payload.error || "上传失败"); if (!Array.isArray(payload.assets) || payload.assets.length !== files.length) { throw new Error("上传结果不完整,请刷新素材后重试。"); @@ -715,19 +763,22 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate } if (selectedSeedreamSource && !window.confirm("替换当前待拆分图片吗?当前标注会清空,原图片仍保留在素材中。")) return; } - } else if (minimaxVideoMode) { - options.maxFiles = 1; - options.replaceAll = true; - if (materials.length && !window.confirm("MiniMax H3 只支持一张首帧图,是否替换当前素材?")) return; - } else if ((generateMode === "image" && imageEngine === "seedream") || (generateMode === "video" && videoEngine === "bailian")) { - const limit = generateMode === "image" ? 10 : 2; + } else if (generateMode === "image" && imageEngine === "seedream") { + const limit = 10; const remaining = Math.max(0, limit - materials.filter((material) => material.type === "image").length); if (!remaining) { - setError(generateMode === "image" ? "Seedream 5.0 Pro 最多支持 10 张参考图,请先移除图片。" : "百炼图生视频最多支持 2 张图片,请先移除图片。"); + setError("Seedream 5.0 Pro 最多支持 10 张参考图,请先移除图片。"); return; } options.maxFiles = remaining; } + if (generateMode === "video") { + const nextInputError = videoMaterialError(videoSelection, [...materials, ...Array.from({ length: imageURL ? 1 : files.length }, () => ({ type: "image" as const }))]); + if (nextInputError) { + setError(nextInputError); + return; + } + } const textarea = event.target === promptRef.current ? promptRef.current : null; options.paste = { mode: generateMode, @@ -784,6 +835,22 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate setError("Seedream 5.0 Pro 图层拆分只支持 PNG 或 JPEG 输入图片。"); return; } + if (generateMode === "video") { + const incoming = selectedFiles.map((file) => ({ type: file.type.startsWith("video/") ? "video" as const : file.type.startsWith("audio/") ? "audio" as const : "image" as const })); + const nextInputError = videoMaterialError(videoSelection, [...materialsRef.current, ...incoming]); + if (nextInputError) { + setError(nextInputError); + return; + } + for (const file of selectedFiles) { + if (!file.type.startsWith("image/") && !/\.(jpe?g|png|webp|heic|heif|bmp)$/i.test(file.name)) continue; + const fileError = videoImageFileError(videoSelection, file); + if (fileError) { + setError(fileError); + return; + } + } + } await receiveMaterialAssets(() => uploadAssetFiles(selectedFiles), options, allFiles.length); } @@ -821,6 +888,10 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate } next.push(material); }); + if (generateMode === "video") { + const nextInputError = videoMaterialError(videoSelection, next); + if (nextInputError) throw new Error(nextInputError); + } setMaterials(next); if (options.paste?.insertReferences) { const pasted = options.paste; @@ -1263,11 +1334,12 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate kind: "video", capability: "video.generate", engine: videoEngine, - ...(videoEngine === "seedance" ? { model: videoModel } : {}), + ...(videoEngine === "seedance" ? { model: videoModel } : videoEngine === "bailian" ? { model: bailianVideoModel } : {}), prompt, materials, settings: { - ...(videoEngine === "seedance" || videoEngine === "minimax" + inputMode: effectiveVideoInputMode, + ...(videoEngine === "seedance" || videoEngine === "minimax" || (videoEngine === "bailian" && bailianVideoModel === BAILIAN_30_VIDEO_MODEL) ? { ratio: videoEngine === "minimax" && minimaxFirstFrameSelected ? "adaptive" : videoRatio } : {}), duration: videoDuration, @@ -1321,17 +1393,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate if (generateMode === "image" && imageCreationMode === "interactive" && selectedSeedreamSources.length > 10) { throw new Error("Seedream 5.0 Pro 交互编辑最多支持 10 张输入图片。"); } - if (generateMode === "video" && videoEngine === "bailian") { - const imageCount = materials.filter((material) => material.type === "image").length; - if (imageCount < 1 || imageCount > 2 || materials.some((material) => material.type !== "image")) { - throw new Error("百炼图生视频请上传 1 张首帧图,或按顺序上传 2 张首尾帧图。"); - } - } - if (generateMode === "video" && videoEngine === "minimax") { - if (materials.length > 1 || materials.some((material) => material.type !== "image")) { - throw new Error("MiniMax H3 第一版支持纯文字生成,或只上传 1 张图片作为首帧。"); - } - } + if (videoInputError) throw new Error(videoInputError); const markedMaterials = generateMode === "image" && imageCreationMode !== "basic" ? await prepareMarkedSeedreamMaterials() : undefined; @@ -1342,7 +1404,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate body: JSON.stringify(body) }); const payload = await response.json(); - if (!response.ok) throw new Error(payload.error || "提交生成失败"); + if (!response.ok) throw new Error(generateMode === "video" ? friendlyVideoServiceError(payload.error || "提交生成失败") : payload.error || "提交生成失败"); const submittedJob = payload.job as GenerationJob | undefined; if (submittedJob?.id) { setRecentJobs((items) => [submittedJob, ...items.filter((item) => item.id !== submittedJob.id)].sort((a, b) => b.createdAt.localeCompare(a.createdAt))); @@ -1361,6 +1423,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); + setBalanceRefreshVersion((value) => value + 1); } } @@ -1378,6 +1441,10 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
+ {generateMode === "video" && videoSubmitBlockReason ? ( + {videoSubmitBlockReason} + ) : null} + {generateMode === "video" && !videoSubmitBlockReason && error ? {error} : null} : null} + {(videoEngine === "minimax" || videoEngine === "bailian" && bailianVideoModel === BAILIAN_30_VIDEO_MODEL) ? ( +
+ 参考图格式要求 + {videoEngine === "minimax" + ?

MiniMax H3:JPG、PNG、WEBP、HEIC、HEIF;模型单张上限 30 MB,宽高均为 256–5760 像素,宽高比 0.4–2.5。上传还受平台单次容量限制;多张图可分批上传。

+ :

Wan 3.0:JPG、PNG、BMP、WEBP;模型单张上限 20 MB,宽高均为 240–8000 像素。PNG 需为不透明图片;上传还受平台单次容量限制,多张图可分批上传。

} +
+ ) : null} +
+ ) : null} {templateEditorOpen ? (
@@ -2148,6 +2260,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate job={selectedTask} assets={taskAssetById} now={durationNow} + isAdmin={isAdmin} onClose={() => setTaskDetailJobId(null)} /> ) : null} @@ -2216,11 +2329,13 @@ function TaskDetailModal({ job, assets, now, + isAdmin, onClose }: { job: GenerationJob; assets: Map; now: number; + isAdmin: boolean; onClose: () => void; }) { const outputAssets = job.outputAssetIds @@ -2279,7 +2394,7 @@ function TaskDetailModal({ ) : (
{isPendingTask(job) ? : } - {isPendingTask(job) ? "任务完成后,结果会显示在这里" : job.error?.message || "该任务没有可下载的结果"} + {isPendingTask(job) ? "任务完成后,结果会显示在这里" : "该任务没有可下载的结果"}
)} @@ -2359,12 +2474,7 @@ function TaskDetailModal({
- {job.error ? ( -
- 任务信息 - {job.error.message} -
- ) : null} + @@ -2984,3 +3094,11 @@ function shortTypeName(type: MaterialKind) { if (type === "audio") return "音频"; return "图片"; } + +function friendlyVideoServiceError(reason: string): string { + if (/余额|balance|图片|素材|时长|分辨率|比例|超限|过大|格式|上传/i.test(reason)) return reason; + if (/not configured|api.?key|environment|env\b|unauthorized|forbidden|permission|price rule|pricing|未配置|无权限|未开通|模型不可用|model.*(unavailable|unsupported|not found)/i.test(reason)) { + return "该模型暂不可用,请联系管理员检查模型权限或价格配置,或切换其他模型。"; + } + return reason; +} diff --git a/components/creation-balance.tsx b/components/creation-balance.tsx new file mode 100644 index 0000000..0573ccb --- /dev/null +++ b/components/creation-balance.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Loader2, RefreshCw, WalletCards } from "lucide-react"; +import clsx from "clsx"; +import { useBrowserAuth } from "@/components/browser-auth"; +import { formatBillingAmount } from "@/lib/billing"; +import { fetchOrganizationBalance, type OrganizationBalance } from "@/lib/client/organization-balance"; + +type BalanceState = { + scope: string; + balance: OrganizationBalance | null; + loading: boolean; + error: string | null; +}; + +export function CreationBalance({ refreshKey, estimatedAmountFen }: { refreshKey: string; estimatedAmountFen?: number }) { + const { user, status, isSuperAdmin } = useBrowserAuth(); + const organizationId = user?.organizationId || ""; + const scope = `${user?.id || ""}:${organizationId}:${isSuperAdmin}`; + const [retry, setRetry] = useState(0); + const [state, setState] = useState({ scope: "", balance: null, loading: false, error: null }); + + useEffect(() => { + if (status !== "ready" || isSuperAdmin || !organizationId) return; + let active = true; + let controller: AbortController | null = null; + const refresh = async () => { + controller?.abort(); + const request = new AbortController(); + controller = request; + setState((previous) => ({ scope, balance: previous.scope === scope ? previous.balance : null, loading: true, error: null })); + try { + const balance = await fetchOrganizationBalance(organizationId, request.signal); + if (active && !request.signal.aborted) setState({ scope, balance, loading: false, error: null }); + } catch (cause) { + if (active && !request.signal.aborted) { + setState({ scope, balance: null, loading: false, error: cause instanceof Error ? cause.message : "余额读取失败,请重试。" }); + } + } + }; + const refreshWhenVisible = () => { + if (document.visibilityState === "visible") void refresh(); + }; + void refresh(); + const timer = window.setInterval(refreshWhenVisible, 30000); + window.addEventListener("focus", refreshWhenVisible); + document.addEventListener("visibilitychange", refreshWhenVisible); + return () => { + active = false; + controller?.abort(); + window.clearInterval(timer); + window.removeEventListener("focus", refreshWhenVisible); + document.removeEventListener("visibilitychange", refreshWhenVisible); + }; + }, [scope, organizationId, status, isSuperAdmin, refreshKey, retry]); + + if (status !== "ready") return 正在读取余额…; + if (isSuperAdmin) { + return ; + } + if (!organizationId) { + return ; + } + const current = state.scope === scope ? state : null; + const balance = current?.balance; + const loading = !current || current.loading; + const error = current?.error; + const insufficient = balance && (balance.wallet.balanceFen <= 0 || estimatedAmountFen !== undefined && balance.wallet.balanceFen < estimatedAmountFen); + const amount = balance ? formatBillingAmount(balance.wallet.balanceFen) : error ? "暂不可用" : "读取中…"; + const description = error || (insufficient + ? "组织余额不足,请联系管理员充值;点击查看余额明细。" + : `${balance?.organization.name || user?.organizationName || "当前组织"}的共享可用余额,图片和视频共用;点击查看余额明细。`); + + return ( +
+ + + + {insufficient ? 余额不足,请联系管理员充值 : null} +
+ ); +} diff --git a/components/generation-error.tsx b/components/generation-error.tsx new file mode 100644 index 0000000..f0c68f6 --- /dev/null +++ b/components/generation-error.tsx @@ -0,0 +1,38 @@ +"use client"; + +import type { GenerationJob } from "@/lib/types"; +import { generationErrorMessage, providerErrorDiagnostic } from "@/lib/client/provider-error"; + +export function GenerationError({ job, isAdmin, providerName }: { + job: GenerationJob; + isAdmin: boolean; + providerName: string; +}) { + if (!job.error) return null; + const diagnostic = isAdmin ? providerErrorDiagnostic(job) : null; + const isFailed = job.status === "failed" || job.status === "expired"; + const entries = diagnostic ? [ + ["服务商", providerName], + ["HTTP 状态", diagnostic.status], + ["错误码", diagnostic.code], + ["请求 ID", diagnostic.requestId], + ["阶段", diagnostic.phase === "submit" ? "提交" : diagnostic.phase === "query" ? "查询" : undefined], + ["错误类型", diagnostic.kind], + ["服务商说明", diagnostic.detail] + ].filter((entry): entry is [string, string | number] => entry[1] !== undefined && entry[1] !== "") : []; + + return ( +
+ {isFailed ? "失败原因" : "任务提示"} +

{generationErrorMessage(job)}

+ {isAdmin && entries.length > 0 ? ( +
+ 错误详情 +
+ {entries.map(([label, value]) =>
{label}
{value}
)} +
+
+ ) : null} +
+ ); +} diff --git a/contracts/billing/http-v1.json b/contracts/billing/http-v1.json index c5620a1..f94cf05 100644 --- a/contracts/billing/http-v1.json +++ b/contracts/billing/http-v1.json @@ -4,6 +4,7 @@ "moneyUnit": "fen", "routes": [ { "method": "GET", "path": "/api/billing", "requirement": "app", "organizationSource": "refreshed_session", "unboundStatus": 422 }, + { "method": "GET", "path": "/api/billing/balance", "requirement": "app", "organizationSource": "refreshed_session", "unboundStatus": 422, "cacheControl": "no-store", "responseFields": ["organization", "wallet"] }, { "method": "POST", "path": "/api/billing/quote", "requirement": "app", "accountSource": "refreshed_session" }, { "method": "GET", "path": "/api/admin/billing", "requirement": "super_admin" }, { "method": "PATCH", "path": "/api/admin/billing/account", "requirement": "super_admin", "configuration": "injected_store" }, diff --git a/contracts/http/route-surface.v1.json b/contracts/http/route-surface.v1.json index 92c6696..6fc93db 100644 --- a/contracts/http/route-surface.v1.json +++ b/contracts/http/route-surface.v1.json @@ -34,6 +34,7 @@ { "method": "POST", "path": "/api/auth/password" }, { "method": "POST", "path": "/api/auth/password/change" }, { "method": "GET", "path": "/api/billing" }, + { "method": "GET", "path": "/api/billing/balance" }, { "method": "POST", "path": "/api/billing/quote" }, { "method": "GET", "path": "/api/generations/image" }, { "method": "POST", "path": "/api/generations/image" }, diff --git a/contracts/providers/http-v1.json b/contracts/providers/http-v1.json index e64d4ea..8fc4cb9 100644 --- a/contracts/providers/http-v1.json +++ b/contracts/providers/http-v1.json @@ -3,10 +3,10 @@ "providers": { "volcengine-visual": {"model": "jimeng_seedream46_cvtob", "submitAction": "JimengSeedream46CVToBSubmitTask", "queryAction": "JimengSeedream46CVToBGetResult", "version": "2024-06-06", "auth": "HMAC-SHA256"}, "evolink": {"submit": "/v1/images/generations", "query": "/v1/tasks/{id}", "auth": "bearer"}, - "bailian": {"imageSubmit": "/api/v1/services/aigc/image-generation/generation", "videoSubmit": "/api/v1/services/aigc/video-generation/video-synthesis", "query": "/api/v1/tasks/{id}", "auth": "bearer"}, - "seedance": {"model": "doubao-seedance-2-0-260128", "models": ["doubao-seedance-2-0-260128", "doubao-seedance-2-5-260628"], "submit": "/contents/generations/tasks", "query": "/contents/generations/tasks/{id}", "auth": "bearer", "maxMaterials": {"doubao-seedance-2-0-260128": 4, "doubao-seedance-2-5-260628": 50}, "maxDurationSeconds": {"doubao-seedance-2-0-260128": 15, "doubao-seedance-2-5-260628": 30}, "resolutions": ["480p", "720p", "1080p"]}, + "bailian": {"videoModels": ["wan2.7-i2v-2026-04-25", "wan3.0-video"], "videoInputModes": {"wan2.7-i2v-2026-04-25": ["frames"], "wan3.0-video": ["reference", "frames"]}, "maxReferenceImages": {"wan3.0-video": 10}, "imageSubmit": "/api/v1/services/aigc/image-generation/generation", "videoSubmit": "/api/v1/services/aigc/video-generation/video-synthesis", "query": "/api/v1/tasks/{id}", "auth": "bearer"}, + "seedance": {"model": "doubao-seedance-2-0-260128", "models": ["doubao-seedance-2-0-260128", "doubao-seedance-2-5-260628"], "submit": "/contents/generations/tasks", "query": "/contents/generations/tasks/{id}", "auth": "bearer", "maxMaterials": {"doubao-seedance-2-0-260128": 15, "doubao-seedance-2-5-260628": 50}, "maxDurationSeconds": {"doubao-seedance-2-0-260128": 15, "doubao-seedance-2-5-260628": 30}, "resolutions": ["480p", "720p", "1080p"]}, "seedream": {"model": "doubao-seedream-5-0-pro-260628", "submit": "/images/generations", "auth": "bearer", "synchronous": true, "maxReferenceImages": 10, "sizes": ["1K", "1.5K", "2K"], "outputFormats": ["png", "jpeg"], "optimizeModes": ["standard", "fast"]}, - "minimax": {"model": "MiniMax-H3", "submit": "/v2/video_generation", "query": "/v2/query/video_generation/{id}", "auth": "bearer", "modes": ["text-to-video", "first-frame-to-video"], "maxMaterials": 1, "durationSeconds": {"min": 4, "max": 15}, "resolutions": ["768P", "2K"], "ratios": ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "adaptive"]} + "minimax": {"model": "MiniMax-H3", "submit": "/v2/video_generation", "query": "/v2/query/video_generation/{id}", "auth": "bearer", "modes": ["text-to-video", "first-frame-to-video", "reference-to-video"], "maxMaterials": 9, "maxFirstFrameImages": 1, "durationSeconds": {"min": 4, "max": 15}, "resolutions": ["768P", "2K"], "ratios": ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "adaptive"]} }, "errors": {"generic": true, "secretSafe": true}, "liveCallsInTests": false diff --git a/docs/API.md b/docs/API.md index 4e4337f..929962a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -80,14 +80,20 @@ curl -X POST https://你的域名/api/v1/jobs \ | capability | 说明 | | --- | --- | | `image.generate` | 图片生成 | -| `video.generate` | Seedance 视频生成 | +| `video.generate` | Seedance、百炼、MiniMax 视频生成 | ## 计费说明 -开放 API 仍按 API Key 和账号分区运行。当前未绑定组织的开放 API 任务保持兼容,不从组织钱包扣费;平台浏览器用户的真实图片/视频任务会按超级管理员配置的服务商标准单价、计费单位和上浮倍率,从所属组织余额冻结。普通用户余额不足时不会提交服务商并返回余额不足错误;超级管理员仍保存计算费用,但不检查、冻结或扣减组织额度,也不产生钱包扣费、退款流水。计费规则与最终金额会随任务保存,任务失败、取消或过期后由 Worker 在最终终态退款;Seedance 成功后按 `usage.completion_tokens` 多退少补,缺少该字段时保留冻结金额。 +开放 API 仍按 API Key 和账号分区运行。当前未绑定组织的开放 API 任务保持兼容,不从组织钱包扣费;平台浏览器用户的真实图片/视频任务会按超级管理员配置的服务商标准单价、计费单位和上浮倍率,从所属组织余额冻结。普通用户余额不足时不会提交服务商并返回余额不足错误;超级管理员仍保存计算费用,但不检查、冻结或扣减组织额度,也不产生钱包扣费、退款流水。计费规则与最终金额会随任务保存,任务失败、取消或过期后由 Worker 在最终终态退款;Seedance 成功后按 `usage.completion_tokens` 多退少补,缺少该字段时保留冻结金额。MiniMax H3 按实际输出秒数及输入图片数结算;Wan 3.0 按 `usage.output_video_duration` 的实际时长结算(无视频输入时可回退 `usage.duration`),缺少有效用量时保留预扣并标记为估算。 组织余额当前只能由超级管理员通过 `/billing` 的“余额与上账”直接入账。所有充值和人工余额调整都只记入组织账本,不存在个人上账归属;组织管理员和员工共同使用组织额度。未来接入支付时,支付成功回调应使用同一幂等上账逻辑自动入账,不产生待审核申请。余额不足时,平台会拒绝创建真实计费任务并返回错误;未配置真实服务商凭据时也不会生成占位结果。 +创作页通过 `GET /api/billing/balance` 展示当前组织的可用余额。接口仅接受平台会话认证,从服务端刷新后的会话确定组织,忽略客户端组织参数,返回 `organization` 和 `wallet`,不加载账本或价格目录,并设置 `Cache-Control: no-store`。未绑定组织返回 422。页面支持手动刷新,并在提交任务、任务计费状态变化、重新切回页面及可见状态下每 30 秒刷新;超级管理员显示“超管不计额度”。余额提示供用户参考,实际扣费及余额校验仍由服务端执行。 + +生成失败原因统一由 Go 后端处理,覆盖 Seedance、Seedream、百炼、MiniMax、EvoLink 和即梦视觉服务。`job.error.message` 返回中文原因和处理建议;已知错误码优先于 HTTP 状态,未识别的明确拒绝显示通用说明,不能臆测为真人审核或余额不足。提交失败不自动重新提交;网络中断、无效响应等无法确认服务商是否收到请求时保留防重复提交提示。已知服务商任务的临时查询错误可以继续查询,始终保留 `providerTaskId`。 + +平台超级管理员和组织管理员可在任务详情展开“错误详情”,查看 `responsePayload.providerError` 中实际存在的 `phase`、`status`、`code`、`requestId` 和经过脱敏、限长的 `detail`。普通用户及公开 API 不返回该诊断对象;失败任务的原始响应也不会向他们返回,成功任务的结果、用量和图层数据不受影响。新失败任务只持久化安全诊断,不保存原始失败响应。旧任务若已经丢失具体错误码,不能据此恢复原因,需要查询当时的服务日志。 + 首次加载超管计费中心或提交真实任务时会自动补齐内置标准成本目录,默认倍率为 `1.2×`,已有同服务商/能力/模型/变体规则会同步平台维护的标准成本与参数档案,但保留已配置倍率。视频规则按分辨率匹配 `resolution=480p|720p|1080p|4k`;参数化规则按服务下的参数维度选择档位,标准成本为基础成本乘以各档位系数,组合倍率取所选档位中的最高倍率;最终金额使用整数分并向上取整。EvoLink 默认按固定 `1 USD = 7.20 CNY` 换算,并列出质量、分辨率、画面比例和参考图数量档位;即梦 4.6 使用公开资源包折算值作为平台维护的参考标准,实时价格以火山控制台为准。超级管理员只调整倍率,标准成本和参数档案不通过后台修改。 ### 后台统一调整倍率 @@ -235,8 +241,11 @@ Flare 与 Sunburst 各有独立价格规则。按固定汇率 7.2,1K 基础输 ```json { "capability": "video.generate", - "prompt": "生成一条 9:16 品牌短视频,节奏明快,适合信息流投放", + "engine": "bailian", + "model": "wan3.0-video", + "prompt": "以图1的商品为主体,采用图2的场景,生成一条品牌短视频", "settings": { + "inputMode": "reference", "ratio": "9:16", "duration": 5, "resolution": "720p" @@ -246,16 +255,33 @@ Flare 与 Sunburst 各有独立价格规则。按固定汇率 7.2,1K 基础输 "type": "image", "url": "https://example.com/product.png", "label": "@图片1" + }, + { + "type": "image", + "url": "https://example.com/scene.png", + "label": "@图片2" } ] } ``` -视频参数限制: +视频模型和素材限制: -- `duration`:`4` 到 `15` 秒 -- `ratio`:`16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive` -- `resolution`:`480p`、`720p`、`1080p`、`4k` +| engine / model | 素材模式 | 输入限制 | 时长 / 分辨率 | +| --- | --- | --- | --- | +| `seedance` / `doubao-seedance-2-0-260128` | `reference` | 最多 9 图、3 视频、3 音频;音频须搭配图片或视频 | 4–15 秒或 `-1` 自动;480p/720p/1080p | +| `seedance` / `doubao-seedance-2-5-260628` | `reference` | 最多 30 图、10 视频、10 音频,合计 50 个 | 4–30 秒或 `-1` 自动;480p/720p/1080p | +| `bailian` / `wan2.7-i2v-2026-04-25` | `frames` | 1–2 张图片,依次为首帧、尾帧 | 2–15 秒;720p/1080p | +| `bailian` / `wan3.0-video` | `reference` 或 `frames` | 参考模式最多 10 张图片,可不传图;首尾帧模式 1–2 张图片 | 2–30 秒;480p/720p/1080p | +| `minimax` / `MiniMax-H3` | `reference` 或 `frames` | 参考模式最多 9 张图片;首帧模式最多 1 张图片;不传图为文生视频 | 4–15 秒;768P/2K | + +`settings.inputMode` 省略时,旧百炼及 MiniMax 请求继续使用 `frames`,Wan 3.0 和 Seedance 使用 `reference`。`model` 按引擎校验,不能跨服务商混用。模型、模式和素材顺序保存在任务请求中,报价与提交使用同一份参数。 + +`ratio` 支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`;Wan 2.7 由输入图片确定画幅。MiniMax 首帧模式有图时使用 `adaptive`,多图参考可选择画幅,但文生视频不接受 `adaptive`。参考模式不会将第一张图固定为视频开头;首尾帧和多图参考不能在同一次请求中混用。 + +Wan 3.0 本次仅开放文字和图片输入,不接受视频、音频或文档。图片须为 JPG/PNG/BMP/WEBP、单张不超过 20 MB、宽高均为 240–8000 像素,PNG 不可透明;MiniMax 图片须为 JPG/JPEG/PNG/WEBP/HEIC/HEIF、单张不超过 30 MB、宽高均为 256–5760 像素、长宽比 0.4–2.5。远程 URL 的真实文件由服务商校验,数量和类型在平台提交前校验。 + +MiniMax 的实际结算已支持输入图数;预估与预扣也计入第 6 张起的图片费用。Wan 3.0 使用独立的分辨率价格规则,480P/720P/1080P 标准价分别为 ¥0.30/¥0.60/¥1.20 每秒,按官网原价维护,不计限时折扣(来源:[百炼模型价格](https://help.aliyun.com/zh/model-studio/model-pricing),核对日期 2026-10-01)。参考图片不单独收费;可继续通过价格目录统一调整倍率,再单独覆盖某个模型。相关接口依据:[MiniMax H3](https://platform.minimax.io/docs/api-reference/video-generation-v2-create)、[Wan 3.0](https://help.aliyun.com/zh/model-studio/wan3-video-generation-api-reference)、[Seedance](https://docs.volcengine.com/docs/ark/create-video-generation-task-api?lang=zh)。 ## 幂等 diff --git a/lib/bailian/client.ts b/lib/bailian/client.ts index 0bcc1a9..9087754 100644 --- a/lib/bailian/client.ts +++ b/lib/bailian/client.ts @@ -13,6 +13,17 @@ export type BailianTaskResponse = Record & { message?: string; }; +export function bailianOutputVideoSeconds(response: BailianTaskResponse): number | undefined { + const usage = response.usage && typeof response.usage === "object" && !Array.isArray(response.usage) + ? response.usage as Record + : {}; + for (const value of [usage.output_video_duration, usage.duration]) { + const seconds = typeof value === "number" ? value : NaN; + if (Number.isFinite(seconds) && seconds > 0) return seconds; + } + return undefined; +} + export function getBailianConfig() { const compatibleBaseUrl = (process.env.BAILIAN_BASE_URL || BAILIAN_DEFAULT_BASE_URL).replace(/\/+$/, ""); return { @@ -65,25 +76,40 @@ export function validateBailianImageSize(width?: number, height?: number, editin if (ratio < 1 / 8 || ratio > 8) throw new Error("百炼图片宽高比须在 1:8 至 8:1 之间。"); } -export function buildBailianVideoPayload(input: { prompt: string; materials: PromptMaterial[]; origin: string; settings: Record }) { +export function buildBailianVideoPayload(input: { prompt: string; materials: PromptMaterial[]; origin: string; settings: Record; model?: string }) { + const model = input.model || getBailianConfig().videoModel; + const wan30 = model === "wan3.0-video"; + const mode = String(input.settings.inputMode || (wan30 ? "reference" : "frames")); + if (mode !== "frames" && mode !== "reference") throw new Error("百炼请选择首尾帧或多图参考模式。"); + if (!wan30 && mode === "reference") throw new Error("Wan 2.7 仅支持首尾帧;多图参考请选择 Wan 3.0。"); const images = input.materials.filter((item) => item.type === "image"); - if (images.length < 1 || images.length > 2 || input.materials.some((item) => item.type !== "image")) { + if (input.materials.some((item) => item.type !== "image")) { + throw new Error("百炼当前视频模式仅支持图片素材,请移除视频或音频。"); + } + if (mode === "reference" && images.length > 10) { + throw new Error("Wan 3.0 多图参考模式最多支持 10 张图片。"); + } + if (mode === "frames" && (images.length < 1 || images.length > 2)) { throw new Error("百炼图生视频仅支持 1 张首帧图,或 2 张首尾帧图。"); } const duration = numberValue(input.settings.duration) ?? 10; - if (!Number.isInteger(duration) || duration < 2 || duration > 15) throw new Error("百炼视频时长须为 2–15 秒。"); + if (!Number.isInteger(duration) || duration < 2 || duration > (wan30 ? 30 : 15)) throw new Error(`百炼视频时长须为 2–${wan30 ? 30 : 15} 秒。`); const resolution = String(input.settings.resolution || "720P").toUpperCase(); - if (resolution !== "720P" && resolution !== "1080P") throw new Error("百炼视频分辨率仅支持 720P 或 1080P。"); + if (!["720P", "1080P", ...(wan30 ? ["480P"] : [])].includes(resolution)) throw new Error("百炼视频分辨率不支持当前设置。"); + const ratio = input.settings.ratio === undefined ? undefined : String(input.settings.ratio); + if (wan30 && ratio !== undefined && !["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(ratio)) { + throw new Error("Wan 3.0 画面比例支持自适应、21:9、16:9、4:3、1:1、3:4 或 9:16。"); + } return { - model: getBailianConfig().videoModel, + model, input: { prompt: input.prompt, - media: images.map((item, index) => ({ - type: index === 0 ? "first_frame" : "last_frame", + ...(images.length > 0 ? { media: images.map((item, index) => ({ + type: mode === "reference" ? "reference_image" : index === 0 ? "first_frame" : "last_frame", url: toAbsoluteUrl(item.url, input.origin) - })) + })) } : {}) }, - parameters: { resolution, duration, prompt_extend: true, watermark: false } + parameters: { resolution, duration, prompt_extend: true, watermark: false, ...(wan30 && ratio ? { ratio } : {}) } }; } diff --git a/lib/client/organization-balance.ts b/lib/client/organization-balance.ts new file mode 100644 index 0000000..6472279 --- /dev/null +++ b/lib/client/organization-balance.ts @@ -0,0 +1,24 @@ +export type OrganizationBalance = { + organization: { id: string; name: string }; + wallet: { organizationId: string; balanceFen: number; updatedAt: string }; +}; + +export async function fetchOrganizationBalance(organizationId: string, signal?: AbortSignal): Promise { + const response = await fetch("/api/billing/balance", { cache: "no-store", signal }); + const payload = await response.json().catch(() => null); + if (response.status === 401 || response.status === 403 || response.status === 422) { + throw new Error("登录状态或组织信息已变更,请刷新页面后重试。"); + } + if (!response.ok) throw new Error("余额暂时无法读取,请重试。"); + if (typeof payload?.organization?.id === "string" && payload.organization.id !== organizationId) { + throw new Error("账号所属组织已变更,请刷新页面后重试。"); + } + if (!payload || payload.organization?.id !== organizationId + || typeof payload.organization?.name !== "string" + || payload.wallet?.organizationId !== organizationId + || !Number.isSafeInteger(payload.wallet?.balanceFen) + || typeof payload.wallet?.updatedAt !== "string") { + throw new Error("余额信息不完整,请刷新后重试。"); + } + return payload as OrganizationBalance; +} diff --git a/lib/client/provider-error.ts b/lib/client/provider-error.ts new file mode 100644 index 0000000..45b5739 --- /dev/null +++ b/lib/client/provider-error.ts @@ -0,0 +1,52 @@ +import type { GenerationJob } from "@/lib/types"; + +export type ProviderErrorDiagnostic = { + status?: number; + code?: string; + requestId?: string; + detail?: string; + phase?: "submit" | "query"; + kind?: string; +}; + +const unknownSubmission = "provider submission outcome is unknown; refusing duplicate submission"; + +function nonemptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function providerErrorDiagnostic(job: GenerationJob): ProviderErrorDiagnostic | null { + const value = job.responsePayload?.providerError; + if (!value || typeof value !== "object" || Array.isArray(value)) { + const legacyCode = job.error?.code; + if (typeof legacyCode === "string" && /^[A-Za-z0-9_.:-]{1,160}$/.test(legacyCode)) return { code: legacyCode }; + if (typeof legacyCode === "number" && Number.isSafeInteger(legacyCode)) return { code: String(legacyCode) }; + return null; + } + const source = value as Record; + const status = typeof source.status === "number" && Number.isInteger(source.status) && source.status >= 100 && source.status <= 599 + ? source.status : undefined; + const phase = source.phase === "submit" || source.phase === "query" ? source.phase : undefined; + const diagnostic: ProviderErrorDiagnostic = { + status, + code: nonemptyString(source.code), + requestId: nonemptyString(source.requestId), + detail: nonemptyString(source.detail), + phase, + kind: nonemptyString(source.kind) + }; + return Object.values(diagnostic).some((item) => item !== undefined) ? diagnostic : null; +} + +export function generationErrorMessage(job: GenerationJob): string { + const message = job.error?.message?.trim(); + const providerCode = providerErrorDiagnostic(job)?.code || job.error?.code; + if (providerCode === "InputImageSensitiveContentDetected.PrivacyInformation" && + (!message || message === unknownSubmission)) { + return "参考图片涉及真人肖像,未通过服务商审核。请更换符合服务商要求的素材后重试。"; + } + if (message === unknownSubmission) { + return "暂时无法确认服务商是否已接收本次请求,为避免重复生成和扣费,平台已停止自动重试。请联系管理员核查任务状态。"; + } + return message || "生成失败,服务商未提供具体原因,请联系管理员。"; +} diff --git a/lib/minimax/client.ts b/lib/minimax/client.ts index 2579b9b..1ca0aff 100644 --- a/lib/minimax/client.ts +++ b/lib/minimax/client.ts @@ -9,13 +9,14 @@ export type MinimaxH3Settings = { ratio?: string; duration?: number; resolution?: string; + inputMode?: "frames" | "reference"; }; export type MinimaxH3Usage = { totalSeconds: number; inputSeconds: number; outputSeconds: number; - inputImageCount: number; + inputImageCount?: number; }; export function getMinimaxConfig() { @@ -34,9 +35,17 @@ export function buildMinimaxH3Payload(input: { }) { const prompt = input.prompt.trim(); if (!prompt) throw new Error("MiniMax H3 视频提示词不能为空。"); - if (input.materials.length > 1 || input.materials.some((material) => material.type !== "image")) { + const mode = input.settings.inputMode || "frames"; + if (mode !== "frames" && mode !== "reference") throw new Error("MiniMax 请选择首帧或多图参考模式。"); + if (input.materials.some((material) => material.type !== "image")) { throw new Error("MiniMax H3 第一版支持纯文字生成,或只上传 1 张图片作为首帧。"); } + if (mode === "reference" && input.materials.length > 9) { + throw new Error("MiniMax 多图参考模式最多支持 9 张图片。"); + } + if (mode === "frames" && input.materials.length > 1) { + throw new Error("MiniMax 首帧模式最多只能上传 1 张图片;多张图片请切换到多图参考模式。"); + } const duration = Number(input.settings.duration ?? 5); if (!Number.isInteger(duration) || duration < 4 || duration > 15) { throw new Error("MiniMax H3 视频时长须为 4–15 秒整数。"); @@ -45,8 +54,8 @@ export function buildMinimaxH3Payload(input: { if (resolution !== "768P" && resolution !== "2K") { throw new Error("MiniMax H3 分辨率仅支持 768P 或 2K。"); } - const ratio = input.materials.length === 1 ? "adaptive" : String(input.settings.ratio || "16:9"); - if (input.materials.length === 0 && !["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(ratio)) { + const ratio = mode === "frames" && input.materials.length === 1 ? "adaptive" : String(input.settings.ratio || "16:9"); + if ((input.materials.length === 0 && ratio === "adaptive") || !["21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "adaptive"].includes(ratio)) { throw new Error("MiniMax H3 文生视频须选择固定画面比例。"); } return { @@ -56,7 +65,7 @@ export function buildMinimaxH3Payload(input: { ...input.materials.map((material) => ({ type: "image_url", image_url: { url: toAbsoluteUrl(material.url, input.origin) }, - role: "first_frame" + role: mode === "reference" ? "reference_image" : "first_frame" })) ], resolution, @@ -100,7 +109,9 @@ export function extractMinimaxH3Usage(value: unknown): MinimaxH3Usage | undefine totalSeconds: nonNegativeInteger(usage.total_seconds), inputSeconds: nonNegativeInteger(usage.input_seconds), outputSeconds, - inputImageCount: nonNegativeInteger(usage.input_image_count) + ...(typeof usage.input_image_count === "number" && Number.isInteger(usage.input_image_count) && usage.input_image_count >= 0 + ? { inputImageCount: usage.input_image_count } + : {}) }; } diff --git a/lib/seedance/client.ts b/lib/seedance/client.ts index 186d211..8facc3f 100644 --- a/lib/seedance/client.ts +++ b/lib/seedance/client.ts @@ -17,6 +17,7 @@ export type SeedanceSettings = { ratio?: string; duration?: number; resolution?: string; + inputMode?: "frames" | "reference"; }; export type SeedanceCreateInput = { @@ -59,9 +60,7 @@ export async function createSeedanceTask(input: SeedanceCreateInput) { } const model = normalizeSeedanceModel(input.model, config.model); const materialContent = materialContentForProvider(input.materials, input.origin); - if (!isSeedance25Model(model) && materialContent.length > 4) { - throw new Error("Seedance 2.0 最多支持 4 个素材(加上文本后 content 最多 5 项)。"); - } + if (!isSeedance25Model(model)) validateSeedance20Materials(input.materials); if (isSeedance25Model(model)) validateSeedance25Materials(input.materials); const payload = { model, @@ -105,6 +104,19 @@ function validateSeedance25Materials(materials: PromptMaterial[]) { } } +function validateSeedance20Materials(materials: PromptMaterial[]) { + const counts = materials.reduce((result, material) => { + result[material.type] += 1; + return result; + }, { image: 0, video: 0, audio: 0 }); + if (counts.image > 9 || counts.video > 3 || counts.audio > 3) { + throw new Error("Seedance 2.0 最多支持 9 张图片、3 个视频、3 个音频。"); + } + if (counts.audio > 0 && counts.image + counts.video === 0) { + throw new Error("Seedance 2.0 音频不能单独使用,请添加图片或视频素材。"); + } +} + export async function querySeedanceTask(providerTaskId: string): Promise { const config = getSeedanceConfig(); if (!config.apiKey) throw new Error("缺少 SEEDANCE_API_KEY,无法查询真实生成任务。"); diff --git a/lib/server/app-settings.ts b/lib/server/app-settings.ts index da03431..a6381c2 100644 --- a/lib/server/app-settings.ts +++ b/lib/server/app-settings.ts @@ -125,7 +125,7 @@ const settingDefinitions: Array<{ { id: "bailian", title: "阿里云百炼 API", - description: "用于万相 2.7 文生图、参考图生图和图生视频。Base URL 会自动派生原生异步接口。", + description: "支持万相 2.7 图片与首尾帧视频、万相 3.0 多图参考视频。请使用与模型开通地域一致的 API Key 和 Base URL。", fields: [ { key: "BAILIAN_API_KEY", label: "百炼 API Key", secret: true, type: "password" }, { key: "BAILIAN_BASE_URL", label: "Base URL", defaultValue: "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1" }, @@ -136,7 +136,7 @@ const settingDefinitions: Array<{ { id: "minimax", title: "MiniMax API", - description: "用于 MiniMax H3 文生视频与单首帧图生视频。", + description: "支持 MiniMax H3 文生视频、单首帧视频和最多 9 张图片的多图参考视频。", fields: [ { key: "MINIMAX_API_KEY", label: "MiniMax API Key", secret: true, type: "password" }, { key: "MINIMAX_BASE_URL", label: "Base URL", defaultValue: "https://api.minimax.cn" } @@ -177,7 +177,7 @@ const engineFieldDefinitions: FieldDefinition[] = [ options: [ { label: "Seedance", value: "seedance" }, { label: "MiniMax H3", value: "minimax" }, - { label: "阿里云百炼 Wan 2.7", value: "bailian" } + { label: "阿里云百炼 Wan 2.7 / 3.0", value: "bailian" } ] }, ]; diff --git a/lib/server/billing-catalog.ts b/lib/server/billing-catalog.ts index dd5a45e..377c41f 100644 --- a/lib/server/billing-catalog.ts +++ b/lib/server/billing-catalog.ts @@ -231,6 +231,25 @@ export const DEFAULT_BILLING_PRICE_RULES: BillingPriceRuleInput[] = [ observedAt: BILLING_CATALOG_OBSERVED_AT } }, + ...([{"resolution": "480p", "priceFen": 30}, {"resolution": "720p", "priceFen": 60}, {"resolution": "1080p", "priceFen": 120}] as const).map(({ resolution, priceFen }) => ({ + id: `base-bailian-wan30-video-${resolution}`, + provider: "bailian" as const, + capability: "video.generate" as const, + reqKey: "wan3.0-video", + variantKey: `resolution=${resolution}`, + unit: "video_second" as const, + standardUnitPriceFen: priceFen, + markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, + enabled: true, + note: `北京地域官方原价 ¥${(priceFen / 100).toFixed(2)}/秒;官网限时折扣未计入标准成本,图片输入不另收费。`, + source: { + url: "https://help.aliyun.com/zh/model-studio/model-pricing", + currency: "CNY" as const, + unitPrice: priceFen / 100, + basis: "北京地域官方原价;限时7折未计入标准成本;仅输出视频按实际秒数计费(本接入仅支持图片输入)", + observedAt: "2026-10-01" + } + })), { id: "base-seedance-2-0-480p", provider: "seedance", diff --git a/lib/server/billing-service.ts b/lib/server/billing-service.ts index 12da4b9..2a07203 100644 --- a/lib/server/billing-service.ts +++ b/lib/server/billing-service.ts @@ -101,6 +101,15 @@ export async function quoteGenerationCharge(input: BillingGenerationInput): Prom }; } if (input.provider === "minimax" && input.reqKey === MINIMAX_H3_MODEL) { + const inputImageCount = Math.ceil(Number(parameters.referenceImageCount) || 0); + const inputImagePriceFen = Math.max(0, inputImageCount - 5) * 20; + const amountFen = Math.ceil((quote.standardUnitPriceFen * quantity + inputImagePriceFen) * quote.markupMultiplier); + return { + ...quote, amountFen, reservedAmountFen: amountFen, settlementStatus: "pending", + parameters: { ...quote.parameters, inputImageCount, inputImagePriceFen } + }; + } + if (input.provider === "bailian" && input.reqKey === "wan3.0-video") { return { ...quote, reservedAmountFen: quote.amountFen, settlementStatus: "pending" }; } if (input.provider !== "seedance" || !isSeedanceBillingModel(input.reqKey)) { @@ -135,7 +144,7 @@ export async function chargeGenerationJob(job: GenerationJob): Promise { const job = typeof jobOrId === "string" ? await getGenerationJob(jobOrId) : jobOrId; if (!job) return null; @@ -293,9 +302,12 @@ export async function settleMinimaxGenerationCharge( } const resolution = billing.parameters?.resolution; + const inputImageCount = Number.isInteger(usage.inputImageCount) && (usage.inputImageCount ?? -1) >= 0 + ? usage.inputImageCount! + : minimaxQuotedInputImageCount(billing.parameters, job.requestPayload); const actualAmountFen = calculateMinimaxH3ActualAmountFen({ resolution, outputSeconds: usage.outputSeconds, inputVideoSeconds: usage.inputSeconds, - inputImageCount: usage.inputImageCount, markupMultiplier: billing.markupMultiplier + inputImageCount, markupMultiplier: billing.markupMultiplier }); const deltaFen = actualAmountFen - billing.amountFen; let settlementLedgerEntryId: string | undefined; @@ -309,7 +321,7 @@ export async function settleMinimaxGenerationCharge( metadata: { operation: "minimax_h3_actual_settlement", reservedAmountFen: billing.reservedAmountFen ?? billing.amountFen, actualAmountFen, outputSeconds: usage.outputSeconds, inputVideoSeconds: usage.inputSeconds, - inputImageCount: usage.inputImageCount, resolution: String(resolution || "768P"), model: job.reqKey + inputImageCount, resolution: String(resolution || "768P"), model: job.reqKey } }); settlementLedgerEntryId = settlement.entry.id; @@ -320,13 +332,58 @@ export async function settleMinimaxGenerationCharge( ...billing, amountFen: actualAmountFen, settlementStatus: "settled", settlementLedgerEntryId, settledAt, providerUsage: { model: job.reqKey, resolution: String(resolution || "768P"), outputSeconds: usage.outputSeconds, - inputVideoSeconds: usage.inputSeconds, inputImageCount: usage.inputImageCount, + inputVideoSeconds: usage.inputSeconds, inputImageCount, videoPriceFenPerSecond: minimaxH3VideoPriceFenPerSecond(resolution) } } }); } +export async function settleBailianWan3GenerationCharge( + jobOrId: GenerationJob | string, + outputSeconds?: number +): Promise { + const job = typeof jobOrId === "string" ? await getGenerationJob(jobOrId) : jobOrId; + if (!job) return null; + const billing = job.billing; + const quotaExempt = isQuotaExemptBilling(job); + const organizationId = job.usageContext?.organizationId; + const chargeReady = billing?.status === "charged" || quotaExempt && billing?.status === "not_charged"; + if (job.provider !== "bailian" || job.reqKey !== "wan3.0-video" || !billing || !chargeReady || !quotaExempt && !organizationId) return job; + if (billing.settlementStatus === "settled" || billing.settlementStatus === "estimated") return job; + if (!Number.isFinite(outputSeconds) || !outputSeconds || outputSeconds <= 0) { + return updateGenerationJob(job.id, { billing: { ...billing, settlementStatus: "estimated", settlementReason: "provider_usage_unavailable", settledAt: new Date().toISOString() } }); + } + + const actualAmountFen = Math.ceil(outputSeconds * billing.standardUnitPriceFen * billing.markupMultiplier); + const deltaFen = actualAmountFen - billing.amountFen; + let settlementLedgerEntryId: string | undefined; + let settledAt = new Date().toISOString(); + if (deltaFen !== 0 && !quotaExempt && organizationId) { + const settlement = await postWalletEntry({ + organizationId, accountId: job.usageContext?.accountId, jobId: job.id, + kind: deltaFen > 0 ? "charge" : "refund", deltaFen: -deltaFen, + idempotencyKey: `job-settlement:${job.id}`, + description: deltaFen > 0 ? `${capabilityLabel(job.capability)}实际用量补扣` : `${capabilityLabel(job.capability)}实际用量差额退回`, + metadata: { + operation: "bailian_wan30_actual_settlement", reservedAmountFen: billing.reservedAmountFen ?? billing.amountFen, + actualAmountFen, outputSeconds, resolution: String(billing.parameters?.resolution || "720p"), model: job.reqKey + } + }); + settlementLedgerEntryId = settlement.entry.id; + settledAt = settlement.entry.createdAt; + } + return updateGenerationJob(job.id, { + billing: { + ...billing, amountFen: actualAmountFen, settlementStatus: "settled", settlementLedgerEntryId, settledAt, + providerUsage: { + model: job.reqKey, resolution: String(billing.parameters?.resolution || "720p"), outputSeconds, + videoPriceFenPerSecond: billing.standardUnitPriceFen + } + } + }); +} + function isSeedanceBillingModel(model: string) { return model === SEEDANCE_20_MODEL || model === SEEDANCE_25_MODEL; } @@ -532,6 +589,17 @@ export function normalizeBillingParameters(requestPayload: Record +): number { + for (const value of [parameters?.inputImageCount, parameters?.referenceImageCount, normalizeBillingParameters(requestPayload).referenceImageCount]) { + const count = Number(value); + if (value !== undefined && Number.isInteger(count) && count >= 0) return count; + } + return 0; +} + export function effectiveBillingRuleConditions(rule: BillingPriceRule): BillingRuleConditions { const legacyConditions = parseLegacyVariantKey(rule.variantKey); return { ...legacyConditions, ...(rule.conditions || {}) }; diff --git a/lib/server/video-generation-service.ts b/lib/server/video-generation-service.ts index 5b6cf9b..038f8c4 100644 --- a/lib/server/video-generation-service.ts +++ b/lib/server/video-generation-service.ts @@ -5,13 +5,13 @@ import { recordUsageForJob, updateGenerationJob } from "@/lib/server/data-store"; -import { chargeGenerationJob, quoteGenerationCharge, settleMinimaxGenerationCharge, settleSeedanceGenerationCharge } from "@/lib/server/billing-service"; +import { chargeGenerationJob, quoteGenerationCharge, settleBailianWan3GenerationCharge, settleMinimaxGenerationCharge, settleSeedanceGenerationCharge } from "@/lib/server/billing-service"; import { DEFAULT_OWNER_ID } from "@/lib/server/runtime"; import { importRemoteAssetAsAsset } from "@/lib/server/storage"; import { createSeedanceTask, getSeedanceConfig, querySeedanceTask, type SeedanceSettings } from "@/lib/seedance/client"; import type { BillingJobCharge, GenerationJob, UsageContext } from "@/lib/types"; import { VIDEO_DURATION_DEFAULT, clampVideoDuration, isSupportedSeedanceModel, normalizeSeedanceModel, normalizeVideoDuration, normalizeVideoRatio, normalizeVideoResolution } from "@/lib/video-settings"; -import { bailianResultUrls, bailianStatus, bailianTaskId, buildBailianVideoPayload, getBailianConfig, queryBailianTask, submitBailianTask } from "@/lib/bailian/client"; +import { bailianOutputVideoSeconds, bailianResultUrls, bailianStatus, bailianTaskId, buildBailianVideoPayload, getBailianConfig, queryBailianTask, submitBailianTask } from "@/lib/bailian/client"; import { buildMinimaxH3Payload, createMinimaxH3Task, getMinimaxConfig, MINIMAX_H3_MODEL, queryMinimaxH3Task } from "@/lib/minimax/client"; export type VideoCreationEngine = "seedance" | "bailian" | "minimax"; @@ -121,7 +121,14 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre if (engine === "seedance" && input.model !== undefined && !isSupportedSeedanceModel(input.model)) { throw new Error("不支持的 Seedance 视频模型。"); } - const model = normalizeSeedanceModel(input.model, config.model); + if (engine === "minimax" && input.model !== undefined && input.model !== MINIMAX_H3_MODEL) { + throw new Error("MiniMax 仅支持 H3 视频模型。"); + } + const bailianModel = input.model || getBailianConfig().videoModel; + if (engine === "bailian" && !["wan2.7-i2v-2026-04-25", "wan3.0-video", getBailianConfig().videoModel].includes(bailianModel)) { + throw new Error("不支持的百炼视频模型。"); + } + const model = engine === "seedance" ? normalizeSeedanceModel(input.model, config.model) : engine === "bailian" ? bailianModel : MINIMAX_H3_MODEL; const assembled = assemblePrompt({ ...input, mode: "video", @@ -130,9 +137,9 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre const finalPrompt = input.prompt?.trim() || assembled.prompt; const settings: SeedanceSettings = { ...(input.settings || {}) }; if (engine === "bailian") { - settings.duration = normalizeVideoDuration(settings.duration) ?? 10; + settings.duration = settings.duration === undefined ? 10 : Number(settings.duration); settings.resolution = String(settings.resolution || "720P").toUpperCase(); - buildBailianVideoPayload({ prompt: finalPrompt, materials: assembled.materials, origin, settings }); + buildBailianVideoPayload({ prompt: finalPrompt, materials: assembled.materials, origin, settings, model }); } else if (engine === "minimax") { const payload = buildMinimaxH3Payload({ prompt: finalPrompt, materials: assembled.materials, settings, origin }); settings.duration = payload.duration; @@ -152,7 +159,7 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre assembled, settings, engine, - ...(engine === "seedance" ? { model } : {}) + model }; return { ownerId, @@ -163,7 +170,7 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre settings, missingProviderCredentials, provider: engine, - reqKey: engine === "bailian" ? getBailianConfig().videoModel : engine === "minimax" ? MINIMAX_H3_MODEL : model, + reqKey: model, requestPayload }; } @@ -193,7 +200,7 @@ async function dispatchVideoJob(job: GenerationJob, origin: string): Promise, completionTokens?: number, - minimaxUsage?: { outputSeconds: number; inputSeconds: number; inputImageCount: number } + minimaxUsage?: { outputSeconds: number; inputSeconds: number; inputImageCount?: number }, + bailianOutputSeconds?: number ) { const settledJob = job.provider === "seedance" ? await settleSeedanceGenerationCharge(job, completionTokens) || job : job.provider === "minimax" ? await settleMinimaxGenerationCharge(job, minimaxUsage) || job - : job; + : job.provider === "bailian" + ? await settleBailianWan3GenerationCharge(job, bailianOutputSeconds) || job + : job; const asset = await importRemoteAssetAsAsset({ ownerId: settledJob.ownerId, url: resultUrl, diff --git a/lib/video-input.ts b/lib/video-input.ts new file mode 100644 index 0000000..4e0ec32 --- /dev/null +++ b/lib/video-input.ts @@ -0,0 +1,88 @@ +import { SEEDANCE_25_MODEL } from "@/lib/video-settings"; + +export type VideoInputEngine = "seedance" | "minimax" | "bailian"; +export type VideoInputMode = "reference" | "frames"; +export type VideoMaterialKind = "image" | "video" | "audio"; + +export const BAILIAN_27_VIDEO_MODEL = "wan2.7-i2v-2026-04-25"; +export const BAILIAN_30_VIDEO_MODEL = "wan3.0-video"; +export const BAILIAN_VIDEO_MODELS = [ + { id: BAILIAN_27_VIDEO_MODEL, label: "Wan 2.7 · 首尾帧" }, + { id: BAILIAN_30_VIDEO_MODEL, label: "Wan 3.0 · 多图参考" } +] as const; + +export type VideoInputSelection = { + engine: VideoInputEngine; + model: string; + inputMode: VideoInputMode; +}; + +export function videoInputHint(selection: VideoInputSelection): string { + if (selection.engine === "seedance") { + return selection.model === SEEDANCE_25_MODEL + ? "多素材参考:最多 30 张图片、10 段视频、10 段音频,总计 50 个。可在提示词中用 @图片1 等编号指定用途。" + : "多素材参考:最多 9 张图片、3 段视频、3 段音频;音频需要搭配图片或视频。可用 @图片1 等编号指定用途。"; + } + if (selection.engine === "minimax") { + return selection.inputMode === "frames" + ? "首帧模式:可不上传图片,或上传 1 张图片固定视频开头;有首帧时比例跟随图片。若想用多张图共同指导内容,请切换到「多图参考」。" + : "多图参考:最多 9 张图片。图片用于指导人物、服装或场景,不会被固定为视频首帧;可在提示词中引用 @图片1 等编号。"; + } + if (selection.model === BAILIAN_27_VIDEO_MODEL) { + return "Wan 2.7 首尾帧:上传 1 张图片作为首帧,或按顺序上传 2 张作为首帧和尾帧。多图参考请切换到 Wan 3.0。"; + } + return selection.inputMode === "frames" + ? "首尾帧:上传 1 张首帧图,或按顺序上传 2 张首尾帧图。若想让更多图片共同指导内容,请切换到「多图参考」。" + : "多图参考:可只输入文字,或上传最多 10 张图片指导人物、服装和场景;参考图不会固定为首尾帧,可在提示词中引用 @图片1 等编号。"; +} + +export function videoMaterialError(selection: VideoInputSelection, materials: readonly { type: VideoMaterialKind }[]): string | null { + const imageCount = materials.filter((item) => item.type === "image").length; + const videoCount = materials.filter((item) => item.type === "video").length; + const audioCount = materials.filter((item) => item.type === "audio").length; + if (selection.engine === "seedance") { + const is25 = selection.model === SEEDANCE_25_MODEL; + const maxImages = is25 ? 30 : 9; + const maxVideo = is25 ? 10 : 3; + const maxAudio = is25 ? 10 : 3; + if (imageCount > maxImages || videoCount > maxVideo || audioCount > maxAudio || is25 && materials.length > 50) { + return `${is25 ? "Seedance 2.5" : "Seedance 2.0"} 最多支持 ${maxImages} 张图片、${maxVideo} 段视频、${maxAudio} 段音频${is25 ? ",总计 50 个素材" : ""};请移除超出的素材。`; + } + if (!is25 && audioCount > 0 && imageCount + videoCount === 0) { + return "Seedance 2.0 的音频不能单独生成视频;请添加至少 1 张图片或 1 段视频,或切换到 Seedance 2.5。"; + } + return null; + } + if (selection.engine === "minimax") { + if (videoCount || audioCount) return "MiniMax H3 当前只接受图片素材;请移除视频和音频,或切换到 Seedance。"; + if (selection.inputMode === "frames" && imageCount > 1) { + return "MiniMax H3 首帧模式最多只能上传 1 张图;请切换到「多图参考」或移除多余图片。"; + } + if (selection.inputMode === "reference" && imageCount > 9) { + return "MiniMax H3 多图参考最多支持 9 张图片;请移除多余图片。"; + } + return null; + } + if (videoCount || audioCount) return "百炼当前只接受图片素材;请移除视频和音频,或切换到 Seedance。"; + if (selection.model === BAILIAN_27_VIDEO_MODEL || selection.inputMode === "frames") { + if (!imageCount) return "首尾帧模式需要至少 1 张图片作为首帧;请上传图片,或切换到 Wan 3.0 多图参考。"; + if (imageCount > 2) return "首尾帧模式最多支持 2 张图片;请切换到 Wan 3.0 多图参考,或移除多余图片。"; + return null; + } + if (imageCount > 10) return "Wan 3.0 多图参考最多支持 10 张图片;请移除多余图片。"; + return null; +} + +export function videoImageFileError(selection: VideoInputSelection, file: Pick): string | null { + if (selection.engine === "seedance" || selection.engine === "bailian" && selection.model === BAILIAN_27_VIDEO_MODEL) return null; + const format = file.name.split(".").pop()?.toLowerCase() || file.type.split("/").pop()?.toLowerCase() || ""; + const isMinimax = selection.engine === "minimax"; + const formats = isMinimax ? ["jpg", "jpeg", "png", "webp", "heic", "heif"] : ["jpg", "jpeg", "png", "bmp", "webp"]; + const maxBytes = (isMinimax ? 30 : 20) * 1024 * 1024; + const modelLabel = isMinimax ? "MiniMax H3" : "Wan 3.0"; + if (!formats.includes(format)) { + return `${modelLabel} 不支持「${file.name}」的格式;请转换为 ${isMinimax ? "JPG、PNG、WEBP、HEIC 或 HEIF" : "JPG、PNG、BMP 或 WEBP"} 后上传。`; + } + if (file.size > maxBytes) return `${modelLabel} 的单张图片不能超过 ${isMinimax ? 30 : 20} MB;请压缩「${file.name}」后重试。`; + return null; +} diff --git a/tests/bailian-client.test.ts b/tests/bailian-client.test.ts index 58d02d3..73a764b 100644 --- a/tests/bailian-client.test.ts +++ b/tests/bailian-client.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { bailianResultUrls, + bailianOutputVideoSeconds, bailianStatus, buildBailianImagePayload, buildBailianVideoPayload, @@ -58,6 +59,19 @@ describe("Bailian client", () => { expect(payload.parameters).not.toHaveProperty("size"); }); + it("maps Wan 3.0 references while retaining optional first and last frames", () => { + const materials = Array.from({ length: 10 }, (_, index) => ({ type: "image" as const, url: `/image-${index}.png` })); + const reference = buildBailianVideoPayload({ model: "wan3.0-video", prompt: "combine", materials, origin: "https://app.test", settings: { duration: 30, resolution: "480P", ratio: "9:16", inputMode: "reference" } }); + expect(reference.input.media).toHaveLength(10); + expect(reference.input.media?.every((item) => item.type === "reference_image")).toBe(true); + expect(reference.parameters.ratio).toBe("9:16"); + const frames = buildBailianVideoPayload({ model: "wan3.0-video", prompt: "animate", materials: materials.slice(0, 2), origin: "https://app.test", settings: { inputMode: "frames" } }); + expect(frames.input.media?.map((item) => item.type)).toEqual(["first_frame", "last_frame"]); + expect(buildBailianVideoPayload({ model: "wan3.0-video", prompt: "landscape", materials: [], origin: "https://app.test", settings: { inputMode: "reference" } }).input).toEqual({ prompt: "landscape" }); + expect(() => buildBailianVideoPayload({ model: "wan2.7-i2v-2026-04-25", prompt: "x", materials, origin: "https://app.test", settings: { inputMode: "reference" } })).toThrow("请选择 Wan 3.0"); + expect(bailianOutputVideoSeconds({ usage: { output_video_duration: 5.25, duration: 8 } })).toBe(5.25); + }); + it("normalizes task state and result URLs", () => { const response = { output: { task_status: "SUCCEEDED", results: [{ url: "https://example.com/result.png" }] } }; expect(bailianStatus(response)).toBe("succeeded"); diff --git a/tests/billing-catalog-seed.test.ts b/tests/billing-catalog-seed.test.ts index a32e560..6e274cd 100644 --- a/tests/billing-catalog-seed.test.ts +++ b/tests/billing-catalog-seed.test.ts @@ -3,8 +3,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_BILLING_PRICE_RULES, ensureDefaultBillingPriceRules } from "@/lib/server/billing-catalog"; -import { quoteGenerationCharge } from "@/lib/server/billing-service"; +import { quoteGenerationCharge, settleMinimaxGenerationCharge } from "@/lib/server/billing-service"; import { createBillingPriceRule, getBillingPriceRule, updateBillingPriceTierMultiplier } from "@/lib/server/billing-store"; +import { createGenerationJob } from "@/lib/server/data-store"; let runtimeDir = ""; @@ -44,6 +45,50 @@ describe("default billing catalog refresh", () => { expect(tiers?.find((tier) => tier.value === "2K")?.markupMultiplier).toBe(1.2); }); + it("quotes MiniMax nine references and keeps Wan 3.0 resolution pricing independent", async () => { + const context = { source: "platform" as const, accountId: "user-1", displayName: "测试用户", organizationId: "org-1" }; + const minimax = await quoteGenerationCharge({ + provider: "minimax", capability: "video.generate", reqKey: "MiniMax-H3", + requestPayload: { + settings: { duration: 5, resolution: "2K" }, + materials: Array.from({ length: 9 }, (_, index) => ({ type: "image", url: `https://cdn.test/${index}.png` })) + }, + usageContext: context + }); + expect(minimax).toMatchObject({ amountFen: 576, reservedAmountFen: 576, settlementStatus: "pending" }); + expect(minimax?.parameters).toMatchObject({ inputImageCount: 9, inputImagePriceFen: 80 }); + + const wan3 = await quoteGenerationCharge({ + provider: "bailian", capability: "video.generate", reqKey: "wan3.0-video", + requestPayload: { settings: { duration: 5, resolution: "1080P" } }, usageContext: context + }); + expect(wan3).toMatchObject({ + priceRuleId: "base-bailian-wan30-video-1080p", amountFen: 720, + reservedAmountFen: 720, settlementStatus: "pending" + }); + }); + + it("retains quoted MiniMax image fees only when provider omits image usage", async () => { + const materials = Array.from({ length: 9 }, (_, index) => ({ type: "image", url: `https://cdn.test/${index}.png` })); + const makeJob = () => createGenerationJob({ + ownerId: "user-1", capability: "video.generate", provider: "minimax", reqKey: "MiniMax-H3", status: "succeeded", + inputAssetIds: [], inputUrls: [], outputAssetIds: [], requestPayload: { materials }, + usageContext: { source: "platform", accountId: "user-1", displayName: "管理员", role: "super_admin" }, + billing: { + priceRuleId: "base-minimax-h3-2k", provider: "minimax", capability: "video.generate", reqKey: "MiniMax-H3", + unit: "video_second", quantity: 5, standardUnitPriceFen: 80, markupMultiplier: 1.2, + amountFen: 576, reservedAmountFen: 576, currency: "CNY", status: "not_charged", quotaExempt: true, + settlementStatus: "pending", parameters: { resolution: "2k", inputImageCount: 9 } + } + }); + + const missing = await settleMinimaxGenerationCharge(await makeJob(), { outputSeconds: 5, inputSeconds: 0 }); + expect(missing?.billing).toMatchObject({ amountFen: 576, settlementStatus: "settled", providerUsage: { inputImageCount: 9 } }); + + const explicitZero = await settleMinimaxGenerationCharge(await makeJob(), { outputSeconds: 5, inputSeconds: 0, inputImageCount: 0 }); + expect(explicitZero?.billing).toMatchObject({ amountFen: 480, settlementStatus: "settled", providerUsage: { inputImageCount: 0 } }); + }); + it("takes current default definitions while retaining valid matching tier markups", async () => { const candidate = DEFAULT_BILLING_PRICE_RULES.find((rule) => rule.id === "base-seedream-5-0-pro")!; const size = candidate.parameterDimensions!.find((dimension) => dimension.key === "size")!; diff --git a/tests/billing-http-contract.test.ts b/tests/billing-http-contract.test.ts index 1454d8c..fa8fced 100644 --- a/tests/billing-http-contract.test.ts +++ b/tests/billing-http-contract.test.ts @@ -11,6 +11,7 @@ describe("billing HTTP compatibility fixture", () => { }); expect(fixture.routes).toEqual(expect.arrayContaining([ expect.objectContaining({ method: "GET", path: "/api/billing", organizationSource: "refreshed_session" }), + expect.objectContaining({ method: "GET", path: "/api/billing/balance", requirement: "app", organizationSource: "refreshed_session", unboundStatus: 422, cacheControl: "no-store", responseFields: ["organization", "wallet"] }), expect.objectContaining({ method: "PATCH", path: "/api/admin/billing/prices", requirement: "super_admin", allowedFields: ["ruleIds", "markupMultiplier"], atomic: true }), expect.objectContaining({ method: "PATCH", path: "/api/admin/billing/prices/{id}", requirement: "super_admin" }) ])); diff --git a/tests/create-studio-paste.test.ts b/tests/create-studio-paste.test.ts index a2b3764..94fd05e 100644 --- a/tests/create-studio-paste.test.ts +++ b/tests/create-studio-paste.test.ts @@ -46,13 +46,14 @@ describe("create studio image paste wiring", () => { .toBeLessThan(flow.indexOf("setMaterials(next)")); }); - it("routes special modes through existing source limits and adds references only for basic image or video", async () => { + it("validates video paste without replacing or truncating selected materials", async () => { const { flow } = await pasteFlowSource(); expect(flow).toContain('imageCreationMode === "interactive" ? "append" : "single"'); expect(flow).toContain("isSeedreamLayerInputFile(files[0])"); expect(flow).toContain("if (selectedSeedreamSource && !window.confirm("); - expect(flow).toContain("options.maxFiles = 1"); - expect(flow).toContain("options.replaceAll = true"); + expect(flow).toContain("videoMaterialError(videoSelection, [...materials"); + expect(flow).toContain("videoMaterialError(videoSelection, next)"); + expect(flow).not.toContain("options.replaceAll = true"); expect(flow).toContain("options.maxFiles = remaining"); expect(flow).toContain("insertReferences: !specializedImageMode"); expect(flow).toContain('const labels = addedMaterials.map((material) => material.label || "");'); diff --git a/tests/generation-error.test.ts b/tests/generation-error.test.ts new file mode 100644 index 0000000..7c815c2 --- /dev/null +++ b/tests/generation-error.test.ts @@ -0,0 +1,99 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { GenerationError } from "@/components/generation-error"; +import { generationErrorMessage, providerErrorDiagnostic } from "@/lib/client/provider-error"; +import type { GenerationJob } from "@/lib/types"; + +function job(overrides: Partial = {}): GenerationJob { + return { + id: "job-test", + ownerId: "user-test", + capability: "video.generate", + provider: "seedance", + reqKey: "doubao-seedance-2-0-260128", + status: "failed", + inputAssetIds: [], + inputUrls: [], + outputAssetIds: [], + requestPayload: {}, + error: { code: "InputImageSensitiveContentDetected.PrivacyInformation", message: "参考图片涉及真人肖像,未通过服务商审核。" }, + ...overrides + } as GenerationJob; +} + +describe("generation task error presentation", () => { + it("shows the actionable reason without diagnostics to ordinary users", () => { + const result = renderToStaticMarkup(createElement(GenerationError, { + job: job({ responsePayload: { providerError: { + status: 400, + code: "InputImageSensitiveContentDetected.PrivacyInformation", + requestId: "request-secret", + detail: "provider-private-detail", + phase: "submit" + } } }), + isAdmin: false, + providerName: "Seedance" + })); + expect(result).toContain("失败原因"); + expect(result).toContain("参考图片涉及真人肖像"); + expect(result).not.toContain("错误详情"); + expect(result).not.toContain("request-secret"); + expect(result).not.toContain("provider-private-detail"); + }); + + it("shows only available diagnostic fields to an administrator", () => { + const result = renderToStaticMarkup(createElement(GenerationError, { + job: job({ responsePayload: { providerError: { + status: 400, + code: "InputImageSensitiveContentDetected.PrivacyInformation", + requestId: "", + detail: "已脱敏的服务商说明", + phase: "submit" + } } }), + isAdmin: true, + providerName: "Seedance" + })); + expect(result).toContain("错误详情"); + expect(result).toContain("HTTP 状态"); + expect(result).toContain("400"); + expect(result).toContain("错误码"); + expect(result).toContain("已脱敏的服务商说明"); + expect(result).not.toContain("请求 ID"); + }); + + it("does not describe a queued retry as a final failure", () => { + const result = renderToStaticMarkup(createElement(GenerationError, { + job: job({ status: "queued", error: { message: "服务商暂时繁忙,系统稍后重试。" } }), + isAdmin: false, + providerName: "Seedance" + })); + expect(result).toContain("任务提示"); + expect(result).not.toContain("失败原因"); + }); + + it("translates old unknown-submission records without falsely claiming rejection", () => { + const legacy = job({ error: { message: "provider submission outcome is unknown; refusing duplicate submission" }, responsePayload: undefined }); + expect(generationErrorMessage(legacy)).toContain("平台已停止自动重试"); + expect(generationErrorMessage(legacy)).not.toContain("服务商拒绝"); + expect(generationErrorMessage(legacy)).not.toContain("稍后刷新"); + }); + + it("uses a known privacy code for an old task but ignores unrelated payload data", () => { + const legacy = job({ + error: { code: "InputImageSensitiveContentDetected.PrivacyInformation", message: "provider submission outcome is unknown; refusing duplicate submission" }, + responsePayload: { raw: { accessToken: "must-not-appear" } } + }); + expect(generationErrorMessage(legacy)).toContain("真人肖像"); + expect(providerErrorDiagnostic(legacy)).toEqual({ code: "InputImageSensitiveContentDetected.PrivacyInformation" }); + }); + + it("does not infer a privacy review from a code with an extra suffix", () => { + const other = job({ error: { + code: "InputImageSensitiveContentDetected.PrivacyInformation.Unknown", + message: "provider submission outcome is unknown; refusing duplicate submission" + } }); + expect(generationErrorMessage(other)).toContain("暂时无法确认服务商是否已接收"); + expect(generationErrorMessage(other)).not.toContain("真人肖像"); + }); +}); diff --git a/tests/minimax-client.test.ts b/tests/minimax-client.test.ts index 8a04a85..68e226d 100644 --- a/tests/minimax-client.test.ts +++ b/tests/minimax-client.test.ts @@ -27,6 +27,16 @@ describe("MiniMax H3 V2 client", () => { expect(() => buildMinimaxH3Payload({ prompt: "bad", materials: [{ type: "video", url: "/a.mp4" }], settings: {}, origin: "https://app.test" })).toThrow("只上传 1 张图片"); }); + it("sends every selected reference image and rejects overflow", () => { + const materials = Array.from({ length: 9 }, (_, index) => ({ type: "image" as const, url: `/image-${index}.png` })); + const payload = buildMinimaxH3Payload({ prompt: "combine", materials, settings: { inputMode: "reference", ratio: "adaptive" }, origin: "https://app.test" }); + expect(payload.content).toHaveLength(10); + expect(payload.content.slice(1).every((item) => "role" in item && item.role === "reference_image")).toBe(true); + expect(buildMinimaxH3Payload({ prompt: "text only", materials: [], settings: { inputMode: "reference", ratio: "16:9" }, origin: "https://app.test" }).content).toHaveLength(1); + expect(() => buildMinimaxH3Payload({ prompt: "text only", materials: [], settings: { inputMode: "reference", ratio: "adaptive" }, origin: "https://app.test" })).toThrow("固定画面比例"); + expect(() => buildMinimaxH3Payload({ prompt: "x", materials: [...materials, materials[0]], settings: { inputMode: "reference" }, origin: "https://app.test" })).toThrow("最多支持 9 张图片"); + }); + it("submits and queries the official asynchronous endpoints", async () => { process.env.MINIMAX_API_KEY = "test-key"; process.env.MINIMAX_BASE_URL = "https://minimax.test"; @@ -45,6 +55,8 @@ describe("MiniMax H3 V2 client", () => { it("extracts billable usage", () => { expect(extractMinimaxH3Usage({ total_seconds: 8, input_seconds: 0, output_seconds: 8, input_image_count: 1 })).toEqual({ totalSeconds: 8, inputSeconds: 0, outputSeconds: 8, inputImageCount: 1 }); + expect(extractMinimaxH3Usage({ output_seconds: 8 })).not.toHaveProperty("inputImageCount"); + expect(extractMinimaxH3Usage({ output_seconds: 8, input_image_count: 0 })).toHaveProperty("inputImageCount", 0); expect(extractMinimaxH3Usage({ output_seconds: 0 })).toBeUndefined(); }); }); diff --git a/tests/organization-balance.test.ts b/tests/organization-balance.test.ts new file mode 100644 index 0000000..df01e8a --- /dev/null +++ b/tests/organization-balance.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchOrganizationBalance } from "@/lib/client/organization-balance"; + +const payload = (balanceFen = 12345) => ({ + organization: { id: "org-current", name: "当前组织" }, + wallet: { organizationId: "org-current", balanceFen, updatedAt: "2026-10-01T00:00:00Z" } +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe("creation organization balance", () => { + it.each([12345, 0, -100])("preserves the available balance %i without substituting a fallback", async (amount) => { + const fetchMock = vi.fn().mockResolvedValue(Response.json(payload(amount))); + vi.stubGlobal("fetch", fetchMock); + const controller = new AbortController(); + expect((await fetchOrganizationBalance("org-current", controller.signal)).wallet.balanceFen).toBe(amount); + expect(fetchMock).toHaveBeenCalledWith("/api/billing/balance", { cache: "no-store", signal: controller.signal }); + }); + + it.each([ + { ...payload(), wallet: { ...payload().wallet, organizationId: "org-other" } }, + { ...payload(), wallet: { ...payload().wallet, balanceFen: "12345" } }, + { ...payload(), wallet: { ...payload().wallet, balanceFen: 1.5 } }, + { organization: payload().organization }, + null + ])("rejects malformed or other-organization responses instead of showing a balance", async (value) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(value))); + await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("余额信息不完整"); + }); + + it("explains when the server has refreshed the user into a different organization", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ + ...payload(), organization: { id: "org-other", name: "新组织" } + }))); + await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("账号所属组织已变更,请刷新页面"); + }); + + it.each([401, 403, 422])("explains expired or changed access for HTTP %i", async (status) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({}, { status }))); + await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("登录状态或组织信息已变更,请刷新页面"); + }); + + it("does not expose internal errors or turn failures into zero balance", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ error: "internal database details" }, { status: 500 }))); + await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("余额暂时无法读取,请重试。"); + }); + + it("handles a non-JSON proxy response as an error", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("gateway unavailable", { status: 502 }))); + await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("余额暂时无法读取,请重试。"); + }); +}); diff --git a/tests/providers-contract.test.ts b/tests/providers-contract.test.ts index 57dc662..a6c50a3 100644 --- a/tests/providers-contract.test.ts +++ b/tests/providers-contract.test.ts @@ -17,7 +17,7 @@ describe("Go provider adapter contract", () => { submit: "/contents/generations/tasks", query: "/contents/generations/tasks/{id}", maxMaterials: { - "doubao-seedance-2-0-260128": 4, + "doubao-seedance-2-0-260128": 15, "doubao-seedance-2-5-260628": 50 }, maxDurationSeconds: { @@ -40,8 +40,8 @@ describe("Go provider adapter contract", () => { model: "MiniMax-H3", submit: "/v2/video_generation", query: "/v2/query/video_generation/{id}", - modes: ["text-to-video", "first-frame-to-video"], - maxMaterials: 1, + modes: ["text-to-video", "first-frame-to-video", "reference-to-video"], + maxMaterials: 9, durationSeconds: { min: 4, max: 15 }, resolutions: ["768P", "2K"] }); diff --git a/tests/seedance-client.test.ts b/tests/seedance-client.test.ts index b5a0c9c..489a593 100644 --- a/tests/seedance-client.test.ts +++ b/tests/seedance-client.test.ts @@ -88,21 +88,40 @@ describe("Seedance usage extraction", () => { expect(body?.content).toHaveLength(6); }); - it("rejects more materials than the Seedance 2.0 content limit before calling the provider", async () => { + it("accepts nine Seedance 2.0 reference images and rejects a tenth", async () => { + process.env.SEEDANCE_API_KEY = "test-key"; + let body: Record | undefined; + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ id: "task-20" }), { status: 200, headers: { "Content-Type": "application/json" } }); + }); + vi.stubGlobal("fetch", fetchMock); + const materials = Array.from({ length: 9 }, (_, index) => ({ + type: "image" as const, + url: `/image-${index + 1}.png`, + label: `@图片${index + 1}` + })); + await createSeedanceTask({ + prompt: "combine", + settings: {}, + materials, + origin: "https://app.test" + }); + expect(body?.content).toHaveLength(10); + expect(fetchMock).toHaveBeenCalledTimes(1); + await expect(createSeedanceTask({ + prompt: "combine", settings: {}, materials: [...materials, { type: "image", url: "/image-10.png" }], origin: "https://app.test" + })).rejects.toThrow("最多支持 9 张图片"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("rejects Seedance 2.0 audio without an image or video", async () => { process.env.SEEDANCE_API_KEY = "test-key"; const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); - await expect(createSeedanceTask({ - prompt: "combine", - settings: {}, - materials: Array.from({ length: 5 }, (_, index) => ({ - type: "image" as const, - url: `/image-${index + 1}.png`, - label: `@图片${index + 1}` - })), - origin: "https://app.test" - })).rejects.toThrow("最多支持 4 个素材"); + prompt: "music", settings: {}, materials: [{ type: "audio", url: "/music.mp3" }], origin: "https://app.test" + })).rejects.toThrow("音频不能单独使用"); expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/tests/video-input.test.ts b/tests/video-input.test.ts new file mode 100644 index 0000000..cd643dc --- /dev/null +++ b/tests/video-input.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { BAILIAN_27_VIDEO_MODEL, BAILIAN_30_VIDEO_MODEL, videoImageFileError, videoMaterialError } from "@/lib/video-input"; +import { SEEDANCE_20_MODEL, SEEDANCE_25_MODEL } from "@/lib/video-settings"; + +const images = (count: number) => Array.from({ length: count }, () => ({ type: "image" as const })); + +describe("video input mode limits", () => { + it("preserves two-frame mode while allowing ten Wan 3.0 reference images", () => { + expect(videoMaterialError({ engine: "bailian", model: BAILIAN_27_VIDEO_MODEL, inputMode: "frames" }, images(2))).toBeNull(); + expect(videoMaterialError({ engine: "bailian", model: BAILIAN_27_VIDEO_MODEL, inputMode: "frames" }, images(3))).toMatch(/Wan 3.0/); + expect(videoMaterialError({ engine: "bailian", model: BAILIAN_30_VIDEO_MODEL, inputMode: "reference" }, images(10))).toBeNull(); + expect(videoMaterialError({ engine: "bailian", model: BAILIAN_30_VIDEO_MODEL, inputMode: "reference" }, images(11))).toMatch(/10 张/); + expect(videoMaterialError({ engine: "bailian", model: BAILIAN_30_VIDEO_MODEL, inputMode: "frames" }, images(3))).toMatch(/首尾帧/); + }); + + it("explains MiniMax first-frame and reference alternatives", () => { + expect(videoMaterialError({ engine: "minimax", model: "MiniMax-H3", inputMode: "frames" }, images(2))).toMatch(/切换到「多图参考」/); + expect(videoMaterialError({ engine: "minimax", model: "MiniMax-H3", inputMode: "reference" }, images(9))).toBeNull(); + expect(videoMaterialError({ engine: "minimax", model: "MiniMax-H3", inputMode: "reference" }, images(10))).toMatch(/9 张/); + }); + + it("allows Seedance 2.0 category maxima and rejects audio alone", () => { + const selection = { engine: "seedance" as const, model: SEEDANCE_20_MODEL, inputMode: "reference" as const }; + expect(videoMaterialError(selection, [...images(9), ...Array.from({ length: 3 }, () => ({ type: "video" as const })), ...Array.from({ length: 3 }, () => ({ type: "audio" as const }))])).toBeNull(); + expect(videoMaterialError(selection, [{ type: "audio" }])).toMatch(/不能单独/); + expect(videoMaterialError({ ...selection, model: SEEDANCE_25_MODEL }, [{ type: "audio" }])).toBeNull(); + }); + + it("explains local reference-image format and size failures", () => { + const selection = { engine: "bailian" as const, model: BAILIAN_30_VIDEO_MODEL, inputMode: "reference" as const }; + expect(videoImageFileError(selection, { name: "design.heic", size: 12, type: "image/heic" })).toMatch(/格式/); + expect(videoImageFileError(selection, { name: "design.png", size: 21 * 1024 * 1024, type: "image/png" })).toMatch(/20 MB/); + }); +});