feat(cloud-status): classify blocked accounts and surface validation state

This commit is contained in:
Draculabo committed 2026-04-11 16:46:36 +08:00
1 parent bcdf2a6c24
commit 388fbcb2a9
4 files changed
+137 -8

No files matched your search

+28 -4
View File
@@ -37,6 +37,7 @@ import {
import { useState } from 'react'; import { useState } from 'react';
import { useSetAccountProxy } from '@/hooks/useCloudAccounts'; import { useSetAccountProxy } from '@/hooks/useCloudAccounts';
import { isValidProxyUrl } from '@/utils/url'; import { isValidProxyUrl } from '@/utils/url';
import { getValidationBlockedStatusLabel } from '@/components/accountValidationStatus';
type ModelQuotaEntry = [string, CloudQuotaModelInfo]; type ModelQuotaEntry = [string, CloudQuotaModelInfo];
@@ -336,6 +337,12 @@ export function CloudAccountCard({
} }
}; };
const validationBlockedStatusLabel = getValidationBlockedStatusLabel(
account.status,
account.status_reason,
t,
);
return ( return (
<Card <Card
className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`} className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`}
@@ -474,7 +481,11 @@ export function CloudAccountCard({
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <Badge
variant={account.status === 'rate_limited' ? 'destructive' : 'outline'} variant={
account.status === 'rate_limited' || account.status === 'expired'
? 'destructive'
: 'outline'
}
className="text-xs" className="text-xs"
> >
{account.provider.toUpperCase()} {account.provider.toUpperCase()}
@@ -484,9 +495,9 @@ export function CloudAccountCard({
{t('cloud.card.active')} {t('cloud.card.active')}
</Badge> </Badge>
)} )}
{account.status === 'rate_limited' && ( {validationBlockedStatusLabel && (
<span className="text-destructive text-xs font-medium"> <span className="text-destructive text-xs font-medium">
{t('cloud.card.rateLimited')} {validationBlockedStatusLabel}
</span> </span>
)} )}
</div> </div>
@@ -636,6 +647,12 @@ export function CompactCloudAccountCard({
} }
}; };
const validationBlockedStatusLabel = getValidationBlockedStatusLabel(
account.status,
account.status_reason,
t,
);
return ( return (
<div className="group bg-card hover:border-primary/40 flex items-center gap-3 rounded-lg border px-3 py-2 transition-all duration-200"> <div className="group bg-card hover:border-primary/40 flex items-center gap-3 rounded-lg border px-3 py-2 transition-all duration-200">
{account.avatar_url ? ( {account.avatar_url ? (
@@ -656,7 +673,11 @@ export function CompactCloudAccountCard({
{account.name || t('cloud.card.unknown')} {account.name || t('cloud.card.unknown')}
</span> </span>
<Badge <Badge
variant={account.status === 'rate_limited' ? 'destructive' : 'outline'} variant={
account.status === 'rate_limited' || account.status === 'expired'
? 'destructive'
: 'outline'
}
className="shrink-0 text-[10px]" className="shrink-0 text-[10px]"
> >
{account.provider.toUpperCase()} {account.provider.toUpperCase()}
@@ -673,6 +694,9 @@ export function CompactCloudAccountCard({
<div className="text-muted-foreground flex items-center gap-3 text-xs"> <div className="text-muted-foreground flex items-center gap-3 text-xs">
<span className="truncate">{account.email}</span> <span className="truncate">{account.email}</span>
{validationBlockedStatusLabel && (
<span className="text-destructive shrink-0 font-medium">{validationBlockedStatusLabel}</span>
)}
{shouldShowAiCredits && aiCredits && ( {shouldShowAiCredits && aiCredits && (
<span className="shrink-0 text-amber-500"> <span className="shrink-0 text-amber-500">
+29 -1
View File
@@ -3,6 +3,16 @@ import { CloudAccountRepo } from '../ipc/database/cloudHandler';
import { GoogleAPIService } from './GoogleAPIService'; import { GoogleAPIService } from './GoogleAPIService';
import { AutoSwitchService } from './AutoSwitchService'; import { AutoSwitchService } from './AutoSwitchService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { classifyAccountStatusFromError } from '../utils/account-status';
function hasReusableCachedQuota(account: {
quota?: { models?: Record<string, unknown> };
}): boolean {
if (!account.quota || !account.quota.models) {
return false;
}
return Object.keys(account.quota.models).length > 0;
}
export class CloudMonitorService { export class CloudMonitorService {
private static intervalId: NodeJS.Timeout | null = null; private static intervalId: NodeJS.Timeout | null = null;
@@ -106,14 +116,23 @@ export class CloudMonitorService {
const newToken = await GoogleAPIService.refreshAccessToken( const newToken = await GoogleAPIService.refreshAccessToken(
account.token.refresh_token, account.token.refresh_token,
account.proxy_url, account.proxy_url,
account.token.oauth_client_key,
); );
account.token.access_token = newToken.access_token; account.token.access_token = newToken.access_token;
account.token.expires_in = newToken.expires_in; account.token.expires_in = newToken.expires_in;
account.token.expiry_timestamp = now + newToken.expires_in; account.token.expiry_timestamp = now + newToken.expires_in;
account.token.oauth_client_key = GoogleAPIService.normalizeRefreshedOAuthClientKey(
account.token,
newToken.oauth_client_key,
);
await CloudAccountRepo.updateToken(account.id, account.token); await CloudAccountRepo.updateToken(account.id, account.token);
accessToken = newToken.access_token; accessToken = newToken.access_token;
} catch (refreshError) { } catch (refreshError) {
logger.error(`Monitor: Token refresh failed for ${account.email}`, refreshError); logger.error(`Monitor: Token refresh failed for ${account.email}`, refreshError);
const classified = classifyAccountStatusFromError(refreshError);
if (classified) {
await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason);
}
continue; continue;
} }
} }
@@ -139,9 +158,18 @@ export class CloudMonitorService {
// 3. Update DB // 3. Update DB
await CloudAccountRepo.updateQuota(account.id, quota); await CloudAccountRepo.updateQuota(account.id, quota);
await CloudAccountRepo.updateLastUsed(account.id); await CloudAccountRepo.updateLastUsed(account.id);
await CloudAccountRepo.setAccountStatus(account.id, 'active', null);
} catch (error) { } catch (error) {
logger.error(`Monitor: Failed to update ${account.email}`, error); logger.error(`Monitor: Failed to update ${account.email}`, error);
// Could mark status as 'error' or 'rate_limited' if 429 const classified = classifyAccountStatusFromError(error);
if (classified) {
await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason);
if (classified.status === 'rate_limited' && hasReusableCachedQuota(account)) {
logger.warn(
`Monitor: Quota request rate-limited for ${account.email}, keeping cached quota as fallback.`,
);
}
}
} }
} }
+7 -3
View File
@@ -61,7 +61,7 @@ describe('CloudMonitorService', () => {
await pollPromise; await pollPromise;
expect(CloudAccountRepo.getAccounts).toHaveBeenCalled(); expect(CloudAccountRepo.getAccounts).toHaveBeenCalled();
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('valid_token'); expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('valid_token', undefined);
expect(CloudAccountRepo.updateQuota).toHaveBeenCalledWith('acc1', expect.anything()); expect(CloudAccountRepo.updateQuota).toHaveBeenCalledWith('acc1', expect.anything());
expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1'); expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1');
expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled(); expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled();
@@ -93,9 +93,13 @@ describe('CloudMonitorService', () => {
await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(1000);
await pollPromise; await pollPromise;
expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith('ref_token'); expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith(
'ref_token',
undefined,
undefined,
);
expect(CloudAccountRepo.updateToken).toHaveBeenCalled(); expect(CloudAccountRepo.updateToken).toHaveBeenCalled();
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token'); expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token', undefined);
}); });
describe('handleAppFocus (Smart Refresh)', () => { describe('handleAppFocus (Smart Refresh)', () => {
+73
View File
@@ -0,0 +1,73 @@
export const RATE_LIMIT_HINTS = [
'resource_exhausted',
'too many requests',
'429',
'rate limit',
'rate-limited',
'risk control',
'risk-controlled',
'frozen',
] as const;
export const OAUTH_HINTS = [
'unauthorized_client',
'invalid_client',
'invalid_grant',
'oauth client',
'not authorized',
'token expired',
'reauth',
'verify your account',
'further action is required',
'validation required',
'validation_url',
'appeal_url',
] as const;
function includesAny(text: string, hints: readonly string[]): boolean {
return hints.some((hint) => text.includes(hint));
}
export function isRateLimitReason(reason: string): boolean {
return includesAny(reason.toLowerCase(), RATE_LIMIT_HINTS);
}
export function isOAuthReauthReason(reason: string): boolean {
return includesAny(reason.toLowerCase(), OAUTH_HINTS);
}
export function extractErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return String(error);
}
export function classifyAccountStatusFromError(
error: unknown,
): { status: 'rate_limited' | 'expired'; reason: string } | null {
const reason = extractErrorMessage(error).trim();
if (!reason) {
return null;
}
const normalizedReason = reason.toLowerCase();
if (isRateLimitReason(normalizedReason)) {
return { status: 'rate_limited', reason };
}
if (isOAuthReauthReason(normalizedReason)) {
return { status: 'expired', reason };
}
if (normalizedReason.includes('forbidden')) {
return { status: 'rate_limited', reason };
}
if (normalizedReason.includes('unauthorized')) {
return { status: 'expired', reason };
}
return null;
}