277 lines
11 KiB
Go
277 lines
11 KiB
Go
// 渠道模型价格档:选择器规范化、档位能力与计价校验、摘要回填。
|
|
|
|
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"infinite-canvas/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
|
|
}
|