From 720c405e35b087226cdc5a397504526ae066a4d4 Mon Sep 17 00:00:00 2001 From: feeeei Date: Tue, 28 Jul 2026 15:55:56 +0800 Subject: [PATCH] feat: add model plaza with group-scoped pricing showcase - public /model-plaza page (standalone + admin-embedded) listing groups with discounted effective prices alongside LiteLLM official reference - faceted platform/group/rate filters: cross-dimension options gray out instead of disappearing, platform-tinted chips via accent color-mix - paid-price columns highlighted with per-platform tint band - OptionalJWT middleware so anonymous and signed-in users share one route - admin settings: enable switch, require-auth switch, markdown description --- backend/cmd/server/wire_gen.go | 6 +- .../internal/handler/admin/setting_handler.go | 4 + .../handler/admin/setting_handler_audit.go | 9 + .../handler/admin/setting_handler_update.go | 27 ++ backend/internal/handler/dto/settings.go | 8 + backend/internal/handler/handler.go | 1 + .../internal/handler/model_plaza_handler.go | 198 ++++++++++ .../handler/model_plaza_handler_test.go | 126 +++++++ backend/internal/handler/setting_handler.go | 3 + backend/internal/handler/wire.go | 3 + backend/internal/server/api_contract_test.go | 6 + backend/internal/server/http.go | 3 +- .../server/middleware/optional_jwt_auth.go | 30 ++ .../middleware/optional_jwt_auth_test.go | 99 +++++ backend/internal/server/middleware/wire.go | 4 + backend/internal/server/router.go | 9 +- backend/internal/server/routes/model_plaza.go | 30 ++ backend/internal/service/api_key_service.go | 16 + backend/internal/service/channel_plaza.go | 183 +++++++++ .../internal/service/channel_plaza_test.go | 194 ++++++++++ backend/internal/service/domain_constants.go | 14 + backend/internal/service/setting_parse.go | 10 + backend/internal/service/setting_public.go | 36 ++ backend/internal/service/setting_update.go | 5 + backend/internal/service/settings_view.go | 9 + frontend/src/api/admin/settings.ts | 10 + frontend/src/api/modelPlaza.ts | 62 +++ frontend/src/components/layout/AppHeader.vue | 12 + .../modelPlaza/ModelPlazaContent.vue | 195 ++++++++++ .../components/modelPlaza/PlazaFilterBar.vue | 215 +++++++++++ .../modelPlaza/PlazaGroupSection.vue | 93 +++++ .../modelPlaza/PlazaModelPricingTable.vue | 357 ++++++++++++++++++ .../src/components/modelPlaza/PlazaNavBar.vue | 61 +++ .../__tests__/PlazaModelPricingTable.spec.ts | 257 +++++++++++++ .../src/i18n/locales/en/admin/settings.ts | 10 + frontend/src/i18n/locales/en/common.ts | 1 + frontend/src/i18n/locales/en/dashboard.ts | 48 +++ .../src/i18n/locales/zh/admin/settings.ts | 10 + frontend/src/i18n/locales/zh/common.ts | 1 + frontend/src/i18n/locales/zh/dashboard.ts | 48 +++ frontend/src/router/index.ts | 41 ++ frontend/src/stores/__tests__/app.spec.ts | 2 + frontend/src/stores/app.ts | 2 + frontend/src/types/index.ts | 2 + frontend/src/utils/featureFlags.ts | 5 + frontend/src/utils/platformColors.ts | 31 ++ frontend/src/utils/pricing.ts | 21 +- frontend/src/views/ModelPlazaView.vue | 48 +++ frontend/src/views/admin/SettingsView.vue | 58 +++ 49 files changed, 2612 insertions(+), 11 deletions(-) create mode 100644 backend/internal/handler/model_plaza_handler.go create mode 100644 backend/internal/handler/model_plaza_handler_test.go create mode 100644 backend/internal/server/middleware/optional_jwt_auth.go create mode 100644 backend/internal/server/middleware/optional_jwt_auth_test.go create mode 100644 backend/internal/server/routes/model_plaza.go create mode 100644 backend/internal/service/channel_plaza.go create mode 100644 backend/internal/service/channel_plaza_test.go create mode 100644 frontend/src/api/modelPlaza.ts create mode 100644 frontend/src/components/modelPlaza/ModelPlazaContent.vue create mode 100644 frontend/src/components/modelPlaza/PlazaFilterBar.vue create mode 100644 frontend/src/components/modelPlaza/PlazaGroupSection.vue create mode 100644 frontend/src/components/modelPlaza/PlazaModelPricingTable.vue create mode 100644 frontend/src/components/modelPlaza/PlazaNavBar.vue create mode 100644 frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts create mode 100644 frontend/src/views/ModelPlazaView.vue diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 5d101e445..dedf06d46 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -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) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 34591c4ee..703d297fa 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -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, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index 7e59baf3f..30c2495af 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -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") } diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 3e48bd0d8..65c90a3a6 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -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, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index a16ca9d32..dc34771ca 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -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"` diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 5db8584e4..9334ffe26 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -62,6 +62,7 @@ type Handlers struct { Payment *PaymentHandler PaymentWebhook *PaymentWebhookHandler AvailableChannel *AvailableChannelHandler + ModelPlaza *ModelPlazaHandler AsyncImage *AsyncImageHandler BatchImage *BatchImageHandler } diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go new file mode 100644 index 000000000..577b1d85e --- /dev/null +++ b/backend/internal/handler/model_plaza_handler.go @@ -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, + } +} diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go new file mode 100644 index 000000000..1922473e3 --- /dev/null +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -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 } diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 87fe0c2bc..49f4d5f99 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -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, diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 7a4778a82..fe559e634 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -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, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index bbbc25dfd..5c31b4763 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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, diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index 4f6bfced1..f999b343b 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -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) { diff --git a/backend/internal/server/middleware/optional_jwt_auth.go b/backend/internal/server/middleware/optional_jwt_auth.go new file mode 100644 index 000000000..7087e6dbb --- /dev/null +++ b/backend/internal/server/middleware/optional_jwt_auth.go @@ -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) + }) +} diff --git a/backend/internal/server/middleware/optional_jwt_auth_test.go b/backend/internal/server/middleware/optional_jwt_auth_test.go new file mode 100644 index 000000000..9ac449134 --- /dev/null +++ b/backend/internal/server/middleware/optional_jwt_auth_test.go @@ -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`) +} diff --git a/backend/internal/server/middleware/wire.go b/backend/internal/server/middleware/wire.go index f2582e6f4..0a7266ea3 100644 --- a/backend/internal/server/middleware/wire.go +++ b/backend/internal/server/middleware/wire.go @@ -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, diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index d70fab299..c7ac7748e 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -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) diff --git a/backend/internal/server/routes/model_plaza.go b/backend/internal/server/routes/model_plaza.go new file mode 100644 index 000000000..51e1c1296 --- /dev/null +++ b/backend/internal/server/routes/model_plaza.go @@ -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) + } +} diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index b9a780369..d6f0a5b8e 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -980,6 +980,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) { diff --git a/backend/internal/service/channel_plaza.go b/backend/internal/service/channel_plaza.go new file mode 100644 index 000000000..32704339d --- /dev/null +++ b/backend/internal/service/channel_plaza.go @@ -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 +} diff --git a/backend/internal/service/channel_plaza_test.go b/backend/internal/service/channel_plaza_test.go new file mode 100644 index 000000000..fc9c7b723 --- /dev/null +++ b/backend/internal/service/channel_plaza_test.go @@ -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) +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 24700c4c7..766b7381e 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -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" diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 40e528291..a4d409f9c 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -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" diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index 0a13f8b18..76af91c30 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -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, diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 2196b8a08..d094f9026 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -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) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index bc88e4911..4a6d3906f 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -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"` diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index c6f88d290..2fceea7b6 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -659,6 +659,11 @@ export interface SystemSettings { // Available Channels feature switch available_channels_enabled: boolean; + // Model Plaza feature switches + description + model_plaza_enabled: boolean; + model_plaza_require_auth: boolean; + model_plaza_description: string; + // Affiliate (邀请返利) feature switch affiliate_enabled: boolean; @@ -930,6 +935,11 @@ export interface UpdateSettingsRequest { // Available Channels feature switch available_channels_enabled?: boolean; + // Model Plaza feature switches + description + model_plaza_enabled?: boolean; + model_plaza_require_auth?: boolean; + model_plaza_description?: string; + // Affiliate (邀请返利) feature switch affiliate_enabled?: boolean; diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts new file mode 100644 index 000000000..741f2bb0e --- /dev/null +++ b/frontend/src/api/modelPlaza.ts @@ -0,0 +1,62 @@ +/** + * Model Plaza API(公开端点,可匿名访问) + * 以分组为中心的模型价目:分组信息 + 模型渠道定价 + LiteLLM 官方参考价。 + * 带 token 请求时后端会额外返回专属分组与用户专属倍率。 + */ + +import { apiClient } from './client' +import type { UserSupportedModelPricing } from './channels' + +/** LiteLLM 官方参考价(USD per token,字段缺失 = 官方数据未覆盖)。 */ +export interface PlazaOfficialPricing { + input_price: number | null + output_price: number | null + /** 5m 缓存写入(= LiteLLM cache_creation)。 */ + cache_write_price: number | null + /** 1h 缓存写入(LiteLLM cache_creation_above_1hr),多数模型缺失。 */ + cache_write_1h_price?: number | null + cache_read_price: number | null +} + +export interface PlazaModel { + name: string + platform: string + pricing: UserSupportedModelPricing | null + official_pricing: PlazaOfficialPricing | null +} + +export interface ModelPlazaGroup { + id: number + name: string + description: string + platform: string + /** 'standard' | 'subscription' */ + subscription_type: string + rate_multiplier: number + /** 登录且管理员为该用户配了专属倍率时返回;生效倍率 = user_rate ?? rate_multiplier。 */ + user_rate_multiplier?: number + peak_rate_enabled: boolean + peak_start: string + peak_end: string + peak_rate_multiplier: number + is_exclusive: boolean + models: PlazaModel[] +} + +export interface ModelPlazaResponse { + /** 管理员配置的全局价格说明(Markdown)。 */ + description: string + groups: ModelPlazaGroup[] +} + +/** 获取模型广场数据。开关未启用时后端返回 404。 */ +export async function getModelPlaza(options?: { signal?: AbortSignal }): Promise { + const { data } = await apiClient.get('/model-plaza', { + signal: options?.signal + }) + return data +} + +export const modelPlazaAPI = { getModelPlaza } + +export default modelPlazaAPI diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue index aba0ba595..3c33ac700 100644 --- a/frontend/src/components/layout/AppHeader.vue +++ b/frontend/src/components/layout/AppHeader.vue @@ -38,6 +38,16 @@ + + + @@ -250,6 +260,7 @@ import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMi import AnnouncementBell from '@/components/common/AnnouncementBell.vue' import Icon from '@/components/icons/Icon.vue' import { sanitizeUrl } from '@/utils/url' +import { FeatureFlags, isFeatureFlagEnabled } from '@/utils/featureFlags' const router = useRouter() const route = useRoute() @@ -264,6 +275,7 @@ const dropdownOpen = ref(false) const dropdownRef = ref(null) const contactInfo = computed(() => appStore.contactInfo) const docUrl = computed(() => sanitizeUrl(appStore.docUrl)) +const modelPlazaEnabled = computed(() => isFeatureFlagEnabled(FeatureFlags.modelPlaza)) const avatarUrl = computed(() => user.value?.avatar_url?.trim() || '') const availableBalance = computed(() => Number(user.value?.balance || 0)) const frozenBalance = computed(() => Number(user.value?.frozen_balance || 0)) diff --git a/frontend/src/components/modelPlaza/ModelPlazaContent.vue b/frontend/src/components/modelPlaza/ModelPlazaContent.vue new file mode 100644 index 000000000..8ae7da49b --- /dev/null +++ b/frontend/src/components/modelPlaza/ModelPlazaContent.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/frontend/src/components/modelPlaza/PlazaFilterBar.vue b/frontend/src/components/modelPlaza/PlazaFilterBar.vue new file mode 100644 index 000000000..a849d25de --- /dev/null +++ b/frontend/src/components/modelPlaza/PlazaFilterBar.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/frontend/src/components/modelPlaza/PlazaGroupSection.vue b/frontend/src/components/modelPlaza/PlazaGroupSection.vue new file mode 100644 index 000000000..0940233b5 --- /dev/null +++ b/frontend/src/components/modelPlaza/PlazaGroupSection.vue @@ -0,0 +1,93 @@ + + + diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue new file mode 100644 index 000000000..f0f25e5bd --- /dev/null +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/frontend/src/components/modelPlaza/PlazaNavBar.vue b/frontend/src/components/modelPlaza/PlazaNavBar.vue new file mode 100644 index 000000000..b1e166a3d --- /dev/null +++ b/frontend/src/components/modelPlaza/PlazaNavBar.vue @@ -0,0 +1,61 @@ + + + diff --git a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts new file mode 100644 index 000000000..f6c83663b --- /dev/null +++ b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts @@ -0,0 +1,257 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import PlazaModelPricingTable from '../PlazaModelPricingTable.vue' +import type { PlazaModel } from '@/api/modelPlaza' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key + }) + } +}) + +function tokenModel(overrides: Partial = {}): PlazaModel { + return { + name: 'claude-sonnet', + platform: 'anthropic', + pricing: { + billing_mode: 'token', + input_price: 3e-6, + output_price: 1.5e-5, + cache_write_price: 3.75e-6, + cache_read_price: 3e-7, + image_input_price: null, + image_output_price: null, + per_request_price: null, + intervals: [] + }, + official_pricing: { + input_price: 3e-6, + output_price: 1.5e-5, + cache_write_price: 3.75e-6, + cache_write_1h_price: 6e-6, + cache_read_price: 3e-7 + }, + ...overrides + } +} + +function mountTable(models: PlazaModel[], rateMultiplier: number, userRateMultiplier?: number | null) { + return mount(PlazaModelPricingTable, { + props: { models, rateMultiplier, userRateMultiplier: userRateMultiplier ?? null } + }) +} + +describe('PlazaModelPricingTable', () => { + it('倍率为 1 时展示渠道单价原值($/1M),价格保底 2 位小数', () => { + const wrapper = mountTable([tokenModel()], 1) + const text = wrapper.text() + expect(text).toContain('$3.00') + expect(text).toContain('$15.00') + // 缓存写 / 读(超过 2 位小数原样保留) + expect(text).toContain('$3.75') + expect(text).toContain('$0.30') + // 倍率列 + expect(text).toContain('1x') + }) + + it('倍率 ≠ 1 时价格列为折后实付价,官方价列保持原价', () => { + const wrapper = mountTable([tokenModel()], 0.5) + const text = wrapper.text() + // 实付 = 3 × 0.5 / 15 × 0.5 + expect(text).toContain('$1.50') + expect(text).toContain('$7.50') + // 官方价原值仍在(官方列不乘倍率) + expect(text).toContain('$3.00') + expect(text).toContain('$15.00') + expect(text).toContain('0.5x') + }) + + it('用户专属倍率覆盖分组倍率,并划线展示原倍率', () => { + const wrapper = mountTable([tokenModel()], 1, 0.8) + const text = wrapper.text() + // 实付按 0.8:3 × 0.8 = 2.4 + expect(text).toContain('$2.40') + expect(text).toContain('$12.00') + // 倍率列:原倍率划线 + 专属倍率 + const struck = wrapper.find('td .line-through') + expect(struck.exists()).toBe(true) + expect(struck.text()).toBe('1x') + expect(text).toContain('0.8x') + }) + + it('模型按官方输出价从高到低排序,无官方价的排最后', () => { + const expensive = tokenModel({ + name: 'model-expensive', + official_pricing: { + input_price: 1e-5, + output_price: 7.5e-5, + cache_write_price: null, + cache_write_1h_price: null, + cache_read_price: null + } + }) + const cheap = tokenModel({ + name: 'model-cheap', + official_pricing: { + input_price: 1e-6, + output_price: 5e-6, + cache_write_price: null, + cache_write_1h_price: null, + cache_read_price: null + } + }) + const noOfficial = tokenModel({ name: 'model-no-official', official_pricing: null }) + + const wrapper = mountTable([cheap, noOfficial, expensive], 1) + const names = wrapper.findAll('tbody tr').map((tr) => tr.find('td').text()) + expect(names).toEqual(['model-expensive', 'model-cheap', 'model-no-official']) + }) + + it('两级表头:实付区与官方区各拆输入/输出/缓存列', () => { + const wrapper = mountTable([tokenModel()], 1) + const text = wrapper.text() + expect(text).toContain('modelPlaza.table.paidPrice') + expect(text).toContain('modelPlaza.table.officialPrice') + // token 行:模型 + 实付 3 列 + 官方 3 列 + 倍率 + expect(wrapper.findAll('tbody td')).toHaveLength(8) + }) + + it('官方价包含 1h 缓存写入价;official_pricing 为 null 时官方三列显示 -', () => { + const withOfficial = mountTable([tokenModel()], 1) + expect(withOfficial.text()).toContain('$6.00') + expect(withOfficial.text()).toContain('(1h') + + const withoutOfficial = mountTable([tokenModel({ official_pricing: null })], 1) + const cells = withoutOfficial.findAll('tbody td') + // 官方 输入/输出/缓存 三列均为 - + expect(cells[4].text().trim()).toBe('-') + expect(cells[5].text().trim()).toBe('-') + expect(cells[6].text().trim()).toBe('-') + }) + + it('per_request 模型按单次价 × 倍率展示,官方价列显示 -', () => { + const model = tokenModel({ + name: 'search-tool', + pricing: { + billing_mode: 'per_request', + input_price: null, + output_price: null, + cache_write_price: null, + cache_read_price: null, + image_input_price: null, + image_output_price: null, + per_request_price: 0.04, + intervals: [] + }, + official_pricing: null + }) + const wrapper = mountTable([model], 0.5) + const text = wrapper.text() + // 0.04 × 0.5 = 0.02,scale=1 + expect(text).toContain('$0.02') + expect(text).toContain('modelPlaza.table.perRequest') + // 单位后缀跟在价格后(按次 → / 次) + expect(text).toContain('modelPlaza.table.perUnitRequest') + }) + + it('token 模型阶梯定价内联进输入/输出列,按倍率折算', () => { + const model = tokenModel({ + pricing: { + billing_mode: 'token', + input_price: 3e-6, + output_price: 1.5e-5, + cache_write_price: null, + cache_read_price: null, + image_input_price: null, + image_output_price: null, + per_request_price: null, + intervals: [ + { + min_tokens: 0, + max_tokens: 200000, + tier_label: '', + input_price: 3e-6, + output_price: 1.5e-5, + cache_write_price: null, + cache_read_price: null, + per_request_price: null + }, + { + min_tokens: 200000, + max_tokens: null, + tier_label: '', + input_price: 6e-6, + output_price: 3e-5, + cache_write_price: null, + cache_read_price: null, + per_request_price: null + } + ] + } + }) + const wrapper = mountTable([model], 0.5) + const text = wrapper.text() + // 区间标签按 token 数生成 + expect(text).toContain('≤200K') + expect(text).toContain('>200K') + // 折后:输入 1.5 / 3,输出 7.5 / 15 + expect(text).toContain('$1.50') + expect(text).toContain('$7.50') + expect(text).toContain('$15.00') + }) + + it('按图模型主行展示阶梯芯片,不把 image_output_price(每 token)当按次价', () => { + const model = tokenModel({ + name: 'gpt-image-2', + pricing: { + billing_mode: 'image', + input_price: null, + output_price: null, + cache_write_price: null, + cache_read_price: null, + image_input_price: null, + // 每 token 图片输出价:不应被当作按次单价展示 + image_output_price: 3e-5, + per_request_price: null, + intervals: [ + { + min_tokens: 0, + max_tokens: null, + tier_label: '1K', + input_price: null, + output_price: null, + cache_write_price: null, + cache_read_price: null, + per_request_price: 0.01 + }, + { + min_tokens: 0, + max_tokens: null, + tier_label: '2K', + input_price: null, + output_price: null, + cache_write_price: null, + cache_read_price: null, + per_request_price: 0.02 + } + ] + }, + official_pricing: null + }) + const wrapper = mountTable([model], 0.1) + const text = wrapper.text() + expect(text).toContain('modelPlaza.table.perImage') + // 芯片:1K $0.001 / 2K $0.002,单位后缀内嵌(按图 → / 张) + expect(text).toContain('1K') + expect(text).toContain('$0.001') + expect(text).toContain('2K') + expect(text).toContain('$0.002') + expect(text).toContain('modelPlaza.table.perUnitImage') + // 旧 bug:image_output_price × 0.1 = 0.000003 被当按次价 + expect(text).not.toContain('$0.000003') + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 7b21a23af..5e54b2221 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -30,6 +30,16 @@ export default { enabled: 'Enable Available Channels', enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.', }, + modelPlaza: { + title: 'Model Plaza', + description: 'A public page showcasing available models and pricing by group. Disabled by default.', + enabled: 'Enable Model Plaza', + enabledHint: 'When enabled, an entry appears in the header and the page is reachable at /model-plaza.', + requireAuth: 'Require sign-in to access', + requireAuthHint: 'When on, anonymous visitors are redirected to the login page; when off, the page is public and anonymous visitors only see non-exclusive groups.', + priceDescription: 'Pricing notes (Markdown)', + priceDescriptionHint: 'Rendered at the top of the plaza page. Use it for billing rules, exchange rates, promotions, etc.', + }, riskControl: { title: 'Risk Control', description: 'Enable the content moderation menu and gateway audit entry point. Disabled by default.', diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index 28af7ac31..16c797e61 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -166,6 +166,7 @@ export default { groups: 'Groups', channels: 'Channels', availableChannels: 'Available Channels', + modelPlaza: 'Model Plaza', subscriptions: 'Subscriptions', accounts: 'Accounts', proxies: 'Proxies', diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index e462eff4f..f07f98a12 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -520,6 +520,54 @@ export default { } }, + // Model Plaza (public group/model pricing showcase) + modelPlaza: { + title: 'Model Plaza', + description: 'Browse available models and pricing by group', + loading: 'Loading...', + empty: 'No groups to display', + loadFailed: 'Failed to load model plaza', + noSearchResult: 'No matching models', + anonymousHint: 'Sign in to see your exclusive groups and personal rates', + filters: { + platformLabel: 'Platform', + groupLabel: 'Group', + rateLabel: 'Rate', + modelLabel: 'Model', + searchPlaceholder: 'Search models', + all: 'All' + }, + badges: { + exclusive: 'Exclusive', + subscription: 'Subscription' + }, + detail: { + noModels: 'No models configured for this group', + noPricing: 'Pricing not configured', + peakNote: 'Peak hours {window}: billing rate ×{multiplier}' + }, + table: { + model: 'Model', + input: 'Input', + output: 'Output', + cache: 'Cache', + cacheWrite: 'Write', + cacheRead: 'Read', + paidPrice: 'Your Price (Discounted)', + officialPrice: 'Official Price', + rate: 'Rate', + unitPerMillion: '$ / 1M tokens', + perUnitRequest: '/ request', + perUnitImage: '/ image', + perRequest: 'Per request', + perImage: 'Per image' + }, + nav: { + login: 'Sign In', + backToDashboard: 'Back to Console' + } + }, + affiliate: { title: 'Affiliate Rebates', description: 'Invite new users and convert your rebate quota into account balance', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 217b12775..4989305c4 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -30,6 +30,16 @@ export default { enabled: '启用可用渠道', enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。', }, + modelPlaza: { + title: '模型广场', + description: '以分组为单位向访客展示可用模型与价格的公开页面。默认关闭。', + enabled: '启用模型广场', + enabledHint: '开启后顶栏显示入口,页面可通过 /model-plaza 独立访问。', + requireAuth: '需要登录才可访问', + requireAuthHint: '开启后未登录访问将跳转登录页;关闭则公开可见,匿名访客仅展示非专属分组。', + priceDescription: '价格说明(Markdown)', + priceDescriptionHint: '展示在模型广场页面顶部,可用于说明计费规则、汇率、优惠活动等。', + }, riskControl: { title: '风控中心', description: '启用内容审计菜单和全端点请求审核入口。默认关闭。', diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index 725c13948..0deb1b957 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -166,6 +166,7 @@ export default { groups: '分组管理', channels: '渠道管理', availableChannels: '可用渠道', + modelPlaza: '模型广场', subscriptions: '订阅管理', accounts: '账号管理', proxies: 'IP管理', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index 60448420e..c28f68963 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -525,6 +525,54 @@ export default { } }, + // Model Plaza (public group/model pricing showcase) + modelPlaza: { + title: '模型广场', + description: '按分组浏览可用模型与价格', + loading: '加载中...', + empty: '暂无可展示的分组', + loadFailed: '加载模型广场失败', + noSearchResult: '没有匹配的模型', + anonymousHint: '登录后可查看你的专属分组与专属倍率', + filters: { + platformLabel: '平台', + groupLabel: '分组', + rateLabel: '倍率', + modelLabel: '模型', + searchPlaceholder: '搜索模型名称', + all: '全部' + }, + badges: { + exclusive: '专属分组', + subscription: '订阅' + }, + detail: { + noModels: '该分组暂未配置模型', + noPricing: '未配置定价', + peakNote: '高峰时段 {window} 计费倍率 ×{multiplier}' + }, + table: { + model: '模型', + input: '输入', + output: '输出', + cache: '缓存', + cacheWrite: '写入', + cacheRead: '读取', + paidPrice: '实付价格(折后)', + officialPrice: '官方价格', + rate: '折扣倍率', + unitPerMillion: '$ / 1M token', + perUnitRequest: '/ 次', + perUnitImage: '/ 张', + perRequest: '按次计费', + perImage: '按图片计费' + }, + nav: { + login: '登录', + backToDashboard: '回到后台' + } + }, + affiliate: { title: '邀请返利', description: '邀请新用户注册,并将返利额度转入账户余额', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 259ea5559..1221a53f6 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -175,6 +175,16 @@ const routes: RouteRecordRaw[] = [ title: 'Legal Document' } }, + { + path: '/model-plaza', + name: 'ModelPlaza', + component: () => import('@/views/ModelPlazaView.vue'), + meta: { + requiresAuth: false, + title: 'Model Plaza', + titleKey: 'modelPlaza.title' + } + }, // ==================== User Routes ==================== { @@ -807,6 +817,37 @@ router.beforeEach(async (to, _from, next) => { next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard') return } + // Model Plaza:公开路由但受「启用开关 + 可选强制登录」双重控制(后端同口径 fail-closed) + if (to.path === '/model-plaza') { + if (!appStore.publicSettingsLoaded) { + try { + await appStore.fetchPublicSettings() + } catch (error) { + console.warn('Failed to load public settings in route guard', error) + } + } + const plazaSettings = appStore.cachedPublicSettings + // 仅在设置成功加载且明确为 false 时拦截(瞬时加载失败视为未知,由后端 404 兜底) + if (appStore.publicSettingsLoaded && plazaSettings?.model_plaza_enabled === false) { + next( + authStore.isAuthenticated + ? authStore.isAdmin + ? '/admin/dashboard' + : '/dashboard' + : '/home' + ) + return + } + if (plazaSettings?.model_plaza_require_auth === true && !authStore.isAuthenticated) { + next({ path: '/login', query: { redirect: to.fullPath } }) + return + } + // Backend mode:登录的非管理员也不可见(匿名由下方公共拦截处理,广场不在白名单) + if (appStore.backendModeEnabled && authStore.isAuthenticated && !authStore.isAdmin) { + next('/login') + return + } + } // Backend mode: block public pages for unauthenticated users (except login, key-usage, setup) if (appStore.backendModeEnabled && !authStore.isAuthenticated) { const isAllowed = isBackendModePublicRouteAllowed(to.path, authStore.hasPendingAuthSession) diff --git a/frontend/src/stores/__tests__/app.spec.ts b/frontend/src/stores/__tests__/app.spec.ts index d9e7b17f5..d936f1d2e 100644 --- a/frontend/src/stores/__tests__/app.spec.ts +++ b/frontend/src/stores/__tests__/app.spec.ts @@ -54,6 +54,8 @@ function createPublicSettings(overrides: Partial = {}): PublicSe channel_monitor_enabled: true, channel_monitor_default_interval_seconds: 60, available_channels_enabled: false, + model_plaza_enabled: false, + model_plaza_require_auth: false, service_quota_enabled: false, affiliate_enabled: false, ...overrides, diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 514d0dd8f..9839170a2 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -366,6 +366,8 @@ export const useAppStore = defineStore('app', () => { channel_monitor_enabled: true, channel_monitor_default_interval_seconds: 60, available_channels_enabled: false, + model_plaza_enabled: false, + model_plaza_require_auth: false, risk_control_enabled: false, service_quota_enabled: false, affiliate_enabled: false, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c23534e63..31b4fd891 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -239,6 +239,8 @@ export interface PublicSettings { channel_monitor_enabled: boolean channel_monitor_default_interval_seconds: number available_channels_enabled: boolean + model_plaza_enabled: boolean + model_plaza_require_auth: boolean service_quota_enabled: boolean affiliate_enabled: boolean allow_user_view_error_requests?: boolean diff --git a/frontend/src/utils/featureFlags.ts b/frontend/src/utils/featureFlags.ts index 403e7cdc9..5004c068b 100644 --- a/frontend/src/utils/featureFlags.ts +++ b/frontend/src/utils/featureFlags.ts @@ -104,6 +104,11 @@ export const FeatureFlags = { mode: 'opt-in', label: 'Available Channels', }), + modelPlaza: defineFlag({ + key: 'model_plaza_enabled', + mode: 'opt-in', + label: 'Model Plaza', + }), payment: defineFlag({ key: 'payment_enabled', mode: 'opt-out', diff --git a/frontend/src/utils/platformColors.ts b/frontend/src/utils/platformColors.ts index ac86c8677..8ab5e4413 100644 --- a/frontend/src/utils/platformColors.ts +++ b/frontend/src/utils/platformColors.ts @@ -39,6 +39,29 @@ const BORDER: Record = { } const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700' +// ── Border strong (higher-contrast platform tint, e.g. plaza group cards) ── +const BORDER_STRONG: Record = { + anthropic: 'border-orange-500/35 dark:border-orange-500/30', + openai: 'border-green-500/35 dark:border-green-500/30', + antigravity: 'border-purple-500/35 dark:border-purple-500/30', + gemini: 'border-blue-500/35 dark:border-blue-500/30', + grok: 'border-zinc-800/35 dark:border-zinc-500/35', + composite: 'border-cyan-500/35 dark:border-cyan-500/30', +} +const BORDER_STRONG_DEFAULT = 'border-gray-300 dark:border-dark-600' + +// ── Accent (single raw color per platform; consumers derive washes/tints +// from it via CSS color-mix, e.g. plaza paid-price zone) ── +const ACCENT: Record = { + anthropic: '#f97316', // orange-500 + openai: '#22c55e', // green-500 + antigravity: '#a855f7', // purple-500 + gemini: '#3b82f6', // blue-500 + grok: '#71717a', // zinc-500 + composite: '#06b6d4', // cyan-500 +} +const ACCENT_DEFAULT = '#14b8a6' // primary-500 (teal) + // ── Accent bar (gradient) ─────────────────────────────────────────── const ACCENT_BAR: Record = { anthropic: 'bg-gradient-to-r from-orange-400 to-orange-500', @@ -144,6 +167,14 @@ export function platformBorderClass(p: string): string { return isPlatform(p) ? BORDER[p] : BORDER_DEFAULT } +export function platformBorderStrongClass(p: string): string { + return isPlatform(p) ? BORDER_STRONG[p] : BORDER_STRONG_DEFAULT +} + +export function platformAccentColor(p: string): string { + return isPlatform(p) ? ACCENT[p] : ACCENT_DEFAULT +} + export function platformAccentBarClass(p: string): string { return isPlatform(p) ? ACCENT_BAR[p] : ACCENT_BAR_DEFAULT } diff --git a/frontend/src/utils/pricing.ts b/frontend/src/utils/pricing.ts index 94acb738e..4a5922b01 100644 --- a/frontend/src/utils/pricing.ts +++ b/frontend/src/utils/pricing.ts @@ -1,13 +1,24 @@ /** * formatScaled formats a per-token (or per-request) USD price scaled by `scale`. * - * formatScaled(0.000003, 1_000_000) → "$3" // per 1M tokens - * formatScaled(0.5, 1) → "$0.5" // per request - * formatScaled(null, 1_000_000) → "-" + * formatScaled(0.000003, 1_000_000) → "$3" // per 1M tokens + * formatScaled(0.5, 1) → "$0.5" // per request + * formatScaled(null, 1_000_000) → "-" + * formatScaled(0.000003, 1_000_000, 2) → "$3.00" // pad to ≥2 decimals + * formatScaled(1.25e-8, 1_000_000, 2) → "$0.0125" // longer decimals kept as-is * * Uses toPrecision(10) then strips trailing zeros to avoid IEEE 754 display noise. + * `minFractionDigits` pads the result back up to a minimum number of decimals. */ -export function formatScaled(value: number | null, scale: number): string { +export function formatScaled(value: number | null, scale: number, minFractionDigits = 0): string { if (value == null) return '-' - return `$${(value * scale).toPrecision(10).replace(/\.?0+$/, '')}` + let s = (value * scale).toPrecision(10).replace(/\.?0+$/, '') + if (minFractionDigits > 0 && !s.includes('e')) { + const dot = s.indexOf('.') + const digits = dot === -1 ? 0 : s.length - dot - 1 + if (digits < minFractionDigits) { + s = (dot === -1 ? `${s}.` : s) + '0'.repeat(minFractionDigits - digits) + } + } + return `$${s}` } diff --git a/frontend/src/views/ModelPlazaView.vue b/frontend/src/views/ModelPlazaView.vue new file mode 100644 index 000000000..42c69c871 --- /dev/null +++ b/frontend/src/views/ModelPlazaView.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 0245b74f9..e3047e684 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -6472,6 +6472,56 @@ +
+
+

