feat: implement proxy input file resolution and storage

- Added `inputFiles.ts` for handling input file resolution, including validation and normalization of file types.
- Introduced `proxyFileStore.ts` for managing file storage, retrieval, and deletion with owner-based isolation.
- Created tests for proxy file storage functionality in `proxyFileStore.test.ts`.
- Developed `proxyInputFileResolver.ts` to resolve input files in OpenAI and response formats.
- Added utility functions for handling reasoning signatures in `reasoningTransport.ts` and corresponding tests.
- Enhanced shared input file transformers in `inputFile.ts` for better file handling and type inference.
This commit is contained in:
cita
2026-03-08 22:10:31 +08:00
parent ede8998244
commit 7bd3114ea0
54 changed files with 4504 additions and 164 deletions
+206
View File
@@ -0,0 +1,206 @@
import { and, desc, eq, isNull, or } from 'drizzle-orm';
import { createHash } from 'node:crypto';
import { db, schema } from '../db/index.js';
import type { ProxyResourceOwner } from '../middleware/auth.js';
import { formatUtcSqlDateTime } from './localTimeService.js';
export const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-';
export type ProxyFileRecord = {
publicId: string;
ownerType: ProxyResourceOwner['ownerType'];
ownerId: string;
filename: string;
mimeType: string;
purpose: string | null;
byteSize: number;
sha256: string;
contentBase64: string;
createdAt: string | null;
updatedAt: string | null;
deletedAt: string | null;
};
export type SaveProxyFileInput = ProxyResourceOwner & {
filename: string;
mimeType: string;
purpose?: string | null;
contentBase64: string;
byteSize?: number | null;
sha256?: string | null;
};
export type CreateProxyFileInput = {
owner: ProxyResourceOwner;
filename: string;
mimeType: string;
purpose?: string | null;
buffer: Buffer;
};
function buildPublicFileId(): string {
const timePart = Date.now().toString(36);
const randomPart = Math.random().toString(36).slice(2, 10);
return `${LOCAL_PROXY_FILE_ID_PREFIX}${timePart}-${randomPart}`;
}
function normalizeFilename(value: string): string {
const trimmed = value.trim();
return trimmed || 'upload.bin';
}
function toByteSize(contentBase64: string): number {
return Buffer.from(contentBase64, 'base64').byteLength;
}
function rowToRecord(row: typeof schema.proxyFiles.$inferSelect): ProxyFileRecord {
return {
publicId: row.publicId,
ownerType: row.ownerType as ProxyResourceOwner['ownerType'],
ownerId: row.ownerId,
filename: row.filename,
mimeType: row.mimeType,
purpose: row.purpose,
byteSize: row.byteSize,
sha256: row.sha256,
contentBase64: row.contentBase64,
createdAt: row.createdAt ?? null,
updatedAt: row.updatedAt ?? null,
deletedAt: row.deletedAt ?? null,
};
}
function ownerWhere(owner: ProxyResourceOwner) {
return and(
eq(schema.proxyFiles.ownerType, owner.ownerType),
eq(schema.proxyFiles.ownerId, owner.ownerId),
);
}
export async function saveProxyFile(input: SaveProxyFileInput): Promise<ProxyFileRecord> {
const publicId = buildPublicFileId();
const now = formatUtcSqlDateTime(new Date());
const filename = normalizeFilename(input.filename);
const byteSize = typeof input.byteSize === 'number' && Number.isFinite(input.byteSize)
? Math.max(0, Math.trunc(input.byteSize))
: toByteSize(input.contentBase64);
const sha256 = typeof input.sha256 === 'string' && input.sha256.trim().length > 0
? input.sha256.trim()
: createHash('sha256').update(Buffer.from(input.contentBase64, 'base64')).digest('hex');
await db.insert(schema.proxyFiles).values({
publicId,
ownerType: input.ownerType,
ownerId: input.ownerId,
filename,
mimeType: input.mimeType,
purpose: input.purpose?.trim() || null,
byteSize,
sha256,
contentBase64: input.contentBase64,
createdAt: now,
updatedAt: now,
deletedAt: null,
}).run();
return (await getProxyFileByPublicIdForOwner(publicId, input))!;
}
export async function createProxyFile(input: CreateProxyFileInput): Promise<ProxyFileRecord> {
return saveProxyFile({
ownerType: input.owner.ownerType,
ownerId: input.owner.ownerId,
filename: input.filename,
mimeType: input.mimeType,
purpose: input.purpose,
byteSize: input.buffer.byteLength,
sha256: createHash('sha256').update(input.buffer).digest('hex'),
contentBase64: input.buffer.toString('base64'),
});
}
export async function listProxyFilesByOwner(owner: ProxyResourceOwner): Promise<ProxyFileRecord[]> {
const rows = await db.select().from(schema.proxyFiles)
.where(and(ownerWhere(owner), isNull(schema.proxyFiles.deletedAt)))
.orderBy(desc(schema.proxyFiles.createdAt))
.all();
return rows.map(rowToRecord);
}
export async function getProxyFileByPublicId(publicId: string): Promise<ProxyFileRecord | null> {
const row = await db.select().from(schema.proxyFiles)
.where(and(
eq(schema.proxyFiles.publicId, publicId),
isNull(schema.proxyFiles.deletedAt),
))
.get();
return row ? rowToRecord(row) : null;
}
export async function getProxyFileByPublicIdForOwner(
publicId: string,
owner: ProxyResourceOwner,
): Promise<ProxyFileRecord | null> {
const row = await db.select().from(schema.proxyFiles)
.where(and(
eq(schema.proxyFiles.publicId, publicId),
ownerWhere(owner),
isNull(schema.proxyFiles.deletedAt),
))
.get();
return row ? rowToRecord(row) : null;
}
export async function getProxyFileContentByPublicIdForOwner(
publicId: string,
owner: ProxyResourceOwner,
): Promise<{ filename: string; mimeType: string; buffer: Buffer } | null> {
const record = await getProxyFileByPublicIdForOwner(publicId, owner);
if (!record) return null;
return {
filename: record.filename,
mimeType: record.mimeType,
buffer: Buffer.from(record.contentBase64, 'base64'),
};
}
export async function softDeleteProxyFileByPublicIdForOwner(
publicId: string,
owner: ProxyResourceOwner,
): Promise<boolean> {
const now = formatUtcSqlDateTime(new Date());
const result = await db.update(schema.proxyFiles)
.set({
deletedAt: now,
updatedAt: now,
})
.where(and(
eq(schema.proxyFiles.publicId, publicId),
ownerWhere(owner),
or(isNull(schema.proxyFiles.deletedAt), eq(schema.proxyFiles.deletedAt, '')),
))
.run();
return Number(result?.changes || 0) > 0;
}
export async function softDeleteProxyFileByPublicId(
publicId: string,
owner: ProxyResourceOwner,
): Promise<boolean> {
return softDeleteProxyFileByPublicIdForOwner(publicId, owner);
}
export async function softDeleteProxyFile(publicId: string): Promise<boolean> {
const now = formatUtcSqlDateTime(new Date());
const result = await db.update(schema.proxyFiles)
.set({
deletedAt: now,
updatedAt: now,
})
.where(and(
eq(schema.proxyFiles.publicId, publicId),
or(isNull(schema.proxyFiles.deletedAt), eq(schema.proxyFiles.deletedAt, '')),
))
.run();
return Number(result?.changes || 0) > 0;
}