feat(admin): 添加 S3 备份列表查看、下载和删除功能

- 在数据管理页面添加“查看备份”按钮,用于打开备份列表弹窗
- 新增 S3BackupModal 组件,展示备份文件列表,支持下载和删除操作
- 扩展 S3 工具类,支持列出、获取和删除 S3 对象
- 新增后端 API 接口:获取备份列表、删除备份、下载备份文件
- 为所有支持的语言添加相应的国际化文本
This commit is contained in:
anghunk
2026-02-12 16:26:50 +08:00
parent 1c416ce93e
commit 3cfa5f1f6a
21 changed files with 737 additions and 29 deletions

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -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()
);
}
}