[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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user