Complete admin data backup coverage

This commit is contained in:
Codex
2026-05-13 01:05:19 +00:00
parent fa74bac92f
commit 17a22f6953
2 changed files with 75 additions and 13 deletions

View File

@@ -31,10 +31,14 @@ export async function GET(request: NextRequest) {
'orders',
'user_api_keys',
'system_api_configs',
'api_providers',
'model_recommendations',
'payment_methods',
'image_style_presets',
'work_likes',
'announcements',
'generation_jobs',
'platform_logs',
];
for (const table of tables) {
@@ -61,7 +65,7 @@ export async function GET(request: NextRequest) {
data.auth_users = result.rows || [];
} catch { data.auth_users = []; }
const mediaExport = collectExportMedia(data);
const mediaExport = await collectExportMedia(data);
const exportData = {
_meta: {
@@ -89,18 +93,21 @@ export async function GET(request: NextRequest) {
}
}
function collectExportMedia(data: Record<string, unknown[]>): {
async function collectExportMedia(data: Record<string, unknown[]>): Promise<{
media: Record<string, ExportMediaEntry>;
bytes: number;
missing: string[];
skipped: string[];
} {
}> {
const urls = new Set<string>();
for (const row of data.works || []) {
collectLocalStorageUrls(row, urls);
collectExportableMediaUrls(row, urls);
}
for (const row of data.site_config || []) {
collectLocalStorageUrls(row, urls);
collectExportableMediaUrls(row, urls);
}
for (const row of data.generation_jobs || []) {
collectExportableMediaUrls(row, urls);
}
const media: Record<string, ExportMediaEntry> = {};
@@ -109,12 +116,12 @@ function collectExportMedia(data: Record<string, unknown[]>): {
let bytes = 0;
for (const url of urls) {
const key = localStorage.getKeyFromPublicUrl(url);
if (!key || !localStorage.fileExists(key)) {
const payload = await readExportMedia(url);
if (!payload) {
missing.push(url);
continue;
}
const buffer = localStorage.readFile(key);
const { buffer, contentType } = payload;
if (buffer.byteLength > MAX_EXPORT_SINGLE_MEDIA_BYTES) {
skipped.push(url);
continue;
@@ -125,7 +132,7 @@ function collectExportMedia(data: Record<string, unknown[]>): {
}
bytes += buffer.byteLength;
media[url] = {
contentType: getContentTypeFromKey(key),
contentType,
encoding: 'base64',
data: buffer.toString('base64'),
size: buffer.byteLength,
@@ -136,20 +143,54 @@ function collectExportMedia(data: Record<string, unknown[]>): {
return { media, bytes, missing, skipped };
}
function collectLocalStorageUrls(value: unknown, output: Set<string>): void {
async function readExportMedia(url: string): Promise<{ buffer: Buffer; contentType: string } | null> {
const key = localStorage.getKeyFromPublicUrl(url);
if (key && localStorage.fileExists(key)) {
return { buffer: localStorage.readFile(key), contentType: getContentTypeFromKey(key) };
}
if (!/^https?:\/\//i.test(url)) return null;
try {
const response = await fetch(url, { signal: AbortSignal.timeout(20000) });
if (!response.ok) return null;
const contentLength = Number(response.headers.get('content-length') || 0);
if (contentLength > MAX_EXPORT_SINGLE_MEDIA_BYTES) return null;
const contentType = response.headers.get('content-type') || getContentTypeFromUrl(url);
if (!isSupportedMediaType(contentType)) return null;
const buffer = Buffer.from(await response.arrayBuffer());
return { buffer, contentType };
} catch {
return null;
}
}
function collectExportableMediaUrls(value: unknown, output: Set<string>): void {
if (typeof value === 'string') {
if (localStorage.getKeyFromPublicUrl(value)) output.add(value);
if (localStorage.getKeyFromPublicUrl(value) || /^https?:\/\//i.test(value)) output.add(value);
return;
}
if (Array.isArray(value)) {
value.forEach(item => collectLocalStorageUrls(item, output));
value.forEach(item => collectExportableMediaUrls(item, output));
return;
}
if (value && typeof value === 'object') {
Object.values(value as Record<string, unknown>).forEach(item => collectLocalStorageUrls(item, output));
Object.values(value as Record<string, unknown>).forEach(item => collectExportableMediaUrls(item, output));
}
}
function isSupportedMediaType(contentType: string): boolean {
return /^(image|video)\//i.test(contentType.split(';')[0] || '');
}
function getContentTypeFromUrl(url: string): string {
const path = url.split('?')[0] || '';
return getContentTypeFromKey(path);
}
function getContentTypeFromKey(key: string): string {
const ext = key.split('.').pop()?.toLowerCase();
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';

View File

@@ -42,8 +42,12 @@ const UUID_ID_TABLES = new Set([
'orders',
'user_api_keys',
'system_api_configs',
'api_providers',
'model_recommendations',
'image_style_presets',
'work_likes',
'generation_jobs',
'platform_logs',
]);
const TABLE_COLUMNS: Record<string, string[]> = {
@@ -56,6 +60,10 @@ const TABLE_COLUMNS: Record<string, string[]> = {
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'],
api_providers: ['id', 'name', 'default_api_url', 'default_model', 'type', 'website', 'is_active', 'sort_order', 'created_at', 'updated_at'],
model_recommendations: ['id', 'model_name', 'display_name', 'type', 'provider_id', 'is_active', 'sort_order', 'created_at', 'updated_at'],
generation_jobs: ['id', 'type', 'status', 'payload', 'result', 'error', 'user_id', 'provider', 'model_name', 'api_url', 'progress', 'created_at', 'started_at', 'finished_at', 'updated_at'],
platform_logs: ['id', 'type', 'level', 'action', 'message', 'user_id', 'user_name', 'user_email', 'target_type', 'target_id', 'ip_address', 'user_agent', 'metadata', 'created_at'],
image_style_presets: ['id', 'label', 'prompt', 'usage_count', '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'],
@@ -75,8 +83,12 @@ const CONFLICT_COLUMNS: Record<string, string[]> = {
orders: ['id'],
user_api_keys: ['id'],
system_api_configs: ['id'],
api_providers: ['id'],
model_recommendations: ['id'],
image_style_presets: ['id'],
payment_methods: ['id'],
generation_jobs: ['id'],
platform_logs: ['id'],
work_likes: ['id'],
};
@@ -416,6 +428,15 @@ async function normalizeImportRow(table: string, row: Record<string, unknown>, c
}
}
if (table === 'generation_jobs') {
if (next.payload && typeof next.payload === 'object') {
next.payload = await sanitizeImportMedia(next.payload, 'imported/jobs/payload', context);
}
if (next.result && typeof next.result === 'object') {
next.result = await sanitizeImportMedia(next.result, 'imported/jobs/results', context);
}
}
if (table === 'user_api_keys') {
if (typeof next.note !== 'string' || next.note.trim() === '') {
next.note = '导入的 API Key';