diff --git a/.gitignore b/.gitignore index 8dcdfeaf8..c7798020c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ docs/content/docs/backend/* !docs/content/docs/backend/http-api.mdx !docs/content/docs/backend/code-map.mdx !docs/content/docs/backend/newapi-channel-2.mdx +!docs/content/docs/backend/video-token-billing.mdx !docs/content/docs/backend/creative-agent-runtime.mdx # Keep the creative Agent design versioned without unignoring generated docs. !docs/content/docs/overview/ @@ -69,3 +70,5 @@ camera_language_guide.md # Local backend executable; never commit build output. backend/cmd/server/server +# One-off local probe scripts and their compiled output. +backend/tmp_* diff --git a/AGENTS.md b/AGENTS.md index 61ae02ad2..2cce78452 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,7 @@ - 第三方覆盖限定在具体组件,不新增全局 `.ant-modal-*`、`.dark .ant-switch-*`、`.ant-checkbox-*` 或 Segmented 状态补丁。新增 CSS 前先搜索同名选择器,回到唯一源规则修改。 - 遵循 `docs/ui-design-system.md` 及项目三层 token:Primitive → Semantic → Component。inline style 优先引用 `var(--token-name)`,不要散落颜色、圆角、阴影和层级字面值。 - 主操作、普通选中、Checkbox/Radio、Switch 是不同颜色角色;持久切换使用 `aria-pressed`,`type="primary"` 只表示当前主要命令。尊重 `prefers-reduced-motion`,键盘导航保留 `:focus-visible`。 +- 修改既有页面时直接修改真实组件、样式和路由。不得创建独立 HTML 来代替真实页面验证;只有用户明确要求原型或隔离设计稿时才可生成,并放在临时目录或用户指定位置,不得放入 `web/public/` 或产品构建目录。 ## 7. 本地开发、部署和数据目录 @@ -124,6 +125,7 @@ - 云端 Agent:前端事件流和审批路径依赖后端 `/api/agent` 接口;在该接口实现后补充后端冒烟、Worker 运行和 SSE 断线重连验证。 - 文档站:`cd docs && bun run types:check` 或 `bun run build`。 - UI 变更能浏览器验证时,检查关键路由、明暗主题、滚动、弹窗、空态和核心交互;不能验证时说明替代依据,不把静态阅读或 `git diff` 写成运行验证。 +- 验证必须针对真实实现:优先专项测试、类型检查、项目 build 或真实路由浏览器验收。独立 HTML、静态仿制页面和脱离应用状态的截图不能作为真实页面通过的证据。 同类失败连续三次时停止盲试,记录现象、已排除项和新假设,再切换路径或请求用户决策。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fdaffa5..e5ebefd8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,25 @@ # CHANGELOG -## v1.7.0 +## v1.5.4 -- 融合 PR#535 PoloX 图片编辑能力:图片编辑、去背景、图片文字识别与修改、图片标注编辑、AI 图层拆分、Cloud Agent 图片工具链及权限控制、WaveSpeed 异步图片编辑协议与插件。 -- 新增 wavespeed-image-edit / image-tools 插件;画布端新增图片编辑对话框、图层拆分对话框、文字编辑对话框;后端新增 wavespeed_image_edit 测试。 -- 同步融合官方 v1.5.1 release 源码,修复更新流程引用与前端重复 import。 -- 保留本地定制:PR#530 多维表格、自建插件(a6api/metaso-h3/antigravity-proxy/openai-images 渠道适配)、ddcat 多参考图尺寸校验、万有引力 lxmone 连接错误提示、模型 Description(schema v24)、postgres seccomp、Windows 启动脚本。 -- 镜像推送 `ghcr.io/15657306288/open-ai-canvas-web:1.7.0` 与 `ghcr.io/15657306288/open-ai-canvas-backend:1.7.0`。 +- 创作端直接读取启用的系统渠道模型,一级按模型展示名跨渠道分组,二级显示渠道展示名;移除渠道级“前台名称”,保留每条渠道的调用身份、能力和售价。 +- 模型价格档新增管理员积分成本价,后台请求记录、详情与 CSV 同时展示销售金额和成本金额;成本按下单快照核算,改价不影响历史订单,普通用户接口不返回成本字段。 +- 修复备用供应线路切换时的成本快照归属,成本独立于用户售价策略更新;未配置成本的历史记录不显示虚构的零成本。 +- 修复删除画布时预加载关联素材导致的 MIME 合同校验阻塞,直接按画布 ID 删除并保留服务端失败处理与本地缓存清理顺序。 +- 升级需同步数据库 schema v27,新增价格档成本与账单成本快照列,不重算既有售价、余额或历史订单;受管部署由迁移服务执行,其他部署先运行 `migrate-schema up` 和 `migrate-schema verify`。 +- 历史模型的展示字段按原值读取;若模型展示名和渠道展示名曾填反,需在后台纠正,升级不会自动交换配置。 + +## v1.5.3 + +- 合并 PR #537(Rou325)的 AutoDL H3 Z0901/Z0902/Z0903 工作流更新。 +- 合并 PR #539(QAyong)的 Agent 回车发送、中文顿号 Skills 触发与快捷键提示优化,并移除工具日志无效的 hover 背景规则。 +- 合并 PR #538(Danborad)的支付FM官方支付插件,补齐插件包、通知路由与管理端展示。 +- 修复 Seedance 2 Mini 720p 价格档错误使用固定计费的问题,改为按输出 Token 动态结算。 + +## v1.5.2 + +- 移除图片节点独立的“图片编辑”入口,保留通过图片节点连线完成编辑的工作流。 +- 合并图片编辑工作流、画布交互修复和主题按钮样式优化,并保留近期项目配置与规则基线。 ## v1.6.0 diff --git a/README.md b/README.md index 9ef625465..d933eff43 100644 --- a/README.md +++ b/README.md @@ -412,5 +412,11 @@ Issue 反馈、技术讨论和产品升级建议可以在微信交流群中沟 最难不过坚持
+ + 春秋 + 春秋
Agent记忆核心贡献者 · VV:A13068788886

+ 558328@qq.com
+
+ diff --git a/VERSION b/VERSION index a20e2d82d..f074f24d2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.7.0 +v1.5.4 diff --git a/backend/cmd/payment-zhifufm/main.go b/backend/cmd/payment-zhifufm/main.go new file mode 100644 index 000000000..f84e58fc5 --- /dev/null +++ b/backend/cmd/payment-zhifufm/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "net/http" + "os" + "time" + + paymentplugins "infinite-canvas/backend/payment-plugins" +) + +func main() { + provider := paymentplugins.NewZhiFuFMProvider(&http.Client{Timeout: 25 * time.Second}) + if err := paymentplugins.RunRPC(provider, os.Stdin, os.Stdout); err != nil { + os.Exit(1) + } +} diff --git a/backend/internal/app/admin.go b/backend/internal/app/admin.go index 36065cd03..ea1cc5cd4 100644 --- a/backend/internal/app/admin.go +++ b/backend/internal/app/admin.go @@ -88,7 +88,6 @@ type AdminReferenceData struct { type ChannelRequest struct { Name string `json:"name"` - PublicAlias *string `json:"publicAlias"` SortOrder *int `json:"sortOrder"` BaseURL string `json:"baseUrl"` APIKey string `json:"apiKey"` @@ -106,7 +105,6 @@ type PublicModelChannel struct { Scope model.ChannelScope `json:"scope"` Enabled bool `json:"enabled"` Name string `json:"name"` - PublicAlias string `json:"publicAlias,omitempty"` SortOrder int `json:"sortOrder"` BaseURL string `json:"baseUrl"` APIKey string `json:"apiKey"` @@ -124,6 +122,8 @@ type PublicModelChannel struct { type PublicChannelModelPrice struct { Model string `json:"model"` DisplayName string `json:"displayName"` + ChannelLabel string `json:"channelLabel"` + Description string `json:"description"` Icon string `json:"icon"` Capability string `json:"capability"` Protocol model.ChannelInterfaceType `json:"protocol"` @@ -878,13 +878,6 @@ func (s *Service) channelFromRequest(req ChannelRequest, channel model.ModelChan return channel, err } channel.Name = name - if req.PublicAlias != nil { - alias := strings.TrimSpace(*req.PublicAlias) - if len([]rune(alias)) > 80 { - return channel, BadAuthRequest("前台显示别名不能超过 80 个字符") - } - channel.PublicAlias = alias - } if req.SortOrder != nil { if err := validateChannelSortOrder(*req.SortOrder); err != nil { return channel, err @@ -949,7 +942,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model capabilityConfig = normalized } } - modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Icon: item.Icon, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, CapabilityConfig: capabilityConfig}) + modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, ChannelLabel: item.ChannelLabel, Description: item.Description, Icon: item.Icon, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, CapabilityConfig: capabilityConfig}) } } if len(models) == 0 { @@ -969,17 +962,12 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model } else if admin { apiKey = channel.APIKey } - name, alias := channel.PublicName(), "" - if admin { - name, alias = channel.Name, channel.PublicAlias - } return PublicModelChannel{ ID: channel.ID, UserID: channel.UserID, Scope: channel.Scope, Enabled: channel.Enabled, - Name: name, - PublicAlias: alias, + Name: channel.Name, SortOrder: channel.SortOrder, BaseURL: baseURL, APIKey: apiKey, diff --git a/backend/internal/app/admin_channel_test.go b/backend/internal/app/admin_channel_test.go index a50650943..b48566999 100644 --- a/backend/internal/app/admin_channel_test.go +++ b/backend/internal/app/admin_channel_test.go @@ -141,7 +141,7 @@ func TestDuplicateSystemChannelCopiesSecretsModelsAndPriceTiers(t *testing.T) { if err != nil { t.Fatalf("DuplicateSystemChannel() error = %v", err) } - if copied.Name != "方舟视频 - 副本" || copied.PublicAlias != source.PublicAlias || copied.Enabled != source.Enabled { + if copied.Name != "方舟视频 - 副本" || copied.Enabled != source.Enabled { t.Fatalf("copied channel summary = %#v", copied) } var stored model.ModelChannel diff --git a/backend/internal/app/all_video_token_integration_test.go b/backend/internal/app/all_video_token_integration_test.go new file mode 100644 index 000000000..4bbad7016 --- /dev/null +++ b/backend/internal/app/all_video_token_integration_test.go @@ -0,0 +1,86 @@ +package app + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestAllVideoProtocolsQuoteReserveAndSettleWithoutProviderUsage(t *testing.T) { + for _, protocol := range []model.ChannelInterfaceType{"newapi-channel-2", "newapi-video", "custom-video"} { + t.Run(string(protocol), func(t *testing.T) { + svc, db, channel, channelModel, _ := newVideoTokenQuoteFixture(t) + if err := db.AutoMigrate(&model.ApiCallLog{}); err != nil { + t.Fatal(err) + } + if err := db.Model(&model.ChannelModel{}).Where("id = ?", channelModel.ID).Update("protocol", protocol).Error; err != nil { + t.Fatal(err) + } + intent := ModelRequestIntent{Capability: "video", Options: map[string]any{"vquality": "720p", "size": "16:9", "videoSeconds": 5}} + quote, err := svc.QuoteChannelModel(ChannelModelQuoteRequest{ChannelID: channel.ID, ModelKey: channelModel.ModelKey, Intent: intent}) + if err != nil { + t.Fatal(err) + } + assertVideoTokenQuote(t, quote, 1_900_800) + input := quoteInput(intent, channelModel.ModelKey) + input["config"].(map[string]any)["channelId"] = channel.ID + input, err = svc.resolveSystemChannelModelSelection(input, "canvas_video", "") + if err != nil { + t.Fatal(err) + } + order, err := svc.taskBillingOrder("quote-user", &model.Task{ID: "generic-video", Type: "canvas_video"}, input) + if err != nil { + t.Fatal(err) + } + if order.VideoFormulaTokens != 108_000 || order.AmountMicrocredits != quote.AmountMicrocredits { + t.Fatalf("quote/order disagree: %#v / %#v", quote, order) + } + if err := svc.repo.ReserveBillingOrder(order); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err := svc.SettleBilling(order.ID, "completed-video"); err != nil { + t.Fatal(err) + } + } + settled, err := svc.repo.BillingOrder(order.ID) + if err != nil { + t.Fatal(err) + } + if settled.Status != model.BillingStatusSettled || settled.UsageSource != "video_formula" || settled.UsageAvailable || settled.OutputTokens != 108_000 || settled.ActualAmountMicrocredits != 1_728_000 || settled.RefundedAmountMicrocredits != 172_800 { + t.Fatalf("wrong formula settlement: %#v", settled) + } + var account model.CreditAccount + if err := db.First(&account, "user_id = ?", "quote-user").Error; err != nil { + t.Fatal(err) + } + if account.AvailableMicrocredits != 10_000_000-1_728_000 || account.ReservedMicrocredits != 250_000 { + t.Fatalf("incorrect balance after idempotent settlement: %#v", account) + } + }) + } +} + +func TestGenericVideoTokenDimensions(t *testing.T) { + for _, test := range []struct { + resolution, size string + width, height int64 + }{ + {"1440p", "16:9", 2560, 1440}, + {"2K", "9:16", 1440, 2560}, + {"960p", "4:3", 1280, 960}, + {"768p竖", "9:16", 768, 1366}, + {"720p", "2:1", 1440, 720}, + {"", "1280x720", 1280, 720}, + {"", "", 1280, 720}, + } { + t.Run(test.resolution+"/"+test.size, func(t *testing.T) { + got := estimateArkVideoTokens(map[string]any{"config": map[string]any{ + "model": "generic-video", "vquality": test.resolution, "size": test.size, "videoSeconds": 5, + }}) + if got.Err != nil || got.Video.OutputWidth != test.width || got.Video.OutputHeight != test.height { + t.Fatalf("dimensions=%#v error=%v", got.Video, got.Err) + } + }) + } +} diff --git a/backend/internal/app/analytics.go b/backend/internal/app/analytics.go index 7eab218d5..48a5d4957 100644 --- a/backend/internal/app/analytics.go +++ b/backend/internal/app/analytics.go @@ -285,6 +285,14 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error { if order, exists := billingOrderByID[logs[index].BillingOrderID]; exists && order.UserID == logs[index].UserID { logs[index].BillingAvailable = true logs[index].BillingStatus = order.Status + if order.ChannelID == logs[index].ChannelID { + logs[index].CreditCostConfigured = order.CostPricing.Configured + cost, costErr := billingCreditCost(order) + if costErr != nil { + return costErr + } + logs[index].CreditCostMicrocredits = cost + } if order.Status == model.BillingStatusSettled { logs[index].BillingAmount = order.ActualAmountMicrocredits } else if order.Status != model.BillingStatusRefunded { @@ -403,7 +411,7 @@ func (s *Service) AdminAPICallLogsCSV(actor *model.User, query APICallLogQuery) var buffer bytes.Buffer buffer.WriteString("\xEF\xBB\xBF") writer := csv.NewWriter(&buffer) - _ = writer.Write([]string{"时间", "用户", "用户账号", "渠道", "模型", "能力", "状态", "轮询次数", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "积分计费(微积分)", "积分计费状态", "上游估算费用(微单位)", "币种", "错误码", "错误"}) + _ = writer.Write([]string{"时间", "用户", "用户账号", "渠道", "模型", "能力", "状态", "轮询次数", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "销售价格(微积分)", "积分计费状态", "成本价格(微积分)", "上游估算费用(微单位)", "币种", "错误码", "错误"}) for _, log := range logs { startedAt := log.StartedAt if startedAt.IsZero() { @@ -415,10 +423,14 @@ func (s *Service) AdminAPICallLogsCSV(actor *model.User, query APICallLogQuery) billingStatus = string(log.BillingStatus) } upstreamCost := "" + creditCost := "" + if log.CreditCostMicrocredits != nil { + creditCost = strconv.FormatInt(*log.CreditCostMicrocredits, 10) + } if log.CostAvailable { upstreamCost = strconv.FormatInt(log.EstimatedCostMicros, 10) } - _ = writer.Write([]string{startedAt.UTC().Format(time.RFC3339), log.UserDisplayName, log.UserAccount, log.ChannelName, log.Model, log.Capability, string(log.Status), strconv.Itoa(log.PollCount), strconv.FormatInt(log.DurationMs, 10), strconv.FormatInt(log.InputTokens, 10), strconv.FormatInt(log.OutputTokens, 10), strconv.FormatInt(log.CachedTokens, 10), billingAmount, billingStatus, upstreamCost, log.Currency, log.ErrorCode, log.Error}) + _ = writer.Write([]string{startedAt.UTC().Format(time.RFC3339), log.UserDisplayName, log.UserAccount, log.ChannelName, log.Model, log.Capability, string(log.Status), strconv.Itoa(log.PollCount), strconv.FormatInt(log.DurationMs, 10), strconv.FormatInt(log.InputTokens, 10), strconv.FormatInt(log.OutputTokens, 10), strconv.FormatInt(log.CachedTokens, 10), billingAmount, billingStatus, creditCost, upstreamCost, log.Currency, log.ErrorCode, log.Error}) } writer.Flush() if err := writer.Error(); err != nil { @@ -1035,8 +1047,12 @@ func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[str log.Error = errorMessage } } + arkVideo := log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") usage, _ := payload["usage"].(map[string]any) - if usage != nil { + if log.Capability == "video" { + log.InputTokens, log.CachedTokens = 0, 0 + log.OutputTokens, log.UsageAvailable = videoCompletionTokens(payload, arkVideo) + } else if usage != nil { inputTokens, inputAvailable := firstInt64Value(usage, "input_tokens", "prompt_tokens") outputTokens, outputAvailable := firstInt64Value(usage, "output_tokens", "completion_tokens") if inputAvailable { @@ -1048,14 +1064,6 @@ func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[str if inputAvailable || outputAvailable { log.UsageAvailable = true } - // 火山方舟视频的输入 Token 恒为 0;查询任务以 completion_tokens 为实际用量, - // 兼容只返回 total_tokens 的同协议中转实现。 - if log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") { - if log.OutputTokens == 0 { - log.OutputTokens = firstInt64(usage, "total_tokens") - } - log.UsageAvailable = log.OutputTokens > 0 - } if details, ok := usage["input_tokens_details"].(map[string]any); ok { log.CachedTokens = firstInt64(details, "cached_tokens", "cache_read_input_tokens") } @@ -1066,7 +1074,7 @@ func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[str log.CachedTokens = firstInt64(usage, "cached_tokens", "cache_read_input_tokens", "prompt_cache_hit_tokens") } } - if usageMetadata, ok := payload["usageMetadata"].(map[string]any); ok { + if usageMetadata, ok := payload["usageMetadata"].(map[string]any); ok && log.Capability != "video" { inputTokens, inputAvailable := firstInt64Value(usageMetadata, "promptTokenCount") outputTokens, outputAvailable := firstInt64Value(usageMetadata, "candidatesTokenCount") if inputAvailable { @@ -1101,12 +1109,40 @@ func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[str } } +func videoCompletionTokens(payload map[string]any, arkProtocol bool) (int64, bool) { + if status, exists := payload["status"]; exists { + text, ok := status.(string) + status := strings.ToLower(strings.TrimSpace(text)) + if !ok || (status != "succeeded" && (arkProtocol || (status != "completed" && status != "success"))) { + return 0, false + } + } + usage, _ := payload["usage"].(map[string]any) + value, exists := usage["completion_tokens"] + if !exists && !arkProtocol { + value, exists = usage["output_tokens"] + } + if !exists { + value = usage["total_tokens"] + } + // completion_tokens 一旦返回就是唯一结算依据,非法值不能用 total_tokens 掩盖。 + // JSON Number 保留整数精度,避免浮点取整把小数或超出 int64 的值变成可计费用量。 + number, ok := value.(json.Number) + if !ok { + return 0, false + } + tokens, err := number.Int64() + if err != nil || tokens <= 0 { + return 0, false + } + return tokens, true +} + func providerResponsePayloads(responseBody []byte) []map[string]any { if len(responseBody) == 0 { return nil } - var payload map[string]any - if json.Unmarshal(responseBody, &payload) == nil { + if payload, ok := decodeProviderResponsePayload(responseBody); ok { return []map[string]any{payload} } @@ -1121,8 +1157,7 @@ func providerResponsePayloads(responseBody []byte) []map[string]any { if raw == "" || raw == "[DONE]" { return } - var event map[string]any - if json.Unmarshal([]byte(raw), &event) == nil { + if event, ok := decodeProviderResponsePayload([]byte(raw)); ok { result = append(result, event) } } @@ -1140,6 +1175,17 @@ func providerResponsePayloads(responseBody []byte) []map[string]any { return result } +func decodeProviderResponsePayload(data []byte) (map[string]any, bool) { + if !json.Valid(data) { + return nil, false + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var payload map[string]any + err := decoder.Decode(&payload) + return payload, err == nil && payload != nil +} + func providerRequestIDFromPath(path string) string { parts := strings.Split(strings.Trim(strings.TrimSpace(path), "/"), "/") for index := len(parts) - 1; index >= 0; index-- { diff --git a/backend/internal/app/api_log_billing_test.go b/backend/internal/app/api_log_billing_test.go index 4533465b6..be7c414cc 100644 --- a/backend/internal/app/api_log_billing_test.go +++ b/backend/internal/app/api_log_billing_test.go @@ -26,7 +26,7 @@ func TestDecorateAPICallLogsUsesBillingOrderSnapshot(t *testing.T) { } orders := []model.BillingOrder{ - {ID: "settled", UserID: "user-1", IdempotencyKey: "settled", Status: model.BillingStatusSettled, ReservedAmountMicrocredits: 1_000_000, ActualAmountMicrocredits: 780_000}, + {ID: "settled", UserID: "user-1", IdempotencyKey: "settled", Status: model.BillingStatusSettled, ReservedAmountMicrocredits: 1_000_000, ActualAmountMicrocredits: 780_000, BillingCostSnapshot: model.BillingCostSnapshot{CostBillingMode: "fixed_request", CostQuantity: 1, CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 456_789}}}, {ID: "reserved", UserID: "user-1", IdempotencyKey: "reserved", Status: model.BillingStatusReserved, ReservedAmountMicrocredits: 900_000}, {ID: "uncertain", UserID: "user-1", IdempotencyKey: "uncertain", Status: model.BillingStatusUncertain, ReservedAmountMicrocredits: 1_200_000}, {ID: "refunded", UserID: "user-1", IdempotencyKey: "refunded", Status: model.BillingStatusRefunded, ReservedAmountMicrocredits: 650_000, RefundedAmountMicrocredits: 650_000}, @@ -58,6 +58,14 @@ func TestDecorateAPICallLogsUsesBillingOrderSnapshot(t *testing.T) { assertBillingSnapshot(t, logs[4], false, "", 0) assertBillingSnapshot(t, logs[5], false, "", 0) assertBillingSnapshot(t, logs[6], false, "", 0) + if !logs[0].CreditCostConfigured || logs[0].CreditCostMicrocredits == nil || *logs[0].CreditCostMicrocredits != 456_789 { + t.Fatalf("request cost missing: %#v", logs[0]) + } + for _, log := range logs[1:] { + if log.CreditCostConfigured || log.CreditCostMicrocredits != nil { + t.Fatalf("fabricated cost for %s", log.ID) + } + } } func assertBillingSnapshot(t *testing.T, log model.ApiCallLog, available bool, status model.BillingStatus, amount int64) { diff --git a/backend/internal/app/appearance.go b/backend/internal/app/appearance.go index be40f3328..6cc40b730 100644 --- a/backend/internal/app/appearance.go +++ b/backend/internal/app/appearance.go @@ -31,7 +31,7 @@ const ( ) const ( - appearanceSchemaVersion = 7 + appearanceSchemaVersion = 8 appearanceLogoMaxBytes int64 = 5 << 20 appearancePosterMaxBytes int64 = 10 << 20 appearanceVideoMaxBytes int64 = 256 << 20 @@ -346,6 +346,9 @@ func (s *Service) readAppearance() (*model.SystemSetting, AppearanceSetting, err return nil, AppearanceSetting{}, err } value := defaultAppearanceSetting() + // Decode stored themes without reusing built-in slice entries: omitted + // fields must be migrated according to each stored theme's identity. + value.SkinThemes = nil if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil { return nil, AppearanceSetting{}, errors.New("外观配置格式无效") } diff --git a/backend/internal/app/appearance_button_fill_test.go b/backend/internal/app/appearance_button_fill_test.go new file mode 100644 index 000000000..bb380d7ed --- /dev/null +++ b/backend/internal/app/appearance_button_fill_test.go @@ -0,0 +1,111 @@ +package app + +import ( + "encoding/json" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestAppearanceButtonFillRoundTripAndValidation(t *testing.T) { + svc, _, _, admin := newAppearanceTestService(t) + themes := defaultAppearanceSkinThemes() + custom := cloneAppearanceSkin(themes[0], "custom-gradient", "渐变主题", "主按钮渐变") + custom.Tokens.Buttons.Light.Angle = 45 + custom.Tokens.Buttons.Light.Start = "#123456" + custom.Tokens.Buttons.Dark.Mode = "solid" + themes = append(themes, custom) + input := AppearanceSetting{BrandName: "测试站点", BrandSlug: "test", AuthHeroTitle: defaultAppearanceHeroTitle, SkinID: custom.ID, SkinThemes: themes} + saved, err := svc.UpdateAppearance(admin, input) + if err != nil { + t.Fatal(err) + } + if saved.Public.ActiveSkin.Tokens.Buttons != custom.Tokens.Buttons { + t.Fatal("public projection lost button parameters") + } + loaded, err := svc.AdminAppearance(admin) + if err != nil { + t.Fatal(err) + } + if loaded.Public.ActiveSkin.Tokens.Buttons != custom.Tokens.Buttons { + t.Fatal("stored button parameters changed") + } + for name, mutate := range map[string]func(*AppearanceSkinButtonFill){ + "mode": func(fill *AppearanceSkinButtonFill) { fill.Mode = "image" }, + "negative angle": func(fill *AppearanceSkinButtonFill) { fill.Angle = -1 }, + "large angle": func(fill *AppearanceSkinButtonFill) { fill.Angle = 361 }, + "css injection": func(fill *AppearanceSkinButtonFill) { fill.End = "red; background:url(x)" }, + "missing color": func(fill *AppearanceSkinButtonFill) { fill.Foreground = "" }, + } { + t.Run(name, func(t *testing.T) { + bad := input + bad.SkinThemes = append([]AppearanceSkinTheme(nil), themes...) + mutate(&bad.SkinThemes[len(themes)-1].Tokens.Buttons.Light) + if _, err := svc.UpdateAppearance(admin, bad); err == nil { + t.Fatal("invalid fill accepted") + } + }) + } + input.SkinThemes[0].Tokens.Buttons.Light.Start = "#000000" + if _, err := svc.UpdateAppearance(admin, input); err == nil { + t.Fatal("classic fill mutation accepted") + } +} + +func TestAppearanceLegacyButtonFills(t *testing.T) { + themes := defaultAppearanceSkinThemes() + for index := range themes { + themes[index].Tokens.Buttons = AppearanceSkinButtons{} + } + migrated := normalizeAppearanceSkinThemes(themes) + if err := validateAppearanceSkinThemes(migrated, "classic"); err != nil { + t.Fatal(err) + } + for _, skin := range migrated { + expected := "solid" + if skin.ID == "classic" { + expected = "gradient" + } + if skin.Tokens.Buttons.Light.Mode != expected || skin.Tokens.Buttons.Dark.Mode != expected { + t.Fatalf("wrong fill for %s", skin.ID) + } + } + var fill AppearanceSkinButtonFill + if err := json.Unmarshal([]byte(`{"angle":1.5}`), &fill); err == nil { + t.Fatal("fractional angle accepted") + } +} + +func TestAppearanceReadsLegacyButtonFillsByStoredIdentity(t *testing.T) { + svc, db, _, admin := newAppearanceTestService(t) + classic := defaultClassicAppearanceSkin() + custom := cloneAppearanceSkin(classic, "custom-legacy", "旧主题", "保持纯色") + encoded, err := json.Marshal([]AppearanceSkinTheme{custom, classic}) + if err != nil { + t.Fatal(err) + } + var themes []map[string]any + if err := json.Unmarshal(encoded, &themes); err != nil { + t.Fatal(err) + } + for _, skin := range themes { + delete(skin["tokens"].(map[string]any), "buttons") + } + encoded, err = json.Marshal(map[string]any{"schemaVersion": 7, "skinId": custom.ID, "skinThemes": themes}) + if err != nil { + t.Fatal(err) + } + if err := db.Create(&model.SystemSetting{Key: appearanceSettingKey, ValueJSON: string(encoded)}).Error; err != nil { + t.Fatal(err) + } + loaded, err := svc.AdminAppearance(admin) + if err != nil { + t.Fatal(err) + } + if loaded.Public.ActiveSkin.Tokens.Buttons.Light.Mode != "solid" || loaded.SkinThemes[1].Tokens.Buttons.Light.Mode != "gradient" { + t.Fatal("legacy fields inherited another theme's fill") + } + if err := validateAppearanceSkinThemes(loaded.SkinThemes, custom.ID); err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/app/appearance_skins.go b/backend/internal/app/appearance_skins.go index 31dd71f7c..8cae965f5 100644 --- a/backend/internal/app/appearance_skins.go +++ b/backend/internal/app/appearance_skins.go @@ -96,6 +96,35 @@ type AppearanceSkinTokens struct { Light AppearanceSkinModeTokens `json:"light"` Dark AppearanceSkinModeTokens `json:"dark"` Components AppearanceSkinComponentTokens `json:"components"` + Buttons AppearanceSkinButtons `json:"buttons"` +} + +type AppearanceSkinButtons struct { + Light AppearanceSkinButtonFill `json:"light"` + Dark AppearanceSkinButtonFill `json:"dark"` +} + +type AppearanceSkinButtonFill struct { + Mode string `json:"mode"` + Angle int `json:"angle"` + Start string `json:"start"` + End string `json:"end"` + HoverStart string `json:"hoverStart"` + HoverEnd string `json:"hoverEnd"` + ActiveStart string `json:"activeStart"` + ActiveEnd string `json:"activeEnd"` + Foreground string `json:"foreground"` +} + +func defaultAppearanceSkinButtons(gradient bool) AppearanceSkinButtons { + fill := AppearanceSkinButtonFill{ + Mode: "solid", Angle: 115, Start: "#6554df", End: "#386fbc", + HoverStart: "#5744cf", HoverEnd: "#356bbb", ActiveStart: "#4938b8", ActiveEnd: "#2c5da5", Foreground: "#ffffff", + } + if gradient { + fill.Mode = "gradient" + } + return AppearanceSkinButtons{Light: fill, Dark: fill} } type AppearanceSkinTheme struct { @@ -159,6 +188,9 @@ func defaultAppearanceSkinThemes() []AppearanceSkinTheme { }) violet.Tokens.Components = appearanceSkinComponentPreset(7, 7, 12, 14, 9, 4) + studio.Tokens.Buttons = defaultAppearanceSkinButtons(false) + warm.Tokens.Buttons = defaultAppearanceSkinButtons(false) + violet.Tokens.Buttons = defaultAppearanceSkinButtons(false) return []AppearanceSkinTheme{classic, studio, warm, violet} } @@ -181,6 +213,7 @@ func defaultClassicAppearanceSkin() AppearanceSkinTheme { AdminBackground: "#101010", AdminSurface: "#181818", AdminSubtle: "#202020", AdminStrong: "#2a2a2a", AuthBackground: "#08090c", AuthPanel: "#0b0c10", AuthCard: "#121318", AuthAccent: "#93c5fd", AuthMuted: "#8a8b91", }, Components: appearanceSkinComponentPreset(6, 6, 12, 12, 8, 4), + Buttons: defaultAppearanceSkinButtons(true), }, } } @@ -234,6 +267,11 @@ func normalizeAppearanceSkinThemes(themes []AppearanceSkinTheme) []AppearanceSki result[index].Name = strings.TrimSpace(result[index].Name) result[index].Description = strings.TrimSpace(result[index].Description) result[index].Locked = result[index].ID == defaultAppearanceSkinID + // Only a wholly absent legacy button block is upgraded. Partial or + // malformed submitted parameters remain invalid on the write path. + if result[index].Tokens.Buttons == (AppearanceSkinButtons{}) { + result[index].Tokens.Buttons = defaultAppearanceSkinButtons(result[index].Locked) + } var fallback AppearanceSkinTokens for _, builtin := range builtins { if builtin.ID == result[index].ID { @@ -325,6 +363,11 @@ func validateAppearanceSkinThemes(themes []AppearanceSkinTheme, selectedID strin if err := validateAppearanceSkinComponents(skin.Tokens.Components); err != nil { return err } + for _, fill := range []AppearanceSkinButtonFill{skin.Tokens.Buttons.Light, skin.Tokens.Buttons.Dark} { + if err := validateAppearanceSkinButtonFill(fill); err != nil { + return err + } + } } if !foundClassic { return BadAuthRequest("经典黑白为系统默认主题,不能修改或删除") @@ -345,6 +388,21 @@ func validateAppearanceSkinMode(mode AppearanceSkinModeTokens) error { return nil } +func validateAppearanceSkinButtonFill(fill AppearanceSkinButtonFill) error { + if fill.Mode != "solid" && fill.Mode != "gradient" { + return BadAuthRequest("主按钮填充模式无效") + } + if fill.Angle < 0 || fill.Angle > 360 { + return BadAuthRequest("主按钮渐变角度必须在 0 到 360 之间") + } + for _, color := range []string{fill.Start, fill.End, fill.HoverStart, fill.HoverEnd, fill.ActiveStart, fill.ActiveEnd, fill.Foreground} { + if !appearanceColorPattern.MatchString(color) { + return BadAuthRequest("主按钮颜色必须使用 6 或 8 位十六进制颜色") + } + } + return nil +} + func validateAppearanceSkinComponents(value AppearanceSkinComponentTokens) error { for _, candidate := range []struct { value, min, max int diff --git a/backend/internal/app/channel_model_label_test.go b/backend/internal/app/channel_model_label_test.go new file mode 100644 index 000000000..63fa7ee84 --- /dev/null +++ b/backend/internal/app/channel_model_label_test.go @@ -0,0 +1,86 @@ +package app + +import ( + "strings" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestChannelModelLabelSaveAndCatalogPreserveChannelIdentity(t *testing.T) { + svc, db := newChannelModelTestService(t) + if err := db.AutoMigrate(&model.SystemSetting{}); err != nil { + t.Fatal(err) + } + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + enabled := true + request := ChannelModelRequest{ + ModelKey: "gpt-test", ProviderModelKey: "upstream-text", DisplayName: "GPT Test", Icon: "OpenAI", + ChannelLabel: " 优惠渠道-993 ", Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion), + Description: " 适合分镜脚本,请先确认输入要求。 ", + CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "gpt-test"), + PriceTiers: []ChannelModelPriceTierRequest{{BillingMode: "fixed_request", UnitPriceMicrocredits: 300000, PriceConfigured: true, Enabled: &enabled}}, + Enabled: &enabled, + } + var firstID string + for _, id := range []string{"channel-a", "channel-b"} { + channel := model.ModelChannel{ID: id, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: "https://example.invalid/v1", APIKey: "synthetic", ModelsJSON: `[]`} + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + saved, err := svc.SaveAdminChannelModel(admin, id, "", request) + if err != nil { + t.Fatal(err) + } + var stored model.ChannelModel + if err := db.First(&stored, "id = ?", saved.ID).Error; err != nil { + t.Fatal(err) + } + if stored.ChannelLabel != "优惠渠道-993" || stored.ModelKey != "gpt-test" || stored.ProviderModelKey != "upstream-text" || stored.ChannelID != id { + t.Fatalf("stored channel model: %#v", stored) + } + legacy := publicChannel(channel, false, []model.ChannelModel{*saved}) + if stored.Description != "适合分镜脚本,请先确认输入要求。" || legacy.ModelCosts[0].Description != stored.Description { + t.Fatal("model description was not persisted or projected") + } + if legacy.ModelCosts[0].ChannelLabel != "优惠渠道-993" { + t.Fatal("session projection lost channel label") + } + if id == "channel-a" { + firstID = saved.ID + } + } + catalog, err := svc.ModelCatalog(nil) + if err != nil || len(catalog.Channels) != 2 { + t.Fatalf("catalog: %#v, %v", catalog, err) + } + for _, channel := range catalog.Channels { + if len(channel.Models) == 1 && channel.Models[0].Description != "适合分镜脚本,请先确认输入要求。" { + t.Fatal("catalog lost model description") + } + if len(channel.Models) != 1 || channel.Models[0].ChannelLabel != "优惠渠道-993" || !channel.Models[0].Available || channel.Models[0].PriceTiers[0].UnitPriceMicrocredits != 300000 { + t.Fatalf("catalog lost label or pricing: %#v", channel) + } + } + request.ChannelLabel = strings.Repeat("字", 81) + if _, err := svc.SaveAdminChannelModel(admin, "channel-a", firstID, request); err == nil { + t.Fatal("accepted oversized channel label") + } + request.ChannelLabel = " " + request.Description = strings.Repeat("字", 501) + if _, err := svc.SaveAdminChannelModel(admin, "channel-a", firstID, request); err == nil { + t.Fatal("accepted oversized description") + } + request.Description = strings.Repeat("字", 500) + if _, err := svc.SaveAdminChannelModel(admin, "channel-a", firstID, request); err != nil { + t.Fatalf("rejected valid 500-character description: %v", err) + } + request.Description = " " + updated, err := svc.SaveAdminChannelModel(admin, "channel-a", firstID, request) + if err != nil || updated.ChannelLabel != "" || updated.Description != "" || updated.ID != firstID { + t.Fatalf("label clearing changed identity: %#v, %v", updated, err) + } + if _, err := svc.SaveAdminChannelModel(&model.User{ID: "user", Role: model.UserRoleUser}, "channel-a", firstID, request); err == nil { + t.Fatal("non-admin changed channel label") + } +} diff --git a/backend/internal/app/channel_models.go b/backend/internal/app/channel_models.go index a218405c7..b8f2793dd 100644 --- a/backend/internal/app/channel_models.go +++ b/backend/internal/app/channel_models.go @@ -21,8 +21,9 @@ type ChannelModelRequest struct { ModelKey string `json:"modelKey"` ProviderModelKey string `json:"providerModelKey"` DisplayName string `json:"displayName"` - Icon string `json:"icon"` + ChannelLabel string `json:"channelLabel"` Description string `json:"description"` + Icon string `json:"icon"` Capability string `json:"capability"` Protocol string `json:"protocol"` BillingMode string `json:"billingMode"` @@ -41,7 +42,8 @@ const maxAdminChannelModelBatchDeleteCount = 100 // ChannelModelPriceTierRequest 是系统渠道内某个规格的上游 SKU 与结算价格。 // Resolution="*"、VideoSeconds=0 分别表示任意分辨率和任意时长。 type ChannelModelPriceTierRequest struct { - // Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds、imageCount; + CostPricing model.CreditCostPricing `json:"costPricing"` + // Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds、imageCount、videoGenerateAudio; // operation 可区分文生/图生/视频生,避免同一分辨率下错误复用价格。 Selector map[string]string `json:"selector"` Resolution string `json:"resolution"` @@ -297,6 +299,14 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id if err := s.RequireAdmin(actor); err != nil { return nil, err } + channelLabel := strings.TrimSpace(req.ChannelLabel) + description := strings.TrimSpace(req.Description) + if len([]rune(description)) > 500 { + return nil, BadAuthRequest("模型描述不能超过 500 字") + } + if len([]rune(channelLabel)) > 80 { + return nil, BadAuthRequest("渠道展示名不能超过 80 字") + } channel, err := s.repo.AdminSystemChannel(channelID) if err != nil { return nil, err @@ -348,11 +358,12 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id item.ModelKey = modelKey item.ProviderModelKey = providerModelKey item.DisplayName = strings.TrimSpace(req.DisplayName) + item.ChannelLabel = channelLabel + item.Description = description if item.DisplayName == "" { item.DisplayName = modelKey } item.Icon = strings.TrimSpace(req.Icon) - item.Description = truncateRunes(strings.TrimSpace(req.Description), 600) item.Capability = capability item.Protocol = protocol s.applyChannelModelPriceTierSummary(item, tiers) @@ -439,8 +450,8 @@ func (s *Service) syncLogicalModelsFromChannelModel(actor *model.User, channelMo return nil } -func supportsTokenBilling(capability string, protocol model.ChannelInterfaceType) bool { - return capability == "text" || (capability == "video" && model.IsVolcengineArkVideoProtocol(protocol)) +func supportsTokenBilling(capability string, _ model.ChannelInterfaceType) bool { + return capability == "text" || capability == "video" } func (s *Service) normalizeChannelModelPriceTiers(req ChannelModelRequest, capability string, protocol model.ChannelInterfaceType, fallbackProviderModelKey string) ([]model.ChannelModelPriceTier, error) { @@ -483,6 +494,7 @@ func (s *Service) normalizeChannelModelPriceTiers(req ChannelModelRequest, capab } enabled := input.Enabled == nil || *input.Enabled result = append(result, model.ChannelModelPriceTier{ + CostPricing: input.CostPricing, ID: id, SelectorKey: key, SelectorJSON: key, @@ -539,6 +551,17 @@ func normalizeChannelModelTierSelector(capability string, input ChannelModelPric continue } value = strconv.Itoa(count) + case "videoGenerateAudio": + if capability != "video" { + return nil, "", 0, BadAuthRequest("只有视频模型可以按是否生成音频配置价格档") + } + value = strings.ToLower(value) + if value == "*" { + continue + } + if value != "true" && value != "false" { + return nil, "", 0, BadAuthRequest("视频价格档音频条件必须为 true、false 或 *") + } default: return nil, "", 0, BadAuthRequest("价格档不支持规格字段:" + key) } @@ -584,6 +607,9 @@ func normalizeChannelModelTierSelector(capability string, input ChannelModelPric } func validateChannelModelTierPricing(capability string, protocol model.ChannelInterfaceType, billingMode string, input ChannelModelPriceTierRequest) error { + if err := validateCreditCostPricing(capability, billingMode, input.CostPricing); err != nil { + return err + } if billingMode != "fixed_request" && billingMode != "per_second" && billingMode != "token" { return BadAuthRequest("模型计费方式仅支持按次、按秒或 Token") } @@ -591,19 +617,15 @@ func validateChannelModelTierPricing(capability string, protocol model.ChannelIn return BadAuthRequest("只有视频模型可以按秒计费") } if billingMode == "token" && !supportsTokenBilling(capability, protocol) { - return BadAuthRequest("Token 计费仅支持文本模型和火山方舟视频协议") + return BadAuthRequest("Token 计费仅支持文本和视频模型") } if input.UnitPriceMicrocredits < 0 || input.InputTokenPriceMicrocredits < 0 || input.OutputTokenPriceMicrocredits < 0 || input.CachedTokenPriceMicrocredits < 0 { return BadAuthRequest("模型积分价格不能小于 0") } - if !input.PriceConfigured { - return nil + if billingMode == "token" { + return validateTokenPrices(capability, input.InputTokenPriceMicrocredits, input.OutputTokenPriceMicrocredits, input.CachedTokenPriceMicrocredits) } - const maxTokenPriceMicrocredits = int64(1_000_000) * CreditScale - if input.InputTokenPriceMicrocredits > maxTokenPriceMicrocredits || input.OutputTokenPriceMicrocredits > maxTokenPriceMicrocredits || input.CachedTokenPriceMicrocredits > maxTokenPriceMicrocredits { - return BadAuthRequest("Token 每百万用量价格不能超过 1,000,000 积分") - } - return nil + return validateTokenPrices("", input.InputTokenPriceMicrocredits, input.OutputTokenPriceMicrocredits, input.CachedTokenPriceMicrocredits) } func normalizeChannelModelTierResolution(raw string) string { diff --git a/backend/internal/app/channel_presentation.go b/backend/internal/app/channel_presentation.go index 9d5f2b6fc..a4505dc00 100644 --- a/backend/internal/app/channel_presentation.go +++ b/backend/internal/app/channel_presentation.go @@ -1,14 +1,13 @@ package app import ( - "strings" "time" "infinite-canvas/backend/internal/model" ) func (req ChannelRequest) presentationOnly() bool { - return (req.PublicAlias != nil || req.SortOrder != nil) && req.Name == "" && req.BaseURL == "" && req.APIKey == "" && req.SecretKey == "" && req.ConcurrencyLimit == nil && req.UseGlobalConcurrency == nil && req.Models == nil && req.Headers == nil && req.Enabled == nil + return req.SortOrder != nil && req.Name == "" && req.BaseURL == "" && req.APIKey == "" && req.SecretKey == "" && req.ConcurrencyLimit == nil && req.UseGlobalConcurrency == nil && req.Models == nil && req.Headers == nil && req.Enabled == nil } func (s *Service) updateChannelPresentation(id string, req ChannelRequest) (*PublicModelChannel, error) { @@ -17,14 +16,7 @@ func (s *Service) updateChannelPresentation(id string, req ChannelRequest) (*Pub return nil, err } } - if req.PublicAlias != nil { - alias := strings.TrimSpace(*req.PublicAlias) - if len([]rune(alias)) > 80 { - return nil, BadAuthRequest("前台别名不能超过 80 字") - } - req.PublicAlias = &alias - } - if err := s.repo.UpdateSystemChannelPresentation(id, req.PublicAlias, req.SortOrder, time.Now()); err != nil { + if err := s.repo.UpdateSystemChannelPresentation(id, req.SortOrder, time.Now()); err != nil { return nil, err } s.invalidateRouteCatalog() diff --git a/backend/internal/app/channel_presentation_test.go b/backend/internal/app/channel_presentation_test.go index 0efadb477..5a0e58a8a 100644 --- a/backend/internal/app/channel_presentation_test.go +++ b/backend/internal/app/channel_presentation_test.go @@ -10,12 +10,12 @@ import ( "infinite-canvas/backend/internal/model" ) -func TestChannelPresentationAliasAndPublicOrder(t *testing.T) { +func TestChannelPresentationUsesModelLabelsAndPublicOrder(t *testing.T) { svc, db := newChannelModelTestService(t) svc.dataDir = t.TempDir() admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} for i, id := range []string{"first", "second"} { - channel := model.ModelChannel{ID: id, Name: "内部" + id, Scope: model.ChannelScopeSystem, Enabled: true, BaseURL: "https://dead.invalid/v1", ModelsJSON: `[]`, CreatedAt: time.Unix(int64(i+1), 0)} + channel := model.ModelChannel{ID: id, Name: "渠道" + id, PublicAlias: "已废弃前台名称", Scope: model.ChannelScopeSystem, Enabled: true, BaseURL: "https://dead.invalid/v1", ModelsJSON: `[]`, CreatedAt: time.Unix(int64(i+1), 0)} if err := db.Create(&channel).Error; err != nil { t.Fatal(err) } @@ -27,13 +27,12 @@ func TestChannelPresentationAliasAndPublicOrder(t *testing.T) { t.Fatal(err) } } - alias := " 创作精选 " - updated, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{PublicAlias: &alias}) + updated, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{SortOrder: intPtr(0)}) if err != nil { t.Fatal(err) } - if updated.Name != "内部second" || updated.PublicAlias != "创作精选" { - t.Fatalf("admin alias: %#v", updated) + if updated.Name != "渠道second" { + t.Fatalf("admin name: %#v", updated) } if _, err := svc.UpdateSystemChannel(admin, "first", ChannelRequest{SortOrder: intPtr(20)}); err != nil { t.Fatal(err) @@ -42,52 +41,47 @@ func TestChannelPresentationAliasAndPublicOrder(t *testing.T) { if err != nil || total != 2 || len(adminRows) != 1 || adminRows[0].ID != "second" { t.Fatalf("pagination before sort: %v %v %v", adminRows, total, err) } - search, _, err := svc.repo.AdminSystemChannels("精选", "all", 10, 0) + search, _, err := svc.repo.AdminSystemChannels("second", "all", 10, 0) if err != nil || len(search) != 1 { - t.Fatalf("alias search: %v %v", search, err) + t.Fatalf("name search: %v %v", search, err) } catalog, err := svc.publicSystemChannelCatalog(nil) if err != nil { t.Fatal(err) } - if len(catalog) != 2 || catalog[0].ID != "second" || catalog[0].Name != "创作精选" || catalog[0].DisplayName != "创作精选" { + if len(catalog) != 2 || catalog[0].ID != "second" || catalog[0].Name != "渠道second" || catalog[0].DisplayName != "渠道second" { t.Fatalf("public catalog: %#v", catalog) } legacy, err := svc.PublicSystemChannels() if err != nil { t.Fatal(err) } - if legacy[0].Name != "创作精选" { + if legacy[0].Name != "渠道second" { t.Fatalf("public session: %#v", legacy) } encoded, _ := json.Marshal([]any{catalog[0], legacy[0]}) - if strings.Contains(string(encoded), "内部second") { - t.Fatal("internal channel name leaked into public payload") + if strings.Contains(string(encoded), "已废弃前台名称") || strings.Contains(string(encoded), "publicAlias") { + t.Fatal("retired alias leaked into public payload") } if _, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{SortOrder: intPtr(5)}); err != nil { t.Fatal(err) } stored, _ := svc.repo.AdminSystemChannel("second") - if stored.PublicAlias != "创作精选" { - t.Fatal("partial sort patch cleared alias") + if stored.Name != "渠道second" { + t.Fatal("partial sort patch changed channel name") } - alias = " " - if _, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{PublicAlias: &alias, SortOrder: intPtr(0)}); err != nil { + if _, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{SortOrder: intPtr(0)}); err != nil { t.Fatal(err) } catalog, err = svc.publicSystemChannelCatalog(nil) - if err != nil || catalog[0].Name != "内部second" { - t.Fatalf("empty alias fallback: %v %v", catalog, err) + if err != nil || catalog[0].Name != "渠道second" { + t.Fatalf("channel name: %v %v", catalog, err) } for _, invalid := range []int{-1, 1000000} { if _, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{SortOrder: &invalid}); err == nil { t.Fatal("invalid sort accepted") } } - alias = strings.Repeat("字", 81) - if _, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{PublicAlias: &alias}); err == nil { - t.Fatal("long alias accepted") - } } func TestChannelModelSortPreservesConfigurationAndOwnership(t *testing.T) { diff --git a/backend/internal/app/credit_cost.go b/backend/internal/app/credit_cost.go new file mode 100644 index 000000000..339920617 --- /dev/null +++ b/backend/internal/app/credit_cost.go @@ -0,0 +1,70 @@ +package app + +import ( + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" +) + +func validateCreditCostPricing(capability, billingMode string, cost model.CreditCostPricing) error { + for _, price := range []int64{cost.UnitPriceMicrocredits, cost.InputTokenPriceMicrocredits, cost.OutputTokenPriceMicrocredits, cost.CachedTokenPriceMicrocredits} { + if price < 0 || price > 1_000_000_000_000 { + return BadAuthRequest("积分成本价必须在 0 到 1000000 积分之间") + } + } + if !cost.Configured { + return nil + } + if billingMode != "token" { + capability = "" + } + return validateTokenPrices(capability, cost.InputTokenPriceMicrocredits, cost.OutputTokenPriceMicrocredits, cost.CachedTokenPriceMicrocredits) +} + +func snapshotCreditCost(order *model.BillingOrder, tier *model.ChannelModelPriceTier, quantity int64, estimate tokenBillingEstimate) { + if tier == nil { + return + } + order.CostPricing = tier.CostPricing + order.CostBillingMode = tier.BillingMode + order.CostQuantity = 1 + if tier.BillingMode == "per_second" { + order.CostQuantity = quantity + } + if tier.BillingMode == "token" && estimate.Video != nil { + order.CostVideoFormulaTokens = estimate.Video.FormulaTokens + } +} + +// 成本使用下单快照和真实用量,不套用销售倍率或用户扣费上限。 +// 未结算、未配置或无法确定用量时不编造金额,历史订单也不反查当前价格。 +func billingCreditCost(order model.BillingOrder) (*int64, error) { + if !order.CostPricing.Configured || order.Status != model.BillingStatusSettled { + return nil, nil + } + cost := order.CostPricing + var amount int64 + var err error + switch order.CostBillingMode { + case "fixed_request", "per_second": + amount, err = creditAmount(cost.UnitPriceMicrocredits, order.CostQuantity, 10_000) + case "token": + input, output, cached := order.InputTokens, order.OutputTokens, order.CachedTokens + if !order.UsageAvailable { + if order.Capability != "video" || order.CostVideoFormulaTokens <= 0 { + return nil, nil + } + input, cached, output = 0, 0, order.CostVideoFormulaTokens + } + amount, err = kernel.TokenBillingAmount(10_000, + kernel.TokenBillingTerm{Tokens: max(0, input-cached), PriceMicrocredits: cost.InputTokenPriceMicrocredits}, + kernel.TokenBillingTerm{Tokens: output, PriceMicrocredits: cost.OutputTokenPriceMicrocredits}, + kernel.TokenBillingTerm{Tokens: cached, PriceMicrocredits: cost.CachedTokenPriceMicrocredits}, + ) + default: + return nil, nil + } + if err != nil { + return nil, err + } + return &amount, nil +} diff --git a/backend/internal/app/credit_cost_test.go b/backend/internal/app/credit_cost_test.go new file mode 100644 index 000000000..337df93ca --- /dev/null +++ b/backend/internal/app/credit_cost_test.go @@ -0,0 +1,142 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestCreditCostSnapshotPersistsWithoutChangingSalesOrPublicResponses(t *testing.T) { + svc, db := newChannelModelTestService(t) + if err := db.AutoMigrate(&model.SystemSetting{}, &model.BillingOrder{}); err != nil { + t.Fatal(err) + } + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + channel := model.ModelChannel{ID: "channel", Scope: model.ChannelScopeSystem, Name: "供应渠道", Enabled: true, ModelsJSON: `[]`} + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + req := ChannelModelRequest{ + ModelKey: "model-a", DisplayName: "产品模型", ChannelLabel: "优惠渠道", Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion), + CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "model-a"), + PriceTiers: []ChannelModelPriceTierRequest{{BillingMode: "fixed_request", UnitPriceMicrocredits: 900_000, PriceConfigured: true, CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 123_456}}}, + } + for _, actor := range []*model.User{nil, {ID: "user", Role: model.UserRoleUser}} { + if _, err := svc.SaveAdminChannelModel(actor, channel.ID, "", req); err == nil { + t.Fatal("non-admin wrote cost") + } + if _, err := svc.AdminChannelModels(actor, channel.ID); err == nil { + t.Fatal("non-admin read cost") + } + if _, err := svc.AdminAPICallLogs(actor, APICallLogQuery{}); err == nil { + t.Fatal("non-admin read request cost") + } + } + saved, err := svc.SaveAdminChannelModel(admin, channel.ID, "", req) + if err != nil { + t.Fatal(err) + } + rows, err := svc.AdminChannelModels(admin, channel.ID) + if err != nil || len(rows) != 1 || rows[0].PriceTiers[0].CostPricing.UnitPriceMicrocredits != 123_456 { + t.Fatalf("persisted cost: %#v %v", rows, err) + } + order, err := svc.newBillingOrder("user", "task", "request", channel.ID, saved.ModelKey, "text", "test", 1, tokenBillingEstimate{}) + if err != nil { + t.Fatal(err) + } + if order.AmountMicrocredits != 900_000 || order.CostPricing.UnitPriceMicrocredits != 123_456 { + t.Fatalf("sales/cost snapshot: %#v", order) + } + if err := db.Create(order).Error; err != nil { + t.Fatal(err) + } + req.PriceTiers[0].CostPricing.UnitPriceMicrocredits = 654_321 + if _, err := svc.SaveAdminChannelModel(admin, channel.ID, saved.ID, req); err != nil { + t.Fatal(err) + } + var historical model.BillingOrder + if err := db.First(&historical, "id = ?", order.ID).Error; err != nil { + t.Fatal(err) + } + historical.Status = model.BillingStatusSettled + cost, err := billingCreditCost(historical) + if err != nil || cost == nil || *cost != 123_456 { + t.Fatalf("historical cost changed: %v %v", cost, err) + } + catalog, err := svc.ModelCatalog(nil) + if err != nil { + t.Fatal(err) + } + public := publicChannel(channel, false, []model.ChannelModel{*saved}) + for _, value := range []any{saved, saved.PriceTiers, order, catalog, public, quoteFromOrder("", order, tokenBillingEstimate{})} { + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"costPricing", "costConfigured", "CostPricing", "123456", "654321"} { + if strings.Contains(string(encoded), forbidden) { + t.Fatalf("cost leaked into %T: %s", value, encoded) + } + } + } + // The catalog must not query logical models, even if their old feature flag is enabled. + if err := db.Create(&model.SystemSetting{Key: featureAvailabilitySettingKey, ValueJSON: `{"frontendModelsEnabled":true}`}).Error; err != nil { + t.Fatal(err) + } + if err := db.Migrator().DropTable(&model.LogicalModel{}); err != nil { + t.Fatal(err) + } + catalog, err = svc.ModelCatalog(nil) + if err != nil || catalog.Source != ModelCatalogSourceSystem || len(catalog.Channels) != 1 { + t.Fatalf("catalog depends on logical models: %#v %v", catalog, err) + } +} + +func TestBillingCreditCostUsesExactUsageAndIgnoresSalesMultiplier(t *testing.T) { + for _, tc := range []struct { + name, mode, capability string + quantity, input, output, cached, formula, want int64 + usage bool + }{ + {name: "request", mode: "fixed_request", quantity: 1, want: 90_000}, + {name: "seconds", mode: "per_second", quantity: 15, want: 1_350_000}, + {name: "text cached tokens", mode: "token", capability: "text", input: 1_000_000, output: 500_000, cached: 200_000, usage: true, want: 1_820_000}, + {name: "video formula", mode: "token", capability: "video", formula: 250_000, want: 500_000}, + {name: "video provider", mode: "token", capability: "video", output: 100_000, formula: 250_000, usage: true, want: 200_000}, + } { + t.Run(tc.name, func(t *testing.T) { + order := model.BillingOrder{Status: model.BillingStatusSettled, Capability: tc.capability, InputTokens: tc.input, OutputTokens: tc.output, CachedTokens: tc.cached, UsageAvailable: tc.usage, MultiplierBasisPoints: 30_000, ChargeLimitMicrocredits: 1, + BillingCostSnapshot: model.BillingCostSnapshot{CostBillingMode: tc.mode, CostQuantity: tc.quantity, CostVideoFormulaTokens: tc.formula, + CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 90_000, InputTokenPriceMicrocredits: 1_000_000, OutputTokenPriceMicrocredits: 2_000_000, CachedTokenPriceMicrocredits: 100_000}}} + got, err := billingCreditCost(order) + if err != nil || got == nil || *got != tc.want { + t.Fatalf("cost=%v want=%d err=%v", got, tc.want, err) + } + order.CostPricing = model.CreditCostPricing{Configured: true} + got, err = billingCreditCost(order) + if err != nil || got == nil || *got != 0 { + t.Fatal("explicit zero cost must remain available") + } + for _, status := range []model.BillingStatus{model.BillingStatusReserved, model.BillingStatusRunning, model.BillingStatusRefunded, model.BillingStatusUncertain} { + order.Status = status + got, err = billingCreditCost(order) + if err != nil || got != nil { + t.Fatalf("unsettled cost fabricated for %s", status) + } + } + }) + } +} + +func TestCreditCostRejectsInvalidPrices(t *testing.T) { + for _, price := range []int64{-1, 1_000_000_000_001} { + if err := validateCreditCostPricing("text", "fixed_request", model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: price}); err == nil { + t.Fatalf("accepted cost %d", price) + } + } + if err := validateCreditCostPricing("video", "token", model.CreditCostPricing{Configured: true, InputTokenPriceMicrocredits: 1}); err == nil { + t.Fatal("accepted video input cost") + } +} diff --git a/backend/internal/app/finance.go b/backend/internal/app/finance.go index 295c7acb3..066d9634f 100644 --- a/backend/internal/app/finance.go +++ b/backend/internal/app/finance.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/repository" @@ -106,6 +107,8 @@ type ResolveBillingBatchResult struct { type tokenBillingEstimate struct { InputTokens int64 OutputTokens int64 + Video *VideoTokenEstimate + Err error } func (s *Service) Wallet(user *model.User, entryType string, page int, limit int) (*WalletSummary, error) { @@ -505,6 +508,9 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i if channelModel.Capability != capability || !supportsTokenBilling(capability, channelModel.Protocol) { return nil, BadAuthRequest("当前供应线路不支持前台模型的 Token 计费方式") } + if capability == "video" && (logicalModel.InputPriceMicrocredits != 0 || logicalModel.CachedPriceMicrocredits != 0) { + return nil, BadAuthRequest("视频 Token 仅按视频用量定价,请将输入与缓存价格设为 0") + } pricing := &model.ChannelModel{InputTokenPriceMicrocredits: logicalModel.InputPriceMicrocredits, OutputTokenPriceMicrocredits: logicalModel.OutputPriceMicrocredits, CachedTokenPriceMicrocredits: logicalModel.CachedPriceMicrocredits} amount, err = tokenEstimateAmount(pricing, tokenEstimate, 10_000) quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens @@ -514,22 +520,31 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i if err != nil { return nil, err } - if amount <= 0 { + if amount < 0 { return nil, BadAuthRequest("当前模型尚未配置有效的用户价格") } revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) if err != nil { return nil, err } - return &model.BillingOrder{ + var videoFormulaTokens int64 + if logicalModel.BillingMode == "token" && tokenEstimate.Video != nil { + videoFormulaTokens = tokenEstimate.Video.FormulaTokens + } + order := &model.BillingOrder{ ID: newID(), UserID: userID, IdempotencyKey: "task:" + task.ID + ":" + newID(), TaskID: task.ID, ChannelID: channelModel.ChannelID, ChannelModelID: channelModel.ID, Model: logicalModel.Code, Capability: capability, Scene: truncateRunes(firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), 80), BillingMode: logicalModel.BillingMode, PriceVersion: int64(revision.Version), UnitPriceMicrocredits: logicalModel.UnitPriceMicrocredits, MultiplierBasisPoints: 10_000, Quantity: quantity, AmountMicrocredits: amount, ReservedAmountMicrocredits: amount, InputTokenPriceMicrocredits: logicalModel.InputPriceMicrocredits, OutputTokenPriceMicrocredits: logicalModel.OutputPriceMicrocredits, CachedTokenPriceMicrocredits: logicalModel.CachedPriceMicrocredits, - Status: model.BillingStatusReserved, - }, nil + VideoFormulaTokens: videoFormulaTokens, + Status: model.BillingStatusReserved, + } + intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) + priceTierID, _ := config["priceTierId"].(string) + snapshotCreditCost(order, channelModelPriceTierForBilling(*channelModel, priceTierID, capability, intent), billingQuantity(capability, config["videoSeconds"]), tokenEstimate) + return order, nil } func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey string, capability string, scene string, idempotencyKey string, quantity int64) (*model.BillingOrder, error) { @@ -589,14 +604,20 @@ func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, ide } quantity = requestedQuantity case "token": + if tokenEstimate.Err != nil { + return nil, tokenEstimate.Err + } if !supportsTokenBilling(item.Capability, item.Protocol) || item.Capability != capability { - return nil, BadAuthRequest("Token 计费仅支持文本生成和火山方舟视频生成") + return nil, BadAuthRequest("Token 计费仅支持文本和视频生成") } if capability == "text" && (tokenEstimate.InputTokens <= 0 || tokenEstimate.OutputTokens <= 0) { return nil, BadAuthRequest("无法估算文本 Token 用量") } if capability == "video" && tokenEstimate.OutputTokens <= 0 { - return nil, BadAuthRequest("无法估算火山方舟视频 Token 用量") + return nil, BadAuthRequest("无法计算视频 Token 用量,请检查时长与尺寸") + } + if capability == "video" && (tier.InputTokenPriceMicrocredits != 0 || tier.CachedTokenPriceMicrocredits != 0) { + return nil, BadAuthRequest("视频 Token 仅按视频用量定价,请将输入与缓存价格设为 0") } quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens default: @@ -618,15 +639,22 @@ func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, ide if err != nil { return nil, err } - return &model.BillingOrder{ + var videoFormulaTokens int64 + if tier.BillingMode == "token" && tokenEstimate.Video != nil { + videoFormulaTokens = tokenEstimate.Video.FormulaTokens + } + order := &model.BillingOrder{ ID: newID(), UserID: userID, IdempotencyKey: idempotencyKey, TaskID: taskID, ChannelID: channelID, ChannelModelID: item.ID, PriceTierID: tier.ID, PriceTierVersion: tier.PriceVersion, PriceSelectorJSON: tier.SelectorJSON, Model: modelKey, Capability: capability, Scene: truncateRunes(scene, 80), BillingMode: tier.BillingMode, PriceVersion: item.PriceVersion, UnitPriceMicrocredits: tier.UnitPriceMicrocredits, MultiplierBasisPoints: multiplierBPS, Quantity: quantity, AmountMicrocredits: amount, ReservedAmountMicrocredits: amount, InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits, - Status: model.BillingStatusReserved, - }, nil + VideoFormulaTokens: videoFormulaTokens, + Status: model.BillingStatusReserved, + } + snapshotCreditCost(order, tier, requestedQuantity, tokenEstimate) + return order, nil } func channelModelPriceTierForBilling(channelModel model.ChannelModel, priceTierID string, capability string, intents ...ModelRequestIntent) *model.ChannelModelPriceTier { @@ -661,94 +689,6 @@ func estimateTaskBillingTokens(input map[string]any, capability string) tokenBil return estimateTaskTokens(input) } -// 方舟视频成功后才返回真实 completion_tokens;创建任务前按官方像素帧公式预授权, -// 并保留少量帧率/取整余量,实际结算时会按 usage 自动退回差额。 -func estimateArkVideoTokens(input map[string]any) tokenBillingEstimate { - config, _ := input["config"].(map[string]any) - if config == nil { - return tokenBillingEstimate{} - } - durationSeconds, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(config["videoSeconds"])), 10, 64) - if err != nil || durationSeconds <= 0 { - return tokenBillingEstimate{} - } - pixels := arkVideoOutputPixels(fmt.Sprint(config["vquality"]), fmt.Sprint(config["size"]), fmt.Sprint(config["model"])) - if pixels <= 0 { - return tokenBillingEstimate{} - } - - if durationSeconds > (1<<63-1)/1000 { - return tokenBillingEstimate{} - } - totalDurationMillis := durationSeconds * 1000 - referenceCount := int64(0) - if references, ok := input["referenceVideos"].([]any); ok && len(references) > 0 { - referenceCount = int64(len(references)) - knownDurationMillis := int64(0) - unknownDuration := false - for _, raw := range references { - media, _ := raw.(map[string]any) - durationMillis := firstInt64(media, "durationMs") - if durationMillis <= 0 { - unknownDuration = true - continue - } - knownDurationMillis = min(15_000, knownDurationMillis+min(durationMillis, int64(15_000))) - } - // 方舟参考视频总时长上限为 15 秒;缺少媒体元数据时按上限预留。 - if unknownDuration { - knownDurationMillis = 15_000 - } - totalDurationMillis += knownDurationMillis - } - frames := (totalDurationMillis*24+999)/1000 + 1 + referenceCount - if pixels > (1<<63-1-1023)/frames { - return tokenBillingEstimate{} - } - tokens := (pixels*frames + 1023) / 1024 - if tokens > (1<<63-1-99)/110 { - return tokenBillingEstimate{} - } - return tokenBillingEstimate{OutputTokens: (tokens*110 + 99) / 100} -} - -func arkVideoOutputPixels(resolution string, ratio string, modelName string) int64 { - resolution = normalizeSeedanceResolution(resolution, modelName) - ratio = normalizeSeedanceRatio(ratio) - pixelsByRatio := map[string]map[string]int64{ - "480p": { - "16:9": 864 * 496, "4:3": 752 * 560, "1:1": 640 * 640, - "3:4": 560 * 752, "9:16": 496 * 864, "21:9": 992 * 432, - }, - "720p": { - "16:9": 1280 * 720, "4:3": 1112 * 834, "1:1": 960 * 960, - "3:4": 834 * 1112, "9:16": 720 * 1280, "21:9": 1470 * 630, - }, - "1080p": { - "16:9": 1920 * 1080, "4:3": 1664 * 1248, "1:1": 1440 * 1440, - "3:4": 1248 * 1664, "9:16": 1080 * 1920, "21:9": 2206 * 946, - }, - } - values := pixelsByRatio[resolution] - if resolution == "2160p" { - values = make(map[string]int64, len(pixelsByRatio["1080p"])) - for key, value := range pixelsByRatio["1080p"] { - values[key] = value * 4 - } - } - if len(values) == 0 { - return 0 - } - if ratio != "adaptive" { - return values[ratio] - } - var largest int64 - for _, value := range values { - largest = max(largest, value) - } - return largest -} - func estimateProxyTokens(body []byte) tokenBillingEstimate { var payload map[string]any _ = json.Unmarshal(body, &payload) @@ -781,35 +721,25 @@ func maxOutputTokens(payload map[string]any) int64 { // Token 单价按每百万 Token 配置;预授权使用输入价估算缓存 Token,真实结算再按 usage 拆分。 func tokenEstimateAmount(item *model.ChannelModel, estimate tokenBillingEstimate, multiplierBPS int64) (int64, error) { + if estimate.Err != nil { + return 0, estimate.Err + } if item == nil || estimate.InputTokens < 0 || estimate.OutputTokens <= 0 || multiplierBPS <= 0 { return 0, errors.New("Token 计费参数无效") } - inputAmount, ok := safeTokenProduct(estimate.InputTokens, item.InputTokenPriceMicrocredits) - if !ok { + amount, err := kernel.TokenBillingAmount(multiplierBPS, + kernel.TokenBillingTerm{Tokens: estimate.InputTokens, PriceMicrocredits: item.InputTokenPriceMicrocredits}, + kernel.TokenBillingTerm{Tokens: estimate.OutputTokens, PriceMicrocredits: item.OutputTokenPriceMicrocredits}, + ) + if errors.Is(err, kernel.ErrTokenBillingOverflow) { return 0, errors.New("Token 计费金额溢出") } - outputAmount, ok := safeTokenProduct(estimate.OutputTokens, item.OutputTokenPriceMicrocredits) - if !ok || inputAmount > 1<<63-1-outputAmount { - return 0, errors.New("Token 计费金额溢出") - } - base := inputAmount + outputAmount - if base > (1<<63-1-9_999_999_999)/multiplierBPS { - return 0, errors.New("Token 计费金额溢出") - } - amount := (base*multiplierBPS + 9_999_999_999) / 10_000_000_000 - if amount <= 0 { - return 0, errors.New("Token 计费金额必须大于 0") + if err != nil { + return 0, errors.New("Token 计费参数无效") } return amount, nil } -func safeTokenProduct(tokens int64, price int64) (int64, bool) { - if tokens < 0 || price < 0 || (tokens > 0 && price > (1<<63-1)/tokens) { - return 0, false - } - return tokens * price, true -} - func billingQuantity(capability string, value any) int64 { if capability != "video" { return 1 diff --git a/backend/internal/app/finance_token_arithmetic_test.go b/backend/internal/app/finance_token_arithmetic_test.go new file mode 100644 index 000000000..bdaa13f77 --- /dev/null +++ b/backend/internal/app/finance_token_arithmetic_test.go @@ -0,0 +1,73 @@ +package app + +import ( + "math" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestTokenEstimateAmountUsesExactArithmetic(t *testing.T) { + for _, test := range []struct { + name string + prices model.ChannelModel + estimate tokenBillingEstimate + multiplier int64 + want int64 + }{ + { + name: "video maximum price with multiplier", + prices: model.ChannelModel{ + InputTokenPriceMicrocredits: 1_000_000_000_000, OutputTokenPriceMicrocredits: 1_000_000_000_000, + }, + estimate: tokenBillingEstimate{OutputTokens: 10_000_000}, multiplier: 12_500, want: 12_500_000_000_000, + }, + { + name: "text estimates input at full input price", + prices: model.ChannelModel{ + InputTokenPriceMicrocredits: 2_000_000, OutputTokenPriceMicrocredits: 5_000_000, CachedTokenPriceMicrocredits: 1_000_000, + }, + estimate: tokenBillingEstimate{InputTokens: 100, OutputTokens: 20}, multiplier: 10_000, want: 300, + }, + { + name: "round after combining input and output", + prices: model.ChannelModel{ + InputTokenPriceMicrocredits: 400_000, OutputTokenPriceMicrocredits: 400_000, + }, + estimate: tokenBillingEstimate{InputTokens: 1, OutputTokens: 1}, multiplier: 12_500, want: 1, + }, + { + name: "maximum final amount", + prices: model.ChannelModel{OutputTokenPriceMicrocredits: 1_000_000}, + estimate: tokenBillingEstimate{OutputTokens: math.MaxInt64}, multiplier: 10_000, want: math.MaxInt64, + }, + } { + t.Run(test.name, func(t *testing.T) { + amount, err := tokenEstimateAmount(&test.prices, test.estimate, test.multiplier) + if err != nil || amount != test.want { + t.Fatalf("tokenEstimateAmount() = %d, %v; want %d", amount, err, test.want) + } + }) + } +} + +func TestTokenEstimateAmountRejectsInvalidArithmetic(t *testing.T) { + for _, test := range []struct { + name string + prices model.ChannelModel + estimate tokenBillingEstimate + multiplier int64 + }{ + {name: "negative input", estimate: tokenBillingEstimate{InputTokens: -1, OutputTokens: 1}, multiplier: 10_000}, + {name: "negative output", estimate: tokenBillingEstimate{OutputTokens: -1}, multiplier: 10_000}, + {name: "negative price", prices: model.ChannelModel{OutputTokenPriceMicrocredits: -1}, estimate: tokenBillingEstimate{OutputTokens: 1}, multiplier: 10_000}, + {name: "zero multiplier", estimate: tokenBillingEstimate{OutputTokens: 1}}, + {name: "final overflow", prices: model.ChannelModel{OutputTokenPriceMicrocredits: 1_000_000}, estimate: tokenBillingEstimate{OutputTokens: math.MaxInt64}, multiplier: 10_001}, + } { + t.Run(test.name, func(t *testing.T) { + if amount, err := tokenEstimateAmount(&test.prices, test.estimate, test.multiplier); amount != 0 || err == nil { + t.Fatalf("invalid estimate billed %d, %v", amount, err) + } + }) + } +} diff --git a/backend/internal/app/finance_token_test.go b/backend/internal/app/finance_token_test.go index ecd83ceb0..fdae92c10 100644 --- a/backend/internal/app/finance_token_test.go +++ b/backend/internal/app/finance_token_test.go @@ -15,8 +15,8 @@ func TestSupportsTokenBillingForVolcengineArkVideo(t *testing.T) { if !supportsTokenBilling("video", model.ChannelInterfaceVolcengineArkVideo) { t.Fatal("Volcengine Ark video should support Token billing") } - if supportsTokenBilling("video", model.ChannelInterfaceNewAPIVideo) { - t.Fatal("video protocols without a final usage contract must not support Token billing") + if !supportsTokenBilling("video", model.ChannelInterfaceNewAPIVideo) { + t.Fatal("all video protocols must support formula-based Token billing") } } @@ -26,8 +26,8 @@ func TestEstimateArkVideoTokensUsesPixelFrameEstimate(t *testing.T) { "model": "doubao-seedance-1-5-pro", "videoSeconds": "5", "vquality": "720", "size": "16:9", }, }) - // 1280*720*(5*24+1)/1024 = 108900,预授权再保留 10% 余量。 - if estimate.InputTokens != 0 || estimate.OutputTokens != 119790 { + // 官方公式 1280*720*5*24/1024 = 108000;平台另预留 10%。 + if estimate.Err != nil || estimate.InputTokens != 0 || estimate.OutputTokens != 118800 { t.Fatalf("estimateArkVideoTokens() = %#v", estimate) } } @@ -39,20 +39,20 @@ func TestEstimateArkVideoTokensIncludesUnknownReferenceVideo(t *testing.T) { }, "referenceVideos": []any{map[string]any{"id": "video-1"}}, }) - if estimate.OutputTokens != 477180 { + if estimate.Err != nil || estimate.OutputTokens != 475200 || !estimate.Video.ReferenceDurationEstimated { t.Fatalf("estimateArkVideoTokens() = %#v", estimate) } } -func TestEstimateArkVideoTokensCapsReferenceDuration(t *testing.T) { +func TestEstimateVideoTokensIncludesLongReferenceDuration(t *testing.T) { estimate := estimateArkVideoTokens(map[string]any{ "config": map[string]any{ "model": "doubao-seedance-2-0", "videoSeconds": "5", "vquality": "720p", "size": "16:9", }, "referenceVideos": []any{map[string]any{"id": "video-1", "durationMs": int64(60_000)}}, }) - if estimate.OutputTokens != 477180 { - t.Fatalf("estimateArkVideoTokens() = %#v", estimate) + if estimate.Err != nil || estimate.Video.ReferenceSeconds != 60 || estimate.Video.FormulaTokens != 1_404_000 { + t.Fatalf("reference duration must be counted without silent capping: %#v", estimate) } } diff --git a/backend/internal/app/logical_model_quote.go b/backend/internal/app/logical_model_quote.go index 647aebd7d..1463f8462 100644 --- a/backend/internal/app/logical_model_quote.go +++ b/backend/internal/app/logical_model_quote.go @@ -1,18 +1,78 @@ package app -import "infinite-canvas/backend/internal/model" +import ( + "strings" + + "infinite-canvas/backend/internal/model" +) // LogicalModelQuote 是创作端当前参数命中的实际供应线路报价。 // Token 视频在上游返回真实 usage 前只能预估,创建任务仍以账务预留逻辑为准。 type LogicalModelQuote struct { - LogicalModelID string `json:"logicalModelId"` - BillingMode string `json:"billingMode"` - Quantity int64 `json:"quantity"` - AmountMicrocredits int64 `json:"amountMicrocredits"` - Estimated bool `json:"estimated"` + LogicalModelID string `json:"logicalModelId"` + BillingMode string `json:"billingMode"` + Quantity int64 `json:"quantity"` + AmountMicrocredits int64 `json:"amountMicrocredits"` + Estimated bool `json:"estimated"` + VideoTokenEstimate *VideoTokenEstimate `json:"videoTokenEstimate,omitempty"` +} + +type ChannelModelQuoteRequest struct { + ChannelID string `json:"channelId"` + ModelKey string `json:"modelKey"` + Intent ModelRequestIntent `json:"intent"` +} + +// QuoteChannelModel uses the same admission/defaults/SKU selection as task +// creation, without reserving credits or submitting a provider request. +func (s *Service) QuoteChannelModel(req ChannelModelQuoteRequest) (*LogicalModelQuote, error) { + if strings.TrimSpace(req.ChannelID) == "" || strings.TrimSpace(req.ModelKey) == "" { + return nil, InvalidModelSelection("报价必须指定系统渠道和模型") + } + if err := validateQuoteIntent(req.Intent); err != nil { + return nil, err + } + input := quoteInput(req.Intent, req.ModelKey) + config := input["config"].(map[string]any) + config["channelId"] = req.ChannelID + input, err := s.resolveSystemChannelModelSelection(input, "canvas_"+req.Intent.Capability, req.Intent.Operation) + if err != nil { + return nil, err + } + config = input["config"].(map[string]any) + estimate := estimateTaskBillingTokens(input, req.Intent.Capability) + intent := ModelRequestIntentFromTaskInput(input, "canvas_"+req.Intent.Capability, req.Intent.Operation) + order, err := s.newBillingOrderWithPriceTier("", "", "quote", req.ChannelID, req.ModelKey, req.Intent.Capability, "model_quote", billingQuantity(req.Intent.Capability, config["videoSeconds"]), estimate, stringValue(config["priceTierId"]), intent) + if err != nil { + return nil, err + } + return quoteFromOrder("", order, estimate), nil +} + +func validateQuoteIntent(intent ModelRequestIntent) error { + if intent.Capability != "text" && intent.Capability != "image" && intent.Capability != "video" && intent.Capability != "audio" { + return BadAuthRequest("报价请求缺少有效的模型能力类型") + } + for _, count := range intent.Inputs { + if count < 0 || count > 128 { + return BadAuthRequest("报价参考素材数量必须为 0 到 128 的整数") + } + } + return nil +} + +func quoteFromOrder(logicalModelID string, order *model.BillingOrder, estimate tokenBillingEstimate) *LogicalModelQuote { + quote := &LogicalModelQuote{LogicalModelID: logicalModelID, BillingMode: order.BillingMode, Quantity: order.Quantity, AmountMicrocredits: order.AmountMicrocredits, Estimated: order.BillingMode == "token"} + if order.BillingMode == "token" && order.Capability == "video" { + quote.VideoTokenEstimate = estimate.Video + } + return quote } func (s *Service) QuoteLogicalModel(logicalModelID string, intent ModelRequestIntent) (*LogicalModelQuote, error) { + if err := validateQuoteIntent(intent); err != nil { + return nil, err + } routed, err := s.ResolveLogicalModel(logicalModelID, intent) if err != nil { return nil, err @@ -24,6 +84,11 @@ func (s *Service) QuoteLogicalModel(logicalModelID string, intent ModelRequestIn resolvedIntent := intent resolvedIntent.Options = mergeIntentDefaults(intent.Options, routed.Defaults) input := quoteInput(resolvedIntent, routed.ChannelModel.ModelKey) + providerModelKey := routed.ChannelModel.ProviderModelKey + if routed.PriceTier != nil { + providerModelKey = firstNonEmpty(routed.PriceTier.ProviderModelKey, providerModelKey) + } + input["config"].(map[string]any)["providerModelKey"] = providerModelKey quantity := billingQuantity(capability, inputConfigValue(input, "videoSeconds")) if capability != "video" { quantity = 1 @@ -39,13 +104,7 @@ func (s *Service) QuoteLogicalModel(logicalModelID string, intent ModelRequestIn if billingErr != nil { return nil, billingErr } - return &LogicalModelQuote{ - LogicalModelID: routed.LogicalModel.ID, - BillingMode: order.BillingMode, - Quantity: order.Quantity, - AmountMicrocredits: order.AmountMicrocredits, - Estimated: order.BillingMode == "token", - }, nil + return quoteFromOrder(routed.LogicalModel.ID, order, tokenEstimate), nil } if routed.LogicalModel.PricePolicy != "unified" { @@ -65,6 +124,9 @@ func (s *Service) QuoteLogicalModel(logicalModelID string, intent ModelRequestIn if routed.ChannelModel.Capability != capability || !supportsTokenBilling(capability, routed.ChannelModel.Protocol) { return nil, BadAuthRequest("当前供应线路不支持前台模型的 Token 计费方式") } + if capability == "video" && (routed.LogicalModel.InputPriceMicrocredits != 0 || routed.LogicalModel.CachedPriceMicrocredits != 0) { + return nil, BadAuthRequest("视频 Token 仅按视频用量定价,请将输入与缓存价格设为 0") + } pricing := &model.ChannelModel{ InputTokenPriceMicrocredits: routed.LogicalModel.InputPriceMicrocredits, OutputTokenPriceMicrocredits: routed.LogicalModel.OutputPriceMicrocredits, @@ -78,16 +140,20 @@ func (s *Service) QuoteLogicalModel(logicalModelID string, intent ModelRequestIn if err != nil { return nil, err } - if amount <= 0 { + if amount < 0 { return nil, BadAuthRequest("当前模型尚未配置有效的用户价格") } - return &LogicalModelQuote{ + quote := &LogicalModelQuote{ LogicalModelID: routed.LogicalModel.ID, BillingMode: routed.LogicalModel.BillingMode, Quantity: quantity, AmountMicrocredits: amount, Estimated: routed.LogicalModel.BillingMode == "token", - }, nil + } + if quote.Estimated && capability == "video" { + quote.VideoTokenEstimate = tokenEstimate.Video + } + return quote, nil } func quoteInput(intent ModelRequestIntent, modelKey string) map[string]any { @@ -97,9 +163,10 @@ func quoteInput(intent ModelRequestIntent, modelKey string) map[string]any { } config["model"] = modelKey input := map[string]any{"mode": intent.Capability, "config": config} - if count := intent.Inputs["video"]; count > 0 { - references := make([]any, count) - input["referenceVideos"] = references + for kind, key := range map[string]string{"image": "referenceImages", "video": "referenceVideos", "audio": "referenceAudios"} { + if count := intent.Inputs[kind]; count > 0 { + input[key] = make([]any, count) + } } return input } diff --git a/backend/internal/app/logical_models.go b/backend/internal/app/logical_models.go index ebb5fa08d..8665a2353 100644 --- a/backend/internal/app/logical_models.go +++ b/backend/internal/app/logical_models.go @@ -664,6 +664,11 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo if pricePolicy == "unified" && billingMode == "per_second" && capability != "video" { return nil, nil, nil, false, BadAuthRequest("只有视频前台模型可以按秒计费") } + if pricePolicy == "unified" && billingMode == "token" { + if err := validateTokenPrices(capability, req.InputPriceMicrocredits, req.OutputPriceMicrocredits, req.CachedPriceMicrocredits); err != nil { + return nil, nil, nil, false, err + } + } creating := strings.TrimSpace(id) == "" var item *model.LogicalModel if creating { @@ -756,7 +761,7 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo } if pricePolicy == "unified" && billingMode == "token" { if !supportsLogicalModelTokenBilling(capability, enabledRouteProtocols) { - return nil, nil, nil, false, BadAuthRequest("Token 计费仅支持文本前台模型,或全部启用供应线路均为火山方舟视频协议的视频前台模型") + return nil, nil, nil, false, BadAuthRequest("Token 计费仅支持文本和视频前台模型") } } // 停用必须始终可执行,便于管理员立即阻止失效线路继续对外服务;重新启用时再强校验结构能力和计费可用性。 diff --git a/backend/internal/app/logical_models_test.go b/backend/internal/app/logical_models_test.go index d36c4bcde..a43483a78 100644 --- a/backend/internal/app/logical_models_test.go +++ b/backend/internal/app/logical_models_test.go @@ -279,8 +279,8 @@ func TestSupportsLogicalModelTokenBillingForArkVideoRoutes(t *testing.T) { if supportsLogicalModelTokenBilling("video", nil) { t.Fatal("video logical models without an enabled route must not support Token billing") } - if supportsLogicalModelTokenBilling("video", []model.ChannelInterfaceType{model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceNewAPIVideo}) { - t.Fatal("mixed video protocols must not support Token billing") + if !supportsLogicalModelTokenBilling("video", []model.ChannelInterfaceType{model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceNewAPIVideo}) { + t.Fatal("all video protocols must support Token billing") } } diff --git a/backend/internal/app/model_catalog.go b/backend/internal/app/model_catalog.go index aff2f4a41..d9a9b1018 100644 --- a/backend/internal/app/model_catalog.go +++ b/backend/internal/app/model_catalog.go @@ -8,20 +8,16 @@ import ( "infinite-canvas/backend/internal/model" ) -// ModelCatalogSource 决定 ModelCatalogResponse 中哪一个集合具有语义。 -// frontend 与 system 的数据形状互斥,调用方不能把缺失集合解释成空目录。 +// ModelCatalogSource 标识创作目录来自系统渠道模型。 type ModelCatalogSource string const ( - // ModelCatalogSourceFrontend 表示目录由前台逻辑模型组成。 - ModelCatalogSourceFrontend ModelCatalogSource = "frontend" // ModelCatalogSourceSystem 表示目录由脱敏后的系统渠道与渠道模型组成。 ModelCatalogSourceSystem ModelCatalogSource = "system" ) // ModelCatalogResponse 是创作端模型选择的统一读模型。 -// Source=frontend 时读取 Models;Source=system 时读取 Channels。 -// 两个集合都始终序列化为数组:空目录必须发 [],缺字段会被前端判成畸形响应。 +// Channels 为唯一数据源;Models 固定为空,两个集合始终序列化为数组。 type ModelCatalogResponse struct { Source ModelCatalogSource `json:"source"` Models []PublicLogicalModel `json:"models"` @@ -41,10 +37,11 @@ type PublicChannelCatalog struct { type PublicChannelModel struct { ID string `json:"id"` ModelKey string `json:"modelKey"` + ChannelLabel string `json:"channelLabel"` + Description string `json:"description"` DisplayName string `json:"displayName"` SortOrder int `json:"sortOrder"` Icon string `json:"icon"` - Description string `json:"description,omitempty"` Capability string `json:"capability"` Protocol model.ChannelInterfaceType `json:"protocol"` CapabilityConfig map[string]any `json:"capabilityConfig,omitempty"` @@ -68,26 +65,11 @@ type PublicChannelModelPriceTier struct { CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` } -// ModelCatalog 按功能开关返回互斥的数据形状:frontend 使用 Models,system 使用 Channels。 +// ModelCatalog 的创作端目录始终来自系统渠道模型,不反查逻辑模型或路由。 // 系统渠道目录只负责安全发布可解释的读模型;任务创建仍会用持久化能力与价格档再次强校验。 func (s *Service) ModelCatalog(intent *ModelRequestIntent) (*ModelCatalogResponse, error) { - frontendEnabled, err := s.FeatureEnabled(FeatureFrontendModels) - if err != nil { - return nil, err - } - // 两个集合都初始化成非 nil 空切片:空目录要发 [],不能因为“没有模型”而丢掉字段。 response := &ModelCatalogResponse{Models: []PublicLogicalModel{}, Channels: []PublicChannelCatalog{}} - if frontendEnabled { - models, err := s.PublicLogicalModels(intent) - if err != nil { - return nil, err - } - response.Source = ModelCatalogSourceFrontend - response.Models = append(response.Models, models...) - return response, nil - } - channels, err := s.publicSystemChannelCatalog(intent) if err != nil { return nil, err @@ -146,8 +128,8 @@ func (s *Service) publicSystemChannelCatalog(intent *ModelRequestIntent) ([]Publ if len(publicModels) > 0 { result = append(result, PublicChannelCatalog{ ID: channel.ID, - Name: channel.PublicName(), - DisplayName: channel.PublicName(), + Name: channel.Name, + DisplayName: channel.Name, SortOrder: channel.SortOrder, Models: publicModels, }) @@ -202,10 +184,11 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod return PublicChannelModel{ ID: cm.ID, ModelKey: cm.ModelKey, + ChannelLabel: cm.ChannelLabel, + Description: cm.Description, DisplayName: cm.DisplayName, SortOrder: cm.SortOrder, Icon: cm.Icon, - Description: cm.Description, Capability: cm.Capability, Protocol: cm.Protocol, CapabilityConfig: capabilityConfig, diff --git a/backend/internal/app/model_catalog_test.go b/backend/internal/app/model_catalog_test.go index a7fa29f9a..81cbe78e8 100644 --- a/backend/internal/app/model_catalog_test.go +++ b/backend/internal/app/model_catalog_test.go @@ -11,16 +11,14 @@ import ( "gorm.io/gorm" ) -// 前台模型目录为空时也必须发 models: [],不能因为 omitempty 丢掉字段: -// 前端在 source=frontend 时强制校验 models 是数组,缺字段会被判成畸形响应; -// 而 auth-session-hydrator 会把该异常当成未登录并清掉会话,表现为刷新后跳回登录页。 +// 目录始终来自系统渠道;空目录也要显式返回数组,不能被会话初始化判成畸形响应。 func TestModelCatalogAlwaysSerializesCollectionsAsArrays(t *testing.T) { for _, test := range []struct { name string frontendEnabled bool wantSource string }{ - {name: "空的前台模型目录", frontendEnabled: true, wantSource: "frontend"}, + {name: "逻辑模型开关不改变目录来源", frontendEnabled: true, wantSource: "system"}, {name: "空的系统渠道目录", frontendEnabled: false, wantSource: "system"}, } { t.Run(test.name, func(t *testing.T) { diff --git a/backend/internal/app/model_router.go b/backend/internal/app/model_router.go index 5a4e1b46f..a222f56f5 100644 --- a/backend/internal/app/model_router.go +++ b/backend/internal/app/model_router.go @@ -1,1410 +1,1420 @@ -package app - -import ( - "context" - cryptorand "crypto/rand" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "log" - "math" - "sort" - "strconv" - "strings" - "time" - - "infinite-canvas/backend/internal/model" - "infinite-canvas/backend/internal/repository" -) - -type CapabilityImageSizePreset struct { - Size string `json:"size"` - Tier string `json:"tier"` - Ratio string `json:"ratio"` - Width int `json:"width"` - Height int `json:"height"` -} - -type CapabilityImageSize struct { - Parameter string `json:"parameter,omitempty"` - AllowCustom bool `json:"allowCustom,omitempty"` - Presets []CapabilityImageSizePreset `json:"presets,omitempty"` -} - -type CapabilitySpec struct { - Version int `json:"version"` - Capability string `json:"capability"` - Operations []string `json:"operations,omitempty"` - Inputs map[string]InputConstraint `json:"inputs,omitempty"` - Options map[string]OptionConstraint `json:"options,omitempty"` - ImageSize *CapabilityImageSize `json:"imageSize,omitempty"` -} - -type InputConstraint struct { - Min int `json:"min"` - Max int `json:"max"` -} - -type OptionConstraint struct { - Values []any `json:"values,omitempty"` - Min *float64 `json:"min,omitempty"` - Max *float64 `json:"max,omitempty"` - Step *float64 `json:"step,omitempty"` -} - -type ModelRequestIntent struct { - Capability string `json:"capability"` - Operation string `json:"operation,omitempty"` - Inputs map[string]int `json:"inputs,omitempty"` - Options map[string]any `json:"options,omitempty"` -} - -// ModelRequestIntentFromTaskInput 从统一任务输入推导路由意图;它只统计实际输入和显式参数,不假设任何固定图片数或视频时长。 -func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, operation string) ModelRequestIntent { - capability := normalizeCapability(fmt.Sprint(input["mode"])) - if capability == "" { - capability = capabilityFromTaskType(taskType) - } - intent := ModelRequestIntent{Capability: capability, Operation: strings.TrimSpace(operation), Inputs: map[string]int{}, Options: map[string]any{}} - for inputType, key := range map[string]string{"image": "referenceImages", "video": "referenceVideos", "audio": "referenceAudios"} { - if values, ok := input[key].([]any); ok { - intent.Inputs[inputType] = len(values) - } - } - if mask, exists := input["mask"]; exists && mask != nil { - intent.Inputs["mask"] = 1 - } - explicitOptions := false - if options, ok := input["capabilityOptions"].(map[string]any); ok { - explicitOptions = true - for key, value := range options { - name := canonicalCapabilityOptionName(key) - // auto/any 表示调用方不指定质量;不能把它当作模型必须声明的 - // 枚举值,否则未列出 auto 的模型会被错误判定为参数不支持。 - if name == "quality" { - if normalized, ok := value.(string); ok && (strings.EqualFold(strings.TrimSpace(normalized), "auto") || strings.EqualFold(strings.TrimSpace(normalized), "any")) { - continue - } - } - intent.Options[name] = normalizeModelRequestOption(name, value) - } - } - if config, ok := input["config"].(map[string]any); ok && !explicitOptions { - for key, value := range config { - switch key { - case "channelId", "apiFormat", "interfaceType", "baseUrl", "apiKey", "secretKey", "headers", "model", "capabilityConfig": - continue - default: - canonical := canonicalCapabilityOptionName(key) - if isCapabilityOptionFor(capability, canonical) && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" { - intent.Options[canonical] = normalizeModelRequestOption(canonical, value) - } - } - } - } - return intent -} - -func normalizeModelRequestOption(name string, value any) any { - canonicalName := canonicalCapabilityOptionName(name) - if canonicalName == "quality" || canonicalName == "size" { - if text, ok := value.(string); ok { - return strings.ToLower(strings.TrimSpace(text)) - } - return value - } - if canonicalName != "vquality" { - return value - } - resolution, ok := value.(string) - if !ok { - return value - } - switch strings.ToLower(strings.TrimSpace(resolution)) { - case "low", "480", "480p": - return "480p" - case "720", "720p": - return "720p" - case "1080", "1080p": - return "1080p" - case "2k", "1440", "1440p": - return "1440p" - case "4k", "2160", "2160p": - return "2160p" - default: - return value - } -} - -type CapabilityMatch struct { - Matched bool `json:"matched"` - Reasons []string `json:"reasons,omitempty"` -} - -type cachedLogicalModel struct { - Model model.LogicalModel - Revision model.LogicalModelRevision - ProductSpec CapabilitySpec - Defaults map[string]any - Routes []cachedLogicalRoute -} - -type cachedLogicalRoute struct { - Route model.LogicalModelRoute - CapabilitySpec CapabilitySpec - ChannelModel model.ChannelModel -} - -type routeCatalogSnapshot struct { - LoadedAt time.Time - CatalogVersion int64 - Models map[string]cachedLogicalModel - Ordered []string -} - -type RoutedModel struct { - LogicalModel model.LogicalModel - Revision model.LogicalModelRevision - Route model.LogicalModelRoute - ChannelModel model.ChannelModel - PriceTier *model.ChannelModelPriceTier - Defaults map[string]any -} - -func DecodeCapabilitySpec(raw string) (CapabilitySpec, error) { - var spec CapabilitySpec - if err := json.Unmarshal([]byte(raw), &spec); err != nil { - return spec, BadAuthRequest("能力配置不是有效 JSON") - } - normalized, err := NormalizeCapabilitySpec(spec) - if err != nil { - return spec, err - } - return normalized, nil -} - -func ValidateCapabilitySpec(spec CapabilitySpec) error { - _, err := NormalizeCapabilitySpec(spec) - return err -} - -func NormalizeCapabilitySpec(spec CapabilitySpec) (CapabilitySpec, error) { - spec.Capability = normalizeCapability(spec.Capability) - if spec.Version != 1 { - return spec, BadAuthRequest("能力配置 version 必须为 1") - } - if spec.Capability == "" { - return spec, BadAuthRequest("能力配置必须声明 capability") - } - operations := make([]string, 0, len(spec.Operations)) - seenOperations := make(map[string]bool, len(spec.Operations)) - for _, operation := range spec.Operations { - normalized := normalizeCapabilityValue(operation) - if normalized != "" && !seenOperations[normalized] { - seenOperations[normalized] = true - operations = append(operations, normalized) - } - } - spec.Operations = operations - normalizedInputs := make(map[string]InputConstraint, len(spec.Inputs)) - for rawName, constraint := range spec.Inputs { - name := normalizeCapabilityValue(rawName) - if name == "" || constraint.Min < 0 || constraint.Max < constraint.Min { - return spec, BadAuthRequest("输入能力范围无效") - } - if _, exists := normalizedInputs[name]; exists { - return spec, BadAuthRequest("输入能力存在重复名称:" + name) - } - normalizedInputs[name] = constraint - } - spec.Inputs = normalizedInputs - normalizedOptions := make(map[string]OptionConstraint, len(spec.Options)) - for rawName, constraint := range spec.Options { - name := canonicalCapabilityOptionName(rawName) - if strings.TrimSpace(name) == "" { - return spec, BadAuthRequest("能力参数名称不能为空") - } - if _, exists := normalizedOptions[name]; exists { - return spec, BadAuthRequest("能力参数存在重复别名:" + name) - } - hasValues := len(constraint.Values) > 0 - hasRange := constraint.Min != nil || constraint.Max != nil || constraint.Step != nil - if !hasValues && !hasRange { - return spec, BadAuthRequest("能力参数必须声明 values 或数值范围") - } - if hasValues && hasRange { - return spec, BadAuthRequest("能力参数不能同时声明 values 和数值范围") - } - if hasRange && (constraint.Min == nil || constraint.Max == nil) { - return spec, BadAuthRequest("数值范围必须同时声明 min 和 max") - } - if constraint.Min != nil && constraint.Max != nil && *constraint.Max < *constraint.Min { - return spec, BadAuthRequest("能力参数数值范围无效") - } - if constraint.Step != nil && *constraint.Step <= 0 { - return spec, BadAuthRequest("能力参数 step 必须大于 0") - } - normalizedOptions[name] = constraint - } - spec.Options = normalizedOptions - return spec, nil -} - -func decodeLogicalDefaults(raw string, spec CapabilitySpec) (map[string]any, error) { - defaults := map[string]any{} - if err := json.Unmarshal([]byte(raw), &defaults); err != nil { - return nil, err - } - return normalizeLogicalDefaults(spec, defaults) -} - -func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityMatch { - reasons := make([]string, 0) - if normalizeCapability(intent.Capability) != normalizeCapability(spec.Capability) { - reasons = append(reasons, "能力类型不匹配") - } - if operation := normalizeCapabilityValue(intent.Operation); operation != "" && len(spec.Operations) > 0 && !containsNormalized(spec.Operations, operation) { - reasons = append(reasons, "不支持操作 "+intent.Operation) - } - for inputType, count := range intent.Inputs { - if count < 0 { - reasons = append(reasons, "输入数量不能小于 0") - continue - } - constraint, declared := spec.Inputs[inputType] - if !declared { - // Multimodal text models may accept reference images even if not declared. - if count > 0 && !(normalizeCapability(spec.Capability) == "text" && inputType == "image") { - reasons = append(reasons, "不支持 "+capabilityInputLabel(inputType)+"输入") - } - continue - } - if count < constraint.Min || count > constraint.Max { - reasons = append(reasons, fmt.Sprintf("%s数量需在 %d-%d 之间", capabilityInputLabel(inputType), constraint.Min, constraint.Max)) - } - } - for inputType, constraint := range spec.Inputs { - if intent.Inputs[inputType] < constraint.Min { - reasons = append(reasons, fmt.Sprintf("至少需要 %d 个%s", constraint.Min, capabilityInputLabel(inputType))) - } - } - for name, value := range intent.Options { - constraint, declared := spec.Options[canonicalCapabilityOptionName(name)] - if !declared { - reasons = append(reasons, "不支持参数 "+capabilityOptionLabel(name)) - continue - } - if !matchOptionConstraint(name, constraint, value) { - reasons = append(reasons, "参数 "+capabilityOptionLabel(name)+"超出支持范围") - } - } - return CapabilityMatch{Matched: len(reasons) == 0, Reasons: reasons} -} - -func capabilityInputLabel(name string) string { - switch normalizeCapabilityValue(name) { - case "image": - return "参考图片" - case "video": - return "参考视频" - case "audio": - return "参考音频" - case "mask": - return "蒙版" - default: - return name - } -} - -func capabilityOptionLabel(name string) string { - switch canonicalCapabilityOptionName(name) { - case "size": - return "画面尺寸" - case "quality": - return "生成质量" - case "transparentBackground": - return "透明背景" - case "count": - return "输出数量" - case "videoSeconds": - return "视频时长" - case "vquality": - return "输出分辨率" - case "videoGenerateAudio": - return "同步音频" - case "videoWatermark": - return "水印设置" - case "audioVoice": - return "音色" - case "audioFormat": - return "音频格式" - case "audioSpeed": - return "语速" - case "audioInstructions": - return "朗读指令" - default: - return name - } -} - -func matchOptionConstraint(name string, constraint OptionConstraint, value any) bool { - if len(constraint.Values) > 0 { - for _, candidate := range constraint.Values { - if normalizedScalar(candidate) == "*" { - return true - } - if capabilityOptionValuesEqual(name, candidate, value) { - return true - } - } - return false - } - number, ok := numericScalar(value) - if !ok { - return false - } - if constraint.Min != nil && number < *constraint.Min { - return false - } - if constraint.Max != nil && number > *constraint.Max { - return false - } - if constraint.Step != nil && constraint.Min != nil { - steps := (number - *constraint.Min) / *constraint.Step - return math.Abs(steps-math.Round(steps)) < 1e-9 - } - return true -} - -func capabilityOptionValuesEqual(name string, candidate any, value any) bool { - left := normalizedScalar(candidate) - right := normalizedScalar(value) - if canonicalCapabilityOptionName(name) == "vquality" { - // Compare using the same aliases as request intents and price tiers. - left = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, left)), "p") - right = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, right)), "p") - } - return left == right -} - -func normalizedScalar(value any) string { - switch typed := value.(type) { - case string: - return normalizeCapabilityValue(typed) - case bool: - return strconv.FormatBool(typed) - case float64: - return strconv.FormatFloat(typed, 'f', -1, 64) - case float32: - return strconv.FormatFloat(float64(typed), 'f', -1, 64) - case int: - return strconv.Itoa(typed) - case int64: - return strconv.FormatInt(typed, 10) - default: - encoded, _ := json.Marshal(value) - return string(encoded) - } -} - -func numericScalar(value any) (float64, bool) { - switch typed := value.(type) { - case float64: - return typed, true - case float32: - return float64(typed), true - case int: - return float64(typed), true - case int64: - return float64(typed), true - case json.Number: - parsed, err := typed.Float64() - return parsed, err == nil - case string: - parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) - return parsed, err == nil - default: - return 0, false - } -} - -func containsNormalized(values []string, expected string) bool { - for _, value := range values { - if normalizeCapabilityValue(value) == expected { - return true - } - } - return false -} - -func normalizeCapabilityValue(value string) string { - return strings.ToLower(strings.TrimSpace(value)) -} - -func canonicalCapabilityOptionName(value string) string { - name := strings.TrimSpace(value) - switch name { - case "duration": - return "videoSeconds" - case "aspectRatio": - return "size" - case "resolution": - return "vquality" - default: - return name - } -} - -func isCapabilityOptionFor(capability string, name string) bool { - switch normalizeCapability(capability) { - case "image": - return name == "size" || name == "quality" || name == "transparentBackground" || name == "count" - case "video": - return name == "size" || name == "videoSeconds" || name == "vquality" || name == "videoGenerateAudio" || name == "videoWatermark" - case "audio": - return name == "audioVoice" || name == "audioFormat" || name == "audioSpeed" || name == "audioInstructions" - case "text": - // systemPrompt 是请求内容,不是供应线路能力维度,不能参与路由匹配。 - return false - default: - return false - } -} - -func isProviderCapabilityOption(name string) bool { - return isCapabilityOptionFor("image", name) || isCapabilityOptionFor("video", name) || isCapabilityOptionFor("audio", name) || isCapabilityOptionFor("text", name) -} - -func (s *Service) invalidateRouteCatalog() { - s.routeCatalogRefreshMu.Lock() - s.routeCatalogMu.Lock() - s.routeCatalog = nil - s.routeCatalogVersion++ - s.routeCatalogRetryAt = time.Time{} - s.routeCatalogRefreshError = nil - s.routeCatalogMu.Unlock() - s.routeCatalogRefreshMu.Unlock() - if s.coordinator != nil { - ctx, cancel := context.WithTimeout(context.Background(), runtimeCoordinationTimeout) - defer cancel() - if err := s.coordinator.BumpRouteCatalogVersion(ctx); err != nil { - log.Printf("logical model route catalog distributed invalidation failed: %v", err) - } - } - s.initReadCaches() - s.routeVersionReadCache.Clear() -} - -func (s *Service) routeCatalogSnapshot() (*routeCatalogSnapshot, error) { - now := time.Now() - version := s.currentRouteCatalogVersion() - s.routeCatalogMu.RLock() - snapshot := s.routeCatalog - if snapshot != nil && now.Sub(snapshot.LoadedAt) < s.routeCatalogTTL && snapshot.CatalogVersion == version { - s.routeCatalogMu.RUnlock() - return snapshot, nil - } - s.routeCatalogMu.RUnlock() - - s.routeCatalogRefreshMu.Lock() - defer s.routeCatalogRefreshMu.Unlock() - now = time.Now() - version = s.currentRouteCatalogVersion() - s.routeCatalogMu.RLock() - snapshot = s.routeCatalog - if snapshot != nil && now.Sub(snapshot.LoadedAt) < s.routeCatalogTTL && snapshot.CatalogVersion == version { - s.routeCatalogMu.RUnlock() - return snapshot, nil - } - s.routeCatalogMu.RUnlock() - - // 刷新锁只能防止并行回源,失败后还需要冷却,否则等待者会逐个重打数据库。 - if s.routeCatalogRefreshError != nil && now.Before(s.routeCatalogRetryAt) { - if snapshot != nil && snapshot.CatalogVersion == version && now.Sub(snapshot.LoadedAt) <= s.routeCatalogMaxStale { - return snapshot, nil - } - return nil, s.routeCatalogRefreshError - } - loaded, err := s.loadRouteCatalog() - if err != nil { - s.routeCatalogRefreshError = err - s.routeCatalogRetryAt = time.Now().Add(2 * time.Second) - log.Printf("logical model route catalog refresh failed; retry cooled down: %v", err) - // 已有快照过期时允许短暂继续服务,数据库首次加载失败则明确失败。 - if snapshot != nil && snapshot.CatalogVersion == version && now.Sub(snapshot.LoadedAt) <= s.routeCatalogMaxStale { - return snapshot, nil - } - return nil, err - } - s.routeCatalogRefreshError = nil - s.routeCatalogRetryAt = time.Time{} - s.routeCatalogMu.Lock() - s.routeCatalog = loaded - s.routeCatalogMu.Unlock() - return loaded, nil -} - -func (s *Service) currentRouteCatalogVersion() int64 { - s.routeCatalogMu.RLock() - localVersion := s.routeCatalogVersion - s.routeCatalogMu.RUnlock() - if s.coordinator == nil || !s.coordinator.HasRedis() { - return localVersion - } - s.initReadCaches() - ctx, cancel := context.WithTimeout(context.Background(), runtimeCoordinationTimeout) - defer cancel() - version, err := s.routeVersionReadCache.Get(ctx, routeCatalogVersionKey, func(ctx context.Context) (int64, int, error) { - value, err := s.coordinator.RouteCatalogVersion(ctx) - if err != nil { - log.Printf("logical model route catalog distributed version check failed: %v", err) - } - return value, 256, err - }) - if err != nil { - return localVersion - } - if version > localVersion { - return version - } - return localVersion -} - -func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - repo := s.repo.WithContext(ctx) - items, err := repo.LogicalModels(false) - if err != nil { - return nil, err - } - snapshot := &routeCatalogSnapshot{LoadedAt: time.Now(), CatalogVersion: s.currentRouteCatalogVersion(), Models: make(map[string]cachedLogicalModel), Ordered: make([]string, 0, len(items))} - graphs, err := repo.LogicalModelGraphs(items, false) - if err != nil { - return nil, err - } - systemChannelIDs := make([]string, 0) - for _, graph := range graphs { - if graph == nil { - continue - } - for _, channelModel := range graph.ChannelModels { - systemChannelIDs = append(systemChannelIDs, channelModel.ChannelID) - } - } - systemChannels, err := repo.SystemChannelsByIDs(systemChannelIDs, false) - if err != nil { - return nil, err - } - enabledSystemChannels := make(map[string]bool, len(systemChannels)) - for _, channel := range systemChannels { - enabledSystemChannels[channel.ID] = true - } - for _, item := range items { - graph := graphs[item.ID] - if graph == nil || graph.Revision == nil { - log.Printf("logical model omitted from route catalog id=%s: graph unavailable", item.ID) - continue - } - productSpec, decodeErr := DecodeCapabilitySpec(graph.Revision.CapabilitySpecJSON) - if decodeErr != nil { - log.Printf("logical model omitted from route catalog id=%s: invalid product capability: %v", item.ID, decodeErr) - continue - } - channelModelByID := make(map[string]model.ChannelModel, len(graph.ChannelModels)) - for _, channelModel := range graph.ChannelModels { - channelModelByID[channelModel.ID] = channelModel - } - cached := cachedLogicalModel{Model: item, Revision: *graph.Revision, ProductSpec: productSpec, Defaults: map[string]any{}} - for _, route := range graph.Routes { - channelModel, ok := channelModelByID[route.ChannelModelID] - if !ok || !channelModel.Enabled || !enabledSystemChannels[channelModel.ChannelID] { - continue - } - if item.PricePolicy == "unified" && item.BillingMode == "token" && !supportsTokenBilling(item.Capability, channelModel.Protocol) { - continue - } - if item.PricePolicy == "channel" && !channelModelHasActivePriceTier(channelModel) { - continue - } - capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) - if specErr != nil { - log.Printf("logical route omitted from catalog route_id=%s channel_model_id=%s: invalid capability: %v", route.ID, channelModel.ID, specErr) - continue - } - cached.Routes = append(cached.Routes, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) - } - productSpec = capabilitySpecWithRoutePresets(productSpec, enabledLogicalRouteSpecs(cached.Routes)) - defaults, defaultsErr := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec) - if defaultsErr != nil { - log.Printf("logical model omitted from route catalog id=%s: invalid defaults: %v", item.ID, defaultsErr) - continue - } - cached.ProductSpec = productSpec - cached.Defaults = defaults - snapshot.Models[item.ID] = cached - snapshot.Ordered = append(snapshot.Ordered, item.ID) - } - return snapshot, nil -} - -// ResolveLogicalModel 将创作意图解析为一次可执行的路由快照。 -// 解析同时约束能力合同、启用状态、价格档和渠道协议;调用方不得在解析完成后自行替换其中任一供应链字段, -// 否则会出现“目录显示可用、任务实际走另一条线路”的配置漂移。 -func (s *Service) ResolveLogicalModel(logicalModelID string, intent ModelRequestIntent) (*RoutedModel, error) { - snapshot, err := s.routeCatalogSnapshot() - if err != nil { - return nil, err - } - cached, ok := snapshot.Models[strings.TrimSpace(logicalModelID)] - if !ok { - return nil, BadAuthRequest("所选模型不可用") - } - intent.Options = mergeIntentDefaults(intent.Options, cached.Defaults) - if match := MatchCapability(cached.ProductSpec, intent); !match.Matched { - return nil, BadAuthRequest("所选模型不支持当前请求:" + strings.Join(match.Reasons, ";")) - } - eligible := s.eligibleLogicalRoutes(cached.Routes, intent, nil, cached.Model.PricePolicy == "channel") - if len(eligible) == 0 { - return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") - } - selected := weightedRoute(eligible) - var priceTier *model.ChannelModelPriceTier - if cached.Model.PricePolicy == "channel" { - priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) - if priceTier == nil { - return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") - } - } - return &RoutedModel{LogicalModel: cached.Model, Revision: cached.Revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: cached.Defaults}, nil -} - -func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent ModelRequestIntent, tried map[string]bool, requirePriceTier bool) []cachedLogicalRoute { - eligible := make([]cachedLogicalRoute, 0, len(routes)) - maxPriority := math.MinInt - for _, route := range routes { - if !route.Route.Enabled || route.Route.Weight <= 0 || tried[route.Route.ID] || s.logicalRouteBlocked(route) { - continue - } - if match := MatchCapability(route.CapabilitySpec, intent); !match.Matched { - continue - } - if requirePriceTier && channelModelPriceTierForIntent(route.ChannelModel, intent) == nil { - continue - } - if route.Route.Priority > maxPriority { - eligible = eligible[:0] - maxPriority = route.Route.Priority - } - if route.Route.Priority == maxPriority { - eligible = append(eligible, route) - } - } - return eligible -} - -func channelModelHasActivePriceTier(channelModel model.ChannelModel) bool { - for _, tier := range channelModel.PriceTiers { - if tier.Enabled && tier.PriceConfigured { - return true - } - } - return false -} - -// channelModelPriceTierForIntent 使用“精确规格优先、通配规格兜底”的规则。SKU 选择器与 -// 运行意图使用同一组规范键,因而图片质量/画幅、视频分辨率/时长和生成操作都能独立定价。 -func channelModelPriceTierForIntent(channelModel model.ChannelModel, intent ModelRequestIntent) *model.ChannelModelPriceTier { - selector := skuSelectorForIntent(intent) - bestScore := -1 - var best *model.ChannelModelPriceTier - for index := range channelModel.PriceTiers { - tier := &channelModel.PriceTiers[index] - if !tier.Enabled || !tier.PriceConfigured { - continue - } - matched, score := matchSKUSelector(skuSelectorForTier(*tier), selector) - if !matched { - continue - } - if score > bestScore { - best, bestScore = tier, score - } - } - return best -} - -func skuSelectorForIntent(intent ModelRequestIntent) map[string]string { - selector := map[string]string{} - if operation := strings.ToLower(strings.TrimSpace(intent.Operation)); operation != "" { - selector["operation"] = operation - } - switch normalizeCapability(intent.Capability) { - case "video": - // 价格档按实际参考素材归类。供应商执行仍可使用 reference_to_video、extend - // 等细分操作;计价时视频参考优先归为视频生视频,其余图片参考无论数量 - // 都归为图生视频。 - if intent.Inputs["video"] > 0 { - selector["operation"] = "video_to_video" - } else if intent.Inputs["image"] > 0 { - selector["operation"] = "image_to_video" - } - if count := intent.Inputs["image"]; count > 0 { - selector["imageCount"] = strconv.Itoa(count) - } - if value := normalizeChannelModelTierResolution(fmt.Sprint(intent.Options["vquality"])); value != "*" { - selector["vquality"] = value - } - if seconds, err := strconv.Atoi(strings.TrimSpace(fmt.Sprint(intent.Options["videoSeconds"]))); err == nil && seconds > 0 { - selector["videoSeconds"] = strconv.Itoa(seconds) - } - case "image": - if intent.Inputs["image"] > 0 { - selector["operation"] = "image_to_image" - } else { - selector["operation"] = "text_to_image" - } - rawQuality, _ := intent.Options["quality"].(string) - rawSize, _ := intent.Options["size"].(string) - if quality := normalizeImagePriceQuality(rawQuality, rawSize); quality != "" { - selector["quality"] = quality - } - for _, key := range []string{"quality", "size"} { - if key == "quality" && selector["quality"] != "" { - continue - } - text, _ := intent.Options[key].(string) - if value := strings.ToLower(strings.TrimSpace(text)); value != "" && value != "auto" && value != "any" { - selector[key] = value - } - } - } - return selector -} - -func normalizeImagePriceQuality(rawQuality string, rawSize string) string { - quality := strings.ToLower(strings.TrimSpace(rawQuality)) - if quality != "" && quality != "auto" && quality != "any" { - return quality - } - parts := strings.Split(strings.ToLower(strings.TrimSpace(rawSize)), "x") - if len(parts) != 2 { - return "" - } - width, widthErr := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) - height, heightErr := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) - if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 || width > (1<<32)/height { - return "" - } - pixels := width * height - switch { - case pixels <= 2_000_000: - return "1k" - case pixels <= 4_300_000: - return "2k" - case pixels <= 8_294_400: - return "4k" - default: - return "" - } -} - -func skuSelectorForTier(tier model.ChannelModelPriceTier) map[string]string { - selector := model.DecodeSKUSelector(tier.SelectorJSON) - if len(selector) == 0 { - if resolution := normalizeChannelModelTierResolution(tier.Resolution); resolution != "*" { - selector["vquality"] = resolution - } - if tier.VideoSeconds > 0 { - selector["videoSeconds"] = strconv.Itoa(tier.VideoSeconds) - } - } - return selector -} - -func matchSKUSelector(tier map[string]string, requested map[string]string) (bool, int) { - score := 0 - for key, expected := range tier { - expected = strings.TrimSpace(expected) - if expected == "" || expected == "*" { - continue - } - if requested[key] != expected { - return false, 0 - } - score++ - } - return true, score -} - -func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool { - now := time.Now() - keys := []string{"channel:" + route.ChannelModel.ChannelID, "channel-model:" + route.ChannelModel.ID, "route:" + route.Route.ID} - // 这里会删除过期项,必须使用写锁;不要改成 RLock。 - s.routeHealthMu.Lock() - for _, key := range keys { - until, exists := s.routeHealthBlocked[key] - if !exists { - continue - } - if !until.After(now) { - delete(s.routeHealthBlocked, key) - continue - } - s.routeHealthMu.Unlock() - return true - } - s.routeHealthMu.Unlock() - - if s.coordinator == nil || !s.coordinator.HasRedis() { - return false - } - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - for _, key := range keys { - until, err := s.coordinator.RouteBlockedUntil(ctx, key) - if err != nil { - log.Printf("logical route distributed health check failed key=%s: %v", key, err) - return false - } - if until.After(now) { - return true - } - } - return false -} - -func mergeIntentDefaults(options map[string]any, defaults map[string]any) map[string]any { - result := make(map[string]any, len(defaults)+len(options)) - for key, value := range defaults { - result[key] = value - } - for key, value := range options { - result[key] = value - } - return result -} - -func weightedRoute(routes []cachedLogicalRoute) cachedLogicalRoute { - if len(routes) == 1 { - return routes[0] - } - var total int64 - for _, route := range routes { - total += int64(route.Route.Weight) - } - if total <= 0 { - return routes[0] - } - var raw [8]byte - if _, err := cryptorand.Read(raw[:]); err != nil { - return routes[0] - } - totalUnsigned := uint64(total) - // 丢弃不能整除 total 的低概率尾部,避免取模造成轻微权重偏差。 - threshold := -totalUnsigned % totalUnsigned - randomValue := binary.LittleEndian.Uint64(raw[:]) - for randomValue < threshold { - if _, err := cryptorand.Read(raw[:]); err != nil { - return routes[0] - } - randomValue = binary.LittleEndian.Uint64(raw[:]) - } - pick := int64(randomValue % totalUnsigned) - for _, route := range routes { - if pick < int64(route.Route.Weight) { - return route - } - pick -= int64(route.Route.Weight) - } - return routes[len(routes)-1] -} - -func (s *Service) sortedRouteDiagnostics(routes []cachedLogicalRoute, intent ModelRequestIntent) []RouteSimulationCandidate { - result := make([]RouteSimulationCandidate, 0, len(routes)) - poolPriority := math.MinInt - for _, route := range routes { - match := MatchCapability(route.CapabilitySpec, intent) - blocked := s.logicalRouteBlocked(route) - if route.Route.Enabled && route.Route.Weight > 0 && match.Matched && !blocked && route.Route.Priority > poolPriority { - poolPriority = route.Route.Priority - } - result = append(result, RouteSimulationCandidate{RouteID: route.Route.ID, ChannelModelID: route.ChannelModel.ID, ChannelModelKey: route.ChannelModel.ModelKey, ChannelModelName: route.ChannelModel.DisplayName, Priority: route.Route.Priority, Weight: route.Route.Weight, Enabled: route.Route.Enabled, Matched: match.Matched, Blocked: blocked, Reasons: match.Reasons}) - } - for index := range result { - result[index].InPool = result[index].Enabled && result[index].Weight > 0 && result[index].Matched && !result[index].Blocked && result[index].Priority == poolPriority - } - sort.SliceStable(result, func(i, j int) bool { return result[i].Priority > result[j].Priority }) - return result -} - -func (s *Service) createRouteAttempt(task *model.Task, routed *RoutedModel, attemptNumber int) (*model.RouteAttempt, error) { - id, err := s.repo.NextPrefixedID("ATTEMPT") - if err != nil { - return nil, err - } - channelModel, err := s.repo.ChannelModelByID(routed.ChannelModel.ChannelID, routed.ChannelModel.ID) - if err != nil { - return nil, err - } - attempt := &model.RouteAttempt{ID: id, TaskID: task.ID, RouteRun: task.RouteRun, AttemptNumber: attemptNumber, LogicalModelID: routed.LogicalModel.ID, LogicalModelRevisionID: routed.Revision.ID, RouteID: routed.Route.ID, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, Status: "selected", DispatchState: "not_sent", StartedAt: time.Now()} - if err := s.repo.CreateRouteAttempt(attempt); err != nil { - return nil, err - } - return attempt, nil -} - -func (s *Service) beginTaskRouteAttempt(task *model.Task) (*model.RouteAttempt, error) { - if task == nil { - return nil, nil - } - attempts, err := s.repo.RouteAttempts(task.ID, task.RouteRun) - if err != nil { - return nil, err - } - if len(attempts) > 0 { - existing := &attempts[len(attempts)-1] - switch existing.DispatchState { - case "not_sent": - return existing, nil - case "accepted": - if existing.ProviderRequestID != "" || task.ProviderRequestID != "" { - if task.ProviderRequestID == "" { - task.ProviderRequestID = existing.ProviderRequestID - if err := s.repo.UpdateTaskProviderState(task.ID, task.ProviderRequestID, task.PollStage, task.NextPollAt); err != nil { - return nil, err - } - } - return existing, nil - } - return nil, routeDispatchUncertainError{"上游已接受请求,但没有可恢复的任务 ID"} - case "submission_unknown": - if existing.ProviderRequestID != "" || task.ProviderRequestID != "" { - if task.ProviderRequestID == "" { - task.ProviderRequestID = existing.ProviderRequestID - if err := s.repo.UpdateTaskProviderState(task.ID, task.ProviderRequestID, task.PollStage, task.NextPollAt); err != nil { - return nil, err - } - } - existing.DispatchState = "accepted" - if err := s.repo.SaveRouteAttempt(existing); err != nil { - return nil, err - } - return existing, nil - } - return nil, routeDispatchUncertainError{"上一次提交结果不明确,为避免重复扣费已停止自动重发"} - case "rejected_no_job": - if task.LogicalModelID == "" { - return nil, errors.New("上游已拒绝本次请求,请检查渠道配置后再试") - } - return s.switchTaskToNextRoute(task, attempts) - } - } - if task.LogicalModelID == "" { - return s.createDirectTaskAttempt(task) - } - routed, routeErr := s.routedModelForTaskSelection(task) - if routeErr != nil { - return s.switchTaskToNextRoute(task, attempts) - } - return s.createRouteAttempt(task, routed, len(attempts)+1) -} - -func (s *Service) markRouteAttemptDispatching(attempt *model.RouteAttempt) error { - if attempt == nil || attempt.DispatchState != "not_sent" { - return nil - } - // A stale worker must not dispatch the same selected attempt a second time. - if err := s.repo.MarkRouteAttemptDispatching(attempt.ID); err != nil { - return routeDispatchUncertainError{"提交状态未能独占确认,为避免重复扣费已停止自动重发"} - } - attempt.Status, attempt.DispatchState = "dispatching", "submission_unknown" - return nil -} - -type routeDispatchUncertainError struct{ message string } - -func (e routeDispatchUncertainError) Error() string { return e.message } - -func isRouteDispatchUncertain(err error) bool { - var target routeDispatchUncertainError - return errors.As(err, &target) -} - -func (s *Service) routedModelForTaskSelection(task *model.Task) (*RoutedModel, error) { - route, err := s.repo.LogicalModelRoute(task.RouteID) - if err != nil { - return nil, err - } - if !route.Enabled || route.Weight <= 0 || route.LogicalModelRevisionID != task.LogicalModelRevisionID { - return nil, errors.New("任务使用的模型服务配置已失效") - } - if route.ChannelModelID != task.ChannelModelID { - return nil, errors.New("任务使用的模型服务配置已失效") - } - channelModel, err := s.repo.ChannelModel(task.ChannelModelID) - if err != nil { - return nil, err - } - if !channelModel.Enabled { - return nil, errors.New("任务使用的模型服务配置已失效") - } - if _, err := s.repo.SystemChannel(channelModel.ChannelID); err != nil { - return nil, err - } - logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) - if err != nil { - return nil, err - } - revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) - if err != nil { - return nil, err - } - if revision.LogicalModelID != logicalModel.ID { - return nil, errors.New("任务前台模型版本不一致") - } - productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) - if err != nil { - return nil, err - } - defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) - if err != nil { - return nil, err - } - capabilitySpec, err := channelModelCapabilitySpec(*channelModel) - if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { - return nil, errors.New("当前模型服务暂不可用") - } - if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { - return nil, errors.New("任务使用的模型服务价格配置已失效") - } - if logicalModel.PricePolicy == "unified" && logicalModel.BillingMode == "token" && !supportsTokenBilling(logicalModel.Capability, channelModel.Protocol) { - return nil, errors.New("任务使用的模型服务不再支持当前 Token 计费配置") - } - routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults} - return routed, nil -} - -func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.RouteAttempt) (*model.RouteAttempt, error) { - decrypted, err := s.decryptTaskInputJSON(task.InputJSON) - if err != nil { - return nil, err - } - var input map[string]any - if err := json.Unmarshal([]byte(decrypted), &input); err != nil { - return nil, BadAuthRequest("任务输入格式无效,无法恢复模型服务") - } - logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) - if err != nil { - return nil, err - } - revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) - if err != nil || revision.LogicalModelID != logicalModel.ID { - return nil, errors.New("任务前台模型版本不存在或归属不一致") - } - productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) - if err != nil { - return nil, err - } - defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) - if err != nil { - return nil, err - } - intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) - intent.Options = mergeIntentDefaults(intent.Options, defaults) - if match := MatchCapability(productSpec, intent); !match.Matched { - return nil, BadAuthRequest("任务参数不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) - } - routes, err := s.repo.LogicalModelRoutes(revision.ID, false) - if err != nil { - return nil, err - } - channelModelIDs := make([]string, 0, len(routes)) - for _, route := range routes { - channelModelIDs = append(channelModelIDs, route.ChannelModelID) - } - channelModels, err := s.repo.ChannelModelsByIDs(channelModelIDs) - if err != nil { - return nil, err - } - channelIDs := make([]string, 0, len(channelModels)) - for _, channelModel := range channelModels { - channelIDs = append(channelIDs, channelModel.ChannelID) - } - systemChannels, err := s.repo.SystemChannelsByIDs(channelIDs, false) - if err != nil { - return nil, err - } - enabledSystemChannels := make(map[string]bool, len(systemChannels)) - for _, channel := range systemChannels { - enabledSystemChannels[channel.ID] = true - } - channelModelByID := make(map[string]model.ChannelModel, len(channelModels)) - for _, channelModel := range channelModels { - if channelModel.Enabled && enabledSystemChannels[channelModel.ChannelID] && (logicalModel.PricePolicy != "channel" || channelModelHasActivePriceTier(channelModel)) { - channelModelByID[channelModel.ID] = channelModel - } - } - candidates := make([]cachedLogicalRoute, 0, len(routes)) - for _, route := range routes { - channelModel, channelOK := channelModelByID[route.ChannelModelID] - if !channelOK { - continue - } - capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) - if specErr != nil { - continue - } - candidates = append(candidates, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) - } - tried := make(map[string]bool, len(attempts)) - for _, attempt := range attempts { - tried[attempt.RouteID] = true - } - eligible := s.eligibleLogicalRoutes(candidates, intent, tried, logicalModel.PricePolicy == "channel") - if len(eligible) == 0 { - return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") - } - selected := weightedRoute(eligible) - var priceTier *model.ChannelModelPriceTier - if logicalModel.PricePolicy == "channel" { - priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) - if priceTier == nil { - return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") - } - } - routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: defaults} - nextInput := applyRoutedProviderSelection(input, routed) - if err := s.ValidateTaskCapability(nextInput); err != nil { - return nil, err - } - if err := s.protectTaskSecrets(nextInput); err != nil { - return nil, err - } - encoded, err := json.Marshal(nextInput) - if err != nil { - return nil, err - } - var replacement *model.BillingOrder - if logicalModel.PricePolicy == "channel" && task.BillingOrderID != "" { - config, _ := nextInput["config"].(map[string]any) - capability := normalizeCapability(fmt.Sprint(nextInput["mode"])) - if capability == "" { - capability = capabilityFromTaskType(task.Type) - } - priceTierID, _ := config["priceTierId"].(string) - replacement, err = s.newBillingOrderWithPriceTier(task.UserID, task.ID, "route-switch:"+task.ID+":"+selected.Route.ID, selected.ChannelModel.ChannelID, selected.ChannelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability), strings.TrimSpace(priceTierID), intent) - if err != nil { - return nil, err - } - replacement.Model = logicalModel.Code - } - previousRouteID := task.RouteID - if err := s.repo.SwitchTaskLogicalRoute(task.ID, previousRouteID, selected.Route.ID, string(encoded), task.BillingOrderID, selected.ChannelModel.ChannelID, selected.ChannelModel.ID, replacement); err != nil { - if errors.Is(err, repository.ErrInsufficientCredits) { - return nil, BadAuthRequest("模型服务价格发生变化,当前积分余额不足") - } - return nil, err - } - task.RouteID = selected.Route.ID - task.ChannelModelID = selected.ChannelModel.ID - task.InputJSON = string(encoded) - task.ProviderRequestID = "" - return s.createRouteAttempt(task, routed, len(attempts)+1) -} - -func (s *Service) nextRouteAttemptAfterFailure(task *model.Task, attempt *model.RouteAttempt, taskErr error) (*model.RouteAttempt, error) { - if task == nil || task.LogicalModelID == "" || attempt == nil || attempt.DispatchState != "rejected_no_job" { - return nil, nil - } - if errors.Is(taskErr, context.Canceled) || errors.Is(taskErr, context.DeadlineExceeded) { - return nil, nil - } - s.blockLogicalRouteForFailure(attempt, taskErr) - attempts, err := s.repo.RouteAttempts(task.ID, task.RouteRun) - if err != nil { - return nil, err - } - return s.switchTaskToNextRoute(task, attempts) -} - -func (s *Service) blockLogicalRouteForFailure(attempt *model.RouteAttempt, taskErr error) { - if attempt == nil { - return - } - key := "" - duration := time.Duration(0) - if attempt.FailureCode == "upstream_401" || attempt.FailureCode == "upstream_403" { - key, duration = "channel:"+attempt.ChannelID, 10*time.Minute - } else if attempt.FailureCode == "upstream_404" { - key, duration = "channel-model:"+attempt.ChannelModelID, 10*time.Minute - } else if attempt.FailureCode == "upstream_429" { - key, duration = "channel:"+attempt.ChannelID, 30*time.Second - var upstream providerHTTPError - if errors.As(taskErr, &upstream) && upstream.RetryAfter > 0 { - duration = upstream.RetryAfter - } - } - if key == "" || duration <= 0 { - return - } - until := time.Now().Add(duration) - s.routeHealthMu.Lock() - // 本地状态是 Redis 不可用时的降级,也能覆盖 Redis 写入瞬间的网络抖动。 - s.routeHealthBlocked[key] = until - s.routeHealthMu.Unlock() - if s.coordinator != nil && s.coordinator.HasRedis() { - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - if err := s.coordinator.BlockRoute(ctx, key, until); err != nil { - log.Printf("logical route distributed health update failed key=%s: %v", key, err) - } - } -} - -func (s *Service) prepareLogicalTaskRetry(task *model.Task, input map[string]any) error { - if task == nil || task.LogicalModelID == "" { - return nil - } - intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) - logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) - if err != nil { - return err - } - var routed *RoutedModel - if logicalModel.ArchivedAt != nil { - // 归档只隐藏新任务的公开目录;历史任务重试必须使用任务快照,不能重新从公开目录选路。 - routed, err = s.resolveArchivedTaskRoute(task, intent) - } else { - routed, err = s.ResolveLogicalModel(task.LogicalModelID, intent) - } - if err != nil { - return err - } - input = applyRoutedProviderSelection(input, routed) - if err := s.ValidateTaskCapability(input); err != nil { - return err - } - if err := s.protectTaskSecrets(input); err != nil { - return err - } - encoded, err := json.Marshal(input) - if err != nil { - return err - } - task.LogicalModelRevisionID = routed.Revision.ID - task.RouteID = routed.Route.ID - task.ChannelModelID = routed.ChannelModel.ID - task.Model = routed.LogicalModel.Code - task.Provider = "managed" - task.InputJSON = string(encoded) - return nil -} - -// resolveArchivedTaskRoute 恢复归档模型任务保存的 revision、route 和 channel model 快照。 -// 归档模型不得重新进入公开路由目录;原供应线路失效时应明确拒绝重试,避免静默切换到未知配置。 -func (s *Service) resolveArchivedTaskRoute(task *model.Task, intent ModelRequestIntent) (*RoutedModel, error) { - if task == nil || task.LogicalModelID == "" || task.LogicalModelRevisionID == "" || task.RouteID == "" || task.ChannelModelID == "" { - return nil, BadAuthRequest("历史任务缺少完整的模型服务快照,无法重试") - } - logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) - if err != nil { - return nil, err - } - if logicalModel.ArchivedAt == nil { - return nil, BadAuthRequest("任务模型已恢复为可用模型,请重新选择后重试") - } - revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) - if err != nil || revision.LogicalModelID != logicalModel.ID { - return nil, BadAuthRequest("历史任务前台模型版本不存在或归属不一致") - } - route, err := s.repo.LogicalModelRoute(task.RouteID) - if err != nil || route.LogicalModelRevisionID != revision.ID || route.ChannelModelID != task.ChannelModelID || !route.Enabled || route.Weight <= 0 { - return nil, BadAuthRequest("历史任务原模型供应线路已失效,无法重试") - } - channelModel, err := s.repo.ChannelModel(task.ChannelModelID) - if err != nil || !channelModel.Enabled { - return nil, BadAuthRequest("历史任务原模型服务已失效,无法重试") - } - if _, err := s.repo.SystemChannel(channelModel.ChannelID); err != nil { - return nil, BadAuthRequest("历史任务原模型渠道已失效,无法重试") - } - productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) - if err != nil { - return nil, err - } - defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) - if err != nil { - return nil, err - } - intent.Options = mergeIntentDefaults(intent.Options, defaults) - if match := MatchCapability(productSpec, intent); !match.Matched { - return nil, BadAuthRequest("历史任务不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) - } - capabilitySpec, err := channelModelCapabilitySpec(*channelModel) - if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { - return nil, BadAuthRequest("历史任务原模型供应线路暂不可用,无法重试") - } - if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { - return nil, BadAuthRequest("历史任务原模型价格配置已失效,无法重试") - } - return &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults}, nil -} - -func (s *Service) finishTaskRouteAttempt(attempt *model.RouteAttempt, task *model.Task, taskErr error) { - if attempt == nil { - return - } - now := time.Now() - attempt.CompletedAt = &now - if task != nil { - attempt.ProviderRequestID = task.ProviderRequestID - } - if taskErr == nil { - attempt.Status = "succeeded" - attempt.DispatchState = "accepted" - } else { - attempt.Status = "failed" - attempt.FailureMessage = truncateRunes(taskFailureMessage(taskErr), 1000) - attempt.FailureCode = routeFailureCode(taskErr) - if attempt.ProviderRequestID != "" { - attempt.DispatchState = "accepted" - } else if safeRouteRejection(taskErr) { - attempt.DispatchState = "rejected_no_job" - } else { - attempt.DispatchState = "submission_unknown" - } - } - if err := s.repo.SaveRouteAttempt(attempt); err != nil { - log.Printf("route attempt terminal state save failed attempt_id=%s task_id=%s: %v", attempt.ID, attempt.TaskID, err) - } -} - -func routeFailureCode(err error) string { - if code, _ := ChannelSlotFailureDetails(err); code != "" { - return code - } - var upstream providerHTTPError - if errors.As(err, &upstream) { - return fmt.Sprintf("upstream_%d", upstream.StatusCode) - } - return "submission_unknown" -} - -func safeRouteRejection(err error) bool { - if err == nil { - return false - } - if code, _ := ChannelSlotFailureDetails(err); code != "" { - return true - } - var upstream providerHTTPError - if errors.As(err, &upstream) { - switch upstream.StatusCode { - case 401, 403, 404, 429: - return true - } - } - return false -} +package app + +import ( + "context" + cryptorand "crypto/rand" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "sort" + "strconv" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +type CapabilityImageSizePreset struct { + Size string `json:"size"` + Tier string `json:"tier"` + Ratio string `json:"ratio"` + Width int `json:"width"` + Height int `json:"height"` +} + +type CapabilityImageSize struct { + Parameter string `json:"parameter,omitempty"` + AllowCustom bool `json:"allowCustom,omitempty"` + Presets []CapabilityImageSizePreset `json:"presets,omitempty"` +} + +type CapabilitySpec struct { + Version int `json:"version"` + Capability string `json:"capability"` + Operations []string `json:"operations,omitempty"` + Inputs map[string]InputConstraint `json:"inputs,omitempty"` + Options map[string]OptionConstraint `json:"options,omitempty"` + ImageSize *CapabilityImageSize `json:"imageSize,omitempty"` +} + +type InputConstraint struct { + Min int `json:"min"` + Max int `json:"max"` +} + +type OptionConstraint struct { + Values []any `json:"values,omitempty"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` + Step *float64 `json:"step,omitempty"` +} + +type ModelRequestIntent struct { + Capability string `json:"capability"` + Operation string `json:"operation,omitempty"` + Inputs map[string]int `json:"inputs,omitempty"` + Options map[string]any `json:"options,omitempty"` +} + +// ModelRequestIntentFromTaskInput 从统一任务输入推导路由意图;它只统计实际输入和显式参数,不假设任何固定图片数或视频时长。 +func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, operation string) ModelRequestIntent { + capability := normalizeCapability(fmt.Sprint(input["mode"])) + if capability == "" { + capability = capabilityFromTaskType(taskType) + } + intent := ModelRequestIntent{Capability: capability, Operation: strings.TrimSpace(operation), Inputs: map[string]int{}, Options: map[string]any{}} + for inputType, key := range map[string]string{"image": "referenceImages", "video": "referenceVideos", "audio": "referenceAudios"} { + if values, ok := input[key].([]any); ok { + intent.Inputs[inputType] = len(values) + } + } + if mask, exists := input["mask"]; exists && mask != nil { + intent.Inputs["mask"] = 1 + } + explicitOptions := false + if options, ok := input["capabilityOptions"].(map[string]any); ok { + explicitOptions = true + for key, value := range options { + name := canonicalCapabilityOptionName(key) + // auto/any 表示调用方不指定质量;不能把它当作模型必须声明的 + // 枚举值,否则未列出 auto 的模型会被错误判定为参数不支持。 + if name == "quality" { + if normalized, ok := value.(string); ok && (strings.EqualFold(strings.TrimSpace(normalized), "auto") || strings.EqualFold(strings.TrimSpace(normalized), "any")) { + continue + } + } + intent.Options[name] = normalizeModelRequestOption(name, value) + } + } + if config, ok := input["config"].(map[string]any); ok && !explicitOptions { + for key, value := range config { + switch key { + case "channelId", "apiFormat", "interfaceType", "baseUrl", "apiKey", "secretKey", "headers", "model", "capabilityConfig": + continue + default: + canonical := canonicalCapabilityOptionName(key) + if isCapabilityOptionFor(capability, canonical) && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" { + intent.Options[canonical] = normalizeModelRequestOption(canonical, value) + } + } + } + } + return intent +} + +func normalizeModelRequestOption(name string, value any) any { + canonicalName := canonicalCapabilityOptionName(name) + if canonicalName == "quality" || canonicalName == "size" { + if text, ok := value.(string); ok { + return strings.ToLower(strings.TrimSpace(text)) + } + return value + } + if canonicalName != "vquality" { + return value + } + resolution, ok := value.(string) + if !ok { + return value + } + switch strings.ToLower(strings.TrimSpace(resolution)) { + case "low", "480", "480p": + return "480p" + case "720", "720p": + return "720p" + case "1080", "1080p": + return "1080p" + case "2k", "1440", "1440p": + return "1440p" + case "4k", "2160", "2160p": + return "2160p" + default: + return value + } +} + +type CapabilityMatch struct { + Matched bool `json:"matched"` + Reasons []string `json:"reasons,omitempty"` +} + +type cachedLogicalModel struct { + Model model.LogicalModel + Revision model.LogicalModelRevision + ProductSpec CapabilitySpec + Defaults map[string]any + Routes []cachedLogicalRoute +} + +type cachedLogicalRoute struct { + Route model.LogicalModelRoute + CapabilitySpec CapabilitySpec + ChannelModel model.ChannelModel +} + +type routeCatalogSnapshot struct { + LoadedAt time.Time + CatalogVersion int64 + Models map[string]cachedLogicalModel + Ordered []string +} + +type RoutedModel struct { + LogicalModel model.LogicalModel + Revision model.LogicalModelRevision + Route model.LogicalModelRoute + ChannelModel model.ChannelModel + PriceTier *model.ChannelModelPriceTier + Defaults map[string]any +} + +func DecodeCapabilitySpec(raw string) (CapabilitySpec, error) { + var spec CapabilitySpec + if err := json.Unmarshal([]byte(raw), &spec); err != nil { + return spec, BadAuthRequest("能力配置不是有效 JSON") + } + normalized, err := NormalizeCapabilitySpec(spec) + if err != nil { + return spec, err + } + return normalized, nil +} + +func ValidateCapabilitySpec(spec CapabilitySpec) error { + _, err := NormalizeCapabilitySpec(spec) + return err +} + +func NormalizeCapabilitySpec(spec CapabilitySpec) (CapabilitySpec, error) { + spec.Capability = normalizeCapability(spec.Capability) + if spec.Version != 1 { + return spec, BadAuthRequest("能力配置 version 必须为 1") + } + if spec.Capability == "" { + return spec, BadAuthRequest("能力配置必须声明 capability") + } + operations := make([]string, 0, len(spec.Operations)) + seenOperations := make(map[string]bool, len(spec.Operations)) + for _, operation := range spec.Operations { + normalized := normalizeCapabilityValue(operation) + if normalized != "" && !seenOperations[normalized] { + seenOperations[normalized] = true + operations = append(operations, normalized) + } + } + spec.Operations = operations + normalizedInputs := make(map[string]InputConstraint, len(spec.Inputs)) + for rawName, constraint := range spec.Inputs { + name := normalizeCapabilityValue(rawName) + if name == "" || constraint.Min < 0 || constraint.Max < constraint.Min { + return spec, BadAuthRequest("输入能力范围无效") + } + if _, exists := normalizedInputs[name]; exists { + return spec, BadAuthRequest("输入能力存在重复名称:" + name) + } + normalizedInputs[name] = constraint + } + spec.Inputs = normalizedInputs + normalizedOptions := make(map[string]OptionConstraint, len(spec.Options)) + for rawName, constraint := range spec.Options { + name := canonicalCapabilityOptionName(rawName) + if strings.TrimSpace(name) == "" { + return spec, BadAuthRequest("能力参数名称不能为空") + } + if _, exists := normalizedOptions[name]; exists { + return spec, BadAuthRequest("能力参数存在重复别名:" + name) + } + hasValues := len(constraint.Values) > 0 + hasRange := constraint.Min != nil || constraint.Max != nil || constraint.Step != nil + if !hasValues && !hasRange { + return spec, BadAuthRequest("能力参数必须声明 values 或数值范围") + } + if hasValues && hasRange { + return spec, BadAuthRequest("能力参数不能同时声明 values 和数值范围") + } + if hasRange && (constraint.Min == nil || constraint.Max == nil) { + return spec, BadAuthRequest("数值范围必须同时声明 min 和 max") + } + if constraint.Min != nil && constraint.Max != nil && *constraint.Max < *constraint.Min { + return spec, BadAuthRequest("能力参数数值范围无效") + } + if constraint.Step != nil && *constraint.Step <= 0 { + return spec, BadAuthRequest("能力参数 step 必须大于 0") + } + normalizedOptions[name] = constraint + } + spec.Options = normalizedOptions + return spec, nil +} + +func decodeLogicalDefaults(raw string, spec CapabilitySpec) (map[string]any, error) { + defaults := map[string]any{} + if err := json.Unmarshal([]byte(raw), &defaults); err != nil { + return nil, err + } + return normalizeLogicalDefaults(spec, defaults) +} + +func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityMatch { + reasons := make([]string, 0) + if normalizeCapability(intent.Capability) != normalizeCapability(spec.Capability) { + reasons = append(reasons, "能力类型不匹配") + } + if operation := normalizeCapabilityValue(intent.Operation); operation != "" && len(spec.Operations) > 0 && !containsNormalized(spec.Operations, operation) { + reasons = append(reasons, "不支持操作 "+intent.Operation) + } + for inputType, count := range intent.Inputs { + if count < 0 { + reasons = append(reasons, "输入数量不能小于 0") + continue + } + constraint, declared := spec.Inputs[inputType] + if !declared { + if count > 0 { + reasons = append(reasons, "不支持 "+capabilityInputLabel(inputType)+"输入") + } + continue + } + if count < constraint.Min || count > constraint.Max { + reasons = append(reasons, fmt.Sprintf("%s数量需在 %d-%d 之间", capabilityInputLabel(inputType), constraint.Min, constraint.Max)) + } + } + for inputType, constraint := range spec.Inputs { + if intent.Inputs[inputType] < constraint.Min { + reasons = append(reasons, fmt.Sprintf("至少需要 %d 个%s", constraint.Min, capabilityInputLabel(inputType))) + } + } + for name, value := range intent.Options { + constraint, declared := spec.Options[canonicalCapabilityOptionName(name)] + if !declared { + reasons = append(reasons, "不支持参数 "+capabilityOptionLabel(name)) + continue + } + if !matchOptionConstraint(name, constraint, value) { + reasons = append(reasons, "参数 "+capabilityOptionLabel(name)+"超出支持范围") + } + } + return CapabilityMatch{Matched: len(reasons) == 0, Reasons: reasons} +} + +func capabilityInputLabel(name string) string { + switch normalizeCapabilityValue(name) { + case "image": + return "参考图片" + case "video": + return "参考视频" + case "audio": + return "参考音频" + case "mask": + return "蒙版" + default: + return name + } +} + +func capabilityOptionLabel(name string) string { + switch canonicalCapabilityOptionName(name) { + case "size": + return "画面尺寸" + case "quality": + return "生成质量" + case "transparentBackground": + return "透明背景" + case "count": + return "输出数量" + case "videoSeconds": + return "视频时长" + case "vquality": + return "输出分辨率" + case "videoGenerateAudio": + return "同步音频" + case "videoWatermark": + return "水印设置" + case "audioVoice": + return "音色" + case "audioFormat": + return "音频格式" + case "audioSpeed": + return "语速" + case "audioInstructions": + return "朗读指令" + default: + return name + } +} + +func matchOptionConstraint(name string, constraint OptionConstraint, value any) bool { + if len(constraint.Values) > 0 { + for _, candidate := range constraint.Values { + if normalizedScalar(candidate) == "*" { + return true + } + if capabilityOptionValuesEqual(name, candidate, value) { + return true + } + } + return false + } + number, ok := numericScalar(value) + if !ok { + return false + } + if constraint.Min != nil && number < *constraint.Min { + return false + } + if constraint.Max != nil && number > *constraint.Max { + return false + } + if constraint.Step != nil && constraint.Min != nil { + steps := (number - *constraint.Min) / *constraint.Step + return math.Abs(steps-math.Round(steps)) < 1e-9 + } + return true +} + +func capabilityOptionValuesEqual(name string, candidate any, value any) bool { + left := normalizedScalar(candidate) + right := normalizedScalar(value) + if canonicalCapabilityOptionName(name) == "vquality" { + // Compare using the same aliases as request intents and price tiers. + left = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, left)), "p") + right = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, right)), "p") + } + return left == right +} + +func normalizedScalar(value any) string { + switch typed := value.(type) { + case string: + return normalizeCapabilityValue(typed) + case bool: + return strconv.FormatBool(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case float32: + return strconv.FormatFloat(float64(typed), 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + encoded, _ := json.Marshal(value) + return string(encoded) + } +} + +func numericScalar(value any) (float64, bool) { + switch typed := value.(type) { + case float64: + return typed, true + case float32: + return float64(typed), true + case int: + return float64(typed), true + case int64: + return float64(typed), true + case json.Number: + parsed, err := typed.Float64() + return parsed, err == nil + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + return parsed, err == nil + default: + return 0, false + } +} + +func containsNormalized(values []string, expected string) bool { + for _, value := range values { + if normalizeCapabilityValue(value) == expected { + return true + } + } + return false +} + +func normalizeCapabilityValue(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func canonicalCapabilityOptionName(value string) string { + name := strings.TrimSpace(value) + switch name { + case "duration": + return "videoSeconds" + case "aspectRatio": + return "size" + case "resolution": + return "vquality" + default: + return name + } +} + +func isCapabilityOptionFor(capability string, name string) bool { + switch normalizeCapability(capability) { + case "image": + return name == "size" || name == "quality" || name == "transparentBackground" || name == "count" + case "video": + return name == "size" || name == "videoSeconds" || name == "vquality" || name == "videoGenerateAudio" || name == "videoWatermark" + case "audio": + return name == "audioVoice" || name == "audioFormat" || name == "audioSpeed" || name == "audioInstructions" + case "text": + // systemPrompt 是请求内容,不是供应线路能力维度,不能参与路由匹配。 + return false + default: + return false + } +} + +func isProviderCapabilityOption(name string) bool { + return isCapabilityOptionFor("image", name) || isCapabilityOptionFor("video", name) || isCapabilityOptionFor("audio", name) || isCapabilityOptionFor("text", name) +} + +func (s *Service) invalidateRouteCatalog() { + s.routeCatalogRefreshMu.Lock() + s.routeCatalogMu.Lock() + s.routeCatalog = nil + s.routeCatalogVersion++ + s.routeCatalogRetryAt = time.Time{} + s.routeCatalogRefreshError = nil + s.routeCatalogMu.Unlock() + s.routeCatalogRefreshMu.Unlock() + if s.coordinator != nil { + ctx, cancel := context.WithTimeout(context.Background(), runtimeCoordinationTimeout) + defer cancel() + if err := s.coordinator.BumpRouteCatalogVersion(ctx); err != nil { + log.Printf("logical model route catalog distributed invalidation failed: %v", err) + } + } + s.initReadCaches() + s.routeVersionReadCache.Clear() +} + +func (s *Service) routeCatalogSnapshot() (*routeCatalogSnapshot, error) { + now := time.Now() + version := s.currentRouteCatalogVersion() + s.routeCatalogMu.RLock() + snapshot := s.routeCatalog + if snapshot != nil && now.Sub(snapshot.LoadedAt) < s.routeCatalogTTL && snapshot.CatalogVersion == version { + s.routeCatalogMu.RUnlock() + return snapshot, nil + } + s.routeCatalogMu.RUnlock() + + s.routeCatalogRefreshMu.Lock() + defer s.routeCatalogRefreshMu.Unlock() + now = time.Now() + version = s.currentRouteCatalogVersion() + s.routeCatalogMu.RLock() + snapshot = s.routeCatalog + if snapshot != nil && now.Sub(snapshot.LoadedAt) < s.routeCatalogTTL && snapshot.CatalogVersion == version { + s.routeCatalogMu.RUnlock() + return snapshot, nil + } + s.routeCatalogMu.RUnlock() + + // 刷新锁只能防止并行回源,失败后还需要冷却,否则等待者会逐个重打数据库。 + if s.routeCatalogRefreshError != nil && now.Before(s.routeCatalogRetryAt) { + if snapshot != nil && snapshot.CatalogVersion == version && now.Sub(snapshot.LoadedAt) <= s.routeCatalogMaxStale { + return snapshot, nil + } + return nil, s.routeCatalogRefreshError + } + loaded, err := s.loadRouteCatalog() + if err != nil { + s.routeCatalogRefreshError = err + s.routeCatalogRetryAt = time.Now().Add(2 * time.Second) + log.Printf("logical model route catalog refresh failed; retry cooled down: %v", err) + // 已有快照过期时允许短暂继续服务,数据库首次加载失败则明确失败。 + if snapshot != nil && snapshot.CatalogVersion == version && now.Sub(snapshot.LoadedAt) <= s.routeCatalogMaxStale { + return snapshot, nil + } + return nil, err + } + s.routeCatalogRefreshError = nil + s.routeCatalogRetryAt = time.Time{} + s.routeCatalogMu.Lock() + s.routeCatalog = loaded + s.routeCatalogMu.Unlock() + return loaded, nil +} + +func (s *Service) currentRouteCatalogVersion() int64 { + s.routeCatalogMu.RLock() + localVersion := s.routeCatalogVersion + s.routeCatalogMu.RUnlock() + if s.coordinator == nil || !s.coordinator.HasRedis() { + return localVersion + } + s.initReadCaches() + ctx, cancel := context.WithTimeout(context.Background(), runtimeCoordinationTimeout) + defer cancel() + version, err := s.routeVersionReadCache.Get(ctx, routeCatalogVersionKey, func(ctx context.Context) (int64, int, error) { + value, err := s.coordinator.RouteCatalogVersion(ctx) + if err != nil { + log.Printf("logical model route catalog distributed version check failed: %v", err) + } + return value, 256, err + }) + if err != nil { + return localVersion + } + if version > localVersion { + return version + } + return localVersion +} + +func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + repo := s.repo.WithContext(ctx) + items, err := repo.LogicalModels(false) + if err != nil { + return nil, err + } + snapshot := &routeCatalogSnapshot{LoadedAt: time.Now(), CatalogVersion: s.currentRouteCatalogVersion(), Models: make(map[string]cachedLogicalModel), Ordered: make([]string, 0, len(items))} + graphs, err := repo.LogicalModelGraphs(items, false) + if err != nil { + return nil, err + } + systemChannelIDs := make([]string, 0) + for _, graph := range graphs { + if graph == nil { + continue + } + for _, channelModel := range graph.ChannelModels { + systemChannelIDs = append(systemChannelIDs, channelModel.ChannelID) + } + } + systemChannels, err := repo.SystemChannelsByIDs(systemChannelIDs, false) + if err != nil { + return nil, err + } + enabledSystemChannels := make(map[string]bool, len(systemChannels)) + for _, channel := range systemChannels { + enabledSystemChannels[channel.ID] = true + } + for _, item := range items { + graph := graphs[item.ID] + if graph == nil || graph.Revision == nil { + log.Printf("logical model omitted from route catalog id=%s: graph unavailable", item.ID) + continue + } + productSpec, decodeErr := DecodeCapabilitySpec(graph.Revision.CapabilitySpecJSON) + if decodeErr != nil { + log.Printf("logical model omitted from route catalog id=%s: invalid product capability: %v", item.ID, decodeErr) + continue + } + channelModelByID := make(map[string]model.ChannelModel, len(graph.ChannelModels)) + for _, channelModel := range graph.ChannelModels { + channelModelByID[channelModel.ID] = channelModel + } + cached := cachedLogicalModel{Model: item, Revision: *graph.Revision, ProductSpec: productSpec, Defaults: map[string]any{}} + for _, route := range graph.Routes { + channelModel, ok := channelModelByID[route.ChannelModelID] + if !ok || !channelModel.Enabled || !enabledSystemChannels[channelModel.ChannelID] { + continue + } + if item.PricePolicy == "unified" && item.BillingMode == "token" && !supportsTokenBilling(item.Capability, channelModel.Protocol) { + continue + } + if item.PricePolicy == "channel" && !channelModelHasActivePriceTier(channelModel) { + continue + } + capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) + if specErr != nil { + log.Printf("logical route omitted from catalog route_id=%s channel_model_id=%s: invalid capability: %v", route.ID, channelModel.ID, specErr) + continue + } + cached.Routes = append(cached.Routes, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) + } + productSpec = capabilitySpecWithRoutePresets(productSpec, enabledLogicalRouteSpecs(cached.Routes)) + defaults, defaultsErr := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec) + if defaultsErr != nil { + log.Printf("logical model omitted from route catalog id=%s: invalid defaults: %v", item.ID, defaultsErr) + continue + } + cached.ProductSpec = productSpec + cached.Defaults = defaults + snapshot.Models[item.ID] = cached + snapshot.Ordered = append(snapshot.Ordered, item.ID) + } + return snapshot, nil +} + +// ResolveLogicalModel 将创作意图解析为一次可执行的路由快照。 +// 解析同时约束能力合同、启用状态、价格档和渠道协议;调用方不得在解析完成后自行替换其中任一供应链字段, +// 否则会出现“目录显示可用、任务实际走另一条线路”的配置漂移。 +func (s *Service) ResolveLogicalModel(logicalModelID string, intent ModelRequestIntent) (*RoutedModel, error) { + snapshot, err := s.routeCatalogSnapshot() + if err != nil { + return nil, err + } + cached, ok := snapshot.Models[strings.TrimSpace(logicalModelID)] + if !ok { + return nil, BadAuthRequest("所选模型不可用") + } + intent.Options = mergeIntentDefaults(intent.Options, cached.Defaults) + if match := MatchCapability(cached.ProductSpec, intent); !match.Matched { + return nil, BadAuthRequest("所选模型不支持当前请求:" + strings.Join(match.Reasons, ";")) + } + eligible := s.eligibleLogicalRoutes(cached.Routes, intent, nil, cached.Model.PricePolicy == "channel") + if len(eligible) == 0 { + return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") + } + selected := weightedRoute(eligible) + var priceTier *model.ChannelModelPriceTier + if cached.Model.PricePolicy == "channel" { + priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) + if priceTier == nil { + return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") + } + } + return &RoutedModel{LogicalModel: cached.Model, Revision: cached.Revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: cached.Defaults}, nil +} + +func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent ModelRequestIntent, tried map[string]bool, requirePriceTier bool) []cachedLogicalRoute { + eligible := make([]cachedLogicalRoute, 0, len(routes)) + maxPriority := math.MinInt + for _, route := range routes { + if !route.Route.Enabled || route.Route.Weight <= 0 || tried[route.Route.ID] || s.logicalRouteBlocked(route) { + continue + } + if match := MatchCapability(route.CapabilitySpec, intent); !match.Matched { + continue + } + if requirePriceTier && channelModelPriceTierForIntent(route.ChannelModel, intent) == nil { + continue + } + if route.Route.Priority > maxPriority { + eligible = eligible[:0] + maxPriority = route.Route.Priority + } + if route.Route.Priority == maxPriority { + eligible = append(eligible, route) + } + } + return eligible +} + +func channelModelHasActivePriceTier(channelModel model.ChannelModel) bool { + for _, tier := range channelModel.PriceTiers { + if tier.Enabled && tier.PriceConfigured { + return true + } + } + return false +} + +// channelModelPriceTierForIntent 使用“精确规格优先、通配规格兜底”的规则。SKU 选择器与 +// 运行意图使用同一组规范键,因而图片质量/画幅、视频分辨率/时长和生成操作都能独立定价。 +func channelModelPriceTierForIntent(channelModel model.ChannelModel, intent ModelRequestIntent) *model.ChannelModelPriceTier { + selector := skuSelectorForIntent(intent) + bestScore := -1 + var best *model.ChannelModelPriceTier + for index := range channelModel.PriceTiers { + tier := &channelModel.PriceTiers[index] + if !tier.Enabled || !tier.PriceConfigured { + continue + } + matched, score := matchSKUSelector(skuSelectorForTier(*tier), selector) + if !matched { + continue + } + if score > bestScore { + best, bestScore = tier, score + } + } + return best +} + +func skuSelectorForIntent(intent ModelRequestIntent) map[string]string { + selector := map[string]string{} + if operation := strings.ToLower(strings.TrimSpace(intent.Operation)); operation != "" { + selector["operation"] = operation + } + switch normalizeCapability(intent.Capability) { + case "video": + // 价格档按实际参考素材归类。供应商执行仍可使用 reference_to_video、extend + // 等细分操作;计价时视频参考优先归为视频生视频,其余图片参考无论数量 + // 都归为图生视频。 + if intent.Inputs["video"] > 0 { + selector["operation"] = "video_to_video" + } else if intent.Inputs["image"] > 0 { + selector["operation"] = "image_to_video" + } + if count := intent.Inputs["image"]; count > 0 { + selector["imageCount"] = strconv.Itoa(count) + } + if value := normalizeChannelModelTierResolution(fmt.Sprint(intent.Options["vquality"])); value != "*" { + selector["vquality"] = value + } + if seconds, err := strconv.Atoi(strings.TrimSpace(fmt.Sprint(intent.Options["videoSeconds"]))); err == nil && seconds > 0 { + selector["videoSeconds"] = strconv.Itoa(seconds) + } + if audio := normalizedScalar(intent.Options["videoGenerateAudio"]); audio == "true" || audio == "false" { + selector["videoGenerateAudio"] = audio + } + case "image": + if intent.Inputs["image"] > 0 { + selector["operation"] = "image_to_image" + } else { + selector["operation"] = "text_to_image" + } + rawQuality, _ := intent.Options["quality"].(string) + rawSize, _ := intent.Options["size"].(string) + if quality := normalizeImagePriceQuality(rawQuality, rawSize); quality != "" { + selector["quality"] = quality + } + for _, key := range []string{"quality", "size"} { + if key == "quality" && selector["quality"] != "" { + continue + } + text, _ := intent.Options[key].(string) + if value := strings.ToLower(strings.TrimSpace(text)); value != "" && value != "auto" && value != "any" { + selector[key] = value + } + } + } + return selector +} + +func normalizeImagePriceQuality(rawQuality string, rawSize string) string { + quality := strings.ToLower(strings.TrimSpace(rawQuality)) + if quality != "" && quality != "auto" && quality != "any" { + return quality + } + parts := strings.Split(strings.ToLower(strings.TrimSpace(rawSize)), "x") + if len(parts) != 2 { + return "" + } + width, widthErr := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) + height, heightErr := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 || width > (1<<32)/height { + return "" + } + pixels := width * height + switch { + case pixels <= 2_000_000: + return "1k" + case pixels <= 4_300_000: + return "2k" + case pixels <= 8_294_400: + return "4k" + default: + return "" + } +} + +func skuSelectorForTier(tier model.ChannelModelPriceTier) map[string]string { + selector := model.DecodeSKUSelector(tier.SelectorJSON) + if len(selector) == 0 { + if resolution := normalizeChannelModelTierResolution(tier.Resolution); resolution != "*" { + selector["vquality"] = resolution + } + if tier.VideoSeconds > 0 { + selector["videoSeconds"] = strconv.Itoa(tier.VideoSeconds) + } + } + return selector +} + +func matchSKUSelector(tier map[string]string, requested map[string]string) (bool, int) { + score := 0 + for key, expected := range tier { + expected = strings.TrimSpace(expected) + if expected == "" || expected == "*" { + continue + } + if requested[key] != expected { + return false, 0 + } + score++ + } + return true, score +} + +func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool { + now := time.Now() + keys := []string{"channel:" + route.ChannelModel.ChannelID, "channel-model:" + route.ChannelModel.ID, "route:" + route.Route.ID} + // 这里会删除过期项,必须使用写锁;不要改成 RLock。 + s.routeHealthMu.Lock() + for _, key := range keys { + until, exists := s.routeHealthBlocked[key] + if !exists { + continue + } + if !until.After(now) { + delete(s.routeHealthBlocked, key) + continue + } + s.routeHealthMu.Unlock() + return true + } + s.routeHealthMu.Unlock() + + if s.coordinator == nil || !s.coordinator.HasRedis() { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + for _, key := range keys { + until, err := s.coordinator.RouteBlockedUntil(ctx, key) + if err != nil { + log.Printf("logical route distributed health check failed key=%s: %v", key, err) + return false + } + if until.After(now) { + return true + } + } + return false +} + +func mergeIntentDefaults(options map[string]any, defaults map[string]any) map[string]any { + result := make(map[string]any, len(defaults)+len(options)) + for key, value := range defaults { + result[key] = value + } + for key, value := range options { + result[key] = value + } + return result +} + +func weightedRoute(routes []cachedLogicalRoute) cachedLogicalRoute { + if len(routes) == 1 { + return routes[0] + } + var total int64 + for _, route := range routes { + total += int64(route.Route.Weight) + } + if total <= 0 { + return routes[0] + } + var raw [8]byte + if _, err := cryptorand.Read(raw[:]); err != nil { + return routes[0] + } + totalUnsigned := uint64(total) + // 丢弃不能整除 total 的低概率尾部,避免取模造成轻微权重偏差。 + threshold := -totalUnsigned % totalUnsigned + randomValue := binary.LittleEndian.Uint64(raw[:]) + for randomValue < threshold { + if _, err := cryptorand.Read(raw[:]); err != nil { + return routes[0] + } + randomValue = binary.LittleEndian.Uint64(raw[:]) + } + pick := int64(randomValue % totalUnsigned) + for _, route := range routes { + if pick < int64(route.Route.Weight) { + return route + } + pick -= int64(route.Route.Weight) + } + return routes[len(routes)-1] +} + +func (s *Service) sortedRouteDiagnostics(routes []cachedLogicalRoute, intent ModelRequestIntent) []RouteSimulationCandidate { + result := make([]RouteSimulationCandidate, 0, len(routes)) + poolPriority := math.MinInt + for _, route := range routes { + match := MatchCapability(route.CapabilitySpec, intent) + blocked := s.logicalRouteBlocked(route) + if route.Route.Enabled && route.Route.Weight > 0 && match.Matched && !blocked && route.Route.Priority > poolPriority { + poolPriority = route.Route.Priority + } + result = append(result, RouteSimulationCandidate{RouteID: route.Route.ID, ChannelModelID: route.ChannelModel.ID, ChannelModelKey: route.ChannelModel.ModelKey, ChannelModelName: route.ChannelModel.DisplayName, Priority: route.Route.Priority, Weight: route.Route.Weight, Enabled: route.Route.Enabled, Matched: match.Matched, Blocked: blocked, Reasons: match.Reasons}) + } + for index := range result { + result[index].InPool = result[index].Enabled && result[index].Weight > 0 && result[index].Matched && !result[index].Blocked && result[index].Priority == poolPriority + } + sort.SliceStable(result, func(i, j int) bool { return result[i].Priority > result[j].Priority }) + return result +} + +func (s *Service) createRouteAttempt(task *model.Task, routed *RoutedModel, attemptNumber int) (*model.RouteAttempt, error) { + id, err := s.repo.NextPrefixedID("ATTEMPT") + if err != nil { + return nil, err + } + channelModel, err := s.repo.ChannelModelByID(routed.ChannelModel.ChannelID, routed.ChannelModel.ID) + if err != nil { + return nil, err + } + attempt := &model.RouteAttempt{ID: id, TaskID: task.ID, RouteRun: task.RouteRun, AttemptNumber: attemptNumber, LogicalModelID: routed.LogicalModel.ID, LogicalModelRevisionID: routed.Revision.ID, RouteID: routed.Route.ID, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, Status: "selected", DispatchState: "not_sent", StartedAt: time.Now()} + if err := s.repo.CreateRouteAttempt(attempt); err != nil { + return nil, err + } + return attempt, nil +} + +func (s *Service) beginTaskRouteAttempt(task *model.Task) (*model.RouteAttempt, error) { + if task == nil { + return nil, nil + } + attempts, err := s.repo.RouteAttempts(task.ID, task.RouteRun) + if err != nil { + return nil, err + } + if len(attempts) > 0 { + existing := &attempts[len(attempts)-1] + switch existing.DispatchState { + case "not_sent": + return existing, nil + case "accepted": + if existing.ProviderRequestID != "" || task.ProviderRequestID != "" { + if task.ProviderRequestID == "" { + task.ProviderRequestID = existing.ProviderRequestID + if err := s.repo.UpdateTaskProviderState(task.ID, task.ProviderRequestID, task.PollStage, task.NextPollAt); err != nil { + return nil, err + } + } + return existing, nil + } + return nil, routeDispatchUncertainError{"上游已接受请求,但没有可恢复的任务 ID"} + case "submission_unknown": + if existing.ProviderRequestID != "" || task.ProviderRequestID != "" { + if task.ProviderRequestID == "" { + task.ProviderRequestID = existing.ProviderRequestID + if err := s.repo.UpdateTaskProviderState(task.ID, task.ProviderRequestID, task.PollStage, task.NextPollAt); err != nil { + return nil, err + } + } + existing.DispatchState = "accepted" + if err := s.repo.SaveRouteAttempt(existing); err != nil { + return nil, err + } + return existing, nil + } + return nil, routeDispatchUncertainError{"上一次提交结果不明确,为避免重复扣费已停止自动重发"} + case "rejected_no_job": + if task.LogicalModelID == "" { + return nil, errors.New("上游已拒绝本次请求,请检查渠道配置后再试") + } + return s.switchTaskToNextRoute(task, attempts) + } + } + if task.LogicalModelID == "" { + return s.createDirectTaskAttempt(task) + } + routed, routeErr := s.routedModelForTaskSelection(task) + if routeErr != nil { + return s.switchTaskToNextRoute(task, attempts) + } + return s.createRouteAttempt(task, routed, len(attempts)+1) +} + +func (s *Service) markRouteAttemptDispatching(attempt *model.RouteAttempt) error { + if attempt == nil || attempt.DispatchState != "not_sent" { + return nil + } + // A stale worker must not dispatch the same selected attempt a second time. + if err := s.repo.MarkRouteAttemptDispatching(attempt.ID); err != nil { + return routeDispatchUncertainError{"提交状态未能独占确认,为避免重复扣费已停止自动重发"} + } + attempt.Status, attempt.DispatchState = "dispatching", "submission_unknown" + return nil +} + +type routeDispatchUncertainError struct{ message string } + +func (e routeDispatchUncertainError) Error() string { return e.message } + +func isRouteDispatchUncertain(err error) bool { + var target routeDispatchUncertainError + return errors.As(err, &target) +} + +func (s *Service) routedModelForTaskSelection(task *model.Task) (*RoutedModel, error) { + route, err := s.repo.LogicalModelRoute(task.RouteID) + if err != nil { + return nil, err + } + if !route.Enabled || route.Weight <= 0 || route.LogicalModelRevisionID != task.LogicalModelRevisionID { + return nil, errors.New("任务使用的模型服务配置已失效") + } + if route.ChannelModelID != task.ChannelModelID { + return nil, errors.New("任务使用的模型服务配置已失效") + } + channelModel, err := s.repo.ChannelModel(task.ChannelModelID) + if err != nil { + return nil, err + } + if !channelModel.Enabled { + return nil, errors.New("任务使用的模型服务配置已失效") + } + if _, err := s.repo.SystemChannel(channelModel.ChannelID); err != nil { + return nil, err + } + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return nil, err + } + revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) + if err != nil { + return nil, err + } + if revision.LogicalModelID != logicalModel.ID { + return nil, errors.New("任务前台模型版本不一致") + } + productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) + if err != nil { + return nil, err + } + defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) + if err != nil { + return nil, err + } + capabilitySpec, err := channelModelCapabilitySpec(*channelModel) + if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { + return nil, errors.New("当前模型服务暂不可用") + } + if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { + return nil, errors.New("任务使用的模型服务价格配置已失效") + } + if logicalModel.PricePolicy == "unified" && logicalModel.BillingMode == "token" && !supportsTokenBilling(logicalModel.Capability, channelModel.Protocol) { + return nil, errors.New("任务使用的模型服务不再支持当前 Token 计费配置") + } + routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults} + return routed, nil +} + +func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.RouteAttempt) (*model.RouteAttempt, error) { + decrypted, err := s.decryptTaskInputJSON(task.InputJSON) + if err != nil { + return nil, err + } + var input map[string]any + if err := json.Unmarshal([]byte(decrypted), &input); err != nil { + return nil, BadAuthRequest("任务输入格式无效,无法恢复模型服务") + } + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return nil, err + } + revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) + if err != nil || revision.LogicalModelID != logicalModel.ID { + return nil, errors.New("任务前台模型版本不存在或归属不一致") + } + productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) + if err != nil { + return nil, err + } + defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) + if err != nil { + return nil, err + } + intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) + intent.Options = mergeIntentDefaults(intent.Options, defaults) + if match := MatchCapability(productSpec, intent); !match.Matched { + return nil, BadAuthRequest("任务参数不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) + } + routes, err := s.repo.LogicalModelRoutes(revision.ID, false) + if err != nil { + return nil, err + } + channelModelIDs := make([]string, 0, len(routes)) + for _, route := range routes { + channelModelIDs = append(channelModelIDs, route.ChannelModelID) + } + channelModels, err := s.repo.ChannelModelsByIDs(channelModelIDs) + if err != nil { + return nil, err + } + channelIDs := make([]string, 0, len(channelModels)) + for _, channelModel := range channelModels { + channelIDs = append(channelIDs, channelModel.ChannelID) + } + systemChannels, err := s.repo.SystemChannelsByIDs(channelIDs, false) + if err != nil { + return nil, err + } + enabledSystemChannels := make(map[string]bool, len(systemChannels)) + for _, channel := range systemChannels { + enabledSystemChannels[channel.ID] = true + } + channelModelByID := make(map[string]model.ChannelModel, len(channelModels)) + for _, channelModel := range channelModels { + if channelModel.Enabled && enabledSystemChannels[channelModel.ChannelID] && (logicalModel.PricePolicy != "channel" || channelModelHasActivePriceTier(channelModel)) { + channelModelByID[channelModel.ID] = channelModel + } + } + candidates := make([]cachedLogicalRoute, 0, len(routes)) + for _, route := range routes { + channelModel, channelOK := channelModelByID[route.ChannelModelID] + if !channelOK { + continue + } + capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) + if specErr != nil { + continue + } + candidates = append(candidates, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) + } + tried := make(map[string]bool, len(attempts)) + for _, attempt := range attempts { + tried[attempt.RouteID] = true + } + eligible := s.eligibleLogicalRoutes(candidates, intent, tried, logicalModel.PricePolicy == "channel") + if len(eligible) == 0 { + return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") + } + selected := weightedRoute(eligible) + var priceTier *model.ChannelModelPriceTier + if logicalModel.PricePolicy == "channel" { + priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) + if priceTier == nil { + return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") + } + } + routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: defaults} + nextInput := applyRoutedProviderSelection(input, routed) + if err := s.ValidateTaskCapability(nextInput); err != nil { + return nil, err + } + if err := s.protectTaskSecrets(nextInput); err != nil { + return nil, err + } + encoded, err := json.Marshal(nextInput) + if err != nil { + return nil, err + } + var replacement *model.BillingOrder + if logicalModel.PricePolicy == "channel" && task.BillingOrderID != "" { + config, _ := nextInput["config"].(map[string]any) + capability := normalizeCapability(fmt.Sprint(nextInput["mode"])) + if capability == "" { + capability = capabilityFromTaskType(task.Type) + } + priceTierID, _ := config["priceTierId"].(string) + replacement, err = s.newBillingOrderWithPriceTier(task.UserID, task.ID, "route-switch:"+task.ID+":"+selected.Route.ID, selected.ChannelModel.ChannelID, selected.ChannelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability), strings.TrimSpace(priceTierID), intent) + if err != nil { + return nil, err + } + replacement.Model = logicalModel.Code + } + previousRouteID := task.RouteID + var costOrder model.BillingOrder + if replacement != nil { + costOrder.BillingCostSnapshot = replacement.BillingCostSnapshot + } else if task.BillingOrderID != "" { + config, _ := nextInput["config"].(map[string]any) + capability := selected.ChannelModel.Capability + snapshotCreditCost(&costOrder, channelModelPriceTierForIntent(selected.ChannelModel, intent), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability)) + } + if err := s.repo.SwitchTaskLogicalRoute(task.ID, previousRouteID, selected.Route.ID, string(encoded), task.BillingOrderID, selected.ChannelModel.ChannelID, selected.ChannelModel.ID, replacement, costOrder.BillingCostSnapshot); err != nil { + if errors.Is(err, repository.ErrInsufficientCredits) { + return nil, BadAuthRequest("模型服务价格发生变化,当前积分余额不足") + } + return nil, err + } + task.RouteID = selected.Route.ID + task.ChannelModelID = selected.ChannelModel.ID + task.InputJSON = string(encoded) + task.ProviderRequestID = "" + return s.createRouteAttempt(task, routed, len(attempts)+1) +} + +func (s *Service) nextRouteAttemptAfterFailure(task *model.Task, attempt *model.RouteAttempt, taskErr error) (*model.RouteAttempt, error) { + if task == nil || task.LogicalModelID == "" || attempt == nil || attempt.DispatchState != "rejected_no_job" { + return nil, nil + } + if errors.Is(taskErr, context.Canceled) || errors.Is(taskErr, context.DeadlineExceeded) { + return nil, nil + } + s.blockLogicalRouteForFailure(attempt, taskErr) + attempts, err := s.repo.RouteAttempts(task.ID, task.RouteRun) + if err != nil { + return nil, err + } + return s.switchTaskToNextRoute(task, attempts) +} + +func (s *Service) blockLogicalRouteForFailure(attempt *model.RouteAttempt, taskErr error) { + if attempt == nil { + return + } + key := "" + duration := time.Duration(0) + if attempt.FailureCode == "upstream_401" || attempt.FailureCode == "upstream_403" { + key, duration = "channel:"+attempt.ChannelID, 10*time.Minute + } else if attempt.FailureCode == "upstream_404" { + key, duration = "channel-model:"+attempt.ChannelModelID, 10*time.Minute + } else if attempt.FailureCode == "upstream_429" { + key, duration = "channel:"+attempt.ChannelID, 30*time.Second + var upstream providerHTTPError + if errors.As(taskErr, &upstream) && upstream.RetryAfter > 0 { + duration = upstream.RetryAfter + } + } + if key == "" || duration <= 0 { + return + } + until := time.Now().Add(duration) + s.routeHealthMu.Lock() + // 本地状态是 Redis 不可用时的降级,也能覆盖 Redis 写入瞬间的网络抖动。 + s.routeHealthBlocked[key] = until + s.routeHealthMu.Unlock() + if s.coordinator != nil && s.coordinator.HasRedis() { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if err := s.coordinator.BlockRoute(ctx, key, until); err != nil { + log.Printf("logical route distributed health update failed key=%s: %v", key, err) + } + } +} + +func (s *Service) prepareLogicalTaskRetry(task *model.Task, input map[string]any) error { + if task == nil || task.LogicalModelID == "" { + return nil + } + intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return err + } + var routed *RoutedModel + if logicalModel.ArchivedAt != nil { + // 归档只隐藏新任务的公开目录;历史任务重试必须使用任务快照,不能重新从公开目录选路。 + routed, err = s.resolveArchivedTaskRoute(task, intent) + } else { + routed, err = s.ResolveLogicalModel(task.LogicalModelID, intent) + } + if err != nil { + return err + } + input = applyRoutedProviderSelection(input, routed) + if err := s.ValidateTaskCapability(input); err != nil { + return err + } + if err := s.protectTaskSecrets(input); err != nil { + return err + } + encoded, err := json.Marshal(input) + if err != nil { + return err + } + task.LogicalModelRevisionID = routed.Revision.ID + task.RouteID = routed.Route.ID + task.ChannelModelID = routed.ChannelModel.ID + task.Model = routed.LogicalModel.Code + task.Provider = "managed" + task.InputJSON = string(encoded) + return nil +} + +// resolveArchivedTaskRoute 恢复归档模型任务保存的 revision、route 和 channel model 快照。 +// 归档模型不得重新进入公开路由目录;原供应线路失效时应明确拒绝重试,避免静默切换到未知配置。 +func (s *Service) resolveArchivedTaskRoute(task *model.Task, intent ModelRequestIntent) (*RoutedModel, error) { + if task == nil || task.LogicalModelID == "" || task.LogicalModelRevisionID == "" || task.RouteID == "" || task.ChannelModelID == "" { + return nil, BadAuthRequest("历史任务缺少完整的模型服务快照,无法重试") + } + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return nil, err + } + if logicalModel.ArchivedAt == nil { + return nil, BadAuthRequest("任务模型已恢复为可用模型,请重新选择后重试") + } + revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) + if err != nil || revision.LogicalModelID != logicalModel.ID { + return nil, BadAuthRequest("历史任务前台模型版本不存在或归属不一致") + } + route, err := s.repo.LogicalModelRoute(task.RouteID) + if err != nil || route.LogicalModelRevisionID != revision.ID || route.ChannelModelID != task.ChannelModelID || !route.Enabled || route.Weight <= 0 { + return nil, BadAuthRequest("历史任务原模型供应线路已失效,无法重试") + } + channelModel, err := s.repo.ChannelModel(task.ChannelModelID) + if err != nil || !channelModel.Enabled { + return nil, BadAuthRequest("历史任务原模型服务已失效,无法重试") + } + if _, err := s.repo.SystemChannel(channelModel.ChannelID); err != nil { + return nil, BadAuthRequest("历史任务原模型渠道已失效,无法重试") + } + productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) + if err != nil { + return nil, err + } + defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) + if err != nil { + return nil, err + } + intent.Options = mergeIntentDefaults(intent.Options, defaults) + if match := MatchCapability(productSpec, intent); !match.Matched { + return nil, BadAuthRequest("历史任务不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) + } + capabilitySpec, err := channelModelCapabilitySpec(*channelModel) + if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { + return nil, BadAuthRequest("历史任务原模型供应线路暂不可用,无法重试") + } + if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { + return nil, BadAuthRequest("历史任务原模型价格配置已失效,无法重试") + } + return &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults}, nil +} + +func (s *Service) finishTaskRouteAttempt(attempt *model.RouteAttempt, task *model.Task, taskErr error) { + if attempt == nil { + return + } + now := time.Now() + attempt.CompletedAt = &now + if task != nil { + attempt.ProviderRequestID = task.ProviderRequestID + } + if taskErr == nil { + attempt.Status = "succeeded" + attempt.DispatchState = "accepted" + } else { + attempt.Status = "failed" + attempt.FailureMessage = truncateRunes(taskFailureMessage(taskErr), 1000) + attempt.FailureCode = routeFailureCode(taskErr) + if attempt.ProviderRequestID != "" { + attempt.DispatchState = "accepted" + } else if safeRouteRejection(taskErr) { + attempt.DispatchState = "rejected_no_job" + } else { + attempt.DispatchState = "submission_unknown" + } + } + if err := s.repo.SaveRouteAttempt(attempt); err != nil { + log.Printf("route attempt terminal state save failed attempt_id=%s task_id=%s: %v", attempt.ID, attempt.TaskID, err) + } +} + +func routeFailureCode(err error) string { + if code, _ := ChannelSlotFailureDetails(err); code != "" { + return code + } + var upstream providerHTTPError + if errors.As(err, &upstream) { + return fmt.Sprintf("upstream_%d", upstream.StatusCode) + } + return "submission_unknown" +} + +func safeRouteRejection(err error) bool { + if err == nil { + return false + } + if code, _ := ChannelSlotFailureDetails(err); code != "" { + return true + } + var upstream providerHTTPError + if errors.As(err, &upstream) { + switch upstream.StatusCode { + case 401, 403, 404, 429: + return true + } + } + return false +} diff --git a/backend/internal/app/payment_plugins.go b/backend/internal/app/payment_plugins.go index 1331dcd42..8c3c50b7d 100644 --- a/backend/internal/app/payment_plugins.go +++ b/backend/internal/app/payment_plugins.go @@ -7,8 +7,12 @@ import ( const ( PaymentPluginWeChatNative = "official-payment-wechat-native" PaymentPluginAlipayPage = "official-payment-alipay-page" + PaymentPluginXunHuPay = "official-payment-xunhupay" + PaymentPluginZhiFuFM = "official-payment-zhifufm" PaymentProviderWeChat = "wechat-native" PaymentProviderAlipay = "alipay-page-pay" + PaymentProviderXunHuPay = "xunhupay-aggregate" + PaymentProviderZhiFuFM = "zhifufm-pay" ) func bundledPaymentPluginManifests() []protocol.Manifest { diff --git a/backend/internal/app/payment_plugins_test.go b/backend/internal/app/payment_plugins_test.go index e28ab2d90..883fa5c1a 100644 --- a/backend/internal/app/payment_plugins_test.go +++ b/backend/internal/app/payment_plugins_test.go @@ -151,3 +151,38 @@ func TestXunHuPayOfficialPackageIsPaymentPlugin(t *testing.T) { t.Fatalf("xunhupay provider type = %T", provider) } } + +func TestZhiFuFMOfficialPackageIsPaymentPlugin(t *testing.T) { + center, err := newPluginRuntime(t.TempDir()) + if err != nil { + t.Fatal(err) + } + var plugin PluginView + for _, item := range center.list() { + if item.Manifest.ID == "official-payment-zhifufm" { + plugin = item + plugin.Management = pluginManagementFromView(item) + break + } + } + if plugin.Manifest.ID == "" { + t.Fatal("official-payment-zhifufm is missing") + } + if plugin.Management.Kind != PluginKindPayment || plugin.Source != PluginOriginOfficial { + t.Fatalf("zhifufm plugin = %#v", plugin) + } + if len(plugin.Manifest.Contributes.PaymentProviders) != 1 || plugin.Manifest.Contributes.PaymentProviders[0].ID != "zhifufm-pay" { + t.Fatalf("zhifufm contributions = %#v", plugin.Manifest.Contributes.PaymentProviders) + } + registry := center.paymentRegistrySnapshot() + if registry == nil { + t.Fatal("payment registry is nil") + } + provider, ok := registry.Get("zhifufm-pay") + if !ok { + t.Fatal("zhifufm-pay provider is missing") + } + if _, ok := provider.(*payment.RPCProvider); !ok { + t.Fatalf("zhifufm provider type = %T", provider) + } +} diff --git a/backend/internal/app/price_validation.go b/backend/internal/app/price_validation.go index 060311779..59d6deb74 100644 --- a/backend/internal/app/price_validation.go +++ b/backend/internal/app/price_validation.go @@ -2,6 +2,21 @@ package app import "infinite-canvas/backend/internal/model" +const maxChannelModelTokenPriceMicrocredits = int64(1_000_000) * CreditScale + +func validateTokenPrices(capability string, inputPrice, outputPrice, cachedPrice int64) error { + if inputPrice < 0 || outputPrice < 0 || cachedPrice < 0 { + return BadAuthRequest("模型积分价格不能小于 0") + } + if capability == "video" && (inputPrice != 0 || cachedPrice != 0) { + return BadAuthRequest("视频 Token 仅按视频用量计费,输入和缓存 Token 价格必须为 0") + } + if inputPrice > maxChannelModelTokenPriceMicrocredits || outputPrice > maxChannelModelTokenPriceMicrocredits || cachedPrice > maxChannelModelTokenPriceMicrocredits { + return BadAuthRequest("Token 每百万用量价格不能超过 1,000,000 积分") + } + return nil +} + // ValidateChannelModelPrice 校验渠道模型价格配置的有效性 // 根据 billingMode 检查必要的价格字段是否已配置 func ValidateChannelModelPrice(billingMode string, capability string, protocol model.ChannelInterfaceType, unitPrice, inputPrice, outputPrice, cachedPrice int64) bool { @@ -13,14 +28,17 @@ func ValidateChannelModelPrice(billingMode string, capability string, protocol m // 按秒计费:0 表示免费,负数无效。 return unitPrice >= 0 case "token": + if validateTokenPrices(capability, inputPrice, outputPrice, cachedPrice) != nil { + return false + } if capability == "video" { - return model.IsVolcengineArkVideoProtocol(protocol) && inputPrice >= 0 && outputPrice >= 0 && cachedPrice >= 0 + return true } if capability != "" && capability != "text" { return false } // 文本模型:所有 Token 价格为 0 时表示免费。 - return inputPrice >= 0 && outputPrice >= 0 && cachedPrice >= 0 + return true default: return false } diff --git a/backend/internal/app/price_validation_test.go b/backend/internal/app/price_validation_test.go index d1c09405d..97a6627ba 100644 --- a/backend/internal/app/price_validation_test.go +++ b/backend/internal/app/price_validation_test.go @@ -6,14 +6,14 @@ import ( "infinite-canvas/backend/internal/model" ) -func TestValidateChannelModelPriceSupportsArkVideoTokensOnly(t *testing.T) { +func TestValidateChannelModelPriceSupportsAllVideoTokens(t *testing.T) { const outputPrice = int64(16_000_000) if !ValidateChannelModelPrice("token", "video", model.ChannelInterfaceVolcengineArkVideo, 0, 0, outputPrice, 0) { t.Fatal("Volcengine Ark video Token price should be valid") } for _, protocol := range []model.ChannelInterfaceType{model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceNewAPIVideo} { - if ValidateChannelModelPrice("token", "video", protocol, 0, 0, outputPrice, 0) { - t.Fatalf("video protocol %q should not support Token pricing", protocol) + if !ValidateChannelModelPrice("token", "video", protocol, 0, 0, outputPrice, 0) { + t.Fatalf("video protocol %q should support Token pricing", protocol) } } } @@ -26,7 +26,7 @@ func TestHasValidPriceUsesChannelProtocolForTokenTiers(t *testing.T) { } jimeng := &model.ChannelModel{Capability: "video", Protocol: model.ChannelInterfaceVolcengineJiMengVideo, PriceTiers: []model.ChannelModelPriceTier{tier}} - if HasValidPrice(jimeng) { - t.Fatal("JiMeng video Token tier should be invalid") + if !HasValidPrice(jimeng) { + t.Fatal("JiMeng video Token tier should be valid") } } diff --git a/backend/internal/app/task_creation.go b/backend/internal/app/task_creation.go index 87a59f198..93dfb583b 100644 --- a/backend/internal/app/task_creation.go +++ b/backend/internal/app/task_creation.go @@ -230,10 +230,12 @@ func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map nextConfig["channelId"] = routed.ChannelModel.ChannelID nextConfig["model"] = routed.ChannelModel.ModelKey nextConfig["channelModelKey"] = routed.ChannelModel.ModelKey + providerModelKey := routed.ChannelModel.ProviderModelKey if routed.PriceTier != nil { nextConfig["priceTierId"] = routed.PriceTier.ID - nextConfig["providerModelKey"] = routed.PriceTier.ProviderModelKey + providerModelKey = firstNonEmpty(routed.PriceTier.ProviderModelKey, providerModelKey) } + nextConfig["providerModelKey"] = firstNonEmpty(providerModelKey, routed.ChannelModel.ModelKey) input["config"] = nextConfig return input } diff --git a/backend/internal/app/video_logical_billing_test.go b/backend/internal/app/video_logical_billing_test.go new file mode 100644 index 000000000..779741844 --- /dev/null +++ b/backend/internal/app/video_logical_billing_test.go @@ -0,0 +1,129 @@ +package app + +import ( + "encoding/json" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestUnifiedVideoQuoteAndTaskUseResolvedProviderGeneration(t *testing.T) { + svc, db, _, channelModel, profile := newVideoTokenQuoteFixture(t) + const upstream = "doubao-seedance-2-5" + channelModel.ProviderModelKey = upstream + if err := db.Model(&model.ChannelModel{}).Where("id = ?", channelModel.ID).Update("provider_model_key", upstream).Error; err != nil { + t.Fatal(err) + } + spec, err := CapabilitySpecFromModelCapabilityConfig(profile, "video") + if err != nil { + t.Fatal(err) + } + specJSON, err := json.Marshal(spec) + if err != nil { + t.Fatal(err) + } + logical := model.LogicalModel{ + ID: "logical-aliased-video", Code: "video-alias", Capability: "video", Enabled: true, + ActiveRevisionID: "aliased-video-revision", PricePolicy: "unified", BillingMode: "token", OutputPriceMicrocredits: 16_000_000, + } + revision := model.LogicalModelRevision{ + ID: logical.ActiveRevisionID, LogicalModelID: logical.ID, Version: 1, CapabilitySpecJSON: string(specJSON), + DefaultOptionsJSON: `{"vquality":"480p","size":"16:9","videoSeconds":5,"videoGenerateAudio":true}`, + } + route := model.LogicalModelRoute{ID: "aliased-video-route", LogicalModelRevisionID: revision.ID, ChannelModelID: channelModel.ID, Enabled: true, Weight: 1} + for _, row := range []any{&logical, &revision, &route} { + if err := db.Create(row).Error; err != nil { + t.Fatal(err) + } + } + quote, err := svc.QuoteLogicalModel(logical.ID, ModelRequestIntent{Capability: "video"}) + if err != nil { + t.Fatal(err) + } + routed, input, err := svc.resolveTaskModelSelection(map[string]any{"mode": "video", "config": map[string]any{}}, logical.ID, "canvas_video", "", true) + if err != nil { + t.Fatal(err) + } + if routed.PriceTier != nil || input["config"].(map[string]any)["providerModelKey"] != upstream { + t.Fatalf("unified route did not preserve its provider model: %#v", input) + } + order, err := svc.taskBillingOrder("quote-user", &model.Task{ + ID: "aliased-video-task", Type: "canvas_video", LogicalModelID: logical.ID, + LogicalModelRevisionID: revision.ID, RouteID: route.ID, ChannelModelID: channelModel.ID, + }, input) + if err != nil { + t.Fatal(err) + } + if quote.VideoTokenEstimate == nil || quote.VideoTokenEstimate.OutputWidth != 854 || quote.VideoTokenEstimate.OutputHeight != 480 || quote.VideoTokenEstimate.DimensionsEstimated { + t.Fatalf("wrong generation-specific quote: %#v", quote.VideoTokenEstimate) + } + if order == nil || order.VideoFormulaTokens != 48038 || order.Quantity != 52842 || order.AmountMicrocredits != 845472 || quote.Quantity != order.Quantity || quote.AmountMicrocredits != order.AmountMicrocredits { + t.Fatalf("quote/task mismatch: quote=%#v order=%#v", quote, order) + } + assertVideoQuoteHasNoFinancialWrites(t, db) +} + +func TestRoutedProviderSelectionFallsBackWithoutTierOverride(t *testing.T) { + for _, test := range []struct { + name string + provider string + tier *model.ChannelModelPriceTier + want string + }{ + {name: "unified without tier", provider: "provider-default", want: "provider-default"}, + {name: "empty tier override", provider: "provider-default", tier: &model.ChannelModelPriceTier{ID: "tier"}, want: "provider-default"}, + {name: "tier override", provider: "provider-default", tier: &model.ChannelModelPriceTier{ID: "tier", ProviderModelKey: "provider-tier"}, want: "provider-tier"}, + {name: "model key fallback", tier: &model.ChannelModelPriceTier{ID: "tier"}, want: "model-alias"}, + } { + t.Run(test.name, func(t *testing.T) { + input := applyRoutedProviderSelection(map[string]any{"config": map[string]any{"providerModelKey": "untrusted-client-model"}}, &RoutedModel{ + ChannelModel: model.ChannelModel{ChannelID: "channel", ModelKey: "model-alias", ProviderModelKey: test.provider}, PriceTier: test.tier, + }) + if got := input["config"].(map[string]any)["providerModelKey"]; got != test.want { + t.Fatalf("providerModelKey = %v, want %s", got, test.want) + } + }) + } +} + +func TestUnifiedVideoModelSaveRejectsInvalidTokenPricesBeforePersistence(t *testing.T) { + for _, test := range []struct { + name string + input, output int64 + cached int64 + }{ + {name: "hidden input", input: 1, output: 16_000_000}, + {name: "hidden cache", output: 16_000_000, cached: 1}, + {name: "too expensive", output: maxChannelModelTokenPriceMicrocredits + 1}, + } { + t.Run(test.name, func(t *testing.T) { + // A nil repository proves invalid price fields fail before IDs or revisions are written. + _, _, _, _, err := (&Service{}).logicalModelBundle(&model.User{ID: "admin"}, "", LogicalModelRequest{ + Code: "video-test", Name: "Video", Capability: "video", PricePolicy: "unified", BillingMode: "token", + CapabilitySpec: CapabilitySpec{Version: 1, Capability: "video"}, + InputPriceMicrocredits: test.input, OutputPriceMicrocredits: test.output, CachedPriceMicrocredits: test.cached, + }) + if err == nil { + t.Fatal("invalid unified video pricing accepted") + } + }) + } +} + +func TestUnifiedVideoModelSaveAllowsFreeAndMaximumTokenPrices(t *testing.T) { + for _, outputPrice := range []int64{0, 16_000_000, maxChannelModelTokenPriceMicrocredits} { + svc, _, _, channelModel, profile := newVideoTokenQuoteFixture(t) + spec, err := CapabilitySpecFromModelCapabilityConfig(profile, "video") + if err != nil { + t.Fatal(err) + } + item, _, _, _, err := svc.logicalModelBundle(&model.User{ID: "admin"}, "", LogicalModelRequest{ + Code: "video-test", Name: "Video", Capability: "video", PricePolicy: "unified", BillingMode: "token", Enabled: true, + CapabilitySpec: spec, OutputPriceMicrocredits: outputPrice, + Routes: []LogicalRouteRequest{{ChannelModelID: channelModel.ID, Enabled: true, Weight: 1}}, + }) + if err != nil || item == nil || item.OutputPriceMicrocredits != outputPrice { + t.Fatalf("output price %d: model=%#v error=%v", outputPrice, item, err) + } + } +} diff --git a/backend/internal/app/video_token_billing.go b/backend/internal/app/video_token_billing.go new file mode 100644 index 000000000..66d5f7631 --- /dev/null +++ b/backend/internal/app/video_token_billing.go @@ -0,0 +1,241 @@ +package app + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +// VideoTokenEstimate separates the provider's pixel-frame estimate from our +// reservation margin. The formula snapshot can settle videos without provider usage. +type VideoTokenEstimate struct { + FormulaTokens int64 `json:"formulaTokens"` + ReservedTokens int64 `json:"reservedTokens"` + OutputWidth int64 `json:"outputWidth"` + OutputHeight int64 `json:"outputHeight"` + FramesPerSecond int64 `json:"framesPerSecond"` + OutputSeconds int64 `json:"outputSeconds"` + ReferenceSeconds float64 `json:"referenceSeconds"` + ReferenceDurationEstimated bool `json:"referenceDurationEstimated"` + DimensionsEstimated bool `json:"dimensionsEstimated"` + ReservationMarginPercent int64 `json:"reservationMarginPercent"` +} + +type videoDimensions struct{ width, height int64 } + +// Official Seedance dimensions, not resolution-label multiplication. 4K's +// 4:3 and 21:9 dimensions in particular differ from twice the 1080p dimensions. +var arkVideoDimensions = map[string][]videoDimensions{ + "480p": {{864, 496}, {752, 560}, {640, 640}, {560, 752}, {496, 864}, {992, 432}}, + "720p": {{1280, 720}, {1112, 834}, {960, 960}, {834, 1112}, {720, 1280}, {1470, 630}}, + "1080p": {{1920, 1080}, {1664, 1248}, {1440, 1440}, {1248, 1664}, {1080, 1920}, {2206, 946}}, + "2160p": {{3840, 2160}, {3326, 2494}, {2880, 2880}, {2494, 3326}, {2160, 3840}, {4398, 1886}}, +} + +var arkVideo10Dimensions = map[string][]videoDimensions{ + "480p": {{864, 480}, {736, 544}, {640, 640}, {544, 736}, {480, 864}, {960, 416}}, + "720p": {{1248, 704}, {1120, 832}, {960, 960}, {832, 1120}, {704, 1248}, {1504, 640}}, + "1080p": {{1920, 1088}, {1664, 1248}, {1440, 1440}, {1248, 1664}, {1088, 1920}, {2176, 928}}, +} + +func estimateArkVideoTokens(input map[string]any) tokenBillingEstimate { + detail, err := estimateArkVideoTokenUsage(input) + if err != nil { + return tokenBillingEstimate{Err: err} + } + return tokenBillingEstimate{OutputTokens: detail.ReservedTokens, Video: detail} +} + +func estimateArkVideoTokenUsage(input map[string]any) (*VideoTokenEstimate, error) { + config, _ := input["config"].(map[string]any) + seconds, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(config["videoSeconds"])), 10, 64) + if err != nil || seconds <= 0 || seconds > (math.MaxInt64-15_000)/1000 { + return nil, BadAuthRequest("视频 Token 预估需要有效的正整数生成时长") + } + modelName := firstNonEmpty(stringValue(config["providerModelKey"]), stringValue(config["model"])) + dimensions, dimensionsEstimated, err := arkVideoBillingDimensions(stringValue(config["vquality"]), stringValue(config["size"]), modelName) + if err != nil { + return nil, err + } + referenceMillis, durationEstimated, err := videoReferenceMillis(input["referenceVideos"]) + if err != nil { + return nil, err + } + const fps, margin = int64(24), int64(10) + if dimensions.width > math.MaxInt64/dimensions.height/fps { + return nil, BadAuthRequest("视频尺寸超出 Token 计算范围") + } + pixels := dimensions.width * dimensions.height + if seconds > (math.MaxInt64-referenceMillis)/1000 { + return nil, BadAuthRequest("视频时长超出 Token 计算范围") + } + millis := seconds*1000 + referenceMillis + if millis > (math.MaxInt64-1_023_999)/(pixels*fps) { + return nil, BadAuthRequest("视频 Token 预估用量超出支持范围") + } + // Official formula: (input seconds + output seconds) * W * H * FPS / 1024. + // Provider usage takes precedence; otherwise the formula snapshot is billable. + formulaTokens := (millis*pixels*fps + 1_023_999) / 1_024_000 + if formulaTokens > (math.MaxInt64-99)/(100+margin) { + return nil, BadAuthRequest("视频 Token 预授权用量超出支持范围") + } + return &VideoTokenEstimate{ + FormulaTokens: formulaTokens, ReservedTokens: (formulaTokens*(100+margin) + 99) / 100, + OutputWidth: dimensions.width, OutputHeight: dimensions.height, FramesPerSecond: fps, + OutputSeconds: seconds, ReferenceSeconds: float64(referenceMillis) / 1000, + ReferenceDurationEstimated: durationEstimated, DimensionsEstimated: dimensionsEstimated, + ReservationMarginPercent: margin, + }, nil +} + +func videoReferenceMillis(raw any) (int64, bool, error) { + if raw == nil { + return 0, false, nil + } + references, ok := raw.([]any) + if !ok || len(references) > 128 { + return 0, false, BadAuthRequest("视频 Token 预估的参考视频参数无效") + } + var total int64 + unknown := false + for _, rawReference := range references { + media, ok := rawReference.(map[string]any) + if rawReference != nil && !ok { + return 0, false, BadAuthRequest("视频 Token 预估的参考视频参数无效") + } + value := media["durationMs"] + if value == nil { + unknown = true + continue + } + duration, err := strconv.ParseInt(fmt.Sprint(value), 10, 64) + if err != nil || duration < 0 || duration > math.MaxInt64-total { + return 0, false, BadAuthRequest("参考视频时长参数无效,durationMs 必须为非负整数且不能溢出") + } + if duration == 0 { + unknown = true + } + total += duration + } + if unknown { + // The catalog quote has only reference counts; reserve the documented + // maximum and disclose it instead of pretending the references cost zero. + return max(total, 15_000), true, nil + } + return total, false, nil +} + +func arkVideoBillingDimensions(resolution, ratio, modelName string) (videoDimensions, bool, error) { + resolution = strings.ToLower(strings.TrimSpace(resolution)) + ratio = strings.ToLower(strings.TrimSpace(ratio)) + // Some video protocols expose exact pixels in size instead of a resolution tier. + if dimensions, ok := videoPixelDimensions(ratio); ok { + return dimensions, false, nil + } + defaulted := resolution == "" || resolution == "auto" || resolution == "high" || resolution == "medium" + if defaulted { + resolution = "720p" + } + if resolution == "low" { + resolution = "480p" + } + if ratio == "" { + ratio = "16:9" + defaulted = true + } + switch resolution { + case "480", "720", "1080", "2160": + resolution += "p" + case "4k": + resolution = "2160p" + case "2k": + resolution = "1440p" + } + base, exists := arkVideoDimensions[resolution] + if !exists { + return videoScaledDimensions(resolution, ratio) + } + ratios := []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9"} + ratio = strings.ToLower(strings.TrimSpace(ratio)) + adaptive := ratio == "adaptive" || ratio == "auto" + ratioIndex := -1 + for index, value := range ratios { + if value == ratio { + ratioIndex = index + } + } + if ratioIndex < 0 && !adaptive { + return videoScaledDimensions(resolution, ratio) + } + name := strings.ReplaceAll(strings.ToLower(modelName), ".", "-") + is10 := strings.Contains(name, "seedance-1-0") + is25 := strings.Contains(name, "seedance-2-5") + known := is10 || is25 || strings.Contains(name, "seedance-1-5") || strings.Contains(name, "seedance-2-0") + values := append([]videoDimensions(nil), base...) + if is10 { + if resolution == "2160p" { + defaulted = true + } else { + values = arkVideo10Dimensions[resolution] + } + } else if is25 && resolution == "480p" { + values[0], values[4] = videoDimensions{854, 480}, videoDimensions{480, 854} + } else if !known { + // Endpoint IDs don't identify a model generation. Use the largest + // documented dimensions per ratio, with an explicit estimate flag. + for index, value := range arkVideo10Dimensions[resolution] { + if value.width*value.height > values[index].width*values[index].height { + values[index] = value + } + } + } + if !adaptive { + return values[ratioIndex], !known || defaulted, nil + } + largest := values[0] + for _, value := range values[1:] { + if value.width*value.height > largest.width*largest.height { + largest = value + } + } + return largest, true, nil +} + +func videoPixelDimensions(size string) (videoDimensions, bool) { + parts := strings.Split(strings.ReplaceAll(size, "×", "x"), "x") + if len(parts) != 2 { + return videoDimensions{}, false + } + w, ew := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) + h, eh := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) + return videoDimensions{w, h}, ew == nil && eh == nil && w > 0 && h > 0 +} + +// Generic video tiers use the short edge and requested aspect ratio. This is a +// platform pricing convention for non-Seedance sizes, not a provider pixel claim. +func videoScaledDimensions(resolution, ratio string) (videoDimensions, bool, error) { + raw := strings.TrimSuffix(strings.TrimSuffix(resolution, "横"), "竖") + edge, err := strconv.ParseInt(strings.TrimSuffix(raw, "p"), 10, 64) + if err != nil || edge <= 0 { + return videoDimensions{}, false, BadAuthRequest("视频 Token 计费需要有效分辨率或明确的宽×高尺寸") + } + if ratio == "auto" || ratio == "adaptive" { + ratio = "16:9" + } + parts := strings.Split(ratio, ":") + if len(parts) != 2 { + return videoDimensions{}, false, BadAuthRequest("视频 Token 计费需要有效画幅比例") + } + w, ew := strconv.ParseInt(parts[0], 10, 64) + h, eh := strconv.ParseInt(parts[1], 10, 64) + if ew != nil || eh != nil || w <= 0 || h <= 0 || edge > (math.MaxInt64-min(w, h))/max(w, h) { + return videoDimensions{}, false, BadAuthRequest("视频画幅超出 Token 计算范围") + } + longEdge := (edge*max(w, h) + min(w, h) - 1) / min(w, h) + longEdge = (longEdge + 1) / 2 * 2 + if w >= h { + return videoDimensions{longEdge, edge}, true, nil + } + return videoDimensions{edge, longEdge}, true, nil +} diff --git a/backend/internal/app/video_token_billing_test.go b/backend/internal/app/video_token_billing_test.go new file mode 100644 index 000000000..c52e8670c --- /dev/null +++ b/backend/internal/app/video_token_billing_test.go @@ -0,0 +1,67 @@ +package app + +import ( + "math" + "testing" +) + +func TestArkVideoEstimateUsesOfficialDimensionsAndNoExtraFrames(t *testing.T) { + for _, tt := range []struct { + name, model, resolution, ratio string + width, height int64 + estimated bool + }{ + {"1.0 fast retains 1080", "doubao-seedance-1-0-pro-fast", "1080P", "16:9", 1920, 1088, false}, + {"1.5", "doubao-seedance-1-5-pro", "720", "16:9", 1280, 720, false}, + {"2.5", "doubao-seedance-2-5", "480p", "9:16", 480, 854, false}, + {"4K widescreen", "doubao-seedance-2-0", "4K", "21:9", 4398, 1886, false}, + {"4K classic", "doubao-seedance-2-0", "2160p", "4:3", 3326, 2494, false}, + {"endpoint conservative size", "ep-custom", "720p", "21:9", 1504, 640, true}, + {"adaptive", "doubao-seedance-2-0", "720p", "adaptive", 1112, 834, true}, + } { + t.Run(tt.name, func(t *testing.T) { + estimate := estimateArkVideoTokens(map[string]any{"config": map[string]any{ + "model": "display-name-fast", "providerModelKey": tt.model, "vquality": tt.resolution, "size": tt.ratio, "videoSeconds": "5", + }}) + if estimate.Err != nil { + t.Fatal(estimate.Err) + } + got := estimate.Video + want := (tt.width*tt.height*24*5 + 1023) / 1024 + if got.OutputWidth != tt.width || got.OutputHeight != tt.height || got.FormulaTokens != want || got.DimensionsEstimated != tt.estimated || got.ReservedTokens != (want*110+99)/100 { + t.Fatalf("estimate=%#v, want %dx%d, tokens=%d", got, tt.width, tt.height, want) + } + }) + } +} + +func TestArkVideoEstimateRejectsInvalidParameters(t *testing.T) { + for _, patch := range []map[string]any{ + {"vquality": "garbage"}, {"size": "invalid"}, {"size": "0:7"}, + {"videoSeconds": "-1"}, {"videoSeconds": "1.5"}, {"videoSeconds": math.MaxInt64}, + } { + config := map[string]any{"model": "doubao-seedance-2-0", "vquality": "720p", "size": "16:9", "videoSeconds": "5"} + for k, v := range patch { + config[k] = v + } + if got := estimateArkVideoTokens(map[string]any{"config": config}); got.Err == nil || got.OutputTokens != 0 { + t.Fatalf("invalid config %#v yielded %#v", patch, got) + } + } +} + +func TestArkVideoReferenceUsesOutputPixelsAndFractionalSeconds(t *testing.T) { + input := map[string]any{"config": map[string]any{"model": "doubao-seedance-2-0", "videoSeconds": "5", "vquality": "720p", "size": "16:9"}, + "referenceVideos": []any{map[string]any{"durationMs": 1250, "width": 3840, "height": 2160}, map[string]any{"durationMs": 250}}, + } + got := estimateArkVideoTokens(input) + if got.Err != nil || got.Video.FormulaTokens != 140400 || got.Video.ReferenceSeconds != 1.5 || got.Video.ReferenceDurationEstimated { + t.Fatalf("estimate=%#v error=%v", got.Video, got.Err) + } + for _, references := range []any{[]any{map[string]any{"durationMs": -1}}, []any{map[string]any{"durationMs": 1.5}}, []any{map[string]any{"durationMs": int64(math.MaxInt64)}, map[string]any{"durationMs": 6000}}, "invalid", make([]any, 129)} { + input["referenceVideos"] = references + if got := estimateArkVideoTokens(input); got.Err == nil { + t.Fatalf("accepted invalid references: %#v", references) + } + } +} diff --git a/backend/internal/app/video_token_quote_test.go b/backend/internal/app/video_token_quote_test.go new file mode 100644 index 000000000..9e9f6aa5c --- /dev/null +++ b/backend/internal/app/video_token_quote_test.go @@ -0,0 +1,221 @@ +package app + +import ( + "encoding/json" + "fmt" + "reflect" + "testing" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +func TestVideoTokenChannelQuoteAgreesWithTaskBilling(t *testing.T) { + for _, test := range []struct { + name string + audio any + free bool + wantTier string + wantPrice int64 + wantCost int64 + }{ + {name: "audio default", wantTier: "video-audio", wantPrice: 16_000_000, wantCost: 1_900_800}, + {name: "explicit audio", audio: true, wantTier: "video-audio", wantPrice: 16_000_000, wantCost: 1_900_800}, + {name: "silent", audio: false, wantTier: "video-silent", wantPrice: 8_000_000, wantCost: 950_400}, + {name: "configured free", audio: true, free: true, wantTier: "video-audio"}, + } { + t.Run(test.name, func(t *testing.T) { + svc, db, channel, channelModel, _ := newVideoTokenQuoteFixture(t) + if test.free { + if err := db.Model(&model.ChannelModelPriceTier{}).Where("id = ?", test.wantTier).Update("output_token_price_microcredits", 0).Error; err != nil { + t.Fatal(err) + } + } + intent := ModelRequestIntent{Capability: "video", Options: map[string]any{ + "vquality": "720p", "size": "16:9", "videoSeconds": 5, + }} + if test.audio != nil { + intent.Options["videoGenerateAudio"] = test.audio + } + quote, err := svc.QuoteChannelModel(ChannelModelQuoteRequest{ChannelID: channel.ID, ModelKey: channelModel.ModelKey, Intent: intent}) + if err != nil { + t.Fatalf("QuoteChannelModel() error = %v", err) + } + assertVideoTokenQuote(t, quote, test.wantCost) + assertVideoQuoteHasNoFinancialWrites(t, db) + + input := quoteInput(intent, channelModel.ModelKey) + input["config"].(map[string]any)["channelId"] = channel.ID + resolved, err := svc.resolveSystemChannelModelSelection(input, "canvas_video", "") + if err != nil { + t.Fatalf("resolveSystemChannelModelSelection() error = %v", err) + } + order, err := svc.taskBillingOrder("quote-user", &model.Task{ID: "video-task", Type: "canvas_video"}, resolved) + if err != nil { + t.Fatalf("taskBillingOrder() error = %v", err) + } + if order == nil || order.PriceTierID != test.wantTier || order.OutputTokenPriceMicrocredits != test.wantPrice || order.InputTokenPriceMicrocredits != 0 || order.CachedTokenPriceMicrocredits != 0 { + t.Fatalf("wrong video price tier: %#v", order) + } + if order.AmountMicrocredits != quote.AmountMicrocredits || order.ReservedAmountMicrocredits != quote.AmountMicrocredits || order.Quantity != quote.Quantity { + t.Fatalf("quote and task reservation disagree: quote=%#v order=%#v", quote, order) + } + assertVideoQuoteHasNoFinancialWrites(t, db) + }) + } +} + +func TestVideoTokenLogicalQuoteAgreesWithSystemChannel(t *testing.T) { + for _, policy := range []string{"channel", "unified"} { + t.Run(policy, func(t *testing.T) { + svc, db, channel, channelModel, profile := newVideoTokenQuoteFixture(t) + spec, err := CapabilitySpecFromModelCapabilityConfig(profile, "video") + if err != nil { + t.Fatal(err) + } + specJSON, err := json.Marshal(spec) + if err != nil { + t.Fatal(err) + } + logical := model.LogicalModel{ + ID: "logical-video", Code: "seedance-video", Capability: "video", Enabled: true, + ActiveRevisionID: "video-revision", PricePolicy: policy, BillingMode: "token", OutputPriceMicrocredits: 16_000_000, + } + revision := model.LogicalModelRevision{ + ID: logical.ActiveRevisionID, LogicalModelID: logical.ID, Version: 1, + CapabilitySpecJSON: string(specJSON), + DefaultOptionsJSON: `{"vquality":"720p","size":"16:9","videoSeconds":5,"videoGenerateAudio":true}`, + } + route := model.LogicalModelRoute{ + ID: "video-route", LogicalModelRevisionID: revision.ID, ChannelModelID: channelModel.ID, Enabled: true, Weight: 1, + } + for _, row := range []any{&logical, &revision, &route} { + if err := db.Create(row).Error; err != nil { + t.Fatal(err) + } + } + intent := ModelRequestIntent{Capability: "video"} + logicalQuote, err := svc.QuoteLogicalModel(logical.ID, intent) + if err != nil { + t.Fatalf("QuoteLogicalModel() error = %v", err) + } + assertVideoTokenQuote(t, logicalQuote, 1_900_800) + if logicalQuote.LogicalModelID != logical.ID { + t.Fatalf("logicalModelId = %q, want %q", logicalQuote.LogicalModelID, logical.ID) + } + channelQuote, err := svc.QuoteChannelModel(ChannelModelQuoteRequest{ChannelID: channel.ID, ModelKey: channelModel.ModelKey, Intent: intent}) + if err != nil { + t.Fatal(err) + } + if logicalQuote.AmountMicrocredits != channelQuote.AmountMicrocredits || logicalQuote.Quantity != channelQuote.Quantity || !reflect.DeepEqual(logicalQuote.VideoTokenEstimate, channelQuote.VideoTokenEstimate) { + t.Fatalf("logical and channel quote disagree: logical=%#v channel=%#v", logicalQuote, channelQuote) + } + assertVideoQuoteHasNoFinancialWrites(t, db) + }) + } +} + +func TestVideoTokenChannelQuoteRejectsUnavailableChannel(t *testing.T) { + for _, test := range []struct { + name string + field string + value any + }{ + {name: "disabled channel", field: "enabled", value: false}, + {name: "private channel", field: "scope", value: model.ChannelScopeUser}, + {name: "disabled model", field: "enabled", value: false}, + } { + t.Run(test.name, func(t *testing.T) { + svc, db, channel, channelModel, _ := newVideoTokenQuoteFixture(t) + row, id := any(&model.ModelChannel{}), channel.ID + if test.name == "disabled model" { + row, id = &model.ChannelModel{}, channelModel.ID + } + if err := db.Model(row).Where("id = ?", id).Update(test.field, test.value).Error; err != nil { + t.Fatal(err) + } + quote, err := svc.QuoteChannelModel(ChannelModelQuoteRequest{ChannelID: channel.ID, ModelKey: channelModel.ModelKey, Intent: ModelRequestIntent{Capability: "video"}}) + if err == nil || quote != nil { + t.Fatalf("unavailable channel/model must be rejected: quote=%#v error=%v", quote, err) + } + assertVideoQuoteHasNoFinancialWrites(t, db) + }) + } +} + +func TestVideoTokenQuoteRejectsInvalidReferenceCounts(t *testing.T) { + for _, count := range []int{-1, 129, int(^uint(0) >> 1)} { + for _, kind := range []string{"image", "video", "audio"} { + t.Run(fmt.Sprintf("%s/%d", kind, count), func(t *testing.T) { + intent := ModelRequestIntent{Capability: "video", Inputs: map[string]int{kind: count}} + // A nil repository verifies counts fail before allocation, lookup, or routing. + svc := &Service{} + if quote, err := svc.QuoteChannelModel(ChannelModelQuoteRequest{ChannelID: "system-video", ModelKey: "video", Intent: intent}); err == nil || quote != nil { + t.Fatalf("channel quote must reject invalid count: quote=%#v error=%v", quote, err) + } + if quote, err := svc.QuoteLogicalModel("logical-video", intent); err == nil || quote != nil { + t.Fatalf("logical quote must reject invalid count: quote=%#v error=%v", quote, err) + } + }) + } + } +} + +func newVideoTokenQuoteFixture(t *testing.T) (*Service, *gorm.DB, model.ModelChannel, model.ChannelModel, *ModelCapabilityConfig) { + t.Helper() + const providerModel = "doubao-seedance-1-5-pro-251215" + profile := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceVolcengineArkVideo), providerModel) + profile.Video.Duration.Default = 5 + tiers := []model.ChannelModelPriceTier{ + {ID: "video-audio", SelectorKey: `{"videoGenerateAudio":"true"}`, SelectorJSON: `{"videoGenerateAudio":"true"}`, ProviderModelKey: providerModel, BillingMode: "token", OutputTokenPriceMicrocredits: 16_000_000, PriceConfigured: true, Enabled: true}, + {ID: "video-silent", SelectorKey: `{"videoGenerateAudio":"false"}`, SelectorJSON: `{"videoGenerateAudio":"false"}`, ProviderModelKey: providerModel, BillingMode: "token", OutputTokenPriceMicrocredits: 8_000_000, PriceConfigured: true, Enabled: true}, + } + svc, db, channel, channelModel := createSystemChannelSelectionFixture(t, "video", model.ChannelInterfaceVolcengineArkVideo, profile, tiers) + channelModel.ProviderModelKey = providerModel + if err := db.Model(&model.ChannelModel{}).Where("id = ?", channelModel.ID).Update("provider_model_key", providerModel).Error; err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.SystemSetting{}, &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.BillingOrder{}); err != nil { + t.Fatal(err) + } + account := model.CreditAccount{UserID: "quote-user", AvailableMicrocredits: 10_000_000, ReservedMicrocredits: 250_000, Version: 7} + if err := db.Create(&account).Error; err != nil { + t.Fatal(err) + } + return svc, db, channel, channelModel, profile +} + +func assertVideoTokenQuote(t *testing.T, quote *LogicalModelQuote, amount int64) { + t.Helper() + if quote == nil || quote.BillingMode != "token" || !quote.Estimated || quote.Quantity != 118_800 || quote.AmountMicrocredits != amount { + t.Fatalf("unexpected video quote: %#v, want 118800 tokens and %d microcredits", quote, amount) + } + want := VideoTokenEstimate{ + FormulaTokens: 108_000, ReservedTokens: 118_800, OutputWidth: 1280, OutputHeight: 720, + FramesPerSecond: 24, OutputSeconds: 5, ReservationMarginPercent: 10, + } + if quote.VideoTokenEstimate == nil || *quote.VideoTokenEstimate != want { + t.Fatalf("video estimate = %#v, want %#v", quote.VideoTokenEstimate, want) + } +} + +func assertVideoQuoteHasNoFinancialWrites(t *testing.T, db *gorm.DB) { + t.Helper() + var account model.CreditAccount + if err := db.First(&account, "user_id = ?", "quote-user").Error; err != nil { + t.Fatal(err) + } + if account.AvailableMicrocredits != 10_000_000 || account.ReservedMicrocredits != 250_000 || account.Version != 7 { + t.Fatalf("read-only quote changed the credit account: %#v", account) + } + for _, row := range []any{&model.BillingOrder{}, &model.CreditLedgerEntry{}, &model.Task{}} { + var count int64 + if err := db.Model(row).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("read-only quote created %d records of %T", count, row) + } + } +} diff --git a/backend/internal/app/video_token_tier_test.go b/backend/internal/app/video_token_tier_test.go new file mode 100644 index 000000000..babe90919 --- /dev/null +++ b/backend/internal/app/video_token_tier_test.go @@ -0,0 +1,123 @@ +package app + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestNormalizeVideoAudioPriceSelector(t *testing.T) { + for _, test := range []struct { + value string + want string + }{ + {value: "true", want: "true"}, + {value: "false", want: "false"}, + {value: " FALSE ", want: "false"}, + {value: "*", want: ""}, + } { + t.Run(test.value, func(t *testing.T) { + selector, _, _, err := normalizeChannelModelTierSelector("video", ChannelModelPriceTierRequest{ + Selector: map[string]string{"videoGenerateAudio": test.value}, + }) + if err != nil || selector["videoGenerateAudio"] != test.want { + t.Fatalf("selector = %#v, error = %v, want audio %q", selector, err, test.want) + } + if test.want == "" && len(selector) != 0 { + t.Fatalf("wildcard must share the default selector, got %#v", selector) + } + }) + } + for _, value := range []string{"yes", "1", "0", "auto"} { + if _, _, _, err := normalizeChannelModelTierSelector("video", ChannelModelPriceTierRequest{ + Selector: map[string]string{"videoGenerateAudio": value}, + }); err == nil { + t.Fatalf("invalid audio selector %q accepted", value) + } + } + for _, capability := range []string{"text", "image", "audio"} { + for _, value := range []string{"true", "false", "*"} { + if _, _, _, err := normalizeChannelModelTierSelector(capability, ChannelModelPriceTierRequest{ + Selector: map[string]string{"videoGenerateAudio": value}, + }); err == nil { + t.Fatalf("%s model accepted video audio selector %q", capability, value) + } + } + } +} + +func TestVideoPriceTierMatchesAudioAndActualReferences(t *testing.T) { + channelModel := model.ChannelModel{PriceTiers: []model.ChannelModelPriceTier{ + {ID: "default", SelectorJSON: `{}`, Enabled: true, PriceConfigured: true}, + {ID: "reference", SelectorJSON: `{"operation":"video_to_video"}`, Enabled: true, PriceConfigured: true}, + {ID: "reference-audio", SelectorJSON: `{"operation":"video_to_video","videoGenerateAudio":"true"}`, Enabled: true, PriceConfigured: true}, + {ID: "reference-silent", SelectorJSON: `{"operation":"video_to_video","videoGenerateAudio":"false"}`, Enabled: true, PriceConfigured: true}, + }} + for _, test := range []struct { + name string + value any + want string + }{ + {name: "audio boolean", value: true, want: "reference-audio"}, + {name: "silent boolean", value: false, want: "reference-silent"}, + {name: "audio string", value: "true", want: "reference-audio"}, + {name: "silent string", value: "false", want: "reference-silent"}, + {name: "unspecified", want: "reference"}, + {name: "invalid is not silent", value: "invalid", want: "reference"}, + } { + t.Run(test.name, func(t *testing.T) { + options := map[string]any{} + if test.value != nil { + options["videoGenerateAudio"] = test.value + } + for _, source := range []string{"config", "capabilityOptions"} { + intent := ModelRequestIntentFromTaskInput(map[string]any{ + "mode": "video", "referenceVideos": []any{map[string]any{"id": "video-reference"}}, source: options, + }, "canvas_video", "reference_to_video") + tier := channelModelPriceTierForIntent(channelModel, intent) + if tier == nil || tier.ID != test.want { + t.Fatalf("%s matched %#v, want %s", source, tier, test.want) + } + } + }) + } + intent := ModelRequestIntent{Capability: "video", Inputs: map[string]int{"video": 1}} + intent.Options = mergeIntentDefaults(map[string]any{"videoGenerateAudio": false}, map[string]any{"videoGenerateAudio": true}) + if tier := channelModelPriceTierForIntent(channelModel, intent); tier == nil || tier.ID != "reference-silent" { + t.Fatalf("explicit false must override audio default: %#v", tier) + } +} + +func TestVideoTokenPricesUseOutputOnlyAndSharedBounds(t *testing.T) { + for _, protocol := range []model.ChannelInterfaceType{model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineArkAgentPlanVideo, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceType("newapi-channel-2"), model.ChannelInterfaceType("custom-video")} { + for _, test := range []struct { + name string + input, output int64 + cached int64 + valid bool + }{ + {name: "free", valid: true}, + {name: "paid", output: 16_000_000, valid: true}, + {name: "maximum", output: maxChannelModelTokenPriceMicrocredits, valid: true}, + {name: "over maximum", output: maxChannelModelTokenPriceMicrocredits + 1}, + {name: "negative", output: -1}, + {name: "hidden input price", input: 1, output: 16_000_000}, + {name: "hidden cached price", output: 16_000_000, cached: 1}, + } { + t.Run(string(protocol)+"/"+test.name, func(t *testing.T) { + if got := ValidateChannelModelPrice("token", "video", protocol, 0, test.input, test.output, test.cached); got != test.valid { + t.Fatalf("price validity = %v, want %v", got, test.valid) + } + for _, configured := range []bool{true, false} { + err := validateChannelModelTierPricing("video", protocol, "token", ChannelModelPriceTierRequest{ + PriceConfigured: configured, InputTokenPriceMicrocredits: test.input, + OutputTokenPriceMicrocredits: test.output, CachedTokenPriceMicrocredits: test.cached, + }) + if (err == nil) != test.valid { + t.Fatalf("configured=%v error=%v, want validity %v", configured, err, test.valid) + } + } + }) + } + } +} diff --git a/backend/internal/app/video_usage_test.go b/backend/internal/app/video_usage_test.go new file mode 100644 index 000000000..7c8ff67bb --- /dev/null +++ b/backend/internal/app/video_usage_test.go @@ -0,0 +1,90 @@ +package app + +import ( + "fmt" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestArkVideoUsageRequiresValidFinalCompletionTokens(t *testing.T) { + for _, path := range []string{"/api/v3/contents/generations/tasks/video-1", "/api/plan/v3/contents/generations/tasks/video-1"} { + for _, test := range []struct { + name string + payload string + want int64 + }{ + {name: "completion", payload: `{"status":"succeeded","usage":{"completion_tokens":108900}}`, want: 108900}, + {name: "completion precedes output and total", payload: `{"usage":{"completion_tokens":108900,"output_tokens":900000,"total_tokens":200000}}`, want: 108900}, + {name: "total when completion missing", payload: `{"usage":{"total_tokens":35800}}`, want: 35800}, + {name: "total precedes output alias", payload: `{"usage":{"output_tokens":900000,"total_tokens":35800}}`, want: 35800}, + {name: "nested response", payload: `{"data":{"status":"succeeded","usage":{"completion_tokens":35800}}}`, want: 35800}, + {name: "integer beyond float precision", payload: `{"usage":{"completion_tokens":9007199254740993}}`, want: 9007199254740993}, + {name: "largest int64", payload: `{"usage":{"completion_tokens":9223372036854775807}}`, want: 9223372036854775807}, + {name: "processing", payload: `{"status":"running","usage":{"completion_tokens":108900}}`}, + {name: "queued", payload: `{"status":"queued","usage":{"completion_tokens":108900}}`}, + {name: "failed", payload: `{"status":"failed","usage":{"completion_tokens":108900}}`}, + {name: "invalid status", payload: `{"status":true,"usage":{"completion_tokens":108900}}`}, + {name: "missing usage", payload: `{"status":"succeeded"}`}, + {name: "empty usage", payload: `{"status":"succeeded","usage":{}}`}, + {name: "output alias only", payload: `{"usage":{"output_tokens":108900}}`}, + {name: "unrelated usage metadata", payload: `{"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":108900,"cachedContentTokenCount":1}}`}, + } { + t.Run(path+"/"+test.name, func(t *testing.T) { + log := &model.ApiCallLog{Capability: "video", Path: path, InputTokens: 1, CachedTokens: 1, OutputTokens: 1, UsageAvailable: true} + (&Service{}).EnrichAPICallLog(log, []byte(test.payload)) + if log.InputTokens != 0 || log.CachedTokens != 0 || log.OutputTokens != test.want || log.UsageAvailable != (test.want > 0) { + t.Fatalf("usage = input:%d cached:%d output:%d available:%v, want output:%d", log.InputTokens, log.CachedTokens, log.OutputTokens, log.UsageAvailable, test.want) + } + }) + } + } +} + +func TestArkVideoUsageDoesNotMaskMalformedCompletionWithTotal(t *testing.T) { + for _, invalid := range []string{"0", "-1", "1.5", "1.0", `"108900"`, "9223372036854775808", "1e100", "null", "true", "{}"} { + for _, field := range []string{"completion_tokens", "total_tokens"} { + t.Run(field+"/"+invalid, func(t *testing.T) { + usage := fmt.Sprintf(`{"%s":%s}`, field, invalid) + if field == "completion_tokens" { + usage = fmt.Sprintf(`{"completion_tokens":%s,"total_tokens":108900}`, invalid) + } + log := &model.ApiCallLog{Capability: "video", Path: "/api/v3/contents/generations/tasks/video-1"} + (&Service{}).EnrichAPICallLog(log, []byte(`{"status":"succeeded","usage":`+usage+`}`)) + if log.UsageAvailable || log.OutputTokens != 0 { + t.Fatalf("accepted invalid usage %s: output:%d available:%v", usage, log.OutputTokens, log.UsageAvailable) + } + }) + } + } +} + +func TestArkVideoUsageNeverAddsInputOrCachedTokens(t *testing.T) { + log := &model.ApiCallLog{Capability: "video", Path: "/api/v3/contents/generations/tasks/video-1"} + (&Service{}).EnrichAPICallLog(log, []byte(`{"status":"succeeded","usage":{"completion_tokens":108900,"input_tokens":999,"cached_tokens":50,"prompt_tokens_details":{"cached_tokens":100}},"usageMetadata":{"promptTokenCount":888,"candidatesTokenCount":777,"cachedContentTokenCount":200}}`)) + if !log.UsageAvailable || log.InputTokens != 0 || log.CachedTokens != 0 || log.OutputTokens != 108900 { + t.Fatalf("video usage was contaminated by text usage: %#v", log) + } +} + +func TestGenericVideoUsageRequiresPositiveFinalIntegerTokens(t *testing.T) { + for _, test := range []struct { + payload string + want int64 + }{ + {`{"data":{"status":"completed","usage":{"completion_tokens":108000}}}`, 108000}, + {`{"status":"SUCCESS","usage":{"output_tokens":100000,"input_tokens":20}}`, 100000}, + {`{"status":"succeeded","usage":{"total_tokens":90000}}`, 90000}, + {`{"status":"running","usage":{"completion_tokens":108000}}`, 0}, + {`{"status":"failed","usage":{"output_tokens":108000}}`, 0}, + {`{"usage":{"completion_tokens":1.5,"total_tokens":108000}}`, 0}, + {`{"usage":{"output_tokens":-1,"total_tokens":108000}}`, 0}, + {`{"usage":{"input_tokens":108000}}`, 0}, + } { + log := &model.ApiCallLog{Capability: "video", Path: "/v1/video/generations/task-1"} + (&Service{}).EnrichAPICallLog(log, []byte(test.payload)) + if log.OutputTokens != test.want || log.UsageAvailable != (test.want > 0) || log.InputTokens != 0 || log.CachedTokens != 0 { + t.Fatalf("payload=%s log=%#v", test.payload, log) + } + } +} diff --git a/backend/internal/database/channel_credit_cost_test.go b/backend/internal/database/channel_credit_cost_test.go new file mode 100644 index 000000000..7ca3808a7 --- /dev/null +++ b/backend/internal/database/channel_credit_cost_test.go @@ -0,0 +1,49 @@ +package database + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestChannelCreditCostMigrationPreservesSalesAndUnknownHistoricalCosts(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + for _, sql := range []string{ + `CREATE TABLE channel_model_price_tiers (id TEXT PRIMARY KEY, unit_price_microcredits INTEGER, deleted_at DATETIME)`, + `CREATE TABLE billing_orders (id TEXT PRIMARY KEY, actual_amount_microcredits INTEGER)`, + `INSERT INTO channel_model_price_tiers VALUES ('tier',900000,NULL)`, + `INSERT INTO billing_orders VALUES ('order',13500000)`, + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + for i := 0; i < 2; i++ { + if err := migrateChannelCreditCost(db); err != nil { + t.Fatal(err) + } + } + var tier model.ChannelModelPriceTier + var order model.BillingOrder + if err := db.First(&tier, "id = ?", "tier").Error; err != nil { + t.Fatal(err) + } + if err := db.First(&order, "id = ?", "order").Error; err != nil { + t.Fatal(err) + } + if tier.UnitPriceMicrocredits != 900000 || order.ActualAmountMicrocredits != 13500000 || tier.CostPricing.Configured || order.CostPricing.Configured || order.CostBillingMode != "" { + t.Fatalf("migration changed history: %#v %#v", tier, order) + } + if err := db.Table("channel_model_price_tiers").Where("id = ?", "tier").Updates(map[string]any{"cost_configured": true, "cost_unit_price_microcredits": 123456}).Error; err != nil { + t.Fatal(err) + } + if err := db.First(&tier, "id = ?", "tier").Error; err != nil { + t.Fatal(err) + } + if !tier.CostPricing.Configured || tier.CostPricing.UnitPriceMicrocredits != 123456 { + t.Fatalf("cost columns not mapped: %#v", tier) + } +} diff --git a/backend/internal/database/channel_model_description_test.go b/backend/internal/database/channel_model_description_test.go new file mode 100644 index 000000000..5f5843bde --- /dev/null +++ b/backend/internal/database/channel_model_description_test.go @@ -0,0 +1,40 @@ +package database + +import "testing" + +func TestChannelModelDescriptionMigration(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + for _, sql := range []string{ + `CREATE TABLE channel_models (id TEXT PRIMARY KEY, model_key TEXT, channel_label TEXT, unit_price_microcredits INTEGER)`, + `INSERT INTO channel_models VALUES ('model-a', 'seedance-2.0', '优惠渠道-993', 300000)`, + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + for i := 0; i < 2; i++ { + if err := migrateChannelModelDescription(db); err != nil { + t.Fatal(err) + } + } + var row struct { + ModelKey, ChannelLabel, Description string + UnitPriceMicrocredits int64 + } + if err := db.Table("channel_models").Where("id = ?", "model-a").First(&row).Error; err != nil { + t.Fatal(err) + } + if row.ModelKey != "seedance-2.0" || row.ChannelLabel != "优惠渠道-993" || row.UnitPriceMicrocredits != 300000 || row.Description != "" { + t.Fatalf("migration changed existing data: %#v", row) + } + if err := db.Exec(`INSERT INTO channel_models (id) VALUES ('model-b')`).Error; err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Table("channel_models").Where("description = ''").Count(&count).Error; err != nil || count != 2 { + t.Fatalf("description default missing: count=%d err=%v", count, err) + } +} diff --git a/backend/internal/database/channel_model_label_test.go b/backend/internal/database/channel_model_label_test.go new file mode 100644 index 000000000..d38c87103 --- /dev/null +++ b/backend/internal/database/channel_model_label_test.go @@ -0,0 +1,40 @@ +package database + +import "testing" + +func TestChannelModelLabelMigrationPreservesIdentityAndPricing(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + for _, sql := range []string{ + `CREATE TABLE channel_models (id TEXT PRIMARY KEY, channel_id TEXT, model_key TEXT, provider_model_key TEXT, price_version INTEGER, unit_price_microcredits INTEGER)`, + `INSERT INTO channel_models VALUES ('model-a', 'channel-a', 'seedance-2.0', 'upstream-v2', 7, 300000)`, + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + for i := 0; i < 2; i++ { + if err := migrateChannelModelLabel(db); err != nil { + t.Fatal(err) + } + } + var row struct { + ID, ChannelID, ModelKey, ProviderModelKey, ChannelLabel string + PriceVersion, UnitPriceMicrocredits int64 + } + if err := db.Table("channel_models").First(&row).Error; err != nil { + t.Fatal(err) + } + if row.ID != "model-a" || row.ChannelID != "channel-a" || row.ModelKey != "seedance-2.0" || row.ProviderModelKey != "upstream-v2" || row.ChannelLabel != "" || row.PriceVersion != 7 || row.UnitPriceMicrocredits != 300000 { + t.Fatalf("migration changed existing identity/pricing: %#v", row) + } + if err := db.Exec(`INSERT INTO channel_models (id) VALUES ('model-b')`).Error; err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Table("channel_models").Where("channel_label = ''").Count(&count).Error; err != nil || count != 2 { + t.Fatalf("empty label default missing: count=%d err=%v", count, err) + } +} diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 479a9cdf7..8f91bb050 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 24 +const CurrentSchemaVersion int64 = 27 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -89,18 +89,57 @@ var schemaMigrations = []migration{ {version: 23, name: "canvas_revision_history", checksum: "sha256:canvas-revision-history-v23-20260918", apply: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.CanvasProject{}, &model.CanvasSnapshot{}, &model.CanvasSnapshotResource{}) }}, - {version: 24, name: "channel_model_description", checksum: "sha256:channel-model-description-v24-20260918", apply: migrateSchemaV24}, + {version: 24, name: "channel_model_label", checksum: "sha256:channel-model-label-v24", apply: migrateChannelModelLabel}, + {version: 25, name: "video_token_formula_snapshot", checksum: "sha256:video-token-formula-snapshot-v25", apply: migrateVideoTokenFormulaSnapshot}, + {version: 26, name: "channel_model_description", checksum: "sha256:channel-model-description-v26", apply: migrateChannelModelDescription}, + {version: 27, name: "channel_credit_cost", checksum: "sha256:channel-credit-cost-v27", apply: migrateChannelCreditCost}, } -// migrateSchemaV24 为系统渠道模型补充面向用户的说明字段。 -// 说明只用于前台选型展示,存量模型保持空说明,不影响价格、路由与结算。 -func migrateSchemaV24(tx *gorm.DB) error { - if !tx.Migrator().HasColumn(&model.ChannelModel{}, "Description") { - return tx.Migrator().AddColumn(&model.ChannelModel{}, "Description") +func migrateChannelCreditCost(tx *gorm.DB) error { + for _, entity := range []any{&model.ChannelModelPriceTier{}, &model.BillingOrder{}} { + for _, column := range []string{"cost_configured", "cost_unit_price_microcredits", "cost_input_token_price_microcredits", "cost_output_token_price_microcredits", "cost_cached_token_price_microcredits"} { + if !tx.Migrator().HasColumn(entity, column) { + if err := tx.Migrator().AddColumn(entity, column); err != nil { + return err + } + } + } + } + for _, column := range []string{"CostBillingMode", "CostQuantity", "CostVideoFormulaTokens"} { + if !tx.Migrator().HasColumn(&model.BillingOrder{}, column) { + if err := tx.Migrator().AddColumn(&model.BillingOrder{}, column); err != nil { + return err + } + } } return nil } +func migrateChannelModelDescription(tx *gorm.DB) error { + if tx.Migrator().HasColumn(&model.ChannelModel{}, "Description") { + return nil + } + return tx.Migrator().AddColumn(&model.ChannelModel{}, "Description") +} + +func migrateVideoTokenFormulaSnapshot(tx *gorm.DB) error { + for _, field := range []string{"VideoFormulaTokens", "UsageSource"} { + if !tx.Migrator().HasColumn(&model.BillingOrder{}, field) { + if err := tx.Migrator().AddColumn(&model.BillingOrder{}, field); err != nil { + return fmt.Errorf("增加视频 Token 结算字段 %s:%w", field, err) + } + } + } + return nil +} + +func migrateChannelModelLabel(tx *gorm.DB) error { + if tx.Migrator().HasColumn(&model.ChannelModel{}, "ChannelLabel") { + return nil + } + return tx.Migrator().AddColumn(&model.ChannelModel{}, "ChannelLabel") +} + func migrateSchemaV14(tx *gorm.DB) error { if err := tx.AutoMigrate(&model.CloudAgentExecution{}); err != nil { return err diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index 21f869dec..b06d2c6a8 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -446,7 +446,7 @@ func TestMigrateSchemaV4AddsResourceUploadKeyToExistingSchema(t *testing.T) { if err := db.Exec(`CREATE TABLE resources (id TEXT PRIMARY KEY, user_id TEXT NOT NULL)`).Error; err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}); err != nil { + if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.BillingOrder{}); err != nil { t.Fatal(err) } if err := db.AutoMigrate(&schemaMigration{}); err != nil { @@ -490,7 +490,7 @@ func TestMigrateSchemaRepairsLegacyAssetFoldersMigrationOrder(t *testing.T) { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}, &model.ModelChannel{}, &model.ChannelModel{}); err != nil { + if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}, &model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.BillingOrder{}); err != nil { t.Fatal(err) } if err := db.AutoMigrate(&schemaMigration{}); err != nil { diff --git a/backend/internal/database/migrations_video_token_test.go b/backend/internal/database/migrations_video_token_test.go new file mode 100644 index 000000000..5323e95da --- /dev/null +++ b/backend/internal/database/migrations_video_token_test.go @@ -0,0 +1,44 @@ +package database + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestMigrateSchemaV25PreservesHistoricalBillingOrders(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-video-token-formula-v25?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + for _, field := range []string{"VideoFormulaTokens", "UsageSource"} { + if err := db.Migrator().DropColumn(&model.BillingOrder{}, field); err != nil { + t.Fatal(err) + } + } + if err := db.Exec(`INSERT INTO billing_orders (id, user_id, idempotency_key, capability, billing_mode, quantity, amount_microcredits, output_tokens, usage_available, status) VALUES ('legacy', 'user', 'task', 'video', 'token', 118800, 1900800, 108000, true, 'settled')`).Error; err != nil { + t.Fatal(err) + } + if err := db.Where("version = ?", 25).Delete(&schemaMigration{}).Error; err != nil { + t.Fatal(err) + } + for range 2 { + if err := MigrateSchema(db); err != nil { + t.Fatalf("upgrade to v25: %v", err) + } + } + var order model.BillingOrder + if err := db.First(&order, "id = ?", "legacy").Error; err != nil { + t.Fatal(err) + } + if order.VideoFormulaTokens != 0 || order.UsageSource != "" || order.Quantity != 118800 || order.AmountMicrocredits != 1900800 || order.OutputTokens != 108000 || !order.UsageAvailable || order.Status != model.BillingStatusSettled { + t.Fatalf("historical billing changed during migration: %+v", order) + } + status, err := ReadSchemaStatus(db) + if err != nil || !status.Ready || status.Current != CurrentSchemaVersion { + t.Fatalf("schema not ready after upgrade: %+v, %v", status, err) + } +} diff --git a/backend/internal/handler/admin_channel_model_response.go b/backend/internal/handler/admin_channel_model_response.go new file mode 100644 index 000000000..5858e8741 --- /dev/null +++ b/backend/internal/handler/admin_channel_model_response.go @@ -0,0 +1,22 @@ +package handler + +import "infinite-canvas/backend/internal/model" + +// 成本字段只在已通过 service 管理员校验的模型编辑接口中显式投影。 +type adminChannelModelPriceTier struct { + model.ChannelModelPriceTier + CostPricing model.CreditCostPricing `json:"costPricing"` +} + +type adminChannelModelResponse struct { + model.ChannelModel + PriceTiers []adminChannelModelPriceTier `json:"priceTiers"` +} + +func adminChannelModel(item model.ChannelModel) adminChannelModelResponse { + tiers := make([]adminChannelModelPriceTier, 0, len(item.PriceTiers)) + for _, tier := range item.PriceTiers { + tiers = append(tiers, adminChannelModelPriceTier{ChannelModelPriceTier: tier, CostPricing: tier.CostPricing}) + } + return adminChannelModelResponse{ChannelModel: item, PriceTiers: tiers} +} diff --git a/backend/internal/handler/credit_cost_routes_test.go b/backend/internal/handler/credit_cost_routes_test.go new file mode 100644 index 000000000..f08ec9557 --- /dev/null +++ b/backend/internal/handler/credit_cost_routes_test.go @@ -0,0 +1,102 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "infinite-canvas/backend/internal/auth" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestChannelCostHTTPAuthorizationAndPublicProjection(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.IDSequence{}, &model.SystemSetting{}); err != nil { + t.Fatal(err) + } + for _, role := range []model.UserRole{model.UserRoleAdmin, model.UserRoleUser} { + id := string(role) + if err := db.Create(&model.User{ID: id, Username: id, Email: id + "@example.invalid", Role: role, Status: model.UserStatusActive}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.AuthSession{ID: id, UserID: id, TokenHash: auth.HashToken("test-token"), ExpiresAt: time.Now().Add(time.Hour)}).Error; err != nil { + t.Fatal(err) + } + } + channel := model.ModelChannel{ID: "channel", Name: "渠道", Scope: model.ChannelScopeSystem, Enabled: true, ModelsJSON: `[]`} + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + svc := service.New(repository.New(db), t.TempDir()) + router := gin.New() + RegisterFinanceRoutes(router.Group("/api"), svc) + RegisterModelCatalogRoutes(router.Group("/api"), svc) + request := service.ChannelModelRequest{ModelKey: "text-model", DisplayName: "文本模型", ChannelLabel: "优惠渠道", Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion), CapabilityConfig: service.DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "text-model"), PriceTiers: []service.ChannelModelPriceTierRequest{{BillingMode: "fixed_request", UnitPriceMicrocredits: 900_000, PriceConfigured: true, CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 123_456}}}} + body, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + call := func(role, method, path string, payload []byte) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, bytes.NewReader(payload)) + r.Header.Set("Content-Type", "application/json") + if role != "" { + r.AddCookie(&http.Cookie{Name: service.SessionCookieName, Value: role + ".test-token"}) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, r) + return w + } + for _, method := range []string{http.MethodGet, http.MethodPost} { + for _, role := range []string{"", "user"} { + w := call(role, method, "/api/admin/channels/channel/models", body) + want := http.StatusForbidden + if role == "" { + want = http.StatusUnauthorized + } + if w.Code != want { + t.Fatalf("%s %s: %d %s", role, method, w.Code, w.Body.String()) + } + } + } + for _, method := range []string{http.MethodPost, http.MethodGet} { + w := call("admin", method, "/api/admin/channels/channel/models", body) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"costPricing":`) || !strings.Contains(w.Body.String(), "123456") { + t.Fatalf("admin cost missing: %d %s", w.Code, w.Body.String()) + } + } + for _, path := range []string{"/api/model-catalog", "/api/model-catalog/available", "/api/model-catalog/quote"} { + method := http.MethodPost + payload := []byte(`{"capability":"text"}`) + if path == "/api/model-catalog" { + method = http.MethodGet + payload = nil + } + if strings.HasSuffix(path, "/quote") { + payload = []byte(`{"channelId":"channel","modelKey":"text-model","intent":{"capability":"text","operation":"text_generation","inputs":{"text":1}}}`) + } + w := call("user", method, path, payload) + if w.Code != http.StatusOK { + t.Fatalf("public %s: %d %s", path, w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "costPricing") || strings.Contains(w.Body.String(), "123456") { + t.Fatalf("public cost leaked: %s", w.Body.String()) + } + } +} diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go index c73a052d2..6829b1636 100644 --- a/backend/internal/handler/finance.go +++ b/backend/internal/handler/finance.go @@ -205,7 +205,11 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - ok(c, gin.H{"models": items}) + models := make([]adminChannelModelResponse, 0, len(items)) + for _, item := range items { + models = append(models, adminChannelModel(item)) + } + ok(c, gin.H{"models": models}) }) r.POST("/admin/channels/:id/models/fetch", func(c *gin.Context) { user, err := currentUser(c, svc) @@ -501,5 +505,5 @@ func saveChannelModel(c *gin.Context, svc *service.Service, id string) { failService(c, err) return } - ok(c, gin.H{"model": item}) + ok(c, gin.H{"model": adminChannelModel(*item)}) } diff --git a/backend/internal/handler/model_catalog.go b/backend/internal/handler/model_catalog.go index 507e433c1..b966540ed 100644 --- a/backend/internal/handler/model_catalog.go +++ b/backend/internal/handler/model_catalog.go @@ -11,6 +11,23 @@ import ( // RegisterModelCatalogRoutes 注册统一模型目录路由 func RegisterModelCatalogRoutes(r *gin.RouterGroup, svc *service.Service) { + r.POST("/model-catalog/quote", func(c *gin.Context) { + if _, err := currentUser(c, svc); err != nil { + failService(c, err) + return + } + var req service.ChannelModelQuoteRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, errors.New("模型报价请求格式错误")) + return + } + quote, err := svc.QuoteChannelModel(req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"quote": quote}) + }) r.GET("/model-catalog", func(c *gin.Context) { if _, err := currentUser(c, svc); err != nil { failService(c, err) diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index 3ae4f43d1..aa8881146 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -22,9 +22,132 @@ tags: - name: resources - name: tasks - name: assets + - name: models - name: agent - name: admin paths: + /model-catalog: + get: + tags: [models] + summary: 获取创作端模型目录 + description: | + 固定 source=system,channels 直接来自系统渠道下的模型列表,models 为空数组;不反查逻辑模型。 + 一级跨渠道按 displayName 分组,二级显示 channelLabel;模型请求仍使用 channelId + modelKey 定位。 + channelLabel 是后台渠道模型创建和更新时配置的渠道展示名,去除首尾空白后最多 80 字,空字符串表示使用所属渠道名称。 + description 是后台渠道模型创建和更新时填写的公开模型描述,去除首尾空白后最多 500 字;空字符串表示未填写。 + displayName 和 icon 用于一级模型菜单,channelLabel 不参与路由、权限或计费。 + 渠道级 publicAlias 已废弃;成本配置和成本快照仅供管理端,不包含在本接口及用户报价中。 + responses: + "200": + description: 当前启用的模型目录 + content: + application/json: + schema: + type: object + properties: + code: + type: integer + enum: [0] + msg: + type: string + data: + type: object + required: [source, models, channels] + properties: + source: + type: string + enum: [system] + models: + type: array + items: + type: object + channels: + type: array + items: + type: object + properties: + id: + type: string + displayName: + type: string + models: + type: array + items: + type: object + properties: + modelKey: + type: string + displayName: + type: string + icon: + type: string + channelLabel: + type: string + maxLength: 80 + default: "" + description: + type: string + maxLength: 500 + default: "" + "401": + $ref: "#/components/responses/Error" + /model-catalog/quote: + post: + tags: [models] + summary: 按当前参数预估系统渠道模型费用 + description: | + 需要登录会话 Cookie。仅接受启用的系统渠道和模型,采用服务端能力默认值及价格档选择规则。 + 不创建任务或账单、不冻结或扣除积分,也不调用模型供应商。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [channelId, modelKey, intent] + properties: + channelId: + type: string + minLength: 1 + modelKey: + type: string + minLength: 1 + description: 系统渠道中配置的模型键 + intent: + $ref: "#/components/schemas/ModelRequestIntent" + responses: + "200": + $ref: "#/components/responses/ModelQuote" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + /models/{id}/quote: + post: + tags: [models] + summary: 按当前参数预估前台模型费用 + description: | + 需要登录会话 Cookie。请求正文直接为创作意图,不使用 intent 外层包装。 + 服务端按前台模型能力、默认参数、供应线路和价格策略报价,不创建任务、账单或积分冻结。 + parameters: + - in: path + name: id + required: true + schema: { type: string } + description: 前台逻辑模型 ID + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModelRequestIntent" + responses: + "200": + $ref: "#/components/responses/ModelQuote" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" /canvas-projects/{id}: parameters: - in: path @@ -776,6 +899,119 @@ components: in: cookie name: open_ai_canvas_session schemas: + ModelRequestIntent: + type: object + required: [capability] + properties: + capability: + type: string + enum: [text, image, video, audio] + operation: + type: string + description: 可选生成操作,必须符合所选模型的能力合同 + inputs: + type: object + description: 按素材类型统计的参考数量,如 image、video、audio;另受所选模型的数量上限约束 + additionalProperties: + type: integer + minimum: 0 + maximum: 128 + options: + type: object + description: 当前创作规格;未指定的参数使用服务端默认值 + additionalProperties: true + example: + vquality: 720p + size: "16:9" + videoSeconds: 5 + videoGenerateAudio: true + LogicalModelQuote: + type: object + required: [logicalModelId, billingMode, quantity, amountMicrocredits, estimated] + properties: + logicalModelId: + type: string + description: 前台逻辑模型 ID;直接查询系统渠道模型时为空字符串 + billingMode: + type: string + enum: [fixed_request, per_second, token] + quantity: + type: integer + format: int64 + minimum: 1 + description: 按次为 1,按秒为生成秒数,按 Token 为预留用量(视频含平台预留余量) + amountMicrocredits: + type: integer + format: int64 + minimum: 0 + description: 预估预留微积分,1000000 微积分等于 1 积分;明确配置的免费价格返回 0 + estimated: + type: boolean + description: Token 计费为 true;优先有效上游 usage,视频无有效用量时按提交的公式快照结算,预留不构成费用上限 + videoTokenEstimate: + $ref: "#/components/schemas/VideoTokenEstimate" + VideoTokenEstimate: + type: object + description: 仅视频 Token 报价返回;将火山公式用量和平台预留余量分开披露 + required: [formulaTokens, reservedTokens, outputWidth, outputHeight, framesPerSecond, outputSeconds, referenceSeconds, referenceDurationEstimated, dimensionsEstimated, reservationMarginPercent] + properties: + formulaTokens: + type: integer + format: int64 + minimum: 1 + description: 按(参考视频总秒数 + 输出秒数)× 输出宽 × 输出高 × 帧率 / 1024 向上取整;上游最低计费用量可能更高 + reservedTokens: + type: integer + format: int64 + minimum: 1 + description: 公式用量叠加平台预留余量后向上取整 + outputWidth: + type: integer + format: int64 + minimum: 1 + outputHeight: + type: integer + format: int64 + minimum: 1 + framesPerSecond: + type: integer + format: int64 + minimum: 1 + outputSeconds: + type: integer + format: int64 + minimum: 1 + referenceSeconds: + type: number + format: double + minimum: 0 + description: 计入公式的参考视频总秒数;报价仅有参考数量时采用预估时长 + referenceDurationEstimated: + type: boolean + description: 参考时长未知、使用预估值时为 true + dimensionsEstimated: + type: boolean + description: 自适应画幅或上游型号无法确定,使用预估像素尺寸时为 true + reservationMarginPercent: + type: integer + format: int64 + minimum: 0 + description: 平台预留余量百分比,不属于火山官方计费公式 + ModelQuoteEnvelope: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + required: [data] + properties: + code: + type: integer + enum: [0] + data: + type: object + required: [quote] + properties: + quote: + $ref: "#/components/schemas/LogicalModelQuote" AgentProfileRequest: type: object additionalProperties: false @@ -902,6 +1138,12 @@ components: - model_catalog_mismatch - invalid_model_selection responses: + ModelQuote: + description: 当前参数对应的费用报价,位于信封 data.quote + content: + application/json: + schema: + $ref: "#/components/schemas/ModelQuoteEnvelope" Envelope: description: 业务成功信封 content: diff --git a/backend/internal/handler/payment.go b/backend/internal/handler/payment.go index e12ac6803..06994802e 100644 --- a/backend/internal/handler/payment.go +++ b/backend/internal/handler/payment.go @@ -144,13 +144,16 @@ func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) { // Provider callbacks are intentionally unauthenticated at the application // layer. Authenticity is established by the pinned provider config and raw // request signature before any durable event is accepted. - r.POST("/payments/notify/:providerId/:configId", func(c *gin.Context) { + notifyHandler := func(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, paymentNotificationMaxBytes) rawBody, err := io.ReadAll(c.Request.Body) if err != nil { writePaymentNotificationFailure(c, svc, c.Param("providerId"), http.StatusBadRequest) return } + if len(rawBody) == 0 && c.Request.URL.RawQuery != "" { + rawBody = []byte(c.Request.URL.RawQuery) + } err = svc.AcceptPaymentNotification(c.Request.Context(), c.Param("providerId"), c.Param("configId"), c.Request.Header.Clone(), rawBody) if err != nil { status := http.StatusInternalServerError @@ -167,7 +170,10 @@ func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) { return } c.Status(status) - }) + } + + r.POST("/payments/notify/:providerId/:configId", notifyHandler) + r.GET("/payments/notify/:providerId/:configId", notifyHandler) r.GET("/payments/return/:providerId", func(c *gin.Context) { orderID := strings.ToLower(strings.TrimSpace(c.Query("orderId"))) if !paymentOrderIDPattern.MatchString(orderID) { diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index e130d7dc7..d5800435a 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -295,8 +295,8 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { return } if delivery.RedirectURL != "" { - // CDN 或对象存储直连地址允许安全短期缓存 - c.Header("Cache-Control", "private, max-age=86400, stale-while-revalidate=3600") + // 签名链接仅有效 5 分钟;重定向缓存必须短于签名 TTL,避免命中过期地址。 + c.Header("Cache-Control", "private, max-age=240") c.Header("Referrer-Policy", "no-referrer") c.Header("X-Content-Type-Options", "nosniff") c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL) diff --git a/backend/internal/kernel/token_billing.go b/backend/internal/kernel/token_billing.go new file mode 100644 index 000000000..c20babd2b --- /dev/null +++ b/backend/internal/kernel/token_billing.go @@ -0,0 +1,41 @@ +package kernel + +import ( + "errors" + "math/big" +) + +var ( + ErrInvalidTokenBilling = errors.New("invalid token billing parameters") + ErrTokenBillingOverflow = errors.New("token billing amount overflows int64") +) + +type TokenBillingTerm struct { + Tokens int64 + PriceMicrocredits int64 +} + +// TokenBillingAmount 按每百万 Token 单价及万分比倍率汇总费用,只对最终微积分金额上取整。 +func TokenBillingAmount(multiplierBPS int64, terms ...TokenBillingTerm) (int64, error) { + if multiplierBPS <= 0 { + return 0, ErrInvalidTokenBilling + } + var total, product big.Int + for _, term := range terms { + if term.Tokens < 0 || term.PriceMicrocredits < 0 { + return 0, ErrInvalidTokenBilling + } + product.Mul(big.NewInt(term.Tokens), big.NewInt(term.PriceMicrocredits)) + total.Add(&total, &product) + } + total.Mul(&total, big.NewInt(multiplierBPS)) + var remainder big.Int + total.QuoRem(&total, big.NewInt(10_000_000_000), &remainder) + if remainder.Sign() != 0 { + total.Add(&total, big.NewInt(1)) + } + if !total.IsInt64() { + return 0, ErrTokenBillingOverflow + } + return total.Int64(), nil +} diff --git a/backend/internal/kernel/token_billing_test.go b/backend/internal/kernel/token_billing_test.go new file mode 100644 index 000000000..7bab3ddb0 --- /dev/null +++ b/backend/internal/kernel/token_billing_test.go @@ -0,0 +1,90 @@ +package kernel + +import ( + "errors" + "math" + "testing" +) + +func TestTokenBillingAmount(t *testing.T) { + for _, test := range []struct { + name string + multiplier int64 + terms []TokenBillingTerm + want int64 + }{ + { + name: "maximum configured price", multiplier: 10_000, + terms: []TokenBillingTerm{{Tokens: 10_000_000, PriceMicrocredits: 1_000_000_000_000}}, + want: 10_000_000_000_000, + }, + { + name: "maximum configured price with multiplier", multiplier: 12_500, + terms: []TokenBillingTerm{{Tokens: 10_000_000, PriceMicrocredits: 1_000_000_000_000}}, + want: 12_500_000_000_000, + }, + { + name: "round once after combining terms", multiplier: 10_000, + terms: []TokenBillingTerm{{Tokens: 1, PriceMicrocredits: 400_000}, {Tokens: 1, PriceMicrocredits: 400_000}, {Tokens: 1, PriceMicrocredits: 100_000}}, + want: 1, + }, + { + name: "apply multiplier before rounding", multiplier: 12_500, + terms: []TokenBillingTerm{{Tokens: 1, PriceMicrocredits: 400_000}, {Tokens: 1, PriceMicrocredits: 400_000}}, + want: 1, + }, + { + name: "fractional microcredit rounds up", multiplier: 1, + terms: []TokenBillingTerm{{Tokens: 1, PriceMicrocredits: 1}}, + want: 1, + }, + { + name: "maximum int64 result after overflowing intermediate sum", multiplier: 10_000, + terms: []TokenBillingTerm{{Tokens: math.MaxInt64, PriceMicrocredits: 500_000}, {Tokens: math.MaxInt64, PriceMicrocredits: 500_000}}, + want: math.MaxInt64, + }, + { + name: "free usage", multiplier: math.MaxInt64, + terms: []TokenBillingTerm{{Tokens: math.MaxInt64, PriceMicrocredits: 0}}, + want: 0, + }, + } { + t.Run(test.name, func(t *testing.T) { + amount, err := TokenBillingAmount(test.multiplier, test.terms...) + if err != nil || amount != test.want { + t.Fatalf("TokenBillingAmount() = %d, %v; want %d", amount, err, test.want) + } + }) + } +} + +func TestTokenBillingAmountRejectsInvalidParametersAndFinalOverflow(t *testing.T) { + for _, test := range []struct { + name string + multiplier int64 + terms []TokenBillingTerm + wantErr error + }{ + {name: "zero multiplier", terms: []TokenBillingTerm{{Tokens: 1, PriceMicrocredits: 1}}, wantErr: ErrInvalidTokenBilling}, + {name: "negative multiplier", multiplier: -1, wantErr: ErrInvalidTokenBilling}, + {name: "negative tokens", multiplier: 10_000, terms: []TokenBillingTerm{{Tokens: -1}}, wantErr: ErrInvalidTokenBilling}, + {name: "negative price even with zero tokens", multiplier: 10_000, terms: []TokenBillingTerm{{PriceMicrocredits: -1}}, wantErr: ErrInvalidTokenBilling}, + { + name: "final amount overflow", multiplier: 10_001, + terms: []TokenBillingTerm{{Tokens: math.MaxInt64, PriceMicrocredits: 1_000_000}}, + wantErr: ErrTokenBillingOverflow, + }, + { + name: "rounding causes final overflow", multiplier: 10_000, + terms: []TokenBillingTerm{{Tokens: math.MaxInt64, PriceMicrocredits: 1_000_000}, {Tokens: 1, PriceMicrocredits: 1}}, + wantErr: ErrTokenBillingOverflow, + }, + } { + t.Run(test.name, func(t *testing.T) { + amount, err := TokenBillingAmount(test.multiplier, test.terms...) + if amount != 0 || !errors.Is(err, test.wantErr) { + t.Fatalf("TokenBillingAmount() = %d, %v; want 0, %v", amount, err, test.wantErr) + } + }) + } +} diff --git a/backend/internal/model/credit_cost.go b/backend/internal/model/credit_cost.go new file mode 100644 index 000000000..6b0994807 --- /dev/null +++ b/backend/internal/model/credit_cost.go @@ -0,0 +1,19 @@ +package model + +// CreditCostPricing 仅供管理员配置和内部核算。持有者必须使用 json:"-", +// 管理端通过专用响应投影公开,禁止随用户目录、报价或账单序列化。 +type CreditCostPricing struct { + Configured bool `json:"configured" gorm:"not null;default:false"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits" gorm:"not null;default:0"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits" gorm:"not null;default:0"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits" gorm:"not null;default:0"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits" gorm:"not null;default:0"` +} + +// BillingCostSnapshot 独立于销售价格,切换供应线路时始终更新为实际执行线路的成本。 +type BillingCostSnapshot struct { + CostPricing CreditCostPricing `json:"-" gorm:"embedded;embeddedPrefix:cost_"` + CostBillingMode string `json:"-" gorm:"size:32;not null;default:''"` + CostQuantity int64 `json:"-" gorm:"not null;default:0"` + CostVideoFormulaTokens int64 `json:"-" gorm:"not null;default:0"` +} diff --git a/backend/internal/model/models_channel.go b/backend/internal/model/models_channel.go index 07eb1abd2..89dac46a0 100644 --- a/backend/internal/model/models_channel.go +++ b/backend/internal/model/models_channel.go @@ -1,26 +1,26 @@ package model import ( - "strings" "time" "gorm.io/gorm" ) type ModelChannel struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - Scope ChannelScope `json:"scope" gorm:"index;size:24"` - Enabled bool `json:"enabled" gorm:"index"` - Name string `json:"name" gorm:"size:80"` - PublicAlias string `json:"publicAlias" gorm:"size:80;not null;default:''"` - SortOrder int `json:"sortOrder" gorm:"not null;default:0"` - BaseURL string `json:"baseUrl"` - APIKey string `json:"-"` - SecretKey string `json:"-"` - APIFormat string `json:"apiFormat" gorm:"size:24"` - ConcurrencyLimit int `json:"concurrencyLimit"` - ModelsJSON string `json:"modelsJson" gorm:"type:text"` + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + Scope ChannelScope `json:"scope" gorm:"index;size:24"` + Enabled bool `json:"enabled" gorm:"index"` + Name string `json:"name" gorm:"size:80"` + // PublicAlias 仅保留历史列供旧迁移读取,运行时不再使用渠道级前台名称。 + PublicAlias string `json:"-" gorm:"size:80;not null;default:''"` + SortOrder int `json:"sortOrder" gorm:"not null;default:0"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"-"` + SecretKey string `json:"-"` + APIFormat string `json:"apiFormat" gorm:"size:24"` + ConcurrencyLimit int `json:"concurrencyLimit"` + ModelsJSON string `json:"modelsJson" gorm:"type:text"` // RetiredModelsJSON 记录已被一个模型家族吸收的上游 SKU,防止目录拉取时重新创建重复记录。 RetiredModelsJSON string `json:"-" gorm:"type:text"` HeadersJSON string `json:"-" gorm:"type:text"` @@ -29,22 +29,16 @@ type ModelChannel struct { DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` } -func (channel ModelChannel) PublicName() string { - if alias := strings.TrimSpace(channel.PublicAlias); alias != "" { - return alias - } - return channel.Name -} - type ChannelModel struct { ID string `json:"id" gorm:"primaryKey;size:36"` ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"` ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"` ProviderModelKey string `json:"providerModelKey" gorm:"size:120"` DisplayName string `json:"displayName" gorm:"size:160"` + ChannelLabel string `json:"channelLabel" gorm:"size:80;not null;default:''"` + Description string `json:"description" gorm:"size:500;not null;default:''"` SortOrder int `json:"sortOrder" gorm:"not null;default:0"` Icon string `json:"icon" gorm:"size:80"` - Description string `json:"description" gorm:"size:600"` Capability string `json:"capability" gorm:"size:32;index"` Protocol ChannelInterfaceType `json:"protocol" gorm:"size:32;index"` BillingMode string `json:"billingMode" gorm:"size:32"` @@ -72,6 +66,7 @@ type ChannelModel struct { // SelectorKey 是规范化 JSON,用于 PostgreSQL 的活动规格唯一约束。旧的 Resolution / VideoSeconds // 仅保留给历史 API 与升级回填,不能再作为 SKU 唯一性依据。 type ChannelModelPriceTier struct { + CostPricing CreditCostPricing `json:"-" gorm:"embedded;embeddedPrefix:cost_"` ID string `json:"id" gorm:"primaryKey;size:36"` ChannelModelID string `json:"channelModelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_price_tier_active,priority:1,where:deleted_at IS NULL"` SelectorKey string `json:"selectorKey" gorm:"size:500;not null;default:{};uniqueIndex:idx_channel_model_price_tier_active,priority:2,where:deleted_at IS NULL"` @@ -94,53 +89,55 @@ type ChannelModelPriceTier struct { } type ApiCallLog struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_api_logs_user_created,priority:1"` - TraceID string `json:"traceId,omitempty" gorm:"index;size:96"` - RequestID string `json:"requestId,omitempty" gorm:"index;size:96"` - UserDisplayName string `json:"userDisplayName,omitempty" gorm:"-"` - UserAccount string `json:"userAccount,omitempty" gorm:"-"` - ChannelID string `json:"channelId" gorm:"index;size:36;index:idx_api_logs_channel_created,priority:1"` - ChannelName string `json:"channelName" gorm:"-"` - TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` - TaskStatus TaskStatus `json:"taskStatus,omitempty" gorm:"-"` - BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` - BillingStatus BillingStatus `json:"billingStatus,omitempty" gorm:"-"` - BillingAmount int64 `json:"billingAmountMicrocredits" gorm:"-"` - BillingAvailable bool `json:"billingAvailable" gorm:"-"` - Source string `json:"source" gorm:"index;size:64"` - Capability string `json:"capability" gorm:"index;size:32"` - Operation string `json:"operation" gorm:"size:64"` - RequestKind string `json:"requestKind" gorm:"index;size:24"` - Billable bool `json:"billable" gorm:"index"` - APIFormat string `json:"apiFormat" gorm:"size:24"` - Method string `json:"method" gorm:"size:16"` - Path string `json:"path"` - Model string `json:"model" gorm:"size:120;index:idx_api_logs_model_created,priority:1"` - Status ApiCallStatus `json:"status" gorm:"index;size:24;index:idx_api_logs_status_created,priority:1"` - StatusCode int `json:"statusCode"` - DurationMs int64 `json:"durationMs"` - PollCount int `json:"pollCount"` - ProviderStatus string `json:"providerStatus,omitempty" gorm:"size:32"` - InputTokens int64 `json:"inputTokens"` - OutputTokens int64 `json:"outputTokens"` - CachedTokens int64 `json:"cachedTokens"` - UsageAvailable bool `json:"usageAvailable"` - MediaCount int `json:"mediaCount"` - MediaPreviewURL string `json:"mediaPreviewUrl,omitempty" gorm:"-"` - MediaPreviewKind string `json:"mediaPreviewKind,omitempty" gorm:"-"` - VideoSeconds int `json:"videoSeconds"` - ProviderRequestID string `json:"providerRequestId" gorm:"size:160"` - EstimatedCostMicros int64 `json:"estimatedCostMicros"` - CostAvailable bool `json:"costAvailable"` - Currency string `json:"currency" gorm:"size:12"` - ErrorCode string `json:"errorCode,omitempty" gorm:"index;size:80"` - Error string `json:"error"` - ConcurrencyLimit int `json:"concurrencyLimit"` - UpstreamURL string `json:"upstreamUrl"` - RequestContentType string `json:"requestContentType,omitempty" gorm:"size:160"` - RequestBody string `json:"requestBody,omitempty" gorm:"type:text"` - ResponseBody string `json:"responseBody,omitempty" gorm:"type:text"` - StartedAt time.Time `json:"startedAt"` - CreatedAt time.Time `json:"createdAt" gorm:"index;index:idx_api_logs_user_created,priority:2;index:idx_api_logs_channel_created,priority:2;index:idx_api_logs_model_created,priority:2;index:idx_api_logs_status_created,priority:2"` + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_api_logs_user_created,priority:1"` + TraceID string `json:"traceId,omitempty" gorm:"index;size:96"` + RequestID string `json:"requestId,omitempty" gorm:"index;size:96"` + UserDisplayName string `json:"userDisplayName,omitempty" gorm:"-"` + UserAccount string `json:"userAccount,omitempty" gorm:"-"` + ChannelID string `json:"channelId" gorm:"index;size:36;index:idx_api_logs_channel_created,priority:1"` + ChannelName string `json:"channelName" gorm:"-"` + TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` + TaskStatus TaskStatus `json:"taskStatus,omitempty" gorm:"-"` + BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` + BillingStatus BillingStatus `json:"billingStatus,omitempty" gorm:"-"` + BillingAmount int64 `json:"billingAmountMicrocredits" gorm:"-"` + BillingAvailable bool `json:"billingAvailable" gorm:"-"` + CreditCostConfigured bool `json:"creditCostConfigured" gorm:"-"` + CreditCostMicrocredits *int64 `json:"creditCostMicrocredits,omitempty" gorm:"-"` + Source string `json:"source" gorm:"index;size:64"` + Capability string `json:"capability" gorm:"index;size:32"` + Operation string `json:"operation" gorm:"size:64"` + RequestKind string `json:"requestKind" gorm:"index;size:24"` + Billable bool `json:"billable" gorm:"index"` + APIFormat string `json:"apiFormat" gorm:"size:24"` + Method string `json:"method" gorm:"size:16"` + Path string `json:"path"` + Model string `json:"model" gorm:"size:120;index:idx_api_logs_model_created,priority:1"` + Status ApiCallStatus `json:"status" gorm:"index;size:24;index:idx_api_logs_status_created,priority:1"` + StatusCode int `json:"statusCode"` + DurationMs int64 `json:"durationMs"` + PollCount int `json:"pollCount"` + ProviderStatus string `json:"providerStatus,omitempty" gorm:"size:32"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + CachedTokens int64 `json:"cachedTokens"` + UsageAvailable bool `json:"usageAvailable"` + MediaCount int `json:"mediaCount"` + MediaPreviewURL string `json:"mediaPreviewUrl,omitempty" gorm:"-"` + MediaPreviewKind string `json:"mediaPreviewKind,omitempty" gorm:"-"` + VideoSeconds int `json:"videoSeconds"` + ProviderRequestID string `json:"providerRequestId" gorm:"size:160"` + EstimatedCostMicros int64 `json:"estimatedCostMicros"` + CostAvailable bool `json:"costAvailable"` + Currency string `json:"currency" gorm:"size:12"` + ErrorCode string `json:"errorCode,omitempty" gorm:"index;size:80"` + Error string `json:"error"` + ConcurrencyLimit int `json:"concurrencyLimit"` + UpstreamURL string `json:"upstreamUrl"` + RequestContentType string `json:"requestContentType,omitempty" gorm:"size:160"` + RequestBody string `json:"requestBody,omitempty" gorm:"type:text"` + ResponseBody string `json:"responseBody,omitempty" gorm:"type:text"` + StartedAt time.Time `json:"startedAt"` + CreatedAt time.Time `json:"createdAt" gorm:"index;index:idx_api_logs_user_created,priority:2;index:idx_api_logs_channel_created,priority:2;index:idx_api_logs_model_created,priority:2;index:idx_api_logs_status_created,priority:2"` } diff --git a/backend/internal/model/models_finance.go b/backend/internal/model/models_finance.go index c5d7b2e6c..801f0bcab 100644 --- a/backend/internal/model/models_finance.go +++ b/backend/internal/model/models_finance.go @@ -33,12 +33,13 @@ type CreditLedgerEntry struct { } type BillingOrder struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_billing_user_idempotency,priority:1"` - IdempotencyKey string `json:"idempotencyKey" gorm:"size:160;uniqueIndex:idx_billing_user_idempotency,priority:2"` - TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` - ChannelID string `json:"channelId" gorm:"index;size:36"` - ChannelModelID string `json:"channelModelId" gorm:"index;size:36"` + BillingCostSnapshot `json:"-" gorm:"embedded"` + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_billing_user_idempotency,priority:1"` + IdempotencyKey string `json:"idempotencyKey" gorm:"size:160;uniqueIndex:idx_billing_user_idempotency,priority:2"` + TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` + ChannelID string `json:"channelId" gorm:"index;size:36"` + ChannelModelID string `json:"channelModelId" gorm:"index;size:36"` // PriceTierID/Version 记录任务实际命中的规格档;金额字段仍是不可变结算快照。 PriceTierID string `json:"priceTierId,omitempty" gorm:"index;size:36"` PriceTierVersion int64 `json:"priceTierVersion"` @@ -54,26 +55,29 @@ type BillingOrder struct { AmountMicrocredits int64 `json:"amountMicrocredits"` ReservedAmountMicrocredits int64 `json:"reservedAmountMicrocredits"` // ChargeLimitMicrocredits 非零时限制最终用户扣费;Agent 的 Token 报价用它把预授权金额固化为本轮硬上限。 - ChargeLimitMicrocredits int64 `json:"chargeLimitMicrocredits,omitempty"` - ActualAmountMicrocredits int64 `json:"actualAmountMicrocredits"` - RefundedAmountMicrocredits int64 `json:"refundedAmountMicrocredits"` - InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` - OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` - CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` - InputTokens int64 `json:"inputTokens"` - OutputTokens int64 `json:"outputTokens"` - CachedTokens int64 `json:"cachedTokens"` - UsageAvailable bool `json:"usageAvailable"` - Status BillingStatus `json:"status" gorm:"index;size:24"` - ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"` - Error string `json:"error,omitempty" gorm:"size:1000"` - ResolvedBy string `json:"resolvedBy,omitempty" gorm:"index;size:36"` - ResolutionNote string `json:"resolutionNote,omitempty" gorm:"size:500"` - StartedAt *time.Time `json:"startedAt"` - SettledAt *time.Time `json:"settledAt"` - RefundedAt *time.Time `json:"refundedAt"` - CreatedAt time.Time `json:"createdAt" gorm:"index"` - UpdatedAt time.Time `json:"updatedAt"` + ChargeLimitMicrocredits int64 `json:"chargeLimitMicrocredits,omitempty"` + ActualAmountMicrocredits int64 `json:"actualAmountMicrocredits"` + RefundedAmountMicrocredits int64 `json:"refundedAmountMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + CachedTokens int64 `json:"cachedTokens"` + // VideoFormulaTokens 固化提交时的视频公式用量,不含预授权余量;旧订单保持 0,不反推历史用量。 + VideoFormulaTokens int64 `json:"videoFormulaTokens" gorm:"not null;default:0"` + UsageSource string `json:"usageSource,omitempty" gorm:"size:32;not null;default:''"` + UsageAvailable bool `json:"usageAvailable"` + Status BillingStatus `json:"status" gorm:"index;size:24"` + ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"` + Error string `json:"error,omitempty" gorm:"size:1000"` + ResolvedBy string `json:"resolvedBy,omitempty" gorm:"index;size:36"` + ResolutionNote string `json:"resolutionNote,omitempty" gorm:"size:500"` + StartedAt *time.Time `json:"startedAt"` + SettledAt *time.Time `json:"settledAt"` + RefundedAt *time.Time `json:"refundedAt"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + UpdatedAt time.Time `json:"updatedAt"` } type RedeemBatch struct { diff --git a/backend/internal/protocol/docs/autodl-comfyui.md b/backend/internal/protocol/docs/autodl-comfyui.md index 7f7aa20ab..c6516ea0d 100644 --- a/backend/internal/protocol/docs/autodl-comfyui.md +++ b/backend/internal/protocol/docs/autodl-comfyui.md @@ -6,7 +6,7 @@ AutoDL.Art 将 ComfyUI 工作流包装为异步任务接口。创建任务使用 ## 模型 -AutoDL 的模型选择对应 ComfyUI 工作流 ID。不同工作流可以声明不同参数,插件清单中的工作流贡献负责描述参数、默认值和画布能力。当前内置示例使用 `minimax_h3_lightx2v_no_pic`,表示 MiniMax H3 文生视频工作流。后续新增工作流只需增加工作流贡献,不需要修改生成服务代码。 +AutoDL 的模型选择对应 ComfyUI 工作流 ID。不同工作流可以声明不同参数,插件清单中的工作流贡献负责描述参数、默认值和画布能力。当前内置示例使用 `minimax_h3_lightx2v_no_pic`,表示 MiniMax H3 文生视频工作流;另已内置 `minimax_h3_z0901` 文生视频、`minimax_h3_z0902` 六图生视频与 `minimax_h3_z0903` 六图三音频生视频。后续新增工作流只需增加工作流贡献,不需要修改生成服务代码。 ## 参数 diff --git a/backend/internal/protocol/official_catalog_test.go b/backend/internal/protocol/official_catalog_test.go index a7839099c..c949d10a8 100644 --- a/backend/internal/protocol/official_catalog_test.go +++ b/backend/internal/protocol/official_catalog_test.go @@ -441,9 +441,11 @@ func TestNewAPIVideoGenerationsParsesNestedVideoResults(t *testing.T) { tests := []struct { name string payload string + wantURL string }{ - {name: "channel result URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","result_url":"https://cdn.example/channel-result.mp4"}}`}, - {name: "provider nested video URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","data":{"status":"completed","video_url":"https://cdn.example/provider-result.mp4"}}}`}, + {name: "channel result URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","result_url":"https://cdn.example/channel-result.mp4"}}`, wantURL: "https://cdn.example/channel-result.mp4"}, + {name: "provider nested video URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","data":{"status":"completed","video_url":"https://cdn.example/provider-result.mp4"}}}`, wantURL: "https://cdn.example/provider-result.mp4"}, + {name: "provider data array URL", payload: `{"created":1789773326,"data":[{"url":"https://cdn.example/seedance-result.mp4?preview=1"}],"id":"task-upstream","object":"video.generation","status":"completed","usage":{"completion_tokens":108872,"total_tokens":108872}}`, wantURL: "https://cdn.example/seedance-result.mp4?preview=1"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -454,8 +456,8 @@ func TestNewAPIVideoGenerationsParsesNestedVideoResults(t *testing.T) { if state.Status != StatusSucceeded || state.Result == nil || len(state.Result.Videos) != 1 { t.Fatalf("state = %#v, want one completed video", state) } - if state.Result.Videos[0].URL == "" { - t.Fatalf("video = %#v, want a result URL", state.Result.Videos[0]) + if state.Result.Videos[0].URL != test.wantURL { + t.Fatalf("video = %#v, want URL %q", state.Result.Videos[0], test.wantURL) } }) } diff --git a/backend/internal/protocol/payment_artifact_test.go b/backend/internal/protocol/payment_artifact_test.go index 25f1185da..ffa8d7f66 100644 --- a/backend/internal/protocol/payment_artifact_test.go +++ b/backend/internal/protocol/payment_artifact_test.go @@ -16,6 +16,7 @@ var officialPaymentPackageIDs = []string{ "official-payment-wechat-native", "official-payment-alipay-page", "official-payment-xunhupay", + "official-payment-zhifufm", } var officialPaymentTaggedArtifacts = []string{ diff --git a/backend/internal/repository/channel_presentation.go b/backend/internal/repository/channel_presentation.go index 957dbe7e4..974b70a9e 100644 --- a/backend/internal/repository/channel_presentation.go +++ b/backend/internal/repository/channel_presentation.go @@ -8,11 +8,8 @@ import ( "gorm.io/gorm" ) -func (r *Repository) UpdateSystemChannelPresentation(channelID string, alias *string, sortOrder *int, now time.Time) error { +func (r *Repository) UpdateSystemChannelPresentation(channelID string, sortOrder *int, now time.Time) error { updates := map[string]any{"updated_at": now} - if alias != nil { - updates["public_alias"] = *alias - } if sortOrder != nil { updates["sort_order"] = *sortOrder } diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 24f10ddc5..db81f5a3c 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" "gorm.io/gorm" @@ -657,6 +658,29 @@ func billingUsage(db *gorm.DB, orderID string) (*BillingUsage, error) { return &BillingUsage{InputTokens: log.InputTokens, OutputTokens: log.OutputTokens, CachedTokens: log.CachedTokens}, nil } +const ( + billingUsageSourceProvider = "provider" + billingUsageSourceVideoFormula = "video_formula" +) + +func tokenSettlementUsage(db *gorm.DB, order model.BillingOrder) (*BillingUsage, string, error) { + usage, err := billingUsage(db, order.ID) + if err != nil && !errors.Is(err, ErrBillingUsageUnavailable) { + return nil, "", err + } + if order.Capability != "video" { + return usage, billingUsageSourceProvider, err + } + if err == nil && usage.OutputTokens > 0 && usage.InputTokens >= 0 && usage.CachedTokens >= 0 { + return &BillingUsage{OutputTokens: usage.OutputTokens}, billingUsageSourceProvider, nil + } + // 只有提交时明确记录的公式用量可以结算,预授权 Quantity 含余量,不能用作最终用量。 + if order.VideoFormulaTokens > 0 { + return &BillingUsage{OutputTokens: order.VideoFormulaTokens}, billingUsageSourceVideoFormula, nil + } + return usage, billingUsageSourceProvider, err +} + func (r *Repository) RecordBillingResolution(id string, actorUserID string, note string) error { return r.db.Model(&model.BillingOrder{}).Where("id = ?", id).Updates(map[string]any{ "resolved_by": actorUserID, "resolution_note": note, "updated_at": time.Now(), @@ -705,6 +729,7 @@ func (r *Repository) MarkBillingUncertain(id string, errorText string) error { func (r *Repository) SettleBillingOrder(id string, providerRequestID string) error { var observedUsage *BillingUsage + var observedUsageSource string var observedActual int64 observedActualAvailable := false err := r.db.Transaction(func(tx *gorm.DB) error { @@ -719,11 +744,12 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err return errors.New("billing order already refunded") } if order.BillingMode == "token" && !zeroPricedTokenOrder(order) { - usage, err := billingUsage(tx, id) + usage, usageSource, err := tokenSettlementUsage(tx, order) if err != nil { return err } observedUsage = usage + observedUsageSource = usageSource reserved := order.ReservedAmountMicrocredits if reserved <= 0 { reserved = order.AmountMicrocredits @@ -761,7 +787,7 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err updates := map[string]any{"status": model.BillingStatusSettled, "settled_at": &now, "updated_at": now, "actual_amount_microcredits": actual, "refunded_amount_microcredits": refund, "input_tokens": usage.InputTokens, "output_tokens": usage.OutputTokens, "cached_tokens": usage.CachedTokens, - "usage_available": true} + "usage_available": usageSource == billingUsageSourceProvider, "usage_source": usageSource} if providerRequestID != "" { updates["provider_request_id"] = providerRequestID } @@ -774,6 +800,9 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err } else if supplement > 0 { consumeNote = "Token 实际用量超过预授权,已补扣差额" } + if usageSource == billingUsageSourceVideoFormula { + consumeNote = strings.TrimSpace("按提交时的视频 Token 公式快照结算;" + consumeNote) + } if err := tx.Create(&model.CreditLedgerEntry{ID: newRepositoryID(), UserID: order.UserID, Type: model.CreditLedgerConsume, AmountMicrocredits: -actual, AvailableDeltaMicrocredits: -supplement, ReservedDeltaMicrocredits: -reserved, AvailableAfterMicrocredits: account.AvailableMicrocredits, ReservedAfterMicrocredits: account.ReservedMicrocredits, @@ -830,10 +859,11 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err }).Error }) if err != nil && observedUsage != nil { - // usage 是上游已经确认的事实;即使结算因账户状态异常回滚,也要保留给用户和管理员核对。 + // 即使账户状态异常导致回滚,也保留结算依据并标明来源,供用户和管理员核对。 updates := map[string]any{ "input_tokens": observedUsage.InputTokens, "output_tokens": observedUsage.OutputTokens, - "cached_tokens": observedUsage.CachedTokens, "usage_available": true, "updated_at": time.Now(), + "cached_tokens": observedUsage.CachedTokens, "usage_available": observedUsageSource == billingUsageSourceProvider, + "usage_source": observedUsageSource, "updated_at": time.Now(), } if observedActualAvailable { updates["actual_amount_microcredits"] = observedActual @@ -873,12 +903,13 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st actual := order.AmountMicrocredits var usage *BillingUsage + var usageSource string if order.BillingMode == "token" { if zeroPricedTokenOrder(order) { actual = 0 } else { var err error - usage, err = billingUsage(tx, id) + usage, usageSource, err = tokenSettlementUsage(tx, order) if err != nil { return err } @@ -888,6 +919,10 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st } } } + chargeCapped := order.BillingMode == "token" && order.ChargeLimitMicrocredits > 0 && actual > order.ChargeLimitMicrocredits + if chargeCapped { + actual = order.ChargeLimitMicrocredits + } if actual < 0 { return errors.New("invalid restored billing amount") } @@ -927,7 +962,8 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st orderUpdates["input_tokens"] = usage.InputTokens orderUpdates["output_tokens"] = usage.OutputTokens orderUpdates["cached_tokens"] = usage.CachedTokens - orderUpdates["usage_available"] = true + orderUpdates["usage_available"] = usageSource == billingUsageSourceProvider + orderUpdates["usage_source"] = usageSource } orderUpdate := tx.Model(&model.BillingOrder{}). Where("id = ? AND status = ?", order.ID, model.BillingStatusRefunded). @@ -939,6 +975,13 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st return ErrBillingStateConflict } + consumeNote := "人工查询确认上游成功,退款订单重新扣费" + if usageSource == billingUsageSourceVideoFormula { + consumeNote += ";按提交时的视频 Token 公式快照结算" + } + if chargeCapped { + consumeNote += ";已按本轮 Agent 硬上限结算" + } return tx.Create(&model.CreditLedgerEntry{ ID: newRepositoryID(), UserID: order.UserID, @@ -951,7 +994,7 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st Model: order.Model, ChannelID: order.ChannelID, Scene: order.Scene, - Note: "人工查询确认上游成功,退款订单重新扣费", + Note: consumeNote, }).Error }) } @@ -1020,38 +1063,28 @@ func tokenUsageAmount(order model.BillingOrder, usage *BillingUsage) (int64, err if usage == nil { return 0, ErrBillingUsageUnavailable } + if usage.InputTokens < 0 || usage.OutputTokens < 0 || usage.CachedTokens < 0 { + return 0, errors.New("invalid token usage amount") + } if order.Capability == "video" && usage.OutputTokens <= 0 { return 0, ErrBillingUsageUnavailable } - input := usage.InputTokens - usage.CachedTokens - if input < 0 { - input = 0 + terms := []kernel.TokenBillingTerm{ + {Tokens: usage.OutputTokens, PriceMicrocredits: order.OutputTokenPriceMicrocredits}, } - inputAmount, ok := safeTokenUsageProduct(input, order.InputTokenPriceMicrocredits) - if !ok { + // 视频 Token 总用量已由供应商 completion_tokens 表达,不再叠加文本输入和缓存费用。 + if order.Capability != "video" { + input := max(usage.InputTokens-usage.CachedTokens, 0) + terms = append(terms, + kernel.TokenBillingTerm{Tokens: input, PriceMicrocredits: order.InputTokenPriceMicrocredits}, + kernel.TokenBillingTerm{Tokens: usage.CachedTokens, PriceMicrocredits: order.CachedTokenPriceMicrocredits}, + ) + } + amount, err := kernel.TokenBillingAmount(order.MultiplierBasisPoints, terms...) + if err != nil { return 0, errors.New("invalid token usage amount") } - outputAmount, ok := safeTokenUsageProduct(usage.OutputTokens, order.OutputTokenPriceMicrocredits) - if !ok || inputAmount > 1<<63-1-outputAmount { - return 0, errors.New("invalid token usage amount") - } - cachedAmount, ok := safeTokenUsageProduct(usage.CachedTokens, order.CachedTokenPriceMicrocredits) - base := inputAmount + outputAmount - if !ok || base > 1<<63-1-cachedAmount { - return 0, errors.New("invalid token usage amount") - } - base += cachedAmount - if order.MultiplierBasisPoints <= 0 || base > (1<<63-1-9_999_999_999)/order.MultiplierBasisPoints { - return 0, errors.New("invalid token usage amount") - } - return (base*order.MultiplierBasisPoints + 9_999_999_999) / 10_000_000_000, nil -} - -func safeTokenUsageProduct(tokens int64, price int64) (int64, bool) { - if tokens < 0 || price < 0 || (tokens > 0 && price > (1<<63-1)/tokens) { - return 0, false - } - return tokens * price, true + return amount, nil } func (r *Repository) AdjustCredits(userID string, actorUserID string, amount int64, note string) (*model.CreditAccount, error) { diff --git a/backend/internal/repository/finance_token_arithmetic_test.go b/backend/internal/repository/finance_token_arithmetic_test.go new file mode 100644 index 000000000..999407bdf --- /dev/null +++ b/backend/internal/repository/finance_token_arithmetic_test.go @@ -0,0 +1,94 @@ +package repository + +import ( + "math" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestTokenUsageAmountUsesExactArithmetic(t *testing.T) { + for _, test := range []struct { + name string + order model.BillingOrder + usage BillingUsage + want int64 + }{ + { + name: "video maximum price with multiplier ignores text charges", + order: model.BillingOrder{ + Capability: "video", MultiplierBasisPoints: 12_500, + InputTokenPriceMicrocredits: 1_000_000_000_000, OutputTokenPriceMicrocredits: 1_000_000_000_000, + CachedTokenPriceMicrocredits: 1_000_000_000_000, + }, + usage: BillingUsage{InputTokens: math.MaxInt64, OutputTokens: 10_000_000, CachedTokens: math.MaxInt64}, + want: 12_500_000_000_000, + }, + { + name: "text subtracts cache and preserves distinct prices", + order: model.BillingOrder{ + Capability: "text", MultiplierBasisPoints: 12_500, + InputTokenPriceMicrocredits: 500_000_000_000, OutputTokenPriceMicrocredits: 1_000_000_000_000, + CachedTokenPriceMicrocredits: 100_000_000_000, + }, + usage: BillingUsage{InputTokens: 20_000_000, OutputTokens: 10_000_000, CachedTokens: 5_000_000}, + want: 22_500_000_000_000, + }, + { + name: "round once after all text charges", + order: model.BillingOrder{ + Capability: "text", MultiplierBasisPoints: 10_000, + InputTokenPriceMicrocredits: 400_000, OutputTokenPriceMicrocredits: 400_000, CachedTokenPriceMicrocredits: 100_000, + }, + usage: BillingUsage{InputTokens: 2, OutputTokens: 1, CachedTokens: 1}, + want: 1, + }, + { + name: "maximum final amount", + order: model.BillingOrder{Capability: "video", MultiplierBasisPoints: 10_000, OutputTokenPriceMicrocredits: 1_000_000}, + usage: BillingUsage{OutputTokens: math.MaxInt64}, want: math.MaxInt64, + }, + } { + t.Run(test.name, func(t *testing.T) { + amount, err := tokenUsageAmount(test.order, &test.usage) + if err != nil || amount != test.want { + t.Fatalf("tokenUsageAmount() = %d, %v; want %d", amount, err, test.want) + } + }) + } +} + +func TestTokenUsageAmountRejectsInvalidArithmetic(t *testing.T) { + for _, test := range []struct { + name string + order model.BillingOrder + usage BillingUsage + }{ + { + name: "negative output price", + order: model.BillingOrder{Capability: "video", MultiplierBasisPoints: 10_000, OutputTokenPriceMicrocredits: -1}, + usage: BillingUsage{OutputTokens: 1}, + }, + { + name: "negative unused text cache price", + order: model.BillingOrder{Capability: "text", MultiplierBasisPoints: 10_000, CachedTokenPriceMicrocredits: -1}, + usage: BillingUsage{OutputTokens: 1}, + }, + { + name: "zero multiplier", + order: model.BillingOrder{Capability: "video", OutputTokenPriceMicrocredits: 1}, + usage: BillingUsage{OutputTokens: 1}, + }, + { + name: "final overflow", + order: model.BillingOrder{Capability: "video", MultiplierBasisPoints: 10_001, OutputTokenPriceMicrocredits: 1_000_000}, + usage: BillingUsage{OutputTokens: math.MaxInt64}, + }, + } { + t.Run(test.name, func(t *testing.T) { + if amount, err := tokenUsageAmount(test.order, &test.usage); amount != 0 || err == nil { + t.Fatalf("invalid usage billed %d, %v", amount, err) + } + }) + } +} diff --git a/backend/internal/repository/finance_video_formula_test.go b/backend/internal/repository/finance_video_formula_test.go new file mode 100644 index 000000000..f4dc99a1e --- /dev/null +++ b/backend/internal/repository/finance_video_formula_test.go @@ -0,0 +1,183 @@ +package repository + +import ( + "errors" + "strings" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestVideoTokenSettlementChoosesProviderOrFormulaSnapshot(t *testing.T) { + for _, restore := range []bool{false, true} { + mode := "settle" + if restore { + mode = "restore" + } + for _, test := range []struct { + name string + log *model.ApiCallLog + formula int64 + cap int64 + wantTokens int64 + wantAmount int64 + wantSource string + wantError bool + }{ + {name: "missing usage", formula: 108000, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"}, + {name: "zero usage", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true}, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"}, + {name: "negative usage", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: -1}, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"}, + {name: "unavailable usage", formula: 108000, log: &model.ApiCallLog{OutputTokens: 200000}, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"}, + {name: "provider refund", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: 100000, InputTokens: 20, CachedTokens: 10}, wantTokens: 100000, wantAmount: 1600000, wantSource: "provider"}, + {name: "provider supplement", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: 200000}, wantTokens: 200000, wantAmount: 3200000, wantSource: "provider"}, + {name: "provider cap", formula: 108000, cap: 1000000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: 200000}, wantTokens: 200000, wantAmount: 1000000, wantSource: "provider"}, + {name: "formula cap", formula: 108000, cap: 1000000, wantTokens: 108000, wantAmount: 1000000, wantSource: "video_formula"}, + {name: "legacy no usage", wantError: true}, + {name: "invalid snapshot", formula: -1, wantError: true}, + } { + t.Run(mode+"/"+test.name, func(t *testing.T) { + repo, db := newRefundedBillingRecoveryRepository(t) + const available = int64(10000000) + const reserved = int64(1900800) + order := model.BillingOrder{ + ID: "order", UserID: "user", IdempotencyKey: "task", Capability: "video", BillingMode: "token", + Quantity: 118800, VideoFormulaTokens: test.formula, AmountMicrocredits: reserved, + ReservedAmountMicrocredits: reserved, OutputTokenPriceMicrocredits: 16000000, + MultiplierBasisPoints: 10000, ChargeLimitMicrocredits: test.cap, Status: model.BillingStatusRunning, + } + account := model.CreditAccount{UserID: "user", AvailableMicrocredits: available, ReservedMicrocredits: reserved} + settle := repo.SettleBillingOrder + if restore { + settle = repo.RestoreRefundedBillingOrder + order.Status = model.BillingStatusRefunded + order.RefundedAmountMicrocredits = reserved + account.ReservedMicrocredits = 0 + account.AvailableMicrocredits += reserved + } + if err := db.Create(&account).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if test.log != nil { + log := *test.log + log.ID, log.BillingOrderID, log.Status = "log", "order", model.ApiCallStatusSucceeded + if err := db.Create(&log).Error; err != nil { + t.Fatal(err) + } + } + if err := settle("order", "provider-id"); test.wantError { + if !errors.Is(err, ErrBillingUsageUnavailable) { + t.Fatalf("expected unavailable usage, got %v", err) + } + var after model.CreditAccount + if err := db.First(&after, "user_id = ?", "user").Error; err != nil { + t.Fatal(err) + } + if after.AvailableMicrocredits != account.AvailableMicrocredits || after.ReservedMicrocredits != account.ReservedMicrocredits { + t.Fatalf("rejected settlement changed balance: %+v", after) + } + return + } else if err != nil { + t.Fatal(err) + } + if err := settle("order", "provider-id"); err != nil { + t.Fatalf("repeat settlement: %v", err) + } + if err := db.First(&order, "id = ?", "order").Error; err != nil { + t.Fatal(err) + } + if order.Status != model.BillingStatusSettled || order.ActualAmountMicrocredits != test.wantAmount || order.OutputTokens != test.wantTokens || order.UsageSource != test.wantSource || order.UsageAvailable != (test.wantSource == "provider") || order.InputTokens != 0 || order.CachedTokens != 0 { + t.Fatalf("unexpected settled order: %+v", order) + } + if order.VideoFormulaTokens != test.formula || order.Quantity != 118800 || order.RefundedAmountMicrocredits != max(reserved-test.wantAmount, 0) { + t.Fatalf("snapshot or refund changed: %+v", order) + } + if err := db.First(&account, "user_id = ?", "user").Error; err != nil { + t.Fatal(err) + } + if account.AvailableMicrocredits != available+reserved-test.wantAmount || account.ReservedMicrocredits != 0 { + t.Fatalf("unexpected account after settlement: %+v", account) + } + var entries []model.CreditLedgerEntry + if err := db.Where("billing_order_id = ? AND type = ?", "order", model.CreditLedgerConsume).Find(&entries).Error; err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].AmountMicrocredits != -test.wantAmount { + t.Fatalf("settlement not idempotent: %+v", entries) + } + if test.wantSource == "video_formula" && !strings.Contains(entries[0].Note, "公式") { + t.Fatalf("formula source not recorded: %+v", entries[0]) + } + if test.cap > 0 && !strings.Contains(entries[0].Note, "硬上限") { + t.Fatalf("charge cap not recorded: %+v", entries[0]) + } + }) + } + } +} + +func TestVideoTokenSettlementDoesNotHideDatabaseErrors(t *testing.T) { + for _, restore := range []bool{false, true} { + repo, db := newRefundedBillingRecoveryRepository(t) + if err := db.Migrator().DropTable(&model.ApiCallLog{}); err != nil { + t.Fatal(err) + } + order := model.BillingOrder{ID: "order", UserID: "user", IdempotencyKey: "task", Capability: "video", BillingMode: "token", VideoFormulaTokens: 108000, AmountMicrocredits: 1900800, ReservedAmountMicrocredits: 1900800, OutputTokenPriceMicrocredits: 16000000, MultiplierBasisPoints: 10000, Status: model.BillingStatusRunning} + settle := repo.SettleBillingOrder + if restore { + order.Status = model.BillingStatusRefunded + settle = repo.RestoreRefundedBillingOrder + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if err := settle("order", ""); err == nil || errors.Is(err, ErrBillingUsageUnavailable) { + t.Fatalf("database failure was hidden: %v", err) + } + if err := db.First(&order, "id = ?", "order").Error; err != nil { + t.Fatal(err) + } + if order.Status == model.BillingStatusSettled || order.UsageSource != "" || order.ActualAmountMicrocredits != 0 { + t.Fatalf("database failure changed settlement: %+v", order) + } + } +} + +func TestFreeVideoTokenSettlementDoesNotRequireUsage(t *testing.T) { + for _, restore := range []bool{false, true} { + repo, db := newRefundedBillingRecoveryRepository(t) + if err := db.Migrator().DropTable(&model.ApiCallLog{}); err != nil { + t.Fatal(err) + } + order := model.BillingOrder{ID: "order", UserID: "user", IdempotencyKey: "task", Capability: "video", BillingMode: "token", VideoFormulaTokens: 108000, Status: model.BillingStatusRunning} + settle := repo.SettleBillingOrder + if restore { + order.Status = model.BillingStatusRefunded + settle = repo.RestoreRefundedBillingOrder + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.CreditAccount{UserID: "user", AvailableMicrocredits: 123}).Error; err != nil { + t.Fatal(err) + } + if err := settle("order", ""); err != nil { + t.Fatal(err) + } + if err := db.First(&order, "id = ?", "order").Error; err != nil { + t.Fatal(err) + } + if order.Status != model.BillingStatusSettled || order.ActualAmountMicrocredits != 0 || order.UsageAvailable { + t.Fatalf("unexpected free settlement: %+v", order) + } + var account model.CreditAccount + if err := db.First(&account, "user_id = ?", "user").Error; err != nil { + t.Fatal(err) + } + if account.AvailableMicrocredits != 123 || account.ReservedMicrocredits != 0 { + t.Fatalf("free order charged account: %+v", account) + } + } +} diff --git a/backend/internal/repository/finance_video_usage_test.go b/backend/internal/repository/finance_video_usage_test.go new file mode 100644 index 000000000..38736e188 --- /dev/null +++ b/backend/internal/repository/finance_video_usage_test.go @@ -0,0 +1,55 @@ +package repository + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestVideoTokenUsageAmountIgnoresTextCharges(t *testing.T) { + order := model.BillingOrder{ + Capability: "video", InputTokenPriceMicrocredits: 30_000_000, + OutputTokenPriceMicrocredits: 16_000_000, CachedTokenPriceMicrocredits: 20_000_000, + MultiplierBasisPoints: 10_000, + } + usage := &BillingUsage{InputTokens: 1000, OutputTokens: 108900, CachedTokens: 200} + amount, err := tokenUsageAmount(order, usage) + if err != nil || amount != 1_742_400 { + t.Fatalf("video settlement = %d, %v, want 1742400", amount, err) + } + if usage.InputTokens != 1000 || usage.CachedTokens != 200 { + t.Fatalf("settlement mutated observed usage: %#v", usage) + } +} + +func TestVideoTokenUsageAmountRejectsInvalidUsage(t *testing.T) { + order := model.BillingOrder{Capability: "video", OutputTokenPriceMicrocredits: 16_000_000, MultiplierBasisPoints: 10_000} + for _, test := range []struct { + name string + usage *BillingUsage + }{ + {name: "missing"}, + {name: "zero output", usage: &BillingUsage{}}, + {name: "negative output", usage: &BillingUsage{OutputTokens: -1}}, + {name: "negative input", usage: &BillingUsage{InputTokens: -1, OutputTokens: 108900}}, + {name: "negative cached", usage: &BillingUsage{CachedTokens: -1, OutputTokens: 108900}}, + {name: "overflow", usage: &BillingUsage{OutputTokens: 1<<63 - 1}}, + } { + t.Run(test.name, func(t *testing.T) { + if amount, err := tokenUsageAmount(order, test.usage); err == nil { + t.Fatalf("invalid usage settled to %d", amount) + } + }) + } +} + +func TestTextTokenUsageAmountPreservesInputOutputAndCachePrices(t *testing.T) { + amount, err := tokenUsageAmount(model.BillingOrder{ + Capability: "text", InputTokenPriceMicrocredits: 2_000_000, + OutputTokenPriceMicrocredits: 5_000_000, CachedTokenPriceMicrocredits: 1_000_000, + MultiplierBasisPoints: 10_000, + }, &BillingUsage{InputTokens: 100, OutputTokens: 20, CachedTokens: 40}) + if err != nil || amount != 260 { + t.Fatalf("text settlement = %d, %v, want 260", amount, err) + } +} diff --git a/backend/internal/repository/logical_models.go b/backend/internal/repository/logical_models.go index 38353c860..f2955e635 100644 --- a/backend/internal/repository/logical_models.go +++ b/backend/internal/repository/logical_models.go @@ -278,7 +278,7 @@ func (r *Repository) RouteAttempts(taskID string, routeRun int) ([]model.RouteAt // SwitchTaskLogicalRoute 同时更新任务执行目标和账单。跟随供应价格时会原子调整预留积分, // 保证明确未创建上游任务后的故障切线不会沿用上一条线路的价格快照。 -func (r *Repository) SwitchTaskLogicalRoute(taskID string, expectedRouteID string, routeID string, inputJSON string, billingOrderID string, channelID string, channelModelID string, replacement *model.BillingOrder) error { +func (r *Repository) SwitchTaskLogicalRoute(taskID string, expectedRouteID string, routeID string, inputJSON string, billingOrderID string, channelID string, channelModelID string, replacement *model.BillingOrder, cost model.BillingCostSnapshot) error { return r.db.Transaction(func(tx *gorm.DB) error { updated := tx.Model(&model.Task{}). Where("id = ? AND status = ? AND route_id = ?", taskID, model.TaskStatusRunning, expectedRouteID). @@ -298,6 +298,14 @@ func (r *Repository) SwitchTaskLogicalRoute(taskID string, expectedRouteID strin } now := time.Now() updates := map[string]any{"channel_id": channelID, "channel_model_id": channelModelID, "updated_at": now} + updates["cost_configured"] = cost.CostPricing.Configured + updates["cost_unit_price_microcredits"] = cost.CostPricing.UnitPriceMicrocredits + updates["cost_input_token_price_microcredits"] = cost.CostPricing.InputTokenPriceMicrocredits + updates["cost_output_token_price_microcredits"] = cost.CostPricing.OutputTokenPriceMicrocredits + updates["cost_cached_token_price_microcredits"] = cost.CostPricing.CachedTokenPriceMicrocredits + updates["cost_billing_mode"] = cost.CostBillingMode + updates["cost_quantity"] = cost.CostQuantity + updates["cost_video_formula_tokens"] = cost.CostVideoFormulaTokens if replacement != nil { if replacement.UserID != order.UserID || replacement.TaskID != taskID || replacement.AmountMicrocredits <= 0 { return ErrBillingStateConflict diff --git a/backend/internal/repository/logical_models_test.go b/backend/internal/repository/logical_models_test.go index 26b61f011..07f3fffa8 100644 --- a/backend/internal/repository/logical_models_test.go +++ b/backend/internal/repository/logical_models_test.go @@ -11,6 +11,47 @@ import ( "gorm.io/gorm" ) +func TestSwitchTaskLogicalRouteUpdatesCostWithoutChangingUnifiedSales(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Task{}, &model.BillingOrder{}); err != nil { + t.Fatal(err) + } + task := model.Task{ID: "task", UserID: "user", Status: model.TaskStatusRunning, RouteID: "old-route"} + order := model.BillingOrder{ID: "order", TaskID: task.ID, UserID: task.UserID, Status: model.BillingStatusRunning, ChannelID: "old-channel", AmountMicrocredits: 900_000, ReservedAmountMicrocredits: 900_000, UnitPriceMicrocredits: 900_000, + BillingCostSnapshot: model.BillingCostSnapshot{CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 500_000}, CostBillingMode: "fixed_request", CostQuantity: 1}} + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + repo := New(db) + cost := model.BillingCostSnapshot{CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 123_456}, CostBillingMode: "per_second", CostQuantity: 15} + if err := repo.SwitchTaskLogicalRoute(task.ID, "old-route", "new-route", "{}", order.ID, "new-channel", "new-model", nil, cost); err != nil { + t.Fatal(err) + } + var saved model.BillingOrder + if err := db.First(&saved, "id = ?", order.ID).Error; err != nil { + t.Fatal(err) + } + if saved.BillingCostSnapshot != cost || saved.ChannelID != "new-channel" || saved.AmountMicrocredits != 900_000 || saved.ReservedAmountMicrocredits != 900_000 || saved.UnitPriceMicrocredits != 900_000 { + t.Fatalf("wrong route snapshot: %#v", saved) + } + // A route without configured costs must clear the previous route's snapshot. + if err := repo.SwitchTaskLogicalRoute(task.ID, "new-route", "unknown-cost-route", "{}", order.ID, "third-channel", "third-model", nil, model.BillingCostSnapshot{}); err != nil { + t.Fatal(err) + } + if err := db.First(&saved, "id = ?", order.ID).Error; err != nil { + t.Fatal(err) + } + if saved.BillingCostSnapshot != (model.BillingCostSnapshot{}) || saved.AmountMicrocredits != 900_000 { + t.Fatalf("stale cost after route switch: %#v", saved) + } +} + func TestSaveLogicalModelBundleAllocatesMonotonicRevisionSequence(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:logical-model-revision-sequence?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 096cb3f0a..200408307 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -708,7 +708,7 @@ func (r *Repository) AdminSystemChannels(keyword string, status string, limit in query := r.db.Model(&model.ModelChannel{}).Where("scope = ?", model.ChannelScopeSystem) if value := strings.TrimSpace(keyword); value != "" { pattern := "%" + strings.ToLower(value) + "%" - query = query.Where("lower(name) LIKE ? OR lower(public_alias) LIKE ? OR lower(base_url) LIKE ?", pattern, pattern, pattern) + query = query.Where("lower(name) LIKE ? OR lower(base_url) LIKE ?", pattern, pattern) } if status == "enabled" { query = query.Where("enabled = ?", true) diff --git a/backend/internal/service/aliases_consts.go b/backend/internal/service/aliases_consts.go index 042451754..211666984 100644 --- a/backend/internal/service/aliases_consts.go +++ b/backend/internal/service/aliases_consts.go @@ -43,7 +43,6 @@ const ( FeatureSystemPlugins = app.FeatureSystemPlugins FeatureTaskCenter = app.FeatureTaskCenter FeatureTimelineTranscription = app.FeatureTimelineTranscription - ModelCatalogSourceFrontend = app.ModelCatalogSourceFrontend ModelCatalogSourceSystem = app.ModelCatalogSourceSystem PaymentPluginAlipayPage = app.PaymentPluginAlipayPage PaymentPluginWeChatNative = app.PaymentPluginWeChatNative diff --git a/backend/internal/service/aliases_types.go b/backend/internal/service/aliases_types.go index 7ee247630..75c5c8e05 100644 --- a/backend/internal/service/aliases_types.go +++ b/backend/internal/service/aliases_types.go @@ -68,6 +68,8 @@ type ( AnnouncementPage = app.AnnouncementPage AppError = app.AppError AppearanceSetting = app.AppearanceSetting + AppearanceSkinButtonFill = app.AppearanceSkinButtonFill + AppearanceSkinButtons = app.AppearanceSkinButtons AppearanceSkinComponentTokens = app.AppearanceSkinComponentTokens AppearanceSkinModeTokens = app.AppearanceSkinModeTokens AppearanceSkinTheme = app.AppearanceSkinTheme @@ -96,6 +98,7 @@ type ( ChannelModelCatalogOptions = app.ChannelModelCatalogOptions ChannelModelPriceTierRequest = app.ChannelModelPriceTierRequest ChannelModelRequest = app.ChannelModelRequest + ChannelModelQuoteRequest = app.ChannelModelQuoteRequest ChannelModelSortRequest = app.ChannelModelSortRequest ChannelModelsRequest = app.ChannelModelsRequest ChannelOrderItem = app.ChannelOrderItem diff --git a/backend/payment-plugins/aliases.go b/backend/payment-plugins/aliases.go index 6ddecb1f3..674ab8914 100644 --- a/backend/payment-plugins/aliases.go +++ b/backend/payment-plugins/aliases.go @@ -25,7 +25,9 @@ const ( ProviderWeChatNative = "wechat-native" ProviderAlipayPage = "alipay-page-pay" ProviderXunHuPay = "xunhupay-aggregate" + ProviderZhiFuFM = "zhifufm-pay" PluginWeChatNative = "official-payment-wechat-native" PluginAlipayPage = "official-payment-alipay-page" PluginXunHuPay = "official-payment-xunhupay" + PluginZhiFuFM = "official-payment-zhifufm" ) diff --git a/backend/payment-plugins/zhifufm.go b/backend/payment-plugins/zhifufm.go new file mode 100644 index 000000000..4856caec3 --- /dev/null +++ b/backend/payment-plugins/zhifufm.go @@ -0,0 +1,291 @@ +package paymentplugins + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + defaultZhiFuFMGateway = "https://api-5a0zvlcbi80.zhifu.fm.it88168.com/api" + zhifuFMStartOrderPath = "/startOrder" + zhifuFMQRLifetime = 10 * time.Minute +) + +type ZhiFuFMProvider struct { + client *http.Client + now func() time.Time +} + +func NewZhiFuFMProvider(client *http.Client) *ZhiFuFMProvider { + if client == nil { + client = http.DefaultClient + } + return &ZhiFuFMProvider{client: client, now: time.Now} +} + +func (p *ZhiFuFMProvider) Descriptor() Descriptor { + return Descriptor{ + ID: ProviderZhiFuFM, PluginID: PluginZhiFuFM, PluginVersion: "1.0.0", + Name: "支付FM", Icon: "assets/icon.svg", CheckoutMode: "redirect", + IdentityFields: []string{"merchantNum"}, + NotificationSuccess: NotificationResponse{Status: 200, ContentType: "text/plain; charset=utf-8", Body: "success"}, + NotificationFailure: NotificationResponse{Status: 400, ContentType: "text/plain; charset=utf-8", Body: "fail"}, + } +} + +func (p *ZhiFuFMProvider) ValidateConfig(config Config) error { + if strings.TrimSpace(config["merchantNum"]) == "" { + return errors.New("支付FM配置缺少商户号 merchantNum") + } + if strings.TrimSpace(config["secretKey"]) == "" { + return errors.New("支付FM配置缺少接入密钥 secretKey") + } + mode := zhifuFMCheckoutMode(config) + if mode != "qr_code" && mode != "redirect" { + return errors.New("支付FM checkoutMode 必须是 qr_code 或 redirect") + } + gateway := zhifuFMGateway(config) + if _, err := url.Parse(gateway); err != nil || (!strings.HasPrefix(gateway, "http://") && !strings.HasPrefix(gateway, "https://")) { + return fmt.Errorf("支付FM网关地址无效: %s", gateway) + } + return nil +} + +type zhifuFMStartOrderResponse struct { + Success bool `json:"success"` + Code int `json:"code"` + Msg string `json:"msg"` + Timestamp int64 `json:"timestamp"` + Data struct { + ID string `json:"id"` + PayURL string `json:"payUrl"` + } `json:"data"` +} + +func (p *ZhiFuFMProvider) CreateOrder(ctx context.Context, config Config, request CreateRequest) (Checkout, error) { + if err := p.ValidateConfig(config); err != nil { + return Checkout{}, err + } + if request.AmountFen <= 0 || request.Currency != "CNY" || request.MerchantOrderNo == "" || request.NotifyURL == "" { + return Checkout{}, errors.New("支付FM下单参数无效") + } + + merchantNum := strings.TrimSpace(config["merchantNum"]) + secretKey := strings.TrimSpace(config["secretKey"]) + amountYuan := fmt.Sprintf("%.2f", float64(request.AmountFen)/100.0) + + // sign = md5(商户号 + 商户订单号 + 支付金额 + 异步通知地址 + 接入密钥) + signRaw := merchantNum + request.MerchantOrderNo + amountYuan + request.NotifyURL + secretKey + sign := md5Hex(signRaw) + + payType := strings.TrimSpace(config["payType"]) + if payType == "" { + payType = "alipay" + } + + form := url.Values{} + form.Set("merchantNum", merchantNum) + form.Set("orderNo", request.MerchantOrderNo) + form.Set("amount", amountYuan) + form.Set("notifyUrl", request.NotifyURL) + form.Set("payType", payType) + form.Set("sign", sign) + form.Set("returnType", "json") + form.Set("apiMode", "post_form") + if request.ReturnURL != "" { + form.Set("returnUrl", request.ReturnURL) + } + if request.Description != "" { + form.Set("subject", request.Description) + } + + gateway := zhifuFMGateway(config) + endpoint := strings.TrimSuffix(gateway, "/") + zhifuFMStartOrderPath + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return Checkout{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := p.client.Do(req) + if err != nil { + return Checkout{}, &ProviderError{Code: "zhifufm_transport_error", Message: "支付FM网络请求失败", Temporary: true, Cause: err} + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + return Checkout{}, &ProviderError{Code: "zhifufm_response_read_error", Message: "读取支付FM响应失败", Temporary: true, Cause: err} + } + + var res zhifuFMStartOrderResponse + if err := json.Unmarshal(body, &res); err != nil { + return Checkout{}, &ProviderError{Code: "zhifufm_response_parse_error", Message: fmt.Sprintf("解析支付FM响应失败 (HTTP %d): %s", resp.StatusCode, string(body)), Temporary: true, Cause: err} + } + + if !res.Success || res.Code != 200 || res.Data.PayURL == "" { + msg := res.Msg + if msg == "" { + msg = fmt.Sprintf("创建订单失败(code=%d)", res.Code) + } + return Checkout{}, &ProviderError{Code: "zhifufm_order_create_failed", Message: fmt.Sprintf("支付FM创建订单失败: %s", msg)} + } + + mode := zhifuFMCheckoutMode(config) + expiresAt := request.ExpiresAt + if expiresAt.IsZero() { + expiresAt = p.now().Add(zhifuFMQRLifetime) + } + + return Checkout{ + Mode: mode, + Value: res.Data.PayURL, + ExpiresAt: expiresAt, + }, nil +} + +func (p *ZhiFuFMProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) { + return Result{ + MerchantOrderNo: request.MerchantOrderNo, + Currency: "CNY", + }, nil +} + +func (p *ZhiFuFMProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) { + return Result{ + MerchantOrderNo: request.MerchantOrderNo, + Closed: true, + ProviderStatus: "CLOSED", + Currency: "CNY", + }, nil +} + +func (p *ZhiFuFMProvider) VerifyNotification(_ context.Context, config Config, headers http.Header, rawBody []byte) (Notification, error) { + if err := p.ValidateConfig(config); err != nil { + return Notification{}, err + } + + params, err := parseNotificationValues(rawBody) + if err != nil { + return Notification{}, err + } + + merchantNum := strings.TrimSpace(params.Get("merchantNum")) + orderNo := strings.TrimSpace(params.Get("orderNo")) + amountStr := strings.TrimSpace(params.Get("amount")) + state := strings.TrimSpace(params.Get("state")) + platformOrderNo := strings.TrimSpace(params.Get("platformOrderNo")) + sign := strings.TrimSpace(params.Get("sign")) + + if merchantNum != strings.TrimSpace(config["merchantNum"]) { + return Notification{}, errors.New("支付FM异步通知商户号不匹配") + } + + // 验签: sign = md5(state + merchantNum + orderNo + amount + secretKey) + expectedSign := md5Hex(state + merchantNum + orderNo + amountStr + strings.TrimSpace(config["secretKey"])) + if !strings.EqualFold(sign, expectedSign) { + return Notification{}, fmt.Errorf("支付FM异步通知签名不匹配: got %s, want %s", sign, expectedSign) + } + + actualAmountStr := strings.TrimSpace(params.Get("actualPayAmount")) + targetAmountStr := amountStr + if actualAmountStr != "" { + targetAmountStr = actualAmountStr + } + + amountFen, err := parseYuanToFen(targetAmountStr) + if err != nil { + amountFen, err = parseYuanToFen(amountStr) + if err != nil { + return Notification{}, fmt.Errorf("支付FM异步通知金额格式错误: %s", amountStr) + } + } + + paid := state == "1" + paidAt := time.Now() + if payTimeStr := strings.TrimSpace(params.Get("payTime")); payTimeStr != "" { + if t, err := time.ParseInLocation("2006-01-02 15:04:05", payTimeStr, time.Local); err == nil { + paidAt = t + } + } + + eventID := platformOrderNo + if eventID == "" { + eventID = orderNo + ":" + state + } + + return Notification{ + EventID: eventID, + Result: Result{ + MerchantOrderNo: orderNo, + ProviderTradeNo: platformOrderNo, + ProviderStatus: state, + AmountFen: amountFen, + Currency: "CNY", + Paid: paid, + Closed: !paid, + PaidAt: paidAt, + }, + }, nil +} + +func (p *ZhiFuFMProvider) DownloadTradeBill(context.Context, Config, time.Time) ([]BillRecord, error) { + return nil, fmt.Errorf("%w: 支付FM未提供交易账单下载接口", ErrTradeBillNotFound) +} + +func zhifuFMGateway(config Config) string { + g := strings.TrimSpace(config["gateway"]) + if g == "" { + return defaultZhiFuFMGateway + } + return strings.TrimSuffix(g, "/") +} + +func zhifuFMCheckoutMode(config Config) string { + mode := strings.TrimSpace(config["checkoutMode"]) + if mode == "qr_code" { + return "qr_code" + } + return "redirect" +} + +func md5Hex(s string) string { + h := md5.Sum([]byte(s)) + return hex.EncodeToString(h[:]) +} + +func parseNotificationValues(rawBody []byte) (url.Values, error) { + bodyStr := string(rawBody) + if strings.Contains(bodyStr, "=") { + return url.ParseQuery(bodyStr) + } + var jsonMap map[string]any + if err := json.Unmarshal(rawBody, &jsonMap); err == nil { + vals := url.Values{} + for k, v := range jsonMap { + switch val := v.(type) { + case string: + vals.Set(k, val) + case float64: + vals.Set(k, strconv.FormatFloat(val, 'f', -1, 64)) + case int64: + vals.Set(k, strconv.FormatInt(val, 10)) + default: + vals.Set(k, fmt.Sprint(v)) + } + } + return vals, nil + } + return nil, errors.New("无法解析支付通知内容") +} diff --git a/backend/payment-plugins/zhifufm_test.go b/backend/payment-plugins/zhifufm_test.go new file mode 100644 index 000000000..fd5aa7d74 --- /dev/null +++ b/backend/payment-plugins/zhifufm_test.go @@ -0,0 +1,83 @@ +package paymentplugins + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestZhiFuFMProviderCreateOrderAndVerifyNotification(t *testing.T) { + merchantNum := "694771317639364608" + secretKey := "f17bc85d99c1d2b4b4d81eaba1489c7e" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/startOrder" { + http.NotFound(w, r) + return + } + body, _ := io.ReadAll(r.Body) + vals, _ := parseNotificationValues(body) + if vals.Get("merchantNum") != merchantNum { + t.Errorf("got merchantNum = %q, want %q", vals.Get("merchantNum"), merchantNum) + } + expectedSign := md5Hex(merchantNum + vals.Get("orderNo") + vals.Get("amount") + vals.Get("notifyUrl") + secretKey) + if vals.Get("sign") != expectedSign { + t.Errorf("got sign = %q, want %q", vals.Get("sign"), expectedSign) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "success": true, + "code": 200, + "msg": "success", + "timestamp": 1720000000000, + "data": { + "id": "ZFM202609180001", + "payUrl": "https://page.zhifu.fm/pay?id=ZFM202609180001" + } + }`)) + })) + defer server.Close() + + provider := NewZhiFuFMProvider(server.Client()) + config := Config{ + "merchantNum": merchantNum, + "secretKey": secretKey, + "gateway": server.URL, + "checkoutMode": "redirect", + } + + checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{ + MerchantOrderNo: "TEST_ORDER_12345", + Description: "充值 1000 积分", + AmountFen: 1000, // 10.00 元 + Currency: "CNY", + NotifyURL: "https://my.domain/api/payments/notify", + ExpiresAt: time.Now().Add(10 * time.Minute), + }) + if err != nil { + t.Fatalf("CreateOrder failed: %v", err) + } + if checkout.Mode != "redirect" || checkout.Value != "https://page.zhifu.fm/pay?id=ZFM202609180001" { + t.Fatalf("unexpected checkout: %#v", checkout) + } + + // 模拟异步通知回调 + // sign = md5(state + merchantNum + orderNo + amount + secretKey) + state := "1" + orderNo := "TEST_ORDER_12345" + amount := "10.00" + notifySign := md5Hex(state + merchantNum + orderNo + amount + secretKey) + notifyForm := "merchantNum=" + merchantNum + "&orderNo=" + orderNo + "&amount=" + amount + "&state=" + state + "&platformOrderNo=ZFM202609180001&payTime=2026-09-18+12:00:00&sign=" + notifySign + + notification, err := provider.VerifyNotification(context.Background(), config, nil, []byte(notifyForm)) + if err != nil { + t.Fatalf("VerifyNotification failed: %v", err) + } + if !notification.Paid || notification.AmountFen != 1000 || notification.MerchantOrderNo != "TEST_ORDER_12345" { + t.Fatalf("unexpected notification: %#v", notification) + } +} diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index d7d47c4ad..cfe4d3fe3 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -1,8 +1,6 @@ services: postgres: image: postgres:17-alpine - security_opt: - - seccomp:unconfined environment: POSTGRES_DB: ${POSTGRES_DB:-open_ai_canvas} POSTGRES_USER: ${POSTGRES_USER:-open_ai_canvas} diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 4ff03adb9..fbddaa34b 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -23,7 +23,7 @@ description: 影策后端核心持久化表与状态边界 容器内命令 `migrate-schema up` 应用缺失迁移,`migrate-schema status` 输出当前和期望版本,`migrate-schema verify` 只检查兼容性。`CANVAS_AUTO_MIGRATE=true` 仅用于开发和旧部署兼容;受管生产部署设置为 `false`,并由 Compose 的一次性 `migrate` 服务先完成迁移。后续迁移必须新增版本,不得修改已经发布迁移的实现或校验和。 -当前结构版本为 23。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置;版本 10 `creation_runtime` 创建创作运行与提交表,并为任务增加可空且唯一的 `creation_submission_id`,旧任务无需伪造提交标识即可继续使用;版本 11 `cloud_agent_runtime` 创建 `cloud_agent_executions`;版本 12 `agent_token_charge_limit` 为账单增加 Agent Token 收费上限;版本 13 `cloud_agent_canvas_mutation` 持久化 Agent 画布变更;版本 14 `cloud_agent_recovery_control` 增加运行恢复控制字段和旧运行回填;版本 15 `agent_profiles` 创建常驻偏好表;版本 16 `agent_lessons` 创建用户记忆表(含分类、路线 JSON、注入/查阅计数);版本 17 `agent_lessons_owner_index` 为 `(author_user_id, status)` 增加复合索引;版本 18 `agent_memory_settings` 创建按用户隔离的压缩周期与最近一次压缩任务状态;版本 19 `payment_plugin_version` 为已有 `payment_provider_configs` 和 `payment_orders` 补上 `plugin_version`,用于把配置和订单钉在具体插件版本上;版本 20 `banner_announcements` 创建首页常驻滚动通知表;版本 21 `banner_announcement_title_runs` 为通知标题增加样式分段;版本 22 `banner_announcement_notice_type` 增加通知类型底色;版本 23 `canvas_revision_history` 为既有画布回填 `revision = 1` 并新增历史快照与资源引用表。已发布版本的实现和校验和不得修改。 +当前结构版本为 27。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置;版本 10 `creation_runtime` 创建创作运行与提交表,并为任务增加可空且唯一的 `creation_submission_id`,旧任务无需伪造提交标识即可继续使用;版本 11 `cloud_agent_runtime` 创建 `cloud_agent_executions`;版本 12 `agent_token_charge_limit` 为账单增加 Agent Token 收费上限;版本 13 `cloud_agent_canvas_mutation` 持久化 Agent 画布变更;版本 14 `cloud_agent_recovery_control` 增加运行恢复控制字段和旧运行回填;版本 15 `agent_profiles` 创建常驻偏好表;版本 16 `agent_lessons` 创建用户记忆表(含分类、路线 JSON、注入/查阅计数);版本 17 `agent_lessons_owner_index` 为 `(author_user_id, status)` 增加复合索引;版本 18 `agent_memory_settings` 创建按用户隔离的压缩周期与最近一次压缩任务状态;版本 19 `payment_plugin_version` 为已有 `payment_provider_configs` 和 `payment_orders` 补上 `plugin_version`,用于把配置和订单钉在具体插件版本上;版本 20 `banner_announcements` 创建首页常驻滚动通知表;版本 21 `banner_announcement_title_runs` 为通知标题增加样式分段;版本 22 `banner_announcement_notice_type` 增加通知类型底色;版本 23 `canvas_revision_history` 为既有画布回填 `revision = 1` 并新增历史快照与资源引用表。版本 24 `channel_model_label` 为渠道模型增加 `channel_label`(最长 80 字、非空、默认空字符串),不重写模型标识、上游 ID、价格或能力;版本 25 `video_token_formula_snapshot` 为账单增加视频公式用量及用量来源快照。版本 26 `channel_model_description` 为渠道模型增加 `description`(最长 500 字、非空、默认空字符串),供二级选项展示使用说明,不重写身份或价格。版本 27 `channel_credit_cost` 为价格档添加管理员积分成本,并在账单保存执行线路的成本快照;成本不进入普通用户 API。已发布版本的实现和校验和不得修改。 历史发布曾使用版本 6 `asset_library_folders`,之后合并引入了上述 6/7 顺序冲突。迁移器仅在该历史记录的名称和校验和均精确匹配时选择历史顺序:保留版本 6 的素材目录记录及时间,在版本 7 应用 `resource_playback_variant`,再继续版本 8。新数据库及已采用“6 播放副本、7 素材目录”的数据库保持原顺序;状态检查与启动校验采用同一套识别规则。未知名称、被改动的校验和或冲突的版本 7 记录仍会拒绝迁移,不通过删除、改名或伪造已应用记录绕过检查。 @@ -86,6 +86,8 @@ docker compose -f docker-compose.deploy.yml up -d backend web ## 平台外观配置 +当前外观 JSON 合同版本为 8,主题参数新增 `skinThemes[].tokens.buttons.light/dark`:填充模式、角度、常态/悬停/按下双色和文字色。经典主题默认为紫蓝渐变,其余旧主题缺少此块时保持纯色;公开 `activeSkin` 使用相同结构。无表结构迁移,读取补齐只在内存执行,管理员保存时写回。回退应用版本前保留外观 JSON 备份,旧版本不支持新按钮填充参数。 + 平台外观复用既有 `system_settings` 表,不新增结构迁移。键 `appearance` 的 JSON 值使用版本化合同;版本 3 保存品牌名称、英文品牌标识、登录页主标题与可选说明文案、浅色与可选深色 Logo 资源 ID、Logo 外框开关、登录页视频资源 ID、可选封面资源 ID 和预留皮肤 ID。只配置一套 Logo 时,公开投影会在浅色和深色主题复用同一资源;旧版本缺少深色 Logo 与外框字段时分别回退到单 Logo 和启用外框,不改变既有视觉。资源元数据和文件仍分别由 `resources` 表与当前资源存储承担。恢复默认外观时删除这条设置记录并回到内置值,不级联删除已经上传的资源。 公开接口只投影品牌名、英文品牌标识、登录页公开文案、深浅主题使用的稳定 slot 资源 URL、Logo 外框状态、配置状态和修订标识,不返回内部资源 ID、对象路径或存储凭据。管理员保存新配置前,服务层会校验文本长度与安全字符、资源为就绪状态、类型与 slot 匹配,且新绑定资源由当前管理员上传;两套 Logo、视频和封面中任何正在使用的资源都会进入管理员删除保护。品牌变更同时写入 `admin_audit_events`。 @@ -186,10 +188,16 @@ docker compose -f docker-compose.deploy.yml up -d backend web | 表 | 用途 | 关键边界 | | --- | --- | --- | -| `model_channels` | 保存系统渠道与用户渠道的连接配置,以及系统渠道前台别名和排序 | 系统渠道密钥不进入公开目录;公开名称优先使用 `public_alias`,为空时使用原名;按 `sort_order, created_at, id` 升序展示 | -| `channel_models` | 保存系统渠道中的产品模型标识、上游模型 ID、显示名称、模型 Logo、排序、能力、协议和版本状态 | 模型排序独立保存,不递增价格或能力版本;能力、协议、启用状态和价格仍由 service 强校验 | +| `model_channels` | 保存系统渠道与用户渠道的连接配置和排序 | 系统渠道密钥不进入公开目录;废弃渠道级前台名称,历史 `public_alias` 列保留但不再用于取名或 API 返回;按 `sort_order, created_at, id` 升序展示 | +| `channel_models` | 保存系统渠道中的调用标识、上游模型 ID、模型展示名、渠道展示名、描述、Logo、排序、能力、协议和版本状态 | 创作目录跨渠道按 `display_name` 分组一级,`channel_label` 为二级、最长 80 字、空值使用所属渠道名称;`description` 最长 500 字、非空、默认空字符串;同一渠道内调用标识唯一,跨渠道归组不依赖调用标识;模型排序不递增价格或能力版本 | | `channel_model_price_tiers` | 保存系统模型按规格匹配的上游 SKU 与价格 | 价格档与渠道模型同事务保存;未启用或未完成定价的规格不进入普通用户目录。档位 `provider_model_key` 非空时优先于渠道模型上游键发往供应商;管理端修改模型级上游 ID 时,与旧值相同的档位键自动级联跟随,其余显式 SKU 保持不变 | -| `logical_models` | 保存前台模型的公开名称、Logo、能力类型和归档状态 | 前台模型与系统渠道模型分别维护展示信息;供应线路通过 revision 和 route 关联;`code` 仅在 `archived_at IS NULL` 的活动记录中唯一,归档后可用同 code 重建目录项 | +| `logical_models` | 保存逻辑模型的名称、Logo、能力类型和归档状态 | 不再作为创作端模型选择目录的数据源;既有任务与供应线路仍通过 revision 和 route 关联;`code` 仅在 `archived_at IS NULL` 的活动记录中唯一 | + +schema v27 为 `channel_model_price_tiers` 和 `billing_orders` 添加 `cost_configured`(非空、默认 false)及 `cost_unit_price_microcredits`、`cost_input_token_price_microcredits`、`cost_output_token_price_microcredits`、`cost_cached_token_price_microcredits`(非空整数、默认 0)。账单另外保存 `cost_billing_mode`(默认空字符串)、`cost_quantity`、`cost_video_formula_tokens`(默认 0),构成下单时不可变成本快照。请求日志成本从内部账单快照计算,非持久字段 `creditCostMicrocredits` 仅供管理端投影;旧订单不反查新价格,不回填假零成本。已有售价、余额和结算金额不变。 + +部署需前后端与 schema v27 一起升级;关闭自动迁移时执行 `migrate-schema up`、`migrate-schema verify`。迁移只新增列,不删除旧数据,可重复校验;没有降级迁移,回滚使用匹配的数据库备份和程序版本,旧程序不记录成本快照。 + +所有视频协议开放 Token 定价。`channel_model_price_tiers.selector_json` 支持 `videoGenerateAudio: "true" | "false"`,省略表示任意音频;参考视频仍匹配 `operation: "video_to_video"`。视频仅使用输出 Token 单价,输入和缓存价必须为 0,明确配置的零价表示免费。schema v25 为 `billing_orders` 新增 `video_formula_tokens`(非空、默认 0,提交时固化不含余量的公式用量)及 `usage_source`(非空、默认空字符串,`provider` 或 `video_formula`)。缺少有效上游用量的新视频订单按公式快照结算并记录 `usage_available=false`,不伪造上游用量;历史快照为 0 的订单保持旧规则。价格档、费率、倍率、预留金额和既有账单不被迁移重算。前后端和 schema 一起升级,旧程序不支持新公式结算及非 Ark Token 配置,回滚需匹配数据库备份与二进制版本。完整算法见[视频 Token 计费](/docs/backend/video-token-billing)。 图片分档尺寸保存在现有模型能力 JSON 的 `image.size.presets` 中,不新增表、列或 SQL 迁移。每项结构为 `{ tier, ratio, size, width, height }`,例如 `{ "tier": "1k", "ratio": "16:9", "size": "1824x1024", "width": 1824, "height": 1024 }`。 diff --git a/docs/content/docs/backend/code-map.mdx b/docs/content/docs/backend/code-map.mdx index d4e4beafb..1a4a57332 100644 --- a/docs/content/docs/backend/code-map.mdx +++ b/docs/content/docs/backend/code-map.mdx @@ -17,7 +17,9 @@ description: 从产品功能快速定位主要代码入口 | 创作工作台 | `web/src/pages/create/index.tsx` | `creation-workspace.tsx`、`creation-conversations.ts`、`creation-types.ts`、`creation-assets.ts`、`creation-references.ts` | | 登录注册 | `web/src/pages/auth/` | `stores/use-user-store.ts`、`services/api/auth.ts`、后端 `internal/auth/` | | 积分与计费 | `web/src/components/layout/workspace-wallet-modal.tsx`、`pages/admin/components/platform-finance-panel.tsx` | `services/api/wallet.ts`、`services/api/payments.ts`、后端 `internal/app/finance.go`、`internal/app/payment.go`、`payment-plugins/`、`plugin-packages/official-payment-*` | +| 视频 Token 报价与结算 | `web/src/pages/admin/components/channel-model-price-tier-fields.tsx`、`web/src/components/model-picker.tsx` | `lib/model-pricing.ts`、`services/api/logical-models.ts`;后端 `app/video_token_billing.go` 管像素与时长估算,`app/logical_model_quote.go` 复用准入与价格规则,`app/analytics.go` 提取可信用量,`repository/finance.go` 按订单快照退补差额 | | 任务中心 | `web/src/pages/tasks/` | `services/api/task-center.ts`、`services/api/task-text-stream.ts` | +| 产品模型与渠道选择 | `web/src/components/model-picker.tsx`、`pages/admin/components/channel-model-editor.tsx` | `lib/model-picker-groups.ts` 只构建跨渠道展示分组;`lib/user-session.ts` 透传渠道展示名;`components/model-logo.tsx` 按需加载原色图标;后端 `app/channel_models.go` 校验写入,`app/model_catalog.go` 发布目录,schema v24 增加 `channel_label` | | 我的素材 | `web/src/pages/assets/` | `stores/use-asset-store.ts`、`services/image-storage.ts` | | 技能库 | `web/src/pages/skills/` | `services/api/skills.ts`、`services/skill-runtime.ts`、`components/skills/skill-runtime-picker.tsx` | | 画布列表 | `web/src/pages/canvas/index.tsx` | `stores/canvas/use-canvas-store.ts` | diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index d94cfbf40..5a252536a 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -9,6 +9,41 @@ description: 信封、错误码与 OpenAPI 机器可读规范:运行中的 `GET /api/openapi.yaml`,源文件 `backend/internal/handler/openapi.yaml`。前端 TypeScript 仍走 `web/src/services/api` 的 `http`,不从 OpenAPI 生成客户端。 +## 站点皮肤主按钮 + +`GET /public/appearance` 返回当前 `activeSkin`;管理员通过 `GET/PUT /admin/settings/appearance` 读取、保存完整主题库。外观合同版本 8 新增 `tokens.buttons.light/dark`,字段为 `mode`(`solid|gradient`)、整数 `angle`(0–360)、HEX 色值 `start/end`、`hoverStart/hoverEnd`、`activeStart/activeEnd`、`foreground`。纯色模式使用既有主操作颜色,渐变仅用于主按钮。非法模式、角度、色值或修改锁定经典主题均拒绝保存;整块缺失的旧配置按主题身份补齐。此变更不增加数据表迁移。 + +## 模型规格报价 + +`GET /model-catalog` 固定返回 `source=system`,直接查询所有启用系统渠道下的渠道模型,目录数据在 `channels[].models[]`,`models` 固定为空数组。不受逻辑模型开关影响,不反查逻辑模型或供应线路。每项的 `modelKey` 是调用标识,`displayName` 是模型展示名,`icon` 是模型图标键,`channelLabel` 是可选的渠道展示名(默认空字符串)。后台渠道模型创建与更新请求使用相同的 `channelLabel` 字段;服务端去除首尾空白并限制为 80 字,空字符串表示使用所属渠道名称。 + +渠道模型创建、更新、管理列表、公开目录和会话模型价格信息均使用 `description` 表达模型描述。服务端去除首尾空白后限制为 500 字,允许空字符串清除描述;无管理员权限或超长内容拒绝保存。创作端二级选项在悬浮、聚焦或选中时展示该字段,长内容可悬浮查看完整文本;未填写的系统模型不按名称生成宣传文案。描述区域保留固定行高,悬浮不改变菜单项尺寸。 + +创作端跨渠道按模型 `displayName` 分组为一级菜单,二级菜单使用每条模型的 `channelLabel`,例如 `MiniMax H3 → 秘塔(满血渠道)`。选中值继续使用 `channelId::modelKey`,报价和生成请求保留该渠道身份。展示字段不参与路由、权限及计费。系统渠道的“前台名称/前台显示别名”已移除,管理 API 不再接受或返回 `publicAlias`。 + +同一产品在不同渠道中使用相同的模型展示名即可归组,`modelKey` 可以不同;相同 `modelKey` 但展示名不同会分为不同组。一级顺序与图标按后台渠道和模型排序首次出现的位置确定,二级保持后台渠道与模型顺序。即使二级展示名相同,也保留独立选项及各自能力、价格,不自动改写历史配置或交换两个展示字段。 + +以下接口需要登录 Cookie,返回 `data.quote`,仅校验和报价,不创建任务、账单或冻结余额,不调用供应商生成接口。 + +| 接口 | 请求 | +| --- | --- | +| `POST /model-catalog/quote` | `{ channelId, modelKey, intent }`,仅允许启用的系统渠道与可用模型,复用任务准入、默认参数和价格档选择 | +| `POST /models/:id/quote` | 直接提交 `intent`,按前台逻辑模型当前可用线路和价格策略报价 | + +`intent` 包含 `capability`(`text/image/video/audio`)、`operation`、`inputs`(素材类型到数量的映射)和 `options`(请求规格)。素材数量必须为 0–128 的整数,再按模型能力限制校验。视频规格包括 `vquality`、`size`、`videoSeconds`、布尔值 `videoGenerateAudio`,显式 `false` 必须保留。 + +报价包含 `logicalModelId`(系统渠道直连为空)、`billingMode`、`quantity`、`amountMicrocredits`、`estimated`。视频 Token 报价另有 `videoTokenEstimate`:`formulaTokens`、`reservedTokens`、`outputWidth`、`outputHeight`、`framesPerSecond`、`outputSeconds`、`referenceSeconds`、`referenceDurationEstimated`、`dimensionsEstimated`、`reservationMarginPercent`。 + +Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考素材数量不包含时长信息,带参考视频时统一按 15 秒估算并返回 `referenceDurationEstimated: true`。所有视频协议均支持 Token 定价;10% 预留是平台策略,不是费用上限。成功后优先有效上游用量,无有效用量时按提交的公式快照结算,余量不计入公式费用。账单新增 `videoFormulaTokens` 及 `usageSource`(`provider` / `video_formula`),公式结算的 `usageAvailable` 为 `false`,`outputTokens` 表示公式数量。算法、配置与异常用量处理见[视频 Token 计费](/docs/backend/video-token-billing)。 + +## 管理员积分成本 + +`GET/POST /admin/channels/:id/models` 与 `PATCH /admin/channels/:id/models/:modelId` 的 `priceTiers[]` 增加 `costPricing`:`configured`、`unitPriceMicrocredits`、`inputTokenPriceMicrocredits`、`outputTokenPriceMicrocredits`、`cachedTokenPriceMicrocredits`。成本沿用该价格档的计费方式;按次/秒使用单位价,文本 Token 按输入/输出/缓存分别定价,视频 Token 只使用输出价。价格为整数微积分,范围为 0 到 1000000000000;`configured=false` 表示未配置,`true` 且价格为 0 表示显式零成本。 + +成本保存到下单时的内部账单快照,不受售价倍率或扣费上限影响,后续改价不重算旧订单。历史逻辑模型任务切换备用线路时,成本更新为实际执行线路的快照,独立于用户售价策略。管理员请求列表、详情与 CSV 同时展示销售金额及成本金额;JSON 增加 `creditCostConfigured` 和可选 `creditCostMicrocredits`。成功结算后按实际计费数量、Token 用量或视频公式快照核算;未结算、已退回、历史无快照或缺少有效用量时不编造成本金额。 + +成本字段仅通过经过管理员权限校验的接口显式返回;普通用户的模型目录、会话模型信息、报价、任务与账单 JSON 均不包含成本配置、成本快照或成本金额。底层价格档和账单成本字段禁止 JSON 序列化,不以页面隐藏代替接口隔离。 + ## 画布保存、历史与恢复 画布内容使用数据库递增的 `revision` 做乐观并发校验;`updatedAt` 仅用于展示,不作为版本依据。新建 `PUT /canvas-projects/:id` 的 `project.revision` 为 0,已有画布必须带上读取到的版本。数据库在同一次条件更新中匹配 ID、用户与版本,成功后返回 `data.project` 摘要及递增后的版本;缺失版本返回 428,陈旧版本返回 409,非法版本返回 400。客户端不能获取新版号后直接重发旧内容。 diff --git a/docs/content/docs/backend/meta.json b/docs/content/docs/backend/meta.json index 3f3b87feb..8ad5a2da0 100644 --- a/docs/content/docs/backend/meta.json +++ b/docs/content/docs/backend/meta.json @@ -7,6 +7,7 @@ "http-api", "code-map", "backend-database", + "video-token-billing", "task-runtime-stability", "newapi-channel-2", "system-update", diff --git a/docs/content/docs/backend/video-token-billing.mdx b/docs/content/docs/backend/video-token-billing.mdx new file mode 100644 index 000000000..ff0e92b86 --- /dev/null +++ b/docs/content/docs/backend/video-token-billing.mdx @@ -0,0 +1,65 @@ +--- +title: 视频 Token 计费 +description: 所有视频模型共用火山式公式定价、预留与用量结算 +--- + +# 视频 Token 计费 + +所有系统视频模型均支持 `billingMode: "token"`,不设协议白名单,包括 `newapi-channel-2`、Ark、Agent Plan 和自定义视频协议。当前统一采用火山式像素帧公式:有有效上游用量时优先采用上游数值;没有有效用量时按提交时保存的公式 Token 数结算。图片和音频仍不开放 Token 定价。 + +## 管理员配置 + +视频 Token 单价单位是「积分 / 百万视频 Token」,保存为每百万 Token 的微积分价格。输入价与缓存价必须为 0,输出价允许为 0(明确免费),最大为 1,000,000 积分。平台不自动导入人民币报价,也不自动调整已有模型价格。 + +同一模型可以按生成方式、分辨率、时长、参考图数量、是否生成音频配置价格档。`selector.videoGenerateAudio` 为字符串 `"true"` 或 `"false"`;不限音频时省略该条件。参考视频使用已有 `operation: "video_to_video"`,不另加参考视频布尔字段。显式关闭音频的 `false` 参与匹配,不能当作未填写。 + +前台逻辑模型沿用渠道价时使用实际命中的规格价格;统一定价时使用逻辑模型的费率。报价和任务预留均按所选线路的上游模型 ID 识别 Seedance 代际,内部展示别名不改变像素表。 + +## 提交前估算与预留 + +参考[火山引擎模型价格](https://www.volcengine.com/docs/82379/1544106)与[视频生成说明](https://www.volcengine.com/docs/82379/2298881),平台统一按 24 FPS 为所有视频模型估算: + +```text +公式 Token = ceil((参考视频总秒数 + 输出视频秒数) × 输出宽 × 输出高 × 24 ÷ 1024) +预留 Token = ceil(公式 Token × 1.10) +预留微积分 = ceil(预留 Token × 每百万 Token 微积分单价 × 平台倍率 ÷ 1,000,000) +``` + +10% 是平台预留策略,不属于火山引擎公式,也不保证覆盖最终费用。Seedance 2.x 参考视频有与清晰度、画幅及生成时长相关的最低消耗,供应商没有统一的闭式估算公式;实际费用可能超过预留。 + +像素表按模型代际区分,不将「720p」直接当成固定面积,也不因为模型名含 `fast` 就强制降为 720p。例如: + +| 模型与规格 | 预估输出像素 | +| --- | --- | +| Seedance 1.0,720p,16:9 | 1248 × 704 | +| Seedance 1.5 / 2.0,720p,16:9 | 1280 × 720 | +| Seedance 2.5,480p,16:9 | 854 × 480 | +| Seedance 2.0,4K,4:3 | 3326 × 2494 | + +完整表在 `backend/internal/app/video_token_billing.go`。自适应画幅使用该清晰度下最大的已知面积;上游型号无法识别代际时使用请求画幅下已知代际的最大面积,并标注尺寸为估算值。`size` 为明确的 `宽x高` 时直接使用像素;其他正数分辨率按短边和画幅推导,长边向上对齐偶数,2K 按 1440p、4K 按 2160p。缺少尺寸配置时平台计价约定为 720p、16:9,并标记尺寸估算;通用分辨率的自适应画幅按 16:9 推导。这些是平台计价规则,不保证上游输出相同像素。无效时长、非法尺寸和溢出仍拒绝,实际可生成规格仍由模型能力校验。 + +参考视频数量和时长限制由所选模型能力校验,通用计费公式不再强制套用 Ark 的 3 个、15 秒限制。任务包含时长元数据时按毫秒合计;时长缺失时公式采用已知时长与 15 秒中的较大值并标注估算。报价接口只接收素材数量,因此参考视频按 15 秒估算;任务创建时的公式快照可因实际元数据而不同。负时长、小数毫秒或整数溢出均拒绝。 + +例如,无参考视频的 Seedance 2.0、720p、16:9、5 秒:公式为 108,000 Token,平台预留 118,800 Token。若管理员配置 16 积分 / 百万 Token、平台倍率为 1,预留 1.9008 积分。16 只是计算示例,不是内置售价。 + +## 成功后的结算 + +Ark 协议读取[查询视频任务接口](https://www.volcengine.com/docs/82379/1521309)返回的 `usage.completion_tokens`,字段缺少时接受 `total_tokens`。其他视频协议按 `completion_tokens`、`output_tokens`、`total_tokens` 的顺序读取;优先字段存在但无效时不改用后续字段。视频不另收文本输入或缓存费用。0、负数、小数、字符串、空值、溢出和非成功状态的用量不视为有效上游用量,转为公式快照结算;不从输入量或不明用量字段推断视频消耗。 + +```text +实际微积分 = ceil(实际视频 Token × 订单快照中的单价 × 订单倍率 ÷ 1,000,000) +``` + +账单保存提交时的价格档、单价、倍率、预留量以及不含 10% 余量的 `videoFormulaTokens`。有效上游用量优先结算,记录 `usageSource: "provider"`、`usageAvailable: true`;否则新视频订单采用公式快照,记录 `usageSource: "video_formula"`、`usageAvailable: false`,`outputTokens` 为公式数,账本说明标明公式结算,不伪造上游调用日志。以上 5 秒示例缺少上游用量时结算 1.728 积分,退回预留差额 0.1728 积分。 + +较少的费用退回差额,较多的费用沿用现有补扣机制;Agent 硬预算上限继续生效。失败任务沿用退款或待核对规则,不因存在公式快照就自动收费。没有公式快照的历史收费订单仍要求有效上游用量,不从含预留余量的数量反推历史费用。免费档可零金额完成。数据库读取或实际金额计算出错时仍明确报错,不把错误当成缺少用量。 + +预估与结算共用 `internal/kernel/token_billing.go` 的整数金额算法,汇总 Token 费用、应用倍率后只对最终微积分向上取整,避免合法高单价因中间乘积溢出而被拒绝;最终金额超出 `int64` 范围时明确失败。 + +普通用户在模型选择器、画布生成入口与短剧生产页看到当前参数的「预计」金额,可查看公式 Token、预留余量以及估算边界。参数变更后重新报价;报价本身不创建账单、不冻结积分、不请求上游生成。 + +## 发布与验证 + +前后端需一起发布,并执行 schema v25 `video_token_formula_snapshot`:在 `billing_orders` 增加 `video_formula_tokens`(默认 0)与 `usage_source`(默认空字符串),不重算历史订单、不改既有模型售价。关闭自动迁移的部署先执行新版本 `migrate-schema up`、`migrate-schema verify`,然后启动后端。回退旧二进制需匹配数据库备份和 schema 版本,不能手工伪造迁移记录;旧程序也不支持新音频 selector 和非 Ark 视频 Token 配置。本次仅实现并测试迁移,没有操作真实账号数据库。 + +本地测试覆盖模型像素、公式取整、参考时长、音频分档、免费档、渠道与逻辑模型报价、任务预留一致性,以及真实用量的退差、补扣和非法值拒绝。真实账号页面及真实上游账单仍需按[待测试清单](/docs/progress/pending-test)验收。 diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 6f0c20aa3..e1d1704b4 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -93,6 +93,10 @@ description: 当前项目已实现的核心工作台能力与使用入口 ## 系统渠道排序与前台别名 +系统渠道模型选择器直接读取渠道下的模型列表:一级跨渠道按“模型展示名”分组并显示原色 Logo,二级显示该模型的“渠道展示名”及规格售价;留空使用所属渠道名称。模型调用标识不必相同,也不反查逻辑模型。系统渠道的“前台名称”已移除。调用保留用户选中的具体渠道,收起后显示“模型 · 渠道”,不因另一渠道更便宜而自动切换。图标优先使用图标库的原色版本,缺少彩色版本时使用单色版本;缺少配置时显示通用图标,不猜测产品品牌。 + +渠道模型价格档支持管理员单独配置积分成本价;后台请求记录同时显示销售金额和成本金额。成本按下单快照计算,不追随之后改价;普通用户页面及接口不返回成本字段。 + 后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。渠道行的“更多”菜单支持复制渠道,复制会生成独立的渠道、模型和价格档,并保留原连接配置与凭证。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。 系统渠道可设置可选的前台别名;为空时显示渠道名,设置后公开目录和用户会话只返回别名,后台仍保留原名。别名最长 80 字,支持后台搜索。前台模型目录模式保留其独立配置。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 84bd13ab0..f14df479a 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,67 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 模型展示名归组与管理员积分成本 + +创作目录固定直接读取系统渠道模型,一级按模型展示名跨渠道分组,二级按各模型的渠道展示名展示;删除渠道级“前台名称”,不反查逻辑模型。成本配置限定在管理员价格档编辑,按下单快照核算,后台请求记录与 CSV 显示销售价、成本价;普通用户目录、报价和账单不返回成本字段。 + +- [x] 前端生产构建(含类型检查)与 35 项专项通过,覆盖跨渠道展示名分组、独立调用身份、成本回填与微积分换算。后端数据库、仓储、HTTP handler 全套回归通过;全包编译与成本/目录专项通过,覆盖管理员权限、用户接口字段隔离、历史快照、精确用量核算、迁移幂等和备用线路成本切换。 +- [ ] 在真实后台对调历史填反的模型展示名/渠道展示名,确认 MiniMax H3 一级下显示秘塔(满血渠道);同名模型跨渠道归组,不同调用 ID 保持独立。 +- [ ] 在 PostgreSQL 部署验证 schema v27,编辑按次、按秒、Token 成本后刷新回填;生成成功后核对销售金额与成本金额,改价不得影响旧订单。 +- [ ] 用普通用户登录检查网络响应,确认没有成本配置、快照或成本金额;对管理员成本接口的读写均应被拒绝。 + +## 画布删除不再预加载关联素材 + +登录后的画布删除直接按 ID 请求服务端,不再先进入编辑加载流程,避免历史素材的 MIME 等字段校验阻塞删除。保留账号会话检查、服务端删除失败上抛及成功后清理本地缓存的顺序。 + +- [x] 26 项画布同步与素材合同专项测试通过;新增回归覆盖关联素材为 `image/*`、未缓存画布、重复 ID,以及批量删除部分失败后缓存与模拟服务端状态一致。 +- [ ] 在真实账号下从画布列表和编辑页删除包含异常 MIME 素材的画布,刷新确认已删除;自动化验证使用模拟 API,未删除实际账号数据。 + +## 站点皮肤、控件焦点与外观分类 + +主按钮填充改为主题参数统一管理,经典黑白采用黑白基础色与 115° 紫蓝渐变;复制主题后可分别编辑明暗模式的纯色/渐变、角度、常态/悬停/按下双色和文字色。创作页“开始创作”与前后台主按钮使用相同参数,危险、幽灵和禁用按钮不套用渐变。后台隔离用户工作台外壳与浮层样式。输入、选择、日期和搜索控件移除重复焦点阴影,保留键盘焦点与错误状态。站点及外观按品牌、登录页、欢迎页、SEO、页尾备案、皮肤六个 Tab 展示,页面草稿统一保存,欢迎页开关继续独立即时保存。 + +- [x] 前端生产构建、后续类型检查及 9 项主题/渐变专项通过,覆盖主按钮状态、纯色切换清理、复制隔离和非法参数降级。后端外观专项通过,覆盖保存/读取/公开投影、旧数据补齐、非法参数拒绝及经典主题保护。外观 JSON 升为版本 8,无表结构迁移。 +- [x] 隔离浏览器在真实外观路由验证跨 Tab 草稿保留、撤销、校验定位、隐藏视频卸载,以及明暗模式下经典/自定义按钮常态和悬停颜色、输入框无阴影、键盘焦点可见及 390px 无横向溢出。会话与配置使用测试状态,未保存真实站点设置。 +- [x] 渐变扩展后,真实创作页和插件页验证经典主按钮明暗配色;后台编辑器验证纯色/渐变切换、角度修改、跨 Tab 草稿保留、撤销后保存按钮禁用、危险按钮不套用渐变。修复旧配置归一化后误报未保存状态;选择框键盘导航使用可见外框。浏览器 API 为隔离测试配置,持久化由后端临时 SQLite 服务测试验证。 +- [ ] 用真实管理账号保存主题并刷新前后台,确认常态、悬停、按下、禁用、危险操作和浮层控件配色一致;验证媒体文件跨 Tab 选择后保存与刷新回填。 +- [ ] 渐变相关三文件回归为 16 通过、1 失败,失败为原有后台旧警告色别名断言。上一轮完整相关回归还发现模型编辑器网格 CSS 顺序、首屏内置 Logo 引用、BrandLogo 旧主题 hook 三项原有失败;未修改这些无关实现。 + +## 模型描述与稳定悬浮 + +后台渠道模型编辑的“模型身份”区新增“模型描述”,公开目录和会话配置透传到二级选项。说明行预留固定高度,一级菜单取消悬浮位移,二级选项取消高度动画;选中、悬浮及键盘聚焦仍显示说明。系统模型未填写描述时不生成名称推测文案。 + +- [x] 前端 21 项专项测试及含类型检查的生产构建通过;后端保存/公开投影/描述长度专项、数据库包全套迁移回归及接口路由/OpenAPI 冒烟通过。 +- [x] 隔离浏览器加载真实选择器,连续六次切换悬浮的位置和尺寸一致;已配置/空描述、聚焦显示、渠道选择、键盘、明暗图标及 390px 无横向溢出通过。真实编辑组件验证描述回填、修改后提交去空白文本、清空后提交空字符串;接口被测试拦截,未写真实模型。 +- [ ] 在真实管理账号下保存、重新打开并清空描述,刷新创作端确认内容同步;在画布与创作页确认长描述、明暗主题与键盘选择。 +- [ ] PostgreSQL 部署验证 schema v26。此次迁移只添加默认空字符串列;关闭自动迁移时先执行 `migrate-schema up` 和 `migrate-schema verify`。不提供降级迁移,回退使用匹配的数据库备份与二进制版本;未操作实际部署数据库。 + +## 产品模型分组与渠道展示名 + +系统渠道目录按模型展示名分组,二级展示独立渠道名称和价格;“渠道展示名”留空使用所属渠道名称。选中值仍为渠道 ID 与模型调用标识,个人渠道保持原选择合同。模型 Logo 按需加载原色版本,移除菜单位置染色。 + +- [x] 前端专项覆盖同产品多个渠道、价格透传、名称变化不改调用身份、能力隔离、排序时元数据稳定,以及逻辑模型和个人渠道边界。 +- [x] 后端 SQLite 专项覆盖字段保存、公开目录及会话投影、长度与管理员校验;版本 24 迁移重复执行保留旧记录的身份和价格。 +- [x] 前端构建与类型检查通过;隔离浏览器在现有应用中加载真实选择器组件,验证 3 个渠道及 0.3/0.3/0.2 积分单价、鼠标和键盘选中身份、Esc 关闭、390px 无横向溢出,以及明暗主题下图标原色保持一致。使用测试配置,未修改真实渠道。 +- [x] 数据库包全套回归通过;旧迁移测试夹具补齐基线应有的 `billing_orders`,覆盖 v24、v25 连续升级。 +- [ ] 使用实际配置确认跨渠道的产品模型标识、展示名和 Logo 一致,填写各渠道展示名后验证画布、创作页与 Agent 模型入口。 +- [ ] 在 PostgreSQL 部署执行版本 24 迁移并验证目录;迁移只新增默认空字符串列,不自动规范化现有产品模型标识。关闭自动迁移的部署先运行 `migrate-schema up` 与 `migrate-schema verify`;没有降级迁移,回退需匹配数据库备份与二进制版本。 + +## 全协议视频 Token 计费闭环 + +所有视频协议开放 Token 价格,包含 NewAPI 通道 2;系统渠道和前台逻辑模型共用报价与火山式 24 FPS 公式。平台额外预留 10%,成功后优先有效上游用量,无有效用量时按提交的公式快照结算并退回余量,账单标注公式来源。schema v25 新增公式快照和来源列,不重算历史订单。方案与回滚边界见[视频 Token 计费](/docs/backend/video-token-billing)。 + +- [x] 后端专项覆盖官方公式、不同代际像素、4K、参考时长非法值、音频分档、免费价、真实用量严格解析、账单退差和补扣。 +- [x] SQLite 集成测试覆盖系统渠道报价与任务预留一致、两种逻辑模型价格策略、报价无账户/账本/任务写入、不可用渠道拒绝与非法素材数量拒绝。 +- [x] 收尾回归覆盖内部模型别名下的报价/预留一致、统一价格保存强校验,以及最高合法单价、倍率、最终金额溢出和汇总后一次取整;预估与结算共用精确金额算法。 +- [x] 前端专项覆盖表单保存/回填、音频 false 匹配、Token 单价摘要、新旧报价接口与预估边界文案。 +- [x] NewAPI 通道 2、NewAPI 视频及自定义视频的 SQLite 集成覆盖报价、预留、无上游用量公式结算、退回 10% 余量和重复结算幂等;v25 迁移重复执行保留历史账单。 +- [x] 最新后端 app/repository/database 视频计费专项、数据库包全套回归通过;前端 41 项计费测试、`bun run build` 和文档 `bun run types:check` 通过。未执行真实收费生成或真实数据库迁移。 +- [ ] 登录管理端配置有声/无声及参考视频价格档,在模型菜单、画布与短剧生产页变更时长/清晰度/音频,确认当前参数报价更新,切换免费档显示 0。 +- [ ] 用真实方舟与 Agent Plan 渠道完成视频任务,核对上游 `completion_tokens`、账单实际金额及退补差额;参考视频最低消耗可能高于公式预估,确认用户能查看预估边界说明。 +- [ ] 使用 Artbox 的 NewAPI 通道 2 保存“视频 Token”,完成无用量视频任务后确认公式结算、10% 余量退回、来源标注;重复轮询/回调不重复扣费。 +- [ ] PostgreSQL 部署验证 schema v25;新代码未启动或未迁移时不要用旧后端保存非 Ark Token 价格。历史无公式快照的收费订单缺少用量仍进入待核对。 + ## Agent 历史事件重放导致画布反复冲突 旧会话的画布增量可能缺少版本号。页面刷新重放事件时,原实现先标记冲突;后续完整快照即使确认内容一致,也提前返回而不清除冲突。现在增量拒绝只触发快照校准,由完整快照确认真实冲突;内容一致时更新同步基线并恢复已同步状态。云端仍为已核对的同一基线时,保留待保存的本地编辑,不误判为并发修改。真实冲突继续保留草稿并阻止覆盖,不重放无版本增量,不修改历史数据。 diff --git a/docs/design/site-appearance-and-skins.mdx b/docs/design/site-appearance-and-skins.mdx index d4261c955..5c03c91c5 100644 --- a/docs/design/site-appearance-and-skins.mdx +++ b/docs/design/site-appearance-and-skins.mdx @@ -2,7 +2,7 @@ ## 1. 目标与边界 -本模块让部署方在不修改源码的前提下管理站点身份、登录页内容、搜索摘要、首页收尾信息与全站皮肤。默认 `classic` 皮肤必须等同项目既有样式;自定义品牌不得在首次渲染、邮件、诊断包、素材插件或后台说明中短暂或持续露出内置品牌。 +本模块让部署方在不修改源码的前提下管理站点身份、登录页内容、搜索摘要、首页收尾信息与全站皮肤。默认 `classic` 皮肤采用黑白基础配色与紫蓝渐变主按钮;自定义品牌不得在首次渲染、邮件、诊断包、素材插件或后台说明中短暂或持续露出内置品牌。 技术兼容标识不属于品牌文案:仓库名、容器/升级器路径、剪贴板协议、localStorage key、插件 API version、数据库基线校验值和 User-Agent 可以继续使用 `open-ai-canvas` 或 `infinite-canvas`。英文品牌标识只用于新资源路径建议,不能自动改写已有对象存储前缀或历史资源。 @@ -17,7 +17,7 @@ | 首页收尾 | 版权信息、备案开关、备案号 | 备案号只有在显式开启且非空时显示,并链接工信部备案系统 | | 皮肤 | `skinId`、`skinThemes` | 选择当前皮肤并维护主题库;每套同时定义亮色、暗色与组件参数 | -配置继续存储在 `system_settings.appearance` JSON 中,schema version 为 6,不增加关系型数据库迁移。旧配置缺失 SEO、版权、备案、主题库、独立开关状态或破坏性操作交互色时,运行时会按原有主色、控件色和危险色向后补齐;只有管理员保存后才写回数据库。管理接口返回完整 `skinThemes`,公开接口只返回当前 `activeSkin`,避免将未启用的运营草稿暴露给前台。 +配置继续存储在 `system_settings.appearance` JSON 中,schema version 为 8,不增加关系型数据库迁移。旧配置缺失 SEO、版权、备案、主题库、独立开关状态或破坏性操作交互色时,运行时会按原有主色、控件色和危险色向后补齐;只有管理员保存后才写回数据库。管理接口返回完整 `skinThemes`,公开接口只返回当前 `activeSkin`,避免将未启用的运营草稿暴露给前台。 ## 3. 皮肤架构 @@ -28,7 +28,11 @@ 3. Component:按钮、输入、卡片、弹层、菜单与复选框圆角,以及控件高度、描边、焦点环、图标、字重、悬停抬升、动效和阴影。 4. Adapter:将同一份语义令牌同时映射到 CSS Custom Properties 和 Ant Design 全局/组件 token。 -皮肤合同位于 `backend/internal/service/appearance_skins.go`,前端映射位于 `web/src/lib/skin-themes.ts`。页面不得按皮肤 ID 写条件样式;业务组件只能消费语义 token。`classic` 在运行时不写覆盖变量,使既有 `globals.css` 与 `app-theme.ts` 保持原样式唯一事实源;服务端会逐字段比对经典主题,任何删除或伪造修改都会被拒绝。其他主题均为可编辑数据,用根节点 CSS 变量覆盖自研组件语义层,并向 Ant Design `ConfigProvider` 提供同一套颜色、交互状态、圆角、尺寸、动效和阴影值。 +皮肤合同位于 `backend/internal/app/appearance_skins.go`,前端映射位于 `web/src/lib/skin-themes.ts`。页面不得按皮肤 ID 写条件样式;业务组件只能消费语义 token。`classic` 只注入主按钮填充变量,基础表面继续由 `globals.css` 与 `app-theme.ts` 提供;服务端会逐字段比对经典主题,任何删除或伪造修改都会被拒绝。其他主题均为可编辑数据,用根节点 CSS 变量覆盖自研组件语义层,并向 Ant Design `ConfigProvider` 提供同一套颜色、交互状态、圆角、尺寸、动效和阴影值。 + +`tokens.buttons.light/dark` 分别配置主按钮填充:`mode` 为 `solid` 或 `gradient`,`angle` 为 0–360 的整数;`start/end`、`hoverStart/hoverEnd`、`activeStart/activeEnd` 和 `foreground` 为 HEX 色值。纯色模式消费原有 `primary/primaryHover/primaryActive/primaryForeground`,渐变模式使用独立参数,不改变链接、选中态、开关或危险操作。经典默认角度为 115°,常态 `#6554df → #386fbc`,悬停 `#5744cf → #356bbb`,按下 `#4938b8 → #2c5da5`,文字为白色。后台可复制经典主题后调整两种模式的全部参数。 + +旧数据整块缺少 `buttons` 时,经典主题补入默认渐变,其余主题保留纯色;局部缺失或非法写入被拒绝。读取补齐不自动写库,保存后写入版本 8;回退旧版本前应备份外观 JSON,旧版本不会呈现新渐变配置。 主题库初始包含: @@ -73,7 +77,9 @@ SEO 标题建议控制在 70 字符内,描述 200 字符内,关键词 300 ## 6. 后台交互合同 -后台入口名称为“站点及外观”,继续复用系统设置页的页面框架、状态操作栏、`SettingsSectionCard`、Ant Form/Input/Switch 与统一保存模型。五个顺序固定为:品牌识别、登录页内容与媒体、SEO 信息、首页收尾与备案、皮肤主题。主题编辑器由主题库、身份编辑、浅/深模式切换、实时控件预览、语义颜色分组和组件参数分组组成;默认主题展示锁定原因,自定义主题显示复制与删除动作。 +后台入口名称为“站点及外观”,继续复用系统设置页的页面框架、状态操作栏、`SettingsSectionCard`、Ant Form/Input/Switch 与统一保存模型。内容按六个 Tab 展示:品牌识别、登录页、欢迎页、SEO 信息、页尾与备案、皮肤主题。切换 Tab 保留页面草稿与待上传文件;统一保存校验失败时切回问题字段所在分类。欢迎页开关沿用独立接口,切换后立即保存,其余分类共用保存、撤销和未保存离开保护。主题编辑器由主题库、身份编辑、浅/深模式切换、实时控件预览、语义颜色分组和组件参数分组组成;默认主题展示锁定原因,自定义主题显示复制与删除动作。 + +用户工作台和后台的主按钮通过公共 Ant Design 主题及 `primary-buttons.css` 适配器消费皮肤参数,创作页复合按钮使用相同的 `--button-primary-*` 填充变量;页面不再硬编码渐变颜色。危险、幽灵和禁用按钮不套用渐变。后台路由不挂载用户工作台外壳和浮层样式;后台保留独立表面与控件密度。输入、数字输入、选择和日期控件关闭焦点阴影,按钮关闭点击波纹;键盘导航仍保留可见焦点,校验错误状态保留。 所有调整先形成页面草稿;只有点击“保存修改”才上传新资源并提交配置。保存响应成为新基线。离开、刷新覆盖和恢复内置品牌需要沿用现有未保存保护;恢复操作不删除已经上传的存储资源。皮肤卡必须可键盘操作,并以 radio/`aria-checked` 语义暴露选中状态。原生取色器只在点击颜色方块本身时打开;点击字段名称、说明或 HEX 输入不会代理触发系统取色器。取色器与 HEX 输入共享同一字段,非法中间值允许留在草稿中便于继续编辑,但前端保存校验和后端权威校验都会阻止其生效。 diff --git a/plugin-packages/autodl-comfyui.yingce-plugin b/plugin-packages/autodl-comfyui.yingce-plugin index ed6a7155c..8af8ce60d 100644 Binary files a/plugin-packages/autodl-comfyui.yingce-plugin and b/plugin-packages/autodl-comfyui.yingce-plugin differ diff --git a/plugin-packages/build-packages.sh b/plugin-packages/build-packages.sh index 5ece7f3e9..b6d486373 100755 --- a/plugin-packages/build-packages.sh +++ b/plugin-packages/build-packages.sh @@ -3,7 +3,7 @@ set -eu root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) repo_dir=$(CDPATH= cd -- "$root_dir/.." && pwd) -payment_plugins="official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay" +payment_plugins="official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay official-payment-zhifufm" payments_only=false default_payment_targets="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64" @@ -118,6 +118,7 @@ for target in $payment_targets; do build_payment_provider official-payment-wechat-native ./cmd/payment-wechat "$target_goos" "$target_goarch" build_payment_provider official-payment-alipay-page ./cmd/payment-alipay "$target_goos" "$target_goarch" build_payment_provider official-payment-xunhupay ./cmd/payment-xunhupay "$target_goos" "$target_goarch" + build_payment_provider official-payment-zhifufm ./cmd/payment-zhifufm "$target_goos" "$target_goarch" done package_plugin() { diff --git a/plugin-packages/newapi-video-generations-v1.yingce-plugin b/plugin-packages/newapi-video-generations-v1.yingce-plugin index a66e26a90..ca2eace89 100644 Binary files a/plugin-packages/newapi-video-generations-v1.yingce-plugin and b/plugin-packages/newapi-video-generations-v1.yingce-plugin differ diff --git a/plugin-packages/newapi-video-generations-v1/docs/interface.md b/plugin-packages/newapi-video-generations-v1/docs/interface.md index 34d20c7c6..4ee4c7153 100644 --- a/plugin-packages/newapi-video-generations-v1/docs/interface.md +++ b/plugin-packages/newapi-video-generations-v1/docs/interface.md @@ -67,7 +67,7 @@ | `response.taskId` | `{"$coalesce":[{"$ref":"response.data.task_id"},{"$ref":"response.data.taskId"},{"$ref":"response.task_id"},{"$ref":"response.taskId"},{"$ref":"response.data.id"},{"$ref":"response.id"},{"$ref":"taskId"}]}` | | `response.status` | `{"$coalesce":[{"$ref":"response.status"},{"$ref":"response.state"},{"$ref":"response.data.status"},"pending"]}` | | `response.message` | `{"$coalesce":[{"$ref":"response.error.message"},{"$ref":"response.message"},{"$ref":"response.fail_reason"}]}` | -| `response.videos` | `{"$coalesce":[{"$ref":"response.data.result_url"},{"$ref":"response.data.video_url"},{"$ref":"response.data.output_url"},{"$ref":"response.data.url"},{"$ref":"response.data.metadata.url"},{"$ref":"response.data.data.video_url"},{"$ref":"response.data.data.output_url"},{"$ref":"response.data.data.result_url"},{"$ref":"response.data.data.url"},{"$ref":"response.data.data.metadata.url"},{"$ref":"response.video_url"},{"$ref":"response.videoUrl"},{"$ref":"response.result_url"},{"$ref":"response.output_url"},{"$ref":"response.url"},{"$ref":"response.metadata.url"},{"$ref":"response.output.url"}]}` | +| `response.videos` | `{"$coalesce":[{"$map":{"from":{"$ref":"response.data"},"as":"item","in":{"$coalesce":[{"$ref":"item.url"},{"$ref":"item.video_url"},{"$ref":"item.videoUrl"},{"$ref":"item.output_url"},{"$ref":"item.outputUrl"}]}}},{"$ref":"response.data.result_url"},{"$ref":"response.data.video_url"},{"$ref":"response.data.output_url"},{"$ref":"response.data.url"},{"$ref":"response.data.metadata.url"},{"$ref":"response.data.data.video_url"},{"$ref":"response.data.data.output_url"},{"$ref":"response.data.data.result_url"},{"$ref":"response.data.data.url"},{"$ref":"response.data.data.metadata.url"},{"$ref":"response.video_url"},{"$ref":"response.videoUrl"},{"$ref":"response.result_url"},{"$ref":"response.output_url"},{"$ref":"response.url"},{"$ref":"response.metadata.url"},{"$ref":"response.output.url"}]}` | | `response.errorPaths[0]` | `"error.code"` | | `response.resultEphemeral` | `true` | @@ -345,6 +345,33 @@ }, "videos": { "$coalesce": [ + { + "$map": { + "from": { + "$ref": "response.data" + }, + "as": "item", + "in": { + "$coalesce": [ + { + "$ref": "item.url" + }, + { + "$ref": "item.video_url" + }, + { + "$ref": "item.videoUrl" + }, + { + "$ref": "item.output_url" + }, + { + "$ref": "item.outputUrl" + } + ] + } + } + }, { "$ref": "response.data.result_url" }, diff --git a/plugin-packages/newapi-video-generations-v1/manifest.json b/plugin-packages/newapi-video-generations-v1/manifest.json index b8b5bb0a2..58eeae9d7 100644 --- a/plugin-packages/newapi-video-generations-v1/manifest.json +++ b/plugin-packages/newapi-video-generations-v1/manifest.json @@ -5,7 +5,7 @@ "version": "2.0.0", "author": "NewAPI / 影策", "description": "NewAPI Video Generations Channel 2 独立请求协议插件。", - "documentation": "# NewAPI Video Generations Channel 2\n\n该目录是独立官方协议插件源码。后端从生成的 `newapi-video-generations-v1.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# NewAPI Video Generations Channel 2 接口字段\n\n## 协议身份\n\n- 插件 ID:`newapi-video-generations-v1`。\n- Provider ID:`newapi-channel-2`。\n- 能力:`video`。\n- 默认 Base URL:`http://127.0.0.1:3000`。\n- 鉴权驱动:`bearer`。\n- 创建:`POST /v1/video/generations`。\n- 查询:`GET /v1/video/generations/{{taskId}}`。\n\n## 配置字段\n\n| 字段 | 类型 | 必填 | 含义 |\n| --- | --- | --- | --- |\n| `apiKey` | secret | 是 | API Key |\n\n## 统一字段映射\n\n| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |\n| --- | --- | --- | --- | --- |\n| `model` | string | 是 | `model` | 视频模型 ID。 |\n| `prompt` | string | 是 | `prompt/content/input` | 视频提示词。 |\n| `images` | media[] | 否 | `first/last/reference image` | 显式 role 图片输入。 |\n| `videos` | media[] | 否 | `reference video` | 参考视频。 |\n| `audios` | media[] | 否 | `reference audio/voice` | 参考音频或音色。 |\n| `duration` | integer | 否 | `duration/seconds` | 时长秒数。 |\n| `aspectRatio` | string | 否 | `ratio/aspect_ratio/size` | 画幅比例或尺寸。 |\n| `resolution` | string | 否 | `resolution` | 分辨率档位。 |\n| `generateAudio` | boolean | 否 | `generate_audio` | 是否生成音频。 |\n| `watermark` | boolean | 否 | `watermark` | 水印开关。 |\n| `providerOptions` | object | 否 | `provider-specific fields` | 插件命名空间内的厂商扩展字段。 |\n\n## 上游请求模板逐字段清单\n\n下表由插件请求模板生成,覆盖 body、query、headers 和 multipart 文件声明中的每个字段。\n\n| 上游位置 | 值或转换表达式 |\n| --- | --- |\n| `create.method` | `\"POST\"` |\n| `create.path` | `\"/v1/video/generations\"` |\n| `create.contentType` | `\"application/json\"` |\n| `create.body.model` | `{\"$ref\":\"request.model\"}` |\n| `create.body.prompt` | `{\"$ref\":\"request.prompt\"}` |\n| `create.body.seconds` | `{\"$toString\":{\"$ref\":\"request.duration\"}}` |\n| `create.body.aspect_ratio` | `{\"$coalesce\":[{\"$ref\":\"request.aspectRatio\"},\"16:9\"]}` |\n| `create.body.resolution` | `{\"$omitEmpty\":{\"$ref\":\"request.resolution\"}}` |\n| `create.body.generate_audio` | `{\"$ref\":\"request.generateAudio\"}` |\n| `create.body.image_urls` | `{\"$omitEmpty\":{\"$map\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.images\"}},\"as\":\"media\",\"in\":{\"$ref\":\"media.value\"}}}}` |\n| `create.body.video_urls` | `{\"$omitEmpty\":{\"$map\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.videos\"}},\"as\":\"media\",\"in\":{\"$ref\":\"media.value\"}}}}` |\n| `create.body.audio_urls` | `{\"$omitEmpty\":{\"$map\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.audios\"}},\"as\":\"media\",\"in\":{\"$ref\":\"media.value\"}}}}` |\n| `poll.method` | `\"GET\"` |\n| `poll.path` | `\"/v1/video/generations/{{taskId}}\"` |\n| `poll.contentType` | `\"application/json\"` |\n\n## Provider 扩展键\n\n- 无额外扩展键。\n\n动态模型或工作流允许使用文档声明的完整 `parameters/input/extra_body` 对象;该对象是协议本身的开放 schema,不会被宿主裁剪。\n\n## 响应映射逐字段清单\n\n| 映射位置 | 上游路径或转换表达式 |\n| --- | --- |\n| `response.taskId` | `{\"$coalesce\":[{\"$ref\":\"response.data.task_id\"},{\"$ref\":\"response.data.taskId\"},{\"$ref\":\"response.task_id\"},{\"$ref\":\"response.taskId\"},{\"$ref\":\"response.data.id\"},{\"$ref\":\"response.id\"},{\"$ref\":\"taskId\"}]}` |\n| `response.status` | `{\"$coalesce\":[{\"$ref\":\"response.status\"},{\"$ref\":\"response.state\"},{\"$ref\":\"response.data.status\"},\"pending\"]}` |\n| `response.message` | `{\"$coalesce\":[{\"$ref\":\"response.error.message\"},{\"$ref\":\"response.message\"},{\"$ref\":\"response.fail_reason\"}]}` |\n| `response.videos` | `{\"$coalesce\":[{\"$ref\":\"response.data.result_url\"},{\"$ref\":\"response.data.video_url\"},{\"$ref\":\"response.data.output_url\"},{\"$ref\":\"response.data.url\"},{\"$ref\":\"response.data.metadata.url\"},{\"$ref\":\"response.data.data.video_url\"},{\"$ref\":\"response.data.data.output_url\"},{\"$ref\":\"response.data.data.result_url\"},{\"$ref\":\"response.data.data.url\"},{\"$ref\":\"response.data.data.metadata.url\"},{\"$ref\":\"response.video_url\"},{\"$ref\":\"response.videoUrl\"},{\"$ref\":\"response.result_url\"},{\"$ref\":\"response.output_url\"},{\"$ref\":\"response.url\"},{\"$ref\":\"response.metadata.url\"},{\"$ref\":\"response.output.url\"}]}` |\n| `response.errorPaths[0]` | `\"error.code\"` |\n| `response.resultEphemeral` | `true` |\n\n## 响应与错误\n\n插件把上游 task/status/text/media/usage 映射为统一结果。临时媒体 URL 标记为 ephemeral,由宿主立即下载持久化。HTTP 错误、业务 code 和 error object 保持失败语义,不包装成成功。\n\n## 兼容边界\n\n该包只代表上述线协议 profile;同一品牌的其他 endpoint、云区域或网关包装必须使用独立插件,不能根据模型名猜测。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"newapi-video-generations-v1\",\n \"name\": \"NewAPI Video Generations Channel 2\",\n \"version\": \"2.0.0\",\n \"author\": \"NewAPI / 影策\",\n \"description\": \"NewAPI Video Generations Channel 2 独立请求协议插件。\",\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"newapi-channel-2\",\n \"label\": \"NewAPI Video Generations Channel 2\",\n \"capabilities\": [\n \"video\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"http://127.0.0.1:3000\",\n \"requiresPublicMediaUrls\": true,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"视频模型 ID。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt/content/input\",\n \"description\": \"视频提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"first/last/reference image\",\n \"description\": \"显式 role 图片输入。\"\n },\n {\n \"name\": \"videos\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"reference video\",\n \"description\": \"参考视频。\"\n },\n {\n \"name\": \"audios\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"reference audio/voice\",\n \"description\": \"参考音频或音色。\"\n },\n {\n \"name\": \"duration\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"duration/seconds\",\n \"description\": \"时长秒数。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"ratio/aspect_ratio/size\",\n \"description\": \"画幅比例或尺寸。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution\",\n \"description\": \"分辨率档位。\"\n },\n {\n \"name\": \"generateAudio\",\n \"type\": \"boolean\",\n \"required\": false,\n \"mapping\": \"generate_audio\",\n \"description\": \"是否生成音频。\"\n },\n {\n \"name\": \"watermark\",\n \"type\": \"boolean\",\n \"required\": false,\n \"mapping\": \"watermark\",\n \"description\": \"水印开关。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/video/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"seconds\": {\n \"$toString\": {\n \"$ref\": \"request.duration\"\n }\n },\n \"aspect_ratio\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.aspectRatio\"\n },\n \"16:9\"\n ]\n },\n \"resolution\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.resolution\"\n }\n },\n \"generate_audio\": {\n \"$ref\": \"request.generateAudio\"\n },\n \"image_urls\": {\n \"$omitEmpty\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n },\n \"video_urls\": {\n \"$omitEmpty\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.videos\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n },\n \"audio_urls\": {\n \"$omitEmpty\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.audios\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n }\n }\n },\n \"poll\": {\n \"method\": \"GET\",\n \"path\": \"/v1/video/generations/{{taskId}}\"\n },\n \"response\": {\n \"taskId\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.task_id\"\n },\n {\n \"$ref\": \"response.data.taskId\"\n },\n {\n \"$ref\": \"response.task_id\"\n },\n {\n \"$ref\": \"response.taskId\"\n },\n {\n \"$ref\": \"response.data.id\"\n },\n {\n \"$ref\": \"response.id\"\n },\n {\n \"$ref\": \"taskId\"\n }\n ]\n },\n \"status\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.status\"\n },\n {\n \"$ref\": \"response.state\"\n },\n {\n \"$ref\": \"response.data.status\"\n },\n \"pending\"\n ]\n },\n \"message\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.error.message\"\n },\n {\n \"$ref\": \"response.message\"\n },\n {\n \"$ref\": \"response.fail_reason\"\n }\n ]\n },\n \"videos\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.result_url\"\n },\n {\n \"$ref\": \"response.data.video_url\"\n },\n {\n \"$ref\": \"response.data.output_url\"\n },\n {\n \"$ref\": \"response.data.url\"\n },\n {\n \"$ref\": \"response.data.metadata.url\"\n },\n {\n \"$ref\": \"response.data.data.video_url\"\n },\n {\n \"$ref\": \"response.data.data.output_url\"\n },\n {\n \"$ref\": \"response.data.data.result_url\"\n },\n {\n \"$ref\": \"response.data.data.url\"\n },\n {\n \"$ref\": \"response.data.data.metadata.url\"\n },\n {\n \"$ref\": \"response.video_url\"\n },\n {\n \"$ref\": \"response.videoUrl\"\n },\n {\n \"$ref\": \"response.result_url\"\n },\n {\n \"$ref\": \"response.output_url\"\n },\n {\n \"$ref\": \"response.url\"\n },\n {\n \"$ref\": \"response.metadata.url\"\n },\n {\n \"$ref\": \"response.output.url\"\n }\n ]\n },\n \"errorPaths\": [\n \"error.code\"\n ],\n \"resultEphemeral\": true\n }\n }\n ]\n }\n}\n```\n\n", + "documentation": "# NewAPI Video Generations Channel 2\n\n该目录是独立官方协议插件源码。后端从生成的 `newapi-video-generations-v1.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# NewAPI Video Generations Channel 2 接口字段\n\n## 协议身份\n\n- 插件 ID:`newapi-video-generations-v1`。\n- Provider ID:`newapi-channel-2`。\n- 能力:`video`。\n- 默认 Base URL:`http://127.0.0.1:3000`。\n- 鉴权驱动:`bearer`。\n- 创建:`POST /v1/video/generations`。\n- 查询:`GET /v1/video/generations/{{taskId}}`。\n\n## 配置字段\n\n| 字段 | 类型 | 必填 | 含义 |\n| --- | --- | --- | --- |\n| `apiKey` | secret | 是 | API Key |\n\n## 统一字段映射\n\n| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |\n| --- | --- | --- | --- | --- |\n| `model` | string | 是 | `model` | 视频模型 ID。 |\n| `prompt` | string | 是 | `prompt/content/input` | 视频提示词。 |\n| `images` | media[] | 否 | `first/last/reference image` | 显式 role 图片输入。 |\n| `videos` | media[] | 否 | `reference video` | 参考视频。 |\n| `audios` | media[] | 否 | `reference audio/voice` | 参考音频或音色。 |\n| `duration` | integer | 否 | `duration/seconds` | 时长秒数。 |\n| `aspectRatio` | string | 否 | `ratio/aspect_ratio/size` | 画幅比例或尺寸。 |\n| `resolution` | string | 否 | `resolution` | 分辨率档位。 |\n| `generateAudio` | boolean | 否 | `generate_audio` | 是否生成音频。 |\n| `watermark` | boolean | 否 | `watermark` | 水印开关。 |\n| `providerOptions` | object | 否 | `provider-specific fields` | 插件命名空间内的厂商扩展字段。 |\n\n## 上游请求模板逐字段清单\n\n下表由插件请求模板生成,覆盖 body、query、headers 和 multipart 文件声明中的每个字段。\n\n| 上游位置 | 值或转换表达式 |\n| --- | --- |\n| `create.method` | `\"POST\"` |\n| `create.path` | `\"/v1/video/generations\"` |\n| `create.contentType` | `\"application/json\"` |\n| `create.body.model` | `{\"$ref\":\"request.model\"}` |\n| `create.body.prompt` | `{\"$ref\":\"request.prompt\"}` |\n| `create.body.seconds` | `{\"$toString\":{\"$ref\":\"request.duration\"}}` |\n| `create.body.aspect_ratio` | `{\"$coalesce\":[{\"$ref\":\"request.aspectRatio\"},\"16:9\"]}` |\n| `create.body.resolution` | `{\"$omitEmpty\":{\"$ref\":\"request.resolution\"}}` |\n| `create.body.generate_audio` | `{\"$ref\":\"request.generateAudio\"}` |\n| `create.body.image_urls` | `{\"$omitEmpty\":{\"$map\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.images\"}},\"as\":\"media\",\"in\":{\"$ref\":\"media.value\"}}}}` |\n| `create.body.video_urls` | `{\"$omitEmpty\":{\"$map\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.videos\"}},\"as\":\"media\",\"in\":{\"$ref\":\"media.value\"}}}}` |\n| `create.body.audio_urls` | `{\"$omitEmpty\":{\"$map\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.audios\"}},\"as\":\"media\",\"in\":{\"$ref\":\"media.value\"}}}}` |\n| `poll.method` | `\"GET\"` |\n| `poll.path` | `\"/v1/video/generations/{{taskId}}\"` |\n| `poll.contentType` | `\"application/json\"` |\n\n## Provider 扩展键\n\n- 无额外扩展键。\n\n动态模型或工作流允许使用文档声明的完整 `parameters/input/extra_body` 对象;该对象是协议本身的开放 schema,不会被宿主裁剪。\n\n## 响应映射逐字段清单\n\n| 映射位置 | 上游路径或转换表达式 |\n| --- | --- |\n| `response.taskId` | `{\"$coalesce\":[{\"$ref\":\"response.data.task_id\"},{\"$ref\":\"response.data.taskId\"},{\"$ref\":\"response.task_id\"},{\"$ref\":\"response.taskId\"},{\"$ref\":\"response.data.id\"},{\"$ref\":\"response.id\"},{\"$ref\":\"taskId\"}]}` |\n| `response.status` | `{\"$coalesce\":[{\"$ref\":\"response.status\"},{\"$ref\":\"response.state\"},{\"$ref\":\"response.data.status\"},\"pending\"]}` |\n| `response.message` | `{\"$coalesce\":[{\"$ref\":\"response.error.message\"},{\"$ref\":\"response.message\"},{\"$ref\":\"response.fail_reason\"}]}` |\n| `response.videos` | `{\"$coalesce\":[{\"$map\":{\"from\":{\"$ref\":\"response.data\"},\"as\":\"item\",\"in\":{\"$coalesce\":[{\"$ref\":\"item.url\"},{\"$ref\":\"item.video_url\"},{\"$ref\":\"item.videoUrl\"},{\"$ref\":\"item.output_url\"},{\"$ref\":\"item.outputUrl\"}]}}},{\"$ref\":\"response.data.result_url\"},{\"$ref\":\"response.data.video_url\"},{\"$ref\":\"response.data.output_url\"},{\"$ref\":\"response.data.url\"},{\"$ref\":\"response.data.metadata.url\"},{\"$ref\":\"response.data.data.video_url\"},{\"$ref\":\"response.data.data.output_url\"},{\"$ref\":\"response.data.data.result_url\"},{\"$ref\":\"response.data.data.url\"},{\"$ref\":\"response.data.data.metadata.url\"},{\"$ref\":\"response.video_url\"},{\"$ref\":\"response.videoUrl\"},{\"$ref\":\"response.result_url\"},{\"$ref\":\"response.output_url\"},{\"$ref\":\"response.url\"},{\"$ref\":\"response.metadata.url\"},{\"$ref\":\"response.output.url\"}]}` |\n| `response.errorPaths[0]` | `\"error.code\"` |\n| `response.resultEphemeral` | `true` |\n\n## 响应与错误\n\n插件把上游 task/status/text/media/usage 映射为统一结果。临时媒体 URL 标记为 ephemeral,由宿主立即下载持久化。HTTP 错误、业务 code 和 error object 保持失败语义,不包装成成功。\n\n## 兼容边界\n\n该包只代表上述线协议 profile;同一品牌的其他 endpoint、云区域或网关包装必须使用独立插件,不能根据模型名猜测。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"newapi-video-generations-v1\",\n \"name\": \"NewAPI Video Generations Channel 2\",\n \"version\": \"2.0.0\",\n \"author\": \"NewAPI / 影策\",\n \"description\": \"NewAPI Video Generations Channel 2 独立请求协议插件。\",\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"newapi-channel-2\",\n \"label\": \"NewAPI Video Generations Channel 2\",\n \"capabilities\": [\n \"video\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"http://127.0.0.1:3000\",\n \"requiresPublicMediaUrls\": true,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"视频模型 ID。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt/content/input\",\n \"description\": \"视频提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"first/last/reference image\",\n \"description\": \"显式 role 图片输入。\"\n },\n {\n \"name\": \"videos\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"reference video\",\n \"description\": \"参考视频。\"\n },\n {\n \"name\": \"audios\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"reference audio/voice\",\n \"description\": \"参考音频或音色。\"\n },\n {\n \"name\": \"duration\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"duration/seconds\",\n \"description\": \"时长秒数。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"ratio/aspect_ratio/size\",\n \"description\": \"画幅比例或尺寸。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution\",\n \"description\": \"分辨率档位。\"\n },\n {\n \"name\": \"generateAudio\",\n \"type\": \"boolean\",\n \"required\": false,\n \"mapping\": \"generate_audio\",\n \"description\": \"是否生成音频。\"\n },\n {\n \"name\": \"watermark\",\n \"type\": \"boolean\",\n \"required\": false,\n \"mapping\": \"watermark\",\n \"description\": \"水印开关。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/video/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"seconds\": {\n \"$toString\": {\n \"$ref\": \"request.duration\"\n }\n },\n \"aspect_ratio\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.aspectRatio\"\n },\n \"16:9\"\n ]\n },\n \"resolution\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.resolution\"\n }\n },\n \"generate_audio\": {\n \"$ref\": \"request.generateAudio\"\n },\n \"image_urls\": {\n \"$omitEmpty\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n },\n \"video_urls\": {\n \"$omitEmpty\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.videos\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n },\n \"audio_urls\": {\n \"$omitEmpty\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.audios\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n }\n }\n },\n \"poll\": {\n \"method\": \"GET\",\n \"path\": \"/v1/video/generations/{{taskId}}\"\n },\n \"response\": {\n \"taskId\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.task_id\"\n },\n {\n \"$ref\": \"response.data.taskId\"\n },\n {\n \"$ref\": \"response.task_id\"\n },\n {\n \"$ref\": \"response.taskId\"\n },\n {\n \"$ref\": \"response.data.id\"\n },\n {\n \"$ref\": \"response.id\"\n },\n {\n \"$ref\": \"taskId\"\n }\n ]\n },\n \"status\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.status\"\n },\n {\n \"$ref\": \"response.state\"\n },\n {\n \"$ref\": \"response.data.status\"\n },\n \"pending\"\n ]\n },\n \"message\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.error.message\"\n },\n {\n \"$ref\": \"response.message\"\n },\n {\n \"$ref\": \"response.fail_reason\"\n }\n ]\n },\n \"videos\": {\n \"$coalesce\": [\n {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"$coalesce\": [\n {\n \"$ref\": \"item.url\"\n },\n {\n \"$ref\": \"item.video_url\"\n },\n {\n \"$ref\": \"item.videoUrl\"\n },\n {\n \"$ref\": \"item.output_url\"\n },\n {\n \"$ref\": \"item.outputUrl\"\n }\n ]\n }\n }\n },\n {\n \"$ref\": \"response.data.result_url\"\n },\n {\n \"$ref\": \"response.data.video_url\"\n },\n {\n \"$ref\": \"response.data.output_url\"\n },\n {\n \"$ref\": \"response.data.url\"\n },\n {\n \"$ref\": \"response.data.metadata.url\"\n },\n {\n \"$ref\": \"response.data.data.video_url\"\n },\n {\n \"$ref\": \"response.data.data.output_url\"\n },\n {\n \"$ref\": \"response.data.data.result_url\"\n },\n {\n \"$ref\": \"response.data.data.url\"\n },\n {\n \"$ref\": \"response.data.data.metadata.url\"\n },\n {\n \"$ref\": \"response.video_url\"\n },\n {\n \"$ref\": \"response.videoUrl\"\n },\n {\n \"$ref\": \"response.result_url\"\n },\n {\n \"$ref\": \"response.output_url\"\n },\n {\n \"$ref\": \"response.url\"\n },\n {\n \"$ref\": \"response.metadata.url\"\n },\n {\n \"$ref\": \"response.output.url\"\n }\n ]\n },\n \"errorPaths\": [\n \"error.code\"\n ],\n \"resultEphemeral\": true\n }\n }\n ]\n }\n}\n```\n\n", "permissions": [ "generation.run", "media.read" @@ -258,6 +258,33 @@ }, "videos": { "$coalesce": [ + { + "$map": { + "from": { + "$ref": "response.data" + }, + "as": "item", + "in": { + "$coalesce": [ + { + "$ref": "item.url" + }, + { + "$ref": "item.video_url" + }, + { + "$ref": "item.videoUrl" + }, + { + "$ref": "item.output_url" + }, + { + "$ref": "item.outputUrl" + } + ] + } + } + }, { "$ref": "response.data.result_url" }, diff --git a/plugin-packages/official-payment-zhifufm.yingce-plugin b/plugin-packages/official-payment-zhifufm.yingce-plugin new file mode 100644 index 000000000..26a52080f Binary files /dev/null and b/plugin-packages/official-payment-zhifufm.yingce-plugin differ diff --git a/plugin-packages/official-payment-zhifufm/README.md b/plugin-packages/official-payment-zhifufm/README.md new file mode 100644 index 000000000..48aaf5b07 --- /dev/null +++ b/plugin-packages/official-payment-zhifufm/README.md @@ -0,0 +1,11 @@ +# 支付FM 聚合支付插件 + +支持对接支付FM免签个人收款码与签约支付通道,支持支付宝/微信扫码及收银台跳转。 + +### 插件配置参数 +- **服务器公网地址**: 本站对外公网地址,用于接收 `notifyUrl` 异步回调通知。 +- **商户号 (merchantNum)**: 支付FM商户后台【用户中心】查看的商户号。 +- **接入密钥 (secretKey)**: 支付FM商户后台【用户中心】查看的接口接入密钥。 +- **接口根地址**: 支付FM后台显示的专属根地址,例如 `https://api-5a0zvlcbi80.zhifu.fm.it88168.com/api`。 +- **默认支付通道 (payType)**: 推荐使用 `aloop` / `tloop` 或 `alipay` / `wechat`。 +- **收银方式**: 默认为 `redirect` 跳转模式。 diff --git a/plugin-packages/official-payment-zhifufm/assets/icon.svg b/plugin-packages/official-payment-zhifufm/assets/icon.svg new file mode 100644 index 000000000..ae57140ea --- /dev/null +++ b/plugin-packages/official-payment-zhifufm/assets/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugin-packages/official-payment-zhifufm/backend/provider b/plugin-packages/official-payment-zhifufm/backend/provider new file mode 100755 index 000000000..56fe3d8f0 Binary files /dev/null and b/plugin-packages/official-payment-zhifufm/backend/provider differ diff --git a/plugin-packages/official-payment-zhifufm/backend/provider-darwin-amd64 b/plugin-packages/official-payment-zhifufm/backend/provider-darwin-amd64 new file mode 100755 index 000000000..57b43ebcd Binary files /dev/null and b/plugin-packages/official-payment-zhifufm/backend/provider-darwin-amd64 differ diff --git a/plugin-packages/official-payment-zhifufm/backend/provider-darwin-arm64 b/plugin-packages/official-payment-zhifufm/backend/provider-darwin-arm64 new file mode 100755 index 000000000..c6776367c Binary files /dev/null and b/plugin-packages/official-payment-zhifufm/backend/provider-darwin-arm64 differ diff --git a/plugin-packages/official-payment-zhifufm/backend/provider-linux-amd64 b/plugin-packages/official-payment-zhifufm/backend/provider-linux-amd64 new file mode 100755 index 000000000..56fe3d8f0 Binary files /dev/null and b/plugin-packages/official-payment-zhifufm/backend/provider-linux-amd64 differ diff --git a/plugin-packages/official-payment-zhifufm/backend/provider-linux-arm64 b/plugin-packages/official-payment-zhifufm/backend/provider-linux-arm64 new file mode 100755 index 000000000..eccea4c00 Binary files /dev/null and b/plugin-packages/official-payment-zhifufm/backend/provider-linux-arm64 differ diff --git a/plugin-packages/official-payment-zhifufm/backend/provider-windows-amd64.exe b/plugin-packages/official-payment-zhifufm/backend/provider-windows-amd64.exe new file mode 100644 index 000000000..fc16319df Binary files /dev/null and b/plugin-packages/official-payment-zhifufm/backend/provider-windows-amd64.exe differ diff --git a/plugin-packages/official-payment-zhifufm/backend/provider-windows-arm64.exe b/plugin-packages/official-payment-zhifufm/backend/provider-windows-arm64.exe new file mode 100644 index 000000000..672df1325 Binary files /dev/null and b/plugin-packages/official-payment-zhifufm/backend/provider-windows-arm64.exe differ diff --git a/plugin-packages/official-payment-zhifufm/docs/interface.md b/plugin-packages/official-payment-zhifufm/docs/interface.md new file mode 100644 index 000000000..35c4e7a8f --- /dev/null +++ b/plugin-packages/official-payment-zhifufm/docs/interface.md @@ -0,0 +1,119 @@ +# 支付FM 接口协议说明 + +遵循 `yingce.payment/v1` 标准,自动转换影策系统订单至支付FM `/startOrder` 接口,并在收到通知时按 MD5 规则完成异步验签。 + + +## Manifest 完整接口定义 + +以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。 + +```json +{ + "apiVersion": "yingce.plugin/v1", + "id": "official-payment-zhifufm", + "name": "支付FM", + "version": "1.0.0", + "author": "支付FM", + "description": "支付FM个人免签/签约聚合支付适配器,支持微信/支付宝扫码或收银台跳转。", + "enabled": true, + "installable": true, + "runtime": { + "backend": "rpc", + "backendEntry": "backend/provider" + }, + "surfaces": [ + "wallet", + "settings" + ], + "permissions": [ + "payment.create", + "payment.query", + "payment.close", + "payment.reconcile" + ], + "configuration": { + "fields": [ + { + "name": "publicBaseUrl", + "type": "url", + "label": "服务器公网地址", + "required": true, + "description": "用于生成异步通知 notifyUrl 地址,必须可被支付FM外网服务器访问回调。" + }, + { + "name": "merchantNum", + "type": "string", + "label": "商户号 (merchantNum)", + "required": true, + "description": "支付FM商户后台【用户中心】查看的商户号(8-18位数字)。" + }, + { + "name": "secretKey", + "type": "password", + "label": "接入密钥 (secretKey)", + "required": true, + "secret": true, + "description": "支付FM商户后台【用户中心】查看的接口接入密钥。" + }, + { + "name": "gateway", + "type": "url", + "label": "接口根地址", + "required": true, + "default": "https://api-5a0zvlcbi80.zhifu.fm.it88168.com/api", + "description": "支付FM分配的接口根地址,如 https://api-5a0zvlcbi80.zhifu.fm.it88168.com/api。" + }, + { + "name": "payType", + "type": "string", + "label": "默认支付通道 (payType)", + "required": false, + "default": "alipay", + "description": "支付产品通道:alipay(支付宝)、wechat(微信)、aloop(支付宝轮询池)、tloop(微信轮询池)等。" + }, + { + "name": "checkoutMode", + "type": "string", + "label": "收银方式", + "required": false, + "default": "redirect", + "values": [ + "redirect", + "qr_code" + ], + "description": "redirect 跳转支付FM收银页;qr_code 在钱包中直接将 payUrl 渲染为二维码扫码。" + } + ] + }, + "contributes": { + "paymentProviders": [ + { + "id": "zhifufm-pay", + "label": "支付FM", + "icon": "assets/icon.svg", + "checkoutMode": "redirect", + "identityFields": [ + "merchantNum" + ], + "expiryPolicy": { + "defaultMinutes": 10, + "minMinutes": 3, + "maxMinutes": 15 + }, + "notificationSuccess": { + "status": 200, + "contentType": "text/plain; charset=utf-8", + "body": "success" + }, + "notificationFailure": { + "status": 400, + "contentType": "text/plain; charset=utf-8", + "body": "fail" + } + } + ] + }, + "documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>" +} +``` + diff --git a/plugin-packages/official-payment-zhifufm/manifest.json b/plugin-packages/official-payment-zhifufm/manifest.json new file mode 100644 index 000000000..e39e4f75b --- /dev/null +++ b/plugin-packages/official-payment-zhifufm/manifest.json @@ -0,0 +1,107 @@ +{ + "apiVersion": "yingce.plugin/v1", + "id": "official-payment-zhifufm", + "name": "支付FM", + "version": "1.0.0", + "author": "支付FM", + "description": "支付FM个人免签/签约聚合支付适配器,支持微信/支付宝扫码或收银台跳转。", + "enabled": true, + "installable": true, + "runtime": { + "backend": "rpc", + "backendEntry": "backend/provider" + }, + "surfaces": [ + "wallet", + "settings" + ], + "permissions": [ + "payment.create", + "payment.query", + "payment.close", + "payment.reconcile" + ], + "configuration": { + "fields": [ + { + "name": "publicBaseUrl", + "type": "url", + "label": "服务器公网地址", + "required": true, + "description": "用于生成异步通知 notifyUrl 地址,必须可被支付FM外网服务器访问回调。" + }, + { + "name": "merchantNum", + "type": "string", + "label": "商户号 (merchantNum)", + "required": true, + "description": "支付FM商户后台【用户中心】查看的商户号(8-18位数字)。" + }, + { + "name": "secretKey", + "type": "password", + "label": "接入密钥 (secretKey)", + "required": true, + "secret": true, + "description": "支付FM商户后台【用户中心】查看的接口接入密钥。" + }, + { + "name": "gateway", + "type": "url", + "label": "接口根地址", + "required": true, + "default": "https://api-5a0zvlcbi80.zhifu.fm.it88168.com/api", + "description": "支付FM分配的接口根地址,如 https://api-5a0zvlcbi80.zhifu.fm.it88168.com/api。" + }, + { + "name": "payType", + "type": "string", + "label": "默认支付通道 (payType)", + "required": false, + "default": "alipay", + "description": "支付产品通道:alipay(支付宝)、wechat(微信)、aloop(支付宝轮询池)、tloop(微信轮询池)等。" + }, + { + "name": "checkoutMode", + "type": "string", + "label": "收银方式", + "required": false, + "default": "redirect", + "values": [ + "redirect", + "qr_code" + ], + "description": "redirect 跳转支付FM收银页;qr_code 在钱包中直接将 payUrl 渲染为二维码扫码。" + } + ] + }, + "contributes": { + "paymentProviders": [ + { + "id": "zhifufm-pay", + "label": "支付FM", + "icon": "assets/icon.svg", + "checkoutMode": "redirect", + "identityFields": [ + "merchantNum" + ], + "expiryPolicy": { + "defaultMinutes": 10, + "minMinutes": 3, + "maxMinutes": 15 + }, + "notificationSuccess": { + "status": 200, + "contentType": "text/plain; charset=utf-8", + "body": "success" + }, + "notificationFailure": { + "status": 400, + "contentType": "text/plain; charset=utf-8", + "body": "fail" + } + } + ] + }, + "documentation": "# 支付FM 聚合支付插件\n\n支持对接支付FM免签个人收款码与签约支付通道,支持支付宝/微信扫码及收银台跳转。\n\n### 插件配置参数\n- **服务器公网地址**: 本站对外公网地址,用于接收 `notifyUrl` 异步回调通知。\n- **商户号 (merchantNum)**: 支付FM商户后台【用户中心】查看的商户号。\n- **接入密钥 (secretKey)**: 支付FM商户后台【用户中心】查看的接口接入密钥。\n- **接口根地址**: 支付FM后台显示的专属根地址,例如 `https://api-5a0zvlcbi80.zhifu.fm.it88168.com/api`。\n- **默认支付通道 (payType)**: 推荐使用 `aloop` / `tloop` 或 `alipay` / `wechat`。\n- **收银方式**: 默认为 `redirect` 跳转模式。\n\n---\n\n# 支付FM 接口协议说明\n\n遵循 `yingce.payment/v1` 标准,自动转换影策系统订单至支付FM `/startOrder` 接口,并在收到通知时按 MD5 规则完成异步验签。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v1\",\n \"id\": \"official-payment-zhifufm\",\n \"name\": \"支付FM\",\n \"version\": \"1.0.0\",\n \"author\": \"支付FM\",\n \"description\": \"支付FM个人免签/签约聚合支付适配器,支持微信/支付宝扫码或收银台跳转。\",\n \"enabled\": true,\n \"installable\": true,\n \"runtime\": {\n \"backend\": \"rpc\",\n \"backendEntry\": \"backend/provider\"\n },\n \"surfaces\": [\n \"wallet\",\n \"settings\"\n ],\n \"permissions\": [\n \"payment.create\",\n \"payment.query\",\n \"payment.close\",\n \"payment.reconcile\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"publicBaseUrl\",\n \"type\": \"url\",\n \"label\": \"服务器公网地址\",\n \"required\": true,\n \"description\": \"用于生成异步通知 notifyUrl 地址,必须可被支付FM外网服务器访问回调。\"\n },\n {\n \"name\": \"merchantNum\",\n \"type\": \"string\",\n \"label\": \"商户号 (merchantNum)\",\n \"required\": true,\n \"description\": \"支付FM商户后台【用户中心】查看的商户号(8-18位数字)。\"\n },\n {\n \"name\": \"secretKey\",\n \"type\": \"password\",\n \"label\": \"接入密钥 (secretKey)\",\n \"required\": true,\n \"secret\": true,\n \"description\": \"支付FM商户后台【用户中心】查看的接口接入密钥。\"\n },\n {\n \"name\": \"gateway\",\n \"type\": \"url\",\n \"label\": \"接口根地址\",\n \"required\": true,\n \"default\": \"https://api-5a0zvlcbi80.zhifu.fm.it88168.com/api\",\n \"description\": \"支付FM分配的接口根地址,如 https://api-5a0zvlcbi80.zhifu.fm.it88168.com/api。\"\n },\n {\n \"name\": \"payType\",\n \"type\": \"string\",\n \"label\": \"默认支付通道 (payType)\",\n \"required\": false,\n \"default\": \"alipay\",\n \"description\": \"支付产品通道:alipay(支付宝)、wechat(微信)、aloop(支付宝轮询池)、tloop(微信轮询池)等。\"\n },\n {\n \"name\": \"checkoutMode\",\n \"type\": \"string\",\n \"label\": \"收银方式\",\n \"required\": false,\n \"default\": \"redirect\",\n \"values\": [\n \"redirect\",\n \"qr_code\"\n ],\n \"description\": \"redirect 跳转支付FM收银页;qr_code 在钱包中直接将 payUrl 渲染为二维码扫码。\"\n }\n ]\n },\n \"contributes\": {\n \"paymentProviders\": [\n {\n \"id\": \"zhifufm-pay\",\n \"label\": \"支付FM\",\n \"icon\": \"assets/icon.svg\",\n \"checkoutMode\": \"redirect\",\n \"identityFields\": [\n \"merchantNum\"\n ],\n \"expiryPolicy\": {\n \"defaultMinutes\": 10,\n \"minMinutes\": 3,\n \"maxMinutes\": 15\n },\n \"notificationSuccess\": {\n \"status\": 200,\n \"contentType\": \"text/plain; charset=utf-8\",\n \"body\": \"success\"\n },\n \"notificationFailure\": {\n \"status\": 400,\n \"contentType\": \"text/plain; charset=utf-8\",\n \"body\": \"fail\"\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n\n" +} diff --git a/plugin-packages/openai-images.yingce-plugin b/plugin-packages/openai-images.yingce-plugin index 385a9f4b5..806624b25 100644 Binary files a/plugin-packages/openai-images.yingce-plugin and b/plugin-packages/openai-images.yingce-plugin differ diff --git a/plugin-packages/verify-payment-packages.sh b/plugin-packages/verify-payment-packages.sh index e44029e81..2d44adec2 100644 --- a/plugin-packages/verify-payment-packages.sh +++ b/plugin-packages/verify-payment-packages.sh @@ -118,7 +118,7 @@ if [ "$require_all" = true ]; then fi fi -for package_id in official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay; do +for package_id in official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay official-payment-zhifufm; do backend_dir="$root_dir/$package_id/backend" provider="$backend_dir/provider" package_file="$root_dir/$package_id.yingce-plugin" diff --git a/web/public/icons/zhifufm-logo.png b/web/public/icons/zhifufm-logo.png new file mode 100644 index 000000000..5f2801b6d Binary files /dev/null and b/web/public/icons/zhifufm-logo.png differ diff --git a/web/src/components/assets/asset-library-picker-modal.tsx b/web/src/components/assets/asset-library-picker-modal.tsx index 5c781d6f9..84c68951d 100644 --- a/web/src/components/assets/asset-library-picker-modal.tsx +++ b/web/src/components/assets/asset-library-picker-modal.tsx @@ -488,7 +488,7 @@ export function AssetLibraryPickerModal({
- {remoteEnabled && remoteQuery.isError ?
素材读取失败
: loading || (useRemoteItems && remoteQuery.isFetching) ? ( + {remoteEnabled && remoteQuery.isError && !localItems.length ?
素材读取失败服务端暂时不可用,重试不会影响本地已保存素材。
: loading || (useRemoteItems && remoteQuery.isFetching) ? (
正在读取素材 @@ -519,8 +519,8 @@ export function AssetLibraryPickerModal({ - ) : footerNote ? ( - {footerNote} + ) : footerNote || (remoteEnabled && remoteQuery.isError) ? ( + {remoteEnabled && remoteQuery.isError ? "云端素材暂时不可用,当前显示本地缓存" : footerNote} ) : ( )} diff --git a/web/src/components/canvas/canvas-agent-skill-library-modal.tsx b/web/src/components/canvas/canvas-agent-skill-library-modal.tsx index e04874931..a2430222a 100644 --- a/web/src/components/canvas/canvas-agent-skill-library-modal.tsx +++ b/web/src/components/canvas/canvas-agent-skill-library-modal.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Button, Input } from "antd"; -import { Check, LibraryBig, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react"; +import { Check, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react"; import { AppModal } from "@/components/ui/product/app-modal"; import type { CanvasTheme } from "@/lib/canvas-theme"; @@ -45,16 +45,20 @@ export function CanvasAgentSkillLibraryModal({ onInstall, onLoadMore, }: CanvasAgentSkillLibraryModalProps) { - const [tab, setTab] = useState("enabled"); + const [tab, setTab] = useState("market"); const listRef = useRef(null); const loadMoreRef = useRef(null); const wasOpenRef = useRef(false); const selectedCount = selectedSkillIds.length; useEffect(() => { - if (open && !wasOpenRef.current) setTab(selectedCount > 0 ? "enabled" : "installed"); + if (open && !wasOpenRef.current) setTab("market"); wasOpenRef.current = open; - }, [open, selectedCount]); + }, [open]); + + useEffect(() => { + listRef.current?.scrollTo({ top: 0 }); + }, [category, search, tab]); const visibleSkills = useMemo(() => { const keyword = search.trim().toLocaleLowerCase("zh-CN"); @@ -86,16 +90,21 @@ export function CanvasAgentSkillLibraryModal({ const categoryItems = useMemo(() => { const unique = new Map(); categories.forEach((item) => { - if (item.value && item.label) unique.set(item.value, item); + if (item.value && item.value !== "all" && item.label) unique.set(item.value, item); }); - return [{ value: "all", label: "全部" }, ...unique.values()]; + return [...unique.values()]; }, [categories]); const emptyText = tab === "enabled" - ? "本轮还没有启用 Skill,可在“已加入”中选择" + ? "本轮还没有启用技能,可在“我的技能”中选择" : tab === "installed" - ? "还没有匹配的已加入 Skill,可前往“发现 Skills”添加" - : "没有匹配的公开 Skill,换个关键词或分类试试"; + ? "还没有匹配的已加入技能,可前往“全部”添加" + : "没有匹配的公开技能,换个关键词或分类试试"; + + const changeTab = (value: SkillLibraryTab) => { + setTab(value); + onCategoryChange("all"); + }; return ( ( +
+ + {modal} +
+ )} onCancel={onClose} flush > @@ -115,85 +133,78 @@ export function CanvasAgentSkillLibraryModal({ data-canvas-wheel-scroll aria-label="Agent Skills 技能库" > -
-
-
-
-

按分类浏览和搜索技能,本轮最多启用 8 个,发送消息时固定版本。

-
-
- 已启用 = 8 ? "var(--destructive)" : theme.accent.primary }}>{selectedCount}/8 -
-
- -
-
- setTab("enabled")} /> - setTab("installed")} /> - setTab("market")} /> -
+
+ + - + -
- {visibleSkills.map((skill) => ( - onToggle(skill.skillId)} - onInstall={() => onInstall(skill)} - /> - ))} - {loading && visibleSkills.length === 0 ? ( -
-
- ) : null} - {!loading && visibleSkills.length === 0 ? ( -
-
- ) : null} - {tab === "market" && hasMore ?