feat(admin): 添加 S3 备份列表查看、下载和删除功能
- 在数据管理页面添加“查看备份”按钮,用于打开备份列表弹窗 - 新增 S3BackupModal 组件,展示备份文件列表,支持下载和删除操作 - 扩展 S3 工具类,支持列出、获取和删除 S3 对象 - 新增后端 API 接口:获取备份列表、删除备份、下载备份文件 - 为所有支持的语言添加相应的国际化文本
This commit is contained in:
@@ -5,13 +5,27 @@ import { S3Client } from '../../utils/s3';
|
||||
import { getConfigs } from './exportConfig';
|
||||
import { getStatsData } from './exportStats';
|
||||
|
||||
async function getS3Client(c: Context<{ Bindings: Bindings }>): Promise<{ s3: S3Client; settings: any } | Response> {
|
||||
const settings = await loadS3Settings(c.env);
|
||||
if (!settings.endpoint || !settings.bucket || !settings.accessKeyId || !settings.secretAccessKey) {
|
||||
return c.json({ message: 'S3 配置不完整,请先配置 S3 信息' }, 400);
|
||||
}
|
||||
const s3 = new S3Client({
|
||||
endpoint: settings.endpoint,
|
||||
accessKeyId: settings.accessKeyId,
|
||||
secretAccessKey: settings.secretAccessKey,
|
||||
bucket: settings.bucket,
|
||||
region: settings.region,
|
||||
});
|
||||
return { s3, settings };
|
||||
}
|
||||
|
||||
export async function triggerS3Backup(c: Context<{ Bindings: Bindings }>) {
|
||||
try {
|
||||
// 1. Load S3 Settings
|
||||
const settings = await loadS3Settings(c.env);
|
||||
if (!settings.endpoint || !settings.bucket || !settings.accessKeyId || !settings.secretAccessKey) {
|
||||
return c.json({ message: 'S3 配置不完整,请先配置 S3 信息' }, 400);
|
||||
}
|
||||
const result = await getS3Client(c);
|
||||
if (result instanceof Response) return result;
|
||||
const { s3 } = result;
|
||||
|
||||
// 2. Gather Backup Data (Logic from exportBackup.ts)
|
||||
const { results: comments } = await c.env.CWD_DB.prepare('SELECT * FROM Comment ORDER BY priority DESC, created DESC').all();
|
||||
@@ -33,14 +47,6 @@ export async function triggerS3Backup(c: Context<{ Bindings: Bindings }>) {
|
||||
const fileName = `cwd-backup-${dateStr}-${Date.now()}.json`;
|
||||
|
||||
// 3. Upload to S3
|
||||
const s3 = new S3Client({
|
||||
endpoint: settings.endpoint,
|
||||
accessKeyId: settings.accessKeyId,
|
||||
secretAccessKey: settings.secretAccessKey,
|
||||
bucket: settings.bucket,
|
||||
region: settings.region,
|
||||
});
|
||||
|
||||
await s3.putObject(fileName, jsonString);
|
||||
|
||||
return c.json({
|
||||
@@ -52,3 +58,62 @@ export async function triggerS3Backup(c: Context<{ Bindings: Bindings }>) {
|
||||
return c.json({ message: e.message || 'S3 备份失败' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listS3Backups(c: Context<{ Bindings: Bindings }>) {
|
||||
try {
|
||||
const result = await getS3Client(c);
|
||||
if (result instanceof Response) return result;
|
||||
const { s3 } = result;
|
||||
|
||||
const files = await s3.listObjects('cwd-backup-');
|
||||
return c.json({ files });
|
||||
} catch (e: any) {
|
||||
console.error('S3 List Backups Error:', e);
|
||||
return c.json({ message: e.message || '获取备份列表失败' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteS3BackupHandler(c: Context<{ Bindings: Bindings }>) {
|
||||
try {
|
||||
const key = c.req.query('key');
|
||||
if (!key) {
|
||||
return c.json({ message: '缺少 key 参数' }, 400);
|
||||
}
|
||||
|
||||
const result = await getS3Client(c);
|
||||
if (result instanceof Response) return result;
|
||||
const { s3 } = result;
|
||||
|
||||
await s3.deleteObject(key);
|
||||
return c.json({ message: '删除成功' });
|
||||
} catch (e: any) {
|
||||
console.error('S3 Delete Backup Error:', e);
|
||||
return c.json({ message: e.message || '删除备份失败' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadS3BackupHandler(c: Context<{ Bindings: Bindings }>) {
|
||||
try {
|
||||
const key = c.req.query('key');
|
||||
if (!key) {
|
||||
return c.json({ message: '缺少 key 参数' }, 400);
|
||||
}
|
||||
|
||||
const result = await getS3Client(c);
|
||||
if (result instanceof Response) return result;
|
||||
const { s3 } = result;
|
||||
|
||||
const s3Response = await s3.getObject(key);
|
||||
const body = await s3Response.arrayBuffer();
|
||||
|
||||
// Set headers for file download
|
||||
c.header('Content-Type', 'application/json');
|
||||
c.header('Content-Disposition', `attachment; filename="${key}"`);
|
||||
c.header('Content-Length', String(body.byteLength));
|
||||
|
||||
return c.body(body);
|
||||
} catch (e: any) {
|
||||
console.error('S3 Download Backup Error:', e);
|
||||
return c.json({ message: e.message || '下载备份失败' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ import { getLikeStats } from './api/admin/likeStats';
|
||||
import { getFeatureSettings, updateFeatureSettings } from './api/admin/featureSettings';
|
||||
import { getTelegramSettings, updateTelegramSettings, setupTelegramWebhook, testTelegramMessage } from './api/admin/telegramSettings';
|
||||
import { getS3Settings, updateS3Settings } from './api/admin/s3Settings';
|
||||
import { triggerS3Backup } from './api/admin/triggerS3Backup';
|
||||
import { triggerS3Backup, listS3Backups, deleteS3BackupHandler, downloadS3BackupHandler } from './api/admin/triggerS3Backup';
|
||||
import { telegramWebhook } from './api/telegram/webhook';
|
||||
import { ensureSchema } from './utils/dbMigration';
|
||||
|
||||
@@ -345,6 +345,9 @@ app.post('/admin/settings/telegram/test', testTelegramMessage);
|
||||
app.get('/admin/settings/s3', getS3Settings);
|
||||
app.put('/admin/settings/s3', updateS3Settings);
|
||||
app.post('/admin/backup/s3', triggerS3Backup);
|
||||
app.get('/admin/backup/s3/list', listS3Backups);
|
||||
app.delete('/admin/backup/s3', deleteS3BackupHandler);
|
||||
app.get('/admin/backup/s3/download', downloadS3BackupHandler);
|
||||
|
||||
app.get('/admin/settings/admin-display', async (c) => {
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,12 @@ export interface S3Config {
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface S3Object {
|
||||
key: string;
|
||||
size: number;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
export class S3Client {
|
||||
private client: AwsClient;
|
||||
private bucket: string;
|
||||
@@ -49,4 +55,74 @@ export class S3Client {
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async listObjects(prefix?: string): Promise<S3Object[]> {
|
||||
let url = `${this.endpoint}/${this.bucket}?list-type=2`;
|
||||
if (prefix) {
|
||||
url += `&prefix=${encodeURIComponent(prefix)}`;
|
||||
}
|
||||
|
||||
const res = await this.client.fetch(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`S3 List Failed: ${res.status} ${res.statusText} - ${text}`);
|
||||
}
|
||||
|
||||
const xml = await res.text();
|
||||
return this.parseListObjectsXml(xml);
|
||||
}
|
||||
|
||||
async deleteObject(key: string): Promise<void> {
|
||||
const url = `${this.endpoint}/${this.bucket}/${key}`;
|
||||
const res = await this.client.fetch(url, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const text = await res.text();
|
||||
throw new Error(`S3 Delete Failed: ${res.status} ${res.statusText} - ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getObject(key: string): Promise<Response> {
|
||||
const url = `${this.endpoint}/${this.bucket}/${key}`;
|
||||
const res = await this.client.fetch(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`S3 Get Failed: ${res.status} ${res.statusText} - ${text}`);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
private parseListObjectsXml(xml: string): S3Object[] {
|
||||
const objects: S3Object[] = [];
|
||||
const contentsRegex = /<Contents>([\s\S]*?)<\/Contents>/g;
|
||||
let match;
|
||||
|
||||
while ((match = contentsRegex.exec(xml)) !== null) {
|
||||
const content = match[1];
|
||||
const keyMatch = content.match(/<Key>([^<]*)<\/Key>/);
|
||||
const sizeMatch = content.match(/<Size>(\d+)<\/Size>/);
|
||||
const lastModifiedMatch = content.match(/<LastModified>([^<]*)<\/LastModified>/);
|
||||
|
||||
if (keyMatch && sizeMatch && lastModifiedMatch) {
|
||||
objects.push({
|
||||
key: keyMatch[1],
|
||||
size: parseInt(sizeMatch[1], 10),
|
||||
lastModified: lastModifiedMatch[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return objects.sort((a, b) =>
|
||||
new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user