Complete admin data backup coverage
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user