feat(admin): 新增数据备份与恢复功能

添加配置、统计数据和全量备份的导入导出接口
重构数据管理界面,提供分类导出选项
优化导入流程,支持批量操作和错误处理
This commit is contained in:
anghunk
2026-01-29 20:22:40 +08:00
parent 534ea65f41
commit d126e2c4c3
10 changed files with 669 additions and 354 deletions

View File

@@ -0,0 +1,29 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
import { getConfigs } from './exportConfig';
import { getStatsData } from './exportStats';
export const exportBackup = async (c: Context<{ Bindings: Bindings }>) => {
try {
const { results: comments } = await c.env.CWD_DB.prepare(
'SELECT * FROM Comment ORDER BY priority DESC, created DESC'
).all();
const configs = await getConfigs(c.env);
const stats = await getStatsData(c.env);
const backupData = {
version: '1.0',
timestamp: Date.now(),
comments: comments,
settings: configs,
page_stats: stats.page_stats,
page_visit_daily: stats.page_visit_daily,
likes: stats.likes
};
return c.json(backupData);
} catch (e: any) {
return c.json({ message: e.message || '全量导出失败' }, 500);
}
};

View File

@@ -0,0 +1,20 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const getConfigs = async (env: Bindings) => {
await env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const { results } = await env.CWD_DB.prepare('SELECT * FROM Settings').all();
return results;
};
export const exportConfig = async (c: Context<{ Bindings: Bindings }>) => {
try {
const results = await getConfigs(c.env);
return c.json(results);
} catch (e: any) {
return c.json({ message: e.message || '导出配置失败' }, 500);
}
};

View File

@@ -0,0 +1,52 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const getStatsData = async (env: Bindings) => {
// Ensure tables exist
await env.CWD_DB.exec(`
CREATE TABLE IF NOT EXISTS page_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_slug TEXT UNIQUE NOT NULL,
post_title TEXT,
post_url TEXT,
pv INTEGER NOT NULL DEFAULT 0,
last_visit_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS page_visit_daily (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
domain TEXT,
count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS Likes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_slug TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(page_slug, user_id)
);
`);
const { results: pageStats } = await env.CWD_DB.prepare('SELECT * FROM page_stats').all();
const { results: dailyVisits } = await env.CWD_DB.prepare('SELECT * FROM page_visit_daily').all();
const { results: likes } = await env.CWD_DB.prepare('SELECT * FROM Likes').all();
return {
page_stats: pageStats,
page_visit_daily: dailyVisits,
likes: likes
};
};
export const exportStats = async (c: Context<{ Bindings: Bindings }>) => {
try {
const data = await getStatsData(c.env);
return c.json(data);
} catch (e: any) {
return c.json({ message: e.message || '导出统计数据失败' }, 500);
}
};

View File

@@ -0,0 +1,108 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
import { saveConfigs } from './importConfig';
import { saveStatsData } from './importStats';
const saveComments = async (env: Bindings, comments: any[]) => {
if (!Array.isArray(comments) || comments.length === 0) {
return;
}
const stmts = comments.map((comment: any) => {
const {
id,
created,
post_slug,
name,
email,
url,
ip_address,
device,
os,
browser,
ua,
content_text,
content_html,
parent_id,
status,
likes
} = comment;
const fields = [
'created', 'post_slug', 'name', 'email', 'url',
'ip_address', 'device', 'os', 'browser', 'ua',
'content_text', 'content_html', 'parent_id', 'status', 'likes'
];
const values = [
created || Date.now(),
post_slug || "",
name || "Anonymous",
email || "",
url || null,
ip_address || null,
device || null,
os || null,
browser || null,
ua || null,
content_text || "",
content_html || "",
parent_id || null,
status || "approved",
typeof likes === 'number' && Number.isFinite(likes) && likes >= 0 ? likes : 0
];
if (id !== undefined && id !== null) {
fields.unshift('id');
values.unshift(id);
}
const placeholders = fields.map(() => '?').join(', ');
const sql = `INSERT OR REPLACE INTO Comment (${fields.join(', ')}) VALUES (${placeholders})`;
return env.CWD_DB.prepare(sql).bind(...values);
});
// Batch execute
const BATCH_SIZE = 50;
for (let i = 0; i < stmts.length; i += BATCH_SIZE) {
const batch = stmts.slice(i, i + BATCH_SIZE);
await env.CWD_DB.batch(batch);
}
};
export const importBackup = async (c: Context<{ Bindings: Bindings }>) => {
try {
const body = await c.req.json();
if (!body || typeof body !== 'object') {
return c.json({ message: '数据格式错误' }, 400);
}
let message = '导入结果:';
// 1. Comments
if (Array.isArray(body.comments) && body.comments.length > 0) {
await saveComments(c.env, body.comments);
message += ` 评论 ${body.comments.length} 条;`;
}
// 2. Settings
if (Array.isArray(body.settings) && body.settings.length > 0) {
await saveConfigs(c.env, body.settings);
message += ` 配置 ${body.settings.length} 条;`;
}
// 3. Stats
// Pass the whole body as it expects { page_stats, ... }
await saveStatsData(c.env, body);
const statsCount = (body.page_stats?.length || 0) + (body.page_visit_daily?.length || 0) + (body.likes?.length || 0);
if (statsCount > 0) {
message += ` 统计数据 ${statsCount} 条;`;
}
return c.json({ message });
} catch (e: any) {
console.error(e);
return c.json({ message: e.message || '全量导入失败' }, 500);
}
};

