Files
ttstd-infinite-canvas/backend/internal/app/channel_models_pricing.go
T

277 lines
11 KiB
Go

// 渠道模型价格档:选择器规范化、档位能力与计价校验、摘要回填。
package app
import (
"fmt"
"strconv"
"strings"
"yingce/backend/internal/model"
)
func validateChannelModelTierCapabilities(tiers []model.ChannelModelPriceTier, rawCapabilityConfig string, capability string) error {
if capability != "video" {
return nil
}
config, err := DecodeModelCapabilityConfig(rawCapabilityConfig)
if err != nil || config == nil || config.Video == nil {
return BadAuthRequest("视频模型能力配置无效,无法校验价格档规格")
}
resolutionSupported := make(map[string]bool, len(config.Video.Resolutions))
for _, resolution := range config.Video.Resolutions {
resolutionSupported[normalizeChannelModelTierResolution(resolution)] = true
}
// Keep preset prices when narrowing the selected list; tasks use only selected values.
if config.Video.FixedScreenSpec != nil {
for _, resolution := range config.Video.FixedScreenSpec.Resolutions {
resolutionSupported[normalizeChannelModelTierResolution(resolution)] = true
}
}
durationSupported := make(map[int]bool, len(config.Video.Duration.Values))
for _, seconds := range config.Video.Duration.Values {
durationSupported[seconds] = true
}
for _, tier := range tiers {
if tier.Resolution != "*" && !resolutionSupported[normalizeChannelModelTierResolution(tier.Resolution)] {
return BadAuthRequest("价格档分辨率不在该视频模型支持范围内:" + tier.Resolution)
}
if tier.VideoSeconds == 0 {
continue
}
if !videoDurationSupported(config.Video) {
continue
}
if config.Video.Duration.Selection == "enum" && !durationSupported[tier.VideoSeconds] {
return BadAuthRequest(fmt.Sprintf("价格档时长 %d 秒不在该视频模型支持范围内", tier.VideoSeconds))
}
if config.Video.Duration.Selection == "range" && (tier.VideoSeconds < config.Video.Duration.Min || tier.VideoSeconds > config.Video.Duration.Max || (config.Video.Duration.Step > 0 && (tier.VideoSeconds-config.Video.Duration.Min)%config.Video.Duration.Step != 0)) {
return BadAuthRequest(fmt.Sprintf("价格档时长 %d 秒不在该视频模型支持范围内", tier.VideoSeconds))
}
}
return nil
}
// syncLogicalModelsFromChannelModel 只失效路由目录。系统渠道 SKU 与前台模型目录
// 分别维护:保存渠道模型绝不能自动创建、覆盖或删除前台模型及其线路配置。
func (s *Service) syncLogicalModelsFromChannelModel(actor *model.User, channelModel *model.ChannelModel) error {
_ = actor
_ = channelModel
s.invalidateRouteCatalog()
return nil
}
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) {
inputs := req.PriceTiers
// 兼容旧管理 API:没有 priceTiers 的请求等价于一个默认价格档。
if len(inputs) == 0 {
enabled := true
inputs = []ChannelModelPriceTierRequest{{
Resolution: "*", VideoSeconds: 0, ProviderModelKey: fallbackProviderModelKey,
BillingMode: req.BillingMode, UnitPriceMicrocredits: req.UnitPriceMicrocredits,
InputTokenPriceMicrocredits: req.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: req.OutputTokenPriceMicrocredits,
CachedTokenPriceMicrocredits: req.CachedTokenPriceMicrocredits, PriceConfigured: req.PriceConfigured, Enabled: &enabled,
}}
}
result := make([]model.ChannelModelPriceTier, 0, len(inputs))
seen := make(map[string]bool, len(inputs))
for _, input := range inputs {
selector, resolution, videoSeconds, selectorErr := normalizeChannelModelTierSelector(capability, input)
if selectorErr != nil {
return nil, selectorErr
}
_, key, keyErr := model.CanonicalSKUSelector(selector)
if keyErr != nil {
return nil, keyErr
}
if seen[key] {
return nil, BadAuthRequest("同一个操作和规格组合只能配置一个价格档")
}
seen[key] = true
billingMode := strings.TrimSpace(input.BillingMode)
if billingMode == "" {
billingMode = "fixed_request"
}
if err := validateChannelModelTierPricing(capability, protocol, billingMode, input); err != nil {
return nil, err
}
id, idErr := s.repo.NextPrefixedID("PTIER")
if idErr != nil {
return nil, idErr
}
enabled := input.Enabled == nil || *input.Enabled
result = append(result, model.ChannelModelPriceTier{
CostPricing: input.CostPricing,
ID: id,
SelectorKey: key,
SelectorJSON: key,
Selector: selector,
Resolution: resolution,
VideoSeconds: videoSeconds,
ProviderModelKey: strings.TrimPrefix(strings.TrimSpace(firstNonEmpty(input.ProviderModelKey, fallbackProviderModelKey)), "models/"),
BillingMode: billingMode,
UnitPriceMicrocredits: input.UnitPriceMicrocredits,
InputTokenPriceMicrocredits: input.InputTokenPriceMicrocredits,
OutputTokenPriceMicrocredits: input.OutputTokenPriceMicrocredits,
CachedTokenPriceMicrocredits: input.CachedTokenPriceMicrocredits,
PriceConfigured: input.PriceConfigured,
Enabled: enabled,
PriceVersion: 1,
})
}
return result, nil
}
func normalizeChannelModelTierSelector(capability string, input ChannelModelPriceTierRequest) (map[string]string, string, int, error) {
selector := make(map[string]string, len(input.Selector)+3)
for rawKey, rawValue := range input.Selector {
key := strings.TrimSpace(rawKey)
value := strings.TrimSpace(rawValue)
if key == "" || value == "" {
continue
}
switch key {
case "operation":
value = strings.ToLower(value)
case "quality", "size":
value = strings.ToLower(value)
if value == "auto" || value == "any" {
value = "*"
}
case "vquality":
value = normalizeChannelModelTierResolution(value)
case "videoSeconds":
seconds, err := strconv.Atoi(value)
if err != nil || seconds < 0 {
return nil, "", 0, BadAuthRequest("视频价格档时长必须是非负整数")
}
if seconds == 0 {
continue
}
value = strconv.Itoa(seconds)
case "imageCount":
count, err := strconv.Atoi(value)
if err != nil || count < 0 {
return nil, "", 0, BadAuthRequest("参考图片数量必须是非负整数")
}
if count == 0 {
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)
}
selector[key] = value
}
if capability == "video" {
if _, exists := selector["vquality"]; !exists {
if resolution := normalizeChannelModelTierResolution(input.Resolution); resolution != "*" {
selector["vquality"] = resolution
}
}
if _, exists := selector["videoSeconds"]; !exists && input.VideoSeconds > 0 {
selector["videoSeconds"] = strconv.Itoa(input.VideoSeconds)
}
} else if input.Resolution != "" && normalizeChannelModelTierResolution(input.Resolution) != "*" {
return nil, "", 0, BadAuthRequest("非视频模型不能使用视频分辨率价格档")
} else if input.VideoSeconds != 0 {
return nil, "", 0, BadAuthRequest("非视频模型不能使用视频时长价格档")
}
for _, key := range []string{"quality", "size"} {
if _, exists := selector[key]; exists && capability != "image" {
return nil, "", 0, BadAuthRequest("只有图片模型可以按 " + key + " 配置价格档")
}
}
if _, exists := selector["vquality"]; exists && capability != "video" {
return nil, "", 0, BadAuthRequest("只有视频模型可以按分辨率配置价格档")
}
if _, exists := selector["videoSeconds"]; exists && capability != "video" {
return nil, "", 0, BadAuthRequest("只有视频模型可以按时长配置价格档")
}
if _, exists := selector["imageCount"]; exists && capability != "video" {
return nil, "", 0, BadAuthRequest("只有视频模型可以按参考图片数量配置价格档")
}
resolution := "*"
if value := selector["vquality"]; value != "" {
resolution = value
}
videoSeconds := 0
if value := selector["videoSeconds"]; value != "" {
videoSeconds, _ = strconv.Atoi(value)
}
return selector, resolution, videoSeconds, nil
}
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")
}
if billingMode == "per_second" && capability != "video" && capability != "audio" {
return BadAuthRequest("只有视频或音频模型可以按秒计费")
}
if billingMode == "token" && !supportsTokenBilling(capability, protocol) {
return BadAuthRequest("Token 计费仅支持文本和视频模型")
}
if input.UnitPriceMicrocredits < 0 || input.InputTokenPriceMicrocredits < 0 || input.OutputTokenPriceMicrocredits < 0 || input.CachedTokenPriceMicrocredits < 0 {
return BadAuthRequest("模型积分价格不能小于 0")
}
if billingMode == "token" {
return validateTokenPrices(capability, input.InputTokenPriceMicrocredits, input.OutputTokenPriceMicrocredits, input.CachedTokenPriceMicrocredits)
}
return validateTokenPrices("", input.InputTokenPriceMicrocredits, input.OutputTokenPriceMicrocredits, input.CachedTokenPriceMicrocredits)
}
func normalizeChannelModelTierResolution(raw string) string {
value := strings.TrimSpace(raw)
if value == "" || value == "*" || strings.EqualFold(value, "any") {
return "*"
}
normalized := normalizeModelRequestOption("vquality", value)
return strings.ToLower(strings.TrimSpace(fmt.Sprint(normalized)))
}
func (s *Service) applyChannelModelPriceTierSummary(item *model.ChannelModel, tiers []model.ChannelModelPriceTier) {
item.PriceConfigured = false
item.BillingMode = "fixed_request"
item.UnitPriceMicrocredits = 0
item.InputTokenPriceMicrocredits = 0
item.OutputTokenPriceMicrocredits = 0
item.CachedTokenPriceMicrocredits = 0
var summary *model.ChannelModelPriceTier
for index := range tiers {
tier := &tiers[index]
if tier.Enabled && tier.PriceConfigured {
item.PriceConfigured = true
}
if summary == nil || (tier.Resolution == "*" && tier.VideoSeconds == 0) {
summary = tier
}
}
if summary == nil {
return
}
item.BillingMode = summary.BillingMode
item.UnitPriceMicrocredits = summary.UnitPriceMicrocredits
item.InputTokenPriceMicrocredits = summary.InputTokenPriceMicrocredits
item.OutputTokenPriceMicrocredits = summary.OutputTokenPriceMicrocredits
item.CachedTokenPriceMicrocredits = summary.CachedTokenPriceMicrocredits
}