381 lines
14 KiB
Python
381 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
MBTI 王:通过 kr 宝塔面板 API 上传 PHP 与 admin 静态资源并触发表层服务重载。
|
||
|
||
鉴权:环境变量 BT_PANEL_URL(默认 https://43.139.27.93:9988)、BT_API_KEY(面板「设置 → API 接口」密钥)。
|
||
本机出口 IP 须加入面板 API 白名单。若报 IP 校验失败,可用腾讯云 TAT 在机内追加白名单(无需先加白):
|
||
卡若AI/…/服务器管理/scripts/腾讯云_TAT_kr宝塔_API白名单_追加出口IP.py
|
||
可加 --ip 与宝塔报错括号内 IPv4 一致。
|
||
|
||
用法:
|
||
BT_API_KEY=xxx python3 scripts/deploy_mbti_bt_api.py --list-sites
|
||
BT_API_KEY=xxx python3 scripts/deploy_mbti_bt_api.py --all
|
||
BT_API_KEY=xxx python3 scripts/deploy_mbti_bt_api.py --api-only
|
||
BT_API_KEY=xxx python3 scripts/deploy_mbti_bt_api.py --admin-only
|
||
|
||
也可将密钥写入 scripts/.env.bt(见 scripts/env.bt.example),勿提交;脚本启动时会自动加载。
|
||
|
||
可选覆盖(自动识别失败时):
|
||
MBTI_API_CODE_ROOT 服务器上与本地 api/ 同级的目录(含 app/、public/),例:/www/wwwroot/self/mbti-api/api
|
||
MBTI_ADMIN_SITE_ROOT 静态站点根(含 index.html),例:/www/wwwroot/self/mbti-admin
|
||
|
||
PHP-FPM 重载(版本因机而异,不填则跳过):
|
||
BT_PHP_FPM_SERVICE=php-fpm-82
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import ssl
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
ssl._create_default_https_context = ssl._create_unverified_context
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
API_LOCAL = REPO_ROOT / "api"
|
||
ADMIN_DIST = REPO_ROOT / "admin" / "dist"
|
||
|
||
|
||
def load_env_bt() -> None:
|
||
"""从 scripts/.env.bt 注入环境变量(仅当当前环境未设置同名变量时)。"""
|
||
p = REPO_ROOT / "scripts" / ".env.bt"
|
||
if not p.is_file():
|
||
return
|
||
try:
|
||
raw_text = p.read_text(encoding="utf-8")
|
||
except OSError:
|
||
return
|
||
for raw in raw_text.splitlines():
|
||
line = raw.strip()
|
||
if not line or line.startswith("#"):
|
||
continue
|
||
if line.startswith("export "):
|
||
line = line[7:].strip()
|
||
if "=" not in line:
|
||
continue
|
||
key, _, val = line.partition("=")
|
||
key = key.strip()
|
||
val = val.strip().strip("'").strip('"')
|
||
# 跳过空值,避免 .env.bt 里 BT_API_KEY= 占位导致覆盖不了终端里已 export 的密钥
|
||
if key and val and key not in os.environ:
|
||
os.environ[key] = val
|
||
|
||
DEFAULT_API_REL_PATHS = [
|
||
"app/controller/admin/AppUser.php",
|
||
"app/controller/admin/Order.php",
|
||
"app/controller/admin/Dashboard.php",
|
||
"app/controller/admin/Finance.php",
|
||
"app/controller/admin/concern/ExtractsTestResults.php",
|
||
"app/model/WechatUser.php",
|
||
# 路由与小程序核心 API
|
||
"route/api.php",
|
||
"app/controller/api/Auth.php",
|
||
"app/controller/api/AppConfig.php",
|
||
"app/controller/api/MpConfig.php",
|
||
"app/controller/api/Order.php",
|
||
# 神仙 AI 异步投递(triggerAiChatDeferredJob → /api/internal/outbound-push/dispatch)
|
||
"app/controller/api/InternalPushHook.php",
|
||
# 神仙 AI(对话 + 报告)
|
||
"app/controller/api/AiChat.php",
|
||
"app/controller/api/AiReport.php",
|
||
"app/controller/api/Analyze.php",
|
||
"app/controller/api/Test.php",
|
||
"app/common/service/AiCallService.php",
|
||
"app/common/service/AiChatArticleDisplayService.php",
|
||
"app/common/service/SoulArticleService.php",
|
||
"app/common/service/AiReportService.php",
|
||
# 神仙 AI 依赖模型(避免线上仍为旧版表映射)
|
||
"app/model/AiConversation.php",
|
||
"app/model/AiMessage.php",
|
||
"app/model/AiProvider.php",
|
||
"app/model/SoulArticle.php",
|
||
"app/model/SystemConfig.php",
|
||
# 微信支付 / 转账回调
|
||
"app/controller/api/Payment.php",
|
||
"app/controller/api/WechatTransferNotify.php",
|
||
"app/common/service/WechatService.php",
|
||
"app/common/service/WechatAuditSyncService.php",
|
||
"app/common/service/WechatTransferService.php",
|
||
"app/controller/superadmin/Settings.php",
|
||
# 认证、配置、埋点/推送依赖
|
||
"app/common/service/JwtService.php",
|
||
"app/common/service/MpTabbarService.php",
|
||
"app/common/service/FeishuLeadWebhookService.php",
|
||
"app/common/service/OutboundPushHookService.php",
|
||
"app/common/service/ThirdPartyChannelService.php",
|
||
# 上线自检 / 冒烟(可选;上传至服务器 api/scripts 供 SSH 执行)
|
||
"scripts/check_ai_chat_ready.php",
|
||
"scripts/smoke_ai_provider.php",
|
||
]
|
||
|
||
API_DOMAIN_HINT = "mbtiapi"
|
||
# 面板上可能是 mbtiadmin / mbti.quwanzhi.com 等
|
||
ADMIN_DOMAIN_HINTS = ("mbtiadmin", "mbti.quwanzhi", "mbti-admin")
|
||
|
||
|
||
def panel_url() -> str:
|
||
return os.environ.get("BT_PANEL_URL", "https://43.139.27.93:9988").rstrip("/")
|
||
|
||
|
||
def api_key() -> str:
|
||
k = os.environ.get("BT_API_KEY") or os.environ.get("MBTI_BT_API_KEY") or ""
|
||
return k.strip()
|
||
|
||
|
||
def sign(key: str) -> dict:
|
||
t = int(time.time())
|
||
s = str(t) + hashlib.md5(key.encode("utf-8")).hexdigest()
|
||
return {"request_time": t, "request_token": hashlib.md5(s.encode("utf-8")).hexdigest()}
|
||
|
||
|
||
def post(endpoint: str, data: dict | None, key: str, timeout: int = 120) -> dict:
|
||
url = panel_url() + endpoint
|
||
payload = sign(key)
|
||
if data:
|
||
payload.update(data)
|
||
body = urllib.parse.urlencode(payload).encode()
|
||
req = urllib.request.Request(url, data=body, method="POST")
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
raw = resp.read().decode("utf-8", errors="replace")
|
||
return json.loads(raw)
|
||
except urllib.error.HTTPError as e:
|
||
try:
|
||
raw = e.read().decode("utf-8", errors="replace")
|
||
return {"status": False, "msg": "HTTP %s: %s" % (e.code, raw[:500])}
|
||
except Exception:
|
||
return {"status": False, "msg": "HTTP %s" % e.code}
|
||
except Exception as e:
|
||
return {"status": False, "msg": str(e)}
|
||
|
||
|
||
def get_sites(key: str, limit: int = 500) -> list:
|
||
r = post("/data?action=getData", {"table": "sites", "limit": str(limit), "p": "1"}, key)
|
||
data = r.get("data")
|
||
return data if isinstance(data, list) else []
|
||
|
||
|
||
def find_site_path(sites: list, hint: str) -> str | None:
|
||
hint_l = hint.lower()
|
||
for s in sites:
|
||
name = str(s.get("name", "")).lower()
|
||
if hint_l in name:
|
||
p = s.get("path")
|
||
if p:
|
||
return str(p).rstrip("/")
|
||
return None
|
||
|
||
|
||
def find_admin_site_path(sites: list) -> str | None:
|
||
for h in ADMIN_DOMAIN_HINTS:
|
||
p = find_site_path(sites, h)
|
||
if p:
|
||
return p
|
||
return None
|
||
|
||
|
||
def resolve_api_code_root(site_path: str | None) -> str | None:
|
||
"""服务器上对应本地仓库 api/ 的目录(内含 app、public)。"""
|
||
override = os.environ.get("MBTI_API_CODE_ROOT", "").strip()
|
||
if override:
|
||
return override.rstrip("/")
|
||
if not site_path:
|
||
return None
|
||
p = Path(site_path.rstrip("/"))
|
||
if p.name.lower() == "public":
|
||
return str(p.parent)
|
||
# 面板网站目录已指向 .../mbti-api/api 时,basename 为 api,即代码根
|
||
if p.name.lower() == "api":
|
||
return str(p)
|
||
# 站点根为仓库根(如 .../mbti-api)时,代码在子目录 api/
|
||
return str(p / "api")
|
||
|
||
|
||
def ensure_remote_dir(remote_dir: str, key: str) -> None:
|
||
r = post("/files?action=CreateDir", {"path": remote_dir}, key)
|
||
if r.get("status") is not True and "已存在" not in str(r.get("msg", "")):
|
||
pass
|
||
|
||
|
||
def save_remote_file(remote_path: str, text: str, key: str) -> dict:
|
||
r = post(
|
||
"/files?action=SaveFileBody",
|
||
{"path": remote_path, "data": text, "encoding": "utf-8"},
|
||
key,
|
||
timeout=180,
|
||
)
|
||
if r.get("status") is True:
|
||
return r
|
||
msg = str(r.get("msg", ""))
|
||
# 宝塔对新文件需要先 CreateFile,再 SaveFileBody
|
||
if "不存在" in msg or "not exist" in msg.lower():
|
||
cf = post("/files?action=CreateFile", {"path": remote_path}, key)
|
||
if cf.get("status") is True or "已存在" in str(cf.get("msg", "")):
|
||
return post(
|
||
"/files?action=SaveFileBody",
|
||
{"path": remote_path, "data": text, "encoding": "utf-8"},
|
||
key,
|
||
timeout=180,
|
||
)
|
||
return r
|
||
|
||
|
||
def read_text_local(path: Path) -> str:
|
||
return path.read_text(encoding="utf-8")
|
||
|
||
|
||
def deploy_api_files(api_root_remote: str, key: str, rel_paths: list[str]) -> int:
|
||
base = Path(api_root_remote.rstrip("/"))
|
||
errs = 0
|
||
for rel in rel_paths:
|
||
local = API_LOCAL / rel
|
||
if not local.is_file():
|
||
print(" 跳过(本地不存在):", local)
|
||
continue
|
||
remote = str(base / rel).replace("\\", "/")
|
||
text = read_text_local(local)
|
||
r = save_remote_file(remote, text, key)
|
||
if r.get("status") is True:
|
||
print(" OK", remote)
|
||
else:
|
||
print(" FAIL", remote, r.get("msg", r))
|
||
errs += 1
|
||
return errs
|
||
|
||
|
||
def deploy_admin_dist(admin_root_remote: str, key: str) -> int:
|
||
if not ADMIN_DIST.is_dir():
|
||
print(" 本地无 admin/dist,请先: cd admin && npm run build")
|
||
return 1
|
||
root = admin_root_remote.rstrip("/")
|
||
errs = 0
|
||
for dirpath, _dirnames, filenames in os.walk(ADMIN_DIST):
|
||
rel = os.path.relpath(dirpath, ADMIN_DIST)
|
||
if rel == ".":
|
||
remote_dir = root
|
||
else:
|
||
remote_dir = root + "/" + rel.replace("\\", "/")
|
||
ensure_remote_dir(remote_dir, key)
|
||
for name in filenames:
|
||
lp = Path(dirpath) / name
|
||
remote_path = remote_dir.rstrip("/") + "/" + name
|
||
try:
|
||
text = read_text_local(lp)
|
||
except UnicodeDecodeError:
|
||
print(" 非 UTF-8 跳过:", lp)
|
||
errs += 1
|
||
continue
|
||
r = save_remote_file(remote_path, text, key)
|
||
if r.get("status") is True:
|
||
print(" OK", remote_path)
|
||
else:
|
||
print(" FAIL", remote_path, r.get("msg", r))
|
||
errs += 1
|
||
return errs
|
||
|
||
|
||
def service_admin(name: str, op: str, key: str) -> None:
|
||
r = post("/system?action=ServiceAdmin", {"name": name, "type": op}, key)
|
||
print(" ServiceAdmin %s %s -> %s" % (name, op, r.get("msg", r.get("status", r))))
|
||
|
||
|
||
def main() -> int:
|
||
load_env_bt()
|
||
ap = argparse.ArgumentParser(description="MBTI 宝塔 API 部署")
|
||
ap.add_argument("--list-sites", action="store_true", help="列出站点并匹配 mbti 路径后退出")
|
||
ap.add_argument("--all", action="store_true", help="上传 API 默认文件 + admin/dist")
|
||
ap.add_argument("--api-only", action="store_true")
|
||
ap.add_argument("--admin-only", action="store_true")
|
||
ap.add_argument("--dry-run", action="store_true", help="只打印将上传的路径,不请求面板")
|
||
args = ap.parse_args()
|
||
|
||
key = api_key()
|
||
if args.list_sites and not key:
|
||
print("请设置环境变量 BT_API_KEY(或 MBTI_BT_API_KEY)")
|
||
return 1
|
||
if not key and not args.dry_run:
|
||
print("请设置环境变量 BT_API_KEY(或 MBTI_BT_API_KEY)")
|
||
return 1
|
||
|
||
sites: list = []
|
||
if key and not args.dry_run:
|
||
ping = post("/system?action=GetSystemTotal", {}, key)
|
||
if ping.get("cpuRealUsed") is None:
|
||
print("面板连接失败:", ping)
|
||
return 1
|
||
sites = get_sites(key)
|
||
|
||
admin_override = os.environ.get("MBTI_ADMIN_SITE_ROOT", "").strip()
|
||
|
||
api_site_path = find_site_path(sites, API_DOMAIN_HINT)
|
||
admin_site_path = admin_override or find_admin_site_path(sites)
|
||
|
||
if args.list_sites:
|
||
print("面板:", panel_url())
|
||
for s in sites:
|
||
n = s.get("name", "")
|
||
if "mbti" in str(n).lower():
|
||
print(" ", n, "->", s.get("path"))
|
||
print(
|
||
"解析 API:面板站点 path -> 代码根(本地 api/ 对应):",
|
||
api_site_path,
|
||
"->",
|
||
resolve_api_code_root(api_site_path),
|
||
)
|
||
print("解析 Admin 根:", admin_site_path)
|
||
return 0
|
||
|
||
if not args.all and not args.api_only and not args.admin_only:
|
||
print("请指定 --all / --api-only / --admin-only 或 --list-sites")
|
||
return 1
|
||
|
||
api_code_root = resolve_api_code_root(api_site_path)
|
||
if not api_code_root:
|
||
print("未解析到 API 代码根。请 --list-sites 核对,或设置 MBTI_API_CODE_ROOT")
|
||
return 1
|
||
if (args.all or args.admin_only) and not admin_site_path:
|
||
print("未找到 mbtiadmin 站点路径。请 --list-sites 核对,或设置 MBTI_ADMIN_SITE_ROOT")
|
||
return 1
|
||
|
||
if args.dry_run:
|
||
print("API 代码根:", api_code_root)
|
||
for rel in DEFAULT_API_REL_PATHS:
|
||
print(" 将写:", api_code_root + "/" + rel)
|
||
if args.all or args.admin_only:
|
||
print("Admin 根:", admin_site_path)
|
||
return 0
|
||
|
||
err = 0
|
||
if args.all or args.api_only:
|
||
print("[API] 上传到", api_code_root)
|
||
err += deploy_api_files(api_code_root, key, DEFAULT_API_REL_PATHS)
|
||
|
||
if args.all or args.admin_only:
|
||
print("[Admin] 上传到", admin_site_path)
|
||
err += deploy_admin_dist(admin_site_path, key)
|
||
|
||
php_svc = os.environ.get("BT_PHP_FPM_SERVICE", "").strip()
|
||
if php_svc:
|
||
print("[重载 PHP-FPM]", php_svc)
|
||
service_admin(php_svc, "reload", key)
|
||
print("[重载 Nginx]")
|
||
service_admin("nginx", "reload", key)
|
||
|
||
if err:
|
||
print("完成,但有 %s 个文件失败" % err)
|
||
return 1
|
||
print("完成。")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|