mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-17 21:34:45 +08:00
Merge pull request #944 from miraserver/feat/backend-mode
feat: add Backend Mode toggle to disable user self-service
This commit is contained in:
@@ -1087,6 +1087,12 @@ type TokenPair struct {
|
||||
ExpiresIn int `json:"expires_in"` // Access Token有效期(秒)
|
||||
}
|
||||
|
||||
// TokenPairWithUser extends TokenPair with user role for backend mode checks
|
||||
type TokenPairWithUser struct {
|
||||
TokenPair
|
||||
UserRole string
|
||||
}
|
||||
|
||||
// GenerateTokenPair 生成Access Token和Refresh Token对
|
||||
// familyID: 可选的Token家族ID,用于Token轮转时保持家族关系
|
||||
func (s *AuthService) GenerateTokenPair(ctx context.Context, user *User, familyID string) (*TokenPair, error) {
|
||||
@@ -1168,7 +1174,7 @@ func (s *AuthService) generateRefreshToken(ctx context.Context, user *User, fami
|
||||
|
||||
// RefreshTokenPair 使用Refresh Token刷新Token对
|
||||
// 实现Token轮转:每次刷新都会生成新的Refresh Token,旧Token立即失效
|
||||
func (s *AuthService) RefreshTokenPair(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||
func (s *AuthService) RefreshTokenPair(ctx context.Context, refreshToken string) (*TokenPairWithUser, error) {
|
||||
// 检查 refreshTokenCache 是否可用
|
||||
if s.refreshTokenCache == nil {
|
||||
return nil, ErrRefreshTokenInvalid
|
||||
@@ -1233,7 +1239,14 @@ func (s *AuthService) RefreshTokenPair(ctx context.Context, refreshToken string)
|
||||
}
|
||||
|
||||
// 生成新的Token对,保持同一个家族ID
|
||||
return s.GenerateTokenPair(ctx, user, data.FamilyID)
|
||||
pair, err := s.GenerateTokenPair(ctx, user, data.FamilyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TokenPairWithUser{
|
||||
TokenPair: *pair,
|
||||
UserRole: user.Role,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RevokeRefreshToken 撤销单个Refresh Token
|
||||
|
||||
@@ -220,6 +220,9 @@ const (
|
||||
|
||||
// SettingKeyAllowUngroupedKeyScheduling 允许未分组 API Key 调度(默认 false:未分组 Key 返回 403)
|
||||
SettingKeyAllowUngroupedKeyScheduling = "allow_ungrouped_key_scheduling"
|
||||
|
||||
// SettingKeyBackendModeEnabled Backend 模式:禁用用户注册和自助服务,仅管理员可登录
|
||||
SettingKeyBackendModeEnabled = "backend_mode_enabled"
|
||||
)
|
||||
|
||||
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
|
||||
|
||||
@@ -65,6 +65,19 @@ const minVersionErrorTTL = 5 * time.Second
|
||||
// minVersionDBTimeout singleflight 内 DB 查询超时,独立于请求 context
|
||||
const minVersionDBTimeout = 5 * time.Second
|
||||
|
||||
// cachedBackendMode Backend Mode cache (in-process, 60s TTL)
|
||||
type cachedBackendMode struct {
|
||||
value bool
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
var backendModeCache atomic.Value // *cachedBackendMode
|
||||
var backendModeSF singleflight.Group
|
||||
|
||||
const backendModeCacheTTL = 60 * time.Second
|
||||
const backendModeErrorTTL = 5 * time.Second
|
||||
const backendModeDBTimeout = 5 * time.Second
|
||||
|
||||
// DefaultSubscriptionGroupReader validates group references used by default subscriptions.
|
||||
type DefaultSubscriptionGroupReader interface {
|
||||
GetByID(ctx context.Context, id int64) (*Group, error)
|
||||
@@ -128,6 +141,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SettingKeySoraClientEnabled,
|
||||
SettingKeyCustomMenuItems,
|
||||
SettingKeyLinuxDoConnectEnabled,
|
||||
SettingKeyBackendModeEnabled,
|
||||
}
|
||||
|
||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||
@@ -172,6 +186,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
|
||||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -223,6 +238,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
|
||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}{
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
@@ -247,6 +263,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
SoraClientEnabled: settings.SoraClientEnabled,
|
||||
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||
BackendModeEnabled: settings.BackendModeEnabled,
|
||||
Version: s.version,
|
||||
}, nil
|
||||
}
|
||||
@@ -461,6 +478,9 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
||||
// 分组隔离
|
||||
updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling)
|
||||
|
||||
// Backend Mode
|
||||
updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled)
|
||||
|
||||
err = s.settingRepo.SetMultiple(ctx, updates)
|
||||
if err == nil {
|
||||
// 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口
|
||||
@@ -469,6 +489,11 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
||||
value: settings.MinClaudeCodeVersion,
|
||||
expiresAt: time.Now().Add(minVersionCacheTTL).UnixNano(),
|
||||
})
|
||||
backendModeSF.Forget("backend_mode")
|
||||
backendModeCache.Store(&cachedBackendMode{
|
||||
value: settings.BackendModeEnabled,
|
||||
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
|
||||
})
|
||||
if s.onUpdate != nil {
|
||||
s.onUpdate() // Invalidate cache after settings update
|
||||
}
|
||||
@@ -525,6 +550,52 @@ func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
|
||||
return value == "true"
|
||||
}
|
||||
|
||||
// IsBackendModeEnabled checks if backend mode is enabled
|
||||
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path
|
||||
func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
|
||||
if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value
|
||||
}
|
||||
}
|
||||
result, _, _ := backendModeSF.Do("backend_mode", func() (any, error) {
|
||||
if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value, nil
|
||||
}
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), backendModeDBTimeout)
|
||||
defer cancel()
|
||||
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyBackendModeEnabled)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
// Setting not yet created (fresh install) - default to disabled with full TTL
|
||||
backendModeCache.Store(&cachedBackendMode{
|
||||
value: false,
|
||||
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
slog.Warn("failed to get backend_mode_enabled setting", "error", err)
|
||||
backendModeCache.Store(&cachedBackendMode{
|
||||
value: false,
|
||||
expiresAt: time.Now().Add(backendModeErrorTTL).UnixNano(),
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
enabled := value == "true"
|
||||
backendModeCache.Store(&cachedBackendMode{
|
||||
value: enabled,
|
||||
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
|
||||
})
|
||||
return enabled, nil
|
||||
})
|
||||
if val, ok := result.(bool); ok {
|
||||
return val
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsEmailVerifyEnabled 检查是否开启邮件验证
|
||||
func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled)
|
||||
@@ -719,6 +790,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
|
||||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
||||
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
||||
}
|
||||
|
||||
// 解析整数类型
|
||||
|
||||
199
backend/internal/service/setting_service_backend_mode_test.go
Normal file
199
backend/internal/service/setting_service_backend_mode_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type bmRepoStub struct {
|
||||
getValueFn func(ctx context.Context, key string) (string, error)
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *bmRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
|
||||
panic("unexpected Get call")
|
||||
}
|
||||
|
||||
func (s *bmRepoStub) GetValue(ctx context.Context, key string) (string, error) {
|
||||
s.calls++
|
||||
if s.getValueFn == nil {
|
||||
panic("unexpected GetValue call")
|
||||
}
|
||||
return s.getValueFn(ctx, key)
|
||||
}
|
||||
|
||||
func (s *bmRepoStub) Set(ctx context.Context, key, value string) error {
|
||||
panic("unexpected Set call")
|
||||
}
|
||||
|
||||
func (s *bmRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
|
||||
panic("unexpected GetMultiple call")
|
||||
}
|
||||
|
||||
func (s *bmRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
|
||||
panic("unexpected SetMultiple call")
|
||||
}
|
||||
|
||||
func (s *bmRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
|
||||
panic("unexpected GetAll call")
|
||||
}
|
||||
|
||||
func (s *bmRepoStub) Delete(ctx context.Context, key string) error {
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
|
||||
type bmUpdateRepoStub struct {
|
||||
updates map[string]string
|
||||
getValueFn func(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
func (s *bmUpdateRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
|
||||
panic("unexpected Get call")
|
||||
}
|
||||
|
||||
func (s *bmUpdateRepoStub) GetValue(ctx context.Context, key string) (string, error) {
|
||||
if s.getValueFn == nil {
|
||||
panic("unexpected GetValue call")
|
||||
}
|
||||
return s.getValueFn(ctx, key)
|
||||
}
|
||||
|
||||
func (s *bmUpdateRepoStub) Set(ctx context.Context, key, value string) error {
|
||||
panic("unexpected Set call")
|
||||
}
|
||||
|
||||
func (s *bmUpdateRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
|
||||
panic("unexpected GetMultiple call")
|
||||
}
|
||||
|
||||
func (s *bmUpdateRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
|
||||
s.updates = make(map[string]string, len(settings))
|
||||
for k, v := range settings {
|
||||
s.updates[k] = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *bmUpdateRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
|
||||
panic("unexpected GetAll call")
|
||||
}
|
||||
|
||||
func (s *bmUpdateRepoStub) Delete(ctx context.Context, key string) error {
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
|
||||
func resetBackendModeTestCache(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
backendModeCache.Store((*cachedBackendMode)(nil))
|
||||
t.Cleanup(func() {
|
||||
backendModeCache.Store((*cachedBackendMode)(nil))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsBackendModeEnabled_ReturnsTrue(t *testing.T) {
|
||||
resetBackendModeTestCache(t)
|
||||
|
||||
repo := &bmRepoStub{
|
||||
getValueFn: func(ctx context.Context, key string) (string, error) {
|
||||
require.Equal(t, SettingKeyBackendModeEnabled, key)
|
||||
return "true", nil
|
||||
},
|
||||
}
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
require.True(t, svc.IsBackendModeEnabled(context.Background()))
|
||||
require.Equal(t, 1, repo.calls)
|
||||
}
|
||||
|
||||
func TestIsBackendModeEnabled_ReturnsFalse(t *testing.T) {
|
||||
resetBackendModeTestCache(t)
|
||||
|
||||
repo := &bmRepoStub{
|
||||
getValueFn: func(ctx context.Context, key string) (string, error) {
|
||||
require.Equal(t, SettingKeyBackendModeEnabled, key)
|
||||
return "false", nil
|
||||
},
|
||||
}
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
require.False(t, svc.IsBackendModeEnabled(context.Background()))
|
||||
require.Equal(t, 1, repo.calls)
|
||||
}
|
||||
|
||||
func TestIsBackendModeEnabled_ReturnsFalseOnNotFound(t *testing.T) {
|
||||
resetBackendModeTestCache(t)
|
||||
|
||||
repo := &bmRepoStub{
|
||||
getValueFn: func(ctx context.Context, key string) (string, error) {
|
||||
require.Equal(t, SettingKeyBackendModeEnabled, key)
|
||||
return "", ErrSettingNotFound
|
||||
},
|
||||
}
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
require.False(t, svc.IsBackendModeEnabled(context.Background()))
|
||||
require.Equal(t, 1, repo.calls)
|
||||
}
|
||||
|
||||
func TestIsBackendModeEnabled_ReturnsFalseOnDBError(t *testing.T) {
|
||||
resetBackendModeTestCache(t)
|
||||
|
||||
repo := &bmRepoStub{
|
||||
getValueFn: func(ctx context.Context, key string) (string, error) {
|
||||
require.Equal(t, SettingKeyBackendModeEnabled, key)
|
||||
return "", errors.New("db down")
|
||||
},
|
||||
}
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
require.False(t, svc.IsBackendModeEnabled(context.Background()))
|
||||
require.Equal(t, 1, repo.calls)
|
||||
}
|
||||
|
||||
func TestIsBackendModeEnabled_CachesResult(t *testing.T) {
|
||||
resetBackendModeTestCache(t)
|
||||
|
||||
repo := &bmRepoStub{
|
||||
getValueFn: func(ctx context.Context, key string) (string, error) {
|
||||
require.Equal(t, SettingKeyBackendModeEnabled, key)
|
||||
return "true", nil
|
||||
},
|
||||
}
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
require.True(t, svc.IsBackendModeEnabled(context.Background()))
|
||||
require.True(t, svc.IsBackendModeEnabled(context.Background()))
|
||||
require.Equal(t, 1, repo.calls)
|
||||
}
|
||||
|
||||
func TestUpdateSettings_InvalidatesBackendModeCache(t *testing.T) {
|
||||
resetBackendModeTestCache(t)
|
||||
|
||||
backendModeCache.Store(&cachedBackendMode{
|
||||
value: true,
|
||||
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
|
||||
})
|
||||
|
||||
repo := &bmUpdateRepoStub{
|
||||
getValueFn: func(ctx context.Context, key string) (string, error) {
|
||||
require.Equal(t, SettingKeyBackendModeEnabled, key)
|
||||
return "true", nil
|
||||
},
|
||||
}
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
err := svc.UpdateSettings(context.Background(), &SystemSettings{
|
||||
BackendModeEnabled: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "false", repo.updates[SettingKeyBackendModeEnabled])
|
||||
require.False(t, svc.IsBackendModeEnabled(context.Background()))
|
||||
}
|
||||
@@ -69,6 +69,9 @@ type SystemSettings struct {
|
||||
|
||||
// 分组隔离:允许未分组 Key 调度(默认 false → 403)
|
||||
AllowUngroupedKeyScheduling bool
|
||||
|
||||
// Backend 模式:禁用用户注册和自助服务,仅管理员可登录
|
||||
BackendModeEnabled bool
|
||||
}
|
||||
|
||||
type DefaultSubscriptionSetting struct {
|
||||
@@ -101,6 +104,7 @@ type PublicSettings struct {
|
||||
CustomMenuItems string // JSON array of custom menu items
|
||||
|
||||
LinuxDoOAuthEnabled bool
|
||||
BackendModeEnabled bool
|
||||
Version string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user