[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:
@@ -58,6 +58,29 @@ describe('db proxy query wrapper', () => {
|
||||
expect(testUtils.shouldWrapObject(Promise.resolve())).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes postgres inserts with returning ids for numeric-id tables', async () => {
|
||||
const query = vi.fn(async () => ({
|
||||
rows: [{ id: 42 }],
|
||||
rowCount: 1,
|
||||
}));
|
||||
const executor = { query };
|
||||
|
||||
const result = await testUtils.pgProxyQuery(
|
||||
executor as any,
|
||||
'insert into "sites" ("name") values ($1)',
|
||||
['demo'],
|
||||
'execute',
|
||||
);
|
||||
|
||||
expect(query).toHaveBeenCalledWith({
|
||||
text: 'insert into "sites" ("name") values ($1) returning id',
|
||||
values: ['demo'],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
rows: [{ changes: 1, lastInsertRowid: 42 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds mysql pool options with jsonStrings enabled', () => {
|
||||
expect(testUtils.buildMysqlPoolOptions('mysql://root:pass@db.example.com:3306/metapi', false)).toMatchObject({
|
||||
uri: 'mysql://root:pass@db.example.com:3306/metapi',
|
||||
|
||||
@@ -1398,6 +1398,7 @@ export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextD
|
||||
export const __dbProxyTestUtils = {
|
||||
wrapQueryLike,
|
||||
shouldWrapObject,
|
||||
pgProxyQuery,
|
||||
resolveSqlitePath,
|
||||
resolveVitestSqlitePath,
|
||||
buildMysqlPoolOptions,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
type DbModule = typeof import('./index.js');
|
||||
type InsertHelpersModule = typeof import('./insertHelpers.js');
|
||||
|
||||
describe('insert helpers', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let insertHelpers: InsertHelpersModule;
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-insert-helpers-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
await import('./migrate.js');
|
||||
const dbModule = await import('./index.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
insertHelpers = await import('./insertHelpers.js');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
if (dataDir) {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts positive inserted ids from run results', () => {
|
||||
expect(insertHelpers.getInsertedRowId({ changes: 1, lastInsertRowid: 12 })).toBe(12);
|
||||
expect(insertHelpers.getInsertedRowId({ changes: 1, lastInsertRowid: 0 })).toBeNull();
|
||||
expect(insertHelpers.getInsertedRowId(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('throws when a required inserted id is missing', () => {
|
||||
expect(() => insertHelpers.requireInsertedRowId({ changes: 1, lastInsertRowid: 0 }, 'missing insert id'))
|
||||
.toThrow('missing insert id');
|
||||
});
|
||||
|
||||
it('inserts and reloads rows through the shared helper', async () => {
|
||||
const created = await insertHelpers.insertAndGetById({
|
||||
table: schema.sites,
|
||||
idColumn: schema.sites.id,
|
||||
values: {
|
||||
name: 'Shared helper site',
|
||||
url: 'https://shared-helper.example.com',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
useSystemProxy: false,
|
||||
isPinned: false,
|
||||
globalWeight: 1,
|
||||
sortOrder: 0,
|
||||
},
|
||||
insertErrorMessage: 'insert site failed',
|
||||
loadErrorMessage: 'load site failed',
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
name: 'Shared helper site',
|
||||
url: 'https://shared-helper.example.com',
|
||||
platform: 'codex',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from './index.js';
|
||||
|
||||
type InsertRunResult = {
|
||||
changes?: number;
|
||||
lastInsertRowid?: number;
|
||||
} | null | undefined;
|
||||
|
||||
export function getInsertedRowId(result: InsertRunResult): number | null {
|
||||
const insertedId = Number(result?.lastInsertRowid || 0);
|
||||
return insertedId > 0 ? insertedId : null;
|
||||
}
|
||||
|
||||
export function requireInsertedRowId(result: InsertRunResult, errorMessage: string): number {
|
||||
const insertedId = getInsertedRowId(result);
|
||||
if (insertedId == null) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return insertedId;
|
||||
}
|
||||
|
||||
export async function insertAndGetById<T>(input: {
|
||||
txDb?: typeof db;
|
||||
table: any;
|
||||
idColumn: any;
|
||||
values: Record<string, unknown>;
|
||||
insertErrorMessage: string;
|
||||
loadErrorMessage?: string;
|
||||
}): Promise<T> {
|
||||
const txDb = input.txDb ?? db;
|
||||
const inserted = await txDb.insert(input.table).values(input.values).run();
|
||||
const insertedId = requireInsertedRowId(inserted, input.insertErrorMessage);
|
||||
const created = await txDb.select().from(input.table).where(eq(input.idColumn, insertedId)).get();
|
||||
if (!created) {
|
||||
throw new Error(input.loadErrorMessage ?? input.insertErrorMessage);
|
||||
}
|
||||
return created as T;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const SERVER_ROOT = fileURLToPath(new URL('../', import.meta.url));
|
||||
const FORBIDDEN_PATTERN = '.returning(';
|
||||
const INSERT_ID_PATTERN = 'lastInsertRowid';
|
||||
|
||||
function collectProductionServerFiles(rootDir: string): string[] {
|
||||
const entries = readdirSync(rootDir).sort();
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = join(rootDir, entry);
|
||||
const stats = statSync(absolutePath);
|
||||
if (stats.isDirectory()) {
|
||||
files.push(...collectProductionServerFiles(absolutePath));
|
||||
continue;
|
||||
}
|
||||
if (!entry.endsWith('.ts')) continue;
|
||||
if (entry.endsWith('.test.ts')) continue;
|
||||
files.push(absolutePath);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
describe('server database dialect architecture boundaries', () => {
|
||||
it('keeps production server code free of drizzle builder returning calls', () => {
|
||||
const rootDir = SERVER_ROOT;
|
||||
const offenders = collectProductionServerFiles(rootDir)
|
||||
.filter((filePath) => readFileSync(filePath, 'utf8').includes(FORBIDDEN_PATTERN))
|
||||
.map((filePath) => relative(rootDir, filePath));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps lastInsertRowid handling inside the db layer', () => {
|
||||
const rootDir = SERVER_ROOT;
|
||||
const offenders = collectProductionServerFiles(rootDir)
|
||||
.filter((filePath) => !relative(rootDir, filePath).split(sep).join('/').startsWith('db/'))
|
||||
.filter((filePath) => readFileSync(filePath, 'utf8').includes(INSERT_ID_PATTERN))
|
||||
.map((filePath) => relative(rootDir, filePath));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getInsertedRowId } from '../db/insertHelpers.js';
|
||||
import { getCredentialModeFromExtraConfig } from './accountExtraConfig.js';
|
||||
|
||||
type UpstreamApiToken = {
|
||||
@@ -215,8 +216,8 @@ export async function ensureDefaultTokenForAccount(
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
const insertedId = Number(inserted.lastInsertRowid || 0);
|
||||
target = insertedId > 0
|
||||
const insertedId = getInsertedRowId(inserted);
|
||||
target = insertedId != null
|
||||
? (await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, insertedId)).get()) ?? null
|
||||
: null;
|
||||
if (!target) return null;
|
||||
@@ -448,8 +449,8 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
const insertedId = Number(inserted.lastInsertRowid || 0);
|
||||
if (insertedId <= 0) continue;
|
||||
const insertedId = getInsertedRowId(inserted);
|
||||
if (insertedId == null) continue;
|
||||
const createdRow = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, insertedId)).get();
|
||||
if (!createdRow) continue;
|
||||
|
||||
@@ -506,4 +507,3 @@ export async function listTokensWithRelations(accountId?: number) {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import cron from 'node-cron';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { requireInsertedRowId } from '../db/insertHelpers.js';
|
||||
import { upsertSetting } from '../db/upsertSetting.js';
|
||||
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
|
||||
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
||||
@@ -1738,8 +1739,12 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
|
||||
allowedRouteIds: row.allowedRouteIds ?? null,
|
||||
siteWeightMultipliers: row.siteWeightMultipliers ?? null,
|
||||
lastUsedAt: runtimeDownstream?.lastUsedAt ?? row.lastUsedAt ?? null,
|
||||
}).returning({ id: schema.downstreamApiKeys.id }).get();
|
||||
downstreamApiKeyIdByKey.set(normalizedKey, insertedKey.id);
|
||||
}).run();
|
||||
const downstreamApiKeyId = requireInsertedRowId(
|
||||
insertedKey,
|
||||
`failed to import downstream api key: ${maskSecret(normalizedKey)}`,
|
||||
);
|
||||
downstreamApiKeyIdByKey.set(normalizedKey, downstreamApiKeyId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getInsertedRowId } from '../db/insertHelpers.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import {
|
||||
ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
||||
@@ -1032,8 +1033,8 @@ export async function rebuildTokenRoutesFromAvailability() {
|
||||
modelPattern: modelName,
|
||||
enabled: true,
|
||||
}).run();
|
||||
const insertedId = Number(inserted.lastInsertRowid || 0);
|
||||
route = insertedId > 0
|
||||
const insertedId = getInsertedRowId(inserted);
|
||||
route = insertedId != null
|
||||
? await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, insertedId)).get()
|
||||
: undefined;
|
||||
if (!route) continue;
|
||||
@@ -1060,8 +1061,8 @@ export async function rebuildTokenRoutesFromAvailability() {
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).run();
|
||||
const insertedId = Number(inserted.lastInsertRowid || 0);
|
||||
if (insertedId <= 0) continue;
|
||||
const insertedId = getInsertedRowId(inserted);
|
||||
if (insertedId == null) continue;
|
||||
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, insertedId)).get();
|
||||
if (!created) continue;
|
||||
channels.push(created);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { insertAndGetById } from '../../db/insertHelpers.js';
|
||||
import { listOAuthProviderDefinitions, type OAuthProviderDefinition } from './providers.js';
|
||||
|
||||
function isUniqueConstraintError(error: unknown): boolean {
|
||||
@@ -26,16 +27,22 @@ export async function ensureOauthProviderSite(definition: OAuthProviderDefinitio
|
||||
if (existing) return existing;
|
||||
|
||||
try {
|
||||
return await db.insert(schema.sites).values({
|
||||
name: definition.site.name,
|
||||
url: definition.site.url,
|
||||
platform: definition.site.platform,
|
||||
status: 'active',
|
||||
useSystemProxy: false,
|
||||
isPinned: false,
|
||||
globalWeight: 1,
|
||||
sortOrder: await getNextSiteSortOrder(),
|
||||
}).returning().get();
|
||||
return await insertAndGetById<typeof schema.sites.$inferSelect>({
|
||||
table: schema.sites,
|
||||
idColumn: schema.sites.id,
|
||||
values: {
|
||||
name: definition.site.name,
|
||||
url: definition.site.url,
|
||||
platform: definition.site.platform,
|
||||
status: 'active',
|
||||
useSystemProxy: false,
|
||||
isPinned: false,
|
||||
globalWeight: 1,
|
||||
sortOrder: await getNextSiteSortOrder(),
|
||||
},
|
||||
insertErrorMessage: `failed to create oauth provider site: ${definition.site.platform}`,
|
||||
loadErrorMessage: `failed to load created oauth provider site: ${definition.site.platform}`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintError(error)) throw error;
|
||||
const recovered = await db.select().from(schema.sites).where(and(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { insertAndGetById } from '../../db/insertHelpers.js';
|
||||
import { getProxyUrlFromExtraConfig, mergeAccountExtraConfig } from '../accountExtraConfig.js';
|
||||
import { refreshModelsForAccount } from '../modelService.js';
|
||||
import * as routeRefreshWorkflow from '../routeRefreshWorkflow.js';
|
||||
@@ -255,20 +256,26 @@ async function upsertOauthAccount(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const created = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username,
|
||||
accessToken: input.exchange.accessToken,
|
||||
apiToken: null,
|
||||
checkinEnabled: false,
|
||||
status: 'active',
|
||||
oauthProvider: input.definition.metadata.provider,
|
||||
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
|
||||
oauthProjectId: oauth.projectId || null,
|
||||
extraConfig,
|
||||
isPinned: false,
|
||||
sortOrder: await getNextAccountSortOrder(),
|
||||
}).returning().get();
|
||||
const created = await insertAndGetById<typeof schema.accounts.$inferSelect>({
|
||||
table: schema.accounts,
|
||||
idColumn: schema.accounts.id,
|
||||
values: {
|
||||
siteId: site.id,
|
||||
username,
|
||||
accessToken: input.exchange.accessToken,
|
||||
apiToken: null,
|
||||
checkinEnabled: false,
|
||||
status: 'active',
|
||||
oauthProvider: input.definition.metadata.provider,
|
||||
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
|
||||
oauthProjectId: oauth.projectId || null,
|
||||
extraConfig,
|
||||
isPinned: false,
|
||||
sortOrder: await getNextAccountSortOrder(),
|
||||
},
|
||||
insertErrorMessage: `failed to create oauth account: ${input.definition.metadata.provider}`,
|
||||
loadErrorMessage: `failed to load created oauth account: ${input.definition.metadata.provider}`,
|
||||
});
|
||||
return { account: created, site, created: true, previousAccount: null };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, asc, desc, eq, lt } from 'drizzle-orm';
|
||||
import { config } from '../config.js';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { requireInsertedRowId } from '../db/insertHelpers.js';
|
||||
import { formatUtcSqlDateTime } from './localTimeService.js';
|
||||
|
||||
type HeadersLike = Headers | Record<string, unknown> | null | undefined;
|
||||
@@ -193,7 +194,7 @@ export async function createProxyDebugTrace(input: {
|
||||
}).run();
|
||||
|
||||
return {
|
||||
id: Number(inserted.lastInsertRowid || 0),
|
||||
id: requireInsertedRowId(inserted, 'failed to create proxy debug trace'),
|
||||
createdAt: now,
|
||||
};
|
||||
}
|
||||
@@ -309,7 +310,7 @@ export async function insertProxyDebugAttempt(input: {
|
||||
}).run();
|
||||
|
||||
return {
|
||||
id: Number(inserted.lastInsertRowid || 0),
|
||||
id: requireInsertedRowId(inserted, 'failed to create proxy debug attempt'),
|
||||
createdAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { requireInsertedRowId } from '../db/insertHelpers.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import { sendNotification } from './notifyService.js';
|
||||
import { formatUtcSqlDateTime } from './localTimeService.js';
|
||||
@@ -130,7 +131,11 @@ export async function syncSiteAnnouncements(options?: { siteId?: number | null }
|
||||
sourceKey: announcement.sourceKey,
|
||||
firstSeenAt: seenAt,
|
||||
...patch,
|
||||
}).returning().get();
|
||||
}).run();
|
||||
const announcementId = requireInsertedRowId(
|
||||
inserted,
|
||||
`failed to create site announcement for site ${site.id}`,
|
||||
);
|
||||
result.inserted += 1;
|
||||
|
||||
const title = `站点公告:${site.name}`;
|
||||
@@ -140,7 +145,7 @@ export async function syncSiteAnnouncements(options?: { siteId?: number | null }
|
||||
title,
|
||||
message,
|
||||
level: announcement.level,
|
||||
relatedId: inserted.id,
|
||||
relatedId: announcementId,
|
||||
relatedType: 'site_announcement',
|
||||
createdAt: seenAt,
|
||||
}).run();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
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('failed to create migrated site account');
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type MigrationModule = typeof import('./siteApiKeyMigrationService.js');
|
||||
|
||||
describe('siteApiKeyMigrationService insert boundary', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let migrateSiteApiKeysToAccounts: MigrationModule['migrateSiteApiKeysToAccounts'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-api-key-migration-boundary-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
const migrationModule = await import('./siteApiKeyMigrationService.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
migrateSiteApiKeysToAccounts = migrationModule.migrateSiteApiKeysToAccounts;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.accountTokens).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (dataDir) {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('does not clear the site apiKey when creating the replacement account cannot complete', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'boundary-site',
|
||||
url: 'https://boundary.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-boundary-site-token',
|
||||
}).returning().get();
|
||||
|
||||
await expect(migrateSiteApiKeysToAccounts()).rejects.toThrow('failed to create migrated site account');
|
||||
|
||||
const persistedSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get();
|
||||
expect(persistedSite?.apiKey).toBe('sk-boundary-site-token');
|
||||
expect(await db.select().from(schema.accounts).all()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { insertAndGetById } from '../db/insertHelpers.js';
|
||||
import { getCredentialModeFromExtraConfig, mergeAccountExtraConfig } from './accountExtraConfig.js';
|
||||
|
||||
export type SiteApiKeyMigrationSummary = {
|
||||
@@ -58,26 +59,26 @@ export async function migrateSiteApiKeysToAccounts(): Promise<SiteApiKeyMigratio
|
||||
if (targetAccount) {
|
||||
summary.deduped += 1;
|
||||
} else {
|
||||
const inserted = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: null,
|
||||
accessToken: '',
|
||||
apiToken: siteApiKey,
|
||||
checkinEnabled: false,
|
||||
status: 'active',
|
||||
extraConfig: mergeAccountExtraConfig(undefined, { credentialMode: 'apikey' }),
|
||||
isPinned: false,
|
||||
sortOrder: nextSortOrder,
|
||||
}).run();
|
||||
const accountId = Number(inserted.lastInsertRowid || 0);
|
||||
if (accountId > 0) {
|
||||
targetAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get() || null;
|
||||
if (targetAccount) {
|
||||
accounts.push(targetAccount);
|
||||
nextSortOrder += 1;
|
||||
summary.migrated += 1;
|
||||
}
|
||||
}
|
||||
targetAccount = await insertAndGetById<typeof schema.accounts.$inferSelect>({
|
||||
table: schema.accounts,
|
||||
idColumn: schema.accounts.id,
|
||||
values: {
|
||||
siteId: site.id,
|
||||
username: null,
|
||||
accessToken: '',
|
||||
apiToken: siteApiKey,
|
||||
checkinEnabled: false,
|
||||
status: 'active',
|
||||
extraConfig: mergeAccountExtraConfig(undefined, { credentialMode: 'apikey' }),
|
||||
isPinned: false,
|
||||
sortOrder: nextSortOrder,
|
||||
},
|
||||
insertErrorMessage: 'failed to create migrated site account',
|
||||
loadErrorMessage: 'failed to create migrated site account',
|
||||
});
|
||||
accounts.push(targetAccount);
|
||||
nextSortOrder += 1;
|
||||
summary.migrated += 1;
|
||||
}
|
||||
|
||||
if (targetAccount) {
|
||||
|
||||
Reference in New Issue
Block a user