From 0b1ce6be8f1e53cab1927fccff74aa7f5af3c4fd Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 30 Mar 2026 15:04:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel):=20=E7=BC=93=E5=AD=98=E6=89=81?= =?UTF-8?q?=E5=B9=B3=E5=8C=96=20+=20=E7=BD=91=E5=85=B3=E6=98=A0=E5=B0=84?= =?UTF-8?q?=E9=9B=86=E6=88=90=20+=20=E8=AE=A1=E8=B4=B9=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=20+=20=E6=A8=A1=E5=9E=8B=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缓存按 (groupID, platform, model) 三维 key 扁平化,避免跨平台同名模型冲突 - buildCache 批量查询 group platform,按平台过滤展开定价和映射 - model_mapping 改为嵌套格式 {platform: {src: dst}} - channel_model_pricing 新增 platform 列 - 前端按平台维度重构:每个平台独立配置分组/映射/定价 - 迁移 086: platform 列 + model_mapping 嵌套格式迁移 --- .../internal/handler/admin/channel_handler.go | 66 +- backend/internal/repository/channel_repo.go | 40 +- .../repository/channel_repo_pricing.go | 22 +- backend/internal/service/channel.go | 28 +- backend/internal/service/channel_service.go | 62 +- .../086_channel_platform_pricing.sql | 21 + frontend/src/api/admin/channels.ts | 7 +- frontend/src/i18n/locales/en.ts | 8 +- frontend/src/i18n/locales/zh.ts | 8 +- frontend/src/views/admin/ChannelsView.vue | 600 ++++++++++-------- 10 files changed, 542 insertions(+), 320 deletions(-) create mode 100644 backend/migrations/086_channel_platform_pricing.sql diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index 80c707e6..30cd0645 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -24,27 +24,28 @@ func NewChannelHandler(channelService *service.ChannelService) *ChannelHandler { // --- Request / Response types --- type createChannelRequest struct { - Name string `json:"name" binding:"required,max=100"` - Description string `json:"description"` - GroupIDs []int64 `json:"group_ids"` - ModelPricing []channelModelPricingRequest `json:"model_pricing"` - ModelMapping map[string]string `json:"model_mapping"` - BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream"` - RestrictModels bool `json:"restrict_models"` + Name string `json:"name" binding:"required,max=100"` + Description string `json:"description"` + GroupIDs []int64 `json:"group_ids"` + ModelPricing []channelModelPricingRequest `json:"model_pricing"` + ModelMapping map[string]map[string]string `json:"model_mapping"` + BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream"` + RestrictModels bool `json:"restrict_models"` } type updateChannelRequest struct { - Name string `json:"name" binding:"omitempty,max=100"` - Description *string `json:"description"` - Status string `json:"status" binding:"omitempty,oneof=active disabled"` - GroupIDs *[]int64 `json:"group_ids"` - ModelPricing *[]channelModelPricingRequest `json:"model_pricing"` - ModelMapping map[string]string `json:"model_mapping"` - BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream"` - RestrictModels *bool `json:"restrict_models"` + Name string `json:"name" binding:"omitempty,max=100"` + Description *string `json:"description"` + Status string `json:"status" binding:"omitempty,oneof=active disabled"` + GroupIDs *[]int64 `json:"group_ids"` + ModelPricing *[]channelModelPricingRequest `json:"model_pricing"` + ModelMapping map[string]map[string]string `json:"model_mapping"` + BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream"` + RestrictModels *bool `json:"restrict_models"` } type channelModelPricingRequest struct { + Platform string `json:"platform" binding:"omitempty,max=50"` Models []string `json:"models" binding:"required,min=1,max=100"` BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"` InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"` @@ -69,21 +70,22 @@ type pricingIntervalRequest struct { } type channelResponse struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Status string `json:"status"` - BillingModelSource string `json:"billing_model_source"` - RestrictModels bool `json:"restrict_models"` - GroupIDs []int64 `json:"group_ids"` - ModelPricing []channelModelPricingResponse `json:"model_pricing"` - ModelMapping map[string]string `json:"model_mapping"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + BillingModelSource string `json:"billing_model_source"` + RestrictModels bool `json:"restrict_models"` + GroupIDs []int64 `json:"group_ids"` + ModelPricing []channelModelPricingResponse `json:"model_pricing"` + ModelMapping map[string]map[string]string `json:"model_mapping"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type channelModelPricingResponse struct { ID int64 `json:"id"` + Platform string `json:"platform"` Models []string `json:"models"` BillingMode string `json:"billing_mode"` InputPrice *float64 `json:"input_price"` @@ -131,7 +133,7 @@ func channelToResponse(ch *service.Channel) *channelResponse { resp.GroupIDs = []int64{} } if resp.ModelMapping == nil { - resp.ModelMapping = map[string]string{} + resp.ModelMapping = map[string]map[string]string{} } resp.ModelPricing = make([]channelModelPricingResponse, 0, len(ch.ModelPricing)) @@ -144,6 +146,10 @@ func channelToResponse(ch *service.Channel) *channelResponse { if billingMode == "" { billingMode = "token" } + platform := p.Platform + if platform == "" { + platform = "anthropic" + } intervals := make([]pricingIntervalResponse, 0, len(p.Intervals)) for _, iv := range p.Intervals { intervals = append(intervals, pricingIntervalResponse{ @@ -161,6 +167,7 @@ func channelToResponse(ch *service.Channel) *channelResponse { } resp.ModelPricing = append(resp.ModelPricing, channelModelPricingResponse{ ID: p.ID, + Platform: platform, Models: models, BillingMode: billingMode, InputPrice: p.InputPrice, @@ -182,6 +189,10 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe if billingMode == "" { billingMode = service.BillingModeToken } + platform := r.Platform + if platform == "" { + platform = "anthropic" + } intervals := make([]service.PricingInterval, 0, len(r.Intervals)) for _, iv := range r.Intervals { intervals = append(intervals, service.PricingInterval{ @@ -197,6 +208,7 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe }) } result = append(result, service.ChannelModelPricing{ + Platform: platform, Models: r.Models, BillingMode: billingMode, InputPrice: r.InputPrice, diff --git a/backend/internal/repository/channel_repo.go b/backend/internal/repository/channel_repo.go index 6d4008c8..99b9e8a6 100644 --- a/backend/internal/repository/channel_repo.go +++ b/backend/internal/repository/channel_repo.go @@ -406,8 +406,9 @@ func (r *channelRepository) GetGroupsInOtherChannels(ctx context.Context, channe return conflicting, nil } -// marshalModelMapping 将 model mapping 序列化为 JSON 字节,nil/空 map 返回 '{}' -func marshalModelMapping(m map[string]string) ([]byte, error) { +// marshalModelMapping 将 model mapping 序列化为嵌套 JSON 字节 +// 格式:{"platform": {"src": "dst"}, ...} +func marshalModelMapping(m map[string]map[string]string) ([]byte, error) { if len(m) == 0 { return []byte("{}"), nil } @@ -418,14 +419,43 @@ func marshalModelMapping(m map[string]string) ([]byte, error) { return data, nil } -// unmarshalModelMapping 将 JSON 字节反序列化为 model mapping -func unmarshalModelMapping(data []byte) map[string]string { +// unmarshalModelMapping 将 JSON 字节反序列化为嵌套 model mapping +func unmarshalModelMapping(data []byte) map[string]map[string]string { if len(data) == 0 { return nil } - var m map[string]string + var m map[string]map[string]string if err := json.Unmarshal(data, &m); err != nil { return nil } return m } + +// GetGroupPlatforms 批量查询分组 ID 对应的平台 +func (r *channelRepository) GetGroupPlatforms(ctx context.Context, groupIDs []int64) (map[int64]string, error) { + if len(groupIDs) == 0 { + return make(map[int64]string), nil + } + rows, err := r.db.QueryContext(ctx, + `SELECT id, platform FROM groups WHERE id = ANY($1)`, + pq.Array(groupIDs), + ) + if err != nil { + return nil, fmt.Errorf("get group platforms: %w", err) + } + defer rows.Close() + + result := make(map[int64]string, len(groupIDs)) + for rows.Next() { + var id int64 + var platform string + if err := rows.Scan(&id, &platform); err != nil { + return nil, fmt.Errorf("scan group platform: %w", err) + } + result[id] = platform + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate group platforms: %w", err) + } + return result, nil +} diff --git a/backend/internal/repository/channel_repo_pricing.go b/backend/internal/repository/channel_repo_pricing.go index ad903ead..73887617 100644 --- a/backend/internal/repository/channel_repo_pricing.go +++ b/backend/internal/repository/channel_repo_pricing.go @@ -15,7 +15,7 @@ import ( func (r *channelRepository) ListModelPricing(ctx context.Context, channelID int64) ([]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_output_price, per_request_price, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_output_price, per_request_price, created_at, updated_at FROM channel_model_pricing WHERE channel_id = $1 ORDER BY id`, channelID, ) if err != nil { @@ -56,10 +56,10 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser } result, err := r.db.ExecContext(ctx, `UPDATE channel_model_pricing - SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_read_price = $6, image_output_price = $7, per_request_price = $8, updated_at = NOW() - WHERE id = $9`, + SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_read_price = $6, image_output_price = $7, per_request_price = $8, platform = $9, updated_at = NOW() + WHERE id = $10`, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice, - pricing.ImageOutputPrice, pricing.PerRequestPrice, pricing.ID, + pricing.ImageOutputPrice, pricing.PerRequestPrice, pricing.Platform, pricing.ID, ) if err != nil { return fmt.Errorf("update model pricing: %w", err) @@ -90,7 +90,7 @@ func (r *channelRepository) ReplaceModelPricing(ctx context.Context, channelID i // batchLoadModelPricing 批量加载多个渠道的模型定价(含区间) func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelIDs []int64) (map[int64][]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_output_price, per_request_price, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_output_price, per_request_price, created_at, updated_at FROM channel_model_pricing WHERE channel_id = ANY($1) ORDER BY channel_id, id`, pq.Array(channelIDs), ) @@ -169,7 +169,7 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6 var p service.ChannelModelPricing var modelsJSON []byte if err := rows.Scan( - &p.ID, &p.ChannelID, &modelsJSON, &p.BillingMode, + &p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode, &p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheReadPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &p.CreatedAt, &p.UpdatedAt, ); err != nil { @@ -223,10 +223,14 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C if billingMode == "" { billingMode = service.BillingModeToken } + platform := pricing.Platform + if platform == "" { + platform = "anthropic" + } err = exec.QueryRowContext(ctx, - `INSERT INTO channel_model_pricing (channel_id, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_output_price, per_request_price) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, created_at, updated_at`, - pricing.ChannelID, modelsJSON, billingMode, + `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_output_price, per_request_price) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at`, + pricing.ChannelID, platform, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, ).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt) diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index ccb5473f..bc13d642 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -41,16 +41,17 @@ type Channel struct { // 关联的分组 ID 列表 GroupIDs []int64 - // 模型定价列表 + // 模型定价列表(每条含 Platform 字段) ModelPricing []ChannelModelPricing - // 渠道级模型映射 - ModelMapping map[string]string + // 渠道级模型映射(按平台分组:platform → {src→dst}) + ModelMapping map[string]map[string]string } // ChannelModelPricing 渠道模型定价条目 type ChannelModelPricing struct { ID int64 ChannelID int64 + Platform string // 所属平台(anthropic/openai/gemini/...) Models []string // 绑定的模型列表 BillingMode BillingMode // 计费模式 InputPrice *float64 // 每 token 输入价格(USD)— 向后兼容 flat 定价 @@ -82,21 +83,26 @@ type PricingInterval struct { } // ResolveMappedModel 解析渠道级模型映射,返回映射后的模型名。 +// platform 指定查找哪个平台的映射规则。 // 支持通配符(如 "claude-*" → "claude-sonnet-4")。 // 如果没有匹配的映射规则,返回原始模型名。 -func (c *Channel) ResolveMappedModel(requestedModel string) string { +func (c *Channel) ResolveMappedModel(platform, requestedModel string) string { if len(c.ModelMapping) == 0 { return requestedModel } + platformMapping, ok := c.ModelMapping[platform] + if !ok || len(platformMapping) == 0 { + return requestedModel + } lower := strings.ToLower(requestedModel) // 精确匹配优先 - for src, dst := range c.ModelMapping { + for src, dst := range platformMapping { if strings.ToLower(src) == lower { return dst } } // 通配符匹配 - for src, dst := range c.ModelMapping { + for src, dst := range platformMapping { srcLower := strings.ToLower(src) if strings.HasSuffix(srcLower, "*") { prefix := strings.TrimSuffix(srcLower, "*") @@ -190,9 +196,13 @@ func (c *Channel) Clone() *Channel { } } if c.ModelMapping != nil { - cp.ModelMapping = make(map[string]string, len(c.ModelMapping)) - for k, v := range c.ModelMapping { - cp.ModelMapping[k] = v + cp.ModelMapping = make(map[string]map[string]string, len(c.ModelMapping)) + for platform, mapping := range c.ModelMapping { + inner := make(map[string]string, len(mapping)) + for k, v := range mapping { + inner[k] = v + } + cp.ModelMapping[platform] = inner } } return &cp diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 91b69ad2..6025ffcf 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -39,6 +39,9 @@ type ChannelRepository interface { GetChannelIDByGroupID(ctx context.Context, groupID int64) (int64, error) GetGroupsInOtherChannels(ctx context.Context, channelID int64, groupIDs []int64) ([]int64, error) + // 分组平台查询 + GetGroupPlatforms(ctx context.Context, groupIDs []int64) (map[int64]string, error) + // 模型定价 ListModelPricing(ctx context.Context, channelID int64) ([]ChannelModelPricing, error) CreateModelPricing(ctx context.Context, pricing *ChannelModelPricing) error @@ -47,18 +50,20 @@ type ChannelRepository interface { ReplaceModelPricing(ctx context.Context, channelID int64, pricingList []ChannelModelPricing) error } -// channelModelKey 渠道缓存复合键 +// channelModelKey 渠道缓存复合键(显式包含 platform 防止跨平台同名模型冲突) type channelModelKey struct { - groupID int64 - model string // lowercase + groupID int64 + platform string // 平台标识 + model string // lowercase } // channelCache 渠道缓存快照(扁平化哈希结构,热路径 O(1) 查找) type channelCache struct { // 热路径查找 - pricingByGroupModel map[channelModelKey]*ChannelModelPricing // (groupID, model) → 定价 - mappingByGroupModel map[channelModelKey]string // (groupID, model) → 映射目标 + pricingByGroupModel map[channelModelKey]*ChannelModelPricing // (groupID, platform, model) → 定价 + mappingByGroupModel map[channelModelKey]string // (groupID, platform, model) → 映射目标 channelByGroupID map[int64]*Channel // groupID → 渠道 + groupPlatform map[int64]string // groupID → platform // 冷路径(CRUD 操作) byID map[int64]*Channel @@ -135,6 +140,7 @@ func (s *ChannelService) buildCache(ctx context.Context) (*channelCache, error) pricingByGroupModel: make(map[channelModelKey]*ChannelModelPricing), mappingByGroupModel: make(map[channelModelKey]string), channelByGroupID: make(map[int64]*Channel), + groupPlatform: make(map[int64]string), byID: make(map[int64]*Channel), loadedAt: time.Now().Add(channelCacheTTL - channelErrorTTL), // 使剩余 TTL = errorTTL } @@ -142,10 +148,25 @@ func (s *ChannelService) buildCache(ctx context.Context) (*channelCache, error) return nil, fmt.Errorf("list all channels: %w", err) } + // 收集所有 groupID,批量查询 platform + var allGroupIDs []int64 + for i := range channels { + allGroupIDs = append(allGroupIDs, channels[i].GroupIDs...) + } + groupPlatforms := make(map[int64]string) + if len(allGroupIDs) > 0 { + groupPlatforms, err = s.repo.GetGroupPlatforms(dbCtx, allGroupIDs) + if err != nil { + slog.Warn("failed to load group platforms for channel cache", "error", err) + // 降级:继续构建缓存但无法按平台过滤 + } + } + cache := &channelCache{ pricingByGroupModel: make(map[channelModelKey]*ChannelModelPricing), mappingByGroupModel: make(map[channelModelKey]string), channelByGroupID: make(map[int64]*Channel), + groupPlatform: groupPlatforms, byID: make(map[int64]*Channel, len(channels)), loadedAt: time.Now(), } @@ -157,20 +178,26 @@ func (s *ChannelService) buildCache(ctx context.Context) (*channelCache, error) // 展开到分组维度 for _, gid := range ch.GroupIDs { cache.channelByGroupID[gid] = ch + platform := groupPlatforms[gid] // e.g. "anthropic" - // 展开模型定价到 (groupID, model) → *ChannelModelPricing + // 只展开该平台的模型定价到 (groupID, platform, model) → *ChannelModelPricing for j := range ch.ModelPricing { pricing := &ch.ModelPricing[j] + if pricing.Platform != platform { + continue // 跳过非本平台的定价 + } for _, model := range pricing.Models { - key := channelModelKey{groupID: gid, model: strings.ToLower(model)} + key := channelModelKey{groupID: gid, platform: platform, model: strings.ToLower(model)} cache.pricingByGroupModel[key] = pricing } } - // 展开模型映射到 (groupID, model) → target - for src, dst := range ch.ModelMapping { - key := channelModelKey{groupID: gid, model: strings.ToLower(src)} - cache.mappingByGroupModel[key] = dst + // 只展开该平台的模型映射到 (groupID, platform, model) → target + if platformMapping, ok := ch.ModelMapping[platform]; ok { + for src, dst := range platformMapping { + key := channelModelKey{groupID: gid, platform: platform, model: strings.ToLower(src)} + cache.mappingByGroupModel[key] = dst + } } } } @@ -214,7 +241,8 @@ func (s *ChannelService) GetChannelModelPricing(ctx context.Context, groupID int return nil } - key := channelModelKey{groupID: groupID, model: strings.ToLower(model)} + platform := cache.groupPlatform[groupID] + key := channelModelKey{groupID: groupID, platform: platform, model: strings.ToLower(model)} pricing, ok := cache.pricingByGroupModel[key] if !ok { return nil @@ -246,7 +274,8 @@ func (s *ChannelService) ResolveChannelMapping(ctx context.Context, groupID int6 result.BillingModelSource = BillingModelSourceRequested } - key := channelModelKey{groupID: groupID, model: strings.ToLower(model)} + platform := cache.groupPlatform[groupID] + key := channelModelKey{groupID: groupID, platform: platform, model: strings.ToLower(model)} if mapped, ok := cache.mappingByGroupModel[key]; ok { result.MappedModel = mapped result.Mapped = true @@ -270,7 +299,8 @@ func (s *ChannelService) IsModelRestricted(ctx context.Context, groupID int64, m } // 检查模型是否在定价列表中 - key := channelModelKey{groupID: groupID, model: strings.ToLower(model)} + platform := cache.groupPlatform[groupID] + key := channelModelKey{groupID: groupID, platform: platform, model: strings.ToLower(model)} _, exists := cache.pricingByGroupModel[key] return !exists } @@ -458,7 +488,7 @@ type CreateChannelInput struct { Description string GroupIDs []int64 ModelPricing []ChannelModelPricing - ModelMapping map[string]string + ModelMapping map[string]map[string]string // platform → {src→dst} BillingModelSource string RestrictModels bool } @@ -470,7 +500,7 @@ type UpdateChannelInput struct { Status string GroupIDs *[]int64 ModelPricing *[]ChannelModelPricing - ModelMapping map[string]string + ModelMapping map[string]map[string]string // platform → {src→dst} BillingModelSource string RestrictModels *bool } diff --git a/backend/migrations/086_channel_platform_pricing.sql b/backend/migrations/086_channel_platform_pricing.sql new file mode 100644 index 00000000..f2d08562 --- /dev/null +++ b/backend/migrations/086_channel_platform_pricing.sql @@ -0,0 +1,21 @@ +-- 086_channel_platform_pricing.sql +-- 渠道按平台维度:model_pricing 加 platform 列,model_mapping 改为嵌套格式 + +-- 1. channel_model_pricing 加 platform 列 +ALTER TABLE channel_model_pricing + ADD COLUMN IF NOT EXISTS platform VARCHAR(50) NOT NULL DEFAULT 'anthropic'; + +CREATE INDEX IF NOT EXISTS idx_channel_model_pricing_platform + ON channel_model_pricing (platform); + +-- 2. model_mapping: 从扁平 {"src":"dst"} 迁移为嵌套 {"anthropic":{"src":"dst"}} +-- 仅迁移非空、非 '{}' 的旧格式数据(通过检查第一个 value 是否为字符串来判断是否为旧格式) +UPDATE channels +SET model_mapping = jsonb_build_object('anthropic', model_mapping) +WHERE model_mapping IS NOT NULL + AND model_mapping::text NOT IN ('{}', 'null', '') + AND NOT EXISTS ( + SELECT 1 FROM jsonb_each(model_mapping) AS kv + WHERE jsonb_typeof(kv.value) = 'object' + LIMIT 1 + ); diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index 23244a4f..df23db93 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -22,6 +22,7 @@ export interface PricingInterval { export interface ChannelModelPricing { id?: number + platform: string models: string[] billing_mode: BillingMode input_price: number | null @@ -42,7 +43,7 @@ export interface Channel { restrict_models: boolean group_ids: number[] model_pricing: ChannelModelPricing[] - model_mapping: Record + model_mapping: Record> // platform → {src→dst} created_at: string updated_at: string } @@ -52,7 +53,7 @@ export interface CreateChannelRequest { description?: string group_ids?: number[] model_pricing?: ChannelModelPricing[] - model_mapping?: Record + model_mapping?: Record> billing_model_source?: string restrict_models?: boolean } @@ -63,7 +64,7 @@ export interface UpdateChannelRequest { status?: string group_ids?: number[] model_pricing?: ChannelModelPricing[] - model_mapping?: Record + model_mapping?: Record> billing_model_source?: string restrict_models?: boolean } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 18cd812d..d04541e4 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1806,7 +1806,13 @@ export default { restrictModels: 'Restrict Models', restrictModelsHint: 'When enabled, only models in the pricing list are allowed. Others will be rejected.', defaultPerRequestPrice: 'Default per-request price (fallback when no tier matches)', - defaultImagePrice: 'Default image price (fallback when no tier matches)' + defaultImagePrice: 'Default image price (fallback when no tier matches)', + platformConfig: 'Platform Configuration', + addPlatform: 'Add Platform', + noPlatforms: 'Click "Add Platform" to start configuring the channel', + mappingCount: 'mappings', + pricingEntry: 'Pricing Entry', + noModels: 'No models added' } }, diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index da800153..d636e323 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1886,7 +1886,13 @@ export default { restrictModels: '限制模型', restrictModelsHint: '开启后,仅允许模型定价列表中的模型。不在列表中的模型请求将被拒绝。', defaultPerRequestPrice: '默认单次价格(未命中层级时使用)', - defaultImagePrice: '默认图片价格(未命中层级时使用)' + defaultImagePrice: '默认图片价格(未命中层级时使用)', + platformConfig: '平台配置', + addPlatform: '添加平台', + noPlatforms: '点击"添加平台"开始配置渠道', + mappingCount: '条映射', + pricingEntry: '定价配置', + noModels: '未添加模型' } }, diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index 5663aeb7..cd67aed0 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -188,86 +188,6 @@

- -
- -
- - -
-
-
- {{ t('common.loading', 'Loading...') }} -
-
- {{ t('admin.channels.form.noGroupsAvailable', 'No groups available') }} -
-
- {{ t('admin.channels.form.noGroupsMatch', 'No groups match your search') }} -
- -
-
-
@@ -277,81 +197,203 @@

- -
-
- - + +
+
+ + +
+ +
+ +
+
-

- {{ t('admin.channels.form.modelMappingHint', 'Map request model names to actual model names. Runs before account-level mapping.') }} -

+
- {{ t('admin.channels.form.noMappingRules', 'No mapping rules. Click "Add" to create one.') }} + {{ t('admin.channels.form.noPlatforms', '点击"添加平台"开始配置渠道') }}
-
+ + +
+
- - - +
+ + + + {{ t('admin.groups.platforms.' + section.platform, section.platform) }} + + + + {{ section.group_ids.length }} {{ t('admin.channels.groupsUnit', 'groups') }} + + + · {{ Object.keys(section.model_mapping).length }} {{ t('admin.channels.form.mappingCount', 'mappings') }} + + + · {{ section.model_pricing.length }} {{ t('admin.channels.pricingUnit', 'pricing rules') }} + +
-
-
- -
-
- - -
+ +
+ +
+ +
+
+ {{ t('common.loading', 'Loading...') }} +
+
+ {{ t('admin.channels.form.noGroupsAvailable', 'No groups available') }} +
+
+ +
+
+
-
- {{ t('admin.channels.form.noPricingRules', 'No pricing rules yet. Click "Add" to create one.') }} -
+ +
+
+ + +
+
+ {{ t('admin.channels.form.noMappingRules', 'No mapping rules. Click "Add" to create one.') }} +
+
+
+ + + + +
+
+
-
- + +
+
+ + +
+
+ {{ t('admin.channels.form.noPricingRules', 'No pricing rules yet. Click "Add" to create one.') }} +
+
+ +
+
+
@@ -418,6 +460,15 @@ import { getPersistedPageSize } from '@/composables/usePersistedPageSize' const { t } = useI18n() const appStore = useAppStore() +// ── Platform Section type ── +interface PlatformSection { + platform: GroupPlatform + collapsed: boolean + group_ids: number[] + model_mapping: Record + model_pricing: PricingFormEntry[] +} + // ── Table columns ── const columns = computed(() => [ { key: 'name', label: t('admin.channels.columns.name', 'Name'), sortable: true }, @@ -462,11 +513,11 @@ const editingChannel = ref(null) const submitting = ref(false) const showDeleteDialog = ref(false) const deletingChannel = ref(null) +const showPlatformMenu = ref(false) // Groups const allGroups = ref([]) const groupsLoading = ref(false) -const groupSearchQuery = ref('') // Form data const form = reactive({ @@ -474,22 +525,13 @@ const form = reactive({ description: '', status: 'active', restrict_models: false, - group_ids: [] as number[], - model_pricing: [] as PricingFormEntry[], - model_mapping: {} as Record, - billing_model_source: 'requested' as string + billing_model_source: 'requested' as string, + platforms: [] as PlatformSection[] }) let abortController: AbortController | null = null -// ── Helpers ── -function formatDate(value: string): string { - if (!value) return '-' - return new Date(value).toLocaleDateString() -} - -// ── Group helpers ── -// Platform color helpers +// ── Platform config ── const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'sora'] function getPlatformTextColor(platform: string): string { @@ -514,39 +556,39 @@ function getRateBadgeClass(platform: string): string { } } -const groupsByPlatform = computed(() => { - const query = groupSearchQuery.value.trim().toLowerCase() - const groups = query - ? allGroups.value.filter(g => g.name.toLowerCase().includes(query)) - : allGroups.value +// ── Helpers ── +function formatDate(value: string): string { + if (!value) return '-' + return new Date(value).toLocaleDateString() +} - const grouped = new Map() - for (const g of groups) { - const platform = g.platform - if (!platform) continue - if (!grouped.has(platform)) grouped.set(platform, []) - grouped.get(platform)!.push(g) - } +// ── Platform section helpers ── +const activePlatforms = computed(() => form.platforms.map(s => s.platform)) - // Sort by platformOrder - const result: Array<{ platform: GroupPlatform; groups: typeof groups }> = [] - for (const p of platformOrder) { - const list = grouped.get(p) - if (list && list.length > 0) { - result.push({ platform: p, groups: list }) - } - } - // Add any remaining platforms not in platformOrder - for (const [p, list] of grouped) { - if (!platformOrder.includes(p) && list.length > 0) { - result.push({ platform: p, groups: list }) - } - } - return result -}) +const availablePlatformsToAdd = computed(() => + platformOrder.filter(p => !activePlatforms.value.includes(p)) +) -const selectedGroupCount = computed(() => form.group_ids.length) +function addPlatformSection(platform: GroupPlatform) { + form.platforms.push({ + platform, + collapsed: false, + group_ids: [], + model_mapping: {}, + model_pricing: [] + }) + showPlatformMenu.value = false +} +function removePlatformSection(idx: number) { + form.platforms.splice(idx, 1) +} + +function getGroupsForPlatform(platform: GroupPlatform): AdminGroup[] { + return allGroups.value.filter(g => g.platform === platform) +} + +// ── Group helpers ── const groupToChannelMap = computed(() => { const map = new Map() for (const ch of channels.value) { @@ -558,7 +600,7 @@ const groupToChannelMap = computed(() => { return map }) -function isGroupInOtherChannel(groupId: number): boolean { +function isGroupInOtherChannel(groupId: number, _platform: string): boolean { return groupToChannelMap.value.has(groupId) } @@ -580,18 +622,19 @@ const deleteConfirmMessage = computed(() => { ) }) -function toggleGroup(groupId: number) { - const idx = form.group_ids.indexOf(groupId) +function toggleGroupInSection(sectionIdx: number, groupId: number) { + const section = form.platforms[sectionIdx] + const idx = section.group_ids.indexOf(groupId) if (idx >= 0) { - form.group_ids.splice(idx, 1) + section.group_ids.splice(idx, 1) } else { - form.group_ids.push(groupId) + section.group_ids.push(groupId) } } // ── Pricing helpers ── -function addPricingEntry() { - form.model_pricing.push({ +function addPricingEntry(sectionIdx: number) { + form.platforms[sectionIdx].model_pricing.push({ models: [], billing_mode: 'token', input_price: null, @@ -604,67 +647,126 @@ function addPricingEntry() { }) } -function updatePricingEntry(idx: number, updated: PricingFormEntry) { - form.model_pricing[idx] = updated +function updatePricingEntry(sectionIdx: number, idx: number, updated: PricingFormEntry) { + form.platforms[sectionIdx].model_pricing[idx] = updated } -function removePricingEntry(idx: number) { - form.model_pricing.splice(idx, 1) -} - -function formPricingToAPI(): ChannelModelPricing[] { - return form.model_pricing - .filter(e => e.models.length > 0) - .map(e => ({ - models: e.models, - billing_mode: e.billing_mode, - input_price: mTokToPerToken(e.input_price), - output_price: mTokToPerToken(e.output_price), - cache_write_price: mTokToPerToken(e.cache_write_price), - cache_read_price: mTokToPerToken(e.cache_read_price), - image_output_price: mTokToPerToken(e.image_output_price), - per_request_price: e.per_request_price != null && e.per_request_price !== '' ? Number(e.per_request_price) : null, - intervals: formIntervalsToAPI(e.intervals || []) - })) -} - -function apiPricingToForm(pricing: ChannelModelPricing[]): PricingFormEntry[] { - return pricing.map(p => ({ - models: p.models || [], - billing_mode: p.billing_mode, - input_price: perTokenToMTok(p.input_price), - output_price: perTokenToMTok(p.output_price), - cache_write_price: perTokenToMTok(p.cache_write_price), - cache_read_price: perTokenToMTok(p.cache_read_price), - image_output_price: perTokenToMTok(p.image_output_price), - per_request_price: p.per_request_price, - intervals: apiIntervalsToForm(p.intervals || []) - })) +function removePricingEntry(sectionIdx: number, idx: number) { + form.platforms[sectionIdx].model_pricing.splice(idx, 1) } // ── Model Mapping helpers ── -function addMappingEntry() { - // Find a unique key +function addMappingEntry(sectionIdx: number) { + const mapping = form.platforms[sectionIdx].model_mapping let key = '' let i = 1 - while (key === '' || key in form.model_mapping) { + while (key === '' || key in mapping) { key = `model-${i}` i++ } - form.model_mapping[key] = '' + mapping[key] = '' } -function removeMappingEntry(key: string) { - delete form.model_mapping[key] +function removeMappingEntry(sectionIdx: number, key: string) { + delete form.platforms[sectionIdx].model_mapping[key] } -function renameMappingKey(oldKey: string, newKey: string) { +function renameMappingKey(sectionIdx: number, oldKey: string, newKey: string) { newKey = newKey.trim() if (!newKey || newKey === oldKey) return - if (newKey in form.model_mapping) return // prevent duplicate keys - const value = form.model_mapping[oldKey] - delete form.model_mapping[oldKey] - form.model_mapping[newKey] = value + const mapping = form.platforms[sectionIdx].model_mapping + if (newKey in mapping) return + const value = mapping[oldKey] + delete mapping[oldKey] + mapping[newKey] = value +} + +// ── Form ↔ API conversion ── +function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[], model_mapping: Record> } { + const group_ids: number[] = [] + const model_pricing: ChannelModelPricing[] = [] + const model_mapping: Record> = {} + + for (const section of form.platforms) { + group_ids.push(...section.group_ids) + + // Model mapping per platform + if (Object.keys(section.model_mapping).length > 0) { + model_mapping[section.platform] = { ...section.model_mapping } + } + + // Model pricing with platform tag + for (const entry of section.model_pricing) { + if (entry.models.length === 0) continue + model_pricing.push({ + platform: section.platform, + models: entry.models, + billing_mode: entry.billing_mode, + input_price: mTokToPerToken(entry.input_price), + output_price: mTokToPerToken(entry.output_price), + cache_write_price: mTokToPerToken(entry.cache_write_price), + cache_read_price: mTokToPerToken(entry.cache_read_price), + image_output_price: mTokToPerToken(entry.image_output_price), + per_request_price: entry.per_request_price != null && entry.per_request_price !== '' ? Number(entry.per_request_price) : null, + intervals: formIntervalsToAPI(entry.intervals || []) + }) + } + } + + return { group_ids, model_pricing, model_mapping } +} + +function apiToForm(channel: Channel): PlatformSection[] { + // Build a map: groupID → platform + const groupPlatformMap = new Map() + for (const g of allGroups.value) { + groupPlatformMap.set(g.id, g.platform) + } + + // Determine which platforms are active (from groups + pricing + mapping) + const activePlatforms = new Set() + for (const gid of channel.group_ids || []) { + const p = groupPlatformMap.get(gid) + if (p) activePlatforms.add(p) + } + for (const p of channel.model_pricing || []) { + if (p.platform) activePlatforms.add(p.platform as GroupPlatform) + } + for (const p of Object.keys(channel.model_mapping || {})) { + if (platformOrder.includes(p as GroupPlatform)) activePlatforms.add(p as GroupPlatform) + } + + // Build sections in platform order + const sections: PlatformSection[] = [] + for (const platform of platformOrder) { + if (!activePlatforms.has(platform)) continue + + const groupIds = (channel.group_ids || []).filter(gid => groupPlatformMap.get(gid) === platform) + const mapping = (channel.model_mapping || {})[platform] || {} + const pricing = (channel.model_pricing || []) + .filter(p => (p.platform || 'anthropic') === platform) + .map(p => ({ + models: p.models || [], + billing_mode: p.billing_mode, + input_price: perTokenToMTok(p.input_price), + output_price: perTokenToMTok(p.output_price), + cache_write_price: perTokenToMTok(p.cache_write_price), + cache_read_price: perTokenToMTok(p.cache_read_price), + image_output_price: perTokenToMTok(p.image_output_price), + per_request_price: p.per_request_price, + intervals: apiIntervalsToForm(p.intervals || []) + } as PricingFormEntry)) + + sections.push({ + platform, + collapsed: false, + group_ids: groupIds, + model_mapping: { ...mapping }, + model_pricing: pricing + }) + } + + return sections } // ── Load data ── @@ -732,11 +834,9 @@ function resetForm() { form.description = '' form.status = 'active' form.restrict_models = false - form.group_ids = [] - form.model_pricing = [] - form.model_mapping = {} form.billing_model_source = 'requested' - groupSearchQuery.value = '' + form.platforms = [] + showPlatformMenu.value = false } function openCreateDialog() { @@ -752,11 +852,11 @@ function openEditDialog(channel: Channel) { form.description = channel.description || '' form.status = channel.status form.restrict_models = channel.restrict_models || false - form.group_ids = [...(channel.group_ids || [])] - form.model_pricing = apiPricingToForm(channel.model_pricing || []) - form.model_mapping = { ...(channel.model_mapping || {}) } form.billing_model_source = channel.billing_model_source || 'requested' - loadGroups() + // Must load groups first so apiToForm can map groupID → platform + loadGroups().then(() => { + form.platforms = apiToForm(channel) + }) showDialog.value = true } @@ -773,14 +873,16 @@ async function handleSubmit() { return } - // 检查模型重复 - const allModels = form.model_pricing.flatMap(e => e.models.map(m => m.toLowerCase())) + // Check duplicate models across all platform sections + const allModels = form.platforms.flatMap(s => s.model_pricing.flatMap(e => e.models.map(m => m.toLowerCase()))) const duplicates = allModels.filter((m, i) => allModels.indexOf(m) !== i) if (duplicates.length > 0) { appStore.showError(t('admin.channels.duplicateModels', `模型 "${duplicates[0]}" 在多个定价条目中重复`)) return } + const { group_ids, model_pricing, model_mapping } = formToAPI() + submitting.value = true try { if (editingChannel.value) { @@ -788,9 +890,9 @@ async function handleSubmit() { name: form.name.trim(), description: form.description.trim() || undefined, status: form.status, - group_ids: form.group_ids, - model_pricing: formPricingToAPI(), - model_mapping: Object.keys(form.model_mapping).length > 0 ? form.model_mapping : undefined, + group_ids, + model_pricing, + model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : undefined, billing_model_source: form.billing_model_source, restrict_models: form.restrict_models } @@ -800,9 +902,9 @@ async function handleSubmit() { const req: CreateChannelRequest = { name: form.name.trim(), description: form.description.trim() || undefined, - group_ids: form.group_ids, - model_pricing: formPricingToAPI(), - model_mapping: Object.keys(form.model_mapping).length > 0 ? form.model_mapping : undefined, + group_ids, + model_pricing, + model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : undefined, billing_model_source: form.billing_model_source, restrict_models: form.restrict_models }