feat(channel): 模型映射 + 分组搜索 + 卡片折叠 + 冲突校验

- 渠道模型映射:新增 model_mapping JSONB 字段,在账号映射之前执行
- 分组选择:添加搜索过滤 + 平台图标
- 定价卡片:支持折叠/展开,已有数据默认折叠
- 模型冲突校验:前后端均禁止同一渠道内重复模型
- 迁移 083: channels 表添加 model_mapping 列
This commit is contained in:
erio
2026-03-30 02:36:04 +08:00
parent dca0054e93
commit 29d58f2414
9 changed files with 405 additions and 171 deletions

View File

@@ -36,6 +36,8 @@ type Channel struct {
GroupIDs []int64
// 模型定价列表
ModelPricing []ChannelModelPricing
// 渠道级模型映射
ModelMapping map[string]string
}
// ChannelModelPricing 渠道模型定价条目
@@ -71,6 +73,33 @@ type PricingInterval struct {
UpdatedAt time.Time
}
// ResolveMappedModel 解析渠道级模型映射,返回映射后的模型名。
// 支持通配符(如 "claude-*" → "claude-sonnet-4")。
// 如果没有匹配的映射规则,返回原始模型名。
func (c *Channel) ResolveMappedModel(requestedModel string) string {
if len(c.ModelMapping) == 0 {
return requestedModel
}
lower := strings.ToLower(requestedModel)
// 精确匹配优先
for src, dst := range c.ModelMapping {
if strings.ToLower(src) == lower {
return dst
}
}
// 通配符匹配
for src, dst := range c.ModelMapping {
srcLower := strings.ToLower(src)
if strings.HasSuffix(srcLower, "*") {
prefix := strings.TrimSuffix(srcLower, "*")
if strings.HasPrefix(lower, prefix) {
return dst
}
}
}
return requestedModel
}
// IsActive 判断渠道是否启用
func (c *Channel) IsActive() bool {
return c.Status == StatusActive
@@ -168,5 +197,11 @@ func (c *Channel) Clone() *Channel {
cp.ModelPricing[i] = c.ModelPricing[i].Clone()
}
}
if c.ModelMapping != nil {
cp.ModelMapping = make(map[string]string, len(c.ModelMapping))
for k, v := range c.ModelMapping {
cp.ModelMapping[k] = v
}
}
return &cp
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"strings"
"sync/atomic"
"time"
@@ -213,6 +214,11 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput)
Status: StatusActive,
GroupIDs: input.GroupIDs,
ModelPricing: input.ModelPricing,
ModelMapping: input.ModelMapping,
}
if err := validateNoDuplicateModels(channel.ModelPricing); err != nil {
return nil, err
}
if err := s.repo.Create(ctx, channel); err != nil {
@@ -270,6 +276,14 @@ func (s *ChannelService) Update(ctx context.Context, id int64, input *UpdateChan
channel.ModelPricing = *input.ModelPricing
}
if input.ModelMapping != nil {
channel.ModelMapping = input.ModelMapping
}
if err := validateNoDuplicateModels(channel.ModelPricing); err != nil {
return nil, err
}
if err := s.repo.Update(ctx, channel); err != nil {
return nil, fmt.Errorf("update channel: %w", err)
}
@@ -318,6 +332,21 @@ func (s *ChannelService) List(ctx context.Context, params pagination.PaginationP
return s.repo.List(ctx, params, status, search)
}
// validateNoDuplicateModels 检查定价列表中是否有重复模型
func validateNoDuplicateModels(pricingList []ChannelModelPricing) error {
seen := make(map[string]bool)
for _, p := range pricingList {
for _, model := range p.Models {
lower := strings.ToLower(model)
if seen[lower] {
return infraerrors.BadRequest("DUPLICATE_MODEL", fmt.Sprintf("model '%s' appears in multiple pricing entries", model))
}
seen[lower] = true
}
}
return nil
}
// --- Input types ---
// CreateChannelInput 创建渠道输入
@@ -326,6 +355,7 @@ type CreateChannelInput struct {
Description string
GroupIDs []int64
ModelPricing []ChannelModelPricing
ModelMapping map[string]string
}
// UpdateChannelInput 更新渠道输入
@@ -335,4 +365,5 @@ type UpdateChannelInput struct {
Status string
GroupIDs *[]int64
ModelPricing *[]ChannelModelPricing
ModelMapping map[string]string
}