chore: prepare 0.1.127 release
This commit is contained in:
@@ -1 +1 @@
|
|||||||
0.1.126
|
0.1.127
|
||||||
|
|||||||
@@ -76,22 +76,6 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if setup is needed
|
|
||||||
if setup.NeedsSetup() {
|
|
||||||
// Check if auto-setup is enabled (for Docker deployment)
|
|
||||||
if setup.AutoSetupEnabled() {
|
|
||||||
log.Println("Auto setup mode enabled...")
|
|
||||||
if err := setup.AutoSetupFromEnv(); err != nil {
|
|
||||||
log.Fatalf("Auto setup failed: %v", err)
|
|
||||||
}
|
|
||||||
// Continue to main server after auto-setup
|
|
||||||
} else {
|
|
||||||
log.Println("First run detected, starting setup wizard...")
|
|
||||||
runSetupServer()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal server mode
|
// Normal server mode
|
||||||
runMainServer()
|
runMainServer()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
updateCache := repository.NewUpdateCache(redisClient)
|
updateCache := repository.NewUpdateCache(redisClient)
|
||||||
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
|
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
|
||||||
serviceBuildInfo := provideServiceBuildInfo(buildInfo)
|
serviceBuildInfo := provideServiceBuildInfo(buildInfo)
|
||||||
updateService := service.ProvideUpdateService(updateCache, gitHubReleaseClient, serviceBuildInfo)
|
updateService := service.ProvideUpdateService(updateCache, gitHubReleaseClient, serviceBuildInfo, configConfig)
|
||||||
idempotencyRepository := repository.NewIdempotencyRepository(client, db)
|
idempotencyRepository := repository.NewIdempotencyRepository(client, db)
|
||||||
systemOperationLockService := service.ProvideSystemOperationLockService(idempotencyRepository, configConfig)
|
systemOperationLockService := service.ProvideSystemOperationLockService(idempotencyRepository, configConfig)
|
||||||
systemHandler := handler.ProvideSystemHandler(updateService, systemOperationLockService)
|
systemHandler := handler.ProvideSystemHandler(updateService, systemOperationLockService)
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ type GeminiTierQuotaConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateConfig struct {
|
type UpdateConfig struct {
|
||||||
|
// GitHubRepo 用于在线更新的 GitHub 仓库,格式 owner/repo
|
||||||
|
GitHubRepo string `mapstructure:"github_repo"`
|
||||||
// ProxyURL 用于访问 GitHub 的代理地址
|
// ProxyURL 用于访问 GitHub 的代理地址
|
||||||
// 支持 http/https/socks5/socks5h 协议
|
// 支持 http/https/socks5/socks5h 协议
|
||||||
// 例如: "http://127.0.0.1:7890", "socks5://127.0.0.1:1080"
|
// 例如: "http://127.0.0.1:7890", "socks5://127.0.0.1:1080"
|
||||||
@@ -988,6 +990,8 @@ type DatabaseConfig struct {
|
|||||||
Password string `mapstructure:"password"`
|
Password string `mapstructure:"password"`
|
||||||
DBName string `mapstructure:"dbname"`
|
DBName string `mapstructure:"dbname"`
|
||||||
SSLMode string `mapstructure:"sslmode"`
|
SSLMode string `mapstructure:"sslmode"`
|
||||||
|
// AutoMigrate controls whether startup applies embedded SQL migrations.
|
||||||
|
AutoMigrate bool `mapstructure:"auto_migrate"`
|
||||||
// 连接池配置(性能优化:可配置化连接池参数)
|
// 连接池配置(性能优化:可配置化连接池参数)
|
||||||
// MaxOpenConns: 最大打开连接数,控制数据库连接上限,防止资源耗尽
|
// MaxOpenConns: 最大打开连接数,控制数据库连接上限,防止资源耗尽
|
||||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||||
@@ -1543,6 +1547,7 @@ func setDefaults() {
|
|||||||
viper.SetDefault("database.password", "postgres")
|
viper.SetDefault("database.password", "postgres")
|
||||||
viper.SetDefault("database.dbname", "sub2api")
|
viper.SetDefault("database.dbname", "sub2api")
|
||||||
viper.SetDefault("database.sslmode", "prefer")
|
viper.SetDefault("database.sslmode", "prefer")
|
||||||
|
viper.SetDefault("database.auto_migrate", true)
|
||||||
viper.SetDefault("database.max_open_conns", 256)
|
viper.SetDefault("database.max_open_conns", 256)
|
||||||
viper.SetDefault("database.max_idle_conns", 128)
|
viper.SetDefault("database.max_idle_conns", 128)
|
||||||
viper.SetDefault("database.conn_max_lifetime_minutes", 30)
|
viper.SetDefault("database.conn_max_lifetime_minutes", 30)
|
||||||
@@ -1606,6 +1611,10 @@ func setDefaults() {
|
|||||||
viper.SetDefault("pricing.update_interval_hours", 24)
|
viper.SetDefault("pricing.update_interval_hours", 24)
|
||||||
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
|
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
|
||||||
|
|
||||||
|
// Update
|
||||||
|
viper.SetDefault("update.github_repo", "man209111-cpu/sub2api")
|
||||||
|
viper.SetDefault("update.proxy_url", "")
|
||||||
|
|
||||||
// Timezone (default to Asia/Shanghai for Chinese users)
|
// Timezone (default to Asia/Shanghai for Chinese users)
|
||||||
viper.SetDefault("timezone", "Asia/Shanghai")
|
viper.SetDefault("timezone", "Asia/Shanghai")
|
||||||
|
|
||||||
|
|||||||
@@ -54,14 +54,14 @@ func InitEnt(cfg *config.Config) (*ent.Client, *sql.DB, error) {
|
|||||||
}
|
}
|
||||||
applyDBPoolSettings(drv.DB(), cfg)
|
applyDBPoolSettings(drv.DB(), cfg)
|
||||||
|
|
||||||
// 确保数据库 schema 已准备就绪。
|
|
||||||
// SQL 迁移文件是 schema 的权威来源(source of truth)。
|
|
||||||
// 这种方式比 Ent 的自动迁移更可控,支持复杂的迁移场景。
|
|
||||||
migrationCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
migrationCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := applyMigrationsFS(migrationCtx, drv.DB(), migrations.FS); err != nil {
|
if cfg.Database.AutoMigrate {
|
||||||
_ = drv.Close() // 迁移失败时关闭驱动,避免资源泄露
|
// SQL migration files are the source of truth for schema changes.
|
||||||
return nil, nil, err
|
if err := applyMigrationsFS(migrationCtx, drv.DB(), migrations.FS); err != nil {
|
||||||
|
_ = drv.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建 Ent 客户端,绑定到已配置的数据库驱动。
|
// 创建 Ent 客户端,绑定到已配置的数据库驱动。
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
updateCacheKey = "update_check_cache"
|
updateCacheKey = "update_check_cache"
|
||||||
updateCacheTTL = 1200 // 20 minutes
|
updateCacheTTL = 1200 // 20 minutes
|
||||||
githubRepo = "Wei-Shaw/sub2api"
|
defaultGitHubRepo = "man209111-cpu/sub2api"
|
||||||
|
|
||||||
// Security: allowed download domains for updates
|
// Security: allowed download domains for updates
|
||||||
allowedDownloadHost = "github.com"
|
allowedDownloadHost = "github.com"
|
||||||
@@ -51,15 +51,21 @@ type UpdateService struct {
|
|||||||
githubClient GitHubReleaseClient
|
githubClient GitHubReleaseClient
|
||||||
currentVersion string
|
currentVersion string
|
||||||
buildType string // "source" for manual builds, "release" for CI builds
|
buildType string // "source" for manual builds, "release" for CI builds
|
||||||
|
githubRepo string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUpdateService creates a new UpdateService
|
// NewUpdateService creates a new UpdateService
|
||||||
func NewUpdateService(cache UpdateCache, githubClient GitHubReleaseClient, version, buildType string) *UpdateService {
|
func NewUpdateService(cache UpdateCache, githubClient GitHubReleaseClient, version, buildType, githubRepo string) *UpdateService {
|
||||||
|
githubRepo = strings.TrimSpace(githubRepo)
|
||||||
|
if githubRepo == "" {
|
||||||
|
githubRepo = defaultGitHubRepo
|
||||||
|
}
|
||||||
return &UpdateService{
|
return &UpdateService{
|
||||||
cache: cache,
|
cache: cache,
|
||||||
githubClient: githubClient,
|
githubClient: githubClient,
|
||||||
currentVersion: version,
|
currentVersion: version,
|
||||||
buildType: buildType,
|
buildType: buildType,
|
||||||
|
githubRepo: githubRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +78,7 @@ type UpdateInfo struct {
|
|||||||
Cached bool `json:"cached"`
|
Cached bool `json:"cached"`
|
||||||
Warning string `json:"warning,omitempty"`
|
Warning string `json:"warning,omitempty"`
|
||||||
BuildType string `json:"build_type"` // "source" or "release"
|
BuildType string `json:"build_type"` // "source" or "release"
|
||||||
|
GitHubRepo string `json:"github_repo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReleaseInfo contains GitHub release details
|
// ReleaseInfo contains GitHub release details
|
||||||
@@ -129,6 +136,7 @@ func (s *UpdateService) CheckUpdate(ctx context.Context, force bool) (*UpdateInf
|
|||||||
HasUpdate: false,
|
HasUpdate: false,
|
||||||
Warning: err.Error(),
|
Warning: err.Error(),
|
||||||
BuildType: s.buildType,
|
BuildType: s.buildType,
|
||||||
|
GitHubRepo: s.githubRepo,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +282,7 @@ func (s *UpdateService) Rollback() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, error) {
|
func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, error) {
|
||||||
release, err := s.githubClient.FetchLatestRelease(ctx, githubRepo)
|
release, err := s.githubClient.FetchLatestRelease(ctx, s.githubRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -301,8 +309,9 @@ func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, er
|
|||||||
HTMLURL: release.HTMLURL,
|
HTMLURL: release.HTMLURL,
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
},
|
},
|
||||||
Cached: false,
|
Cached: false,
|
||||||
BuildType: s.buildType,
|
BuildType: s.buildType,
|
||||||
|
GitHubRepo: s.githubRepo,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,11 +486,16 @@ func (s *UpdateService) getFromCache(ctx context.Context) (*UpdateInfo, error) {
|
|||||||
Latest string `json:"latest"`
|
Latest string `json:"latest"`
|
||||||
ReleaseInfo *ReleaseInfo `json:"release_info"`
|
ReleaseInfo *ReleaseInfo `json:"release_info"`
|
||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
GitHubRepo string `json:"github_repo"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(data), &cached); err != nil {
|
if err := json.Unmarshal([]byte(data), &cached); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(cached.GitHubRepo) != s.githubRepo {
|
||||||
|
return nil, fmt.Errorf("cache repo mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
if time.Now().Unix()-cached.Timestamp > updateCacheTTL {
|
if time.Now().Unix()-cached.Timestamp > updateCacheTTL {
|
||||||
return nil, fmt.Errorf("cache expired")
|
return nil, fmt.Errorf("cache expired")
|
||||||
}
|
}
|
||||||
@@ -493,6 +507,7 @@ func (s *UpdateService) getFromCache(ctx context.Context) (*UpdateInfo, error) {
|
|||||||
ReleaseInfo: cached.ReleaseInfo,
|
ReleaseInfo: cached.ReleaseInfo,
|
||||||
Cached: true,
|
Cached: true,
|
||||||
BuildType: s.buildType,
|
BuildType: s.buildType,
|
||||||
|
GitHubRepo: s.githubRepo,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,10 +516,12 @@ func (s *UpdateService) saveToCache(ctx context.Context, info *UpdateInfo) {
|
|||||||
Latest string `json:"latest"`
|
Latest string `json:"latest"`
|
||||||
ReleaseInfo *ReleaseInfo `json:"release_info"`
|
ReleaseInfo *ReleaseInfo `json:"release_info"`
|
||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
GitHubRepo string `json:"github_repo"`
|
||||||
}{
|
}{
|
||||||
Latest: info.LatestVersion,
|
Latest: info.LatestVersion,
|
||||||
ReleaseInfo: info.ReleaseInfo,
|
ReleaseInfo: info.ReleaseInfo,
|
||||||
Timestamp: time.Now().Unix(),
|
Timestamp: time.Now().Unix(),
|
||||||
|
GitHubRepo: s.githubRepo,
|
||||||
}
|
}
|
||||||
|
|
||||||
data, _ := json.Marshal(cacheData)
|
data, _ := json.Marshal(cacheData)
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ func ProvidePricingService(cfg *config.Config, remoteClient PricingRemoteClient)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProvideUpdateService creates UpdateService with BuildInfo
|
// ProvideUpdateService creates UpdateService with BuildInfo
|
||||||
func ProvideUpdateService(cache UpdateCache, githubClient GitHubReleaseClient, buildInfo BuildInfo) *UpdateService {
|
func ProvideUpdateService(cache UpdateCache, githubClient GitHubReleaseClient, buildInfo BuildInfo, cfg *config.Config) *UpdateService {
|
||||||
return NewUpdateService(cache, githubClient, buildInfo.Version, buildInfo.BuildType)
|
return NewUpdateService(cache, githubClient, buildInfo.Version, buildInfo.BuildType, cfg.Update.GitHubRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideEmailQueueService creates EmailQueueService with default worker count
|
// ProvideEmailQueueService creates EmailQueueService with default worker count
|
||||||
|
|||||||
@@ -401,6 +401,10 @@ OPS_ENABLED=true
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Update Configuration (在线更新配置)
|
# Update Configuration (在线更新配置)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
# GitHub repository used for online update checks and binary downloads
|
||||||
|
# 在线更新检查和二进制下载使用的 GitHub 仓库
|
||||||
|
UPDATE_GITHUB_REPO=man209111-cpu/sub2api
|
||||||
|
|
||||||
# Proxy URL for accessing GitHub (used for online updates and pricing data)
|
# Proxy URL for accessing GitHub (used for online updates and pricing data)
|
||||||
# 用于访问 GitHub 的代理地址(用于在线更新和定价数据获取)
|
# 用于访问 GitHub 的代理地址(用于在线更新和定价数据获取)
|
||||||
# Supports: http, https, socks5, socks5h
|
# Supports: http, https, socks5, socks5h
|
||||||
|
|||||||
@@ -1081,6 +1081,9 @@ gemini:
|
|||||||
# Update Configuration (在线更新配置)
|
# Update Configuration (在线更新配置)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
update:
|
update:
|
||||||
|
# GitHub repository used for online update checks and binary downloads.
|
||||||
|
# 在线更新检查和二进制下载使用的 GitHub 仓库。
|
||||||
|
github_repo: "man209111-cpu/sub2api"
|
||||||
# Proxy URL for accessing GitHub (used for online updates and pricing data)
|
# Proxy URL for accessing GitHub (used for online updates and pricing data)
|
||||||
# 用于访问 GitHub 的代理地址(用于在线更新和定价数据获取)
|
# 用于访问 GitHub 的代理地址(用于在线更新和定价数据获取)
|
||||||
# Supports: http, https, socks5, socks5h
|
# Supports: http, https, socks5, socks5h
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ services:
|
|||||||
# =======================================================================
|
# =======================================================================
|
||||||
# Update Configuration (在线更新配置)
|
# Update Configuration (在线更新配置)
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
|
# GitHub repo for online update checks and binary downloads
|
||||||
|
- UPDATE_GITHUB_REPO=${UPDATE_GITHUB_REPO:-man209111-cpu/sub2api}
|
||||||
# Proxy for accessing GitHub (online updates + pricing data)
|
# Proxy for accessing GitHub (online updates + pricing data)
|
||||||
# Examples: http://host:port, socks5://host:port
|
# Examples: http://host:port, socks5://host:port
|
||||||
- UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-}
|
- UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-}
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ services:
|
|||||||
# =======================================================================
|
# =======================================================================
|
||||||
# Update Configuration (在线更新配置)
|
# Update Configuration (在线更新配置)
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
|
# GitHub repo for online update checks and binary downloads
|
||||||
|
- UPDATE_GITHUB_REPO=${UPDATE_GITHUB_REPO:-man209111-cpu/sub2api}
|
||||||
# Proxy for accessing GitHub (online updates + pricing data)
|
# Proxy for accessing GitHub (online updates + pricing data)
|
||||||
# Examples: http://host:port, socks5://host:port
|
# Examples: http://host:port, socks5://host:port
|
||||||
- UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-}
|
- UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-}
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ interface NavItem {
|
|||||||
icon: unknown
|
icon: unknown
|
||||||
iconSvg?: string
|
iconSvg?: string
|
||||||
hideInSimpleMode?: boolean
|
hideInSimpleMode?: boolean
|
||||||
|
requiresSuperAdmin?: boolean
|
||||||
children?: NavItem[]
|
children?: NavItem[]
|
||||||
/**
|
/**
|
||||||
* When true, the parent item only toggles the expand/collapse state and
|
* When true, the parent item only toggles the expand/collapse state and
|
||||||
@@ -224,6 +225,22 @@ function applyFeatureFlags(items: NavItem[]): NavItem[] {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyRoleVisibility(items: NavItem[]): NavItem[] {
|
||||||
|
const out: NavItem[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.requiresSuperAdmin && !authStore.isSuperAdmin) continue
|
||||||
|
if (item.children) {
|
||||||
|
const children = applyRoleVisibility(item.children)
|
||||||
|
if (children.length > 0) {
|
||||||
|
out.push({ ...item, children })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -719,25 +736,26 @@ const adminNavItems = computed((): NavItem[] => {
|
|||||||
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
||||||
{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon, featureFlag: flagOpsMonitoring },
|
{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon, featureFlag: flagOpsMonitoring },
|
||||||
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
|
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
|
||||||
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
|
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true, requiresSuperAdmin: true },
|
||||||
{
|
{
|
||||||
path: '/admin/channels',
|
path: '/admin/channels',
|
||||||
label: t('nav.channelManagement'),
|
label: t('nav.channelManagement'),
|
||||||
icon: ChannelIcon,
|
icon: ChannelIcon,
|
||||||
hideInSimpleMode: true,
|
hideInSimpleMode: true,
|
||||||
|
requiresSuperAdmin: true,
|
||||||
expandOnly: true,
|
expandOnly: true,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/admin/channels/pricing', label: t('nav.channelPricing'), icon: PriceTagIcon },
|
{ path: '/admin/channels/pricing', label: t('nav.channelPricing'), icon: PriceTagIcon },
|
||||||
{ path: '/admin/channels/monitor', label: t('nav.channelMonitor'), icon: SignalIcon, featureFlag: flagChannelMonitor },
|
{ path: '/admin/channels/monitor', label: t('nav.channelMonitor'), icon: SignalIcon, featureFlag: flagChannelMonitor },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true, requiresSuperAdmin: true },
|
||||||
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
|
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon, requiresSuperAdmin: true },
|
||||||
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
|
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
|
||||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon, requiresSuperAdmin: true },
|
||||||
{ path: '/admin/risk-control', label: t('nav.riskControl'), icon: ShieldIcon, hideInSimpleMode: true, featureFlag: flagRiskControl },
|
{ path: '/admin/risk-control', label: t('nav.riskControl'), icon: ShieldIcon, hideInSimpleMode: true, featureFlag: flagRiskControl },
|
||||||
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
|
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true, requiresSuperAdmin: true },
|
||||||
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
|
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true, requiresSuperAdmin: true },
|
||||||
{
|
{
|
||||||
path: '/admin/affiliates',
|
path: '/admin/affiliates',
|
||||||
label: t('nav.affiliateManagement'),
|
label: t('nav.affiliateManagement'),
|
||||||
@@ -767,20 +785,24 @@ const adminNavItems = computed((): NavItem[] => {
|
|||||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }
|
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }
|
||||||
]
|
]
|
||||||
|
|
||||||
const visible = applyFeatureFlags(baseItems)
|
const visible = applyRoleVisibility(applyFeatureFlags(baseItems))
|
||||||
|
|
||||||
// 简单模式下,在系统设置前插入 API密钥
|
// 简单模式下,在系统设置前插入 API密钥
|
||||||
if (authStore.isSimpleMode) {
|
if (authStore.isSimpleMode) {
|
||||||
const filtered = visible.filter(item => !item.hideInSimpleMode)
|
const filtered = visible.filter(item => !item.hideInSimpleMode)
|
||||||
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
|
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
|
||||||
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
if (authStore.isSuperAdmin) {
|
||||||
|
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||||
|
}
|
||||||
for (const cm of customMenuItemsForAdmin.value) {
|
for (const cm of customMenuItemsForAdmin.value) {
|
||||||
filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
||||||
}
|
}
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
visible.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
if (authStore.isSuperAdmin) {
|
||||||
|
visible.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||||
|
}
|
||||||
for (const cm of customMenuItemsForAdmin.value) {
|
for (const cm of customMenuItemsForAdmin.value) {
|
||||||
visible.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
visible.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ vi.mock('@/api/auth', () => ({
|
|||||||
interface MockAuthState {
|
interface MockAuthState {
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
|
isSuperAdmin?: boolean
|
||||||
isSimpleMode: boolean
|
isSimpleMode: boolean
|
||||||
backendModeEnabled: boolean
|
backendModeEnabled: boolean
|
||||||
hasPendingAuthSession: boolean
|
hasPendingAuthSession: boolean
|
||||||
@@ -65,6 +66,7 @@ function simulateGuard(
|
|||||||
): string | null {
|
): string | null {
|
||||||
const requiresAuth = toMeta.requiresAuth !== false
|
const requiresAuth = toMeta.requiresAuth !== false
|
||||||
const requiresAdmin = toMeta.requiresAdmin === true
|
const requiresAdmin = toMeta.requiresAdmin === true
|
||||||
|
const isSuperAdmin = authState.isSuperAdmin ?? authState.isAdmin
|
||||||
|
|
||||||
// 不需要认证的路由
|
// 不需要认证的路由
|
||||||
if (!requiresAuth) {
|
if (!requiresAuth) {
|
||||||
@@ -108,6 +110,10 @@ function simulateGuard(
|
|||||||
return '/dashboard'
|
return '/dashboard'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toMeta.requiresSuperAdmin && !isSuperAdmin) {
|
||||||
|
return '/admin/dashboard'
|
||||||
|
}
|
||||||
|
|
||||||
// 简易模式限制
|
// 简易模式限制
|
||||||
if (authState.isSimpleMode) {
|
if (authState.isSimpleMode) {
|
||||||
const restrictedPaths = [
|
const restrictedPaths = [
|
||||||
@@ -249,6 +255,31 @@ describe('路由守卫逻辑', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('已认证 useradmin', () => {
|
||||||
|
const authState: MockAuthState = {
|
||||||
|
isAuthenticated: true,
|
||||||
|
isAdmin: true,
|
||||||
|
isSuperAdmin: false,
|
||||||
|
isSimpleMode: false,
|
||||||
|
backendModeEnabled: false,
|
||||||
|
hasPendingAuthSession: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('访问管理页面允许通过', () => {
|
||||||
|
const redirect = simulateGuard('/admin/users', { requiresAdmin: true }, authState)
|
||||||
|
expect(redirect).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('访问仅超级管理员页面会重定向到后台首页', () => {
|
||||||
|
const redirect = simulateGuard(
|
||||||
|
'/admin/settings',
|
||||||
|
{ requiresAdmin: true, requiresSuperAdmin: true },
|
||||||
|
authState
|
||||||
|
)
|
||||||
|
expect(redirect).toBe('/admin/dashboard')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// --- 简易模式 ---
|
// --- 简易模式 ---
|
||||||
|
|
||||||
describe('简易模式受限路由', () => {
|
describe('简易模式受限路由', () => {
|
||||||
|
|||||||
@@ -399,6 +399,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiresSuperAdmin: true,
|
||||||
title: 'Group Management',
|
title: 'Group Management',
|
||||||
titleKey: 'admin.groups.title',
|
titleKey: 'admin.groups.title',
|
||||||
descriptionKey: 'admin.groups.description'
|
descriptionKey: 'admin.groups.description'
|
||||||
@@ -415,6 +416,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiresSuperAdmin: true,
|
||||||
title: 'Channel Management',
|
title: 'Channel Management',
|
||||||
titleKey: 'admin.channels.title',
|
titleKey: 'admin.channels.title',
|
||||||
descriptionKey: 'admin.channels.description'
|
descriptionKey: 'admin.channels.description'
|
||||||
@@ -427,6 +429,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiresSuperAdmin: true,
|
||||||
title: 'Channel Monitor',
|
title: 'Channel Monitor',
|
||||||
titleKey: 'admin.channelMonitor.title',
|
titleKey: 'admin.channelMonitor.title',
|
||||||
descriptionKey: 'admin.channelMonitor.description'
|
descriptionKey: 'admin.channelMonitor.description'
|
||||||
@@ -450,6 +453,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiresSuperAdmin: true,
|
||||||
title: 'Subscription Management',
|
title: 'Subscription Management',
|
||||||
titleKey: 'admin.subscriptions.title',
|
titleKey: 'admin.subscriptions.title',
|
||||||
descriptionKey: 'admin.subscriptions.description'
|
descriptionKey: 'admin.subscriptions.description'
|
||||||
@@ -462,6 +466,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiresSuperAdmin: true,
|
||||||
title: 'Account Management',
|
title: 'Account Management',
|
||||||
titleKey: 'admin.accounts.title',
|
titleKey: 'admin.accounts.title',
|
||||||
descriptionKey: 'admin.accounts.description'
|
descriptionKey: 'admin.accounts.description'
|
||||||
@@ -486,6 +491,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiresSuperAdmin: true,
|
||||||
title: 'Proxy Management',
|
title: 'Proxy Management',
|
||||||
titleKey: 'admin.proxies.title',
|
titleKey: 'admin.proxies.title',
|
||||||
descriptionKey: 'admin.proxies.description'
|
descriptionKey: 'admin.proxies.description'
|
||||||
@@ -498,6 +504,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiresSuperAdmin: true,
|
||||||
title: 'Redeem Code Management',
|
title: 'Redeem Code Management',
|
||||||
titleKey: 'admin.redeem.title',
|
titleKey: 'admin.redeem.title',
|
||||||
descriptionKey: 'admin.redeem.description'
|
descriptionKey: 'admin.redeem.description'
|
||||||
@@ -510,6 +517,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiresSuperAdmin: true,
|
||||||
title: 'Promo Code Management',
|
title: 'Promo Code Management',
|
||||||
titleKey: 'admin.promo.title',
|
titleKey: 'admin.promo.title',
|
||||||
descriptionKey: 'admin.promo.description'
|
descriptionKey: 'admin.promo.description'
|
||||||
@@ -522,6 +530,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiresSuperAdmin: true,
|
||||||
title: 'System Settings',
|
title: 'System Settings',
|
||||||
titleKey: 'admin.settings.title',
|
titleKey: 'admin.settings.title',
|
||||||
descriptionKey: 'admin.settings.description'
|
descriptionKey: 'admin.settings.description'
|
||||||
@@ -765,13 +774,19 @@ router.beforeEach((to, _from, next) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check admin requirement (requires admin role, not useradmin)
|
// Check admin requirement (admin and useradmin can access admin routes)
|
||||||
if (requiresAdmin && !authStore.isSuperAdmin) {
|
if (requiresAdmin && !authStore.isAdmin) {
|
||||||
// User is authenticated but not admin, redirect to user dashboard
|
// User is authenticated but not admin, redirect to user dashboard
|
||||||
next('/dashboard')
|
next('/dashboard')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check full admin requirement (admin only, excluding useradmin)
|
||||||
|
if (to.meta.requiresSuperAdmin && !authStore.isSuperAdmin) {
|
||||||
|
next('/admin/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Check payment requirement (internal payment system only)
|
// Check payment requirement (internal payment system only)
|
||||||
if (to.meta.requiresPayment) {
|
if (to.meta.requiresPayment) {
|
||||||
@@ -785,7 +800,7 @@ router.beforeEach((to, _from, next) => {
|
|||||||
if (to.meta.requiresRiskControl) {
|
if (to.meta.requiresRiskControl) {
|
||||||
const riskControlEnabled = appStore.cachedPublicSettings?.risk_control_enabled === true
|
const riskControlEnabled = appStore.cachedPublicSettings?.risk_control_enabled === true
|
||||||
if (!riskControlEnabled) {
|
if (!riskControlEnabled) {
|
||||||
next(authStore.isAdmin ? '/admin/settings' : '/dashboard')
|
next(authStore.isSuperAdmin ? '/admin/settings' : '/dashboard')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+6
@@ -19,6 +19,12 @@ declare module 'vue-router' {
|
|||||||
*/
|
*/
|
||||||
requiresAdmin?: boolean
|
requiresAdmin?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this route requires the full admin role, excluding useradmin
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
requiresSuperAdmin?: boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page title for this route
|
* Page title for this route
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export interface User {
|
|||||||
linuxdo_bound?: boolean
|
linuxdo_bound?: boolean
|
||||||
oidc_bound?: boolean
|
oidc_bound?: boolean
|
||||||
wechat_bound?: boolean
|
wechat_bound?: boolean
|
||||||
role: 'admin' | 'user' // User role for authorization
|
role: 'admin' | 'user' | 'useradmin' // User role for authorization
|
||||||
balance: number // User balance for API usage
|
balance: number // User balance for API usage
|
||||||
concurrency: number // Allowed concurrent requests
|
concurrency: number // Allowed concurrent requests
|
||||||
rpm_limit?: number // User-level RPM cap (0 = unlimited); effective as fallback when group has no rpm_limit
|
rpm_limit?: number // User-level RPM cap (0 = unlimited); effective as fallback when group has no rpm_limit
|
||||||
|
|||||||
Reference in New Issue
Block a user