feat(account): 账号编辑弹窗支持手动覆盖 OpenAI 订阅档位 plan_type(仅 OAuth)

此前 plan_type(ChatGPT Plus/Pro/Free)只由系统自动写入(OAuth 换码/刷新、
429 同步、导入),UI 无编辑入口。现于 EditAccountModal 为 OpenAI OAuth 非影子
账号新增一个订阅档位下拉,可手动纠正(走既有 PUT /admin/accounts/:id 合并,
plan_type 非敏感字段无需改后端)。

- 仅 OAuth 非影子账号显示/提交:setup-token 无订阅调度语义
  (IsOpenAIChatGPTSubscription 要求 oauth),影子账号凭据由母账号管理
- 下拉:清空 + Plus/Pro/Free;别名(chatgptpro→Pro)与自定义值(team 等)
  友好显示并保留 canonical 值,避免编辑丢失或写脏
- readPlanType 仅接受字符串,挡住脏数据;清空则删除该键(恢复自动识别)
- 抽 buildPlanTypeOptions/applyPlanType/readPlanType/planTypeDisplayLabel 为
  纯函数入 credentialsBuilder.ts,并补 12 个单测(别名去重、保留其余凭据键、
  set/delete 语义)
- 文案(zh/en)注明:令牌临期刷新或 429 会用真实档位自动覆盖手改值

