fix: 支持零价系统渠道模型分发 (#303)

* docs(spec): 模型计费 - 记录零价免费模型设计

* fix(billing): 模型定价 - 支持零价免费模型

* fix(config): 系统模型目录 - 支持零价模型
This commit is contained in:
ahyang
2026-08-24 21:32:56 +08:00
committed by GitHub
parent e56a33008a
commit a7a728d24a
8 changed files with 79 additions and 28 deletions

View File

@@ -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

View File

@@ -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 积分")

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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 校验前台模型的价格配置

View File

@@ -0,0 +1,10 @@
# 待测试清单
## 零价免费模型
1. 管理员设置启用系统渠道模型的按次价格为 `0` 后,普通用户刷新设置即可看到并成功创建任务。
2. 前台逻辑模型使用统一按次价格 `0` 后,普通用户可见并能成功创建任务。
3. 零价任务完成后,账单状态为已结算,用户可用积分和预留积分均不变化。
4. 零价 Token 任务在上游未返回 usage 时仍能以 `0` 结算;正价 Token 任务继续依赖 usage 结算。
5. 管理端提交任一负价格仍会被拒绝。
6. 未勾选价格已配置的默认零价模型仍不会向普通用户公开。

View File

@@ -0,0 +1,42 @@
# 零价免费模型设计
## 目标
管理员将系统渠道价格档或前台逻辑模型统一价格设为 `0` 时,该模型表示免费且可用。普通用户可以看到并创建对应任务,系统不扣减积分。
## 范围
- 系统渠道模型的固定按次、按秒和 Token 价格档允许零价。
- 前台逻辑模型的统一价格允许零价。
- 前台逻辑模型跟随渠道定价时,零价系统渠道线路仍可参与可用性和规格匹配。
- 任务继续经过现有鉴权、并发、频率、配额、审计、账单创建和结算流程,金额为 `0`。
## 非目标
- 不新增“免费”开关或数据库字段。
- 不改变负价格的校验规则。
- 不放宽管理员权限、渠道启用状态、模型启用状态或能力/规格匹配规则。
## 语义与数据流
价格有效性从“正数才有效”改为“非负数有效”。零价与正价一样进入公开模型目录;负数继续被管理端请求校验拒绝。
系统渠道目录会保留零价启用价格档,并将具有匹配有效价格档的模型标记为可用。普通用户前端继续只合并 `available=true` 的模型,因此无需为零价添加单独的客户端分支。
逻辑模型沿用现有路由可用性判断。统一定价为零时通过价格校验;跟随渠道价格时,零价渠道 SKU 可以匹配请求规格。
零价任务仍建立金额为 `0` 的账单订单和用量记录。积分余额、预留积分和退款金额保持不变,从而保留任务审计与运营统计,同时不绕过现有资源保护。
## 实现边界
- `backend/internal/service/price_validation.go` 统一价格有效性的判断。
- 既有系统目录、逻辑模型目录和任务创建链路复用该判断,不新增平行免费路径。
- 如零价订单在预扣或结算的数据库约束中不成立,仅在账单协调边界补足零金额分支,保持正价订单行为不变。
## 测试
- 零价固定按次、按秒和 Token 价格档通过有效性校验;负价格仍被拒绝。
- 启用的零价系统渠道模型出现在公开目录中并标记为可用。
- 零价系统渠道任务选择通过价格校验。
- 统一定价为零的前台逻辑模型可用。
- 零价账单不改变用户的可用或预留积分,并可以正常结算。

View File

@@ -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;
}