fix(*): 模型目录 - 修复前台开关、系统模型价格与访问控制

This commit is contained in:
ddCat committed 2026-08-22 20:57:31 +08:00
1 parent 5d9986f6c7
commit 1bb5d1423a
19 files changed
+989 -84

No files matched your search

+1
View File
@@ -90,6 +90,7 @@ func main() {
// 登录态模型目录代理:避免浏览器直连各上游时分别处理 CORS。
handler.RegisterChannelModelRoutes(api, svc)
handler.RegisterLogicalModelRoutes(api, svc)
handler.RegisterModelCatalogRoutes(api, svc)
handler.RegisterSystemProxyRoutes(api, svc)
handler.RegisterCustomRelayRoutes(api, svc)
handler.RegisterTaskRoutes(api, svc)
+80
View File
@@ -0,0 +1,80 @@
package handler
import (
"errors"
"net/http"
"infinite-canvas/backend/internal/service"
"github.com/gin-gonic/gin"
)
// RegisterModelCatalogRoutes 注册统一模型目录路由
func RegisterModelCatalogRoutes(r *gin.RouterGroup, svc *service.Service) {
r.GET("/model-catalog", func(c *gin.Context) {
if _, err := currentUser(c, svc); err != nil {
failService(c, err)
return
}
catalog, err := svc.ModelCatalog(nil)
if err != nil {
failService(c, err)
return
}
ok(c, catalog)
})
r.POST("/model-catalog/available", func(c *gin.Context) {
if _, err := currentUser(c, svc); err != nil {
failService(c, err)
return
}
var intent service.ModelRequestIntent
if err := c.ShouldBindJSON(&intent); err != nil {
fail(c, http.StatusBadRequest, errors.New("模型能力请求格式错误"))
return
}
catalog, err := svc.ModelCatalog(&intent)
if err != nil {
failService(c, err)
return
}
ok(c, catalog)
})
r.POST("/model-catalog/quote", func(c *gin.Context) {
if _, err := currentUser(c, svc); err != nil {
failService(c, err)
return
}
var req struct {
ModelID string `json:"modelId"`
Intent service.ModelRequestIntent `json:"intent"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, errors.New("模型报价请求格式错误"))
return
}
// 根据 frontendModelsEnabled 决定使用哪种报价方式
frontendEnabled, err := svc.FeatureEnabled(service.FeatureFrontendModels)
if err != nil {
failService(c, err)
return
}
if frontendEnabled {
// 使用前台模型报价
quote, err := svc.QuoteLogicalModel(req.ModelID, req.Intent)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"quote": quote})
} else {
// 使用系统渠道模型报价
// TODO: 实现系统渠道模型报价逻辑
fail(c, http.StatusNotImplemented, errors.New("系统渠道模型报价功能尚未实现"))
}
})
}
@@ -80,3 +80,26 @@ type RouteAttempt struct {
StartedAt time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt"`
}
// LogicalModelPriceSKU 表示前台模型在统一定价模式下的独立售价 SKU。
// 当 pricePolicy = unified 时,用户价格由此 SKU 决定,而非渠道价格。
// SelectorJSON 统一使用结构化意图选择器,支持复杂的能力组合定价。
type LogicalModelPriceSKU struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
LogicalModelRevisionID string `json:"logicalModelRevisionId" gorm:"size:36;index;uniqueIndex:idx_logical_sku_active,priority:1,where:deleted_at IS NULL"`
Code string `json:"code" gorm:"size:80"`
Name string `json:"name" gorm:"size:160"`
SelectorKey string `json:"selectorKey" gorm:"size:500;not null;default:{};uniqueIndex:idx_logical_sku_active,priority:2,where:deleted_at IS NULL"`
SelectorJSON string `json:"-" gorm:"type:text;not null;default:{}"`
BillingMode string `json:"billingMode" gorm:"size:32"`
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
Priority int `json:"priority" gorm:"index"`
Enabled bool `json:"enabled" gorm:"index"`
Version int `json:"version"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"-" gorm:"index"`
}
+123 -1
View File
@@ -1,5 +1,7 @@
package service
import "fmt"
// AppError 是 service 层对外公开的结构化错误。
// Message 必须可安全展示给用户,Cause 仅用于保留内部诊断链路,不得直接写入 HTTP 响应。
type AppError struct {
@@ -9,7 +11,6 @@ type AppError struct {
Retryable bool
Cause error
}
func (e *AppError) Error() string {
if e == nil {
return ""
@@ -31,3 +32,124 @@ func NewAppError(status int, message string) *AppError {
func WrapAppError(status int, message string, cause error) *AppError {
return &AppError{Status: status, Message: message, Cause: cause}
}
// ModelErrorCode 定义模型相关的错误码
type ModelErrorCode string
const (
// ErrCodeModelCapabilityNotSupported 当前模型能力不支持请求
ErrCodeModelCapabilityNotSupported ModelErrorCode = "model_capability_not_supported"
// ErrCodeModelPriceNotConfigured 当前模型未配置该能力组合价格
ErrCodeModelPriceNotConfigured ModelErrorCode = "model_price_not_configured"
// ErrCodeModelRouteUnavailable 没有可用供应线路
ErrCodeModelRouteUnavailable ModelErrorCode = "model_route_unavailable"
// ErrCodeProviderRequestFailed 供应商异常、响应格式错误或上游失败
ErrCodeProviderRequestFailed ModelErrorCode = "provider_request_failed"
// ErrCodeModelCatalogMismatch 模型目录已更新,请重新选择
ErrCodeModelCatalogMismatch ModelErrorCode = "model_catalog_mismatch"
// ErrCodeInvalidModelSelection 无效的模型选择
ErrCodeInvalidModelSelection ModelErrorCode = "invalid_model_selection"
)
// ModelError 模型相关的错误,包含错误码和详细信息
// 继承 AppError 以保持兼容性
type ModelError struct {
*AppError
ErrorCode ModelErrorCode
Details map[string]any
}
func (e *ModelError) Error() string {
if e.AppError != nil {
return e.AppError.Error()
}
return string(e.ErrorCode)
}
// NewModelError 创建模型错误
func NewModelError(code ModelErrorCode, message string) *ModelError {
return &ModelError{
AppError: NewAppError(400, message),
ErrorCode: code,
Details: make(map[string]any),
}
}
// WithDetails 添加错误详情
func (e *ModelError) WithDetails(details map[string]any) *ModelError {
e.Details = details
return e
}
// ModelCapabilityNotSupported 当前模型能力不支持请求
func ModelCapabilityNotSupported(message string) error {
if message == "" {
message = "当前模型不支持该能力"
}
return NewModelError(ErrCodeModelCapabilityNotSupported, message)
}
// ModelPriceNotConfigured 当前模型未配置价格
func ModelPriceNotConfigured(message string) error {
if message == "" {
message = "当前模型未配置该能力组合价格"
}
return NewModelError(ErrCodeModelPriceNotConfigured, message)
}
// ModelRouteUnavailable 没有可用供应线路
func ModelRouteUnavailable(message string) error {
if message == "" {
message = "没有可用供应线路"
}
return NewModelError(ErrCodeModelRouteUnavailable, message)
}
// ProviderRequestFailed 供应商请求失败
func ProviderRequestFailed(message string) error {
if message == "" {
message = "模型服务返回失败,请检查请求内容或渠道配置"
}
return NewModelError(ErrCodeProviderRequestFailed, message)
}
// ModelCatalogMismatch 模型目录已更新
func ModelCatalogMismatch(message string) error {
if message == "" {
message = "模型目录已更新,请重新选择"
}
return NewModelError(ErrCodeModelCatalogMismatch, message)
}
// InvalidModelSelection 无效的模型选择
func InvalidModelSelection(message string) error {
if message == "" {
message = "无效的模型选择"
}
return NewModelError(ErrCodeInvalidModelSelection, message)
}
// IsModelError 判断是否为模型错误
func IsModelError(err error) bool {
_, ok := err.(*ModelError)
return ok
}
// GetModelErrorCode 获取模型错误码
func GetModelErrorCode(err error) ModelErrorCode {
if modelErr, ok := err.(*ModelError); ok {
return modelErr.ErrorCode
}
return ""
}
// FormatModelError 格式化模型错误消息
func FormatModelError(err error) string {
if modelErr, ok := err.(*ModelError); ok {
if len(modelErr.Details) > 0 {
return fmt.Sprintf("%s (错误码: %s)", modelErr.Message, modelErr.ErrorCode)
}
return modelErr.Message
}
return err.Error()
}
@@ -14,10 +14,11 @@ import (
const featureAvailabilitySettingKey = "feature_availability"
const (
FeatureShortDrama = "shortDrama"
FeatureTaskCenter = "taskCenter"
FeatureCredits = "credits"
FeatureCustomChannels = "customChannels"
FeatureShortDrama = "shortDrama"
FeatureTaskCenter = "taskCenter"
FeatureCredits = "credits"
FeatureCustomChannels = "customChannels"
FeatureFrontendModels = "frontendModels"
)
type FeatureAvailability struct {
@@ -25,6 +26,7 @@ type FeatureAvailability struct {
TaskCenterEnabled bool `json:"taskCenterEnabled"`
CreditsEnabled bool `json:"creditsEnabled"`
CustomChannelsEnabled bool `json:"customChannelsEnabled"`
FrontendModelsEnabled bool `json:"frontendModelsEnabled"`
}
type PublicFeatureAvailability struct {
@@ -37,7 +39,7 @@ type PublicFeatureAvailability struct {
func defaultFeatureAvailability() FeatureAvailability {
// 缺少配置代表尚未由运维接管,默认保持现有功能全部开放。
return FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: true, CustomChannelsEnabled: true}
return FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: true, CustomChannelsEnabled: true, FrontendModelsEnabled: true}
}
func (s *Service) FeatureAvailability() (*PublicFeatureAvailability, error) {
@@ -94,6 +96,8 @@ func (s *Service) FeatureEnabled(feature string) (bool, error) {
return value.CreditsEnabled, nil
case FeatureCustomChannels:
return value.CustomChannelsEnabled, nil
case FeatureFrontendModels:
return value.FrontendModelsEnabled, nil
default:
return false, errors.New("未知功能开放配置")
}
@@ -116,6 +120,8 @@ func (s *Service) RequireFeature(feature string) error {
return Forbidden("积分功能暂未开放")
case FeatureCustomChannels:
return Forbidden("自定义渠道暂未开放")
case FeatureFrontendModels:
return Forbidden("前台模型目录暂未开放")
default:
return Forbidden("该功能暂未开放")
}
+74 -2
View File
@@ -41,7 +41,6 @@ type LogicalModelRequest struct {
// SourceChannelModelID 仅供系统渠道同步流程使用,前台模型不再拥有独立的能力和价格真相。
SourceChannelModelID string `json:"-"`
}
type LogicalRouteRequest struct {
ChannelModelID string `json:"channelModelId"`
Enabled bool `json:"enabled"`
@@ -58,6 +57,9 @@ type PublicLogicalModel struct {
Capability string `json:"capability"`
SortOrder int `json:"sortOrder"`
PricePolicy string `json:"pricePolicy"`
PricingMode string `json:"pricingMode"`
DisplayPrice *int64 `json:"displayPrice,omitempty"`
PriceLabel string `json:"priceLabel"`
BillingMode string `json:"billingMode"`
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"`
@@ -180,7 +182,23 @@ func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogical
profiles = append(profiles, route.CapabilitySpec)
}
}
return PublicLogicalModel{ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, PricePolicy: item.PricePolicy, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputPriceMicrocredits: item.InputPriceMicrocredits, OutputPriceMicrocredits: item.OutputPriceMicrocredits, CachedPriceMicrocredits: item.CachedPriceMicrocredits, PriceTiers: publicLogicalModelPriceTiers(cached), LegacyModelIDs: decodeLegacyModelIDs(item.LegacyModelIDsJSON), CapabilitySpec: productSpec, CapabilityProfiles: profiles, DefaultOptions: cached.Defaults, Available: available}
priceTiers := publicLogicalModelPriceTiers(cached)
pricingMode, displayPrice, priceLabel := computeModelPriceDisplay(item, priceTiers)
return PublicLogicalModel{
ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon,
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,
OutputPriceMicrocredits: item.OutputPriceMicrocredits,
CachedPriceMicrocredits: item.CachedPriceMicrocredits,
PriceTiers: priceTiers, LegacyModelIDs: decodeLegacyModelIDs(item.LegacyModelIDsJSON),
CapabilitySpec: productSpec, CapabilityProfiles: profiles,
DefaultOptions: cached.Defaults, Available: available,
}
}
func publicLogicalModelPriceTiers(cached cachedLogicalModel) []PublicLogicalModelPriceTier {
@@ -1060,3 +1078,57 @@ func boolValues(supportsTrue bool) OptionConstraint {
func numericRange(minimum float64, maximum float64, step float64) OptionConstraint {
return OptionConstraint{Min: &minimum, Max: &maximum, Step: &step}
}
// computeModelPriceDisplay 计算模型的价格展示信息
// 返回:pricingMode, displayPrice, priceLabel
func computeModelPriceDisplay(model model.LogicalModel, priceTiers []PublicLogicalModelPriceTier) (string, *int64, string) {
if model.PricePolicy == "channel" {
// 跟随渠道价格
if len(priceTiers) == 0 {
return "provider", nil, "未配置"
}
// 检查是否所有价格档都相同
if len(priceTiers) == 1 {
tier := priceTiers[0]
price := getTierDisplayPrice(tier)
if price > 0 {
return "provider", &price, ""
}
}
// 多个价格档或价格为0,显示"按渠道规格计费"
return "provider", nil, "按渠道规格计费"
}
// 统一定价模式
if model.BillingMode == "fixed_request" && model.UnitPriceMicrocredits > 0 {
price := model.UnitPriceMicrocredits
return "unified", &price, ""
}
if model.BillingMode == "per_second" && model.UnitPriceMicrocredits > 0 {
price := model.UnitPriceMicrocredits
return "unified", &price, "按秒"
}
if model.BillingMode == "token" {
// Token 计费显示输入/输出价格
if model.InputPriceMicrocredits > 0 || model.OutputPriceMicrocredits > 0 {
return "unified", nil, "按 Token"
}
}
return "unified", nil, "未配置"
}
// getTierDisplayPrice 获取价格档的展示价格
func getTierDisplayPrice(tier PublicLogicalModelPriceTier) int64 {
if tier.BillingMode == "fixed_request" || tier.BillingMode == "per_second" {
return tier.UnitPriceMicrocredits
}
// Token 计费返回输出价格(如果有)
if tier.OutputTokenPriceMicrocredits > 0 {
return tier.OutputTokenPriceMicrocredits
}
if tier.InputTokenPriceMicrocredits > 0 {
return tier.InputTokenPriceMicrocredits
}
return 0
}
+247
View File
@@ -0,0 +1,247 @@
package service
import (
"encoding/json"
"infinite-canvas/backend/internal/model"
)
// ModelCatalogSource 表示模型目录来源
type ModelCatalogSource string
const (
// ModelCatalogSourceFrontend 前台模型虚拟渠道
ModelCatalogSourceFrontend ModelCatalogSource = "frontend"
// ModelCatalogSourceSystem 脱敏的系统渠道模型
ModelCatalogSourceSystem ModelCatalogSource = "system"
)
// ModelCatalogResponse 统一模型目录响应
type ModelCatalogResponse struct {
Source ModelCatalogSource `json:"source"`
Models []PublicLogicalModel `json:"models,omitempty"`
Channels []PublicChannelCatalog `json:"channels,omitempty"`
}
// PublicChannelCatalog 公开的渠道目录信息(脱敏)
type PublicChannelCatalog struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Models []PublicChannelModel `json:"models"`
}
// PublicChannelModel 公开的渠道模型信息(脱敏)
type PublicChannelModel struct {
ID string `json:"id"`
ModelKey string `json:"modelKey"`
DisplayName string `json:"displayName"`
Capability string `json:"capability"`
CapabilityConfig map[string]any `json:"capabilityConfig,omitempty"`
PriceTiers []PublicChannelModelPriceTier `json:"priceTiers"`
PricingMode string `json:"pricingMode"`
DisplayPrice *int64 `json:"displayPrice,omitempty"`
PriceLabel string `json:"priceLabel"`
Available bool `json:"available"`
}
// PublicChannelModelPriceTier 公开的渠道模型价格档(脱敏)
type PublicChannelModelPriceTier struct {
ID string `json:"id"`
Selector map[string]string `json:"selector,omitempty"`
Resolution string `json:"resolution"`
VideoSeconds int `json:"videoSeconds"`
BillingMode string `json:"billingMode"`
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
}
// ModelCatalog 返回统一的模型目录
// 根据 frontendModelsEnabled 开关返回前台模型或系统渠道模型
func (s *Service) ModelCatalog(intent *ModelRequestIntent) (*ModelCatalogResponse, error) {
frontendEnabled, err := s.FeatureEnabled(FeatureFrontendModels)
if err != nil {
return nil, err
}
if frontendEnabled {
// 返回前台模型目录
models, err := s.PublicLogicalModels(intent)
if err != nil {
return nil, err
}
return &ModelCatalogResponse{
Source: ModelCatalogSourceFrontend,
Models: models,
}, nil
}
// 返回脱敏的系统渠道模型目录
channels, err := s.publicSystemChannelCatalog(intent)
if err != nil {
return nil, err
}
return &ModelCatalogResponse{
Source: ModelCatalogSourceSystem,
Channels: channels,
}, nil
}
// publicSystemChannelCatalog 返回脱敏后的系统渠道模型目录
// 只包含普通用户可见的信息,不包含密钥、Base URL、供应商信息等
func (s *Service) publicSystemChannelCatalog(intent *ModelRequestIntent) ([]PublicChannelCatalog, error) {
channels, err := s.repo.SystemChannels(true)
if err != nil {
return nil, err
}
result := make([]PublicChannelCatalog, 0, len(channels))
for _, channel := range channels {
if !channel.Enabled {
continue
}
channelModels, err := s.repo.ChannelModels(channel.ID, false)
if err != nil {
return nil, err
}
publicModels := make([]PublicChannelModel, 0, len(channelModels))
for _, cm := range channelModels {
if !cm.Enabled {
continue
}
// 如果提供了意图,进行能力过滤
if intent != nil && !s.channelModelMatchesIntent(&cm, intent) {
continue
}
publicModel := s.sanitizeChannelModel(&cm)
publicModels = append(publicModels, publicModel)
}
if len(publicModels) > 0 {
result = append(result, PublicChannelCatalog{
ID: channel.ID,
Name: channel.Name,
DisplayName: channel.Name,
Models: publicModels,
})
}
}
return result, nil
}
// sanitizeChannelModel 脱敏渠道模型,只保留用户可见的信息
func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) PublicChannelModel {
// 价格档已经在 cm.PriceTiers 中加载
priceTiers := cm.PriceTiers
// 转换为公开的价格档
publicTiers := make([]PublicChannelModelPriceTier, 0, len(priceTiers))
for _, tier := range priceTiers {
if !tier.Enabled || !ValidatePriceTierPrice(&tier) {
continue
}
publicTiers = append(publicTiers, PublicChannelModelPriceTier{
ID: tier.ID,
Selector: model.DecodeSKUSelector(tier.SelectorJSON),
Resolution: tier.Resolution,
VideoSeconds: tier.VideoSeconds,
BillingMode: tier.BillingMode,
UnitPriceMicrocredits: tier.UnitPriceMicrocredits,
InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits,
OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits,
CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits,
})
}
// 计算价格展示
pricingMode, displayPrice, priceLabel := computeChannelModelPriceDisplay(cm, publicTiers)
// 解析能力配置
var capabilityConfig map[string]any
if cm.CapabilityConfigJSON != "" {
config, _ := DecodeModelCapabilityConfig(cm.CapabilityConfigJSON)
if config != nil {
normalized, _ := NormalizeModelCapabilityConfig(cm.Capability, string(cm.Protocol), config)
if normalized != nil {
// 转换为 map[string]any
capabilityConfig = modelCapabilityConfigToMap(normalized)
}
}
}
return PublicChannelModel{
ID: cm.ID,
ModelKey: cm.ModelKey,
DisplayName: cm.DisplayName,
Capability: cm.Capability,
CapabilityConfig: capabilityConfig,
PriceTiers: publicTiers,
PricingMode: pricingMode,
DisplayPrice: displayPrice,
PriceLabel: priceLabel,
Available: len(publicTiers) > 0 || (len(priceTiers) == 0 && HasValidPrice(cm)),
}
}
// computeChannelModelPriceDisplay 计算渠道模型的价格展示信息
func computeChannelModelPriceDisplay(cm *model.ChannelModel, priceTiers []PublicChannelModelPriceTier) (string, *int64, string) {
if len(priceTiers) == 0 {
return "provider", nil, "未配置"
}
if len(priceTiers) == 1 {
tier := priceTiers[0]
price := getChannelTierDisplayPrice(tier)
if price > 0 {
return "provider", &price, ""
}
}
// 多个价格档,显示"按渠道规格计费"
return "provider", nil, "按渠道规格计费"
}
// getChannelTierDisplayPrice 获取渠道价格档的展示价格
func getChannelTierDisplayPrice(tier PublicChannelModelPriceTier) int64 {
if tier.BillingMode == "fixed_request" || tier.BillingMode == "per_second" {
return tier.UnitPriceMicrocredits
}
if tier.OutputTokenPriceMicrocredits > 0 {
return tier.OutputTokenPriceMicrocredits
}
if tier.InputTokenPriceMicrocredits > 0 {
return tier.InputTokenPriceMicrocredits
}
return 0
}
// channelModelMatchesIntent 检查渠道模型是否匹配意图
func (s *Service) channelModelMatchesIntent(cm *model.ChannelModel, intent *ModelRequestIntent) bool {
if intent.Capability != "" && cm.Capability != intent.Capability {
return false
}
// 这里可以添加更多的能力匹配逻辑
return true
}
// modelCapabilityConfigToMap 将 ModelCapabilityConfig 转换为 map[string]any
func modelCapabilityConfigToMap(config *ModelCapabilityConfig) map[string]any {
if config == nil {
return nil
}
encoded, err := json.Marshal(config)
if err != nil {
return nil
}
var result map[string]any
if err := json.Unmarshal(encoded, &result); err != nil {
return nil
}
return result
}
@@ -0,0 +1,79 @@
package service
import "infinite-canvas/backend/internal/model"
// ValidateChannelModelPrice 校验渠道模型价格配置的有效性
// 根据 billingMode 检查必要的价格字段是否已配置
func ValidateChannelModelPrice(billingMode string, capability string, protocol model.ChannelInterfaceType, unitPrice, inputPrice, outputPrice, cachedPrice int64) bool {
switch billingMode {
case "fixed_request":
// 固定价格模式:必须配置单价且大于 0
return unitPrice > 0
case "per_second":
// 按秒计费:必须配置单价且大于 0
return unitPrice > 0
case "token":
// Token 计费:根据协议要求校验
if capability == "video" && (protocol == model.ChannelInterfaceVolcengineArkVideo || protocol == model.ChannelInterfaceVolcengineJiMengVideo) {
// 火山方舟视频必须配置输出价格
return outputPrice > 0
}
// 文本模型:至少配置一项价格
return inputPrice > 0 || outputPrice > 0 || cachedPrice > 0
default:
return false
}
}
// ValidatePriceTierPrice 校验价格档的价格配置有效性
func ValidatePriceTierPrice(tier *model.ChannelModelPriceTier) bool {
if tier == nil {
return false
}
return ValidateChannelModelPrice(tier.BillingMode, "", "", tier.UnitPriceMicrocredits, tier.InputTokenPriceMicrocredits, tier.OutputTokenPriceMicrocredits, tier.CachedTokenPriceMicrocredits)
}
// ComputePriceConfigured 根据价格内容计算 PriceConfigured 标志
// 不再允许手动设置 PriceConfigured,必须由价格字段派生
func ComputePriceConfigured(billingMode string, capability string, protocol model.ChannelInterfaceType, unitPrice, inputPrice, outputPrice, cachedPrice int64) bool {
return ValidateChannelModelPrice(billingMode, capability, protocol, unitPrice, inputPrice, outputPrice, cachedPrice)
}
// ComputeTierPriceConfigured 计算价格档的 PriceConfigured 标志
func ComputeTierPriceConfigured(tier *model.ChannelModelPriceTier) bool {
if tier == nil {
return false
}
return ComputePriceConfigured(tier.BillingMode, "", "", tier.UnitPriceMicrocredits, tier.InputTokenPriceMicrocredits, tier.OutputTokenPriceMicrocredits, tier.CachedTokenPriceMicrocredits)
}
// HasValidPrice 检查渠道模型是否有有效价格
// 对于有价格档的模型,检查是否至少有一个启用且价格有效的价格档
func HasValidPrice(channelModel *model.ChannelModel) bool {
if channelModel == nil {
return false
}
// 如果有价格档,检查价格档
if len(channelModel.PriceTiers) > 0 {
for _, tier := range channelModel.PriceTiers {
if tier.Enabled && ValidatePriceTierPrice(&tier) {
return true
}
}
return false
}
// 否则检查模型级别的价格
return ValidateChannelModelPrice(channelModel.BillingMode, channelModel.Capability, channelModel.Protocol, channelModel.UnitPriceMicrocredits, channelModel.InputTokenPriceMicrocredits, channelModel.OutputTokenPriceMicrocredits, channelModel.CachedTokenPriceMicrocredits)
}
// ValidateLogicalModelPrice 校验前台模型的价格配置
func ValidateLogicalModelPrice(pricePolicy string, billingMode string, unitPrice, inputPrice, outputPrice, cachedPrice int64) bool {
if pricePolicy == "channel" {
// 跟随渠道价格,不需要校验前台价格
return true
}
// 统一定价模式,按 billingMode 校验
return ValidateChannelModelPrice(billingMode, "", "", unitPrice, inputPrice, outputPrice, cachedPrice)
}
+66 -1
View File
@@ -25,16 +25,38 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
if err != nil {
return nil, err
}
// 根据 frontendModelsEnabled 开关强制分流模型选择
frontendEnabled, err := s.FeatureEnabled(FeatureFrontendModels)
if err != nil {
return nil, err
}
var routed *RoutedModel
logicalModelID := strings.TrimSpace(req.LogicalModelID)
if logicalModelID != "" {
if frontendEnabled {
// 前台模型模式:必须有 logicalModelId
if logicalModelID == "" {
return nil, InvalidModelSelection("前台模型模式下必须指定 logicalModelId")
}
intent := ModelRequestIntentFromTaskInput(normalizedInput, taskType, req.Operation)
routed, err = s.ResolveLogicalModel(logicalModelID, intent)
if err != nil {
return nil, err
}
normalizedInput = applyRoutedProviderSelection(normalizedInput, routed)
} else {
// 系统渠道模型模式:禁止 logicalModelId
if logicalModelID != "" {
return nil, ModelCatalogMismatch("模型目录已更新,请重新选择")
}
// 必须校验 channelId + model 属于启用的系统渠道模型
if err := s.validateSystemChannelModelSelection(normalizedInput); err != nil {
return nil, err
}
}
if strings.HasPrefix(taskType, "video_") && !hasExecutableProviderVideoConfig(normalizedInput) {
if mode, _ := normalizedInput["mode"].(string); mode != "video" {
return nil, errors.New("视频任务必须使用 video 模式")
@@ -236,6 +258,49 @@ func (s *Service) requireCustomChannelsForTaskInput(input map[string]any) error
return s.RequireFeature(FeatureCustomChannels)
}
// validateSystemChannelModelSelection 校验系统渠道模型选择的有效性
func (s *Service) validateSystemChannelModelSelection(input map[string]any) error {
config, ok := input["config"].(map[string]any)
if !ok {
return InvalidModelSelection("缺少模型配置")
}
channelID, _ := config["channelId"].(string)
modelKey, _ := config["model"].(string)
channelID = strings.TrimSpace(channelID)
modelKey = strings.TrimSpace(modelKey)
if channelID == "" || modelKey == "" {
return InvalidModelSelection("必须指定系统渠道和模型")
}
// 验证渠道存在且启用
channel, err := s.repo.SystemChannel(channelID)
if err != nil {
return InvalidModelSelection("指定的渠道不存在")
}
if !channel.Enabled || channel.Scope != model.ChannelScopeSystem {
return InvalidModelSelection("指定的渠道不可用")
}
// 验证渠道模型存在且启用
channelModel, err := s.repo.ChannelModelByKey(channelID, modelKey)
if err != nil {
return InvalidModelSelection("指定的模型不存在")
}
if !channelModel.Enabled {
return InvalidModelSelection("指定的模型已停用")
}
// 验证价格配置
if !HasValidPrice(channelModel) {
return ModelPriceNotConfigured("指定的模型未配置有效价格")
}
return nil
}
func taskInputUsesCustomChannel(input map[string]any) bool {
config, ok := input["config"].(map[string]any)
if !ok {