Follow-up to the available-channels review pass. No behavior change for end users; tightens internals based on three independent code reviews. Backend - service/channel.go: collapse buildPricingLookup + pricedNamesFor into a single platformPricingIndex (byLower + originalCase + ordered names), built once per SupportedModels call. Fixes a casing- consistency bug where the same logical model appeared with mapping case in the exact branch but pricing case in the wildcard branch — pricing's original case now wins everywhere. - service/channel.go: doc that a mapping key of just "*" expands to every priced model on the platform (intentional "passthrough all"). - service/channel_available.go: normalize empty BillingModelSource to channel_mapped at construction time, removing the same fallback duplicated in the admin DTO mapper and the admin Vue template. - handler/admin/available_channel_handler.go: unexport availableChannelToAdminResponse (same-package usage only); mapper is now a pure passthrough. - handler/available_channel_handler.go: drop the middleware2 alias (no name collision in this file). Frontend - utils/pricing.ts: extract formatScaled, used by SupportedModelChip and PricingRow. - api/admin/channels.ts: re-export BillingMode from constants/channel; tighten Channel.status / billing_model_source to ChannelStatus / BillingModelSource (and same for AvailableChannel). - components/channels/AvailableChannelsTable.vue: drop dead withDefaults wrapper (loading is required, both call sites pass it). - views/admin/AvailableChannelsView.vue: drop the redundant || BILLING_MODEL_SOURCE_CHANNEL_MAPPED fallback (now applied in service layer); remove unused import. - i18n zh + en: delete unused tierLabel and tokenRange keys from both availableChannels.pricing and admin.availableChannels.pricing. Tests - New: SupportedModels_ExactKeyUsesPricedCaseWhenAvailable locks the pricing-case-wins rule. - New: SupportedModels_AsteriskOnlyMappingExpandsAllPriced documents the "*" expansion rule. - Admin handler: existing tests adjusted to pass an explicit BillingModelSource (default-fill is now exercised by service tests).
217 lines
7.2 KiB
Go
217 lines
7.2 KiB
Go
package handler
|
||
|
||
import (
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// AvailableChannelHandler 处理用户侧「可用渠道」查询。
|
||
//
|
||
// 用户侧接口委托 ChannelService.ListAvailable,并在返回前做三层过滤:
|
||
// 1. 行过滤:只保留状态为 Active 且与当前用户可访问分组有交集的渠道;
|
||
// 2. 分组过滤:渠道的 Groups 只保留用户可访问的那些;
|
||
// 3. 平台过滤:渠道的 SupportedModels 只保留平台在用户可见 Groups 中出现过的模型,
|
||
// 防止"渠道同时挂在 antigravity / anthropic 两个平台的分组上,用户只访问
|
||
// antigravity,却看到 anthropic 模型"这类跨平台信息泄漏;
|
||
// 4. 字段白名单:仅返回用户需要的字段(省略 BillingModelSource / RestrictModels
|
||
// / 内部 ID / Status 等管理字段)。
|
||
type AvailableChannelHandler struct {
|
||
channelService *service.ChannelService
|
||
apiKeyService *service.APIKeyService
|
||
}
|
||
|
||
// NewAvailableChannelHandler 创建用户侧可用渠道 handler。
|
||
func NewAvailableChannelHandler(
|
||
channelService *service.ChannelService,
|
||
apiKeyService *service.APIKeyService,
|
||
) *AvailableChannelHandler {
|
||
return &AvailableChannelHandler{
|
||
channelService: channelService,
|
||
apiKeyService: apiKeyService,
|
||
}
|
||
}
|
||
|
||
// userAvailableGroup 用户可见的分组概要(白名单字段)。
|
||
type userAvailableGroup struct {
|
||
ID int64 `json:"id"`
|
||
Name string `json:"name"`
|
||
Platform string `json:"platform"`
|
||
}
|
||
|
||
// userSupportedModelPricing 用户可见的定价字段白名单。
|
||
type userSupportedModelPricing struct {
|
||
BillingMode string `json:"billing_mode"`
|
||
InputPrice *float64 `json:"input_price"`
|
||
OutputPrice *float64 `json:"output_price"`
|
||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||
PerRequestPrice *float64 `json:"per_request_price"`
|
||
Intervals []userPricingIntervalDTO `json:"intervals"`
|
||
}
|
||
|
||
// userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。
|
||
type userPricingIntervalDTO struct {
|
||
MinTokens int `json:"min_tokens"`
|
||
MaxTokens *int `json:"max_tokens"`
|
||
TierLabel string `json:"tier_label,omitempty"`
|
||
InputPrice *float64 `json:"input_price"`
|
||
OutputPrice *float64 `json:"output_price"`
|
||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||
PerRequestPrice *float64 `json:"per_request_price"`
|
||
}
|
||
|
||
// userSupportedModel 用户可见的支持模型条目。
|
||
type userSupportedModel struct {
|
||
Name string `json:"name"`
|
||
Platform string `json:"platform"`
|
||
Pricing *userSupportedModelPricing `json:"pricing"`
|
||
}
|
||
|
||
// userAvailableChannel 用户可见的渠道条目(白名单字段)。
|
||
type userAvailableChannel struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
Groups []userAvailableGroup `json:"groups"`
|
||
SupportedModels []userSupportedModel `json:"supported_models"`
|
||
}
|
||
|
||
// List 列出当前用户可见的「可用渠道」。
|
||
// GET /api/v1/channels/available
|
||
func (h *AvailableChannelHandler) List(c *gin.Context) {
|
||
subject, ok := middleware.GetAuthSubjectFromContext(c)
|
||
if !ok {
|
||
response.Unauthorized(c, "User not authenticated")
|
||
return
|
||
}
|
||
|
||
userGroups, err := h.apiKeyService.GetAvailableGroups(c.Request.Context(), subject.UserID)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
allowedGroupIDs := make(map[int64]struct{}, len(userGroups))
|
||
for i := range userGroups {
|
||
allowedGroupIDs[userGroups[i].ID] = struct{}{}
|
||
}
|
||
|
||
channels, err := h.channelService.ListAvailable(c.Request.Context())
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
out := make([]userAvailableChannel, 0, len(channels))
|
||
for _, ch := range channels {
|
||
if ch.Status != service.StatusActive {
|
||
continue
|
||
}
|
||
visibleGroups := filterUserVisibleGroups(ch.Groups, allowedGroupIDs)
|
||
if len(visibleGroups) == 0 {
|
||
continue
|
||
}
|
||
allowedPlatforms := collectGroupPlatforms(visibleGroups)
|
||
out = append(out, userAvailableChannel{
|
||
Name: ch.Name,
|
||
Description: ch.Description,
|
||
Groups: visibleGroups,
|
||
SupportedModels: toUserSupportedModels(ch.SupportedModels, allowedPlatforms),
|
||
})
|
||
}
|
||
|
||
response.Success(c, out)
|
||
}
|
||
|
||
// collectGroupPlatforms 聚合 visible groups 覆盖的平台集合,用于过滤 SupportedModels。
|
||
func collectGroupPlatforms(groups []userAvailableGroup) map[string]struct{} {
|
||
set := make(map[string]struct{}, len(groups))
|
||
for _, g := range groups {
|
||
if g.Platform == "" {
|
||
continue
|
||
}
|
||
set[g.Platform] = struct{}{}
|
||
}
|
||
return set
|
||
}
|
||
|
||
// filterUserVisibleGroups 仅保留用户可访问的分组。
|
||
func filterUserVisibleGroups(
|
||
groups []service.AvailableGroupRef,
|
||
allowed map[int64]struct{},
|
||
) []userAvailableGroup {
|
||
visible := make([]userAvailableGroup, 0, len(groups))
|
||
for _, g := range groups {
|
||
if _, ok := allowed[g.ID]; !ok {
|
||
continue
|
||
}
|
||
visible = append(visible, userAvailableGroup{
|
||
ID: g.ID,
|
||
Name: g.Name,
|
||
Platform: g.Platform,
|
||
})
|
||
}
|
||
return visible
|
||
}
|
||
|
||
// toUserSupportedModels 将 service 层支持模型转换为用户 DTO(字段白名单)。
|
||
// 仅保留平台在 allowedPlatforms 中的条目,防止跨平台模型信息泄漏。
|
||
// allowedPlatforms 为 nil 时不做平台过滤(保留全部,供测试或明确无过滤场景使用)。
|
||
func toUserSupportedModels(
|
||
src []service.SupportedModel,
|
||
allowedPlatforms map[string]struct{},
|
||
) []userSupportedModel {
|
||
out := make([]userSupportedModel, 0, len(src))
|
||
for i := range src {
|
||
m := src[i]
|
||
if allowedPlatforms != nil {
|
||
if _, ok := allowedPlatforms[m.Platform]; !ok {
|
||
continue
|
||
}
|
||
}
|
||
out = append(out, userSupportedModel{
|
||
Name: m.Name,
|
||
Platform: m.Platform,
|
||
Pricing: toUserPricing(m.Pricing),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// toUserPricing 将 service 层定价转换为用户 DTO;入参为 nil 时返回 nil。
|
||
func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||
if p == nil {
|
||
return nil
|
||
}
|
||
intervals := make([]userPricingIntervalDTO, 0, len(p.Intervals))
|
||
for _, iv := range p.Intervals {
|
||
intervals = append(intervals, userPricingIntervalDTO{
|
||
MinTokens: iv.MinTokens,
|
||
MaxTokens: iv.MaxTokens,
|
||
TierLabel: iv.TierLabel,
|
||
InputPrice: iv.InputPrice,
|
||
OutputPrice: iv.OutputPrice,
|
||
CacheWritePrice: iv.CacheWritePrice,
|
||
CacheReadPrice: iv.CacheReadPrice,
|
||
PerRequestPrice: iv.PerRequestPrice,
|
||
})
|
||
}
|
||
billingMode := string(p.BillingMode)
|
||
if billingMode == "" {
|
||
billingMode = string(service.BillingModeToken)
|
||
}
|
||
return &userSupportedModelPricing{
|
||
BillingMode: billingMode,
|
||
InputPrice: p.InputPrice,
|
||
OutputPrice: p.OutputPrice,
|
||
CacheWritePrice: p.CacheWritePrice,
|
||
CacheReadPrice: p.CacheReadPrice,
|
||
ImageOutputPrice: p.ImageOutputPrice,
|
||
PerRequestPrice: p.PerRequestPrice,
|
||
Intervals: intervals,
|
||
}
|
||
}
|