更新小程序隐私保护机制,新增手机号一键登录功能,用户需同意隐私协议后方可获取手机号。优化多个页面的登录交互,提升用户体验。调整相关配置以支持新功能。
This commit is contained in:
@@ -7,16 +7,19 @@ soul-api 一键部署到宝塔【测试环境】
|
||||
- 默认:本地 go build → Dockerfile.local 打镜像(--pull=false 不拉 base 镜像)
|
||||
- 首次部署前请本地先拉好:alpine:3.19、redis:7-alpine,后续一律用本地缓存
|
||||
|
||||
两种模式均部署到测试环境 /www/wwwroot/self/soul-dev:
|
||||
三种模式:
|
||||
|
||||
- runner(推荐):容器内红蓝切换,宝塔固定 proxy_pass 到 9001,无需改配置
|
||||
- 使用 network_mode: host,避免 iptables 端口映射问题
|
||||
- 首次:服务器执行 deploy/runner-init.sh 构建并启动容器
|
||||
- 部署:python devloy.py --mode runner
|
||||
|
||||
- docker(默认):本地 go build → Dockerfile.local 打镜像 → 宿主机蓝绿切换
|
||||
- 需宿主机改 Nginx proxy_pass 或宝塔 API
|
||||
|
||||
- binary:Go 二进制 + 宝塔 soulDev 项目,用 .env.development
|
||||
- docker(默认):本地 go build → Dockerfile.local 打镜像 → 蓝绿无缝切换
|
||||
- 镜像内包含:二进制、soul-api/certs/ → /app/certs/、选定环境文件 → /app/.env
|
||||
- 环境文件:--env-file 或 DOCKER_ENV_FILE,否则自动 .env.development > .env.production > .env(.dockerignore 已放行)
|
||||
- 不加 --docker-in-go 时:本地 Go 编译 + 本地 base 镜像,不联网
|
||||
- 加 --docker-in-go 时:在 Docker 内用 golang 镜像编译(需本地已有 golang:1.25)
|
||||
|
||||
环境变量:DEPLOY_DOCKER_PATH、DEPLOY_NGINX_CONF、DEPLOY_HOST 等
|
||||
环境变量:DEPLOY_DOCKER_PATH、DEPLOY_NGINX_CONF、DEPLOY_HOST、DEPLOY_RUNNER_CONTAINER 等
|
||||
"""
|
||||
|
||||
from __future__ import print_function
|
||||
@@ -65,12 +68,14 @@ def get_cfg():
|
||||
bt_url = (os.environ.get("BT_PANEL_URL") or "").strip().rstrip("/")
|
||||
if not bt_url:
|
||||
bt_url = "https://%s:9988" % host
|
||||
deploy_path = os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH).rstrip("/")
|
||||
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),
|
||||
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH).rstrip("/"),
|
||||
"deploy_path": deploy_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", "soulDev"),
|
||||
@@ -171,6 +176,49 @@ def set_env_mini_program_state(env_path, state):
|
||||
f.writelines(new_lines)
|
||||
|
||||
|
||||
def set_env_key(env_path, key, value):
|
||||
"""将 .env 中指定 key 设为 value"""
|
||||
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() == key:
|
||||
new_lines.append("%s=%s\n" % (key, value))
|
||||
found = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
if not found:
|
||||
new_lines.append("%s=%s\n" % (key, value))
|
||||
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
|
||||
def set_env_redis_url(env_path, url):
|
||||
"""将 .env 中的 REDIS_URL 设为指定值"""
|
||||
if not os.path.isfile(env_path):
|
||||
return
|
||||
key = "REDIS_URL"
|
||||
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, url))
|
||||
found = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
if not found:
|
||||
new_lines.append("%s=%s\n" % (key, url))
|
||||
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
|
||||
def resolve_binary_pack_env_src(root):
|
||||
"""binary 模式 tar 包内 .env 的来源,与 Docker 自动优先级一致。"""
|
||||
for name in (".env.development", ".env.production", ".env"):
|
||||
@@ -180,6 +228,52 @@ def resolve_binary_pack_env_src(root):
|
||||
return None, None
|
||||
|
||||
|
||||
def pack_runner_deploy(root, binary_path, include_env=True):
|
||||
"""打包 Runner 部署包:二进制 + .env + certs,供容器内红蓝切换"""
|
||||
print("[2/4] 打包 Runner 部署包 ...")
|
||||
staging = tempfile.mkdtemp(prefix="soul_api_runner_deploy_")
|
||||
try:
|
||||
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
|
||||
staging_env = os.path.join(staging, ".env")
|
||||
if include_env:
|
||||
env_src, env_label = resolve_binary_pack_env_src(root)
|
||||
if env_src:
|
||||
shutil.copy2(env_src, staging_env)
|
||||
print(" [已包含] %s -> .env" % env_label)
|
||||
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, 18081)
|
||||
set_env_redis_url(staging_env, "redis://:soul-docker-redis@127.0.0.1:6379/0")
|
||||
set_env_mini_program_state(staging_env, "developer")
|
||||
set_env_key(staging_env, "UPLOAD_DIR", "/app/uploads")
|
||||
print(" [已设置] PORT=18081, REDIS_URL, UPLOAD_DIR=/app/uploads, WECHAT_MINI_PROGRAM_STATE=developer")
|
||||
certs_src = os.path.join(root, "certs")
|
||||
if os.path.isdir(certs_src):
|
||||
certs_dst = os.path.join(staging, "certs")
|
||||
os.makedirs(certs_dst, exist_ok=True)
|
||||
for f in os.listdir(certs_src):
|
||||
src = os.path.join(certs_src, f)
|
||||
if os.path.isfile(src):
|
||||
shutil.copy2(src, os.path.join(certs_dst, f))
|
||||
print(" [已包含] certs/")
|
||||
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):
|
||||
p = os.path.join(staging, name)
|
||||
tf.add(p, 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)
|
||||
|
||||
|
||||
def pack_deploy(root, binary_path, include_env=True):
|
||||
"""打包二进制和 .env 为 tar.gz"""
|
||||
print("[2/4] 打包部署文件 ...")
|
||||
@@ -277,8 +371,8 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
else:
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
|
||||
sftp = client.open_sftp()
|
||||
remote_tar = "/tmp/soul_api_deploy.tar.gz"
|
||||
project_path = cfg["project_path"]
|
||||
remote_tar = project_path + "/soul_api_deploy.tar.gz"
|
||||
sftp.put(tarball_path, remote_tar)
|
||||
sftp.close()
|
||||
|
||||
@@ -524,7 +618,8 @@ def pack_docker_image(root):
|
||||
|
||||
def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_method="ssh"):
|
||||
"""上传镜像与配置到服务器,执行蓝绿部署。deploy_method: ssh=脚本内 Nginx 切换, btapi=宝塔 API 更新 Nginx"""
|
||||
deploy_path = os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH)
|
||||
deploy_path = cfg.get("deploy_path") or os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH)
|
||||
deploy_path = deploy_path.rstrip("/")
|
||||
nginx_conf = os.environ.get("DEPLOY_NGINX_CONF", DEPLOY_NGINX_CONF)
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
@@ -542,7 +637,7 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=30)
|
||||
sftp = client.open_sftp()
|
||||
|
||||
remote_tar = "/tmp/soul_api_image.tar.gz"
|
||||
remote_tar = deploy_path + "/soul_api_image.tar.gz"
|
||||
sftp.put(image_tar_path, remote_tar)
|
||||
print(" [已上传] 镜像 tar.gz")
|
||||
|
||||
@@ -611,13 +706,157 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho
|
||||
client.close()
|
||||
|
||||
|
||||
# ==================== Runner 部署(容器内红蓝切换) ====================
|
||||
|
||||
RUNNER_CONTAINER = os.environ.get("DEPLOY_RUNNER_CONTAINER", "soul-api-runner")
|
||||
CHUNK_SIZE = 65536
|
||||
|
||||
|
||||
def _ssh_connect(cfg, timeout=30):
|
||||
"""建立 SSH 连接"""
|
||||
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=timeout)
|
||||
else:
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=timeout)
|
||||
return client
|
||||
|
||||
|
||||
def deploy_runner_container(cfg):
|
||||
"""推送 Runner 容器到服务器:本地构建镜像 → 上传 → docker load → compose up"""
|
||||
print("=" * 60)
|
||||
print(" soul-api Runner 容器推送(首次或更新容器)")
|
||||
print("=" * 60)
|
||||
deploy_path = cfg.get("deploy_path") or os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH)
|
||||
deploy_path = deploy_path.rstrip("/")
|
||||
root = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
print("[1/4] 本地构建 Runner 镜像 ...")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "build", "-f", "deploy/Dockerfile.runner", "-t", "soul-api-runner:latest", "."],
|
||||
cwd=root, shell=False, timeout=120, capture_output=True, text=True, encoding="utf-8", errors="replace"
|
||||
)
|
||||
if r.returncode != 0:
|
||||
print(" [失败] docker build 失败:", (r.stderr or "")[-500:])
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
print(" [失败] 未找到 docker 命令")
|
||||
return False
|
||||
|
||||
print("[2/4] 导出镜像为 tar.gz ...")
|
||||
import gzip
|
||||
img_tar = os.path.join(tempfile.gettempdir(), "soul_runner_image.tar.gz")
|
||||
try:
|
||||
r = subprocess.run(["docker", "save", "soul-api-runner:latest"], capture_output=True, timeout=180, cwd=root)
|
||||
if r.returncode != 0:
|
||||
print(" [失败] docker save 失败")
|
||||
return False
|
||||
with gzip.open(img_tar, "wb") as f:
|
||||
f.write(r.stdout)
|
||||
except Exception as e:
|
||||
print(" [失败] 导出异常:", str(e))
|
||||
return False
|
||||
|
||||
print("[3/4] SSH 上传镜像并加载 ...")
|
||||
client = None
|
||||
try:
|
||||
client = _ssh_connect(cfg, timeout=60)
|
||||
sftp = client.open_sftp()
|
||||
remote_img = deploy_path + "/soul_runner_image.tar.gz"
|
||||
sftp.put(img_tar, remote_img)
|
||||
sftp.close()
|
||||
os.remove(img_tar)
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
compose_standalone = os.path.join(script_dir, "deploy", "docker-compose.runner.standalone.yml")
|
||||
sftp = client.open_sftp()
|
||||
sftp.put(compose_standalone, deploy_path + "/docker-compose.runner.standalone.yml")
|
||||
sftp.close()
|
||||
cmd = (
|
||||
"mkdir -p %s && cd %s && gunzip -c soul_runner_image.tar.gz | docker load && "
|
||||
"docker compose -f docker-compose.runner.standalone.yml up -d && "
|
||||
"rm -f soul_runner_image.tar.gz && echo OK"
|
||||
) % (deploy_path, deploy_path)
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=300)
|
||||
out = stdout.read().decode("utf-8", errors="replace")
|
||||
err = stderr.read().decode("utf-8", errors="replace")
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
print(out)
|
||||
if err:
|
||||
print(err[:500])
|
||||
if exit_status != 0 or "OK" not in out:
|
||||
print(" [失败] 远程执行退出码:", exit_status)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误:", str(e))
|
||||
return False
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
print("[4/4] Runner 容器已启动")
|
||||
print(" 宝塔 proxy_pass 保持 127.0.0.1:9001")
|
||||
return True
|
||||
|
||||
|
||||
def upload_and_deploy_runner(cfg, tarball_path):
|
||||
"""将部署包通过 SSH 管道直接传入容器,宿主机不落盘,防止机密泄露"""
|
||||
print("[3/4] 管道直传容器(宿主机不落盘)...")
|
||||
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||||
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
||||
return False
|
||||
client = None
|
||||
try:
|
||||
client = _ssh_connect(cfg, timeout=60)
|
||||
file_size = os.path.getsize(tarball_path)
|
||||
print(" 传输 %.2f MB 到容器 /tmp/incoming.tar.gz ..." % (file_size / 1024 / 1024))
|
||||
stdin, stdout, stderr = client.exec_command(
|
||||
"docker exec -i %s sh -c 'cat > /tmp/incoming.tar.gz'" % RUNNER_CONTAINER,
|
||||
timeout=300,
|
||||
)
|
||||
with open(tarball_path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
stdin.write(chunk)
|
||||
stdin.channel.shutdown_write()
|
||||
stdout.channel.recv_exit_status()
|
||||
err = stderr.read().decode("utf-8", errors="replace")
|
||||
if err and "Error" in err:
|
||||
print(" [失败] 管道写入异常:", err[:300])
|
||||
return False
|
||||
print(" [已传入容器] 执行红蓝切换 ...")
|
||||
stdin, stdout, stderr = client.exec_command(
|
||||
"docker exec %s /app/deploy.sh /tmp/incoming.tar.gz" % RUNNER_CONTAINER,
|
||||
timeout=180,
|
||||
)
|
||||
out = stdout.read().decode("utf-8", errors="replace")
|
||||
err = stderr.read().decode("utf-8", errors="replace")
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
print(out)
|
||||
if err:
|
||||
print(err[:800])
|
||||
if exit_status != 0:
|
||||
print(" [失败] 远程部署退出码:", exit_status)
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误: %s" % str(e))
|
||||
return False
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="soul-api 测试环境一键部署到宝塔")
|
||||
parser.add_argument("--mode", choices=("binary", "docker"), default="docker",
|
||||
help="docker=Docker 蓝绿部署 (默认), binary=Go 二进制")
|
||||
parser.add_argument("--mode", choices=("binary", "docker", "runner", "start"), default="runner",
|
||||
help="runner=仅上传代码(默认), start=容器+代码, docker=Docker蓝绿, binary=Go二进制")
|
||||
parser.add_argument("--no-build", action="store_true", help="跳过本地编译/构建")
|
||||
parser.add_argument("--no-env", action="store_true",
|
||||
help="binary: 不打进 tar;docker: 不上传服务器目录 .env.production(镜像内配置不变)")
|
||||
@@ -630,12 +869,52 @@ def main():
|
||||
help="[docker] 部署方式: ssh=脚本内 Nginx 切换, btapi=宝塔 API 更新 Nginx 配置并重载 (默认 ssh)")
|
||||
parser.add_argument("--env-file", default=None, metavar="NAME",
|
||||
help="[docker] 打入镜像的环境文件名(默认自动:.env.development > .env.production > .env)")
|
||||
parser.add_argument("--init-runner", action="store_true",
|
||||
help="[runner] 等同于 --mode start,先推送容器再部署代码")
|
||||
args = parser.parse_args()
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
root = script_dir
|
||||
cfg = get_cfg()
|
||||
|
||||
# start = 容器+代码;runner = 仅代码
|
||||
init_runner = args.init_runner or (args.mode == "start")
|
||||
if args.mode == "start":
|
||||
args.mode = "runner"
|
||||
|
||||
if args.mode == "runner":
|
||||
print("=" * 60)
|
||||
print(" soul-api Runner 模式(容器内红蓝切换,宝塔固定 9001)")
|
||||
print("=" * 60)
|
||||
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||||
print(" 容器: %s" % RUNNER_CONTAINER)
|
||||
print("=" * 60)
|
||||
if init_runner:
|
||||
if not deploy_runner_container(cfg):
|
||||
return 1
|
||||
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 二进制")
|
||||
return 1
|
||||
print("[1/4] 跳过编译")
|
||||
tarball = pack_runner_deploy(root, binary_path, include_env=not args.no_env)
|
||||
if not tarball:
|
||||
return 1
|
||||
if not upload_and_deploy_runner(cfg, tarball):
|
||||
return 1
|
||||
try:
|
||||
os.remove(tarball)
|
||||
except Exception:
|
||||
pass
|
||||
print("")
|
||||
print(" 部署完成!宝塔代理 9001 无需修改")
|
||||
return 0
|
||||
|
||||
if args.mode == "docker":
|
||||
docker_path = os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH)
|
||||
print("=" * 60)
|
||||
|
||||
Reference in New Issue
Block a user