feat: add features for chunked upload and backup file management to the backup section (#4237)

* feat: 添加分片上传备份文件功能

* feat: 为上传备份文件添加异步并发以提升速度

* feat: 使用浏览器原生下载方式以显示进度条

* feat: 添加从已上传备份列表恢复的功能

* feat: 允许重命名备份文件

* feat: 在后端校验可用备份文件后在前端部分显示备份版本号,添加手动上传提示

* style: format code

* fix: 更新备份部分测试

* fix: 修复浏览器原生下载鉴权问题,通过url传参的方式完成认证

* feat(backup): 改进备份系统的分片上传和下载鉴权

- 修复浏览器原生下载鉴权问题,支持 URL 参数传递 token
- 修复上传会话过期判断,使用 last_activity 避免活跃上传被清理
- 延迟启动后台清理任务,避免 asyncio 事件循环问题
- 统一由后端计算 chunk_size 和 total_chunks,避免前后端不一致
- 更新 generate_unique_filename 文档注释与实际行为一致
- 更新测试用例以验证 origin 字段

修复问题:
- 浏览器下载时显示"需要授权"
- 大文件上传可能因会话过期失败
- __init__ 中 asyncio.create_task 可能失败

* style: format code
This commit is contained in:
RC-CHN
2025-12-29 12:30:59 +08:00
committed by GitHub
parent fc61f7ad32
commit 9eafd7b44a
7 changed files with 923 additions and 57 deletions
+1
View File
@@ -447,6 +447,7 @@ class AstrBotExporter:
"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",
+519 -15
View File
@@ -1,13 +1,18 @@
"""备份管理 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
import jwt
from quart import request, send_file
from astrbot.core import logger
@@ -22,6 +27,10 @@ from astrbot.core.utils.astrbot_path import (
from .route import Response, Route, RouteContext
# 分片上传常量
CHUNK_SIZE = 1024 * 1024 # 1MB
UPLOAD_EXPIRE_SECONDS = 3600 # 上传会话过期时间(1小时)
def secure_filename(filename: str) -> str:
"""清洗文件名,移除路径遍历字符和危险字符
@@ -54,17 +63,17 @@ def secure_filename(filename: str) -> str:
def generate_unique_filename(original_filename: str) -> str:
"""生成唯一的文件名,添加时间戳前缀
"""生成唯一的文件名,在原文件名后添加时间戳后缀避免重名
Args:
original_filename: 原始文件名(已清洗)
Returns:
唯一文件名
添加了时间戳后缀的唯一文件名,格式为 {原文件名}_{YYYYMMDD_HHMMSS}.{扩展名}
"""
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):
@@ -84,21 +93,34 @@ class BackupRoute(Route):
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, 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),
"/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),
"/backup/download": ("GET", self.download_backup),
"/backup/delete": ("POST", self.delete_backup),
"/backup/rename": ("POST", self.rename_backup), # 重命名备份
}
self.register_routes()
@@ -173,7 +195,81 @@ class BackupRoute(Route):
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分钟检查一次
current_time = time.time()
expired_sessions = []
for upload_id, session in self.upload_sessions.items():
# 使用 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}")
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]
def _get_backup_manifest(self, zip_path: str) -> dict | None:
"""从备份文件读取 manifest.json
Args:
zip_path: ZIP 文件路径
Returns:
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")
return json.loads(manifest_data.decode("utf-8"))
else:
# 没有 manifest.json,不是有效的 AstrBot 备份
return None
except Exception as e:
logger.debug(f"读取备份 manifest 失败: {e}")
return None # 无法读取,不是有效备份
async def list_backups(self):
# 确保后台清理任务已启动
self._ensure_cleanup_task_started()
"""获取备份列表
Query 参数:
@@ -190,16 +286,34 @@ class BackupRoute(Route):
# 获取所有备份文件
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
# 读取 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": manifest.get(
"origin", "exported"
), # 老版本没有 origin 默认为 exported
"astrbot_version": manifest.get("astrbot_version", "未知"),
"exported_at": manifest.get("exported_at"),
}
)
# 按创建时间倒序排序
backup_files.sort(key=lambda x: x["created_at"], reverse=True)
@@ -345,6 +459,309 @@ class BackupRoute(Route):
logger.error(traceback.format_exc())
return Response().error(f"上传备份文件失败: {e!s}").__dict__
async def upload_init(self):
"""初始化分片上传
创建一个上传会话,返回 upload_id 供后续分片上传使用。
JSON Body:
- filename: 原始文件名
- total_size: 文件总大小(字节)
返回:
- upload_id: 上传会话 ID
- chunk_size: 分片大小(由后端决定)
- total_chunks: 分片总数(由后端根据 total_size 和 chunk_size 计算)
"""
try:
data = await request.json
filename = data.get("filename")
total_size = data.get("total_size", 0)
if not filename:
return Response().error("缺少 filename 参数").__dict__
if not filename.endswith(".zip"):
return Response().error("请上传 ZIP 格式的备份文件").__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())
# 创建分片存储目录
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)
# 创建上传会话
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": current_time,
"last_activity": current_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,
"total_chunks": total_chunks,
"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)
session["last_activity"] = time.time() # 刷新活动时间,防止活跃上传被清理
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__
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):
"""完成分片上传
合并所有分片为完整文件。
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)
# 标记备份为上传来源(修改 manifest.json 中的 origin 字段)
self._mark_backup_as_uploaded(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):
"""预检查备份文件
@@ -537,12 +954,33 @@ class BackupRoute(Route):
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__
@@ -587,3 +1025,69 @@ class BackupRoute(Route):
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__
+1
View File
@@ -115,6 +115,7 @@ class AstrBotDashboard:
"/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
+358 -36
View File
@@ -110,9 +110,23 @@
<!-- 步骤1.5: 上传中 -->
<div v-else-if="importStatus === 'uploading'" class="text-center py-8">
<v-progress-circular indeterminate color="primary" size="64" class="mb-4"></v-progress-circular>
<v-icon size="64" color="primary" class="mb-4">mdi-cloud-upload</v-icon>
<h3 class="mb-4">{{ t('features.settings.backup.import.uploading') }}</h3>
<p class="text-grey">{{ t('features.settings.backup.import.uploadWait') }}</p>
<p class="text-grey mb-2">
{{ uploadProgress.message || t('features.settings.backup.import.uploadWait') }}
</p>
<p class="text-grey-darken-1 mb-4">
{{ formatFileSize(uploadProgress.uploaded) }} / {{ formatFileSize(uploadProgress.total) }}
({{ uploadProgress.percent }}%)
</p>
<v-progress-linear
:model-value="uploadProgress.percent"
:max="100"
class="mt-2"
color="primary"
height="8"
rounded
></v-progress-linear>
</div>
<!-- 步骤2: 确认导入 -->
@@ -242,15 +256,38 @@
:key="backup.filename"
>
<template v-slot:prepend>
<v-icon color="primary">mdi-zip-box</v-icon>
<v-icon :color="backup.type === 'uploaded' ? 'orange' : 'primary'">
{{ backup.type === 'uploaded' ? 'mdi-upload' : 'mdi-zip-box' }}
</v-icon>
</template>
<v-list-item-title>{{ backup.filename }}</v-list-item-title>
<v-list-item-subtitle>
{{ formatFileSize(backup.size) }} · {{ formatDate(backup.created_at) }}
<v-chip size="x-small" color="primary" variant="tonal" class="ml-2">
v{{ backup.astrbot_version }}
</v-chip>
<v-chip v-if="backup.type === 'uploaded'" size="x-small" color="orange" variant="tonal" class="ml-1">
{{ t('features.settings.backup.list.uploaded') }}
</v-chip>
</v-list-item-subtitle>
<template v-slot:append>
<v-btn
icon="mdi-restore"
variant="text"
size="small"
color="success"
:title="t('features.settings.backup.list.restore')"
@click="restoreFromList(backup.filename)"
></v-btn>
<v-btn
icon="mdi-pencil"
variant="text"
size="small"
:title="t('features.settings.backup.list.rename')"
@click="openRenameDialog(backup.filename)"
></v-btn>
<v-btn icon="mdi-download" variant="text" size="small" @click="downloadBackup(backup.filename)"></v-btn>
<v-btn icon="mdi-delete" variant="text" size="small" color="error" @click="deleteBackup(backup.filename)"></v-btn>
</template>
@@ -263,6 +300,12 @@
{{ t('features.settings.backup.list.refresh') }}
</v-btn>
</div>
<!-- 提示信息 -->
<p class="text-caption text-grey text-center mt-4">
<v-icon size="small" class="mr-1">mdi-information-outline</v-icon>
{{ t('features.settings.backup.list.ftpHint') }}
</p>
</v-window-item>
</v-window>
</v-card-text>
@@ -276,6 +319,50 @@
</v-card>
</v-dialog>
<!-- 重命名对话框 -->
<v-dialog v-model="renameDialogOpen" max-width="450" persistent>
<v-card>
<v-card-title>
<v-icon class="mr-2">mdi-pencil</v-icon>
{{ t('features.settings.backup.list.renameTitle') }}
</v-card-title>
<v-card-text>
<v-text-field
v-model="renameNewName"
:label="t('features.settings.backup.list.newName')"
:rules="[renameValidationRule]"
:error-messages="renameError"
variant="outlined"
density="comfortable"
autofocus
@keyup.enter="confirmRename"
>
<template v-slot:append-inner>
<span class="text-grey">.zip</span>
</template>
</v-text-field>
<p class="text-caption text-grey mt-1">
{{ t('features.settings.backup.list.renameHint') }}
</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="closeRenameDialog">
{{ t('core.common.cancel') }}
</v-btn>
<v-btn
color="primary"
variant="flat"
@click="confirmRename"
:loading="renameLoading"
:disabled="!renameNewName || !!renameError"
>
{{ t('core.common.confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<WaitingForRestart ref="wfr"></WaitingForRestart>
</template>
@@ -307,13 +394,33 @@ const importError = ref('')
const uploadedFilename = ref('') // 已上传的文件名
const checkResult = ref(null) // 预检查结果
// 分片上传状态
const CONCURRENT_UPLOADS = 5 // 并发上传数
const uploadId = ref('')
const chunkSize = ref(0) // 分片大小(从后端获取)
const uploadProgress = ref({
uploaded: 0,
total: 0,
percent: 0,
message: ''
})
// 备份列表
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' || importStatus.value === 'processing'
return exportStatus.value === 'processing' ||
importStatus.value === 'processing' ||
importStatus.value === 'uploading'
})
// 版本检查相关的计算属性
@@ -440,28 +547,127 @@ const resetExport = () => {
exportError.value = ''
}
/**
* 并发上传分片
*
* 使用并发控制同时上传多个分片,提升上传速度。
* 后端按分片索引命名文件(如 0.part, 1.part),合并时按顺序读取,
* 因此分片到达顺序不影响最终结果。
*/
const uploadChunksInParallel = async (file, totalChunks, currentUploadId, currentChunkSize) => {
// 跟踪已完成的字节数(使用原子操作避免并发问题)
let completedBytes = 0
const chunkSizes = []
// 预计算每个分片的大小(使用后端返回的 chunk_size
for (let i = 0; i < totalChunks; i++) {
const start = i * currentChunkSize
const end = Math.min(start + currentChunkSize, file.size)
chunkSizes[i] = end - start
}
// 上传单个分片的函数
const uploadSingleChunk = async (chunkIndex) => {
const start = chunkIndex * currentChunkSize
const end = Math.min(start + currentChunkSize, 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
importStatus.value = 'uploading'
const file = importFile.value
try {
// 步骤1: 上传文件
const formData = new FormData()
formData.append('file', importFile.value)
const uploadResponse = await axios.post('/api/backup/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
if (uploadResponse.data.status !== 'ok') {
throw new Error(uploadResponse.data.message)
// 初始化上传进度
uploadProgress.value = {
uploaded: 0,
total: file.size,
percent: 0,
message: t('features.settings.backup.import.uploadInit')
}
uploadedFilename.value = uploadResponse.data.data.filename
// 步骤1: 初始化分片上传(后端计算并返回 chunk_size 和 total_chunks
const initResponse = await axios.post('/api/backup/upload/init', {
filename: file.name,
total_size: file.size
})
if (initResponse.data.status !== 'ok') {
throw new Error(initResponse.data.message)
}
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, chunkSize.value)
// 步骤3: 完成上传
uploadProgress.value.message = t('features.settings.backup.import.uploadComplete')
const completeResponse = await axios.post('/api/backup/upload/complete', {
upload_id: uploadId.value
})
if (completeResponse.data.status !== 'ok') {
throw new Error(completeResponse.data.message)
}
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 +689,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 +765,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,29 +784,61 @@ const resetImport = () => {
importError.value = ''
uploadedFilename.value = ''
checkResult.value = null
uploadId.value = ''
chunkSize.value = 0
uploadProgress.value = { uploaded: 0, total: 0, percent: 0, message: '' }
}
// 下载备份
const downloadBackup = async (filename) => {
// 下载备份(使用浏览器原生下载,可显示下载进度)
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)}&token=${encodeURIComponent(token)}`
// 创建隐藏的 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)
}
// 从列表中恢复备份
const restoreFromList = async (filename) => {
// 切换到导入标签页并设置文件名
uploadedFilename.value = filename
// 预检查
try {
const response = await axios.get('/api/backup/download', {
params: { filename },
responseType: 'blob'
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
// 创建 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)
if (!checkResult.value.valid) {
alert(checkResult.value.error || t('features.settings.backup.import.invalidBackup'))
return
}
// 切换到导入标签页并显示确认
activeTab.value = 'import'
importStatus.value = 'confirm'
} catch (error) {
console.error('Download failed:', error)
alert(t('features.settings.backup.export.failed') + ': ' + (error.message || 'Unknown error'))
alert(error.response?.data?.message || error.message || 'Check failed')
}
}
@@ -598,6 +858,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'
@@ -632,9 +954,9 @@ const restartAstrBot = () => {
}
// 重置所有状态
const resetAll = () => {
const resetAll = async () => {
resetExport()
resetImport()
await resetImport()
activeTab.value = 'export'
}
@@ -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",
@@ -93,7 +97,17 @@
"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",
"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",
"ftpHint": "For large backup files, you can also upload directly to the data/backups directory via FTP/SFTP"
}
}
}
@@ -64,6 +64,10 @@
"uploadAndCheck": "上传并检查",
"uploading": "正在上传...",
"uploadWait": "请稍候,正在上传备份文件...",
"uploadInit": "正在初始化上传...",
"uploadingChunks": "正在上传分片...",
"uploadComplete": "上传完成,正在合并文件...",
"checking": "正在检查备份文件...",
"invalidBackup": "无效的备份文件",
"backupContents": "备份内容",
"tables": "个数据表",
@@ -93,7 +97,17 @@
"list": {
"empty": "暂无备份文件",
"refresh": "刷新列表",
"confirmDelete": "确定要删除这个备份文件吗?此操作不可撤销。"
"confirmDelete": "确定要删除这个备份文件吗?此操作不可撤销。",
"uploaded": "已上传",
"restore": "恢复此备份",
"rename": "重命名",
"renameTitle": "重命名备份文件",
"newName": "新文件名",
"renameHint": "文件名只能包含字母、数字、下划线、连字符和点",
"renameRequired": "请输入文件名",
"renameInvalidChars": "文件名包含非法字符",
"renameFailed": "重命名失败",
"ftpHint": "对于较大的备份文件,也可以通过 FTP/SFTP 等方式直接上传到 data/backups 目录"
}
}
}
+14 -4
View File
@@ -195,6 +195,7 @@ class TestAstrBotExporter:
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 @@ class TestSecureFilename:
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 @@ class TestBackupIntegration:
# 读取 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"))