今日新增修改:解决好友自动推送问题
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
"""
|
||||
兼容入口:默认走 soul-admin-dev 配置档。
|
||||
|
||||
实际部署逻辑复用 master.py,避免与正式环境脚本长期双份维护。
|
||||
部署逻辑在 master.py:全程宝塔面板 API(上传 zip、解压、MvFile 切换 dist),
|
||||
与 soul-api/master.py 的 BT_PANEL_URL / BT_API_KEY 环境变量一致。
|
||||
"""
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
@@ -1,32 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
soul-admin 静态站点部署(统一主入口)
|
||||
soul-admin 静态站点部署(统一主入口,全程宝塔面板 API,无需 SSH)
|
||||
|
||||
流程:本地构建 dist -> 上传 zip -> 服务器解压到 dist2 -> dist/dist2 无缝切换。
|
||||
默认 profile=prod(正式环境);deploy.py 作为 dev 包装入口调用本脚本。
|
||||
流程与 soul-api/master.py 对齐:
|
||||
探活(GetDiskInfo) → 本地 pnpm 构建 dist → 打包 zip → /files?action=upload
|
||||
→ /files?action=UnZip 到 dist2 → MvFile 切换 dist/dist2(等价原 dist→dist1→删)→ DeleteFile 删包
|
||||
|
||||
环境变量(与 soul-api 共用宝塔相关项):
|
||||
BT_PANEL_URL、BT_API_KEY、BT_UPLOAD_CHUNK_MB(可选,默认 4)
|
||||
DEPLOY_HOST(未设 BT_PANEL_URL 时默认 https://{DEPLOY_HOST}:9988)
|
||||
DEPLOY_BASE_PATH / DEPLOY_BASE_PATH_PROD / DEPLOY_BASE_PATH_DEV
|
||||
|
||||
deploy.py 作为 dev 包装入口:default_profile=dev。
|
||||
"""
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
import requests
|
||||
|
||||
try:
|
||||
import urllib3
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
except Exception:
|
||||
pass
|
||||
except ImportError:
|
||||
print("错误: 请先安装 paramiko")
|
||||
print(" pip install paramiko")
|
||||
sys.exit(1)
|
||||
requests = None
|
||||
|
||||
|
||||
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
|
||||
DEFAULT_WWW_USER = "www:www"
|
||||
# 与 soul-api/master.py 一致,便于同一套面板密钥
|
||||
BT_API_KEY_DEFAULT = "qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT"
|
||||
|
||||
PROFILE_PRESETS = {
|
||||
"prod": {
|
||||
@@ -67,20 +81,22 @@ def _resolve_base_path(profile, cli_base_path=None):
|
||||
|
||||
def get_cfg(profile, cli_base_path=None):
|
||||
base = _resolve_base_path(profile, cli_base_path=cli_base_path)
|
||||
www_user = (os.environ.get("DEPLOY_WWW_USER") or DEFAULT_WWW_USER).strip()
|
||||
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 {
|
||||
"profile": profile,
|
||||
"title": PROFILE_PRESETS[profile]["title"],
|
||||
"build_cmd": list(PROFILE_PRESETS[profile]["build_cmd"]),
|
||||
"build_desc": PROFILE_PRESETS[profile]["build_desc"],
|
||||
"host": os.environ.get("DEPLOY_HOST", "43.139.27.93"),
|
||||
"user": os.environ.get("DEPLOY_USER", "root"),
|
||||
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
|
||||
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
|
||||
"host": host,
|
||||
"base_path": base,
|
||||
"dist_path": base + "/dist",
|
||||
"dist2_path": base + "/dist2",
|
||||
"www_user": www_user,
|
||||
"dist1_path": base + "/dist1",
|
||||
"bt_panel_url": bt_url,
|
||||
"bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT),
|
||||
}
|
||||
|
||||
|
||||
@@ -91,30 +107,6 @@ def resolve_project_root():
|
||||
return os.path.dirname(script_dir)
|
||||
|
||||
|
||||
def connect_ssh(cfg, timeout=30):
|
||||
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,
|
||||
banner_timeout=timeout,
|
||||
)
|
||||
else:
|
||||
client.connect(
|
||||
cfg["host"],
|
||||
port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"],
|
||||
password=cfg["password"],
|
||||
timeout=timeout,
|
||||
banner_timeout=timeout,
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
def ensure_dist_ready(root):
|
||||
return os.path.isdir(os.path.join(root, "dist")) and os.path.isfile(
|
||||
os.path.join(root, "dist", "index.html")
|
||||
@@ -122,7 +114,7 @@ def ensure_dist_ready(root):
|
||||
|
||||
|
||||
def run_build(root, cfg):
|
||||
print("[1/4] 本地构建 %s ..." % cfg["build_desc"])
|
||||
print("[1/5] 本地构建 %s ..." % cfg["build_desc"])
|
||||
use_shell = sys.platform == "win32"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
@@ -162,7 +154,7 @@ def run_build(root, cfg):
|
||||
|
||||
|
||||
def pack_dist_zip(root, profile):
|
||||
print("[2/4] 打包 dist 为 zip ...")
|
||||
print("[2/5] 打包 dist 为 zip ...")
|
||||
dist_dir = os.path.join(root, "dist")
|
||||
if not os.path.isdir(dist_dir):
|
||||
print(" [失败] 未找到 dist 目录")
|
||||
@@ -192,101 +184,335 @@ def pack_dist_zip(root, profile):
|
||||
return None
|
||||
|
||||
|
||||
def upload_zip_and_extract_to_dist2(cfg, zip_path):
|
||||
print("[3/4] SSH 上传 zip 并解压到 dist2 ...")
|
||||
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||||
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
||||
return False
|
||||
# ---------- 宝塔 API(与 soul-api/master.py 同源精简) ----------
|
||||
|
||||
zip_size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||||
remote_zip = cfg["base_path"] + "/soul_admin_deploy.zip"
|
||||
client = None
|
||||
|
||||
def _bt_signed_post(base_url, key, path, extra_data, timeout=20):
|
||||
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=timeout, verify=False)
|
||||
|
||||
|
||||
def _bt_parse_json_response(r):
|
||||
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_panel_ping(cfg):
|
||||
if not requests:
|
||||
return True
|
||||
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||
key = cfg.get("bt_api_key") or ""
|
||||
if not url or not key:
|
||||
print("[宝塔API 探活] 缺少 BT_PANEL_URL 或 BT_API_KEY,已跳过")
|
||||
return True
|
||||
req_time = int(time.time())
|
||||
sk_md5 = hashlib.md5(key.encode()).hexdigest()
|
||||
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
|
||||
try:
|
||||
print(" 正在连接 %s@%s:%s ..." % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||||
client = connect_ssh(cfg, timeout=30)
|
||||
print(" [OK] SSH 已连接,正在上传 zip(%.1f MB)..." % zip_size_mb)
|
||||
sftp = client.open_sftp()
|
||||
sftp.put(zip_path, remote_zip)
|
||||
sftp.close()
|
||||
|
||||
dist2 = cfg["dist2_path"]
|
||||
cmd = (
|
||||
"rm -rf {dist2} && mkdir -p {dist2} && "
|
||||
"unzip -o -q {remote_zip} -d {dist2} && rm -f {remote_zip} && echo OK"
|
||||
).format(
|
||||
dist2=shlex.quote(dist2),
|
||||
remote_zip=shlex.quote(remote_zip),
|
||||
r = requests.post(
|
||||
url + "/system?action=GetDiskInfo",
|
||||
data={"request_time": req_time, "request_token": req_token},
|
||||
timeout=20,
|
||||
verify=False,
|
||||
)
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=120)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
if err:
|
||||
print(" 服务器 stderr: %s" % err[:500])
|
||||
if exit_status != 0 or "OK" not in out:
|
||||
print(" [失败] 解压失败,退出码: %s" % exit_status)
|
||||
if out:
|
||||
print(" stdout: %s" % out[:300])
|
||||
if r.status_code != 200:
|
||||
print("[宝塔API 探活] HTTP %s" % r.status_code)
|
||||
return False
|
||||
print(" [成功] 已解压到: %s" % dist2)
|
||||
ct = (r.headers.get("content-type") or "").lower()
|
||||
j = {}
|
||||
if "json" in ct:
|
||||
try:
|
||||
j = r.json()
|
||||
except Exception:
|
||||
j = {}
|
||||
else:
|
||||
t = (r.text or "").lstrip()
|
||||
if t.startswith("{"):
|
||||
try:
|
||||
j = json.loads(r.text)
|
||||
except Exception:
|
||||
j = {}
|
||||
if isinstance(j, dict) and j.get("status") is False:
|
||||
print("[宝塔API 探活] %s" % (j.get("msg") or j))
|
||||
return False
|
||||
print("[宝塔API 探活] 成功(GetDiskInfo)", flush=True)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误: %s" % str(e))
|
||||
print("[宝塔API 探活] 异常: %s" % e)
|
||||
return False
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
|
||||
def remote_swap_dist(cfg):
|
||||
print("[4/4] 服务器切换目录: dist→dist1, dist2→dist ...")
|
||||
client = None
|
||||
def _bt_upload_headers():
|
||||
return {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
|
||||
def _bt_parse_upload_response(r):
|
||||
if r is None or r.status_code != 200:
|
||||
return "err", None, "HTTP %s" % (r.status_code if r else "?")
|
||||
raw = (r.text or "").strip()
|
||||
if raw.isdigit():
|
||||
return "progress", int(raw), None
|
||||
j = _bt_parse_json_response(r)
|
||||
if isinstance(j, dict):
|
||||
if j.get("status") is True:
|
||||
return "ok", None, None
|
||||
return "err", None, j.get("msg") or str(j)
|
||||
return "err", None, raw[:300] if raw else "empty body"
|
||||
|
||||
|
||||
def bt_upload_file_resumable(cfg, local_path, remote_dir, remote_name):
|
||||
if not requests:
|
||||
print(" [失败] 需要 requests:pip install requests")
|
||||
return False
|
||||
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||
key = cfg.get("bt_api_key") or ""
|
||||
if not url or not key:
|
||||
print(" [失败] 缺少 BT_PANEL_URL 或 BT_API_KEY")
|
||||
return False
|
||||
total = os.path.getsize(local_path)
|
||||
chunk_mb = int(os.environ.get("BT_UPLOAD_CHUNK_MB", "4"))
|
||||
chunk_size = max(1, chunk_mb) * 1024 * 1024
|
||||
f_start = 0
|
||||
last_start = -1
|
||||
upload_url = url + "/files?action=upload"
|
||||
headers = _bt_upload_headers()
|
||||
print(
|
||||
" [宝塔API] 上传 %s → %s/%s(共 %.2f MB,每块 %d MB)…"
|
||||
% (
|
||||
os.path.basename(local_path),
|
||||
remote_dir,
|
||||
remote_name,
|
||||
total / 1024 / 1024,
|
||||
chunk_mb,
|
||||
)
|
||||
)
|
||||
try:
|
||||
client = connect_ssh(cfg, timeout=15)
|
||||
base = shlex.quote(cfg["base_path"])
|
||||
cmd = (
|
||||
"cd {base} && "
|
||||
"(test -d dist && (mv dist dist1 && mv dist2 dist && rm -rf dist1) || mv dist2 dist) "
|
||||
"&& echo OK"
|
||||
).format(base=base)
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
if exit_status != 0 or "OK" not in out:
|
||||
print(" [失败] 切换失败 (退出码: %s)" % exit_status)
|
||||
if err:
|
||||
print(" 服务器 stderr: %s" % err[:300])
|
||||
if out and "OK" not in out:
|
||||
print(" 服务器 stdout: %s" % out[:300])
|
||||
return False
|
||||
|
||||
print(" [成功] 新版本已切换至: %s" % cfg["dist_path"])
|
||||
www_user = cfg.get("www_user")
|
||||
if www_user:
|
||||
chown_cmd = "chown -R {user} {dist} && echo OK".format(
|
||||
user=shlex.quote(www_user),
|
||||
dist=shlex.quote(cfg["dist_path"]),
|
||||
)
|
||||
stdin, stdout, stderr = client.exec_command(chown_cmd, timeout=60)
|
||||
chown_out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
chown_err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||
if stdout.channel.recv_exit_status() != 0 or "OK" not in chown_out:
|
||||
print(" [警告] chown 失败,站点可能无法访问: %s" % (chown_err or chown_out))
|
||||
else:
|
||||
print(" [成功] 已设置属主: %s" % www_user)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误: %s" % str(e))
|
||||
with open(local_path, "rb") as fp:
|
||||
while f_start < total:
|
||||
if f_start == last_start:
|
||||
print(" [失败] 上传停滞 offset=%s(请删服务器上同名 .upload.tmp 后重试)" % f_start)
|
||||
return False
|
||||
last_start = f_start
|
||||
fp.seek(f_start)
|
||||
buf = fp.read(min(chunk_size, total - f_start))
|
||||
if not buf:
|
||||
print(" [失败] 读取本地文件失败 offset=%s" % f_start)
|
||||
return False
|
||||
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": str(req_time),
|
||||
"request_token": req_token,
|
||||
"f_path": remote_dir,
|
||||
"f_name": remote_name,
|
||||
"f_size": str(total),
|
||||
"f_start": str(f_start),
|
||||
}
|
||||
files = {"blob": ("blob", buf, "application/octet-stream")}
|
||||
r = requests.post(
|
||||
upload_url,
|
||||
data=data,
|
||||
files=files,
|
||||
headers=headers,
|
||||
timeout=600,
|
||||
verify=False,
|
||||
)
|
||||
kind, prog, err = _bt_parse_upload_response(r)
|
||||
if kind == "ok":
|
||||
print(" [成功] 宝塔上传完成")
|
||||
return True
|
||||
if kind == "progress":
|
||||
if prog <= f_start:
|
||||
print(" [失败] 上传偏移未前进: server=%s local=%s" % (prog, f_start))
|
||||
return False
|
||||
f_start = prog
|
||||
continue
|
||||
print(" [失败] 上传接口: %s" % (err or kind))
|
||||
return False
|
||||
print(" [失败] 上传未正常结束(offset=%s size=%s)" % (f_start, total))
|
||||
return False
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
except Exception as e:
|
||||
print(" [失败] 上传异常: %s" % e)
|
||||
return False
|
||||
|
||||
|
||||
def bt_delete_file(cfg, remote_path):
|
||||
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||
key = cfg.get("bt_api_key") or ""
|
||||
if not url or not key or not remote_path:
|
||||
return False
|
||||
j = _bt_parse_json_response(
|
||||
_bt_signed_post(url, key, "/files?action=DeleteFile", {"path": remote_path})
|
||||
)
|
||||
if isinstance(j, dict) and j.get("status") is True:
|
||||
return True
|
||||
if isinstance(j, dict) and j.get("msg"):
|
||||
print(" [宝塔API] DeleteFile: %s" % j.get("msg"))
|
||||
return False
|
||||
|
||||
|
||||
def bt_delete_dir(cfg, remote_dir):
|
||||
"""递归删除目录(不存在时可能返回失败,调用方可忽略)。"""
|
||||
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||
key = cfg.get("bt_api_key") or ""
|
||||
if not url or not key or not remote_dir:
|
||||
return False
|
||||
j = _bt_parse_json_response(
|
||||
_bt_signed_post(
|
||||
url,
|
||||
key,
|
||||
"/files?action=DeleteDir",
|
||||
{"path": remote_dir},
|
||||
timeout=120,
|
||||
)
|
||||
)
|
||||
if isinstance(j, dict) and j.get("status") is True:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def bt_create_dir(cfg, path):
|
||||
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||
key = cfg.get("bt_api_key") or ""
|
||||
if not url or not key or not path:
|
||||
return False
|
||||
j = _bt_parse_json_response(
|
||||
_bt_signed_post(url, key, "/files?action=CreateDir", {"path": path})
|
||||
)
|
||||
if isinstance(j, dict) and j.get("status") is True:
|
||||
return True
|
||||
msg = (j.get("msg") if isinstance(j, dict) else "") or ""
|
||||
if msg and ("已存在" in msg or "exist" in msg.lower()):
|
||||
return True
|
||||
if isinstance(j, dict) and j.get("msg"):
|
||||
print(" [宝塔API] CreateDir: %s" % j.get("msg"))
|
||||
return False
|
||||
|
||||
|
||||
def bt_mv_file(cfg, sfile, dfile):
|
||||
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||
key = cfg.get("bt_api_key") or ""
|
||||
if not url or not key:
|
||||
return False, "no url/key"
|
||||
j = _bt_parse_json_response(
|
||||
_bt_signed_post(
|
||||
url,
|
||||
key,
|
||||
"/files?action=MvFile",
|
||||
{"sfile": sfile, "dfile": dfile},
|
||||
timeout=120,
|
||||
)
|
||||
)
|
||||
if isinstance(j, dict) and j.get("status") is True:
|
||||
return True, None
|
||||
msg = j.get("msg") if isinstance(j, dict) else str(j)
|
||||
return False, msg
|
||||
|
||||
|
||||
def bt_unzip_remote_zip(cfg, sfile, dfile):
|
||||
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||
key = cfg.get("bt_api_key") or ""
|
||||
coding = "UTF-8"
|
||||
for type1 in ("zip", "ZIP"):
|
||||
j = _bt_parse_json_response(
|
||||
_bt_signed_post(
|
||||
url,
|
||||
key,
|
||||
"/files?action=UnZip",
|
||||
{"sfile": sfile, "dfile": dfile, "type1": type1, "coding": coding},
|
||||
timeout=300,
|
||||
)
|
||||
)
|
||||
if isinstance(j, dict) and j.get("status") is True:
|
||||
print(" [成功] 宝塔解压完成 (type1=%s)" % type1)
|
||||
return True
|
||||
if isinstance(j, dict) and j.get("msg"):
|
||||
print(" [宝塔API] UnZip type1=%s: %s" % (type1, j.get("msg")))
|
||||
return False
|
||||
|
||||
|
||||
def deploy_via_btapi(cfg, zip_path):
|
||||
print("[3/5] 宝塔 API:准备 dist2、上传、解压 …")
|
||||
if not requests:
|
||||
print(" [失败] 需要 requests:pip install requests")
|
||||
return False
|
||||
|
||||
base = cfg["base_path"].rstrip("/")
|
||||
dist = cfg["dist_path"].rstrip("/")
|
||||
dist2 = cfg["dist2_path"].rstrip("/")
|
||||
dist1 = cfg["dist1_path"].rstrip("/")
|
||||
remote_zip_name = "soul_admin_deploy.zip"
|
||||
remote_zip = base + "/" + remote_zip_name
|
||||
dfile_dist2 = dist2 + "/"
|
||||
|
||||
bt_delete_dir(cfg, dist1)
|
||||
bt_delete_dir(cfg, dist2)
|
||||
if not bt_create_dir(cfg, dist2):
|
||||
print(" [失败] 无法创建 dist2: %s" % dist2)
|
||||
return False
|
||||
|
||||
if not bt_upload_file_resumable(cfg, zip_path, base, remote_zip_name):
|
||||
return False
|
||||
|
||||
print(" [宝塔API] 解压 %s → %s …" % (remote_zip, dfile_dist2))
|
||||
if not bt_unzip_remote_zip(cfg, remote_zip, dfile_dist2):
|
||||
print(" [提示] 解压失败时可检查面板「文件」权限与压缩包格式")
|
||||
return False
|
||||
|
||||
if bt_delete_file(cfg, remote_zip):
|
||||
print(" [已清理] 远程 %s" % remote_zip)
|
||||
else:
|
||||
print(" [提示] 未能删除远程压缩包,可在面板手动删: %s" % remote_zip)
|
||||
|
||||
print("[4/5] 宝塔 API:目录切换(等价原 dist→dist1→dist2→dist)…")
|
||||
bt_delete_dir(cfg, dist1)
|
||||
|
||||
ok_move_old, err_old = bt_mv_file(cfg, dist, dist1)
|
||||
if not ok_move_old:
|
||||
if err_old and isinstance(err_old, str):
|
||||
low = err_old.lower()
|
||||
if "不存在" not in err_old and "not exist" not in low and "不存在" not in low:
|
||||
print(" [宝塔API] 移动 dist→dist1: %s" % err_old)
|
||||
|
||||
ok_swap, err_swap = bt_mv_file(cfg, dist2, dist)
|
||||
if not ok_swap:
|
||||
print(" [失败] 将 dist2 切换为 dist 失败: %s" % err_swap)
|
||||
print(" [提示] 可到面板「文件」手动把 %s 改名为 dist" % dist2)
|
||||
return False
|
||||
|
||||
if ok_move_old:
|
||||
if not bt_delete_dir(cfg, dist1):
|
||||
print(" [警告] 未能删除临时目录 dist1: %s(可面板手动删)" % dist1)
|
||||
|
||||
print(" [成功] 新版本已生效: %s" % dist)
|
||||
print("[5/5] 完成(纯 API;若 403 请在面板核对站点目录属主 www)")
|
||||
return True
|
||||
|
||||
|
||||
def create_parser(default_profile):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="soul-admin 静态站点部署(dist2 解压后目录切换,无缝更新)",
|
||||
description="soul-admin 静态站点部署(宝塔 API:上传 zip、解压、目录切换)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -301,6 +527,11 @@ def create_parser(default_profile):
|
||||
help="覆盖部署目录(默认按 profile 取 DEPLOY_BASE_PATH 或预设路径)",
|
||||
)
|
||||
parser.add_argument("--no-build", action="store_true", help="跳过本地构建,直接上传现有 dist")
|
||||
parser.add_argument(
|
||||
"--skip-bt-ping",
|
||||
action="store_true",
|
||||
help="跳过宝塔面板 API 探活(GetDiskInfo)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -314,13 +545,19 @@ def main(argv=None, default_profile="prod"):
|
||||
cfg = get_cfg(profile=profile, cli_base_path=(args.base_path or "").strip())
|
||||
|
||||
print("=" * 60)
|
||||
print(" %s 部署(dist/dist2 无缝切换)" % cfg["title"])
|
||||
print(" %s 部署(宝塔 API)" % cfg["title"])
|
||||
print("=" * 60)
|
||||
print(" profile: %s" % cfg["profile"])
|
||||
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||||
print(" 站点目录: %s" % cfg["dist_path"])
|
||||
print(" 面板: %s" % cfg["bt_panel_url"])
|
||||
print(" 站点根: %s" % cfg["base_path"])
|
||||
print(" 静态目录: %s" % cfg["dist_path"])
|
||||
print("=" * 60)
|
||||
|
||||
if not args.skip_bt_ping:
|
||||
if not bt_panel_ping(cfg):
|
||||
print("[失败] 宝塔 API 探活未通过(可用 --skip-bt-ping 跳过)")
|
||||
return 1
|
||||
|
||||
if not args.no_build:
|
||||
if not run_build(root, cfg):
|
||||
return 1
|
||||
@@ -328,14 +565,14 @@ def main(argv=None, default_profile="prod"):
|
||||
if not ensure_dist_ready(root):
|
||||
print("[错误] 未找到 dist/index.html,请先执行构建或去掉 --no-build")
|
||||
return 1
|
||||
print("[1/4] 跳过本地构建")
|
||||
print("[1/5] 跳过本地构建")
|
||||
|
||||
zip_path = pack_dist_zip(root, profile=profile)
|
||||
if not zip_path:
|
||||
return 1
|
||||
|
||||
try:
|
||||
if not upload_zip_and_extract_to_dist2(cfg, zip_path):
|
||||
if not deploy_via_btapi(cfg, zip_path):
|
||||
return 1
|
||||
finally:
|
||||
try:
|
||||
@@ -344,9 +581,6 @@ def main(argv=None, default_profile="prod"):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not remote_swap_dist(cfg):
|
||||
return 1
|
||||
|
||||
print("")
|
||||
print(" 部署完成!站点目录: %s" % cfg["dist_path"])
|
||||
return 0
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/leads/ckbleadspanel.tsx","./src/components/modules/mbti/mbtiavatarsmanager.tsx","./src/components/modules/user/memberuserselect.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/ckbworkbench.ts","./src/lib/mbtiavatarprompts.ts","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordsbytype.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/open-platform/openplatformdocstab.tsx","./src/pages/open-platform/openplatformkeystab.tsx","./src/pages/open-platform/openplatformlogstab.tsx","./src/pages/open-platform/openplatformpage.tsx","./src/pages/open-platform/openplatformdocdefinitions.ts","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/mpuipopuptablesection.tsx","./src/pages/settings/settingspage.tsx","./src/pages/settings/mpuicopyconfig.ts","./src/pages/site/sitepage.tsx","./src/pages/users/homeentryconfigtab.tsx","./src/pages/users/superindividualtab.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/leads/ckbleadspanel.tsx","./src/components/modules/mbti/mbtiavatarsmanager.tsx","./src/components/modules/user/memberuserselect.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/ckbleadtypelabels.ts","./src/lib/ckbworkbench.ts","./src/lib/mbtiavatarprompts.ts","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordsbytype.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/open-platform/openplatformdocstab.tsx","./src/pages/open-platform/openplatformkeystab.tsx","./src/pages/open-platform/openplatformlogstab.tsx","./src/pages/open-platform/openplatformpage.tsx","./src/pages/open-platform/openplatformdocdefinitions.ts","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/mpuipopuptablesection.tsx","./src/pages/settings/settingspage.tsx","./src/pages/settings/mpuicopyconfig.ts","./src/pages/site/sitepage.tsx","./src/pages/users/homeentryconfigtab.tsx","./src/pages/users/superindividualtab.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}
|
||||
@@ -287,6 +287,7 @@ func pushLeadToCKB(name, phone, wechatId, leadKey string) (ckbLeadPushResult, er
|
||||
}
|
||||
q.Set("sign", params["sign"].(string))
|
||||
reqURL := ckbAPIURL + "?" + q.Encode()
|
||||
fmt.Printf("[存客宝 scenarios] 请求 URL(GET): %s\n", reqURL)
|
||||
resp, err := http.Get(reqURL)
|
||||
if err != nil {
|
||||
return ckbLeadPushResult{}, err
|
||||
@@ -327,6 +328,55 @@ func resolvePersonForLead(db *gorm.DB, targetUserID string) (model.Person, bool)
|
||||
return model.Person{}, false
|
||||
}
|
||||
|
||||
// resolveLeadPlanAPIKeyForRetry 与 GET /api/db/ckb-leads?mode=contact 中 planApiKey 展示顺序一致:
|
||||
// 1) ckb_lead_records.plan_api_key
|
||||
// 2) action/source 默认(join/match → 内置 ckbAPIKey;lead+index_link_button → getCkbLeadApiKey)
|
||||
// 3) 仍为空:按 target_person_id 或 params.targetUserId 查人物 persons.ckb_api_key
|
||||
// 4) index_link 且无 target:与列表一致,用「全局 leadKey 对应 Person」兜底
|
||||
// 5) 最后才 getCkbLeadApiKey()
|
||||
//
|
||||
// 旧逻辑在 plan_api_key 为空时直接 getCkbLeadApiKey(),且非 index_link 时用人物 key 无条件覆盖 —
|
||||
// 会导致:列表因人物兜底显示 2y4v5…,重推却带全局 IPQ9s…;或在库内已有 plan_api_key 时仍被人物旧 key 覆盖。
|
||||
func resolveLeadPlanAPIKeyForRetry(db *gorm.DB, r model.CkbLeadRecord, p map[string]interface{}) string {
|
||||
planKey := strings.TrimSpace(r.PlanAPIKey)
|
||||
action := strings.TrimSpace(r.Action)
|
||||
source := strings.TrimSpace(r.Source)
|
||||
|
||||
if planKey == "" {
|
||||
if action == "join" || action == "match" {
|
||||
planKey = ckbAPIKey
|
||||
} else if action == "lead" && source == "index_link_button" {
|
||||
planKey = getCkbLeadApiKey()
|
||||
}
|
||||
}
|
||||
if planKey == "" {
|
||||
targetTok := strings.TrimSpace(r.TargetPersonID)
|
||||
if targetTok == "" && p != nil {
|
||||
if v, ok := p["targetUserId"].(string); ok {
|
||||
targetTok = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if targetTok != "" {
|
||||
if person, found := resolvePersonForLead(db, targetTok); found && strings.TrimSpace(person.CkbApiKey) != "" {
|
||||
planKey = strings.TrimSpace(person.CkbApiKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
if planKey == "" && source == "index_link_button" && strings.TrimSpace(r.TargetPersonID) == "" {
|
||||
gk := getCkbLeadApiKey()
|
||||
if gk != "" {
|
||||
var fp model.Person
|
||||
if db.Where("ckb_api_key = ? AND ckb_api_key != ''", gk).First(&fp).Error == nil && strings.TrimSpace(fp.CkbApiKey) != "" {
|
||||
planKey = strings.TrimSpace(fp.CkbApiKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
if planKey == "" {
|
||||
planKey = getCkbLeadApiKey()
|
||||
}
|
||||
return planKey
|
||||
}
|
||||
|
||||
// existsUnifiedLeadRecent join/match 幂等去重:同用户+动作+来源+联系方式在窗口期内仅保留一条,避免重复点击刷数据
|
||||
func existsUnifiedLeadRecent(db *gorm.DB, action, userID, source, phone, wechatID string, within time.Duration) bool {
|
||||
if db == nil {
|
||||
@@ -391,10 +441,14 @@ func retryOneLeadRecord(ctx context.Context, db *gorm.DB, r model.CkbLeadRecord)
|
||||
wechatId = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
leadKey := strings.TrimSpace(r.PlanAPIKey)
|
||||
if leadKey == "" {
|
||||
leadKey = getCkbLeadApiKey()
|
||||
if wechatId == "" && p != nil {
|
||||
if v, ok := p["wechat"].(string); ok {
|
||||
wechatId = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
|
||||
leadKey := resolveLeadPlanAPIKeyForRetry(db, r, p)
|
||||
|
||||
targetName := ""
|
||||
targetMemberID := ""
|
||||
targetMemberName := ""
|
||||
@@ -402,16 +456,23 @@ func retryOneLeadRecord(ctx context.Context, db *gorm.DB, r model.CkbLeadRecord)
|
||||
if v, ok := p["userId"].(string); ok && leadUserID == "" {
|
||||
leadUserID = strings.TrimSpace(v)
|
||||
}
|
||||
if source != "index_link_button" {
|
||||
if v, ok := p["targetUserId"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
if person, found := resolvePersonForLead(db, v); found && strings.TrimSpace(person.CkbApiKey) != "" {
|
||||
leadKey = strings.TrimSpace(person.CkbApiKey)
|
||||
targetTok := strings.TrimSpace(r.TargetPersonID)
|
||||
if targetTok == "" && p != nil {
|
||||
if v, ok := p["targetUserId"].(string); ok {
|
||||
targetTok = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if targetTok != "" {
|
||||
if person, found := resolvePersonForLead(db, targetTok); found {
|
||||
if strings.TrimSpace(person.Name) != "" {
|
||||
targetName = strings.TrimSpace(person.Name)
|
||||
if person.UserID != nil {
|
||||
targetMemberID = strings.TrimSpace(*person.UserID)
|
||||
}
|
||||
}
|
||||
if person.UserID != nil {
|
||||
targetMemberID = strings.TrimSpace(*person.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if p != nil {
|
||||
if v, ok := p["targetNickname"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
targetName = strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -13,6 +15,54 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// jsonValAsTrimmedString 从 JSON map 取值(兼容 number / string),用于 params 里手机号等字段
|
||||
func jsonValAsTrimmedString(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
case float64:
|
||||
if t == 0 {
|
||||
return ""
|
||||
}
|
||||
if t == float64(int64(t)) {
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
}
|
||||
return strings.TrimSpace(strconv.FormatFloat(t, 'f', -1, 64))
|
||||
case json.Number:
|
||||
return strings.TrimSpace(string(t))
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
|
||||
// extractPhoneWechatFromParamsJSON 从线索 params JSON 抽取联系方式(加入/匹配上报结构可能只用顶层 phone/wechat)
|
||||
func extractPhoneWechatFromParamsJSON(paramsJSON string) (phone, wechat string) {
|
||||
s := strings.TrimSpace(paramsJSON)
|
||||
if s == "" {
|
||||
return "", ""
|
||||
}
|
||||
var p map[string]interface{}
|
||||
if json.Unmarshal([]byte(s), &p) != nil {
|
||||
return "", ""
|
||||
}
|
||||
for _, k := range []string{"phone", "mobile", "tel"} {
|
||||
if v := jsonValAsTrimmedString(p[k]); v != "" {
|
||||
phone = v
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"wechat", "wechatId", "wechat_id", "wx", "wxId"} {
|
||||
if v := jsonValAsTrimmedString(p[k]); v != "" {
|
||||
wechat = v
|
||||
break
|
||||
}
|
||||
}
|
||||
return phone, wechat
|
||||
}
|
||||
|
||||
// DBCKBLeadList GET /api/db/ckb-leads 管理端-CKB线索明细
|
||||
// mode=submitted: ckb_lead_records(action=join/match,兼容旧面板命名)
|
||||
// mode=contact: ckb_lead_records(链接卡若留资,有 phone/wechat)
|
||||
@@ -232,22 +282,22 @@ func DBCKBLeadList(c *gin.Context) {
|
||||
for _, r := range records {
|
||||
phone := strings.TrimSpace(r.Phone)
|
||||
wechatID := strings.TrimSpace(r.WechatID)
|
||||
// 工作台 join/match 行曾只读 row.phone;老数据或仅写在 params 里的,回退解析便于运营核对
|
||||
if (phone == "" || wechatID == "") && strings.TrimSpace(r.Params) != "" {
|
||||
var p map[string]interface{}
|
||||
if json.Unmarshal([]byte(r.Params), &p) == nil {
|
||||
if phone == "" {
|
||||
if v, ok := p["phone"].(string); ok {
|
||||
phone = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if wechatID == "" {
|
||||
if v, ok := p["wechat"].(string); ok {
|
||||
wechatID = strings.TrimSpace(v)
|
||||
} else if v, ok := p["wechatId"].(string); ok {
|
||||
wechatID = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if phone == "" || wechatID == "" {
|
||||
pp, ww := extractPhoneWechatFromParamsJSON(r.Params)
|
||||
if phone == "" {
|
||||
phone = pp
|
||||
}
|
||||
if wechatID == "" {
|
||||
wechatID = ww
|
||||
}
|
||||
}
|
||||
// 仍为空:按 user_id 回填会员资料(autoCKBReport 历史误写、或库内列为空但用户已补全资料)
|
||||
if u := userMap[r.UserID]; u != nil {
|
||||
if phone == "" && u.Phone != nil {
|
||||
phone = strings.TrimSpace(*u.Phone)
|
||||
}
|
||||
if wechatID == "" && u.WechatID != nil {
|
||||
wechatID = strings.TrimSpace(*u.WechatID)
|
||||
}
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
@@ -265,13 +315,48 @@ func DBCKBLeadList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
|
||||
}
|
||||
|
||||
func logCkbLeadRetryResponse(resp gin.H) {
|
||||
b, err := json.MarshalIndent(resp, "", " ")
|
||||
if err != nil {
|
||||
fmt.Printf("[DBCKBLeadRetry] response(marshal err=%v): %+v\n", err, resp)
|
||||
return
|
||||
}
|
||||
fmt.Printf("[DBCKBLeadRetry] response JSON:\n%s\n", string(b))
|
||||
}
|
||||
|
||||
func logCkbLeadRetryRequest(raw []byte, readErr error) {
|
||||
if readErr != nil {
|
||||
fmt.Printf("[DBCKBLeadRetry] request body read error: %v\n", readErr)
|
||||
return
|
||||
}
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" {
|
||||
fmt.Printf("[DBCKBLeadRetry] request body: (empty)\n")
|
||||
return
|
||||
}
|
||||
if json.Valid([]byte(s)) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.Indent(&buf, []byte(s), "", " "); err == nil {
|
||||
fmt.Printf("[DBCKBLeadRetry] request JSON:\n%s\n", buf.String())
|
||||
return
|
||||
}
|
||||
}
|
||||
fmt.Printf("[DBCKBLeadRetry] request body: %s\n", s)
|
||||
}
|
||||
|
||||
// DBCKBLeadRetry POST /api/db/ckb-leads/retry 管理端-手动重推单条失败线索
|
||||
func DBCKBLeadRetry(c *gin.Context) {
|
||||
raw, readErr := io.ReadAll(c.Request.Body)
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(raw))
|
||||
logCkbLeadRetryRequest(raw, readErr)
|
||||
|
||||
var body struct {
|
||||
ID int64 `json:"id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.ID <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少有效 id"})
|
||||
resp := gin.H{"success": false, "error": "缺少有效 id"}
|
||||
logCkbLeadRetryResponse(resp)
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
ok, err := RetryCkbLeadByID(c.Request.Context(), body.ID)
|
||||
@@ -280,16 +365,20 @@ func DBCKBLeadRetry(c *gin.Context) {
|
||||
if msg == "" {
|
||||
msg = "重推失败"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": msg})
|
||||
resp := gin.H{"success": false, "error": msg}
|
||||
logCkbLeadRetryResponse(resp)
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var r model.CkbLeadRecord
|
||||
if err := db.Where("id = ?", body.ID).First(&r).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "pushed": ok})
|
||||
resp := gin.H{"success": true, "pushed": ok}
|
||||
logCkbLeadRetryResponse(resp)
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
resp := gin.H{
|
||||
"success": true,
|
||||
"pushed": ok,
|
||||
"record": gin.H{
|
||||
@@ -303,7 +392,9 @@ func DBCKBLeadRetry(c *gin.Context) {
|
||||
"lastPushAt": r.LastPushAt,
|
||||
"nextRetryAt": r.NextRetryAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
logCkbLeadRetryResponse(resp)
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// DBCKBLeadDelete POST /api/db/ckb-leads/delete 管理端-删除一条留资记录(运营清理误报/测试数据)
|
||||
|
||||
@@ -403,8 +403,17 @@ func MatchUsers(c *gin.Context) {
|
||||
fmt.Printf("[MatchUsers] 写入 match_records 失败: %v\n", err)
|
||||
}
|
||||
// 后端兜底:匹配成功自动上报 CKB(小程序也会上报,CKBMatch 内部有 5 分钟去重)
|
||||
// 必须用「发起人」联系方式写入 ckb_lead_records;误用被匹配人 r 会导致 user_id 与手机号不一致或整列为空
|
||||
go func() {
|
||||
autoCKBReport(db, body.UserID, body.MatchType, phone, wechat)
|
||||
initPhone := strings.TrimSpace(body.Phone)
|
||||
initWechat := strings.TrimSpace(body.WechatID)
|
||||
if initPhone == "" && user.Phone != nil {
|
||||
initPhone = strings.TrimSpace(*user.Phone)
|
||||
}
|
||||
if initWechat == "" && user.WechatID != nil {
|
||||
initWechat = strings.TrimSpace(*user.WechatID)
|
||||
}
|
||||
autoCKBReport(db, body.UserID, body.MatchType, initPhone, initWechat)
|
||||
}()
|
||||
commonInterests := buildCommonInterests(user, r)
|
||||
dynamicTags := buildDynamicTags(r, tag)
|
||||
|
||||
Reference in New Issue
Block a user