feat(security): harden gateway boundaries and usage policies

Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change.

Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
elky
2026-09-04 03:45:52 +08:00
parent ddcbeb3ae9
commit 579f2c7cc1
1019 changed files with 190437 additions and 26080 deletions
+55 -11
View File
@@ -921,6 +921,8 @@ const showDetail = ref(false)
const selectedTask = ref<AsyncTaskDetail | null>(null)
const detailAutoRefresh = ref(false)
let detailRefreshInterval: ReturnType<typeof setInterval> | null = null
const authenticatedVideoUrls = ref<Record<string, string>>({})
let authenticatedVideoLoadGeneration = 0
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
let overviewRefreshInFlight = false
@@ -1015,8 +1017,10 @@ async function refreshOverview() {
// 打开任务详情
async function openTaskDetail(task: AsyncTaskItem) {
try {
selectedTask.value = await asyncTasksApi.getDetail(task.id)
const detail = await asyncTasksApi.getDetail(task.id)
selectedTask.value = detail
showDetail.value = true
await loadAuthenticatedVideo(detail)
} catch (error: unknown) {
toast({
title: '获取任务详情失败',
@@ -1030,7 +1034,9 @@ async function openTaskDetail(task: AsyncTaskItem) {
async function refreshTaskDetail() {
if (!selectedTask.value) return
try {
selectedTask.value = await asyncTasksApi.getDetail(selectedTask.value.id)
const detail = await asyncTasksApi.getDetail(selectedTask.value.id)
selectedTask.value = detail
await loadAuthenticatedVideo(detail)
} catch (error: unknown) {
toast({
title: '刷新失败',
@@ -1079,6 +1085,7 @@ function stopDetailAutoRefresh() {
// 关闭详情抽屉
function closeDetail() {
stopDetailAutoRefresh()
clearAuthenticatedVideoUrls()
showDetail.value = false
selectedTask.value = null
}
@@ -1215,16 +1222,52 @@ function formatFileSize(bytes: number | null): string {
return `${size.toFixed(unitIndex > 0 ? 2 : 0)} ${units[unitIndex]}`
}
// 获取视频 URL(需要认证的 Google URL 使用代理)
function getVideoUrl(taskId: string, originalUrl: string): string {
// Google API 链接需要代理
if (originalUrl.includes('generativelanguage.googleapis.com')) {
// 从 localStorage 获取 token 作为 query param
const token = localStorage.getItem('access_token')
if (token) {
return `/api/admin/video-tasks/${taskId}/video?token=${encodeURIComponent(token)}`
function authenticatedVideoKey(taskId: string, originalUrl: string): string {
return `${taskId}\n${originalUrl}`
}
function requiresAuthenticatedVideoProxy(originalUrl: string): boolean {
try {
return new URL(originalUrl).hostname === 'generativelanguage.googleapis.com'
} catch {
return false
}
}
function clearAuthenticatedVideoUrls() {
authenticatedVideoLoadGeneration += 1
for (const objectUrl of Object.values(authenticatedVideoUrls.value)) {
URL.revokeObjectURL(objectUrl)
}
authenticatedVideoUrls.value = {}
}
async function loadAuthenticatedVideo(task: AsyncTaskDetail) {
const candidates = [task.video_url, ...(task.video_urls || [])]
.filter((value): value is string => Boolean(value && requiresAuthenticatedVideoProxy(value)))
if (candidates.length === 0) return
const generation = ++authenticatedVideoLoadGeneration
try {
const blob = await asyncTasksApi.getVideoBlob(task.id)
if (generation !== authenticatedVideoLoadGeneration || selectedTask.value?.id !== task.id) {
return
}
return `/api/admin/video-tasks/${taskId}/video`
const objectUrl = URL.createObjectURL(blob)
const next = { ...authenticatedVideoUrls.value }
for (const originalUrl of candidates) {
next[authenticatedVideoKey(task.id, originalUrl)] = objectUrl
}
authenticatedVideoUrls.value = next
} catch (error) {
log.error('Failed to load authenticated video preview', error)
}
}
// 获取视频 URL(需要认证的 Google URL 使用 Bearer 请求后的临时 Blob URL)
function getVideoUrl(taskId: string, originalUrl: string): string {
if (requiresAuthenticatedVideoProxy(originalUrl)) {
return authenticatedVideoUrls.value[authenticatedVideoKey(taskId, originalUrl)] || ''
}
return originalUrl
}
@@ -1339,6 +1382,7 @@ onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
stopAutoRefresh()
stopDetailAutoRefresh()
clearAuthenticatedVideoUrls()
clearTimeout(filterTimeout)
})
</script>
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -379,7 +379,8 @@ async function handleEdit(node: ProxyNode) {
name: detail.name,
proxy_url: detail.proxy_url || '',
username: detail.proxy_username || '',
password: detail.proxy_password || '',
// 密码是 write-only;留空时更新接口会保留已存密码。
password: '',
region: detail.region || '',
}
addMode.value = 'manual'
@@ -389,7 +389,6 @@ async function loadRewards() {
offset: 0
})
rewards.value = response.items
stats.value = response.stats
}
async function loadAll() {
+30 -3
View File
@@ -247,6 +247,11 @@ import {
} from '@/features/users/components/user-management-config'
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
import { parseApiError } from '@/utils/errorParser'
import {
entitlementsWillReplaceExisting,
isPlanEntitlementReplacementCandidate,
usagePolicyEntitlementLabels,
} from '@/utils/billingEntitlements'
import { formatTokens, formatRateLimitInheritable, formatRateLimitSimple, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
import { log } from '@/utils/logger'
import { useBatchSelection } from '@/composables/useBatchSelection'
@@ -558,7 +563,7 @@ function formatPlanDuration(plan: BillingPlan): string {
}
function entitlementLabels(items: BillingEntitlement[] | undefined): string[] {
return (items || []).map((item) => {
return (items || []).flatMap((item) => {
if (item.type === 'wallet_credit') {
return `${legacyT('附赠余额')} $${Number(item.amount_usd || 0).toFixed(2)}`
}
@@ -568,12 +573,19 @@ function entitlementLabels(items: BillingEntitlement[] | undefined): string[] {
if (item.type === 'membership_group') {
return legacyT('会员权益')
}
return item.type
if (item.type === 'usage_policy') {
return usagePolicyEntitlementLabels(item)
}
return []
})
}
function hasPackageEntitlement(items: BillingEntitlement[] | undefined): boolean {
return (items || []).some((item) => item.type === 'daily_quota' || item.type === 'membership_group')
return (items || []).some((item) =>
item.type === 'daily_quota'
|| item.type === 'membership_group'
|| item.type === 'usage_policy'
)
}
async function loadUserWallets(options: { cacheTtlMs?: number } = {}) {
@@ -869,6 +881,21 @@ async function loadAvailableBillingPlans() {
async function grantPlanToSelectedUser() {
if (!selectedUser.value || !selectedGrantPlanId.value) return
const selectedPlan = availableBillingPlans.value.find(
plan => plan.id === selectedGrantPlanId.value,
)
const replacesExisting = selectedPlan && userPlanEntitlements.value.some(item =>
isPlanEntitlementReplacementCandidate(item)
&& entitlementsWillReplaceExisting(selectedPlan.entitlements, item.entitlements)
)
if (replacesExisting) {
const confirmed = await confirmDanger(
legacyT('发放成功后,冲突的旧套餐及其组合权益会整包失效。确定继续发放吗?'),
legacyT('确认替换旧套餐'),
legacyT('继续发放'),
)
if (!confirmed) return
}
grantingUserPlan.value = true
try {
const response = await usersApi.grantUserPlan(selectedUser.value.id, {
@@ -1215,7 +1215,7 @@
</div>
<div
v-if="canFailRefund(currentRefund.status)"
v-if="canFailRefund(currentRefund)"
class="rounded-xl border border-border/60 p-4 space-y-2"
>
<Label>驳回原因</Label>
@@ -1264,7 +1264,7 @@
{{ submittingRefundAction ? '提交中...' : '确认完成' }}
</Button>
<Button
v-if="canFailRefund(currentRefund.status)"
v-if="canFailRefund(currentRefund)"
variant="destructive"
:disabled="submittingRefundAction"
@click="submitFailRefund"
@@ -2121,8 +2121,12 @@ function canProcessRefund(status: string) {
return status === 'pending_approval' || status === 'approved'
}
function canFailRefund(status: string) {
return status === 'processing' || status === 'pending_approval' || status === 'approved'
function canFailRefund(refund: Pick<AdminGlobalRefund, 'status' | 'refund_mode' | 'gateway_refund_id' | 'payout_proof'>) {
if (refund.status === 'pending_approval' || refund.status === 'approved') return true
return refund.status === 'processing'
&& refund.refund_mode === 'offline_payout'
&& !refund.gateway_refund_id?.trim()
&& !refund.payout_proof
}
function canCompleteRefund(status: string) {
@@ -87,7 +87,7 @@
</div>
<p class="text-xs text-muted-foreground">
注意:完整备份会先导入配置数据,再导入用户数据;文件包含用户、用户组、API Keys、Key 用量、钱包快照与统计聚合。正常导出的 API Keys 会在导入时使用目标系统密钥重新加密;仅当备份中包含 key_encrypted 等未解密密文字段时,才需要目标系统使用兼容的 ENCRYPTION_KEY。
注意:完整备份会先导入配置数据,再导入用户数据。用户密码及用户和独立余额 API Key 凭据不会导出或恢复;仅保留账户、Key 元数据、用量、钱包快照与统计聚合。导入后的这些 Keys 默认禁用,需重置用户密码并重新签发 Keys 后再使用。
</p>
<div
@@ -304,6 +304,7 @@
:model-value="referralRechargePercent"
type="number"
min="0"
max="100"
step="0.01"
class="mt-1"
@update:model-value="$emit('update:referralRechargePercent', Number($event))"
@@ -66,7 +66,7 @@
已存在的配置将被导入的配置覆盖
</template>
<template v-else>
如果发现任何冲突,导入将中止并回滚
如果发现任何冲突,导入将在写入前预检并中止
</template>
</p>
</div>
@@ -59,13 +59,13 @@
已存在的用户将被导入的数据覆盖
</template>
<template v-else>
如果发现任何冲突,导入将中止并回滚
如果发现任何冲突,导入将在写入前预检并中止
</template>
</p>
</div>
<p class="text-xs text-muted-foreground">
注意:用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY 环境变量才能正常工作。
注意:备份不会导出或恢复用户密码及 API Key 凭据。导入仅恢复账户、Key 元数据、用量与钱包数据;导入后的用户和独立余额 API Keys 默认禁用,需重置用户密码并重新签发 Keys 后再使用。
</p>
<div
@@ -76,7 +76,7 @@ describe('useSystemConfig', () => {
const state = useSystemConfig()
await state.loadSystemConfig()
expect(state.systemConfig.value.request_record_level).toBe('full')
expect(state.systemConfig.value.request_record_level).toBe('basic')
expect(state.systemConfig.value).not.toHaveProperty('max_request_body_size')
expect(state.systemConfig.value).not.toHaveProperty('max_response_body_size')
})
@@ -136,7 +136,7 @@ function createDefaultConfig(): SystemConfig {
// 格式转换
enable_format_conversion: false,
// 请求记录
request_record_level: 'full',
request_record_level: 'basic',
sensitive_headers: ['authorization', 'x-api-key', 'api-key', 'cookie', 'set-cookie'],
// 请求记录清理
enable_auto_cleanup: true,
+6 -15
View File
@@ -15,10 +15,10 @@
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Card from '@/components/ui/card.vue'
import apiClient from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import { useI18n } from '@/i18n'
import { safeInternalNavigationPath } from '@/utils/navigationSecurity'
const route = useRoute()
const router = useRouter()
@@ -32,9 +32,8 @@ function consumeRedirectPath(): string | null {
const redirectPath = sessionStorage.getItem('redirectPath')
if (redirectPath) {
sessionStorage.removeItem('redirectPath')
return redirectPath
}
return null
return safeInternalNavigationPath(redirectPath)
}
function clearUrlState() {
@@ -87,24 +86,16 @@ onMounted(async () => {
return
}
// 3) 登录成功:解析 fragment token
const hash = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : window.location.hash
const params = new URLSearchParams(hash)
const accessToken = params.get('access_token')
// 3) Login success: recover through the HttpOnly refresh cookie. Any
// legacy fragment is removed without reading or retaining its token.
clearUrlState()
if (!accessToken) {
hint.value = t('site.auth.writing')
if (!(await authStore.restoreSession(false, true))) {
showError(t('site.auth.noToken'))
await router.replace('/')
return
}
hint.value = t('site.auth.writing')
apiClient.setToken(accessToken)
authStore.syncToken()
hint.value = t('site.auth.fetchingUser')
await authStore.fetchCurrentUser()
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import AuthCallback from '../AuthCallback.vue'
const routerReplaceMock = vi.hoisted(() => vi.fn())
const authStoreMock = vi.hoisted(() => ({
canAccessAdmin: false,
restoreSession: vi.fn(),
fetchCurrentUser: vi.fn(),
}))
const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return {
...actual,
useRoute: () => ({ query: {} }),
useRouter: () => ({ replace: routerReplaceMock }),
}
})
vi.mock('@/stores/auth', () => ({ useAuthStore: () => authStoreMock }))
vi.mock('@/composables/useToast', () => ({ useToast: () => toastMocks }))
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
legacyT: (value: string) => value,
}),
setI18nLocale: vi.fn(),
}))
vi.mock('@/components/ui/card.vue', () => ({
default: defineComponent({
name: 'CardStub',
setup(_props, { attrs, slots }) {
return () => h('div', attrs, slots.default?.())
},
}),
}))
let app: App | null = null
async function settle() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve()
await nextTick()
}
}
describe('AuthCallback', () => {
beforeEach(() => {
routerReplaceMock.mockReset()
authStoreMock.restoreSession.mockReset().mockResolvedValue(true)
authStoreMock.fetchCurrentUser.mockReset().mockResolvedValue({ id: 'user-1' })
toastMocks.success.mockReset()
toastMocks.error.mockReset()
sessionStorage.clear()
window.history.replaceState({}, '', '/auth/callback#access_token=legacy-url-token')
})
afterEach(() => {
app?.unmount()
app = null
document.body.innerHTML = ''
})
it('discards a legacy fragment and restores only through the HttpOnly cookie', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
app = createApp(AuthCallback)
app.mount(root)
await settle()
expect(window.location.hash).toBe('')
expect(authStoreMock.restoreSession).toHaveBeenCalledWith(false, true)
expect(authStoreMock.fetchCurrentUser).toHaveBeenCalledTimes(1)
expect(routerReplaceMock).toHaveBeenCalledWith('/dashboard')
})
})
@@ -12,8 +12,8 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { marked, type Renderer } from 'marked'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js'
import { sanitizeMarkdown } from '@/utils/sanitize'
import 'highlight.js/styles/github-dark.css'
const props = defineProps<{
@@ -41,7 +41,7 @@ const renderMarkdown = () => {
try {
const rawHtml = marked.parse(props.content) as string
renderedHtml.value = DOMPurify.sanitize(rawHtml)
renderedHtml.value = sanitizeMarkdown(rawHtml)
} catch {
renderedHtml.value = '<p class="text-red-500">Failed to render content</p>'
}
+50 -25
View File
@@ -2,7 +2,7 @@
<PageContainer>
<PageHeader
title="套餐中心"
description="购买每日额度或会员权益"
description="购买额度、会员或使用限制套餐"
/>
<div class="mt-6 space-y-6">
@@ -42,8 +42,8 @@
</div>
<div class="mt-3 flex flex-wrap gap-1.5">
<Badge
v-for="label in entitlementLabels(item.entitlements)"
:key="label"
v-for="(label, index) in entitlementLabels(item.entitlements)"
:key="`${label}-${index}`"
variant="outline"
>
{{ label }}
@@ -93,8 +93,8 @@
<div class="mt-5 flex flex-wrap gap-1.5">
<Badge
v-for="label in entitlementLabels(plan.entitlements)"
:key="label"
v-for="(label, index) in entitlementLabels(plan.entitlements)"
:key="`${label}-${index}`"
variant="outline"
>
{{ label }}
@@ -217,7 +217,14 @@ import { CardSection, PageContainer, PageHeader } from '@/components/layout'
import { useToast } from '@/composables/useToast'
import { useI18n } from '@/i18n'
import { parseApiError } from '@/utils/errorParser'
import {
entitlementReplacementGroups,
entitlementsWillReplaceExisting,
isPlanEntitlementReplacementCandidate,
usagePolicyEntitlementLabels,
} from '@/utils/billingEntitlements'
import { log } from '@/utils/logger'
import { safePaymentTargetUrl } from '@/utils/paymentUrl'
import {
getPaymentInstructionString,
getStripePaymentInstructions,
@@ -257,6 +264,12 @@ const selectedCheckoutOption = computed(() => {
|| checkoutOptions.value[0]
})
const replacementCandidateEntitlements = computed(() =>
entitlements.value.filter((item) =>
isPlanEntitlementReplacementCandidate(item)
)
)
const activeEntitlements = computed(() =>
entitlements.value.filter((item) =>
item.active !== false
@@ -332,7 +345,7 @@ async function loadRechargeOptions() {
async function checkoutPlan(plan: BillingPlan) {
if (hasMatchingActivePlan(plan)) {
const confirmed = window.confirm(legacyT('购买成功后,同类旧套餐会自动失效。确定继续购买吗?'))
const confirmed = window.confirm(legacyT('购买成功后,冲突的旧套餐及其组合权益会整体失效。确定继续购买吗?'))
if (!confirmed) return
}
const option = selectedCheckoutOption.value
@@ -381,20 +394,30 @@ function submitPaymentInstructions(instructions: Record<string, unknown> | null
}
const paymentUrl = getPaymentInstructionString(instructions, 'payment_url')
if (!paymentUrl) return
const paymentParams = instructions.payment_params
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
submitPaymentForm(paymentUrl, paymentParams as Record<string, unknown>)
const safePaymentUrl = safePaymentTargetUrl(paymentUrl)
if (!safePaymentUrl) {
showError('支付网关返回了不安全的支付地址')
return
}
const opened = window.open(paymentUrl, '_blank', 'noopener,noreferrer')
const paymentParams = instructions.payment_params
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
submitPaymentForm(safePaymentUrl, paymentParams as Record<string, unknown>)
return
}
const opened = window.open(safePaymentUrl, '_blank', 'noopener,noreferrer')
if (!opened) {
window.location.href = paymentUrl
window.location.href = safePaymentUrl
}
}
function submitPaymentForm(url: string, params: Record<string, unknown>) {
const safeUrl = safePaymentTargetUrl(url)
if (!safeUrl) {
showError('支付网关返回了不安全的支付地址')
return
}
const form = document.createElement('form')
form.action = url
form.action = safeUrl
form.method = 'POST'
if (!isSafariBrowser()) {
form.target = '_blank'
@@ -430,12 +453,8 @@ function planTitle(planId: string): string {
}
function hasMatchingActivePlan(plan: BillingPlan): boolean {
const replacesDailyQuota = hasDailyQuotaEntitlement(plan.entitlements)
const replacesMembership = hasMembershipEntitlement(plan.entitlements)
if (!replacesDailyQuota && !replacesMembership) return false
return activeEntitlements.value.some((item) =>
(replacesDailyQuota && hasDailyQuotaEntitlement(item.entitlements))
|| (replacesMembership && hasMembershipEntitlement(item.entitlements))
return replacementCandidateEntitlements.value.some((item) =>
entitlementsWillReplaceExisting(plan.entitlements, item.entitlements)
)
}
@@ -443,13 +462,13 @@ function replacementNotice(plan: BillingPlan): string {
const labels = replacementClassLabels(plan.entitlements)
if (labels.length === 0) return ''
if (hasMatchingActivePlan(plan)) {
return `你已有有效${labels.join('和')},购买成功后旧同类套餐会自动失效。`
return '你已有与本套餐冲突的有效套餐,购买成功后旧套餐会整包失效。'
}
return `若已有有效${labels.join('和')},购买成功后旧同类套餐会自动失效。`
return `若已有冲突的有效${labels.join('和')},购买成功后旧套餐会整体失效。`
}
function entitlementLabels(items: BillingEntitlement[]): string[] {
return (items || []).map((item) => {
return (items || []).flatMap((item) => {
if (item.type === 'wallet_credit') {
return `附赠余额 $${Number(item.amount_usd || 0).toFixed(2)}`
}
@@ -459,13 +478,18 @@ function entitlementLabels(items: BillingEntitlement[]): string[] {
if (item.type === 'membership_group') {
return `会员组 ${item.grant_user_groups.join(', ')}`
}
return item.type
if (item.type === 'usage_policy') {
return usagePolicyEntitlementLabels(item)
}
return []
})
}
function hasPackageEntitlement(items: BillingEntitlement[] | undefined): boolean {
return (items || []).some((item) =>
item.type === 'daily_quota' || item.type === 'membership_group'
item.type === 'daily_quota'
|| item.type === 'membership_group'
|| item.type === 'usage_policy'
)
}
@@ -481,6 +505,7 @@ function replacementClassLabels(items: BillingEntitlement[] | undefined): string
const labels: string[] = []
if (hasDailyQuotaEntitlement(items)) labels.push('每日额度套餐')
if (hasMembershipEntitlement(items)) labels.push('会员权益包')
labels.push(...entitlementReplacementGroups(items).map(group => `互斥组「${group}」`))
return labels
}
@@ -489,9 +514,9 @@ function formatDuration(unit: BillingDurationUnit, value: number): string {
day: '天',
month: '个月',
year: '年',
custom: '自定义周期',
custom: '天',
}
return unit === 'custom' ? `${value} ${labels[unit]}` : `${value}${labels[unit]}`
return `${value}${labels[unit]}`
}
function formatDate(value: string | null | undefined): string {
+16 -15
View File
@@ -661,7 +661,6 @@ import { meApi, type Profile } from '@/api/me'
import { type UserSession, formatSessionMeta } from '@/types/session'
import { authApi } from '@/api/auth'
import { oauthApi, type OAuthLinkInfo, type OAuthProviderInfo } from '@/api/oauth'
import { getClientDeviceId } from '@/utils/deviceId'
import { getOAuthIcon } from '@/utils/oauth-icons'
import { useDarkMode, type ThemeMode } from '@/composables/useDarkMode'
import {
@@ -685,8 +684,8 @@ import SelectItem from '@/components/ui/select-item.vue'
import Switch from '@/components/ui/switch.vue'
import { useToast } from '@/composables/useToast'
import { formatCurrency } from '@/utils/format'
import { getApiUrl } from '@/utils/url'
import { log } from '@/utils/logger'
import { safeExternalHttpsUrl } from '@/utils/navigationSecurity'
import { getErrorMessage, getErrorStatus } from '@/types/api-error'
import {
mergeChatPiiRedactionFeatureSettings,
@@ -934,20 +933,22 @@ function handleBind(providerType: string) {
// 保存返回路径(OAuth callback 会读取)
sessionStorage.setItem('redirectPath', route.fullPath)
// 先获取一次性绑定令牌,再在新标签页打开(避免在 URL 中暴露 access_token)
// 后端以当前认证会话创建一次性 OAuth state;URL 中不携带任何绑定凭据。
oauthActionLoading.value = true
oauthApi.createBindToken(providerType)
.then((bindToken) => {
// getApiUrl 可能返回相对路径,需要拼接完整 URL
const basePath = getApiUrl(`/api/user/oauth/${providerType}/bind`)
const bindUrl = basePath.startsWith('http')
? new URL(basePath)
: new URL(basePath, window.location.origin)
bindUrl.searchParams.set('bind_token', bindToken)
bindUrl.searchParams.set('client_device_id', getClientDeviceId())
oauthApi.createBindAuthorization(providerType)
.then((authorizeUrl) => {
const bindUrl = safeExternalHttpsUrl(authorizeUrl)
if (!bindUrl) {
throw new Error('OAuth 服务返回了不安全的授权地址')
}
// 新标签页打开 OAuth 流程
const newTab = window.open(bindUrl.toString(), '_blank')
// Keep a handle for close detection, but sever opener before the tab reaches
// the external OAuth provider so it cannot navigate the authenticated page.
const newTab = window.open('', '_blank')
if (newTab) {
newTab.opener = null
newTab.location.replace(bindUrl)
}
// 监听标签页关闭,刷新绑定状态
if (newTab) {
@@ -963,7 +964,7 @@ function handleBind(providerType: string) {
} else {
// 被浏览器阻止,回退到当前页面跳转
oauthActionLoading.value = false
window.location.href = bindUrl.toString()
window.location.href = bindUrl
}
})
.catch((err) => {
+50 -10
View File
@@ -722,6 +722,7 @@ import {
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
import { safePaymentTargetUrl } from '@/utils/paymentUrl'
import {
getPaymentInstructionString,
getStripePaymentInstructions,
@@ -789,7 +790,9 @@ let todayCostPollTimer: ReturnType<typeof setInterval> | null = null
const rechargeForm = reactive({
amount_usd: 10,
payment_option_key: '',
idempotency_key: '',
})
let rechargeIdempotencyFingerprint = ''
const refundForm = reactive({
amount_usd: 0,
@@ -859,9 +862,13 @@ const estimatedRechargeFeeAmount = computed(() =>
const estimatedRechargeFeeRate = computed(() =>
rechargePaymentBreakdown.value?.feeRate || 0
)
const latestRechargePaymentUrl = computed(() =>
getPaymentInstructionString(latestRecharge.value?.payment_instructions, 'payment_url')
)
const latestRechargePaymentUrl = computed(() => {
const paymentUrl = getPaymentInstructionString(
latestRecharge.value?.payment_instructions,
'payment_url',
)
return paymentUrl ? safePaymentTargetUrl(paymentUrl) : null
})
const latestRechargeStripeInstructions = computed(() =>
getStripePaymentInstructions(latestRecharge.value?.payment_instructions)
)
@@ -1124,16 +1131,40 @@ async function submitRecharge() {
submittingRecharge.value = true
try {
// Keep a failed request retryable only while its business parameters stay
// identical. Changing the amount or channel must get a fresh key.
const requestFingerprint = JSON.stringify([
Number(rechargeForm.amount_usd),
option.payment_method || '',
option.payment_provider || '',
option.payment_channel || '',
])
if (
rechargeForm.idempotency_key
&& rechargeIdempotencyFingerprint
&& rechargeIdempotencyFingerprint !== requestFingerprint
) {
rechargeForm.idempotency_key = ''
}
if (!rechargeForm.idempotency_key) {
rechargeForm.idempotency_key = typeof globalThis.crypto?.randomUUID === 'function'
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
}
rechargeIdempotencyFingerprint = requestFingerprint
latestRecharge.value = await walletApi.createRechargeOrder({
amount_usd: rechargeForm.amount_usd,
payment_method: option.payment_method,
payment_provider: option.payment_provider,
payment_channel: option.payment_channel,
idempotency_key: rechargeForm.idempotency_key,
})
success('充值订单创建成功')
await Promise.all([loadOrders(), loadBalance()])
activeTab.value = 'orders'
submitPaymentInstructions(latestRecharge.value.payment_instructions)
rechargeForm.idempotency_key = ''
rechargeIdempotencyFingerprint = ''
} catch (error) {
log.error('创建充值订单失败:', error)
showError(parseApiError(error, '创建充值订单失败'))
@@ -1152,20 +1183,30 @@ function submitPaymentInstructions(instructions: Record<string, unknown> | null
}
const paymentUrl = getPaymentInstructionString(instructions, 'payment_url')
if (!paymentUrl) return
const paymentParams = instructions.payment_params
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
submitPaymentForm(paymentUrl, paymentParams as Record<string, unknown>)
const safePaymentUrl = safePaymentTargetUrl(paymentUrl)
if (!safePaymentUrl) {
showError('支付网关返回了不安全的支付地址')
return
}
const opened = window.open(paymentUrl, '_blank', 'noopener,noreferrer')
const paymentParams = instructions.payment_params
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
submitPaymentForm(safePaymentUrl, paymentParams as Record<string, unknown>)
return
}
const opened = window.open(safePaymentUrl, '_blank', 'noopener,noreferrer')
if (!opened) {
window.location.href = paymentUrl
window.location.href = safePaymentUrl
}
}
function submitPaymentForm(url: string, params: Record<string, unknown>) {
const safeUrl = safePaymentTargetUrl(url)
if (!safeUrl) {
showError('支付网关返回了不安全的支付地址')
return
}
const form = document.createElement('form')
form.action = url
form.action = safeUrl
form.method = 'POST'
if (!isSafariBrowser()) {
form.target = '_blank'
@@ -1219,7 +1260,6 @@ async function submitRefund() {
await walletApi.createRefund({
amount_usd: refundForm.amount_usd,
payment_order_id: selectedOrder.id,
refund_mode: refundForm.refund_mode || undefined,
reason: refundForm.reason || undefined,
idempotency_key: `web_refund_${buildRefundIdempotencyKey()}`,
})