| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844 |
- package service
- import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "log/slog"
- "net/url"
- "strconv"
- "strings"
- "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
- )
- func normalizeLoginAgreementMode(raw string) string {
- switch strings.ToLower(strings.TrimSpace(raw)) {
- case "checkbox":
- return "checkbox"
- default:
- return defaultLoginAgreementMode
- }
- }
- func defaultLoginAgreementDocuments() []LoginAgreementDocument {
- return []LoginAgreementDocument{
- {
- ID: "terms",
- Title: "服务条款",
- ContentMD: "",
- },
- {
- ID: "usage-policy",
- Title: "使用政策",
- ContentMD: "",
- },
- {
- ID: "supported-regions",
- Title: "支持的国家和地区",
- ContentMD: "",
- },
- {
- ID: "service-specific-terms",
- Title: "服务特定条款",
- ContentMD: "",
- },
- }
- }
- func normalizeLoginAgreementDocumentID(raw string) string {
- raw = strings.ToLower(strings.TrimSpace(raw))
- var b strings.Builder
- lastSeparator := false
- for _, r := range raw {
- if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
- _, _ = b.WriteRune(r)
- lastSeparator = false
- continue
- }
- if r == '-' || r == '_' || r == ' ' || r == '.' || r == '/' {
- if !lastSeparator && b.Len() > 0 {
- if r == '_' {
- _, _ = b.WriteRune('_')
- } else {
- _, _ = b.WriteRune('-')
- }
- lastSeparator = true
- }
- }
- }
- return strings.Trim(b.String(), "-_")
- }
- func normalizeLoginAgreementDocuments(docs []LoginAgreementDocument) []LoginAgreementDocument {
- normalized := make([]LoginAgreementDocument, 0, len(docs))
- seen := make(map[string]int, len(docs))
- for i, doc := range docs {
- title := strings.TrimSpace(doc.Title)
- content := strings.TrimSpace(doc.ContentMD)
- if title == "" && content == "" {
- continue
- }
- id := normalizeLoginAgreementDocumentID(doc.ID)
- if id == "" {
- sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%s:%s", i, title, content)))
- id = hex.EncodeToString(sum[:])[:12]
- }
- baseID := id
- for suffix := 2; seen[id] > 0; suffix++ {
- id = fmt.Sprintf("%s-%d", baseID, suffix)
- }
- seen[id]++
- normalized = append(normalized, LoginAgreementDocument{
- ID: id,
- Title: title,
- ContentMD: content,
- })
- }
- return normalized
- }
- func parseLoginAgreementDocuments(raw string) []LoginAgreementDocument {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return defaultLoginAgreementDocuments()
- }
- var docs []LoginAgreementDocument
- if err := json.Unmarshal([]byte(raw), &docs); err != nil {
- return defaultLoginAgreementDocuments()
- }
- docs = normalizeLoginAgreementDocuments(docs)
- if len(docs) == 0 {
- return defaultLoginAgreementDocuments()
- }
- return docs
- }
- func marshalLoginAgreementDocuments(docs []LoginAgreementDocument) (string, error) {
- normalized := normalizeLoginAgreementDocuments(docs)
- if len(normalized) == 0 {
- normalized = defaultLoginAgreementDocuments()
- }
- b, err := json.Marshal(normalized)
- if err != nil {
- return "", fmt.Errorf("marshal login agreement documents: %w", err)
- }
- return string(b), nil
- }
- func buildLoginAgreementRevision(updatedAt string, docs []LoginAgreementDocument) string {
- normalized := normalizeLoginAgreementDocuments(docs)
- payload, err := json.Marshal(struct {
- UpdatedAt string `json:"updated_at"`
- Documents []LoginAgreementDocument `json:"documents"`
- }{
- UpdatedAt: strings.TrimSpace(updatedAt),
- Documents: normalized,
- })
- if err != nil {
- payload = []byte(strings.TrimSpace(updatedAt))
- }
- sum := sha256.Sum256(payload)
- return hex.EncodeToString(sum[:])[:16]
- }
- // GetFrontendURL 获取前端基础URL(数据库优先,fallback 到配置文件)
- func (s *SettingService) GetFrontendURL(ctx context.Context) string {
- val, err := s.settingRepo.GetValue(ctx, SettingKeyFrontendURL)
- if err == nil && strings.TrimSpace(val) != "" {
- return strings.TrimSpace(val)
- }
- return s.cfg.Server.FrontendURL
- }
- // GetPublicSettings 获取公开设置(无需登录)
- func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) {
- keys := []string{
- SettingKeyRegistrationEnabled,
- SettingKeyEmailVerifyEnabled,
- SettingKeyForceEmailOnThirdPartySignup,
- SettingKeyRegistrationEmailSuffixWhitelist,
- SettingKeyRegistrationEmailDomainQuotaEnabled,
- SettingKeyPromoCodeEnabled,
- SettingKeyPasswordResetEnabled,
- SettingKeyInvitationCodeEnabled,
- SettingKeyTotpEnabled,
- SettingKeyPasskeyEnabled,
- SettingKeyLoginAgreementEnabled,
- SettingKeyLoginAgreementMode,
- SettingKeyLoginAgreementUpdatedAt,
- SettingKeyLoginAgreementDocuments,
- SettingKeyTurnstileEnabled,
- SettingKeyTurnstileSiteKey,
- SettingKeyTencentCaptchaEnabled,
- SettingKeyTencentCaptchaAppID,
- SettingKeyTencentCaptchaRegion,
- SettingKeyAliyunCaptchaEnabled,
- SettingKeyAliyunCaptchaSceneID,
- SettingKeyAliyunCaptchaPrefix,
- SettingKeyAliyunCaptchaRegion,
- SettingKeyAPIKeyACLTrustForwardedIP,
- SettingKeySiteName,
- SettingKeySiteLogo,
- SettingKeySiteSubtitle,
- SettingKeyAPIBaseURL,
- SettingKeyContactInfo,
- SettingKeyDocURL,
- SettingKeyHomeContent,
- SettingKeyCompactHomeEnabled,
- SettingKeyHideCcsImportButton,
- SettingKeyPurchaseSubscriptionEnabled,
- SettingKeyPurchaseSubscriptionURL,
- SettingKeyTableDefaultPageSize,
- SettingKeyTablePageSizeOptions,
- SettingKeyCustomMenuItems,
- SettingKeyCustomEndpoints,
- SettingKeyLinuxDoConnectEnabled,
- SettingKeyDingTalkConnectEnabled,
- SettingKeyWeChatConnectEnabled,
- SettingKeyWeChatConnectAppID,
- SettingKeyWeChatConnectAppSecret,
- SettingKeyWeChatConnectOpenAppID,
- SettingKeyWeChatConnectOpenAppSecret,
- SettingKeyWeChatConnectMPAppID,
- SettingKeyWeChatConnectMPAppSecret,
- SettingKeyWeChatConnectMobileAppID,
- SettingKeyWeChatConnectMobileAppSecret,
- SettingKeyWeChatConnectOpenEnabled,
- SettingKeyWeChatConnectMPEnabled,
- SettingKeyWeChatConnectMobileEnabled,
- SettingKeyWeChatConnectMode,
- SettingKeyWeChatConnectScopes,
- SettingKeyWeChatConnectRedirectURL,
- SettingKeyWeChatConnectFrontendRedirectURL,
- SettingKeyBackendModeEnabled,
- SettingPaymentEnabled,
- SettingKeyOIDCConnectEnabled,
- SettingKeyOIDCConnectProviderName,
- SettingKeyGitHubOAuthEnabled,
- SettingKeyGitHubOAuthClientID,
- SettingKeyGitHubOAuthClientSecret,
- SettingKeyGoogleOAuthEnabled,
- SettingKeyGoogleOAuthClientID,
- SettingKeyGoogleOAuthClientSecret,
- SettingKeyBalanceLowNotifyEnabled,
- SettingKeyBalanceLowNotifyThreshold,
- SettingKeyBalanceLowNotifyRechargeURL,
- SettingKeyAccountQuotaNotifyEnabled,
- SettingKeyChannelMonitorEnabled,
- SettingKeyChannelMonitorMode,
- SettingKeyChannelMonitorDefaultIntervalSeconds,
- SettingKeyChannelMonitorHideThroughput,
- SettingKeyChannelMonitorShowQuota,
- SettingKeyChannelMonitorHideUserRanking,
- SettingKeyAvailableChannelsEnabled,
- SettingKeyModelPlazaEnabled,
- SettingKeyModelPlazaRequireAuth,
- SettingKeyPluginManagementEnabled,
- SettingKeyAffiliateEnabled,
- SettingKeyRiskControlEnabled,
- SettingKeyAllowUserViewErrorRequests,
- }
- settings, err := s.settingRepo.GetMultiple(ctx, keys)
- if err != nil {
- return nil, fmt.Errorf("get public settings: %w", err)
- }
- linuxDoEnabled := false
- if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
- linuxDoEnabled = raw == "true"
- } else {
- linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled
- }
- dingTalkEnabled := false
- if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok {
- dingTalkEnabled = raw == "true"
- } else {
- dingTalkEnabled = s.cfg != nil && s.cfg.DingTalk.Enabled
- }
- oidcEnabled := false
- if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
- oidcEnabled = raw == "true"
- } else {
- oidcEnabled = s.cfg != nil && s.cfg.OIDC.Enabled
- }
- oidcProviderName := strings.TrimSpace(settings[SettingKeyOIDCConnectProviderName])
- if oidcProviderName == "" && s.cfg != nil {
- oidcProviderName = strings.TrimSpace(s.cfg.OIDC.ProviderName)
- }
- if oidcProviderName == "" {
- oidcProviderName = "OIDC"
- }
- gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github")
- googleEnabled := s.emailOAuthPublicEnabled(settings, "google")
- weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings)
- // Password reset requires email verification to be enabled
- emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
- passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true"
- registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist(
- settings[SettingKeyRegistrationEmailSuffixWhitelist],
- )
- tableDefaultPageSize, tablePageSizeOptions := parseTablePreferences(
- settings[SettingKeyTableDefaultPageSize],
- settings[SettingKeyTablePageSizeOptions],
- )
- loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments])
- loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt])
- if loginAgreementUpdatedAt == "" {
- loginAgreementUpdatedAt = defaultLoginAgreementDate
- }
- var balanceLowNotifyThreshold float64
- if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
- balanceLowNotifyThreshold = v
- }
- return &PublicSettings{
- RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
- EmailVerifyEnabled: emailVerifyEnabled,
- ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
- RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
- RegistrationEmailDomainQuotaEnabled: settings[SettingKeyRegistrationEmailDomainQuotaEnabled] == "true",
- PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
- PasswordResetEnabled: passwordResetEnabled,
- InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
- TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
- PasskeyEnabled: s.passkeyConfigured() && s.passkeySettingEnabled(settings),
- LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0,
- LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
- LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
- LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments),
- LoginAgreementDocuments: loginAgreementDocuments,
- TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
- TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
- TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true",
- TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID],
- TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]),
- AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true",
- AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID],
- AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix],
- AliyunCaptchaRegion: normalizeAliyunCaptchaRegion(settings[SettingKeyAliyunCaptchaRegion]),
- SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
- SiteLogo: settings[SettingKeySiteLogo],
- SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
- APIBaseURL: settings[SettingKeyAPIBaseURL],
- ContactInfo: settings[SettingKeyContactInfo],
- DocURL: settings[SettingKeyDocURL],
- HomeContent: settings[SettingKeyHomeContent],
- CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true",
- HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
- PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
- PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
- TableDefaultPageSize: tableDefaultPageSize,
- TablePageSizeOptions: tablePageSizeOptions,
- CustomMenuItems: settings[SettingKeyCustomMenuItems],
- CustomEndpoints: settings[SettingKeyCustomEndpoints],
- LinuxDoOAuthEnabled: linuxDoEnabled,
- DingTalkOAuthEnabled: dingTalkEnabled,
- WeChatOAuthEnabled: weChatEnabled,
- WeChatOAuthOpenEnabled: weChatOpenEnabled,
- WeChatOAuthMPEnabled: weChatMPEnabled,
- WeChatOAuthMobileEnabled: weChatMobileEnabled,
- BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
- PaymentEnabled: settings[SettingPaymentEnabled] == "true",
- OIDCOAuthEnabled: oidcEnabled,
- OIDCOAuthProviderName: oidcProviderName,
- GitHubOAuthEnabled: gitHubEnabled,
- GoogleOAuthEnabled: googleEnabled,
- BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
- AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
- BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
- BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL],
- ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]),
- ChannelMonitorMode: normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode]),
- ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
- ChannelMonitorHideThroughput: !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]),
- ChannelMonitorShowQuota: settings[SettingKeyChannelMonitorShowQuota] == "true",
- ChannelMonitorHideUserRanking: isTrueSettingValue(settings[SettingKeyChannelMonitorHideUserRanking]),
- AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
- ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true",
- ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true",
- PluginManagementEnabled: settings[SettingKeyPluginManagementEnabled] == "true",
- AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
- RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true",
- AllowUserViewErrorRequests: settings[SettingKeyAllowUserViewErrorRequests] == "true",
- }, nil
- }
- // channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval
- // (mirrors the monitor-level constraint but lives here so setting_service stays decoupled).
- const (
- channelMonitorIntervalMin = 15
- channelMonitorIntervalMax = 3600
- channelMonitorIntervalFallback = 60
- defaultChannelMonitorMode = ChannelMonitorModeV1
- )
- // normalizeChannelMonitorMode accepts only v1/v2; empty/invalid → v1 (safe default).
- func normalizeChannelMonitorMode(raw string) string {
- switch strings.ToLower(strings.TrimSpace(raw)) {
- case ChannelMonitorModeV1, "":
- return ChannelMonitorModeV1
- case ChannelMonitorModeV2:
- return ChannelMonitorModeV2
- default:
- return defaultChannelMonitorMode
- }
- }
- // parseChannelMonitorInterval parses the stored string and clamps to [15, 3600].
- // Empty / invalid input falls back to channelMonitorIntervalFallback.
- func parseChannelMonitorInterval(raw string) int {
- v, err := strconv.Atoi(strings.TrimSpace(raw))
- if err != nil {
- return channelMonitorIntervalFallback
- }
- return clampChannelMonitorInterval(v)
- }
- // clampChannelMonitorInterval clamps v to the allowed range. 0 means "not provided".
- func clampChannelMonitorInterval(v int) int {
- if v <= 0 {
- return 0
- }
- if v < channelMonitorIntervalMin {
- return channelMonitorIntervalMin
- }
- if v > channelMonitorIntervalMax {
- return channelMonitorIntervalMax
- }
- return v
- }
- // ChannelMonitorRuntime is the lightweight view of the channel monitor feature
- // consumed by the runner, V2 aggregator, and user-facing handlers.
- type ChannelMonitorRuntime struct {
- Enabled bool
- Mode string // ChannelMonitorModeV1 or ChannelMonitorModeV2
- DefaultIntervalSeconds int
- // HideThroughput: when true, user-facing V2 APIs omit RPM/TPM scale signals.
- HideThroughput bool
- // ShowQuota: when true, user-facing monitor views keep the quota/balance
- // snapshots; otherwise the user handler strips them server-side.
- // Parsed fail-closed (only literal "true" enables). Admin always sees them.
- ShowQuota bool
- // HideUserRanking: when true, user-facing V2 views hide the user ranking tab
- // and the /users payload. Parsed fail-open (only literal "true" hides it).
- HideUserRanking bool
- }
- // ActiveProbesAllowed reports whether V1 active provider probes may run.
- func (r ChannelMonitorRuntime) ActiveProbesAllowed() bool {
- return r.Enabled && r.Mode == ChannelMonitorModeV1
- }
- // PassiveAggregationAllowed reports whether V2 passive aggregation may run.
- func (r ChannelMonitorRuntime) PassiveAggregationAllowed() bool {
- return r.Enabled && r.Mode == ChannelMonitorModeV2
- }
- // GetChannelMonitorRuntime reads the channel monitor feature flags directly from
- // the settings store. Fail-open: on error returns Enabled=true, Mode=v1, default interval.
- func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime {
- if s == nil || s.settingRepo == nil {
- return ChannelMonitorRuntime{
- Enabled: true,
- Mode: defaultChannelMonitorMode,
- DefaultIntervalSeconds: channelMonitorIntervalFallback,
- HideThroughput: true,
- }
- }
- vals, err := s.settingRepo.GetMultiple(ctx, []string{
- SettingKeyChannelMonitorEnabled,
- SettingKeyChannelMonitorMode,
- SettingKeyChannelMonitorDefaultIntervalSeconds,
- SettingKeyChannelMonitorHideThroughput,
- SettingKeyChannelMonitorShowQuota,
- SettingKeyChannelMonitorHideUserRanking,
- })
- if err != nil {
- return ChannelMonitorRuntime{
- Enabled: true,
- Mode: defaultChannelMonitorMode,
- DefaultIntervalSeconds: channelMonitorIntervalFallback,
- HideThroughput: true,
- }
- }
- return ChannelMonitorRuntime{
- Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]),
- Mode: normalizeChannelMonitorMode(vals[SettingKeyChannelMonitorMode]),
- DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]),
- HideThroughput: !isFalseSettingValue(vals[SettingKeyChannelMonitorHideThroughput]),
- ShowQuota: vals[SettingKeyChannelMonitorShowQuota] == "true",
- HideUserRanking: isTrueSettingValue(vals[SettingKeyChannelMonitorHideUserRanking]),
- }
- }
- // AvailableChannelsRuntime is the lightweight view of the available-channels feature
- // switch consumed by the user-facing handler.
- type AvailableChannelsRuntime struct {
- Enabled bool
- }
- // GetAvailableChannelsRuntime reads the available-channels feature switch directly
- // from the settings store. Fail-closed: on error returns Enabled=false, matching
- // the opt-in default (unknown ↔ disabled).
- func (s *SettingService) GetAvailableChannelsRuntime(ctx context.Context) AvailableChannelsRuntime {
- vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAvailableChannelsEnabled})
- if err != nil {
- return AvailableChannelsRuntime{Enabled: false}
- }
- return AvailableChannelsRuntime{
- Enabled: vals[SettingKeyAvailableChannelsEnabled] == "true",
- }
- }
- // 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 {
- vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAllowUserViewErrorRequests})
- if err != nil {
- slog.Warn("failed to get allow_user_view_error_requests setting, defaulting to false", "error", err)
- return false
- }
- return vals[SettingKeyAllowUserViewErrorRequests] == "true"
- }
- // PublicSettingsInjectionPayload is the JSON shape embedded into HTML as
- // `window.__APP_CONFIG__` so the frontend can hydrate feature flags & site
- // config before the first XHR finishes.
- //
- // INVARIANT: every `json` tag here MUST also exist on handler/dto.PublicSettings.
- // If you forget a feature-flag field here, the frontend's
- // `cachedPublicSettings.xxx_enabled` will be `undefined` on refresh until the
- // async `/api/v1/settings/public` call returns — which causes opt-in menus
- // (strict `=== true`) to flicker off/on. See
- // frontend/src/utils/featureFlags.ts for the matching registry.
- //
- // A unit test diffs this struct's JSON keys against dto.PublicSettings to catch
- // drift automatically (see setting_service_injection_test.go).
- type PublicSettingsInjectionPayload struct {
- RegistrationEnabled bool `json:"registration_enabled"`
- EmailVerifyEnabled bool `json:"email_verify_enabled"`
- RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
- RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"`
- PromoCodeEnabled bool `json:"promo_code_enabled"`
- PasswordResetEnabled bool `json:"password_reset_enabled"`
- InvitationCodeEnabled bool `json:"invitation_code_enabled"`
- TotpEnabled bool `json:"totp_enabled"`
- PasskeyEnabled bool `json:"passkey_enabled"`
- LoginAgreementEnabled bool `json:"login_agreement_enabled"`
- LoginAgreementMode string `json:"login_agreement_mode"`
- LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
- LoginAgreementRevision string `json:"login_agreement_revision"`
- LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
- TurnstileEnabled bool `json:"turnstile_enabled"`
- TurnstileSiteKey string `json:"turnstile_site_key"`
- TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
- TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
- TencentCaptchaRegion string `json:"tencent_captcha_region"`
- AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
- AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
- AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
- AliyunCaptchaRegion string `json:"aliyun_captcha_region"`
- SiteName string `json:"site_name"`
- SiteLogo string `json:"site_logo"`
- SiteSubtitle string `json:"site_subtitle"`
- APIBaseURL string `json:"api_base_url"`
- ContactInfo string `json:"contact_info"`
- DocURL string `json:"doc_url"`
- HomeContent string `json:"home_content"`
- CompactHomeEnabled bool `json:"compact_home_enabled"`
- HideCcsImportButton bool `json:"hide_ccs_import_button"`
- PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
- PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
- TableDefaultPageSize int `json:"table_default_page_size"`
- TablePageSizeOptions []int `json:"table_page_size_options"`
- CustomMenuItems json.RawMessage `json:"custom_menu_items"`
- CustomEndpoints json.RawMessage `json:"custom_endpoints"`
- LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
- DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"`
- WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
- WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
- WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
- WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
- OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
- OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
- GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
- GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
- BackendModeEnabled bool `json:"backend_mode_enabled"`
- PaymentEnabled bool `json:"payment_enabled"`
- Version string `json:"version"`
- // 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用
- ServerTimezone string `json:"server_timezone"`
- ServerUTCOffset string `json:"server_utc_offset"`
- BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
- AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
- BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
- BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
- // Feature flags — MUST match the opt-in/opt-out registry in
- // frontend/src/utils/featureFlags.ts. Missing a field here is the bug
- // that hid the "可用渠道" menu on page refresh.
- ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
- ChannelMonitorMode string `json:"channel_monitor_mode"`
- ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
- // ChannelMonitorHideThroughput is public so the user UI can hide RPM/TPM
- // without waiting for API redaction alone (defense in depth).
- ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
- // ChannelMonitorShowQuota gates the user-facing quota/balance display on
- // monitors; fail-closed (absent/false = hidden). Admin UI always shows it.
- // ChannelMonitorHideUserRanking hides the user ranking tab and /users payload
- // from non-admin channel-monitor v2 viewers; default false (visible).
- ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
- ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
- AvailableChannelsEnabled bool `json:"available_channels_enabled"`
- ModelPlazaEnabled bool `json:"model_plaza_enabled"`
- ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
- PluginManagementEnabled bool `json:"plugin_management_enabled"`
- AffiliateEnabled bool `json:"affiliate_enabled"`
- RiskControlEnabled bool `json:"risk_control_enabled"`
- AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
- }
- // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
- // This implements the web.PublicSettingsProvider interface.
- func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) {
- settings, err := s.GetPublicSettings(ctx)
- if err != nil {
- return nil, err
- }
- return &PublicSettingsInjectionPayload{
- RegistrationEnabled: settings.RegistrationEnabled,
- EmailVerifyEnabled: settings.EmailVerifyEnabled,
- RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
- RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled,
- PromoCodeEnabled: settings.PromoCodeEnabled,
- PasswordResetEnabled: settings.PasswordResetEnabled,
- InvitationCodeEnabled: settings.InvitationCodeEnabled,
- TotpEnabled: settings.TotpEnabled,
- PasskeyEnabled: settings.PasskeyEnabled,
- LoginAgreementEnabled: settings.LoginAgreementEnabled,
- LoginAgreementMode: settings.LoginAgreementMode,
- LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
- LoginAgreementRevision: settings.LoginAgreementRevision,
- LoginAgreementDocuments: settings.LoginAgreementDocuments,
- TurnstileEnabled: settings.TurnstileEnabled,
- TurnstileSiteKey: settings.TurnstileSiteKey,
- TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
- TencentCaptchaAppID: settings.TencentCaptchaAppID,
- TencentCaptchaRegion: settings.TencentCaptchaRegion,
- AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
- AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
- AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
- AliyunCaptchaRegion: settings.AliyunCaptchaRegion,
- SiteName: settings.SiteName,
- SiteLogo: settings.SiteLogo,
- SiteSubtitle: settings.SiteSubtitle,
- APIBaseURL: settings.APIBaseURL,
- ContactInfo: settings.ContactInfo,
- DocURL: settings.DocURL,
- HomeContent: settings.HomeContent,
- CompactHomeEnabled: settings.CompactHomeEnabled,
- HideCcsImportButton: settings.HideCcsImportButton,
- PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
- PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
- TableDefaultPageSize: settings.TableDefaultPageSize,
- TablePageSizeOptions: settings.TablePageSizeOptions,
- CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
- CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
- LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
- DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
- WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
- WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
- WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
- WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
- OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
- OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
- GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
- GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
- BackendModeEnabled: settings.BackendModeEnabled,
- PaymentEnabled: settings.PaymentEnabled,
- Version: s.version,
- ServerTimezone: timezone.Name(),
- ServerUTCOffset: timezone.UTCOffset(),
- BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
- AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
- BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
- BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
- ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
- ChannelMonitorMode: settings.ChannelMonitorMode,
- ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
- ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
- ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
- ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
- AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
- ModelPlazaEnabled: settings.ModelPlazaEnabled,
- ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
- PluginManagementEnabled: settings.PluginManagementEnabled,
- AffiliateEnabled: settings.AffiliateEnabled,
- RiskControlEnabled: settings.RiskControlEnabled,
- AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
- }, nil
- }
- // filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
- // array string, returning only items with visibility != "admin".
- func filterUserVisibleMenuItems(raw string) json.RawMessage {
- raw = strings.TrimSpace(raw)
- if raw == "" || raw == "[]" {
- return json.RawMessage("[]")
- }
- var items []struct {
- Visibility string `json:"visibility"`
- }
- if err := json.Unmarshal([]byte(raw), &items); err != nil {
- return json.RawMessage("[]")
- }
- // Parse full items to preserve all fields
- var fullItems []json.RawMessage
- if err := json.Unmarshal([]byte(raw), &fullItems); err != nil {
- return json.RawMessage("[]")
- }
- var filtered []json.RawMessage
- for i, item := range items {
- if item.Visibility != "admin" {
- filtered = append(filtered, fullItems[i])
- }
- }
- if len(filtered) == 0 {
- return json.RawMessage("[]")
- }
- result, err := json.Marshal(filtered)
- if err != nil {
- return json.RawMessage("[]")
- }
- return result
- }
- // safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]".
- func safeRawJSONArray(raw string) json.RawMessage {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return json.RawMessage("[]")
- }
- if json.Valid([]byte(raw)) {
- return json.RawMessage(raw)
- }
- return json.RawMessage("[]")
- }
- // GetFrameSrcOrigins returns deduplicated http(s) origins from home_content URL,
- // purchase_subscription_url, and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection.
- func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) {
- settings, err := s.GetPublicSettings(ctx)
- if err != nil {
- return nil, err
- }
- seen := make(map[string]struct{})
- var origins []string
- addOrigin := func(rawURL string) {
- if origin := extractOriginFromURL(rawURL); origin != "" {
- if _, ok := seen[origin]; !ok {
- seen[origin] = struct{}{}
- origins = append(origins, origin)
- }
- }
- }
- // home content URL (when home_content is set to a URL for iframe embedding)
- addOrigin(settings.HomeContent)
- // purchase subscription URL
- if settings.PurchaseSubscriptionEnabled {
- addOrigin(settings.PurchaseSubscriptionURL)
- }
- // all custom menu items (including admin-only, since CSP must allow all iframes)
- for _, item := range parseCustomMenuItemURLs(settings.CustomMenuItems) {
- addOrigin(item)
- }
- return origins, nil
- }
- // extractOriginFromURL returns the scheme+host origin from rawURL.
- // Only http and https schemes are accepted.
- func extractOriginFromURL(rawURL string) string {
- rawURL = strings.TrimSpace(rawURL)
- if rawURL == "" {
- return ""
- }
- u, err := url.Parse(rawURL)
- if err != nil || u.Host == "" {
- return ""
- }
- if u.Scheme != "http" && u.Scheme != "https" {
- return ""
- }
- return u.Scheme + "://" + u.Host
- }
- // parseCustomMenuItemURLs extracts URLs from a raw JSON array of custom menu items.
- func parseCustomMenuItemURLs(raw string) []string {
- raw = strings.TrimSpace(raw)
- if raw == "" || raw == "[]" {
- return nil
- }
- var items []struct {
- URL string `json:"url"`
- }
- if err := json.Unmarshal([]byte(raw), &items); err != nil {
- return nil
- }
- urls := make([]string, 0, len(items))
- for _, item := range items {
- if item.URL != "" {
- urls = append(urls, item.URL)
- }
- }
- return urls
- }
|