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:
@@ -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",
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user