1
0
forked from boos/BoosAPI

fix backup import preview and refresh race (#167)

This commit is contained in:
Cita
2026-03-19 17:14:07 +08:00
committed by GitHub
parent 24483c28eb
commit 36e9a85664
6 changed files with 210 additions and 7 deletions
@@ -148,6 +148,8 @@ describe('backupService', () => {
expect(result.allImported).toBe(true);
expect(result.sections.accounts).toBe(true);
expect(result.summary).toBeUndefined();
expect(result.warnings).toBeUndefined();
const restoredSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get();
const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
+22 -6
View File
@@ -283,13 +283,29 @@ function buildAllApiHubV2AccountsSection(data: RawBackupData): {
const accountsContainer = isRecord(data.accounts) ? data.accounts : null;
if (!accountsContainer || !Array.isArray(accountsContainer.accounts)) return null;
if (coerceAccountsSection(accountsContainer)) return null;
const looksLikeLegacyAccountRow = accountsContainer.accounts.some((row) => (
isRecord(row) && (
Object.prototype.hasOwnProperty.call(row, 'site_url')
|| Object.prototype.hasOwnProperty.call(row, 'site_type')
|| Object.prototype.hasOwnProperty.call(row, 'account_info')
|| Object.prototype.hasOwnProperty.call(row, 'cookieAuth')
|| Object.prototype.hasOwnProperty.call(row, 'authType')
|| Object.prototype.hasOwnProperty.call(row, 'sub2apiAuth')
)
));
const looksLikeV2 =
(typeof data.version === 'string' && data.version.startsWith('2'))
|| Object.prototype.hasOwnProperty.call(accountsContainer, 'last_updated')
|| Array.isArray(accountsContainer.bookmarks)
|| Array.isArray(accountsContainer.pinnedAccountIds)
|| Array.isArray(accountsContainer.orderedAccountIds)
|| (isRecord(data.apiCredentialProfiles) && Array.isArray(data.apiCredentialProfiles.profiles));
looksLikeLegacyAccountRow
&& (
(typeof data.version === 'string' && data.version.startsWith('2'))
|| Object.prototype.hasOwnProperty.call(accountsContainer, 'last_updated')
|| Array.isArray(accountsContainer.bookmarks)
|| Array.isArray(accountsContainer.pinnedAccountIds)
|| Array.isArray(accountsContainer.orderedAccountIds)
|| (isRecord(data.apiCredentialProfiles) && Array.isArray(data.apiCredentialProfiles.profiles))
);
if (!looksLikeV2) return null;
@@ -26,6 +26,7 @@ describe('refreshModelsForAccount credential discovery', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let refreshModelsForAccount: ModelServiceModule['refreshModelsForAccount'];
let refreshModelsAndRebuildRoutes: ModelServiceModule['refreshModelsAndRebuildRoutes'];
let dataDir = '';
beforeAll(async () => {
@@ -39,6 +40,7 @@ describe('refreshModelsForAccount credential discovery', () => {
db = dbModule.db;
schema = dbModule.schema;
refreshModelsForAccount = modelService.refreshModelsForAccount;
refreshModelsAndRebuildRoutes = modelService.refreshModelsAndRebuildRoutes;
});
beforeEach(async () => {
@@ -142,6 +144,68 @@ describe('refreshModelsForAccount credential discovery', () => {
expect(rows.map((row) => row.modelName).sort()).toEqual(['?', 'GPT-4.1']);
});
it('reuses one in-flight full refresh when concurrent callers request a rebuild', async () => {
getApiTokenMock.mockResolvedValue(null);
let releaseGate: (() => void) | null = null;
const gate = new Promise<void>((resolve) => {
releaseGate = resolve;
});
getModelsMock.mockImplementation(async () => {
await gate;
return ['gpt-5-nano'];
});
const site = await db.insert(schema.sites).values({
name: 'site-concurrent-refresh',
url: 'https://site-concurrent-refresh.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'concurrent-refresh-user',
accessToken: 'shared-credential',
apiToken: 'shared-credential',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
}).returning().get();
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'shared-credential',
source: 'manual',
enabled: true,
isDefault: true,
}).run();
const firstRefresh = refreshModelsAndRebuildRoutes();
const secondRefresh = refreshModelsAndRebuildRoutes();
await Promise.resolve();
await Promise.resolve();
releaseGate?.();
const results = await Promise.allSettled([firstRefresh, secondRefresh]);
expect(results.every((item) => item.status === 'fulfilled')).toBe(true);
expect(getModelsMock).toHaveBeenCalledTimes(2);
const modelRows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
expect(modelRows.map((row) => row.modelName)).toEqual(['gpt-5-nano']);
const token = await db.select().from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.get();
const tokenRows = await db.select().from(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, token!.id))
.all();
expect(tokenRows.map((row) => row.modelName)).toEqual(['gpt-5-nano']);
});
it('marks runtime health unhealthy when model discovery fails', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockRejectedValue(new Error('HTTP 401: invalid token'));
+21 -1
View File
@@ -59,6 +59,10 @@ const GEMINI_CLI_STATIC_MODELS = [
'gemini-3-flash-preview',
'gemini-3.1-flash-lite-preview',
];
let inFlightRefreshModelsAndRebuildRoutes: Promise<{
refresh: ModelRefreshResult[];
rebuild: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>>;
}> | null = null;
type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown';
type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status';
@@ -1172,8 +1176,24 @@ export async function rebuildTokenRoutesFromAvailability() {
};
}
export async function refreshModelsAndRebuildRoutes() {
async function runRefreshModelsAndRebuildRoutes() {
const refresh = await refreshModelsForAllActiveAccounts();
const rebuild = await rebuildTokenRoutesFromAvailability();
return { refresh, rebuild };
}
export async function refreshModelsAndRebuildRoutes() {
if (inFlightRefreshModelsAndRebuildRoutes) {
return inFlightRefreshModelsAndRebuildRoutes;
}
inFlightRefreshModelsAndRebuildRoutes = (async () => {
try {
return await runRefreshModelsAndRebuildRoutes();
} finally {
inFlightRefreshModelsAndRebuildRoutes = null;
}
})();
return inFlightRefreshModelsAndRebuildRoutes;
}