经三方评审会(Claude + Codex + 作者)一轮交叉评审达成共识后落地。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
iMouseWu
2026-07-11 18:31:45 +08:00
co-authored by Claude Opus 4.8
parent e316ebf528
commit c56a64fabd
5 changed files with 220 additions and 0 deletions
@@ -1838,6 +1838,24 @@
</div>
</div>
<!-- OpenAI 订阅档位手动覆盖(Plus/Pro/Free),仅 OAuth 非影子账号 -->
<div
v-if="account?.platform === 'openai' && account?.type === 'oauth' && !isSparkShadow"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<label class="input-label mb-0">{{ t('admin.accounts.openai.planType') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.planTypeDesc') }}
</p>
</div>
<div class="w-44 flex-shrink-0">
<Select v-model="editPlanType" :options="planTypeOptions" />
</div>
</div>
</div>
<div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
@@ -2539,6 +2557,9 @@ import {
applyAntigravityProjectID,
applyHeaderOverride,
applyInterceptWarmup,
applyPlanType,
buildPlanTypeOptions,
readPlanType,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
splitHeaderOverridesObject,
@@ -2757,6 +2778,8 @@ const customBaseUrl = ref('')
// OpenAI 自动透传开关(OAuth/API Key)
const openaiPassthroughEnabled = ref(false)
// OpenAI 订阅档位(Plus/Pro/Free)手动覆盖值,存于 credentials.plan_type;'' 表示清空/自动识别
const editPlanType = ref<string>('')
const openAICompactMode = ref<OpenAICompactMode>('auto')
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
@@ -2884,6 +2907,10 @@ const openAICompactModeOptions = computed(() => [
{ value: 'force_on', label: t('admin.accounts.openai.compactModeForceOn') },
{ value: 'force_off', label: t('admin.accounts.openai.compactModeForceOff') }
])
// OpenAI 订阅档位手动覆盖选项(清空 + Plus/Pro/Free;别名/自定义值友好显示且保留 canonical)
const planTypeOptions = computed(() =>
buildPlanTypeOptions(editPlanType.value, t('admin.accounts.openai.planTypeClear'))
)
const openAIResponsesModeOptions = computed(() => [
{ value: 'auto', label: t('admin.accounts.openai.responsesModeAuto') },
{ value: 'force_responses', label: t('admin.accounts.openai.responsesModeForceResponses') },
@@ -3183,6 +3210,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
// Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key)
openaiPassthroughEnabled.value = false
editPlanType.value = ''
openAICompactMode.value = 'auto'
openAIResponsesMode.value = 'auto'
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
@@ -3197,6 +3225,10 @@ const syncFormFromAccount = (newAccount: Account | null) => {
webSearchEmulationMode.value = 'default'
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'setup-token' || newAccount.type === 'apikey')) {
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
// plan_type 手动覆盖仅 OAuth 有实际调度语义(IsOpenAIChatGPTSubscription 要求 oauth),故只对 oauth 回填
editPlanType.value = newAccount.type === 'oauth'
? readPlanType(newAccount.credentials as Record<string, unknown> | undefined)
: ''
openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto'
if (newAccount.type === 'apikey') {
openAIResponsesMode.value = normalizeOpenAIResponsesMode(extra?.openai_responses_mode)
@@ -4175,6 +4207,14 @@ const handleSubmit = async () => {
updatePayload.credentials = newCredentials
}
// OpenAI: 手动覆盖订阅档位 plan_type(Plus/Pro/Free)。仅 OAuth 非影子账号:
// 影子账号凭据由母账号管理(且后端会 sanitize),setup-token 无订阅调度语义。
if (props.account.platform === 'openai' && props.account.type === 'oauth' && !isSparkShadow.value) {
const currentCredentials = (updatePayload.credentials as Record<string, unknown>) ||
((props.account.credentials as Record<string, unknown>) || {})
updatePayload.credentials = applyPlanType({ ...currentCredentials }, editPlanType.value)
}
// Antigravity: persist model mapping to credentials (applies to all antigravity types)
// Antigravity 只支持映射模式
if (props.account.platform === 'antigravity') {
@@ -6,9 +6,13 @@ import {
applyAntigravityProjectID,
applyHeaderOverride,
applyInterceptWarmup,
applyPlanType,
buildHeaderOverridesObject,
buildPlanTypeOptions,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
planTypeDisplayLabel,
readPlanType,
splitHeaderOverridesObject,
validateHeaderOverrideRows
} from '../credentialsBuilder'
@@ -289,3 +293,88 @@ describe('validateHeaderOverrideRows session isolation headers', () => {
expect(validateHeaderOverrideRows([{ name: 'x'.repeat(201), value: 'v' }])).toBe('invalidName')
})
})
describe('plan_type helpers', () => {
describe('planTypeDisplayLabel', () => {
it('maps canonical + alias values to friendly labels', () => {
expect(planTypeDisplayLabel('plus')).toBe('Plus')
expect(planTypeDisplayLabel('pro')).toBe('Pro')
expect(planTypeDisplayLabel('chatgptpro')).toBe('Pro')
expect(planTypeDisplayLabel('free')).toBe('Free')
expect(planTypeDisplayLabel('team')).toBe('Team')
expect(planTypeDisplayLabel('CHATGPTPRO')).toBe('Pro')
})
it('returns unknown values verbatim', () => {
expect(planTypeDisplayLabel('self_serve_business')).toBe('self_serve_business')
})
})
describe('readPlanType', () => {
it('reads a string plan_type', () => {
expect(readPlanType({ plan_type: 'plus' })).toBe('plus')
})
it('treats non-string / missing values as empty', () => {
expect(readPlanType({ plan_type: 42 })).toBe('')
expect(readPlanType({ plan_type: true })).toBe('')
expect(readPlanType({})).toBe('')
expect(readPlanType(undefined)).toBe('')
expect(readPlanType(null)).toBe('')
})
})
describe('buildPlanTypeOptions', () => {
const clear = 'Clear'
it('returns clear + presets when current is empty', () => {
expect(buildPlanTypeOptions('', clear)).toEqual([
{ value: '', label: clear },
{ value: 'plus', label: 'Plus' },
{ value: 'pro', label: 'Pro' },
{ value: 'free', label: 'Free' }
])
})
it('keeps canonical chatgptpro under a single friendly "Pro" option (no duplicate)', () => {
const opts = buildPlanTypeOptions('chatgptpro', clear)
const pros = opts.filter(o => o.label === 'Pro')
expect(pros).toHaveLength(1)
expect(pros[0].value).toBe('chatgptpro')
expect(opts.map(o => o.value)).toEqual(['', 'plus', 'chatgptpro', 'free'])
})
it('appends an unknown-but-labeled value (team) as its own option', () => {
const opts = buildPlanTypeOptions('team', clear)
expect(opts.find(o => o.value === 'team')).toEqual({ value: 'team', label: 'Team' })
// presets untouched
expect(opts.map(o => o.value)).toEqual(['', 'plus', 'pro', 'free', 'team'])
})
it('appends a fully custom value with a raw label', () => {
const opts = buildPlanTypeOptions('weird_x', clear)
expect(opts.at(-1)).toEqual({ value: 'weird_x', label: 'weird_x' })
})
it('does not duplicate an exact preset value', () => {
const opts = buildPlanTypeOptions('pro', clear)
expect(opts.filter(o => o.value === 'pro')).toHaveLength(1)
expect(opts.map(o => o.value)).toEqual(['', 'plus', 'pro', 'free'])
})
})
describe('applyPlanType', () => {
it('sets plan_type and preserves all other credential keys', () => {
const creds = {
chatgpt_account_id: 'acc',
email: 'a@b.c',
subscription_expires_at: '2026-01-01',
model_mapping: { x: 'y' }
}
const out = applyPlanType({ ...creds }, 'plus')
expect(out).toEqual({ ...creds, plan_type: 'plus' })
})
it('trims the value', () => {
expect(applyPlanType({}, ' pro ')).toEqual({ plan_type: 'pro' })
})
it('deletes the key when cleared (empty), keeping other keys', () => {
const out = applyPlanType({ plan_type: 'pro', email: 'a@b.c' }, '')
expect(out).toEqual({ email: 'a@b.c' })
expect('plan_type' in out).toBe(false)
})
})
})
@@ -201,3 +201,87 @@ export function applyHeaderOverride(
delete credentials[HEADER_OVERRIDES_CREDENTIAL_KEY]
}
}
// ===== OpenAI plan_type (ChatGPT 订阅档位) 手动覆盖 =====
export interface PlanTypeOption {
value: string
label: string
// 兼容 common/Select.vue 的 SelectOption(含索引签名)
[key: string]: unknown
}
/**
* plan_type 值的友好显示标签,镜像 PlatformTypeBadge 的映射
* (canonical 值 chatgptpro 显示为 Pro,team 显示为 Team)。未知值原样返回。
*/
export function planTypeDisplayLabel(value: string): string {
switch (value.trim().toLowerCase()) {
case 'plus':
return 'Plus'
case 'pro':
case 'chatgptpro':
return 'Pro'
case 'free':
return 'Free'
case 'team':
return 'Team'
default:
return value
}
}
/**
* 从凭据里读取 plan_type,仅接受字符串(脏数据 42/true 等一律视为空,
* 避免被当作合法自定义项保留)。
*/
export function readPlanType(credentials: Record<string, unknown> | undefined | null): string {
const v = credentials?.plan_type
return typeof v === 'string' ? v : ''
}
/**
* 构建 plan_type 下拉选项:清空 + Plus/Pro/Free 预设。
* 若当前值是某预设的别名(如 chatgptpro↔Pro),用当前的 canonical 值占据该
* 标签位(保留 canonical,显示友好标签,避免重复项);若是完全预设外的值
* (如 team 或异常值),追加为一项,避免编辑时下拉丢失原值。
*/
export function buildPlanTypeOptions(current: string, clearLabel: string): PlanTypeOption[] {
const cur = (current || '').trim()
const curLabel = cur ? planTypeDisplayLabel(cur) : ''
const presets: PlanTypeOption[] = [
{ value: 'plus', label: 'Plus' },
{ value: 'pro', label: 'Pro' },
{ value: 'free', label: 'Free' }
]
const opts: PlanTypeOption[] = [{ value: '', label: clearLabel }]
for (const p of presets) {
if (cur && p.value !== cur.toLowerCase() && p.label === curLabel) {
// 当前值是该预设的别名:用 canonical 当前值占位,标签仍显示友好名
opts.push({ value: cur, label: p.label })
} else {
opts.push(p)
}
}
if (cur && !opts.some(o => o.value.toLowerCase() === cur.toLowerCase())) {
opts.push({ value: cur, label: planTypeDisplayLabel(cur) })
}
return opts
}
/**
* 把手动选择的 plan_type 写入凭据:非空则设置,空则删除该键(清空/自动识别)。
* 直接修改传入对象并返回。
*/
export function applyPlanType(
credentials: Record<string, unknown>,
planType: string
): Record<string, unknown> {
const pt = (planType || '').trim()
if (pt) {
credentials.plan_type = pt
} else {
delete credentials.plan_type
}
return credentials
}
@@ -446,6 +446,10 @@ export default {
responsesStatusAutoUnknown: 'Auto probe: unknown',
responsesStatusForcedResponses: 'Forced Responses',
responsesStatusForcedChatCompletions: 'Forced Chat Completions',
planType: 'Plan tier (manual override)',
planTypeDesc:
"Manually correct this account's ChatGPT plan tier (Plus / Pro / Free). Note: a token refresh near expiry or a 429 rate-limit response will auto-overwrite this with the real tier.",
planTypeClear: 'Clear (auto-detect)',
codexCLIOnly: 'Codex official clients only',
codexCLIOnlyDesc:
'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.',
@@ -546,6 +546,9 @@ export default {
responsesStatusAutoUnknown: '自动探测:未探测',
responsesStatusForcedResponses: '已强制 Responses',
responsesStatusForcedChatCompletions: '已强制 Chat Completions',
planType: '订阅档位(手动覆盖)',
planTypeDesc: '手动纠正本账号的 ChatGPT 订阅档位(Plus / Pro / Free)。注意:令牌临期刷新或命中 429 限流时,会用真实档位自动覆盖此处设置。',
planTypeClear: '清空(自动识别)',
codexCLIOnly: '仅允许 Codex 官方客户端',
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
codexCLIOnlyAppServer: '允许 Codex app-server 客户端',