2026-07-28 15:55:56 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"log/slog"
|
|
|
|
|
|
|
|
|
|
|
|
"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"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ModelPlazaHandler 处理「模型广场」查询。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 广场路由挂 OptionalJWT 中间件:匿名可访问(除非 require_auth 开启),带 token 则
|
|
|
|
|
|
// 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同):
|
|
|
|
|
|
// - 匿名:仅非专属分组(订阅型照常展示);
|
2026-09-08 13:37:53 +08:00
|
|
|
|
// - 登录:非专属分组 + user_allowed_groups 授权或持有有效订阅的专属分组;
|
2026-08-28 02:26:46 +08:00
|
|
|
|
// 若该用户开启了公开分组限制,则公开分组同样需要落在授权集合内。
|
2026-07-28 15:55:56 +08:00
|
|
|
|
type ModelPlazaHandler struct {
|
2026-08-23 13:41:33 +08:00
|
|
|
|
plazaService *service.ModelPlazaService
|
2026-07-28 15:55:56 +08:00
|
|
|
|
apiKeyService *service.APIKeyService
|
|
|
|
|
|
settingService *service.SettingService
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewModelPlazaHandler 创建模型广场 handler。
|
|
|
|
|
|
func NewModelPlazaHandler(
|
2026-08-23 13:41:33 +08:00
|
|
|
|
plazaService *service.ModelPlazaService,
|
2026-07-28 15:55:56 +08:00
|
|
|
|
apiKeyService *service.APIKeyService,
|
|
|
|
|
|
settingService *service.SettingService,
|
|
|
|
|
|
) *ModelPlazaHandler {
|
|
|
|
|
|
return &ModelPlazaHandler{
|
2026-08-23 13:41:33 +08:00
|
|
|
|
plazaService: plazaService,
|
2026-07-28 15:55:56 +08:00
|
|
|
|
apiKeyService: apiKeyService,
|
|
|
|
|
|
settingService: settingService,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 13:41:33 +08:00
|
|
|
|
// modelPlazaOfficialPricing 官方参考价(USD per token,与计费目录同源)。
|
2026-07-28 15:55:56 +08:00
|
|
|
|
type modelPlazaOfficialPricing struct {
|
|
|
|
|
|
InputPrice *float64 `json:"input_price"`
|
|
|
|
|
|
OutputPrice *float64 `json:"output_price"`
|
|
|
|
|
|
CacheWritePrice *float64 `json:"cache_write_price"`
|
|
|
|
|
|
CacheWrite1hPrice *float64 `json:"cache_write_1h_price,omitempty"`
|
|
|
|
|
|
CacheReadPrice *float64 `json:"cache_read_price"`
|
2026-08-23 13:41:33 +08:00
|
|
|
|
// Intervals 官方长上下文阶梯,仅多档模型给出。
|
|
|
|
|
|
Intervals []userPricingIntervalDTO `json:"intervals,omitempty"`
|
2026-07-28 15:55:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 15:07:39 +08:00
|
|
|
|
// modelPlazaTimePricingPeriod 分时倍率时段(配置时区当天 [start, end))。
|
|
|
|
|
|
type modelPlazaTimePricingPeriod struct {
|
|
|
|
|
|
StartTime string `json:"start_time"`
|
|
|
|
|
|
EndTime string `json:"end_time"`
|
|
|
|
|
|
Multiplier float64 `json:"multiplier"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// modelPlazaTimePricing 计费会生效的分时倍率(仅倍率 ≠ 1 的时段)。
|
2026-08-24 11:16:00 +08:00
|
|
|
|
// WeekdaysOnly 为 true 时时段仅周一至周五生效,周末整天按标准价计费。
|
2026-08-23 15:07:39 +08:00
|
|
|
|
type modelPlazaTimePricing struct {
|
2026-08-24 11:16:00 +08:00
|
|
|
|
Timezone string `json:"timezone"`
|
|
|
|
|
|
WeekdaysOnly bool `json:"weekdays_only,omitempty"`
|
|
|
|
|
|
Periods []modelPlazaTimePricingPeriod `json:"periods"`
|
2026-08-23 15:07:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 13:41:33 +08:00
|
|
|
|
// modelPlazaModel 广场模型条目:实收口径展示定价(白名单形态)+ 官方参考价。
|
2026-07-28 15:55:56 +08:00
|
|
|
|
type modelPlazaModel struct {
|
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
|
Platform string `json:"platform"`
|
|
|
|
|
|
Pricing *userSupportedModelPricing `json:"pricing"`
|
|
|
|
|
|
OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"`
|
2026-08-23 13:41:33 +08:00
|
|
|
|
// LongContextBasis 多档时的计价基准:"whole_request"(整单按档)| "marginal"(仅超出部分)。
|
|
|
|
|
|
LongContextBasis string `json:"long_context_basis,omitempty"`
|
2026-08-23 15:07:39 +08:00
|
|
|
|
// TimePricing 分时倍率时段,落在时段内的请求整单乘倍率;无分时省略。
|
|
|
|
|
|
TimePricing *modelPlazaTimePricing `json:"time_pricing,omitempty"`
|
2026-07-28 15:55:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// modelPlazaGroup 广场分组条目(白名单字段)。
|
|
|
|
|
|
type modelPlazaGroup struct {
|
2026-08-04 11:23:15 +08:00
|
|
|
|
ID int64 `json:"id"`
|
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
|
Description string `json:"description"`
|
|
|
|
|
|
Platform string `json:"platform"`
|
|
|
|
|
|
SubscriptionType string `json:"subscription_type"`
|
|
|
|
|
|
RateMultiplier float64 `json:"rate_multiplier"`
|
|
|
|
|
|
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
|
|
|
|
|
|
PeakRateEnabled bool `json:"peak_rate_enabled"`
|
|
|
|
|
|
PeakStart string `json:"peak_start"`
|
|
|
|
|
|
PeakEnd string `json:"peak_end"`
|
|
|
|
|
|
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
|
|
|
|
|
|
IsExclusive bool `json:"is_exclusive"`
|
|
|
|
|
|
// 生图独立倍率:为 true 时图片计费模型的实付倍率取 ImageRateMultiplier,
|
|
|
|
|
|
// 不取分组/用户专属倍率。
|
2026-08-23 13:41:33 +08:00
|
|
|
|
ImageRateIndependent bool `json:"image_rate_independent"`
|
|
|
|
|
|
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
|
2026-09-23 11:28:41 +08:00
|
|
|
|
VideoRateIndependent bool `json:"video_rate_independent"`
|
|
|
|
|
|
VideoRateMultiplier float64 `json:"video_rate_multiplier"`
|
2026-08-23 13:41:33 +08:00
|
|
|
|
// 分组是否启用长上下文阶梯计费;关闭时模型实付列只展示最低档/基础价。
|
|
|
|
|
|
LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
|
|
|
|
|
|
Models []modelPlazaModel `json:"models"`
|
2026-07-28 15:55:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// modelPlazaResponse 广场页响应。
|
|
|
|
|
|
type modelPlazaResponse struct {
|
|
|
|
|
|
Description string `json:"description"`
|
|
|
|
|
|
Groups []modelPlazaGroup `json:"groups"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get 返回模型广场数据。
|
|
|
|
|
|
// GET /api/v1/model-plaza
|
|
|
|
|
|
func (h *ModelPlazaHandler) Get(c *gin.Context) {
|
|
|
|
|
|
if h.settingService == nil {
|
|
|
|
|
|
response.NotFound(c, "Model plaza is not enabled")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
rt := h.settingService.GetModelPlazaRuntime(c.Request.Context())
|
|
|
|
|
|
if !rt.Enabled {
|
|
|
|
|
|
response.NotFound(c, "Model plaza is not enabled")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
subject, authed := middleware.GetAuthSubjectFromContext(c)
|
|
|
|
|
|
if rt.RequireAuth && !authed {
|
|
|
|
|
|
response.Unauthorized(c, "Authentication required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 13:41:33 +08:00
|
|
|
|
groups, err := h.plazaService.ListGroups(c.Request.Context())
|
2026-07-28 15:55:56 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 02:26:46 +08:00
|
|
|
|
// allowedGroups == nil 表示匿名;登录用户恒为非 nil(可能为空集合)。
|
|
|
|
|
|
var allowedGroups map[int64]struct{}
|
|
|
|
|
|
var restrictPublicGroups bool
|
2026-07-28 15:55:56 +08:00
|
|
|
|
var userRates map[int64]float64
|
|
|
|
|
|
if authed {
|
2026-08-28 02:26:46 +08:00
|
|
|
|
allowedGroups, restrictPublicGroups, err = h.apiKeyService.GetUserGroupVisibility(c.Request.Context(), subject.UserID)
|
2026-07-28 15:55:56 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
// 可见性数据拿不到时不能静默降级成匿名视图(会错漏专属分组),直接报错。
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
userRates, err = h.apiKeyService.GetUserGroupRates(c.Request.Context(), subject.UserID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
// 专属倍率仅是展示增强,失败降级为分组默认倍率。
|
|
|
|
|
|
slog.Warn("model_plaza_user_rates_failed", "error", err, "user_id", subject.UserID)
|
|
|
|
|
|
userRates = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 02:26:46 +08:00
|
|
|
|
visible := filterPlazaVisibleGroups(groups, allowedGroups, restrictPublicGroups)
|
2026-07-28 15:55:56 +08:00
|
|
|
|
|
2026-10-06 16:23:40 +08:00
|
|
|
|
// 广场可匿名访问,分组文案随请求语言变化,提示中间缓存按语言区分。
|
2026-10-12 18:09:03 +08:00
|
|
|
|
// 追加而非覆盖:CORS 中间件已为具体来源写入 Vary: Origin。
|
|
|
|
|
|
c.Writer.Header().Add("Vary", "Accept-Language")
|
2026-10-06 16:23:40 +08:00
|
|
|
|
out := toLocalizedModelPlazaGroups(visible, userRates, c.GetHeader("Accept-Language"))
|
2026-07-28 15:55:56 +08:00
|
|
|
|
response.Success(c, modelPlazaResponse{
|
|
|
|
|
|
Description: rt.Description,
|
|
|
|
|
|
Groups: out,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// filterPlazaVisibleGroups 按登录态裁剪分组可见性。
|
2026-09-08 13:37:53 +08:00
|
|
|
|
// allowedGroups == nil 表示匿名(仅非专属);非 nil 包含普通授权及有效订阅分组。
|
2026-08-28 02:26:46 +08:00
|
|
|
|
// restrictPublicGroups 为 true 时,公开分组也必须落在 allowedGroups 内,否则用户会
|
|
|
|
|
|
// 在广场看到自己实际绑定不了的分组。
|
2026-07-28 15:55:56 +08:00
|
|
|
|
func filterPlazaVisibleGroups(
|
|
|
|
|
|
groups []service.PlazaGroup,
|
2026-08-28 02:26:46 +08:00
|
|
|
|
allowedGroups map[int64]struct{},
|
|
|
|
|
|
restrictPublicGroups bool,
|
2026-07-28 15:55:56 +08:00
|
|
|
|
) []service.PlazaGroup {
|
|
|
|
|
|
visible := make([]service.PlazaGroup, 0, len(groups))
|
|
|
|
|
|
for _, g := range groups {
|
2026-08-28 02:26:46 +08:00
|
|
|
|
if g.IsExclusive || (restrictPublicGroups && allowedGroups != nil) {
|
|
|
|
|
|
if allowedGroups == nil {
|
2026-07-28 15:55:56 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-08-28 02:26:46 +08:00
|
|
|
|
if _, ok := allowedGroups[g.ID]; !ok {
|
2026-07-28 15:55:56 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
visible = append(visible, g)
|
|
|
|
|
|
}
|
|
|
|
|
|
return visible
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 16:23:40 +08:00
|
|
|
|
// toLocalizedModelPlazaGroups 转换可见分组,并把名称与描述换成请求首选语言的版本。
|
|
|
|
|
|
func toLocalizedModelPlazaGroups(visible []service.PlazaGroup, userRates map[int64]float64, acceptLanguage string) []modelPlazaGroup {
|
|
|
|
|
|
out := make([]modelPlazaGroup, 0, len(visible))
|
|
|
|
|
|
for i := range visible {
|
|
|
|
|
|
group := toModelPlazaGroupDTO(&visible[i], userRates)
|
|
|
|
|
|
group.Name, group.Description = visible[i].I18n.Localize(acceptLanguage, group.Name, group.Description)
|
|
|
|
|
|
out = append(out, group)
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 15:55:56 +08:00
|
|
|
|
// toModelPlazaGroupDTO 将 service 层广场分组映射为白名单 DTO,并合并用户专属倍率。
|
|
|
|
|
|
func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) modelPlazaGroup {
|
|
|
|
|
|
models := make([]modelPlazaModel, 0, len(g.Models))
|
|
|
|
|
|
for i := range g.Models {
|
|
|
|
|
|
m := &g.Models[i]
|
|
|
|
|
|
models = append(models, modelPlazaModel{
|
2026-08-23 13:41:33 +08:00
|
|
|
|
Name: m.Name,
|
|
|
|
|
|
Platform: m.Platform,
|
|
|
|
|
|
Pricing: toUserPricing(m.Pricing),
|
|
|
|
|
|
OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
|
|
|
|
|
|
LongContextBasis: string(m.LongContextBasis),
|
2026-08-23 15:07:39 +08:00
|
|
|
|
TimePricing: toModelPlazaTimePricing(m.TimePricing),
|
2026-07-28 15:55:56 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
dto := modelPlazaGroup{
|
2026-08-23 13:41:33 +08:00
|
|
|
|
ID: g.ID,
|
|
|
|
|
|
Name: g.Name,
|
|
|
|
|
|
Description: g.Description,
|
|
|
|
|
|
Platform: g.Platform,
|
|
|
|
|
|
SubscriptionType: g.SubscriptionType,
|
|
|
|
|
|
RateMultiplier: g.RateMultiplier,
|
|
|
|
|
|
PeakRateEnabled: g.PeakRateEnabled,
|
|
|
|
|
|
PeakStart: g.PeakStart,
|
|
|
|
|
|
PeakEnd: g.PeakEnd,
|
|
|
|
|
|
PeakRateMultiplier: g.PeakRateMultiplier,
|
|
|
|
|
|
IsExclusive: g.IsExclusive,
|
|
|
|
|
|
ImageRateIndependent: g.ImageRateIndependent,
|
|
|
|
|
|
ImageRateMultiplier: g.ImageRateMultiplier,
|
2026-09-23 11:28:41 +08:00
|
|
|
|
VideoRateIndependent: g.VideoRateIndependent,
|
|
|
|
|
|
VideoRateMultiplier: g.VideoRateMultiplier,
|
2026-08-23 13:41:33 +08:00
|
|
|
|
LongContextPricingEnabled: g.LongContextPricingEnabled,
|
|
|
|
|
|
Models: models,
|
2026-07-28 15:55:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
if rate, ok := userRates[g.ID]; ok {
|
|
|
|
|
|
dto.UserRateMultiplier = &rate
|
|
|
|
|
|
}
|
|
|
|
|
|
return dto
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 15:07:39 +08:00
|
|
|
|
// toModelPlazaTimePricing 转换分时倍率;nil 透传(JSON 省略)。
|
|
|
|
|
|
func toModelPlazaTimePricing(p *service.TimePricingSchedule) *modelPlazaTimePricing {
|
|
|
|
|
|
if p == nil || len(p.Periods) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
periods := make([]modelPlazaTimePricingPeriod, 0, len(p.Periods))
|
|
|
|
|
|
for _, period := range p.Periods {
|
|
|
|
|
|
periods = append(periods, modelPlazaTimePricingPeriod{
|
|
|
|
|
|
StartTime: period.StartTime,
|
|
|
|
|
|
EndTime: period.EndTime,
|
|
|
|
|
|
Multiplier: period.Multiplier,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-08-24 11:16:00 +08:00
|
|
|
|
return &modelPlazaTimePricing{Timezone: p.Timezone, WeekdaysOnly: p.WeekdaysOnly, Periods: periods}
|
2026-08-23 15:07:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 15:55:56 +08:00
|
|
|
|
// toModelPlazaOfficialPricing 转换官方参考价;nil 透传(前端显示 "-")。
|
|
|
|
|
|
func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOfficialPricing {
|
|
|
|
|
|
if p == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return &modelPlazaOfficialPricing{
|
|
|
|
|
|
InputPrice: p.InputPrice,
|
|
|
|
|
|
OutputPrice: p.OutputPrice,
|
|
|
|
|
|
CacheWritePrice: p.CacheWritePrice,
|
|
|
|
|
|
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
|
|
|
|
|
CacheReadPrice: p.CacheReadPrice,
|
2026-08-23 13:41:33 +08:00
|
|
|
|
Intervals: toUserPricingIntervals(p.Intervals),
|
2026-07-28 15:55:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|