AstrBot 4.10.2__py3-none-any.whl → 4.10.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- astrbot/builtin_stars/astrbot/long_term_memory.py +186 -0
- astrbot/builtin_stars/astrbot/main.py +120 -0
- astrbot/builtin_stars/astrbot/metadata.yaml +4 -0
- astrbot/builtin_stars/astrbot/process_llm_request.py +245 -0
- astrbot/builtin_stars/builtin_commands/commands/__init__.py +31 -0
- astrbot/builtin_stars/builtin_commands/commands/admin.py +77 -0
- astrbot/builtin_stars/builtin_commands/commands/alter_cmd.py +173 -0
- astrbot/builtin_stars/builtin_commands/commands/conversation.py +366 -0
- astrbot/builtin_stars/builtin_commands/commands/help.py +88 -0
- astrbot/builtin_stars/builtin_commands/commands/llm.py +20 -0
- astrbot/builtin_stars/builtin_commands/commands/persona.py +142 -0
- astrbot/builtin_stars/builtin_commands/commands/plugin.py +120 -0
- astrbot/builtin_stars/builtin_commands/commands/provider.py +329 -0
- astrbot/builtin_stars/builtin_commands/commands/setunset.py +36 -0
- astrbot/builtin_stars/builtin_commands/commands/sid.py +36 -0
- astrbot/builtin_stars/builtin_commands/commands/t2i.py +23 -0
- astrbot/builtin_stars/builtin_commands/commands/tool.py +31 -0
- astrbot/builtin_stars/builtin_commands/commands/tts.py +36 -0
- astrbot/builtin_stars/builtin_commands/commands/utils/rst_scene.py +26 -0
- astrbot/builtin_stars/builtin_commands/main.py +237 -0
- astrbot/builtin_stars/builtin_commands/metadata.yaml +4 -0
- astrbot/builtin_stars/python_interpreter/main.py +536 -0
- astrbot/builtin_stars/python_interpreter/metadata.yaml +4 -0
- astrbot/builtin_stars/python_interpreter/requirements.txt +1 -0
- astrbot/builtin_stars/python_interpreter/shared/api.py +22 -0
- astrbot/builtin_stars/reminder/main.py +266 -0
- astrbot/builtin_stars/reminder/metadata.yaml +4 -0
- astrbot/builtin_stars/session_controller/main.py +114 -0
- astrbot/builtin_stars/session_controller/metadata.yaml +5 -0
- astrbot/builtin_stars/web_searcher/engines/__init__.py +111 -0
- astrbot/builtin_stars/web_searcher/engines/bing.py +30 -0
- astrbot/builtin_stars/web_searcher/engines/sogo.py +52 -0
- astrbot/builtin_stars/web_searcher/main.py +436 -0
- astrbot/builtin_stars/web_searcher/metadata.yaml +4 -0
- astrbot/cli/__init__.py +1 -1
- astrbot/core/agent/message.py +32 -1
- astrbot/core/agent/runners/tool_loop_agent_runner.py +26 -8
- astrbot/core/astr_agent_hooks.py +6 -0
- astrbot/core/backup/__init__.py +26 -0
- astrbot/core/backup/constants.py +77 -0
- astrbot/core/backup/exporter.py +477 -0
- astrbot/core/backup/importer.py +761 -0
- astrbot/core/config/astrbot_config.py +2 -0
- astrbot/core/config/default.py +47 -6
- astrbot/core/knowledge_base/chunking/recursive.py +10 -2
- astrbot/core/log.py +1 -1
- astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +184 -174
- astrbot/core/pipeline/result_decorate/stage.py +65 -57
- astrbot/core/pipeline/waking_check/stage.py +31 -3
- astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +15 -29
- astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +1 -6
- astrbot/core/platform/sources/dingtalk/dingtalk_event.py +15 -1
- astrbot/core/platform/sources/lark/lark_adapter.py +2 -10
- astrbot/core/platform/sources/misskey/misskey_adapter.py +0 -5
- astrbot/core/platform/sources/misskey/misskey_utils.py +0 -3
- astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +4 -9
- astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +4 -9
- astrbot/core/platform/sources/satori/satori_adapter.py +6 -1
- astrbot/core/platform/sources/slack/slack_adapter.py +3 -6
- astrbot/core/platform/sources/webchat/webchat_adapter.py +0 -1
- astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +3 -5
- astrbot/core/provider/entities.py +41 -10
- astrbot/core/provider/provider.py +3 -1
- astrbot/core/provider/sources/anthropic_source.py +140 -30
- astrbot/core/provider/sources/fishaudio_tts_api_source.py +14 -6
- astrbot/core/provider/sources/gemini_source.py +112 -29
- astrbot/core/provider/sources/minimax_tts_api_source.py +4 -1
- astrbot/core/provider/sources/openai_source.py +93 -56
- astrbot/core/provider/sources/xai_source.py +29 -0
- astrbot/core/provider/sources/xinference_stt_provider.py +24 -12
- astrbot/core/star/context.py +1 -1
- astrbot/core/star/star_manager.py +52 -13
- astrbot/core/utils/astrbot_path.py +34 -0
- astrbot/core/utils/pip_installer.py +20 -1
- astrbot/dashboard/routes/__init__.py +2 -0
- astrbot/dashboard/routes/backup.py +1093 -0
- astrbot/dashboard/routes/config.py +45 -0
- astrbot/dashboard/routes/log.py +44 -10
- astrbot/dashboard/server.py +9 -1
- {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/METADATA +1 -1
- {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/RECORD +84 -44
- {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/WHEEL +0 -0
- {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/entry_points.txt +0 -0
- {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
"""备份管理 API 路由"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import time
|
|
9
|
+
import traceback
|
|
10
|
+
import uuid
|
|
11
|
+
import zipfile
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import jwt
|
|
16
|
+
from quart import request, send_file
|
|
17
|
+
|
|
18
|
+
from astrbot.core import logger
|
|
19
|
+
from astrbot.core.backup.exporter import AstrBotExporter
|
|
20
|
+
from astrbot.core.backup.importer import AstrBotImporter
|
|
21
|
+
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
|
22
|
+
from astrbot.core.db import BaseDatabase
|
|
23
|
+
from astrbot.core.utils.astrbot_path import (
|
|
24
|
+
get_astrbot_backups_path,
|
|
25
|
+
get_astrbot_data_path,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .route import Response, Route, RouteContext
|
|
29
|
+
|
|
30
|
+
# 分片上传常量
|
|
31
|
+
CHUNK_SIZE = 1024 * 1024 # 1MB
|
|
32
|
+
UPLOAD_EXPIRE_SECONDS = 3600 # 上传会话过期时间(1小时)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def secure_filename(filename: str) -> str:
|
|
36
|
+
"""清洗文件名,移除路径遍历字符和危险字符
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
filename: 原始文件名
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
安全的文件名
|
|
43
|
+
"""
|
|
44
|
+
# 跨平台处理:先将反斜杠替换为正斜杠,再取文件名
|
|
45
|
+
filename = filename.replace("\\", "/")
|
|
46
|
+
# 仅保留文件名部分,移除路径
|
|
47
|
+
filename = os.path.basename(filename)
|
|
48
|
+
|
|
49
|
+
# 替换路径遍历字符
|
|
50
|
+
filename = filename.replace("..", "_")
|
|
51
|
+
|
|
52
|
+
# 仅保留字母、数字、下划线、连字符、点
|
|
53
|
+
filename = re.sub(r"[^\w\-.]", "_", filename)
|
|
54
|
+
|
|
55
|
+
# 移除前导点(隐藏文件)和尾部点
|
|
56
|
+
filename = filename.strip(".")
|
|
57
|
+
|
|
58
|
+
# 如果文件名为空或只包含下划线,生成一个默认名称
|
|
59
|
+
if not filename or filename.replace("_", "") == "":
|
|
60
|
+
filename = "backup"
|
|
61
|
+
|
|
62
|
+
return filename
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def generate_unique_filename(original_filename: str) -> str:
|
|
66
|
+
"""生成唯一的文件名,在原文件名后添加时间戳后缀避免重名
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
original_filename: 原始文件名(已清洗)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
添加了时间戳后缀的唯一文件名,格式为 {原文件名}_{YYYYMMDD_HHMMSS}.{扩展名}
|
|
73
|
+
"""
|
|
74
|
+
name, ext = os.path.splitext(original_filename)
|
|
75
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
76
|
+
return f"{name}_{timestamp}{ext}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class BackupRoute(Route):
|
|
80
|
+
"""备份管理路由
|
|
81
|
+
|
|
82
|
+
提供备份导出、导入、列表等 API 接口
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
context: RouteContext,
|
|
88
|
+
db: BaseDatabase,
|
|
89
|
+
core_lifecycle: AstrBotCoreLifecycle,
|
|
90
|
+
) -> None:
|
|
91
|
+
super().__init__(context)
|
|
92
|
+
self.db = db
|
|
93
|
+
self.core_lifecycle = core_lifecycle
|
|
94
|
+
self.backup_dir = get_astrbot_backups_path()
|
|
95
|
+
self.data_dir = get_astrbot_data_path()
|
|
96
|
+
self.chunks_dir = os.path.join(self.backup_dir, ".chunks")
|
|
97
|
+
|
|
98
|
+
# 任务状态跟踪
|
|
99
|
+
self.backup_tasks: dict[str, dict] = {}
|
|
100
|
+
self.backup_progress: dict[str, dict] = {}
|
|
101
|
+
|
|
102
|
+
# 分片上传会话跟踪
|
|
103
|
+
# upload_id -> {filename, total_chunks, received_chunks, last_activity, chunk_dir}
|
|
104
|
+
self.upload_sessions: dict[str, dict] = {}
|
|
105
|
+
|
|
106
|
+
# 后台清理任务句柄
|
|
107
|
+
self._cleanup_task: asyncio.Task | None = None
|
|
108
|
+
|
|
109
|
+
# 注册路由
|
|
110
|
+
self.routes = {
|
|
111
|
+
"/backup/list": ("GET", self.list_backups),
|
|
112
|
+
"/backup/export": ("POST", self.export_backup),
|
|
113
|
+
"/backup/upload": ("POST", self.upload_backup), # 上传文件(兼容小文件)
|
|
114
|
+
"/backup/upload/init": ("POST", self.upload_init), # 分片上传初始化
|
|
115
|
+
"/backup/upload/chunk": ("POST", self.upload_chunk), # 上传分片
|
|
116
|
+
"/backup/upload/complete": ("POST", self.upload_complete), # 完成分片上传
|
|
117
|
+
"/backup/upload/abort": ("POST", self.upload_abort), # 取消上传
|
|
118
|
+
"/backup/check": ("POST", self.check_backup), # 预检查
|
|
119
|
+
"/backup/import": ("POST", self.import_backup), # 确认导入
|
|
120
|
+
"/backup/progress": ("GET", self.get_progress),
|
|
121
|
+
"/backup/download": ("GET", self.download_backup),
|
|
122
|
+
"/backup/delete": ("POST", self.delete_backup),
|
|
123
|
+
"/backup/rename": ("POST", self.rename_backup), # 重命名备份
|
|
124
|
+
}
|
|
125
|
+
self.register_routes()
|
|
126
|
+
|
|
127
|
+
def _init_task(self, task_id: str, task_type: str, status: str = "pending") -> None:
|
|
128
|
+
"""初始化任务状态"""
|
|
129
|
+
self.backup_tasks[task_id] = {
|
|
130
|
+
"type": task_type,
|
|
131
|
+
"status": status,
|
|
132
|
+
"result": None,
|
|
133
|
+
"error": None,
|
|
134
|
+
}
|
|
135
|
+
self.backup_progress[task_id] = {
|
|
136
|
+
"status": status,
|
|
137
|
+
"stage": "waiting",
|
|
138
|
+
"current": 0,
|
|
139
|
+
"total": 100,
|
|
140
|
+
"message": "",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def _set_task_result(
|
|
144
|
+
self,
|
|
145
|
+
task_id: str,
|
|
146
|
+
status: str,
|
|
147
|
+
result: dict | None = None,
|
|
148
|
+
error: str | None = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""设置任务结果"""
|
|
151
|
+
if task_id in self.backup_tasks:
|
|
152
|
+
self.backup_tasks[task_id]["status"] = status
|
|
153
|
+
self.backup_tasks[task_id]["result"] = result
|
|
154
|
+
self.backup_tasks[task_id]["error"] = error
|
|
155
|
+
if task_id in self.backup_progress:
|
|
156
|
+
self.backup_progress[task_id]["status"] = status
|
|
157
|
+
|
|
158
|
+
def _update_progress(
|
|
159
|
+
self,
|
|
160
|
+
task_id: str,
|
|
161
|
+
*,
|
|
162
|
+
status: str | None = None,
|
|
163
|
+
stage: str | None = None,
|
|
164
|
+
current: int | None = None,
|
|
165
|
+
total: int | None = None,
|
|
166
|
+
message: str | None = None,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""更新任务进度"""
|
|
169
|
+
if task_id not in self.backup_progress:
|
|
170
|
+
return
|
|
171
|
+
p = self.backup_progress[task_id]
|
|
172
|
+
if status is not None:
|
|
173
|
+
p["status"] = status
|
|
174
|
+
if stage is not None:
|
|
175
|
+
p["stage"] = stage
|
|
176
|
+
if current is not None:
|
|
177
|
+
p["current"] = current
|
|
178
|
+
if total is not None:
|
|
179
|
+
p["total"] = total
|
|
180
|
+
if message is not None:
|
|
181
|
+
p["message"] = message
|
|
182
|
+
|
|
183
|
+
def _make_progress_callback(self, task_id: str):
|
|
184
|
+
"""创建进度回调函数"""
|
|
185
|
+
|
|
186
|
+
async def _callback(stage: str, current: int, total: int, message: str = ""):
|
|
187
|
+
self._update_progress(
|
|
188
|
+
task_id,
|
|
189
|
+
status="processing",
|
|
190
|
+
stage=stage,
|
|
191
|
+
current=current,
|
|
192
|
+
total=total,
|
|
193
|
+
message=message,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return _callback
|
|
197
|
+
|
|
198
|
+
def _ensure_cleanup_task_started(self):
|
|
199
|
+
"""确保后台清理任务已启动(在异步上下文中延迟启动)"""
|
|
200
|
+
if self._cleanup_task is None or self._cleanup_task.done():
|
|
201
|
+
try:
|
|
202
|
+
self._cleanup_task = asyncio.create_task(
|
|
203
|
+
self._cleanup_expired_uploads()
|
|
204
|
+
)
|
|
205
|
+
except RuntimeError:
|
|
206
|
+
# 如果没有运行中的事件循环,跳过(等待下次异步调用时启动)
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
async def _cleanup_expired_uploads(self):
|
|
210
|
+
"""定期清理过期的上传会话
|
|
211
|
+
|
|
212
|
+
基于 last_activity 字段判断过期,避免清理活跃的上传会话。
|
|
213
|
+
"""
|
|
214
|
+
while True:
|
|
215
|
+
try:
|
|
216
|
+
await asyncio.sleep(300) # 每5分钟检查一次
|
|
217
|
+
current_time = time.time()
|
|
218
|
+
expired_sessions = []
|
|
219
|
+
|
|
220
|
+
for upload_id, session in self.upload_sessions.items():
|
|
221
|
+
# 使用 last_activity 判断过期,而非 created_at
|
|
222
|
+
last_activity = session.get("last_activity", session["created_at"])
|
|
223
|
+
if current_time - last_activity > UPLOAD_EXPIRE_SECONDS:
|
|
224
|
+
expired_sessions.append(upload_id)
|
|
225
|
+
|
|
226
|
+
for upload_id in expired_sessions:
|
|
227
|
+
await self._cleanup_upload_session(upload_id)
|
|
228
|
+
logger.info(f"清理过期的上传会话: {upload_id}")
|
|
229
|
+
|
|
230
|
+
except asyncio.CancelledError:
|
|
231
|
+
# 任务被取消,正常退出
|
|
232
|
+
break
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.error(f"清理过期上传会话失败: {e}")
|
|
235
|
+
|
|
236
|
+
async def _cleanup_upload_session(self, upload_id: str):
|
|
237
|
+
"""清理上传会话"""
|
|
238
|
+
if upload_id in self.upload_sessions:
|
|
239
|
+
session = self.upload_sessions[upload_id]
|
|
240
|
+
chunk_dir = session.get("chunk_dir")
|
|
241
|
+
if chunk_dir and os.path.exists(chunk_dir):
|
|
242
|
+
try:
|
|
243
|
+
shutil.rmtree(chunk_dir)
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.warning(f"清理分片目录失败: {e}")
|
|
246
|
+
del self.upload_sessions[upload_id]
|
|
247
|
+
|
|
248
|
+
def _get_backup_manifest(self, zip_path: str) -> dict | None:
|
|
249
|
+
"""从备份文件读取 manifest.json
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
zip_path: ZIP 文件路径
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
dict | None: manifest 内容,如果不是有效备份则返回 None
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
259
|
+
if "manifest.json" in zf.namelist():
|
|
260
|
+
manifest_data = zf.read("manifest.json")
|
|
261
|
+
return json.loads(manifest_data.decode("utf-8"))
|
|
262
|
+
else:
|
|
263
|
+
# 没有 manifest.json,不是有效的 AstrBot 备份
|
|
264
|
+
return None
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.debug(f"读取备份 manifest 失败: {e}")
|
|
267
|
+
return None # 无法读取,不是有效备份
|
|
268
|
+
|
|
269
|
+
async def list_backups(self):
|
|
270
|
+
# 确保后台清理任务已启动
|
|
271
|
+
self._ensure_cleanup_task_started()
|
|
272
|
+
|
|
273
|
+
"""获取备份列表
|
|
274
|
+
|
|
275
|
+
Query 参数:
|
|
276
|
+
- page: 页码 (默认 1)
|
|
277
|
+
- page_size: 每页数量 (默认 20)
|
|
278
|
+
"""
|
|
279
|
+
try:
|
|
280
|
+
page = request.args.get("page", 1, type=int)
|
|
281
|
+
page_size = request.args.get("page_size", 20, type=int)
|
|
282
|
+
|
|
283
|
+
# 确保备份目录存在
|
|
284
|
+
Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
|
|
285
|
+
|
|
286
|
+
# 获取所有备份文件
|
|
287
|
+
backup_files = []
|
|
288
|
+
for filename in os.listdir(self.backup_dir):
|
|
289
|
+
# 只处理 .zip 文件,排除隐藏文件和目录
|
|
290
|
+
if not filename.endswith(".zip") or filename.startswith("."):
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
file_path = os.path.join(self.backup_dir, filename)
|
|
294
|
+
if not os.path.isfile(file_path):
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
# 读取 manifest.json 获取备份信息
|
|
298
|
+
# 如果返回 None,说明不是有效的 AstrBot 备份,跳过
|
|
299
|
+
manifest = self._get_backup_manifest(file_path)
|
|
300
|
+
if manifest is None:
|
|
301
|
+
logger.debug(f"跳过无效备份文件: {filename}")
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
stat = os.stat(file_path)
|
|
305
|
+
backup_files.append(
|
|
306
|
+
{
|
|
307
|
+
"filename": filename,
|
|
308
|
+
"size": stat.st_size,
|
|
309
|
+
"created_at": stat.st_mtime,
|
|
310
|
+
"type": manifest.get(
|
|
311
|
+
"origin", "exported"
|
|
312
|
+
), # 老版本没有 origin 默认为 exported
|
|
313
|
+
"astrbot_version": manifest.get("astrbot_version", "未知"),
|
|
314
|
+
"exported_at": manifest.get("exported_at"),
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# 按创建时间倒序排序
|
|
319
|
+
backup_files.sort(key=lambda x: x["created_at"], reverse=True)
|
|
320
|
+
|
|
321
|
+
# 分页
|
|
322
|
+
start = (page - 1) * page_size
|
|
323
|
+
end = start + page_size
|
|
324
|
+
items = backup_files[start:end]
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
Response()
|
|
328
|
+
.ok(
|
|
329
|
+
{
|
|
330
|
+
"items": items,
|
|
331
|
+
"total": len(backup_files),
|
|
332
|
+
"page": page,
|
|
333
|
+
"page_size": page_size,
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
.__dict__
|
|
337
|
+
)
|
|
338
|
+
except Exception as e:
|
|
339
|
+
logger.error(f"获取备份列表失败: {e}")
|
|
340
|
+
logger.error(traceback.format_exc())
|
|
341
|
+
return Response().error(f"获取备份列表失败: {e!s}").__dict__
|
|
342
|
+
|
|
343
|
+
async def export_backup(self):
|
|
344
|
+
"""创建备份
|
|
345
|
+
|
|
346
|
+
返回:
|
|
347
|
+
- task_id: 任务ID,用于查询导出进度
|
|
348
|
+
"""
|
|
349
|
+
try:
|
|
350
|
+
# 生成任务ID
|
|
351
|
+
task_id = str(uuid.uuid4())
|
|
352
|
+
|
|
353
|
+
# 初始化任务状态
|
|
354
|
+
self._init_task(task_id, "export", "pending")
|
|
355
|
+
|
|
356
|
+
# 启动后台导出任务
|
|
357
|
+
asyncio.create_task(self._background_export_task(task_id))
|
|
358
|
+
|
|
359
|
+
return (
|
|
360
|
+
Response()
|
|
361
|
+
.ok(
|
|
362
|
+
{
|
|
363
|
+
"task_id": task_id,
|
|
364
|
+
"message": "export task created, processing in background",
|
|
365
|
+
}
|
|
366
|
+
)
|
|
367
|
+
.__dict__
|
|
368
|
+
)
|
|
369
|
+
except Exception as e:
|
|
370
|
+
logger.error(f"创建备份失败: {e}")
|
|
371
|
+
logger.error(traceback.format_exc())
|
|
372
|
+
return Response().error(f"创建备份失败: {e!s}").__dict__
|
|
373
|
+
|
|
374
|
+
async def _background_export_task(self, task_id: str):
|
|
375
|
+
"""后台导出任务"""
|
|
376
|
+
try:
|
|
377
|
+
self._update_progress(task_id, status="processing", message="正在初始化...")
|
|
378
|
+
|
|
379
|
+
# 获取知识库管理器
|
|
380
|
+
kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
|
|
381
|
+
|
|
382
|
+
exporter = AstrBotExporter(
|
|
383
|
+
main_db=self.db,
|
|
384
|
+
kb_manager=kb_manager,
|
|
385
|
+
config_path=os.path.join(self.data_dir, "cmd_config.json"),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# 创建进度回调
|
|
389
|
+
progress_callback = self._make_progress_callback(task_id)
|
|
390
|
+
|
|
391
|
+
# 执行导出
|
|
392
|
+
zip_path = await exporter.export_all(
|
|
393
|
+
output_dir=self.backup_dir,
|
|
394
|
+
progress_callback=progress_callback,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# 设置成功结果
|
|
398
|
+
self._set_task_result(
|
|
399
|
+
task_id,
|
|
400
|
+
"completed",
|
|
401
|
+
result={
|
|
402
|
+
"filename": os.path.basename(zip_path),
|
|
403
|
+
"path": zip_path,
|
|
404
|
+
"size": os.path.getsize(zip_path),
|
|
405
|
+
},
|
|
406
|
+
)
|
|
407
|
+
except Exception as e:
|
|
408
|
+
logger.error(f"后台导出任务 {task_id} 失败: {e}")
|
|
409
|
+
logger.error(traceback.format_exc())
|
|
410
|
+
self._set_task_result(task_id, "failed", error=str(e))
|
|
411
|
+
|
|
412
|
+
async def upload_backup(self):
|
|
413
|
+
"""上传备份文件
|
|
414
|
+
|
|
415
|
+
将备份文件上传到服务器,返回保存的文件名。
|
|
416
|
+
上传后应调用 check_backup 进行预检查。
|
|
417
|
+
|
|
418
|
+
Form Data:
|
|
419
|
+
- file: 备份文件 (.zip)
|
|
420
|
+
|
|
421
|
+
返回:
|
|
422
|
+
- filename: 保存的文件名
|
|
423
|
+
"""
|
|
424
|
+
try:
|
|
425
|
+
files = await request.files
|
|
426
|
+
if "file" not in files:
|
|
427
|
+
return Response().error("缺少备份文件").__dict__
|
|
428
|
+
|
|
429
|
+
file = files["file"]
|
|
430
|
+
if not file.filename or not file.filename.endswith(".zip"):
|
|
431
|
+
return Response().error("请上传 ZIP 格式的备份文件").__dict__
|
|
432
|
+
|
|
433
|
+
# 清洗文件名并生成唯一名称,防止路径遍历和覆盖
|
|
434
|
+
safe_filename = secure_filename(file.filename)
|
|
435
|
+
unique_filename = generate_unique_filename(safe_filename)
|
|
436
|
+
|
|
437
|
+
# 保存上传的文件
|
|
438
|
+
Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
|
|
439
|
+
zip_path = os.path.join(self.backup_dir, unique_filename)
|
|
440
|
+
await file.save(zip_path)
|
|
441
|
+
|
|
442
|
+
logger.info(
|
|
443
|
+
f"上传的备份文件已保存: {unique_filename} (原始名称: {file.filename})"
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
return (
|
|
447
|
+
Response()
|
|
448
|
+
.ok(
|
|
449
|
+
{
|
|
450
|
+
"filename": unique_filename,
|
|
451
|
+
"original_filename": file.filename,
|
|
452
|
+
"size": os.path.getsize(zip_path),
|
|
453
|
+
}
|
|
454
|
+
)
|
|
455
|
+
.__dict__
|
|
456
|
+
)
|
|
457
|
+
except Exception as e:
|
|
458
|
+
logger.error(f"上传备份文件失败: {e}")
|
|
459
|
+
logger.error(traceback.format_exc())
|
|
460
|
+
return Response().error(f"上传备份文件失败: {e!s}").__dict__
|
|
461
|
+
|
|
462
|
+
async def upload_init(self):
|
|
463
|
+
"""初始化分片上传
|
|
464
|
+
|
|
465
|
+
创建一个上传会话,返回 upload_id 供后续分片上传使用。
|
|
466
|
+
|
|
467
|
+
JSON Body:
|
|
468
|
+
- filename: 原始文件名
|
|
469
|
+
- total_size: 文件总大小(字节)
|
|
470
|
+
|
|
471
|
+
返回:
|
|
472
|
+
- upload_id: 上传会话 ID
|
|
473
|
+
- chunk_size: 分片大小(由后端决定)
|
|
474
|
+
- total_chunks: 分片总数(由后端根据 total_size 和 chunk_size 计算)
|
|
475
|
+
"""
|
|
476
|
+
try:
|
|
477
|
+
data = await request.json
|
|
478
|
+
filename = data.get("filename")
|
|
479
|
+
total_size = data.get("total_size", 0)
|
|
480
|
+
|
|
481
|
+
if not filename:
|
|
482
|
+
return Response().error("缺少 filename 参数").__dict__
|
|
483
|
+
|
|
484
|
+
if not filename.endswith(".zip"):
|
|
485
|
+
return Response().error("请上传 ZIP 格式的备份文件").__dict__
|
|
486
|
+
|
|
487
|
+
if total_size <= 0:
|
|
488
|
+
return Response().error("无效的文件大小").__dict__
|
|
489
|
+
|
|
490
|
+
# 由后端计算分片总数,确保前后端一致
|
|
491
|
+
import math
|
|
492
|
+
|
|
493
|
+
total_chunks = math.ceil(total_size / CHUNK_SIZE)
|
|
494
|
+
|
|
495
|
+
# 生成上传 ID
|
|
496
|
+
upload_id = str(uuid.uuid4())
|
|
497
|
+
|
|
498
|
+
# 创建分片存储目录
|
|
499
|
+
chunk_dir = os.path.join(self.chunks_dir, upload_id)
|
|
500
|
+
Path(chunk_dir).mkdir(parents=True, exist_ok=True)
|
|
501
|
+
|
|
502
|
+
# 清洗文件名
|
|
503
|
+
safe_filename = secure_filename(filename)
|
|
504
|
+
unique_filename = generate_unique_filename(safe_filename)
|
|
505
|
+
|
|
506
|
+
# 创建上传会话
|
|
507
|
+
current_time = time.time()
|
|
508
|
+
self.upload_sessions[upload_id] = {
|
|
509
|
+
"filename": unique_filename,
|
|
510
|
+
"original_filename": filename,
|
|
511
|
+
"total_size": total_size,
|
|
512
|
+
"total_chunks": total_chunks,
|
|
513
|
+
"received_chunks": set(),
|
|
514
|
+
"created_at": current_time,
|
|
515
|
+
"last_activity": current_time, # 用于判断会话是否活跃
|
|
516
|
+
"chunk_dir": chunk_dir,
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
logger.info(
|
|
520
|
+
f"初始化分片上传: upload_id={upload_id}, "
|
|
521
|
+
f"filename={unique_filename}, total_chunks={total_chunks}"
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
return (
|
|
525
|
+
Response()
|
|
526
|
+
.ok(
|
|
527
|
+
{
|
|
528
|
+
"upload_id": upload_id,
|
|
529
|
+
"chunk_size": CHUNK_SIZE,
|
|
530
|
+
"total_chunks": total_chunks,
|
|
531
|
+
"filename": unique_filename,
|
|
532
|
+
}
|
|
533
|
+
)
|
|
534
|
+
.__dict__
|
|
535
|
+
)
|
|
536
|
+
except Exception as e:
|
|
537
|
+
logger.error(f"初始化分片上传失败: {e}")
|
|
538
|
+
logger.error(traceback.format_exc())
|
|
539
|
+
return Response().error(f"初始化分片上传失败: {e!s}").__dict__
|
|
540
|
+
|
|
541
|
+
async def upload_chunk(self):
|
|
542
|
+
"""上传分片
|
|
543
|
+
|
|
544
|
+
上传单个分片数据。
|
|
545
|
+
|
|
546
|
+
Form Data:
|
|
547
|
+
- upload_id: 上传会话 ID
|
|
548
|
+
- chunk_index: 分片索引(从 0 开始)
|
|
549
|
+
- chunk: 分片数据
|
|
550
|
+
|
|
551
|
+
返回:
|
|
552
|
+
- received: 已接收的分片数量
|
|
553
|
+
- total: 分片总数
|
|
554
|
+
"""
|
|
555
|
+
try:
|
|
556
|
+
form = await request.form
|
|
557
|
+
files = await request.files
|
|
558
|
+
|
|
559
|
+
upload_id = form.get("upload_id")
|
|
560
|
+
chunk_index_str = form.get("chunk_index")
|
|
561
|
+
|
|
562
|
+
if not upload_id or chunk_index_str is None:
|
|
563
|
+
return Response().error("缺少必要参数").__dict__
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
chunk_index = int(chunk_index_str)
|
|
567
|
+
except ValueError:
|
|
568
|
+
return Response().error("无效的分片索引").__dict__
|
|
569
|
+
|
|
570
|
+
if "chunk" not in files:
|
|
571
|
+
return Response().error("缺少分片数据").__dict__
|
|
572
|
+
|
|
573
|
+
# 验证上传会话
|
|
574
|
+
if upload_id not in self.upload_sessions:
|
|
575
|
+
return Response().error("上传会话不存在或已过期").__dict__
|
|
576
|
+
|
|
577
|
+
session = self.upload_sessions[upload_id]
|
|
578
|
+
|
|
579
|
+
# 验证分片索引
|
|
580
|
+
if chunk_index < 0 or chunk_index >= session["total_chunks"]:
|
|
581
|
+
return Response().error("分片索引超出范围").__dict__
|
|
582
|
+
|
|
583
|
+
# 保存分片
|
|
584
|
+
chunk_file = files["chunk"]
|
|
585
|
+
chunk_path = os.path.join(session["chunk_dir"], f"{chunk_index}.part")
|
|
586
|
+
await chunk_file.save(chunk_path)
|
|
587
|
+
|
|
588
|
+
# 记录已接收的分片,并更新最后活动时间
|
|
589
|
+
session["received_chunks"].add(chunk_index)
|
|
590
|
+
session["last_activity"] = time.time() # 刷新活动时间,防止活跃上传被清理
|
|
591
|
+
|
|
592
|
+
received_count = len(session["received_chunks"])
|
|
593
|
+
total_chunks = session["total_chunks"]
|
|
594
|
+
|
|
595
|
+
logger.debug(
|
|
596
|
+
f"接收分片: upload_id={upload_id}, "
|
|
597
|
+
f"chunk={chunk_index + 1}/{total_chunks}"
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
return (
|
|
601
|
+
Response()
|
|
602
|
+
.ok(
|
|
603
|
+
{
|
|
604
|
+
"received": received_count,
|
|
605
|
+
"total": total_chunks,
|
|
606
|
+
"chunk_index": chunk_index,
|
|
607
|
+
}
|
|
608
|
+
)
|
|
609
|
+
.__dict__
|
|
610
|
+
)
|
|
611
|
+
except Exception as e:
|
|
612
|
+
logger.error(f"上传分片失败: {e}")
|
|
613
|
+
logger.error(traceback.format_exc())
|
|
614
|
+
return Response().error(f"上传分片失败: {e!s}").__dict__
|
|
615
|
+
|
|
616
|
+
def _mark_backup_as_uploaded(self, zip_path: str) -> None:
|
|
617
|
+
"""修改备份文件的 manifest.json,将 origin 设置为 uploaded
|
|
618
|
+
|
|
619
|
+
使用 zipfile 的 append 模式添加新的 manifest.json,
|
|
620
|
+
ZIP 规范中后添加的同名文件会覆盖先前的文件。
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
zip_path: ZIP 文件路径
|
|
624
|
+
"""
|
|
625
|
+
try:
|
|
626
|
+
# 读取原有 manifest
|
|
627
|
+
manifest = {"origin": "uploaded", "uploaded_at": datetime.now().isoformat()}
|
|
628
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
629
|
+
if "manifest.json" in zf.namelist():
|
|
630
|
+
manifest_data = zf.read("manifest.json")
|
|
631
|
+
manifest = json.loads(manifest_data.decode("utf-8"))
|
|
632
|
+
manifest["origin"] = "uploaded"
|
|
633
|
+
manifest["uploaded_at"] = datetime.now().isoformat()
|
|
634
|
+
|
|
635
|
+
# 使用 append 模式添加新的 manifest.json
|
|
636
|
+
# ZIP 规范中,后添加的同名文件会覆盖先前的
|
|
637
|
+
with zipfile.ZipFile(zip_path, "a") as zf:
|
|
638
|
+
new_manifest = json.dumps(manifest, ensure_ascii=False, indent=2)
|
|
639
|
+
zf.writestr("manifest.json", new_manifest)
|
|
640
|
+
|
|
641
|
+
logger.debug(f"已标记备份为上传来源: {zip_path}")
|
|
642
|
+
except Exception as e:
|
|
643
|
+
logger.warning(f"标记备份来源失败: {e}")
|
|
644
|
+
|
|
645
|
+
async def upload_complete(self):
|
|
646
|
+
"""完成分片上传
|
|
647
|
+
|
|
648
|
+
合并所有分片为完整文件。
|
|
649
|
+
|
|
650
|
+
JSON Body:
|
|
651
|
+
- upload_id: 上传会话 ID
|
|
652
|
+
|
|
653
|
+
返回:
|
|
654
|
+
- filename: 合并后的文件名
|
|
655
|
+
- size: 文件大小
|
|
656
|
+
"""
|
|
657
|
+
try:
|
|
658
|
+
data = await request.json
|
|
659
|
+
upload_id = data.get("upload_id")
|
|
660
|
+
|
|
661
|
+
if not upload_id:
|
|
662
|
+
return Response().error("缺少 upload_id 参数").__dict__
|
|
663
|
+
|
|
664
|
+
# 验证上传会话
|
|
665
|
+
if upload_id not in self.upload_sessions:
|
|
666
|
+
return Response().error("上传会话不存在或已过期").__dict__
|
|
667
|
+
|
|
668
|
+
session = self.upload_sessions[upload_id]
|
|
669
|
+
|
|
670
|
+
# 检查是否所有分片都已接收
|
|
671
|
+
received = session["received_chunks"]
|
|
672
|
+
total = session["total_chunks"]
|
|
673
|
+
|
|
674
|
+
if len(received) != total:
|
|
675
|
+
missing = set(range(total)) - received
|
|
676
|
+
return (
|
|
677
|
+
Response()
|
|
678
|
+
.error(f"分片不完整,缺少: {sorted(missing)[:10]}...")
|
|
679
|
+
.__dict__
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
# 合并分片
|
|
683
|
+
chunk_dir = session["chunk_dir"]
|
|
684
|
+
filename = session["filename"]
|
|
685
|
+
|
|
686
|
+
Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
|
|
687
|
+
output_path = os.path.join(self.backup_dir, filename)
|
|
688
|
+
|
|
689
|
+
try:
|
|
690
|
+
with open(output_path, "wb") as outfile:
|
|
691
|
+
for i in range(total):
|
|
692
|
+
chunk_path = os.path.join(chunk_dir, f"{i}.part")
|
|
693
|
+
with open(chunk_path, "rb") as chunk_file:
|
|
694
|
+
# 分块读取,避免内存溢出
|
|
695
|
+
while True:
|
|
696
|
+
data_block = chunk_file.read(8192)
|
|
697
|
+
if not data_block:
|
|
698
|
+
break
|
|
699
|
+
outfile.write(data_block)
|
|
700
|
+
|
|
701
|
+
file_size = os.path.getsize(output_path)
|
|
702
|
+
|
|
703
|
+
# 标记备份为上传来源(修改 manifest.json 中的 origin 字段)
|
|
704
|
+
self._mark_backup_as_uploaded(output_path)
|
|
705
|
+
|
|
706
|
+
logger.info(
|
|
707
|
+
f"分片上传完成: {filename}, size={file_size}, chunks={total}"
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
# 清理分片目录
|
|
711
|
+
await self._cleanup_upload_session(upload_id)
|
|
712
|
+
|
|
713
|
+
return (
|
|
714
|
+
Response()
|
|
715
|
+
.ok(
|
|
716
|
+
{
|
|
717
|
+
"filename": filename,
|
|
718
|
+
"original_filename": session["original_filename"],
|
|
719
|
+
"size": file_size,
|
|
720
|
+
}
|
|
721
|
+
)
|
|
722
|
+
.__dict__
|
|
723
|
+
)
|
|
724
|
+
except Exception as e:
|
|
725
|
+
# 如果合并失败,删除不完整的文件
|
|
726
|
+
if os.path.exists(output_path):
|
|
727
|
+
os.remove(output_path)
|
|
728
|
+
raise e
|
|
729
|
+
|
|
730
|
+
except Exception as e:
|
|
731
|
+
logger.error(f"完成分片上传失败: {e}")
|
|
732
|
+
logger.error(traceback.format_exc())
|
|
733
|
+
return Response().error(f"完成分片上传失败: {e!s}").__dict__
|
|
734
|
+
|
|
735
|
+
async def upload_abort(self):
|
|
736
|
+
"""取消分片上传
|
|
737
|
+
|
|
738
|
+
取消上传并清理已上传的分片。
|
|
739
|
+
|
|
740
|
+
JSON Body:
|
|
741
|
+
- upload_id: 上传会话 ID
|
|
742
|
+
"""
|
|
743
|
+
try:
|
|
744
|
+
data = await request.json
|
|
745
|
+
upload_id = data.get("upload_id")
|
|
746
|
+
|
|
747
|
+
if not upload_id:
|
|
748
|
+
return Response().error("缺少 upload_id 参数").__dict__
|
|
749
|
+
|
|
750
|
+
if upload_id not in self.upload_sessions:
|
|
751
|
+
# 会话已不存在,可能已过期或已完成
|
|
752
|
+
return Response().ok(message="上传已取消").__dict__
|
|
753
|
+
|
|
754
|
+
# 清理会话
|
|
755
|
+
await self._cleanup_upload_session(upload_id)
|
|
756
|
+
|
|
757
|
+
logger.info(f"取消分片上传: {upload_id}")
|
|
758
|
+
|
|
759
|
+
return Response().ok(message="上传已取消").__dict__
|
|
760
|
+
except Exception as e:
|
|
761
|
+
logger.error(f"取消上传失败: {e}")
|
|
762
|
+
logger.error(traceback.format_exc())
|
|
763
|
+
return Response().error(f"取消上传失败: {e!s}").__dict__
|
|
764
|
+
|
|
765
|
+
async def check_backup(self):
|
|
766
|
+
"""预检查备份文件
|
|
767
|
+
|
|
768
|
+
检查备份文件的版本兼容性,返回确认信息。
|
|
769
|
+
用户确认后调用 import_backup 执行导入。
|
|
770
|
+
|
|
771
|
+
JSON Body:
|
|
772
|
+
- filename: 已上传的备份文件名
|
|
773
|
+
|
|
774
|
+
返回:
|
|
775
|
+
- ImportPreCheckResult: 预检查结果
|
|
776
|
+
"""
|
|
777
|
+
try:
|
|
778
|
+
data = await request.json
|
|
779
|
+
filename = data.get("filename")
|
|
780
|
+
if not filename:
|
|
781
|
+
return Response().error("缺少 filename 参数").__dict__
|
|
782
|
+
|
|
783
|
+
# 安全检查 - 防止路径遍历
|
|
784
|
+
if ".." in filename or "/" in filename or "\\" in filename:
|
|
785
|
+
return Response().error("无效的文件名").__dict__
|
|
786
|
+
|
|
787
|
+
zip_path = os.path.join(self.backup_dir, filename)
|
|
788
|
+
if not os.path.exists(zip_path):
|
|
789
|
+
return Response().error(f"备份文件不存在: {filename}").__dict__
|
|
790
|
+
|
|
791
|
+
# 获取知识库管理器(用于构造 importer)
|
|
792
|
+
kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
|
|
793
|
+
|
|
794
|
+
importer = AstrBotImporter(
|
|
795
|
+
main_db=self.db,
|
|
796
|
+
kb_manager=kb_manager,
|
|
797
|
+
config_path=os.path.join(self.data_dir, "cmd_config.json"),
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
# 执行预检查
|
|
801
|
+
check_result = importer.pre_check(zip_path)
|
|
802
|
+
|
|
803
|
+
return Response().ok(check_result.to_dict()).__dict__
|
|
804
|
+
except Exception as e:
|
|
805
|
+
logger.error(f"预检查备份文件失败: {e}")
|
|
806
|
+
logger.error(traceback.format_exc())
|
|
807
|
+
return Response().error(f"预检查备份文件失败: {e!s}").__dict__
|
|
808
|
+
|
|
809
|
+
async def import_backup(self):
|
|
810
|
+
"""执行备份导入
|
|
811
|
+
|
|
812
|
+
在用户确认后执行实际的导入操作。
|
|
813
|
+
需要先调用 upload_backup 上传文件,再调用 check_backup 预检查。
|
|
814
|
+
|
|
815
|
+
JSON Body:
|
|
816
|
+
- filename: 已上传的备份文件名(必填)
|
|
817
|
+
- confirmed: 用户已确认(必填,必须为 true)
|
|
818
|
+
|
|
819
|
+
返回:
|
|
820
|
+
- task_id: 任务ID,用于查询导入进度
|
|
821
|
+
"""
|
|
822
|
+
try:
|
|
823
|
+
data = await request.json
|
|
824
|
+
filename = data.get("filename")
|
|
825
|
+
confirmed = data.get("confirmed", False)
|
|
826
|
+
|
|
827
|
+
if not filename:
|
|
828
|
+
return Response().error("缺少 filename 参数").__dict__
|
|
829
|
+
|
|
830
|
+
if not confirmed:
|
|
831
|
+
return (
|
|
832
|
+
Response()
|
|
833
|
+
.error("请先确认导入。导入将会清空并覆盖现有数据,此操作不可撤销。")
|
|
834
|
+
.__dict__
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
# 安全检查 - 防止路径遍历
|
|
838
|
+
if ".." in filename or "/" in filename or "\\" in filename:
|
|
839
|
+
return Response().error("无效的文件名").__dict__
|
|
840
|
+
|
|
841
|
+
zip_path = os.path.join(self.backup_dir, filename)
|
|
842
|
+
if not os.path.exists(zip_path):
|
|
843
|
+
return Response().error(f"备份文件不存在: {filename}").__dict__
|
|
844
|
+
|
|
845
|
+
# 生成任务ID
|
|
846
|
+
task_id = str(uuid.uuid4())
|
|
847
|
+
|
|
848
|
+
# 初始化任务状态
|
|
849
|
+
self._init_task(task_id, "import", "pending")
|
|
850
|
+
|
|
851
|
+
# 启动后台导入任务
|
|
852
|
+
asyncio.create_task(self._background_import_task(task_id, zip_path))
|
|
853
|
+
|
|
854
|
+
return (
|
|
855
|
+
Response()
|
|
856
|
+
.ok(
|
|
857
|
+
{
|
|
858
|
+
"task_id": task_id,
|
|
859
|
+
"message": "import task created, processing in background",
|
|
860
|
+
}
|
|
861
|
+
)
|
|
862
|
+
.__dict__
|
|
863
|
+
)
|
|
864
|
+
except Exception as e:
|
|
865
|
+
logger.error(f"导入备份失败: {e}")
|
|
866
|
+
logger.error(traceback.format_exc())
|
|
867
|
+
return Response().error(f"导入备份失败: {e!s}").__dict__
|
|
868
|
+
|
|
869
|
+
async def _background_import_task(self, task_id: str, zip_path: str):
|
|
870
|
+
"""后台导入任务"""
|
|
871
|
+
try:
|
|
872
|
+
self._update_progress(task_id, status="processing", message="正在初始化...")
|
|
873
|
+
|
|
874
|
+
# 获取知识库管理器
|
|
875
|
+
kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
|
|
876
|
+
|
|
877
|
+
importer = AstrBotImporter(
|
|
878
|
+
main_db=self.db,
|
|
879
|
+
kb_manager=kb_manager,
|
|
880
|
+
config_path=os.path.join(self.data_dir, "cmd_config.json"),
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
# 创建进度回调
|
|
884
|
+
progress_callback = self._make_progress_callback(task_id)
|
|
885
|
+
|
|
886
|
+
# 执行导入
|
|
887
|
+
result = await importer.import_all(
|
|
888
|
+
zip_path=zip_path,
|
|
889
|
+
mode="replace",
|
|
890
|
+
progress_callback=progress_callback,
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
# 设置结果
|
|
894
|
+
if result.success:
|
|
895
|
+
self._set_task_result(
|
|
896
|
+
task_id,
|
|
897
|
+
"completed",
|
|
898
|
+
result=result.to_dict(),
|
|
899
|
+
)
|
|
900
|
+
else:
|
|
901
|
+
self._set_task_result(
|
|
902
|
+
task_id,
|
|
903
|
+
"failed",
|
|
904
|
+
error="; ".join(result.errors),
|
|
905
|
+
)
|
|
906
|
+
except Exception as e:
|
|
907
|
+
logger.error(f"后台导入任务 {task_id} 失败: {e}")
|
|
908
|
+
logger.error(traceback.format_exc())
|
|
909
|
+
self._set_task_result(task_id, "failed", error=str(e))
|
|
910
|
+
|
|
911
|
+
async def get_progress(self):
|
|
912
|
+
"""获取任务进度
|
|
913
|
+
|
|
914
|
+
Query 参数:
|
|
915
|
+
- task_id: 任务 ID (必填)
|
|
916
|
+
"""
|
|
917
|
+
try:
|
|
918
|
+
task_id = request.args.get("task_id")
|
|
919
|
+
if not task_id:
|
|
920
|
+
return Response().error("缺少参数 task_id").__dict__
|
|
921
|
+
|
|
922
|
+
if task_id not in self.backup_tasks:
|
|
923
|
+
return Response().error("找不到该任务").__dict__
|
|
924
|
+
|
|
925
|
+
task_info = self.backup_tasks[task_id]
|
|
926
|
+
status = task_info["status"]
|
|
927
|
+
|
|
928
|
+
response_data = {
|
|
929
|
+
"task_id": task_id,
|
|
930
|
+
"type": task_info["type"],
|
|
931
|
+
"status": status,
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
# 如果任务正在处理,返回进度信息
|
|
935
|
+
if status == "processing" and task_id in self.backup_progress:
|
|
936
|
+
response_data["progress"] = self.backup_progress[task_id]
|
|
937
|
+
|
|
938
|
+
# 如果任务完成,返回结果
|
|
939
|
+
if status == "completed":
|
|
940
|
+
response_data["result"] = task_info["result"]
|
|
941
|
+
|
|
942
|
+
# 如果任务失败,返回错误信息
|
|
943
|
+
if status == "failed":
|
|
944
|
+
response_data["error"] = task_info["error"]
|
|
945
|
+
|
|
946
|
+
return Response().ok(response_data).__dict__
|
|
947
|
+
except Exception as e:
|
|
948
|
+
logger.error(f"获取任务进度失败: {e}")
|
|
949
|
+
logger.error(traceback.format_exc())
|
|
950
|
+
return Response().error(f"获取任务进度失败: {e!s}").__dict__
|
|
951
|
+
|
|
952
|
+
async def download_backup(self):
|
|
953
|
+
"""下载备份文件
|
|
954
|
+
|
|
955
|
+
Query 参数:
|
|
956
|
+
- filename: 备份文件名 (必填)
|
|
957
|
+
- token: JWT token (必填,用于浏览器原生下载鉴权)
|
|
958
|
+
|
|
959
|
+
注意: 此路由已被添加到 auth_middleware 白名单中,
|
|
960
|
+
使用 URL 参数中的 token 进行鉴权,以支持浏览器原生下载。
|
|
961
|
+
"""
|
|
962
|
+
try:
|
|
963
|
+
filename = request.args.get("filename")
|
|
964
|
+
token = request.args.get("token")
|
|
965
|
+
|
|
966
|
+
if not filename:
|
|
967
|
+
return Response().error("缺少参数 filename").__dict__
|
|
968
|
+
|
|
969
|
+
if not token:
|
|
970
|
+
return Response().error("缺少参数 token").__dict__
|
|
971
|
+
|
|
972
|
+
# 验证 JWT token
|
|
973
|
+
try:
|
|
974
|
+
jwt_secret = self.config.get("dashboard", {}).get("jwt_secret")
|
|
975
|
+
if not jwt_secret:
|
|
976
|
+
return Response().error("服务器配置错误").__dict__
|
|
977
|
+
|
|
978
|
+
jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
|
979
|
+
except jwt.ExpiredSignatureError:
|
|
980
|
+
return Response().error("Token 已过期,请刷新页面后重试").__dict__
|
|
981
|
+
except jwt.InvalidTokenError:
|
|
982
|
+
return Response().error("Token 无效").__dict__
|
|
983
|
+
|
|
984
|
+
# 安全检查 - 防止路径遍历
|
|
985
|
+
if ".." in filename or "/" in filename or "\\" in filename:
|
|
986
|
+
return Response().error("无效的文件名").__dict__
|
|
987
|
+
|
|
988
|
+
file_path = os.path.join(self.backup_dir, filename)
|
|
989
|
+
if not os.path.exists(file_path):
|
|
990
|
+
return Response().error("备份文件不存在").__dict__
|
|
991
|
+
|
|
992
|
+
return await send_file(
|
|
993
|
+
file_path,
|
|
994
|
+
as_attachment=True,
|
|
995
|
+
attachment_filename=filename,
|
|
996
|
+
)
|
|
997
|
+
except Exception as e:
|
|
998
|
+
logger.error(f"下载备份失败: {e}")
|
|
999
|
+
logger.error(traceback.format_exc())
|
|
1000
|
+
return Response().error(f"下载备份失败: {e!s}").__dict__
|
|
1001
|
+
|
|
1002
|
+
async def delete_backup(self):
|
|
1003
|
+
"""删除备份文件
|
|
1004
|
+
|
|
1005
|
+
Body:
|
|
1006
|
+
- filename: 备份文件名 (必填)
|
|
1007
|
+
"""
|
|
1008
|
+
try:
|
|
1009
|
+
data = await request.json
|
|
1010
|
+
filename = data.get("filename")
|
|
1011
|
+
if not filename:
|
|
1012
|
+
return Response().error("缺少参数 filename").__dict__
|
|
1013
|
+
|
|
1014
|
+
# 安全检查 - 防止路径遍历
|
|
1015
|
+
if ".." in filename or "/" in filename or "\\" in filename:
|
|
1016
|
+
return Response().error("无效的文件名").__dict__
|
|
1017
|
+
|
|
1018
|
+
file_path = os.path.join(self.backup_dir, filename)
|
|
1019
|
+
if not os.path.exists(file_path):
|
|
1020
|
+
return Response().error("备份文件不存在").__dict__
|
|
1021
|
+
|
|
1022
|
+
os.remove(file_path)
|
|
1023
|
+
return Response().ok(message="删除备份成功").__dict__
|
|
1024
|
+
except Exception as e:
|
|
1025
|
+
logger.error(f"删除备份失败: {e}")
|
|
1026
|
+
logger.error(traceback.format_exc())
|
|
1027
|
+
return Response().error(f"删除备份失败: {e!s}").__dict__
|
|
1028
|
+
|
|
1029
|
+
async def rename_backup(self):
|
|
1030
|
+
"""重命名备份文件
|
|
1031
|
+
|
|
1032
|
+
Body:
|
|
1033
|
+
- filename: 当前文件名 (必填)
|
|
1034
|
+
- new_name: 新文件名 (必填,不含扩展名)
|
|
1035
|
+
"""
|
|
1036
|
+
try:
|
|
1037
|
+
data = await request.json
|
|
1038
|
+
filename = data.get("filename")
|
|
1039
|
+
new_name = data.get("new_name")
|
|
1040
|
+
|
|
1041
|
+
if not filename:
|
|
1042
|
+
return Response().error("缺少参数 filename").__dict__
|
|
1043
|
+
|
|
1044
|
+
if not new_name:
|
|
1045
|
+
return Response().error("缺少参数 new_name").__dict__
|
|
1046
|
+
|
|
1047
|
+
# 安全检查 - 防止路径遍历
|
|
1048
|
+
if ".." in filename or "/" in filename or "\\" in filename:
|
|
1049
|
+
return Response().error("无效的文件名").__dict__
|
|
1050
|
+
|
|
1051
|
+
# 清洗新文件名(移除路径和危险字符)
|
|
1052
|
+
new_name = secure_filename(new_name)
|
|
1053
|
+
|
|
1054
|
+
# 移除新文件名中的扩展名(如果有的话)
|
|
1055
|
+
if new_name.endswith(".zip"):
|
|
1056
|
+
new_name = new_name[:-4]
|
|
1057
|
+
|
|
1058
|
+
# 验证新文件名不为空
|
|
1059
|
+
if not new_name or new_name.replace("_", "") == "":
|
|
1060
|
+
return Response().error("新文件名无效").__dict__
|
|
1061
|
+
|
|
1062
|
+
# 强制使用 .zip 扩展名
|
|
1063
|
+
new_filename = f"{new_name}.zip"
|
|
1064
|
+
|
|
1065
|
+
# 检查原文件是否存在
|
|
1066
|
+
old_path = os.path.join(self.backup_dir, filename)
|
|
1067
|
+
if not os.path.exists(old_path):
|
|
1068
|
+
return Response().error("备份文件不存在").__dict__
|
|
1069
|
+
|
|
1070
|
+
# 检查新文件名是否已存在
|
|
1071
|
+
new_path = os.path.join(self.backup_dir, new_filename)
|
|
1072
|
+
if os.path.exists(new_path):
|
|
1073
|
+
return Response().error(f"文件名 '{new_filename}' 已存在").__dict__
|
|
1074
|
+
|
|
1075
|
+
# 执行重命名
|
|
1076
|
+
os.rename(old_path, new_path)
|
|
1077
|
+
|
|
1078
|
+
logger.info(f"备份文件重命名: {filename} -> {new_filename}")
|
|
1079
|
+
|
|
1080
|
+
return (
|
|
1081
|
+
Response()
|
|
1082
|
+
.ok(
|
|
1083
|
+
{
|
|
1084
|
+
"old_filename": filename,
|
|
1085
|
+
"new_filename": new_filename,
|
|
1086
|
+
}
|
|
1087
|
+
)
|
|
1088
|
+
.__dict__
|
|
1089
|
+
)
|
|
1090
|
+
except Exception as e:
|
|
1091
|
+
logger.error(f"重命名备份失败: {e}")
|
|
1092
|
+
logger.error(traceback.format_exc())
|
|
1093
|
+
return Response().error(f"重命名备份失败: {e!s}").__dict__
|