- TopK initial filter now drops quota-paused accounts: fold the quota check into isAccountRequestCompatible so session-hash, TopK pool, and per-candidate rechecks all skip paused accounts. Previously the candidate pool was built without the quota check, so paused accounts could fill TopK and leave the scheduler returning "no available accounts" even with healthy ones available. - Add per-account explicit disable flags auto_pause_5h_disabled / auto_pause_7d_disabled with toggles in EditAccountModal. Without these, leaving the account threshold blank silently falls back to the global default, so admins could not exempt a single account once a global default existed. Disable is per-window: an account can opt out of 5h auto-pause while still honoring 7d. Schedule snapshot whitelist includes the new fields, i18n EN/ZH updated, threshold-hint text revised to explain "blank = global default". - Move quota auto-pause settings off the request hot path: replace the per-repo TTL+singleflight sync DB read with a per-SettingService stale-while-revalidate in-memory snapshot. Get is non-blocking (atomic.Pointer load + async refresh on staleness); writes via UpdateOpsAdvancedSettings push directly into the cache through an injected sink; wire warms the cache at startup. Adds Warm (sync) for tests/init and SetOpenAIQuotaAutoPauseSettings (sink target). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
4870 lines
202 KiB
Go
4870 lines
202 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"math"
|
||
"net/url"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||
"github.com/imroc/req/v3"
|
||
"golang.org/x/sync/singleflight"
|
||
)
|
||
|
||
// CoerceDingTalkCorpPolicyForWrite 是 coerceDeprecatedDingTalkCorpPolicy 的导出版本,
|
||
// 用于 admin handler 在写入路径上对客户端直传的入参做防御性 coerce(前端 UI 虽已无 whitelist 选项,
|
||
// 但 API 可被直接调用)。
|
||
func CoerceDingTalkCorpPolicyForWrite(policy string) string {
|
||
return coerceDeprecatedDingTalkCorpPolicy(policy)
|
||
}
|
||
|
||
// coerceDeprecatedDingTalkCorpPolicy 把已废弃的 corp_restriction_policy 值替换成安全的等价值。
|
||
// 升级前残留在 DB 中的 "whitelist" 会导致 callback 链路在 default case 静默 fail-closed
|
||
// (所有钉钉登录被拒)。这里统一退化为 "none" 让服务保持可用,并 warn 日志提醒 admin 重新保存设置。
|
||
func coerceDeprecatedDingTalkCorpPolicy(policy string) string {
|
||
if policy == "whitelist" {
|
||
slog.Warn("dingtalk: corp_restriction_policy=whitelist is deprecated and unsupported, coercing to none",
|
||
"hint", "re-save DingTalk settings in admin UI to clear this warning")
|
||
return "none"
|
||
}
|
||
return policy
|
||
}
|
||
|
||
var (
|
||
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",
|
||
)
|
||
)
|
||
|
||
type SettingRepository interface {
|
||
Get(ctx context.Context, key string) (*Setting, error)
|
||
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
|
||
}
|
||
|
||
// cachedVersionBounds 缓存 Claude Code 版本号上下限(进程内缓存,60s TTL)
|
||
type cachedVersionBounds struct {
|
||
min string // 空字符串 = 不检查
|
||
max string // 空字符串 = 不检查
|
||
expiresAt int64 // unix nano
|
||
}
|
||
|
||
// versionBoundsCache 版本号上下限进程内缓存
|
||
var versionBoundsCache atomic.Value // *cachedVersionBounds
|
||
|
||
// versionBoundsSF 防止缓存过期时 thundering herd
|
||
var versionBoundsSF singleflight.Group
|
||
|
||
// versionBoundsCacheTTL 缓存有效期
|
||
const versionBoundsCacheTTL = 60 * time.Second
|
||
|
||
// versionBoundsErrorTTL DB 错误时的短缓存,快速重试
|
||
const versionBoundsErrorTTL = 5 * time.Second
|
||
|
||
// versionBoundsDBTimeout singleflight 内 DB 查询超时,独立于请求 context
|
||
const versionBoundsDBTimeout = 5 * time.Second
|
||
|
||
// 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
|
||
|
||
// cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL)
|
||
type cachedGatewayForwardingSettings struct {
|
||
fingerprintUnification bool
|
||
metadataPassthrough bool
|
||
cchSigning bool
|
||
anthropicCacheTTL1hInjection bool
|
||
rewriteMessageCacheControl bool
|
||
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
|
||
|
||
// cachedAntigravityUserAgentVersion 缓存 Antigravity UA 版本号(进程内缓存,60s TTL)
|
||
type cachedAntigravityUserAgentVersion struct {
|
||
version string
|
||
expiresAt int64 // unix nano
|
||
}
|
||
|
||
const antigravityUserAgentVersionCacheTTL = 60 * time.Second
|
||
const antigravityUserAgentVersionErrorTTL = 5 * time.Second
|
||
const antigravityUserAgentVersionDBTimeout = 5 * time.Second
|
||
|
||
// DefaultOpenAICodexUserAgent OpenAI Codex 默认 User-Agent(用于规避 Cloudflare 对浏览器 UA 的质询)
|
||
const DefaultOpenAICodexUserAgent = "codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)"
|
||
|
||
// cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL)
|
||
type cachedOpenAICodexUserAgent struct {
|
||
value string
|
||
expiresAt int64 // unix nano
|
||
}
|
||
|
||
type cachedOpenAIQuotaAutoPauseSettings struct {
|
||
settings OpsOpenAIAccountQuotaAutoPauseSettings
|
||
expiresAt int64
|
||
}
|
||
|
||
const openAICodexUserAgentCacheTTL = 60 * time.Second
|
||
const openAICodexUserAgentErrorTTL = 5 * time.Second
|
||
const openAICodexUserAgentDBTimeout = 5 * time.Second
|
||
|
||
// cachedOpenAIAllowCodexPlugin Codex 插件放行开关缓存(进程内缓存,60s TTL)。
|
||
// IsOpenAIAllowClaudeCodeCodexPluginEnabled 在每个 codex_cli_only 账号的网关请求热路径上被调用,避免每次访问 DB。
|
||
type cachedOpenAIAllowCodexPlugin struct {
|
||
value bool
|
||
expiresAt int64 // unix nano
|
||
}
|
||
|
||
const openAIAllowCodexPluginCacheTTL = 60 * time.Second
|
||
const openAIAllowCodexPluginErrorTTL = 5 * time.Second
|
||
const openAIAllowCodexPluginDBTimeout = 5 * time.Second
|
||
|
||
const openAIQuotaAutoPauseSettingsCacheTTL = 60 * time.Second
|
||
const openAIQuotaAutoPauseSettingsErrorTTL = 5 * time.Second
|
||
const openAIQuotaAutoPauseSettingsDBTimeout = 5 * time.Second
|
||
|
||
const openAIQuotaAutoPauseSettingsRefreshKey = "openai_quota_auto_pause_settings"
|
||
|
||
// DefaultSubscriptionGroupReader validates group references used by default subscriptions.
|
||
type DefaultSubscriptionGroupReader interface {
|
||
GetByID(ctx context.Context, id int64) (*Group, error)
|
||
}
|
||
|
||
// 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)
|
||
|
||
// SettingService 系统设置服务
|
||
type SettingService struct {
|
||
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
|
||
antigravityUAVersionCache atomic.Value // *cachedAntigravityUserAgentVersion
|
||
antigravityUAVersionSF singleflight.Group
|
||
openAICodexUACache atomic.Value // *cachedOpenAICodexUserAgent
|
||
openAICodexUASF singleflight.Group
|
||
openAIAllowCodexPluginCache atomic.Value // *cachedOpenAIAllowCodexPlugin
|
||
openAIAllowCodexPluginSF singleflight.Group
|
||
|
||
// openAIQuotaAutoPauseSettingsCache holds the most recently observed quota auto-pause
|
||
// settings. GetOpenAIQuotaAutoPauseSettings reads this atomic.Value on the request hot
|
||
// path without ever blocking on the DB; when the cached entry expires, a background
|
||
// goroutine refreshes it via openAIQuotaAutoPauseSettingsSF (stale-while-revalidate).
|
||
// This per-service field also gives tests natural isolation — each SettingService
|
||
// instance owns its own cache, no shared package-level state.
|
||
openAIQuotaAutoPauseSettingsCache atomic.Value // *cachedOpenAIQuotaAutoPauseSettings
|
||
openAIQuotaAutoPauseSettingsSF singleflight.Group
|
||
}
|
||
|
||
// DefaultPlatformQuotaSetting 单 platform 三档限额(nil = 沿用上层;0 = 显式禁用;>0 = 上限)
|
||
type DefaultPlatformQuotaSetting struct {
|
||
DailyLimitUSD *float64 `json:"daily"`
|
||
WeeklyLimitUSD *float64 `json:"weekly"`
|
||
MonthlyLimitUSD *float64 `json:"monthly"`
|
||
}
|
||
|
||
type ProviderDefaultGrantSettings struct {
|
||
Balance float64
|
||
Concurrency int
|
||
Subscriptions []DefaultSubscriptionSetting
|
||
GrantOnSignup bool
|
||
GrantOnFirstBind bool
|
||
PlatformQuotas map[string]*DefaultPlatformQuotaSetting // key = platform name
|
||
}
|
||
|
||
type AuthSourceDefaultSettings struct {
|
||
Email ProviderDefaultGrantSettings
|
||
LinuxDo ProviderDefaultGrantSettings
|
||
OIDC ProviderDefaultGrantSettings
|
||
WeChat ProviderDefaultGrantSettings
|
||
GitHub ProviderDefaultGrantSettings
|
||
Google ProviderDefaultGrantSettings
|
||
DingTalk ProviderDefaultGrantSettings
|
||
ForceEmailOnThirdPartySignup bool
|
||
}
|
||
|
||
type authSourceDefaultKeySet struct {
|
||
// source 是 auth source 标识(如 "email"、"github"),仅用于 parse 时
|
||
// slog.Warn 诊断输出,不再参与 key 拼接(platformQuotas 字段已存完整 key)。
|
||
source string
|
||
balance string
|
||
concurrency string
|
||
subscriptions string
|
||
grantOnSignup string
|
||
grantOnFirstBind string
|
||
platformQuotas string // SettingKeyAuthSourcePlatformQuotas(source)
|
||
}
|
||
|
||
var (
|
||
emailAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
||
source: "email",
|
||
balance: SettingKeyAuthSourceDefaultEmailBalance,
|
||
concurrency: SettingKeyAuthSourceDefaultEmailConcurrency,
|
||
subscriptions: SettingKeyAuthSourceDefaultEmailSubscriptions,
|
||
grantOnSignup: SettingKeyAuthSourceDefaultEmailGrantOnSignup,
|
||
grantOnFirstBind: SettingKeyAuthSourceDefaultEmailGrantOnFirstBind,
|
||
platformQuotas: SettingKeyAuthSourcePlatformQuotas("email"),
|
||
}
|
||
linuxDoAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
||
source: "linuxdo",
|
||
balance: SettingKeyAuthSourceDefaultLinuxDoBalance,
|
||
concurrency: SettingKeyAuthSourceDefaultLinuxDoConcurrency,
|
||
subscriptions: SettingKeyAuthSourceDefaultLinuxDoSubscriptions,
|
||
grantOnSignup: SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup,
|
||
grantOnFirstBind: SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind,
|
||
platformQuotas: SettingKeyAuthSourcePlatformQuotas("linuxdo"),
|
||
}
|
||
oidcAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
||
source: "oidc",
|
||
balance: SettingKeyAuthSourceDefaultOIDCBalance,
|
||
concurrency: SettingKeyAuthSourceDefaultOIDCConcurrency,
|
||
subscriptions: SettingKeyAuthSourceDefaultOIDCSubscriptions,
|
||
grantOnSignup: SettingKeyAuthSourceDefaultOIDCGrantOnSignup,
|
||
grantOnFirstBind: SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind,
|
||
platformQuotas: SettingKeyAuthSourcePlatformQuotas("oidc"),
|
||
}
|
||
weChatAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
||
source: "wechat",
|
||
balance: SettingKeyAuthSourceDefaultWeChatBalance,
|
||
concurrency: SettingKeyAuthSourceDefaultWeChatConcurrency,
|
||
subscriptions: SettingKeyAuthSourceDefaultWeChatSubscriptions,
|
||
grantOnSignup: SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
|
||
grantOnFirstBind: SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
|
||
platformQuotas: SettingKeyAuthSourcePlatformQuotas("wechat"),
|
||
}
|
||
gitHubAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
||
source: "github",
|
||
balance: SettingKeyAuthSourceDefaultGitHubBalance,
|
||
concurrency: SettingKeyAuthSourceDefaultGitHubConcurrency,
|
||
subscriptions: SettingKeyAuthSourceDefaultGitHubSubscriptions,
|
||
grantOnSignup: SettingKeyAuthSourceDefaultGitHubGrantOnSignup,
|
||
grantOnFirstBind: SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind,
|
||
platformQuotas: SettingKeyAuthSourcePlatformQuotas("github"),
|
||
}
|
||
googleAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
||
source: "google",
|
||
balance: SettingKeyAuthSourceDefaultGoogleBalance,
|
||
concurrency: SettingKeyAuthSourceDefaultGoogleConcurrency,
|
||
subscriptions: SettingKeyAuthSourceDefaultGoogleSubscriptions,
|
||
grantOnSignup: SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
|
||
grantOnFirstBind: SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
|
||
platformQuotas: SettingKeyAuthSourcePlatformQuotas("google"),
|
||
}
|
||
dingTalkAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
||
source: "dingtalk",
|
||
balance: SettingKeyAuthSourceDefaultDingTalkBalance,
|
||
concurrency: SettingKeyAuthSourceDefaultDingTalkConcurrency,
|
||
subscriptions: SettingKeyAuthSourceDefaultDingTalkSubscriptions,
|
||
grantOnSignup: SettingKeyAuthSourceDefaultDingTalkGrantOnSignup,
|
||
grantOnFirstBind: SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind,
|
||
platformQuotas: SettingKeyAuthSourcePlatformQuotas("dingtalk"),
|
||
}
|
||
)
|
||
|
||
const (
|
||
defaultAuthSourceBalance = 0
|
||
defaultAuthSourceConcurrency = 5
|
||
defaultWeChatConnectMode = "open"
|
||
defaultWeChatConnectScopes = "snsapi_login"
|
||
defaultWeChatConnectFrontend = "/auth/wechat/callback"
|
||
defaultGitHubOAuthAuthorize = "https://github.com/login/oauth/authorize"
|
||
defaultGitHubOAuthToken = "https://github.com/login/oauth/access_token"
|
||
defaultGitHubOAuthUserInfo = "https://api.github.com/user"
|
||
defaultGitHubOAuthEmails = "https://api.github.com/user/emails"
|
||
defaultGitHubOAuthScopes = "read:user user:email"
|
||
defaultGitHubOAuthFrontend = "/auth/oauth/callback"
|
||
defaultGoogleOAuthAuthorize = "https://accounts.google.com/o/oauth2/v2/auth"
|
||
defaultGoogleOAuthToken = "https://oauth2.googleapis.com/token"
|
||
defaultGoogleOAuthUserInfo = "https://openidconnect.googleapis.com/v1/userinfo"
|
||
defaultGoogleOAuthScopes = "openid email profile"
|
||
defaultGoogleOAuthFrontend = "/auth/oauth/callback"
|
||
defaultLoginAgreementMode = "modal"
|
||
defaultLoginAgreementDate = "2026-03-31"
|
||
)
|
||
|
||
func normalizeLoginAgreementMode(raw string) string {
|
||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||
case "checkbox":
|
||
return "checkbox"
|
||
default:
|
||
return defaultLoginAgreementMode
|
||
}
|
||
}
|
||
|
||
func defaultLoginAgreementDocuments() []LoginAgreementDocument {
|
||
return []LoginAgreementDocument{
|
||
{
|
||
ID: "terms",
|
||
Title: "服务条款",
|
||
ContentMD: "",
|
||
},
|
||
{
|
||
ID: "usage-policy",
|
||
Title: "使用政策",
|
||
ContentMD: "",
|
||
},
|
||
{
|
||
ID: "supported-regions",
|
||
Title: "支持的国家和地区",
|
||
ContentMD: "",
|
||
},
|
||
{
|
||
ID: "service-specific-terms",
|
||
Title: "服务特定条款",
|
||
ContentMD: "",
|
||
},
|
||
}
|
||
}
|
||
|
||
func normalizeLoginAgreementDocumentID(raw string) string {
|
||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||
var b strings.Builder
|
||
lastSeparator := false
|
||
for _, r := range raw {
|
||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||
_, _ = b.WriteRune(r)
|
||
lastSeparator = false
|
||
continue
|
||
}
|
||
if r == '-' || r == '_' || r == ' ' || r == '.' || r == '/' {
|
||
if !lastSeparator && b.Len() > 0 {
|
||
if r == '_' {
|
||
_, _ = b.WriteRune('_')
|
||
} else {
|
||
_, _ = b.WriteRune('-')
|
||
}
|
||
lastSeparator = true
|
||
}
|
||
}
|
||
}
|
||
return strings.Trim(b.String(), "-_")
|
||
}
|
||
|
||
func normalizeLoginAgreementDocuments(docs []LoginAgreementDocument) []LoginAgreementDocument {
|
||
normalized := make([]LoginAgreementDocument, 0, len(docs))
|
||
seen := make(map[string]int, len(docs))
|
||
for i, doc := range docs {
|
||
title := strings.TrimSpace(doc.Title)
|
||
content := strings.TrimSpace(doc.ContentMD)
|
||
if title == "" && content == "" {
|
||
continue
|
||
}
|
||
id := normalizeLoginAgreementDocumentID(doc.ID)
|
||
if id == "" {
|
||
sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%s:%s", i, title, content)))
|
||
id = hex.EncodeToString(sum[:])[:12]
|
||
}
|
||
baseID := id
|
||
for suffix := 2; seen[id] > 0; suffix++ {
|
||
id = fmt.Sprintf("%s-%d", baseID, suffix)
|
||
}
|
||
seen[id]++
|
||
normalized = append(normalized, LoginAgreementDocument{
|
||
ID: id,
|
||
Title: title,
|
||
ContentMD: content,
|
||
})
|
||
}
|
||
return normalized
|
||
}
|
||
|
||
func parseLoginAgreementDocuments(raw string) []LoginAgreementDocument {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return defaultLoginAgreementDocuments()
|
||
}
|
||
var docs []LoginAgreementDocument
|
||
if err := json.Unmarshal([]byte(raw), &docs); err != nil {
|
||
return defaultLoginAgreementDocuments()
|
||
}
|
||
docs = normalizeLoginAgreementDocuments(docs)
|
||
if len(docs) == 0 {
|
||
return defaultLoginAgreementDocuments()
|
||
}
|
||
return docs
|
||
}
|
||
|
||
func marshalLoginAgreementDocuments(docs []LoginAgreementDocument) (string, error) {
|
||
normalized := normalizeLoginAgreementDocuments(docs)
|
||
if len(normalized) == 0 {
|
||
normalized = defaultLoginAgreementDocuments()
|
||
}
|
||
b, err := json.Marshal(normalized)
|
||
if err != nil {
|
||
return "", fmt.Errorf("marshal login agreement documents: %w", err)
|
||
}
|
||
return string(b), nil
|
||
}
|
||
|
||
func buildLoginAgreementRevision(updatedAt string, docs []LoginAgreementDocument) string {
|
||
normalized := normalizeLoginAgreementDocuments(docs)
|
||
payload, err := json.Marshal(struct {
|
||
UpdatedAt string `json:"updated_at"`
|
||
Documents []LoginAgreementDocument `json:"documents"`
|
||
}{
|
||
UpdatedAt: strings.TrimSpace(updatedAt),
|
||
Documents: normalized,
|
||
})
|
||
if err != nil {
|
||
payload = []byte(strings.TrimSpace(updatedAt))
|
||
}
|
||
sum := sha256.Sum256(payload)
|
||
return hex.EncodeToString(sum[:])[:16]
|
||
}
|
||
|
||
func normalizeWeChatConnectModeSetting(raw string) string {
|
||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||
case "mp":
|
||
return "mp"
|
||
case "mobile":
|
||
return "mobile"
|
||
default:
|
||
return "open"
|
||
}
|
||
}
|
||
|
||
func defaultWeChatConnectScopeForMode(mode string) string {
|
||
switch normalizeWeChatConnectModeSetting(mode) {
|
||
case "mp":
|
||
return "snsapi_userinfo"
|
||
case "mobile":
|
||
return ""
|
||
}
|
||
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)
|
||
}
|
||
case "mobile":
|
||
return ""
|
||
default:
|
||
return defaultWeChatConnectScopes
|
||
}
|
||
}
|
||
|
||
func parseWeChatConnectCapabilitySettings(settings map[string]string, enabled bool, mode string) (bool, bool, bool) {
|
||
mode = normalizeWeChatConnectModeSetting(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 {
|
||
openEnabled := strings.TrimSpace(rawOpen) == "true"
|
||
mpEnabled := strings.TrimSpace(rawMP) == "true"
|
||
mobileEnabled := strings.TrimSpace(rawMobile) == "true"
|
||
return openEnabled, mpEnabled, mobileEnabled
|
||
}
|
||
|
||
if !enabled {
|
||
return false, false, false
|
||
}
|
||
if mode == "mp" {
|
||
return false, true, false
|
||
}
|
||
if mode == "mobile" {
|
||
return false, false, true
|
||
}
|
||
return true, false, false
|
||
}
|
||
|
||
func normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled bool, mode string) string {
|
||
mode = normalizeWeChatConnectModeSetting(mode)
|
||
switch mode {
|
||
case "open":
|
||
if openEnabled {
|
||
return "open"
|
||
}
|
||
case "mp":
|
||
if mpEnabled {
|
||
return "mp"
|
||
}
|
||
case "mobile":
|
||
if mobileEnabled {
|
||
return "mobile"
|
||
}
|
||
}
|
||
switch {
|
||
case openEnabled:
|
||
return "open"
|
||
case mpEnabled:
|
||
return "mp"
|
||
case mobileEnabled:
|
||
return "mobile"
|
||
default:
|
||
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 {
|
||
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
|
||
}
|
||
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)),
|
||
}
|
||
}
|
||
|
||
// NewSettingService 创建系统设置服务实例
|
||
func NewSettingService(settingRepo SettingRepository, cfg *config.Config) *SettingService {
|
||
return &SettingService{
|
||
settingRepo: settingRepo,
|
||
cfg: cfg,
|
||
}
|
||
}
|
||
|
||
// SetDefaultSubscriptionGroupReader injects an optional group reader for default subscription validation.
|
||
func (s *SettingService) SetDefaultSubscriptionGroupReader(reader DefaultSubscriptionGroupReader) {
|
||
s.defaultSubGroupReader = reader
|
||
}
|
||
|
||
// SetProxyRepository injects a proxy repo for resolving websearch provider proxy URLs.
|
||
func (s *SettingService) SetProxyRepository(repo ProxyRepository) {
|
||
s.proxyRepo = repo
|
||
}
|
||
|
||
func (s *SettingService) LoadAPIKeyACLTrustForwardedIPSetting(ctx context.Context) error {
|
||
if s == nil || s.cfg == nil || s.settingRepo == nil {
|
||
return nil
|
||
}
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyAPIKeyACLTrustForwardedIP)
|
||
if err != nil {
|
||
if errors.Is(err, ErrSettingNotFound) {
|
||
s.cfg.SetTrustForwardedIPForAPIKeyACL(s.cfg.Security.TrustForwardedIPForAPIKeyACL)
|
||
return nil
|
||
}
|
||
return fmt.Errorf("get api key acl forwarded ip setting: %w", err)
|
||
}
|
||
enabled := value == "true"
|
||
s.cfg.SetTrustForwardedIPForAPIKeyACL(enabled)
|
||
return nil
|
||
}
|
||
|
||
// GetAllSettings 获取所有系统设置
|
||
func (s *SettingService) GetAllSettings(ctx context.Context) (*SystemSettings, error) {
|
||
settings, err := s.settingRepo.GetAll(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get all settings: %w", err)
|
||
}
|
||
|
||
return s.parseSettings(settings), nil
|
||
}
|
||
|
||
// GetFrontendURL 获取前端基础URL(数据库优先,fallback 到配置文件)
|
||
func (s *SettingService) GetFrontendURL(ctx context.Context) string {
|
||
val, err := s.settingRepo.GetValue(ctx, SettingKeyFrontendURL)
|
||
if err == nil && strings.TrimSpace(val) != "" {
|
||
return strings.TrimSpace(val)
|
||
}
|
||
return s.cfg.Server.FrontendURL
|
||
}
|
||
|
||
// GetPublicSettings 获取公开设置(无需登录)
|
||
func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) {
|
||
keys := []string{
|
||
SettingKeyRegistrationEnabled,
|
||
SettingKeyEmailVerifyEnabled,
|
||
SettingKeyForceEmailOnThirdPartySignup,
|
||
SettingKeyRegistrationEmailSuffixWhitelist,
|
||
SettingKeyPromoCodeEnabled,
|
||
SettingKeyPasswordResetEnabled,
|
||
SettingKeyInvitationCodeEnabled,
|
||
SettingKeyTotpEnabled,
|
||
SettingKeyLoginAgreementEnabled,
|
||
SettingKeyLoginAgreementMode,
|
||
SettingKeyLoginAgreementUpdatedAt,
|
||
SettingKeyLoginAgreementDocuments,
|
||
SettingKeyTurnstileEnabled,
|
||
SettingKeyTurnstileSiteKey,
|
||
SettingKeyAPIKeyACLTrustForwardedIP,
|
||
SettingKeySiteName,
|
||
SettingKeySiteLogo,
|
||
SettingKeySiteSubtitle,
|
||
SettingKeyAPIBaseURL,
|
||
SettingKeyContactInfo,
|
||
SettingKeyDocURL,
|
||
SettingKeyHomeContent,
|
||
SettingKeyHideCcsImportButton,
|
||
SettingKeyPurchaseSubscriptionEnabled,
|
||
SettingKeyPurchaseSubscriptionURL,
|
||
SettingKeyTableDefaultPageSize,
|
||
SettingKeyTablePageSizeOptions,
|
||
SettingKeyCustomMenuItems,
|
||
SettingKeyCustomEndpoints,
|
||
SettingKeyLinuxDoConnectEnabled,
|
||
SettingKeyDingTalkConnectEnabled,
|
||
SettingKeyWeChatConnectEnabled,
|
||
SettingKeyWeChatConnectAppID,
|
||
SettingKeyWeChatConnectAppSecret,
|
||
SettingKeyWeChatConnectOpenAppID,
|
||
SettingKeyWeChatConnectOpenAppSecret,
|
||
SettingKeyWeChatConnectMPAppID,
|
||
SettingKeyWeChatConnectMPAppSecret,
|
||
SettingKeyWeChatConnectMobileAppID,
|
||
SettingKeyWeChatConnectMobileAppSecret,
|
||
SettingKeyWeChatConnectOpenEnabled,
|
||
SettingKeyWeChatConnectMPEnabled,
|
||
SettingKeyWeChatConnectMobileEnabled,
|
||
SettingKeyWeChatConnectMode,
|
||
SettingKeyWeChatConnectScopes,
|
||
SettingKeyWeChatConnectRedirectURL,
|
||
SettingKeyWeChatConnectFrontendRedirectURL,
|
||
SettingKeyBackendModeEnabled,
|
||
SettingPaymentEnabled,
|
||
SettingKeyOIDCConnectEnabled,
|
||
SettingKeyOIDCConnectProviderName,
|
||
SettingKeyGitHubOAuthEnabled,
|
||
SettingKeyGitHubOAuthClientID,
|
||
SettingKeyGitHubOAuthClientSecret,
|
||
SettingKeyGoogleOAuthEnabled,
|
||
SettingKeyGoogleOAuthClientID,
|
||
SettingKeyGoogleOAuthClientSecret,
|
||
SettingKeyBalanceLowNotifyEnabled,
|
||
SettingKeyBalanceLowNotifyThreshold,
|
||
SettingKeyBalanceLowNotifyRechargeURL,
|
||
SettingKeyAccountQuotaNotifyEnabled,
|
||
SettingKeyChannelMonitorEnabled,
|
||
SettingKeyChannelMonitorDefaultIntervalSeconds,
|
||
SettingKeyAvailableChannelsEnabled,
|
||
SettingKeyAffiliateEnabled,
|
||
SettingKeyRiskControlEnabled,
|
||
}
|
||
|
||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get public settings: %w", err)
|
||
}
|
||
|
||
linuxDoEnabled := false
|
||
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
|
||
linuxDoEnabled = raw == "true"
|
||
} else {
|
||
linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled
|
||
}
|
||
dingTalkEnabled := false
|
||
if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok {
|
||
dingTalkEnabled = raw == "true"
|
||
} else {
|
||
dingTalkEnabled = s.cfg != nil && s.cfg.DingTalk.Enabled
|
||
}
|
||
oidcEnabled := false
|
||
if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
|
||
oidcEnabled = raw == "true"
|
||
} else {
|
||
oidcEnabled = s.cfg != nil && s.cfg.OIDC.Enabled
|
||
}
|
||
oidcProviderName := strings.TrimSpace(settings[SettingKeyOIDCConnectProviderName])
|
||
if oidcProviderName == "" && s.cfg != nil {
|
||
oidcProviderName = strings.TrimSpace(s.cfg.OIDC.ProviderName)
|
||
}
|
||
if oidcProviderName == "" {
|
||
oidcProviderName = "OIDC"
|
||
}
|
||
gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github")
|
||
googleEnabled := s.emailOAuthPublicEnabled(settings, "google")
|
||
weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings)
|
||
|
||
// Password reset requires email verification to be enabled
|
||
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
||
passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true"
|
||
registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist(
|
||
settings[SettingKeyRegistrationEmailSuffixWhitelist],
|
||
)
|
||
tableDefaultPageSize, tablePageSizeOptions := parseTablePreferences(
|
||
settings[SettingKeyTableDefaultPageSize],
|
||
settings[SettingKeyTablePageSizeOptions],
|
||
)
|
||
loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments])
|
||
loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt])
|
||
if loginAgreementUpdatedAt == "" {
|
||
loginAgreementUpdatedAt = defaultLoginAgreementDate
|
||
}
|
||
|
||
var balanceLowNotifyThreshold float64
|
||
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
|
||
balanceLowNotifyThreshold = v
|
||
}
|
||
|
||
return &PublicSettings{
|
||
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
||
EmailVerifyEnabled: emailVerifyEnabled,
|
||
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
|
||
RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
|
||
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
||
PasswordResetEnabled: passwordResetEnabled,
|
||
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
||
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
||
LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0,
|
||
LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
|
||
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
|
||
LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments),
|
||
LoginAgreementDocuments: loginAgreementDocuments,
|
||
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
||
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
||
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]),
|
||
TableDefaultPageSize: tableDefaultPageSize,
|
||
TablePageSizeOptions: tablePageSizeOptions,
|
||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
||
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||
DingTalkOAuthEnabled: dingTalkEnabled,
|
||
WeChatOAuthEnabled: weChatEnabled,
|
||
WeChatOAuthOpenEnabled: weChatOpenEnabled,
|
||
WeChatOAuthMPEnabled: weChatMPEnabled,
|
||
WeChatOAuthMobileEnabled: weChatMobileEnabled,
|
||
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
||
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
|
||
OIDCOAuthEnabled: oidcEnabled,
|
||
OIDCOAuthProviderName: oidcProviderName,
|
||
GitHubOAuthEnabled: gitHubEnabled,
|
||
GoogleOAuthEnabled: googleEnabled,
|
||
BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
|
||
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
|
||
BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
|
||
BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL],
|
||
|
||
ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]),
|
||
ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
|
||
|
||
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
|
||
|
||
AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
|
||
|
||
RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true",
|
||
}, nil
|
||
}
|
||
|
||
// channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval
|
||
// (mirrors the monitor-level constraint but lives here so setting_service stays decoupled).
|
||
const (
|
||
channelMonitorIntervalMin = 15
|
||
channelMonitorIntervalMax = 3600
|
||
channelMonitorIntervalFallback = 60
|
||
)
|
||
|
||
// 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 {
|
||
Enabled bool
|
||
DefaultIntervalSeconds int
|
||
}
|
||
|
||
// 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]),
|
||
}
|
||
}
|
||
|
||
// 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",
|
||
}
|
||
}
|
||
|
||
// GetAntigravityUserAgentVersion 返回 Antigravity 上游请求使用的版本号。
|
||
// 后台设置优先;为空、缺失或非法时回退到 ANTIGRAVITY_USER_AGENT_VERSION / 内置默认值。
|
||
func (s *SettingService) GetAntigravityUserAgentVersion(ctx context.Context) string {
|
||
fallback := antigravity.GetDefaultUserAgentVersion()
|
||
if s == nil || s.settingRepo == nil {
|
||
return fallback
|
||
}
|
||
if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil {
|
||
if time.Now().UnixNano() < cached.expiresAt {
|
||
return cached.version
|
||
}
|
||
}
|
||
|
||
result, _, _ := s.antigravityUAVersionSF.Do("antigravity_user_agent_version", func() (any, error) {
|
||
if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil {
|
||
if time.Now().UnixNano() < cached.expiresAt {
|
||
return cached.version, nil
|
||
}
|
||
}
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), antigravityUserAgentVersionDBTimeout)
|
||
defer cancel()
|
||
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyAntigravityUserAgentVersion)
|
||
if err != nil && !errors.Is(err, ErrSettingNotFound) {
|
||
slog.Warn("failed to get antigravity user agent version setting", "error", err)
|
||
s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{
|
||
version: fallback,
|
||
expiresAt: time.Now().Add(antigravityUserAgentVersionErrorTTL).UnixNano(),
|
||
})
|
||
return fallback, nil
|
||
}
|
||
version := antigravity.NormalizeUserAgentVersion(value)
|
||
if version == "" {
|
||
version = fallback
|
||
}
|
||
s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{
|
||
version: version,
|
||
expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(),
|
||
})
|
||
return version, nil
|
||
})
|
||
if version, ok := result.(string); ok && version != "" {
|
||
return version
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// GetOpenAICodexUserAgent 返回 OpenAI Codex 上游请求使用的 User-Agent。
|
||
// 后台设置优先;为空时回退到内置默认值。
|
||
func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string {
|
||
fallback := DefaultOpenAICodexUserAgent
|
||
if s == nil || s.settingRepo == nil {
|
||
return fallback
|
||
}
|
||
if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil {
|
||
if time.Now().UnixNano() < cached.expiresAt {
|
||
return cached.value
|
||
}
|
||
}
|
||
|
||
result, _, _ := s.openAICodexUASF.Do("openai_codex_user_agent", func() (any, error) {
|
||
if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil {
|
||
if time.Now().UnixNano() < cached.expiresAt {
|
||
return cached.value, nil
|
||
}
|
||
}
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAICodexUserAgentDBTimeout)
|
||
defer cancel()
|
||
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAICodexUserAgent)
|
||
if err != nil && !errors.Is(err, ErrSettingNotFound) {
|
||
slog.Warn("failed to get openai codex user agent setting", "error", err)
|
||
s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{
|
||
value: fallback,
|
||
expiresAt: time.Now().Add(openAICodexUserAgentErrorTTL).UnixNano(),
|
||
})
|
||
return fallback, nil
|
||
}
|
||
ua := strings.TrimSpace(value)
|
||
if ua == "" {
|
||
ua = fallback
|
||
}
|
||
s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{
|
||
value: ua,
|
||
expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(),
|
||
})
|
||
return ua, nil
|
||
})
|
||
if ua, ok := result.(string); ok && ua != "" {
|
||
return ua
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// IsOpenAIAllowClaudeCodeCodexPluginEnabled 全局开关:是否额外放行 Claude Code 的 Codex 插件(默认关闭)。
|
||
// 仅在调用方已确认账号 codex_cli_only 开启时读取,避免对非受限账号产生无谓查询。
|
||
// 使用进程内 atomic.Value 缓存(60s TTL),避免在每个网关请求热路径上访问 DB。
|
||
func (s *SettingService) IsOpenAIAllowClaudeCodeCodexPluginEnabled(ctx context.Context) bool {
|
||
if cached, ok := s.openAIAllowCodexPluginCache.Load().(*cachedOpenAIAllowCodexPlugin); ok && cached != nil {
|
||
if time.Now().UnixNano() < cached.expiresAt {
|
||
return cached.value
|
||
}
|
||
}
|
||
result, _, _ := s.openAIAllowCodexPluginSF.Do("openai_allow_codex_plugin_enabled", func() (any, error) {
|
||
if cached, ok := s.openAIAllowCodexPluginCache.Load().(*cachedOpenAIAllowCodexPlugin); ok && cached != nil {
|
||
if time.Now().UnixNano() < cached.expiresAt {
|
||
return cached.value, nil
|
||
}
|
||
}
|
||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIAllowCodexPluginDBTimeout)
|
||
defer cancel()
|
||
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAIAllowClaudeCodeCodexPlugin)
|
||
if err != nil {
|
||
if errors.Is(err, ErrSettingNotFound) {
|
||
// 设置不存在 → 默认关闭,正常 TTL 缓存
|
||
s.openAIAllowCodexPluginCache.Store(&cachedOpenAIAllowCodexPlugin{
|
||
value: false,
|
||
expiresAt: time.Now().Add(openAIAllowCodexPluginCacheTTL).UnixNano(),
|
||
})
|
||
return false, nil
|
||
}
|
||
slog.Warn("failed to get openai_allow_claude_code_codex_plugin setting", "error", err)
|
||
// DB 错误 → 安全默认关闭,短 TTL 快速重试
|
||
s.openAIAllowCodexPluginCache.Store(&cachedOpenAIAllowCodexPlugin{
|
||
value: false,
|
||
expiresAt: time.Now().Add(openAIAllowCodexPluginErrorTTL).UnixNano(),
|
||
})
|
||
return false, nil
|
||
}
|
||
enabled := value == "true"
|
||
s.openAIAllowCodexPluginCache.Store(&cachedOpenAIAllowCodexPlugin{
|
||
value: enabled,
|
||
expiresAt: time.Now().Add(openAIAllowCodexPluginCacheTTL).UnixNano(),
|
||
})
|
||
return enabled, nil
|
||
})
|
||
if val, ok := result.(bool); ok {
|
||
return val
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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"`
|
||
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
|
||
LoginAgreementMode string `json:"login_agreement_mode"`
|
||
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
|
||
LoginAgreementRevision string `json:"login_agreement_revision"`
|
||
LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
|
||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||
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"`
|
||
DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"`
|
||
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
|
||
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
|
||
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
|
||
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
|
||
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
||
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
||
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
|
||
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
|
||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||
PaymentEnabled bool `json:"payment_enabled"`
|
||
Version string `json:"version"`
|
||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
||
|
||
// Feature flags — MUST match the opt-in/opt-out registry in
|
||
// frontend/src/utils/featureFlags.ts. Missing a field here is the bug
|
||
// that hid the "可用渠道" menu on page refresh.
|
||
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
|
||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||
}
|
||
|
||
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
|
||
// This implements the web.PublicSettingsProvider interface.
|
||
func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) {
|
||
settings, err := s.GetPublicSettings(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &PublicSettingsInjectionPayload{
|
||
RegistrationEnabled: settings.RegistrationEnabled,
|
||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
|
||
PromoCodeEnabled: settings.PromoCodeEnabled,
|
||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||
TotpEnabled: settings.TotpEnabled,
|
||
LoginAgreementEnabled: settings.LoginAgreementEnabled,
|
||
LoginAgreementMode: settings.LoginAgreementMode,
|
||
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
|
||
LoginAgreementRevision: settings.LoginAgreementRevision,
|
||
LoginAgreementDocuments: settings.LoginAgreementDocuments,
|
||
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,
|
||
TableDefaultPageSize: settings.TableDefaultPageSize,
|
||
TablePageSizeOptions: settings.TablePageSizeOptions,
|
||
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
||
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||
DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
|
||
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
|
||
WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
|
||
WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
|
||
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
|
||
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
||
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
||
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
|
||
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
|
||
BackendModeEnabled: settings.BackendModeEnabled,
|
||
PaymentEnabled: settings.PaymentEnabled,
|
||
Version: s.version,
|
||
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
||
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
||
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
||
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
|
||
|
||
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
|
||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||
AffiliateEnabled: settings.AffiliateEnabled,
|
||
RiskControlEnabled: settings.RiskControlEnabled,
|
||
}, nil
|
||
}
|
||
|
||
func DefaultWeChatConnectScopesForMode(mode string) string {
|
||
return defaultWeChatConnectScopeForMode(mode)
|
||
}
|
||
|
||
func (s *SettingService) parseWeChatConnectOAuthConfig(settings map[string]string) (WeChatConnectOAuthConfig, error) {
|
||
cfg := s.effectiveWeChatConnectOAuthConfig(settings)
|
||
|
||
if !cfg.Enabled || (!cfg.OpenEnabled && !cfg.MPEnabled) {
|
||
return WeChatConnectOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled")
|
||
}
|
||
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")
|
||
}
|
||
}
|
||
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")
|
||
}
|
||
}
|
||
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")
|
||
}
|
||
}
|
||
if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil {
|
||
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth frontend redirect url invalid")
|
||
}
|
||
return cfg, nil
|
||
}
|
||
|
||
func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string]string) (bool, bool, bool, bool) {
|
||
cfg := s.effectiveWeChatConnectOAuthConfig(settings)
|
||
if !cfg.Enabled {
|
||
return false, false, false, false
|
||
}
|
||
|
||
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") != ""
|
||
|
||
return openReady || mpReady, openReady, mpReady, mobileReady
|
||
}
|
||
|
||
func (s *SettingService) emailOAuthBaseConfig(provider string) config.EmailOAuthProviderConfig {
|
||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||
case "github":
|
||
cfg := config.EmailOAuthProviderConfig{
|
||
AuthorizeURL: defaultGitHubOAuthAuthorize,
|
||
TokenURL: defaultGitHubOAuthToken,
|
||
UserInfoURL: defaultGitHubOAuthUserInfo,
|
||
EmailsURL: defaultGitHubOAuthEmails,
|
||
Scopes: defaultGitHubOAuthScopes,
|
||
FrontendRedirectURL: defaultGitHubOAuthFrontend,
|
||
}
|
||
if s != nil && s.cfg != nil {
|
||
cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GitHubOAuth)
|
||
}
|
||
return cfg
|
||
case "google":
|
||
cfg := config.EmailOAuthProviderConfig{
|
||
AuthorizeURL: defaultGoogleOAuthAuthorize,
|
||
TokenURL: defaultGoogleOAuthToken,
|
||
UserInfoURL: defaultGoogleOAuthUserInfo,
|
||
Scopes: defaultGoogleOAuthScopes,
|
||
FrontendRedirectURL: defaultGoogleOAuthFrontend,
|
||
}
|
||
if s != nil && s.cfg != nil {
|
||
cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GoogleOAuth)
|
||
}
|
||
return cfg
|
||
default:
|
||
return config.EmailOAuthProviderConfig{}
|
||
}
|
||
}
|
||
|
||
func mergeEmailOAuthBaseConfig(base, override config.EmailOAuthProviderConfig) config.EmailOAuthProviderConfig {
|
||
base.Enabled = override.Enabled
|
||
if strings.TrimSpace(override.ClientID) != "" {
|
||
base.ClientID = strings.TrimSpace(override.ClientID)
|
||
}
|
||
if strings.TrimSpace(override.ClientSecret) != "" {
|
||
base.ClientSecret = strings.TrimSpace(override.ClientSecret)
|
||
}
|
||
if strings.TrimSpace(override.AuthorizeURL) != "" {
|
||
base.AuthorizeURL = strings.TrimSpace(override.AuthorizeURL)
|
||
}
|
||
if strings.TrimSpace(override.TokenURL) != "" {
|
||
base.TokenURL = strings.TrimSpace(override.TokenURL)
|
||
}
|
||
if strings.TrimSpace(override.UserInfoURL) != "" {
|
||
base.UserInfoURL = strings.TrimSpace(override.UserInfoURL)
|
||
}
|
||
if strings.TrimSpace(override.EmailsURL) != "" {
|
||
base.EmailsURL = strings.TrimSpace(override.EmailsURL)
|
||
}
|
||
if strings.TrimSpace(override.Scopes) != "" {
|
||
base.Scopes = strings.TrimSpace(override.Scopes)
|
||
}
|
||
if strings.TrimSpace(override.RedirectURL) != "" {
|
||
base.RedirectURL = strings.TrimSpace(override.RedirectURL)
|
||
}
|
||
if strings.TrimSpace(override.FrontendRedirectURL) != "" {
|
||
base.FrontendRedirectURL = strings.TrimSpace(override.FrontendRedirectURL)
|
||
}
|
||
return base
|
||
}
|
||
|
||
func (s *SettingService) emailOAuthPublicEnabled(settings map[string]string, provider string) bool {
|
||
cfg := s.effectiveEmailOAuthConfig(settings, provider)
|
||
return cfg.Enabled && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.ClientSecret) != ""
|
||
}
|
||
|
||
func (s *SettingService) effectiveEmailOAuthConfig(settings map[string]string, provider string) config.EmailOAuthProviderConfig {
|
||
cfg := s.emailOAuthBaseConfig(provider)
|
||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||
case "github":
|
||
if raw, ok := settings[SettingKeyGitHubOAuthEnabled]; ok {
|
||
cfg.Enabled = raw == "true"
|
||
}
|
||
cfg.ClientID = firstNonEmpty(settings[SettingKeyGitHubOAuthClientID], cfg.ClientID)
|
||
cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGitHubOAuthClientSecret], cfg.ClientSecret)
|
||
cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthRedirectURL], cfg.RedirectURL)
|
||
cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGitHubOAuthFrontend)
|
||
case "google":
|
||
if raw, ok := settings[SettingKeyGoogleOAuthEnabled]; ok {
|
||
cfg.Enabled = raw == "true"
|
||
}
|
||
cfg.ClientID = firstNonEmpty(settings[SettingKeyGoogleOAuthClientID], cfg.ClientID)
|
||
cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGoogleOAuthClientSecret], cfg.ClientSecret)
|
||
cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthRedirectURL], cfg.RedirectURL)
|
||
cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGoogleOAuthFrontend)
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
|
||
// array string, returning only items with visibility != "admin".
|
||
func filterUserVisibleMenuItems(raw string) json.RawMessage {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" || raw == "[]" {
|
||
return json.RawMessage("[]")
|
||
}
|
||
var items []struct {
|
||
Visibility string `json:"visibility"`
|
||
}
|
||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||
return json.RawMessage("[]")
|
||
}
|
||
|
||
// Parse full items to preserve all fields
|
||
var fullItems []json.RawMessage
|
||
if err := json.Unmarshal([]byte(raw), &fullItems); err != nil {
|
||
return json.RawMessage("[]")
|
||
}
|
||
|
||
var filtered []json.RawMessage
|
||
for i, item := range items {
|
||
if item.Visibility != "admin" {
|
||
filtered = append(filtered, fullItems[i])
|
||
}
|
||
}
|
||
if len(filtered) == 0 {
|
||
return json.RawMessage("[]")
|
||
}
|
||
result, err := json.Marshal(filtered)
|
||
if err != nil {
|
||
return json.RawMessage("[]")
|
||
}
|
||
return result
|
||
}
|
||
|
||
// safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]".
|
||
func safeRawJSONArray(raw string) json.RawMessage {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return json.RawMessage("[]")
|
||
}
|
||
if json.Valid([]byte(raw)) {
|
||
return json.RawMessage(raw)
|
||
}
|
||
return json.RawMessage("[]")
|
||
}
|
||
|
||
// GetFrameSrcOrigins returns deduplicated http(s) origins from home_content URL,
|
||
// purchase_subscription_url, and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection.
|
||
func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) {
|
||
settings, err := s.GetPublicSettings(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
seen := make(map[string]struct{})
|
||
var origins []string
|
||
|
||
addOrigin := func(rawURL string) {
|
||
if origin := extractOriginFromURL(rawURL); origin != "" {
|
||
if _, ok := seen[origin]; !ok {
|
||
seen[origin] = struct{}{}
|
||
origins = append(origins, origin)
|
||
}
|
||
}
|
||
}
|
||
|
||
// home content URL (when home_content is set to a URL for iframe embedding)
|
||
addOrigin(settings.HomeContent)
|
||
|
||
// purchase subscription URL
|
||
if settings.PurchaseSubscriptionEnabled {
|
||
addOrigin(settings.PurchaseSubscriptionURL)
|
||
}
|
||
|
||
// all custom menu items (including admin-only, since CSP must allow all iframes)
|
||
for _, item := range parseCustomMenuItemURLs(settings.CustomMenuItems) {
|
||
addOrigin(item)
|
||
}
|
||
|
||
return origins, nil
|
||
}
|
||
|
||
// extractOriginFromURL returns the scheme+host origin from rawURL.
|
||
// Only http and https schemes are accepted.
|
||
func extractOriginFromURL(rawURL string) string {
|
||
rawURL = strings.TrimSpace(rawURL)
|
||
if rawURL == "" {
|
||
return ""
|
||
}
|
||
u, err := url.Parse(rawURL)
|
||
if err != nil || u.Host == "" {
|
||
return ""
|
||
}
|
||
if u.Scheme != "http" && u.Scheme != "https" {
|
||
return ""
|
||
}
|
||
return u.Scheme + "://" + u.Host
|
||
}
|
||
|
||
// parseCustomMenuItemURLs extracts URLs from a raw JSON array of custom menu items.
|
||
func parseCustomMenuItemURLs(raw string) []string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" || raw == "[]" {
|
||
return nil
|
||
}
|
||
var items []struct {
|
||
URL string `json:"url"`
|
||
}
|
||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||
return nil
|
||
}
|
||
urls := make([]string, 0, len(items))
|
||
for _, item := range items {
|
||
if item.URL != "" {
|
||
urls = append(urls, item.URL)
|
||
}
|
||
}
|
||
return urls
|
||
}
|
||
|
||
func oidcUsePKCECompatibilityDefault(base config.OIDCConnectConfig) bool {
|
||
if base.UsePKCEExplicit {
|
||
return base.UsePKCE
|
||
}
|
||
return true
|
||
}
|
||
|
||
func oidcValidateIDTokenCompatibilityDefault(base config.OIDCConnectConfig) bool {
|
||
if base.ValidateIDTokenExplicit {
|
||
return base.ValidateIDToken
|
||
}
|
||
return true
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// UpdateSettings 更新系统设置
|
||
func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error {
|
||
updates, err := s.buildSystemSettingsUpdates(ctx, settings)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
err = s.settingRepo.SetMultiple(ctx, updates)
|
||
if err == nil {
|
||
s.refreshCachedSettings(settings)
|
||
}
|
||
return err
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
normalizedWhitelist, err := NormalizeRegistrationEmailSuffixWhitelist(settings.RegistrationEmailSuffixWhitelist)
|
||
if err != nil {
|
||
return nil, infraerrors.BadRequest("INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", err.Error())
|
||
}
|
||
if normalizedWhitelist == nil {
|
||
normalizedWhitelist = []string{}
|
||
}
|
||
settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist
|
||
alipaySource, err := normalizeVisibleMethodSettingSource("alipay", settings.PaymentVisibleMethodAlipaySource, settings.PaymentVisibleMethodAlipayEnabled)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
wxpaySource, err := normalizeVisibleMethodSettingSource("wxpay", settings.PaymentVisibleMethodWxpaySource, settings.PaymentVisibleMethodWxpayEnabled)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
settings.PaymentVisibleMethodAlipaySource = alipaySource
|
||
settings.PaymentVisibleMethodWxpaySource = wxpaySource
|
||
settings.WeChatConnectAppID = strings.TrimSpace(settings.WeChatConnectAppID)
|
||
settings.WeChatConnectAppSecret = strings.TrimSpace(settings.WeChatConnectAppSecret)
|
||
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))
|
||
settings.WeChatConnectMode = normalizeWeChatConnectStoredMode(
|
||
settings.WeChatConnectOpenEnabled,
|
||
settings.WeChatConnectMPEnabled,
|
||
settings.WeChatConnectMobileEnabled,
|
||
settings.WeChatConnectMode,
|
||
)
|
||
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
|
||
}
|
||
settings.GitHubOAuthRedirectURL = strings.TrimSpace(settings.GitHubOAuthRedirectURL)
|
||
settings.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(settings.GitHubOAuthFrontendRedirectURL)
|
||
if settings.GitHubOAuthFrontendRedirectURL == "" {
|
||
settings.GitHubOAuthFrontendRedirectURL = defaultGitHubOAuthFrontend
|
||
}
|
||
settings.GoogleOAuthRedirectURL = strings.TrimSpace(settings.GoogleOAuthRedirectURL)
|
||
settings.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(settings.GoogleOAuthFrontendRedirectURL)
|
||
if settings.GoogleOAuthFrontendRedirectURL == "" {
|
||
settings.GoogleOAuthFrontendRedirectURL = defaultGoogleOAuthFrontend
|
||
}
|
||
|
||
updates := make(map[string]string)
|
||
|
||
// 注册设置
|
||
updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled)
|
||
updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled)
|
||
registrationEmailSuffixWhitelistJSON, err := json.Marshal(settings.RegistrationEmailSuffixWhitelist)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("marshal registration email suffix whitelist: %w", err)
|
||
}
|
||
updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON)
|
||
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
|
||
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
|
||
updates[SettingKeyFrontendURL] = settings.FrontendURL
|
||
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
|
||
updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled)
|
||
settings.LoginAgreementMode = normalizeLoginAgreementMode(settings.LoginAgreementMode)
|
||
settings.LoginAgreementUpdatedAt = strings.TrimSpace(settings.LoginAgreementUpdatedAt)
|
||
if settings.LoginAgreementUpdatedAt == "" {
|
||
settings.LoginAgreementUpdatedAt = defaultLoginAgreementDate
|
||
}
|
||
loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(settings.LoginAgreementDocuments)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
updates[SettingKeyLoginAgreementEnabled] = strconv.FormatBool(settings.LoginAgreementEnabled)
|
||
updates[SettingKeyLoginAgreementMode] = settings.LoginAgreementMode
|
||
updates[SettingKeyLoginAgreementUpdatedAt] = settings.LoginAgreementUpdatedAt
|
||
updates[SettingKeyLoginAgreementDocuments] = loginAgreementDocumentsJSON
|
||
|
||
// 邮件服务设置(只有非空才更新密码)
|
||
updates[SettingKeySMTPHost] = settings.SMTPHost
|
||
updates[SettingKeySMTPPort] = strconv.Itoa(settings.SMTPPort)
|
||
updates[SettingKeySMTPUsername] = settings.SMTPUsername
|
||
if settings.SMTPPassword != "" {
|
||
updates[SettingKeySMTPPassword] = settings.SMTPPassword
|
||
}
|
||
updates[SettingKeySMTPFrom] = settings.SMTPFrom
|
||
updates[SettingKeySMTPFromName] = settings.SMTPFromName
|
||
updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS)
|
||
|
||
// Cloudflare Turnstile 设置(只有非空才更新密钥)
|
||
updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled)
|
||
updates[SettingKeyTurnstileSiteKey] = settings.TurnstileSiteKey
|
||
if settings.TurnstileSecretKey != "" {
|
||
updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey
|
||
}
|
||
updates[SettingKeyAPIKeyACLTrustForwardedIP] = strconv.FormatBool(settings.APIKeyACLTrustForwardedIP)
|
||
|
||
// LinuxDo Connect OAuth 登录
|
||
updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled)
|
||
updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID
|
||
updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL
|
||
if settings.LinuxDoConnectClientSecret != "" {
|
||
updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret
|
||
}
|
||
|
||
// DingTalk Connect OAuth 登录
|
||
updates[SettingKeyDingTalkConnectEnabled] = strconv.FormatBool(settings.DingTalkConnectEnabled)
|
||
updates[SettingKeyDingTalkConnectClientID] = settings.DingTalkConnectClientID
|
||
updates[SettingKeyDingTalkConnectRedirectURL] = settings.DingTalkConnectRedirectURL
|
||
if settings.DingTalkConnectClientSecret != "" {
|
||
updates[SettingKeyDingTalkConnectClientSecret] = settings.DingTalkConnectClientSecret
|
||
}
|
||
updates[SettingKeyDingTalkConnectCorpRestrictionPolicy] = settings.DingTalkConnectCorpRestrictionPolicy
|
||
updates[SettingKeyDingTalkConnectInternalCorpID] = settings.DingTalkConnectInternalCorpID
|
||
updates[SettingKeyDingTalkConnectBypassRegistration] = strconv.FormatBool(settings.DingTalkConnectBypassRegistration)
|
||
updates[SettingKeyDingTalkConnectSyncCorpEmail] = strconv.FormatBool(settings.DingTalkConnectSyncCorpEmail)
|
||
updates[SettingKeyDingTalkConnectSyncDisplayName] = strconv.FormatBool(settings.DingTalkConnectSyncDisplayName)
|
||
updates[SettingKeyDingTalkConnectSyncDept] = strconv.FormatBool(settings.DingTalkConnectSyncDept)
|
||
updates[SettingKeyDingTalkConnectSyncCorpEmailAttrKey] = settings.DingTalkConnectSyncCorpEmailAttrKey
|
||
updates[SettingKeyDingTalkConnectSyncDisplayNameAttrKey] = settings.DingTalkConnectSyncDisplayNameAttrKey
|
||
updates[SettingKeyDingTalkConnectSyncDeptAttrKey] = settings.DingTalkConnectSyncDeptAttrKey
|
||
updates[SettingKeyDingTalkConnectSyncCorpEmailAttrName] = settings.DingTalkConnectSyncCorpEmailAttrName
|
||
updates[SettingKeyDingTalkConnectSyncDisplayNameAttrName] = settings.DingTalkConnectSyncDisplayNameAttrName
|
||
updates[SettingKeyDingTalkConnectSyncDeptAttrName] = settings.DingTalkConnectSyncDeptAttrName
|
||
|
||
// 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
|
||
}
|
||
|
||
// GitHub / Google 邮箱快捷登录
|
||
updates[SettingKeyGitHubOAuthEnabled] = strconv.FormatBool(settings.GitHubOAuthEnabled)
|
||
updates[SettingKeyGitHubOAuthClientID] = strings.TrimSpace(settings.GitHubOAuthClientID)
|
||
updates[SettingKeyGitHubOAuthRedirectURL] = settings.GitHubOAuthRedirectURL
|
||
updates[SettingKeyGitHubOAuthFrontendRedirectURL] = settings.GitHubOAuthFrontendRedirectURL
|
||
if settings.GitHubOAuthClientSecret != "" {
|
||
updates[SettingKeyGitHubOAuthClientSecret] = strings.TrimSpace(settings.GitHubOAuthClientSecret)
|
||
}
|
||
updates[SettingKeyGoogleOAuthEnabled] = strconv.FormatBool(settings.GoogleOAuthEnabled)
|
||
updates[SettingKeyGoogleOAuthClientID] = strings.TrimSpace(settings.GoogleOAuthClientID)
|
||
updates[SettingKeyGoogleOAuthRedirectURL] = settings.GoogleOAuthRedirectURL
|
||
updates[SettingKeyGoogleOAuthFrontendRedirectURL] = settings.GoogleOAuthFrontendRedirectURL
|
||
if settings.GoogleOAuthClientSecret != "" {
|
||
updates[SettingKeyGoogleOAuthClientSecret] = strings.TrimSpace(settings.GoogleOAuthClientSecret)
|
||
}
|
||
|
||
// WeChat Connect OAuth 登录
|
||
updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled)
|
||
updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID
|
||
updates[SettingKeyWeChatConnectOpenAppID] = settings.WeChatConnectOpenAppID
|
||
updates[SettingKeyWeChatConnectMPAppID] = settings.WeChatConnectMPAppID
|
||
updates[SettingKeyWeChatConnectMobileAppID] = settings.WeChatConnectMobileAppID
|
||
updates[SettingKeyWeChatConnectOpenEnabled] = strconv.FormatBool(settings.WeChatConnectOpenEnabled)
|
||
updates[SettingKeyWeChatConnectMPEnabled] = strconv.FormatBool(settings.WeChatConnectMPEnabled)
|
||
updates[SettingKeyWeChatConnectMobileEnabled] = strconv.FormatBool(settings.WeChatConnectMobileEnabled)
|
||
updates[SettingKeyWeChatConnectMode] = settings.WeChatConnectMode
|
||
updates[SettingKeyWeChatConnectScopes] = settings.WeChatConnectScopes
|
||
updates[SettingKeyWeChatConnectRedirectURL] = settings.WeChatConnectRedirectURL
|
||
updates[SettingKeyWeChatConnectFrontendRedirectURL] = settings.WeChatConnectFrontendRedirectURL
|
||
if settings.WeChatConnectAppSecret != "" {
|
||
updates[SettingKeyWeChatConnectAppSecret] = settings.WeChatConnectAppSecret
|
||
}
|
||
if settings.WeChatConnectOpenAppSecret != "" {
|
||
updates[SettingKeyWeChatConnectOpenAppSecret] = settings.WeChatConnectOpenAppSecret
|
||
}
|
||
if settings.WeChatConnectMPAppSecret != "" {
|
||
updates[SettingKeyWeChatConnectMPAppSecret] = settings.WeChatConnectMPAppSecret
|
||
}
|
||
if settings.WeChatConnectMobileAppSecret != "" {
|
||
updates[SettingKeyWeChatConnectMobileAppSecret] = settings.WeChatConnectMobileAppSecret
|
||
}
|
||
|
||
// OEM设置
|
||
updates[SettingKeySiteName] = settings.SiteName
|
||
updates[SettingKeySiteLogo] = settings.SiteLogo
|
||
updates[SettingKeySiteSubtitle] = settings.SiteSubtitle
|
||
updates[SettingKeyAPIBaseURL] = settings.APIBaseURL
|
||
updates[SettingKeyContactInfo] = settings.ContactInfo
|
||
updates[SettingKeyDocURL] = settings.DocURL
|
||
updates[SettingKeyHomeContent] = settings.HomeContent
|
||
updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton)
|
||
updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled)
|
||
updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL)
|
||
tableDefaultPageSize, tablePageSizeOptions := normalizeTablePreferences(
|
||
settings.TableDefaultPageSize,
|
||
settings.TablePageSizeOptions,
|
||
)
|
||
updates[SettingKeyTableDefaultPageSize] = strconv.Itoa(tableDefaultPageSize)
|
||
tablePageSizeOptionsJSON, err := json.Marshal(tablePageSizeOptions)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("marshal table page size options: %w", err)
|
||
}
|
||
updates[SettingKeyTablePageSizeOptions] = string(tablePageSizeOptionsJSON)
|
||
updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems
|
||
updates[SettingKeyCustomEndpoints] = settings.CustomEndpoints
|
||
|
||
// 默认配置
|
||
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
|
||
updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64)
|
||
settings.AffiliateRebateRate = clampAffiliateRebateRate(settings.AffiliateRebateRate)
|
||
updates[SettingKeyAffiliateRebateRate] = strconv.FormatFloat(settings.AffiliateRebateRate, 'f', 8, 64)
|
||
if settings.AffiliateRebateFreezeHours < 0 {
|
||
settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursDefault
|
||
}
|
||
if settings.AffiliateRebateFreezeHours > AffiliateRebateFreezeHoursMax {
|
||
settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursMax
|
||
}
|
||
updates[SettingKeyAffiliateRebateFreezeHours] = strconv.Itoa(settings.AffiliateRebateFreezeHours)
|
||
if settings.AffiliateRebateDurationDays < 0 {
|
||
settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysDefault
|
||
}
|
||
if settings.AffiliateRebateDurationDays > AffiliateRebateDurationDaysMax {
|
||
settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysMax
|
||
}
|
||
updates[SettingKeyAffiliateRebateDurationDays] = strconv.Itoa(settings.AffiliateRebateDurationDays)
|
||
if settings.AffiliateRebatePerInviteeCap < 0 {
|
||
settings.AffiliateRebatePerInviteeCap = AffiliateRebatePerInviteeCapDefault
|
||
}
|
||
updates[SettingKeyAffiliateRebatePerInviteeCap] = strconv.FormatFloat(settings.AffiliateRebatePerInviteeCap, 'f', 8, 64)
|
||
updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit)
|
||
defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("marshal default subscriptions: %w", err)
|
||
}
|
||
updates[SettingKeyDefaultSubscriptions] = string(defaultSubsJSON)
|
||
|
||
// 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
|
||
|
||
// Identity patch configuration (Claude -> Gemini)
|
||
updates[SettingKeyEnableIdentityPatch] = strconv.FormatBool(settings.EnableIdentityPatch)
|
||
updates[SettingKeyIdentityPatchPrompt] = settings.IdentityPatchPrompt
|
||
|
||
// Ops monitoring (vNext)
|
||
updates[SettingKeyOpsMonitoringEnabled] = strconv.FormatBool(settings.OpsMonitoringEnabled)
|
||
updates[SettingKeyOpsRealtimeMonitoringEnabled] = strconv.FormatBool(settings.OpsRealtimeMonitoringEnabled)
|
||
updates[SettingKeyOpsQueryModeDefault] = string(ParseOpsQueryMode(settings.OpsQueryModeDefault))
|
||
if settings.OpsMetricsIntervalSeconds > 0 {
|
||
updates[SettingKeyOpsMetricsIntervalSeconds] = strconv.Itoa(settings.OpsMetricsIntervalSeconds)
|
||
}
|
||
|
||
// Channel monitor feature switch
|
||
updates[SettingKeyChannelMonitorEnabled] = strconv.FormatBool(settings.ChannelMonitorEnabled)
|
||
if v := clampChannelMonitorInterval(settings.ChannelMonitorDefaultIntervalSeconds); v > 0 {
|
||
updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v)
|
||
}
|
||
|
||
// Available channels feature switch
|
||
updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled)
|
||
|
||
// Affiliate (邀请返利) feature switch
|
||
updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled)
|
||
|
||
// 风控中心功能开关
|
||
updates[SettingKeyRiskControlEnabled] = strconv.FormatBool(settings.RiskControlEnabled)
|
||
|
||
// Claude Code version check
|
||
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
|
||
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
|
||
|
||
// 分组隔离
|
||
updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling)
|
||
|
||
// Backend Mode
|
||
updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled)
|
||
|
||
// Gateway forwarding behavior
|
||
updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification)
|
||
updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough)
|
||
updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning)
|
||
updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection)
|
||
updates[SettingKeyRewriteMessageCacheControl] = strconv.FormatBool(settings.RewriteMessageCacheControl)
|
||
updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion)
|
||
updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent)
|
||
updates[SettingKeyOpenAIAllowClaudeCodeCodexPlugin] = strconv.FormatBool(settings.OpenAIAllowClaudeCodeCodexPlugin)
|
||
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)
|
||
|
||
// 余额、订阅到期与账号限额通知
|
||
updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled)
|
||
updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64)
|
||
updates[SettingKeyBalanceLowNotifyRechargeURL] = settings.BalanceLowNotifyRechargeURL
|
||
updates[SettingKeySubscriptionExpiryNotifyEnabled] = strconv.FormatBool(settings.SubscriptionExpiryNotifyEnabled)
|
||
updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled)
|
||
updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails)
|
||
|
||
// 系统全局 platform quota:整体替换语义(null/缺省 = 不限制)。
|
||
if settings.DefaultPlatformQuotas != nil {
|
||
if err := validateDefaultPlatformQuotaMap(settings.DefaultPlatformQuotas); err != nil {
|
||
return nil, err
|
||
}
|
||
blob, err := json.Marshal(settings.DefaultPlatformQuotas)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("marshal default platform quotas: %w", err)
|
||
}
|
||
updates[SettingKeyDefaultPlatformQuotas] = string(blob)
|
||
}
|
||
|
||
return updates, nil
|
||
}
|
||
|
||
// validateDefaultPlatformQuotaMap 校验 platform quota map 的合法性:
|
||
// 平台名须在 AllowedQuotaPlatforms 白名单内,每个非 nil 上限须 finite 且 >= 0。
|
||
// 系统层和 auth-source 层共用此 helper。
|
||
func validateDefaultPlatformQuotaMap(m map[string]*DefaultPlatformQuotaSetting) error {
|
||
for platform, pq := range m {
|
||
if !IsAllowedQuotaPlatform(platform) {
|
||
return infraerrors.BadRequest("INVALID_DEFAULT_PLATFORM_QUOTA", fmt.Sprintf("unknown platform %q", platform))
|
||
}
|
||
if pq == nil {
|
||
continue
|
||
}
|
||
for _, v := range []*float64{pq.DailyLimitUSD, pq.WeeklyLimitUSD, pq.MonthlyLimitUSD} {
|
||
if v != nil && (*v < 0 || math.IsNaN(*v) || math.IsInf(*v, 0)) {
|
||
return infraerrors.BadRequest("INVALID_DEFAULT_PLATFORM_QUOTA", "platform quota limit must be a finite non-negative number")
|
||
}
|
||
}
|
||
}
|
||
return 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,
|
||
settings.GitHub.Subscriptions,
|
||
settings.Google.Subscriptions,
|
||
settings.DingTalk.Subscriptions,
|
||
} {
|
||
if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
// 校验各 auth source 的 platform quota map(改动 C:对等系统层校验)
|
||
for _, pgs := range []struct {
|
||
name string
|
||
pq map[string]*DefaultPlatformQuotaSetting
|
||
}{
|
||
{"email", settings.Email.PlatformQuotas},
|
||
{"linuxdo", settings.LinuxDo.PlatformQuotas},
|
||
{"oidc", settings.OIDC.PlatformQuotas},
|
||
{"wechat", settings.WeChat.PlatformQuotas},
|
||
{"github", settings.GitHub.PlatformQuotas},
|
||
{"google", settings.Google.PlatformQuotas},
|
||
{"dingtalk", settings.DingTalk.PlatformQuotas},
|
||
} {
|
||
if pgs.pq != nil {
|
||
if err := validateDefaultPlatformQuotaMap(pgs.pq); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
}
|
||
|
||
updates := make(map[string]string, 36)
|
||
writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email)
|
||
writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo)
|
||
writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC)
|
||
writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat)
|
||
writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub)
|
||
writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google)
|
||
writeProviderDefaultGrantUpdates(updates, dingTalkAuthSourceDefaultKeys, settings.DingTalk)
|
||
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,
|
||
anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
|
||
rewriteMessageCacheControl: settings.RewriteMessageCacheControl,
|
||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||
})
|
||
s.antigravityUAVersionSF.Forget("antigravity_user_agent_version")
|
||
antigravityUserAgentVersion := antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion)
|
||
if antigravityUserAgentVersion == "" {
|
||
antigravityUserAgentVersion = antigravity.GetDefaultUserAgentVersion()
|
||
}
|
||
s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{
|
||
version: antigravityUserAgentVersion,
|
||
expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(),
|
||
})
|
||
s.openAICodexUASF.Forget("openai_codex_user_agent")
|
||
codexUA := strings.TrimSpace(settings.OpenAICodexUserAgent)
|
||
if codexUA == "" {
|
||
codexUA = DefaultOpenAICodexUserAgent
|
||
}
|
||
s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{
|
||
value: codexUA,
|
||
expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(),
|
||
})
|
||
openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey)
|
||
openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{
|
||
enabled: settings.OpenAIAdvancedSchedulerEnabled,
|
||
expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(),
|
||
})
|
||
// Invalidate the quota auto-pause cache and let the next read trigger a fresh load.
|
||
// We can't know from here whether ops_advanced_settings was also touched, so be
|
||
// defensive: store an expired entry — GetOpenAIQuotaAutoPauseSettings will serve
|
||
// stale and kick off an async refresh, never blocking the request that follows.
|
||
s.openAIQuotaAutoPauseSettingsSF.Forget(openAIQuotaAutoPauseSettingsRefreshKey)
|
||
if cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); cached != nil {
|
||
s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{
|
||
settings: cached.settings,
|
||
expiresAt: 0,
|
||
})
|
||
}
|
||
if s.cfg != nil {
|
||
s.cfg.SetTrustForwardedIPForAPIKeyACL(settings.APIKeyACLTrustForwardedIP)
|
||
}
|
||
s.openAIAllowCodexPluginSF.Forget("openai_allow_codex_plugin_enabled")
|
||
s.openAIAllowCodexPluginCache.Store(&cachedOpenAIAllowCodexPlugin{
|
||
value: settings.OpenAIAllowClaudeCodeCodexPlugin,
|
||
expiresAt: time.Now().Add(openAIAllowCodexPluginCacheTTL).UnixNano(),
|
||
})
|
||
if s.onUpdate != nil {
|
||
s.onUpdate() // Invalidate cache after settings update
|
||
}
|
||
}
|
||
|
||
func (s *SettingService) defaultRewriteMessageCacheControl() bool {
|
||
return false
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func (s *SettingService) GetEmailOAuthProviderConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) {
|
||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||
if provider != "github" && provider != "google" {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_PROVIDER_NOT_FOUND", "oauth provider not found")
|
||
}
|
||
keys := []string{
|
||
SettingKeyGitHubOAuthEnabled,
|
||
SettingKeyGitHubOAuthClientID,
|
||
SettingKeyGitHubOAuthClientSecret,
|
||
SettingKeyGitHubOAuthRedirectURL,
|
||
SettingKeyGitHubOAuthFrontendRedirectURL,
|
||
SettingKeyGoogleOAuthEnabled,
|
||
SettingKeyGoogleOAuthClientID,
|
||
SettingKeyGoogleOAuthClientSecret,
|
||
SettingKeyGoogleOAuthRedirectURL,
|
||
SettingKeyGoogleOAuthFrontendRedirectURL,
|
||
}
|
||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||
if err != nil {
|
||
return config.EmailOAuthProviderConfig{}, fmt.Errorf("get email oauth settings: %w", err)
|
||
}
|
||
cfg := s.effectiveEmailOAuthConfig(settings, provider)
|
||
if !cfg.Enabled {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
||
}
|
||
if strings.TrimSpace(cfg.ClientID) == "" {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
|
||
}
|
||
if strings.TrimSpace(cfg.ClientSecret) == "" {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
|
||
}
|
||
for label, rawURL := range map[string]string{
|
||
"authorize": cfg.AuthorizeURL,
|
||
"token": cfg.TokenURL,
|
||
"userinfo": cfg.UserInfoURL,
|
||
"redirect": cfg.RedirectURL,
|
||
} {
|
||
if strings.TrimSpace(rawURL) == "" {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url not configured")
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(rawURL); err != nil {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url invalid")
|
||
}
|
||
}
|
||
if strings.TrimSpace(cfg.EmailsURL) != "" {
|
||
if err := config.ValidateAbsoluteHTTPURL(cfg.EmailsURL); err != nil {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth emails url invalid")
|
||
}
|
||
}
|
||
if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
|
||
}
|
||
return cfg, nil
|
||
}
|
||
|
||
// IsRegistrationEnabled 检查是否开放注册
|
||
func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
|
||
if err != nil {
|
||
// 安全默认:如果设置不存在或查询出错,默认关闭注册
|
||
return false
|
||
}
|
||
return value == "true"
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
type gatewayForwardingSettingsResult struct {
|
||
fp, mp, cch, cacheTTL1h, rewriteMessageCacheControl bool
|
||
}
|
||
|
||
func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) gatewayForwardingSettingsResult {
|
||
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
||
if time.Now().UnixNano() < cached.expiresAt {
|
||
return gatewayForwardingSettingsResult{
|
||
fp: cached.fingerprintUnification,
|
||
mp: cached.metadataPassthrough,
|
||
cch: cached.cchSigning,
|
||
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
|
||
rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
|
||
}
|
||
}
|
||
}
|
||
val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) {
|
||
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
||
if time.Now().UnixNano() < cached.expiresAt {
|
||
return gatewayForwardingSettingsResult{
|
||
fp: cached.fingerprintUnification,
|
||
mp: cached.metadataPassthrough,
|
||
cch: cached.cchSigning,
|
||
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
|
||
rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
|
||
}, nil
|
||
}
|
||
}
|
||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
|
||
defer cancel()
|
||
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
|
||
SettingKeyEnableFingerprintUnification,
|
||
SettingKeyEnableMetadataPassthrough,
|
||
SettingKeyEnableCCHSigning,
|
||
SettingKeyEnableAnthropicCacheTTL1hInjection,
|
||
SettingKeyRewriteMessageCacheControl,
|
||
})
|
||
if err != nil {
|
||
slog.Warn("failed to get gateway forwarding settings", "error", err)
|
||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||
fingerprintUnification: true,
|
||
metadataPassthrough: false,
|
||
cchSigning: false,
|
||
anthropicCacheTTL1hInjection: false,
|
||
rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(),
|
||
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
|
||
})
|
||
return gatewayForwardingSettingsResult{fp: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl()}, nil
|
||
}
|
||
fp := true
|
||
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
||
fp = v == "true"
|
||
}
|
||
mp := values[SettingKeyEnableMetadataPassthrough] == "true"
|
||
cch := values[SettingKeyEnableCCHSigning] == "true"
|
||
cacheTTL1h := values[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true"
|
||
rewriteMessageCacheControl := s.defaultRewriteMessageCacheControl()
|
||
if v, ok := values[SettingKeyRewriteMessageCacheControl]; ok && v != "" {
|
||
rewriteMessageCacheControl = v == "true"
|
||
}
|
||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||
fingerprintUnification: fp,
|
||
metadataPassthrough: mp,
|
||
cchSigning: cch,
|
||
anthropicCacheTTL1hInjection: cacheTTL1h,
|
||
rewriteMessageCacheControl: rewriteMessageCacheControl,
|
||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||
})
|
||
return gatewayForwardingSettingsResult{
|
||
fp: fp,
|
||
mp: mp,
|
||
cch: cch,
|
||
cacheTTL1h: cacheTTL1h,
|
||
rewriteMessageCacheControl: rewriteMessageCacheControl,
|
||
}, nil
|
||
})
|
||
if r, ok := val.(gatewayForwardingSettingsResult); ok {
|
||
return r
|
||
}
|
||
return gatewayForwardingSettingsResult{fp: true}
|
||
}
|
||
|
||
// GetGatewayForwardingSettings returns cached gateway forwarding settings.
|
||
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path.
|
||
// Returns (fingerprintUnification, metadataPassthrough, cchSigning).
|
||
func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough, cchSigning bool) {
|
||
result := s.getGatewayForwardingSettingsCached(ctx)
|
||
return result.fp, result.mp, result.cch
|
||
}
|
||
|
||
// IsAnthropicCacheTTL1hInjectionEnabled 检查是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl。
|
||
func (s *SettingService) IsAnthropicCacheTTL1hInjectionEnabled(ctx context.Context) bool {
|
||
return s.getGatewayForwardingSettingsCached(ctx).cacheTTL1h
|
||
}
|
||
|
||
// IsRewriteMessageCacheControlEnabled 检查是否启用 messages cache_control 改写。
|
||
func (s *SettingService) IsRewriteMessageCacheControlEnabled(ctx context.Context) bool {
|
||
return s.getGatewayForwardingSettingsCached(ctx).rewriteMessageCacheControl
|
||
}
|
||
|
||
// IsEmailVerifyEnabled 检查是否开启邮件验证
|
||
func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return value == "true"
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// IsPromoCodeEnabled 检查是否启用优惠码功能
|
||
func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled)
|
||
if err != nil {
|
||
return true // 默认启用
|
||
}
|
||
return value != "false"
|
||
}
|
||
|
||
// IsInvitationCodeEnabled 检查是否启用邀请码注册功能
|
||
func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyInvitationCodeEnabled)
|
||
if err != nil {
|
||
return false // 默认关闭
|
||
}
|
||
return value == "true"
|
||
}
|
||
|
||
// GetCustomMenuItemsRaw returns the raw JSON string of custom_menu_items setting.
|
||
func (s *SettingService) GetCustomMenuItemsRaw(ctx context.Context) string {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyCustomMenuItems)
|
||
if err != nil {
|
||
return "[]"
|
||
}
|
||
return value
|
||
}
|
||
|
||
// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关)
|
||
func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled)
|
||
if err != nil {
|
||
return false // 默认关闭
|
||
}
|
||
return value == "true"
|
||
}
|
||
|
||
// GetAffiliateRebateRatePercent 读取并 clamp 全局返利比例。
|
||
// 解析失败、缺失或越界都回退到 AffiliateRebateRateDefault — 该比例从不抛错,
|
||
// 调用方只关心一个可用的数值。
|
||
func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) float64 {
|
||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateRate)
|
||
if err != nil {
|
||
return AffiliateRebateRateDefault
|
||
}
|
||
rate, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||
if err != nil || math.IsNaN(rate) || math.IsInf(rate, 0) {
|
||
return AffiliateRebateRateDefault
|
||
}
|
||
return clampAffiliateRebateRate(rate)
|
||
}
|
||
|
||
// GetAffiliateRebateFreezeHours 返回返利冻结期(小时)。
|
||
// 返回 0 表示不冻结(向后兼容)。
|
||
func (s *SettingService) GetAffiliateRebateFreezeHours(ctx context.Context) int {
|
||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateFreezeHours)
|
||
if err != nil {
|
||
return AffiliateRebateFreezeHoursDefault
|
||
}
|
||
hours, err := strconv.Atoi(strings.TrimSpace(raw))
|
||
if err != nil || hours < 0 {
|
||
return AffiliateRebateFreezeHoursDefault
|
||
}
|
||
if hours > AffiliateRebateFreezeHoursMax {
|
||
return AffiliateRebateFreezeHoursMax
|
||
}
|
||
return hours
|
||
}
|
||
|
||
// GetAffiliateRebateDurationDays 返回返利有效期(天)。
|
||
// 返回 0 表示永久有效。
|
||
func (s *SettingService) GetAffiliateRebateDurationDays(ctx context.Context) int {
|
||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateDurationDays)
|
||
if err != nil {
|
||
return AffiliateRebateDurationDaysDefault
|
||
}
|
||
days, err := strconv.Atoi(strings.TrimSpace(raw))
|
||
if err != nil || days < 0 {
|
||
return AffiliateRebateDurationDaysDefault
|
||
}
|
||
if days > AffiliateRebateDurationDaysMax {
|
||
return AffiliateRebateDurationDaysMax
|
||
}
|
||
return days
|
||
}
|
||
|
||
// GetAffiliateRebatePerInviteeCap 返回单人返利上限。
|
||
// 返回 0 表示无上限。
|
||
func (s *SettingService) GetAffiliateRebatePerInviteeCap(ctx context.Context) float64 {
|
||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebatePerInviteeCap)
|
||
if err != nil {
|
||
return AffiliateRebatePerInviteeCapDefault
|
||
}
|
||
cap, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||
if err != nil || cap < 0 || math.IsNaN(cap) || math.IsInf(cap, 0) {
|
||
return AffiliateRebatePerInviteeCapDefault
|
||
}
|
||
return cap
|
||
}
|
||
|
||
// 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"
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// GetSiteName 获取网站名称
|
||
func (s *SettingService) GetSiteName(ctx context.Context) string {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
|
||
if err != nil || value == "" {
|
||
return "Sub2API"
|
||
}
|
||
return value
|
||
}
|
||
|
||
// GetDefaultConcurrency 获取默认并发量
|
||
func (s *SettingService) GetDefaultConcurrency(ctx context.Context) int {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultConcurrency)
|
||
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 {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultBalance)
|
||
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
|
||
}
|
||
|
||
// GetDefaultUserRPMLimit 获取新用户默认 RPM 限制(0 = 不限制)。未配置则返回 0。
|
||
func (s *SettingService) GetDefaultUserRPMLimit(ctx context.Context) int {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultUserRPMLimit)
|
||
if err != nil || value == "" {
|
||
return 0
|
||
}
|
||
if v, err := strconv.Atoi(value); err == nil && v >= 0 {
|
||
return v
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// GetDefaultSubscriptions 获取新用户默认订阅配置列表。
|
||
func (s *SettingService) GetDefaultSubscriptions(ctx context.Context) []DefaultSubscriptionSetting {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultSubscriptions)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return parseDefaultSubscriptions(value)
|
||
}
|
||
|
||
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,
|
||
SettingKeyAuthSourceDefaultGitHubBalance,
|
||
SettingKeyAuthSourceDefaultGitHubConcurrency,
|
||
SettingKeyAuthSourceDefaultGitHubSubscriptions,
|
||
SettingKeyAuthSourceDefaultGitHubGrantOnSignup,
|
||
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind,
|
||
SettingKeyAuthSourceDefaultGoogleBalance,
|
||
SettingKeyAuthSourceDefaultGoogleConcurrency,
|
||
SettingKeyAuthSourceDefaultGoogleSubscriptions,
|
||
SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
|
||
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
|
||
SettingKeyAuthSourceDefaultDingTalkBalance,
|
||
SettingKeyAuthSourceDefaultDingTalkConcurrency,
|
||
SettingKeyAuthSourceDefaultDingTalkSubscriptions,
|
||
SettingKeyAuthSourceDefaultDingTalkGrantOnSignup,
|
||
SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind,
|
||
SettingKeyAuthSourcePlatformQuotas("email"),
|
||
SettingKeyAuthSourcePlatformQuotas("linuxdo"),
|
||
SettingKeyAuthSourcePlatformQuotas("oidc"),
|
||
SettingKeyAuthSourcePlatformQuotas("wechat"),
|
||
SettingKeyAuthSourcePlatformQuotas("github"),
|
||
SettingKeyAuthSourcePlatformQuotas("google"),
|
||
SettingKeyAuthSourcePlatformQuotas("dingtalk"),
|
||
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),
|
||
GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys),
|
||
Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys),
|
||
DingTalk: parseProviderDefaultGrantSettings(settings, dingTalkAuthSourceDefaultKeys),
|
||
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
|
||
}, nil
|
||
}
|
||
|
||
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),
|
||
}
|
||
|
||
defaults, err := s.GetAuthSourceDefaultSettings(ctx)
|
||
if err != nil {
|
||
return result, false, err
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
if err := s.settingRepo.SetMultiple(ctx, updates); err != nil {
|
||
return fmt.Errorf("update auth source default settings: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// InitializeDefaultSettings 初始化默认设置
|
||
func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||
// 检查是否已有设置
|
||
_, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
|
||
if err == nil {
|
||
// 已有设置,不需要初始化
|
||
return nil
|
||
}
|
||
if !errors.Is(err, ErrSettingNotFound) {
|
||
return fmt.Errorf("check existing settings: %w", err)
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(defaultLoginAgreementDocuments())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 初始化默认设置
|
||
defaults := map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
SettingKeyEmailVerifyEnabled: "false",
|
||
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
|
||
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
|
||
SettingKeyLoginAgreementEnabled: "false",
|
||
SettingKeyLoginAgreementMode: defaultLoginAgreementMode,
|
||
SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate,
|
||
SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON,
|
||
SettingKeyAPIKeyACLTrustForwardedIP: "false",
|
||
SettingKeySiteName: "Sub2API",
|
||
SettingKeySiteLogo: "",
|
||
SettingKeyPurchaseSubscriptionEnabled: "false",
|
||
SettingKeyPurchaseSubscriptionURL: "",
|
||
SettingKeyTableDefaultPageSize: "20",
|
||
SettingKeyTablePageSizeOptions: "[10,20,50,100]",
|
||
SettingKeyCustomMenuItems: "[]",
|
||
SettingKeyCustomEndpoints: "[]",
|
||
SettingKeyWeChatConnectEnabled: "false",
|
||
SettingKeyWeChatConnectAppID: "",
|
||
SettingKeyWeChatConnectAppSecret: "",
|
||
SettingKeyWeChatConnectOpenAppID: "",
|
||
SettingKeyWeChatConnectOpenAppSecret: "",
|
||
SettingKeyWeChatConnectMPAppID: "",
|
||
SettingKeyWeChatConnectMPAppSecret: "",
|
||
SettingKeyWeChatConnectMobileAppID: "",
|
||
SettingKeyWeChatConnectMobileAppSecret: "",
|
||
SettingKeyWeChatConnectOpenEnabled: "false",
|
||
SettingKeyWeChatConnectMPEnabled: "false",
|
||
SettingKeyWeChatConnectMobileEnabled: "false",
|
||
SettingKeyWeChatConnectMode: "open",
|
||
SettingKeyWeChatConnectScopes: "snsapi_login",
|
||
SettingKeyWeChatConnectRedirectURL: "",
|
||
SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend,
|
||
SettingKeyGitHubOAuthEnabled: "false",
|
||
SettingKeyGitHubOAuthClientID: "",
|
||
SettingKeyGitHubOAuthClientSecret: "",
|
||
SettingKeyGitHubOAuthRedirectURL: "",
|
||
SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend,
|
||
SettingKeyGoogleOAuthEnabled: "false",
|
||
SettingKeyGoogleOAuthClientID: "",
|
||
SettingKeyGoogleOAuthClientSecret: "",
|
||
SettingKeyGoogleOAuthRedirectURL: "",
|
||
SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend,
|
||
SettingKeyOIDCConnectEnabled: "false",
|
||
SettingKeyOIDCConnectProviderName: "OIDC",
|
||
SettingKeyOIDCConnectClientID: "",
|
||
SettingKeyOIDCConnectClientSecret: "",
|
||
SettingKeyOIDCConnectIssuerURL: "",
|
||
SettingKeyOIDCConnectDiscoveryURL: "",
|
||
SettingKeyOIDCConnectAuthorizeURL: "",
|
||
SettingKeyOIDCConnectTokenURL: "",
|
||
SettingKeyOIDCConnectUserInfoURL: "",
|
||
SettingKeyOIDCConnectJWKSURL: "",
|
||
SettingKeyOIDCConnectScopes: "openid email profile",
|
||
SettingKeyOIDCConnectRedirectURL: "",
|
||
SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback",
|
||
SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post",
|
||
SettingKeyOIDCConnectUsePKCE: strconv.FormatBool(oidcUsePKCEDefault),
|
||
SettingKeyOIDCConnectValidateIDToken: strconv.FormatBool(oidcValidateIDTokenDefault),
|
||
SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256",
|
||
SettingKeyOIDCConnectClockSkewSeconds: "120",
|
||
SettingKeyOIDCConnectRequireEmailVerified: "false",
|
||
SettingKeyOIDCConnectUserInfoEmailPath: "",
|
||
SettingKeyOIDCConnectUserInfoIDPath: "",
|
||
SettingKeyOIDCConnectUserInfoUsernamePath: "",
|
||
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
||
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
||
SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64),
|
||
SettingKeyAffiliateRebateFreezeHours: strconv.Itoa(AffiliateRebateFreezeHoursDefault),
|
||
SettingKeyAffiliateRebateDurationDays: strconv.Itoa(AffiliateRebateDurationDaysDefault),
|
||
SettingKeyAffiliateRebatePerInviteeCap: strconv.FormatFloat(AffiliateRebatePerInviteeCapDefault, 'f', 2, 64),
|
||
SettingKeyDefaultUserRPMLimit: "0",
|
||
SettingKeyDefaultSubscriptions: "[]",
|
||
SettingKeyAuthSourceDefaultEmailBalance: "0",
|
||
SettingKeyAuthSourceDefaultEmailConcurrency: "5",
|
||
SettingKeyAuthSourceDefaultEmailSubscriptions: "[]",
|
||
SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false",
|
||
SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "false",
|
||
SettingKeyAuthSourceDefaultLinuxDoBalance: "0",
|
||
SettingKeyAuthSourceDefaultLinuxDoConcurrency: "5",
|
||
SettingKeyAuthSourceDefaultLinuxDoSubscriptions: "[]",
|
||
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup: "false",
|
||
SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind: "false",
|
||
SettingKeyAuthSourceDefaultOIDCBalance: "0",
|
||
SettingKeyAuthSourceDefaultOIDCConcurrency: "5",
|
||
SettingKeyAuthSourceDefaultOIDCSubscriptions: "[]",
|
||
SettingKeyAuthSourceDefaultOIDCGrantOnSignup: "false",
|
||
SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind: "false",
|
||
SettingKeyAuthSourceDefaultWeChatBalance: "0",
|
||
SettingKeyAuthSourceDefaultWeChatConcurrency: "5",
|
||
SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]",
|
||
SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false",
|
||
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false",
|
||
SettingKeyAuthSourceDefaultGitHubBalance: "0",
|
||
SettingKeyAuthSourceDefaultGitHubConcurrency: "5",
|
||
SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]",
|
||
SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false",
|
||
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false",
|
||
SettingKeyAuthSourceDefaultGoogleBalance: "0",
|
||
SettingKeyAuthSourceDefaultGoogleConcurrency: "5",
|
||
SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]",
|
||
SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false",
|
||
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false",
|
||
SettingKeyAuthSourceDefaultDingTalkBalance: "0",
|
||
SettingKeyAuthSourceDefaultDingTalkConcurrency: "5",
|
||
SettingKeyAuthSourceDefaultDingTalkSubscriptions: "[]",
|
||
SettingKeyAuthSourceDefaultDingTalkGrantOnSignup: "false",
|
||
SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind: "false",
|
||
SettingKeyForceEmailOnThirdPartySignup: "false",
|
||
SettingKeySMTPPort: "587",
|
||
SettingKeySMTPUseTLS: "false",
|
||
// Model fallback defaults
|
||
SettingKeyEnableModelFallback: "false",
|
||
SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022",
|
||
SettingKeyFallbackModelOpenAI: "gpt-4o",
|
||
SettingKeyFallbackModelGemini: "gemini-2.5-pro",
|
||
SettingKeyFallbackModelAntigravity: "gemini-2.5-pro",
|
||
// Identity patch defaults
|
||
SettingKeyEnableIdentityPatch: "true",
|
||
SettingKeyIdentityPatchPrompt: "",
|
||
|
||
// Ops monitoring defaults (vNext)
|
||
SettingKeyOpsMonitoringEnabled: "true",
|
||
SettingKeyOpsRealtimeMonitoringEnabled: "true",
|
||
SettingKeyOpsQueryModeDefault: "auto",
|
||
SettingKeyOpsMetricsIntervalSeconds: "60",
|
||
|
||
// Channel monitor defaults (enabled, 60s)
|
||
SettingKeyChannelMonitorEnabled: "true",
|
||
SettingKeyChannelMonitorDefaultIntervalSeconds: "60",
|
||
|
||
// Available channels feature (default disabled; opt-in)
|
||
SettingKeyAvailableChannelsEnabled: "false",
|
||
|
||
// Affiliate (邀请返利) feature (default disabled; opt-in)
|
||
SettingKeyAffiliateEnabled: "false",
|
||
|
||
// 风控中心功能(默认关闭,显式启用)
|
||
SettingKeyRiskControlEnabled: "false",
|
||
|
||
// Claude Code version check (default: empty = disabled)
|
||
SettingKeyMinClaudeCodeVersion: "",
|
||
SettingKeyMaxClaudeCodeVersion: "",
|
||
|
||
// 分组隔离(默认不允许未分组 Key 调度)
|
||
SettingKeyAllowUngroupedKeyScheduling: "false",
|
||
SettingKeyEnableAnthropicCacheTTL1hInjection: "false",
|
||
SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()),
|
||
SettingKeyAntigravityUserAgentVersion: "",
|
||
SettingKeyOpenAICodexUserAgent: "",
|
||
SettingPaymentVisibleMethodAlipaySource: "",
|
||
SettingPaymentVisibleMethodWxpaySource: "",
|
||
SettingPaymentVisibleMethodAlipayEnabled: "false",
|
||
SettingPaymentVisibleMethodWxpayEnabled: "false",
|
||
openAIAdvancedSchedulerSettingKey: "false",
|
||
}
|
||
|
||
return s.settingRepo.SetMultiple(ctx, defaults)
|
||
}
|
||
|
||
// parseSettings 解析设置到结构体
|
||
func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings {
|
||
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
||
loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments])
|
||
loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt])
|
||
if loginAgreementUpdatedAt == "" {
|
||
loginAgreementUpdatedAt = defaultLoginAgreementDate
|
||
}
|
||
apiKeyACLTrustForwardedIP := false
|
||
if value, ok := settings[SettingKeyAPIKeyACLTrustForwardedIP]; ok {
|
||
apiKeyACLTrustForwardedIP = value == "true"
|
||
} else if s != nil && s.cfg != nil {
|
||
apiKeyACLTrustForwardedIP = s.cfg.Security.TrustForwardedIPForAPIKeyACL
|
||
}
|
||
result := &SystemSettings{
|
||
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
||
EmailVerifyEnabled: emailVerifyEnabled,
|
||
RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]),
|
||
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
||
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
|
||
FrontendURL: settings[SettingKeyFrontendURL],
|
||
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
||
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
||
LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true",
|
||
LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
|
||
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
|
||
LoginAgreementDocuments: loginAgreementDocuments,
|
||
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] != "",
|
||
APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP,
|
||
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],
|
||
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
||
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
||
}
|
||
result.TableDefaultPageSize, result.TablePageSizeOptions = parseTablePreferences(
|
||
settings[SettingKeyTableDefaultPageSize],
|
||
settings[SettingKeyTablePageSizeOptions],
|
||
)
|
||
|
||
// 解析整数类型
|
||
if port, err := strconv.Atoi(settings[SettingKeySMTPPort]); err == nil {
|
||
result.SMTPPort = port
|
||
} else {
|
||
result.SMTPPort = 587
|
||
}
|
||
|
||
if concurrency, err := strconv.Atoi(settings[SettingKeyDefaultConcurrency]); err == nil {
|
||
result.DefaultConcurrency = concurrency
|
||
} else {
|
||
result.DefaultConcurrency = s.cfg.Default.UserConcurrency
|
||
}
|
||
|
||
if rpm, err := strconv.Atoi(settings[SettingKeyDefaultUserRPMLimit]); err == nil && rpm >= 0 {
|
||
result.DefaultUserRPMLimit = rpm
|
||
}
|
||
|
||
// 解析浮点数类型
|
||
if balance, err := strconv.ParseFloat(settings[SettingKeyDefaultBalance], 64); err == nil {
|
||
result.DefaultBalance = balance
|
||
} else {
|
||
result.DefaultBalance = s.cfg.Default.UserBalance
|
||
}
|
||
if rebateRate, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebateRate], 64); err == nil {
|
||
result.AffiliateRebateRate = clampAffiliateRebateRate(rebateRate)
|
||
} else {
|
||
result.AffiliateRebateRate = AffiliateRebateRateDefault
|
||
}
|
||
if freezeHours, err := strconv.Atoi(settings[SettingKeyAffiliateRebateFreezeHours]); err == nil && freezeHours >= 0 {
|
||
if freezeHours > AffiliateRebateFreezeHoursMax {
|
||
freezeHours = AffiliateRebateFreezeHoursMax
|
||
}
|
||
result.AffiliateRebateFreezeHours = freezeHours
|
||
}
|
||
if durationDays, err := strconv.Atoi(settings[SettingKeyAffiliateRebateDurationDays]); err == nil && durationDays >= 0 {
|
||
if durationDays > AffiliateRebateDurationDaysMax {
|
||
durationDays = AffiliateRebateDurationDaysMax
|
||
}
|
||
result.AffiliateRebateDurationDays = durationDays
|
||
}
|
||
if perInviteeCap, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebatePerInviteeCap], 64); err == nil && perInviteeCap >= 0 {
|
||
result.AffiliateRebatePerInviteeCap = perInviteeCap
|
||
}
|
||
result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions])
|
||
|
||
// 敏感信息直接返回,方便测试连接时使用
|
||
result.SMTPPassword = settings[SettingKeySMTPPassword]
|
||
result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey]
|
||
|
||
// LinuxDo Connect 设置:
|
||
// - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭)
|
||
// - 支持在后台“系统设置”中覆盖并持久化(存储于 DB)
|
||
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 != ""
|
||
|
||
// DingTalk Connect 设置:
|
||
// - 兼容 config.yaml/env
|
||
// - 支持后台系统设置覆盖并持久化(存储于 DB)
|
||
dingTalkBase := config.DingTalkConnectConfig{}
|
||
if s.cfg != nil {
|
||
dingTalkBase = s.cfg.DingTalk
|
||
}
|
||
|
||
if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok {
|
||
result.DingTalkConnectEnabled = raw == "true"
|
||
} else {
|
||
result.DingTalkConnectEnabled = dingTalkBase.Enabled
|
||
}
|
||
|
||
if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" {
|
||
result.DingTalkConnectClientID = strings.TrimSpace(v)
|
||
} else {
|
||
result.DingTalkConnectClientID = dingTalkBase.ClientID
|
||
}
|
||
|
||
if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
||
result.DingTalkConnectRedirectURL = strings.TrimSpace(v)
|
||
} else {
|
||
result.DingTalkConnectRedirectURL = dingTalkBase.RedirectURL
|
||
}
|
||
|
||
result.DingTalkConnectClientSecret = strings.TrimSpace(settings[SettingKeyDingTalkConnectClientSecret])
|
||
if result.DingTalkConnectClientSecret == "" {
|
||
result.DingTalkConnectClientSecret = strings.TrimSpace(dingTalkBase.ClientSecret)
|
||
}
|
||
result.DingTalkConnectClientSecretConfigured = result.DingTalkConnectClientSecret != ""
|
||
|
||
if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" {
|
||
result.DingTalkConnectCorpRestrictionPolicy = strings.TrimSpace(v)
|
||
} else {
|
||
result.DingTalkConnectCorpRestrictionPolicy = dingTalkBase.CorpRestrictionPolicy
|
||
}
|
||
result.DingTalkConnectCorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(result.DingTalkConnectCorpRestrictionPolicy)
|
||
|
||
if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" {
|
||
result.DingTalkConnectInternalCorpID = strings.TrimSpace(v)
|
||
} else {
|
||
result.DingTalkConnectInternalCorpID = dingTalkBase.InternalCorpID
|
||
}
|
||
|
||
if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" {
|
||
result.DingTalkConnectBypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true")
|
||
} else {
|
||
result.DingTalkConnectBypassRegistration = dingTalkBase.BypassRegistration
|
||
}
|
||
// bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false,
|
||
// 以保证加载出的 effective config 永远是一致状态。
|
||
if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" {
|
||
result.DingTalkConnectBypassRegistration = false
|
||
}
|
||
|
||
if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" {
|
||
result.DingTalkConnectSyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true")
|
||
} else {
|
||
result.DingTalkConnectSyncCorpEmail = dingTalkBase.SyncCorpEmail
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" {
|
||
result.DingTalkConnectSyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true")
|
||
} else {
|
||
result.DingTalkConnectSyncDisplayName = dingTalkBase.SyncDisplayName
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" {
|
||
result.DingTalkConnectSyncDept = strings.EqualFold(strings.TrimSpace(v), "true")
|
||
} else {
|
||
result.DingTalkConnectSyncDept = dingTalkBase.SyncDept
|
||
}
|
||
// 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。
|
||
if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" {
|
||
result.DingTalkConnectSyncCorpEmail = false
|
||
result.DingTalkConnectSyncDisplayName = false
|
||
result.DingTalkConnectSyncDept = false
|
||
}
|
||
|
||
// 身份同步目标 attr key(DB 空 → fallback 默认值)
|
||
result.DingTalkConnectSyncCorpEmailAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey])
|
||
if result.DingTalkConnectSyncCorpEmailAttrKey == "" {
|
||
if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrKey); v != "" {
|
||
result.DingTalkConnectSyncCorpEmailAttrKey = v
|
||
} else {
|
||
result.DingTalkConnectSyncCorpEmailAttrKey = "dingtalk_email"
|
||
}
|
||
}
|
||
result.DingTalkConnectSyncDisplayNameAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey])
|
||
if result.DingTalkConnectSyncDisplayNameAttrKey == "" {
|
||
if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrKey); v != "" {
|
||
result.DingTalkConnectSyncDisplayNameAttrKey = v
|
||
} else {
|
||
result.DingTalkConnectSyncDisplayNameAttrKey = "dingtalk_name"
|
||
}
|
||
}
|
||
result.DingTalkConnectSyncDeptAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey])
|
||
if result.DingTalkConnectSyncDeptAttrKey == "" {
|
||
if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrKey); v != "" {
|
||
result.DingTalkConnectSyncDeptAttrKey = v
|
||
} else {
|
||
result.DingTalkConnectSyncDeptAttrKey = "dingtalk_department"
|
||
}
|
||
}
|
||
|
||
// 身份同步目标 attr 显示名称(DB 空 → fallback 默认中文)
|
||
result.DingTalkConnectSyncCorpEmailAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrName])
|
||
if result.DingTalkConnectSyncCorpEmailAttrName == "" {
|
||
if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrName); v != "" {
|
||
result.DingTalkConnectSyncCorpEmailAttrName = v
|
||
} else {
|
||
result.DingTalkConnectSyncCorpEmailAttrName = "钉钉企业邮箱"
|
||
}
|
||
}
|
||
result.DingTalkConnectSyncDisplayNameAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrName])
|
||
if result.DingTalkConnectSyncDisplayNameAttrName == "" {
|
||
if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrName); v != "" {
|
||
result.DingTalkConnectSyncDisplayNameAttrName = v
|
||
} else {
|
||
result.DingTalkConnectSyncDisplayNameAttrName = "钉钉姓名"
|
||
}
|
||
}
|
||
result.DingTalkConnectSyncDeptAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrName])
|
||
if result.DingTalkConnectSyncDeptAttrName == "" {
|
||
if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrName); v != "" {
|
||
result.DingTalkConnectSyncDeptAttrName = v
|
||
} else {
|
||
result.DingTalkConnectSyncDeptAttrName = "钉钉部门"
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
result.OIDCConnectUsePKCE = oidcUsePKCECompatibilityDefault(oidcBase)
|
||
}
|
||
if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok {
|
||
result.OIDCConnectValidateIDToken = raw == "true"
|
||
} else {
|
||
result.OIDCConnectValidateIDToken = oidcValidateIDTokenCompatibilityDefault(oidcBase)
|
||
}
|
||
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 != ""
|
||
|
||
gitHubEffective := s.effectiveEmailOAuthConfig(settings, "github")
|
||
result.GitHubOAuthEnabled = gitHubEffective.Enabled
|
||
result.GitHubOAuthClientID = strings.TrimSpace(gitHubEffective.ClientID)
|
||
result.GitHubOAuthClientSecret = strings.TrimSpace(gitHubEffective.ClientSecret)
|
||
result.GitHubOAuthClientSecretConfigured = result.GitHubOAuthClientSecret != ""
|
||
result.GitHubOAuthRedirectURL = strings.TrimSpace(gitHubEffective.RedirectURL)
|
||
result.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(gitHubEffective.FrontendRedirectURL)
|
||
|
||
googleEffective := s.effectiveEmailOAuthConfig(settings, "google")
|
||
result.GoogleOAuthEnabled = googleEffective.Enabled
|
||
result.GoogleOAuthClientID = strings.TrimSpace(googleEffective.ClientID)
|
||
result.GoogleOAuthClientSecret = strings.TrimSpace(googleEffective.ClientSecret)
|
||
result.GoogleOAuthClientSecretConfigured = result.GoogleOAuthClientSecret != ""
|
||
result.GoogleOAuthRedirectURL = strings.TrimSpace(googleEffective.RedirectURL)
|
||
result.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(googleEffective.FrontendRedirectURL)
|
||
|
||
// 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
|
||
|
||
// 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")
|
||
|
||
// 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]
|
||
|
||
// Ops monitoring settings (default: enabled, fail-open)
|
||
result.OpsMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsMonitoringEnabled])
|
||
result.OpsRealtimeMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsRealtimeMonitoringEnabled])
|
||
result.OpsQueryModeDefault = string(ParseOpsQueryMode(settings[SettingKeyOpsQueryModeDefault]))
|
||
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
|
||
}
|
||
}
|
||
|
||
// Channel monitor feature (default: enabled, 60s)
|
||
result.ChannelMonitorEnabled = !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled])
|
||
result.ChannelMonitorDefaultIntervalSeconds = parseChannelMonitorInterval(
|
||
settings[SettingKeyChannelMonitorDefaultIntervalSeconds],
|
||
)
|
||
|
||
// Available channels feature (default: disabled; strict true)
|
||
result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true"
|
||
|
||
// Affiliate (邀请返利) feature (default: disabled; strict true)
|
||
result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true"
|
||
|
||
// 风控中心功能(默认关闭,严格 true 才启用)
|
||
result.RiskControlEnabled = settings[SettingKeyRiskControlEnabled] == "true"
|
||
|
||
// Claude Code version check
|
||
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
|
||
result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion]
|
||
|
||
// 分组隔离
|
||
result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true"
|
||
|
||
// Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false, cch_signing=false)
|
||
if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
||
result.EnableFingerprintUnification = v == "true"
|
||
} else {
|
||
result.EnableFingerprintUnification = true // default: enabled (current behavior)
|
||
}
|
||
result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true"
|
||
result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true"
|
||
result.EnableAnthropicCacheTTL1hInjection = settings[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true"
|
||
if v, ok := settings[SettingKeyRewriteMessageCacheControl]; ok && v != "" {
|
||
result.RewriteMessageCacheControl = v == "true"
|
||
} else {
|
||
result.RewriteMessageCacheControl = s.defaultRewriteMessageCacheControl()
|
||
}
|
||
result.AntigravityUserAgentVersion = antigravity.NormalizeUserAgentVersion(settings[SettingKeyAntigravityUserAgentVersion])
|
||
result.OpenAICodexUserAgent = strings.TrimSpace(settings[SettingKeyOpenAICodexUserAgent])
|
||
result.OpenAIAllowClaudeCodeCodexPlugin = settings[SettingKeyOpenAIAllowClaudeCodeCodexPlugin] == "true"
|
||
|
||
// 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
|
||
}
|
||
}
|
||
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"
|
||
|
||
// 余额、订阅到期与账号限额通知
|
||
result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true"
|
||
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
|
||
result.BalanceLowNotifyThreshold = v
|
||
}
|
||
result.BalanceLowNotifyRechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL]
|
||
result.SubscriptionExpiryNotifyEnabled = !isFalseSettingValue(settings[SettingKeySubscriptionExpiryNotifyEnabled])
|
||
|
||
// 账号限额通知
|
||
result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true"
|
||
if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" {
|
||
result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw)
|
||
}
|
||
if result.AccountQuotaNotifyEmails == nil {
|
||
result.AccountQuotaNotifyEmails = []NotifyEmailEntry{}
|
||
}
|
||
|
||
// 系统层默认 platform quota(修复 Bug B:parseSettings 不填充导致回显恒为 nil)
|
||
if raw := settings[SettingKeyDefaultPlatformQuotas]; raw != "" {
|
||
parsed := map[string]*DefaultPlatformQuotaSetting{}
|
||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||
slog.Warn("[Setting] parseSettings: unmarshal default_platform_quotas failed", "error", err)
|
||
} else {
|
||
result.DefaultPlatformQuotas = parsed
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
func clampAffiliateRebateRate(value float64) float64 {
|
||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||
return AffiliateRebateRateDefault
|
||
}
|
||
if value < AffiliateRebateRateMin {
|
||
return AffiliateRebateRateMin
|
||
}
|
||
if value > AffiliateRebateRateMax {
|
||
return AffiliateRebateRateMax
|
||
}
|
||
return value
|
||
}
|
||
|
||
func isFalseSettingValue(value string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "false", "0", "off", "disabled":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func normalizeVisibleMethodSettingSource(method, source string, enabled bool) (string, error) {
|
||
_ = enabled
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func parseProviderDefaultGrantSettings(settings map[string]string, keys authSourceDefaultKeySet) ProviderDefaultGrantSettings {
|
||
result := ProviderDefaultGrantSettings{
|
||
Balance: defaultAuthSourceBalance,
|
||
Concurrency: defaultAuthSourceConcurrency,
|
||
Subscriptions: []DefaultSubscriptionSetting{},
|
||
GrantOnSignup: false,
|
||
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"
|
||
}
|
||
|
||
if raw := settings[keys.platformQuotas]; raw != "" {
|
||
parsed := map[string]*DefaultPlatformQuotaSetting{}
|
||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||
slog.Warn("[Setting] parseProviderDefaultGrantSettings: unmarshal auth source platform quotas failed", "source", keys.source, "error", err)
|
||
} else {
|
||
result.PlatformQuotas = parsed
|
||
}
|
||
}
|
||
|
||
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)
|
||
|
||
// auth source platform quota:整体替换语义。
|
||
// nil = 请求未携带该字段,跳过写入以保留既有配置(与系统层 buildSystemSettingsUpdates 的
|
||
// DefaultPlatformQuotas nil 守卫一致);非 nil(含空 map)才整体替换。二者语义不可混同。
|
||
if keys.platformQuotas != "" && settings.PlatformQuotas != nil {
|
||
blob, err := json.Marshal(settings.PlatformQuotas)
|
||
if err != nil {
|
||
blob = []byte("{}")
|
||
}
|
||
updates[keys.platformQuotas] = string(blob)
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
// 注意:不能把 parse 默认值 (defaultAuthSourceBalance / defaultAuthSourceConcurrency)
|
||
// 当作"未配置"哨兵——admin 完全有权显式设成相同的值,那时仍应覆盖 globalDefaults。
|
||
// 旧实现的 `!= defaultAuthSourceConcurrency` 会把 admin 设的 5 与 fallback 5 混淆,
|
||
// 导致渠道发放退回到全局默认(如 1),表现为"管理员设 5、新用户实际拿 1"。
|
||
if providerDefaults.Balance >= 0 {
|
||
result.Balance = providerDefaults.Balance
|
||
}
|
||
if providerDefaults.Concurrency > 0 {
|
||
result.Concurrency = providerDefaults.Concurrency
|
||
}
|
||
if len(providerDefaults.Subscriptions) > 0 {
|
||
result.Subscriptions = append([]DefaultSubscriptionSetting(nil), providerDefaults.Subscriptions...)
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 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 {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileEnabled)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return value == "true"
|
||
}
|
||
|
||
// GetTurnstileSecretKey 获取 Turnstile Secret Key
|
||
func (s *SettingService) GetTurnstileSecretKey(ctx context.Context) string {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileSecretKey)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return value
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// GenerateAdminAPIKey 生成新的管理员 API Key
|
||
func (s *SettingService) GenerateAdminAPIKey(ctx context.Context) (string, error) {
|
||
// 生成 32 字节随机数 = 64 位十六进制字符
|
||
bytes := make([]byte, 32)
|
||
if _, err := rand.Read(bytes); err != nil {
|
||
return "", fmt.Errorf("generate random bytes: %w", err)
|
||
}
|
||
|
||
key := AdminAPIKeyPrefix + hex.EncodeToString(bytes)
|
||
|
||
// 存储到 settings 表
|
||
if err := s.settingRepo.Set(ctx, SettingKeyAdminAPIKey, key); err != nil {
|
||
return "", fmt.Errorf("save admin api key: %w", err)
|
||
}
|
||
|
||
return key, nil
|
||
}
|
||
|
||
// GetAdminAPIKeyStatus 获取管理员 API Key 状态
|
||
// 返回脱敏的 key、是否存在、错误
|
||
func (s *SettingService) GetAdminAPIKeyStatus(ctx context.Context) (maskedKey string, exists bool, err error) {
|
||
key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey)
|
||
if err != nil {
|
||
if errors.Is(err, ErrSettingNotFound) {
|
||
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
|
||
}
|
||
|
||
// GetAdminAPIKey 获取完整的管理员 API Key(仅供内部验证使用)
|
||
// 如果未配置返回空字符串和 nil 错误,只有数据库错误时才返回 error
|
||
func (s *SettingService) GetAdminAPIKey(ctx context.Context) (string, error) {
|
||
key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey)
|
||
if err != nil {
|
||
if errors.Is(err, ErrSettingNotFound) {
|
||
return "", nil // 未配置,返回空字符串
|
||
}
|
||
return "", err // 数据库错误
|
||
}
|
||
return key, nil
|
||
}
|
||
|
||
// DeleteAdminAPIKey 删除管理员 API Key
|
||
func (s *SettingService) DeleteAdminAPIKey(ctx context.Context) error {
|
||
return s.settingRepo.Delete(ctx, SettingKeyAdminAPIKey)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// GetDingTalkConnectOAuthConfig 返回用于登录的"最终生效" DingTalk Connect 配置。
|
||
//
|
||
// 优先级:
|
||
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
|
||
// - 否则回退到 config.yaml/env 的值
|
||
func (s *SettingService) GetDingTalkConnectOAuthConfig(ctx context.Context) (config.DingTalkConnectConfig, error) {
|
||
if s == nil || s.cfg == nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
|
||
}
|
||
|
||
effective := s.cfg.DingTalk
|
||
|
||
keys := []string{
|
||
SettingKeyDingTalkConnectEnabled,
|
||
SettingKeyDingTalkConnectClientID,
|
||
SettingKeyDingTalkConnectClientSecret,
|
||
SettingKeyDingTalkConnectRedirectURL,
|
||
SettingKeyDingTalkConnectCorpRestrictionPolicy,
|
||
SettingKeyDingTalkConnectInternalCorpID,
|
||
SettingKeyDingTalkConnectBypassRegistration,
|
||
SettingKeyDingTalkConnectSyncCorpEmail,
|
||
SettingKeyDingTalkConnectSyncDisplayName,
|
||
SettingKeyDingTalkConnectSyncDept,
|
||
SettingKeyDingTalkConnectSyncCorpEmailAttrKey,
|
||
SettingKeyDingTalkConnectSyncDisplayNameAttrKey,
|
||
SettingKeyDingTalkConnectSyncDeptAttrKey,
|
||
}
|
||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||
if err != nil {
|
||
return config.DingTalkConnectConfig{}, fmt.Errorf("get dingtalk connect settings: %w", err)
|
||
}
|
||
|
||
if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok {
|
||
effective.Enabled = raw == "true"
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" {
|
||
effective.ClientID = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectClientSecret]; ok && strings.TrimSpace(v) != "" {
|
||
effective.ClientSecret = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
||
effective.RedirectURL = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" {
|
||
effective.CorpRestrictionPolicy = strings.TrimSpace(v)
|
||
}
|
||
effective.CorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(effective.CorpRestrictionPolicy)
|
||
if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" {
|
||
effective.InternalCorpID = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" {
|
||
effective.BypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true")
|
||
}
|
||
// bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false,
|
||
// 以保证 OAuth callback 看到的 effective config 永远是一致状态。
|
||
if effective.CorpRestrictionPolicy != "internal_only" {
|
||
effective.BypassRegistration = false
|
||
}
|
||
|
||
if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" {
|
||
effective.SyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true")
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" {
|
||
effective.SyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true")
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" {
|
||
effective.SyncDept = strings.EqualFold(strings.TrimSpace(v), "true")
|
||
}
|
||
// 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。
|
||
if effective.CorpRestrictionPolicy != "internal_only" {
|
||
effective.SyncCorpEmail = false
|
||
effective.SyncDisplayName = false
|
||
effective.SyncDept = false
|
||
}
|
||
|
||
// 身份同步目标 attr key(DB 空 → fallback 默认值)
|
||
if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey]); v != "" {
|
||
effective.SyncCorpEmailAttrKey = v
|
||
}
|
||
if effective.SyncCorpEmailAttrKey == "" {
|
||
effective.SyncCorpEmailAttrKey = "dingtalk_email"
|
||
}
|
||
if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey]); v != "" {
|
||
effective.SyncDisplayNameAttrKey = v
|
||
}
|
||
if effective.SyncDisplayNameAttrKey == "" {
|
||
effective.SyncDisplayNameAttrKey = "dingtalk_name"
|
||
}
|
||
if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey]); v != "" {
|
||
effective.SyncDeptAttrKey = v
|
||
}
|
||
if effective.SyncDeptAttrKey == "" {
|
||
effective.SyncDeptAttrKey = "dingtalk_department"
|
||
}
|
||
|
||
if !effective.Enabled {
|
||
return config.DingTalkConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "dingtalk oauth login is disabled")
|
||
}
|
||
|
||
// 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。
|
||
if strings.TrimSpace(effective.ClientID) == "" {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client id not configured")
|
||
}
|
||
if strings.TrimSpace(effective.AuthorizeURL) == "" {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.TokenURL) == "" {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.UserInfoURL) == "" {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.RedirectURL) == "" {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.FrontendRedirectURL) == "" {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url not configured")
|
||
}
|
||
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url invalid")
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url invalid")
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url invalid")
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url invalid")
|
||
}
|
||
if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url invalid")
|
||
}
|
||
if strings.TrimSpace(effective.ClientSecret) == "" {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client secret not configured")
|
||
}
|
||
|
||
// 镜像 admin handler 行为:internal_only policy 隐式要求 AppType=internal
|
||
if effective.CorpRestrictionPolicy == "internal_only" {
|
||
effective.AppType = "internal"
|
||
}
|
||
|
||
if err := config.ValidateDingTalkConfig(effective); err != nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", err.Error())
|
||
}
|
||
|
||
return effective, nil
|
||
}
|
||
|
||
// GetWeChatConnectOAuthConfig 返回用于登录的最终生效 WeChat Connect 配置。
|
||
//
|
||
// WeChat Connect 已回归 DB 系统设置模型,不再回退到 config/env。
|
||
func (s *SettingService) GetWeChatConnectOAuthConfig(ctx context.Context) (WeChatConnectOAuthConfig, error) {
|
||
keys := []string{
|
||
SettingKeyWeChatConnectEnabled,
|
||
SettingKeyWeChatConnectAppID,
|
||
SettingKeyWeChatConnectAppSecret,
|
||
SettingKeyWeChatConnectOpenAppID,
|
||
SettingKeyWeChatConnectOpenAppSecret,
|
||
SettingKeyWeChatConnectMPAppID,
|
||
SettingKeyWeChatConnectMPAppSecret,
|
||
SettingKeyWeChatConnectMobileAppID,
|
||
SettingKeyWeChatConnectMobileAppSecret,
|
||
SettingKeyWeChatConnectOpenEnabled,
|
||
SettingKeyWeChatConnectMPEnabled,
|
||
SettingKeyWeChatConnectMobileEnabled,
|
||
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)
|
||
}
|
||
|
||
// 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))
|
||
}
|
||
|
||
// GetRateLimit429CooldownSettings 获取429默认回避配置
|
||
func (s *SettingService) GetRateLimit429CooldownSettings(ctx context.Context) (*RateLimit429CooldownSettings, error) {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyRateLimit429CooldownSettings)
|
||
if err != nil {
|
||
if errors.Is(err, ErrSettingNotFound) {
|
||
return DefaultRateLimit429CooldownSettings(), nil
|
||
}
|
||
return nil, fmt.Errorf("get 429 cooldown settings: %w", err)
|
||
}
|
||
if value == "" {
|
||
return DefaultRateLimit429CooldownSettings(), nil
|
||
}
|
||
|
||
var settings RateLimit429CooldownSettings
|
||
if err := json.Unmarshal([]byte(value), &settings); err != nil {
|
||
return DefaultRateLimit429CooldownSettings(), nil
|
||
}
|
||
|
||
if settings.CooldownSeconds < 1 {
|
||
settings.CooldownSeconds = 1
|
||
}
|
||
if settings.CooldownSeconds > 7200 {
|
||
settings.CooldownSeconds = 7200
|
||
}
|
||
|
||
return &settings, nil
|
||
}
|
||
|
||
// SetRateLimit429CooldownSettings 设置429默认回避配置
|
||
func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, settings *RateLimit429CooldownSettings) error {
|
||
if settings == nil {
|
||
return fmt.Errorf("settings cannot be nil")
|
||
}
|
||
|
||
if settings.CooldownSeconds < 1 || settings.CooldownSeconds > 7200 {
|
||
if settings.Enabled {
|
||
return fmt.Errorf("cooldown_seconds must be between 1-7200")
|
||
}
|
||
settings.CooldownSeconds = 5
|
||
}
|
||
|
||
data, err := json.Marshal(settings)
|
||
if err != nil {
|
||
return fmt.Errorf("marshal 429 cooldown settings: %w", err)
|
||
}
|
||
|
||
return s.settingRepo.Set(ctx, SettingKeyRateLimit429CooldownSettings, string(data))
|
||
}
|
||
|
||
// 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"
|
||
} else {
|
||
effective.UsePKCE = oidcUsePKCECompatibilityDefault(effective)
|
||
}
|
||
if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok {
|
||
effective.ValidateIDToken = raw == "true"
|
||
} else {
|
||
effective.ValidateIDToken = oidcValidateIDTokenCompatibilityDefault(effective)
|
||
}
|
||
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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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"
|
||
}
|
||
|
||
// GetClaudeCodeVersionBounds 获取 Claude Code 版本号上下限要求
|
||
// 使用进程内 atomic.Value 缓存,60 秒 TTL,热路径零锁开销
|
||
// singleflight 防止缓存过期时 thundering herd
|
||
// 返回空字符串表示不做对应方向的版本检查
|
||
func (s *SettingService) GetClaudeCodeVersionBounds(ctx context.Context) (min, max string) {
|
||
if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok {
|
||
if time.Now().UnixNano() < cached.expiresAt {
|
||
return cached.min, cached.max
|
||
}
|
||
}
|
||
// singleflight: 同一时刻只有一个 goroutine 查询 DB,其余复用结果
|
||
type bounds struct{ min, max string }
|
||
result, err, _ := versionBoundsSF.Do("version_bounds", func() (any, error) {
|
||
// 二次检查,避免排队的 goroutine 重复查询
|
||
if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok {
|
||
if time.Now().UnixNano() < cached.expiresAt {
|
||
return bounds{cached.min, cached.max}, nil
|
||
}
|
||
}
|
||
// 使用独立 context:断开请求取消链,避免客户端断连导致空值被长期缓存
|
||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), versionBoundsDBTimeout)
|
||
defer cancel()
|
||
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
|
||
SettingKeyMinClaudeCodeVersion,
|
||
SettingKeyMaxClaudeCodeVersion,
|
||
})
|
||
if err != nil {
|
||
// fail-open: DB 错误时不阻塞请求,但记录日志并使用短 TTL 快速重试
|
||
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(),
|
||
})
|
||
return bounds{"", ""}, nil
|
||
}
|
||
b := bounds{
|
||
min: values[SettingKeyMinClaudeCodeVersion],
|
||
max: values[SettingKeyMaxClaudeCodeVersion],
|
||
}
|
||
versionBoundsCache.Store(&cachedVersionBounds{
|
||
min: b.min,
|
||
max: b.max,
|
||
expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(),
|
||
})
|
||
return b, nil
|
||
})
|
||
if err != nil {
|
||
return "", ""
|
||
}
|
||
b, ok := result.(bounds)
|
||
if !ok {
|
||
return "", ""
|
||
}
|
||
return b.min, b.max
|
||
}
|
||
|
||
// GetOpenAIQuotaAutoPauseSettings returns the current global default quota auto-pause
|
||
// settings. It is invoked on the OpenAI scheduling hot path (once per request) and is
|
||
// therefore designed to never block on the DB:
|
||
//
|
||
// - Fresh cached value → returned immediately.
|
||
// - Stale or empty cache → the last known value is returned, and a background
|
||
// goroutine refreshes the cache via singleflight (stale-while-revalidate).
|
||
// - First call with no cache yet → zero defaults are returned and the same async
|
||
// refresh is kicked off; the next call gets the freshly populated value.
|
||
//
|
||
// Callers that need the freshly persisted value synchronously (tests, post-update
|
||
// confirmation, optional startup warm-up) should call WarmOpenAIQuotaAutoPauseSettings.
|
||
func (s *SettingService) GetOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings {
|
||
if s == nil {
|
||
return OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||
}
|
||
cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings)
|
||
now := time.Now().UnixNano()
|
||
if cached != nil && now < cached.expiresAt {
|
||
return cached.settings
|
||
}
|
||
// Stale or unset: trigger background refresh without blocking this request.
|
||
// singleflight.DoChan dedupes concurrent refreshes; we deliberately ignore the
|
||
// returned channel — the result is observable via the atomic cache.
|
||
s.openAIQuotaAutoPauseSettingsSF.DoChan(openAIQuotaAutoPauseSettingsRefreshKey, func() (any, error) {
|
||
s.refreshOpenAIQuotaAutoPauseSettings(context.Background())
|
||
return nil, nil
|
||
})
|
||
if cached != nil {
|
||
return cached.settings // serve stale value while revalidating
|
||
}
|
||
return OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||
}
|
||
|
||
// WarmOpenAIQuotaAutoPauseSettings synchronously loads the quota auto-pause settings
|
||
// into the in-memory cache. Useful for application startup (so the first request hits
|
||
// a warm cache) and for tests that need deterministic reads immediately after
|
||
// constructing the service.
|
||
func (s *SettingService) WarmOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings {
|
||
if s == nil {
|
||
return OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||
}
|
||
s.refreshOpenAIQuotaAutoPauseSettings(ctx)
|
||
cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings)
|
||
if cached == nil {
|
||
return OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||
}
|
||
return cached.settings
|
||
}
|
||
|
||
// refreshOpenAIQuotaAutoPauseSettings reads the latest settings from the DB and stores
|
||
// them into the in-memory cache. On error it stores the prior value (or zero defaults
|
||
// if nothing is cached yet) with the shorter error TTL so the next refresh comes
|
||
// sooner. Always uses its own timeout-bounded context to keep refresh latency
|
||
// predictable regardless of the caller.
|
||
func (s *SettingService) refreshOpenAIQuotaAutoPauseSettings(ctx context.Context) {
|
||
if s == nil || s.settingRepo == nil {
|
||
return
|
||
}
|
||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIQuotaAutoPauseSettingsDBTimeout)
|
||
defer cancel()
|
||
|
||
settings := OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||
ttl := openAIQuotaAutoPauseSettingsCacheTTL
|
||
raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpsAdvancedSettings)
|
||
if err == nil {
|
||
cfg := defaultOpsAdvancedSettings()
|
||
if strings.TrimSpace(raw) != "" {
|
||
if jsonErr := json.Unmarshal([]byte(raw), cfg); jsonErr == nil {
|
||
normalizeOpsAdvancedSettings(cfg)
|
||
}
|
||
}
|
||
settings = cfg.OpenAIAccountQuotaAutoPause
|
||
} else if !errors.Is(err, ErrSettingNotFound) {
|
||
// Real error: keep serving prior value but refresh sooner.
|
||
if prior, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); prior != nil {
|
||
settings = prior.settings
|
||
}
|
||
ttl = openAIQuotaAutoPauseSettingsErrorTTL
|
||
}
|
||
|
||
s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{
|
||
settings: settings,
|
||
expiresAt: time.Now().Add(ttl).UnixNano(),
|
||
})
|
||
}
|
||
|
||
// SetOpenAIQuotaAutoPauseSettings writes the given settings directly into the in-memory
|
||
// cache. Called from settings-write code paths so that the next read reflects the new
|
||
// value immediately, without waiting for the background refresh.
|
||
func (s *SettingService) SetOpenAIQuotaAutoPauseSettings(settings OpsOpenAIAccountQuotaAutoPauseSettings) {
|
||
if s == nil {
|
||
return
|
||
}
|
||
s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{
|
||
settings: settings,
|
||
expiresAt: time.Now().Add(openAIQuotaAutoPauseSettingsCacheTTL).UnixNano(),
|
||
})
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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{
|
||
BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true,
|
||
}
|
||
|
||
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)
|
||
}
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
// GetOpenAIFastPolicySettings 获取 OpenAI fast 策略配置
|
||
func (s *SettingService) GetOpenAIFastPolicySettings(ctx context.Context) (*OpenAIFastPolicySettings, error) {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAIFastPolicySettings)
|
||
if err != nil {
|
||
if errors.Is(err, ErrSettingNotFound) {
|
||
return DefaultOpenAIFastPolicySettings(), nil
|
||
}
|
||
return nil, fmt.Errorf("get openai fast policy settings: %w", err)
|
||
}
|
||
if value == "" {
|
||
return DefaultOpenAIFastPolicySettings(), nil
|
||
}
|
||
|
||
var settings OpenAIFastPolicySettings
|
||
if err := json.Unmarshal([]byte(value), &settings); err != nil {
|
||
// JSON 损坏时静默 fallback 到默认配置会让策略意外失效(管理员配
|
||
// 置的 block/filter 规则被忽略)。记录 Warn 让运维能在出现异常
|
||
// 行为时定位到 settings 表里的脏数据。
|
||
slog.Warn("failed to unmarshal openai fast policy settings, falling back to defaults",
|
||
"error", err,
|
||
"key", SettingKeyOpenAIFastPolicySettings)
|
||
return DefaultOpenAIFastPolicySettings(), nil
|
||
}
|
||
|
||
return &settings, nil
|
||
}
|
||
|
||
// SetOpenAIFastPolicySettings 设置 OpenAI fast 策略配置
|
||
func (s *SettingService) SetOpenAIFastPolicySettings(ctx context.Context, settings *OpenAIFastPolicySettings) 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{
|
||
BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true,
|
||
}
|
||
validTiers := map[string]bool{
|
||
OpenAIFastTierAny: true, OpenAIFastTierPriority: true, OpenAIFastTierFlex: true,
|
||
}
|
||
|
||
for i, rule := range settings.Rules {
|
||
tier := strings.ToLower(strings.TrimSpace(rule.ServiceTier))
|
||
if tier == "" {
|
||
tier = OpenAIFastTierAny
|
||
}
|
||
if !validTiers[tier] {
|
||
return fmt.Errorf("rule[%d]: invalid service_tier %q", i, rule.ServiceTier)
|
||
}
|
||
settings.Rules[i].ServiceTier = tier
|
||
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)
|
||
}
|
||
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
|
||
}
|
||
if rule.FallbackAction != "" && !validActions[rule.FallbackAction] {
|
||
return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction)
|
||
}
|
||
}
|
||
|
||
data, err := json.Marshal(settings)
|
||
if err != nil {
|
||
return fmt.Errorf("marshal openai fast policy settings: %w", err)
|
||
}
|
||
|
||
return s.settingRepo.Set(ctx, SettingKeyOpenAIFastPolicySettings, string(data))
|
||
}
|
||
|
||
// 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))
|
||
}
|
||
|
||
// GetDefaultPlatformQuotas 读取系统全局 platform quota JSON key,返回 4 platform x 3 window 的设置。
|
||
// 永远返回包含全部 4 platform key 的 map(值可能为零值/nil 字段,表示"上层未配置 = 不限制")。
|
||
//
|
||
// 使用单个 JSON key(default_platform_quotas),一次 DB roundtrip,消除旧 12-KV 格式的 N+1 问题。
|
||
// 容错语义:取值失败或 unmarshal 失败 → 返回补齐 4 key 的空 map(fail-open,注册不被阻断)。
|
||
func (s *SettingService) GetDefaultPlatformQuotas(ctx context.Context) (map[string]*DefaultPlatformQuotaSetting, error) {
|
||
out := map[string]*DefaultPlatformQuotaSetting{
|
||
"anthropic": {},
|
||
"openai": {},
|
||
"gemini": {},
|
||
"antigravity": {},
|
||
}
|
||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultPlatformQuotas)
|
||
if err != nil || raw == "" {
|
||
return out, nil // 无配置 = 全部不限制
|
||
}
|
||
parsed := map[string]*DefaultPlatformQuotaSetting{}
|
||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||
slog.Warn("[Setting] unmarshal default_platform_quotas failed (fail-open)", "error", err)
|
||
return out, nil
|
||
}
|
||
for _, platform := range AllowedQuotaPlatforms {
|
||
if v := parsed[platform]; v != nil {
|
||
out[platform] = v
|
||
}
|
||
}
|
||
return out, nil // 补齐 4 platform key,保持与旧实现一致的下游契约
|
||
}
|
||
|
||
// GetAuthSourcePlatformQuotas 读取指定 auth source 的 platform quota 覆盖(仅返回有配置的平台,override 语义)。
|
||
func (s *SettingService) GetAuthSourcePlatformQuotas(ctx context.Context, source string) map[string]*DefaultPlatformQuotaSetting {
|
||
out := map[string]*DefaultPlatformQuotaSetting{}
|
||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAuthSourcePlatformQuotas(source))
|
||
if err != nil || raw == "" {
|
||
return out // 无 override
|
||
}
|
||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||
slog.Warn("[Setting] unmarshal auth source platform quotas failed (fail-open)", "source", source, "error", err)
|
||
return map[string]*DefaultPlatformQuotaSetting{}
|
||
}
|
||
return out // 仅含已配置平台,保持 override 语义
|
||
}
|
||
|
||
// mergePlatformQuotaDefaults 按字段级 patch:src 中非 nil 字段覆盖 dst。
|
||
// 区分 nil("未配置",保留 dst)vs &0.0("显式禁用",覆盖 dst 为 0)
|
||
func mergePlatformQuotaDefaults(dst, src *DefaultPlatformQuotaSetting) {
|
||
if src == nil || dst == nil {
|
||
return
|
||
}
|
||
if src.DailyLimitUSD != nil {
|
||
dst.DailyLimitUSD = src.DailyLimitUSD
|
||
}
|
||
if src.WeeklyLimitUSD != nil {
|
||
dst.WeeklyLimitUSD = src.WeeklyLimitUSD
|
||
}
|
||
if src.MonthlyLimitUSD != nil {
|
||
dst.MonthlyLimitUSD = src.MonthlyLimitUSD
|
||
}
|
||
}
|