diff --git a/backend/internal/server/middleware/api_key_auth_google.go b/backend/internal/server/middleware/api_key_auth_google.go index 5c5ee147a4..c75d5b99f2 100644 --- a/backend/internal/server/middleware/api_key_auth_google.go +++ b/backend/internal/server/middleware/api_key_auth_google.go @@ -2,10 +2,12 @@ package middleware import ( "errors" + "fmt" "strings" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/googleapi" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -46,10 +48,32 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs // user/group/platform。 SetOpsFallbackAPIKey(c, apiKey) - if !apiKey.IsActive() { + // disabled / 未知状态 → 无条件拦截(expired 和 quota_exhausted 留给计费阶段, + // 与主中间件 api_key_auth.go 保持一致)。 + if !apiKey.IsActive() && + apiKey.Status != service.StatusAPIKeyExpired && + apiKey.Status != service.StatusAPIKeyQuotaExhausted { abortWithGoogleError(c, 401, "API key is disabled") return } + + // 检查 IP 限制(白名单/黑名单)。与主中间件保持一致,避免 Gemini 端点绕过 Key 的 IP ACL。 + if len(apiKey.IPWhitelist) > 0 || len(apiKey.IPBlacklist) > 0 { + clientIP := ip.GetTrustedClientIP(c) + if cfg.TrustForwardedIPForAPIKeyACL() { + clientIP = ip.GetClientIP(c) + } + allowed, _ := ip.CheckIPRestrictionWithCompiledRules(clientIP, apiKey.CompiledIPWhitelist, apiKey.CompiledIPBlacklist) + if !allowed { + if clientIP == "" { + clientIP = "unknown" + } + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonIPRestriction) + abortWithGoogleError(c, 403, fmt.Sprintf("Access denied. Your IP is %s", clientIP)) + return + } + } + if apiKey.User == nil { abortWithGoogleError(c, 401, "User associated with API key not found") return @@ -63,6 +87,12 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs abortWithGoogleError(c, 403, message) return } + // 专属分组授权校验:用户对该专属分组的授权被撤销后应拒绝(与主中间件一致,防止越权)。 + if !validateAPIKeyGroupAllowed(apiKey) { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable) + abortWithGoogleError(c, 403, "API Key 所属专属分组不再允许当前用户使用") + return + } // 简易模式:跳过余额和订阅检查 if cfg.RunMode == config.RunModeSimple { @@ -78,6 +108,26 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs return } + // Key 状态检查(状态字段可能因后台异步刷新而滞后,故显式拦截)。 + switch apiKey.Status { + case service.StatusAPIKeyQuotaExhausted: + abortWithGoogleError(c, 429, "API key 额度已用完") + return + case service.StatusAPIKeyExpired: + abortWithGoogleError(c, 403, "API key 已过期") + return + } + + // 运行时过期/配额检查(即使状态是 active,也要检查时间和用量,与主中间件一致)。 + if apiKey.IsExpired() { + abortWithGoogleError(c, 403, "API key 已过期") + return + } + if apiKey.IsQuotaExhausted() { + abortWithGoogleError(c, 429, "API key 额度已用完") + return + } + isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType() if isSubscriptionType && subscriptionService != nil { subscription, err := subscriptionService.GetActiveSubscription( diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index e690aaab22..1418989780 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -165,7 +165,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // 最低缓存门槛,导致系统级缓存失效)。 // // 对于非 Claude Code 的第三方客户端(opencode 等),仍然走完整 mimicry。 - isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) + var clientUserAgent string + if c != nil { + clientUserAgent = c.GetHeader("User-Agent") + } + isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(clientUserAgent, parsed.MetadataUserID) shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode if shouldMimicClaudeCode { @@ -190,7 +194,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // 未重写时(haiku / 注入开关关闭)剥离客户端 cache_control,与原有行为一致。 // 两种情况下 enforceCacheControlLimit 都会兜底处理上限。 normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} - if s.identityService != nil { + if s.identityService != nil && c != nil { fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) if err == nil && fp != nil { // metadata 透传开启时跳过 metadata 注入 @@ -220,7 +224,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if err := replaceBody(applyToolNameRewriteToBody(body, rw)); err != nil { return nil, err } - c.Set(toolNameRewriteKey, rw) + if c != nil { + c.Set(toolNameRewriteKey, rw) + } } else { if err := replaceBody(applyToolsLastCacheBreakpoint(body)); err != nil { return nil, err diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index 9a640d9fe8..d72303cd92 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -360,15 +360,15 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro stickyCacheMissReason = "session_limit" // 会话限制已满,继续到负载感知选择 } else { - return &AccountSelectionResult{ - Account: stickyAccount, - WaitPlan: &AccountWaitPlan{ - AccountID: stickyAccountID, - MaxConcurrency: stickyAccount.Concurrency, - Timeout: cfg.StickySessionWaitTimeout, - MaxWaiting: cfg.StickySessionMaxWaiting, - }, - }, nil + // 必须走 newSelectionResult 以 hydrate 账号凭证: + // 调度快照中的账号是精简版(OAuth token 等被剥离), + // 直接返回会导致后续转发缺少凭证而鉴权失败。 + return s.newSelectionResult(ctx, stickyAccount, false, nil, &AccountWaitPlan{ + AccountID: stickyAccountID, + MaxConcurrency: stickyAccount.Concurrency, + Timeout: cfg.StickySessionWaitTimeout, + MaxWaiting: cfg.StickySessionMaxWaiting, + }) } } else { stickyCacheMissReason = "wait_queue_full" diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 7a66e12a9a..03f757dabc 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -356,7 +356,13 @@ func (s *GatewayService) readUpstreamErrorBody(resp *http.Response) ([]byte, err } func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, requestedModel ...string) (*ForwardResult, error) { - body, _ := s.readUpstreamErrorBody(resp) + body, readErr := s.readUpstreamErrorBody(resp) + if readErr != nil { + // 读取失败时 body 可能被截断,错误分类会基于不完整数据;记录日志以便排查, + // 避免静默吞掉导致误判。 + logger.LegacyPrintf("service.gateway", "[Forward] Failed to fully read upstream error body: Account=%d(%s) Status=%d err=%v", + account.ID, account.Name, resp.StatusCode, readErr) + } // 调试日志:打印上游错误响应 logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (non-retryable): Account=%d(%s) Status=%d RequestID=%s Body=%s", @@ -1023,11 +1029,14 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http if _, werr := fmt.Fprint(w, string(restored)); werr != nil { clientDisconnected = true logger.LegacyPrintf("service.gateway", "Client disconnected during streaming, continuing to drain upstream for billing") - break + // 不 break:客户端断开后仍需继续合并本事件及后续事件的 usage, + // 否则会漏计当前事件携带的 usage 导致少计费。后续写入由 + // clientDisconnected 守卫跳过。 + } else { + flusher.Flush() + lastDataAt = time.Now() + resetKeepaliveTimer() } - flusher.Flush() - lastDataAt = time.Now() - resetKeepaliveTimer() } if data != "" { if firstTokenMs == nil && data != "[DONE]" { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c1b9abcd5e..5df969f188 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -205,7 +205,9 @@ apiClient.interceptors.response.use( const refreshResponse = await axios.post( `${getAPIBaseURL()}/auth/refresh`, { refresh_token: refreshToken }, - { headers: { 'Content-Type': 'application/json' } } + // 显式设置超时:裸 axios 默认无限等待,若刷新请求挂起会导致 isRefreshing + // 永远为 true,所有排队的 401 重试请求永久卡死,页面 loading 无法恢复。 + { headers: { 'Content-Type': 'application/json' }, timeout: 30000 } ) const refreshData = refreshResponse.data as ApiResponse<{ diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index c7232fd640..75c4164777 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -275,22 +275,33 @@ async function tryRecoverPendingOrder(order: PaymentOrder): Promise { } + // 公共设置可能尚未加载(App.vue 的 onMounted 异步拉取晚于首次导航,且纯静态部署 + // 无 __APP_CONFIG__ 注入)。此时 cachedPublicSettings 为空会把 payment/risk_control + // 误判为“未启用”而错误拦截,故这里先确保设置加载完成。 + if ((to.meta.requiresPayment || to.meta.requiresRiskControl) && !appStore.publicSettingsLoaded) { + try { + await appStore.fetchPublicSettings() + } catch (error) { + console.warn('Failed to load public settings in route guard', error) + } + } + // Check payment requirement (internal payment system only) if (to.meta.requiresPayment) { const paymentEnabled = appStore.cachedPublicSettings?.payment_enabled diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 4b712692b3..1346262ee8 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -397,11 +397,16 @@ export const useAuthStore = defineStore('auth', () => { * Clears all authentication state and persisted data */ async function logout(): Promise { - // Call API logout (revokes refresh token on server) - await authAPI.logout() - - // Clear state - clearAuth() + try { + // Call API logout (revokes refresh token on server) + await authAPI.logout() + } catch (err) { + // 服务端吊销失败(网络/5xx/超时)不应阻止本地登出,否则用户点了退出仍处于登录态。 + console.warn('Logout API call failed, clearing local session anyway', err) + } finally { + // Always clear local state (tokens, user data, refresh timers) + clearAuth() + } } /** diff --git a/frontend/src/views/user/PaymentQRCodeView.vue b/frontend/src/views/user/PaymentQRCodeView.vue index 5df67d0fe9..0569bf6907 100644 --- a/frontend/src/views/user/PaymentQRCodeView.vue +++ b/frontend/src/views/user/PaymentQRCodeView.vue @@ -132,16 +132,26 @@ async function renderQR() { } } +let pollInFlight = false async function pollStatus() { if (!orderId.value) return - const order = await paymentStore.pollOrderStatus(orderId.value) - if (!order) return - if (order.status === 'COMPLETED' || order.status === 'PAID') { - cleanup() - router.push({ path: '/payment/result', query: { order_id: String(orderId.value), status: 'success' } }) - } else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') { - cleanup() - expired.value = true + // 防重入:接口响应慢于 3 秒轮询间隔时避免并发重叠请求与重复跳转。 + if (pollInFlight) return + pollInFlight = true + try { + const order = await paymentStore.pollOrderStatus(orderId.value) + if (!order) return + // 定时器已被 cleanup 清除时不再执行终态跳转(响应可能在 cleanup 后才回来)。 + if (!pollTimer) return + if (order.status === 'COMPLETED' || order.status === 'PAID') { + cleanup() + router.push({ path: '/payment/result', query: { order_id: String(orderId.value), status: 'success' } }) + } else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') { + cleanup() + expired.value = true + } + } finally { + pollInFlight = false } } diff --git a/frontend/src/views/user/StripePopupView.vue b/frontend/src/views/user/StripePopupView.vue index 063101cccb..0e852e5206 100644 --- a/frontend/src/views/user/StripePopupView.vue +++ b/frontend/src/views/user/StripePopupView.vue @@ -84,23 +84,38 @@ const success = ref(false) const hint = ref(t('payment.stripePopup.redirecting')) let pollTimer: ReturnType | null = null +let initTimeoutTimer: ReturnType | null = null +let messageHandler: ((event: MessageEvent) => void) | null = null function closeWindow() { window.close() } +function clearInitTimeout() { + if (initTimeoutTimer) { + clearTimeout(initTimeoutTimer) + initTimeoutTimer = null + } +} + onMounted(() => { - const handler = (event: MessageEvent) => { + messageHandler = (event: MessageEvent) => { if (event.origin !== window.location.origin) return if (event.data?.type !== 'STRIPE_POPUP_INIT') return - window.removeEventListener('message', handler) + // INIT 已到达,取消兜底超时,避免长时间的扫码支付被误判为超时。 + clearInitTimeout() + if (messageHandler) { + window.removeEventListener('message', messageHandler) + messageHandler = null + } initStripe(event.data.clientSecret, event.data.publishableKey) } - window.addEventListener('message', handler) + window.addEventListener('message', messageHandler) if (window.opener) { window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin) } - setTimeout(() => { + // 仅兜底“父窗口始终未发 STRIPE_POPUP_INIT”的场景。 + initTimeoutTimer = setTimeout(() => { if (!error.value && !success.value) { error.value = t('payment.stripePopup.timeout') } @@ -108,7 +123,12 @@ onMounted(() => { }) onUnmounted(() => { - if (pollTimer) clearInterval(pollTimer) + if (pollTimer) { clearInterval(pollTimer); pollTimer = null } + clearInitTimeout() + if (messageHandler) { + window.removeEventListener('message', messageHandler) + messageHandler = null + } }) async function initStripe(clientSecret: string, publishableKey: string) { @@ -149,10 +169,15 @@ async function initStripe(clientSecret: string, publishableKey: string) { } function startPolling() { + let inFlight = false pollTimer = setInterval(async () => { + // 防重入:接口响应慢于轮询间隔时避免并发重叠请求。 + if (inFlight) return + inFlight = true try { - const token = document.cookie.split('; ').find(c => c.startsWith('token='))?.split('=')[1] - || localStorage.getItem('token') || '' + // access token 存储在 localStorage 的 'auth_token' 键下(见 api/client.ts), + // 之前误读 'token' 导致轮询请求不带认证、永远 401,支付成功无法被检测到。 + const token = localStorage.getItem('auth_token') || '' const res = await fetch(buildApiUrl(`/payment/orders/${orderId}`), { headers: token ? { Authorization: 'Bearer ' + token } : {}, credentials: 'include', @@ -165,7 +190,9 @@ function startPolling() { success.value = true setTimeout(closeWindow, 2000) } - } catch { /* ignore */ } + } catch { /* ignore */ } finally { + inFlight = false + } }, 3000) }