Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
1185 lines
41 KiB
Go
1185 lines
41 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"math"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// IsRegistrationEnabled 检查是否开放注册
|
||
func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
|
||
if err != nil {
|
||
// 安全默认:如果设置不存在或查询出错,默认关闭注册
|
||
return false
|
||
}
|
||
return value == "true"
|
||
}
|
||
|
||
// IsEmailVerifyEnabled 检查是否开启邮件验证
|
||
func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return value == "true"
|
||
}
|
||
|
||
// IsRegistrationEmailDomainQuotaEnabled 检查白名单非空时是否放行非白名单域名限量注册。
|
||
// 安全默认:设置缺失或查询出错时按关闭处理(保持白名单严格模式)。
|
||
func (s *SettingService) IsRegistrationEmailDomainQuotaEnabled(ctx context.Context) bool {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailDomainQuotaEnabled)
|
||
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"
|
||
}
|
||
|
||
// IsAffiliateAdminRechargeEnabled reports whether admin balance
|
||
// deposits should participate in the affiliate rebate program.
|
||
func (s *SettingService) IsAffiliateAdminRechargeEnabled(ctx context.Context) bool {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateAdminRechargeEnabled)
|
||
if err != nil {
|
||
return AdminRechargeRebateEnabledDefault
|
||
}
|
||
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"
|
||
}
|
||
|
||
// PasskeyEnabled reports the effective runtime switch. WebAuthn deployment
|
||
// configuration remains the security boundary; the database setting can only
|
||
// disable a valid configured relying party, never replace or weaken it.
|
||
func (s *SettingService) PasskeyEnabled(ctx context.Context) (bool, error) {
|
||
if !s.passkeyConfigured() {
|
||
return false, nil
|
||
}
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyPasskeyEnabled)
|
||
if errors.Is(err, ErrSettingNotFound) {
|
||
return true, nil // configured deployments default to enabled until the admin persists the switch
|
||
}
|
||
if err != nil {
|
||
return false, fmt.Errorf("read passkey setting: %w", err)
|
||
}
|
||
return value == "true", nil
|
||
}
|
||
|
||
// PasskeyConfiguration returns non-secret relying-party configuration for the
|
||
// admin status UI. Enabled configurations have already passed Config.Validate.
|
||
func (s *SettingService) PasskeyConfiguration() (configured bool, rpID string, origins []string) {
|
||
if s == nil || s.cfg == nil {
|
||
return false, "", []string{}
|
||
}
|
||
origins = append([]string{}, s.cfg.WebAuthn.RPOrigins...)
|
||
return s.cfg.WebAuthn.Enabled,
|
||
strings.TrimSpace(s.cfg.WebAuthn.RPID),
|
||
origins
|
||
}
|
||
|
||
func (s *SettingService) passkeyConfigured() bool {
|
||
return s != nil && s.cfg != nil && s.cfg.WebAuthn.Enabled
|
||
}
|
||
|
||
// passkeySettingEnabled must stay ANDed with passkeyConfigured: a stale
|
||
// "true" row after the WebAuthn config is removed would otherwise make the
|
||
// admin update gate reject every settings save while the UI toggle is locked.
|
||
func (s *SettingService) passkeySettingEnabled(settings map[string]string) bool {
|
||
if !s.passkeyConfigured() {
|
||
return false
|
||
}
|
||
value, ok := settings[SettingKeyPasskeyEnabled]
|
||
if !ok {
|
||
return true
|
||
}
|
||
return value == "true"
|
||
}
|
||
|
||
// IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置
|
||
// 只有手动配置了密钥才允许在管理后台启用 TOTP 功能
|
||
func (s *SettingService) IsTotpEncryptionKeyConfigured() bool {
|
||
return s.cfg.Totp.EncryptionKeyConfigured
|
||
}
|
||
|
||
// IsSessionBindingEnabled 检查会话 IP/UA 绑定是否启用(默认关闭)。
|
||
// 开启时会话与登录时的 IP/User-Agent 绑定,任一变化立即失效并撤销该会话。
|
||
// 默认关闭:移动网络/多出口 IP 场景下 IP 频繁变化会导致登录后立即掉线。
|
||
func (s *SettingService) IsSessionBindingEnabled(ctx context.Context) bool {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeySessionBindingEnabled)
|
||
if err != nil {
|
||
return false // 默认关闭
|
||
}
|
||
return value == "true"
|
||
}
|
||
|
||
// IsStepUpEnabled 检查敏感操作 step-up 2FA 门控是否启用(默认关闭)。
|
||
// 开启时账号/代理导出、备份创建/下载、S3 配置修改、提升管理员等操作
|
||
// 要求当前会话在有效期内完成过 TOTP step-up 验证。
|
||
func (s *SettingService) IsStepUpEnabled(ctx context.Context) bool {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyStepUpEnabled)
|
||
if err != nil {
|
||
return false // 默认关闭
|
||
}
|
||
return value == "true"
|
||
}
|
||
|
||
// defaultAuditLogRetentionDays 审计日志默认保留天数。
|
||
const defaultAuditLogRetentionDays = 180
|
||
|
||
// GetAuditLogRetentionDays 审计日志保留天数(<=0 表示永久保留,仅支持手动清空)。
|
||
func (s *SettingService) GetAuditLogRetentionDays(ctx context.Context) int {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyAuditLogRetentionDays)
|
||
if err != nil {
|
||
return defaultAuditLogRetentionDays
|
||
}
|
||
return parseAuditLogRetentionDays(value)
|
||
}
|
||
|
||
// parseAuditLogRetentionDays 解析保留天数配置,空/非法值回退默认值。
|
||
func parseAuditLogRetentionDays(value string) int {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return defaultAuditLogRetentionDays
|
||
}
|
||
n, err := strconv.Atoi(value)
|
||
if err != nil {
|
||
return defaultAuditLogRetentionDays
|
||
}
|
||
if n < 0 {
|
||
return 0
|
||
}
|
||
return n
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// TencentCaptchaConfig contains the credentials required by Tencent Cloud's
|
||
// ticket verification API. It must never be returned by a public handler.
|
||
type TencentCaptchaConfig struct {
|
||
Enabled bool
|
||
AppID string
|
||
AppSecretKey string
|
||
CloudSecretID string
|
||
CloudSecretKey string
|
||
Region string
|
||
}
|
||
|
||
// AliyunCaptchaConfig contains the credentials required by Aliyun Captcha 2.0's
|
||
// server-side verification API. It must never be returned by a public handler.
|
||
type AliyunCaptchaConfig struct {
|
||
Enabled bool
|
||
AccessKeyID string
|
||
AccessKeySecret string
|
||
SceneID string
|
||
Region string
|
||
}
|
||
|
||
type CaptchaProviderConfig struct {
|
||
TurnstileEnabled bool
|
||
TurnstileSecretKey string
|
||
Tencent TencentCaptchaConfig
|
||
Aliyun AliyunCaptchaConfig
|
||
}
|
||
|
||
func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaProviderConfig, error) {
|
||
values, err := s.settingRepo.GetMultiple(ctx, []string{
|
||
SettingKeyTurnstileEnabled,
|
||
SettingKeyTurnstileSecretKey,
|
||
SettingKeyTencentCaptchaEnabled,
|
||
SettingKeyTencentCaptchaAppID,
|
||
SettingKeyTencentCaptchaAppSecretKey,
|
||
SettingKeyTencentCaptchaCloudSecretID,
|
||
SettingKeyTencentCaptchaCloudSecretKey,
|
||
SettingKeyTencentCaptchaRegion,
|
||
SettingKeyAliyunCaptchaEnabled,
|
||
SettingKeyAliyunCaptchaAccessKeyID,
|
||
SettingKeyAliyunCaptchaAccessKeySecret,
|
||
SettingKeyAliyunCaptchaSceneID,
|
||
SettingKeyAliyunCaptchaRegion,
|
||
})
|
||
if err != nil {
|
||
return CaptchaProviderConfig{}, fmt.Errorf("read captcha provider settings: %w", err)
|
||
}
|
||
return CaptchaProviderConfig{
|
||
TurnstileEnabled: values[SettingKeyTurnstileEnabled] == "true",
|
||
TurnstileSecretKey: values[SettingKeyTurnstileSecretKey],
|
||
Tencent: TencentCaptchaConfig{
|
||
Enabled: values[SettingKeyTencentCaptchaEnabled] == "true",
|
||
AppID: values[SettingKeyTencentCaptchaAppID],
|
||
AppSecretKey: values[SettingKeyTencentCaptchaAppSecretKey],
|
||
CloudSecretID: values[SettingKeyTencentCaptchaCloudSecretID],
|
||
CloudSecretKey: values[SettingKeyTencentCaptchaCloudSecretKey],
|
||
Region: normalizeTencentCaptchaRegion(values[SettingKeyTencentCaptchaRegion]),
|
||
},
|
||
Aliyun: AliyunCaptchaConfig{
|
||
Enabled: values[SettingKeyAliyunCaptchaEnabled] == "true",
|
||
AccessKeyID: values[SettingKeyAliyunCaptchaAccessKeyID],
|
||
AccessKeySecret: values[SettingKeyAliyunCaptchaAccessKeySecret],
|
||
SceneID: values[SettingKeyAliyunCaptchaSceneID],
|
||
Region: normalizeAliyunCaptchaRegion(values[SettingKeyAliyunCaptchaRegion]),
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
func (s *SettingService) IsTencentCaptchaEnabled(ctx context.Context) bool {
|
||
value, err := s.settingRepo.GetValue(ctx, SettingKeyTencentCaptchaEnabled)
|
||
return err == nil && value == "true"
|
||
}
|
||
|
||
func (s *SettingService) GetTencentCaptchaConfig(ctx context.Context) TencentCaptchaConfig {
|
||
config, err := s.GetCaptchaProviderConfig(ctx)
|
||
if err != nil {
|
||
return TencentCaptchaConfig{}
|
||
}
|
||
return config.Tencent
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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))
|
||
}
|
||
|
||
// 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"
|
||
}
|
||
|
||
// 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,
|
||
OpenAIFastPolicyActionForcePriority: 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)
|
||
}
|
||
seenUserIDs := make(map[int64]struct{}, len(rule.UserIDs))
|
||
for j, userID := range rule.UserIDs {
|
||
if userID <= 0 {
|
||
return fmt.Errorf("rule[%d]: user_ids[%d] must be positive", i, j)
|
||
}
|
||
if _, exists := seenUserIDs[userID]; exists {
|
||
return fmt.Errorf("rule[%d]: user_ids[%d] duplicates user_id %d", i, j, userID)
|
||
}
|
||
seenUserIDs[userID] = struct{}{}
|
||
}
|
||
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,返回全部允许平台 x 3 window 的设置。
|
||
// 永远返回包含全部允许 platform key 的 map(值可能为零值/nil 字段,表示"上层未配置 = 不限制")。
|
||
//
|
||
// 使用单个 JSON key(default_platform_quotas),一次 DB roundtrip,消除旧 12-KV 格式的 N+1 问题。
|
||
// 容错语义:取值失败或 unmarshal 失败 → 返回补齐全部允许平台 key 的空 map(fail-open,注册不被阻断)。
|
||
func (s *SettingService) GetDefaultPlatformQuotas(ctx context.Context) (map[string]*DefaultPlatformQuotaSetting, error) {
|
||
out := make(map[string]*DefaultPlatformQuotaSetting, len(AllowedQuotaPlatforms))
|
||
for _, platform := range AllowedQuotaPlatforms {
|
||
out[platform] = &DefaultPlatformQuotaSetting{}
|
||
}
|
||
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 // 补齐全部允许 platform key,保持与旧实现一致的下游契约
|
||
}
|
||
|
||
// GetAccountSchedulingThresholds returns per-platform auto-pause thresholds (1..100).
|
||
// 100 disables the threshold for that platform. Hot-path cached with singleflight.
|
||
func (s *SettingService) GetAccountSchedulingThresholds(ctx context.Context) map[string]int {
|
||
if s == nil || s.settingRepo == nil {
|
||
return defaultAccountSchedulingThresholds()
|
||
}
|
||
if cached, ok := accountSchedulingThresholdsCache.Load().(*cachedAccountSchedulingThresholds); ok {
|
||
if cached != nil && len(cached.thresholds) > 0 && time.Now().UnixNano() < cached.expiresAt {
|
||
return cloneAccountSchedulingThresholds(cached.thresholds)
|
||
}
|
||
}
|
||
|
||
result, err, _ := accountSchedulingThresholdsSF.Do(SettingKeyAccountSchedulingThresholds, func() (any, error) {
|
||
if cached, ok := accountSchedulingThresholdsCache.Load().(*cachedAccountSchedulingThresholds); ok {
|
||
if cached != nil && len(cached.thresholds) > 0 && time.Now().UnixNano() < cached.expiresAt {
|
||
return cloneAccountSchedulingThresholds(cached.thresholds), nil
|
||
}
|
||
}
|
||
|
||
thresholds := defaultAccountSchedulingThresholds()
|
||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), accountSchedulingThresholdsDBTimeout)
|
||
defer cancel()
|
||
|
||
raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyAccountSchedulingThresholds)
|
||
if err != nil {
|
||
if errors.Is(err, ErrSettingNotFound) {
|
||
accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{
|
||
thresholds: cloneAccountSchedulingThresholds(thresholds),
|
||
expiresAt: time.Now().Add(accountSchedulingThresholdsCacheTTL).UnixNano(),
|
||
})
|
||
return cloneAccountSchedulingThresholds(thresholds), nil
|
||
}
|
||
slog.Warn("failed to get account scheduling thresholds, falling back to defaults", "error", err)
|
||
accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{
|
||
thresholds: cloneAccountSchedulingThresholds(thresholds),
|
||
expiresAt: time.Now().Add(accountSchedulingThresholdsErrorTTL).UnixNano(),
|
||
})
|
||
return cloneAccountSchedulingThresholds(thresholds), nil
|
||
}
|
||
|
||
if trimmed := strings.TrimSpace(raw); trimmed != "" {
|
||
if parsed, err := parseAccountSchedulingThresholdsSetting(trimmed); err != nil {
|
||
slog.Warn("failed to parse account scheduling thresholds, falling back to defaults", "error", err)
|
||
} else {
|
||
thresholds = parsed
|
||
}
|
||
}
|
||
|
||
accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{
|
||
thresholds: cloneAccountSchedulingThresholds(thresholds),
|
||
expiresAt: time.Now().Add(accountSchedulingThresholdsCacheTTL).UnixNano(),
|
||
})
|
||
return cloneAccountSchedulingThresholds(thresholds), nil
|
||
})
|
||
if err != nil {
|
||
return defaultAccountSchedulingThresholds()
|
||
}
|
||
if thresholds, ok := result.(map[string]int); ok {
|
||
return cloneAccountSchedulingThresholds(thresholds)
|
||
}
|
||
return defaultAccountSchedulingThresholds()
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|