feat(sora): 新增 Sora 平台支持并修复高危安全和性能问题

新增功能:
- 新增 Sora 账号管理和 OAuth 认证
- 新增 Sora 视频/图片生成 API 网关
- 新增 Sora 任务调度和缓存机制
- 新增 Sora 使用统计和计费支持
- 前端增加 Sora 平台配置界面

安全修复(代码审核):
- [SEC-001] 限制媒体下载响应体大小(图片 20MB、视频 200MB),防止 DoS 攻击
- [SEC-002] 限制 SDK API 响应大小(1MB),防止内存耗尽
- [SEC-003] 修复 SSRF 风险,添加 URL 验证并强制使用代理配置

BUG 修复(代码审核):
- [BUG-001] 修复 for 循环内 defer 累积导致的资源泄漏
- [BUG-002] 修复图片并发槽位获取失败时已持有锁未释放的永久泄漏

性能优化(代码审核):
- [PERF-001] 添加 Sentinel Token 缓存(3 分钟有效期),减少 PoW 计算开销

技术细节:
- 使用 io.LimitReader 限制所有外部输入的大小
- 添加 urlvalidator 验证防止 SSRF 攻击
- 使用 sync.Map 实现线程安全的包级缓存
- 优化并发槽位管理,添加 releaseAll 模式防止泄漏

影响范围:
- 后端:新增 Sora 相关数据模型、服务、网关和管理接口
- 前端:新增 Sora 平台配置、账号管理和监控界面
- 配置:新增 Sora 相关配置项和环境变量

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
yangjianbo
2026-01-29 16:18:38 +08:00
parent bece1b5201
commit 13262a5698
97 changed files with 29541 additions and 68 deletions

View File

@@ -147,6 +147,19 @@
<Icon name="cloud" size="sm" />
Antigravity
</button>
<button
type="button"
@click="form.platform = 'sora'"
:class="[
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'sora'
? 'bg-white text-rose-600 shadow-sm dark:bg-dark-600 dark:text-rose-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
<Icon name="play" size="sm" />
Sora
</button>
</div>
</div>
@@ -672,6 +685,8 @@
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: form.platform === 'sora'
? 'https://sora.chatgpt.com/backend'
: 'https://api.anthropic.com'
"
/>
@@ -689,6 +704,8 @@
? 'sk-proj-...'
: form.platform === 'gemini'
? 'AIza...'
: form.platform === 'sora'
? 'access-token...'
: 'sk-ant-...'
"
/>
@@ -1850,12 +1867,14 @@ const oauthStepTitle = computed(() => {
const baseUrlHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
if (form.platform === 'sora') return t('admin.accounts.sora.baseUrlHint')
return t('admin.accounts.baseUrlHint')
})
const apiKeyHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint')
if (form.platform === 'sora') return t('admin.accounts.sora.apiKeyHint')
return t('admin.accounts.apiKeyHint')
})
@@ -2100,7 +2119,9 @@ watch(
? 'https://api.openai.com'
: newPlatform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: newPlatform === 'sora'
? 'https://sora.chatgpt.com/backend'
: 'https://api.anthropic.com'
// Clear model-related settings
allowedModels.value = []
modelMappings.value = []
@@ -2112,6 +2133,9 @@ watch(
if (newPlatform === 'antigravity') {
accountCategory.value = 'oauth-based'
}
if (newPlatform === 'sora') {
accountCategory.value = 'apikey'
}
// Reset OAuth states
oauth.resetState()
openaiOAuth.resetState()
@@ -2383,12 +2407,17 @@ const handleSubmit = async () => {
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: form.platform === 'sora'
? 'https://sora.chatgpt.com/backend'
: 'https://api.anthropic.com'
// Build credentials with optional model mapping
const credentials: Record<string, unknown> = {
base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl,
api_key: apiKeyValue.value.trim()
const credentials: Record<string, unknown> = {}
if (form.platform === 'sora') {
credentials.access_token = apiKeyValue.value.trim()
} else {
credentials.base_url = apiKeyBaseUrl.value.trim() || defaultBaseUrl
credentials.api_key = apiKeyValue.value.trim()
}
if (form.platform === 'gemini') {
credentials.tier_id = geminiTierAIStudio.value

View File

@@ -39,6 +39,8 @@
? 'https://api.openai.com'
: account.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: account.platform === 'sora'
? 'https://sora.chatgpt.com/backend'
: 'https://api.anthropic.com'
"
/>
@@ -55,6 +57,8 @@
? 'sk-proj-...'
: account.platform === 'gemini'
? 'AIza...'
: account.platform === 'sora'
? 'access-token...'
: 'sk-ant-...'
"
/>
@@ -919,6 +923,7 @@ const baseUrlHint = computed(() => {
if (!props.account) return t('admin.accounts.baseUrlHint')
if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
if (props.account.platform === 'sora') return t('admin.accounts.sora.baseUrlHint')
return t('admin.accounts.baseUrlHint')
})
@@ -997,6 +1002,7 @@ const tempUnschedPresets = computed(() => [
const defaultBaseUrl = computed(() => {
if (props.account?.platform === 'openai') return 'https://api.openai.com'
if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com'
if (props.account?.platform === 'sora') return 'https://sora.chatgpt.com/backend'
return 'https://api.anthropic.com'
})
@@ -1061,7 +1067,9 @@ watch(
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: newAccount.platform === 'sora'
? 'https://sora.chatgpt.com/backend'
: 'https://api.anthropic.com'
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
// Load model mappings and detect mode
@@ -1104,7 +1112,9 @@ watch(
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: newAccount.platform === 'sora'
? 'https://sora.chatgpt.com/backend'
: 'https://api.anthropic.com'
editBaseUrl.value = platformDefaultUrl
modelRestrictionMode.value = 'whitelist'
modelMappings.value = []
@@ -1381,17 +1391,32 @@ const handleSubmit = async () => {
if (props.account.type === 'apikey') {
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
const newBaseUrl = editBaseUrl.value.trim() || defaultBaseUrl.value
const isSora = props.account.platform === 'sora'
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
// Always update credentials for apikey type to handle model mapping changes
const newCredentials: Record<string, unknown> = {
base_url: newBaseUrl
const newCredentials: Record<string, unknown> = {}
if (!isSora) {
newCredentials.base_url = newBaseUrl
}
// Handle API key
if (editApiKey.value.trim()) {
// User provided a new API key
newCredentials.api_key = editApiKey.value.trim()
if (isSora) {
newCredentials.access_token = editApiKey.value.trim()
} else {
newCredentials.api_key = editApiKey.value.trim()
}
} else if (isSora) {
const existingToken = (currentCredentials.access_token || currentCredentials.token) as string | undefined
if (existingToken) {
newCredentials.access_token = existingToken
} else {
appStore.showError(t('admin.accounts.apiKeyIsRequired'))
submitting.value = false
return
}
} else if (currentCredentials.api_key) {
// Preserve existing api_key
newCredentials.api_key = currentCredentials.api_key

View File

@@ -428,7 +428,7 @@ interface Props {
allowMultiple?: boolean
methodLabel?: string
showCookieOption?: boolean // Whether to show cookie auto-auth option
platform?: 'anthropic' | 'openai' | 'gemini' | 'antigravity' // Platform type for different UI/text
platform?: 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora' // Platform type for different UI/text
showProjectId?: boolean // New prop to control project ID visibility
}

View File

@@ -19,7 +19,7 @@ const props = defineProps(['searchQuery', 'filters']); const emit = defineEmits(
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'sora', label: 'Sora' }, { value: 'antigravity', label: 'Antigravity' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }])
</script>

View File

@@ -97,6 +97,9 @@ const labelClass = computed(() => {
if (props.platform === 'gemini') {
return `${base} bg-blue-200/60 text-blue-800 dark:bg-blue-800/40 dark:text-blue-300`
}
if (props.platform === 'sora') {
return `${base} bg-rose-200/60 text-rose-800 dark:bg-rose-800/40 dark:text-rose-300`
}
return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
})
@@ -118,6 +121,11 @@ const badgeClass = computed(() => {
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400'
}
if (props.platform === 'sora') {
return isSubscription.value
? 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
: 'bg-rose-50 text-rose-700 dark:bg-rose-900/20 dark:text-rose-400'
}
// Fallback: original colors
return isSubscription.value
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'

View File

@@ -19,6 +19,10 @@
<svg v-else-if="platform === 'antigravity'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
</svg>
<!-- Sora logo (play icon) -->
<svg v-else-if="platform === 'sora'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.486 2 2 6.486 2 12s4.486 10 10 10 10-4.486 10-10S17.514 2 12 2zm-1 6 6 4-6 4V8z" />
</svg>
<!-- Fallback: generic platform icon -->
<svg v-else :class="sizeClass" fill="currentColor" viewBox="0 0 24 24">
<path

View File

@@ -48,6 +48,7 @@ const platformLabel = computed(() => {
if (props.platform === 'anthropic') return 'Anthropic'
if (props.platform === 'openai') return 'OpenAI'
if (props.platform === 'antigravity') return 'Antigravity'
if (props.platform === 'sora') return 'Sora'
return 'Gemini'
})
@@ -74,6 +75,9 @@ const platformClass = computed(() => {
if (props.platform === 'antigravity') {
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
}
if (props.platform === 'sora') {
return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
}
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
})
@@ -87,6 +91,9 @@ const typeClass = computed(() => {
if (props.platform === 'antigravity') {
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400'
}
if (props.platform === 'sora') {
return 'bg-rose-100 text-rose-600 dark:bg-rose-900/30 dark:text-rose-400'
}
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
})
</script>

View File

@@ -180,6 +180,8 @@ const defaultClientTab = computed(() => {
switch (props.platform) {
case 'openai':
return 'codex'
case 'sora':
return 'codex'
case 'gemini':
return 'gemini'
case 'antigravity':
@@ -266,6 +268,7 @@ const clientTabs = computed((): TabConfig[] => {
if (!props.platform) return []
switch (props.platform) {
case 'openai':
case 'sora':
return [
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
@@ -306,7 +309,7 @@ const showShellTabs = computed(() => activeClientTab.value !== 'opencode')
const currentTabs = computed(() => {
if (!showShellTabs.value) return []
if (props.platform === 'openai') {
if (props.platform === 'openai' || props.platform === 'sora') {
return openaiTabs
}
return shellTabs
@@ -315,6 +318,7 @@ const currentTabs = computed(() => {
const platformDescription = computed(() => {
switch (props.platform) {
case 'openai':
case 'sora':
return t('keys.useKeyModal.openai.description')
case 'gemini':
return t('keys.useKeyModal.gemini.description')
@@ -328,6 +332,7 @@ const platformDescription = computed(() => {
const platformNote = computed(() => {
switch (props.platform) {
case 'openai':
case 'sora':
return activeTab.value === 'windows'
? t('keys.useKeyModal.openai.noteWindows')
: t('keys.useKeyModal.openai.note')
@@ -386,6 +391,7 @@ const currentFiles = computed((): FileConfig[] => {
case 'anthropic':
return [generateOpenCodeConfig('anthropic', apiBase, apiKey)]
case 'openai':
case 'sora':
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
case 'gemini':
return [generateOpenCodeConfig('gemini', geminiBase, apiKey)]
@@ -401,6 +407,7 @@ const currentFiles = computed((): FileConfig[] => {
switch (props.platform) {
case 'openai':
case 'sora':
return generateOpenAIFiles(baseUrl, apiKey)
case 'gemini':
return [generateGeminiCliContent(baseUrl, apiKey)]