2025-12-18 13:50:39 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
2025-12-20 15:11:43 +08:00
|
|
|
|
"crypto/rand"
|
|
|
|
|
|
"encoding/hex"
|
2026-01-11 21:54:52 -08:00
|
|
|
|
"encoding/json"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
2025-12-25 17:15:01 +08:00
|
|
|
|
"strconv"
|
2026-01-09 20:57:32 +08:00
|
|
|
|
"strings"
|
2026-02-28 15:01:20 +08:00
|
|
|
|
"time"
|
2025-12-25 17:15:01 +08:00
|
|
|
|
|
2025-12-24 21:07:21 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
2025-12-31 23:42:01 +08:00
|
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
2026-02-28 15:01:20 +08:00
|
|
|
|
ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
|
|
|
|
|
|
ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found")
|
|
|
|
|
|
ErrSoraS3ProfileNotFound = infraerrors.NotFound("SORA_S3_PROFILE_NOT_FOUND", "sora s3 profile not found")
|
|
|
|
|
|
ErrSoraS3ProfileExists = infraerrors.Conflict("SORA_S3_PROFILE_EXISTS", "sora s3 profile already exists")
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-25 17:15:01 +08:00
|
|
|
|
type SettingRepository interface {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
Get(ctx context.Context, key string) (*Setting, error)
|
2025-12-25 17:15:01 +08:00
|
|
|
|
GetValue(ctx context.Context, key string) (string, error)
|
|
|
|
|
|
Set(ctx context.Context, key, value string) error
|
|
|
|
|
|
GetMultiple(ctx context.Context, keys []string) (map[string]string, error)
|
|
|
|
|
|
SetMultiple(ctx context.Context, settings map[string]string) error
|
|
|
|
|
|
GetAll(ctx context.Context) (map[string]string, error)
|
|
|
|
|
|
Delete(ctx context.Context, key string) error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// SettingService 系统设置服务
|
|
|
|
|
|
type SettingService struct {
|
2025-12-25 17:15:01 +08:00
|
|
|
|
settingRepo SettingRepository
|
2025-12-18 13:50:39 +08:00
|
|
|
|
cfg *config.Config
|
2026-01-10 18:37:44 +08:00
|
|
|
|
onUpdate func() // Callback when settings are updated (for cache invalidation)
|
2026-02-28 15:01:20 +08:00
|
|
|
|
onS3Update func() // Callback when Sora S3 settings are updated
|
2026-01-10 18:37:44 +08:00
|
|
|
|
version string // Application version
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewSettingService 创建系统设置服务实例
|
2025-12-25 17:15:01 +08:00
|
|
|
|
func NewSettingService(settingRepo SettingRepository, cfg *config.Config) *SettingService {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return &SettingService{
|
|
|
|
|
|
settingRepo: settingRepo,
|
|
|
|
|
|
cfg: cfg,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetAllSettings 获取所有系统设置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *SettingService) GetAllSettings(ctx context.Context) (*SystemSettings, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
settings, err := s.settingRepo.GetAll(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get all settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.parseSettings(settings), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetPublicSettings 获取公开设置(无需登录)
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
keys := []string{
|
2025-12-26 15:40:24 +08:00
|
|
|
|
SettingKeyRegistrationEnabled,
|
|
|
|
|
|
SettingKeyEmailVerifyEnabled,
|
2026-01-20 15:56:26 +08:00
|
|
|
|
SettingKeyPromoCodeEnabled,
|
2026-01-24 22:33:45 +08:00
|
|
|
|
SettingKeyPasswordResetEnabled,
|
2026-01-29 16:29:59 +08:00
|
|
|
|
SettingKeyInvitationCodeEnabled,
|
2026-01-26 08:45:43 +08:00
|
|
|
|
SettingKeyTotpEnabled,
|
2025-12-26 15:40:24 +08:00
|
|
|
|
SettingKeyTurnstileEnabled,
|
|
|
|
|
|
SettingKeyTurnstileSiteKey,
|
|
|
|
|
|
SettingKeySiteName,
|
|
|
|
|
|
SettingKeySiteLogo,
|
|
|
|
|
|
SettingKeySiteSubtitle,
|
2026-01-04 19:27:53 +08:00
|
|
|
|
SettingKeyAPIBaseURL,
|
2025-12-26 15:40:24 +08:00
|
|
|
|
SettingKeyContactInfo,
|
2026-01-04 19:27:53 +08:00
|
|
|
|
SettingKeyDocURL,
|
2026-01-10 18:37:44 +08:00
|
|
|
|
SettingKeyHomeContent,
|
2026-01-19 19:25:16 +08:00
|
|
|
|
SettingKeyHideCcsImportButton,
|
2026-01-28 13:54:32 +08:00
|
|
|
|
SettingKeyPurchaseSubscriptionEnabled,
|
|
|
|
|
|
SettingKeyPurchaseSubscriptionURL,
|
2026-02-28 15:01:20 +08:00
|
|
|
|
SettingKeySoraClientEnabled,
|
2026-01-09 13:52:27 +08:00
|
|
|
|
SettingKeyLinuxDoConnectEnabled,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get public settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 13:52:27 +08:00
|
|
|
|
linuxDoEnabled := false
|
|
|
|
|
|
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
|
|
|
|
|
|
linuxDoEnabled = raw == "true"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-24 22:33:45 +08:00
|
|
|
|
// Password reset requires email verification to be enabled
|
|
|
|
|
|
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
|
|
|
|
|
passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true"
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
return &PublicSettings{
|
2026-01-28 13:54:32 +08:00
|
|
|
|
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
|
|
|
|
|
EmailVerifyEnabled: emailVerifyEnabled,
|
|
|
|
|
|
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
|
|
|
|
|
PasswordResetEnabled: passwordResetEnabled,
|
2026-01-29 16:29:59 +08:00
|
|
|
|
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
2026-01-28 13:54:32 +08:00
|
|
|
|
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
|
|
|
|
|
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
|
|
|
|
|
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
|
|
|
|
|
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
|
|
|
|
|
|
SiteLogo: settings[SettingKeySiteLogo],
|
|
|
|
|
|
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
|
|
|
|
|
|
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
|
|
|
|
|
ContactInfo: settings[SettingKeyContactInfo],
|
|
|
|
|
|
DocURL: settings[SettingKeyDocURL],
|
|
|
|
|
|
HomeContent: settings[SettingKeyHomeContent],
|
|
|
|
|
|
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
|
|
|
|
|
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
|
|
|
|
|
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
2026-02-28 15:01:20 +08:00
|
|
|
|
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
|
2026-01-28 13:54:32 +08:00
|
|
|
|
LinuxDoOAuthEnabled: linuxDoEnabled,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 18:37:44 +08:00
|
|
|
|
// SetOnUpdateCallback sets a callback function to be called when settings are updated
|
|
|
|
|
|
// This is used for cache invalidation (e.g., HTML cache in frontend server)
|
|
|
|
|
|
func (s *SettingService) SetOnUpdateCallback(callback func()) {
|
|
|
|
|
|
s.onUpdate = callback
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 15:01:20 +08:00
|
|
|
|
// SetOnS3UpdateCallback 设置 Sora S3 配置变更时的回调函数(用于刷新 S3 客户端缓存)。
|
|
|
|
|
|
func (s *SettingService) SetOnS3UpdateCallback(callback func()) {
|
|
|
|
|
|
s.onS3Update = callback
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 18:37:44 +08:00
|
|
|
|
// SetVersion sets the application version for injection into public settings
|
|
|
|
|
|
func (s *SettingService) SetVersion(version string) {
|
|
|
|
|
|
s.version = version
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection
|
|
|
|
|
|
// This implements the web.PublicSettingsProvider interface
|
2026-01-10 19:08:41 +08:00
|
|
|
|
func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) {
|
2026-01-10 18:37:44 +08:00
|
|
|
|
settings, err := s.GetPublicSettings(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Return a struct that matches the frontend's expected format
|
|
|
|
|
|
return &struct {
|
2026-01-28 13:54:32 +08:00
|
|
|
|
RegistrationEnabled bool `json:"registration_enabled"`
|
|
|
|
|
|
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
|
|
|
|
|
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
|
|
|
|
|
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
2026-01-29 16:29:59 +08:00
|
|
|
|
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
2026-01-28 13:54:32 +08:00
|
|
|
|
TotpEnabled bool `json:"totp_enabled"`
|
|
|
|
|
|
TurnstileEnabled bool `json:"turnstile_enabled"`
|
|
|
|
|
|
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
|
|
|
|
|
|
SiteName string `json:"site_name"`
|
|
|
|
|
|
SiteLogo string `json:"site_logo,omitempty"`
|
|
|
|
|
|
SiteSubtitle string `json:"site_subtitle,omitempty"`
|
|
|
|
|
|
APIBaseURL string `json:"api_base_url,omitempty"`
|
|
|
|
|
|
ContactInfo string `json:"contact_info,omitempty"`
|
|
|
|
|
|
DocURL string `json:"doc_url,omitempty"`
|
|
|
|
|
|
HomeContent string `json:"home_content,omitempty"`
|
|
|
|
|
|
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
|
|
|
|
|
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
|
|
|
|
|
PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"`
|
2026-02-28 15:01:20 +08:00
|
|
|
|
SoraClientEnabled bool `json:"sora_client_enabled"`
|
2026-01-28 13:54:32 +08:00
|
|
|
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
|
|
|
|
|
Version string `json:"version,omitempty"`
|
2026-01-10 18:37:44 +08:00
|
|
|
|
}{
|
2026-01-28 13:54:32 +08:00
|
|
|
|
RegistrationEnabled: settings.RegistrationEnabled,
|
|
|
|
|
|
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
|
|
|
|
|
PromoCodeEnabled: settings.PromoCodeEnabled,
|
|
|
|
|
|
PasswordResetEnabled: settings.PasswordResetEnabled,
|
2026-01-29 16:29:59 +08:00
|
|
|
|
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
2026-01-28 13:54:32 +08:00
|
|
|
|
TotpEnabled: settings.TotpEnabled,
|
|
|
|
|
|
TurnstileEnabled: settings.TurnstileEnabled,
|
|
|
|
|
|
TurnstileSiteKey: settings.TurnstileSiteKey,
|
|
|
|
|
|
SiteName: settings.SiteName,
|
|
|
|
|
|
SiteLogo: settings.SiteLogo,
|
|
|
|
|
|
SiteSubtitle: settings.SiteSubtitle,
|
|
|
|
|
|
APIBaseURL: settings.APIBaseURL,
|
|
|
|
|
|
ContactInfo: settings.ContactInfo,
|
|
|
|
|
|
DocURL: settings.DocURL,
|
|
|
|
|
|
HomeContent: settings.HomeContent,
|
|
|
|
|
|
HideCcsImportButton: settings.HideCcsImportButton,
|
|
|
|
|
|
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
|
|
|
|
|
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
2026-02-28 15:01:20 +08:00
|
|
|
|
SoraClientEnabled: settings.SoraClientEnabled,
|
2026-01-28 13:54:32 +08:00
|
|
|
|
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
|
|
|
|
|
Version: s.version,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UpdateSettings 更新系统设置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
updates := make(map[string]string)
|
|
|
|
|
|
|
|
|
|
|
|
// 注册设置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled)
|
|
|
|
|
|
updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled)
|
2026-01-20 15:56:26 +08:00
|
|
|
|
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
|
2026-01-24 22:33:45 +08:00
|
|
|
|
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
|
2026-01-29 16:29:59 +08:00
|
|
|
|
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
|
2026-01-26 08:45:43 +08:00
|
|
|
|
updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 邮件服务设置(只有非空才更新密码)
|
2026-01-04 19:27:53 +08:00
|
|
|
|
updates[SettingKeySMTPHost] = settings.SMTPHost
|
|
|
|
|
|
updates[SettingKeySMTPPort] = strconv.Itoa(settings.SMTPPort)
|
|
|
|
|
|
updates[SettingKeySMTPUsername] = settings.SMTPUsername
|
2026-01-04 17:02:38 +08:00
|
|
|
|
if settings.SMTPPassword != "" {
|
2026-01-04 19:27:53 +08:00
|
|
|
|
updates[SettingKeySMTPPassword] = settings.SMTPPassword
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2026-01-04 19:27:53 +08:00
|
|
|
|
updates[SettingKeySMTPFrom] = settings.SMTPFrom
|
|
|
|
|
|
updates[SettingKeySMTPFromName] = settings.SMTPFromName
|
|
|
|
|
|
updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
// Cloudflare Turnstile 设置(只有非空才更新密钥)
|
2025-12-26 15:40:24 +08:00
|
|
|
|
updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled)
|
|
|
|
|
|
updates[SettingKeyTurnstileSiteKey] = settings.TurnstileSiteKey
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if settings.TurnstileSecretKey != "" {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 09:14:32 +08:00
|
|
|
|
// LinuxDo Connect OAuth 登录
|
2026-01-09 13:52:27 +08:00
|
|
|
|
updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled)
|
|
|
|
|
|
updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID
|
|
|
|
|
|
updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL
|
|
|
|
|
|
if settings.LinuxDoConnectClientSecret != "" {
|
|
|
|
|
|
updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// OEM设置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
updates[SettingKeySiteName] = settings.SiteName
|
|
|
|
|
|
updates[SettingKeySiteLogo] = settings.SiteLogo
|
|
|
|
|
|
updates[SettingKeySiteSubtitle] = settings.SiteSubtitle
|
2026-01-04 19:27:53 +08:00
|
|
|
|
updates[SettingKeyAPIBaseURL] = settings.APIBaseURL
|
2025-12-26 15:40:24 +08:00
|
|
|
|
updates[SettingKeyContactInfo] = settings.ContactInfo
|
2026-01-04 19:27:53 +08:00
|
|
|
|
updates[SettingKeyDocURL] = settings.DocURL
|
2026-01-10 18:37:44 +08:00
|
|
|
|
updates[SettingKeyHomeContent] = settings.HomeContent
|
2026-01-19 19:25:16 +08:00
|
|
|
|
updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton)
|
2026-01-28 13:54:32 +08:00
|
|
|
|
updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled)
|
|
|
|
|
|
updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL)
|
2026-02-28 15:01:20 +08:00
|
|
|
|
updates[SettingKeySoraClientEnabled] = strconv.FormatBool(settings.SoraClientEnabled)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 默认配置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
|
|
|
|
|
|
updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-03 06:37:08 -08:00
|
|
|
|
// Model fallback configuration
|
|
|
|
|
|
updates[SettingKeyEnableModelFallback] = strconv.FormatBool(settings.EnableModelFallback)
|
|
|
|
|
|
updates[SettingKeyFallbackModelAnthropic] = settings.FallbackModelAnthropic
|
|
|
|
|
|
updates[SettingKeyFallbackModelOpenAI] = settings.FallbackModelOpenAI
|
|
|
|
|
|
updates[SettingKeyFallbackModelGemini] = settings.FallbackModelGemini
|
|
|
|
|
|
updates[SettingKeyFallbackModelAntigravity] = settings.FallbackModelAntigravity
|
|
|
|
|
|
|
2026-01-04 22:49:40 +08:00
|
|
|
|
// Identity patch configuration (Claude -> Gemini)
|
|
|
|
|
|
updates[SettingKeyEnableIdentityPatch] = strconv.FormatBool(settings.EnableIdentityPatch)
|
|
|
|
|
|
updates[SettingKeyIdentityPatchPrompt] = settings.IdentityPatchPrompt
|
|
|
|
|
|
|
2026-01-09 20:57:32 +08:00
|
|
|
|
// Ops monitoring (vNext)
|
|
|
|
|
|
updates[SettingKeyOpsMonitoringEnabled] = strconv.FormatBool(settings.OpsMonitoringEnabled)
|
|
|
|
|
|
updates[SettingKeyOpsRealtimeMonitoringEnabled] = strconv.FormatBool(settings.OpsRealtimeMonitoringEnabled)
|
|
|
|
|
|
updates[SettingKeyOpsQueryModeDefault] = string(ParseOpsQueryMode(settings.OpsQueryModeDefault))
|
2026-01-10 01:38:47 +08:00
|
|
|
|
if settings.OpsMetricsIntervalSeconds > 0 {
|
|
|
|
|
|
updates[SettingKeyOpsMetricsIntervalSeconds] = strconv.Itoa(settings.OpsMetricsIntervalSeconds)
|
|
|
|
|
|
}
|
2026-01-09 20:57:32 +08:00
|
|
|
|
|
2026-01-10 18:37:44 +08:00
|
|
|
|
err := s.settingRepo.SetMultiple(ctx, updates)
|
|
|
|
|
|
if err == nil && s.onUpdate != nil {
|
|
|
|
|
|
s.onUpdate() // Invalidate cache after settings update
|
|
|
|
|
|
}
|
|
|
|
|
|
return err
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsRegistrationEnabled 检查是否开放注册
|
|
|
|
|
|
func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
2026-01-12 09:14:32 +08:00
|
|
|
|
// 安全默认:如果设置不存在或查询出错,默认关闭注册
|
|
|
|
|
|
return false
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsEmailVerifyEnabled 检查是否开启邮件验证
|
|
|
|
|
|
func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-20 15:56:26 +08:00
|
|
|
|
// IsPromoCodeEnabled 检查是否启用优惠码功能
|
|
|
|
|
|
func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return true // 默认启用
|
|
|
|
|
|
}
|
|
|
|
|
|
return value != "false"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 16:29:59 +08:00
|
|
|
|
// IsInvitationCodeEnabled 检查是否启用邀请码注册功能
|
|
|
|
|
|
func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyInvitationCodeEnabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false // 默认关闭
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-24 22:33:45 +08:00
|
|
|
|
// IsPasswordResetEnabled 检查是否启用密码重置功能
|
|
|
|
|
|
// 要求:必须同时开启邮件验证
|
|
|
|
|
|
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
|
|
|
|
|
|
// Password reset requires email verification to be enabled
|
|
|
|
|
|
if !s.IsEmailVerifyEnabled(ctx) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyPasswordResetEnabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false // 默认关闭
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-26 08:45:43 +08:00
|
|
|
|
// IsTotpEnabled 检查是否启用 TOTP 双因素认证功能
|
|
|
|
|
|
func (s *SettingService) IsTotpEnabled(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyTotpEnabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false // 默认关闭
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置
|
|
|
|
|
|
// 只有手动配置了密钥才允许在管理后台启用 TOTP 功能
|
|
|
|
|
|
func (s *SettingService) IsTotpEncryptionKeyConfigured() bool {
|
|
|
|
|
|
return s.cfg.Totp.EncryptionKeyConfigured
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// GetSiteName 获取网站名称
|
|
|
|
|
|
func (s *SettingService) GetSiteName(ctx context.Context) string {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil || value == "" {
|
|
|
|
|
|
return "Sub2API"
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetDefaultConcurrency 获取默认并发量
|
|
|
|
|
|
func (s *SettingService) GetDefaultConcurrency(ctx context.Context) int {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultConcurrency)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.cfg.Default.UserConcurrency
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, err := strconv.Atoi(value); err == nil && v > 0 {
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.cfg.Default.UserConcurrency
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetDefaultBalance 获取默认余额
|
|
|
|
|
|
func (s *SettingService) GetDefaultBalance(ctx context.Context) float64 {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultBalance)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.cfg.Default.UserBalance
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 {
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.cfg.Default.UserBalance
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// InitializeDefaultSettings 初始化默认设置
|
|
|
|
|
|
func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|
|
|
|
|
// 检查是否已有设置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
_, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err == nil {
|
|
|
|
|
|
// 已有设置,不需要初始化
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2025-12-25 20:52:47 +08:00
|
|
|
|
if !errors.Is(err, ErrSettingNotFound) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return fmt.Errorf("check existing settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化默认设置
|
|
|
|
|
|
defaults := map[string]string{
|
2026-01-28 13:54:32 +08:00
|
|
|
|
SettingKeyRegistrationEnabled: "true",
|
|
|
|
|
|
SettingKeyEmailVerifyEnabled: "false",
|
|
|
|
|
|
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
|
|
|
|
|
|
SettingKeySiteName: "Sub2API",
|
|
|
|
|
|
SettingKeySiteLogo: "",
|
|
|
|
|
|
SettingKeyPurchaseSubscriptionEnabled: "false",
|
|
|
|
|
|
SettingKeyPurchaseSubscriptionURL: "",
|
2026-02-28 15:01:20 +08:00
|
|
|
|
SettingKeySoraClientEnabled: "false",
|
2026-01-28 13:54:32 +08:00
|
|
|
|
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
|
|
|
|
|
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
|
|
|
|
|
SettingKeySMTPPort: "587",
|
|
|
|
|
|
SettingKeySMTPUseTLS: "false",
|
2026-01-03 06:37:08 -08:00
|
|
|
|
// Model fallback defaults
|
|
|
|
|
|
SettingKeyEnableModelFallback: "false",
|
|
|
|
|
|
SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022",
|
|
|
|
|
|
SettingKeyFallbackModelOpenAI: "gpt-4o",
|
|
|
|
|
|
SettingKeyFallbackModelGemini: "gemini-2.5-pro",
|
|
|
|
|
|
SettingKeyFallbackModelAntigravity: "gemini-2.5-pro",
|
2026-01-04 22:49:40 +08:00
|
|
|
|
// Identity patch defaults
|
|
|
|
|
|
SettingKeyEnableIdentityPatch: "true",
|
|
|
|
|
|
SettingKeyIdentityPatchPrompt: "",
|
2026-01-09 20:57:32 +08:00
|
|
|
|
|
|
|
|
|
|
// Ops monitoring defaults (vNext)
|
|
|
|
|
|
SettingKeyOpsMonitoringEnabled: "true",
|
|
|
|
|
|
SettingKeyOpsRealtimeMonitoringEnabled: "true",
|
|
|
|
|
|
SettingKeyOpsQueryModeDefault: "auto",
|
2026-01-10 01:38:47 +08:00
|
|
|
|
SettingKeyOpsMetricsIntervalSeconds: "60",
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.settingRepo.SetMultiple(ctx, defaults)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parseSettings 解析设置到结构体
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings {
|
2026-01-24 22:33:45 +08:00
|
|
|
|
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
2025-12-26 15:40:24 +08:00
|
|
|
|
result := &SystemSettings{
|
2026-01-04 21:06:12 +08:00
|
|
|
|
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
2026-01-24 22:33:45 +08:00
|
|
|
|
EmailVerifyEnabled: emailVerifyEnabled,
|
2026-01-20 15:56:26 +08:00
|
|
|
|
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
2026-01-24 22:33:45 +08:00
|
|
|
|
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
|
2026-01-29 16:29:59 +08:00
|
|
|
|
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
2026-01-26 08:45:43 +08:00
|
|
|
|
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
2026-01-04 21:06:12 +08:00
|
|
|
|
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],
|
2026-01-02 17:40:57 +08:00
|
|
|
|
TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "",
|
2026-01-04 21:06:12 +08:00
|
|
|
|
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],
|
2026-01-10 18:37:44 +08:00
|
|
|
|
HomeContent: settings[SettingKeyHomeContent],
|
2026-01-19 19:25:16 +08:00
|
|
|
|
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
2026-01-28 13:54:32 +08:00
|
|
|
|
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
|
|
|
|
|
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
2026-02-28 15:01:20 +08:00
|
|
|
|
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析整数类型
|
2026-01-04 19:27:53 +08:00
|
|
|
|
if port, err := strconv.Atoi(settings[SettingKeySMTPPort]); err == nil {
|
2026-01-04 17:02:38 +08:00
|
|
|
|
result.SMTPPort = port
|
2025-12-18 13:50:39 +08:00
|
|
|
|
} else {
|
2026-01-04 17:02:38 +08:00
|
|
|
|
result.SMTPPort = 587
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
if concurrency, err := strconv.Atoi(settings[SettingKeyDefaultConcurrency]); err == nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
result.DefaultConcurrency = concurrency
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.DefaultConcurrency = s.cfg.Default.UserConcurrency
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析浮点数类型
|
2025-12-26 15:40:24 +08:00
|
|
|
|
if balance, err := strconv.ParseFloat(settings[SettingKeyDefaultBalance], 64); err == nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
result.DefaultBalance = balance
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.DefaultBalance = s.cfg.Default.UserBalance
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 06:37:08 -08:00
|
|
|
|
// 敏感信息直接返回,方便测试连接时使用
|
2026-01-04 19:27:53 +08:00
|
|
|
|
result.SMTPPassword = settings[SettingKeySMTPPassword]
|
2025-12-26 15:40:24 +08:00
|
|
|
|
result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey]
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-09 19:32:06 +08:00
|
|
|
|
// LinuxDo Connect 设置:
|
|
|
|
|
|
// - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭)
|
|
|
|
|
|
// - 支持在后台“系统设置”中覆盖并持久化(存储于 DB)
|
2026-01-09 13:52:27 +08:00
|
|
|
|
linuxDoBase := config.LinuxDoConnectConfig{}
|
|
|
|
|
|
if s.cfg != nil {
|
|
|
|
|
|
linuxDoBase = s.cfg.LinuxDo
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
|
|
|
|
|
|
result.LinuxDoConnectEnabled = raw == "true"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.LinuxDoConnectEnabled = linuxDoBase.Enabled
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.LinuxDoConnectClientID = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.LinuxDoConnectClientID = linuxDoBase.ClientID
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.LinuxDoConnectRedirectURL = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.LinuxDoConnectRedirectURL = linuxDoBase.RedirectURL
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result.LinuxDoConnectClientSecret = strings.TrimSpace(settings[SettingKeyLinuxDoConnectClientSecret])
|
|
|
|
|
|
if result.LinuxDoConnectClientSecret == "" {
|
|
|
|
|
|
result.LinuxDoConnectClientSecret = strings.TrimSpace(linuxDoBase.ClientSecret)
|
|
|
|
|
|
}
|
|
|
|
|
|
result.LinuxDoConnectClientSecretConfigured = result.LinuxDoConnectClientSecret != ""
|
|
|
|
|
|
|
2026-01-03 06:37:08 -08:00
|
|
|
|
// Model fallback settings
|
|
|
|
|
|
result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true"
|
|
|
|
|
|
result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022")
|
|
|
|
|
|
result.FallbackModelOpenAI = s.getStringOrDefault(settings, SettingKeyFallbackModelOpenAI, "gpt-4o")
|
|
|
|
|
|
result.FallbackModelGemini = s.getStringOrDefault(settings, SettingKeyFallbackModelGemini, "gemini-2.5-pro")
|
|
|
|
|
|
result.FallbackModelAntigravity = s.getStringOrDefault(settings, SettingKeyFallbackModelAntigravity, "gemini-2.5-pro")
|
|
|
|
|
|
|
2026-01-04 22:49:40 +08:00
|
|
|
|
// Identity patch settings (default: enabled, to preserve existing behavior)
|
|
|
|
|
|
if v, ok := settings[SettingKeyEnableIdentityPatch]; ok && v != "" {
|
|
|
|
|
|
result.EnableIdentityPatch = v == "true"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.EnableIdentityPatch = true
|
|
|
|
|
|
}
|
|
|
|
|
|
result.IdentityPatchPrompt = settings[SettingKeyIdentityPatchPrompt]
|
|
|
|
|
|
|
2026-01-09 20:57:32 +08:00
|
|
|
|
// Ops monitoring settings (default: enabled, fail-open)
|
|
|
|
|
|
result.OpsMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsMonitoringEnabled])
|
|
|
|
|
|
result.OpsRealtimeMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsRealtimeMonitoringEnabled])
|
|
|
|
|
|
result.OpsQueryModeDefault = string(ParseOpsQueryMode(settings[SettingKeyOpsQueryModeDefault]))
|
2026-01-10 01:38:47 +08:00
|
|
|
|
result.OpsMetricsIntervalSeconds = 60
|
|
|
|
|
|
if raw := strings.TrimSpace(settings[SettingKeyOpsMetricsIntervalSeconds]); raw != "" {
|
|
|
|
|
|
if v, err := strconv.Atoi(raw); err == nil {
|
|
|
|
|
|
if v < 60 {
|
|
|
|
|
|
v = 60
|
|
|
|
|
|
}
|
|
|
|
|
|
if v > 3600 {
|
|
|
|
|
|
v = 3600
|
|
|
|
|
|
}
|
|
|
|
|
|
result.OpsMetricsIntervalSeconds = v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-09 20:57:32 +08:00
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 20:57:32 +08:00
|
|
|
|
func isFalseSettingValue(value string) bool {
|
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
|
|
|
|
case "false", "0", "off", "disabled":
|
|
|
|
|
|
return true
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// getStringOrDefault 获取字符串值或默认值
|
|
|
|
|
|
func (s *SettingService) getStringOrDefault(settings map[string]string, key, defaultValue string) string {
|
|
|
|
|
|
if value, ok := settings[key]; ok && value != "" {
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
return defaultValue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsTurnstileEnabled 检查是否启用 Turnstile 验证
|
|
|
|
|
|
func (s *SettingService) IsTurnstileEnabled(ctx context.Context) bool {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileEnabled)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetTurnstileSecretKey 获取 Turnstile Secret Key
|
|
|
|
|
|
func (s *SettingService) GetTurnstileSecretKey(ctx context.Context) string {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileSecretKey)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
2025-12-20 15:11:43 +08:00
|
|
|
|
|
2026-01-04 22:49:40 +08:00
|
|
|
|
// IsIdentityPatchEnabled 检查是否启用身份补丁(Claude -> Gemini systemInstruction 注入)
|
|
|
|
|
|
func (s *SettingService) IsIdentityPatchEnabled(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableIdentityPatch)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
// 默认开启,保持兼容
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetIdentityPatchPrompt 获取自定义身份补丁提示词(为空表示使用内置默认模板)
|
|
|
|
|
|
func (s *SettingService) GetIdentityPatchPrompt(ctx context.Context) string {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyIdentityPatchPrompt)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:27:53 +08:00
|
|
|
|
// GenerateAdminAPIKey 生成新的管理员 API Key
|
|
|
|
|
|
func (s *SettingService) GenerateAdminAPIKey(ctx context.Context) (string, error) {
|
2025-12-20 15:11:43 +08:00
|
|
|
|
// 生成 32 字节随机数 = 64 位十六进制字符
|
|
|
|
|
|
bytes := make([]byte, 32)
|
|
|
|
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("generate random bytes: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:27:53 +08:00
|
|
|
|
key := AdminAPIKeyPrefix + hex.EncodeToString(bytes)
|
2025-12-20 15:11:43 +08:00
|
|
|
|
|
|
|
|
|
|
// 存储到 settings 表
|
2026-01-04 19:27:53 +08:00
|
|
|
|
if err := s.settingRepo.Set(ctx, SettingKeyAdminAPIKey, key); err != nil {
|
2025-12-20 15:11:43 +08:00
|
|
|
|
return "", fmt.Errorf("save admin api key: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return key, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:27:53 +08:00
|
|
|
|
// GetAdminAPIKeyStatus 获取管理员 API Key 状态
|
2025-12-20 15:11:43 +08:00
|
|
|
|
// 返回脱敏的 key、是否存在、错误
|
2026-01-04 19:27:53 +08:00
|
|
|
|
func (s *SettingService) GetAdminAPIKeyStatus(ctx context.Context) (maskedKey string, exists bool, err error) {
|
|
|
|
|
|
key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey)
|
2025-12-20 15:11:43 +08:00
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
if errors.Is(err, ErrSettingNotFound) {
|
2025-12-20 15:11:43 +08:00
|
|
|
|
return "", false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return "", false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if key == "" {
|
|
|
|
|
|
return "", false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 脱敏:显示前 10 位和后 4 位
|
|
|
|
|
|
if len(key) > 14 {
|
|
|
|
|
|
maskedKey = key[:10] + "..." + key[len(key)-4:]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
maskedKey = key
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return maskedKey, true, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:27:53 +08:00
|
|
|
|
// GetAdminAPIKey 获取完整的管理员 API Key(仅供内部验证使用)
|
2025-12-20 15:11:43 +08:00
|
|
|
|
// 如果未配置返回空字符串和 nil 错误,只有数据库错误时才返回 error
|
2026-01-04 19:27:53 +08:00
|
|
|
|
func (s *SettingService) GetAdminAPIKey(ctx context.Context) (string, error) {
|
|
|
|
|
|
key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey)
|
2025-12-20 15:11:43 +08:00
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
if errors.Is(err, ErrSettingNotFound) {
|
2025-12-20 15:11:43 +08:00
|
|
|
|
return "", nil // 未配置,返回空字符串
|
|
|
|
|
|
}
|
|
|
|
|
|
return "", err // 数据库错误
|
|
|
|
|
|
}
|
|
|
|
|
|
return key, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:27:53 +08:00
|
|
|
|
// DeleteAdminAPIKey 删除管理员 API Key
|
|
|
|
|
|
func (s *SettingService) DeleteAdminAPIKey(ctx context.Context) error {
|
|
|
|
|
|
return s.settingRepo.Delete(ctx, SettingKeyAdminAPIKey)
|
2026-01-03 06:37:08 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsModelFallbackEnabled 检查是否启用模型兜底机制
|
|
|
|
|
|
func (s *SettingService) IsModelFallbackEnabled(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableModelFallback)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false // Default: disabled
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetFallbackModel 获取指定平台的兜底模型
|
|
|
|
|
|
func (s *SettingService) GetFallbackModel(ctx context.Context, platform string) string {
|
|
|
|
|
|
var key string
|
|
|
|
|
|
var defaultModel string
|
|
|
|
|
|
|
|
|
|
|
|
switch platform {
|
|
|
|
|
|
case PlatformAnthropic:
|
|
|
|
|
|
key = SettingKeyFallbackModelAnthropic
|
|
|
|
|
|
defaultModel = "claude-3-5-sonnet-20241022"
|
|
|
|
|
|
case PlatformOpenAI:
|
|
|
|
|
|
key = SettingKeyFallbackModelOpenAI
|
|
|
|
|
|
defaultModel = "gpt-4o"
|
|
|
|
|
|
case PlatformGemini:
|
|
|
|
|
|
key = SettingKeyFallbackModelGemini
|
|
|
|
|
|
defaultModel = "gemini-2.5-pro"
|
|
|
|
|
|
case PlatformAntigravity:
|
|
|
|
|
|
key = SettingKeyFallbackModelAntigravity
|
|
|
|
|
|
defaultModel = "gemini-2.5-pro"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, key)
|
|
|
|
|
|
if err != nil || value == "" {
|
|
|
|
|
|
return defaultModel
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
2025-12-20 15:11:43 +08:00
|
|
|
|
}
|
2026-01-11 10:59:01 +08:00
|
|
|
|
|
|
|
|
|
|
// GetLinuxDoConnectOAuthConfig 返回用于登录的"最终生效" LinuxDo Connect 配置。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 优先级:
|
|
|
|
|
|
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
|
|
|
|
|
|
// - 否则回退到 config.yaml/env 的值
|
|
|
|
|
|
func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
|
|
|
|
|
|
if s == nil || s.cfg == nil {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
effective := s.cfg.LinuxDo
|
|
|
|
|
|
|
|
|
|
|
|
keys := []string{
|
|
|
|
|
|
SettingKeyLinuxDoConnectEnabled,
|
|
|
|
|
|
SettingKeyLinuxDoConnectClientID,
|
|
|
|
|
|
SettingKeyLinuxDoConnectClientSecret,
|
|
|
|
|
|
SettingKeyLinuxDoConnectRedirectURL,
|
|
|
|
|
|
}
|
|
|
|
|
|
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, fmt.Errorf("get linuxdo connect settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
|
|
|
|
|
|
effective.Enabled = raw == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.ClientID = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyLinuxDoConnectClientSecret]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.ClientSecret = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
effective.RedirectURL = strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if !effective.Enabled {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。
|
|
|
|
|
|
if strings.TrimSpace(effective.ClientID) == "" {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.AuthorizeURL) == "" {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.TokenURL) == "" {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.UserInfoURL) == "" {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.RedirectURL) == "" {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(effective.FrontendRedirectURL) == "" {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod))
|
|
|
|
|
|
switch method {
|
|
|
|
|
|
case "", "client_secret_post", "client_secret_basic":
|
|
|
|
|
|
if strings.TrimSpace(effective.ClientSecret) == "" {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
case "none":
|
|
|
|
|
|
if !effective.UsePKCE {
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth pkce must be enabled when token_auth_method=none")
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return effective, nil
|
|
|
|
|
|
}
|
2026-01-11 21:54:52 -08:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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))
|
|
|
|
|
|
}
|
2026-02-28 15:01:20 +08:00
|
|
|
|
|
|
|
|
|
|
type soraS3ProfilesStore struct {
|
|
|
|
|
|
ActiveProfileID string `json:"active_profile_id"`
|
|
|
|
|
|
Items []soraS3ProfileStoreItem `json:"items"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type soraS3ProfileStoreItem struct {
|
|
|
|
|
|
ProfileID string `json:"profile_id"`
|
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
|
|
Endpoint string `json:"endpoint"`
|
|
|
|
|
|
Region string `json:"region"`
|
|
|
|
|
|
Bucket string `json:"bucket"`
|
|
|
|
|
|
AccessKeyID string `json:"access_key_id"`
|
|
|
|
|
|
SecretAccessKey string `json:"secret_access_key"`
|
|
|
|
|
|
Prefix string `json:"prefix"`
|
|
|
|
|
|
ForcePathStyle bool `json:"force_path_style"`
|
|
|
|
|
|
CDNURL string `json:"cdn_url"`
|
|
|
|
|
|
DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"`
|
|
|
|
|
|
UpdatedAt string `json:"updated_at"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetSoraS3Settings 获取 Sora S3 存储配置(兼容旧单配置语义:返回当前激活配置)
|
|
|
|
|
|
func (s *SettingService) GetSoraS3Settings(ctx context.Context) (*SoraS3Settings, error) {
|
|
|
|
|
|
profiles, err := s.ListSoraS3Profiles(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
activeProfile := pickActiveSoraS3Profile(profiles.Items, profiles.ActiveProfileID)
|
|
|
|
|
|
if activeProfile == nil {
|
|
|
|
|
|
return &SoraS3Settings{}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &SoraS3Settings{
|
|
|
|
|
|
Enabled: activeProfile.Enabled,
|
|
|
|
|
|
Endpoint: activeProfile.Endpoint,
|
|
|
|
|
|
Region: activeProfile.Region,
|
|
|
|
|
|
Bucket: activeProfile.Bucket,
|
|
|
|
|
|
AccessKeyID: activeProfile.AccessKeyID,
|
|
|
|
|
|
SecretAccessKey: activeProfile.SecretAccessKey,
|
|
|
|
|
|
SecretAccessKeyConfigured: activeProfile.SecretAccessKeyConfigured,
|
|
|
|
|
|
Prefix: activeProfile.Prefix,
|
|
|
|
|
|
ForcePathStyle: activeProfile.ForcePathStyle,
|
|
|
|
|
|
CDNURL: activeProfile.CDNURL,
|
|
|
|
|
|
DefaultStorageQuotaBytes: activeProfile.DefaultStorageQuotaBytes,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SetSoraS3Settings 更新 Sora S3 存储配置(兼容旧单配置语义:写入当前激活配置)
|
|
|
|
|
|
func (s *SettingService) SetSoraS3Settings(ctx context.Context, settings *SoraS3Settings) error {
|
|
|
|
|
|
if settings == nil {
|
|
|
|
|
|
return fmt.Errorf("settings cannot be nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
store, err := s.loadSoraS3ProfilesStore(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
|
|
activeIndex := findSoraS3ProfileIndex(store.Items, store.ActiveProfileID)
|
|
|
|
|
|
if activeIndex < 0 {
|
|
|
|
|
|
activeID := "default"
|
|
|
|
|
|
if hasSoraS3ProfileID(store.Items, activeID) {
|
|
|
|
|
|
activeID = fmt.Sprintf("default-%d", time.Now().Unix())
|
|
|
|
|
|
}
|
|
|
|
|
|
store.Items = append(store.Items, soraS3ProfileStoreItem{
|
|
|
|
|
|
ProfileID: activeID,
|
|
|
|
|
|
Name: "Default",
|
|
|
|
|
|
UpdatedAt: now,
|
|
|
|
|
|
})
|
|
|
|
|
|
store.ActiveProfileID = activeID
|
|
|
|
|
|
activeIndex = len(store.Items) - 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
active := store.Items[activeIndex]
|
|
|
|
|
|
active.Enabled = settings.Enabled
|
|
|
|
|
|
active.Endpoint = strings.TrimSpace(settings.Endpoint)
|
|
|
|
|
|
active.Region = strings.TrimSpace(settings.Region)
|
|
|
|
|
|
active.Bucket = strings.TrimSpace(settings.Bucket)
|
|
|
|
|
|
active.AccessKeyID = strings.TrimSpace(settings.AccessKeyID)
|
|
|
|
|
|
active.Prefix = strings.TrimSpace(settings.Prefix)
|
|
|
|
|
|
active.ForcePathStyle = settings.ForcePathStyle
|
|
|
|
|
|
active.CDNURL = strings.TrimSpace(settings.CDNURL)
|
|
|
|
|
|
active.DefaultStorageQuotaBytes = maxInt64(settings.DefaultStorageQuotaBytes, 0)
|
|
|
|
|
|
if settings.SecretAccessKey != "" {
|
|
|
|
|
|
active.SecretAccessKey = settings.SecretAccessKey
|
|
|
|
|
|
}
|
|
|
|
|
|
active.UpdatedAt = now
|
|
|
|
|
|
store.Items[activeIndex] = active
|
|
|
|
|
|
|
|
|
|
|
|
return s.persistSoraS3ProfilesStore(ctx, store)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ListSoraS3Profiles 获取 Sora S3 多配置列表
|
|
|
|
|
|
func (s *SettingService) ListSoraS3Profiles(ctx context.Context) (*SoraS3ProfileList, error) {
|
|
|
|
|
|
store, err := s.loadSoraS3ProfilesStore(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return convertSoraS3ProfilesStore(store), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CreateSoraS3Profile 创建 Sora S3 配置
|
|
|
|
|
|
func (s *SettingService) CreateSoraS3Profile(ctx context.Context, profile *SoraS3Profile, setActive bool) (*SoraS3Profile, error) {
|
|
|
|
|
|
if profile == nil {
|
|
|
|
|
|
return nil, fmt.Errorf("profile cannot be nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
profileID := strings.TrimSpace(profile.ProfileID)
|
|
|
|
|
|
if profileID == "" {
|
|
|
|
|
|
return nil, infraerrors.BadRequest("SORA_S3_PROFILE_ID_REQUIRED", "profile_id is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
name := strings.TrimSpace(profile.Name)
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
return nil, infraerrors.BadRequest("SORA_S3_PROFILE_NAME_REQUIRED", "name is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
store, err := s.loadSoraS3ProfilesStore(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if hasSoraS3ProfileID(store.Items, profileID) {
|
|
|
|
|
|
return nil, ErrSoraS3ProfileExists
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
|
|
store.Items = append(store.Items, soraS3ProfileStoreItem{
|
|
|
|
|
|
ProfileID: profileID,
|
|
|
|
|
|
Name: name,
|
|
|
|
|
|
Enabled: profile.Enabled,
|
|
|
|
|
|
Endpoint: strings.TrimSpace(profile.Endpoint),
|
|
|
|
|
|
Region: strings.TrimSpace(profile.Region),
|
|
|
|
|
|
Bucket: strings.TrimSpace(profile.Bucket),
|
|
|
|
|
|
AccessKeyID: strings.TrimSpace(profile.AccessKeyID),
|
|
|
|
|
|
SecretAccessKey: profile.SecretAccessKey,
|
|
|
|
|
|
Prefix: strings.TrimSpace(profile.Prefix),
|
|
|
|
|
|
ForcePathStyle: profile.ForcePathStyle,
|
|
|
|
|
|
CDNURL: strings.TrimSpace(profile.CDNURL),
|
|
|
|
|
|
DefaultStorageQuotaBytes: maxInt64(profile.DefaultStorageQuotaBytes, 0),
|
|
|
|
|
|
UpdatedAt: now,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if setActive || store.ActiveProfileID == "" {
|
|
|
|
|
|
store.ActiveProfileID = profileID
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := s.persistSoraS3ProfilesStore(ctx, store); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
profiles := convertSoraS3ProfilesStore(store)
|
|
|
|
|
|
created := findSoraS3ProfileByID(profiles.Items, profileID)
|
|
|
|
|
|
if created == nil {
|
|
|
|
|
|
return nil, ErrSoraS3ProfileNotFound
|
|
|
|
|
|
}
|
|
|
|
|
|
return created, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UpdateSoraS3Profile 更新 Sora S3 配置
|
|
|
|
|
|
func (s *SettingService) UpdateSoraS3Profile(ctx context.Context, profileID string, profile *SoraS3Profile) (*SoraS3Profile, error) {
|
|
|
|
|
|
if profile == nil {
|
|
|
|
|
|
return nil, fmt.Errorf("profile cannot be nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
targetID := strings.TrimSpace(profileID)
|
|
|
|
|
|
if targetID == "" {
|
|
|
|
|
|
return nil, infraerrors.BadRequest("SORA_S3_PROFILE_ID_REQUIRED", "profile_id is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
store, err := s.loadSoraS3ProfilesStore(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
targetIndex := findSoraS3ProfileIndex(store.Items, targetID)
|
|
|
|
|
|
if targetIndex < 0 {
|
|
|
|
|
|
return nil, ErrSoraS3ProfileNotFound
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
target := store.Items[targetIndex]
|
|
|
|
|
|
name := strings.TrimSpace(profile.Name)
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
return nil, infraerrors.BadRequest("SORA_S3_PROFILE_NAME_REQUIRED", "name is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
target.Name = name
|
|
|
|
|
|
target.Enabled = profile.Enabled
|
|
|
|
|
|
target.Endpoint = strings.TrimSpace(profile.Endpoint)
|
|
|
|
|
|
target.Region = strings.TrimSpace(profile.Region)
|
|
|
|
|
|
target.Bucket = strings.TrimSpace(profile.Bucket)
|
|
|
|
|
|
target.AccessKeyID = strings.TrimSpace(profile.AccessKeyID)
|
|
|
|
|
|
target.Prefix = strings.TrimSpace(profile.Prefix)
|
|
|
|
|
|
target.ForcePathStyle = profile.ForcePathStyle
|
|
|
|
|
|
target.CDNURL = strings.TrimSpace(profile.CDNURL)
|
|
|
|
|
|
target.DefaultStorageQuotaBytes = maxInt64(profile.DefaultStorageQuotaBytes, 0)
|
|
|
|
|
|
if profile.SecretAccessKey != "" {
|
|
|
|
|
|
target.SecretAccessKey = profile.SecretAccessKey
|
|
|
|
|
|
}
|
|
|
|
|
|
target.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
|
|
|
|
|
store.Items[targetIndex] = target
|
|
|
|
|
|
|
|
|
|
|
|
if err := s.persistSoraS3ProfilesStore(ctx, store); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
profiles := convertSoraS3ProfilesStore(store)
|
|
|
|
|
|
updated := findSoraS3ProfileByID(profiles.Items, targetID)
|
|
|
|
|
|
if updated == nil {
|
|
|
|
|
|
return nil, ErrSoraS3ProfileNotFound
|
|
|
|
|
|
}
|
|
|
|
|
|
return updated, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// DeleteSoraS3Profile 删除 Sora S3 配置
|
|
|
|
|
|
func (s *SettingService) DeleteSoraS3Profile(ctx context.Context, profileID string) error {
|
|
|
|
|
|
targetID := strings.TrimSpace(profileID)
|
|
|
|
|
|
if targetID == "" {
|
|
|
|
|
|
return infraerrors.BadRequest("SORA_S3_PROFILE_ID_REQUIRED", "profile_id is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
store, err := s.loadSoraS3ProfilesStore(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
targetIndex := findSoraS3ProfileIndex(store.Items, targetID)
|
|
|
|
|
|
if targetIndex < 0 {
|
|
|
|
|
|
return ErrSoraS3ProfileNotFound
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
store.Items = append(store.Items[:targetIndex], store.Items[targetIndex+1:]...)
|
|
|
|
|
|
if store.ActiveProfileID == targetID {
|
|
|
|
|
|
store.ActiveProfileID = ""
|
|
|
|
|
|
if len(store.Items) > 0 {
|
|
|
|
|
|
store.ActiveProfileID = store.Items[0].ProfileID
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.persistSoraS3ProfilesStore(ctx, store)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SetActiveSoraS3Profile 设置激活的 Sora S3 配置
|
|
|
|
|
|
func (s *SettingService) SetActiveSoraS3Profile(ctx context.Context, profileID string) (*SoraS3Profile, error) {
|
|
|
|
|
|
targetID := strings.TrimSpace(profileID)
|
|
|
|
|
|
if targetID == "" {
|
|
|
|
|
|
return nil, infraerrors.BadRequest("SORA_S3_PROFILE_ID_REQUIRED", "profile_id is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
store, err := s.loadSoraS3ProfilesStore(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
targetIndex := findSoraS3ProfileIndex(store.Items, targetID)
|
|
|
|
|
|
if targetIndex < 0 {
|
|
|
|
|
|
return nil, ErrSoraS3ProfileNotFound
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
store.ActiveProfileID = targetID
|
|
|
|
|
|
store.Items[targetIndex].UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
|
|
|
|
|
if err := s.persistSoraS3ProfilesStore(ctx, store); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
profiles := convertSoraS3ProfilesStore(store)
|
|
|
|
|
|
active := pickActiveSoraS3Profile(profiles.Items, profiles.ActiveProfileID)
|
|
|
|
|
|
if active == nil {
|
|
|
|
|
|
return nil, ErrSoraS3ProfileNotFound
|
|
|
|
|
|
}
|
|
|
|
|
|
return active, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *SettingService) loadSoraS3ProfilesStore(ctx context.Context) (*soraS3ProfilesStore, error) {
|
|
|
|
|
|
raw, err := s.settingRepo.GetValue(ctx, SettingKeySoraS3Profiles)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
trimmed := strings.TrimSpace(raw)
|
|
|
|
|
|
if trimmed == "" {
|
|
|
|
|
|
return &soraS3ProfilesStore{}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
var store soraS3ProfilesStore
|
|
|
|
|
|
if unmarshalErr := json.Unmarshal([]byte(trimmed), &store); unmarshalErr != nil {
|
|
|
|
|
|
legacy, legacyErr := s.getLegacySoraS3Settings(ctx)
|
|
|
|
|
|
if legacyErr != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("unmarshal sora s3 profiles: %w", unmarshalErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
if isEmptyLegacySoraS3Settings(legacy) {
|
|
|
|
|
|
return &soraS3ProfilesStore{}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
|
|
return &soraS3ProfilesStore{
|
|
|
|
|
|
ActiveProfileID: "default",
|
|
|
|
|
|
Items: []soraS3ProfileStoreItem{
|
|
|
|
|
|
{
|
|
|
|
|
|
ProfileID: "default",
|
|
|
|
|
|
Name: "Default",
|
|
|
|
|
|
Enabled: legacy.Enabled,
|
|
|
|
|
|
Endpoint: strings.TrimSpace(legacy.Endpoint),
|
|
|
|
|
|
Region: strings.TrimSpace(legacy.Region),
|
|
|
|
|
|
Bucket: strings.TrimSpace(legacy.Bucket),
|
|
|
|
|
|
AccessKeyID: strings.TrimSpace(legacy.AccessKeyID),
|
|
|
|
|
|
SecretAccessKey: legacy.SecretAccessKey,
|
|
|
|
|
|
Prefix: strings.TrimSpace(legacy.Prefix),
|
|
|
|
|
|
ForcePathStyle: legacy.ForcePathStyle,
|
|
|
|
|
|
CDNURL: strings.TrimSpace(legacy.CDNURL),
|
|
|
|
|
|
DefaultStorageQuotaBytes: maxInt64(legacy.DefaultStorageQuotaBytes, 0),
|
|
|
|
|
|
UpdatedAt: now,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
normalized := normalizeSoraS3ProfilesStore(store)
|
|
|
|
|
|
return &normalized, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if !errors.Is(err, ErrSettingNotFound) {
|
|
|
|
|
|
return nil, fmt.Errorf("get sora s3 profiles: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
legacy, legacyErr := s.getLegacySoraS3Settings(ctx)
|
|
|
|
|
|
if legacyErr != nil {
|
|
|
|
|
|
return nil, legacyErr
|
|
|
|
|
|
}
|
|
|
|
|
|
if isEmptyLegacySoraS3Settings(legacy) {
|
|
|
|
|
|
return &soraS3ProfilesStore{}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
|
|
return &soraS3ProfilesStore{
|
|
|
|
|
|
ActiveProfileID: "default",
|
|
|
|
|
|
Items: []soraS3ProfileStoreItem{
|
|
|
|
|
|
{
|
|
|
|
|
|
ProfileID: "default",
|
|
|
|
|
|
Name: "Default",
|
|
|
|
|
|
Enabled: legacy.Enabled,
|
|
|
|
|
|
Endpoint: strings.TrimSpace(legacy.Endpoint),
|
|
|
|
|
|
Region: strings.TrimSpace(legacy.Region),
|
|
|
|
|
|
Bucket: strings.TrimSpace(legacy.Bucket),
|
|
|
|
|
|
AccessKeyID: strings.TrimSpace(legacy.AccessKeyID),
|
|
|
|
|
|
SecretAccessKey: legacy.SecretAccessKey,
|
|
|
|
|
|
Prefix: strings.TrimSpace(legacy.Prefix),
|
|
|
|
|
|
ForcePathStyle: legacy.ForcePathStyle,
|
|
|
|
|
|
CDNURL: strings.TrimSpace(legacy.CDNURL),
|
|
|
|
|
|
DefaultStorageQuotaBytes: maxInt64(legacy.DefaultStorageQuotaBytes, 0),
|
|
|
|
|
|
UpdatedAt: now,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *SettingService) persistSoraS3ProfilesStore(ctx context.Context, store *soraS3ProfilesStore) error {
|
|
|
|
|
|
if store == nil {
|
|
|
|
|
|
return fmt.Errorf("sora s3 profiles store cannot be nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
normalized := normalizeSoraS3ProfilesStore(*store)
|
|
|
|
|
|
data, err := json.Marshal(normalized)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("marshal sora s3 profiles: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updates := map[string]string{
|
|
|
|
|
|
SettingKeySoraS3Profiles: string(data),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
active := pickActiveSoraS3ProfileFromStore(normalized.Items, normalized.ActiveProfileID)
|
|
|
|
|
|
if active == nil {
|
|
|
|
|
|
updates[SettingKeySoraS3Enabled] = "false"
|
|
|
|
|
|
updates[SettingKeySoraS3Endpoint] = ""
|
|
|
|
|
|
updates[SettingKeySoraS3Region] = ""
|
|
|
|
|
|
updates[SettingKeySoraS3Bucket] = ""
|
|
|
|
|
|
updates[SettingKeySoraS3AccessKeyID] = ""
|
|
|
|
|
|
updates[SettingKeySoraS3Prefix] = ""
|
|
|
|
|
|
updates[SettingKeySoraS3ForcePathStyle] = "false"
|
|
|
|
|
|
updates[SettingKeySoraS3CDNURL] = ""
|
|
|
|
|
|
updates[SettingKeySoraDefaultStorageQuotaBytes] = "0"
|
|
|
|
|
|
updates[SettingKeySoraS3SecretAccessKey] = ""
|
|
|
|
|
|
} else {
|
|
|
|
|
|
updates[SettingKeySoraS3Enabled] = strconv.FormatBool(active.Enabled)
|
|
|
|
|
|
updates[SettingKeySoraS3Endpoint] = strings.TrimSpace(active.Endpoint)
|
|
|
|
|
|
updates[SettingKeySoraS3Region] = strings.TrimSpace(active.Region)
|
|
|
|
|
|
updates[SettingKeySoraS3Bucket] = strings.TrimSpace(active.Bucket)
|
|
|
|
|
|
updates[SettingKeySoraS3AccessKeyID] = strings.TrimSpace(active.AccessKeyID)
|
|
|
|
|
|
updates[SettingKeySoraS3Prefix] = strings.TrimSpace(active.Prefix)
|
|
|
|
|
|
updates[SettingKeySoraS3ForcePathStyle] = strconv.FormatBool(active.ForcePathStyle)
|
|
|
|
|
|
updates[SettingKeySoraS3CDNURL] = strings.TrimSpace(active.CDNURL)
|
|
|
|
|
|
updates[SettingKeySoraDefaultStorageQuotaBytes] = strconv.FormatInt(maxInt64(active.DefaultStorageQuotaBytes, 0), 10)
|
|
|
|
|
|
updates[SettingKeySoraS3SecretAccessKey] = active.SecretAccessKey
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := s.settingRepo.SetMultiple(ctx, updates); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if s.onUpdate != nil {
|
|
|
|
|
|
s.onUpdate()
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.onS3Update != nil {
|
|
|
|
|
|
s.onS3Update()
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *SettingService) getLegacySoraS3Settings(ctx context.Context) (*SoraS3Settings, error) {
|
|
|
|
|
|
keys := []string{
|
|
|
|
|
|
SettingKeySoraS3Enabled,
|
|
|
|
|
|
SettingKeySoraS3Endpoint,
|
|
|
|
|
|
SettingKeySoraS3Region,
|
|
|
|
|
|
SettingKeySoraS3Bucket,
|
|
|
|
|
|
SettingKeySoraS3AccessKeyID,
|
|
|
|
|
|
SettingKeySoraS3SecretAccessKey,
|
|
|
|
|
|
SettingKeySoraS3Prefix,
|
|
|
|
|
|
SettingKeySoraS3ForcePathStyle,
|
|
|
|
|
|
SettingKeySoraS3CDNURL,
|
|
|
|
|
|
SettingKeySoraDefaultStorageQuotaBytes,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
values, err := s.settingRepo.GetMultiple(ctx, keys)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get legacy sora s3 settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result := &SoraS3Settings{
|
|
|
|
|
|
Enabled: values[SettingKeySoraS3Enabled] == "true",
|
|
|
|
|
|
Endpoint: values[SettingKeySoraS3Endpoint],
|
|
|
|
|
|
Region: values[SettingKeySoraS3Region],
|
|
|
|
|
|
Bucket: values[SettingKeySoraS3Bucket],
|
|
|
|
|
|
AccessKeyID: values[SettingKeySoraS3AccessKeyID],
|
|
|
|
|
|
SecretAccessKey: values[SettingKeySoraS3SecretAccessKey],
|
|
|
|
|
|
SecretAccessKeyConfigured: values[SettingKeySoraS3SecretAccessKey] != "",
|
|
|
|
|
|
Prefix: values[SettingKeySoraS3Prefix],
|
|
|
|
|
|
ForcePathStyle: values[SettingKeySoraS3ForcePathStyle] == "true",
|
|
|
|
|
|
CDNURL: values[SettingKeySoraS3CDNURL],
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, parseErr := strconv.ParseInt(values[SettingKeySoraDefaultStorageQuotaBytes], 10, 64); parseErr == nil {
|
|
|
|
|
|
result.DefaultStorageQuotaBytes = v
|
|
|
|
|
|
}
|
|
|
|
|
|
return result, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeSoraS3ProfilesStore(store soraS3ProfilesStore) soraS3ProfilesStore {
|
|
|
|
|
|
seen := make(map[string]struct{}, len(store.Items))
|
|
|
|
|
|
normalized := soraS3ProfilesStore{
|
|
|
|
|
|
ActiveProfileID: strings.TrimSpace(store.ActiveProfileID),
|
|
|
|
|
|
Items: make([]soraS3ProfileStoreItem, 0, len(store.Items)),
|
|
|
|
|
|
}
|
|
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
|
|
|
|
|
|
|
|
for idx := range store.Items {
|
|
|
|
|
|
item := store.Items[idx]
|
|
|
|
|
|
item.ProfileID = strings.TrimSpace(item.ProfileID)
|
|
|
|
|
|
if item.ProfileID == "" {
|
|
|
|
|
|
item.ProfileID = fmt.Sprintf("profile-%d", idx+1)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, exists := seen[item.ProfileID]; exists {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[item.ProfileID] = struct{}{}
|
|
|
|
|
|
|
|
|
|
|
|
item.Name = strings.TrimSpace(item.Name)
|
|
|
|
|
|
if item.Name == "" {
|
|
|
|
|
|
item.Name = item.ProfileID
|
|
|
|
|
|
}
|
|
|
|
|
|
item.Endpoint = strings.TrimSpace(item.Endpoint)
|
|
|
|
|
|
item.Region = strings.TrimSpace(item.Region)
|
|
|
|
|
|
item.Bucket = strings.TrimSpace(item.Bucket)
|
|
|
|
|
|
item.AccessKeyID = strings.TrimSpace(item.AccessKeyID)
|
|
|
|
|
|
item.Prefix = strings.TrimSpace(item.Prefix)
|
|
|
|
|
|
item.CDNURL = strings.TrimSpace(item.CDNURL)
|
|
|
|
|
|
item.DefaultStorageQuotaBytes = maxInt64(item.DefaultStorageQuotaBytes, 0)
|
|
|
|
|
|
item.UpdatedAt = strings.TrimSpace(item.UpdatedAt)
|
|
|
|
|
|
if item.UpdatedAt == "" {
|
|
|
|
|
|
item.UpdatedAt = now
|
|
|
|
|
|
}
|
|
|
|
|
|
normalized.Items = append(normalized.Items, item)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(normalized.Items) == 0 {
|
|
|
|
|
|
normalized.ActiveProfileID = ""
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if findSoraS3ProfileIndex(normalized.Items, normalized.ActiveProfileID) >= 0 {
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
normalized.ActiveProfileID = normalized.Items[0].ProfileID
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func convertSoraS3ProfilesStore(store *soraS3ProfilesStore) *SoraS3ProfileList {
|
|
|
|
|
|
if store == nil {
|
|
|
|
|
|
return &SoraS3ProfileList{}
|
|
|
|
|
|
}
|
|
|
|
|
|
items := make([]SoraS3Profile, 0, len(store.Items))
|
|
|
|
|
|
for idx := range store.Items {
|
|
|
|
|
|
item := store.Items[idx]
|
|
|
|
|
|
items = append(items, SoraS3Profile{
|
|
|
|
|
|
ProfileID: item.ProfileID,
|
|
|
|
|
|
Name: item.Name,
|
|
|
|
|
|
IsActive: item.ProfileID == store.ActiveProfileID,
|
|
|
|
|
|
Enabled: item.Enabled,
|
|
|
|
|
|
Endpoint: item.Endpoint,
|
|
|
|
|
|
Region: item.Region,
|
|
|
|
|
|
Bucket: item.Bucket,
|
|
|
|
|
|
AccessKeyID: item.AccessKeyID,
|
|
|
|
|
|
SecretAccessKey: item.SecretAccessKey,
|
|
|
|
|
|
SecretAccessKeyConfigured: item.SecretAccessKey != "",
|
|
|
|
|
|
Prefix: item.Prefix,
|
|
|
|
|
|
ForcePathStyle: item.ForcePathStyle,
|
|
|
|
|
|
CDNURL: item.CDNURL,
|
|
|
|
|
|
DefaultStorageQuotaBytes: item.DefaultStorageQuotaBytes,
|
|
|
|
|
|
UpdatedAt: item.UpdatedAt,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return &SoraS3ProfileList{
|
|
|
|
|
|
ActiveProfileID: store.ActiveProfileID,
|
|
|
|
|
|
Items: items,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func pickActiveSoraS3Profile(items []SoraS3Profile, activeProfileID string) *SoraS3Profile {
|
|
|
|
|
|
for idx := range items {
|
|
|
|
|
|
if items[idx].ProfileID == activeProfileID {
|
|
|
|
|
|
return &items[idx]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(items) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return &items[0]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func findSoraS3ProfileByID(items []SoraS3Profile, profileID string) *SoraS3Profile {
|
|
|
|
|
|
for idx := range items {
|
|
|
|
|
|
if items[idx].ProfileID == profileID {
|
|
|
|
|
|
return &items[idx]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func pickActiveSoraS3ProfileFromStore(items []soraS3ProfileStoreItem, activeProfileID string) *soraS3ProfileStoreItem {
|
|
|
|
|
|
for idx := range items {
|
|
|
|
|
|
if items[idx].ProfileID == activeProfileID {
|
|
|
|
|
|
return &items[idx]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(items) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return &items[0]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func findSoraS3ProfileIndex(items []soraS3ProfileStoreItem, profileID string) int {
|
|
|
|
|
|
for idx := range items {
|
|
|
|
|
|
if items[idx].ProfileID == profileID {
|
|
|
|
|
|
return idx
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return -1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func hasSoraS3ProfileID(items []soraS3ProfileStoreItem, profileID string) bool {
|
|
|
|
|
|
return findSoraS3ProfileIndex(items, profileID) >= 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func isEmptyLegacySoraS3Settings(settings *SoraS3Settings) bool {
|
|
|
|
|
|
if settings == nil {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.Enabled {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(settings.Endpoint) != "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(settings.Region) != "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(settings.Bucket) != "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(settings.AccessKeyID) != "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.SecretAccessKey != "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(settings.Prefix) != "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(settings.CDNURL) != "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return settings.DefaultStorageQuotaBytes == 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func maxInt64(value int64, min int64) int64 {
|
|
|
|
|
|
if value < min {
|
|
|
|
|
|
return min
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|