diff --git a/soul-admin/deploy.py b/soul-admin/deploy.py index e9101487..2c605431 100644 --- a/soul-admin/deploy.py +++ b/soul-admin/deploy.py @@ -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 diff --git a/soul-admin/master.py b/soul-admin/master.py index 3d9761de..7582ef3a 100644 --- a/soul-admin/master.py +++ b/soul-admin/master.py @@ -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 diff --git a/soul-admin/tsconfig.tsbuildinfo b/soul-admin/tsconfig.tsbuildinfo index e226e2b3..ff18d555 100644 --- a/soul-admin/tsconfig.tsbuildinfo +++ b/soul-admin/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/soul-api/internal/handler/ckb.go b/soul-api/internal/handler/ckb.go index 0d99ea1b..6e3752e3 100644 --- a/soul-api/internal/handler/ckb.go +++ b/soul-api/internal/handler/ckb.go @@ -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) } diff --git a/soul-api/internal/handler/db_ckb_leads.go b/soul-api/internal/handler/db_ckb_leads.go index e9ba767e..b71d934e 100644 --- a/soul-api/internal/handler/db_ckb_leads.go +++ b/soul-api/internal/handler/db_ckb_leads.go @@ -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 管理端-删除一条留资记录(运营清理误报/测试数据) diff --git a/soul-api/internal/handler/match.go b/soul-api/internal/handler/match.go index 5e51c00f..40696253 100644 --- a/soul-api/internal/handler/match.go +++ b/soul-api/internal/handler/match.go @@ -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)