From a23e399ccfdf928ed3905388cd36d68fc04a9be2 Mon Sep 17 00:00:00 2001 From: ahyang <53590964+zAhYAng@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:32:56 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=E9=9B=B6=E4=BB=B7?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=B8=A0=E9=81=93=E6=A8=A1=E5=9E=8B=E5=88=86?= =?UTF-8?q?=E5=8F=91=20(#303)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(spec): 模型计费 - 记录零价免费模型设计 * fix(billing): 模型定价 - 支持零价免费模型 * fix(config): 系统模型目录 - 支持零价模型 --- backend/internal/repository/finance.go | 9 +++- backend/internal/service/channel_models.go | 6 --- backend/internal/service/logical_models.go | 9 ++-- backend/internal/service/model_catalog.go | 2 +- backend/internal/service/price_validation.go | 18 ++++---- docs/content/docs/progress/pending-test.mdx | 10 +++++ .../2026-08-24-zero-price-models-design.md | 42 +++++++++++++++++++ web/src/stores/use-config-store.ts | 11 ++--- 8 files changed, 79 insertions(+), 28 deletions(-) create mode 100644 docs/content/docs/progress/pending-test.mdx create mode 100644 docs/superpowers/specs/2026-08-24-zero-price-models-design.md diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index a6517d88d..55a7e0202 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -615,7 +615,7 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err if order.Status == model.BillingStatusRefunded { return errors.New("billing order already refunded") } - if order.BillingMode == "token" { + if order.BillingMode == "token" && !zeroPricedTokenOrder(order) { usage, err := billingUsage(tx, id) if err != nil { return err @@ -717,6 +717,13 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err }) } +func zeroPricedTokenOrder(order model.BillingOrder) bool { + return order.BillingMode == "token" && + order.InputTokenPriceMicrocredits == 0 && + order.OutputTokenPriceMicrocredits == 0 && + order.CachedTokenPriceMicrocredits == 0 +} + func (r *Repository) RefundBillingOrder(id string, errorText string) error { return r.db.Transaction(func(tx *gorm.DB) error { var order model.BillingOrder diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 5f891ae53..3f4b7016a 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -473,12 +473,6 @@ func validateChannelModelTierPricing(capability string, protocol model.ChannelIn if !input.PriceConfigured { return nil } - if billingMode == "token" && input.InputTokenPriceMicrocredits == 0 && input.OutputTokenPriceMicrocredits == 0 && input.CachedTokenPriceMicrocredits == 0 { - return BadAuthRequest("Token 计费至少需要配置一项价格") - } - if billingMode == "token" && capability == "video" && input.OutputTokenPriceMicrocredits == 0 { - return BadAuthRequest("火山方舟视频 Token 计费需要配置每百万视频 Token 价格") - } const maxTokenPriceMicrocredits = int64(1_000_000) * CreditScale if input.InputTokenPriceMicrocredits > maxTokenPriceMicrocredits || input.OutputTokenPriceMicrocredits > maxTokenPriceMicrocredits || input.CachedTokenPriceMicrocredits > maxTokenPriceMicrocredits { return BadAuthRequest("Token 每百万用量价格不能超过 1,000,000 积分") diff --git a/backend/internal/service/logical_models.go b/backend/internal/service/logical_models.go index 9d21f937a..e3bdc8f32 100644 --- a/backend/internal/service/logical_models.go +++ b/backend/internal/service/logical_models.go @@ -191,11 +191,11 @@ func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogical Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, PricePolicy: item.PricePolicy, PricingMode: pricingMode, DisplayPrice: displayPrice, PriceLabel: priceLabel, BillingMode: item.BillingMode, - UnitPriceMicrocredits: item.UnitPriceMicrocredits, - InputPriceMicrocredits: item.InputPriceMicrocredits, + UnitPriceMicrocredits: item.UnitPriceMicrocredits, + InputPriceMicrocredits: item.InputPriceMicrocredits, OutputPriceMicrocredits: item.OutputPriceMicrocredits, CachedPriceMicrocredits: item.CachedPriceMicrocredits, - PriceTiers: priceTiers, LegacyModelIDs: decodeLegacyModelIDs(item.LegacyModelIDsJSON), + PriceTiers: priceTiers, LegacyModelIDs: decodeLegacyModelIDs(item.LegacyModelIDsJSON), CapabilitySpec: productSpec, CapabilityProfiles: profiles, DefaultOptions: cached.Defaults, Available: available, } @@ -693,9 +693,6 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo if !supportsLogicalModelTokenBilling(capability, enabledRouteProtocols) { return nil, nil, nil, false, BadAuthRequest("Token 计费仅支持文本前台模型,或全部启用供应线路均为火山方舟视频协议的视频前台模型") } - if capability == "video" && req.OutputPriceMicrocredits <= 0 { - return nil, nil, nil, false, BadAuthRequest("火山方舟视频 Token 计费需要配置每百万视频 Token 价格") - } } // 停用必须始终可执行,便于管理员立即阻止失效线路继续对外服务;重新启用时再强校验结构能力和计费可用性。 if req.Enabled { diff --git a/backend/internal/service/model_catalog.go b/backend/internal/service/model_catalog.go index f0f62d36a..d8e0d5cc3 100644 --- a/backend/internal/service/model_catalog.go +++ b/backend/internal/service/model_catalog.go @@ -144,7 +144,7 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) PublicChannelMode // 转换为公开的价格档 publicTiers := make([]PublicChannelModelPriceTier, 0, len(priceTiers)) for _, tier := range priceTiers { - if !tier.Enabled || !ValidatePriceTierPrice(&tier, cm.Capability, cm.Protocol) { + if !tier.Enabled || !tier.PriceConfigured || !ValidatePriceTierPrice(&tier, cm.Capability, cm.Protocol) { continue } publicTiers = append(publicTiers, PublicChannelModelPriceTier{ diff --git a/backend/internal/service/price_validation.go b/backend/internal/service/price_validation.go index 2426936a4..1948f3a3c 100644 --- a/backend/internal/service/price_validation.go +++ b/backend/internal/service/price_validation.go @@ -7,20 +7,20 @@ import "infinite-canvas/backend/internal/model" func ValidateChannelModelPrice(billingMode string, capability string, protocol model.ChannelInterfaceType, unitPrice, inputPrice, outputPrice, cachedPrice int64) bool { switch billingMode { case "fixed_request": - // 固定价格模式:必须配置单价且大于 0 - return unitPrice > 0 + // 固定价格模式:0 表示免费,负数无效。 + return unitPrice >= 0 case "per_second": - // 按秒计费:必须配置单价且大于 0 - return unitPrice > 0 + // 按秒计费:0 表示免费,负数无效。 + return unitPrice >= 0 case "token": if capability == "video" { - return protocol == model.ChannelInterfaceVolcengineArkVideo && outputPrice > 0 + return protocol == model.ChannelInterfaceVolcengineArkVideo && inputPrice >= 0 && outputPrice >= 0 && cachedPrice >= 0 } if capability != "" && capability != "text" { return false } - // 文本模型:至少配置一项价格 - return inputPrice > 0 || outputPrice > 0 || cachedPrice > 0 + // 文本模型:所有 Token 价格为 0 时表示免费。 + return inputPrice >= 0 && outputPrice >= 0 && cachedPrice >= 0 default: return false } @@ -58,7 +58,7 @@ func HasValidPrice(channelModel *model.ChannelModel) bool { // 如果有价格档,检查价格档 if len(channelModel.PriceTiers) > 0 { for _, tier := range channelModel.PriceTiers { - if tier.Enabled && ValidatePriceTierPrice(&tier, channelModel.Capability, channelModel.Protocol) { + if tier.Enabled && tier.PriceConfigured && ValidatePriceTierPrice(&tier, channelModel.Capability, channelModel.Protocol) { return true } } @@ -66,7 +66,7 @@ func HasValidPrice(channelModel *model.ChannelModel) bool { } // 否则检查模型级别的价格 - return ValidateChannelModelPrice(channelModel.BillingMode, channelModel.Capability, channelModel.Protocol, channelModel.UnitPriceMicrocredits, channelModel.InputTokenPriceMicrocredits, channelModel.OutputTokenPriceMicrocredits, channelModel.CachedTokenPriceMicrocredits) + return channelModel.PriceConfigured && ValidateChannelModelPrice(channelModel.BillingMode, channelModel.Capability, channelModel.Protocol, channelModel.UnitPriceMicrocredits, channelModel.InputTokenPriceMicrocredits, channelModel.OutputTokenPriceMicrocredits, channelModel.CachedTokenPriceMicrocredits) } // ValidateLogicalModelPrice 校验前台模型的价格配置 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx new file mode 100644 index 000000000..cbe04e404 --- /dev/null +++ b/docs/content/docs/progress/pending-test.mdx @@ -0,0 +1,10 @@ +# 待测试清单 + +## 零价免费模型 + +1. 管理员设置启用系统渠道模型的按次价格为 `0` 后,普通用户刷新设置即可看到并成功创建任务。 +2. 前台逻辑模型使用统一按次价格 `0` 后,普通用户可见并能成功创建任务。 +3. 零价任务完成后,账单状态为已结算,用户可用积分和预留积分均不变化。 +4. 零价 Token 任务在上游未返回 usage 时仍能以 `0` 结算;正价 Token 任务继续依赖 usage 结算。 +5. 管理端提交任一负价格仍会被拒绝。 +6. 未勾选价格已配置的默认零价模型仍不会向普通用户公开。 diff --git a/docs/superpowers/specs/2026-08-24-zero-price-models-design.md b/docs/superpowers/specs/2026-08-24-zero-price-models-design.md new file mode 100644 index 000000000..2385e4ef8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-zero-price-models-design.md @@ -0,0 +1,42 @@ +# 零价免费模型设计 + +## 目标 + +管理员将系统渠道价格档或前台逻辑模型统一价格设为 `0` 时,该模型表示免费且可用。普通用户可以看到并创建对应任务,系统不扣减积分。 + +## 范围 + +- 系统渠道模型的固定按次、按秒和 Token 价格档允许零价。 +- 前台逻辑模型的统一价格允许零价。 +- 前台逻辑模型跟随渠道定价时,零价系统渠道线路仍可参与可用性和规格匹配。 +- 任务继续经过现有鉴权、并发、频率、配额、审计、账单创建和结算流程,金额为 `0`。 + +## 非目标 + +- 不新增“免费”开关或数据库字段。 +- 不改变负价格的校验规则。 +- 不放宽管理员权限、渠道启用状态、模型启用状态或能力/规格匹配规则。 + +## 语义与数据流 + +价格有效性从“正数才有效”改为“非负数有效”。零价与正价一样进入公开模型目录;负数继续被管理端请求校验拒绝。 + +系统渠道目录会保留零价启用价格档,并将具有匹配有效价格档的模型标记为可用。普通用户前端继续只合并 `available=true` 的模型,因此无需为零价添加单独的客户端分支。 + +逻辑模型沿用现有路由可用性判断。统一定价为零时通过价格校验;跟随渠道价格时,零价渠道 SKU 可以匹配请求规格。 + +零价任务仍建立金额为 `0` 的账单订单和用量记录。积分余额、预留积分和退款金额保持不变,从而保留任务审计与运营统计,同时不绕过现有资源保护。 + +## 实现边界 + +- `backend/internal/service/price_validation.go` 统一价格有效性的判断。 +- 既有系统目录、逻辑模型目录和任务创建链路复用该判断,不新增平行免费路径。 +- 如零价订单在预扣或结算的数据库约束中不成立,仅在账单协调边界补足零金额分支,保持正价订单行为不变。 + +## 测试 + +- 零价固定按次、按秒和 Token 价格档通过有效性校验;负价格仍被拒绝。 +- 启用的零价系统渠道模型出现在公开目录中并标记为可用。 +- 零价系统渠道任务选择通过价格校验。 +- 统一定价为零的前台逻辑模型可用。 +- 零价账单不改变用户的可用或预留积分,并可以正常结算。 diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index f84a50a7b..e530eae79 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -478,19 +478,20 @@ export function modelOptionsFromChannels(channels: ModelChannel[]) { export function hasSystemModelPrice(channel: ModelChannel, model: string) { if (channel.scope !== "system") return true; - const positive = (value: number | undefined) => typeof value === "number" && Number.isFinite(value) && value > 0; + // 价格字段已由后端按“非负数”校验;0 表示免费模型,不能在目录重建时被过滤。 + const configured = (value: number | undefined) => typeof value === "number" && Number.isFinite(value) && value >= 0; return channel.modelCosts?.some((item) => { if (item.model !== model) return false; const tiers = item.logicalPriceTiers || []; if (tiers.length) { return tiers.some((tier) => tier.billingMode === "token" - ? [tier.inputTokenPriceMicrocredits, tier.outputTokenPriceMicrocredits, tier.cachedTokenPriceMicrocredits].some(positive) - : positive(tier.unitPriceMicrocredits)); + ? [tier.inputTokenPriceMicrocredits, tier.outputTokenPriceMicrocredits, tier.cachedTokenPriceMicrocredits].every(configured) + : configured(tier.unitPriceMicrocredits)); } if (item.billingMode === "token") { - return [item.inputTokenPriceMicrocredits, item.outputTokenPriceMicrocredits, item.cachedTokenPriceMicrocredits].some(positive); + return [item.inputTokenPriceMicrocredits, item.outputTokenPriceMicrocredits, item.cachedTokenPriceMicrocredits].every(configured); } - return positive(item.unitPriceMicrocredits); + return configured(item.unitPriceMicrocredits); }) === true; }