View File

@@ -0,0 +1,50 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const saveConfigs = async (env: Bindings, configs: any[]) => {
if (!Array.isArray(configs) || configs.length === 0) {
return;
}
await env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const stmts = configs.map((item) => {
return env.CWD_DB.prepare(
'REPLACE INTO Settings (key, value) VALUES (?, ?)'
).bind(item.key, item.value);
});
// Batch execute
const BATCH_SIZE = 50;
for (let i = 0; i < stmts.length; i += BATCH_SIZE) {
const batch = stmts.slice(i, i + BATCH_SIZE);
await env.CWD_DB.batch(batch);
}
};
export const importConfig = async (c: Context<{ Bindings: Bindings }>) => {
try {
const body = await c.req.json();
const configs = Array.isArray(body) ? body : [body];
if (configs.length === 0) {
return c.json({ message: '导入数据为空' }, 400);
}
// Validate basic structure
const validConfigs = configs.filter((item: any) => item && item.key && typeof item.value === 'string');
if (validConfigs.length === 0) {
return c.json({ message: '没有有效的配置数据' }, 400);
}
await saveConfigs(c.env, validConfigs);
return c.json({ message: `成功导入 ${validConfigs.length} 条配置` });
} catch (e: any) {
console.error(e);
return c.json({ message: e.message || '导入配置失败' }, 500);
}
};

View File

@@ -0,0 +1,125 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const saveStatsData = async (env: Bindings, data: any) => {
// Ensure tables exist
await env.CWD_DB.exec(`
CREATE TABLE IF NOT EXISTS page_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_slug TEXT UNIQUE NOT NULL,
post_title TEXT,
post_url TEXT,
pv INTEGER NOT NULL DEFAULT 0,
last_visit_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS page_visit_daily (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
domain TEXT,
count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS Likes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_slug TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(page_slug, user_id)
);
`);
const stmts: any[] = [];
// 1. page_stats
if (Array.isArray(data.page_stats)) {
for (const item of data.page_stats) {
const fields = ['post_slug', 'post_title', 'post_url', 'pv', 'last_visit_at', 'created_at', 'updated_at'];
const values = [
item.post_slug,
item.post_title,
item.post_url,
item.pv,
item.last_visit_at,
item.created_at,
item.updated_at
];
if (item.id) {
fields.unshift('id');
values.unshift(item.id);
}
const placeholders = fields.map(() => '?').join(', ');
stmts.push(
env.CWD_DB.prepare(`INSERT OR REPLACE INTO page_stats (${fields.join(', ')}) VALUES (${placeholders})`).bind(...values)
);
}
}
// 2. page_visit_daily
if (Array.isArray(data.page_visit_daily)) {
for (const item of data.page_visit_daily) {
const fields = ['date', 'domain', 'count', 'created_at', 'updated_at'];
const values = [
item.date,
item.domain,
item.count,
item.created_at,
item.updated_at
];
if (item.id) {
fields.unshift('id');
values.unshift(item.id);
}
const placeholders = fields.map(() => '?').join(', ');
stmts.push(
env.CWD_DB.prepare(`INSERT OR REPLACE INTO page_visit_daily (${fields.join(', ')}) VALUES (${placeholders})`).bind(...values)
);
}
}
// 3. Likes
if (Array.isArray(data.likes)) {
for (const item of data.likes) {
const fields = ['page_slug', 'user_id', 'created_at'];
const values = [
item.page_slug,
item.user_id,
item.created_at
];
if (item.id) {
fields.unshift('id');
values.unshift(item.id);
}
const placeholders = fields.map(() => '?').join(', ');
stmts.push(
env.CWD_DB.prepare(`INSERT OR REPLACE INTO Likes (${fields.join(', ')}) VALUES (${placeholders})`).bind(...values)
);
}
}
// Batch execute
const BATCH_SIZE = 50;
for (let i = 0; i < stmts.length; i += BATCH_SIZE) {
const batch = stmts.slice(i, i + BATCH_SIZE);
await env.CWD_DB.batch(batch);
}
};
export const importStats = async (c: Context<{ Bindings: Bindings }>) => {
try {
const body = await c.req.json();
if (!body || typeof body !== 'object') {
return c.json({ message: '数据格式错误' }, 400);
}
await saveStatsData(c.env, body);
const statsCount = (body.page_stats?.length || 0) + (body.page_visit_daily?.length || 0) + (body.likes?.length || 0);
return c.json({ message: `成功导入 ${statsCount} 条统计数据` });
} catch (e: any) {
console.error(e);
return c.json({ message: e.message || '导入统计数据失败' }, 500);
}
};