Merge remote-tracking branch 'upstream/main'

This commit is contained in:
IanShaw027
2025-12-31 21:56:17 +08:00
9 changed files with 193 additions and 98 deletions

View File

@@ -109,7 +109,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService) antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
proxyHandler := admin.NewProxyHandler(adminService) proxyHandler := admin.NewProxyHandler(adminService)
adminRedeemHandler := admin.NewRedeemHandler(adminService) adminRedeemHandler := admin.NewRedeemHandler(adminService)
settingHandler := admin.NewSettingHandler(settingService, emailService) settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService)
updateCache := repository.NewUpdateCache(redisClient) updateCache := repository.NewUpdateCache(redisClient)
gitHubReleaseClient := repository.NewGitHubReleaseClient() gitHubReleaseClient := repository.NewGitHubReleaseClient()
serviceBuildInfo := provideServiceBuildInfo(buildInfo) serviceBuildInfo := provideServiceBuildInfo(buildInfo)

View File

@@ -10,15 +10,17 @@ import (
// SettingHandler 系统设置处理器 // SettingHandler 系统设置处理器
type SettingHandler struct { type SettingHandler struct {
settingService *service.SettingService settingService *service.SettingService
emailService *service.EmailService emailService *service.EmailService
turnstileService *service.TurnstileService
} }
// NewSettingHandler 创建系统设置处理器 // NewSettingHandler 创建系统设置处理器
func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService) *SettingHandler { func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService) *SettingHandler {
return &SettingHandler{ return &SettingHandler{
settingService: settingService, settingService: settingService,
emailService: emailService, emailService: emailService,
turnstileService: turnstileService,
} }
} }
@@ -108,6 +110,36 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
req.SmtpPort = 587 req.SmtpPort = 587
} }
// Turnstile 参数验证
if req.TurnstileEnabled {
// 检查必填字段
if req.TurnstileSiteKey == "" {
response.BadRequest(c, "Turnstile Site Key is required when enabled")
return
}
if req.TurnstileSecretKey == "" {
response.BadRequest(c, "Turnstile Secret Key is required when enabled")
return
}
// 获取当前设置,检查参数是否有变化
currentSettings, err := h.settingService.GetAllSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
// 当 site_key 或 secret_key 任一变化时验证(避免配置错误导致无法登录)
siteKeyChanged := currentSettings.TurnstileSiteKey != req.TurnstileSiteKey
secretKeyChanged := currentSettings.TurnstileSecretKey != req.TurnstileSecretKey
if siteKeyChanged || secretKeyChanged {
if err := h.turnstileService.ValidateSecretKey(c.Request.Context(), req.TurnstileSecretKey); err != nil {
response.ErrorFrom(c, err)
return
}
}
}
settings := &service.SystemSettings{ settings := &service.SystemSettings{
RegistrationEnabled: req.RegistrationEnabled, RegistrationEnabled: req.RegistrationEnabled,
EmailVerifyEnabled: req.EmailVerifyEnabled, EmailVerifyEnabled: req.EmailVerifyEnabled,

View File

@@ -385,7 +385,7 @@ func newContractDeps(t *testing.T) *contractDeps {
authHandler := handler.NewAuthHandler(cfg, nil, userService) authHandler := handler.NewAuthHandler(cfg, nil, userService)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil) adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil)
jwtAuth := func(c *gin.Context) { jwtAuth := func(c *gin.Context) {
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{ c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{

View File

@@ -11,6 +11,7 @@ import (
var ( var (
ErrTurnstileVerificationFailed = infraerrors.BadRequest("TURNSTILE_VERIFICATION_FAILED", "turnstile verification failed") ErrTurnstileVerificationFailed = infraerrors.BadRequest("TURNSTILE_VERIFICATION_FAILED", "turnstile verification failed")
ErrTurnstileNotConfigured = infraerrors.ServiceUnavailable("TURNSTILE_NOT_CONFIGURED", "turnstile not configured") ErrTurnstileNotConfigured = infraerrors.ServiceUnavailable("TURNSTILE_NOT_CONFIGURED", "turnstile not configured")
ErrTurnstileInvalidSecretKey = infraerrors.BadRequest("TURNSTILE_INVALID_SECRET_KEY", "invalid turnstile secret key")
) )
// TurnstileVerifier 验证 Turnstile token 的接口 // TurnstileVerifier 验证 Turnstile token 的接口
@@ -83,3 +84,22 @@ func (s *TurnstileService) VerifyToken(ctx context.Context, token string, remote
func (s *TurnstileService) IsEnabled(ctx context.Context) bool { func (s *TurnstileService) IsEnabled(ctx context.Context) bool {
return s.settingService.IsTurnstileEnabled(ctx) return s.settingService.IsTurnstileEnabled(ctx)
} }
// ValidateSecretKey 验证 Turnstile Secret Key 是否有效
func (s *TurnstileService) ValidateSecretKey(ctx context.Context, secretKey string) error {
// 发送一个测试token的验证请求来检查secret_key是否有效
result, err := s.verifier.VerifyToken(ctx, secretKey, "test-validation", "")
if err != nil {
return fmt.Errorf("validate secret key: %w", err)
}
// 检查是否有 invalid-input-secret 错误
for _, code := range result.ErrorCodes {
if code == "invalid-input-secret" {
return ErrTurnstileInvalidSecretKey
}
}
// 其他错误(如 invalid-input-response说明 secret key 是有效的
return nil
}

View File

@@ -69,94 +69,108 @@
</span> </span>
</div> </div>
<!-- Progress bars --> <!-- Progress bars or Unlimited badge -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<div v-if="subscription.group?.daily_limit_usd" class="flex items-center gap-2"> <!-- Unlimited subscription badge -->
<span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{ <div
t('subscriptionProgress.daily') v-if="isUnlimited(subscription)"
}}</span> class="flex items-center gap-2 rounded-lg bg-gradient-to-r from-emerald-50 to-teal-50 px-2.5 py-1.5 dark:from-emerald-900/20 dark:to-teal-900/20"
<div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600"> >
<div <span class="text-lg text-emerald-600 dark:text-emerald-400"></span>
class="h-1.5 rounded-full transition-all" <span class="text-xs font-medium text-emerald-700 dark:text-emerald-300">
:class=" {{ t('subscriptionProgress.unlimited') }}
getProgressBarClass(
subscription.daily_usage_usd,
subscription.group?.daily_limit_usd
)
"
:style="{
width: getProgressWidth(
subscription.daily_usage_usd,
subscription.group?.daily_limit_usd
)
}"
></div>
</div>
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
{{
formatUsage(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)
}}
</span> </span>
</div> </div>
<div v-if="subscription.group?.weekly_limit_usd" class="flex items-center gap-2"> <!-- Progress bars for limited subscriptions -->
<span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{ <template v-else>
t('subscriptionProgress.weekly') <div v-if="subscription.group?.daily_limit_usd" class="flex items-center gap-2">
}}</span> <span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{
<div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600"> t('subscriptionProgress.daily')
<div }}</span>
class="h-1.5 rounded-full transition-all" <div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600">
:class=" <div
getProgressBarClass( class="h-1.5 rounded-full transition-all"
subscription.weekly_usage_usd, :class="
subscription.group?.weekly_limit_usd getProgressBarClass(
) subscription.daily_usage_usd,
" subscription.group?.daily_limit_usd
:style="{ )
width: getProgressWidth( "
subscription.weekly_usage_usd, :style="{
subscription.group?.weekly_limit_usd width: getProgressWidth(
) subscription.daily_usage_usd,
}" subscription.group?.daily_limit_usd
></div> )
}"
></div>
</div>
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
{{
formatUsage(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)
}}
</span>
</div> </div>
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
{{
formatUsage(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)
}}
</span>
</div>
<div v-if="subscription.group?.monthly_limit_usd" class="flex items-center gap-2"> <div v-if="subscription.group?.weekly_limit_usd" class="flex items-center gap-2">
<span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{ <span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{
t('subscriptionProgress.monthly') t('subscriptionProgress.weekly')
}}</span> }}</span>
<div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600"> <div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600">
<div <div
class="h-1.5 rounded-full transition-all" class="h-1.5 rounded-full transition-all"
:class=" :class="
getProgressBarClass( getProgressBarClass(
subscription.monthly_usage_usd, subscription.weekly_usage_usd,
subscription.group?.monthly_limit_usd subscription.group?.weekly_limit_usd
) )
" "
:style="{ :style="{
width: getProgressWidth( width: getProgressWidth(
subscription.monthly_usage_usd, subscription.weekly_usage_usd,
subscription.group?.monthly_limit_usd subscription.group?.weekly_limit_usd
) )
}" }"
></div> ></div>
</div>
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
{{
formatUsage(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)
}}
</span>
</div> </div>
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
{{ <div v-if="subscription.group?.monthly_limit_usd" class="flex items-center gap-2">
formatUsage( <span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{
subscription.monthly_usage_usd, t('subscriptionProgress.monthly')
subscription.group?.monthly_limit_usd }}</span>
) <div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600">
}} <div
</span> class="h-1.5 rounded-full transition-all"
</div> :class="
getProgressBarClass(
subscription.monthly_usage_usd,
subscription.group?.monthly_limit_usd
)
"
:style="{
width: getProgressWidth(
subscription.monthly_usage_usd,
subscription.group?.monthly_limit_usd
)
}"
></div>
</div>
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
{{
formatUsage(
subscription.monthly_usage_usd,
subscription.group?.monthly_limit_usd
)
}}
</span>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
@@ -215,7 +229,19 @@ function getMaxUsagePercentage(sub: UserSubscription): number {
return percentages.length > 0 ? Math.max(...percentages) : 0 return percentages.length > 0 ? Math.max(...percentages) : 0
} }
function isUnlimited(sub: UserSubscription): boolean {
return (
!sub.group?.daily_limit_usd &&
!sub.group?.weekly_limit_usd &&
!sub.group?.monthly_limit_usd
)
}
function getProgressDotClass(sub: UserSubscription): string { function getProgressDotClass(sub: UserSubscription): string {
// Unlimited subscriptions get a special color
if (isUnlimited(sub)) {
return 'bg-emerald-500'
}
const maxPercentage = getMaxUsagePercentage(sub) const maxPercentage = getMaxUsagePercentage(sub)
if (maxPercentage >= 90) return 'bg-red-500' if (maxPercentage >= 90) return 'bg-red-500'
if (maxPercentage >= 70) return 'bg-orange-500' if (maxPercentage >= 70) return 'bg-orange-500'

View File

@@ -749,6 +749,7 @@ export default {
weekly: 'Weekly', weekly: 'Weekly',
monthly: 'Monthly', monthly: 'Monthly',
noLimits: 'No limits configured', noLimits: 'No limits configured',
unlimited: 'Unlimited',
resetNow: 'Resetting soon', resetNow: 'Resetting soon',
windowNotActive: 'Window not active', windowNotActive: 'Window not active',
resetInMinutes: 'Resets in {minutes}m', resetInMinutes: 'Resets in {minutes}m',
@@ -1492,7 +1493,8 @@ export default {
expiresToday: 'Expires today', expiresToday: 'Expires today',
expiresTomorrow: 'Expires tomorrow', expiresTomorrow: 'Expires tomorrow',
viewAll: 'View all subscriptions', viewAll: 'View all subscriptions',
noSubscriptions: 'No active subscriptions' noSubscriptions: 'No active subscriptions',
unlimited: 'Unlimited'
}, },
// Version Badge // Version Badge
@@ -1535,6 +1537,7 @@ export default {
expires: 'Expires', expires: 'Expires',
noExpiration: 'No expiration', noExpiration: 'No expiration',
unlimited: 'Unlimited', unlimited: 'Unlimited',
unlimitedDesc: 'No usage limits on this subscription',
daily: 'Daily', daily: 'Daily',
weekly: 'Weekly', weekly: 'Weekly',
monthly: 'Monthly', monthly: 'Monthly',

View File

@@ -840,6 +840,7 @@ export default {
weekly: '每周', weekly: '每周',
monthly: '每月', monthly: '每月',
noLimits: '未配置限额', noLimits: '未配置限额',
unlimited: '无限制',
resetNow: '即将重置', resetNow: '即将重置',
windowNotActive: '窗口未激活', windowNotActive: '窗口未激活',
resetInMinutes: '{minutes} 分钟后重置', resetInMinutes: '{minutes} 分钟后重置',
@@ -1689,7 +1690,8 @@ export default {
expiresToday: '今天到期', expiresToday: '今天到期',
expiresTomorrow: '明天到期', expiresTomorrow: '明天到期',
viewAll: '查看全部订阅', viewAll: '查看全部订阅',
noSubscriptions: '暂无有效订阅' noSubscriptions: '暂无有效订阅',
unlimited: '无限制'
}, },
// Version Badge // Version Badge
@@ -1731,6 +1733,7 @@ export default {
expires: '到期时间', expires: '到期时间',
noExpiration: '无到期时间', noExpiration: '无到期时间',
unlimited: '无限制', unlimited: '无限制',
unlimitedDesc: '该订阅无用量限制',
daily: '每日', daily: '每日',
weekly: '每周', weekly: '每周',
monthly: '每月', monthly: '每月',

View File

@@ -202,16 +202,19 @@
</div> </div>
</div> </div>
<!-- No Limits --> <!-- No Limits - Unlimited badge -->
<div <div
v-if=" v-if="
!row.group?.daily_limit_usd && !row.group?.daily_limit_usd &&
!row.group?.weekly_limit_usd && !row.group?.weekly_limit_usd &&
!row.group?.monthly_limit_usd !row.group?.monthly_limit_usd
" "
class="text-xs text-gray-500" class="flex items-center gap-2 rounded-lg bg-gradient-to-r from-emerald-50 to-teal-50 px-3 py-2 dark:from-emerald-900/20 dark:to-teal-900/20"
> >
{{ t('admin.subscriptions.noLimits') }} <span class="text-lg text-emerald-600 dark:text-emerald-400">∞</span>
<span class="text-xs font-medium text-emerald-700 dark:text-emerald-300">
{{ t('admin.subscriptions.unlimited') }}
</span>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -230,18 +230,26 @@
</p> </p>
</div> </div>
<!-- No limits configured --> <!-- No limits configured - Unlimited badge -->
<div <div
v-if=" v-if="
!subscription.group?.daily_limit_usd && !subscription.group?.daily_limit_usd &&
!subscription.group?.weekly_limit_usd && !subscription.group?.weekly_limit_usd &&
!subscription.group?.monthly_limit_usd !subscription.group?.monthly_limit_usd
" "
class="py-4 text-center" class="flex items-center justify-center rounded-xl bg-gradient-to-r from-emerald-50 to-teal-50 py-6 dark:from-emerald-900/20 dark:to-teal-900/20"
> >
<span class="text-sm text-gray-500 dark:text-dark-400">{{ <div class="flex items-center gap-3">
t('userSubscriptions.unlimited') <span class="text-4xl text-emerald-600 dark:text-emerald-400">∞</span>
}}</span> <div>
<p class="text-sm font-medium text-emerald-700 dark:text-emerald-300">
{{ t('userSubscriptions.unlimited') }}
</p>
<p class="text-xs text-emerald-600/70 dark:text-emerald-400/70">
{{ t('userSubscriptions.unlimitedDesc') }}
</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>