Files
sub2api/backend/internal/handler/model_plaza_handler.go
T
feeeei b07d85c497 模型广场:分时计价同步渠道仅工作日规则
- 阶梯表分时倍率透传渠道 weekdays_only;探针锚点显式固定在工作日
  (原 2026-01-01 恰为周四是巧合,锚点落周末会把仅工作日时段整组剔除)
- 前端时段徽章加「工作日」前缀,tooltip 说明周末全天按标准价计费
2026-08-24 11:16:00 +08:00

248 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 则
// 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同):
// - 匿名:仅非专属分组(订阅型照常展示);
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性)。
type ModelPlazaHandler struct {
plazaService *service.ModelPlazaService
apiKeyService *service.APIKeyService
settingService *service.SettingService
}
// NewModelPlazaHandler 创建模型广场 handler。
func NewModelPlazaHandler(
plazaService *service.ModelPlazaService,
apiKeyService *service.APIKeyService,
settingService *service.SettingService,
) *ModelPlazaHandler {
return &ModelPlazaHandler{
plazaService: plazaService,
apiKeyService: apiKeyService,
settingService: settingService,
}
}
// modelPlazaOfficialPricing 官方参考价(USD per token,与计费目录同源)。
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"`
// Intervals 官方长上下文阶梯,仅多档模型给出。
Intervals []userPricingIntervalDTO `json:"intervals,omitempty"`
}
// modelPlazaTimePricingPeriod 分时倍率时段(配置时区当天 [start, end))。
type modelPlazaTimePricingPeriod struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Multiplier float64 `json:"multiplier"`
}
// modelPlazaTimePricing 计费会生效的分时倍率(仅倍率 ≠ 1 的时段)。
// WeekdaysOnly 为 true 时时段仅周一至周五生效,周末整天按标准价计费。
type modelPlazaTimePricing struct {
Timezone string `json:"timezone"`
WeekdaysOnly bool `json:"weekdays_only,omitempty"`
Periods []modelPlazaTimePricingPeriod `json:"periods"`
}
// modelPlazaModel 广场模型条目:实收口径展示定价(白名单形态)+ 官方参考价。
type modelPlazaModel struct {
Name string `json:"name"`
Platform string `json:"platform"`
Pricing *userSupportedModelPricing `json:"pricing"`
OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"`
// LongContextBasis 多档时的计价基准:"whole_request"(整单按档)| "marginal"(仅超出部分)。
LongContextBasis string `json:"long_context_basis,omitempty"`
// TimePricing 分时倍率时段,落在时段内的请求整单乘倍率;无分时省略。
TimePricing *modelPlazaTimePricing `json:"time_pricing,omitempty"`
}
// modelPlazaGroup 广场分组条目(白名单字段)。
type modelPlazaGroup struct {
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,
// 不取分组/用户专属倍率。
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
// 分组是否启用长上下文阶梯计费;关闭时模型实付列只展示最低档/基础价。
LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
Models []modelPlazaModel `json:"models"`
}
// 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
}
groups, err := h.plazaService.ListGroups(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
// allowedExclusive == nil 表示匿名;登录用户恒为非 nil(可能为空集合)。
var allowedExclusive map[int64]struct{}
var userRates map[int64]float64
if authed {
allowedExclusive, err = h.apiKeyService.GetUserAllowedGroupIDSet(c.Request.Context(), subject.UserID)
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
}
}
visible := filterPlazaVisibleGroups(groups, allowedExclusive)
out := make([]modelPlazaGroup, 0, len(visible))
for i := range visible {
out = append(out, toModelPlazaGroupDTO(&visible[i], userRates))
}
response.Success(c, modelPlazaResponse{
Description: rt.Description,
Groups: out,
})
}
// filterPlazaVisibleGroups 按登录态裁剪分组可见性。
// allowedExclusive == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。
func filterPlazaVisibleGroups(
groups []service.PlazaGroup,
allowedExclusive map[int64]struct{},
) []service.PlazaGroup {
visible := make([]service.PlazaGroup, 0, len(groups))
for _, g := range groups {
if g.IsExclusive {
if allowedExclusive == nil {
continue
}
if _, ok := allowedExclusive[g.ID]; !ok {
continue
}
}
visible = append(visible, g)
}
return visible
}
// 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{
Name: m.Name,
Platform: m.Platform,
Pricing: toUserPricing(m.Pricing),
OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
LongContextBasis: string(m.LongContextBasis),
TimePricing: toModelPlazaTimePricing(m.TimePricing),
})
}
dto := modelPlazaGroup{
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,
LongContextPricingEnabled: g.LongContextPricingEnabled,
Models: models,
}
if rate, ok := userRates[g.ID]; ok {
dto.UserRateMultiplier = &rate
}
return dto
}
// 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,
})
}
return &modelPlazaTimePricing{Timezone: p.Timezone, WeekdaysOnly: p.WeekdaysOnly, Periods: periods}
}
// 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,
Intervals: toUserPricingIntervals(p.Intervals),
}
}