Commit 09a35038 authored by wangteng's avatar wangteng

整理优化完善

parent 8cb592c8
...@@ -29,11 +29,10 @@ npm run dev ...@@ -29,11 +29,10 @@ npm run dev
## 验证与发布 ## 验证与发布
```powershell ```powershell
cd web # 正式发布:自动构建、上传、重启并检查前后端和 Oracle
npm run build python deploy/deploy_server.py
cd ..
node scripts/verify/verify_tree.cjs
python deploy/build_release.py
``` ```
发布产物生成到 `release/`,部署步骤见 [deploy/README.md](deploy/README.md)。 最后一条命令会自动构建发布包、读取 `deploy/server.local.md` 中的本机私有发布配置、上传并重启正式服务,同时执行健康检查。只构建不发布时仍可执行 `python deploy/build_release.py`;完整说明见 [deploy/README.md](deploy/README.md)。
发布前如需单独运行本地前端树校验,可执行 `node scripts/verify/verify_tree.cjs`。
...@@ -2,14 +2,36 @@ ...@@ -2,14 +2,36 @@
本目录只保存部署相关文件,业务代码不应放入此处。 本目录只保存部署相关文件,业务代码不应放入此处。
1. 在项目根目录执行 `python deploy/build_release.py`,构建产物生成到 `release/`。 ## 正式发布(一条命令)
2. 设置 `DBTOOL_PW` 后执行 `python deploy/deploy_server.py`,上传发布包并重启服务。
3. 使用 `python deploy/remote_shell.py "命令"` 执行临时远程检查;密码不会写入脚本或仓库。
`db-tool.service` 是 systemd 服务定义,`db-tool-nginx.conf` 是 Nginx 反向代理示例。 在项目根目录执行:
```powershell
python deploy/deploy_server.py
```
脚本会自动完成以下步骤:
1. 从 `deploy/server.local.md` 读取本机私有服务器地址、用户、密码和 Oracle 测试数据源 ID。
2. 执行 `build_release.py` 构建前后端发布包。
3. 上传前检查后端入口、正式配置、前端 `index.html/assets/`,并检查 Oracle `libclntsh.so` 符号链接和 ELF 实体库。
4. 备份线上版本和 SQLite 快照,上传并解压包,安装 systemd 配置,迁移数据后重启服务。
5. 检查 systemd、后端本机接口、Nginx `/db-tool/api/health`,并默认测试 Oracle 数据源连接。
`server.local.md` 已加入 `.gitignore`,只允许保存在本机,禁止提交 Git 或打入发布包。环境变量 `DBTOOL_HOST`、`DBTOOL_USER`、`DBTOOL_PW` 和 `DBTOOL_ORACLE_SOURCE_ID` 可临时覆盖文档中的值。
部署脚本默认使用现有服务器地址和 `root` 用户,也可通过 `DBTOOL_HOST`、`DBTOOL_USER` 覆盖;SSH 密码只从 `DBTOOL_PW` 读取。 ## 特殊场景
本机正式服务器凭据记录在被 Git 忽略的 `deploy/server.local.md` 中。发布前从该文件读取密码并设置 `DBTOOL_PW`,不要将凭据写入可提交文件或发布包。 ```powershell
# 只使用已有 release/,不重新构建
python deploy/deploy_server.py --skip-build
# 临时跳过 Oracle 测试(不建议正式发布使用)
python deploy/deploy_server.py --skip-oracle-check
```
如需只构建不发布,可执行 `python deploy/build_release.py`。临时远程检查使用 `python deploy/remote_shell.py "命令"`;该脚本仍从 `DBTOOL_PW` 读取密码。
`db-tool.service` 是 systemd 服务定义,`db-tool-nginx.conf` 是 Nginx 反向代理示例。
如生产环境需要 Oracle Thick 模式,请在构建前从 Oracle 官方获取 Linux Instant Client,并解压到项目根目录的 `oracle_client/linux/`。该目录是本机构建依赖,不纳入版本控制。 如生产环境需要 Oracle Thick 模式,请从 Oracle 官方获取 Linux Instant Client,并解压到项目根目录的 `oracle_client/linux/`。该目录是本机构建依赖,不纳入版本控制。Linux 服务通过 `db-tool.service` 中的 `LD_LIBRARY_PATH` 加载 Instant Client;不要改成运行时传 `lib_dir`,CentOS 7 与 python-oracledb 3.x 的组合可能因此触发原生段错误。构建脚本会把 Windows 解压产生的伪符号链接恢复为 Linux tar 符号链接。
...@@ -8,6 +8,7 @@ from __future__ import annotations ...@@ -8,6 +8,7 @@ from __future__ import annotations
import os import os
import subprocess import subprocess
import tarfile import tarfile
import time
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
...@@ -28,7 +29,33 @@ def _add_tree(archive: tarfile.TarFile, root: Path, arcname: str) -> None: ...@@ -28,7 +29,33 @@ def _add_tree(archive: tarfile.TarFile, root: Path, arcname: str) -> None:
for path in root.rglob("*"): for path in root.rglob("*"):
if _skip(path) or not path.is_file(): if _skip(path) or not path.is_file():
continue continue
archive.add(path, arcname=f"{arcname}/{path.relative_to(root).as_posix()}") archive_name = f"{arcname}/{path.relative_to(root).as_posix()}"
# Windows 解压 Oracle 官方 ZIP 时,Linux 符号链接会变成内容仅为
# 目标文件名的小文本文件。恢复为 tar 符号链接,否则 Linux 加载
# libclntsh.so 时会把文本当动态库,严重时直接导致进程 SIGSEGV。
if "oracle_client" in path.parts and path.stat().st_size < 256:
try:
link_target = path.read_text(encoding="ascii").strip()
except (OSError, UnicodeDecodeError):
link_target = ""
if link_target and (path.parent / link_target).is_file():
info = tarfile.TarInfo(archive_name)
info.type = tarfile.SYMTYPE
info.linkname = link_target
info.mode = 0o777
# Windows 伪链接常带 1970 时间戳,避免 Linux 解包时出现
# "unknown date" 警告,使用当前构建时间作为链接时间。
info.mtime = int(time.time())
archive.addfile(info)
continue
info = archive.gettarinfo(str(path), arcname=archive_name)
if "oracle_client" in path.parts:
# Windows 文件系统不保存 Unix 可执行位,发布时统一补齐。
info.mode = 0o755
with path.open("rb") as source:
archive.addfile(info, source)
def main() -> None: def main() -> None:
......
...@@ -10,6 +10,7 @@ Environment=PYTHONPATH=/opt/app/db-tool ...@@ -10,6 +10,7 @@ Environment=PYTHONPATH=/opt/app/db-tool
Environment=APP_ENV=production Environment=APP_ENV=production
Environment=WEB_HOST=127.0.0.1 Environment=WEB_HOST=127.0.0.1
Environment=WEB_PORT=8767 Environment=WEB_PORT=8767
Environment=LD_LIBRARY_PATH=/opt/app/db-tool/web/oracle_client/linux/instantclient_19_32
ExecStart=/opt/app/db-tool/.venv/bin/python -m web.backend.start ExecStart=/opt/app/db-tool/.venv/bin/python -m web.backend.start
Restart=always Restart=always
RestartSec=5 RestartSec=5
......
#!/usr/bin/env python3 #!/usr/bin/env python3
"""db-tools 正式发版:上传、迁移旧 SQLite、切换 MySQL、重启并验证。""" """一键构建并发布 db-tools 正式版本。
默认流程:读取本机私有服务器文档、构建发布包、校验产物、上传、备份、
重启 systemd 服务,并检查后端、Nginx 和可选 Oracle 数据源连接。
"""
from __future__ import annotations from __future__ import annotations
import argparse
import json
import os import os
import posixpath
import re
import subprocess
import sys import sys
import tarfile
from pathlib import Path from pathlib import Path
import paramiko import paramiko
HOST = os.environ.get("DBTOOL_HOST", "192.168.20.175")
USER = os.environ.get("DBTOOL_USER", "root")
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
DEPLOY_DIR = ROOT / "deploy"
RELEASE_DIR = ROOT / "release" RELEASE_DIR = ROOT / "release"
SERVICE_FILE = Path(__file__).with_name("db-tool.service") SERVICE_FILE = DEPLOY_DIR / "db-tool.service"
pw = os.environ.get("DBTOOL_PW") SERVER_FILE = DEPLOY_DIR / "server.local.md"
if not pw:
sys.exit("set DBTOOL_PW first")
def _markdown_value(text: str, label: str) -> str | None:
c = paramiko.SSHClient() """读取 ``- 标签:`值` `` 形式的私有配置,兼容无反引号写法。"""
c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) match = re.search(
c.connect(HOST, username=USER, password=pw, timeout=10, look_for_keys=False, allow_agent=False) rf"^\s*-\s*{re.escape(label)}\s*[::]\s*`?([^`\r\n]+?)`?\s*$",
text,
def run(cmd, must=True): flags=re.MULTILINE,
_, out, err = c.exec_command(cmd, timeout=120) )
o, e = out.read().decode("utf-8", "replace"), err.read().decode("utf-8", "replace") return match.group(1).strip() if match else None
rc = out.channel.recv_exit_status()
print(f"$ {cmd}\n{o}{('[stderr] ' + e) if e.strip() else ''}rc={rc}\n")
if must and rc != 0: def load_server_settings() -> tuple[str, str, str, int | None]:
sys.exit(f"FAILED rc={rc}: {cmd}") """加载服务器配置,环境变量优先,私有 Markdown 作为默认来源。"""
return o private_text = SERVER_FILE.read_text(encoding="utf-8") if SERVER_FILE.is_file() else ""
host = os.environ.get("DBTOOL_HOST") or _markdown_value(private_text, "主机") or "192.168.20.175"
sftp = c.open_sftp() user = os.environ.get("DBTOOL_USER") or _markdown_value(private_text, "SSH 用户") or "root"
sftp.put(str(RELEASE_DIR / "backend-deploy.tgz"), "/tmp/dbtool-backend.tgz") password = os.environ.get("DBTOOL_PW") or _markdown_value(private_text, "SSH 密码")
sftp.put(str(RELEASE_DIR / "frontend-deploy.tgz"), "/tmp/dbtool-frontend.tgz") source_id = os.environ.get("DBTOOL_ORACLE_SOURCE_ID") or _markdown_value(
sftp.put(str(SERVICE_FILE), "/tmp/db-tool.service") private_text, "Oracle 测试数据源 ID"
sftp.close() )
print("uploaded both deployment packages\n") if not password:
raise SystemExit(
# 0) 部署前快照(旧 SQLite 只作为首次 MySQL 迁移源) f"未找到 SSH 密码,请填写 {SERVER_FILE},或设置 DBTOOL_PW 环境变量。"
run("test -f /opt/app/db-tool/web/data/web.db && md5sum /opt/app/db-tool/web/data/web.db || true") )
try:
# 1) 备份当前版本 oracle_source_id = int(source_id) if source_id else None
run("TS=$(date +%Y%m%d-%H%M%S) && mkdir -p /opt/app/db-tool/releases/$TS && " except ValueError as exc:
"cp -a /opt/app/db-tool/web/backend /opt/app/db-tool/releases/$TS/backend && " raise SystemExit("Oracle 测试数据源 ID 必须是数字。") from exc
"cp -a /opt/web/db-tool /opt/app/db-tool/releases/$TS/frontend && echo backup at $TS") return host, user, password, oracle_source_id
# 2) 停止旧服务并备份其 SQLite,保证迁移读取一致快照
run("systemctl stop db-tool && TS=$(date +%Y%m%d-%H%M%S) && " def _archive_members(path: Path) -> list[tarfile.TarInfo]:
"mkdir -p /opt/app/db-tool/releases/$TS && " if not path.is_file() or path.stat().st_size == 0:
"cp -a /opt/app/db-tool/web/data/web.db /opt/app/db-tool/releases/$TS/web.db") raise SystemExit(f"发布包不存在或为空:{path}")
try:
# 3) 解压后端(不动 data/、logs/),并锁定私有配置权限 with tarfile.open(path, "r:gz") as archive:
run("tar xzf /tmp/dbtool-backend.tgz -C /opt/app/db-tool/web/ && " return archive.getmembers()
"find /opt/app/db-tool/web/backend -name __pycache__ -type d -exec rm -rf {} + ; " except (tarfile.TarError, OSError) as exc:
"chmod 600 /opt/app/db-tool/web/backend/configs/llm.yaml /opt/app/db-tool/web/backend/configs/database.production.yaml") raise SystemExit(f"发布包无法读取:{path}: {exc}") from exc
# 4) 首次发布时迁移旧 SQLite 到正式 MySQL;已有 MySQL 数据时不覆盖
run("cd /opt/app/db-tool && APP_ENV=production PYTHONPATH=/opt/app/db-tool " def validate_release_packages(require_oracle: bool = False) -> None:
"/opt/app/db-tool/.venv/bin/python -m web.backend.db.migrate_sqlite_to_mysql " """上传前校验关键文件,避免坏包把线上服务停在半途。"""
"--source /opt/app/db-tool/web/data/web.db --skip-if-populated") backend = RELEASE_DIR / "backend-deploy.tgz"
frontend = RELEASE_DIR / "frontend-deploy.tgz"
# 5) 安装正式 systemd 配置并解压前端 backend_members = _archive_members(backend)
run("install -m 644 /tmp/db-tool.service /etc/systemd/system/db-tool.service && systemctl daemon-reload") frontend_members = _archive_members(frontend)
run("rm -rf /opt/web/db-tool/assets && tar xzf /tmp/dbtool-frontend.tgz -C /opt/web/db-tool/ && ls /opt/web/db-tool/ /opt/web/db-tool/assets/") backend_names = {member.name for member in backend_members}
# tarfile 可能保留 ``./`` 前缀(前端归档的根目录就是 ``.``)。
# 6) 重启 frontend_names = {member.name.lstrip("./").rstrip("/") for member in frontend_members}
run("systemctl restart db-tool && sleep 4 && systemctl is-active db-tool && ss -tlnp | grep 8767")
missing = sorted(
# 7) 部署后核对 {"backend/app.py", "backend/configs/database.production.yaml"} - backend_names
run("curl -fsS http://127.0.0.1:8767/api/health && journalctl -u db-tool -n 12 --no-pager | tail -12") )
if missing:
c.close() raise SystemExit(f"后端发布包缺少:{', '.join(missing)}")
print("DEPLOY DONE") if "index.html" not in frontend_names or not any(
name.startswith("assets/") for name in frontend_names
):
raise SystemExit("前端发布包缺少 index.html 或 assets/。")
oracle_links = [member for member in backend_members if member.name.endswith("/libclntsh.so")]
if oracle_links or require_oracle:
if not oracle_links:
raise SystemExit("已配置 Oracle 测试数据源,但后端发布包未包含 Oracle Instant Client。")
if not any(member.issym() for member in oracle_links):
raise SystemExit("Oracle 发布包中的 libclntsh.so 不是符号链接。")
link = next(member for member in oracle_links if member.issym())
target_name = posixpath.normpath(
posixpath.join(posixpath.dirname(link.name), link.linkname)
)
target = next(
(member for member in backend_members if member.name == target_name and not member.issym()),
None,
)
if target is None:
raise SystemExit(f"Oracle 符号链接目标不存在:{link.name} -> {link.linkname}")
with tarfile.open(backend, "r:gz") as archive:
content = archive.extractfile(target)
if content is None or content.read(4) != b"\x7fELF":
raise SystemExit("Oracle 目标库不是 ELF 文件。")
print("发布包预检查通过:后端、前端和 Oracle 文件结构正常。")
def build_release() -> None:
print("开始构建发布包……")
subprocess.run([sys.executable, str(DEPLOY_DIR / "build_release.py")], cwd=ROOT, check=True)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="构建并发布 db-tools 正式版本")
parser.add_argument("--skip-build", action="store_true", help="使用现有 release/ 发布包")
parser.add_argument(
"--skip-oracle-check", action="store_true", help="跳过发布后的 Oracle 数据源连接测试"
)
return parser.parse_args()
def main() -> None:
args = parse_args()
if not args.skip_build:
build_release()
host, user, password, oracle_source_id = load_server_settings()
validate_release_packages(require_oracle=oracle_source_id is not None)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
host,
username=user,
password=password,
timeout=10,
look_for_keys=False,
allow_agent=False,
)
def run(command: str, must: bool = True) -> str:
_, stdout, stderr = client.exec_command(command, timeout=120)
output = stdout.read().decode("utf-8", "replace")
error = stderr.read().decode("utf-8", "replace")
code = stdout.channel.recv_exit_status()
print(f"$ {command}\n{output}{('[stderr] ' + error) if error.strip() else ''}rc={code}\n")
if must and code != 0:
raise SystemExit(f"远程命令失败 rc={code}: {command}")
return output
sftp = client.open_sftp()
try:
sftp.put(str(RELEASE_DIR / "backend-deploy.tgz"), "/tmp/dbtool-backend.tgz")
sftp.put(str(RELEASE_DIR / "frontend-deploy.tgz"), "/tmp/dbtool-frontend.tgz")
sftp.put(str(SERVICE_FILE), "/tmp/db-tool.service")
finally:
sftp.close()
print("发布包和 systemd 配置上传完成。")
# 部署前确认目标目录存在,并保留旧 SQLite 作为迁移快照。
run("test -d /opt/app/db-tool/web/backend && test -d /opt/web/db-tool")
run("test -f /opt/app/db-tool/web/data/web.db && md5sum /opt/app/db-tool/web/data/web.db || true")
run(
"TS=$(date +%Y%m%d-%H%M%S) && mkdir -p /opt/app/db-tool/releases/$TS && "
"cp -a /opt/app/db-tool/web/backend /opt/app/db-tool/releases/$TS/backend && "
"cp -a /opt/web/db-tool /opt/app/db-tool/releases/$TS/frontend && echo backup at $TS"
)
run(
"systemctl stop db-tool && TS=$(date +%Y%m%d-%H%M%S) && "
"mkdir -p /opt/app/db-tool/releases/$TS && "
"cp -a /opt/app/db-tool/web/data/web.db /opt/app/db-tool/releases/$TS/web.db"
)
run(
"tar xzf /tmp/dbtool-backend.tgz -C /opt/app/db-tool/web/ && "
"find /opt/app/db-tool/web/backend -name __pycache__ -type d -exec rm -rf {} + ; "
"chmod 600 /opt/app/db-tool/web/backend/configs/llm.yaml "
"/opt/app/db-tool/web/backend/configs/database.production.yaml"
)
run(
"cd /opt/app/db-tool && APP_ENV=production PYTHONPATH=/opt/app/db-tool "
"/opt/app/db-tool/.venv/bin/python -m web.backend.db.migrate_sqlite_to_mysql "
"--source /opt/app/db-tool/web/data/web.db --skip-if-populated"
)
run("install -m 644 /tmp/db-tool.service /etc/systemd/system/db-tool.service && systemctl daemon-reload")
run(
"rm -rf /opt/web/db-tool/assets && tar xzf /tmp/dbtool-frontend.tgz -C /opt/web/db-tool/ "
"&& test -f /opt/web/db-tool/index.html"
)
run("systemctl restart db-tool && sleep 4 && systemctl is-active db-tool && ss -tlnp | grep 8767")
run("curl -fsS http://127.0.0.1:8767/api/health")
run("curl -fsS http://127.0.0.1/db-tool/api/health")
if oracle_source_id is not None and not args.skip_oracle_check:
result = run(
f"curl -fsS -X POST http://127.0.0.1/db-tool/api/data-sources/{oracle_source_id}/test "
"-H 'Content-Type: application/json' --data '{}'"
)
try:
if json.loads(result).get("ok") is not True:
raise SystemExit("Oracle 数据源连接测试未返回 ok=true。")
except json.JSONDecodeError as exc:
raise SystemExit("Oracle 数据源连接测试返回了非 JSON 内容。") from exc
run("journalctl -u db-tool -n 12 --no-pager | tail -12")
print("正式发布完成,服务和健康检查均通过。")
finally:
client.close()
if __name__ == "__main__":
main()
...@@ -7,6 +7,10 @@ import sys ...@@ -7,6 +7,10 @@ import sys
import paramiko import paramiko
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
HOST = os.environ.get("DBTOOL_HOST", "192.168.20.175") HOST = os.environ.get("DBTOOL_HOST", "192.168.20.175")
USER = os.environ.get("DBTOOL_USER", "root") USER = os.environ.get("DBTOOL_USER", "root")
password = os.environ.get("DBTOOL_PW") password = os.environ.get("DBTOOL_PW")
......
...@@ -338,7 +338,19 @@ def _ensure_oracle_client(lib_dir: Optional[str] = None) -> str: ...@@ -338,7 +338,19 @@ def _ensure_oracle_client(lib_dir: Optional[str] = None) -> str:
import oracledb import oracledb
bundled_dir = _bundled_oracle_client_dir(lib_dir) bundled_dir = _bundled_oracle_client_dir(lib_dir)
if bundled_dir: if bundled_dir:
oracledb.init_oracle_client(lib_dir=bundled_dir) if sys.platform.startswith("linux"):
# CentOS 7 + python-oracledb 3.x 使用 lib_dir 动态加载时可能在
# ODPI-C 内部触发 SIGSEGV。Linux 应在进程启动前通过
# LD_LIBRARY_PATH 暴露客户端,再使用无参数初始化。
library_paths = os.environ.get("LD_LIBRARY_PATH", "").split(os.pathsep)
if bundled_dir not in library_paths:
raise RuntimeError(
"Linux Oracle Thick 模式要求在进程启动前将 Instant Client "
f"加入 LD_LIBRARY_PATH:{bundled_dir}"
)
oracledb.init_oracle_client()
else:
oracledb.init_oracle_client(lib_dir=bundled_dir)
logger.info(f"[DB] Oracle Instant Client 已加载(项目内置)lib_dir={bundled_dir}") logger.info(f"[DB] Oracle Instant Client 已加载(项目内置)lib_dir={bundled_dir}")
else: else:
oracledb.init_oracle_client() # 默认搜索 PATH / 注册表 / ORACLE_HOME oracledb.init_oracle_client() # 默认搜索 PATH / 注册表 / ORACLE_HOME
...@@ -351,7 +363,6 @@ def _ensure_oracle_client(lib_dir: Optional[str] = None) -> str: ...@@ -351,7 +363,6 @@ def _ensure_oracle_client(lib_dir: Optional[str] = None) -> str:
# (Instant Client 12.x 是用 MSVC 2013 编译的,依赖 MSVCR120.dll; # (Instant Client 12.x 是用 MSVC 2013 编译的,依赖 MSVCR120.dll;
# 装 vcredist 2015-2022 不够,必须装 VC++ 2013 Redist) # 装 vcredist 2015-2022 不够,必须装 VC++ 2013 Redist)
if "DPI-1047" in err_str: if "DPI-1047" in err_str:
import os
oci_in_lib_dir = False oci_in_lib_dir = False
if lib_dir: if lib_dir:
oci_in_lib_dir = os.path.exists(os.path.join(lib_dir, "oci.dll")) oci_in_lib_dir = os.path.exists(os.path.join(lib_dir, "oci.dll"))
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment