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"
>
- mdi-zip-box
+
+ {{ backup.type === 'uploaded' ? 'mdi-upload' : 'mdi-zip-box' }}
+
{{ 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') }}
+
+
+
+
+ .zip
+
+
+
+ {{ 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