From 451b3dccf41b37f937e80b8056192b5c463d285f Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Sun, 28 Dec 2025 15:02:19 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=88=86?= =?UTF-8?q?=E7=89=87=E4=B8=8A=E4=BC=A0=E5=A4=87=E4=BB=BD=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/dashboard/routes/backup.py | 314 +++++++++++++++++- .../src/components/shared/BackupDialog.vue | 133 +++++++- .../i18n/locales/en-US/features/settings.json | 4 + .../i18n/locales/zh-CN/features/settings.json | 4 + 4 files changed, 439 insertions(+), 16 deletions(-) diff --git a/astrbot/dashboard/routes/backup.py b/astrbot/dashboard/routes/backup.py index bfd7b047d..bec8f970e 100644 --- a/astrbot/dashboard/routes/backup.py +++ b/astrbot/dashboard/routes/backup.py @@ -3,6 +3,8 @@ import asyncio import os import re +import shutil +import time import traceback import uuid from datetime import datetime @@ -22,6 +24,10 @@ from .route import Response, Route, RouteContext +# 分片上传常量 +CHUNK_SIZE = 1024 * 1024 # 1MB +UPLOAD_EXPIRE_SECONDS = 3600 # 上传会话过期时间(1小时) + def secure_filename(filename: str) -> str: """清洗文件名,移除路径遍历字符和危险字符 @@ -84,16 +90,25 @@ def __init__( self.core_lifecycle = core_lifecycle self.backup_dir = get_astrbot_backups_path() self.data_dir = get_astrbot_data_path() + self.chunks_dir = os.path.join(self.backup_dir, ".chunks") # 任务状态跟踪 self.backup_tasks: dict[str, dict] = {} self.backup_progress: dict[str, dict] = {} + # 分片上传会话跟踪 + # upload_id -> {filename, total_chunks, received_chunks, created_at, chunk_dir} + self.upload_sessions: dict[str, dict] = {} + # 注册路由 self.routes = { "/backup/list": ("GET", self.list_backups), "/backup/export": ("POST", self.export_backup), - "/backup/upload": ("POST", self.upload_backup), # 上传文件 + "/backup/upload": ("POST", self.upload_backup), # 上传文件(兼容小文件) + "/backup/upload/init": ("POST", self.upload_init), # 分片上传初始化 + "/backup/upload/chunk": ("POST", self.upload_chunk), # 上传分片 + "/backup/upload/complete": ("POST", self.upload_complete), # 完成分片上传 + "/backup/upload/abort": ("POST", self.upload_abort), # 取消上传 "/backup/check": ("POST", self.check_backup), # 预检查 "/backup/import": ("POST", self.import_backup), # 确认导入 "/backup/progress": ("GET", self.get_progress), @@ -102,6 +117,9 @@ def __init__( } self.register_routes() + # 启动清理过期上传会话的任务 + asyncio.create_task(self._cleanup_expired_uploads()) + def _init_task(self, task_id: str, task_type: str, status: str = "pending") -> None: """初始化任务状态""" self.backup_tasks[task_id] = { @@ -173,6 +191,37 @@ async def _callback(stage: str, current: int, total: int, message: str = ""): return _callback + async def _cleanup_expired_uploads(self): + """定期清理过期的上传会话""" + while True: + try: + await asyncio.sleep(300) # 每5分钟检查一次 + current_time = time.time() + expired_sessions = [] + + for upload_id, session in self.upload_sessions.items(): + if current_time - session["created_at"] > UPLOAD_EXPIRE_SECONDS: + expired_sessions.append(upload_id) + + for upload_id in expired_sessions: + await self._cleanup_upload_session(upload_id) + logger.info(f"清理过期的上传会话: {upload_id}") + + except Exception as e: + logger.error(f"清理过期上传会话失败: {e}") + + async def _cleanup_upload_session(self, upload_id: str): + """清理上传会话""" + if upload_id in self.upload_sessions: + session = self.upload_sessions[upload_id] + chunk_dir = session.get("chunk_dir") + if chunk_dir and os.path.exists(chunk_dir): + try: + shutil.rmtree(chunk_dir) + except Exception as e: + logger.warning(f"清理分片目录失败: {e}") + del self.upload_sessions[upload_id] + async def list_backups(self): """获取备份列表 @@ -345,6 +394,269 @@ async def upload_backup(self): logger.error(traceback.format_exc()) return Response().error(f"上传备份文件失败: {e!s}").__dict__ + async def upload_init(self): + """初始化分片上传 + + 创建一个上传会话,返回 upload_id 供后续分片上传使用。 + + JSON Body: + - filename: 原始文件名 + - total_size: 文件总大小(字节) + - total_chunks: 分片总数 + + 返回: + - upload_id: 上传会话 ID + - chunk_size: 建议的分片大小 + """ + try: + data = await request.json + filename = data.get("filename") + total_size = data.get("total_size", 0) + total_chunks = data.get("total_chunks", 0) + + if not filename: + return Response().error("缺少 filename 参数").__dict__ + + if not filename.endswith(".zip"): + return Response().error("请上传 ZIP 格式的备份文件").__dict__ + + if total_chunks <= 0: + return Response().error("无效的分片数量").__dict__ + + # 生成上传 ID + upload_id = str(uuid.uuid4()) + + # 创建分片存储目录 + chunk_dir = os.path.join(self.chunks_dir, upload_id) + Path(chunk_dir).mkdir(parents=True, exist_ok=True) + + # 清洗文件名 + safe_filename = secure_filename(filename) + unique_filename = generate_unique_filename(safe_filename) + + # 创建上传会话 + self.upload_sessions[upload_id] = { + "filename": unique_filename, + "original_filename": filename, + "total_size": total_size, + "total_chunks": total_chunks, + "received_chunks": set(), + "created_at": time.time(), + "chunk_dir": chunk_dir, + } + + logger.info( + f"初始化分片上传: upload_id={upload_id}, " + f"filename={unique_filename}, total_chunks={total_chunks}" + ) + + return ( + Response() + .ok( + { + "upload_id": upload_id, + "chunk_size": CHUNK_SIZE, + "filename": unique_filename, + } + ) + .__dict__ + ) + except Exception as e: + logger.error(f"初始化分片上传失败: {e}") + logger.error(traceback.format_exc()) + return Response().error(f"初始化分片上传失败: {e!s}").__dict__ + + async def upload_chunk(self): + """上传分片 + + 上传单个分片数据。 + + Form Data: + - upload_id: 上传会话 ID + - chunk_index: 分片索引(从 0 开始) + - chunk: 分片数据 + + 返回: + - received: 已接收的分片数量 + - total: 分片总数 + """ + try: + form = await request.form + files = await request.files + + upload_id = form.get("upload_id") + chunk_index_str = form.get("chunk_index") + + if not upload_id or chunk_index_str is None: + return Response().error("缺少必要参数").__dict__ + + try: + chunk_index = int(chunk_index_str) + except ValueError: + return Response().error("无效的分片索引").__dict__ + + if "chunk" not in files: + return Response().error("缺少分片数据").__dict__ + + # 验证上传会话 + if upload_id not in self.upload_sessions: + return Response().error("上传会话不存在或已过期").__dict__ + + session = self.upload_sessions[upload_id] + + # 验证分片索引 + if chunk_index < 0 or chunk_index >= session["total_chunks"]: + return Response().error("分片索引超出范围").__dict__ + + # 保存分片 + chunk_file = files["chunk"] + chunk_path = os.path.join(session["chunk_dir"], f"{chunk_index}.part") + await chunk_file.save(chunk_path) + + # 记录已接收的分片 + session["received_chunks"].add(chunk_index) + + received_count = len(session["received_chunks"]) + total_chunks = session["total_chunks"] + + logger.debug( + f"接收分片: upload_id={upload_id}, " + f"chunk={chunk_index + 1}/{total_chunks}" + ) + + return ( + Response() + .ok( + { + "received": received_count, + "total": total_chunks, + "chunk_index": chunk_index, + } + ) + .__dict__ + ) + except Exception as e: + logger.error(f"上传分片失败: {e}") + logger.error(traceback.format_exc()) + return Response().error(f"上传分片失败: {e!s}").__dict__ + + async def upload_complete(self): + """完成分片上传 + + 合并所有分片为完整文件。 + + JSON Body: + - upload_id: 上传会话 ID + + 返回: + - filename: 合并后的文件名 + - size: 文件大小 + """ + try: + data = await request.json + upload_id = data.get("upload_id") + + if not upload_id: + return Response().error("缺少 upload_id 参数").__dict__ + + # 验证上传会话 + if upload_id not in self.upload_sessions: + return Response().error("上传会话不存在或已过期").__dict__ + + session = self.upload_sessions[upload_id] + + # 检查是否所有分片都已接收 + received = session["received_chunks"] + total = session["total_chunks"] + + if len(received) != total: + missing = set(range(total)) - received + return ( + Response() + .error(f"分片不完整,缺少: {sorted(missing)[:10]}...") + .__dict__ + ) + + # 合并分片 + chunk_dir = session["chunk_dir"] + filename = session["filename"] + + Path(self.backup_dir).mkdir(parents=True, exist_ok=True) + output_path = os.path.join(self.backup_dir, filename) + + try: + with open(output_path, "wb") as outfile: + for i in range(total): + chunk_path = os.path.join(chunk_dir, f"{i}.part") + with open(chunk_path, "rb") as chunk_file: + # 分块读取,避免内存溢出 + while True: + data_block = chunk_file.read(8192) + if not data_block: + break + outfile.write(data_block) + + file_size = os.path.getsize(output_path) + + logger.info( + f"分片上传完成: {filename}, size={file_size}, chunks={total}" + ) + + # 清理分片目录 + await self._cleanup_upload_session(upload_id) + + return ( + Response() + .ok( + { + "filename": filename, + "original_filename": session["original_filename"], + "size": file_size, + } + ) + .__dict__ + ) + except Exception as e: + # 如果合并失败,删除不完整的文件 + if os.path.exists(output_path): + os.remove(output_path) + raise e + + except Exception as e: + logger.error(f"完成分片上传失败: {e}") + logger.error(traceback.format_exc()) + return Response().error(f"完成分片上传失败: {e!s}").__dict__ + + async def upload_abort(self): + """取消分片上传 + + 取消上传并清理已上传的分片。 + + JSON Body: + - upload_id: 上传会话 ID + """ + try: + data = await request.json + upload_id = data.get("upload_id") + + if not upload_id: + return Response().error("缺少 upload_id 参数").__dict__ + + if upload_id not in self.upload_sessions: + # 会话已不存在,可能已过期或已完成 + return Response().ok(message="上传已取消").__dict__ + + # 清理会话 + await self._cleanup_upload_session(upload_id) + + logger.info(f"取消分片上传: {upload_id}") + + return Response().ok(message="上传已取消").__dict__ + except Exception as e: + logger.error(f"取消上传失败: {e}") + logger.error(traceback.format_exc()) + return Response().error(f"取消上传失败: {e!s}").__dict__ + async def check_backup(self): """预检查备份文件 diff --git a/dashboard/src/components/shared/BackupDialog.vue b/dashboard/src/components/shared/BackupDialog.vue index 629e4e559..f2ab40274 100644 --- a/dashboard/src/components/shared/BackupDialog.vue +++ b/dashboard/src/components/shared/BackupDialog.vue @@ -110,9 +110,23 @@
- + mdi-cloud-upload

