2025-12-18 13:50:39 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
2025-12-20 15:11:43 +08:00
|
|
|
|
"crypto/rand"
|
|
|
|
|
|
"encoding/hex"
|
2026-01-11 21:54:52 -08:00
|
|
|
|
"encoding/json"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
2026-03-01 15:35:46 +08:00
|
|
|
|
"log/slog"
|
2026-03-03 07:05:01 +08:00
|
|
|
|
"net/url"
|
2026-04-09 18:14:28 +08:00
|
|
|
|
"sort"
|
2025-12-25 17:15:01 +08:00
|
|
|
|
"strconv"
|
2026-01-09 20:57:32 +08:00
|
|
|
|
"strings"
|
2026-03-01 15:35:46 +08:00
|
|
|
|
"sync/atomic"
|
2026-02-28 15:01:20 +08:00
|
|
|
|
"time"
|
2025-12-25 17:15:01 +08:00
|
|
|
|
|
2025-12-24 21:07:21 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
2025-12-31 23:42:01 +08:00
|
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
2026-03-13 23:38:58 +08:00
|
|
|
|
"github.com/imroc/req/v3"
|
2026-03-01 15:35:46 +08:00
|
|
|
|
"golang.org/x/sync/singleflight"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
2026-03-02 03:41:50 +08:00
|
|
|
|
ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
|
|
|
|
|
|
ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found")
|
|
|
|
|
|
ErrDefaultSubGroupInvalid = infraerrors.BadRequest(
|
|
|
|
|
|
"DEFAULT_SUBSCRIPTION_GROUP_INVALID",
|
|
|
|
|
|
"default subscription group must exist and be subscription type",
|
|
|
|
|
|
)
|
|
|
|
|
|
ErrDefaultSubGroupDuplicate = infraerrors.BadRequest(
|
|
|
|
|
|
"DEFAULT_SUBSCRIPTION_GROUP_DUPLICATE",
|
|
|
|
|
|
"default subscription group cannot be duplicated",
|
|
|
|
|
|
)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-25 17:15:01 +08:00
|
|
|
|
type SettingRepository interface {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
Get(ctx context.Context, key string) (*Setting, error)
|
2025-12-25 17:15:01 +08:00
|
|
|
|
GetValue(ctx context.Context, key string) (string, error)
|
|
|
|
|
|
Set(ctx context.Context, key, value string) error
|
|
|
|
|
|
GetMultiple(ctx context.Context, keys []string) (map[string]string, error)
|
|
|
|
|
|
SetMultiple(ctx context.Context, settings map[string]string) error
|
|
|
|
|
|
GetAll(ctx context.Context) (map[string]string, error)
|
|
|
|
|
|
Delete(ctx context.Context, key string) error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 09:10:01 +08:00
|
|
|
|
// cachedVersionBounds 缓存 Claude Code 版本号上下限(进程内缓存,60s TTL)
|
|
|
|
|
|
type cachedVersionBounds struct {
|
|
|
|
|
|
min string // 空字符串 = 不检查
|
|
|
|
|
|
max string // 空字符串 = 不检查
|
2026-03-01 15:35:46 +08:00
|
|
|
|
expiresAt int64 // unix nano
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 09:10:01 +08:00
|
|
|
|
// versionBoundsCache 版本号上下限进程内缓存
|
|
|
|
|
|
var versionBoundsCache atomic.Value // *cachedVersionBounds
|
2026-03-01 15:35:46 +08:00
|
|
|
|
|
2026-03-20 09:10:01 +08:00
|
|
|
|
// versionBoundsSF 防止缓存过期时 thundering herd
|
|
|
|
|
|
var versionBoundsSF singleflight.Group
|
2026-03-01 15:35:46 +08:00
|
|
|
|
|
2026-03-20 09:10:01 +08:00
|
|
|
|
// versionBoundsCacheTTL 缓存有效期
|
|
|
|
|
|
const versionBoundsCacheTTL = 60 * time.Second
|
2026-03-01 15:35:46 +08:00
|
|
|
|
|
2026-03-20 09:10:01 +08:00
|
|
|
|
// versionBoundsErrorTTL DB 错误时的短缓存,快速重试
|
|
|
|
|
|
const versionBoundsErrorTTL = 5 * time.Second
|
2026-03-01 15:35:46 +08:00
|
|
|
|
|
2026-03-20 09:10:01 +08:00
|
|
|
|
// versionBoundsDBTimeout singleflight 内 DB 查询超时,独立于请求 context
|
|
|
|
|
|
const versionBoundsDBTimeout = 5 * time.Second
|
2026-03-01 15:35:46 +08:00
|
|
|
|
|
2026-03-12 02:42:57 +03:00
|
|
|
|
// cachedBackendMode Backend Mode cache (in-process, 60s TTL)
|
|
|
|
|
|
type cachedBackendMode struct {
|
|
|
|
|
|
value bool
|
|
|
|
|
|
expiresAt int64 // unix nano
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var backendModeCache atomic.Value // *cachedBackendMode
|
|
|
|
|
|
var backendModeSF singleflight.Group
|
|
|
|
|
|
|
|
|
|
|
|
const backendModeCacheTTL = 60 * time.Second
|
|
|
|
|
|
const backendModeErrorTTL = 5 * time.Second
|
|
|
|
|
|
const backendModeDBTimeout = 5 * time.Second
|
|
|
|
|
|
|
2026-03-26 10:22:03 +08:00
|
|
|
|
// cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL)
|
|
|
|
|
|
type cachedGatewayForwardingSettings struct {
|
|
|
|
|
|
fingerprintUnification bool
|
|
|
|
|
|
metadataPassthrough bool
|
2026-04-08 16:11:19 +08:00
|
|
|
|
cchSigning bool
|
2026-03-26 10:22:03 +08:00
|
|
|
|
expiresAt int64 // unix nano
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var gatewayForwardingCache atomic.Value // *cachedGatewayForwardingSettings
|
|
|
|
|
|
var gatewayForwardingSF singleflight.Group
|
|
|
|
|
|
|
|
|
|
|
|
const gatewayForwardingCacheTTL = 60 * time.Second
|
|
|
|
|
|
const gatewayForwardingErrorTTL = 5 * time.Second
|
|
|
|
|
|
const gatewayForwardingDBTimeout = 5 * time.Second
|
|
|
|
|
|
|
2026-03-02 03:41:50 +08:00
|
|
|
|
// DefaultSubscriptionGroupReader validates group references used by default subscriptions.
|
|
|
|
|
|
type DefaultSubscriptionGroupReader interface {
|
|
|
|
|
|
GetByID(ctx context.Context, id int64) (*Group, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 14:43:12 +08:00
|
|
|
|
// WebSearchManagerBuilder creates a websearch.Manager from config (injected by infra layer).
|
|
|
|
|
|
// proxyURLs maps proxy ID to resolved URL for provider-level proxy support.
|
|
|
|
|
|
type WebSearchManagerBuilder func(cfg *WebSearchEmulationConfig, proxyURLs map[int64]string)
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// SettingService 系统设置服务
|
|
|
|
|
|
type SettingService struct {
|
2026-04-12 14:43:12 +08:00
|
|
|
|
settingRepo SettingRepository
|
|
|
|
|
|
defaultSubGroupReader DefaultSubscriptionGroupReader
|
|
|
|
|
|
proxyRepo ProxyRepository // for resolving websearch provider proxy URLs
|
|
|
|
|
|
cfg *config.Config
|
|
|
|
|
|
onUpdate func() // Callback when settings are updated (for cache invalidation)
|
|
|
|
|
|
version string // Application version
|
|
|
|
|
|
webSearchManagerBuilder WebSearchManagerBuilder
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 17:39:57 +08:00
|
|
|
|
type ProviderDefaultGrantSettings struct {
|
|
|
|
|
|
Balance float64
|
|
|
|
|
|
Concurrency int
|
|
|
|
|
|
Subscriptions []DefaultSubscriptionSetting
|
|
|
|
|
|
GrantOnSignup bool
|
|
|
|
|
|
GrantOnFirstBind bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AuthSourceDefaultSettings struct {
|
|
|
|
|
|
Email ProviderDefaultGrantSettings
|
|
|
|
|
|
LinuxDo ProviderDefaultGrantSettings
|
|
|
|
|
|
OIDC ProviderDefaultGrantSettings
|
|
|
|
|
|
WeChat ProviderDefaultGrantSettings
|
|
|
|
|
|
ForceEmailOnThirdPartySignup bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type authSourceDefaultKeySet struct {
|
|
|
|
|
|
balance string
|
|
|
|
|
|
concurrency string
|
|
|
|
|
|
subscriptions string
|
|
|
|
|
|
grantOnSignup string
|
|
|
|
|
|
grantOnFirstBind string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
emailAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
|
|
|
|
|
balance: SettingKeyAuthSourceDefaultEmailBalance,
|
|
|
|
|
|
concurrency: SettingKeyAuthSourceDefaultEmailConcurrency,
|
|
|
|
|
|
subscriptions: SettingKeyAuthSourceDefaultEmailSubscriptions,
|
|
|
|
|
|
grantOnSignup: SettingKeyAuthSourceDefaultEmailGrantOnSignup,
|
|
|
|
|
|
grantOnFirstBind: SettingKeyAuthSourceDefaultEmailGrantOnFirstBind,
|
|
|
|
|
|
}
|
|
|
|
|
|
linuxDoAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
|
|
|
|
|
balance: SettingKeyAuthSourceDefaultLinuxDoBalance,
|
|
|
|
|
|
concurrency: SettingKeyAuthSourceDefaultLinuxDoConcurrency,
|
|
|
|
|
|
subscriptions: SettingKeyAuthSourceDefaultLinuxDoSubscriptions,
|
|
|
|
|
|
grantOnSignup: SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup,
|
|
|
|
|
|
grantOnFirstBind: SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind,
|
|
|
|
|
|
}
|
|
|
|
|
|
oidcAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
|
|
|
|
|
balance: SettingKeyAuthSourceDefaultOIDCBalance,
|
|
|
|
|
|
concurrency: SettingKeyAuthSourceDefaultOIDCConcurrency,
|
|
|
|
|
|
subscriptions: SettingKeyAuthSourceDefaultOIDCSubscriptions,
|
|
|
|
|
|
grantOnSignup: SettingKeyAuthSourceDefaultOIDCGrantOnSignup,
|
|
|
|
|
|
grantOnFirstBind: SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind,
|
|
|
|
|
|
}
|
|
|
|
|
|
weChatAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
|
|
|
|
|
balance: SettingKeyAuthSourceDefaultWeChatBalance,
|
|
|
|
|
|
concurrency: SettingKeyAuthSourceDefaultWeChatConcurrency,
|
|
|
|
|
|
subscriptions: SettingKeyAuthSourceDefaultWeChatSubscriptions,
|
|
|
|
|
|
grantOnSignup: SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
|
|
|
|
|
|
grantOnFirstBind: SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
defaultAuthSourceBalance = 0
|
|
|
|
|
|
defaultAuthSourceConcurrency = 5
|
2026-04-21 17:35:12 +08:00
|
|
|
|
defaultWeChatConnectMode = "open"
|
|
|
|
|
|
defaultWeChatConnectScopes = "snsapi_login"
|
|
|
|
|
|
defaultWeChatConnectFrontend = "/auth/wechat/callback"
|
2026-04-20 17:39:57 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-21 17:35:12 +08:00
|
|
|
|
func normalizeWeChatConnectModeSetting(raw string) string {
|
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
|
|
|
|
case "mp":
|
|
|
|
|
|
return "mp"
|
2026-04-21 07:48:42 -07:00
|
|
|
|
case "mobile":
|
|
|
|
|
|
return "mobile"
|
2026-04-21 17:35:12 +08:00
|
|
|
|
default:
|
|
|
|
|
|
return "open"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func defaultWeChatConnectScopeForMode(mode string) string {
|
2026-04-21 07:48:42 -07:00
|
|
|
|
switch normalizeWeChatConnectModeSetting(mode) {
|
|
|
|
|
|
case "mp":
|
2026-04-21 17:35:12 +08:00
|
|
|
|
return "snsapi_userinfo"
|
2026-04-21 07:48:42 -07:00
|
|
|
|
case "mobile":
|
|
|
|
|
|
return ""
|
2026-04-21 17:35:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
return defaultWeChatConnectScopes
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeWeChatConnectScopeSetting(raw, mode string) string {
|
|
|
|
|
|
switch normalizeWeChatConnectModeSetting(mode) {
|
|
|
|
|
|
case "mp":
|
|
|
|
|
|
switch strings.TrimSpace(raw) {
|
|
|
|
|
|
case "snsapi_base":
|
|
|
|
|
|
return "snsapi_base"
|
|
|
|
|
|
case "snsapi_userinfo":
|
|
|
|
|
|
return "snsapi_userinfo"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return defaultWeChatConnectScopeForMode(mode)
|
|
|
|
|
|
}
|
2026-04-21 07:48:42 -07:00
|
|
|
|
case "mobile":
|
|
|
|
|
|
return ""
|
2026-04-21 17:35:12 +08:00
|
|
|
|
default:
|
|
|
|
|
|
return defaultWeChatConnectScopes
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 07:48:42 -07:00
|
|
|
|
func parseWeChatConnectCapabilitySettings(settings map[string]string, enabled bool, mode string) (bool, bool, bool) {
|
2026-04-21 20:36:10 +08:00
|
|
|
|
mode = normalizeWeChatConnectModeSetting(mode)
|
|
|
|
|
|
rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled]
|
|
|
|
|
|
rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled]
|
2026-04-21 07:48:42 -07:00
|
|
|
|
rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled]
|
2026-04-21 20:36:10 +08:00
|
|
|
|
openConfigured := hasOpen && strings.TrimSpace(rawOpen) != ""
|
|
|
|
|
|
mpConfigured := hasMP && strings.TrimSpace(rawMP) != ""
|
2026-04-21 07:48:42 -07:00
|
|
|
|
mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != ""
|
2026-04-21 20:36:10 +08:00
|
|
|
|
|
2026-04-21 07:48:42 -07:00
|
|
|
|
if openConfigured || mpConfigured || mobileConfigured {
|
2026-04-21 20:36:10 +08:00
|
|
|
|
openEnabled := strings.TrimSpace(rawOpen) == "true"
|
|
|
|
|
|
mpEnabled := strings.TrimSpace(rawMP) == "true"
|
2026-04-21 07:48:42 -07:00
|
|
|
|
mobileEnabled := strings.TrimSpace(rawMobile) == "true"
|
|
|
|
|
|
return openEnabled, mpEnabled, mobileEnabled
|
2026-04-21 20:36:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if !enabled {
|
2026-04-21 07:48:42 -07:00
|
|
|
|
return false, false, false
|
2026-04-21 20:36:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
if mode == "mp" {
|
2026-04-21 07:48:42 -07:00
|
|
|
|
return false, true, false
|
|
|
|
|
|
}
|
|
|
|
|
|
if mode == "mobile" {
|
|
|
|
|
|
return false, false, true
|
2026-04-21 20:36:10 +08:00
|
|
|
|
}
|
2026-04-21 07:48:42 -07:00
|
|
|
|
return true, false, false
|
2026-04-21 20:36:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 07:48:42 -07:00
|
|
|
|
func normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled bool, mode string) string {
|
2026-04-22 12:30:07 +08:00
|
|
|
|
mode = normalizeWeChatConnectModeSetting(mode)
|
|
|
|
|
|
switch mode {
|
|
|
|
|
|
case "open":
|
|
|
|
|
|
if openEnabled {
|
|
|
|
|
|
return "open"
|
|
|
|
|
|
}
|
|
|
|
|
|
case "mp":
|
|
|
|
|
|
if mpEnabled {
|
|
|
|
|
|
return "mp"
|
|
|
|
|
|
}
|
|
|
|
|
|
case "mobile":
|
|
|
|
|
|
if mobileEnabled {
|
|
|
|
|
|
return "mobile"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-21 20:36:10 +08:00
|
|
|
|
switch {
|
2026-04-22 12:30:07 +08:00
|
|
|
|
case openEnabled:
|
|
|
|
|
|
return "open"
|
2026-04-21 20:36:10 +08:00
|
|
|
|
case mpEnabled:
|
|
|
|
|
|
return "mp"
|
2026-04-21 07:48:42 -07:00
|
|
|
|
case mobileEnabled:
|
|
|
|
|
|
return "mobile"
|
2026-04-21 20:36:10 +08:00
|
|
|
|
default:
|
2026-04-22 12:30:07 +08:00
|
|
|
|
return mode
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func mergeWeChatConnectCapabilitySettings(settings map[string]string, base config.WeChatConnectConfig, enabled bool, mode string) (bool, bool, bool) {
|
|
|
|
|
|
mode = normalizeWeChatConnectModeSetting(firstNonEmpty(mode, base.Mode))
|
|
|
|
|
|
rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled]
|
|
|
|
|
|
rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled]
|
|
|
|
|
|
rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled]
|
|
|
|
|
|
openConfigured := hasOpen && strings.TrimSpace(rawOpen) != ""
|
|
|
|
|
|
mpConfigured := hasMP && strings.TrimSpace(rawMP) != ""
|
|
|
|
|
|
mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != ""
|
|
|
|
|
|
|
|
|
|
|
|
if openConfigured || mpConfigured || mobileConfigured {
|
2026-04-22 13:18:10 +08:00
|
|
|
|
openEnabled := strings.TrimSpace(rawOpen) == "true"
|
|
|
|
|
|
mpEnabled := strings.TrimSpace(rawMP) == "true"
|
|
|
|
|
|
mobileEnabled := strings.TrimSpace(rawMobile) == "true"
|
|
|
|
|
|
_, enabledConfigured := settings[SettingKeyWeChatConnectEnabled]
|
|
|
|
|
|
if !enabledConfigured &&
|
|
|
|
|
|
enabled &&
|
|
|
|
|
|
!openEnabled &&
|
|
|
|
|
|
!mpEnabled &&
|
|
|
|
|
|
!mobileEnabled &&
|
|
|
|
|
|
(base.OpenEnabled || base.MPEnabled || base.MobileEnabled) {
|
|
|
|
|
|
return base.OpenEnabled, base.MPEnabled, base.MobileEnabled
|
|
|
|
|
|
}
|
|
|
|
|
|
return openEnabled, mpEnabled, mobileEnabled
|
2026-04-22 12:30:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
if !enabled {
|
|
|
|
|
|
return false, false, false
|
|
|
|
|
|
}
|
|
|
|
|
|
if base.OpenEnabled || base.MPEnabled || base.MobileEnabled {
|
|
|
|
|
|
return base.OpenEnabled, base.MPEnabled, base.MobileEnabled
|
|
|
|
|
|
}
|
|
|
|
|
|
return parseWeChatConnectCapabilitySettings(settings, enabled, mode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *SettingService) effectiveWeChatConnectOAuthConfig(settings map[string]string) WeChatConnectOAuthConfig {
|
|
|
|
|
|
base := config.WeChatConnectConfig{}
|
|
|
|
|
|
if s != nil && s.cfg != nil {
|
|
|
|
|
|
base = s.cfg.WeChat
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
enabled := base.Enabled
|
|
|
|
|
|
if raw, ok := settings[SettingKeyWeChatConnectEnabled]; ok {
|
|
|
|
|
|
enabled = strings.TrimSpace(raw) == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
legacyAppID := strings.TrimSpace(firstNonEmpty(
|
|
|
|
|
|
settings[SettingKeyWeChatConnectAppID],
|
|
|
|
|
|
base.AppID,
|
|
|
|
|
|
base.OpenAppID,
|
|
|
|
|
|
base.MPAppID,
|
|
|
|
|
|
base.MobileAppID,
|
|
|
|
|
|
))
|
|
|
|
|
|
legacyAppSecret := strings.TrimSpace(firstNonEmpty(
|
|
|
|
|
|
settings[SettingKeyWeChatConnectAppSecret],
|
|
|
|
|
|
base.AppSecret,
|
|
|
|
|
|
base.OpenAppSecret,
|
|
|
|
|
|
base.MPAppSecret,
|
|
|
|
|
|
base.MobileAppSecret,
|
|
|
|
|
|
))
|
|
|
|
|
|
openAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppID], base.OpenAppID, legacyAppID))
|
|
|
|
|
|
openAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppSecret], base.OpenAppSecret, legacyAppSecret))
|
|
|
|
|
|
mpAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppID], base.MPAppID, legacyAppID))
|
|
|
|
|
|
mpAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppSecret], base.MPAppSecret, legacyAppSecret))
|
|
|
|
|
|
mobileAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppID], base.MobileAppID, legacyAppID))
|
|
|
|
|
|
mobileAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppSecret], base.MobileAppSecret, legacyAppSecret))
|
|
|
|
|
|
|
|
|
|
|
|
modeRaw := firstNonEmpty(settings[SettingKeyWeChatConnectMode], base.Mode)
|
|
|
|
|
|
openEnabled, mpEnabled, mobileEnabled := mergeWeChatConnectCapabilitySettings(settings, base, enabled, modeRaw)
|
|
|
|
|
|
mode := normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled, modeRaw)
|
|
|
|
|
|
|
|
|
|
|
|
return WeChatConnectOAuthConfig{
|
|
|
|
|
|
Enabled: enabled,
|
|
|
|
|
|
LegacyAppID: legacyAppID,
|
|
|
|
|
|
LegacyAppSecret: legacyAppSecret,
|
|
|
|
|
|
OpenAppID: openAppID,
|
|
|
|
|
|
OpenAppSecret: openAppSecret,
|
|
|
|
|
|
MPAppID: mpAppID,
|
|
|
|
|
|
MPAppSecret: mpAppSecret,
|
|
|
|
|
|
MobileAppID: mobileAppID,
|
|
|
|
|
|
MobileAppSecret: mobileAppSecret,
|
|
|
|
|
|
OpenEnabled: openEnabled,
|
|
|
|
|
|
MPEnabled: mpEnabled,
|
|
|
|
|
|
MobileEnabled: mobileEnabled,
|
|
|
|
|
|
Mode: mode,
|
|
|
|
|
|
Scopes: normalizeWeChatConnectScopeSetting(firstNonEmpty(settings[SettingKeyWeChatConnectScopes], base.Scopes), mode),
|
|
|
|
|
|
RedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectRedirectURL], base.RedirectURL)),
|
|
|
|
|
|
FrontendRedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectFrontendRedirectURL], base.FrontendRedirectURL, defaultWeChatConnectFrontend)),
|
2026-04-21 20:36:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// NewSettingService 创建系统设置服务实例
|
2025-12-25 17:15:01 +08:00
|
|
|
|
func NewSettingService(settingRepo SettingRepository, cfg *config.Config) *SettingService {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return &SettingService{
|
|
|
|
|
|
settingRepo: settingRepo,
|
|
|
|
|
|
cfg: cfg,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 03:41:50 +08:00
|
|
|
|
// SetDefaultSubscriptionGroupReader injects an optional group reader for default subscription validation.
|
|
|
|
|
|
func (s *SettingService) SetDefaultSubscriptionGroupReader(reader DefaultSubscriptionGroupReader) {
|
|
|
|
|
|
s.defaultSubGroupReader = reader
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 14:43:12 +08:00
|
|
|
|
// SetProxyRepository injects a proxy repo for resolving websearch provider proxy URLs.
|
|
|
|
|
|
func (s *SettingService) SetProxyRepository(repo ProxyRepository) {
|
|
|
|
|
|
s.proxyRepo = repo
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// GetAllSettings 获取所有系统设置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *SettingService) GetAllSettings(ctx context.Context) (*SystemSettings, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
settings, err := s.settingRepo.GetAll(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get all settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.parseSettings(settings), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 17:52:29 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// GetPublicSettings 获取公开设置(无需登录)
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
keys := []string{
|
2025-12-26 15:40:24 +08:00
|
|
|
|
SettingKeyRegistrationEnabled,
|
|
|
|
|
|
SettingKeyEmailVerifyEnabled,
|
2026-04-20 19:30:09 +08:00
|
|
|
|
SettingKeyForceEmailOnThirdPartySignup,
|
2026-03-02 23:13:39 +08:00
|
|
|
|
SettingKeyRegistrationEmailSuffixWhitelist,
|
2026-01-20 15:56:26 +08:00
|
|
|
|
SettingKeyPromoCodeEnabled,
|
2026-01-24 22:33:45 +08:00
|
|
|
|
SettingKeyPasswordResetEnabled,
|
2026-01-29 16:29:59 +08:00
|
|
|
|
SettingKeyInvitationCodeEnabled,
|
2026-01-26 08:45:43 +08:00
|
|
|
|
SettingKeyTotpEnabled,
|
2025-12-26 15:40:24 +08:00
|
|
|
|
SettingKeyTurnstileEnabled,
|
|
|
|
|
|
SettingKeyTurnstileSiteKey,
|
|
|
|
|
|
SettingKeySiteName,
|
|
|
|
|
|
SettingKeySiteLogo,
|
|
|
|
|
|
SettingKeySiteSubtitle,
|
2026-01-04 19:27:53 +08:00
|
|
|
|
SettingKeyAPIBaseURL,
|
2025-12-26 15:40:24 +08:00
|
|
|
|
SettingKeyContactInfo,
|
2026-01-04 19:27:53 +08:00
|
|
|
|
SettingKeyDocURL,
|
2026-01-10 18:37:44 +08:00
|
|
|
|
SettingKeyHomeContent,
|
2026-01-19 19:25:16 +08:00
|
|
|
|
SettingKeyHideCcsImportButton,
|
2026-01-28 13:54:32 +08:00
|
|
|
|
SettingKeyPurchaseSubscriptionEnabled,
|
|
|
|
|
|
SettingKeyPurchaseSubscriptionURL,
|
2026-04-09 18:14:28 +08:00
|
|
|
|
SettingKeyTableDefaultPageSize,
|
|
|
|
|
|
SettingKeyTablePageSizeOptions,
|
2026-03-02 19:37:40 +08:00
|
|
|
|
SettingKeyCustomMenuItems,
|
2026-03-24 10:13:28 +08:00
|
|
|
|
SettingKeyCustomEndpoints,
|
2026-01-09 13:52:27 +08:00
|
|
|
|
SettingKeyLinuxDoConnectEnabled,
|
2026-04-21 17:35:12 +08:00
|
|
|
|
SettingKeyWeChatConnectEnabled,
|
|
|
|
|
|
SettingKeyWeChatConnectAppID,
|
|
|
|
|
|
SettingKeyWeChatConnectAppSecret,
|
2026-04-21 07:48:42 -07:00
|
|
|
|
SettingKeyWeChatConnectOpenAppID,
|
|
|
|
|
|
SettingKeyWeChatConnectOpenAppSecret,
|
|
|
|
|
|
SettingKeyWeChatConnectMPAppID,
|
|
|
|
|
|
SettingKeyWeChatConnectMPAppSecret,
|
|
|
|
|
|
SettingKeyWeChatConnectMobileAppID,
|
|
|
|
|
|
SettingKeyWeChatConnectMobileAppSecret,
|
2026-04-21 20:36:10 +08:00
|
|
|
|
SettingKeyWeChatConnectOpenEnabled,
|
|
|
|
|
|
SettingKeyWeChatConnectMPEnabled,
|
2026-04-21 07:48:42 -07:00
|
|
|
|
SettingKeyWeChatConnectMobileEnabled,
|
2026-04-21 17:35:12 +08:00
|
|
|
|
SettingKeyWeChatConnectMode,
|
|
|
|
|
|
SettingKeyWeChatConnectScopes,
|
|
|
|
|
|
SettingKeyWeChatConnectRedirectURL,
|
|
|
|
|
|
SettingKeyWeChatConnectFrontendRedirectURL,
|
2026-03-12 02:42:57 +03:00
|
|
|
|
SettingKeyBackendModeEnabled,
|
2026-04-12 02:48:57 +08:00
|
|
|
|
SettingPaymentEnabled,
|
2026-03-13 23:38:58 +08:00
|
|
|
|
SettingKeyOIDCConnectEnabled,
|
|
|
|
|
|
SettingKeyOIDCConnectProviderName,
|
2026-04-12 17:49:58 +08:00
|
|
|
|
SettingKeyBalanceLowNotifyEnabled,
|
2026-04-12 20:29:26 +08:00
|
|
|
|
SettingKeyBalanceLowNotifyThreshold,
|
2026-04-13 18:39:45 +08:00
|
|
|
|
SettingKeyBalanceLowNotifyRechargeURL,
|
2026-04-12 17:49:58 +08:00
|
|
|
|
SettingKeyAccountQuotaNotifyEnabled,
|
2026-04-21 00:21:29 +08:00
|
|
|
|
SettingKeyChannelMonitorEnabled,
|
|
|
|
|
|
SettingKeyChannelMonitorDefaultIntervalSeconds,
|
2026-04-21 17:23:20 +08:00
|
|
|
|
SettingKeyAvailableChannelsEnabled,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get public settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 13:52:27 +08:00
|
|
|
|
linuxDoEnabled := false
|
|
|
|
|
|
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
|
|
|
|
|
|
linuxDoEnabled = raw == "true"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled
|
|
|
|
|
|
}
|
2026-03-13 23:38:58 +08:00
|
|
|
|
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"
|
|
|
|
|
|
}
|
2026-04-21 07:48:42 -07:00
|
|
|
|
weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings)
|
2026-01-09 13:52:27 +08:00
|
|
|
|
|
2026-01-24 22:33:45 +08:00
|
|
|
|
// Password reset requires email verification to be enabled
|
|
|
|
|
|
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
|
|
|
|
|
passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true"
|
2026-03-02 23:13:39 +08:00
|
|
|
|
registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist(
|
|
|
|
|
|
settings[SettingKeyRegistrationEmailSuffixWhitelist],
|
|
|
|
|
|
)
|
2026-04-09 18:14:28 +08:00
|
|
|
|
tableDefaultPageSize, tablePageSizeOptions := parseTablePreferences(
|
|
|
|
|
|
settings[SettingKeyTableDefaultPageSize],
|
|
|
|
|
|
settings[SettingKeyTablePageSizeOptions],
|
|
|
|
|
|
)
|
2026-01-24 22:33:45 +08:00
|
|
|
|
|
2026-04-12 20:29:26 +08:00
|
|
|
|
var balanceLowNotifyThreshold float64
|
|
|
|
|
|
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
|
|
|
|
|
|
balanceLowNotifyThreshold = v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
return &PublicSettings{
|
2026-03-02 23:13:39 +08:00
|
|
|
|
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
|
|
|
|
|
EmailVerifyEnabled: emailVerifyEnabled,
|
2026-04-20 19:30:09 +08:00
|
|
|
|
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
|
2026-03-02 23:13:39 +08:00
|
|
|
|
RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
|
|
|
|
|
|
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
|
|
|
|
|
PasswordResetEnabled: passwordResetEnabled,
|
|
|
|
|
|
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
|
|
|
|
|
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
|
|
|
|
|
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
|
|
|
|
|
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
|
|
|
|
|
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],
|
|
|
|
|
|
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
|
|
|
|
|
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
|
|
|
|
|
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
2026-04-09 18:14:28 +08:00
|
|
|
|
TableDefaultPageSize: tableDefaultPageSize,
|
|
|
|
|
|
TablePageSizeOptions: tablePageSizeOptions,
|
2026-03-02 23:13:39 +08:00
|
|
|
|
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
2026-03-24 10:13:28 +08:00
|
|
|
|
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
2026-03-02 23:13:39 +08:00
|
|
|
|
LinuxDoOAuthEnabled: linuxDoEnabled,
|
2026-04-20 17:39:57 +08:00
|
|
|
|
WeChatOAuthEnabled: weChatEnabled,
|
2026-04-21 00:46:40 +08:00
|
|
|
|
WeChatOAuthOpenEnabled: weChatOpenEnabled,
|
|
|
|
|
|
WeChatOAuthMPEnabled: weChatMPEnabled,
|
2026-04-21 07:48:42 -07:00
|
|
|
|
WeChatOAuthMobileEnabled: weChatMobileEnabled,
|
2026-03-12 02:42:57 +03:00
|
|
|
|
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
2026-04-12 02:48:57 +08:00
|
|
|
|
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
|
2026-03-13 23:38:58 +08:00
|
|
|
|
OIDCOAuthEnabled: oidcEnabled,
|
|
|
|
|
|
OIDCOAuthProviderName: oidcProviderName,
|
2026-04-12 17:49:58 +08:00
|
|
|
|
BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
|
|
|
|
|
|
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
|
2026-04-12 20:29:26 +08:00
|
|
|
|
BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
|
2026-04-13 18:39:45 +08:00
|
|
|
|
BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL],
|
2026-04-21 00:21:29 +08:00
|
|
|
|
|
|
|
|
|
|
ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]),
|
|
|
|
|
|
ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
|
2026-04-21 17:23:20 +08:00
|
|
|
|
|
|
|
|
|
|
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 00:21:29 +08:00
|
|
|
|
// channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval
|
|
|
|
|
|
// (mirrors the monitor-level constraint but lives here so setting_service stays decoupled).
|
|
|
|
|
|
const (
|
2026-04-23 20:55:18 +08:00
|
|
|
|
channelMonitorIntervalMin = 15
|
|
|
|
|
|
channelMonitorIntervalMax = 3600
|
2026-04-21 00:21:29 +08:00
|
|
|
|
channelMonitorIntervalFallback = 60
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 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 and user-facing handlers.
|
|
|
|
|
|
type ChannelMonitorRuntime struct {
|
2026-04-23 20:55:18 +08:00
|
|
|
|
Enabled bool
|
|
|
|
|
|
DefaultIntervalSeconds int
|
2026-04-21 00:21:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetChannelMonitorRuntime reads the channel monitor feature flags directly from
|
|
|
|
|
|
// the settings store. Fail-open: on error returns Enabled=true with the default interval.
|
|
|
|
|
|
func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime {
|
|
|
|
|
|
vals, err := s.settingRepo.GetMultiple(ctx, []string{
|
|
|
|
|
|
SettingKeyChannelMonitorEnabled,
|
|
|
|
|
|
SettingKeyChannelMonitorDefaultIntervalSeconds,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return ChannelMonitorRuntime{Enabled: true, DefaultIntervalSeconds: channelMonitorIntervalFallback}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ChannelMonitorRuntime{
|
|
|
|
|
|
Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]),
|
|
|
|
|
|
DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 17:23:20 +08:00
|
|
|
|
// 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",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 18:37:44 +08:00
|
|
|
|
// SetOnUpdateCallback sets a callback function to be called when settings are updated
|
|
|
|
|
|
// This is used for cache invalidation (e.g., HTML cache in frontend server)
|
|
|
|
|
|
func (s *SettingService) SetOnUpdateCallback(callback func()) {
|
|
|
|
|
|
s.onUpdate = callback
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SetVersion sets the application version for injection into public settings
|
|
|
|
|
|
func (s *SettingService) SetVersion(version string) {
|
|
|
|
|
|
s.version = version
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 20:55:18 +08:00
|
|
|
|
// 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"`
|
|
|
|
|
|
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
|
|
|
|
|
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
|
|
|
|
|
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
|
|
|
|
|
TotpEnabled bool `json:"totp_enabled"`
|
|
|
|
|
|
TurnstileEnabled bool `json:"turnstile_enabled"`
|
|
|
|
|
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
|
|
|
|
|
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"`
|
|
|
|
|
|
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"`
|
|
|
|
|
|
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"`
|
|
|
|
|
|
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
|
|
|
|
|
PaymentEnabled bool `json:"payment_enabled"`
|
|
|
|
|
|
Version string `json:"version"`
|
|
|
|
|
|
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.
|
|
|
|
|
|
ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"`
|
|
|
|
|
|
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
|
|
|
|
|
|
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
|
|
|
|
|
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
|
|
|
|
|
|
// This implements the web.PublicSettingsProvider interface.
|
2026-01-10 19:08:41 +08:00
|
|
|
|
func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) {
|
2026-01-10 18:37:44 +08:00
|
|
|
|
settings, err := s.GetPublicSettings(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 20:55:18 +08:00
|
|
|
|
return &PublicSettingsInjectionPayload{
|
2026-03-02 23:13:39 +08:00
|
|
|
|
RegistrationEnabled: settings.RegistrationEnabled,
|
|
|
|
|
|
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
|
|
|
|
|
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
|
|
|
|
|
|
PromoCodeEnabled: settings.PromoCodeEnabled,
|
|
|
|
|
|
PasswordResetEnabled: settings.PasswordResetEnabled,
|
|
|
|
|
|
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
|
|
|
|
|
TotpEnabled: settings.TotpEnabled,
|
|
|
|
|
|
TurnstileEnabled: settings.TurnstileEnabled,
|
|
|
|
|
|
TurnstileSiteKey: settings.TurnstileSiteKey,
|
|
|
|
|
|
SiteName: settings.SiteName,
|
|
|
|
|
|
SiteLogo: settings.SiteLogo,
|
|
|
|
|
|
SiteSubtitle: settings.SiteSubtitle,
|
|
|
|
|
|
APIBaseURL: settings.APIBaseURL,
|
|
|
|
|
|
ContactInfo: settings.ContactInfo,
|
|
|
|
|
|
DocURL: settings.DocURL,
|
|
|
|
|
|
HomeContent: settings.HomeContent,
|
|
|
|
|
|
HideCcsImportButton: settings.HideCcsImportButton,
|
|
|
|
|
|
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
|
|
|
|
|
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
2026-04-09 18:14:28 +08:00
|
|
|
|
TableDefaultPageSize: settings.TableDefaultPageSize,
|
|
|
|
|
|
TablePageSizeOptions: settings.TablePageSizeOptions,
|
2026-03-02 23:13:39 +08:00
|
|
|
|
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
2026-03-24 10:13:28 +08:00
|
|
|
|
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
2026-03-02 23:13:39 +08:00
|
|
|
|
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
2026-04-20 17:39:57 +08:00
|
|
|
|
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
|
2026-04-21 00:46:40 +08:00
|
|
|
|
WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
|
|
|
|
|
|
WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
|
2026-04-21 07:48:42 -07:00
|
|
|
|
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
|
2026-03-13 23:38:58 +08:00
|
|
|
|
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
|
|
|
|
|
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
2026-04-23 20:55:18 +08:00
|
|
|
|
BackendModeEnabled: settings.BackendModeEnabled,
|
|
|
|
|
|
PaymentEnabled: settings.PaymentEnabled,
|
2026-03-02 23:13:39 +08:00
|
|
|
|
Version: s.version,
|
2026-04-12 17:49:58 +08:00
|
|
|
|
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
|
|
|
|
|
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
2026-04-12 20:29:26 +08:00
|
|
|
|
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
2026-04-13 18:39:45 +08:00
|
|
|
|
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
|
2026-04-23 20:55:18 +08:00
|
|
|
|
|
|
|
|
|
|
ForceEmailOnThirdPartySignup: settings.ForceEmailOnThirdPartySignup,
|
|
|
|
|
|
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
|
|
|
|
|
|
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
|
|
|
|
|
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 17:35:12 +08:00
|
|
|
|
func DefaultWeChatConnectScopesForMode(mode string) string {
|
|
|
|
|
|
return defaultWeChatConnectScopeForMode(mode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *SettingService) parseWeChatConnectOAuthConfig(settings map[string]string) (WeChatConnectOAuthConfig, error) {
|
2026-04-22 12:30:07 +08:00
|
|
|
|
cfg := s.effectiveWeChatConnectOAuthConfig(settings)
|
2026-04-21 17:35:12 +08:00
|
|
|
|
|
2026-04-21 20:36:10 +08:00
|
|
|
|
if !cfg.Enabled || (!cfg.OpenEnabled && !cfg.MPEnabled) {
|
2026-04-21 17:35:12 +08:00
|
|
|
|
return WeChatConnectOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled")
|
|
|
|
|
|
}
|
2026-04-21 07:48:42 -07:00
|
|
|
|
if cfg.OpenEnabled {
|
|
|
|
|
|
if cfg.AppIDForMode("open") == "" {
|
|
|
|
|
|
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app id not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.AppSecretForMode("open") == "" {
|
|
|
|
|
|
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app secret not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.MPEnabled {
|
|
|
|
|
|
if cfg.AppIDForMode("mp") == "" {
|
|
|
|
|
|
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app id not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.AppSecretForMode("mp") == "" {
|
|
|
|
|
|
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app secret not configured")
|
|
|
|
|
|
}
|
2026-04-21 17:35:12 +08:00
|
|
|
|
}
|
2026-04-21 07:48:42 -07:00
|
|
|
|
if cfg.MobileEnabled {
|
|
|
|
|
|
if cfg.AppIDForMode("mobile") == "" {
|
|
|
|
|
|
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app id not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.AppSecretForMode("mobile") == "" {
|
|
|
|
|
|
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app secret not configured")
|
|
|
|
|
|
}
|
2026-04-21 17:35:12 +08:00
|
|
|
|
}
|
2026-04-22 12:30:07 +08:00
|
|
|
|
if v := strings.TrimSpace(cfg.RedirectURL); v != "" {
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(v); err != nil {
|
|
|
|
|
|
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url invalid")
|
|
|
|
|
|
}
|
2026-04-21 17:35:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil {
|
|
|
|
|
|
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth frontend redirect url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
return cfg, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 07:48:42 -07:00
|
|
|
|
func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string]string) (bool, bool, bool, bool) {
|
2026-04-22 12:30:07 +08:00
|
|
|
|
cfg := s.effectiveWeChatConnectOAuthConfig(settings)
|
|
|
|
|
|
if !cfg.Enabled {
|
2026-04-21 07:48:42 -07:00
|
|
|
|
return false, false, false, false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 12:30:07 +08:00
|
|
|
|
openReady := cfg.OpenEnabled && cfg.AppIDForMode("open") != "" && cfg.AppSecretForMode("open") != ""
|
|
|
|
|
|
mpReady := cfg.MPEnabled && cfg.AppIDForMode("mp") != "" && cfg.AppSecretForMode("mp") != ""
|
|
|
|
|
|
mobileReady := cfg.MobileEnabled && cfg.AppIDForMode("mobile") != "" && cfg.AppSecretForMode("mobile") != ""
|
2026-04-21 07:48:42 -07:00
|
|
|
|
|
2026-04-22 11:17:32 +08:00
|
|
|
|
return openReady || mpReady, openReady, mpReady, mobileReady
|
2026-04-21 17:35:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 07:05:01 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 10:13:28 +08:00
|
|
|
|
// 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("[]")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 09:47:27 +08:00
|
|
|
|
// 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.
|
2026-03-03 07:05:01 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 09:47:27 +08:00
|
|
|
|
// home content URL (when home_content is set to a URL for iframe embedding)
|
|
|
|
|
|
addOrigin(settings.HomeContent)
|
|
|
|
|
|
|
2026-03-03 07:05:01 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
2026-03-03 06:21:23 +08:00
|
|
|
|
}
|
2026-03-03 07:05:01 +08:00
|
|
|
|
return urls
|
2026-03-03 06:21:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 14:56:56 +08:00
|
|
|
|
func oidcUsePKCECompatibilityDefault(base config.OIDCConnectConfig) bool {
|
|
|
|
|
|
if base.UsePKCEExplicit {
|
|
|
|
|
|
return base.UsePKCE
|
|
|
|
|
|
}
|
2026-04-22 16:51:23 +08:00
|
|
|
|
return true
|
2026-04-22 14:56:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func oidcValidateIDTokenCompatibilityDefault(base config.OIDCConnectConfig) bool {
|
|
|
|
|
|
if base.ValidateIDTokenExplicit {
|
|
|
|
|
|
return base.ValidateIDToken
|
|
|
|
|
|
}
|
2026-04-22 16:51:23 +08:00
|
|
|
|
return true
|
2026-04-22 14:56:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 17:22:24 +08:00
|
|
|
|
func oidcCompatibilityWriteDefault(base config.OIDCConnectConfig, configured bool, raw string, explicit bool, explicitValue bool) bool {
|
|
|
|
|
|
if configured {
|
|
|
|
|
|
return strings.TrimSpace(raw) == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
if explicit {
|
|
|
|
|
|
return explicitValue
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// UpdateSettings 更新系统设置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error {
|
2026-04-21 02:08:04 +08:00
|
|
|
|
updates, err := s.buildSystemSettingsUpdates(ctx, settings)
|
|
|
|
|
|
if err != nil {
|
2026-03-02 03:41:50 +08:00
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-04-21 02:08:04 +08:00
|
|
|
|
|
|
|
|
|
|
err = s.settingRepo.SetMultiple(ctx, updates)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
s.refreshCachedSettings(settings)
|
|
|
|
|
|
}
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 17:22:24 +08:00
|
|
|
|
func (s *SettingService) OIDCSecurityWriteDefaults(ctx context.Context) (bool, bool, error) {
|
|
|
|
|
|
rawSettings, err := s.settingRepo.GetMultiple(ctx, []string{
|
|
|
|
|
|
SettingKeyOIDCConnectUsePKCE,
|
|
|
|
|
|
SettingKeyOIDCConnectValidateIDToken,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false, false, fmt.Errorf("get oidc security write defaults: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
base := config.OIDCConnectConfig{}
|
|
|
|
|
|
if s != nil && s.cfg != nil {
|
|
|
|
|
|
base = s.cfg.OIDC
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
rawUsePKCE, hasUsePKCE := rawSettings[SettingKeyOIDCConnectUsePKCE]
|
|
|
|
|
|
rawValidateIDToken, hasValidateIDToken := rawSettings[SettingKeyOIDCConnectValidateIDToken]
|
|
|
|
|
|
|
|
|
|
|
|
return oidcCompatibilityWriteDefault(base, hasUsePKCE, rawUsePKCE, base.UsePKCEExplicit, base.UsePKCE),
|
|
|
|
|
|
oidcCompatibilityWriteDefault(base, hasValidateIDToken, rawValidateIDToken, base.ValidateIDTokenExplicit, base.ValidateIDToken),
|
|
|
|
|
|
nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 02:08:04 +08:00
|
|
|
|
// UpdateSettingsWithAuthSourceDefaults persists system settings and auth-source defaults in a single write.
|
|
|
|
|
|
func (s *SettingService) UpdateSettingsWithAuthSourceDefaults(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings) error {
|
|
|
|
|
|
updates, err := s.buildSystemSettingsUpdates(ctx, settings)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
authSourceUpdates, err := s.buildAuthSourceDefaultUpdates(ctx, authDefaults)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
for key, value := range authSourceUpdates {
|
|
|
|
|
|
updates[key] = value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
err = s.settingRepo.SetMultiple(ctx, updates)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
s.refreshCachedSettings(settings)
|
|
|
|
|
|
}
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, settings *SystemSettings) (map[string]string, error) {
|
|
|
|
|
|
if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
2026-03-02 23:13:39 +08:00
|
|
|
|
normalizedWhitelist, err := NormalizeRegistrationEmailSuffixWhitelist(settings.RegistrationEmailSuffixWhitelist)
|
|
|
|
|
|
if err != nil {
|
2026-04-21 02:08:04 +08:00
|
|
|
|
return nil, infraerrors.BadRequest("INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", err.Error())
|
2026-03-02 23:13:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
if normalizedWhitelist == nil {
|
|
|
|
|
|
normalizedWhitelist = []string{}
|
|
|
|
|
|
}
|
|
|
|
|
|
settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist
|
2026-04-21 00:05:17 +08:00
|
|
|
|
alipaySource, err := normalizeVisibleMethodSettingSource("alipay", settings.PaymentVisibleMethodAlipaySource, settings.PaymentVisibleMethodAlipayEnabled)
|
|
|
|
|
|
if err != nil {
|
2026-04-21 02:08:04 +08:00
|
|
|
|
return nil, err
|
2026-04-21 00:05:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
wxpaySource, err := normalizeVisibleMethodSettingSource("wxpay", settings.PaymentVisibleMethodWxpaySource, settings.PaymentVisibleMethodWxpayEnabled)
|
|
|
|
|
|
if err != nil {
|
2026-04-21 02:08:04 +08:00
|
|
|
|
return nil, err
|
2026-04-21 00:05:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
settings.PaymentVisibleMethodAlipaySource = alipaySource
|
|
|
|
|
|
settings.PaymentVisibleMethodWxpaySource = wxpaySource
|
2026-04-21 17:35:12 +08:00
|
|
|
|
settings.WeChatConnectAppID = strings.TrimSpace(settings.WeChatConnectAppID)
|
|
|
|
|
|
settings.WeChatConnectAppSecret = strings.TrimSpace(settings.WeChatConnectAppSecret)
|
2026-04-21 07:48:42 -07:00
|
|
|
|
settings.WeChatConnectOpenAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectOpenAppID, settings.WeChatConnectAppID))
|
|
|
|
|
|
settings.WeChatConnectOpenAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectOpenAppSecret, settings.WeChatConnectAppSecret))
|
|
|
|
|
|
settings.WeChatConnectMPAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMPAppID, settings.WeChatConnectAppID))
|
|
|
|
|
|
settings.WeChatConnectMPAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMPAppSecret, settings.WeChatConnectAppSecret))
|
|
|
|
|
|
settings.WeChatConnectMobileAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMobileAppID, settings.WeChatConnectAppID))
|
|
|
|
|
|
settings.WeChatConnectMobileAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMobileAppSecret, settings.WeChatConnectAppSecret))
|
2026-04-21 20:36:10 +08:00
|
|
|
|
settings.WeChatConnectMode = normalizeWeChatConnectStoredMode(
|
|
|
|
|
|
settings.WeChatConnectOpenEnabled,
|
|
|
|
|
|
settings.WeChatConnectMPEnabled,
|
2026-04-21 07:48:42 -07:00
|
|
|
|
settings.WeChatConnectMobileEnabled,
|
2026-04-21 20:36:10 +08:00
|
|
|
|
settings.WeChatConnectMode,
|
|
|
|
|
|
)
|
2026-04-21 17:35:12 +08:00
|
|
|
|
settings.WeChatConnectScopes = normalizeWeChatConnectScopeSetting(settings.WeChatConnectScopes, settings.WeChatConnectMode)
|
|
|
|
|
|
settings.WeChatConnectRedirectURL = strings.TrimSpace(settings.WeChatConnectRedirectURL)
|
|
|
|
|
|
settings.WeChatConnectFrontendRedirectURL = strings.TrimSpace(settings.WeChatConnectFrontendRedirectURL)
|
|
|
|
|
|
if settings.WeChatConnectFrontendRedirectURL == "" {
|
|
|
|
|
|
settings.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend
|
|
|
|
|
|
}
|
2026-03-02 03:41:50 +08:00
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
updates := make(map[string]string)
|
|
|
|
|
|
|
|
|
|
|
|
// 注册设置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled)
|
|
|
|
|
|
updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled)
|
2026-03-02 23:13:39 +08:00
|
|
|
|
registrationEmailSuffixWhitelistJSON, err := json.Marshal(settings.RegistrationEmailSuffixWhitelist)
|
|
|
|
|
|
if err != nil {
|
2026-04-21 02:08:04 +08:00
|
|
|
|
return nil, fmt.Errorf("marshal registration email suffix whitelist: %w", err)
|
2026-03-02 23:13:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON)
|
2026-01-20 15:56:26 +08:00
|
|
|
|
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
|
2026-01-24 22:33:45 +08:00
|
|
|
|
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
|
2026-03-15 17:52:29 +08:00
|
|
|
|
updates[SettingKeyFrontendURL] = settings.FrontendURL
|
2026-01-29 16:29:59 +08:00
|
|
|
|
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
|
2026-01-26 08:45:43 +08:00
|
|
|
|
updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 邮件服务设置(只有非空才更新密码)
|
2026-01-04 19:27:53 +08:00
|
|
|
|
updates[SettingKeySMTPHost] = settings.SMTPHost
|
|
|
|
|
|
updates[SettingKeySMTPPort] = strconv.Itoa(settings.SMTPPort)
|
|
|
|
|
|
updates[SettingKeySMTPUsername] = settings.SMTPUsername
|
2026-01-04 17:02:38 +08:00
|
|
|
|
if settings.SMTPPassword != "" {
|
2026-01-04 19:27:53 +08:00
|
|
|
|
updates[SettingKeySMTPPassword] = settings.SMTPPassword
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2026-01-04 19:27:53 +08:00
|
|
|
|
updates[SettingKeySMTPFrom] = settings.SMTPFrom
|
|
|
|
|
|
updates[SettingKeySMTPFromName] = settings.SMTPFromName
|
|
|
|
|
|
updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
// Cloudflare Turnstile 设置(只有非空才更新密钥)
|
2025-12-26 15:40:24 +08:00
|
|
|
|
updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled)
|
|
|
|
|
|
updates[SettingKeyTurnstileSiteKey] = settings.TurnstileSiteKey
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if settings.TurnstileSecretKey != "" {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 09:14:32 +08:00
|
|
|
|
// LinuxDo Connect OAuth 登录
|
2026-01-09 13:52:27 +08:00
|
|
|
|
updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled)
|
|
|
|
|
|
updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID
|
|
|
|
|
|
updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL
|
|
|
|
|
|
if settings.LinuxDoConnectClientSecret != "" {
|
|
|
|
|
|
updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 23:38:58 +08:00
|
|
|
|
// Generic OIDC OAuth 登录
|
|
|
|
|
|
updates[SettingKeyOIDCConnectEnabled] = strconv.FormatBool(settings.OIDCConnectEnabled)
|
|
|
|
|
|
updates[SettingKeyOIDCConnectProviderName] = settings.OIDCConnectProviderName
|
|
|
|
|
|
updates[SettingKeyOIDCConnectClientID] = settings.OIDCConnectClientID
|
|
|
|
|
|
updates[SettingKeyOIDCConnectIssuerURL] = settings.OIDCConnectIssuerURL
|
|
|
|
|
|
updates[SettingKeyOIDCConnectDiscoveryURL] = settings.OIDCConnectDiscoveryURL
|
|
|
|
|
|
updates[SettingKeyOIDCConnectAuthorizeURL] = settings.OIDCConnectAuthorizeURL
|
|
|
|
|
|
updates[SettingKeyOIDCConnectTokenURL] = settings.OIDCConnectTokenURL
|
|
|
|
|
|
updates[SettingKeyOIDCConnectUserInfoURL] = settings.OIDCConnectUserInfoURL
|
|
|
|
|
|
updates[SettingKeyOIDCConnectJWKSURL] = settings.OIDCConnectJWKSURL
|
|
|
|
|
|
updates[SettingKeyOIDCConnectScopes] = settings.OIDCConnectScopes
|
|
|
|
|
|
updates[SettingKeyOIDCConnectRedirectURL] = settings.OIDCConnectRedirectURL
|
|
|
|
|
|
updates[SettingKeyOIDCConnectFrontendRedirectURL] = settings.OIDCConnectFrontendRedirectURL
|
|
|
|
|
|
updates[SettingKeyOIDCConnectTokenAuthMethod] = settings.OIDCConnectTokenAuthMethod
|
|
|
|
|
|
updates[SettingKeyOIDCConnectUsePKCE] = strconv.FormatBool(settings.OIDCConnectUsePKCE)
|
|
|
|
|
|
updates[SettingKeyOIDCConnectValidateIDToken] = strconv.FormatBool(settings.OIDCConnectValidateIDToken)
|
|
|
|
|
|
updates[SettingKeyOIDCConnectAllowedSigningAlgs] = settings.OIDCConnectAllowedSigningAlgs
|
|
|
|
|
|
updates[SettingKeyOIDCConnectClockSkewSeconds] = strconv.Itoa(settings.OIDCConnectClockSkewSeconds)
|
|
|
|
|
|
updates[SettingKeyOIDCConnectRequireEmailVerified] = strconv.FormatBool(settings.OIDCConnectRequireEmailVerified)
|
|
|
|
|
|
updates[SettingKeyOIDCConnectUserInfoEmailPath] = settings.OIDCConnectUserInfoEmailPath
|
|
|
|
|
|
updates[SettingKeyOIDCConnectUserInfoIDPath] = settings.OIDCConnectUserInfoIDPath
|
|
|
|
|
|
updates[SettingKeyOIDCConnectUserInfoUsernamePath] = settings.OIDCConnectUserInfoUsernamePath
|
|
|
|
|
|
if settings.OIDCConnectClientSecret != "" {
|
|
|
|
|
|
updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 17:35:12 +08:00
|
|
|
|
// WeChat Connect OAuth 登录
|
|
|
|
|
|
updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled)
|
|
|
|
|
|
updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID
|
2026-04-21 07:48:42 -07:00
|
|
|
|
updates[SettingKeyWeChatConnectOpenAppID] = settings.WeChatConnectOpenAppID
|
|
|
|
|
|
updates[SettingKeyWeChatConnectMPAppID] = settings.WeChatConnectMPAppID
|
|
|
|
|
|
updates[SettingKeyWeChatConnectMobileAppID] = settings.WeChatConnectMobileAppID
|
2026-04-21 20:36:10 +08:00
|
|
|
|
updates[SettingKeyWeChatConnectOpenEnabled] = strconv.FormatBool(settings.WeChatConnectOpenEnabled)
|
|
|
|
|
|
updates[SettingKeyWeChatConnectMPEnabled] = strconv.FormatBool(settings.WeChatConnectMPEnabled)
|
2026-04-21 07:48:42 -07:00
|
|
|
|
updates[SettingKeyWeChatConnectMobileEnabled] = strconv.FormatBool(settings.WeChatConnectMobileEnabled)
|
2026-04-21 17:35:12 +08:00
|
|
|
|
updates[SettingKeyWeChatConnectMode] = settings.WeChatConnectMode
|
|
|
|
|
|
updates[SettingKeyWeChatConnectScopes] = settings.WeChatConnectScopes
|
|
|
|
|
|
updates[SettingKeyWeChatConnectRedirectURL] = settings.WeChatConnectRedirectURL
|
|
|
|
|
|
updates[SettingKeyWeChatConnectFrontendRedirectURL] = settings.WeChatConnectFrontendRedirectURL
|
|
|
|
|
|
if settings.WeChatConnectAppSecret != "" {
|
|
|
|
|
|
updates[SettingKeyWeChatConnectAppSecret] = settings.WeChatConnectAppSecret
|
|
|
|
|
|
}
|
2026-04-21 07:48:42 -07:00
|
|
|
|
if settings.WeChatConnectOpenAppSecret != "" {
|
|
|
|
|
|
updates[SettingKeyWeChatConnectOpenAppSecret] = settings.WeChatConnectOpenAppSecret
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.WeChatConnectMPAppSecret != "" {
|
|
|
|
|
|
updates[SettingKeyWeChatConnectMPAppSecret] = settings.WeChatConnectMPAppSecret
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.WeChatConnectMobileAppSecret != "" {
|
|
|
|
|
|
updates[SettingKeyWeChatConnectMobileAppSecret] = settings.WeChatConnectMobileAppSecret
|
|
|
|
|
|
}
|
2026-04-21 17:35:12 +08:00
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// OEM设置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
updates[SettingKeySiteName] = settings.SiteName
|
|
|
|
|
|
updates[SettingKeySiteLogo] = settings.SiteLogo
|
|
|
|
|
|
updates[SettingKeySiteSubtitle] = settings.SiteSubtitle
|
2026-01-04 19:27:53 +08:00
|
|
|
|
updates[SettingKeyAPIBaseURL] = settings.APIBaseURL
|
2025-12-26 15:40:24 +08:00
|
|
|
|
updates[SettingKeyContactInfo] = settings.ContactInfo
|
2026-01-04 19:27:53 +08:00
|
|
|
|
updates[SettingKeyDocURL] = settings.DocURL
|
2026-01-10 18:37:44 +08:00
|
|
|
|
updates[SettingKeyHomeContent] = settings.HomeContent
|
2026-01-19 19:25:16 +08:00
|
|
|
|
updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton)
|
2026-01-28 13:54:32 +08:00
|
|
|
|
updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled)
|
|
|
|
|
|
updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL)
|
2026-04-09 18:14:28 +08:00
|
|
|
|
tableDefaultPageSize, tablePageSizeOptions := normalizeTablePreferences(
|
|
|
|
|
|
settings.TableDefaultPageSize,
|
|
|
|
|
|
settings.TablePageSizeOptions,
|
|
|
|
|
|
)
|
|
|
|
|
|
updates[SettingKeyTableDefaultPageSize] = strconv.Itoa(tableDefaultPageSize)
|
|
|
|
|
|
tablePageSizeOptionsJSON, err := json.Marshal(tablePageSizeOptions)
|
|
|
|
|
|
if err != nil {
|
2026-04-21 02:08:04 +08:00
|
|
|
|
return nil, fmt.Errorf("marshal table page size options: %w", err)
|
2026-04-09 18:14:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
updates[SettingKeyTablePageSizeOptions] = string(tablePageSizeOptionsJSON)
|
2026-03-02 19:37:40 +08:00
|
|
|
|
updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems
|
2026-03-24 10:13:28 +08:00
|
|
|
|
updates[SettingKeyCustomEndpoints] = settings.CustomEndpoints
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 默认配置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
|
|
|
|
|
|
updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64)
|
2026-03-02 03:41:50 +08:00
|
|
|
|
defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions)
|
|
|
|
|
|
if err != nil {
|
2026-04-21 02:08:04 +08:00
|
|
|
|
return nil, fmt.Errorf("marshal default subscriptions: %w", err)
|
2026-03-02 03:41:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
updates[SettingKeyDefaultSubscriptions] = string(defaultSubsJSON)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-03 06:37:08 -08:00
|
|
|
|
// Model fallback configuration
|
|
|
|
|
|
updates[SettingKeyEnableModelFallback] = strconv.FormatBool(settings.EnableModelFallback)
|
|
|
|
|
|
updates[SettingKeyFallbackModelAnthropic] = settings.FallbackModelAnthropic
|
|
|
|
|
|
updates[SettingKeyFallbackModelOpenAI] = settings.FallbackModelOpenAI
|
|
|
|
|
|
updates[SettingKeyFallbackModelGemini] = settings.FallbackModelGemini
|
|
|
|
|
|
updates[SettingKeyFallbackModelAntigravity] = settings.FallbackModelAntigravity
|
|
|
|
|
|
|
2026-01-04 22:49:40 +08:00
|
|
|
|
// Identity patch configuration (Claude -> Gemini)
|
|
|
|
|
|
updates[SettingKeyEnableIdentityPatch] = strconv.FormatBool(settings.EnableIdentityPatch)
|
|
|
|
|
|
updates[SettingKeyIdentityPatchPrompt] = settings.IdentityPatchPrompt
|
|
|
|
|
|
|
2026-01-09 20:57:32 +08:00
|
|
|
|
// Ops monitoring (vNext)
|
|
|
|
|
|
updates[SettingKeyOpsMonitoringEnabled] = strconv.FormatBool(settings.OpsMonitoringEnabled)
|
|
|
|
|
|
updates[SettingKeyOpsRealtimeMonitoringEnabled] = strconv.FormatBool(settings.OpsRealtimeMonitoringEnabled)
|
|
|
|
|
|
updates[SettingKeyOpsQueryModeDefault] = string(ParseOpsQueryMode(settings.OpsQueryModeDefault))
|
2026-01-10 01:38:47 +08:00
|
|
|
|
if settings.OpsMetricsIntervalSeconds > 0 {
|
|
|
|
|
|
updates[SettingKeyOpsMetricsIntervalSeconds] = strconv.Itoa(settings.OpsMetricsIntervalSeconds)
|
|
|
|
|
|
}
|
2026-01-09 20:57:32 +08:00
|
|
|
|
|
2026-04-21 00:21:29 +08:00
|
|
|
|
// Channel monitor feature switch
|
|
|
|
|
|
updates[SettingKeyChannelMonitorEnabled] = strconv.FormatBool(settings.ChannelMonitorEnabled)
|
|
|
|
|
|
if v := clampChannelMonitorInterval(settings.ChannelMonitorDefaultIntervalSeconds); v > 0 {
|
|
|
|
|
|
updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 17:23:20 +08:00
|
|
|
|
// Available channels feature switch
|
|
|
|
|
|
updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled)
|
|
|
|
|
|
|
2026-03-01 15:35:46 +08:00
|
|
|
|
// Claude Code version check
|
|
|
|
|
|
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
|
2026-03-20 09:10:01 +08:00
|
|
|
|
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
|
2026-03-01 15:35:46 +08:00
|
|
|
|
|
2026-03-03 19:56:27 +08:00
|
|
|
|
// 分组隔离
|
|
|
|
|
|
updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling)
|
|
|
|
|
|
|
2026-03-12 02:42:57 +03:00
|
|
|
|
// Backend Mode
|
|
|
|
|
|
updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled)
|
|
|
|
|
|
|
2026-03-26 10:22:03 +08:00
|
|
|
|
// Gateway forwarding behavior
|
|
|
|
|
|
updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification)
|
|
|
|
|
|
updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough)
|
2026-04-08 16:11:19 +08:00
|
|
|
|
updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning)
|
2026-04-21 00:05:17 +08:00
|
|
|
|
updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource
|
|
|
|
|
|
updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource
|
|
|
|
|
|
updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled)
|
|
|
|
|
|
updates[SettingPaymentVisibleMethodWxpayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodWxpayEnabled)
|
|
|
|
|
|
updates[openAIAdvancedSchedulerSettingKey] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerEnabled)
|
2026-03-26 10:22:03 +08:00
|
|
|
|
|
2026-04-12 02:48:57 +08:00
|
|
|
|
// Balance low notification
|
|
|
|
|
|
updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled)
|
|
|
|
|
|
updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64)
|
2026-04-13 18:39:45 +08:00
|
|
|
|
updates[SettingKeyBalanceLowNotifyRechargeURL] = settings.BalanceLowNotifyRechargeURL
|
2026-04-12 17:49:58 +08:00
|
|
|
|
updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled)
|
2026-04-13 12:07:09 +08:00
|
|
|
|
updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails)
|
2026-04-12 02:48:57 +08:00
|
|
|
|
|
2026-04-21 02:08:04 +08:00
|
|
|
|
return updates, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, settings *AuthSourceDefaultSettings) (map[string]string, error) {
|
|
|
|
|
|
if settings == nil {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, subscriptions := range [][]DefaultSubscriptionSetting{
|
|
|
|
|
|
settings.Email.Subscriptions,
|
|
|
|
|
|
settings.LinuxDo.Subscriptions,
|
|
|
|
|
|
settings.OIDC.Subscriptions,
|
|
|
|
|
|
settings.WeChat.Subscriptions,
|
|
|
|
|
|
} {
|
|
|
|
|
|
if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil {
|
|
|
|
|
|
return nil, err
|
2026-03-01 15:35:46 +08:00
|
|
|
|
}
|
2026-01-10 18:37:44 +08:00
|
|
|
|
}
|
2026-04-21 02:08:04 +08:00
|
|
|
|
|
|
|
|
|
|
updates := make(map[string]string, 21)
|
|
|
|
|
|
writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email)
|
|
|
|
|
|
writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo)
|
|
|
|
|
|
writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC)
|
|
|
|
|
|
writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat)
|
|
|
|
|
|
updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup)
|
|
|
|
|
|
return updates, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *SettingService) refreshCachedSettings(settings *SystemSettings) {
|
|
|
|
|
|
if settings == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口
|
|
|
|
|
|
versionBoundsSF.Forget("version_bounds")
|
|
|
|
|
|
versionBoundsCache.Store(&cachedVersionBounds{
|
|
|
|
|
|
min: settings.MinClaudeCodeVersion,
|
|
|
|
|
|
max: settings.MaxClaudeCodeVersion,
|
|
|
|
|
|
expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(),
|
|
|
|
|
|
})
|
|
|
|
|
|
backendModeSF.Forget("backend_mode")
|
|
|
|
|
|
backendModeCache.Store(&cachedBackendMode{
|
|
|
|
|
|
value: settings.BackendModeEnabled,
|
|
|
|
|
|
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
|
|
|
|
|
|
})
|
|
|
|
|
|
gatewayForwardingSF.Forget("gateway_forwarding")
|
|
|
|
|
|
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
|
|
|
|
|
fingerprintUnification: settings.EnableFingerprintUnification,
|
|
|
|
|
|
metadataPassthrough: settings.EnableMetadataPassthrough,
|
|
|
|
|
|
cchSigning: settings.EnableCCHSigning,
|
|
|
|
|
|
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
|
|
|
|
|
})
|
|
|
|
|
|
openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey)
|
|
|
|
|
|
openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{
|
|
|
|
|
|
enabled: settings.OpenAIAdvancedSchedulerEnabled,
|
|
|
|
|
|
expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(),
|
|
|
|
|
|
})
|
|
|
|
|
|
if s.onUpdate != nil {
|
|
|
|
|
|
s.onUpdate() // Invalidate cache after settings update
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 03:41:50 +08:00
|
|
|
|
func (s *SettingService) validateDefaultSubscriptionGroups(ctx context.Context, items []DefaultSubscriptionSetting) error {
|
|
|
|
|
|
if len(items) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
checked := make(map[int64]struct{}, len(items))
|
|
|
|
|
|
for _, item := range items {
|
|
|
|
|
|
if item.GroupID <= 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := checked[item.GroupID]; ok {
|
|
|
|
|
|
return ErrDefaultSubGroupDuplicate.WithMetadata(map[string]string{
|
|
|
|
|
|
"group_id": strconv.FormatInt(item.GroupID, 10),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
checked[item.GroupID] = struct{}{}
|
|
|
|
|
|
if s.defaultSubGroupReader == nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
group, err := s.defaultSubGroupReader.GetByID(ctx, item.GroupID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if errors.Is(err, ErrGroupNotFound) {
|
|
|
|
|
|
return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{
|
|
|
|
|
|
"group_id": strconv.FormatInt(item.GroupID, 10),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Errorf("get default subscription group %d: %w", item.GroupID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !group.IsSubscriptionType() {
|
|
|
|
|
|
return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{
|
|
|
|
|
|
"group_id": strconv.FormatInt(item.GroupID, 10),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// IsRegistrationEnabled 检查是否开放注册
|
|
|
|
|
|
func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
2026-01-12 09:14:32 +08:00
|
|
|
|
// 安全默认:如果设置不存在或查询出错,默认关闭注册
|
|
|
|
|
|
return false
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 02:42:57 +03:00
|
|
|
|
// IsBackendModeEnabled checks if backend mode is enabled
|
|
|
|
|
|
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path
|
|
|
|
|
|
func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
|
|
|
|
|
|
if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil {
|
|
|
|
|
|
if time.Now().UnixNano() < cached.expiresAt {
|
|
|
|
|
|
return cached.value
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
result, _, _ := backendModeSF.Do("backend_mode", func() (any, error) {
|
|
|
|
|
|
if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil {
|
|
|
|
|
|
if time.Now().UnixNano() < cached.expiresAt {
|
|
|
|
|
|
return cached.value, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), backendModeDBTimeout)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyBackendModeEnabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if errors.Is(err, ErrSettingNotFound) {
|
|
|
|
|
|
// Setting not yet created (fresh install) - default to disabled with full TTL
|
|
|
|
|
|
backendModeCache.Store(&cachedBackendMode{
|
|
|
|
|
|
value: false,
|
|
|
|
|
|
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
|
|
|
|
|
|
})
|
|
|
|
|
|
return false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
slog.Warn("failed to get backend_mode_enabled setting", "error", err)
|
|
|
|
|
|
backendModeCache.Store(&cachedBackendMode{
|
|
|
|
|
|
value: false,
|
|
|
|
|
|
expiresAt: time.Now().Add(backendModeErrorTTL).UnixNano(),
|
|
|
|
|
|
})
|
|
|
|
|
|
return false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
enabled := value == "true"
|
|
|
|
|
|
backendModeCache.Store(&cachedBackendMode{
|
|
|
|
|
|
value: enabled,
|
|
|
|
|
|
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
|
|
|
|
|
|
})
|
|
|
|
|
|
return enabled, nil
|
|
|
|
|
|
})
|
|
|
|
|
|
if val, ok := result.(bool); ok {
|
|
|
|
|
|
return val
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 10:22:03 +08:00
|
|
|
|
// GetGatewayForwardingSettings returns cached gateway forwarding settings.
|
|
|
|
|
|
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path.
|
2026-04-08 16:11:19 +08:00
|
|
|
|
// Returns (fingerprintUnification, metadataPassthrough, cchSigning).
|
|
|
|
|
|
func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough, cchSigning bool) {
|
2026-03-26 10:22:03 +08:00
|
|
|
|
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
|
|
|
|
|
if time.Now().UnixNano() < cached.expiresAt {
|
2026-04-08 16:11:19 +08:00
|
|
|
|
return cached.fingerprintUnification, cached.metadataPassthrough, cached.cchSigning
|
2026-03-26 10:22:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
type gwfResult struct {
|
2026-04-08 16:11:19 +08:00
|
|
|
|
fp, mp, cch bool
|
2026-03-26 10:22:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) {
|
|
|
|
|
|
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
|
|
|
|
|
if time.Now().UnixNano() < cached.expiresAt {
|
2026-04-08 16:11:19 +08:00
|
|
|
|
return gwfResult{cached.fingerprintUnification, cached.metadataPassthrough, cached.cchSigning}, nil
|
2026-03-26 10:22:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
|
|
|
|
|
|
SettingKeyEnableFingerprintUnification,
|
|
|
|
|
|
SettingKeyEnableMetadataPassthrough,
|
2026-04-08 16:11:19 +08:00
|
|
|
|
SettingKeyEnableCCHSigning,
|
2026-03-26 10:22:03 +08:00
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
slog.Warn("failed to get gateway forwarding settings", "error", err)
|
|
|
|
|
|
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
|
|
|
|
|
fingerprintUnification: true,
|
|
|
|
|
|
metadataPassthrough: false,
|
2026-04-08 16:11:19 +08:00
|
|
|
|
cchSigning: false,
|
2026-03-26 10:22:03 +08:00
|
|
|
|
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
|
|
|
|
|
|
})
|
2026-04-08 16:11:19 +08:00
|
|
|
|
return gwfResult{true, false, false}, nil
|
2026-03-26 10:22:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
fp := true
|
|
|
|
|
|
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
|
|
|
|
|
fp = v == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
mp := values[SettingKeyEnableMetadataPassthrough] == "true"
|
2026-04-08 16:11:19 +08:00
|
|
|
|
cch := values[SettingKeyEnableCCHSigning] == "true"
|
2026-03-26 10:22:03 +08:00
|
|
|
|
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
|
|
|
|
|
fingerprintUnification: fp,
|
|
|
|
|
|
metadataPassthrough: mp,
|
2026-04-08 16:11:19 +08:00
|
|
|
|
cchSigning: cch,
|
2026-03-26 10:22:03 +08:00
|
|
|
|
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
|
|
|
|
|
})
|
2026-04-08 16:11:19 +08:00
|
|
|
|
return gwfResult{fp, mp, cch}, nil
|
2026-03-26 10:22:03 +08:00
|
|
|
|
})
|
|
|
|
|
|
if r, ok := val.(gwfResult); ok {
|
2026-04-08 16:11:19 +08:00
|
|
|
|
return r.fp, r.mp, r.cch
|
2026-03-26 10:22:03 +08:00
|
|
|
|
}
|
2026-04-08 16:11:19 +08:00
|
|
|
|
return true, false, false // fail-open defaults
|
2026-03-26 10:22:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// IsEmailVerifyEnabled 检查是否开启邮件验证
|
|
|
|
|
|
func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 23:13:39 +08:00
|
|
|
|
// GetRegistrationEmailSuffixWhitelist returns normalized registration email suffix whitelist.
|
|
|
|
|
|
func (s *SettingService) GetRegistrationEmailSuffixWhitelist(ctx context.Context) []string {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailSuffixWhitelist)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return []string{}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ParseRegistrationEmailSuffixWhitelist(value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-20 15:56:26 +08:00
|
|
|
|
// IsPromoCodeEnabled 检查是否启用优惠码功能
|
|
|
|
|
|
func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return true // 默认启用
|
|
|
|
|
|
}
|
|
|
|
|
|
return value != "false"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 16:29:59 +08:00
|
|
|
|
// IsInvitationCodeEnabled 检查是否启用邀请码注册功能
|
|
|
|
|
|
func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyInvitationCodeEnabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false // 默认关闭
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-24 22:33:45 +08:00
|
|
|
|
// IsPasswordResetEnabled 检查是否启用密码重置功能
|
|
|
|
|
|
// 要求:必须同时开启邮件验证
|
|
|
|
|
|
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
|
|
|
|
|
|
// Password reset requires email verification to be enabled
|
|
|
|
|
|
if !s.IsEmailVerifyEnabled(ctx) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyPasswordResetEnabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false // 默认关闭
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-26 08:45:43 +08:00
|
|
|
|
// IsTotpEnabled 检查是否启用 TOTP 双因素认证功能
|
|
|
|
|
|
func (s *SettingService) IsTotpEnabled(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyTotpEnabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false // 默认关闭
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置
|
|
|
|
|
|
// 只有手动配置了密钥才允许在管理后台启用 TOTP 功能
|
|
|
|
|
|
func (s *SettingService) IsTotpEncryptionKeyConfigured() bool {
|
|
|
|
|
|
return s.cfg.Totp.EncryptionKeyConfigured
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// GetSiteName 获取网站名称
|
|
|
|
|
|
func (s *SettingService) GetSiteName(ctx context.Context) string {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil || value == "" {
|
|
|
|
|
|
return "Sub2API"
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetDefaultConcurrency 获取默认并发量
|
|
|
|
|
|
func (s *SettingService) GetDefaultConcurrency(ctx context.Context) int {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultConcurrency)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.cfg.Default.UserConcurrency
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, err := strconv.Atoi(value); err == nil && v > 0 {
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.cfg.Default.UserConcurrency
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetDefaultBalance 获取默认余额
|
|
|
|
|
|
func (s *SettingService) GetDefaultBalance(ctx context.Context) float64 {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultBalance)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.cfg.Default.UserBalance
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 {
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.cfg.Default.UserBalance
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 03:41:50 +08:00
|
|
|
|
// GetDefaultSubscriptions 获取新用户默认订阅配置列表。
|
|
|
|
|
|
func (s *SettingService) GetDefaultSubscriptions(ctx context.Context) []DefaultSubscriptionSetting {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultSubscriptions)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return parseDefaultSubscriptions(value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 17:39:57 +08:00
|
|
|
|
func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*AuthSourceDefaultSettings, error) {
|
|
|
|
|
|
keys := []string{
|
|
|
|
|
|
SettingKeyAuthSourceDefaultEmailBalance,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultEmailConcurrency,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultEmailSubscriptions,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultEmailGrantOnSignup,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultEmailGrantOnFirstBind,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultLinuxDoBalance,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultLinuxDoConcurrency,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultLinuxDoSubscriptions,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultOIDCBalance,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultOIDCConcurrency,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultOIDCSubscriptions,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultOIDCGrantOnSignup,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultWeChatBalance,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultWeChatConcurrency,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultWeChatSubscriptions,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
|
|
|
|
|
|
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
|
|
|
|
|
|
SettingKeyForceEmailOnThirdPartySignup,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get auth source default settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &AuthSourceDefaultSettings{
|
|
|
|
|
|
Email: parseProviderDefaultGrantSettings(settings, emailAuthSourceDefaultKeys),
|
|
|
|
|
|
LinuxDo: parseProviderDefaultGrantSettings(settings, linuxDoAuthSourceDefaultKeys),
|
|
|
|
|
|
OIDC: parseProviderDefaultGrantSettings(settings, oidcAuthSourceDefaultKeys),
|
|
|
|
|
|
WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys),
|
|
|
|
|
|
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 02:08:04 +08:00
|
|
|
|
func (s *SettingService) ResolveAuthSourceGrantSettings(ctx context.Context, signupSource string, firstBind bool) (ProviderDefaultGrantSettings, bool, error) {
|
|
|
|
|
|
result := ProviderDefaultGrantSettings{
|
|
|
|
|
|
Balance: s.GetDefaultBalance(ctx),
|
|
|
|
|
|
Concurrency: s.GetDefaultConcurrency(ctx),
|
|
|
|
|
|
Subscriptions: s.GetDefaultSubscriptions(ctx),
|
2026-04-20 17:39:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 02:08:04 +08:00
|
|
|
|
defaults, err := s.GetAuthSourceDefaultSettings(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return result, false, err
|
2026-04-20 17:39:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 02:08:04 +08:00
|
|
|
|
providerDefaults, ok := authSourceSignupSettings(defaults, signupSource)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return result, false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
enabled := providerDefaults.GrantOnSignup
|
|
|
|
|
|
if firstBind {
|
|
|
|
|
|
enabled = providerDefaults.GrantOnFirstBind
|
|
|
|
|
|
}
|
|
|
|
|
|
if !enabled {
|
|
|
|
|
|
return result, false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return mergeProviderDefaultGrantSettings(result, providerDefaults), true, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *SettingService) UpdateAuthSourceDefaultSettings(ctx context.Context, settings *AuthSourceDefaultSettings) error {
|
|
|
|
|
|
updates, err := s.buildAuthSourceDefaultUpdates(ctx, settings)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(updates) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-04-20 17:39:57 +08:00
|
|
|
|
|
|
|
|
|
|
if err := s.settingRepo.SetMultiple(ctx, updates); err != nil {
|
|
|
|
|
|
return fmt.Errorf("update auth source default settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// InitializeDefaultSettings 初始化默认设置
|
|
|
|
|
|
func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|
|
|
|
|
// 检查是否已有设置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
_, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err == nil {
|
|
|
|
|
|
// 已有设置,不需要初始化
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2025-12-25 20:52:47 +08:00
|
|
|
|
if !errors.Is(err, ErrSettingNotFound) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return fmt.Errorf("check existing settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 14:56:56 +08:00
|
|
|
|
oidcUsePKCEDefault := true
|
|
|
|
|
|
oidcValidateIDTokenDefault := true
|
|
|
|
|
|
if s != nil && s.cfg != nil {
|
|
|
|
|
|
if s.cfg.OIDC.UsePKCEExplicit {
|
|
|
|
|
|
oidcUsePKCEDefault = s.cfg.OIDC.UsePKCE
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.cfg.OIDC.ValidateIDTokenExplicit {
|
|
|
|
|
|
oidcValidateIDTokenDefault = s.cfg.OIDC.ValidateIDToken
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 初始化默认设置
|
|
|
|
|
|
defaults := map[string]string{
|
2026-04-20 17:39:57 +08:00
|
|
|
|
SettingKeyRegistrationEnabled: "true",
|
|
|
|
|
|
SettingKeyEmailVerifyEnabled: "false",
|
|
|
|
|
|
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
|
|
|
|
|
|
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
|
|
|
|
|
|
SettingKeySiteName: "Sub2API",
|
|
|
|
|
|
SettingKeySiteLogo: "",
|
|
|
|
|
|
SettingKeyPurchaseSubscriptionEnabled: "false",
|
|
|
|
|
|
SettingKeyPurchaseSubscriptionURL: "",
|
|
|
|
|
|
SettingKeyTableDefaultPageSize: "20",
|
|
|
|
|
|
SettingKeyTablePageSizeOptions: "[10,20,50,100]",
|
|
|
|
|
|
SettingKeyCustomMenuItems: "[]",
|
|
|
|
|
|
SettingKeyCustomEndpoints: "[]",
|
2026-04-21 17:35:12 +08:00
|
|
|
|
SettingKeyWeChatConnectEnabled: "false",
|
2026-04-22 12:30:07 +08:00
|
|
|
|
SettingKeyWeChatConnectAppID: "",
|
|
|
|
|
|
SettingKeyWeChatConnectAppSecret: "",
|
2026-04-21 07:48:42 -07:00
|
|
|
|
SettingKeyWeChatConnectOpenAppID: "",
|
|
|
|
|
|
SettingKeyWeChatConnectOpenAppSecret: "",
|
|
|
|
|
|
SettingKeyWeChatConnectMPAppID: "",
|
|
|
|
|
|
SettingKeyWeChatConnectMPAppSecret: "",
|
|
|
|
|
|
SettingKeyWeChatConnectMobileAppID: "",
|
|
|
|
|
|
SettingKeyWeChatConnectMobileAppSecret: "",
|
2026-04-21 20:36:10 +08:00
|
|
|
|
SettingKeyWeChatConnectOpenEnabled: "false",
|
|
|
|
|
|
SettingKeyWeChatConnectMPEnabled: "false",
|
2026-04-21 07:48:42 -07:00
|
|
|
|
SettingKeyWeChatConnectMobileEnabled: "false",
|
2026-04-21 17:35:12 +08:00
|
|
|
|
SettingKeyWeChatConnectMode: "open",
|
|
|
|
|
|
SettingKeyWeChatConnectScopes: "snsapi_login",
|
2026-04-22 12:30:07 +08:00
|
|
|
|
SettingKeyWeChatConnectRedirectURL: "",
|
2026-04-21 17:35:12 +08:00
|
|
|
|
SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend,
|
2026-04-20 17:39:57 +08:00
|
|
|
|
SettingKeyOIDCConnectEnabled: "false",
|
|
|
|
|
|
SettingKeyOIDCConnectProviderName: "OIDC",
|
2026-04-22 12:30:07 +08:00
|
|
|
|
SettingKeyOIDCConnectClientID: "",
|
|
|
|
|
|
SettingKeyOIDCConnectClientSecret: "",
|
|
|
|
|
|
SettingKeyOIDCConnectIssuerURL: "",
|
|
|
|
|
|
SettingKeyOIDCConnectDiscoveryURL: "",
|
|
|
|
|
|
SettingKeyOIDCConnectAuthorizeURL: "",
|
|
|
|
|
|
SettingKeyOIDCConnectTokenURL: "",
|
|
|
|
|
|
SettingKeyOIDCConnectUserInfoURL: "",
|
|
|
|
|
|
SettingKeyOIDCConnectJWKSURL: "",
|
|
|
|
|
|
SettingKeyOIDCConnectScopes: "openid email profile",
|
|
|
|
|
|
SettingKeyOIDCConnectRedirectURL: "",
|
|
|
|
|
|
SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback",
|
|
|
|
|
|
SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post",
|
2026-04-22 14:56:56 +08:00
|
|
|
|
SettingKeyOIDCConnectUsePKCE: strconv.FormatBool(oidcUsePKCEDefault),
|
|
|
|
|
|
SettingKeyOIDCConnectValidateIDToken: strconv.FormatBool(oidcValidateIDTokenDefault),
|
2026-04-22 12:30:07 +08:00
|
|
|
|
SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256",
|
|
|
|
|
|
SettingKeyOIDCConnectClockSkewSeconds: "120",
|
|
|
|
|
|
SettingKeyOIDCConnectRequireEmailVerified: "false",
|
|
|
|
|
|
SettingKeyOIDCConnectUserInfoEmailPath: "",
|
|
|
|
|
|
SettingKeyOIDCConnectUserInfoIDPath: "",
|
|
|
|
|
|
SettingKeyOIDCConnectUserInfoUsernamePath: "",
|
2026-04-20 17:39:57 +08:00
|
|
|
|
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
|
|
|
|
|
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
|
|
|
|
|
SettingKeyDefaultSubscriptions: "[]",
|
|
|
|
|
|
SettingKeyAuthSourceDefaultEmailBalance: "0",
|
|
|
|
|
|
SettingKeyAuthSourceDefaultEmailConcurrency: "5",
|
|
|
|
|
|
SettingKeyAuthSourceDefaultEmailSubscriptions: "[]",
|
2026-04-21 20:36:10 +08:00
|
|
|
|
SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false",
|
2026-04-20 17:39:57 +08:00
|
|
|
|
SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "false",
|
|
|
|
|
|
SettingKeyAuthSourceDefaultLinuxDoBalance: "0",
|
|
|
|
|
|
SettingKeyAuthSourceDefaultLinuxDoConcurrency: "5",
|
|
|
|
|
|
SettingKeyAuthSourceDefaultLinuxDoSubscriptions: "[]",
|
2026-04-21 20:36:10 +08:00
|
|
|
|
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup: "false",
|
2026-04-20 17:39:57 +08:00
|
|
|
|
SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind: "false",
|
|
|
|
|
|
SettingKeyAuthSourceDefaultOIDCBalance: "0",
|
|
|
|
|
|
SettingKeyAuthSourceDefaultOIDCConcurrency: "5",
|
|
|
|
|
|
SettingKeyAuthSourceDefaultOIDCSubscriptions: "[]",
|
2026-04-21 20:36:10 +08:00
|
|
|
|
SettingKeyAuthSourceDefaultOIDCGrantOnSignup: "false",
|
2026-04-20 17:39:57 +08:00
|
|
|
|
SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind: "false",
|
|
|
|
|
|
SettingKeyAuthSourceDefaultWeChatBalance: "0",
|
|
|
|
|
|
SettingKeyAuthSourceDefaultWeChatConcurrency: "5",
|
|
|
|
|
|
SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]",
|
2026-04-21 20:36:10 +08:00
|
|
|
|
SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false",
|
2026-04-20 17:39:57 +08:00
|
|
|
|
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false",
|
|
|
|
|
|
SettingKeyForceEmailOnThirdPartySignup: "false",
|
|
|
|
|
|
SettingKeySMTPPort: "587",
|
|
|
|
|
|
SettingKeySMTPUseTLS: "false",
|
2026-01-03 06:37:08 -08:00
|
|
|
|
// Model fallback defaults
|
|
|
|
|
|
SettingKeyEnableModelFallback: "false",
|
|
|
|
|
|
SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022",
|
|
|
|
|
|
SettingKeyFallbackModelOpenAI: "gpt-4o",
|
|
|
|
|
|
SettingKeyFallbackModelGemini: "gemini-2.5-pro",
|
|
|
|
|
|
SettingKeyFallbackModelAntigravity: "gemini-2.5-pro",
|
2026-01-04 22:49:40 +08:00
|
|
|
|
// Identity patch defaults
|
|
|
|
|
|
SettingKeyEnableIdentityPatch: "true",
|
|
|
|
|
|
SettingKeyIdentityPatchPrompt: "",
|
2026-01-09 20:57:32 +08:00
|
|
|
|
|
|
|
|
|
|
// Ops monitoring defaults (vNext)
|
|
|
|
|
|
SettingKeyOpsMonitoringEnabled: "true",
|
|
|
|
|
|
SettingKeyOpsRealtimeMonitoringEnabled: "true",
|
|
|
|
|
|
SettingKeyOpsQueryModeDefault: "auto",
|
2026-01-10 01:38:47 +08:00
|
|
|
|
SettingKeyOpsMetricsIntervalSeconds: "60",
|
2026-03-01 15:35:46 +08:00
|
|
|
|
|
2026-04-21 00:21:29 +08:00
|
|
|
|
// Channel monitor defaults (enabled, 60s)
|
|
|
|
|
|
SettingKeyChannelMonitorEnabled: "true",
|
|
|
|
|
|
SettingKeyChannelMonitorDefaultIntervalSeconds: "60",
|
|
|
|
|
|
|
2026-04-21 17:23:20 +08:00
|
|
|
|
// Available channels feature (default disabled; opt-in)
|
|
|
|
|
|
SettingKeyAvailableChannelsEnabled: "false",
|
|
|
|
|
|
|
2026-03-01 15:35:46 +08:00
|
|
|
|
// Claude Code version check (default: empty = disabled)
|
|
|
|
|
|
SettingKeyMinClaudeCodeVersion: "",
|
2026-03-20 09:10:01 +08:00
|
|
|
|
SettingKeyMaxClaudeCodeVersion: "",
|
2026-03-03 19:56:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 分组隔离(默认不允许未分组 Key 调度)
|
2026-04-21 00:05:17 +08:00
|
|
|
|
SettingKeyAllowUngroupedKeyScheduling: "false",
|
|
|
|
|
|
SettingPaymentVisibleMethodAlipaySource: "",
|
|
|
|
|
|
SettingPaymentVisibleMethodWxpaySource: "",
|
|
|
|
|
|
SettingPaymentVisibleMethodAlipayEnabled: "false",
|
|
|
|
|
|
SettingPaymentVisibleMethodWxpayEnabled: "false",
|
|
|
|
|
|
openAIAdvancedSchedulerSettingKey: "false",
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.settingRepo.SetMultiple(ctx, defaults)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parseSettings 解析设置到结构体
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings {
|
2026-01-24 22:33:45 +08:00
|
|
|
|
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
2025-12-26 15:40:24 +08:00
|
|
|
|
result := &SystemSettings{
|
2026-03-02 23:13:39 +08:00
|
|
|
|
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
|
|
|
|
|
EmailVerifyEnabled: emailVerifyEnabled,
|
|
|
|
|
|
RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]),
|
|
|
|
|
|
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
|
|
|
|
|
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
|
2026-03-15 17:52:29 +08:00
|
|
|
|
FrontendURL: settings[SettingKeyFrontendURL],
|
2026-03-02 23:13:39 +08:00
|
|
|
|
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
|
|
|
|
|
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
|
|
|
|
|
SMTPHost: settings[SettingKeySMTPHost],
|
|
|
|
|
|
SMTPUsername: settings[SettingKeySMTPUsername],
|
|
|
|
|
|
SMTPFrom: settings[SettingKeySMTPFrom],
|
|
|
|
|
|
SMTPFromName: settings[SettingKeySMTPFromName],
|
|
|
|
|
|
SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true",
|
|
|
|
|
|
SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "",
|
|
|
|
|
|
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
|
|
|
|
|
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
|
|
|
|
|
TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "",
|
|
|
|
|
|
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],
|
|
|
|
|
|
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
|
|
|
|
|
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
|
|
|
|
|
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
|
|
|
|
|
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
2026-03-24 10:13:28 +08:00
|
|
|
|
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
2026-03-12 02:42:57 +03:00
|
|
|
|
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2026-04-09 18:14:28 +08:00
|
|
|
|
result.TableDefaultPageSize, result.TablePageSizeOptions = parseTablePreferences(
|
|
|
|
|
|
settings[SettingKeyTableDefaultPageSize],
|
|
|
|
|
|
settings[SettingKeyTablePageSizeOptions],
|
|
|
|
|
|
)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 解析整数类型
|
2026-01-04 19:27:53 +08:00
|
|
|
|
if port, err := strconv.Atoi(settings[SettingKeySMTPPort]); err == nil {
|
2026-01-04 17:02:38 +08:00
|
|
|
|
result.SMTPPort = port
|
2025-12-18 13:50:39 +08:00
|
|
|
|
} else {
|
2026-01-04 17:02:38 +08:00
|
|
|
|
result.SMTPPort = 587
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
if concurrency, err := strconv.Atoi(settings[SettingKeyDefaultConcurrency]); err == nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
result.DefaultConcurrency = concurrency
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.DefaultConcurrency = s.cfg.Default.UserConcurrency
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析浮点数类型
|
2025-12-26 15:40:24 +08:00
|
|
|
|
if balance, err := strconv.ParseFloat(settings[SettingKeyDefaultBalance], 64); err == nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
result.DefaultBalance = balance
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.DefaultBalance = s.cfg.Default.UserBalance
|
|
|
|
|
|
}
|
2026-03-02 03:41:50 +08:00
|
|
|
|
result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions])
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-03 06:37:08 -08:00
|
|
|
|
// 敏感信息直接返回,方便测试连接时使用
|
2026-01-04 19:27:53 +08:00
|
|
|
|
result.SMTPPassword = settings[SettingKeySMTPPassword]
|
2025-12-26 15:40:24 +08:00
|
|
|
|
result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey]
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-09 19:32:06 +08:00
|
|
|
|
// LinuxDo Connect 设置:
|
|
|
|
|
|
// - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭)
|
|
|
|
|
|
// - 支持在后台“系统设置”中覆盖并持久化(存储于 DB)
|
2026-01-09 13:52:27 +08:00
|
|
|
|
linuxDoBase := config.LinuxDoConnectConfig{}
|
|
|
|
|
|
if s.cfg != nil {
|
|
|
|
|
|
linuxDoBase = s.cfg.LinuxDo
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
|
|
|
|
|
|
result.LinuxDoConnectEnabled = raw == "true"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.LinuxDoConnectEnabled = linuxDoBase.Enabled
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.LinuxDoConnectClientID = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.LinuxDoConnectClientID = linuxDoBase.ClientID
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.LinuxDoConnectRedirectURL = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.LinuxDoConnectRedirectURL = linuxDoBase.RedirectURL
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result.LinuxDoConnectClientSecret = strings.TrimSpace(settings[SettingKeyLinuxDoConnectClientSecret])
|
|
|
|
|
|
if result.LinuxDoConnectClientSecret == "" {
|
|
|
|
|
|
result.LinuxDoConnectClientSecret = strings.TrimSpace(linuxDoBase.ClientSecret)
|
|
|
|
|
|
}
|
|
|
|
|
|
result.LinuxDoConnectClientSecretConfigured = result.LinuxDoConnectClientSecret != ""
|
|
|
|
|
|
|
2026-03-13 23:38:58 +08:00
|
|
|
|
// Generic OIDC 设置:
|
|
|
|
|
|
// - 兼容 config.yaml/env
|
|
|
|
|
|
// - 支持后台系统设置覆盖并持久化(存储于 DB)
|
|
|
|
|
|
oidcBase := config.OIDCConnectConfig{}
|
|
|
|
|
|
if s.cfg != nil {
|
|
|
|
|
|
oidcBase = s.cfg.OIDC
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
|
|
|
|
|
|
result.OIDCConnectEnabled = raw == "true"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectEnabled = oidcBase.Enabled
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.OIDCConnectProviderName = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectProviderName = strings.TrimSpace(oidcBase.ProviderName)
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.OIDCConnectProviderName == "" {
|
|
|
|
|
|
result.OIDCConnectProviderName = "OIDC"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.OIDCConnectClientID = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectClientID = strings.TrimSpace(oidcBase.ClientID)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.OIDCConnectIssuerURL = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectIssuerURL = strings.TrimSpace(oidcBase.IssuerURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.OIDCConnectDiscoveryURL = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectDiscoveryURL = strings.TrimSpace(oidcBase.DiscoveryURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.OIDCConnectAuthorizeURL = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectAuthorizeURL = strings.TrimSpace(oidcBase.AuthorizeURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.OIDCConnectTokenURL = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectTokenURL = strings.TrimSpace(oidcBase.TokenURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.OIDCConnectUserInfoURL = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectUserInfoURL = strings.TrimSpace(oidcBase.UserInfoURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.OIDCConnectJWKSURL = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectJWKSURL = strings.TrimSpace(oidcBase.JWKSURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.OIDCConnectScopes = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectScopes = strings.TrimSpace(oidcBase.Scopes)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.OIDCConnectRedirectURL = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectRedirectURL = strings.TrimSpace(oidcBase.RedirectURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(oidcBase.FrontendRedirectURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(v))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(oidcBase.TokenAuthMethod))
|
|
|
|
|
|
}
|
|
|
|
|
|
if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok {
|
|
|
|
|
|
result.OIDCConnectUsePKCE = raw == "true"
|
|
|
|
|
|
} else {
|
2026-04-22 14:56:56 +08:00
|
|
|
|
result.OIDCConnectUsePKCE = oidcUsePKCECompatibilityDefault(oidcBase)
|
2026-03-13 23:38:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok {
|
|
|
|
|
|
result.OIDCConnectValidateIDToken = raw == "true"
|
|
|
|
|
|
} else {
|
2026-04-22 14:56:56 +08:00
|
|
|
|
result.OIDCConnectValidateIDToken = oidcValidateIDTokenCompatibilityDefault(oidcBase)
|
2026-03-13 23:38:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(oidcBase.AllowedSigningAlgs)
|
|
|
|
|
|
}
|
|
|
|
|
|
clockSkewSet := false
|
|
|
|
|
|
if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" {
|
|
|
|
|
|
if parsed, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil {
|
|
|
|
|
|
result.OIDCConnectClockSkewSeconds = parsed
|
|
|
|
|
|
clockSkewSet = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !clockSkewSet {
|
|
|
|
|
|
result.OIDCConnectClockSkewSeconds = oidcBase.ClockSkewSeconds
|
|
|
|
|
|
}
|
|
|
|
|
|
if !clockSkewSet && result.OIDCConnectClockSkewSeconds == 0 {
|
|
|
|
|
|
result.OIDCConnectClockSkewSeconds = 120
|
|
|
|
|
|
}
|
|
|
|
|
|
if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok {
|
|
|
|
|
|
result.OIDCConnectRequireEmailVerified = raw == "true"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectRequireEmailVerified = oidcBase.RequireEmailVerified
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok {
|
|
|
|
|
|
result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(oidcBase.UserInfoEmailPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok {
|
|
|
|
|
|
result.OIDCConnectUserInfoIDPath = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectUserInfoIDPath = strings.TrimSpace(oidcBase.UserInfoIDPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok {
|
|
|
|
|
|
result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(oidcBase.UserInfoUsernamePath)
|
|
|
|
|
|
}
|
|
|
|
|
|
result.OIDCConnectClientSecret = strings.TrimSpace(settings[SettingKeyOIDCConnectClientSecret])
|
|
|
|
|
|
if result.OIDCConnectClientSecret == "" {
|
|
|
|
|
|
result.OIDCConnectClientSecret = strings.TrimSpace(oidcBase.ClientSecret)
|
|
|
|
|
|
}
|
|
|
|
|
|
result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != ""
|
|
|
|
|
|
|
2026-04-22 12:30:07 +08:00
|
|
|
|
// WeChat Connect 设置:
|
|
|
|
|
|
// - 优先读取 DB 系统设置
|
|
|
|
|
|
// - 缺失时回退到 config/env,保持升级兼容
|
|
|
|
|
|
weChatEffective := s.effectiveWeChatConnectOAuthConfig(settings)
|
|
|
|
|
|
result.WeChatConnectEnabled = weChatEffective.Enabled
|
|
|
|
|
|
result.WeChatConnectAppID = weChatEffective.LegacyAppID
|
|
|
|
|
|
result.WeChatConnectAppSecret = weChatEffective.LegacyAppSecret
|
|
|
|
|
|
result.WeChatConnectAppSecretConfigured = weChatEffective.LegacyAppSecret != ""
|
|
|
|
|
|
result.WeChatConnectOpenAppID = weChatEffective.OpenAppID
|
|
|
|
|
|
result.WeChatConnectOpenAppSecret = weChatEffective.OpenAppSecret
|
|
|
|
|
|
result.WeChatConnectOpenAppSecretConfigured = weChatEffective.OpenAppSecret != ""
|
|
|
|
|
|
result.WeChatConnectMPAppID = weChatEffective.MPAppID
|
|
|
|
|
|
result.WeChatConnectMPAppSecret = weChatEffective.MPAppSecret
|
|
|
|
|
|
result.WeChatConnectMPAppSecretConfigured = weChatEffective.MPAppSecret != ""
|
|
|
|
|
|
result.WeChatConnectMobileAppID = weChatEffective.MobileAppID
|
|
|
|
|
|
result.WeChatConnectMobileAppSecret = weChatEffective.MobileAppSecret
|
|
|
|
|
|
result.WeChatConnectMobileAppSecretConfigured = weChatEffective.MobileAppSecret != ""
|
|
|
|
|
|
result.WeChatConnectOpenEnabled = weChatEffective.OpenEnabled
|
|
|
|
|
|
result.WeChatConnectMPEnabled = weChatEffective.MPEnabled
|
|
|
|
|
|
result.WeChatConnectMobileEnabled = weChatEffective.MobileEnabled
|
|
|
|
|
|
result.WeChatConnectMode = weChatEffective.Mode
|
|
|
|
|
|
result.WeChatConnectScopes = weChatEffective.Scopes
|
|
|
|
|
|
result.WeChatConnectRedirectURL = weChatEffective.RedirectURL
|
|
|
|
|
|
result.WeChatConnectFrontendRedirectURL = weChatEffective.FrontendRedirectURL
|
2026-04-21 17:35:12 +08:00
|
|
|
|
|
2026-01-03 06:37:08 -08:00
|
|
|
|
// Model fallback settings
|
|
|
|
|
|
result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true"
|
|
|
|
|
|
result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022")
|
|
|
|
|
|
result.FallbackModelOpenAI = s.getStringOrDefault(settings, SettingKeyFallbackModelOpenAI, "gpt-4o")
|
|
|
|
|
|
result.FallbackModelGemini = s.getStringOrDefault(settings, SettingKeyFallbackModelGemini, "gemini-2.5-pro")
|
|
|
|
|
|
result.FallbackModelAntigravity = s.getStringOrDefault(settings, SettingKeyFallbackModelAntigravity, "gemini-2.5-pro")
|
|
|
|
|
|
|
2026-01-04 22:49:40 +08:00
|
|
|
|
// Identity patch settings (default: enabled, to preserve existing behavior)
|
|
|
|
|
|
if v, ok := settings[SettingKeyEnableIdentityPatch]; ok && v != "" {
|
|
|
|
|
|
result.EnableIdentityPatch = v == "true"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.EnableIdentityPatch = true
|
|
|
|
|
|
}
|
|
|
|
|
|
result.IdentityPatchPrompt = settings[SettingKeyIdentityPatchPrompt]
|
|
|
|
|
|
|
2026-01-09 20:57:32 +08:00
|
|
|
|
// Ops monitoring settings (default: enabled, fail-open)
|
|
|
|
|
|
result.OpsMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsMonitoringEnabled])
|
|
|
|
|
|
result.OpsRealtimeMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsRealtimeMonitoringEnabled])
|
|
|
|
|
|
result.OpsQueryModeDefault = string(ParseOpsQueryMode(settings[SettingKeyOpsQueryModeDefault]))
|
2026-01-10 01:38:47 +08:00
|
|
|
|
result.OpsMetricsIntervalSeconds = 60
|
|
|
|
|
|
if raw := strings.TrimSpace(settings[SettingKeyOpsMetricsIntervalSeconds]); raw != "" {
|
|
|
|
|
|
if v, err := strconv.Atoi(raw); err == nil {
|
|
|
|
|
|
if v < 60 {
|
|
|
|
|
|
v = 60
|
|
|
|
|
|
}
|
|
|
|
|
|
if v > 3600 {
|
|
|
|
|
|
v = 3600
|
|
|
|
|
|
}
|
|
|
|
|
|
result.OpsMetricsIntervalSeconds = v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-09 20:57:32 +08:00
|
|
|
|
|
2026-04-21 00:21:29 +08:00
|
|
|
|
// Channel monitor feature (default: enabled, 60s)
|
|
|
|
|
|
result.ChannelMonitorEnabled = !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled])
|
|
|
|
|
|
result.ChannelMonitorDefaultIntervalSeconds = parseChannelMonitorInterval(
|
|
|
|
|
|
settings[SettingKeyChannelMonitorDefaultIntervalSeconds],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-21 17:23:20 +08:00
|
|
|
|
// Available channels feature (default: disabled; strict true)
|
|
|
|
|
|
result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true"
|
|
|
|
|
|
|
2026-03-01 15:35:46 +08:00
|
|
|
|
// Claude Code version check
|
|
|
|
|
|
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
|
2026-03-20 09:10:01 +08:00
|
|
|
|
result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion]
|
2026-03-01 15:35:46 +08:00
|
|
|
|
|
2026-03-03 19:56:27 +08:00
|
|
|
|
// 分组隔离
|
|
|
|
|
|
result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true"
|
|
|
|
|
|
|
2026-04-08 16:11:19 +08:00
|
|
|
|
// Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false, cch_signing=false)
|
2026-03-26 10:22:03 +08:00
|
|
|
|
if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
|
|
|
|
|
result.EnableFingerprintUnification = v == "true"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.EnableFingerprintUnification = true // default: enabled (current behavior)
|
|
|
|
|
|
}
|
|
|
|
|
|
result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true"
|
2026-04-08 16:11:19 +08:00
|
|
|
|
result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true"
|
2026-03-26 10:22:03 +08:00
|
|
|
|
|
2026-04-12 14:43:12 +08:00
|
|
|
|
// Web search emulation: quick enabled check from the JSON config
|
|
|
|
|
|
if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" {
|
|
|
|
|
|
var wsCfg WebSearchEmulationConfig
|
|
|
|
|
|
if err := json.Unmarshal([]byte(raw), &wsCfg); err == nil {
|
|
|
|
|
|
result.WebSearchEmulationEnabled = wsCfg.Enabled && len(wsCfg.Providers) > 0
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-21 00:05:17 +08:00
|
|
|
|
result.PaymentVisibleMethodAlipaySource = NormalizeVisibleMethodSource("alipay", settings[SettingPaymentVisibleMethodAlipaySource])
|
|
|
|
|
|
result.PaymentVisibleMethodWxpaySource = NormalizeVisibleMethodSource("wxpay", settings[SettingPaymentVisibleMethodWxpaySource])
|
|
|
|
|
|
result.PaymentVisibleMethodAlipayEnabled = settings[SettingPaymentVisibleMethodAlipayEnabled] == "true"
|
|
|
|
|
|
result.PaymentVisibleMethodWxpayEnabled = settings[SettingPaymentVisibleMethodWxpayEnabled] == "true"
|
|
|
|
|
|
result.OpenAIAdvancedSchedulerEnabled = settings[openAIAdvancedSchedulerSettingKey] == "true"
|
2026-04-12 14:43:12 +08:00
|
|
|
|
|
2026-04-12 02:48:57 +08:00
|
|
|
|
// Balance low notification
|
|
|
|
|
|
result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true"
|
|
|
|
|
|
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
|
|
|
|
|
|
result.BalanceLowNotifyThreshold = v
|
|
|
|
|
|
}
|
2026-04-13 18:39:45 +08:00
|
|
|
|
result.BalanceLowNotifyRechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL]
|
2026-04-12 02:48:57 +08:00
|
|
|
|
|
2026-04-12 17:49:58 +08:00
|
|
|
|
// Account quota notification
|
|
|
|
|
|
result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true"
|
2026-04-12 02:48:57 +08:00
|
|
|
|
if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" {
|
2026-04-13 00:52:42 +08:00
|
|
|
|
result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw)
|
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
|
|
|
|
}
|
2026-04-12 02:48:57 +08:00
|
|
|
|
if result.AccountQuotaNotifyEmails == nil {
|
2026-04-13 00:52:42 +08:00
|
|
|
|
result.AccountQuotaNotifyEmails = []NotifyEmailEntry{}
|
2026-04-12 02:48:57 +08:00
|
|
|
|
}
|
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 20:57:32 +08:00
|
|
|
|
func isFalseSettingValue(value string) bool {
|
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
|
|
|
|
case "false", "0", "off", "disabled":
|
|
|
|
|
|
return true
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 00:05:17 +08:00
|
|
|
|
func normalizeVisibleMethodSettingSource(method, source string, enabled bool) (string, error) {
|
2026-04-22 13:18:10 +08:00
|
|
|
|
_ = enabled
|
2026-04-21 00:05:17 +08:00
|
|
|
|
source = strings.TrimSpace(source)
|
|
|
|
|
|
if source == "" {
|
|
|
|
|
|
return "", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
normalized := NormalizeVisibleMethodSource(method, source)
|
|
|
|
|
|
if normalized == "" {
|
|
|
|
|
|
return "", infraerrors.BadRequest(
|
|
|
|
|
|
"INVALID_PAYMENT_VISIBLE_METHOD_SOURCE",
|
|
|
|
|
|
fmt.Sprintf("%s source must be one of the supported payment providers", method),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return normalized, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 03:41:50 +08:00
|
|
|
|
func parseDefaultSubscriptions(raw string) []DefaultSubscriptionSetting {
|
|
|
|
|
|
raw = strings.TrimSpace(raw)
|
|
|
|
|
|
if raw == "" {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var items []DefaultSubscriptionSetting
|
|
|
|
|
|
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
normalized := make([]DefaultSubscriptionSetting, 0, len(items))
|
|
|
|
|
|
for _, item := range items {
|
|
|
|
|
|
if item.GroupID <= 0 || item.ValidityDays <= 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if item.ValidityDays > MaxValidityDays {
|
|
|
|
|
|
item.ValidityDays = MaxValidityDays
|
|
|
|
|
|
}
|
|
|
|
|
|
normalized = append(normalized, item)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 17:39:57 +08:00
|
|
|
|
func parseProviderDefaultGrantSettings(settings map[string]string, keys authSourceDefaultKeySet) ProviderDefaultGrantSettings {
|
|
|
|
|
|
result := ProviderDefaultGrantSettings{
|
|
|
|
|
|
Balance: defaultAuthSourceBalance,
|
|
|
|
|
|
Concurrency: defaultAuthSourceConcurrency,
|
|
|
|
|
|
Subscriptions: []DefaultSubscriptionSetting{},
|
2026-04-21 20:36:10 +08:00
|
|
|
|
GrantOnSignup: false,
|
2026-04-20 17:39:57 +08:00
|
|
|
|
GrantOnFirstBind: false,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if v, err := strconv.ParseFloat(strings.TrimSpace(settings[keys.balance]), 64); err == nil {
|
|
|
|
|
|
result.Balance = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, err := strconv.Atoi(strings.TrimSpace(settings[keys.concurrency])); err == nil {
|
|
|
|
|
|
result.Concurrency = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if items := parseDefaultSubscriptions(settings[keys.subscriptions]); items != nil {
|
|
|
|
|
|
result.Subscriptions = items
|
|
|
|
|
|
}
|
|
|
|
|
|
if raw, ok := settings[keys.grantOnSignup]; ok {
|
|
|
|
|
|
result.GrantOnSignup = raw == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
if raw, ok := settings[keys.grantOnFirstBind]; ok {
|
|
|
|
|
|
result.GrantOnFirstBind = raw == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func writeProviderDefaultGrantUpdates(updates map[string]string, keys authSourceDefaultKeySet, settings ProviderDefaultGrantSettings) {
|
|
|
|
|
|
updates[keys.balance] = strconv.FormatFloat(settings.Balance, 'f', 8, 64)
|
|
|
|
|
|
updates[keys.concurrency] = strconv.Itoa(settings.Concurrency)
|
|
|
|
|
|
|
|
|
|
|
|
subscriptions := settings.Subscriptions
|
|
|
|
|
|
if subscriptions == nil {
|
|
|
|
|
|
subscriptions = []DefaultSubscriptionSetting{}
|
|
|
|
|
|
}
|
|
|
|
|
|
raw, err := json.Marshal(subscriptions)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
raw = []byte("[]")
|
|
|
|
|
|
}
|
|
|
|
|
|
updates[keys.subscriptions] = string(raw)
|
|
|
|
|
|
updates[keys.grantOnSignup] = strconv.FormatBool(settings.GrantOnSignup)
|
|
|
|
|
|
updates[keys.grantOnFirstBind] = strconv.FormatBool(settings.GrantOnFirstBind)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 02:08:04 +08:00
|
|
|
|
func mergeProviderDefaultGrantSettings(globalDefaults ProviderDefaultGrantSettings, providerDefaults ProviderDefaultGrantSettings) ProviderDefaultGrantSettings {
|
|
|
|
|
|
result := ProviderDefaultGrantSettings{
|
|
|
|
|
|
Balance: globalDefaults.Balance,
|
|
|
|
|
|
Concurrency: globalDefaults.Concurrency,
|
|
|
|
|
|
Subscriptions: append([]DefaultSubscriptionSetting(nil), globalDefaults.Subscriptions...),
|
|
|
|
|
|
GrantOnSignup: providerDefaults.GrantOnSignup,
|
|
|
|
|
|
GrantOnFirstBind: providerDefaults.GrantOnFirstBind,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if providerDefaults.Balance != defaultAuthSourceBalance {
|
|
|
|
|
|
result.Balance = providerDefaults.Balance
|
|
|
|
|
|
}
|
|
|
|
|
|
if providerDefaults.Concurrency > 0 && providerDefaults.Concurrency != defaultAuthSourceConcurrency {
|
|
|
|
|
|
result.Concurrency = providerDefaults.Concurrency
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(providerDefaults.Subscriptions) > 0 {
|
|
|
|
|
|
result.Subscriptions = append([]DefaultSubscriptionSetting(nil), providerDefaults.Subscriptions...)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 18:14:28 +08:00
|
|
|
|
func parseTablePreferences(defaultPageSizeRaw, optionsRaw string) (int, []int) {
|
|
|
|
|
|
defaultPageSize := 20
|
|
|
|
|
|
if v, err := strconv.Atoi(strings.TrimSpace(defaultPageSizeRaw)); err == nil {
|
|
|
|
|
|
defaultPageSize = v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var options []int
|
|
|
|
|
|
if strings.TrimSpace(optionsRaw) != "" {
|
|
|
|
|
|
_ = json.Unmarshal([]byte(optionsRaw), &options)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return normalizeTablePreferences(defaultPageSize, options)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeTablePreferences(defaultPageSize int, options []int) (int, []int) {
|
|
|
|
|
|
const minPageSize = 5
|
|
|
|
|
|
const maxPageSize = 1000
|
|
|
|
|
|
const fallbackPageSize = 20
|
|
|
|
|
|
|
|
|
|
|
|
seen := make(map[int]struct{}, len(options))
|
|
|
|
|
|
normalizedOptions := make([]int, 0, len(options))
|
|
|
|
|
|
for _, option := range options {
|
|
|
|
|
|
if option < minPageSize || option > maxPageSize {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := seen[option]; ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[option] = struct{}{}
|
|
|
|
|
|
normalizedOptions = append(normalizedOptions, option)
|
|
|
|
|
|
}
|
|
|
|
|
|
sort.Ints(normalizedOptions)
|
|
|
|
|
|
|
|
|
|
|
|
if defaultPageSize < minPageSize || defaultPageSize > maxPageSize {
|
|
|
|
|
|
defaultPageSize = fallbackPageSize
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(normalizedOptions) == 0 {
|
|
|
|
|
|
normalizedOptions = []int{10, 20, 50}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return defaultPageSize, normalizedOptions
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// getStringOrDefault 获取字符串值或默认值
|
|
|
|
|
|
func (s *SettingService) getStringOrDefault(settings map[string]string, key, defaultValue string) string {
|
|
|
|
|
|
if value, ok := settings[key]; ok && value != "" {
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
return defaultValue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsTurnstileEnabled 检查是否启用 Turnstile 验证
|
|
|
|
|
|
func (s *SettingService) IsTurnstileEnabled(ctx context.Context) bool {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileEnabled)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetTurnstileSecretKey 获取 Turnstile Secret Key
|
|
|
|
|
|
func (s *SettingService) GetTurnstileSecretKey(ctx context.Context) string {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileSecretKey)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
2025-12-20 15:11:43 +08:00
|
|
|
|
|
2026-01-04 22:49:40 +08:00
|
|
|
|
// IsIdentityPatchEnabled 检查是否启用身份补丁(Claude -> Gemini systemInstruction 注入)
|
|
|
|
|
|
func (s *SettingService) IsIdentityPatchEnabled(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableIdentityPatch)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
// 默认开启,保持兼容
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetIdentityPatchPrompt 获取自定义身份补丁提示词(为空表示使用内置默认模板)
|
|
|
|
|
|
func (s *SettingService) GetIdentityPatchPrompt(ctx context.Context) string {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyIdentityPatchPrompt)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:27:53 +08:00
|
|
|
|
// GenerateAdminAPIKey 生成新的管理员 API Key
|
|
|
|
|
|
func (s *SettingService) GenerateAdminAPIKey(ctx context.Context) (string, error) {
|
2025-12-20 15:11:43 +08:00
|
|
|
|
// 生成 32 字节随机数 = 64 位十六进制字符
|
|
|
|
|
|
bytes := make([]byte, 32)
|
|
|
|
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("generate random bytes: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:27:53 +08:00
|
|
|
|
key := AdminAPIKeyPrefix + hex.EncodeToString(bytes)
|
2025-12-20 15:11:43 +08:00
|
|
|
|
|
|
|
|
|
|
// 存储到 settings 表
|
2026-01-04 19:27:53 +08:00
|
|
|
|
if err := s.settingRepo.Set(ctx, SettingKeyAdminAPIKey, key); err != nil {
|
2025-12-20 15:11:43 +08:00
|
|
|
|
return "", fmt.Errorf("save admin api key: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return key, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:27:53 +08:00
|
|
|
|
// GetAdminAPIKeyStatus 获取管理员 API Key 状态
|
2025-12-20 15:11:43 +08:00
|
|
|
|
// 返回脱敏的 key、是否存在、错误
|
2026-01-04 19:27:53 +08:00
|
|
|
|
func (s *SettingService) GetAdminAPIKeyStatus(ctx context.Context) (maskedKey string, exists bool, err error) {
|
|
|
|
|
|
key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey)
|
2025-12-20 15:11:43 +08:00
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
if errors.Is(err, ErrSettingNotFound) {
|
2025-12-20 15:11:43 +08:00
|
|
|
|
return "", false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return "", false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if key == "" {
|
|
|
|
|
|
return "", false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 脱敏:显示前 10 位和后 4 位
|
|
|
|
|
|
if len(key) > 14 {
|
|
|
|
|
|
maskedKey = key[:10] + "..." + key[len(key)-4:]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
maskedKey = key
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return maskedKey, true, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:27:53 +08:00
|
|
|
|
// GetAdminAPIKey 获取完整的管理员 API Key(仅供内部验证使用)
|
2025-12-20 15:11:43 +08:00
|
|
|
|
// 如果未配置返回空字符串和 nil 错误,只有数据库错误时才返回 error
|
2026-01-04 19:27:53 +08:00
|
|
|
|
func (s *SettingService) GetAdminAPIKey(ctx context.Context) (string, error) {
|
|
|
|
|
|
key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey)
|
2025-12-20 15:11:43 +08:00
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
if errors.Is(err, ErrSettingNotFound) {
|
2025-12-20 15:11:43 +08:00
|
|
|
|
return "", nil // 未配置,返回空字符串
|
|
|
|
|
|
}
|
|
|
|
|
|
return "", err // 数据库错误
|
|
|
|
|
|
}
|
|
|
|
|
|
return key, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:27:53 +08:00
|
|
|
|
// DeleteAdminAPIKey 删除管理员 API Key
|
|
|
|
|
|
func (s *SettingService) DeleteAdminAPIKey(ctx context.Context) error {
|
|
|
|
|
|
return s.settingRepo.Delete(ctx, SettingKeyAdminAPIKey)
|
2026-01-03 06:37:08 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsModelFallbackEnabled 检查是否启用模型兜底机制
|
|
|
|
|
|
func (s *SettingService) IsModelFallbackEnabled(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableModelFallback)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false // Default: disabled
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetFallbackModel 获取指定平台的兜底模型
|
|
|
|
|
|
func (s *SettingService) GetFallbackModel(ctx context.Context, platform string) string {
|
|
|
|
|
|
var key string
|
|
|
|
|
|
var defaultModel string
|
|
|
|
|
|
|
|
|
|
|
|
switch platform {
|
|
|
|
|
|
case PlatformAnthropic:
|
|
|
|
|
|
key = SettingKeyFallbackModelAnthropic
|
|
|
|
|
|
defaultModel = "claude-3-5-sonnet-20241022"
|
|
|
|
|
|
case PlatformOpenAI:
|
|
|
|
|
|
key = SettingKeyFallbackModelOpenAI
|
|
|
|
|
|
defaultModel = "gpt-4o"
|
|
|
|
|
|
case PlatformGemini:
|
|
|
|
|
|
key = SettingKeyFallbackModelGemini
|
|
|
|
|
|
defaultModel = "gemini-2.5-pro"
|
|
|
|
|
|
case PlatformAntigravity:
|
|
|
|
|
|
key = SettingKeyFallbackModelAntigravity
|
|
|
|
|
|
defaultModel = "gemini-2.5-pro"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, key)
|
|
|
|
|
|
if err != nil || value == "" {
|
|
|
|
|
|
return defaultModel
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
2025-12-20 15:11:43 +08:00
|
|
|
|
}
|
2026-01-11 10:59:01 +08:00
|
|
|
|
|
|
|
|
|
|
// GetLinuxDoConnectOAuthConfig 返回用于登录的"最终生效" LinuxDo Connect 配置。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 优先级:
|
|
|
|
|
|
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
|
|
|
|
|
|
// - 否则回退到 config.yaml/env 的值
|
|
|
|
|
|
func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
|
|
|
|
|
|
if s == nil || s.cfg == nil {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
effective := s.cfg.LinuxDo
|
|
|
|
|
|
|
|
|
|
|
|
keys := []string{
|
|
|
|
|
|
SettingKeyLinuxDoConnectEnabled,
|
|
|
|
|
|
SettingKeyLinuxDoConnectClientID,
|
|
|
|
|
|
SettingKeyLinuxDoConnectClientSecret,
|
|
|
|
|
|
SettingKeyLinuxDoConnectRedirectURL,
|
|
|
|
|
|
}
|
|
|
|
|
|
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, fmt.Errorf("get linuxdo connect settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
|
|
|
|
|
|
effective.Enabled = raw == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.ClientID = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyLinuxDoConnectClientSecret]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.ClientSecret = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.RedirectURL = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !effective.Enabled {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。
|
|
|
|
|
|
if strings.TrimSpace(effective.ClientID) == "" {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.AuthorizeURL) == "" {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.TokenURL) == "" {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.UserInfoURL) == "" {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.RedirectURL) == "" {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.FrontendRedirectURL) == "" {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod))
|
|
|
|
|
|
switch method {
|
|
|
|
|
|
case "", "client_secret_post", "client_secret_basic":
|
|
|
|
|
|
if strings.TrimSpace(effective.ClientSecret) == "" {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
case "none":
|
|
|
|
|
|
default:
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return effective, nil
|
|
|
|
|
|
}
|
2026-01-11 21:54:52 -08:00
|
|
|
|
|
2026-04-21 17:35:12 +08:00
|
|
|
|
// GetWeChatConnectOAuthConfig 返回用于登录的最终生效 WeChat Connect 配置。
|
|
|
|
|
|
//
|
|
|
|
|
|
// WeChat Connect 已回归 DB 系统设置模型,不再回退到 config/env。
|
|
|
|
|
|
func (s *SettingService) GetWeChatConnectOAuthConfig(ctx context.Context) (WeChatConnectOAuthConfig, error) {
|
|
|
|
|
|
keys := []string{
|
|
|
|
|
|
SettingKeyWeChatConnectEnabled,
|
|
|
|
|
|
SettingKeyWeChatConnectAppID,
|
|
|
|
|
|
SettingKeyWeChatConnectAppSecret,
|
2026-04-21 07:48:42 -07:00
|
|
|
|
SettingKeyWeChatConnectOpenAppID,
|
|
|
|
|
|
SettingKeyWeChatConnectOpenAppSecret,
|
|
|
|
|
|
SettingKeyWeChatConnectMPAppID,
|
|
|
|
|
|
SettingKeyWeChatConnectMPAppSecret,
|
|
|
|
|
|
SettingKeyWeChatConnectMobileAppID,
|
|
|
|
|
|
SettingKeyWeChatConnectMobileAppSecret,
|
2026-04-21 20:36:10 +08:00
|
|
|
|
SettingKeyWeChatConnectOpenEnabled,
|
|
|
|
|
|
SettingKeyWeChatConnectMPEnabled,
|
2026-04-21 07:48:42 -07:00
|
|
|
|
SettingKeyWeChatConnectMobileEnabled,
|
2026-04-21 17:35:12 +08:00
|
|
|
|
SettingKeyWeChatConnectMode,
|
|
|
|
|
|
SettingKeyWeChatConnectScopes,
|
|
|
|
|
|
SettingKeyWeChatConnectRedirectURL,
|
|
|
|
|
|
SettingKeyWeChatConnectFrontendRedirectURL,
|
|
|
|
|
|
}
|
|
|
|
|
|
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return WeChatConnectOAuthConfig{}, fmt.Errorf("get wechat connect settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.parseWeChatConnectOAuthConfig(settings)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 16:22:19 +08:00
|
|
|
|
// GetOverloadCooldownSettings 获取529过载冷却配置
|
|
|
|
|
|
func (s *SettingService) GetOverloadCooldownSettings(ctx context.Context) (*OverloadCooldownSettings, error) {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyOverloadCooldownSettings)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if errors.Is(err, ErrSettingNotFound) {
|
|
|
|
|
|
return DefaultOverloadCooldownSettings(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("get overload cooldown settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
return DefaultOverloadCooldownSettings(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var settings OverloadCooldownSettings
|
|
|
|
|
|
if err := json.Unmarshal([]byte(value), &settings); err != nil {
|
|
|
|
|
|
return DefaultOverloadCooldownSettings(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 修正配置值范围
|
|
|
|
|
|
if settings.CooldownMinutes < 1 {
|
|
|
|
|
|
settings.CooldownMinutes = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.CooldownMinutes > 120 {
|
|
|
|
|
|
settings.CooldownMinutes = 120
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &settings, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SetOverloadCooldownSettings 设置529过载冷却配置
|
|
|
|
|
|
func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settings *OverloadCooldownSettings) error {
|
|
|
|
|
|
if settings == nil {
|
|
|
|
|
|
return fmt.Errorf("settings cannot be nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 禁用时修正为合法值即可,不拒绝请求
|
|
|
|
|
|
if settings.CooldownMinutes < 1 || settings.CooldownMinutes > 120 {
|
|
|
|
|
|
if settings.Enabled {
|
|
|
|
|
|
return fmt.Errorf("cooldown_minutes must be between 1-120")
|
|
|
|
|
|
}
|
|
|
|
|
|
settings.CooldownMinutes = 10 // 禁用状态下归一化为默认值
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
data, err := json.Marshal(settings)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("marshal overload cooldown settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 23:38:58 +08:00
|
|
|
|
// GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 优先级:
|
|
|
|
|
|
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
|
|
|
|
|
|
// - 否则回退到 config.yaml/env 的值
|
|
|
|
|
|
func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) {
|
|
|
|
|
|
if s == nil || s.cfg == nil {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
effective := s.cfg.OIDC
|
|
|
|
|
|
|
|
|
|
|
|
keys := []string{
|
|
|
|
|
|
SettingKeyOIDCConnectEnabled,
|
|
|
|
|
|
SettingKeyOIDCConnectProviderName,
|
|
|
|
|
|
SettingKeyOIDCConnectClientID,
|
|
|
|
|
|
SettingKeyOIDCConnectClientSecret,
|
|
|
|
|
|
SettingKeyOIDCConnectIssuerURL,
|
|
|
|
|
|
SettingKeyOIDCConnectDiscoveryURL,
|
|
|
|
|
|
SettingKeyOIDCConnectAuthorizeURL,
|
|
|
|
|
|
SettingKeyOIDCConnectTokenURL,
|
|
|
|
|
|
SettingKeyOIDCConnectUserInfoURL,
|
|
|
|
|
|
SettingKeyOIDCConnectJWKSURL,
|
|
|
|
|
|
SettingKeyOIDCConnectScopes,
|
|
|
|
|
|
SettingKeyOIDCConnectRedirectURL,
|
|
|
|
|
|
SettingKeyOIDCConnectFrontendRedirectURL,
|
|
|
|
|
|
SettingKeyOIDCConnectTokenAuthMethod,
|
|
|
|
|
|
SettingKeyOIDCConnectUsePKCE,
|
|
|
|
|
|
SettingKeyOIDCConnectValidateIDToken,
|
|
|
|
|
|
SettingKeyOIDCConnectAllowedSigningAlgs,
|
|
|
|
|
|
SettingKeyOIDCConnectClockSkewSeconds,
|
|
|
|
|
|
SettingKeyOIDCConnectRequireEmailVerified,
|
|
|
|
|
|
SettingKeyOIDCConnectUserInfoEmailPath,
|
|
|
|
|
|
SettingKeyOIDCConnectUserInfoIDPath,
|
|
|
|
|
|
SettingKeyOIDCConnectUserInfoUsernamePath,
|
|
|
|
|
|
}
|
|
|
|
|
|
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, fmt.Errorf("get oidc connect settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
|
|
|
|
|
|
effective.Enabled = raw == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.ProviderName = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.ClientID = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectClientSecret]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.ClientSecret = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.IssuerURL = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.DiscoveryURL = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.AuthorizeURL = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.TokenURL = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.UserInfoURL = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.JWKSURL = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.Scopes = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.RedirectURL = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.FrontendRedirectURL = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.TokenAuthMethod = strings.ToLower(strings.TrimSpace(v))
|
|
|
|
|
|
}
|
|
|
|
|
|
if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok {
|
|
|
|
|
|
effective.UsePKCE = raw == "true"
|
2026-04-22 14:56:56 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
effective.UsePKCE = oidcUsePKCECompatibilityDefault(effective)
|
2026-03-13 23:38:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok {
|
|
|
|
|
|
effective.ValidateIDToken = raw == "true"
|
2026-04-22 14:56:56 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
effective.ValidateIDToken = oidcValidateIDTokenCompatibilityDefault(effective)
|
2026-03-13 23:38:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.AllowedSigningAlgs = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" {
|
|
|
|
|
|
if parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil {
|
|
|
|
|
|
effective.ClockSkewSeconds = parsed
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok {
|
|
|
|
|
|
effective.RequireEmailVerified = raw == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok {
|
|
|
|
|
|
effective.UserInfoEmailPath = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok {
|
|
|
|
|
|
effective.UserInfoIDPath = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok {
|
|
|
|
|
|
effective.UserInfoUsernamePath = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if !effective.Enabled {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.ProviderName) == "" {
|
|
|
|
|
|
effective.ProviderName = "OIDC"
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.ClientID) == "" {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.IssuerURL) == "" {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.RedirectURL) == "" {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.FrontendRedirectURL) == "" {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !scopesContainOpenID(effective.Scopes) {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth scopes must contain openid")
|
|
|
|
|
|
}
|
|
|
|
|
|
if effective.ClockSkewSeconds < 0 || effective.ClockSkewSeconds > 600 {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth clock skew must be between 0 and 600")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(effective.IssuerURL); err != nil {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
discoveryURL := strings.TrimSpace(effective.DiscoveryURL)
|
|
|
|
|
|
if discoveryURL == "" {
|
|
|
|
|
|
discoveryURL = oidcDefaultDiscoveryURL(effective.IssuerURL)
|
|
|
|
|
|
effective.DiscoveryURL = discoveryURL
|
|
|
|
|
|
}
|
|
|
|
|
|
if discoveryURL != "" {
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(discoveryURL); err != nil {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
needsDiscovery := strings.TrimSpace(effective.AuthorizeURL) == "" ||
|
|
|
|
|
|
strings.TrimSpace(effective.TokenURL) == "" ||
|
|
|
|
|
|
(effective.ValidateIDToken && strings.TrimSpace(effective.JWKSURL) == "")
|
|
|
|
|
|
if needsDiscovery && discoveryURL != "" {
|
|
|
|
|
|
metadata, resolveErr := oidcResolveProviderMetadata(ctx, discoveryURL)
|
|
|
|
|
|
if resolveErr != nil {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery resolve failed").WithCause(resolveErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.AuthorizeURL) == "" {
|
|
|
|
|
|
effective.AuthorizeURL = strings.TrimSpace(metadata.AuthorizationEndpoint)
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.TokenURL) == "" {
|
|
|
|
|
|
effective.TokenURL = strings.TrimSpace(metadata.TokenEndpoint)
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.UserInfoURL) == "" {
|
|
|
|
|
|
effective.UserInfoURL = strings.TrimSpace(metadata.UserInfoEndpoint)
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.JWKSURL) == "" {
|
|
|
|
|
|
effective.JWKSURL = strings.TrimSpace(metadata.JWKSURI)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(effective.AuthorizeURL) == "" {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.TokenURL) == "" {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := strings.TrimSpace(effective.UserInfoURL); v != "" {
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(v); err != nil {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if effective.ValidateIDToken {
|
|
|
|
|
|
if strings.TrimSpace(effective.JWKSURL) == "" {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.AllowedSigningAlgs) == "" {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth signing algs not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := strings.TrimSpace(effective.JWKSURL); v != "" {
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(v); err != nil {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod))
|
|
|
|
|
|
switch method {
|
|
|
|
|
|
case "", "client_secret_post", "client_secret_basic":
|
|
|
|
|
|
if strings.TrimSpace(effective.ClientSecret) == "" {
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
case "none":
|
|
|
|
|
|
default:
|
|
|
|
|
|
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return effective, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func scopesContainOpenID(scopes string) bool {
|
|
|
|
|
|
for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) {
|
|
|
|
|
|
if scope == "openid" {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type oidcProviderMetadata struct {
|
|
|
|
|
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
|
|
|
|
|
TokenEndpoint string `json:"token_endpoint"`
|
|
|
|
|
|
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
|
|
|
|
|
JWKSURI string `json:"jwks_uri"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func oidcDefaultDiscoveryURL(issuerURL string) string {
|
|
|
|
|
|
issuerURL = strings.TrimSpace(issuerURL)
|
|
|
|
|
|
if issuerURL == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func oidcResolveProviderMetadata(ctx context.Context, discoveryURL string) (*oidcProviderMetadata, error) {
|
|
|
|
|
|
discoveryURL = strings.TrimSpace(discoveryURL)
|
|
|
|
|
|
if discoveryURL == "" {
|
|
|
|
|
|
return nil, fmt.Errorf("discovery url is empty")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
resp, err := req.C().
|
|
|
|
|
|
SetTimeout(15*time.Second).
|
|
|
|
|
|
R().
|
|
|
|
|
|
SetContext(ctx).
|
|
|
|
|
|
SetHeader("Accept", "application/json").
|
|
|
|
|
|
Get(discoveryURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("request discovery document: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !resp.IsSuccessState() {
|
|
|
|
|
|
return nil, fmt.Errorf("discovery request failed: status=%d", resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
metadata := &oidcProviderMetadata{}
|
|
|
|
|
|
if err := json.Unmarshal(resp.Bytes(), metadata); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("parse discovery document: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return metadata, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 21:54:52 -08:00
|
|
|
|
// GetStreamTimeoutSettings 获取流超时处理配置
|
|
|
|
|
|
func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamTimeoutSettings, error) {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyStreamTimeoutSettings)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if errors.Is(err, ErrSettingNotFound) {
|
|
|
|
|
|
return DefaultStreamTimeoutSettings(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("get stream timeout settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
return DefaultStreamTimeoutSettings(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var settings StreamTimeoutSettings
|
|
|
|
|
|
if err := json.Unmarshal([]byte(value), &settings); err != nil {
|
|
|
|
|
|
return DefaultStreamTimeoutSettings(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证并修正配置值
|
|
|
|
|
|
if settings.TempUnschedMinutes < 1 {
|
|
|
|
|
|
settings.TempUnschedMinutes = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.TempUnschedMinutes > 60 {
|
|
|
|
|
|
settings.TempUnschedMinutes = 60
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.ThresholdCount < 1 {
|
|
|
|
|
|
settings.ThresholdCount = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.ThresholdCount > 10 {
|
|
|
|
|
|
settings.ThresholdCount = 10
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.ThresholdWindowMinutes < 1 {
|
|
|
|
|
|
settings.ThresholdWindowMinutes = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.ThresholdWindowMinutes > 60 {
|
|
|
|
|
|
settings.ThresholdWindowMinutes = 60
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证 action
|
|
|
|
|
|
switch settings.Action {
|
|
|
|
|
|
case StreamTimeoutActionTempUnsched, StreamTimeoutActionError, StreamTimeoutActionNone:
|
|
|
|
|
|
// valid
|
|
|
|
|
|
default:
|
|
|
|
|
|
settings.Action = StreamTimeoutActionTempUnsched
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &settings, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 19:56:27 +08:00
|
|
|
|
// IsUngroupedKeySchedulingAllowed 查询是否允许未分组 Key 调度
|
|
|
|
|
|
func (s *SettingService) IsUngroupedKeySchedulingAllowed(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyAllowUngroupedKeyScheduling)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false // fail-closed: 查询失败时默认不允许
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 09:10:01 +08:00
|
|
|
|
// GetClaudeCodeVersionBounds 获取 Claude Code 版本号上下限要求
|
2026-03-01 15:35:46 +08:00
|
|
|
|
// 使用进程内 atomic.Value 缓存,60 秒 TTL,热路径零锁开销
|
|
|
|
|
|
// singleflight 防止缓存过期时 thundering herd
|
2026-03-20 09:10:01 +08:00
|
|
|
|
// 返回空字符串表示不做对应方向的版本检查
|
|
|
|
|
|
func (s *SettingService) GetClaudeCodeVersionBounds(ctx context.Context) (min, max string) {
|
|
|
|
|
|
if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok {
|
2026-03-01 15:35:46 +08:00
|
|
|
|
if time.Now().UnixNano() < cached.expiresAt {
|
2026-03-20 09:10:01 +08:00
|
|
|
|
return cached.min, cached.max
|
2026-03-01 15:35:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// singleflight: 同一时刻只有一个 goroutine 查询 DB,其余复用结果
|
2026-03-20 09:10:01 +08:00
|
|
|
|
type bounds struct{ min, max string }
|
|
|
|
|
|
result, err, _ := versionBoundsSF.Do("version_bounds", func() (any, error) {
|
2026-03-01 15:35:46 +08:00
|
|
|
|
// 二次检查,避免排队的 goroutine 重复查询
|
2026-03-20 09:10:01 +08:00
|
|
|
|
if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok {
|
2026-03-01 15:35:46 +08:00
|
|
|
|
if time.Now().UnixNano() < cached.expiresAt {
|
2026-03-20 09:10:01 +08:00
|
|
|
|
return bounds{cached.min, cached.max}, nil
|
2026-03-01 15:35:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 使用独立 context:断开请求取消链,避免客户端断连导致空值被长期缓存
|
2026-03-20 09:10:01 +08:00
|
|
|
|
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), versionBoundsDBTimeout)
|
2026-03-01 15:35:46 +08:00
|
|
|
|
defer cancel()
|
2026-03-20 09:10:01 +08:00
|
|
|
|
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
|
|
|
|
|
|
SettingKeyMinClaudeCodeVersion,
|
|
|
|
|
|
SettingKeyMaxClaudeCodeVersion,
|
|
|
|
|
|
})
|
2026-03-01 15:35:46 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
// fail-open: DB 错误时不阻塞请求,但记录日志并使用短 TTL 快速重试
|
2026-03-20 09:10:01 +08:00
|
|
|
|
slog.Warn("failed to get claude code version bounds setting, skipping version check", "error", err)
|
|
|
|
|
|
versionBoundsCache.Store(&cachedVersionBounds{
|
|
|
|
|
|
min: "",
|
|
|
|
|
|
max: "",
|
|
|
|
|
|
expiresAt: time.Now().Add(versionBoundsErrorTTL).UnixNano(),
|
2026-03-01 15:35:46 +08:00
|
|
|
|
})
|
2026-03-20 09:10:01 +08:00
|
|
|
|
return bounds{"", ""}, nil
|
2026-03-01 15:35:46 +08:00
|
|
|
|
}
|
2026-03-20 09:10:01 +08:00
|
|
|
|
b := bounds{
|
|
|
|
|
|
min: values[SettingKeyMinClaudeCodeVersion],
|
|
|
|
|
|
max: values[SettingKeyMaxClaudeCodeVersion],
|
|
|
|
|
|
}
|
|
|
|
|
|
versionBoundsCache.Store(&cachedVersionBounds{
|
|
|
|
|
|
min: b.min,
|
|
|
|
|
|
max: b.max,
|
|
|
|
|
|
expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(),
|
2026-03-01 15:35:46 +08:00
|
|
|
|
})
|
2026-03-20 09:10:01 +08:00
|
|
|
|
return b, nil
|
2026-03-01 15:35:46 +08:00
|
|
|
|
})
|
2026-03-03 01:02:39 +08:00
|
|
|
|
if err != nil {
|
2026-03-20 09:10:01 +08:00
|
|
|
|
return "", ""
|
2026-03-03 01:02:39 +08:00
|
|
|
|
}
|
2026-03-20 09:10:01 +08:00
|
|
|
|
b, ok := result.(bounds)
|
2026-03-03 01:02:39 +08:00
|
|
|
|
if !ok {
|
2026-03-20 09:10:01 +08:00
|
|
|
|
return "", ""
|
2026-03-01 16:39:21 +08:00
|
|
|
|
}
|
2026-03-20 09:10:01 +08:00
|
|
|
|
return b.min, b.max
|
2026-03-01 15:35:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 21:45:18 +08:00
|
|
|
|
// GetRectifierSettings 获取请求整流器配置
|
|
|
|
|
|
func (s *SettingService) GetRectifierSettings(ctx context.Context) (*RectifierSettings, error) {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyRectifierSettings)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if errors.Is(err, ErrSettingNotFound) {
|
|
|
|
|
|
return DefaultRectifierSettings(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("get rectifier settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
return DefaultRectifierSettings(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var settings RectifierSettings
|
|
|
|
|
|
if err := json.Unmarshal([]byte(value), &settings); err != nil {
|
|
|
|
|
|
return DefaultRectifierSettings(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &settings, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SetRectifierSettings 设置请求整流器配置
|
|
|
|
|
|
func (s *SettingService) SetRectifierSettings(ctx context.Context, settings *RectifierSettings) error {
|
|
|
|
|
|
if settings == nil {
|
|
|
|
|
|
return fmt.Errorf("settings cannot be nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
data, err := json.Marshal(settings)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("marshal rectifier settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.settingRepo.Set(ctx, SettingKeyRectifierSettings, string(data))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsSignatureRectifierEnabled 判断签名整流是否启用(总开关 && 签名子开关)
|
|
|
|
|
|
func (s *SettingService) IsSignatureRectifierEnabled(ctx context.Context) bool {
|
|
|
|
|
|
settings, err := s.GetRectifierSettings(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return true // fail-open: 查询失败时默认启用
|
|
|
|
|
|
}
|
|
|
|
|
|
return settings.Enabled && settings.ThinkingSignatureEnabled
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsBudgetRectifierEnabled 判断 Budget 整流是否启用(总开关 && Budget 子开关)
|
|
|
|
|
|
func (s *SettingService) IsBudgetRectifierEnabled(ctx context.Context) bool {
|
|
|
|
|
|
settings, err := s.GetRectifierSettings(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return true // fail-open: 查询失败时默认启用
|
|
|
|
|
|
}
|
|
|
|
|
|
return settings.Enabled && settings.ThinkingBudgetEnabled
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 11:14:17 +08:00
|
|
|
|
// GetBetaPolicySettings 获取 Beta 策略配置
|
|
|
|
|
|
func (s *SettingService) GetBetaPolicySettings(ctx context.Context) (*BetaPolicySettings, error) {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyBetaPolicySettings)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if errors.Is(err, ErrSettingNotFound) {
|
|
|
|
|
|
return DefaultBetaPolicySettings(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("get beta policy settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
return DefaultBetaPolicySettings(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var settings BetaPolicySettings
|
|
|
|
|
|
if err := json.Unmarshal([]byte(value), &settings); err != nil {
|
|
|
|
|
|
return DefaultBetaPolicySettings(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &settings, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SetBetaPolicySettings 设置 Beta 策略配置
|
|
|
|
|
|
func (s *SettingService) SetBetaPolicySettings(ctx context.Context, settings *BetaPolicySettings) error {
|
|
|
|
|
|
if settings == nil {
|
|
|
|
|
|
return fmt.Errorf("settings cannot be nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
validActions := map[string]bool{
|
|
|
|
|
|
BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true,
|
|
|
|
|
|
}
|
|
|
|
|
|
validScopes := map[string]bool{
|
2026-03-14 17:13:30 +08:00
|
|
|
|
BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true,
|
2026-03-10 11:14:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for i, rule := range settings.Rules {
|
|
|
|
|
|
if rule.BetaToken == "" {
|
|
|
|
|
|
return fmt.Errorf("rule[%d]: beta_token cannot be empty", i)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !validActions[rule.Action] {
|
|
|
|
|
|
return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !validScopes[rule.Scope] {
|
|
|
|
|
|
return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope)
|
|
|
|
|
|
}
|
2026-04-07 20:28:14 +08:00
|
|
|
|
// Validate model_whitelist patterns
|
|
|
|
|
|
for j, pattern := range rule.ModelWhitelist {
|
|
|
|
|
|
trimmed := strings.TrimSpace(pattern)
|
|
|
|
|
|
if trimmed == "" {
|
|
|
|
|
|
return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j)
|
|
|
|
|
|
}
|
|
|
|
|
|
settings.Rules[i].ModelWhitelist[j] = trimmed
|
|
|
|
|
|
}
|
|
|
|
|
|
// Validate fallback_action
|
|
|
|
|
|
if rule.FallbackAction != "" && !validActions[rule.FallbackAction] {
|
|
|
|
|
|
return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction)
|
|
|
|
|
|
}
|
2026-03-10 11:14:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
data, err := json.Marshal(settings)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("marshal beta policy settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.settingRepo.Set(ctx, SettingKeyBetaPolicySettings, string(data))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 21:54:52 -08:00
|
|
|
|
// SetStreamTimeoutSettings 设置流超时处理配置
|
|
|
|
|
|
func (s *SettingService) SetStreamTimeoutSettings(ctx context.Context, settings *StreamTimeoutSettings) error {
|
|
|
|
|
|
if settings == nil {
|
|
|
|
|
|
return fmt.Errorf("settings cannot be nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证配置值
|
|
|
|
|
|
if settings.TempUnschedMinutes < 1 || settings.TempUnschedMinutes > 60 {
|
|
|
|
|
|
return fmt.Errorf("temp_unsched_minutes must be between 1-60")
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.ThresholdCount < 1 || settings.ThresholdCount > 10 {
|
|
|
|
|
|
return fmt.Errorf("threshold_count must be between 1-10")
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.ThresholdWindowMinutes < 1 || settings.ThresholdWindowMinutes > 60 {
|
|
|
|
|
|
return fmt.Errorf("threshold_window_minutes must be between 1-60")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch settings.Action {
|
|
|
|
|
|
case StreamTimeoutActionTempUnsched, StreamTimeoutActionError, StreamTimeoutActionNone:
|
|
|
|
|
|
// valid
|
|
|
|
|
|
default:
|
|
|
|
|
|
return fmt.Errorf("invalid action: %s", settings.Action)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
data, err := json.Marshal(settings)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("marshal stream timeout settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.settingRepo.Set(ctx, SettingKeyStreamTimeoutSettings, string(data))
|
|
|
|
|
|
}
|