Files
Mycontent/soul-api/master.py
卡若 6d11fb295d feat: 同步本地三端改动并清理上传凭证风险
整合小程序、管理端与后端的最新本地改动,补齐用户管理与首页入口相关能力;提交前已完成敏感信息扫描,并移除本地 gitea 远程 URL 中的明文凭证,避免隐私信息进入远程仓库。

Made-with: Cursor
2026-04-06 15:59:34 +08:00

560 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
soulApisoul-api 后端Go 项目一键部署到宝塔(正式环境)
- 打包使用 .env.production 作为服务器 .env
- 本地交叉编译 Linux 二进制
- 上传到 /www/wwwroot/self/soul-api
- 重启:优先宝塔 API需配置否则 SSH 下 setsid nohup 启动
宝塔 API 重启(可选):在环境变量或 .env 中设置
BT_PANEL_URL = https://你的面板地址:9988
BT_API_KEY = 面板 设置 -> API 接口 中的密钥
BT_GO_PROJECT_NAME = soulApi (与宝塔「网站」里 Go 站点名一致)
BT_GO_SITE_ID = 可选,网站 id不设则从 sites 表自动匹配 Go 站点)
并安装 requests: pip install requests
"""
from __future__ import print_function
import hashlib
import json
import os
import sys
import tempfile
import argparse
import subprocess
import shutil
import tarfile
import time
import threading
import shlex
try:
import paramiko
except ImportError:
print("错误: 请先安装 paramiko")
print(" pip install paramiko")
sys.exit(1)
try:
import requests
try:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except Exception:
pass
except ImportError:
requests = None
# ==================== 配置 ====================
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-api"
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
# 宝塔 API 密钥(写死,用于部署后重启 Go 项目)
BT_API_KEY_DEFAULT = "qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT"
def get_cfg():
host = os.environ.get("DEPLOY_HOST", "43.139.27.93")
bt_url = (os.environ.get("BT_PANEL_URL") or "").strip().rstrip("/")
if not bt_url:
bt_url = "https://%s:9988" % host
return {
"host": host,
"user": os.environ.get("DEPLOY_USER", "root"),
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
"bt_panel_url": bt_url,
"bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT),
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulApi"),
}
# ==================== 本地构建 ====================
def run_build(root):
"""交叉编译 Go 二进制Linux amd64"""
print("[1/4] 本地交叉编译 Go 二进制 ...")
env = os.environ.copy()
env["GOOS"] = "linux"
env["GOARCH"] = "amd64"
env["CGO_ENABLED"] = "0"
# 必须 shell=False否则 Windows 下 -ldflags 等参数会被当成包路径导致 "malformed import path"
cmd = ["go", "build", "-o", "soul-api", "./cmd/server"]
try:
r = subprocess.run(
cmd,
cwd=root,
env=env,
shell=False,
timeout=120,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if r.returncode != 0:
print(" [失败] go build 失败,退出码:", r.returncode)
if r.stderr:
for line in (r.stderr or "").strip().split("\n")[-10:]:
print(" " + line)
return None
out_path = os.path.join(root, "soul-api")
if not os.path.isfile(out_path):
print(" [失败] 未找到编译产物 soul-api")
return None
print(" [成功] 编译完成: %s (%.2f MB)" % (out_path, os.path.getsize(out_path) / 1024 / 1024))
return out_path
except subprocess.TimeoutExpired:
print(" [失败] 编译超时")
return None
except FileNotFoundError:
print(" [失败] 未找到 go 命令,请安装 Go")
return None
except Exception as e:
print(" [失败] 编译异常:", str(e))
return None
# ==================== 打包 ====================
# 正式环境 Nginx 一般反代 8080可用环境变量覆盖DEPLOY_API_PORT=9090
DEPLOY_PORT = int(os.environ.get("DEPLOY_API_PORT", "8080"))
def set_env_port(env_path, port=DEPLOY_PORT):
"""将 .env 文件中的 PORT 设为指定值(用于部署包)"""
if not os.path.isfile(env_path):
return
with open(env_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
found = False
new_lines = []
for line in lines:
s = line.strip()
if "=" in s and s.split("=", 1)[0].strip() == "PORT":
new_lines.append("PORT=%s\n" % port)
found = True
else:
new_lines.append(line)
if not found:
new_lines.append("PORT=%s\n" % port)
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
f.writelines(new_lines)
def set_env_mini_program_state(env_path, state):
"""将 .env 中的 WECHAT_MINI_PROGRAM_STATE 设为 developer/formal打包前按环境覆盖"""
if not os.path.isfile(env_path):
return
key = "WECHAT_MINI_PROGRAM_STATE"
with open(env_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
found = False
new_lines = []
for line in lines:
s = line.strip()
if "=" in s and s.split("=", 1)[0].strip() == key:
new_lines.append("%s=%s\n" % (key, state))
found = True
else:
new_lines.append(line)
if not found:
new_lines.append("%s=%s\n" % (key, state))
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
f.writelines(new_lines)
def pack_deploy(root, binary_path, include_env=True):
"""打包二进制和 .env 为 tar.gz"""
print("[2/4] 打包部署文件 ...")
staging = tempfile.mkdtemp(prefix="soul_api_deploy_")
try:
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
env_src = os.path.join(root, ".env.production")
staging_env = os.path.join(staging, ".env")
if include_env and os.path.isfile(env_src):
shutil.copy2(env_src, staging_env)
print(" [已包含] .env.production -> .env")
else:
env_example = os.path.join(root, ".env.example")
if os.path.isfile(env_example):
shutil.copy2(env_example, staging_env)
print(" [已包含] .env.example -> .env (请服务器上检查配置)")
if os.path.isfile(staging_env):
set_env_port(staging_env, DEPLOY_PORT)
set_env_mini_program_state(staging_env, "formal")
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=formal正式环境" % DEPLOY_PORT)
tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz")
with tarfile.open(tarball, "w:gz") as tf:
for name in os.listdir(staging):
tf.add(os.path.join(staging, name), arcname=name)
print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, os.path.getsize(tarball) / 1024 / 1024))
return tarball
except Exception as e:
print(" [失败] 打包异常:", str(e))
return None
finally:
shutil.rmtree(staging, ignore_errors=True)
# ==================== 宝塔 API 重启 ====================
def _bt_signed_post(base_url, key, path, extra_data):
"""单次宝塔签名 POST每请求独立 request_time/token"""
req_time = int(time.time())
sk_md5 = hashlib.md5(key.encode()).hexdigest()
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
data = {"request_time": req_time, "request_token": req_token}
data.update(extra_data or {})
return requests.post(base_url + path, data=data, timeout=20, verify=False)
def _bt_parse_json_response(r):
"""解析面板 JSON部分响应 Content-Type 不准)。"""
if r is None or r.status_code != 200:
return None
ct = (r.headers.get("content-type") or "").lower()
if "json" in ct:
try:
return r.json()
except Exception:
pass
t = (r.text or "").lstrip()
if t.startswith("{"):
try:
return json.loads(r.text)
except Exception:
pass
return None
def _bt_discover_go_site(base, key, want_name):
"""从「网站」列表匹配 project_type=Go 的站点(新版面板 go_project 插件表常为空)。"""
want = (want_name or "").strip()
want_l = want.lower()
if not want:
return None, None
def _scan_rows(rows):
for row in rows:
if not isinstance(row, dict):
continue
if row.get("project_type") != "Go":
continue
n = row.get("name") or row.get("ps") or ""
path = (row.get("path") or "").lower()
rid = row.get("id")
if rid is None:
continue
if n == want or (isinstance(n, str) and n.strip().lower() == want_l):
return str(rid), (n or want)
if "soul-api" in path and (want_l in path or want_l in (n or "").lower()):
return str(rid), (n or want)
return None, None
for search in (want, ""):
r = _bt_signed_post(
base,
key,
"/data?action=getData&table=sites",
{"p": "1", "limit": "500", "search": search, "type": "-1"},
)
j = _bt_parse_json_response(r)
rows = j.get("data") if isinstance(j, dict) else None
if isinstance(rows, list):
hit = _scan_rows(rows)
if hit[0]:
return hit
return None, None
def _bt_restart_go_via_site_api(base, key, site_id, site_name):
"""/site?action=SiteStop / SiteStart与面板「网站」一致"""
print(" [宝塔API] site SiteStop/SiteStart (id=%s) …" % site_id)
j = _bt_parse_json_response(
_bt_signed_post(
base,
key,
"/site?action=SiteStop",
{"id": str(site_id), "name": site_name},
)
)
if not isinstance(j, dict) or j.get("status") is not True:
if isinstance(j, dict) and j.get("msg"):
print(" [宝塔API] SiteStop: %s" % j.get("msg"))
return False
time.sleep(2)
j2 = _bt_parse_json_response(
_bt_signed_post(
base,
key,
"/site?action=SiteStart",
{"id": str(site_id), "name": site_name},
)
)
if isinstance(j2, dict) and j2.get("status") is True:
print(" [成功] 已通过宝塔 API 重启 Go 站点: %s" % site_name)
return True
if isinstance(j2, dict) and j2.get("msg"):
print(" [宝塔API] SiteStart: %s" % j2.get("msg"))
return False
def restart_via_bt_api(cfg):
"""通过宝塔 API 重启:优先网站型 GoSiteStop/SiteStart失败再试 go_project 插件。"""
url = cfg.get("bt_panel_url") or ""
key = cfg.get("bt_api_key") or ""
name = cfg.get("bt_go_project_name", "soulApi")
if not url or not key:
return False
if not requests:
print(" [提示] 未安装 requests无法使用宝塔 API将用 SSH 重启。pip install requests")
return False
try:
base = url.rstrip("/")
site_id_env = (os.environ.get("BT_GO_SITE_ID") or "").strip()
if site_id_env:
sid, snm = site_id_env, name
else:
sid, snm = _bt_discover_go_site(base, key, name)
if sid and _bt_restart_go_via_site_api(base, key, sid, snm or name):
return True
# 兜底go_project 插件(部分旧面板)
for action in ("stop_go_project", "start_go_project"):
j = _bt_parse_json_response(
_bt_signed_post(
base,
key,
"/plugin?name=go_project",
{"action": action, "project_name": name, "name": name},
)
)
if action == "stop_go_project":
time.sleep(2)
if isinstance(j, dict) and j.get("status") is False and j.get("msg"):
print(" [宝塔API] %s: %s" % (action, j.get("msg", "")))
j = _bt_parse_json_response(
_bt_signed_post(
base,
key,
"/plugin?name=go_project",
{"action": "start_go_project", "project_name": name, "name": name},
)
)
if isinstance(j, dict) and j.get("status") is True:
print(" [成功] 已通过宝塔 API 重启 Go 项目(插件): %s" % name)
return True
return False
except Exception as e:
print(" [宝塔API 失败] %s" % str(e))
return False
# ==================== SSH 上传 ====================
def _connect_ssh(cfg):
"""建立 SSH 连接,启用 keepalive 防大文件上传时断连"""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
client.connect(
cfg["host"], port=DEFAULT_SSH_PORT,
username=cfg["user"], key_filename=cfg["ssh_key"],
timeout=15,
)
else:
client.connect(
cfg["host"], port=DEFAULT_SSH_PORT,
username=cfg["user"], password=cfg["password"],
timeout=15,
)
transport = client.get_transport()
if transport:
transport.set_keepalive(15)
return client
def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto"):
"""上传 tar.gz 到服务器并解压、重启"""
print("[3/4] SSH 上传并解压 ...")
if not cfg.get("password") and not cfg.get("ssh_key"):
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return False
remote_tar = "/tmp/soul_api_deploy.tar.gz"
project_path = cfg["project_path"]
client = None
try:
# SFTP 上传易因网络抖动 EOF失败时重连并重试最多 3 次
for attempt in range(1, 4):
try:
if client:
try:
client.close()
except Exception:
pass
client = _connect_ssh(cfg)
sftp = client.open_sftp()
sftp.put(tarball_path, remote_tar)
sftp.close()
break
except (EOFError, ConnectionResetError, OSError) as e:
if attempt < 3:
print(" [重试 %d/3] 上传中断: %s5 秒后重连 ..." % (attempt, e))
time.sleep(5)
else:
raise
cmd = (
"mkdir -p %s && cd %s && tar -xzf %s && "
"chmod +x soul-api && rm -f %s && echo OK"
) % (project_path, project_path, remote_tar, remote_tar)
stdin, stdout, stderr = client.exec_command(cmd, timeout=120)
ex_err = []
def _drain_tar_stderr():
try:
ex_err.append(stderr.read().decode("utf-8", errors="replace"))
except Exception:
ex_err.append("")
t_tar = threading.Thread(target=_drain_tar_stderr)
t_tar.daemon = True
t_tar.start()
out = stdout.read().decode("utf-8", errors="replace").strip()
t_tar.join(timeout=10)
exit_status = stdout.channel.recv_exit_status()
if exit_status != 0 or "OK" not in out:
print(" [失败] 解压失败,退出码:", exit_status)
return False
print(" [成功] 已解压到: %s" % project_path)
if not no_restart:
print("[4/4] 重启 soulApi 服务 ...")
ok = False
if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")):
ok = restart_via_bt_api(cfg)
if not ok and restart_method in ("auto", "ssh"):
# SSH正式环境固定监听 DEPLOY_PORT默认 8080。用 fuser 释放端口,避免宝塔守护
# 启动的进程 cwd 与项目目录不一致导致 pgrep+cwd 校验永远失败。
# 拆成两次 exec先短命令起进程本机 sleep 后再 curl避免单条远程命令+管道偶发拖死 Paramiko。
start_cmd = (
"cd %s && (fuser -k %d/tcp 2>/dev/null || true) && sleep 2 && "
"( setsid nohup ./soul-api >> soul-api.log 2>&1 </dev/null & ) && "
"sleep 1 && echo START_OK"
) % (project_path, DEPLOY_PORT)
stdin, stdout, stderr = client.exec_command(
"timeout 45 bash -c " + shlex.quote(start_cmd),
timeout=55,
get_pty=True,
)
start_out = stdout.read().decode("utf-8", errors="replace").strip()
if "START_OK" not in start_out:
print(" [stderr] 起进程输出: %s" % start_out[:300])
time.sleep(12)
health_cmd = (
"curl -sf --connect-timeout 5 --max-time 15 "
"http://127.0.0.1:%d/health 2>/dev/null | grep -q '\"status\"' "
"&& echo RESTART_OK || echo RESTART_FAIL"
) % DEPLOY_PORT
stdin, stdout, stderr = client.exec_command(
"timeout 25 bash -c " + shlex.quote(health_cmd),
timeout=35,
get_pty=True,
)
out = stdout.read().decode("utf-8", errors="replace").strip()
ok = "RESTART_OK" in out
if ok:
print(" [成功] soulApi 已通过 SSH 重启")
else:
print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path)
if restart_method == "btapi" and not ok:
print(" [失败] 已指定 --restart-method btapi但宝塔 API 重启未成功(请核对 API 白名单含本机出口 IP、BT_GO_PROJECT_NAME/BT_GO_SITE_ID")
return False
else:
print("[4/4] 跳过重启 (--no-restart)")
return True
except Exception as e:
err_msg = str(e) or repr(e) or type(e).__name__
print(" [失败] SSH 错误:", err_msg)
import traceback
traceback.print_exc()
return False
finally:
if client:
try:
client.close()
except Exception:
pass
# ==================== 主函数 ====================
def main():
parser = argparse.ArgumentParser(
description="soulApisoul-api 后端Go 项目一键部署到宝塔",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--no-build", action="store_true", help="跳过本地编译(使用已有 soul-api 二进制)")
parser.add_argument("--no-env", action="store_true", help="不打包 .env保留服务器现有 .env")
parser.add_argument("--no-restart", action="store_true", help="上传后不重启服务")
parser.add_argument(
"--restart-method",
choices=("auto", "btapi", "ssh"),
default="auto",
help="重启方式: auto=先试宝塔API再SSH, btapi=仅宝塔API, ssh=仅SSH (默认 auto)",
)
args = parser.parse_args()
script_dir = os.path.dirname(os.path.abspath(__file__))
root = script_dir
cfg = get_cfg()
print("=" * 60)
print(" soulApisoul-api一键部署到宝塔")
print("=" * 60)
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
print(" 目标目录: %s" % cfg["project_path"])
print("=" * 60)
binary_path = os.path.join(root, "soul-api")
if not args.no_build:
p = run_build(root)
if not p:
return 1
else:
if not os.path.isfile(binary_path):
print("[错误] 未找到 soul-api 二进制,请先编译或去掉 --no-build")
return 1
print("[1/4] 跳过编译,使用现有 soul-api")
tarball = pack_deploy(root, binary_path, include_env=not args.no_env)
if not tarball:
return 1
if not upload_and_extract(cfg, tarball, no_restart=args.no_restart, restart_method=args.restart_method):
return 1
try:
os.remove(tarball)
except Exception:
pass
print("")
print(" 部署完成!目录: %s" % cfg["project_path"])
return 0
if __name__ == "__main__":
sys.exit(main())