mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 16:26:30 +00:00
feat(spark-shadow): OpenAI Spark 链接型影子账号
背景:gpt-5.3-codex-spark 使用独立于 codex 全局(5h/7d)的配额窗口(数据源是 /wham/usage 响应体的 codex_bengalfox,而非 codex 全局用的 x-codex-* 响应头),且 只能挂在已完成 OAuth 授权的 OpenAI 账号下复用其登录态,不能作为独立账号单独接入。 为此新增“链接型影子账号”(spark shadow account):影子账号本身不持有任何凭据, 通过 parent_account_id 指向母账号,凭据/token/代理透传自母账号并共享母账号的刷新 周期,仅在配额维度(quota_dimension=spark)和用量窗口上与母账号完全独立调度、互不 连坐。 实现: - 数据模型:migration 154(+154a)给 accounts 表加 parent_account_id / quota_dimension 列 + 4 条约束(维度合法 / parent⟺非 global 维度一致 / 禁自指 / FK)+ 2 个 CONCURRENTLY 索引(母账号索引 + 每母账号至多一个影子的唯一索引)。 - 创建:POST /api/v1/admin/accounts/:id/shadow(CreateShadow)—— 一母一影(唯一 索引兜底并发竞态),继承母账号 proxy/分组/并发/优先级(显式传参可覆盖),默认 model_mapping 恒等映射到 spark(拒绝非 spark 模型),母账号必须是真实的 OpenAI OAuth 账号(非影子)。 - 凭据透传:resolveCredentialAccount 把影子解析回母账号,GetAccessToken / 请求头 / WS 三条路径统一走此函数;影子自身 Credentials 恒为空(仅允许写 model_mapping), 凭据写入的汇聚点 persistAccountCredentials 对影子早返 no-op,防止误写。 - 调度:parentHealthyForShadow 只看母账号是否仍是 OpenAI OAuth + 凭据/传输是否 可用(active、token 未过期、未处于 401/刷新失败/传输故障导致的临时不可调度冷却), 刻意不看母账号的 global 限流窗口——两条 429 道互不连坐。 - 用量:影子的 codex_5h/7d 走 OpenAIQuotaService.QueryUsage(/wham/usage 的 codex_bengalfox),与母账号走的 WSv2 探测(/responses 头)完全独立的数据源、 刷新节流与 staleness 判定。 - 备份:ExportData 显式排除影子账号(影子不持凭据,通用凭据型导入强制 credentials 非空、无法表达父子链接),按 skipped_shadows 计数提示前端。 - 前端:账号操作菜单新增“创建 Spark 影子”入口,影子行展示回填的母账号信息 (邮箱 / plan / 隐私模式 / 订阅到期 / chatgpt_account_id),批量操作自动跳过 影子账号。 说明:migrations 目录用完整文件名(而非纯数字前缀)标识迁移,故本次新增的 154_account_spark_shadow.sql / 154a_..._notx.sql 与已有的 154_add_ops_system_logs_api_key_id.sql 按序号共存,与目录里 145/151 已有的 先例一致。 测试:新增约 20 个测试文件,覆盖 handler(CreateShadow 校验 / 母账号信息回填)、 repository(影子 round-trip / 一母一影唯一索引 / 迁移 schema)、service(凭据 透传三路径 / 调度母健康门 / 用量窗口来源与刷新节流 / CRS 母账号不变量 / 各类 早返与 fail-closed 场景)及前端组件(账号列表 / 操作菜单 / 用量重置)。 验证(镜像 CI;golangci-lint 首次全量分析耗时过长被跳过,其余全部实测): - gofmt -l:干净 - go build ./... / go vet ./...:通过 - go test ./... -count=1:全绿(全部包 ok,含 internal/service、 internal/repository、migrations) - go test -tags integration ./internal/repository/... ./internal/service/... (真实 Postgres,testcontainers):全绿,含迁移幂等性 (TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate)与影子相关全部用例 - pnpm lint:check / pnpm typecheck / pnpm build(真实 vite 构建)/ pnpm vitest run:全绿(124 文件 760 用例) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
committed by
QTom
co-authored by
Claude Sonnet 5
parent
5f62625ae6
commit
bdf7ead157
@@ -1296,7 +1296,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div v-if="!isSparkShadow">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label>
|
||||
<ProxyAdBanner />
|
||||
@@ -2454,6 +2454,10 @@ const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Spark 影子账号(parent_account_id 非空):代理恒继承母账号,不可独立编辑(外审 B/P1),
|
||||
// 故隐藏代理选择器。
|
||||
const isSparkShadow = computed(() => props.account?.parent_account_id != null)
|
||||
|
||||
// Platform-specific hint for Base URL
|
||||
const baseUrlHint = computed(() => {
|
||||
if (!props.account) return t('admin.accounts.baseUrlHint')
|
||||
@@ -2930,6 +2934,28 @@ const loadModelRestrictionFromMapping = (rawMapping?: Record<string, unknown>) =
|
||||
const buildModelRestrictionMapping = () =>
|
||||
buildModelMappingObject('combined', allowedModels.value, modelMappings.value)
|
||||
|
||||
const applyOpenAIModelMappingCredentials = (credentials: Record<string, unknown>) => {
|
||||
const shouldApplyModelMapping = !openaiPassthroughEnabled.value
|
||||
|
||||
if (shouldApplyModelMapping) {
|
||||
const modelMapping = buildModelRestrictionMapping()
|
||||
if (modelMapping) {
|
||||
credentials.model_mapping = modelMapping
|
||||
} else {
|
||||
delete credentials.model_mapping
|
||||
}
|
||||
} else if (!credentials.model_mapping) {
|
||||
delete credentials.model_mapping
|
||||
}
|
||||
|
||||
const compactModelMapping = buildModelMappingObject('mapping', [], openAICompactModelMappings.value)
|
||||
if (compactModelMapping) {
|
||||
credentials.compact_model_mapping = compactModelMapping
|
||||
} else {
|
||||
delete credentials.compact_model_mapping
|
||||
}
|
||||
}
|
||||
|
||||
const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
if (!newAccount) {
|
||||
return
|
||||
@@ -3922,28 +3948,12 @@ const handleSubmit = async () => {
|
||||
|
||||
// OpenAI OAuth: persist model mapping to credentials
|
||||
if (props.account.platform === 'openai' && props.account.type === 'oauth') {
|
||||
const currentCredentials = (updatePayload.credentials as Record<string, unknown>) ||
|
||||
((props.account.credentials as Record<string, unknown>) || {})
|
||||
const currentCredentials = isSparkShadow.value
|
||||
? {}
|
||||
: (updatePayload.credentials as Record<string, unknown>) ||
|
||||
((props.account.credentials as Record<string, unknown>) || {})
|
||||
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||||
const shouldApplyModelMapping = !openaiPassthroughEnabled.value
|
||||
|
||||
if (shouldApplyModelMapping) {
|
||||
const modelMapping = buildModelRestrictionMapping()
|
||||
if (modelMapping) {
|
||||
newCredentials.model_mapping = modelMapping
|
||||
} else {
|
||||
delete newCredentials.model_mapping
|
||||
}
|
||||
} else if (currentCredentials.model_mapping) {
|
||||
// 透传模式保留现有映射
|
||||
newCredentials.model_mapping = currentCredentials.model_mapping
|
||||
}
|
||||
const compactModelMapping = buildModelMappingObject('mapping', [], openAICompactModelMappings.value)
|
||||
if (compactModelMapping) {
|
||||
newCredentials.compact_model_mapping = compactModelMapping
|
||||
} else {
|
||||
delete newCredentials.compact_model_mapping
|
||||
}
|
||||
applyOpenAIModelMappingCredentials(newCredentials)
|
||||
|
||||
updatePayload.credentials = newCredentials
|
||||
}
|
||||
|
||||
@@ -119,10 +119,15 @@ const data = ref<OpenAIQuotaUsage | null>(null)
|
||||
const resetMessage = ref<string | null>(null)
|
||||
const showResetConfirm = ref(false)
|
||||
|
||||
// 影子账号的额度查询会 resolve 到母账号,但影子本身不支持重置(后端返回 409);
|
||||
// 重置必须在母账号上进行。前端据此禁用影子的重置入口(外审 F6)。
|
||||
const isShadow = computed(() => props.account.parent_account_id != null)
|
||||
|
||||
const availableResetCount = computed(() => data.value?.rate_limit_reset_credits?.available_count ?? 0)
|
||||
const canReset = computed(() => availableResetCount.value > 0)
|
||||
const canReset = computed(() => availableResetCount.value > 0 && !isShadow.value)
|
||||
|
||||
const resetButtonTitle = computed(() => {
|
||||
if (isShadow.value) return t('admin.accounts.openaiQuotaReset.resetTooltipShadow')
|
||||
if (!data.value) return t('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
|
||||
if (!canReset.value) return t('admin.accounts.openaiQuotaReset.resetTooltipNoCredits')
|
||||
return t('admin.accounts.openaiQuotaReset.resetTooltipReady')
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const { updateAccountMock, checkMixedChannelRiskMock } = vi.hoisted(() => ({
|
||||
const { updateAccountMock, checkMixedChannelRiskMock, authIsSimpleMode } = vi.hoisted(() => ({
|
||||
updateAccountMock: vi.fn(),
|
||||
checkMixedChannelRiskMock: vi.fn()
|
||||
checkMixedChannelRiskMock: vi.fn(),
|
||||
authIsSimpleMode: { value: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
@@ -17,7 +18,9 @@ vi.mock('@/stores/app', () => ({
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
isSimpleMode: true
|
||||
get isSimpleMode() {
|
||||
return authIsSimpleMode.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -115,6 +118,28 @@ const SelectStub = defineComponent({
|
||||
`
|
||||
})
|
||||
|
||||
const GroupSelectorStub = defineComponent({
|
||||
name: 'GroupSelector',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div data-testid="group-selector">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="set-shadow-group"
|
||||
@click="$emit('update:modelValue', [7])"
|
||||
>
|
||||
group
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
function buildAccount() {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -141,6 +166,30 @@ function buildAccount() {
|
||||
} as any
|
||||
}
|
||||
|
||||
function buildOpenAISparkShadowAccount() {
|
||||
const account = buildAccount()
|
||||
return {
|
||||
...account,
|
||||
id: 4,
|
||||
name: 'OpenAI Spark Shadow',
|
||||
type: 'oauth',
|
||||
parent_account_id: 1,
|
||||
credentials: {
|
||||
access_token: 'parent-access-token',
|
||||
refresh_token: 'parent-refresh-token',
|
||||
api_key: 'sk-parent',
|
||||
base_url: 'https://api.openai.com',
|
||||
model_mapping: {
|
||||
'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark'
|
||||
},
|
||||
compact_model_mapping: {
|
||||
'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark-compact'
|
||||
}
|
||||
},
|
||||
group_ids: []
|
||||
} as any
|
||||
}
|
||||
|
||||
function buildVertexAccount() {
|
||||
return {
|
||||
id: 2,
|
||||
@@ -206,7 +255,7 @@ function mountModal(account = buildAccount()) {
|
||||
Select: SelectStub,
|
||||
Icon: true,
|
||||
ProxySelector: true,
|
||||
GroupSelector: true,
|
||||
GroupSelector: GroupSelectorStub,
|
||||
ModelWhitelistSelector: ModelWhitelistSelectorStub
|
||||
}
|
||||
}
|
||||
@@ -214,6 +263,10 @@ function mountModal(account = buildAccount()) {
|
||||
}
|
||||
|
||||
describe('EditAccountModal', () => {
|
||||
beforeEach(() => {
|
||||
authIsSimpleMode.value = true
|
||||
})
|
||||
|
||||
it('reopening the same account rehydrates the OpenAI whitelist from props', async () => {
|
||||
const account = buildAccount()
|
||||
updateAccountMock.mockReset()
|
||||
@@ -293,6 +346,32 @@ describe('EditAccountModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('only submits model mapping credentials when saving an OpenAI spark shadow account', async () => {
|
||||
authIsSimpleMode.value = false
|
||||
const account = buildOpenAISparkShadowAccount()
|
||||
updateAccountMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
await wrapper.get('[data-testid="set-shadow-group"]').trigger('click')
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||
const payload = updateAccountMock.mock.calls[0]?.[1]
|
||||
expect(payload?.group_ids).toEqual([7])
|
||||
expect(payload?.credentials).toEqual({
|
||||
model_mapping: {
|
||||
'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark'
|
||||
},
|
||||
compact_model_mapping: {
|
||||
'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark-compact'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('submits OpenAI APIKey Responses support override mode', async () => {
|
||||
const account = buildAccount()
|
||||
account.extra = {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}
|
||||
})
|
||||
|
||||
function makeAccount(overrides: Partial<Account>): Account {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'acc',
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
proxy_id: null,
|
||||
concurrency: 3,
|
||||
priority: 50,
|
||||
status: 'active',
|
||||
error_message: null,
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
auto_pause_on_expired: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
schedulable: true,
|
||||
rate_limited_at: null,
|
||||
rate_limit_reset_at: null,
|
||||
overload_until: null,
|
||||
temp_unschedulable_until: null,
|
||||
temp_unschedulable_reason: null,
|
||||
session_window_start: null,
|
||||
session_window_end: null,
|
||||
session_window_status: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// 第二个按钮(橙色)是 reset 按钮::disabled="resetting||loading||!canReset" :title="resetButtonTitle"
|
||||
const resetButton = (wrapper: ReturnType<typeof mount>) =>
|
||||
wrapper.findAll('button')[1]
|
||||
|
||||
describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
it('影子账号(parent_account_id 非空)的 reset 按钮被禁用且提示在母账号重置', () => {
|
||||
const account = makeAccount({ parent_account_id: 100 })
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
const btn = resetButton(wrapper)
|
||||
expect(btn.attributes('disabled')).toBeDefined()
|
||||
expect(btn.attributes('title')).toBe('admin.accounts.openaiQuotaReset.resetTooltipShadow')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('普通账号(无 parent_account_id)未查询时禁用原因是「需先查询」而非影子提示', () => {
|
||||
const account = makeAccount({ parent_account_id: null })
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
const btn = resetButton(wrapper)
|
||||
// 未加载数据时本就 disabled(无次数),但提示语必须是 needQuery,不得是 shadow 提示。
|
||||
expect(btn.attributes('title')).toBe('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,8 @@
|
||||
<Icon name="clock" size="sm" class="text-orange-500" />
|
||||
{{ t('admin.scheduledTests.schedule') }}
|
||||
</button>
|
||||
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
|
||||
<!-- 影子账号不持凭据:重授权/刷新 token 对其无效(后端拒绝),故隐藏(外审 G4)。 -->
|
||||
<template v-if="(account.type === 'oauth' || account.type === 'setup-token') && !isShadow">
|
||||
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="link" size="sm" />
|
||||
{{ t('admin.accounts.reAuthorize') }}
|
||||
@@ -32,6 +33,10 @@
|
||||
{{ t('admin.accounts.refreshToken') }}
|
||||
</button>
|
||||
</template>
|
||||
<button v-if="isOpenAIOAuthParent" @click="$emit('create-spark-shadow', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="sparkles" size="sm" />
|
||||
{{ t('admin.accounts.createSparkShadow') }}
|
||||
</button>
|
||||
<button v-if="supportsPrivacy" @click="$emit('set-privacy', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-emerald-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="shield" size="sm" />
|
||||
{{ t('admin.accounts.setPrivacy') }}
|
||||
@@ -59,7 +64,7 @@ import { Icon } from '@/components/icons'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
|
||||
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'recover-state', 'reset-quota', 'set-privacy'])
|
||||
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'recover-state', 'reset-quota', 'set-privacy', 'create-spark-shadow'])
|
||||
const { t } = useI18n()
|
||||
const isRateLimited = computed(() => {
|
||||
if (props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date()) {
|
||||
@@ -81,7 +86,11 @@ const hasRecoverableState = computed(() => {
|
||||
})
|
||||
const isAntigravityOAuth = computed(() => props.account?.platform === 'antigravity' && props.account?.type === 'oauth')
|
||||
const isOpenAIOAuth = computed(() => props.account?.platform === 'openai' && props.account?.type === 'oauth')
|
||||
const supportsPrivacy = computed(() => isAntigravityOAuth.value || isOpenAIOAuth.value)
|
||||
// 影子账号(链接型,持 parent_account_id)不持凭据、type 不可变,凭据/隐私类操作对其无效。
|
||||
const isShadow = computed(() => props.account?.parent_account_id != null)
|
||||
// A "parent" OpenAI OAuth account is one that is NOT itself a shadow (parent_account_id == null)
|
||||
const isOpenAIOAuthParent = computed(() => isOpenAIOAuth.value && !isShadow.value)
|
||||
const supportsPrivacy = computed(() => (isAntigravityOAuth.value || isOpenAIOAuth.value) && !isShadow.value)
|
||||
const hasQuotaLimit = computed(() => {
|
||||
return (props.account?.type === 'apikey' || props.account?.type === 'bedrock') && (
|
||||
(props.account?.quota_limit ?? 0) > 0 ||
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AccountActionMenu from '../AccountActionMenu.vue'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
function makeAccount(overrides: Partial<Account>): Account {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'test-account',
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
proxy_id: null,
|
||||
concurrency: 3,
|
||||
priority: 50,
|
||||
status: 'active',
|
||||
error_message: null,
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
auto_pause_on_expired: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
schedulable: true,
|
||||
rate_limited_at: null,
|
||||
rate_limit_reset_at: null,
|
||||
overload_until: null,
|
||||
temp_unschedulable_until: null,
|
||||
temp_unschedulable_reason: null,
|
||||
session_window_start: null,
|
||||
session_window_end: null,
|
||||
session_window_status: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const position = { top: 100, left: 100 }
|
||||
|
||||
// AccountActionMenu uses <Teleport to="body">; content is rendered in document.body, not in wrapper.
|
||||
const getBodyText = () => document.body.textContent ?? ''
|
||||
const getBodyButtons = () => Array.from(document.body.querySelectorAll('button'))
|
||||
|
||||
describe('AccountActionMenu — spark shadow 按钮可见性', () => {
|
||||
it('OpenAI OAuth 母账号(无 parent_account_id)显示「创建 spark 影子」按钮', () => {
|
||||
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: null })
|
||||
const wrapper = mount(AccountActionMenu, {
|
||||
props: { show: true, account, position },
|
||||
attachTo: document.body,
|
||||
})
|
||||
expect(getBodyText()).toContain('admin.accounts.createSparkShadow')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('影子账号(parent_account_id 非 null)隐藏「创建 spark 影子」按钮', () => {
|
||||
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: 42 })
|
||||
const wrapper = mount(AccountActionMenu, {
|
||||
props: { show: true, account, position },
|
||||
attachTo: document.body,
|
||||
})
|
||||
expect(getBodyText()).not.toContain('admin.accounts.createSparkShadow')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('非 OpenAI 账号隐藏「创建 spark 影子」按钮', () => {
|
||||
const account = makeAccount({ platform: 'antigravity', type: 'oauth', parent_account_id: null })
|
||||
const wrapper = mount(AccountActionMenu, {
|
||||
props: { show: true, account, position },
|
||||
attachTo: document.body,
|
||||
})
|
||||
expect(getBodyText()).not.toContain('admin.accounts.createSparkShadow')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('影子账号隐藏凭据/隐私类操作(重授权/刷新token/隐私)— 外审 G4', () => {
|
||||
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: 42 })
|
||||
const wrapper = mount(AccountActionMenu, {
|
||||
props: { show: true, account, position },
|
||||
attachTo: document.body,
|
||||
})
|
||||
const body = getBodyText()
|
||||
expect(body).not.toContain('admin.accounts.reAuthorize')
|
||||
expect(body).not.toContain('admin.accounts.refreshToken')
|
||||
expect(body).not.toContain('admin.accounts.setPrivacy')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('普通 OpenAI OAuth 母账号仍显示凭据/隐私类操作', () => {
|
||||
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: null })
|
||||
const wrapper = mount(AccountActionMenu, {
|
||||
props: { show: true, account, position },
|
||||
attachTo: document.body,
|
||||
})
|
||||
const body = getBodyText()
|
||||
expect(body).toContain('admin.accounts.reAuthorize')
|
||||
expect(body).toContain('admin.accounts.setPrivacy')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('点击按钮触发 create-spark-shadow 事件并携带 account', async () => {
|
||||
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: null })
|
||||
const wrapper = mount(AccountActionMenu, {
|
||||
props: { show: true, account, position },
|
||||
attachTo: document.body,
|
||||
})
|
||||
|
||||
// Content is teleported to body — find button by text there
|
||||
const sparkBtn = getBodyButtons().find(b => b.textContent?.includes('admin.accounts.createSparkShadow'))
|
||||
expect(sparkBtn).toBeDefined()
|
||||
|
||||
sparkBtn!.click()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const emitted = wrapper.emitted('create-spark-shadow')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted![0][0]).toMatchObject({ id: account.id, platform: 'openai' })
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user