2025-12-18 13:50:39 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
2025-12-20 15:11:43 +08:00
|
|
|
|
"crypto/rand"
|
|
|
|
|
|
"encoding/hex"
|
2026-01-11 21:54:52 -08:00
|
|
|
|
"encoding/json"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
2026-03-01 15:35:46 +08:00
|
|
|
|
"log/slog"
|
2026-03-03 07:05:01 +08:00
|
|
|
|
"net/url"
|
2025-12-25 17:15:01 +08:00
|
|
|
|
"strconv"
|
2026-01-09 20:57:32 +08:00
|
|
|
|
"strings"
|
2026-03-01 15:35:46 +08:00
|
|
|
|
"sync/atomic"
|
2026-02-28 15:01:20 +08:00
|
|
|
|
"time"
|
2025-12-25 17:15:01 +08:00
|
|
|
|
|
2025-12-24 21:07:21 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
2025-12-31 23:42:01 +08:00
|
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
2026-03-01 15:35:46 +08:00
|
|
|
|
"golang.org/x/sync/singleflight"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
2026-03-02 03:41:50 +08:00
|
|
|
|
ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
|
|
|
|
|
|
ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found")
|
|
|
|
|
|
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")
|
|
|
|
|
|
ErrDefaultSubGroupInvalid = infraerrors.BadRequest(
|
|
|
|
|
|
"DEFAULT_SUBSCRIPTION_GROUP_INVALID",
|
|
|
|
|
|
"default subscription group must exist and be subscription type",
|
|
|
|
|
|
)
|
|
|
|
|
|
ErrDefaultSubGroupDuplicate = infraerrors.BadRequest(
|
|
|
|
|
|
"DEFAULT_SUBSCRIPTION_GROUP_DUPLICATE",
|
|
|
|
|
|
"default subscription group cannot be duplicated",
|
|
|
|
|
|
)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-25 17:15:01 +08:00
|
|
|
|
type SettingRepository interface {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
Get(ctx context.Context, key string) (*Setting, error)
|
2025-12-25 17:15:01 +08:00
|
|
|
|
GetValue(ctx context.Context, key string) (string, error)
|
|
|
|
|
|
Set(ctx context.Context, key, value string) error
|
|
|
|
|
|
GetMultiple(ctx context.Context, keys []string) (map[string]string, error)
|
|
|
|
|
|
SetMultiple(ctx context.Context, settings map[string]string) error
|
|
|
|
|
|
GetAll(ctx context.Context) (map[string]string, error)
|
|
|
|
|
|
Delete(ctx context.Context, key string) error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 15:35:46 +08:00
|
|
|
|
// cachedMinVersion 缓存最低 Claude Code 版本号(进程内缓存,60s TTL)
|
|
|
|
|
|
type cachedMinVersion struct {
|
|
|
|
|
|
value string // 空字符串 = 不检查
|
|
|
|
|
|
expiresAt int64 // unix nano
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// minVersionCache 最低版本号进程内缓存
|
|
|
|
|
|
var minVersionCache atomic.Value // *cachedMinVersion
|
|
|
|
|
|
|
|
|
|
|
|
// minVersionSF 防止缓存过期时 thundering herd
|
|
|
|
|
|
var minVersionSF singleflight.Group
|
|
|
|
|
|
|
|
|
|
|
|
// minVersionCacheTTL 缓存有效期
|
|
|
|
|
|
const minVersionCacheTTL = 60 * time.Second
|
|
|
|
|
|
|
|
|
|
|
|
// minVersionErrorTTL DB 错误时的短缓存,快速重试
|
|
|
|
|
|
const minVersionErrorTTL = 5 * time.Second
|
|
|
|
|
|
|
|
|
|
|
|
// minVersionDBTimeout singleflight 内 DB 查询超时,独立于请求 context
|
|
|
|
|
|
const minVersionDBTimeout = 5 * time.Second
|
|
|
|
|
|
|
2026-03-02 03:41:50 +08:00
|
|
|
|
// DefaultSubscriptionGroupReader validates group references used by default subscriptions.
|
|
|
|
|
|
type DefaultSubscriptionGroupReader interface {
|
|
|
|
|
|
GetByID(ctx context.Context, id int64) (*Group, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// SettingService 系统设置服务
|
|
|
|
|
|
type SettingService struct {
|
2026-03-02 03:41:50 +08:00
|
|
|
|
settingRepo SettingRepository
|
|
|
|
|
|
defaultSubGroupReader DefaultSubscriptionGroupReader
|
|
|
|
|
|
cfg *config.Config
|
|
|
|
|
|
onUpdate func() // Callback when settings are updated (for cache invalidation)
|
|
|
|
|
|
onS3Update func() // Callback when Sora S3 settings are updated
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 03:41:50 +08:00
|
|
|
|
// SetDefaultSubscriptionGroupReader injects an optional group reader for default subscription validation.
|
|
|
|
|
|
func (s *SettingService) SetDefaultSubscriptionGroupReader(reader DefaultSubscriptionGroupReader) {
|
|
|
|
|
|
s.defaultSubGroupReader = reader
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// GetAllSettings 获取所有系统设置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *SettingService) GetAllSettings(ctx context.Context) (*SystemSettings, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
settings, err := s.settingRepo.GetAll(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get all settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.parseSettings(settings), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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-03-02 23:13:39 +08:00
|
|
|
|
SettingKeyRegistrationEmailSuffixWhitelist,
|
2026-01-20 15:56:26 +08:00
|
|
|
|
SettingKeyPromoCodeEnabled,
|
2026-01-24 22:33:45 +08:00
|
|
|
|
SettingKeyPasswordResetEnabled,
|
2026-01-29 16:29:59 +08:00
|
|
|
|
SettingKeyInvitationCodeEnabled,
|
2026-01-26 08:45:43 +08:00
|
|
|
|
SettingKeyTotpEnabled,
|
2025-12-26 15:40:24 +08:00
|
|
|
|
SettingKeyTurnstileEnabled,
|
|
|
|
|
|
SettingKeyTurnstileSiteKey,
|
|
|
|
|
|
SettingKeySiteName,
|
|
|
|
|
|
SettingKeySiteLogo,
|
|
|
|
|
|
SettingKeySiteSubtitle,
|
2026-01-04 19:27:53 +08:00
|
|
|
|
SettingKeyAPIBaseURL,
|
2025-12-26 15:40:24 +08:00
|
|
|
|
SettingKeyContactInfo,
|
2026-01-04 19:27:53 +08:00
|
|
|
|
SettingKeyDocURL,
|
2026-01-10 18:37:44 +08:00
|
|
|
|
SettingKeyHomeContent,
|
2026-01-19 19:25:16 +08:00
|
|
|
|
SettingKeyHideCcsImportButton,
|
2026-01-28 13:54:32 +08:00
|
|
|
|
SettingKeyPurchaseSubscriptionEnabled,
|
|
|
|
|
|
SettingKeyPurchaseSubscriptionURL,
|
2026-02-28 15:01:20 +08:00
|
|
|
|
SettingKeySoraClientEnabled,
|
2026-03-02 19:37:40 +08:00
|
|
|
|
SettingKeyCustomMenuItems,
|
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"
|
2026-03-02 23:13:39 +08:00
|
|
|
|
registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist(
|
|
|
|
|
|
settings[SettingKeyRegistrationEmailSuffixWhitelist],
|
|
|
|
|
|
)
|
2026-01-24 22:33:45 +08:00
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
return &PublicSettings{
|
2026-03-02 23:13:39 +08:00
|
|
|
|
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
|
|
|
|
|
EmailVerifyEnabled: emailVerifyEnabled,
|
|
|
|
|
|
RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
|
|
|
|
|
|
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
|
|
|
|
|
PasswordResetEnabled: passwordResetEnabled,
|
|
|
|
|
|
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
|
|
|
|
|
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
|
|
|
|
|
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
|
|
|
|
|
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
|
|
|
|
|
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
|
|
|
|
|
|
SiteLogo: settings[SettingKeySiteLogo],
|
|
|
|
|
|
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
|
|
|
|
|
|
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
|
|
|
|
|
ContactInfo: settings[SettingKeyContactInfo],
|
|
|
|
|
|
DocURL: settings[SettingKeyDocURL],
|
|
|
|
|
|
HomeContent: settings[SettingKeyHomeContent],
|
|
|
|
|
|
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
|
|
|
|
|
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
|
|
|
|
|
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
|
|
|
|
|
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
|
|
|
|
|
|
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
|
|
|
|
|
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-03-02 23:13:39 +08:00
|
|
|
|
RegistrationEnabled bool `json:"registration_enabled"`
|
|
|
|
|
|
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
|
|
|
|
|
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
|
|
|
|
|
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
|
|
|
|
|
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
|
|
|
|
|
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
|
|
|
|
|
TotpEnabled bool `json:"totp_enabled"`
|
|
|
|
|
|
TurnstileEnabled bool `json:"turnstile_enabled"`
|
|
|
|
|
|
TurnstileSiteKey string `json:"turnstile_site_key,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"`
|
|
|
|
|
|
SoraClientEnabled bool `json:"sora_client_enabled"`
|
|
|
|
|
|
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
|
|
|
|
|
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
|
|
|
|
|
Version string `json:"version,omitempty"`
|
2026-01-10 18:37:44 +08:00
|
|
|
|
}{
|
2026-03-02 23:13:39 +08:00
|
|
|
|
RegistrationEnabled: settings.RegistrationEnabled,
|
|
|
|
|
|
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
|
|
|
|
|
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
|
|
|
|
|
|
PromoCodeEnabled: settings.PromoCodeEnabled,
|
|
|
|
|
|
PasswordResetEnabled: settings.PasswordResetEnabled,
|
|
|
|
|
|
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
|
|
|
|
|
TotpEnabled: settings.TotpEnabled,
|
|
|
|
|
|
TurnstileEnabled: settings.TurnstileEnabled,
|
|
|
|
|
|
TurnstileSiteKey: settings.TurnstileSiteKey,
|
|
|
|
|
|
SiteName: settings.SiteName,
|
|
|
|
|
|
SiteLogo: settings.SiteLogo,
|
|
|
|
|
|
SiteSubtitle: settings.SiteSubtitle,
|
|
|
|
|
|
APIBaseURL: settings.APIBaseURL,
|
|
|
|
|
|
ContactInfo: settings.ContactInfo,
|
|
|
|
|
|
DocURL: settings.DocURL,
|
|
|
|
|
|
HomeContent: settings.HomeContent,
|
|
|
|
|
|
HideCcsImportButton: settings.HideCcsImportButton,
|
|
|
|
|
|
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
|
|
|
|
|
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
|
|
|
|
|
SoraClientEnabled: settings.SoraClientEnabled,
|
|
|
|
|
|
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
|
|
|
|
|
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
|
|
|
|
|
Version: s.version,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 07:05:01 +08:00
|
|
|
|
// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
|
|
|
|
|
|
// array string, returning only items with visibility != "admin".
|
|
|
|
|
|
func filterUserVisibleMenuItems(raw string) json.RawMessage {
|
|
|
|
|
|
raw = strings.TrimSpace(raw)
|
|
|
|
|
|
if raw == "" || raw == "[]" {
|
|
|
|
|
|
return json.RawMessage("[]")
|
|
|
|
|
|
}
|
|
|
|
|
|
var items []struct {
|
|
|
|
|
|
Visibility string `json:"visibility"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
|
|
|
|
|
return json.RawMessage("[]")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Parse full items to preserve all fields
|
|
|
|
|
|
var fullItems []json.RawMessage
|
|
|
|
|
|
if err := json.Unmarshal([]byte(raw), &fullItems); err != nil {
|
|
|
|
|
|
return json.RawMessage("[]")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var filtered []json.RawMessage
|
|
|
|
|
|
for i, item := range items {
|
|
|
|
|
|
if item.Visibility != "admin" {
|
|
|
|
|
|
filtered = append(filtered, fullItems[i])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(filtered) == 0 {
|
|
|
|
|
|
return json.RawMessage("[]")
|
|
|
|
|
|
}
|
|
|
|
|
|
result, err := json.Marshal(filtered)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return json.RawMessage("[]")
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetFrameSrcOrigins returns deduplicated http(s) origins from purchase_subscription_url
|
|
|
|
|
|
// and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection.
|
|
|
|
|
|
func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) {
|
|
|
|
|
|
settings, err := s.GetPublicSettings(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
seen := make(map[string]struct{})
|
|
|
|
|
|
var origins []string
|
|
|
|
|
|
|
|
|
|
|
|
addOrigin := func(rawURL string) {
|
|
|
|
|
|
if origin := extractOriginFromURL(rawURL); origin != "" {
|
|
|
|
|
|
if _, ok := seen[origin]; !ok {
|
|
|
|
|
|
seen[origin] = struct{}{}
|
|
|
|
|
|
origins = append(origins, origin)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// purchase subscription URL
|
|
|
|
|
|
if settings.PurchaseSubscriptionEnabled {
|
|
|
|
|
|
addOrigin(settings.PurchaseSubscriptionURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// all custom menu items (including admin-only, since CSP must allow all iframes)
|
|
|
|
|
|
for _, item := range parseCustomMenuItemURLs(settings.CustomMenuItems) {
|
|
|
|
|
|
addOrigin(item)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return origins, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// extractOriginFromURL returns the scheme+host origin from rawURL.
|
|
|
|
|
|
// Only http and https schemes are accepted.
|
|
|
|
|
|
func extractOriginFromURL(rawURL string) string {
|
|
|
|
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
|
|
|
|
if rawURL == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
u, err := url.Parse(rawURL)
|
|
|
|
|
|
if err != nil || u.Host == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return u.Scheme + "://" + u.Host
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parseCustomMenuItemURLs extracts URLs from a raw JSON array of custom menu items.
|
|
|
|
|
|
func parseCustomMenuItemURLs(raw string) []string {
|
|
|
|
|
|
raw = strings.TrimSpace(raw)
|
|
|
|
|
|
if raw == "" || raw == "[]" {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
var items []struct {
|
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
urls := make([]string, 0, len(items))
|
|
|
|
|
|
for _, item := range items {
|
|
|
|
|
|
if item.URL != "" {
|
|
|
|
|
|
urls = append(urls, item.URL)
|
|
|
|
|
|
}
|
2026-03-03 06:21:23 +08:00
|
|
|
|
}
|
2026-03-03 07:05:01 +08:00
|
|
|
|
return urls
|
2026-03-03 06:21:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// UpdateSettings 更新系统设置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error {
|
2026-03-02 03:41:50 +08:00
|
|
|
|
if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-03-02 23:13:39 +08:00
|
|
|
|
normalizedWhitelist, err := NormalizeRegistrationEmailSuffixWhitelist(settings.RegistrationEmailSuffixWhitelist)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return infraerrors.BadRequest("INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", err.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
if normalizedWhitelist == nil {
|
|
|
|
|
|
normalizedWhitelist = []string{}
|
|
|
|
|
|
}
|
|
|
|
|
|
settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist
|
2026-03-02 03:41:50 +08:00
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
updates := make(map[string]string)
|
|
|
|
|
|
|
|
|
|
|
|
// 注册设置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled)
|
|
|
|
|
|
updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled)
|
2026-03-02 23:13:39 +08:00
|
|
|
|
registrationEmailSuffixWhitelistJSON, err := json.Marshal(settings.RegistrationEmailSuffixWhitelist)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("marshal registration email suffix whitelist: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON)
|
2026-01-20 15:56:26 +08:00
|
|
|
|
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
|
2026-01-24 22:33:45 +08:00
|
|
|
|
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
|
2026-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)
|
2026-03-02 19:37:40 +08:00
|
|
|
|
updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 默认配置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
|
|
|
|
|
|
updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64)
|
2026-03-02 03:41:50 +08:00
|
|
|
|
defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("marshal default subscriptions: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
updates[SettingKeyDefaultSubscriptions] = string(defaultSubsJSON)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-03 06:37:08 -08:00
|
|
|
|
// Model fallback configuration
|
|
|
|
|
|
updates[SettingKeyEnableModelFallback] = strconv.FormatBool(settings.EnableModelFallback)
|
|
|
|
|
|
updates[SettingKeyFallbackModelAnthropic] = settings.FallbackModelAnthropic
|
|
|
|
|
|
updates[SettingKeyFallbackModelOpenAI] = settings.FallbackModelOpenAI
|
|
|
|
|
|
updates[SettingKeyFallbackModelGemini] = settings.FallbackModelGemini
|
|
|
|
|
|
updates[SettingKeyFallbackModelAntigravity] = settings.FallbackModelAntigravity
|
|
|
|
|
|
|
2026-01-04 22:49:40 +08:00
|
|
|
|
// Identity patch configuration (Claude -> Gemini)
|
|
|
|
|
|
updates[SettingKeyEnableIdentityPatch] = strconv.FormatBool(settings.EnableIdentityPatch)
|
|
|
|
|
|
updates[SettingKeyIdentityPatchPrompt] = settings.IdentityPatchPrompt
|
|
|
|
|
|
|
2026-01-09 20:57:32 +08:00
|
|
|
|
// Ops monitoring (vNext)
|
|
|
|
|
|
updates[SettingKeyOpsMonitoringEnabled] = strconv.FormatBool(settings.OpsMonitoringEnabled)
|
|
|
|
|
|
updates[SettingKeyOpsRealtimeMonitoringEnabled] = strconv.FormatBool(settings.OpsRealtimeMonitoringEnabled)
|
|
|
|
|
|
updates[SettingKeyOpsQueryModeDefault] = string(ParseOpsQueryMode(settings.OpsQueryModeDefault))
|
2026-01-10 01:38:47 +08:00
|
|
|
|
if settings.OpsMetricsIntervalSeconds > 0 {
|
|
|
|
|
|
updates[SettingKeyOpsMetricsIntervalSeconds] = strconv.Itoa(settings.OpsMetricsIntervalSeconds)
|
|
|
|
|
|
}
|
2026-01-09 20:57:32 +08:00
|
|
|
|
|
2026-03-01 15:35:46 +08:00
|
|
|
|
// Claude Code version check
|
|
|
|
|
|
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
|
|
|
|
|
|
|
2026-03-03 19:56:27 +08:00
|
|
|
|
// 分组隔离
|
|
|
|
|
|
updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling)
|
|
|
|
|
|
|
2026-03-02 03:41:50 +08:00
|
|
|
|
err = s.settingRepo.SetMultiple(ctx, updates)
|
2026-03-01 15:35:46 +08:00
|
|
|
|
if err == nil {
|
|
|
|
|
|
// 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口
|
|
|
|
|
|
minVersionSF.Forget("min_version")
|
|
|
|
|
|
minVersionCache.Store(&cachedMinVersion{
|
|
|
|
|
|
value: settings.MinClaudeCodeVersion,
|
|
|
|
|
|
expiresAt: time.Now().Add(minVersionCacheTTL).UnixNano(),
|
|
|
|
|
|
})
|
|
|
|
|
|
if s.onUpdate != nil {
|
|
|
|
|
|
s.onUpdate() // Invalidate cache after settings update
|
|
|
|
|
|
}
|
2026-01-10 18:37:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
return err
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 03:41:50 +08:00
|
|
|
|
func (s *SettingService) validateDefaultSubscriptionGroups(ctx context.Context, items []DefaultSubscriptionSetting) error {
|
|
|
|
|
|
if len(items) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
checked := make(map[int64]struct{}, len(items))
|
|
|
|
|
|
for _, item := range items {
|
|
|
|
|
|
if item.GroupID <= 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := checked[item.GroupID]; ok {
|
|
|
|
|
|
return ErrDefaultSubGroupDuplicate.WithMetadata(map[string]string{
|
|
|
|
|
|
"group_id": strconv.FormatInt(item.GroupID, 10),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
checked[item.GroupID] = struct{}{}
|
|
|
|
|
|
if s.defaultSubGroupReader == nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
group, err := s.defaultSubGroupReader.GetByID(ctx, item.GroupID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if errors.Is(err, ErrGroupNotFound) {
|
|
|
|
|
|
return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{
|
|
|
|
|
|
"group_id": strconv.FormatInt(item.GroupID, 10),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Errorf("get default subscription group %d: %w", item.GroupID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !group.IsSubscriptionType() {
|
|
|
|
|
|
return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{
|
|
|
|
|
|
"group_id": strconv.FormatInt(item.GroupID, 10),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// IsRegistrationEnabled 检查是否开放注册
|
|
|
|
|
|
func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
2026-01-12 09:14:32 +08:00
|
|
|
|
// 安全默认:如果设置不存在或查询出错,默认关闭注册
|
|
|
|
|
|
return false
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsEmailVerifyEnabled 检查是否开启邮件验证
|
|
|
|
|
|
func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 23:13:39 +08:00
|
|
|
|
// GetRegistrationEmailSuffixWhitelist returns normalized registration email suffix whitelist.
|
|
|
|
|
|
func (s *SettingService) GetRegistrationEmailSuffixWhitelist(ctx context.Context) []string {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailSuffixWhitelist)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return []string{}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ParseRegistrationEmailSuffixWhitelist(value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-20 15:56:26 +08:00
|
|
|
|
// IsPromoCodeEnabled 检查是否启用优惠码功能
|
|
|
|
|
|
func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return true // 默认启用
|
|
|
|
|
|
}
|
|
|
|
|
|
return value != "false"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 16:29:59 +08:00
|
|
|
|
// IsInvitationCodeEnabled 检查是否启用邀请码注册功能
|
|
|
|
|
|
func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyInvitationCodeEnabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false // 默认关闭
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-24 22:33:45 +08:00
|
|
|
|
// IsPasswordResetEnabled 检查是否启用密码重置功能
|
|
|
|
|
|
// 要求:必须同时开启邮件验证
|
|
|
|
|
|
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
|
|
|
|
|
|
// Password reset requires email verification to be enabled
|
|
|
|
|
|
if !s.IsEmailVerifyEnabled(ctx) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyPasswordResetEnabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false // 默认关闭
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-26 08:45:43 +08:00
|
|
|
|
// IsTotpEnabled 检查是否启用 TOTP 双因素认证功能
|
|
|
|
|
|
func (s *SettingService) IsTotpEnabled(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyTotpEnabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false // 默认关闭
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置
|
|
|
|
|
|
// 只有手动配置了密钥才允许在管理后台启用 TOTP 功能
|
|
|
|
|
|
func (s *SettingService) IsTotpEncryptionKeyConfigured() bool {
|
|
|
|
|
|
return s.cfg.Totp.EncryptionKeyConfigured
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// GetSiteName 获取网站名称
|
|
|
|
|
|
func (s *SettingService) GetSiteName(ctx context.Context) string {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil || value == "" {
|
|
|
|
|
|
return "Sub2API"
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetDefaultConcurrency 获取默认并发量
|
|
|
|
|
|
func (s *SettingService) GetDefaultConcurrency(ctx context.Context) int {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultConcurrency)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.cfg.Default.UserConcurrency
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, err := strconv.Atoi(value); err == nil && v > 0 {
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.cfg.Default.UserConcurrency
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetDefaultBalance 获取默认余额
|
|
|
|
|
|
func (s *SettingService) GetDefaultBalance(ctx context.Context) float64 {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultBalance)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.cfg.Default.UserBalance
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 {
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.cfg.Default.UserBalance
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 03:41:50 +08:00
|
|
|
|
// GetDefaultSubscriptions 获取新用户默认订阅配置列表。
|
|
|
|
|
|
func (s *SettingService) GetDefaultSubscriptions(ctx context.Context) []DefaultSubscriptionSetting {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultSubscriptions)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return parseDefaultSubscriptions(value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// InitializeDefaultSettings 初始化默认设置
|
|
|
|
|
|
func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|
|
|
|
|
// 检查是否已有设置
|
2025-12-26 15:40:24 +08:00
|
|
|
|
_, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err == nil {
|
|
|
|
|
|
// 已有设置,不需要初始化
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2025-12-25 20:52:47 +08:00
|
|
|
|
if !errors.Is(err, ErrSettingNotFound) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return fmt.Errorf("check existing settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化默认设置
|
|
|
|
|
|
defaults := map[string]string{
|
2026-03-02 23:13:39 +08:00
|
|
|
|
SettingKeyRegistrationEnabled: "true",
|
|
|
|
|
|
SettingKeyEmailVerifyEnabled: "false",
|
|
|
|
|
|
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
|
|
|
|
|
|
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
|
|
|
|
|
|
SettingKeySiteName: "Sub2API",
|
|
|
|
|
|
SettingKeySiteLogo: "",
|
|
|
|
|
|
SettingKeyPurchaseSubscriptionEnabled: "false",
|
|
|
|
|
|
SettingKeyPurchaseSubscriptionURL: "",
|
|
|
|
|
|
SettingKeySoraClientEnabled: "false",
|
|
|
|
|
|
SettingKeyCustomMenuItems: "[]",
|
|
|
|
|
|
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
|
|
|
|
|
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
|
|
|
|
|
SettingKeyDefaultSubscriptions: "[]",
|
|
|
|
|
|
SettingKeySMTPPort: "587",
|
|
|
|
|
|
SettingKeySMTPUseTLS: "false",
|
2026-01-03 06:37:08 -08:00
|
|
|
|
// Model fallback defaults
|
|
|
|
|
|
SettingKeyEnableModelFallback: "false",
|
|
|
|
|
|
SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022",
|
|
|
|
|
|
SettingKeyFallbackModelOpenAI: "gpt-4o",
|
|
|
|
|
|
SettingKeyFallbackModelGemini: "gemini-2.5-pro",
|
|
|
|
|
|
SettingKeyFallbackModelAntigravity: "gemini-2.5-pro",
|
2026-01-04 22:49:40 +08:00
|
|
|
|
// Identity patch defaults
|
|
|
|
|
|
SettingKeyEnableIdentityPatch: "true",
|
|
|
|
|
|
SettingKeyIdentityPatchPrompt: "",
|
2026-01-09 20:57:32 +08:00
|
|
|
|
|
|
|
|
|
|
// Ops monitoring defaults (vNext)
|
|
|
|
|
|
SettingKeyOpsMonitoringEnabled: "true",
|
|
|
|
|
|
SettingKeyOpsRealtimeMonitoringEnabled: "true",
|
|
|
|
|
|
SettingKeyOpsQueryModeDefault: "auto",
|
2026-01-10 01:38:47 +08:00
|
|
|
|
SettingKeyOpsMetricsIntervalSeconds: "60",
|
2026-03-01 15:35:46 +08:00
|
|
|
|
|
|
|
|
|
|
// Claude Code version check (default: empty = disabled)
|
|
|
|
|
|
SettingKeyMinClaudeCodeVersion: "",
|
2026-03-03 19:56:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 分组隔离(默认不允许未分组 Key 调度)
|
|
|
|
|
|
SettingKeyAllowUngroupedKeyScheduling: "false",
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.settingRepo.SetMultiple(ctx, defaults)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parseSettings 解析设置到结构体
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings {
|
2026-01-24 22:33:45 +08:00
|
|
|
|
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
2025-12-26 15:40:24 +08:00
|
|
|
|
result := &SystemSettings{
|
2026-03-02 23:13:39 +08:00
|
|
|
|
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
|
|
|
|
|
EmailVerifyEnabled: emailVerifyEnabled,
|
|
|
|
|
|
RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]),
|
|
|
|
|
|
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
|
|
|
|
|
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
|
|
|
|
|
|
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
|
|
|
|
|
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
|
|
|
|
|
SMTPHost: settings[SettingKeySMTPHost],
|
|
|
|
|
|
SMTPUsername: settings[SettingKeySMTPUsername],
|
|
|
|
|
|
SMTPFrom: settings[SettingKeySMTPFrom],
|
|
|
|
|
|
SMTPFromName: settings[SettingKeySMTPFromName],
|
|
|
|
|
|
SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true",
|
|
|
|
|
|
SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "",
|
|
|
|
|
|
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
|
|
|
|
|
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
|
|
|
|
|
TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "",
|
|
|
|
|
|
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
|
|
|
|
|
|
SiteLogo: settings[SettingKeySiteLogo],
|
|
|
|
|
|
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
|
|
|
|
|
|
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
|
|
|
|
|
ContactInfo: settings[SettingKeyContactInfo],
|
|
|
|
|
|
DocURL: settings[SettingKeyDocURL],
|
|
|
|
|
|
HomeContent: settings[SettingKeyHomeContent],
|
|
|
|
|
|
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
|
|
|
|
|
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
|
|
|
|
|
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
|
|
|
|
|
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
|
|
|
|
|
|
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析整数类型
|
2026-01-04 19:27:53 +08:00
|
|
|
|
if port, err := strconv.Atoi(settings[SettingKeySMTPPort]); err == nil {
|
2026-01-04 17:02:38 +08:00
|
|
|
|
result.SMTPPort = port
|
2025-12-18 13:50:39 +08:00
|
|
|
|
} else {
|
2026-01-04 17:02:38 +08:00
|
|
|
|
result.SMTPPort = 587
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
if concurrency, err := strconv.Atoi(settings[SettingKeyDefaultConcurrency]); err == nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
result.DefaultConcurrency = concurrency
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.DefaultConcurrency = s.cfg.Default.UserConcurrency
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析浮点数类型
|
2025-12-26 15:40:24 +08:00
|
|
|
|
if balance, err := strconv.ParseFloat(settings[SettingKeyDefaultBalance], 64); err == nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
result.DefaultBalance = balance
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.DefaultBalance = s.cfg.Default.UserBalance
|
|
|
|
|
|
}
|
2026-03-02 03:41:50 +08:00
|
|
|
|
result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions])
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-03 06:37:08 -08:00
|
|
|
|
// 敏感信息直接返回,方便测试连接时使用
|
2026-01-04 19:27:53 +08:00
|
|
|
|
result.SMTPPassword = settings[SettingKeySMTPPassword]
|
2025-12-26 15:40:24 +08:00
|
|
|
|
result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey]
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-09 19:32:06 +08:00
|
|
|
|
// LinuxDo Connect 设置:
|
|
|
|
|
|
// - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭)
|
|
|
|
|
|
// - 支持在后台“系统设置”中覆盖并持久化(存储于 DB)
|
2026-01-09 13:52:27 +08:00
|
|
|
|
linuxDoBase := config.LinuxDoConnectConfig{}
|
|
|
|
|
|
if s.cfg != nil {
|
|
|
|
|
|
linuxDoBase = s.cfg.LinuxDo
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
|
|
|
|
|
|
result.LinuxDoConnectEnabled = raw == "true"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.LinuxDoConnectEnabled = linuxDoBase.Enabled
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.LinuxDoConnectClientID = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.LinuxDoConnectClientID = linuxDoBase.ClientID
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
|
|
|
|
|
result.LinuxDoConnectRedirectURL = strings.TrimSpace(v)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.LinuxDoConnectRedirectURL = linuxDoBase.RedirectURL
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result.LinuxDoConnectClientSecret = strings.TrimSpace(settings[SettingKeyLinuxDoConnectClientSecret])
|
|
|
|
|
|
if result.LinuxDoConnectClientSecret == "" {
|
|
|
|
|
|
result.LinuxDoConnectClientSecret = strings.TrimSpace(linuxDoBase.ClientSecret)
|
|
|
|
|
|
}
|
|
|
|
|
|
result.LinuxDoConnectClientSecretConfigured = result.LinuxDoConnectClientSecret != ""
|
|
|
|
|
|
|
2026-01-03 06:37:08 -08:00
|
|
|
|
// Model fallback settings
|
|
|
|
|
|
result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true"
|
|
|
|
|
|
result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022")
|
|
|
|
|
|
result.FallbackModelOpenAI = s.getStringOrDefault(settings, SettingKeyFallbackModelOpenAI, "gpt-4o")
|
|
|
|
|
|
result.FallbackModelGemini = s.getStringOrDefault(settings, SettingKeyFallbackModelGemini, "gemini-2.5-pro")
|
|
|
|
|
|
result.FallbackModelAntigravity = s.getStringOrDefault(settings, SettingKeyFallbackModelAntigravity, "gemini-2.5-pro")
|
|
|
|
|
|
|
2026-01-04 22:49:40 +08:00
|
|
|
|
// Identity patch settings (default: enabled, to preserve existing behavior)
|
|
|
|
|
|
if v, ok := settings[SettingKeyEnableIdentityPatch]; ok && v != "" {
|
|
|
|
|
|
result.EnableIdentityPatch = v == "true"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.EnableIdentityPatch = true
|
|
|
|
|
|
}
|
|
|
|
|
|
result.IdentityPatchPrompt = settings[SettingKeyIdentityPatchPrompt]
|
|
|
|
|
|
|
2026-01-09 20:57:32 +08:00
|
|
|
|
// Ops monitoring settings (default: enabled, fail-open)
|
|
|
|
|
|
result.OpsMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsMonitoringEnabled])
|
|
|
|
|
|
result.OpsRealtimeMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsRealtimeMonitoringEnabled])
|
|
|
|
|
|
result.OpsQueryModeDefault = string(ParseOpsQueryMode(settings[SettingKeyOpsQueryModeDefault]))
|
2026-01-10 01:38:47 +08:00
|
|
|
|
result.OpsMetricsIntervalSeconds = 60
|
|
|
|
|
|
if raw := strings.TrimSpace(settings[SettingKeyOpsMetricsIntervalSeconds]); raw != "" {
|
|
|
|
|
|
if v, err := strconv.Atoi(raw); err == nil {
|
|
|
|
|
|
if v < 60 {
|
|
|
|
|
|
v = 60
|
|
|
|
|
|
}
|
|
|
|
|
|
if v > 3600 {
|
|
|
|
|
|
v = 3600
|
|
|
|
|
|
}
|
|
|
|
|
|
result.OpsMetricsIntervalSeconds = v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-09 20:57:32 +08:00
|
|
|
|
|
2026-03-01 15:35:46 +08:00
|
|
|
|
// Claude Code version check
|
|
|
|
|
|
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
|
|
|
|
|
|
|
2026-03-03 19:56:27 +08:00
|
|
|
|
// 分组隔离
|
|
|
|
|
|
result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true"
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 20:57:32 +08:00
|
|
|
|
func isFalseSettingValue(value string) bool {
|
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
|
|
|
|
case "false", "0", "off", "disabled":
|
|
|
|
|
|
return true
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 03:41:50 +08:00
|
|
|
|
func parseDefaultSubscriptions(raw string) []DefaultSubscriptionSetting {
|
|
|
|
|
|
raw = strings.TrimSpace(raw)
|
|
|
|
|
|
if raw == "" {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var items []DefaultSubscriptionSetting
|
|
|
|
|
|
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
normalized := make([]DefaultSubscriptionSetting, 0, len(items))
|
|
|
|
|
|
for _, item := range items {
|
|
|
|
|
|
if item.GroupID <= 0 || item.ValidityDays <= 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if item.ValidityDays > MaxValidityDays {
|
|
|
|
|
|
item.ValidityDays = MaxValidityDays
|
|
|
|
|
|
}
|
|
|
|
|
|
normalized = append(normalized, item)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 19:56:27 +08:00
|
|
|
|
// IsUngroupedKeySchedulingAllowed 查询是否允许未分组 Key 调度
|
|
|
|
|
|
func (s *SettingService) IsUngroupedKeySchedulingAllowed(ctx context.Context) bool {
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyAllowUngroupedKeyScheduling)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false // fail-closed: 查询失败时默认不允许
|
|
|
|
|
|
}
|
|
|
|
|
|
return value == "true"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 15:35:46 +08:00
|
|
|
|
// GetMinClaudeCodeVersion 获取最低 Claude Code 版本号要求
|
|
|
|
|
|
// 使用进程内 atomic.Value 缓存,60 秒 TTL,热路径零锁开销
|
|
|
|
|
|
// singleflight 防止缓存过期时 thundering herd
|
|
|
|
|
|
// 返回空字符串表示不做版本检查
|
|
|
|
|
|
func (s *SettingService) GetMinClaudeCodeVersion(ctx context.Context) string {
|
|
|
|
|
|
if cached, ok := minVersionCache.Load().(*cachedMinVersion); ok {
|
|
|
|
|
|
if time.Now().UnixNano() < cached.expiresAt {
|
|
|
|
|
|
return cached.value
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// singleflight: 同一时刻只有一个 goroutine 查询 DB,其余复用结果
|
2026-03-03 01:02:39 +08:00
|
|
|
|
result, err, _ := minVersionSF.Do("min_version", func() (any, error) {
|
2026-03-01 15:35:46 +08:00
|
|
|
|
// 二次检查,避免排队的 goroutine 重复查询
|
|
|
|
|
|
if cached, ok := minVersionCache.Load().(*cachedMinVersion); ok {
|
|
|
|
|
|
if time.Now().UnixNano() < cached.expiresAt {
|
|
|
|
|
|
return cached.value, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 使用独立 context:断开请求取消链,避免客户端断连导致空值被长期缓存
|
|
|
|
|
|
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), minVersionDBTimeout)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyMinClaudeCodeVersion)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
// fail-open: DB 错误时不阻塞请求,但记录日志并使用短 TTL 快速重试
|
|
|
|
|
|
slog.Warn("failed to get min claude code version setting, skipping version check", "error", err)
|
|
|
|
|
|
minVersionCache.Store(&cachedMinVersion{
|
|
|
|
|
|
value: "",
|
|
|
|
|
|
expiresAt: time.Now().Add(minVersionErrorTTL).UnixNano(),
|
|
|
|
|
|
})
|
|
|
|
|
|
return "", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
minVersionCache.Store(&cachedMinVersion{
|
|
|
|
|
|
value: value,
|
|
|
|
|
|
expiresAt: time.Now().Add(minVersionCacheTTL).UnixNano(),
|
|
|
|
|
|
})
|
|
|
|
|
|
return value, nil
|
|
|
|
|
|
})
|
2026-03-03 01:02:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
ver, ok := result.(string)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return ""
|
2026-03-01 16:39:21 +08:00
|
|
|
|
}
|
2026-03-03 01:02:39 +08:00
|
|
|
|
return ver
|
2026-03-01 15:35:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 21:54:52 -08:00
|
|
|
|
// SetStreamTimeoutSettings 设置流超时处理配置
|
|
|
|
|
|
func (s *SettingService) SetStreamTimeoutSettings(ctx context.Context, settings *StreamTimeoutSettings) error {
|
|
|
|
|
|
if settings == nil {
|
|
|
|
|
|
return fmt.Errorf("settings cannot be nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证配置值
|
|
|
|
|
|
if settings.TempUnschedMinutes < 1 || settings.TempUnschedMinutes > 60 {
|
|
|
|
|
|
return fmt.Errorf("temp_unsched_minutes must be between 1-60")
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.ThresholdCount < 1 || settings.ThresholdCount > 10 {
|
|
|
|
|
|
return fmt.Errorf("threshold_count must be between 1-10")
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.ThresholdWindowMinutes < 1 || settings.ThresholdWindowMinutes > 60 {
|
|
|
|
|
|
return fmt.Errorf("threshold_window_minutes must be between 1-60")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch settings.Action {
|
|
|
|
|
|
case StreamTimeoutActionTempUnsched, StreamTimeoutActionError, StreamTimeoutActionNone:
|
|
|
|
|
|
// valid
|
|
|
|
|
|
default:
|
|
|
|
|
|
return fmt.Errorf("invalid action: %s", settings.Action)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
data, err := json.Marshal(settings)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("marshal stream timeout settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.settingRepo.Set(ctx, SettingKeyStreamTimeoutSettings, string(data))
|
|
|
|
|
|
}
|
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
|
|
|
|
|
|
}
|