fix(gateway,frontend): 修复鉴权绕过与前端支付/会话缺陷

后端:
- Gemini /v1beta 鉴权中间件补齐主中间件的授权校验: API Key 的 IP 白/黑名单、
  专属分组授权、运行时过期/配额二次检查, 修复经 Gemini 端点绕过 IP ACL、
  越权访问专属分组、以及状态未刷新时的配额/有效期绕过窗口。
- 粘性会话等待计划分支改走 newSelectionResult 以 hydrate 账号凭证, 修复调度
  快照中账号凭证被剥离导致等待路径转发鉴权失败。
- SSE 流式转发客户端断开时不再 break 跳过当前事件 usage 合并, 修复少计费。
- Forward 对 nil gin.Context 的防御补齐; 上游错误体读取失败时记录日志避免静默。

前端:
- logout 将本地会话清理移入 finally, 服务端吊销失败也保证本地登出。
- Stripe 弹窗轮询改用正确的 auth_token 键并加防重入; 收到 INIT 后清除兜底
  超时定时器, onUnmounted 清理 message 监听器。
- token 刷新请求补充 30s 超时, 避免挂起导致请求队列与 loading 永久卡死。
- 路由守卫在公共设置未加载时先 await fetchPublicSettings, 避免 payment/
  risk_control 被误判为未启用而错误拦截。
- 支付状态轮询回调补充防重入与终态守卫。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
superman2003
2026-07-09 09:06:56 +08:00
committed by Clawd
co-authored by Cursor
parent 6f43986c37
commit 29a5fcd25e
10 changed files with 185 additions and 54 deletions
+3 -1
View File
@@ -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<{
@@ -275,22 +275,33 @@ async function tryRecoverPendingOrder(order: PaymentOrder): Promise<PaymentOrder
}
}
let pollInFlight = false
async function pollStatus() {
if (!props.orderId || outcome.value) return
let order = await paymentStore.pollOrderStatus(props.orderId)
if (!order) return
order = await tryRecoverPendingOrder(order)
if (isSuccessStatus(order.status)) {
cleanup()
paidOrder.value = order
setOutcome('success')
emit('success')
} else if (order.status === 'CANCELLED') {
cleanup()
setOutcome('cancelled')
} else if (order.status === 'EXPIRED' || order.status === 'FAILED') {
cleanup()
setOutcome('expired')
// 防重入:接口(含 verifyOrder 二次确认)响应慢于 3 秒轮询间隔时避免并发重叠请求。
if (pollInFlight) return
pollInFlight = true
try {
let order = await paymentStore.pollOrderStatus(props.orderId)
if (!order) return
// 已进入终态则不再处理迟到的响应。
if (outcome.value) return
order = await tryRecoverPendingOrder(order)
if (outcome.value) return
if (isSuccessStatus(order.status)) {
cleanup()
paidOrder.value = order
setOutcome('success')
emit('success')
} else if (order.status === 'CANCELLED') {
cleanup()
setOutcome('cancelled')
} else if (order.status === 'EXPIRED' || order.status === 'FAILED') {
cleanup()
setOutcome('expired')
}
} finally {
pollInFlight = false
}
}
+11
View File
@@ -826,6 +826,17 @@ router.beforeEach(async (to, _from, next) => {
}
// 公共设置可能尚未加载(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
+10 -5
View File
@@ -397,11 +397,16 @@ export const useAuthStore = defineStore('auth', () => {
* Clears all authentication state and persisted data
*/
async function logout(): Promise<void> {
// 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()
}
}
/**
+18 -8
View File
@@ -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
}
}
+35 -8
View File
@@ -84,23 +84,38 @@ const success = ref(false)
const hint = ref(t('payment.stripePopup.redirecting'))
let pollTimer: ReturnType<typeof setInterval> | null = null
let initTimeoutTimer: ReturnType<typeof setTimeout> | 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)
}
</script>