Files
miaojingAI/src/app/api/admin/data-import/route.ts

585 lines
27 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/admin-auth';
import { localStorage } from '@/lib/local-storage';
import { encryptSecret, previewSecret } from '@/lib/server-crypto';
import { getDbClient } from '@/storage/database/local-db';
interface ImportMeta {
version: string;
platform: string;
exported_at: string;
tables: string[];
counts: Record<string, number>;
}
interface ImportPayload {
_meta: ImportMeta;
data: Record<string, unknown[]>;
options?: {
skipAuth?: boolean;
};
}
const MAX_ROWS_PER_TABLE = 5000;
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const UUID_ID_TABLES = new Set([
'auth.users',
'profiles',
'announcements',
'works',
'credit_transactions',
'orders',
'user_api_keys',
'system_api_configs',
'work_likes',
]);
const TABLE_COLUMNS: Record<string, string[]> = {
profiles: ['id', 'email', 'nickname', 'avatar_url', 'phone', 'role', 'membership_tier', 'membership_expires_at', 'credits_balance', 'daily_quota_used', 'daily_quota_limit', 'is_active', 'preferred_theme', 'created_at', 'updated_at'],
site_config: ['id', 'site_name', 'site_tab_title', 'site_description', 'site_keywords', 'logo_url', 'favicon_url', 'announcement', 'membership_enabled', 'terms_of_service', 'privacy_policy', 'about_us', 'help_center', 'filing_info', 'filing_url', 'public_security_filing_info', 'public_security_filing_url', 'updated_at'],
site_stats: ['id', 'total_visits', 'total_users', 'total_generations', 'updated_at'],
announcements: ['id', 'title', 'content', 'type', 'is_active', 'starts_at', 'expires_at', 'created_at', 'updated_at'],
works: ['id', 'user_id', 'title', 'type', 'prompt', 'negative_prompt', 'params', 'result_url', 'thumbnail_url', 'width', 'height', 'duration', 'status', 'is_public', 'likes_count', 'views_count', 'created_at', 'updated_at'],
credit_transactions: ['id', 'user_id', 'amount', 'balance_after', 'type', 'description', 'related_work_id', 'created_at'],
orders: ['id', 'user_id', 'order_no', 'product_type', 'product_name', 'amount', 'credits_amount', 'status', 'payment_method', 'paid_at', 'created_at', 'updated_at'],
user_api_keys: ['id', 'user_id', 'provider', 'supplier_name', 'api_url', 'model_name', 'note', 'api_key_encrypted', 'api_key_preview', 'type', 'is_active', 'created_at', 'updated_at'],
system_api_configs: ['id', 'provider', 'name', 'api_url', 'model_name', 'note', 'api_key_encrypted', 'api_key_preview', 'type', 'credits_per_use', 'is_active', 'sort_order', 'created_at', 'updated_at'],
payment_methods: ['id', 'type', 'name', 'is_active', 'public_config', 'secret_config_encrypted', 'secret_config_preview', 'created_at', 'updated_at'],
work_likes: ['id', 'user_id', 'work_id', 'created_at'],
};
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
const AUTH_USER_COLUMNS = ['id', 'email', 'created_at', 'raw_user_meta_data', 'password_hash'];
const CONFLICT_COLUMNS: Record<string, string[]> = {
'auth.users': ['id'],
profiles: ['id'],
site_config: ['id'],
site_stats: ['id'],
announcements: ['id'],
works: ['id'],
credit_transactions: ['id'],
orders: ['id'],
user_api_keys: ['id'],
system_api_configs: ['id'],
payment_methods: ['id'],
work_likes: ['id'],
};
type ImportResult = { imported: number; skipped: number; errors: string[] };
type ImportContext = {
userIdMap: Map<string, string>;
workIdMap: Map<string, string>;
emailUserIdMap: Map<string, string>;
apiKeyIdMap: Map<string, string>;
apiKeyOwnerIdMap: Map<string, string>;
columnCache: Map<string, Set<string>>;
};
export async function POST(request: NextRequest) {
const authError = await requireAdmin(request);
if (authError) return authError;
try {
const body: ImportPayload = await request.json();
const { _meta, data } = body;
const skipAuth = body.options?.skipAuth === true;
if (!_meta || _meta.platform !== 'miaojing' || !data || typeof data !== 'object') {
return NextResponse.json({ error: '无效的导入文件:格式不匹配' }, { status: 400 });
}
const client = await getDbClient();
const result: Record<string, ImportResult> = {};
try {
const context = await buildImportContext(client, data);
if (!skipAuth && Array.isArray(data.auth_users)) {
result.auth_users = await importRows(client, 'auth.users', AUTH_USER_COLUMNS, data.auth_users, context);
} else {
result.auth_users = {
imported: 0,
skipped: Array.isArray(data.auth_users) ? data.auth_users.length : 0,
errors: skipAuth ? ['已按选项跳过认证账号导入'] : [],
};
}
for (const [table, allowedColumns] of Object.entries(TABLE_COLUMNS)) {
const rows = data[table];
result[table] = await importRows(client, table, allowedColumns, Array.isArray(rows) ? rows : [], context);
}
return NextResponse.json({ success: true, message: '数据导入完成', details: result, meta: _meta });
} finally {
client.release();
}
} catch (err) {
console.error('[data-import] Error:', err instanceof Error ? err.message : err);
return NextResponse.json({ error: err instanceof Error ? err.message : '导入失败' }, { status: 500 });
}
}
async function importRows(
client: Awaited<ReturnType<typeof getDbClient>>,
table: string,
allowedColumns: string[],
rows: unknown[],
context: ImportContext,
): Promise<ImportResult> {
if (rows.length > MAX_ROWS_PER_TABLE) {
return { imported: 0, skipped: rows.length, errors: [`${table}: 单表最多允许导入 ${MAX_ROWS_PER_TABLE}`] };
}
let imported = 0;
let skipped = 0;
const errors: string[] = [];
const existingColumns = await getExistingColumns(client, table, context);
const effectiveAllowedColumns = allowedColumns.filter(col => existingColumns.has(col));
for (const rawRow of rows) {
const row = await normalizeImportRow(table, rawRow as Record<string, unknown>, context);
const cols = Object.keys(row).filter(col => effectiveAllowedColumns.includes(col));
if (!cols.includes('id') || cols.length === 0) {
skipped++;
errors.push(`${table}: 缺少 id 或没有允许导入的字段`);
continue;
}
try {
const vals = cols.map(col => row[col]);
const placeholders = cols.map((_, i) => `$${i + 1}`).join(', ');
const conflictCols = CONFLICT_COLUMNS[table] || ['id'];
const mergeAssignments = getMergeAssignments(table, cols);
const conflictAction = mergeAssignments.length > 0
? `DO UPDATE SET ${mergeAssignments.join(', ')}`
: 'DO NOTHING';
const insertResult = await client.query(
`INSERT INTO ${table} AS target (${cols.join(', ')}) VALUES (${placeholders}) ON CONFLICT (${conflictCols.join(', ')}) ${conflictAction}`,
vals,
);
if ((insertResult.rowCount || 0) > 0) {
imported++;
} else {
skipped++;
}
} catch (e) {
skipped++;
errors.push(`${table}: ${e instanceof Error ? e.message : 'unknown error'}`);
}
}
return { imported, skipped, errors };
}
async function buildImportContext(
client: Awaited<ReturnType<typeof getDbClient>>,
data: Record<string, unknown[]>,
): Promise<ImportContext> {
const userIdMap = new Map<string, string>();
const workIdMap = new Map<string, string>();
const emailUserIdMap = new Map<string, string>();
const apiKeyIdMap = new Map<string, string>();
const apiKeyOwnerIdMap = new Map<string, string>();
const profileRows = Array.isArray(data.profiles) ? data.profiles : [];
const authRows = Array.isArray(data.auth_users) ? data.auth_users : [];
const profileEmails = new Map<string, string>();
for (const raw of profileRows) {
const row = raw as Record<string, unknown>;
seedUuidMap(userIdMap, row.id);
if (typeof row.id === 'string' && typeof row.email === 'string' && row.email.trim()) {
const email = row.email.trim().toLowerCase();
profileEmails.set(email, row.id);
emailUserIdMap.set(email, userIdMap.get(row.id) || row.id);
}
}
for (const raw of authRows) {
const row = raw as Record<string, unknown>;
seedUuidMap(userIdMap, row.id);
if (typeof row.id === 'string' && typeof row.email === 'string' && row.email.trim() && !profileEmails.has(row.email.trim().toLowerCase())) {
const email = row.email.trim().toLowerCase();
profileEmails.set(email, row.id);
emailUserIdMap.set(email, userIdMap.get(row.id) || row.id);
}
}
if (profileEmails.size > 0) {
const emails = [...profileEmails.keys()];
const existing = await client.query(
'SELECT id, lower(email) AS email FROM profiles WHERE lower(email) = ANY($1)',
[emails],
);
for (const row of existing.rows) {
const importedId = profileEmails.get(row.email);
if (importedId && importedId !== row.id) {
userIdMap.set(importedId, row.id);
emailUserIdMap.set(row.email, row.id);
}
}
}
for (const [email, importedId] of profileEmails.entries()) {
emailUserIdMap.set(email, userIdMap.get(importedId) || importedId);
}
const apiKeyRows = Array.isArray(data.user_api_keys) ? data.user_api_keys : [];
for (const raw of apiKeyRows) {
const row = raw as Record<string, unknown>;
const oldId = typeof row.id === 'string' && row.id.trim() ? row.id.trim() : '';
if (oldId) {
apiKeyIdMap.set(oldId, isUuid(oldId) ? oldId : crypto.randomUUID());
}
const ownerId = findImportedWorkUserId(row);
const ownerByEmail = findUserIdByEmail(row, { userIdMap, workIdMap, emailUserIdMap, apiKeyIdMap, apiKeyOwnerIdMap, columnCache: new Map() });
const mappedOwnerId = ownerId
? (userIdMap.get(ownerId) || ownerId)
: ownerByEmail;
if (oldId && mappedOwnerId) {
apiKeyOwnerIdMap.set(oldId, mappedOwnerId);
}
}
const works = Array.isArray(data.works) ? data.works : [];
const workUrls = new Map<string, string>();
for (const raw of works) {
const row = raw as Record<string, unknown>;
seedUuidMap(workIdMap, row.id);
if (typeof row.id === 'string' && typeof row.result_url === 'string' && row.result_url.trim() && !isDataUrl(row.result_url)) {
workUrls.set(row.result_url.trim(), row.id);
}
}
if (workUrls.size > 0) {
const existing = await client.query(
'SELECT id, result_url FROM works WHERE result_url = ANY($1)',
[[...workUrls.keys()]],
);
for (const row of existing.rows) {
const importedId = workUrls.get(row.result_url);
if (importedId && importedId !== row.id) {
workIdMap.set(importedId, row.id);
}
}
}
return { userIdMap, workIdMap, emailUserIdMap, apiKeyIdMap, apiKeyOwnerIdMap, columnCache: new Map() };
}
async function normalizeImportRow(table: string, row: Record<string, unknown>, context: ImportContext): Promise<Record<string, unknown>> {
const next = { ...row };
if (typeof next.user_id === 'string' && context.userIdMap.has(next.user_id)) {
next.user_id = context.userIdMap.get(next.user_id);
}
if ((!next.user_id || next.user_id === SYSTEM_USER_ID) && findUserIdByEmail(next, context)) {
next.user_id = findUserIdByEmail(next, context);
}
if (typeof next.related_work_id === 'string' && context.workIdMap.has(next.related_work_id)) {
next.related_work_id = context.workIdMap.get(next.related_work_id);
}
if (typeof next.work_id === 'string' && context.workIdMap.has(next.work_id)) {
next.work_id = context.workIdMap.get(next.work_id);
}
if (table === 'auth.users' || table === 'profiles') {
const currentId = typeof next.id === 'string' ? next.id : '';
if (currentId && context.userIdMap.has(currentId)) {
next.id = context.userIdMap.get(currentId);
}
}
if (table === 'user_api_keys') {
const currentId = typeof next.id === 'string' ? next.id : '';
if (currentId && context.apiKeyIdMap.has(currentId)) {
next.id = context.apiKeyIdMap.get(currentId);
}
const importedUserId = findImportedWorkUserId(next);
const emailUserId = findUserIdByEmail(next, context);
if (importedUserId || emailUserId) {
next.user_id = importedUserId
? (context.userIdMap.get(importedUserId) || importedUserId)
: emailUserId;
}
}
if (table === 'works') {
const currentId = typeof next.id === 'string' ? next.id : '';
if (currentId && context.workIdMap.has(currentId)) {
next.id = context.workIdMap.get(currentId);
}
const importedUserId = findImportedWorkUserId(next) || findUserIdByEmail(next, context) || findUserIdByCustomModel(next, context);
if (importedUserId) {
next.user_id = context.userIdMap.get(importedUserId) || importedUserId;
}
if (typeof next.result_url === 'string') {
next.result_url = await persistImportMedia(next.result_url, getWorkMediaFolder(next.type, 'results'));
}
if (typeof next.thumbnail_url === 'string') {
next.thumbnail_url = await persistImportMedia(next.thumbnail_url, 'imported/works/thumbnails');
}
if (next.params && typeof next.params === 'object') {
next.params = await sanitizeImportMedia(next.params, 'imported/works/references');
remapCustomModelId(next.params as Record<string, unknown>, context);
if ((!next.user_id || next.user_id === SYSTEM_USER_ID) && findUserIdByCustomModel(next, context)) {
next.user_id = findUserIdByCustomModel(next, context);
}
}
}
if (table === 'user_api_keys') {
const rawEncrypted = typeof next.api_key_encrypted === 'string' ? next.api_key_encrypted.trim() : '';
const rawApiKey = typeof next.apiKey === 'string' ? next.apiKey.trim() : '';
const secret = rawApiKey || rawEncrypted;
if (secret) {
next.api_key_encrypted = encryptSecret(secret);
next.api_key_preview = typeof next.api_key_preview === 'string' && next.api_key_preview
? next.api_key_preview
: previewSecret(secret);
}
}
if (UUID_ID_TABLES.has(table)) {
const currentId = typeof next.id === 'string' ? next.id : '';
if (!isUuid(currentId)) {
next.id = crypto.randomUUID();
}
}
return next;
}
function findImportedWorkUserId(row: Record<string, unknown>): string | null {
const directKeys = ['user_id', 'userId', 'publisher_id', 'publisherId', 'owner_id', 'ownerId', 'created_by', 'createdBy'];
for (const key of directKeys) {
const value = row[key];
if (typeof value === 'string' && value.trim() && value !== 'anonymous' && value !== '00000000-0000-0000-0000-000000000000') {
return value.trim();
}
}
const params = row.params && typeof row.params === 'object' ? row.params as Record<string, unknown> : null;
if (!params) return null;
for (const key of directKeys) {
const value = params[key];
if (typeof value === 'string' && value.trim() && value !== 'anonymous' && value !== '00000000-0000-0000-0000-000000000000') {
return value.trim();
}
}
return null;
}
function findUserIdByEmail(row: Record<string, unknown>, context: ImportContext): string | null {
const directKeys = ['email', 'user_email', 'userEmail', 'publisher_email', 'publisherEmail', 'owner_email', 'ownerEmail'];
for (const key of directKeys) {
const value = row[key];
if (typeof value === 'string' && value.trim()) {
const mapped = context.emailUserIdMap.get(value.trim().toLowerCase());
if (mapped) return mapped;
}
}
const params = row.params && typeof row.params === 'object' ? row.params as Record<string, unknown> : null;
if (!params) return null;
for (const key of directKeys) {
const value = params[key];
if (typeof value === 'string' && value.trim()) {
const mapped = context.emailUserIdMap.get(value.trim().toLowerCase());
if (mapped) return mapped;
}
}
return null;
}
function findUserIdByCustomModel(row: Record<string, unknown>, context: ImportContext): string | null {
const params = row.params && typeof row.params === 'object' ? row.params as Record<string, unknown> : null;
const model = typeof params?.model === 'string'
? params.model
: typeof row.model === 'string'
? row.model
: '';
if (!model.startsWith('custom:')) return null;
const oldId = model.slice('custom:'.length);
return context.apiKeyOwnerIdMap.get(oldId) || null;
}
function remapCustomModelId(params: Record<string, unknown>, context: ImportContext): void {
const model = typeof params.model === 'string' ? params.model : '';
if (!model.startsWith('custom:')) return;
const oldId = model.slice('custom:'.length);
const newId = context.apiKeyIdMap.get(oldId);
if (newId) {
params.model = `custom:${newId}`;
}
}
function getMergeAssignments(table: string, cols: string[]): string[] {
const has = (column: string) => cols.includes(column);
const assignments: string[] = [];
if (table === 'auth.users') {
if (has('email')) assignments.push(`email = COALESCE(NULLIF(target.email, ''), EXCLUDED.email)`);
if (has('raw_user_meta_data')) assignments.push(`raw_user_meta_data = COALESCE(target.raw_user_meta_data, EXCLUDED.raw_user_meta_data)`);
if (has('password_hash')) assignments.push(`password_hash = COALESCE(NULLIF(target.password_hash, ''), EXCLUDED.password_hash)`);
return assignments;
}
if (table === 'profiles') {
if (has('email')) assignments.push(`email = COALESCE(NULLIF(target.email, ''), EXCLUDED.email)`);
if (has('nickname')) assignments.push(`nickname = COALESCE(NULLIF(target.nickname, ''), EXCLUDED.nickname)`);
if (has('avatar_url')) assignments.push(`avatar_url = COALESCE(NULLIF(target.avatar_url, ''), EXCLUDED.avatar_url)`);
if (has('phone')) assignments.push(`phone = COALESCE(NULLIF(target.phone, ''), EXCLUDED.phone)`);
if (has('role')) assignments.push(`role = CASE WHEN target.role = 'admin' THEN target.role ELSE COALESCE(NULLIF(target.role, ''), EXCLUDED.role) END`);
if (has('membership_tier')) assignments.push(`membership_tier = COALESCE(NULLIF(target.membership_tier, ''), EXCLUDED.membership_tier)`);
if (has('membership_expires_at')) assignments.push(`membership_expires_at = COALESCE(target.membership_expires_at, EXCLUDED.membership_expires_at)`);
if (has('credits_balance')) assignments.push(`credits_balance = COALESCE(target.credits_balance, EXCLUDED.credits_balance)`);
if (has('daily_quota_limit')) assignments.push(`daily_quota_limit = COALESCE(target.daily_quota_limit, EXCLUDED.daily_quota_limit)`);
if (has('is_active')) assignments.push(`is_active = COALESCE(target.is_active, EXCLUDED.is_active)`);
if (has('preferred_theme')) assignments.push(`preferred_theme = CASE WHEN EXCLUDED.preferred_theme IN ('dark', 'light') THEN EXCLUDED.preferred_theme ELSE target.preferred_theme END`);
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);
return assignments;
}
if (table === 'works') {
if (has('user_id')) {
assignments.push(`user_id = CASE WHEN (target.user_id IS NULL OR target.user_id = '${SYSTEM_USER_ID}'::uuid) AND EXCLUDED.user_id IS NOT NULL AND EXCLUDED.user_id <> '${SYSTEM_USER_ID}'::uuid THEN EXCLUDED.user_id ELSE target.user_id END`);
}
if (has('params')) assignments.push(`params = CASE WHEN target.params IS NULL OR target.params = '{}'::jsonb THEN EXCLUDED.params ELSE target.params END`);
if (has('thumbnail_url')) assignments.push(`thumbnail_url = COALESCE(NULLIF(target.thumbnail_url, ''), EXCLUDED.thumbnail_url)`);
if (has('width')) assignments.push(`width = COALESCE(target.width, EXCLUDED.width)`);
if (has('height')) assignments.push(`height = COALESCE(target.height, EXCLUDED.height)`);
if (has('duration')) assignments.push(`duration = COALESCE(target.duration, EXCLUDED.duration)`);
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);
return assignments;
}
if (table === 'user_api_keys') {
if (has('user_id')) assignments.push(`user_id = COALESCE(target.user_id, EXCLUDED.user_id)`);
if (has('provider')) assignments.push(`provider = COALESCE(NULLIF(target.provider, ''), EXCLUDED.provider)`);
if (has('supplier_name')) assignments.push(`supplier_name = COALESCE(NULLIF(target.supplier_name, ''), EXCLUDED.supplier_name)`);
if (has('api_url')) assignments.push(`api_url = COALESCE(NULLIF(target.api_url, ''), EXCLUDED.api_url)`);
if (has('model_name')) assignments.push(`model_name = COALESCE(NULLIF(target.model_name, ''), EXCLUDED.model_name)`);
if (has('note')) assignments.push(`note = COALESCE(NULLIF(target.note, ''), EXCLUDED.note)`);
if (has('api_key_encrypted')) assignments.push(`api_key_encrypted = COALESCE(NULLIF(target.api_key_encrypted, ''), EXCLUDED.api_key_encrypted)`);
if (has('api_key_preview')) assignments.push(`api_key_preview = COALESCE(NULLIF(target.api_key_preview, ''), EXCLUDED.api_key_preview)`);
if (has('type')) assignments.push(`type = COALESCE(NULLIF(target.type, ''), EXCLUDED.type)`);
if (has('is_active')) assignments.push(`is_active = COALESCE(target.is_active, EXCLUDED.is_active)`);
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);
return assignments;
}
if (table === 'system_api_configs') {
if (has('provider')) assignments.push(`provider = COALESCE(NULLIF(target.provider, ''), EXCLUDED.provider)`);
if (has('name')) assignments.push(`name = COALESCE(NULLIF(target.name, ''), EXCLUDED.name)`);
if (has('api_url')) assignments.push(`api_url = COALESCE(NULLIF(target.api_url, ''), EXCLUDED.api_url)`);
if (has('model_name')) assignments.push(`model_name = COALESCE(NULLIF(target.model_name, ''), EXCLUDED.model_name)`);
if (has('note')) assignments.push(`note = COALESCE(NULLIF(target.note, ''), EXCLUDED.note)`);
if (has('api_key_encrypted')) assignments.push(`api_key_encrypted = COALESCE(NULLIF(target.api_key_encrypted, ''), EXCLUDED.api_key_encrypted)`);
if (has('api_key_preview')) assignments.push(`api_key_preview = COALESCE(NULLIF(target.api_key_preview, ''), EXCLUDED.api_key_preview)`);
if (has('type')) assignments.push(`type = COALESCE(NULLIF(target.type, ''), EXCLUDED.type)`);
if (has('credits_per_use')) assignments.push(`credits_per_use = COALESCE(target.credits_per_use, EXCLUDED.credits_per_use)`);
if (has('is_active')) assignments.push(`is_active = COALESCE(target.is_active, EXCLUDED.is_active)`);
if (has('sort_order')) assignments.push(`sort_order = COALESCE(target.sort_order, EXCLUDED.sort_order)`);
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);
return assignments;
}
if (table === 'payment_methods') {
if (has('type')) assignments.push(`type = COALESCE(NULLIF(target.type, ''), EXCLUDED.type)`);
if (has('name')) assignments.push(`name = COALESCE(NULLIF(target.name, ''), EXCLUDED.name)`);
if (has('is_active')) assignments.push(`is_active = COALESCE(target.is_active, EXCLUDED.is_active)`);
if (has('public_config')) assignments.push(`public_config = COALESCE(target.public_config, EXCLUDED.public_config)`);
if (has('secret_config_encrypted')) assignments.push(`secret_config_encrypted = COALESCE(target.secret_config_encrypted, EXCLUDED.secret_config_encrypted)`);
if (has('secret_config_preview')) assignments.push(`secret_config_preview = COALESCE(target.secret_config_preview, EXCLUDED.secret_config_preview)`);
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);
return assignments;
}
return assignments;
}
async function getExistingColumns(
client: Awaited<ReturnType<typeof getDbClient>>,
table: string,
context: ImportContext,
): Promise<Set<string>> {
const cached = context.columnCache.get(table);
if (cached) return cached;
const [schemaName, tableName] = table.includes('.') ? table.split('.', 2) : ['public', table];
const result = await client.query(
'SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2',
[schemaName, tableName],
);
const columns = new Set((result.rows || []).map((row: Record<string, unknown>) => String(row.column_name)));
context.columnCache.set(table, columns);
return columns;
}
function seedUuidMap(map: Map<string, string>, value: unknown): void {
if (typeof value === 'string' && value && !isUuid(value) && !map.has(value)) {
map.set(value, crypto.randomUUID());
}
}
function isUuid(value: unknown): value is string {
return typeof value === 'string' && UUID_REGEX.test(value);
}
function isDataUrl(value: unknown): boolean {
return typeof value === 'string' && /^data:[^,]+,/i.test(value);
}
function getWorkMediaFolder(type: unknown, kind: string): string {
const text = typeof type === 'string' ? type.toLowerCase() : '';
const media = text.includes('video') ? 'videos' : 'images';
return `imported/works/${kind}/${media}`;
}
function extensionFromMime(mime: string): string {
const normalized = mime.toLowerCase();
if (normalized.includes('png')) return 'png';
if (normalized.includes('jpeg') || normalized.includes('jpg')) return 'jpg';
if (normalized.includes('webp')) return 'webp';
if (normalized.includes('gif')) return 'gif';
if (normalized.includes('mp4')) return 'mp4';
if (normalized.includes('webm')) return 'webm';
return 'bin';
}
async function persistImportMedia(value: string, folder: string): Promise<string> {
if (!isDataUrl(value)) return value;
const match = value.match(/^data:([^;,]+)?(;base64)?,([\s\S]*)$/i);
if (!match) return value;
const mime = match[1] || 'application/octet-stream';
const isBase64 = Boolean(match[2]);
const payload = match[3] || '';
const buffer = isBase64 ? Buffer.from(payload, 'base64') : Buffer.from(decodeURIComponent(payload));
const ext = extensionFromMime(mime);
const key = `${folder}/${Date.now()}-${crypto.randomUUID()}.${ext}`;
const savedKey = await localStorage.uploadFile({ fileContent: buffer, fileName: key, contentType: mime });
return localStorage.generatePresignedUrl({ key: savedKey, expireTime: 2592000 });
}
async function sanitizeImportMedia(value: unknown, folder: string): Promise<unknown> {
if (typeof value === 'string') {
return persistImportMedia(value, folder);
}
if (Array.isArray(value)) {
return Promise.all(value.map(item => sanitizeImportMedia(item, folder)));
}
if (value && typeof value === 'object') {
const output: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
output[key] = await sanitizeImportMedia(nested, folder);
}
return output;
}
return value;
}