From 03bebae17fb1863a785350278aa75ac19882487f Mon Sep 17 00:00:00 2001 From: Cita Date: Thu, 9 Apr 2026 11:15:57 +0800 Subject: [PATCH] fix oauth route unit delete rollback (#450) --- src/server/routes/api/oauth.test.ts | 91 +++++++++++++++++++ src/server/services/oauth/routeUnitService.ts | 30 ++++++ 2 files changed, 121 insertions(+) diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts index 990632a..d9d1de3 100644 --- a/src/server/routes/api/oauth.test.ts +++ b/src/server/routes/api/oauth.test.ts @@ -3246,6 +3246,97 @@ describe('oauth routes', { timeout: 15_000 }, () => { } }); + it('restores oauth route unit route channels when delete rebuild keeps failing', async () => { + const site = await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const first = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'rollback-delete-double-fail-a@example.com', + accessToken: 'rollback-delete-double-fail-access-a', + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'rollback-delete-double-fail-a', + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + accountId: 'rollback-delete-double-fail-a', + accountKey: 'rollback-delete-double-fail-a', + email: 'rollback-delete-double-fail-a@example.com', + refreshToken: 'rollback-delete-double-fail-refresh-a', + }, + }), + }).returning().get(); + const second = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'rollback-delete-double-fail-b@example.com', + accessToken: 'rollback-delete-double-fail-access-b', + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'rollback-delete-double-fail-b', + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + accountId: 'rollback-delete-double-fail-b', + accountKey: 'rollback-delete-double-fail-b', + email: 'rollback-delete-double-fail-b@example.com', + refreshToken: 'rollback-delete-double-fail-refresh-b', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values([ + { accountId: first.id, modelName: 'gpt-5.4', available: true }, + { accountId: second.id, modelName: 'gpt-5.4', available: true }, + ]).run(); + + const routeRefreshWorkflow = await import('../../services/routeRefreshWorkflow.js'); + await routeRefreshWorkflow.rebuildRoutesOnly(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/route-units', + payload: { + accountIds: [first.id, second.id], + name: 'Rollback Delete Double Fail Pool', + strategy: 'round_robin', + }, + }); + expect(createResponse.statusCode).toBe(200); + const routeUnitId = createResponse.json().routeUnit?.id as number; + + const rebuildSpy = vi.spyOn(routeRefreshWorkflow, 'rebuildRoutesOnly'); + rebuildSpy.mockImplementation(async () => { + throw new Error('route rebuild failed'); + }); + + try { + const response = await app.inject({ + method: 'DELETE', + url: `/api/oauth/route-units/${routeUnitId}`, + }); + + expect(response.statusCode).toBe(500); + expect(response.json()).toMatchObject({ + message: 'route rebuild failed', + }); + + const routeUnits = await db.select().from(schema.oauthRouteUnits).all(); + expect(routeUnits).toHaveLength(1); + const members = await db.select().from(schema.oauthRouteUnitMembers).all(); + expect(members).toHaveLength(2); + const routeChannels = await db.select().from(schema.routeChannels).all(); + expect(routeChannels).toHaveLength(1); + expect(routeChannels[0]?.oauthRouteUnitId).toBe(routeUnitId); + } finally { + rebuildSpy.mockRestore(); + } + }); + it('accepts normalized route unit strategy values from the HTTP payload', async () => { const site = await db.insert(schema.sites).values({ name: 'ChatGPT Codex OAuth', diff --git a/src/server/services/oauth/routeUnitService.ts b/src/server/services/oauth/routeUnitService.ts index ec5101a..6f10289 100644 --- a/src/server/services/oauth/routeUnitService.ts +++ b/src/server/services/oauth/routeUnitService.ts @@ -153,6 +153,7 @@ async function rollbackCreatedOauthRouteUnit(routeUnitId: number): Promise async function restoreDeletedOauthRouteUnit(snapshot: { unit: typeof schema.oauthRouteUnits.$inferSelect; members: Array; + channels: Array; }): Promise { await db.transaction(async (tx) => { await tx.insert(schema.oauthRouteUnits).values({ @@ -176,6 +177,31 @@ async function restoreDeletedOauthRouteUnit(snapshot: { updatedAt: member.updatedAt, }))).run(); } + + if (snapshot.channels.length > 0) { + await tx.insert(schema.routeChannels).values(snapshot.channels.map((channel) => ({ + id: channel.id, + routeId: channel.routeId, + accountId: channel.accountId, + tokenId: channel.tokenId, + oauthRouteUnitId: channel.oauthRouteUnitId, + sourceModel: channel.sourceModel, + priority: channel.priority, + weight: channel.weight, + enabled: channel.enabled, + manualOverride: channel.manualOverride, + successCount: channel.successCount, + failCount: channel.failCount, + totalLatencyMs: channel.totalLatencyMs, + totalCost: channel.totalCost, + lastUsedAt: channel.lastUsedAt, + lastSelectedAt: channel.lastSelectedAt, + lastFailAt: channel.lastFailAt, + consecutiveFailCount: channel.consecutiveFailCount, + cooldownLevel: channel.cooldownLevel, + cooldownUntil: channel.cooldownUntil, + }))).run(); + } }); } @@ -373,6 +399,9 @@ export async function deleteOauthRouteUnit(routeUnitId: number) { const existingMembers = await db.select().from(schema.oauthRouteUnitMembers) .where(eq(schema.oauthRouteUnitMembers.unitId, routeUnitId)) .all(); + const existingChannels = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.oauthRouteUnitId, routeUnitId)) + .all(); await db.delete(schema.routeChannels) .where(eq(schema.routeChannels.oauthRouteUnitId, routeUnitId)) @@ -390,6 +419,7 @@ export async function deleteOauthRouteUnit(routeUnitId: number) { await restoreDeletedOauthRouteUnit({ unit: existing, members: existingMembers, + channels: existingChannels, }); try { await routeRefreshWorkflow.rebuildRoutesOnly();