From 84c0930e47b8ec8600ed103f21e50916c15a25c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wei=5F=E4=BD=B3?= Date: Tue, 1 Sep 2026 02:16:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(oauth):=20=E5=A2=9E=E5=8A=A0=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E6=84=9F=E7=9F=A5=E8=B4=A6=E5=8F=B7=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/i18n/ja.ts | 24 +++ src/i18n/locales/en.ts | 21 ++ src/i18n/locales/zh-CN.ts | 21 ++ src/pages/AuthFileManagementPage.tsx | 312 +++++++++++++++++++++++++++ src/services/accountRouting.ts | 65 ++++++ src/styles.css | 147 +++++++++++++ tests/accountRouting.test.ts | 64 ++++++ 7 files changed, 654 insertions(+) create mode 100644 src/services/accountRouting.ts create mode 100644 tests/accountRouting.test.ts diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 260c2825..44d11879 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -1,6 +1,9 @@ import type { MessageKey } from './locales/zh-CN'; export const jaOverrides = { + 'config.software.defaultTerminal': '既定のターミナル', + 'config.software.defaultTerminalDescription': 'CLI エージェントの起動に使用するターミナルアプリ', + 'config.software.terminal.auto': '自動選択', 'apiAccess.models.add': 'カスタムモデルを追加', 'apiAccess.models.namePlaceholder': 'モデル名(例: gpt-4o)', 'apiAccess.models.aliasPlaceholder': 'モデル別名(任意)', @@ -477,6 +480,27 @@ export const jaOverrides = { 'authFiles.priority.hint': '整数のみ指定できます。値が大きいほど優先度が高くなります。空欄または 0 で既定値に戻ります。', 'authFiles.priority.invalid': '有効な整数の優先度を入力してください', 'authFiles.priority.updated': '{name} の優先度を更新しました', + 'authFiles.accountSwitch.button': 'アカウント切替', + 'authFiles.accountSwitch.open': 'クォータの推奨または手動で同一プロバイダーのアカウントを切り替えます', + 'authFiles.accountSwitch.unavailable': '同じプロバイダーに、有効でクォータ照会対応のアカウントが 2 件以上必要です', + 'authFiles.accountSwitch.title': '優先アカウントを切替', + 'authFiles.accountSwitch.provider': 'プロバイダー', + 'authFiles.accountSwitch.refresh': 'クォータを更新', + 'authFiles.accountSwitch.hint': '最も制約の厳しいクォータ枠の残量が多いアカウントを推奨します。切替は新しいセッションだけに反映されます。', + 'authFiles.accountSwitch.accounts': '切替可能なアカウント', + 'authFiles.accountSwitch.recommended': 'クォータ推奨', + 'authFiles.accountSwitch.current': '現在の優先', + 'authFiles.accountSwitch.score': '最小残量 {percent}%', + 'authFiles.accountSwitch.quota.idle': '未照会', + 'authFiles.accountSwitch.quota.loading': 'クォータ照会中', + 'authFiles.accountSwitch.quota.success': '比較可能なクォータなし', + 'authFiles.accountSwitch.quota.error': 'クォータ取得失敗', + 'authFiles.accountSwitch.noQuota': '比較可能なクォータ結果がありません。一意の優先アカウントがある場合は選択を維持し、それ以外は手動で選択してください。', + 'authFiles.accountSwitch.selectRequired': '優先するアカウントを選択してください', + 'authFiles.accountSwitch.priorityOverflow': '現在の優先度が安全な整数上限に達しています。先に手動でリセットしてください。', + 'authFiles.accountSwitch.switched': '{provider} の優先アカウントを {name} に切り替えました', + 'authFiles.accountSwitch.partial': '対象アカウントは有効ですが、以前の優先度 {count} 件をリセットできませんでした:{names}', + 'authFiles.accountSwitch.confirm': '優先アカウントに設定', 'authFiles.uploaded': '認証ファイルを {count} 件アップロードしました', 'authFiles.uploadFailed': '{count} 件のファイルをアップロードできませんでした:{errors}', 'authFiles.notice.enabled': '認証ファイルを有効にしました', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 02f102ae..1018ae77 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -480,6 +480,27 @@ export const en: Record = { 'authFiles.priority.hint': 'Integers only. Larger values have higher priority. Leave blank or enter 0 to restore the default.', 'authFiles.priority.invalid': 'Enter a valid integer priority', 'authFiles.priority.updated': 'Priority updated for {name}', + 'authFiles.accountSwitch.button': 'Switch Account', + 'authFiles.accountSwitch.open': 'Use quota guidance or manually switch accounts within one provider', + 'authFiles.accountSwitch.unavailable': 'Add at least two enabled, quota-aware accounts for the same provider', + 'authFiles.accountSwitch.title': 'Switch Preferred Account', + 'authFiles.accountSwitch.provider': 'Provider', + 'authFiles.accountSwitch.refresh': 'Refresh Quota', + 'authFiles.accountSwitch.hint': 'The default recommendation maximizes the most constrained quota window. Switching affects new sessions only; bound sessions keep their current account.', + 'authFiles.accountSwitch.accounts': 'Switchable accounts', + 'authFiles.accountSwitch.recommended': 'Quota Pick', + 'authFiles.accountSwitch.current': 'Current Priority', + 'authFiles.accountSwitch.score': 'Minimum {percent}% left', + 'authFiles.accountSwitch.quota.idle': 'Not queried', + 'authFiles.accountSwitch.quota.loading': 'Querying quota', + 'authFiles.accountSwitch.quota.success': 'No comparable quota', + 'authFiles.accountSwitch.quota.error': 'Quota unavailable', + 'authFiles.accountSwitch.noQuota': 'No comparable quota result was returned. A unique preferred account stays selected; otherwise, select one manually.', + 'authFiles.accountSwitch.selectRequired': 'Select the account to prefer', + 'authFiles.accountSwitch.priorityOverflow': 'The current priority reached the safe integer limit. Reset it manually before switching.', + 'authFiles.accountSwitch.switched': 'Preferred {provider} account switched to {name}', + 'authFiles.accountSwitch.partial': 'The target account is active, but {count} previous priorities could not be reset: {names}', + 'authFiles.accountSwitch.confirm': 'Set as Preferred', 'authFiles.uploaded': 'Uploaded {count} authentication files', 'authFiles.uploadFailed': '{count} files failed to upload: {errors}', 'authFiles.notice.enabled': 'Authentication file enabled', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index cdc97b93..4be83903 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -478,6 +478,27 @@ export const zhCN = { 'authFiles.priority.hint': '仅支持整数;数值越大优先级越高。留空或填写 0 恢复默认。', 'authFiles.priority.invalid': '请输入有效的整数优先级', 'authFiles.priority.updated': '{name} 的优先级已更新', + 'authFiles.accountSwitch.button': '切换账号', + 'authFiles.accountSwitch.open': '按额度推荐或手动切换同一提供商账号', + 'authFiles.accountSwitch.unavailable': '需要同一提供商下至少两个已启用且支持额度查询的账号', + 'authFiles.accountSwitch.title': '切换优先账号', + 'authFiles.accountSwitch.provider': '提供商', + 'authFiles.accountSwitch.refresh': '刷新额度', + 'authFiles.accountSwitch.hint': '默认推荐最紧张额度窗口剩余最多的账号。切换只影响新会话,已绑定会话保持原账号。', + 'authFiles.accountSwitch.accounts': '可切换账号', + 'authFiles.accountSwitch.recommended': '额度推荐', + 'authFiles.accountSwitch.current': '当前优先', + 'authFiles.accountSwitch.score': '最低剩余 {percent}%', + 'authFiles.accountSwitch.quota.idle': '等待查询', + 'authFiles.accountSwitch.quota.loading': '额度查询中', + 'authFiles.accountSwitch.quota.success': '无可比额度', + 'authFiles.accountSwitch.quota.error': '额度获取失败', + 'authFiles.accountSwitch.noQuota': '没有可比较的额度结果;如存在唯一优先账号则保持选择,否则请手动选择。', + 'authFiles.accountSwitch.selectRequired': '请选择要优先使用的账号', + 'authFiles.accountSwitch.priorityOverflow': '现有优先级已达到安全整数上限,请先手动重置优先级', + 'authFiles.accountSwitch.switched': '已将 {provider} 的优先账号切换为 {name}', + 'authFiles.accountSwitch.partial': '目标账号已生效,但 {count} 个旧账号优先级重置失败:{names}', + 'authFiles.accountSwitch.confirm': '设为优先账号', 'authFiles.uploaded': '已上传 {count} 个认证文件', 'authFiles.uploadFailed': '{count} 个文件上传失败:{errors}', 'authFiles.notice.enabled': '认证文件已启用', diff --git a/src/pages/AuthFileManagementPage.tsx b/src/pages/AuthFileManagementPage.tsx index 62c2a069..5b023ecd 100644 --- a/src/pages/AuthFileManagementPage.tsx +++ b/src/pages/AuthFileManagementPage.tsx @@ -1,5 +1,6 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + ArrowLeftRight, Check, Copy, FileDown, @@ -33,6 +34,7 @@ import { loadQuota, providerForFile as quotaProviderForFile, quotaKey, + type QuotaProvider, type QuotaState, } from '../services/quotaService'; import { @@ -49,6 +51,13 @@ import { normalizeAuthFilePriorityInput, parseAuthFilePriority, } from '../services/authFiles'; +import { + accountQuotaScore, + currentPreferredAccountId, + nextAccountSwitchPriority, + preferredQuotaAccountId, + type AccountRoutingCandidate, +} from '../services/accountRouting'; import { exclusionsForOpenOAuthModels, oauthExcludedRulesFromPayload, @@ -67,6 +76,21 @@ type PriorityEditor = { error: string; }; +type AccountSwitchGroup = { + provider: QuotaProvider; + label: string; + files: AuthFile[]; +}; + +type AccountSwitchDialog = { + provider: QuotaProvider; + selectedName: string; + recommendedName: string; + refreshing: boolean; + saving: boolean; + error: string; +}; + const providerIcons: Record = { antigravity: antigravityIcon, claude: claudeIcon, @@ -94,6 +118,15 @@ const fileName = authFileName; const isRuntimeOnly = isRuntimeOnlyAuthFile; +const accountRoutingCandidates = ( + files: AuthFile[], + quotas: Record, +): AccountRoutingCandidate[] => files.map((file) => ({ + id: fileName(file), + priority: parseAuthFilePriority(file.priority) ?? 0, + quota: quotas[quotaKey(file)] ?? idleQuota(), +})); + const statusText = (file: AuthFile) => { if (readBoolean(file, 'disabled')) return translate(getCurrentLocale(), 'authFiles.status.disabled'); if (readBoolean(file, 'unavailable')) return translate(getCurrentLocale(), 'authFiles.status.unavailable'); @@ -155,6 +188,7 @@ export function AuthFileManagementPage() { const [notice, setNotice] = useState(''); const [copied, setCopied] = useState(''); const [priorityEditor, setPriorityEditor] = useState(null); + const [accountSwitch, setAccountSwitch] = useState(null); const [oauthModelProvider, setOauthModelProvider] = useState(''); const [oauthModelProviderLabel, setOauthModelProviderLabel] = useState(''); const [oauthModels, setOauthModels] = useState([]); @@ -167,6 +201,7 @@ export function AuthFileManagementPage() { const quotas = useQuotaCache(); const fileInputRef = useRef(null); const oauthModelRequestRef = useRef(0); + const accountSwitchRequestRef = useRef(0); const loadFiles = useCallback(async () => { setLoading(true); @@ -307,6 +342,153 @@ export function AuthFileManagementPage() { [files], ); + const accountSwitchGroups = useMemo(() => { + const grouped = new Map(); + files.forEach((file) => { + if (readBoolean(file, 'disabled') || readBoolean(file, 'unavailable')) return; + const provider = quotaProviderForFile(file); + if (!provider) return; + const entries = grouped.get(provider) ?? []; + entries.push(file); + grouped.set(provider, entries); + }); + return Array.from(grouped, ([provider, entries]): AccountSwitchGroup => ({ + provider, + label: providerName(entries[0]), + files: entries.sort((left, right) => fileName(left).localeCompare(fileName(right))), + })) + .filter((group) => group.files.length > 1) + .sort((left, right) => left.label.localeCompare(right.label)); + }, [files]); + + const refreshAccountSwitchQuotas = async ( + provider: QuotaProvider, + preservedSelection = '', + ) => { + const group = accountSwitchGroups.find((entry) => entry.provider === provider); + if (!group) return; + const requestId = accountSwitchRequestRef.current + 1; + accountSwitchRequestRef.current = requestId; + const cacheGeneration = captureQuotaCacheGeneration(); + setAccountSwitch((current) => current && current.provider === provider + ? { ...current, refreshing: true, error: '' } + : current); + updateQuotaCache((current) => { + const next = { ...current }; + group.files.forEach((file) => { + next[quotaKey(file)] = { status: 'loading', rows: [] }; + }); + return next; + }); + + const results = await Promise.all(group.files.map(async (file) => ({ + file, + quota: await loadQuota(file), + }))); + const nextQuotas = Object.fromEntries( + results.map(({ file, quota }) => [quotaKey(file), quota]), + ); + const committed = commitQuotaCacheIfCurrent(cacheGeneration, () => { + updateQuotaCache((current) => ({ ...current, ...nextQuotas })); + }); + if (!committed || accountSwitchRequestRef.current !== requestId) return; + + const candidateQuotas = { ...quotas, ...nextQuotas }; + const candidates = accountRoutingCandidates(group.files, candidateQuotas); + const recommendedName = preferredQuotaAccountId(candidates) ?? ''; + const currentPriorityName = currentPreferredAccountId(candidates) ?? ''; + const selectedName = group.files.some((file) => fileName(file) === preservedSelection) + ? preservedSelection + : recommendedName || currentPriorityName; + setAccountSwitch((current) => current && current.provider === provider + ? { + ...current, + selectedName, + recommendedName, + refreshing: false, + error: recommendedName ? '' : t('authFiles.accountSwitch.noQuota'), + } + : current); + }; + + const openAccountSwitch = () => { + const group = accountSwitchGroups.find((entry) => entry.label === providerFilter) + ?? accountSwitchGroups[0]; + if (!group) return; + setAccountSwitch({ + provider: group.provider, + selectedName: '', + recommendedName: '', + refreshing: true, + saving: false, + error: '', + }); + void refreshAccountSwitchQuotas(group.provider); + }; + + const closeAccountSwitch = () => { + if (accountSwitch?.saving) return; + accountSwitchRequestRef.current += 1; + setAccountSwitch(null); + }; + + const applyAccountSwitch = async () => { + if (!accountSwitch || accountSwitch.refreshing || accountSwitch.saving) return; + const group = accountSwitchGroups.find((entry) => entry.provider === accountSwitch.provider); + const target = group?.files.find((file) => fileName(file) === accountSwitch.selectedName); + if (!group || !target) { + setAccountSwitch((current) => current + ? { ...current, error: t('authFiles.accountSwitch.selectRequired') } + : current); + return; + } + const candidates = accountRoutingCandidates(group.files, quotas); + const nextPriority = nextAccountSwitchPriority(candidates); + if (nextPriority === null) { + setAccountSwitch((current) => current + ? { ...current, error: t('authFiles.accountSwitch.priorityOverflow') } + : current); + return; + } + + setError(''); + setNotice(''); + setAccountSwitch((current) => current ? { ...current, saving: true, error: '' } : current); + try { + await managementApi.patch('/auth-files/fields', { + name: fileName(target), + priority: nextPriority, + }); + const resetFailures: string[] = []; + for (const file of group.files) { + if (fileName(file) === fileName(target) || (parseAuthFilePriority(file.priority) ?? 0) === 0) { + continue; + } + try { + await managementApi.patch('/auth-files/fields', { name: fileName(file), priority: 0 }); + } catch { + resetFailures.push(fileName(file)); + } + } + await loadFiles(); + setAccountSwitch(null); + setNotice(t('authFiles.accountSwitch.switched', { + provider: group.label, + name: fileName(target), + })); + if (resetFailures.length > 0) { + setError(t('authFiles.accountSwitch.partial', { + count: resetFailures.length, + names: resetFailures.join(', '), + })); + } + } catch (requestError) { + setAccountSwitch((current) => current + ? { ...current, saving: false, error: String(requestError) } + : current); + } + }; + const visibleFiles = useMemo(() => { const query = filter.trim().toLowerCase(); return files.filter((file) => { @@ -461,6 +643,16 @@ export function AuthFileManagementPage() { const disabledCount = files.filter((file) => readBoolean(file, 'disabled')).length; const runtimeCount = files.filter(isRuntimeOnly).length; + const activeAccountSwitchGroup = accountSwitch + ? accountSwitchGroups.find((group) => group.provider === accountSwitch.provider) ?? null + : null; + const activeAccountSwitchCandidates = activeAccountSwitchGroup + ? accountRoutingCandidates(activeAccountSwitchGroup.files, quotas) + : []; + const activeAccountSwitchPriority = activeAccountSwitchCandidates.reduce( + (current, candidate) => Math.max(current, candidate.priority), + 0, + ); return (
@@ -477,6 +669,15 @@ export function AuthFileManagementPage() { + @@ -543,6 +744,117 @@ export function AuthFileManagementPage() {
{runtimeCount > 0 ?

{t('authFiles.runtimeFootnote', { count: runtimeCount })}

: null} + {accountSwitch && activeAccountSwitchGroup ? ( +
event.currentTarget === event.target && closeAccountSwitch()}> +
+
+

{t('authFiles.accountSwitch.title')}

+ +
+ +
+ + +
+ +

{t('authFiles.accountSwitch.hint')}

+ +
+ {activeAccountSwitchGroup.files.map((file) => { + const name = fileName(file); + const quota = quotas[quotaKey(file)] ?? idleQuota(); + const score = accountQuotaScore(quota); + const priority = parseAuthFilePriority(file.priority) ?? 0; + const selected = accountSwitch.selectedName === name; + return ( + + ); + })} +
+ +
+ {accountSwitch.error || ' '} +
+
+ + +
+
+
+ ) : null} + {priorityEditor ? (
event.currentTarget === event.target && closePriorityEditor()}>
{ + if (value === null || !Number.isFinite(value)) return null; + return Math.max(0, Math.min(100, value)); +}; + +export const accountQuotaScore = (quota: QuotaState): AccountQuotaScore | null => { + if (quota.status !== 'success') return null; + const remaining = quota.rows + .map((row) => normalizeRemaining(row.remainingPercent)) + .filter((value): value is number => value !== null); + if (remaining.length === 0) return null; + return { + bottleneckRemaining: Math.min(...remaining), + averageRemaining: remaining.reduce((sum, value) => sum + value, 0) / remaining.length, + knownWindows: remaining.length, + }; +}; + +export const preferredQuotaAccountId = (candidates: AccountRoutingCandidate[]) => { + const scored = candidates + .map((candidate) => ({ candidate, score: accountQuotaScore(candidate.quota) })) + .filter((item): item is { candidate: AccountRoutingCandidate; score: AccountQuotaScore } => + item.score !== null, + ); + scored.sort((left, right) => + right.score.bottleneckRemaining - left.score.bottleneckRemaining + || right.score.averageRemaining - left.score.averageRemaining + || right.score.knownWindows - left.score.knownWindows + || right.candidate.priority - left.candidate.priority + || left.candidate.id.localeCompare(right.candidate.id), + ); + return scored[0]?.candidate.id ?? null; +}; + +export const currentPreferredAccountId = (candidates: AccountRoutingCandidate[]) => { + if (candidates.length === 0) return null; + const highestPriority = candidates.reduce( + (current, candidate) => Math.max(current, candidate.priority), + Number.MIN_SAFE_INTEGER, + ); + const preferred = candidates.filter((candidate) => candidate.priority === highestPriority); + return preferred.length === 1 ? preferred[0].id : null; +}; + +export const nextAccountSwitchPriority = (candidates: AccountRoutingCandidate[]) => { + const highest = candidates.reduce( + (current, candidate) => Math.max(current, candidate.priority), + 0, + ); + return highest >= Number.MAX_SAFE_INTEGER ? null : highest + 1; +}; diff --git a/src/styles.css b/src/styles.css index f092d752..73576026 100644 --- a/src/styles.css +++ b/src/styles.css @@ -8072,6 +8072,128 @@ body.table-col-resizing * { text-overflow: ellipsis; } +.account-switch-dialog { + grid-template-rows: 40px auto auto minmax(0, 1fr) 20px 40px; + width: min(720px, 100%); + height: min(680px, calc(100vh - 40px)); + min-height: 480px; +} + +.account-switch-toolbar { + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; +} + +.account-switch-toolbar label { + display: grid; + gap: 6px; + min-width: min(260px, 100%); + color: var(--theme-5f5a53); + font-size: var(--font-size-label); + font-weight: 700; +} + +.account-switch-toolbar select { + width: 100%; + height: 38px; + padding: 0 34px 0 10px; + border: 1px solid var(--theme-d5d2cb); + border-radius: 6px; + background: var(--theme-ffffff); + color: var(--theme-2d2a26); +} + +.account-switch-hint { + margin: 0; + color: var(--theme-7a746c); + font-size: var(--font-size-label); + line-height: var(--line-height-body); +} + +.account-switch-list { + display: grid; + align-content: start; + gap: 8px; + min-height: 0; + padding-right: 4px; + overflow: auto; +} + +.account-switch-option { + display: grid; + grid-template-columns: 20px 30px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-width: 0; + padding: 11px 12px; + border: 1px solid var(--theme-e3e1db); + border-radius: 6px; + background: var(--theme-ffffff); + cursor: pointer; +} + +.account-switch-option:hover { + border-color: var(--theme-d5d2cb); + background: var(--theme-f8f5ef); +} + +.account-switch-option.selected { + border-color: var(--theme-c9dfcf); + background: var(--theme-eef7f0); +} + +.account-switch-option input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--theme-2f6b3a); +} + +.account-switch-option > .provider-logo { + width: 26px; + height: 26px; +} + +.account-switch-account { + display: grid; + gap: 3px; + min-width: 0; +} + +.account-switch-account strong, +.account-switch-account small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-switch-account small { + color: var(--theme-7a746c); + font-size: var(--font-size-label); +} + +.account-switch-badges { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + min-width: 0; +} + +.account-switch-score { + color: var(--theme-5f5a53); + font-size: var(--font-size-label); + font-weight: 700; + white-space: nowrap; +} + +.account-switch-option > .auth-file-quota { + grid-column: 3 / -1; + margin-top: 0; +} + .real-auth-file-row.disabled { opacity: 0.68; } @@ -8989,6 +9111,31 @@ body.table-col-resizing * { grid-column: 1 / -1; } + .account-switch-dialog { + grid-template-rows: 40px auto auto minmax(0, 1fr) 20px 40px; + height: calc(100vh - 28px); + min-height: 0; + } + + .account-switch-toolbar { + align-items: stretch; + flex-direction: column; + } + + .account-switch-toolbar label { + min-width: 0; + } + + .account-switch-option { + grid-template-columns: 20px 28px minmax(0, 1fr); + } + + .account-switch-badges, + .account-switch-option > .auth-file-quota { + grid-column: 3; + justify-content: flex-start; + } + .provider-category-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/tests/accountRouting.test.ts b/tests/accountRouting.test.ts new file mode 100644 index 00000000..60c1ae7b --- /dev/null +++ b/tests/accountRouting.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'bun:test'; +import { + accountQuotaScore, + currentPreferredAccountId, + nextAccountSwitchPriority, + preferredQuotaAccountId, +} from '../src/services/accountRouting'; + +const quota = (...remaining: Array) => ({ + status: 'success' as const, + rows: remaining.map((remainingPercent, index) => ({ + label: `window-${index}`, + remainingPercent, + })), +}); + +describe('额度感知账号选择', () => { + it('优先比较最紧张的额度窗口,再比较平均剩余额度', () => { + expect(preferredQuotaAccountId([ + { id: 'high-average', priority: 0, quota: quota(80, 50) }, + { id: 'balanced', priority: 0, quota: quota(60, 60) }, + ])).toBe('balanced'); + + expect(preferredQuotaAccountId([ + { id: 'lower-average', priority: 0, quota: quota(60, 70) }, + { id: 'higher-average', priority: 0, quota: quota(60, 90) }, + ])).toBe('higher-average'); + }); + + it('忽略未知窗口和查询失败账号,并以当前优先级打破额度并列', () => { + expect(accountQuotaScore(quota(null, 120, -10))).toEqual({ + bottleneckRemaining: 0, + averageRemaining: 50, + knownWindows: 2, + }); + expect(preferredQuotaAccountId([ + { id: 'failed', priority: 99, quota: { status: 'error', rows: [], error: 'offline' } }, + { id: 'priority-1', priority: 1, quota: quota(75) }, + { id: 'priority-2', priority: 2, quota: quota(75) }, + ])).toBe('priority-2'); + }); + + it('生成高于同组现有值的安全优先级', () => { + expect(nextAccountSwitchPriority([ + { id: 'first', priority: 0, quota: quota(10) }, + { id: 'second', priority: 12, quota: quota(20) }, + ])).toBe(13); + expect(nextAccountSwitchPriority([ + { id: 'overflow', priority: Number.MAX_SAFE_INTEGER, quota: quota(20) }, + ])).toBeNull(); + }); + + it('额度不可比较时只保留唯一的当前优先账号', () => { + expect(currentPreferredAccountId([ + { id: 'first', priority: 0, quota: quota(10) }, + { id: 'second', priority: 2, quota: quota(20) }, + ])).toBe('second'); + expect(currentPreferredAccountId([ + { id: 'first', priority: 0, quota: quota(10) }, + { id: 'second', priority: 0, quota: quota(20) }, + ])).toBeNull(); + expect(currentPreferredAccountId([])).toBeNull(); + }); +});