[codex] Fix mysql insert boundary handling (#364)

* fix mysql insert boundary handling

* fix review comment follow-ups

* fix account insert boundary regressions
This commit is contained in:
Cita
2026-04-01 14:33:45 +08:00
committed by GitHub
parent aac1307470
commit 1e0dffe543
20 changed files with 544 additions and 171 deletions
+19 -20
View File
@@ -1,6 +1,7 @@
import { FastifyInstance } from 'fastify';
import { and, eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { insertAndGetById } from '../../db/insertHelpers.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING,
ACCOUNT_TOKEN_VALUE_STATUS_READY,
@@ -511,26 +512,24 @@ export async function accountTokensRoutes(app: FastifyInstance) {
? (body.isDefault ?? false)
: false;
const inserted = await db.insert(schema.accountTokens).values({
accountId: body.accountId,
name: (body.name || '').trim() || (existing.length === 0 ? 'default' : `token-${existing.length + 1}`),
token: tokenValue,
tokenGroup: (body.group || '').trim() || null,
valueStatus,
source: body.source || 'manual',
enabled,
isDefault,
createdAt: now,
updatedAt: now,
}).run();
const createdId = Number(inserted.lastInsertRowid || 0);
if (createdId <= 0) {
return reply.code(500).send({ success: false, message: '创建令牌失败' });
}
let created = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, createdId)).get();
if (!created) {
return reply.code(500).send({ success: false, message: '创建令牌失败' });
}
let created = await insertAndGetById<typeof schema.accountTokens.$inferSelect>({
table: schema.accountTokens,
idColumn: schema.accountTokens.id,
values: {
accountId: body.accountId,
name: (body.name || '').trim() || (existing.length === 0 ? 'default' : `token-${existing.length + 1}`),
token: tokenValue,
tokenGroup: (body.group || '').trim() || null,
valueStatus,
source: body.source || 'manual',
enabled,
isDefault,
createdAt: now,
updatedAt: now,
},
insertErrorMessage: '创建令牌失败',
loadErrorMessage: '创建令牌失败',
});
if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && (body.isDefault || (existing.length === 0 && enabled))) {
await setDefaultToken(created.id);
@@ -0,0 +1,105 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const loginMock = vi.fn();
const getApiTokenMock = vi.fn();
const getApiTokensMock = vi.fn();
const convergeAccountMutationMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
login: (...args: unknown[]) => loginMock(...args),
getApiToken: (...args: unknown[]) => getApiTokenMock(...args),
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
}),
}));
vi.mock('../../services/accountMutationWorkflow.js', () => ({
convergeAccountMutation: (...args: unknown[]) => convergeAccountMutationMock(...args),
rebuildRoutesBestEffort: vi.fn(),
}));
vi.mock('../../db/insertHelpers.js', async () => {
const actual = await vi.importActual<typeof import('../../db/insertHelpers.js')>('../../db/insertHelpers.js');
return {
...actual,
insertAndGetById: vi.fn(async () => {
throw new Error('account create failed');
}),
};
});
type DbModule = typeof import('../../db/index.js');
describe('accounts login insert boundary', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-login-insert-boundary-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(async () => {
loginMock.mockReset();
getApiTokenMock.mockReset();
getApiTokensMock.mockReset();
convergeAccountMutationMock.mockReset();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
if (dataDir) {
rmSync(dataDir, { recursive: true, force: true });
}
delete process.env.DATA_DIR;
});
it('fails fast when login account creation cannot complete through the shared insert helper', async () => {
loginMock.mockResolvedValue({
success: true,
accessToken: 'session-token',
});
getApiTokenMock.mockResolvedValue(null);
getApiTokensMock.mockResolvedValue([]);
convergeAccountMutationMock.mockResolvedValue(undefined);
const site = await db.insert(schema.sites).values({
name: 'Login Site',
url: 'https://login.example.com',
platform: 'new-api',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts/login',
payload: {
siteId: site.id,
username: 'demo-user',
password: 'demo-password',
},
});
expect(response.statusCode).toBe(500);
expect(await db.select().from(schema.accounts).all()).toHaveLength(0);
expect(convergeAccountMutationMock).not.toHaveBeenCalled();
});
});
+34 -30
View File
@@ -1,5 +1,6 @@
import { FastifyInstance } from 'fastify';
import { db, schema, runtimeDbDialect } from '../../db/index.js';
import { getInsertedRowId, insertAndGetById } from '../../db/insertHelpers.js';
import { and, eq, gte, lt, sql } from 'drizzle-orm';
import { refreshBalance } from '../../services/balanceService.js';
import { getAdapter } from '../../services/platforms/index.js';
@@ -602,18 +603,23 @@ export async function accountsRoutes(app: FastifyInstance) {
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, existing.id)).run();
} else {
const inserted = await db.insert(schema.accounts).values({
siteId,
username,
accessToken: loginResult.accessToken,
apiToken: preferredApiToken || undefined,
checkinEnabled: true,
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
}).run();
const insertedId = Number(inserted.lastInsertRowid || 0);
accountId = insertedId > 0 ? insertedId : undefined;
const created = await insertAndGetById<typeof schema.accounts.$inferSelect>({
table: schema.accounts,
idColumn: schema.accounts.id,
values: {
siteId,
username,
accessToken: loginResult.accessToken,
apiToken: preferredApiToken || undefined,
checkinEnabled: true,
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
},
insertErrorMessage: 'account create failed',
loadErrorMessage: 'account create failed',
});
accountId = created.id;
}
const result = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get();
@@ -1196,24 +1202,22 @@ export async function accountsRoutes(app: FastifyInstance) {
}
const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch);
const inserted = await db.insert(schema.accounts).values({
siteId: body.siteId,
username: username || undefined,
accessToken,
apiToken: apiToken || undefined,
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
}).run();
const insertedId = Number(inserted.lastInsertRowid || 0);
if (insertedId <= 0) {
return reply.code(500).send({ success: false, message: '创建账号失败' });
}
const result = await db.select().from(schema.accounts).where(eq(schema.accounts.id, insertedId)).get();
if (!result) {
return reply.code(500).send({ success: false, message: '创建账号失败' });
}
const result = await insertAndGetById<typeof schema.accounts.$inferSelect>({
table: schema.accounts,
idColumn: schema.accounts.id,
values: {
siteId: body.siteId,
username: username || undefined,
accessToken,
apiToken: apiToken || undefined,
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
},
insertErrorMessage: '创建账号失败',
loadErrorMessage: '创建账号失败',
});
const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true;
let queuedTaskId: string | undefined;
+25 -28
View File
@@ -1,6 +1,7 @@
import { FastifyInstance } from 'fastify';
import { and, eq, inArray, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
import { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js';
import { insertAndGetById } from '../../db/insertHelpers.js';
import {
getDownstreamApiKeyById,
listDownstreamApiKeys,
@@ -477,34 +478,30 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
const nowIso = new Date().toISOString();
try {
const insertedResult = await db.insert(schema.downstreamApiKeys).values({
name: normalized.name,
key: normalized.key,
description: normalized.description,
groupName: normalized.groupName,
tags: toPersistenceJson(normalized.tags),
enabled: normalized.enabled,
expiresAt: normalized.expiresAt,
maxCost: normalized.maxCost,
usedCost: 0,
maxRequests: normalized.maxRequests,
usedRequests: 0,
supportedModels: toPersistenceJson(normalized.supportedModels),
allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds),
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
createdAt: nowIso,
updatedAt: nowIso,
}).run();
const insertedId = Number(insertedResult.lastInsertRowid || 0);
if (insertedId <= 0) {
return reply.code(500).send({ success: false, message: '创建失败' });
}
const inserted = await db.select().from(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, insertedId))
.get();
if (!inserted) {
return reply.code(500).send({ success: false, message: '创建失败' });
}
const inserted = await insertAndGetById<typeof schema.downstreamApiKeys.$inferSelect>({
table: schema.downstreamApiKeys,
idColumn: schema.downstreamApiKeys.id,
values: {
name: normalized.name,
key: normalized.key,
description: normalized.description,
groupName: normalized.groupName,
tags: toPersistenceJson(normalized.tags),
enabled: normalized.enabled,
expiresAt: normalized.expiresAt,
maxCost: normalized.maxCost,
usedCost: 0,
maxRequests: normalized.maxRequests,
usedRequests: 0,
supportedModels: toPersistenceJson(normalized.supportedModels),
allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds),
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
createdAt: nowIso,
updatedAt: nowIso,
},
insertErrorMessage: '创建失败',
loadErrorMessage: '创建失败',
});
return {
success: true,
+21 -22
View File
@@ -1,5 +1,6 @@
import { FastifyInstance, FastifyReply } from 'fastify';
import { db, schema } from '../../db/index.js';
import { insertAndGetById } from '../../db/insertHelpers.js';
import { and, eq } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js';
import { invalidateSiteProxyCache, normalizeSiteUrl, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
@@ -379,36 +380,34 @@ export async function sitesRoutes(app: FastifyInstance) {
let inserted;
try {
inserted = await db.insert(schema.sites).values({
name,
url: canonicalUrl,
platform: detectedPlatform,
proxyUrl: normalizedProxyUrl.proxyUrl,
useSystemProxy: normalizedUseSystemProxy ?? false,
customHeaders: normalizedCustomHeaders.customHeaders,
externalCheckinUrl: normalizedExternalCheckinUrl.url,
status: normalizedStatus ?? 'active',
isPinned: normalizedPinned ?? false,
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
globalWeight: normalizedGlobalWeight ?? 1,
}).run();
inserted = await insertAndGetById<typeof schema.sites.$inferSelect>({
table: schema.sites,
idColumn: schema.sites.id,
values: {
name,
url: canonicalUrl,
platform: detectedPlatform,
proxyUrl: normalizedProxyUrl.proxyUrl,
useSystemProxy: normalizedUseSystemProxy ?? false,
customHeaders: normalizedCustomHeaders.customHeaders,
externalCheckinUrl: normalizedExternalCheckinUrl.url,
status: normalizedStatus ?? 'active',
isPinned: normalizedPinned ?? false,
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
globalWeight: normalizedGlobalWeight ?? 1,
},
insertErrorMessage: 'Create site failed',
loadErrorMessage: 'Create site failed',
});
} catch (error) {
if (isSitesPlatformUrlConflict(error)) {
return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl);
}
throw error;
}
const siteId = Number(inserted.lastInsertRowid || 0);
if (siteId <= 0) {
return reply.code(500).send({ error: 'Create site failed' });
}
const result = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
if (!result) {
return reply.code(500).send({ error: 'Create site failed' });
}
invalidateSiteCaches();
return {
...result,
...inserted,
...(responseInitializationPresetId ? { initializationPresetId: responseInitializationPresetId } : {}),
};
});
+7 -12
View File
@@ -1,6 +1,7 @@
import { FastifyInstance } from 'fastify';
import { FastifyInstance } from 'fastify';
import { and, eq, inArray } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { requireInsertedRowId } from '../../db/insertHelpers.js';
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_READY,
@@ -996,10 +997,7 @@ export async function tokensRoutes(app: FastifyInstance) {
routingStrategy: normalizedRoutingStrategy,
enabled: body.enabled ?? true,
}).run();
const routeId = Number(insertedRoute.lastInsertRowid || 0);
if (routeId <= 0) {
return { success: false, message: '创建路由失败' };
}
const routeId = requireInsertedRowId(insertedRoute, '创建路由失败');
const route = await getRouteWithSources(routeId);
if (!route) {
return { success: false, message: '创建路由失败' };
@@ -1220,13 +1218,10 @@ export async function tokensRoutes(app: FastifyInstance) {
priority: body.priority ?? 0,
weight: body.weight ?? 10,
}).run();
const channelId = Number(insertedChannel.lastInsertRowid || 0);
if (channelId <= 0) {
return reply.code(500).send({ success: false, message: '创建通道失败' });
}
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!created) {
return reply.code(500).send({ success: false, message: '创建通道失败' });
const channelId = requireInsertedRowId(insertedChannel, '创建通道失败');
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!created) {
return reply.code(500).send({ success: false, message: '创建通道失败' });
}
await clearRouteDecisionSnapshot(routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]);