{{ t('features.settings.backup.import.uploading') }}

-

{{ t('features.settings.backup.import.uploadWait') }}

+

+ {{ uploadProgress.message || t('features.settings.backup.import.uploadWait') }} +

+

+ {{ formatFileSize(uploadProgress.uploaded) }} / {{ formatFileSize(uploadProgress.total) }} + ({{ uploadProgress.percent }}%) +

+
@@ -307,13 +321,25 @@ const importError = ref('') const uploadedFilename = ref('') // 已上传的文件名 const checkResult = ref(null) // 预检查结果 +// 分片上传状态 +const CHUNK_SIZE = 1024 * 1024 // 1MB +const uploadId = ref('') +const uploadProgress = ref({ + uploaded: 0, + total: 0, + percent: 0, + message: '' +}) + // 备份列表 const loadingList = ref(false) const backupList = ref([]) // 计算属性 const isProcessing = computed(() => { - return exportStatus.value === 'processing' || importStatus.value === 'processing' + return exportStatus.value === 'processing' || + importStatus.value === 'processing' || + importStatus.value === 'uploading' }) // 版本检查相关的计算属性 @@ -445,23 +471,76 @@ const uploadAndCheck = async () => { if (!importFile.value) return importStatus.value = 'uploading' + const file = importFile.value try { - // 步骤1: 上传文件 - const formData = new FormData() - formData.append('file', importFile.value) + // 计算分片数量 + const totalChunks = Math.ceil(file.size / CHUNK_SIZE) + + // 初始化上传进度 + uploadProgress.value = { + uploaded: 0, + total: file.size, + percent: 0, + message: t('features.settings.backup.import.uploadInit') + } + + // 步骤1: 初始化分片上传 + const initResponse = await axios.post('/api/backup/upload/init', { + filename: file.name, + total_size: file.size, + total_chunks: totalChunks + }) + + if (initResponse.data.status !== 'ok') { + throw new Error(initResponse.data.message) + } + + uploadId.value = initResponse.data.data.upload_id + const targetFilename = initResponse.data.data.filename + + // 步骤2: 分片上传 + uploadProgress.value.message = t('features.settings.backup.import.uploadingChunks') + + for (let i = 0; i < totalChunks; i++) { + const start = i * CHUNK_SIZE + const end = Math.min(start + CHUNK_SIZE, file.size) + const chunk = file.slice(start, end) - const uploadResponse = await axios.post('/api/backup/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' } + const formData = new FormData() + formData.append('upload_id', uploadId.value) + formData.append('chunk_index', i.toString()) + formData.append('chunk', chunk) + + const chunkResponse = await axios.post('/api/backup/upload/chunk', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + + if (chunkResponse.data.status !== 'ok') { + throw new Error(chunkResponse.data.message) + } + + // 更新进度 + uploadProgress.value.uploaded = end + uploadProgress.value.percent = Math.round((end / file.size) * 100) + } + + // 步骤3: 完成上传 + uploadProgress.value.message = t('features.settings.backup.import.uploadComplete') + + const completeResponse = await axios.post('/api/backup/upload/complete', { + upload_id: uploadId.value }) - if (uploadResponse.data.status !== 'ok') { - throw new Error(uploadResponse.data.message) + if (completeResponse.data.status !== 'ok') { + throw new Error(completeResponse.data.message) } - uploadedFilename.value = uploadResponse.data.data.filename + uploadedFilename.value = completeResponse.data.data.filename + + // 步骤4: 预检查 + uploadProgress.value.message = t('features.settings.backup.import.checking') - // 步骤2: 预检查 const checkResponse = await axios.post('/api/backup/check', { filename: uploadedFilename.value }) @@ -483,6 +562,17 @@ const uploadAndCheck = async () => { importStatus.value = 'confirm' } catch (error) { + // 上传失败时尝试清理已上传的分片 + if (uploadId.value) { + try { + await axios.post('/api/backup/upload/abort', { + upload_id: uploadId.value + }) + } catch (abortError) { + console.error('Failed to abort upload:', abortError) + } + } + importStatus.value = 'failed' importError.value = error.response?.data?.message || error.message || 'Upload failed' } @@ -548,7 +638,18 @@ const pollImportProgress = async () => { } // 重置导入状态 -const resetImport = () => { +const resetImport = async () => { + // 如果有进行中的上传,先取消 + if (uploadId.value && importStatus.value === 'uploading') { + try { + await axios.post('/api/backup/upload/abort', { + upload_id: uploadId.value + }) + } catch (error) { + console.error('Failed to abort upload:', error) + } + } + importStatus.value = 'idle' importFile.value = null importTaskId.value = null @@ -556,6 +657,8 @@ const resetImport = () => { importError.value = '' uploadedFilename.value = '' checkResult.value = null + uploadId.value = '' + uploadProgress.value = { uploaded: 0, total: 0, percent: 0, message: '' } } // 下载备份 @@ -632,9 +735,9 @@ const restartAstrBot = () => { } // 重置所有状态 -const resetAll = () => { +const resetAll = async () => { resetExport() - resetImport() + await resetImport() activeTab.value = 'export' } diff --git a/dashboard/src/i18n/locales/en-US/features/settings.json b/dashboard/src/i18n/locales/en-US/features/settings.json index 3715bb35a..f5aa9356e 100644 --- a/dashboard/src/i18n/locales/en-US/features/settings.json +++ b/dashboard/src/i18n/locales/en-US/features/settings.json @@ -64,6 +64,10 @@ "uploadAndCheck": "Upload & Check", "uploading": "Uploading...", "uploadWait": "Please wait, uploading backup file...", + "uploadInit": "Initializing upload...", + "uploadingChunks": "Uploading chunks...", + "uploadComplete": "Upload complete, merging file...", + "checking": "Checking backup file...", "invalidBackup": "Invalid backup file", "backupContents": "Backup Contents", "tables": "tables", diff --git a/dashboard/src/i18n/locales/zh-CN/features/settings.json b/dashboard/src/i18n/locales/zh-CN/features/settings.json index 3778125aa..388793f61 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/settings.json +++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json @@ -64,6 +64,10 @@ "uploadAndCheck": "上传并检查", "uploading": "正在上传...", "uploadWait": "请稍候,正在上传备份文件...", + "uploadInit": "正在初始化上传...", + "uploadingChunks": "正在上传分片...", + "uploadComplete": "上传完成,正在合并文件...", + "checking": "正在检查备份文件...", "invalidBackup": "无效的备份文件", "backupContents": "备份内容", "tables": "个数据表", From b37f0a41930278e2dfd54376b2ec0b07ea6e5f97 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Sun, 28 Dec 2025 15:09:59 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=E4=B8=BA=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E5=A4=87=E4=BB=BD=E6=96=87=E4=BB=B6=E6=B7=BB=E5=8A=A0=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E5=B9=B6=E5=8F=91=E4=BB=A5=E6=8F=90=E5=8D=87=E9=80=9F?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/shared/BackupDialog.vue | 99 ++++++++++++++----- 1 file changed, 74 insertions(+), 25 deletions(-) diff --git a/dashboard/src/components/shared/BackupDialog.vue b/dashboard/src/components/shared/BackupDialog.vue index f2ab40274..ebdb81001 100644 --- a/dashboard/src/components/shared/BackupDialog.vue +++ b/dashboard/src/components/shared/BackupDialog.vue @@ -323,6 +323,7 @@ const checkResult = ref(null) // 预检查结果 // 分片上传状态 const CHUNK_SIZE = 1024 * 1024 // 1MB +const CONCURRENT_UPLOADS = 5 // 并发上传数 const uploadId = ref('') const uploadProgress = ref({ uploaded: 0, @@ -466,6 +467,76 @@ const resetExport = () => { exportError.value = '' } +/** + * 并发上传分片 + * + * 使用并发控制同时上传多个分片,提升上传速度。 + * 后端按分片索引命名文件(如 0.part, 1.part),合并时按顺序读取, + * 因此分片到达顺序不影响最终结果。 + */ +const uploadChunksInParallel = async (file, totalChunks, currentUploadId) => { + // 跟踪已完成的字节数(使用原子操作避免并发问题) + let completedBytes = 0 + const chunkSizes = [] + + // 预计算每个分片的大小 + for (let i = 0; i < totalChunks; i++) { + const start = i * CHUNK_SIZE + const end = Math.min(start + CHUNK_SIZE, file.size) + chunkSizes[i] = end - start + } + + // 上传单个分片的函数 + const uploadSingleChunk = async (chunkIndex) => { + const start = chunkIndex * CHUNK_SIZE + const end = Math.min(start + CHUNK_SIZE, file.size) + const chunk = file.slice(start, end) + + const formData = new FormData() + formData.append('upload_id', currentUploadId) + formData.append('chunk_index', chunkIndex.toString()) + formData.append('chunk', chunk) + + const response = await axios.post('/api/backup/upload/chunk', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + + if (response.data.status !== 'ok') { + throw new Error(response.data.message) + } + + // 更新进度(累加已完成字节) + completedBytes += chunkSizes[chunkIndex] + uploadProgress.value.uploaded = completedBytes + uploadProgress.value.percent = Math.round((completedBytes / file.size) * 100) + + return response + } + + // 创建分片索引队列 + const pendingChunks = Array.from({ length: totalChunks }, (_, i) => i) + const activePromises = [] + + // 处理队列中的分片 + while (pendingChunks.length > 0 || activePromises.length > 0) { + // 填充并发槽位 + while (pendingChunks.length > 0 && activePromises.length < CONCURRENT_UPLOADS) { + const chunkIndex = pendingChunks.shift() + const promise = uploadSingleChunk(chunkIndex).then(() => { + // 完成后从活动列表移除 + const idx = activePromises.indexOf(promise) + if (idx > -1) activePromises.splice(idx, 1) + }) + activePromises.push(promise) + } + + // 等待至少一个完成 + if (activePromises.length > 0) { + await Promise.race(activePromises) + } + } +} + // 上传并检查 const uploadAndCheck = async () => { if (!importFile.value) return @@ -497,33 +568,11 @@ const uploadAndCheck = async () => { } uploadId.value = initResponse.data.data.upload_id - const targetFilename = initResponse.data.data.filename - // 步骤2: 分片上传 + // 步骤2: 并行分片上传(5个并发连接) uploadProgress.value.message = t('features.settings.backup.import.uploadingChunks') - - for (let i = 0; i < totalChunks; i++) { - const start = i * CHUNK_SIZE - const end = Math.min(start + CHUNK_SIZE, file.size) - const chunk = file.slice(start, end) - - const formData = new FormData() - formData.append('upload_id', uploadId.value) - formData.append('chunk_index', i.toString()) - formData.append('chunk', chunk) - - const chunkResponse = await axios.post('/api/backup/upload/chunk', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }) - - if (chunkResponse.data.status !== 'ok') { - throw new Error(chunkResponse.data.message) - } - - // 更新进度 - uploadProgress.value.uploaded = end - uploadProgress.value.percent = Math.round((end / file.size) * 100) - } + + await uploadChunksInParallel(file, totalChunks, uploadId.value) // 步骤3: 完成上传 uploadProgress.value.message = t('features.settings.backup.import.uploadComplete') From d66fe02a1c7a4ede4faeb5523658ea19773bbcbe Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Sun, 28 Dec 2025 15:12:47 +0800 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=E4=BD=BF=E7=94=A8=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E5=8E=9F=E7=94=9F=E4=B8=8B=E8=BD=BD=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=E4=BB=A5=E6=98=BE=E7=A4=BA=E8=BF=9B=E5=BA=A6=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/shared/BackupDialog.vue | 35 +++++++------------ 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/dashboard/src/components/shared/BackupDialog.vue b/dashboard/src/components/shared/BackupDialog.vue index ebdb81001..cd205e4d2 100644 --- a/dashboard/src/components/shared/BackupDialog.vue +++ b/dashboard/src/components/shared/BackupDialog.vue @@ -710,28 +710,19 @@ const resetImport = async () => { uploadProgress.value = { uploaded: 0, total: 0, percent: 0, message: '' } } -// 下载备份 -const downloadBackup = async (filename) => { - try { - const response = await axios.get('/api/backup/download', { - params: { filename }, - responseType: 'blob' - }) - - // 创建 Blob URL 并触发下载 - const blob = new Blob([response.data], { type: 'application/zip' }) - const url = window.URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = filename - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - window.URL.revokeObjectURL(url) - } catch (error) { - console.error('Download failed:', error) - alert(t('features.settings.backup.export.failed') + ': ' + (error.message || 'Unknown error')) - } +// 下载备份(使用浏览器原生下载,可显示下载进度) +const downloadBackup = (filename) => { + // 直接使用浏览器下载,这样可以看到原生下载进度条 + const downloadUrl = `/api/backup/download?filename=${encodeURIComponent(filename)}` + + // 创建隐藏的 a 标签触发下载 + const link = document.createElement('a') + link.href = downloadUrl + link.download = filename + link.style.display = 'none' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) } // 删除备份 From 36b7b9eca02621d72ccfb60af895a5e32aa2266c Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Sun, 28 Dec 2025 15:31:49 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BB=8E?= =?UTF-8?q?=E5=B7=B2=E4=B8=8A=E4=BC=A0=E5=A4=87=E4=BB=BD=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/backup/exporter.py | 1 + astrbot/dashboard/routes/backup.py | 90 ++++++++++++++++--- .../src/components/shared/BackupDialog.vue | 46 +++++++++- .../i18n/locales/en-US/features/settings.json | 4 +- .../i18n/locales/zh-CN/features/settings.json | 4 +- 5 files changed, 129 insertions(+), 16 deletions(-) diff --git a/astrbot/core/backup/exporter.py b/astrbot/core/backup/exporter.py index 77102c080..51c4a4650 100644 --- a/astrbot/core/backup/exporter.py +++ b/astrbot/core/backup/exporter.py @@ -447,6 +447,7 @@ def _generate_manifest( "version": BACKUP_MANIFEST_VERSION, "astrbot_version": VERSION, "exported_at": datetime.now(timezone.utc).isoformat(), + "origin": "exported", # 标记备份来源:exported=本实例导出, uploaded=用户上传 "schema_version": { "main_db": "v4", "kb_db": "v1", diff --git a/astrbot/dashboard/routes/backup.py b/astrbot/dashboard/routes/backup.py index bec8f970e..42f72932d 100644 --- a/astrbot/dashboard/routes/backup.py +++ b/astrbot/dashboard/routes/backup.py @@ -1,12 +1,14 @@ """备份管理 API 路由""" import asyncio +import json import os import re import shutil import time import traceback import uuid +import zipfile from datetime import datetime from pathlib import Path @@ -60,7 +62,7 @@ def secure_filename(filename: str) -> str: def generate_unique_filename(original_filename: str) -> str: - """生成唯一的文件名,添加时间戳前缀 + """生成唯一的文件名,添加时间戳后缀避免重名 Args: original_filename: 原始文件名(已清洗) @@ -68,9 +70,10 @@ def generate_unique_filename(original_filename: str) -> str: Returns: 唯一的文件名 """ - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") name, ext = os.path.splitext(original_filename) - return f"uploaded_{timestamp}_{name}{ext}" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + # 如果文件名已经包含时间戳格式,直接使用;否则添加时间戳后缀 + return f"{name}_{timestamp}{ext}" class BackupRoute(Route): @@ -222,6 +225,25 @@ async def _cleanup_upload_session(self, upload_id: str): logger.warning(f"清理分片目录失败: {e}") del self.upload_sessions[upload_id] + def _get_backup_origin(self, zip_path: str) -> str: + """从备份文件的 manifest.json 中读取 origin 字段 + + Args: + zip_path: ZIP 文件路径 + + Returns: + str: "exported" 或 "uploaded",如果无法读取则默认返回 "exported" + """ + try: + with zipfile.ZipFile(zip_path, "r") as zf: + if "manifest.json" in zf.namelist(): + manifest_data = zf.read("manifest.json") + manifest = json.loads(manifest_data.decode("utf-8")) + return manifest.get("origin", "exported") + except Exception as e: + logger.debug(f"读取备份 manifest 失败: {e}") + return "exported" # 旧版备份没有 origin 字段,默认为 exported + async def list_backups(self): """获取备份列表 @@ -239,16 +261,26 @@ async def list_backups(self): # 获取所有备份文件 backup_files = [] for filename in os.listdir(self.backup_dir): - if filename.endswith(".zip") and filename.startswith("astrbot_backup_"): - file_path = os.path.join(self.backup_dir, filename) - stat = os.stat(file_path) - backup_files.append( - { - "filename": filename, - "size": stat.st_size, - "created_at": stat.st_mtime, - } - ) + # 只处理 .zip 文件,排除隐藏文件和目录 + if not filename.endswith(".zip") or filename.startswith("."): + continue + + file_path = os.path.join(self.backup_dir, filename) + if not os.path.isfile(file_path): + continue + + stat = os.stat(file_path) + # 从 manifest.json 读取 origin 字段来判断备份类型 + origin = self._get_backup_origin(file_path) + + backup_files.append( + { + "filename": filename, + "size": stat.st_size, + "created_at": stat.st_mtime, + "type": origin, + } + ) # 按创建时间倒序排序 backup_files.sort(key=lambda x: x["created_at"], reverse=True) @@ -540,6 +572,35 @@ async def upload_chunk(self): logger.error(traceback.format_exc()) return Response().error(f"上传分片失败: {e!s}").__dict__ + def _mark_backup_as_uploaded(self, zip_path: str) -> None: + """修改备份文件的 manifest.json,将 origin 设置为 uploaded + + 使用 zipfile 的 append 模式添加新的 manifest.json, + ZIP 规范中后添加的同名文件会覆盖先前的文件。 + + Args: + zip_path: ZIP 文件路径 + """ + try: + # 读取原有 manifest + manifest = {"origin": "uploaded", "uploaded_at": datetime.now().isoformat()} + with zipfile.ZipFile(zip_path, "r") as zf: + if "manifest.json" in zf.namelist(): + manifest_data = zf.read("manifest.json") + manifest = json.loads(manifest_data.decode("utf-8")) + manifest["origin"] = "uploaded" + manifest["uploaded_at"] = datetime.now().isoformat() + + # 使用 append 模式添加新的 manifest.json + # ZIP 规范中,后添加的同名文件会覆盖先前的 + with zipfile.ZipFile(zip_path, "a") as zf: + new_manifest = json.dumps(manifest, ensure_ascii=False, indent=2) + zf.writestr("manifest.json", new_manifest) + + logger.debug(f"已标记备份为上传来源: {zip_path}") + except Exception as e: + logger.warning(f"标记备份来源失败: {e}") + async def upload_complete(self): """完成分片上传 @@ -598,6 +659,9 @@ async def upload_complete(self): file_size = os.path.getsize(output_path) + # 标记备份为上传来源(修改 manifest.json 中的 origin 字段) + self._mark_backup_as_uploaded(output_path) + logger.info( f"分片上传完成: {filename}, size={file_size}, chunks={total}" ) diff --git a/dashboard/src/components/shared/BackupDialog.vue b/dashboard/src/components/shared/BackupDialog.vue index cd205e4d2..4de6d02ff 100644 --- a/dashboard/src/components/shared/BackupDialog.vue +++ b/dashboard/src/components/shared/BackupDialog.vue @@ -256,15 +256,28 @@ :key="backup.filename" > {{ backup.filename }} {{ formatFileSize(backup.size) }} · {{ formatDate(backup.created_at) }} + + {{ t('features.settings.backup.list.uploaded') }} + @@ -725,6 +738,37 @@ const downloadBackup = (filename) => { document.body.removeChild(link) } +// 从列表中恢复备份 +const restoreFromList = async (filename) => { + // 切换到导入标签页并设置文件名 + uploadedFilename.value = filename + + // 预检查 + try { + const checkResponse = await axios.post('/api/backup/check', { + filename: filename + }) + + if (checkResponse.data.status !== 'ok') { + throw new Error(checkResponse.data.message) + } + + checkResult.value = checkResponse.data.data + + if (!checkResult.value.valid) { + alert(checkResult.value.error || t('features.settings.backup.import.invalidBackup')) + return + } + + // 切换到导入标签页并显示确认 + activeTab.value = 'import' + importStatus.value = 'confirm' + + } catch (error) { + alert(error.response?.data?.message || error.message || 'Check failed') + } +} + // 删除备份 const deleteBackup = async (filename) => { if (!confirm(t('features.settings.backup.list.confirmDelete'))) return diff --git a/dashboard/src/i18n/locales/en-US/features/settings.json b/dashboard/src/i18n/locales/en-US/features/settings.json index f5aa9356e..cfdf90d12 100644 --- a/dashboard/src/i18n/locales/en-US/features/settings.json +++ b/dashboard/src/i18n/locales/en-US/features/settings.json @@ -97,7 +97,9 @@ "list": { "empty": "No backup files", "refresh": "Refresh List", - "confirmDelete": "Are you sure you want to delete this backup file? This action cannot be undone." + "confirmDelete": "Are you sure you want to delete this backup file? This action cannot be undone.", + "uploaded": "Uploaded", + "restore": "Restore this backup" } } } \ No newline at end of file diff --git a/dashboard/src/i18n/locales/zh-CN/features/settings.json b/dashboard/src/i18n/locales/zh-CN/features/settings.json index 388793f61..084d486d4 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/settings.json +++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json @@ -97,7 +97,9 @@ "list": { "empty": "暂无备份文件", "refresh": "刷新列表", - "confirmDelete": "确定要删除这个备份文件吗?此操作不可撤销。" + "confirmDelete": "确定要删除这个备份文件吗?此操作不可撤销。", + "uploaded": "已上传", + "restore": "恢复此备份" } } } \ No newline at end of file From 2cdd9e19c856e1a140bbb6542d00019349c46bb9 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Sun, 28 Dec 2025 15:40:30 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=E5=85=81=E8=AE=B8=E9=87=8D?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E5=A4=87=E4=BB=BD=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/dashboard/routes/backup.py | 67 ++++++++++ .../src/components/shared/BackupDialog.vue | 120 ++++++++++++++++++ .../i18n/locales/en-US/features/settings.json | 9 +- .../i18n/locales/zh-CN/features/settings.json | 9 +- 4 files changed, 203 insertions(+), 2 deletions(-) diff --git a/astrbot/dashboard/routes/backup.py b/astrbot/dashboard/routes/backup.py index 42f72932d..7ebc9942a 100644 --- a/astrbot/dashboard/routes/backup.py +++ b/astrbot/dashboard/routes/backup.py @@ -117,6 +117,7 @@ def __init__( "/backup/progress": ("GET", self.get_progress), "/backup/download": ("GET", self.download_backup), "/backup/delete": ("POST", self.delete_backup), + "/backup/rename": ("POST", self.rename_backup), # 重命名备份 } self.register_routes() @@ -963,3 +964,69 @@ async def delete_backup(self): logger.error(f"删除备份失败: {e}") logger.error(traceback.format_exc()) return Response().error(f"删除备份失败: {e!s}").__dict__ + + async def rename_backup(self): + """重命名备份文件 + + Body: + - filename: 当前文件名 (必填) + - new_name: 新文件名 (必填,不含扩展名) + """ + try: + data = await request.json + filename = data.get("filename") + new_name = data.get("new_name") + + if not filename: + return Response().error("缺少参数 filename").__dict__ + + if not new_name: + return Response().error("缺少参数 new_name").__dict__ + + # 安全检查 - 防止路径遍历 + if ".." in filename or "/" in filename or "\\" in filename: + return Response().error("无效的文件名").__dict__ + + # 清洗新文件名(移除路径和危险字符) + new_name = secure_filename(new_name) + + # 移除新文件名中的扩展名(如果有的话) + if new_name.endswith(".zip"): + new_name = new_name[:-4] + + # 验证新文件名不为空 + if not new_name or new_name.replace("_", "") == "": + return Response().error("新文件名无效").__dict__ + + # 强制使用 .zip 扩展名 + new_filename = f"{new_name}.zip" + + # 检查原文件是否存在 + old_path = os.path.join(self.backup_dir, filename) + if not os.path.exists(old_path): + return Response().error("备份文件不存在").__dict__ + + # 检查新文件名是否已存在 + new_path = os.path.join(self.backup_dir, new_filename) + if os.path.exists(new_path): + return Response().error(f"文件名 '{new_filename}' 已存在").__dict__ + + # 执行重命名 + os.rename(old_path, new_path) + + logger.info(f"备份文件重命名: {filename} -> {new_filename}") + + return ( + Response() + .ok( + { + "old_filename": filename, + "new_filename": new_filename, + } + ) + .__dict__ + ) + except Exception as e: + logger.error(f"重命名备份失败: {e}") + logger.error(traceback.format_exc()) + return Response().error(f"重命名备份失败: {e!s}").__dict__ diff --git a/dashboard/src/components/shared/BackupDialog.vue b/dashboard/src/components/shared/BackupDialog.vue index 4de6d02ff..71dde199e 100644 --- a/dashboard/src/components/shared/BackupDialog.vue +++ b/dashboard/src/components/shared/BackupDialog.vue @@ -278,6 +278,13 @@ :title="t('features.settings.backup.list.restore')" @click="restoreFromList(backup.filename)" > + @@ -303,6 +310,50 @@ + + + + + mdi-pencil + {{ t('features.settings.backup.list.renameTitle') }} + + + + + +

