✨ 首次启动时提供使用web ui方式完全配置 (#1870)

* ✨ 添加全局优先级hook

* ✨ 添加基础配置api

* ✨ 添加数据库连接测试

* 💬 提示重启

* 🩹 填充过配置时友好提示

* 🐛 首次生成简易配置后自动加载

* ✨ 添加配置后重启接口

* ✨ 添加重启标志文件

* ✨ 添加重启脚本命令

* ✨ 添加重启系统限制

* ✨ 首次配置判断是否为win系统

* 🔥 移除bat

* ✨ 添加关于菜单

* ✨ 支持整合包插件安装和添加整合包文档

* 🩹 检测数据库路径

* 🩹 修改数据库路径检测

* 🩹 修改数据库路径检测

* 🩹 修复路径注入

* 🎨 显示添加优先级

* 🐛 修改PriorityLifecycle字典类名称

* ⚡ 修复路径问题

* ⚡ 修复路径检测

* ✨ 新增路径验证功能,确保用户输入的路径安全并在项目根目录内

* ✨ 优化路径验证功能,增加对非法字符和路径长度的检查,确保用户输入的路径更加安全

* 🚨 auto fix by pre-commit hooks

* ✨ 优化获取文件列表的代码格式

* 📝 修改README中webui示例图

* ✨ 更新PriorityLifecycle.on_startup装饰器

* ✨ 简化安装依赖的命令构建逻辑

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
HibiKier
2025-06-16 09:11:41 +08:00
committed by GitHub
co-authored by pre-commit-ci[bot]
parent bcfb47d9fd
commit 99f1388e23
54 changed files with 581 additions and 140 deletions
@@ -0,0 +1,133 @@
import asyncio
import os
from pathlib import Path
import re
import subprocess
import sys
import time
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import nonebot
from zhenxun.configs.config import BotConfig, Config
from ...base_model import Result
from .data_source import test_db_connection
from .model import Setting
router = APIRouter(prefix="/configure")
driver = nonebot.get_driver()
port = driver.config.port
BAT_FILE = Path() / "win启动.bat"
FILE_NAME = ".configure_restart"
@router.post(
"/set_configure",
response_model=Result,
response_class=JSONResponse,
description="设置基础配置",
)
async def _(setting: Setting) -> Result:
global port
password = Config.get_config("web-ui", "password")
if password or BotConfig.db_url:
return Result.fail("配置已存在,请先删除DB_URL内容和前端密码再进行设置。")
env_file = Path() / ".env.dev"
if not env_file.exists():
return Result.fail("配置文件.env.dev不存在。")
env_text = env_file.read_text(encoding="utf-8")
if setting.db_url:
if setting.db_url.startswith("sqlite"):
base_dir = Path().resolve()
# 清理和验证数据库路径
db_path_str = setting.db_url.split(":")[-1].strip()
# 移除任何可能的路径遍历尝试
db_path_str = re.sub(r"[\\/]\.\.[\\/]", "", db_path_str)
# 规范化路径
db_path = Path(db_path_str).resolve()
parent_path = db_path.parent
# 验证路径是否在项目根目录内
try:
if not parent_path.absolute().is_relative_to(base_dir):
return Result.fail("数据库路径不在项目根目录内。")
except ValueError:
return Result.fail("无效的数据库路径。")
# 创建目录
try:
parent_path.mkdir(parents=True, exist_ok=True)
except Exception as e:
return Result.fail(f"创建数据库目录失败: {e!s}")
env_text = env_text.replace('DB_URL = ""', f'DB_URL = "{setting.db_url}"')
if setting.superusers:
superusers = ", ".join([f'"{s}"' for s in setting.superusers])
env_text = re.sub(r"SUPERUSERS=\[.*?\]", f"SUPERUSERS=[{superusers}]", env_text)
if setting.host:
env_text = env_text.replace("HOST = 127.0.0.1", f"HOST = {setting.host}")
if setting.port:
env_text = env_text.replace("PORT = 8080", f"PORT = {setting.port}")
port = setting.port
if setting.username:
Config.set_config("web-ui", "username", setting.username)
Config.set_config("web-ui", "password", setting.password, True)
env_file.write_text(env_text, encoding="utf-8")
if BAT_FILE.exists():
for file in os.listdir(Path()):
if file.startswith(FILE_NAME):
Path(file).unlink()
flag_file = Path() / f"{FILE_NAME}_{int(time.time())}"
flag_file.touch()
return Result.ok(BAT_FILE.exists(), info="设置成功,请重启真寻以完成配置!")
@router.get(
"/test_db",
response_model=Result,
response_class=JSONResponse,
description="设置基础配置",
)
async def _(db_url: str) -> Result:
result = await test_db_connection(db_url)
if isinstance(result, str):
return Result.fail(result)
return Result.ok(info="数据库连接成功!")
async def run_restart_command(bat_path: Path, port: int):
"""在后台执行重启命令"""
await asyncio.sleep(1) # 确保 FastAPI 已返回响应
subprocess.Popen([bat_path, str(port)], shell=True) # noqa: ASYNC220
sys.exit(0) # 退出当前进程
@router.post(
"/restart",
response_model=Result,
response_class=JSONResponse,
description="重启",
)
async def _() -> Result:
if not BAT_FILE.exists():
return Result.fail("自动重启仅支持意见整合包,请尝试手动重启")
flag_file = next(
(Path() / file for file in os.listdir(Path()) if file.startswith(FILE_NAME)),
None,
)
if not flag_file or not flag_file.exists():
return Result.fail("重启标志文件不存在...")
set_time = flag_file.name.split("_")[-1]
if time.time() - float(set_time) > 10 * 60:
return Result.fail("重启标志文件已过期,请重新设置配置。")
flag_file.unlink()
try:
return Result.ok(info="执行重启命令成功")
finally:
asyncio.create_task(run_restart_command(BAT_FILE, port)) # noqa: RUF006
@@ -0,0 +1,18 @@
from tortoise import Tortoise
async def test_db_connection(db_url: str) -> bool | str:
try:
# 初始化 Tortoise ORM
await Tortoise.init(
db_url=db_url,
modules={"models": ["__main__"]}, # 这里不需要实际模型
)
# 测试连接
await Tortoise.get_connection("default").execute_query("SELECT 1")
return True
except Exception as e:
return str(e)
finally:
# 关闭连接
await Tortoise.close_connections()
@@ -0,0 +1,16 @@
from pydantic import BaseModel
class Setting(BaseModel):
superusers: list[str]
"""超级用户列表"""
db_url: str
"""数据库地址"""
host: str
"""主机地址"""
port: int
"""端口"""
username: str
"""前端用户名"""
password: str
"""前端密码"""
@@ -5,54 +5,63 @@ from zhenxun.services.log import logger
from .model import MenuData, MenuItem
default_menus = [
MenuItem(
name="仪表盘",
module="dashboard",
router="/dashboard",
icon="dashboard",
default=True,
),
MenuItem(
name="真寻控制台",
module="command",
router="/command",
icon="command",
),
MenuItem(name="插件列表", module="plugin", router="/plugin", icon="plugin"),
MenuItem(name="插件商店", module="store", router="/store", icon="store"),
MenuItem(name="好友/群组", module="manage", router="/manage", icon="user"),
MenuItem(
name="数据库管理",
module="database",
router="/database",
icon="database",
),
MenuItem(name="系统信息", module="system", router="/system", icon="system"),
MenuItem(name="关于我们", module="about", router="/about", icon="about"),
]
class MenuManage:
class MenuManager:
def __init__(self) -> None:
self.file = DATA_PATH / "web_ui" / "menu.json"
self.menu = []
if self.file.exists():
try:
temp_menu = []
self.menu = json.load(self.file.open(encoding="utf8"))
self_menu_name = [menu["name"] for menu in self.menu]
for module in [m.module for m in default_menus]:
if module in self_menu_name:
temp_menu.append(
MenuItem(
**next(m for m in self.menu if m["module"] == module)
)
)
else:
temp_menu.append(self.__get_menu_model(module))
self.menu = temp_menu
except Exception as e:
logger.warning("菜单文件损坏,已重新生成...", "WebUi", e=e)
if not self.menu:
self.menu = [
MenuItem(
name="仪表盘",
module="dashboard",
router="/dashboard",
icon="dashboard",
default=True,
),
MenuItem(
name="真寻控制台",
module="command",
router="/command",
icon="command",
),
MenuItem(
name="插件列表", module="plugin", router="/plugin", icon="plugin"
),
MenuItem(
name="插件商店", module="store", router="/store", icon="store"
),
MenuItem(
name="好友/群组", module="manage", router="/manage", icon="user"
),
MenuItem(
name="数据库管理",
module="database",
router="/database",
icon="database",
),
MenuItem(
name="文件管理", module="system", router="/system", icon="system"
),
MenuItem(
name="关于我们", module="about", router="/about", icon="about"
),
]
self.save()
self.menu = default_menus
self.save()
def __get_menu_model(self, module: str):
return default_menus[
next(i for i, m in enumerate(default_menus) if m.module == module)
]
def get_menus(self):
return MenuData(menus=self.menu)
@@ -64,4 +73,4 @@ class MenuManage:
json.dump(temp, f, ensure_ascii=False, indent=4)
menu_manage = MenuManage()
menu_manage = MenuManager()
@@ -13,6 +13,7 @@ from zhenxun.models.bot_connect_log import BotConnectLog
from zhenxun.models.chat_history import ChatHistory
from zhenxun.models.statistics import Statistics
from zhenxun.services.log import logger
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from zhenxun.utils.platform import PlatformUtils
from ....base_model import BaseResultModel, QueryModel
@@ -31,7 +32,7 @@ driver: Driver = nonebot.get_driver()
CONNECT_TIME = 0
@driver.on_startup
@PriorityLifecycle.on_startup(priority=5)
async def _():
global CONNECT_TIME
CONNECT_TIME = int(time.time())
@@ -8,6 +8,7 @@ from zhenxun.configs.config import BotConfig
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.task_info import TaskInfo
from zhenxun.services.log import logger
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from ....base_model import BaseResultModel, QueryModel, Result
from ....utils import authentication
@@ -21,7 +22,7 @@ router = APIRouter(prefix="/database")
driver: Driver = nonebot.get_driver()
@driver.on_startup
@PriorityLifecycle.on_startup(priority=5)
async def _():
for plugin in nonebot.get_loaded_plugins():
module = plugin.name
@@ -9,7 +9,7 @@ from fastapi.responses import JSONResponse
from zhenxun.utils._build_image import BuildImage
from ....base_model import Result, SystemFolderSize
from ....utils import authentication, get_system_disk
from ....utils import authentication, get_system_disk, validate_path
from .model import AddFile, DeleteFile, DirFile, RenameFile, SaveFile
router = APIRouter(prefix="/system")
@@ -25,22 +25,29 @@ IMAGE_TYPE = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"]
description="获取文件列表",
)
async def _(path: str | None = None) -> Result[list[DirFile]]:
base_path = Path(path) if path else Path()
data_list = []
for file in os.listdir(base_path):
file_path = base_path / file
is_image = any(file.endswith(f".{t}") for t in IMAGE_TYPE)
data_list.append(
DirFile(
is_file=not file_path.is_dir(),
is_image=is_image,
name=file,
parent=path,
size=None if file_path.is_dir() else file_path.stat().st_size,
mtime=file_path.stat().st_mtime,
try:
base_path, error = validate_path(path)
if error:
return Result.fail(error)
if not base_path:
return Result.fail("无效的路径")
data_list = []
for file in os.listdir(base_path):
file_path = base_path / file
is_image = any(file.endswith(f".{t}") for t in IMAGE_TYPE)
data_list.append(
DirFile(
is_file=not file_path.is_dir(),
is_image=is_image,
name=file,
parent=path,
size=None if file_path.is_dir() else file_path.stat().st_size,
mtime=file_path.stat().st_mtime,
)
)
)
return Result.ok(data_list)
return Result.ok(data_list)
except Exception as e:
return Result.fail(f"获取文件列表失败: {e!s}")
@router.get(
@@ -62,8 +69,12 @@ async def _(full_path: str | None = None) -> Result[list[SystemFolderSize]]:
description="删除文件",
)
async def _(param: DeleteFile) -> Result:
path = Path(param.full_path)
if not path or not path.exists():
path, error = validate_path(param.full_path)
if error:
return Result.fail(error)
if not path:
return Result.fail("无效的路径")
if not path.exists():
return Result.warning_("文件不存在...")
try:
path.unlink()
@@ -80,8 +91,12 @@ async def _(param: DeleteFile) -> Result:
description="删除文件夹",
)
async def _(param: DeleteFile) -> Result:
path = Path(param.full_path)
if not path or not path.exists() or path.is_file():
path, error = validate_path(param.full_path)
if error:
return Result.fail(error)
if not path:
return Result.fail("无效的路径")
if not path.exists() or path.is_file():
return Result.warning_("文件夹不存在...")
try:
shutil.rmtree(path.absolute())
@@ -98,10 +113,14 @@ async def _(param: DeleteFile) -> Result:
description="重命名文件",
)
async def _(param: RenameFile) -> Result:
path = (
(Path(param.parent) / param.old_name) if param.parent else Path(param.old_name)
)
if not path or not path.exists():
parent_path, error = validate_path(param.parent)
if error:
return Result.fail(error)
if not parent_path:
return Result.fail("无效的路径")
path = (parent_path / param.old_name) if param.parent else Path(param.old_name)
if not path.exists():
return Result.warning_("文件不存在...")
try:
path.rename(path.parent / param.name)
@@ -118,10 +137,14 @@ async def _(param: RenameFile) -> Result:
description="重命名文件夹",
)
async def _(param: RenameFile) -> Result:
path = (
(Path(param.parent) / param.old_name) if param.parent else Path(param.old_name)
)
if not path or not path.exists() or path.is_file():
parent_path, error = validate_path(param.parent)
if error:
return Result.fail(error)
if not parent_path:
return Result.fail("无效的路径")
path = (parent_path / param.old_name) if param.parent else Path(param.old_name)
if not path.exists() or path.is_file():
return Result.warning_("文件夹不存在...")
try:
new_path = path.parent / param.name
@@ -139,7 +162,13 @@ async def _(param: RenameFile) -> Result:
description="新建文件",
)
async def _(param: AddFile) -> Result:
path = (Path(param.parent) / param.name) if param.parent else Path(param.name)
parent_path, error = validate_path(param.parent)
if error:
return Result.fail(error)
if not parent_path:
return Result.fail("无效的路径")
path = (parent_path / param.name) if param.parent else Path(param.name)
if path.exists():
return Result.warning_("文件已存在...")
try:
@@ -157,7 +186,13 @@ async def _(param: AddFile) -> Result:
description="新建文件夹",
)
async def _(param: AddFile) -> Result:
path = (Path(param.parent) / param.name) if param.parent else Path(param.name)
parent_path, error = validate_path(param.parent)
if error:
return Result.fail(error)
if not parent_path:
return Result.fail("无效的路径")
path = (parent_path / param.name) if param.parent else Path(param.name)
if path.exists():
return Result.warning_("文件夹已存在...")
try:
@@ -175,7 +210,11 @@ async def _(param: AddFile) -> Result:
description="读取文件",
)
async def _(full_path: str) -> Result:
path = Path(full_path)
path, error = validate_path(full_path)
if error:
return Result.fail(error)
if not path:
return Result.fail("无效的路径")
if not path.exists():
return Result.warning_("文件不存在...")
try:
@@ -193,9 +232,13 @@ async def _(full_path: str) -> Result:
description="读取文件",
)
async def _(param: SaveFile) -> Result[str]:
path = Path(param.full_path)
path, error = validate_path(param.full_path)
if error:
return Result.fail(error)
if not path:
return Result.fail("无效的路径")
try:
async with aiofiles.open(path, "w", encoding="utf-8") as f:
async with aiofiles.open(str(path), "w", encoding="utf-8") as f:
await f.write(param.content)
return Result.ok("更新成功!")
except Exception as e:
@@ -210,7 +253,11 @@ async def _(param: SaveFile) -> Result[str]:
description="读取图片base64",
)
async def _(full_path: str) -> Result[str]:
path = Path(full_path)
path, error = validate_path(full_path)
if error:
return Result.fail(error)
if not path:
return Result.fail("无效的路径")
if not path.exists():
return Result.warning_("文件不存在...")
try: