Merge pull request #5003 from feeeei/main

feat(模型广场): add model plaza with group-scoped pricing showcase
This commit is contained in:
Wesley Liddick
2026-07-28 20:02:52 +08:00
committed by GitHub
49 changed files with 2612 additions and 11 deletions
+4 -2
View File
@@ -290,6 +290,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService)
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
modelPlazaHandler := handler.NewModelPlazaHandler(channelService, apiKeyService, settingService)
imageTaskStore := repository.NewImageTaskStore(redisClient)
imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService)
asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler)
@@ -303,13 +304,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService)
optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService)
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
auditLogMiddleware := middleware.NewAuditLogMiddleware(auditLogService)
stepUpAuthMiddleware := middleware.NewStepUpAuthMiddleware(totpService, userService, settingService)
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, compositeRouteResolver, redisClient)
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, optionalJWTAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, compositeRouteResolver, redisClient)
httpServer := server.ProvideHTTPServer(configConfig, engine)
opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig)
opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig)
@@ -351,6 +351,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
ModelPlazaDescription: settings.ModelPlazaDescription,
AffiliateEnabled: settings.AffiliateEnabled,
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
@@ -528,6 +528,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
changed = append(changed, "available_channels_enabled")
}
if before.ModelPlazaEnabled != after.ModelPlazaEnabled {
changed = append(changed, "model_plaza_enabled")
}
if before.ModelPlazaRequireAuth != after.ModelPlazaRequireAuth {
changed = append(changed, "model_plaza_require_auth")
}
if before.ModelPlazaDescription != after.ModelPlazaDescription {
changed = append(changed, "model_plaza_description")
}
if before.AffiliateEnabled != after.AffiliateEnabled {
changed = append(changed, "affiliate_enabled")
}
@@ -313,6 +313,11 @@ type UpdateSettingsRequest struct {
// Available Channels feature switch (user-facing)
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
// Model Plaza feature switches + description
ModelPlazaEnabled *bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
ModelPlazaDescription *string `json:"model_plaza_description"`
// Affiliate (邀请返利) feature switch
AffiliateEnabled *bool `json:"affiliate_enabled"`
@@ -1678,6 +1683,24 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.AvailableChannelsEnabled
}(),
ModelPlazaEnabled: func() bool {
if req.ModelPlazaEnabled != nil {
return *req.ModelPlazaEnabled
}
return previousSettings.ModelPlazaEnabled
}(),
ModelPlazaRequireAuth: func() bool {
if req.ModelPlazaRequireAuth != nil {
return *req.ModelPlazaRequireAuth
}
return previousSettings.ModelPlazaRequireAuth
}(),
ModelPlazaDescription: func() string {
if req.ModelPlazaDescription != nil {
return *req.ModelPlazaDescription
}
return previousSettings.ModelPlazaDescription
}(),
AffiliateEnabled: func() bool {
if req.AffiliateEnabled != nil {
return *req.AffiliateEnabled
@@ -2073,6 +2096,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
AffiliateEnabled: updatedSettings.AffiliateEnabled,
RiskControlEnabled: updatedSettings.RiskControlEnabled,
+8
View File
@@ -290,6 +290,11 @@ type SystemSettings struct {
// Available Channels feature switch (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
// Model Plaza feature (public group/model pricing showcase)
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
ModelPlazaDescription string `json:"model_plaza_description"`
// 风控中心功能开关
RiskControlEnabled bool `json:"risk_control_enabled"`
@@ -374,6 +379,9 @@ type PublicSettings struct {
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
AffiliateEnabled bool `json:"affiliate_enabled"`
RiskControlEnabled bool `json:"risk_control_enabled"`
+1
View File
@@ -62,6 +62,7 @@ type Handlers struct {
Payment *PaymentHandler
PaymentWebhook *PaymentWebhookHandler
AvailableChannel *AvailableChannelHandler
ModelPlaza *ModelPlazaHandler
AsyncImage *AsyncImageHandler
BatchImage *BatchImageHandler
}
@@ -0,0 +1,198 @@
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 {
channelService *service.ChannelService
apiKeyService *service.APIKeyService
settingService *service.SettingService
}
// NewModelPlazaHandler 创建模型广场 handler。
func NewModelPlazaHandler(
channelService *service.ChannelService,
apiKeyService *service.APIKeyService,
settingService *service.SettingService,
) *ModelPlazaHandler {
return &ModelPlazaHandler{
channelService: channelService,
apiKeyService: apiKeyService,
settingService: settingService,
}
}
// modelPlazaOfficialPricing LiteLLM 官方参考价(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"`
}
// modelPlazaModel 广场模型条目:渠道定价(白名单形态)+ 官方参考价。
type modelPlazaModel struct {
Name string `json:"name"`
Platform string `json:"platform"`
Pricing *userSupportedModelPricing `json:"pricing"`
OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"`
}
// 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"`
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.channelService.ListPlazaGroups(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),
})
}
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,
Models: models,
}
if rate, ok := userRates[g.ID]; ok {
dto.UserRateMultiplier = &rate
}
return dto
}
// 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,
}
}
@@ -0,0 +1,126 @@
//go:build unit
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func plazaGroups() []service.PlazaGroup {
return []service.PlazaGroup{
{ID: 1, Name: "public-standard", Platform: "anthropic", SubscriptionType: "standard", RateMultiplier: 1},
{ID: 2, Name: "exclusive-a", Platform: "anthropic", IsExclusive: true, RateMultiplier: 0.5},
{ID: 3, Name: "public-subscription", Platform: "openai", SubscriptionType: "subscription", RateMultiplier: 1},
{ID: 4, Name: "exclusive-b", Platform: "openai", IsExclusive: true, RateMultiplier: 0.8},
}
}
func TestFilterPlazaVisibleGroups_AnonymousSeesOnlyNonExclusive(t *testing.T) {
// 匿名(allowedExclusive == nil):仅非专属分组;订阅型公开分组照常可见(橱窗语义)。
visible := filterPlazaVisibleGroups(plazaGroups(), nil)
require.Len(t, visible, 2)
ids := []int64{visible[0].ID, visible[1].ID}
require.ElementsMatch(t, []int64{1, 3}, ids)
}
func TestFilterPlazaVisibleGroups_AuthedSeesGrantedExclusive(t *testing.T) {
// 登录:非专属 + 授权的专属;未授权的专属仍不可见。
allowed := map[int64]struct{}{2: {}}
visible := filterPlazaVisibleGroups(plazaGroups(), allowed)
require.Len(t, visible, 3)
ids := make([]int64, 0, len(visible))
for _, g := range visible {
ids = append(ids, g.ID)
}
require.ElementsMatch(t, []int64{1, 2, 3}, ids)
}
func TestFilterPlazaVisibleGroups_AuthedEmptySetSeesNoExclusive(t *testing.T) {
// 登录但无任何专属授权(空集合,非 nil):与匿名同样只见非专属,
// 但语义区分要保持——空集合不能被当作 nil 匿名分支。
visible := filterPlazaVisibleGroups(plazaGroups(), map[int64]struct{}{})
require.Len(t, visible, 2)
}
func TestModelPlazaHandler_NilSettingServiceFailsClosed404(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &ModelPlazaHandler{} // settingService == nil → fail-closed
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/model-plaza", nil)
h.Get(c)
require.Equal(t, http.StatusNotFound, w.Code)
}
func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
g := service.PlazaGroup{
ID: 2, Name: "vip", Description: "d", Platform: "anthropic",
SubscriptionType: "standard", RateMultiplier: 1, IsExclusive: true,
Models: []service.PlazaModel{{
Name: "claude-sonnet",
Platform: "anthropic",
Pricing: &service.ChannelModelPricing{
BillingMode: service.BillingModeToken,
InputPrice: testPtr(3e-6),
},
OfficialPricing: &service.PlazaOfficialPricing{
InputPrice: testPtr(3e-6),
CacheReadPrice: testPtr(3e-7),
},
}},
}
// 有专属倍率:user_rate_multiplier 序列化输出
dto := toModelPlazaGroupDTO(&g, map[int64]float64{2: 0.5})
raw, err := json.Marshal(dto)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(raw, &decoded))
for _, key := range []string{
"id", "name", "description", "platform", "subscription_type",
"rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
"peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
} {
_, exists := decoded[key]
require.Truef(t, exists, "plaza group DTO must expose %q", key)
}
require.InDelta(t, 0.5, decoded["user_rate_multiplier"].(float64), 1e-9)
// 模型条目:pricing + official_pricing 并存;official 缺失字段输出 null 而非省略
models := decoded["models"].([]any)
require.Len(t, models, 1)
model := models[0].(map[string]any)
require.Contains(t, model, "pricing")
require.Contains(t, model, "official_pricing")
official := model["official_pricing"].(map[string]any)
require.Contains(t, official, "input_price")
require.Contains(t, official, "cache_read_price")
_, has1h := official["cache_write_1h_price"]
require.False(t, has1h, "1h 缓存写价为 nil 时应 omitempty")
// 无专属倍率:user_rate_multiplier 整个字段省略
dtoNoRate := toModelPlazaGroupDTO(&g, nil)
rawNoRate, err := json.Marshal(dtoNoRate)
require.NoError(t, err)
var decodedNoRate map[string]any
require.NoError(t, json.Unmarshal(rawNoRate, &decodedNoRate))
_, hasRate := decodedNoRate["user_rate_multiplier"]
require.False(t, hasRate, "无专属倍率时 user_rate_multiplier 应 omitempty")
}
func TestToModelPlazaOfficialPricing_NilPassthrough(t *testing.T) {
require.Nil(t, toModelPlazaOfficialPricing(nil))
}
func testPtr(v float64) *float64 { return &v }
@@ -99,6 +99,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
AffiliateEnabled: settings.AffiliateEnabled,
RiskControlEnabled: settings.RiskControlEnabled,
+3
View File
@@ -183,6 +183,7 @@ func ProvideHandlers(
paymentHandler *PaymentHandler,
paymentWebhookHandler *PaymentWebhookHandler,
availableChannelHandler *AvailableChannelHandler,
modelPlazaHandler *ModelPlazaHandler,
asyncImageHandler *AsyncImageHandler,
batchImageHandler *BatchImageHandler,
_ *service.IdempotencyCoordinator,
@@ -206,6 +207,7 @@ func ProvideHandlers(
Payment: paymentHandler,
PaymentWebhook: paymentWebhookHandler,
AvailableChannel: availableChannelHandler,
ModelPlaza: modelPlazaHandler,
AsyncImage: asyncImageHandler,
BatchImage: batchImageHandler,
}
@@ -230,6 +232,7 @@ var ProviderSet = wire.NewSet(
NewPaymentHandler,
NewPaymentWebhookHandler,
NewAvailableChannelHandler,
NewModelPlazaHandler,
NewAsyncImageHandler,
ProvideBatchImageHandler,
@@ -958,6 +958,9 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
"model_plaza_enabled": false,
"model_plaza_require_auth": false,
"model_plaza_description": "",
"risk_control_enabled": false,
"cyber_session_block_enabled": false,
"cyber_session_block_ttl_seconds": 3600,
@@ -1242,6 +1245,9 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
"model_plaza_enabled": false,
"model_plaza_require_auth": false,
"model_plaza_description": "",
"risk_control_enabled": false,
"cyber_session_block_enabled": false,
"cyber_session_block_ttl_seconds": 3600,
+2 -1
View File
@@ -31,6 +31,7 @@ func ProvideRouter(
cfg *config.Config,
handlers *handler.Handlers,
jwtAuth middleware2.JWTAuthMiddleware,
optionalJWTAuth middleware2.OptionalJWTAuthMiddleware,
adminAuth middleware2.AdminAuthMiddleware,
apiKeyAuth middleware2.APIKeyAuthMiddleware,
auditLog middleware2.AuditLogMiddleware,
@@ -86,7 +87,7 @@ func ProvideRouter(
service.SetWebSearchManager(websearch.NewManager(configs, redisClient))
})
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
return SetupRouter(r, handlers, jwtAuth, optionalJWTAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
}
func configureTrustedProxies(r *gin.Engine, cfg config.ServerConfig) {
@@ -0,0 +1,30 @@
package middleware
import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// NewOptionalJWTAuthMiddleware 创建可选 JWT 认证中间件。
//
// 无 Authorization header 时直接放行(匿名,context 中不设置 AuthSubject);
// 带 header 则委托严格 JWT 校验(token 版本 / 用户状态 / 会话绑定),失败返回 401——
// 前端 API client 对 401 会自动走 refresh-token 重试,因此不做静默降级。
func NewOptionalJWTAuthMiddleware(
authService *service.AuthService,
userService *service.UserService,
settingService *service.SettingService,
auditService *service.AuditLogService,
) OptionalJWTAuthMiddleware {
strict := jwtAuth(authService, userService, userService, settingService, auditService)
return OptionalJWTAuthMiddleware(func(c *gin.Context) {
if strings.TrimSpace(c.GetHeader("Authorization")) == "" {
c.Next()
return
}
strict(c)
})
}
@@ -0,0 +1,99 @@
//go:build unit
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// newOptionalJWTTestEnv 创建 OptionalJWT 中间件测试环境。
// handler 回写「是否携带 AuthSubject」,便于断言匿名 vs 登录两种路径。
func newOptionalJWTTestEnv(users map[int64]*service.User) (*gin.Engine, *service.AuthService) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.JWT.Secret = "test-jwt-secret-32bytes-long!!!"
cfg.JWT.AccessTokenExpireMinutes = 60
userRepo := &stubJWTUserRepo{users: users}
authSvc := service.NewAuthService(nil, userRepo, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil)
userSvc := service.NewUserService(userRepo, nil, nil, nil)
mw := NewOptionalJWTAuthMiddleware(authSvc, userSvc, nil, nil)
r := gin.New()
r.Use(gin.HandlerFunc(mw))
r.GET("/plaza", func(c *gin.Context) {
subject, authed := GetAuthSubjectFromContext(c)
c.JSON(http.StatusOK, gin.H{"authed": authed, "user_id": subject.UserID})
})
return r, authSvc
}
func TestOptionalJWTAuth_NoHeaderPassesAnonymously(t *testing.T) {
router, _ := newOptionalJWTTestEnv(nil)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"authed":false`)
}
func TestOptionalJWTAuth_ValidTokenSetsSubject(t *testing.T) {
user := &service.User{
ID: 7,
Email: "plaza@example.com",
Role: "user",
Status: service.StatusActive,
Concurrency: 5,
TokenVersion: 1,
}
router, authSvc := newOptionalJWTTestEnv(map[int64]*service.User{7: user})
token, err := authSvc.GenerateToken(context.Background(), user)
require.NoError(t, err)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
req.Header.Set("Authorization", "Bearer "+token)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"authed":true`)
require.Contains(t, w.Body.String(), `"user_id":7`)
}
func TestOptionalJWTAuth_InvalidTokenRejected401(t *testing.T) {
// 带了 header 就必须通过严格校验:坏 token 返回 401 而非静默降级为匿名,
// 前端 401 拦截器会走 refresh-token 重试。
router, _ := newOptionalJWTTestEnv(nil)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
req.Header.Set("Authorization", "Bearer not-a-real-token")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestOptionalJWTAuth_BlankHeaderTreatedAsAnonymous(t *testing.T) {
// 空白 header(如 "Authorization: ")按匿名处理,不进入严格校验。
router, _ := newOptionalJWTTestEnv(nil)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
req.Header.Set("Authorization", " ")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"authed":false`)
}
@@ -8,6 +8,9 @@ import (
// JWTAuthMiddleware JWT 认证中间件类型
type JWTAuthMiddleware gin.HandlerFunc
// OptionalJWTAuthMiddleware 可选 JWT 认证中间件类型:匿名放行,带 token 严格校验
type OptionalJWTAuthMiddleware gin.HandlerFunc
// AdminAuthMiddleware 管理员认证中间件类型
type AdminAuthMiddleware gin.HandlerFunc
@@ -17,6 +20,7 @@ type APIKeyAuthMiddleware gin.HandlerFunc
// ProviderSet 中间件层的依赖注入
var ProviderSet = wire.NewSet(
NewJWTAuthMiddleware,
NewOptionalJWTAuthMiddleware,
NewAdminAuthMiddleware,
NewAPIKeyAuthMiddleware,
NewAuditLogMiddleware,
+6 -3
View File
@@ -24,6 +24,7 @@ func SetupRouter(
r *gin.Engine,
handlers *handler.Handlers,
jwtAuth middleware2.JWTAuthMiddleware,
optionalJWTAuth middleware2.OptionalJWTAuthMiddleware,
adminAuth middleware2.AdminAuthMiddleware,
apiKeyAuth middleware2.APIKeyAuthMiddleware,
auditLog middleware2.AuditLogMiddleware,
@@ -71,8 +72,8 @@ func SetupRouter(
// Serve embedded frontend with settings injection if available
if web.HasEmbeddedFrontend() {
frontendServer, err := web.NewFrontendServer(settingService)
if err != nil {
frontendServer, err := web.NewFrontendServer(settingService) //nolint:staticcheck // SA4023: the !embed stub always errors; embed builds can return nil
if err != nil { //nolint:staticcheck // SA4023: see above
log.Printf("Warning: Failed to create frontend server with settings injection: %v, using legacy mode", err)
r.Use(web.ServeEmbeddedFrontend())
settingService.SetOnUpdateCallback(refreshFrameOrigins)
@@ -89,7 +90,7 @@ func SetupRouter(
}
// 注册路由
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
registerRoutes(r, handlers, jwtAuth, optionalJWTAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
return r
}
@@ -99,6 +100,7 @@ func registerRoutes(
r *gin.Engine,
h *handler.Handlers,
jwtAuth middleware2.JWTAuthMiddleware,
optionalJWTAuth middleware2.OptionalJWTAuthMiddleware,
adminAuth middleware2.AdminAuthMiddleware,
apiKeyAuth middleware2.APIKeyAuthMiddleware,
auditLog middleware2.AuditLogMiddleware,
@@ -124,6 +126,7 @@ func registerRoutes(
// 注册各模块路由
routes.RegisterAuthRoutes(v1, h, jwtAuth, auditLog, redisClient, settingService, panelRateLimiter)
routes.RegisterUserRoutes(v1, h, jwtAuth, auditLog, settingService, panelRateLimiter)
routes.RegisterModelPlazaRoutes(v1, h, optionalJWTAuth, settingService, panelRateLimiter)
routes.RegisterAdminRoutes(v1, h, adminAuth, auditLog, stepUpAuth, settingService, panelRateLimiter)
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg)
routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, auditLog, settingService, panelRateLimiter)
@@ -0,0 +1,30 @@
package routes
import (
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// RegisterModelPlazaRoutes 注册模型广场路由。
//
// 挂 OptionalJWT:匿名可访问(开关与 require_auth 由 handler fail-closed 判定),
// 带 token 则识别用户以展示专属分组与个人倍率。
// BackendModeUserGuard 保证 backend 模式下广场不对非管理员开放(匿名无 role → 403)。
func RegisterModelPlazaRoutes(
v1 *gin.RouterGroup,
h *handler.Handlers,
optionalJWT middleware.OptionalJWTAuthMiddleware,
settingService *service.SettingService,
panelRateLimiter *middleware.PanelRateLimiter,
) {
plaza := v1.Group("/model-plaza")
plaza.Use(panelRateLimiter.PublicIP())
plaza.Use(gin.HandlerFunc(optionalJWT))
plaza.Use(middleware.BackendModeUserGuard(settingService))
{
plaza.GET("", h.ModelPlaza.Get)
}
}
@@ -1034,6 +1034,22 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword
return keys, nil
}
// GetUserAllowedGroupIDSet 返回 user_allowed_groups 授权给该用户的专属分组 ID 集合。
//
// 与 GetAvailableGroups 的区别:这里是「橱窗」语义(模型广场用),不检查订阅有效性,
// 也不关心分组是否活跃——仅回答"哪些专属分组对该用户可见"。返回值恒非 nil。
func (s *APIKeyService) GetUserAllowedGroupIDSet(ctx context.Context, userID int64) (map[int64]struct{}, error) {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
allowed := make(map[int64]struct{}, len(user.AllowedGroups))
for _, id := range user.AllowedGroups {
allowed[id] = struct{}{}
}
return allowed, nil
}
// GetUserGroupRates 获取用户的专属分组倍率配置
// 返回 map[groupID]rateMultiplier
func (s *APIKeyService) GetUserGroupRates(ctx context.Context, userID int64) (map[int64]float64, error) {
+183
View File
@@ -0,0 +1,183 @@
package service
import (
"context"
"fmt"
"sort"
"strings"
)
// PlazaOfficialPricing 模型广场展示用的 LiteLLM 官方参考价(USD per token)。
// 字段为 nil 表示官方数据中该项缺失(0 视为未配置)。
type PlazaOfficialPricing struct {
InputPrice *float64
OutputPrice *float64
CacheWritePrice *float64 // 5m 缓存写入(= LiteLLM cache_creation)
CacheWrite1hPrice *float64 // 1h 缓存写入(LiteLLM cache_creation_above_1hr)
CacheReadPrice *float64
}
// PlazaModel 模型广场中单个模型条目:渠道定价 + 官方参考价。
type PlazaModel struct {
Name string
Platform string
Pricing *ChannelModelPricing
OfficialPricing *PlazaOfficialPricing
}
// PlazaGroup 模型广场中以分组为顶层的条目。
//
// 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的
// 支持模型(按分组平台隔离,防跨平台泄漏),与「可用渠道」页口径一致。
type PlazaGroup struct {
ID int64
Name string
Description string
Platform string
SubscriptionType string
RateMultiplier float64
PeakRateEnabled bool
PeakStart string
PeakEnd string
PeakRateMultiplier float64
IsExclusive bool
Models []PlazaModel
}
// ListPlazaGroups 返回模型广场数据:每个活跃分组附带其可用模型与定价。
//
// 聚合口径与 ListAvailable 一致(Active 渠道、SupportedModels ∪ 全局定价回落、
// 平台隔离),仅把顶层从渠道换成分组:
// - 渠道按 lower(name) 排序后遍历,保证同名模型去重结果确定;
// - 同分组同名模型「先见者胜」,仅当已存条目无定价而新条目有定价时升级替换;
// - 每个模型附带 LiteLLM 官方参考价(查不到为 nil);
// - 只返回 Models 非空的分组;分组按 RateMultiplier 升序(同倍率按名称),
// 组内模型按名称排序。
//
// 可见性过滤(专属分组)不在此层做,由 handler 按登录态裁剪。
func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, error) {
channels, err := s.repo.ListAll(ctx)
if err != nil {
return nil, fmt.Errorf("list channels: %w", err)
}
groups, err := s.groupRepo.ListActive(ctx)
if err != nil {
return nil, fmt.Errorf("list active groups: %w", err)
}
sort.SliceStable(channels, func(i, j int) bool {
return strings.ToLower(channels[i].Name) < strings.ToLower(channels[j].Name)
})
byGroup := make(map[int64]*PlazaGroup, len(groups))
order := make([]int64, 0, len(groups))
for i := range groups {
g := groups[i]
byGroup[g.ID] = &PlazaGroup{
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,
}
order = append(order, g.ID)
}
// modelIdx[groupID][modelName] = index into byGroup[groupID].Models
modelIdx := make(map[int64]map[string]int, len(groups))
for i := range channels {
ch := &channels[i]
if ch.Status != StatusActive {
continue
}
ch.normalizeBillingModelSource()
supported := ch.SupportedModels()
s.fillGlobalPricingFallback(supported)
for _, gid := range ch.GroupIDs {
pg, ok := byGroup[gid]
if !ok {
continue
}
idx := modelIdx[gid]
if idx == nil {
idx = make(map[string]int, len(supported))
modelIdx[gid] = idx
}
for j := range supported {
m := supported[j]
if m.Platform != pg.Platform {
continue
}
if at, seen := idx[m.Name]; seen {
// 先见者胜;仅当已存条目无定价而新条目有定价时升级。
if pg.Models[at].Pricing == nil && m.Pricing != nil {
pg.Models[at].Pricing = m.Pricing
}
continue
}
idx[m.Name] = len(pg.Models)
pg.Models = append(pg.Models, PlazaModel{
Name: m.Name,
Platform: m.Platform,
Pricing: m.Pricing,
})
}
}
}
officialMemo := make(map[string]*PlazaOfficialPricing)
out := make([]PlazaGroup, 0, len(order))
for _, gid := range order {
pg := byGroup[gid]
if len(pg.Models) == 0 {
continue
}
sort.SliceStable(pg.Models, func(i, j int) bool { return pg.Models[i].Name < pg.Models[j].Name })
for j := range pg.Models {
pg.Models[j].OfficialPricing = s.lookupOfficialPricing(pg.Models[j].Name, officialMemo)
}
out = append(out, *pg)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].RateMultiplier != out[j].RateMultiplier {
return out[i].RateMultiplier < out[j].RateMultiplier
}
return out[i].Name < out[j].Name
})
return out, nil
}
// lookupOfficialPricing 查询模型的 LiteLLM 官方参考价,带 memo 避免同名模型重复转换。
// pricingService 为 nil(测试场景)或查不到时返回 nil。
func (s *ChannelService) lookupOfficialPricing(modelName string, memo map[string]*PlazaOfficialPricing) *PlazaOfficialPricing {
if s.pricingService == nil {
return nil
}
if cached, ok := memo[modelName]; ok {
return cached
}
var result *PlazaOfficialPricing
if lp := s.pricingService.GetModelPricing(modelName); lp != nil && !lp.TokenPricingAbsent {
result = &PlazaOfficialPricing{
InputPrice: nonZeroPtr(lp.InputCostPerToken),
OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost),
CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr),
CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost),
}
if result.InputPrice == nil && result.OutputPrice == nil &&
result.CacheWritePrice == nil && result.CacheWrite1hPrice == nil && result.CacheReadPrice == nil {
result = nil
}
}
memo[modelName] = result
return result
}
@@ -0,0 +1,194 @@
//go:build unit
package service
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/require"
)
// newPlazaChannelService 构造 ListPlazaGroups 测试用的 ChannelService。
func newPlazaChannelService(channels []Channel, groups []Group, pricing *PricingService) *ChannelService {
repo := &mockChannelRepository{
listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil },
}
svc := NewChannelService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, nil, nil)
svc.pricingService = pricing
return svc
}
func plazaPricedChannel(id int64, name string, groupIDs []int64, platform string, models ...string) Channel {
return Channel{
ID: id,
Name: name,
Status: StatusActive,
GroupIDs: groupIDs,
ModelPricing: []ChannelModelPricing{{
Platform: platform,
Models: models,
BillingMode: BillingModeToken,
InputPrice: testPtrFloat64(3e-6),
OutputPrice: testPtrFloat64(1.5e-5),
}},
}
}
func TestListPlazaGroups_GroupCentricAggregation(t *testing.T) {
// 两个渠道挂同一分组:模型并入同一 PlazaGroup;无模型的分组不返回。
channels := []Channel{
plazaPricedChannel(1, "chA", []int64{10}, "anthropic", "claude-sonnet"),
plazaPricedChannel(2, "chB", []int64{10}, "anthropic", "claude-opus"),
}
groups := []Group{
{ID: 10, Name: "g-main", Description: "desc", Platform: "anthropic", RateMultiplier: 1},
{ID: 20, Name: "g-empty", Platform: "anthropic", RateMultiplier: 0.5},
}
svc := newPlazaChannelService(channels, groups, nil)
out, err := svc.ListPlazaGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 1, "无模型的分组不应返回")
require.Equal(t, int64(10), out[0].ID)
require.Equal(t, "desc", out[0].Description)
require.Len(t, out[0].Models, 2)
// 组内模型按名称排序
require.Equal(t, "claude-opus", out[0].Models[0].Name)
require.Equal(t, "claude-sonnet", out[0].Models[1].Name)
}
func TestListPlazaGroups_DedupFirstWinsWithPricingUpgrade(t *testing.T) {
// 同名模型:先见者胜;仅当已存条目无定价而新条目有定价时升级替换。
unpriced := Channel{
ID: 1, Name: "alpha", Status: StatusActive, GroupIDs: []int64{10},
// mapping-only → SupportedModels 产出无定价条目
ModelMapping: map[string]map[string]string{
"anthropic": {"claude-sonnet": "claude-sonnet"},
},
}
priced := plazaPricedChannel(2, "beta", []int64{10}, "anthropic", "claude-sonnet")
groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
// alpha(无价)按名称序先于 beta(有价):先见者无价,应被有价条目升级。
svc := newPlazaChannelService([]Channel{priced, unpriced}, groups, nil)
out, err := svc.ListPlazaGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 1)
require.Len(t, out[0].Models, 1)
require.NotNil(t, out[0].Models[0].Pricing, "无价条目应被有价条目升级")
require.NotNil(t, out[0].Models[0].Pricing.InputPrice)
}
func TestListPlazaGroups_PlatformIsolation(t *testing.T) {
// 渠道同时有 anthropic/openai 定价,anthropic 分组只应看到 anthropic 模型。
ch := Channel{
ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10, 20},
ModelPricing: []ChannelModelPricing{
{Platform: "anthropic", Models: []string{"claude-sonnet"}, InputPrice: testPtrFloat64(3e-6)},
{Platform: "openai", Models: []string{"gpt-5"}, InputPrice: testPtrFloat64(2e-6)},
},
}
groups := []Group{
{ID: 10, Name: "g-claude", Platform: "anthropic", RateMultiplier: 1},
{ID: 20, Name: "g-gpt", Platform: "openai", RateMultiplier: 1},
}
svc := newPlazaChannelService([]Channel{ch}, groups, nil)
out, err := svc.ListPlazaGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 2)
byName := map[string][]PlazaModel{}
for _, g := range out {
byName[g.Name] = g.Models
}
require.Len(t, byName["g-claude"], 1)
require.Equal(t, "claude-sonnet", byName["g-claude"][0].Name)
require.Len(t, byName["g-gpt"], 1)
require.Equal(t, "gpt-5", byName["g-gpt"][0].Name)
}
func TestListPlazaGroups_InactiveChannelSkipped(t *testing.T) {
inactive := plazaPricedChannel(1, "off", []int64{10}, "anthropic", "claude-sonnet")
inactive.Status = "inactive"
groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
svc := newPlazaChannelService([]Channel{inactive}, groups, nil)
out, err := svc.ListPlazaGroups(context.Background())
require.NoError(t, err)
require.Empty(t, out)
}
func TestListPlazaGroups_SortedByRateMultiplierAsc(t *testing.T) {
channels := []Channel{
plazaPricedChannel(1, "ch", []int64{10, 20, 30}, "anthropic", "claude-sonnet"),
}
groups := []Group{
{ID: 10, Name: "b-standard", Platform: "anthropic", RateMultiplier: 1},
{ID: 20, Name: "a-standard", Platform: "anthropic", RateMultiplier: 1},
{ID: 30, Name: "cheap", Platform: "anthropic", RateMultiplier: 0.5},
}
svc := newPlazaChannelService(channels, groups, nil)
out, err := svc.ListPlazaGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 3)
require.Equal(t, "cheap", out[0].Name, "倍率低者在前")
require.Equal(t, "a-standard", out[1].Name, "同倍率按名称")
require.Equal(t, "b-standard", out[2].Name)
}
func TestListPlazaGroups_OfficialPricingFill(t *testing.T) {
pricingSvc := newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
"claude-sonnet": {
Mode: "chat",
InputCostPerToken: 3e-6,
OutputCostPerToken: 1.5e-5,
CacheCreationInputTokenCost: 3.75e-6,
CacheCreationInputTokenCostAbove1hr: 6e-6,
CacheReadInputTokenCost: 3e-7,
},
"token-absent": {Mode: "image_generation", TokenPricingAbsent: true, OutputCostPerImage: 0.04},
})
channels := []Channel{
plazaPricedChannel(1, "ch", []int64{10}, "anthropic", "claude-sonnet", "unknown-model", "token-absent"),
}
groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
svc := newPlazaChannelService(channels, groups, pricingSvc)
out, err := svc.ListPlazaGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 1)
require.Len(t, out[0].Models, 3)
byName := map[string]PlazaModel{}
for _, m := range out[0].Models {
byName[m.Name] = m
}
// 命中:填充完整官方价(含 1h 缓存写入)
official := byName["claude-sonnet"].OfficialPricing
require.NotNil(t, official)
require.InDelta(t, 3e-6, *official.InputPrice, 1e-12)
require.InDelta(t, 6e-6, *official.CacheWrite1hPrice, 1e-12)
require.InDelta(t, 3e-7, *official.CacheReadPrice, 1e-12)
// 未命中:nil(GetModelPricing 的 claude 系列模糊匹配对非 claude 名不生效)
require.Nil(t, byName["unknown-model"].OfficialPricing)
// TokenPricingAbsent 条目不作为官方 token 价展示
require.Nil(t, byName["token-absent"].OfficialPricing)
}
func TestListPlazaGroups_RepoErrorsPropagate(t *testing.T) {
sentinel := errors.New("boom")
repo := &mockChannelRepository{
listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel },
}
svc := NewChannelService(repo, &stubGroupRepoForAvailable{}, nil, nil)
out, err := svc.ListPlazaGroups(context.Background())
require.Nil(t, out)
require.ErrorIs(t, err, sentinel)
svc2 := NewChannelService(
&mockChannelRepository{listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, nil }},
&stubGroupRepoForAvailable{listActiveErr: sentinel},
nil, nil,
)
out2, err2 := svc2.ListPlazaGroups(context.Background())
require.Nil(t, out2)
require.ErrorIs(t, err2, sentinel)
}
@@ -385,6 +385,20 @@ const (
// sidebar entry is hidden. Defaults to false (opt-in feature).
SettingKeyAvailableChannelsEnabled = "available_channels_enabled"
// SettingKeyModelPlazaEnabled is a DB-backed soft switch for the Model Plaza page
// (public group/model pricing showcase). When false: the plaza endpoint returns 404
// and the header entry is hidden. Defaults to false (opt-in feature).
SettingKeyModelPlazaEnabled = "model_plaza_enabled"
// SettingKeyModelPlazaRequireAuth controls whether the Model Plaza page requires a
// logged-in user. When false the page is public and anonymous visitors see only
// non-exclusive groups.
SettingKeyModelPlazaRequireAuth = "model_plaza_require_auth"
// SettingKeyModelPlazaDescription stores the Markdown blurb rendered at the top of
// the Model Plaza page (global pricing notes, exchange rate, promotions, ...).
SettingKeyModelPlazaDescription = "model_plaza_description"
// SettingKeyUpstreamBillingProbeSettings stores the global enable switch and interval
// for probing remote Sub2API API-key billing metadata.
SettingKeyUpstreamBillingProbeSettings = "upstream_billing_probe_settings"
+10
View File
@@ -190,6 +190,11 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// Available channels feature (default disabled; opt-in)
SettingKeyAvailableChannelsEnabled: "false",
// Model plaza feature (default disabled; opt-in, public unless require_auth)
SettingKeyModelPlazaEnabled: "false",
SettingKeyModelPlazaRequireAuth: "false",
SettingKeyModelPlazaDescription: "",
// Affiliate (邀请返利) feature (default disabled; opt-in)
SettingKeyAffiliateEnabled: "false",
SettingKeyAffiliateAdminRechargeEnabled: strconv.FormatBool(AdminRechargeRebateEnabledDefault),
@@ -763,6 +768,11 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
// Available channels feature (default: disabled; strict true)
result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true"
// Model plaza feature (default: disabled; strict true)
result.ModelPlazaEnabled = settings[SettingKeyModelPlazaEnabled] == "true"
result.ModelPlazaRequireAuth = settings[SettingKeyModelPlazaRequireAuth] == "true"
result.ModelPlazaDescription = settings[SettingKeyModelPlazaDescription]
// Affiliate (邀请返利) feature (default: disabled; strict true)
result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true"
@@ -221,6 +221,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyChannelMonitorEnabled,
SettingKeyChannelMonitorDefaultIntervalSeconds,
SettingKeyAvailableChannelsEnabled,
SettingKeyModelPlazaEnabled,
SettingKeyModelPlazaRequireAuth,
SettingKeyAffiliateEnabled,
SettingKeyRiskControlEnabled,
SettingKeyAllowUserViewErrorRequests,
@@ -334,6 +336,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true",
ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true",
AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true",
@@ -416,6 +421,33 @@ func (s *SettingService) GetAvailableChannelsRuntime(ctx context.Context) Availa
}
}
// ModelPlazaRuntime is the lightweight view of the model-plaza feature consumed
// by the public plaza handler.
type ModelPlazaRuntime struct {
Enabled bool
RequireAuth bool
Description string
}
// GetModelPlazaRuntime reads the model-plaza feature switches directly from the
// settings store. Fail-closed: on error returns Enabled=false, matching the
// opt-in default (unknown ↔ disabled).
func (s *SettingService) GetModelPlazaRuntime(ctx context.Context) ModelPlazaRuntime {
vals, err := s.settingRepo.GetMultiple(ctx, []string{
SettingKeyModelPlazaEnabled,
SettingKeyModelPlazaRequireAuth,
SettingKeyModelPlazaDescription,
})
if err != nil {
return ModelPlazaRuntime{Enabled: false}
}
return ModelPlazaRuntime{
Enabled: vals[SettingKeyModelPlazaEnabled] == "true",
RequireAuth: vals[SettingKeyModelPlazaRequireAuth] == "true",
Description: vals[SettingKeyModelPlazaDescription],
}
}
// IsUserErrorViewAllowed reads the user-facing error-requests visibility switch
// directly from the settings store. Fail-closed: on error returns false (opt-in default).
func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool {
@@ -497,6 +529,8 @@ type PublicSettingsInjectionPayload struct {
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
AffiliateEnabled bool `json:"affiliate_enabled"`
RiskControlEnabled bool `json:"risk_control_enabled"`
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
@@ -563,6 +597,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
AffiliateEnabled: settings.AffiliateEnabled,
RiskControlEnabled: settings.RiskControlEnabled,
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
@@ -395,6 +395,11 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
// Available channels feature switch
updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled)
// Model plaza feature switches + description
updates[SettingKeyModelPlazaEnabled] = strconv.FormatBool(settings.ModelPlazaEnabled)
updates[SettingKeyModelPlazaRequireAuth] = strconv.FormatBool(settings.ModelPlazaRequireAuth)
updates[SettingKeyModelPlazaDescription] = settings.ModelPlazaDescription
// Affiliate (邀请返利) feature switch
updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled)
@@ -185,6 +185,11 @@ type SystemSettings struct {
// Available Channels feature (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
// Model Plaza feature (public group/model pricing showcase)
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
ModelPlazaDescription string `json:"model_plaza_description"`
// Claude Code version check
MinClaudeCodeVersion string
MaxClaudeCodeVersion string
@@ -335,6 +340,10 @@ type PublicSettings struct {
// Available Channels feature (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
// Model Plaza feature (public group/model pricing showcase)
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
// Affiliate (邀请返利) feature toggle
AffiliateEnabled bool `json:"affiliate_enabled"`