+ {{ t('admin.settings.features.modelPlaza.title') }} +

+

+ {{ t('admin.settings.features.modelPlaza.description') }} +

+
+
+
+
+ +

+ {{ t('admin.settings.features.modelPlaza.enabledHint') }} +

+
+ +
+ +
+
+ +

+ {{ t('admin.settings.features.modelPlaza.requireAuthHint') }} +

+
+ +
+ +
+ +

+ {{ t('admin.settings.features.modelPlaza.priceDescriptionHint') }} +

+ +
+
+
+

@@ -8990,6 +9040,10 @@ const form = reactive({ channel_monitor_default_interval_seconds: 60, // Available Channels feature switch available_channels_enabled: false, + // Model Plaza feature switches + description + model_plaza_enabled: false, + model_plaza_require_auth: false, + model_plaza_description: '', // Affiliate (邀请返利) feature switch affiliate_enabled: false, // Allow user view error requests @@ -10535,6 +10589,10 @@ async function saveSettings() { Number(form.channel_monitor_default_interval_seconds) || 60, // Available Channels feature switch available_channels_enabled: form.available_channels_enabled, + // Model Plaza feature switches + description + model_plaza_enabled: form.model_plaza_enabled, + model_plaza_require_auth: form.model_plaza_require_auth, + model_plaza_description: form.model_plaza_description, // Affiliate (邀请返利) feature switch affiliate_enabled: form.affiliate_enabled, allow_user_view_error_requests: form.allow_user_view_error_requests,