+ {{ t('features.settings.backup.list.renameHint') }} +

+
+ + + + {{ t('core.common.cancel') }} + + + {{ t('core.common.confirm') }} + + +
+
+ @@ -349,6 +400,13 @@ const uploadProgress = ref({ const loadingList = ref(false) const backupList = ref([]) +// 重命名对话框状态 +const renameDialogOpen = ref(false) +const renameOldFilename = ref('') +const renameNewName = ref('') +const renameLoading = ref(false) +const renameError = ref('') + // 计算属性 const isProcessing = computed(() => { return exportStatus.value === 'processing' || @@ -785,6 +843,68 @@ const deleteBackup = async (filename) => { } } +// 重命名相关函数 +const openRenameDialog = (filename) => { + renameOldFilename.value = filename + // 移除 .zip 后缀,只显示文件名部分 + renameNewName.value = filename.replace(/\.zip$/i, '') + renameError.value = '' + renameDialogOpen.value = true +} + +const closeRenameDialog = () => { + renameDialogOpen.value = false + renameOldFilename.value = '' + renameNewName.value = '' + renameError.value = '' +} + +// 文件名验证规则 +const renameValidationRule = (value) => { + if (!value) return t('features.settings.backup.list.renameRequired') + // 检查是否包含非法字符 + if (/[\\/:*?"<>|]/.test(value)) { + return t('features.settings.backup.list.renameInvalidChars') + } + // 检查是否包含路径遍历字符 + if (value.includes('..')) { + return t('features.settings.backup.list.renameInvalidChars') + } + return true +} + +const confirmRename = async () => { + if (!renameNewName.value || renameError.value) return + + // 前端验证 + const validationResult = renameValidationRule(renameNewName.value) + if (validationResult !== true) { + renameError.value = validationResult + return + } + + renameLoading.value = true + renameError.value = '' + + try { + const response = await axios.post('/api/backup/rename', { + filename: renameOldFilename.value, + new_name: renameNewName.value + }) + + if (response.data.status === 'ok') { + closeRenameDialog() + loadBackupList() + } else { + renameError.value = response.data.message || t('features.settings.backup.list.renameFailed') + } + } catch (error) { + renameError.value = error.response?.data?.message || error.message || t('features.settings.backup.list.renameFailed') + } finally { + renameLoading.value = false + } +} + // 格式化文件大小 const formatFileSize = (bytes) => { if (bytes === 0) return '0 B' diff --git a/dashboard/src/i18n/locales/en-US/features/settings.json b/dashboard/src/i18n/locales/en-US/features/settings.json index cfdf90d12..a70620de5 100644 --- a/dashboard/src/i18n/locales/en-US/features/settings.json +++ b/dashboard/src/i18n/locales/en-US/features/settings.json @@ -99,7 +99,14 @@ "refresh": "Refresh List", "confirmDelete": "Are you sure you want to delete this backup file? This action cannot be undone.", "uploaded": "Uploaded", - "restore": "Restore this backup" + "restore": "Restore this backup", + "rename": "Rename", + "renameTitle": "Rename Backup File", + "newName": "New Filename", + "renameHint": "Filename can only contain letters, numbers, underscores, hyphens and dots", + "renameRequired": "Please enter a filename", + "renameInvalidChars": "Filename contains invalid characters", + "renameFailed": "Rename failed" } } } \ No newline at end of file diff --git a/dashboard/src/i18n/locales/zh-CN/features/settings.json b/dashboard/src/i18n/locales/zh-CN/features/settings.json index 084d486d4..f2e0e799c 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/settings.json +++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json @@ -99,7 +99,14 @@ "refresh": "刷新列表", "confirmDelete": "确定要删除这个备份文件吗?此操作不可撤销。", "uploaded": "已上传", - "restore": "恢复此备份" + "restore": "恢复此备份", + "rename": "重命名", + "renameTitle": "重命名备份文件", + "newName": "新文件名", + "renameHint": "文件名只能包含字母、数字、下划线、连字符和点", + "renameRequired": "请输入文件名", + "renameInvalidChars": "文件名包含非法字符", + "renameFailed": "重命名失败" } } } \ No newline at end of file From dfa3ce4994b81e95226d9201832cf2351f03b51d Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Sun, 28 Dec 2025 15:48:01 +0800 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20=E5=9C=A8=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=8F=AF=E7=94=A8=E5=A4=87=E4=BB=BD=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=90=8E=E5=9C=A8=E5=89=8D=E7=AB=AF=E9=83=A8=E5=88=86?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=A4=87=E4=BB=BD=E7=89=88=E6=9C=AC=E5=8F=B7?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=E6=89=8B=E5=8A=A8=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/dashboard/routes/backup.py | 28 ++++++++++++------- .../src/components/shared/BackupDialog.vue | 11 +++++++- .../i18n/locales/en-US/features/settings.json | 3 +- .../i18n/locales/zh-CN/features/settings.json | 3 +- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/astrbot/dashboard/routes/backup.py b/astrbot/dashboard/routes/backup.py index 7ebc9942a..16e611f02 100644 --- a/astrbot/dashboard/routes/backup.py +++ b/astrbot/dashboard/routes/backup.py @@ -226,24 +226,26 @@ async def _cleanup_upload_session(self, upload_id: str): logger.warning(f"清理分片目录失败: {e}") del self.upload_sessions[upload_id] - def _get_backup_origin(self, zip_path: str) -> str: - """从备份文件的 manifest.json 中读取 origin 字段 + def _get_backup_manifest(self, zip_path: str) -> dict | None: + """从备份文件读取 manifest.json Args: zip_path: ZIP 文件路径 Returns: - str: "exported" 或 "uploaded",如果无法读取则默认返回 "exported" + dict | None: manifest 内容,如果不是有效备份则返回 None """ try: with zipfile.ZipFile(zip_path, "r") as zf: if "manifest.json" in zf.namelist(): manifest_data = zf.read("manifest.json") - manifest = json.loads(manifest_data.decode("utf-8")) - return manifest.get("origin", "exported") + return json.loads(manifest_data.decode("utf-8")) + else: + # 没有 manifest.json,不是有效的 AstrBot 备份 + return None except Exception as e: logger.debug(f"读取备份 manifest 失败: {e}") - return "exported" # 旧版备份没有 origin 字段,默认为 exported + return None # 无法读取,不是有效备份 async def list_backups(self): """获取备份列表 @@ -270,16 +272,22 @@ async def list_backups(self): if not os.path.isfile(file_path): continue - stat = os.stat(file_path) - # 从 manifest.json 读取 origin 字段来判断备份类型 - origin = self._get_backup_origin(file_path) + # 读取 manifest.json 获取备份信息 + # 如果返回 None,说明不是有效的 AstrBot 备份,跳过 + manifest = self._get_backup_manifest(file_path) + if manifest is None: + logger.debug(f"跳过无效备份文件: {filename}") + continue + stat = os.stat(file_path) backup_files.append( { "filename": filename, "size": stat.st_size, "created_at": stat.st_mtime, - "type": origin, + "type": manifest.get("origin", "exported"), # 老版本没有 origin 默认为 exported + "astrbot_version": manifest.get("astrbot_version", "未知"), + "exported_at": manifest.get("exported_at"), } ) diff --git a/dashboard/src/components/shared/BackupDialog.vue b/dashboard/src/components/shared/BackupDialog.vue index 71dde199e..734d37fb6 100644 --- a/dashboard/src/components/shared/BackupDialog.vue +++ b/dashboard/src/components/shared/BackupDialog.vue @@ -264,7 +264,10 @@ {{ backup.filename }} {{ formatFileSize(backup.size) }} · {{ formatDate(backup.created_at) }} - + + v{{ backup.astrbot_version }} + + {{ t('features.settings.backup.list.uploaded') }} @@ -297,6 +300,12 @@ {{ t('features.settings.backup.list.refresh') }} + + +

+ mdi-information-outline + {{ t('features.settings.backup.list.ftpHint') }} +

diff --git a/dashboard/src/i18n/locales/en-US/features/settings.json b/dashboard/src/i18n/locales/en-US/features/settings.json index a70620de5..19fdd7c1a 100644 --- a/dashboard/src/i18n/locales/en-US/features/settings.json +++ b/dashboard/src/i18n/locales/en-US/features/settings.json @@ -106,7 +106,8 @@ "renameHint": "Filename can only contain letters, numbers, underscores, hyphens and dots", "renameRequired": "Please enter a filename", "renameInvalidChars": "Filename contains invalid characters", - "renameFailed": "Rename failed" + "renameFailed": "Rename failed", + "ftpHint": "For large backup files, you can also upload directly to the data/backups directory via FTP/SFTP" } } } \ No newline at end of file diff --git a/dashboard/src/i18n/locales/zh-CN/features/settings.json b/dashboard/src/i18n/locales/zh-CN/features/settings.json index f2e0e799c..c8515b603 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/settings.json +++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json @@ -106,7 +106,8 @@ "renameHint": "文件名只能包含字母、数字、下划线、连字符和点", "renameRequired": "请输入文件名", "renameInvalidChars": "文件名包含非法字符", - "renameFailed": "重命名失败" + "renameFailed": "重命名失败", + "ftpHint": "对于较大的备份文件,也可以通过 FTP/SFTP 等方式直接上传到 data/backups 目录" } } } \ No newline at end of file From ffc4bd005130966fef4910655fe468d362b75da1 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Sun, 28 Dec 2025 15:48:28 +0800 Subject: [PATCH 07/11] style: format code --- astrbot/dashboard/routes/backup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/astrbot/dashboard/routes/backup.py b/astrbot/dashboard/routes/backup.py index 16e611f02..a2fc7642b 100644 --- a/astrbot/dashboard/routes/backup.py +++ b/astrbot/dashboard/routes/backup.py @@ -285,7 +285,9 @@ async def list_backups(self): "filename": filename, "size": stat.st_size, "created_at": stat.st_mtime, - "type": manifest.get("origin", "exported"), # 老版本没有 origin 默认为 exported + "type": manifest.get( + "origin", "exported" + ), # 老版本没有 origin 默认为 exported "astrbot_version": manifest.get("astrbot_version", "未知"), "exported_at": manifest.get("exported_at"), } From b64fef47069d9a641407c1727db3b259ec0068d1 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Sun, 28 Dec 2025 15:52:03 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=E9=83=A8=E5=88=86=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_backup.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/test_backup.py b/tests/test_backup.py index f9aa01b75..91db47009 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -195,6 +195,7 @@ def test_generate_manifest(self, mock_main_db, mock_kb_manager): assert manifest["version"] == BACKUP_MANIFEST_VERSION assert manifest["astrbot_version"] == VERSION + assert manifest["origin"] == "exported" # 验证备份来源标记 assert "exported_at" in manifest assert "tables" in manifest assert "statistics" in manifest @@ -412,11 +413,19 @@ def test_secure_filename_empty(self): def test_generate_unique_filename(self): """测试生成唯一文件名""" result = generate_unique_filename("backup.zip") - # 应包含 uploaded_ 前缀和时间戳 - assert result.startswith("uploaded_") - assert result.endswith("_backup.zip") + # 应包含原文件名和时间戳后缀 + assert result.startswith("backup_") + assert result.endswith(".zip") # 应包含时间戳格式 YYYYMMDD_HHMMSS - assert re.search(r"uploaded_\d{8}_\d{6}_backup\.zip", result) + assert re.search(r"backup_\d{8}_\d{6}\.zip", result) + + def test_generate_unique_filename_with_complex_name(self): + """测试复杂文件名生成唯一文件名""" + result = generate_unique_filename("my_backup_file.zip") + # 应在原文件名后添加时间戳 + assert result.startswith("my_backup_file_") + assert result.endswith(".zip") + assert re.search(r"my_backup_file_\d{8}_\d{6}\.zip", result) class TestVersionComparison: @@ -750,6 +759,7 @@ async def test_export_import_roundtrip(self, tmp_path): # 读取 manifest manifest = json.loads(zf.read("manifest.json")) assert manifest["astrbot_version"] == VERSION + assert manifest["origin"] == "exported" # 验证备份来源标记 # 读取配置 config = json.loads(zf.read("config/cmd_config.json")) From 2ef9630d22259499b4b9f73c600dad29b5b4c879 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Sun, 28 Dec 2025 16:06:13 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E5=8E=9F=E7=94=9F=E4=B8=8B=E8=BD=BD=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E9=97=AE=E9=A2=98=EF=BC=8C=E9=80=9A=E8=BF=87url?= =?UTF-8?q?=E4=BC=A0=E5=8F=82=E7=9A=84=E6=96=B9=E5=BC=8F=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E8=AE=A4=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/dashboard/routes/backup.py | 22 +++++++++++++++++++ astrbot/dashboard/server.py | 1 + .../src/components/shared/BackupDialog.vue | 9 +++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/astrbot/dashboard/routes/backup.py b/astrbot/dashboard/routes/backup.py index a2fc7642b..cbee6fa65 100644 --- a/astrbot/dashboard/routes/backup.py +++ b/astrbot/dashboard/routes/backup.py @@ -12,6 +12,7 @@ from datetime import datetime from pathlib import Path +import jwt from quart import request, send_file from astrbot.core import logger @@ -924,12 +925,33 @@ async def download_backup(self): Query 参数: - filename: 备份文件名 (必填) + - token: JWT token (必填,用于浏览器原生下载鉴权) + + 注意: 此路由已被添加到 auth_middleware 白名单中, + 使用 URL 参数中的 token 进行鉴权,以支持浏览器原生下载。 """ try: filename = request.args.get("filename") + token = request.args.get("token") + if not filename: return Response().error("缺少参数 filename").__dict__ + if not token: + return Response().error("缺少参数 token").__dict__ + + # 验证 JWT token + try: + jwt_secret = self.config.get("dashboard", {}).get("jwt_secret") + if not jwt_secret: + return Response().error("服务器配置错误").__dict__ + + jwt.decode(token, jwt_secret, algorithms=["HS256"]) + except jwt.ExpiredSignatureError: + return Response().error("Token 已过期,请刷新页面后重试").__dict__ + except jwt.InvalidTokenError: + return Response().error("Token 无效").__dict__ + # 安全检查 - 防止路径遍历 if ".." in filename or "/" in filename or "\\" in filename: return Response().error("无效的文件名").__dict__ diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index f778a5049..ad83c4886 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -115,6 +115,7 @@ async def auth_middleware(self): "/api/file", "/api/platform/webhook", "/api/stat/start-time", + "/api/backup/download", # 备份下载使用 URL 参数传递 token ] if any(request.path.startswith(prefix) for prefix in allowed_endpoints): return None diff --git a/dashboard/src/components/shared/BackupDialog.vue b/dashboard/src/components/shared/BackupDialog.vue index 734d37fb6..4c9a96b53 100644 --- a/dashboard/src/components/shared/BackupDialog.vue +++ b/dashboard/src/components/shared/BackupDialog.vue @@ -792,8 +792,15 @@ const resetImport = async () => { // 下载备份(使用浏览器原生下载,可显示下载进度) const downloadBackup = (filename) => { + // 获取 token 用于鉴权(因为浏览器原生下载无法携带 Authorization header) + const token = localStorage.getItem('token') + if (!token) { + alert(t('core.common.unauthorized')) + return + } + // 直接使用浏览器下载,这样可以看到原生下载进度条 - const downloadUrl = `/api/backup/download?filename=${encodeURIComponent(filename)}` + const downloadUrl = `/api/backup/download?filename=${encodeURIComponent(filename)}&token=${encodeURIComponent(token)}` // 创建隐藏的 a 标签触发下载 const link = document.createElement('a') From 06a9e334c3315516b3a3b0104d13f0d06f86c4eb Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Sun, 28 Dec 2025 16:18:28 +0800 Subject: [PATCH 10/11] =?UTF-8?q?feat(backup):=20=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E5=A4=87=E4=BB=BD=E7=B3=BB=E7=BB=9F=E7=9A=84=E5=88=86=E7=89=87?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=92=8C=E4=B8=8B=E8=BD=BD=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复浏览器原生下载鉴权问题,支持 URL 参数传递 token - 修复上传会话过期判断,使用 last_activity 避免活跃上传被清理 - 延迟启动后台清理任务,避免 asyncio 事件循环问题 - 统一由后端计算 chunk_size 和 total_chunks,避免前后端不一致 - 更新 generate_unique_filename 文档注释与实际行为一致 - 更新测试用例以验证 origin 字段 修复问题: - 浏览器下载时显示"需要授权" - 大文件上传可能因会话过期失败 - __init__ 中 asyncio.create_task 可能失败 --- astrbot/dashboard/routes/backup.py | 59 ++++++++++++++----- .../src/components/shared/BackupDialog.vue | 27 ++++----- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/astrbot/dashboard/routes/backup.py b/astrbot/dashboard/routes/backup.py index cbee6fa65..0e53a57f9 100644 --- a/astrbot/dashboard/routes/backup.py +++ b/astrbot/dashboard/routes/backup.py @@ -63,17 +63,16 @@ def secure_filename(filename: str) -> str: def generate_unique_filename(original_filename: str) -> str: - """生成唯一的文件名,添加时间戳后缀避免重名 + """生成唯一的文件名,在原文件名后添加时间戳后缀避免重名 Args: original_filename: 原始文件名(已清洗) Returns: - 唯一的文件名 + 添加了时间戳后缀的唯一文件名,格式为 {原文件名}_{YYYYMMDD_HHMMSS}.{扩展名} """ name, ext = os.path.splitext(original_filename) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - # 如果文件名已经包含时间戳格式,直接使用;否则添加时间戳后缀 return f"{name}_{timestamp}{ext}" @@ -101,9 +100,12 @@ def __init__( self.backup_progress: dict[str, dict] = {} # 分片上传会话跟踪 - # upload_id -> {filename, total_chunks, received_chunks, created_at, chunk_dir} + # upload_id -> {filename, total_chunks, received_chunks, last_activity, chunk_dir} self.upload_sessions: dict[str, dict] = {} + # 后台清理任务句柄 + self._cleanup_task: asyncio.Task | None = None + # 注册路由 self.routes = { "/backup/list": ("GET", self.list_backups), @@ -122,9 +124,6 @@ def __init__( } self.register_routes() - # 启动清理过期上传会话的任务 - asyncio.create_task(self._cleanup_expired_uploads()) - def _init_task(self, task_id: str, task_type: str, status: str = "pending") -> None: """初始化任务状态""" self.backup_tasks[task_id] = { @@ -196,8 +195,20 @@ async def _callback(stage: str, current: int, total: int, message: str = ""): return _callback + def _ensure_cleanup_task_started(self): + """确保后台清理任务已启动(在异步上下文中延迟启动)""" + if self._cleanup_task is None or self._cleanup_task.done(): + try: + self._cleanup_task = asyncio.create_task(self._cleanup_expired_uploads()) + except RuntimeError: + # 如果没有运行中的事件循环,跳过(等待下次异步调用时启动) + pass + async def _cleanup_expired_uploads(self): - """定期清理过期的上传会话""" + """定期清理过期的上传会话 + + 基于 last_activity 字段判断过期,避免清理活跃的上传会话。 + """ while True: try: await asyncio.sleep(300) # 每5分钟检查一次 @@ -205,13 +216,18 @@ async def _cleanup_expired_uploads(self): expired_sessions = [] for upload_id, session in self.upload_sessions.items(): - if current_time - session["created_at"] > UPLOAD_EXPIRE_SECONDS: + # 使用 last_activity 判断过期,而非 created_at + last_activity = session.get("last_activity", session["created_at"]) + if current_time - last_activity > UPLOAD_EXPIRE_SECONDS: expired_sessions.append(upload_id) for upload_id in expired_sessions: await self._cleanup_upload_session(upload_id) logger.info(f"清理过期的上传会话: {upload_id}") + except asyncio.CancelledError: + # 任务被取消,正常退出 + break except Exception as e: logger.error(f"清理过期上传会话失败: {e}") @@ -249,6 +265,9 @@ def _get_backup_manifest(self, zip_path: str) -> dict | None: return None # 无法读取,不是有效备份 async def list_backups(self): + # 确保后台清理任务已启动 + self._ensure_cleanup_task_started() + """获取备份列表 Query 参数: @@ -446,17 +465,16 @@ async def upload_init(self): JSON Body: - filename: 原始文件名 - total_size: 文件总大小(字节) - - total_chunks: 分片总数 返回: - upload_id: 上传会话 ID - - chunk_size: 建议的分片大小 + - chunk_size: 分片大小(由后端决定) + - total_chunks: 分片总数(由后端根据 total_size 和 chunk_size 计算) """ try: data = await request.json filename = data.get("filename") total_size = data.get("total_size", 0) - total_chunks = data.get("total_chunks", 0) if not filename: return Response().error("缺少 filename 参数").__dict__ @@ -464,8 +482,13 @@ async def upload_init(self): if not filename.endswith(".zip"): return Response().error("请上传 ZIP 格式的备份文件").__dict__ - if total_chunks <= 0: - return Response().error("无效的分片数量").__dict__ + if total_size <= 0: + return Response().error("无效的文件大小").__dict__ + + # 由后端计算分片总数,确保前后端一致 + import math + + total_chunks = math.ceil(total_size / CHUNK_SIZE) # 生成上传 ID upload_id = str(uuid.uuid4()) @@ -479,13 +502,15 @@ async def upload_init(self): unique_filename = generate_unique_filename(safe_filename) # 创建上传会话 + current_time = time.time() self.upload_sessions[upload_id] = { "filename": unique_filename, "original_filename": filename, "total_size": total_size, "total_chunks": total_chunks, "received_chunks": set(), - "created_at": time.time(), + "created_at": current_time, + "last_activity": current_time, # 用于判断会话是否活跃 "chunk_dir": chunk_dir, } @@ -500,6 +525,7 @@ async def upload_init(self): { "upload_id": upload_id, "chunk_size": CHUNK_SIZE, + "total_chunks": total_chunks, "filename": unique_filename, } ) @@ -557,8 +583,9 @@ async def upload_chunk(self): chunk_path = os.path.join(session["chunk_dir"], f"{chunk_index}.part") await chunk_file.save(chunk_path) - # 记录已接收的分片 + # 记录已接收的分片,并更新最后活动时间 session["received_chunks"].add(chunk_index) + session["last_activity"] = time.time() # 刷新活动时间,防止活跃上传被清理 received_count = len(session["received_chunks"]) total_chunks = session["total_chunks"] diff --git a/dashboard/src/components/shared/BackupDialog.vue b/dashboard/src/components/shared/BackupDialog.vue index 4c9a96b53..eb0327e4e 100644 --- a/dashboard/src/components/shared/BackupDialog.vue +++ b/dashboard/src/components/shared/BackupDialog.vue @@ -395,9 +395,9 @@ const uploadedFilename = ref('') // 已上传的文件名 const checkResult = ref(null) // 预检查结果 // 分片上传状态 -const CHUNK_SIZE = 1024 * 1024 // 1MB const CONCURRENT_UPLOADS = 5 // 并发上传数 const uploadId = ref('') +const chunkSize = ref(0) // 分片大小(从后端获取) const uploadProgress = ref({ uploaded: 0, total: 0, @@ -554,22 +554,22 @@ const resetExport = () => { * 后端按分片索引命名文件(如 0.part, 1.part),合并时按顺序读取, * 因此分片到达顺序不影响最终结果。 */ -const uploadChunksInParallel = async (file, totalChunks, currentUploadId) => { +const uploadChunksInParallel = async (file, totalChunks, currentUploadId, currentChunkSize) => { // 跟踪已完成的字节数(使用原子操作避免并发问题) let completedBytes = 0 const chunkSizes = [] - // 预计算每个分片的大小 + // 预计算每个分片的大小(使用后端返回的 chunk_size) for (let i = 0; i < totalChunks; i++) { - const start = i * CHUNK_SIZE - const end = Math.min(start + CHUNK_SIZE, file.size) + const start = i * currentChunkSize + const end = Math.min(start + currentChunkSize, file.size) chunkSizes[i] = end - start } // 上传单个分片的函数 const uploadSingleChunk = async (chunkIndex) => { - const start = chunkIndex * CHUNK_SIZE - const end = Math.min(start + CHUNK_SIZE, file.size) + const start = chunkIndex * currentChunkSize + const end = Math.min(start + currentChunkSize, file.size) const chunk = file.slice(start, end) const formData = new FormData() @@ -625,9 +625,6 @@ const uploadAndCheck = async () => { const file = importFile.value try { - // 计算分片数量 - const totalChunks = Math.ceil(file.size / CHUNK_SIZE) - // 初始化上传进度 uploadProgress.value = { uploaded: 0, @@ -636,11 +633,10 @@ const uploadAndCheck = async () => { message: t('features.settings.backup.import.uploadInit') } - // 步骤1: 初始化分片上传 + // 步骤1: 初始化分片上传(后端计算并返回 chunk_size 和 total_chunks) const initResponse = await axios.post('/api/backup/upload/init', { filename: file.name, - total_size: file.size, - total_chunks: totalChunks + total_size: file.size }) if (initResponse.data.status !== 'ok') { @@ -648,11 +644,13 @@ const uploadAndCheck = async () => { } uploadId.value = initResponse.data.data.upload_id + chunkSize.value = initResponse.data.data.chunk_size + const totalChunks = initResponse.data.data.total_chunks // 步骤2: 并行分片上传(5个并发连接) uploadProgress.value.message = t('features.settings.backup.import.uploadingChunks') - await uploadChunksInParallel(file, totalChunks, uploadId.value) + await uploadChunksInParallel(file, totalChunks, uploadId.value, chunkSize.value) // 步骤3: 完成上传 uploadProgress.value.message = t('features.settings.backup.import.uploadComplete') @@ -787,6 +785,7 @@ const resetImport = async () => { uploadedFilename.value = '' checkResult.value = null uploadId.value = '' + chunkSize.value = 0 uploadProgress.value = { uploaded: 0, total: 0, percent: 0, message: '' } } From b89bf1b17f62b13019510e599ae362d38235e1ee Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Sun, 28 Dec 2025 16:19:52 +0800 Subject: [PATCH 11/11] style: format code --- astrbot/dashboard/routes/backup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/astrbot/dashboard/routes/backup.py b/astrbot/dashboard/routes/backup.py index 0e53a57f9..a7c29de0b 100644 --- a/astrbot/dashboard/routes/backup.py +++ b/astrbot/dashboard/routes/backup.py @@ -199,7 +199,9 @@ def _ensure_cleanup_task_started(self): """确保后台清理任务已启动(在异步上下文中延迟启动)""" if self._cleanup_task is None or self._cleanup_task.done(): try: - self._cleanup_task = asyncio.create_task(self._cleanup_expired_uploads()) + self._cleanup_task = asyncio.create_task( + self._cleanup_expired_uploads() + ) except RuntimeError: # 如果没有运行中的事件循环,跳过(等待下次异步调用时启动) pass