]*>([\s\S]*?)<\/li>/gi, function (_, inner) {
var cleaned = inner.replace(/]*>/gi, '').replace(/<\/p>/gi, '').trim()
if (!cleaned) return '\n'
- // 列表项内仅有视频占位时,提升为独立视频块(避免 __VIDEO_n__ 当正文展示)
- var onlyVid = cleaned.match(/^__VIDEO_(\d+)__$/)
- if (onlyVid) {
- return '\n__VIDEO_' + onlyVid[1] + '__\n'
- }
if (olDepth > 0) {
olCounter++
return '\n__LI_O_' + olCounter + '__ ' + cleaned + '\n'
@@ -316,13 +348,13 @@ function parseHtmlToSegments(html, config) {
continue
}
- // video
+ // video(整块占位)
var vidM = block.trim().match(/^__VIDEO_(\d+)__$/)
if (vidM) {
- var vid = videos[parseInt(vidM[1], 10)]
- if (vid && vid.src) {
+ var v = videos[parseInt(vidM[1], 10)]
+ if (v && v.src) {
lines.push('')
- segments.push([{ type: 'video', src: vid.src }])
+ segments.push([{ type: 'video', src: v.src }])
}
continue
}
@@ -380,7 +412,10 @@ function parseHtmlToSegments(html, config) {
var blockSegs = parseBlockToSegments(block, config)
if (!blockSegs.length) continue
- if (blockSegs.length === 1 && blockSegs[0].type === 'image') {
+ if (
+ blockSegs.length === 1 &&
+ (blockSegs[0].type === 'image' || blockSegs[0].type === 'video')
+ ) {
lines.push('')
segments.push(blockSegs)
continue
diff --git a/miniprogram/utils/linkedMiniprogramNavigate.js b/miniprogram/utils/linkedMiniprogramNavigate.js
new file mode 100644
index 00000000..20922bff
--- /dev/null
+++ b/miniprogram/utils/linkedMiniprogramNavigate.js
@@ -0,0 +1,191 @@
+/**
+ * Soul 创业派对 - 关联小程序跳转(与阅读页 onLinkTagTap miniprogram 分支一致)
+ */
+const app = getApp()
+
+function normalizeLinkTagLabel(raw) {
+ return String(raw || '')
+ .replace(/^[##\s\u00a0\u200b\u3000]+/u, '')
+ .replace(/[\s\u00a0\u200b\u3000]+$/u, '')
+ .trim()
+ .toLowerCase()
+}
+
+function resolveLinkTagByLabel(label) {
+ const normalized = normalizeLinkTagLabel(label)
+ if (!normalized) return null
+ const tags = Array.isArray(app.globalData.linkTagsConfig) ? app.globalData.linkTagsConfig : []
+ for (const t of tags) {
+ if (!t) continue
+ const candidates = [t.label]
+ if (typeof t.aliases === 'string' && t.aliases.trim()) {
+ candidates.push(...t.aliases.split(','))
+ }
+ for (const c of candidates) {
+ if (normalizeLinkTagLabel(c) === normalized) return t
+ }
+ }
+ return null
+}
+
+function pickLinkTagField(tag, keys, defaultValue = '') {
+ if (!tag || typeof tag !== 'object') return defaultValue
+ for (const key of keys) {
+ const v = tag[key]
+ if (v == null) continue
+ const s = String(v).trim()
+ if (s) return s
+ }
+ return defaultValue
+}
+
+function parseDatasetBool(v) {
+ if (typeof v === 'boolean') return v
+ const s = String(v || '').trim().toLowerCase()
+ return s === '1' || s === 'true' || s === 'yes' || s === 'on'
+}
+
+function normalizeQueryKey(raw, defaultKey = 'phone') {
+ const s = String(raw || '').trim()
+ if (!s) return defaultKey
+ if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(s)) return defaultKey
+ return s
+}
+
+/** 跳转其他小程序时在 path 上追加/覆盖 query(保留已有 ?…&…,避免重复同名 key) */
+function appendQueryToPath(path, key, value) {
+ if (value == null || value === '') return (path || '').trim()
+ const base = (path || '').trim()
+ const hashIdx = base.indexOf('#')
+ const hashPart = hashIdx >= 0 ? base.slice(hashIdx) : ''
+ const noHash = hashIdx >= 0 ? base.slice(0, hashIdx) : base
+ const qIdx = noHash.indexOf('?')
+ const pathname = qIdx >= 0 ? noHash.slice(0, qIdx) : noHash
+ const query = qIdx >= 0 ? noHash.slice(qIdx + 1) : ''
+ const params = []
+ if (query) {
+ query.split('&').forEach((pair) => {
+ if (!pair) return
+ const [k = '', v = ''] = pair.split('=')
+ const dk = decodeURIComponent(k || '')
+ if (dk && dk !== key) params.push(`${encodeURIComponent(dk)}=${v}`)
+ })
+ }
+ params.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
+ return `${pathname}${params.length ? '?' + params.join('&') : ''}${hashPart}`
+}
+
+function getLoggedInUserPhone() {
+ const u = app.globalData.userInfo || {}
+ return (u.phone || u.phoneNumber || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
+}
+
+async function ensureLoggedInUserPhone() {
+ let phone = getLoggedInUserPhone()
+ if (phone) return phone
+ try {
+ const profileRes = await app.request({ url: '/api/miniprogram/user/profile', silent: true, timeout: 3000 })
+ const p = (profileRes && profileRes.data) ? profileRes.data : {}
+ phone = String(p.phone || p.phoneNumber || '').trim().replace(/\s/g, '')
+ if (phone) {
+ const merged = { ...(app.globalData.userInfo || {}), ...p, phone }
+ app.globalData.userInfo = merged
+ try {
+ wx.setStorageSync('userInfo', merged)
+ wx.setStorageSync('user_phone', phone)
+ } catch (_) {}
+ }
+ } catch (e) {}
+ return phone
+}
+
+function resolveMiniprogramLinkFromTag(tag) {
+ if (!tag || typeof tag !== 'object') return null
+ const tagType = pickLinkTagField(tag, ['type', 'tagType'], '').toLowerCase()
+ if (tagType !== 'miniprogram') return null
+ const mpKey = pickLinkTagField(tag, ['mpKey', 'mp_key', 'appId', 'app_id'], '')
+ const pagePath = pickLinkTagField(tag, ['pagePath', 'page_path'], '')
+ let passPhone = true
+ const hasCachedPassPhone = !(tag.passPhone == null || tag.passPhone === '')
+ if (hasCachedPassPhone) passPhone = parseDatasetBool(tag.passPhone)
+ const phoneParamName = normalizeQueryKey(tag.phoneParamName, 'phone')
+ return { mpKey, pagePath, passPhone, phoneParamName }
+}
+
+/**
+ * @param {{ mpKey: string, pagePath?: string, passPhone?: boolean, phoneParamName?: string }} opts
+ */
+async function navigateLinkedMiniprogram(opts) {
+ const mpKey = String(opts.mpKey || '').trim()
+ if (!mpKey) {
+ wx.showToast({ title: '未配置小程序', icon: 'none' })
+ return
+ }
+ await app.getReadExtras()
+ const linked = (app.globalData.linkedMiniprograms || []).find((m) => m.key === mpKey)
+ if (!linked || !linked.appId) {
+ wx.showToast({ title: '未找到关联小程序配置', icon: 'none' })
+ return
+ }
+ let targetPath = (opts.pagePath != null && String(opts.pagePath).trim())
+ ? String(opts.pagePath).trim()
+ : (linked.path || '')
+ const phone = await ensureLoggedInUserPhone()
+ const passPhone = opts.passPhone !== false
+ const phoneParamName = normalizeQueryKey(opts.phoneParamName, 'phone')
+ if (passPhone && phone) {
+ targetPath = appendQueryToPath(targetPath, phoneParamName, phone)
+ }
+ wx.navigateToMiniProgram({
+ appId: linked.appId,
+ path: targetPath || '',
+ envVersion: 'release',
+ success: () => {},
+ fail: (err) => {
+ wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' })
+ },
+ })
+}
+
+async function navigateMiniprogramFromLinkTagConfig(tag) {
+ const r = resolveMiniprogramLinkFromTag(tag)
+ if (!r || !r.mpKey) return false
+ await navigateLinkedMiniprogram(r)
+ return true
+}
+
+/**
+ * @param {Record} myPageUi mp_config.mpUi.myPage
+ */
+function findMbtiLinkTagInConfig(myPageUi) {
+ const tags = Array.isArray(app.globalData.linkTagsConfig) ? app.globalData.linkTagsConfig : []
+ const preferred = myPageUi && String(myPageUi.mbtiLinkLabel || '').trim()
+ if (preferred) {
+ const t = resolveLinkTagByLabel(preferred)
+ if (t) {
+ const r = resolveMiniprogramLinkFromTag(t)
+ if (r && r.mpKey) return t
+ }
+ }
+ for (const t of tags) {
+ const r = resolveMiniprogramLinkFromTag(t)
+ if (!r || !r.mpKey) continue
+ const lbl = pickLinkTagField(t, ['label'], '').toLowerCase()
+ const key = String(r.mpKey || '').toLowerCase()
+ const path = String(r.pagePath || '').toLowerCase()
+ if (lbl.includes('mbti') || key.includes('mbti') || path.includes('mbti')) return t
+ }
+ return null
+}
+
+module.exports = {
+ appendQueryToPath,
+ getLoggedInUserPhone,
+ ensureLoggedInUserPhone,
+ navigateLinkedMiniprogram,
+ navigateMiniprogramFromLinkTagConfig,
+ findMbtiLinkTagInConfig,
+ resolveLinkTagByLabel,
+ resolveMiniprogramLinkFromTag,
+ normalizeQueryKey,
+}
diff --git a/soul-admin/deploy.py b/soul-admin/deploy.py
index 85dc79ab..e9101487 100644
--- a/soul-admin/deploy.py
+++ b/soul-admin/deploy.py
@@ -1,318 +1,17 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
-soul-admin-dev 静态站点部署:打包 dist → 上传 → 解压到 dist2 → dist/dist2 互换实现无缝切换。
-不安装依赖、不重启、不调用宝塔 API。
+兼容入口:默认走 soul-admin-dev 配置档。
+
+实际部署逻辑复用 master.py,避免与正式环境脚本长期双份维护。
"""
from __future__ import print_function
-import os
import sys
-import shlex
-import tempfile
-import argparse
-import zipfile
-try:
- import paramiko
-except ImportError:
- print("错误: 请先安装 paramiko")
- print(" pip install paramiko")
- sys.exit(1)
-
-# ==================== 配置 ====================
-
-# 站点根目录(Nginx 等指向的目录的上一级,即 dist 的父目录)
-DEPLOY_BASE_PATH = "/www/wwwroot/self/soul-admin-dev"
-# 切换后 chown 的属主,宝塔一般为 www:www,空则跳过
-DEPLOY_WWW_USER = os.environ.get("DEPLOY_WWW_USER", "www:www")
-DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
-
-
-def get_cfg():
- base = os.environ.get("DEPLOY_BASE_PATH", DEPLOY_BASE_PATH).rstrip("/")
- return {
- "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", ""),
- "base_path": base,
- "dist_path": base + "/dist",
- "dist2_path": base + "/dist2",
- "www_user": os.environ.get("DEPLOY_WWW_USER", DEPLOY_WWW_USER).strip(),
- }
-
-
-# ==================== 本地构建 ====================
-
-
-def run_build(root):
- """执行本地 pnpm build(使用 .env.development 测试环境配置)"""
- use_shell = sys.platform == "win32"
- dist_dir = os.path.join(root, "dist")
- index_html = os.path.join(dist_dir, "index.html")
-
- try:
- r = __import__("subprocess").run(
- ["pnpm", "run", "build:dev"],
- cwd=root,
- shell=use_shell,
- timeout=300,
- capture_output=True,
- text=True,
- encoding="utf-8",
- errors="replace",
- )
- if r.returncode != 0:
- print(" [失败] 构建失败,退出码:", r.returncode)
- for line in (r.stdout or "").strip().split("\n")[-10:]:
- print(" " + line)
- return False
- except __import__("subprocess").TimeoutExpired:
- print(" [失败] 构建超时")
- return False
- except FileNotFoundError:
- print(" [失败] 未找到 pnpm,请安装: npm install -g pnpm")
- return False
- except Exception as e:
- print(" [失败] 构建异常:", str(e))
- return False
-
- if not os.path.isfile(index_html):
- print(" [失败] 未找到 dist/index.html")
- return False
- print(" [成功] 构建完成")
- return True
-
-
-# ==================== 打包 dist 为 zip ====================
-
-
-def pack_dist_zip(root):
- """将本地 dist 目录打包为 zip(解压到 dist2 后即为站点根内容)"""
- print("[2/4] 打包 dist 为 zip ...")
- dist_dir = os.path.join(root, "dist")
- if not os.path.isdir(dist_dir):
- print(" [失败] 未找到 dist 目录")
- return None
- index_html = os.path.join(dist_dir, "index.html")
- if not os.path.isfile(index_html):
- print(" [失败] 未找到 dist/index.html,请先执行 pnpm build")
- return None
-
- zip_path = os.path.join(tempfile.gettempdir(), "soul_admin_deploy.zip")
- try:
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
- for dirpath, _dirs, filenames in os.walk(dist_dir):
- for f in filenames:
- full = os.path.join(dirpath, f)
- arcname = os.path.relpath(full, dist_dir).replace("\\", "/")
- zf.write(full, arcname)
- print(" [成功] 打包完成: %s (%.2f MB)" % (zip_path, os.path.getsize(zip_path) / 1024 / 1024))
- return zip_path
- except Exception as e:
- print(" [失败] 打包异常:", str(e))
- return None
-
-
-# ==================== SSH 上传并解压到 dist2 ====================
-
-
-def upload_zip_and_extract_to_dist2(cfg, zip_path):
- """上传 zip 到服务器并解压到 dist2"""
- print("[3/4] SSH 上传 zip 并解压到 dist2 ...")
- sys.stdout.flush()
- if not cfg.get("password") and not cfg.get("ssh_key"):
- print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
- return False
- zip_size_mb = os.path.getsize(zip_path) / (1024 * 1024)
- client = paramiko.SSHClient()
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
- try:
- print(" 正在连接 %s@%s:%s ..." % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
- sys.stdout.flush()
- 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=30,
- banner_timeout=30,
- )
- else:
- client.connect(
- cfg["host"],
- port=DEFAULT_SSH_PORT,
- username=cfg["user"],
- password=cfg["password"],
- timeout=30,
- banner_timeout=30,
- )
- print(" [OK] SSH 已连接,正在上传 zip(%.1f MB)..." % zip_size_mb)
- sys.stdout.flush()
- remote_zip = cfg["base_path"] + "/soul_admin_deploy.zip"
- sftp = client.open_sftp()
- chunk_mb = 5.0
- last_reported = [0]
-
- def _progress(transferred, total):
- if total and total > 0:
- now_mb = transferred / (1024 * 1024)
- if now_mb - last_reported[0] >= chunk_mb or transferred >= total:
- last_reported[0] = now_mb
- print("\r 上传进度: %.1f / %.1f MB" % (now_mb, total / (1024 * 1024)), end="")
- sys.stdout.flush()
-
- sftp.put(zip_path, remote_zip, callback=_progress)
- if zip_size_mb >= chunk_mb:
- print("")
- print(" [OK] zip 已上传,正在服务器解压到 dist2 ...")
- sys.stdout.flush()
- sftp.close()
- dist2 = cfg["dist2_path"]
- cmd = "rm -rf %s && mkdir -p %s && unzip -o -q %s -d %s && rm -f %s && echo OK" % (
- dist2,
- dist2,
- remote_zip,
- dist2,
- remote_zip,
- )
- 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()
- if err:
- print(" 服务器 stderr: %s" % err[:500])
- exit_status = stdout.channel.recv_exit_status()
- if exit_status != 0 or "OK" not in out:
- print(" [失败] 解压失败,退出码: %s" % exit_status)
- if out:
- print(" stdout: %s" % out[:300])
- return False
- print(" [成功] 已解压到: %s" % dist2)
- return True
- except Exception as e:
- print(" [失败] SSH 错误: %s" % str(e))
- import traceback
- traceback.print_exc()
- return False
- finally:
- client.close()
-
-
-# ==================== 服务器目录切换:dist → dist1,dist2 → dist ====================
-
-
-def remote_swap_dist(cfg):
- """服务器上:dist→dist1,dist2→dist,删除 dist1,实现无缝切换"""
- print("[4/4] 服务器切换目录: dist→dist1, dist2→dist ...")
- client = paramiko.SSHClient()
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
- try:
- 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,
- )
- base = cfg["base_path"]
- # 若当前没有 dist(首次部署),则 dist2 直接改名为 dist;若有 dist 则先备份再替换
- cmd = "cd %s && (test -d dist && (mv dist dist1 && mv dist2 dist && rm -rf dist1) || mv dist2 dist) && echo OK" % 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)
- if out and "OK" not in out:
- print(" 服务器 stdout: %s" % out)
- return False
- print(" [成功] 新版本已切换至: %s" % cfg["dist_path"])
- # 切换后设置 www 访问权限,否则 Nginx 无法读文件导致无法访问
- www_user = cfg.get("www_user")
- if www_user:
- dist_path = cfg["dist_path"]
- chown_cmd = "chown -R %s %s && echo OK" % (www_user, shlex.quote(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))
- return False
- finally:
- client.close()
-
-
-# ==================== 主函数 ====================
-
-
-def main():
- parser = argparse.ArgumentParser(
- description="soul-admin-dev 静态站点部署(dist2 解压后目录切换,无缝更新)",
- formatter_class=argparse.RawDescriptionHelpFormatter,
- epilog="不安装依赖、不重启、不调用宝塔 API。站点路径: " + DEPLOY_BASE_PATH + "/dist",
- )
- parser.add_argument("--no-build", action="store_true", help="跳过本地 pnpm build")
- args = parser.parse_args()
-
- script_dir = os.path.dirname(os.path.abspath(__file__))
- if os.path.isfile(os.path.join(script_dir, "package.json")):
- root = script_dir
- else:
- root = os.path.dirname(script_dir)
-
- cfg = get_cfg()
- print("=" * 60)
- print(" soul-admin-dev 部署(dist/dist2 无缝切换)")
- print("=" * 60)
- print(" 服务器: %s@%s 站点目录: %s" % (cfg["user"], cfg["host"], cfg["dist_path"]))
- print("=" * 60)
-
- if not args.no_build:
- print("[1/4] 本地构建 pnpm build ...")
- if not run_build(root):
- return 1
- else:
- if not os.path.isdir(os.path.join(root, "dist")) or not os.path.isfile(
- os.path.join(root, "dist", "index.html")
- ):
- print("[错误] 未找到 dist/index.html,请先执行 pnpm build 或去掉 --no-build")
- return 1
- print("[1/4] 跳过本地构建")
-
- zip_path = pack_dist_zip(root)
- if not zip_path:
- return 1
- if not upload_zip_and_extract_to_dist2(cfg, zip_path):
- return 1
- try:
- os.remove(zip_path)
- except Exception:
- pass
- if not remote_swap_dist(cfg):
- return 1
- print("")
- print(" 部署完成!站点目录: %s" % cfg["dist_path"])
- return 0
+from master import main as master_main
if __name__ == "__main__":
- sys.exit(main())
+ sys.exit(master_main(default_profile="dev"))
diff --git a/soul-admin/master.py b/soul-admin/master.py
index c6995820..3d9761de 100644
--- a/soul-admin/master.py
+++ b/soul-admin/master.py
@@ -1,17 +1,20 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
-soul-admin 静态站点部署:打包 dist → 上传 → 解压到 dist2 → dist/dist2 互换实现无缝切换。
-不安装依赖、不重启、不调用宝塔 API。
+soul-admin 静态站点部署(统一主入口)
+
+流程:本地构建 dist -> 上传 zip -> 服务器解压到 dist2 -> dist/dist2 无缝切换。
+默认 profile=prod(正式环境);deploy.py 作为 dev 包装入口调用本脚本。
"""
from __future__ import print_function
-import os
-import sys
-import shlex
-import tempfile
import argparse
+import os
+import shlex
+import subprocess
+import sys
+import tempfile
import zipfile
try:
@@ -21,18 +24,55 @@ except ImportError:
print(" pip install paramiko")
sys.exit(1)
-# ==================== 配置 ====================
-# 站点根目录(Nginx 等指向的目录的上一级,即 dist 的父目录)
-DEPLOY_BASE_PATH = "/www/wwwroot/self/soul-admin"
-# 切换后 chown 的属主,宝塔一般为 www:www,空则跳过
-DEPLOY_WWW_USER = os.environ.get("DEPLOY_WWW_USER", "www:www")
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
+DEFAULT_WWW_USER = "www:www"
+
+PROFILE_PRESETS = {
+ "prod": {
+ "title": "soul-admin",
+ "default_base_path": "/www/wwwroot/self/soul-admin",
+ "build_cmd": ["pnpm", "build"],
+ "build_desc": "pnpm build(正式环境)",
+ },
+ "dev": {
+ "title": "soul-admin-dev",
+ "default_base_path": "/www/wwwroot/self/soul-admin-dev",
+ "build_cmd": ["pnpm", "run", "build:dev"],
+ "build_desc": "pnpm run build:dev(测试环境)",
+ },
+}
-def get_cfg():
- base = os.environ.get("DEPLOY_BASE_PATH", DEPLOY_BASE_PATH).rstrip("/")
+def resolve_profile(profile_name):
+ profile = (profile_name or "prod").strip().lower()
+ if profile not in PROFILE_PRESETS:
+ raise ValueError("不支持的 profile: %s" % profile_name)
+ return profile
+
+
+def _resolve_base_path(profile, cli_base_path=None):
+ preset = PROFILE_PRESETS[profile]
+ if cli_base_path:
+ return cli_base_path.rstrip("/")
+
+ profile_env_key = "DEPLOY_BASE_PATH_%s" % profile.upper()
+ base = (os.environ.get(profile_env_key) or "").strip()
+ if not base:
+ base = (os.environ.get("DEPLOY_BASE_PATH") or "").strip()
+ if not base:
+ base = preset["default_base_path"]
+ return base.rstrip("/")
+
+
+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()
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"),
@@ -40,22 +80,53 @@ def get_cfg():
"base_path": base,
"dist_path": base + "/dist",
"dist2_path": base + "/dist2",
- "www_user": os.environ.get("DEPLOY_WWW_USER", DEPLOY_WWW_USER).strip(),
+ "www_user": www_user,
}
-# ==================== 本地构建 ====================
+def resolve_project_root():
+ script_dir = os.path.dirname(os.path.abspath(__file__))
+ if os.path.isfile(os.path.join(script_dir, "package.json")):
+ return script_dir
+ return os.path.dirname(script_dir)
-def run_build(root):
- """执行本地 pnpm build(使用 .env.production 正式环境配置)"""
+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")
+ )
+
+
+def run_build(root, cfg):
+ print("[1/4] 本地构建 %s ..." % cfg["build_desc"])
use_shell = sys.platform == "win32"
- dist_dir = os.path.join(root, "dist")
- index_html = os.path.join(dist_dir, "index.html")
-
try:
- r = __import__("subprocess").run(
- ["pnpm", "build"],
+ r = subprocess.run(
+ cfg["build_cmd"],
cwd=root,
shell=use_shell,
timeout=300,
@@ -67,9 +138,13 @@ def run_build(root):
if r.returncode != 0:
print(" [失败] 构建失败,退出码:", r.returncode)
for line in (r.stdout or "").strip().split("\n")[-10:]:
- print(" " + line)
+ if line:
+ print(" " + line)
+ for line in (r.stderr or "").strip().split("\n")[-10:]:
+ if line:
+ print(" " + line)
return False
- except __import__("subprocess").TimeoutExpired:
+ except subprocess.TimeoutExpired:
print(" [失败] 构建超时")
return False
except FileNotFoundError:
@@ -79,112 +154,75 @@ def run_build(root):
print(" [失败] 构建异常:", str(e))
return False
- if not os.path.isfile(index_html):
+ if not ensure_dist_ready(root):
print(" [失败] 未找到 dist/index.html")
return False
print(" [成功] 构建完成")
return True
-# ==================== 打包 dist 为 zip ====================
-
-
-def pack_dist_zip(root):
- """将本地 dist 目录打包为 zip(解压到 dist2 后即为站点根内容)"""
+def pack_dist_zip(root, profile):
print("[2/4] 打包 dist 为 zip ...")
dist_dir = os.path.join(root, "dist")
if not os.path.isdir(dist_dir):
print(" [失败] 未找到 dist 目录")
return None
+
index_html = os.path.join(dist_dir, "index.html")
if not os.path.isfile(index_html):
- print(" [失败] 未找到 dist/index.html,请先执行 pnpm build")
+ print(" [失败] 未找到 dist/index.html,请先执行构建")
return None
- zip_path = os.path.join(tempfile.gettempdir(), "soul_admin_deploy.zip")
+ zip_name = "soul_admin_%s_deploy.zip" % profile
+ zip_path = os.path.join(tempfile.gettempdir(), zip_name)
try:
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for dirpath, _dirs, filenames in os.walk(dist_dir):
- for f in filenames:
- full = os.path.join(dirpath, f)
+ for name in filenames:
+ full = os.path.join(dirpath, name)
arcname = os.path.relpath(full, dist_dir).replace("\\", "/")
zf.write(full, arcname)
- print(" [成功] 打包完成: %s (%.2f MB)" % (zip_path, os.path.getsize(zip_path) / 1024 / 1024))
+ print(
+ " [成功] 打包完成: %s (%.2f MB)"
+ % (zip_path, os.path.getsize(zip_path) / 1024 / 1024)
+ )
return zip_path
except Exception as e:
print(" [失败] 打包异常:", str(e))
return None
-# ==================== SSH 上传并解压到 dist2 ====================
-
-
def upload_zip_and_extract_to_dist2(cfg, zip_path):
- """上传 zip 到服务器并解压到 dist2"""
print("[3/4] SSH 上传 zip 并解压到 dist2 ...")
- sys.stdout.flush()
if not cfg.get("password") and not cfg.get("ssh_key"):
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return False
+
zip_size_mb = os.path.getsize(zip_path) / (1024 * 1024)
- client = paramiko.SSHClient()
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ remote_zip = cfg["base_path"] + "/soul_admin_deploy.zip"
+ client = None
try:
print(" 正在连接 %s@%s:%s ..." % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
- sys.stdout.flush()
- 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=30,
- banner_timeout=30,
- )
- else:
- client.connect(
- cfg["host"],
- port=DEFAULT_SSH_PORT,
- username=cfg["user"],
- password=cfg["password"],
- timeout=30,
- banner_timeout=30,
- )
+ client = connect_ssh(cfg, timeout=30)
print(" [OK] SSH 已连接,正在上传 zip(%.1f MB)..." % zip_size_mb)
- sys.stdout.flush()
- remote_zip = cfg["base_path"] + "/soul_admin_deploy.zip"
sftp = client.open_sftp()
- chunk_mb = 5.0
- last_reported = [0]
-
- def _progress(transferred, total):
- if total and total > 0:
- now_mb = transferred / (1024 * 1024)
- if now_mb - last_reported[0] >= chunk_mb or transferred >= total:
- last_reported[0] = now_mb
- print("\r 上传进度: %.1f / %.1f MB" % (now_mb, total / (1024 * 1024)), end="")
- sys.stdout.flush()
-
- sftp.put(zip_path, remote_zip, callback=_progress)
- if zip_size_mb >= chunk_mb:
- print("")
- print(" [OK] zip 已上传,正在服务器解压到 dist2 ...")
- sys.stdout.flush()
+ sftp.put(zip_path, remote_zip)
sftp.close()
+
dist2 = cfg["dist2_path"]
- cmd = "rm -rf %s && mkdir -p %s && unzip -o -q %s -d %s && rm -f %s && echo OK" % (
- dist2,
- dist2,
- remote_zip,
- dist2,
- remote_zip,
+ 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),
)
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])
- exit_status = stdout.channel.recv_exit_status()
if exit_status != 0 or "OK" not in out:
print(" [失败] 解压失败,退出码: %s" % exit_status)
if out:
@@ -194,41 +232,23 @@ def upload_zip_and_extract_to_dist2(cfg, zip_path):
return True
except Exception as e:
print(" [失败] SSH 错误: %s" % str(e))
- import traceback
- traceback.print_exc()
return False
finally:
- client.close()
-
-
-# ==================== 服务器目录切换:dist → dist1,dist2 → dist ====================
+ if client:
+ client.close()
def remote_swap_dist(cfg):
- """服务器上:dist→dist1,dist2→dist,删除 dist1,实现无缝切换"""
print("[4/4] 服务器切换目录: dist→dist1, dist2→dist ...")
- client = paramiko.SSHClient()
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client = None
try:
- 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,
- )
- base = cfg["base_path"]
- # 若当前没有 dist(首次部署),则 dist2 直接改名为 dist;若有 dist 则先备份再替换
- cmd = "cd %s && (test -d dist && (mv dist dist1 && mv dist2 dist && rm -rf dist1) || mv dist2 dist) && echo OK" % base
+ 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()
@@ -236,16 +256,18 @@ def remote_swap_dist(cfg):
if exit_status != 0 or "OK" not in out:
print(" [失败] 切换失败 (退出码: %s)" % exit_status)
if err:
- print(" 服务器 stderr: %s" % err)
+ print(" 服务器 stderr: %s" % err[:300])
if out and "OK" not in out:
- print(" 服务器 stdout: %s" % out)
+ print(" 服务器 stdout: %s" % out[:300])
return False
+
print(" [成功] 新版本已切换至: %s" % cfg["dist_path"])
- # 切换后设置 www 访问权限,否则 Nginx 无法读文件导致无法访问
www_user = cfg.get("www_user")
if www_user:
- dist_path = cfg["dist_path"]
- chown_cmd = "chown -R %s %s && echo OK" % (www_user, shlex.quote(dist_path))
+ 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()
@@ -258,57 +280,73 @@ def remote_swap_dist(cfg):
print(" [失败] SSH 错误: %s" % str(e))
return False
finally:
- client.close()
+ if client:
+ client.close()
-# ==================== 主函数 ====================
-
-
-def main():
+def create_parser(default_profile):
parser = argparse.ArgumentParser(
description="soul-admin 静态站点部署(dist2 解压后目录切换,无缝更新)",
formatter_class=argparse.RawDescriptionHelpFormatter,
- epilog="不安装依赖、不重启、不调用宝塔 API。站点路径: " + DEPLOY_BASE_PATH + "/dist",
)
- parser.add_argument("--no-build", action="store_true", help="跳过本地 pnpm build")
- args = parser.parse_args()
+ parser.add_argument(
+ "--profile",
+ choices=tuple(PROFILE_PRESETS.keys()),
+ default=default_profile,
+ help="部署档位:prod=正式环境,dev=测试环境",
+ )
+ parser.add_argument(
+ "--base-path",
+ default="",
+ help="覆盖部署目录(默认按 profile 取 DEPLOY_BASE_PATH 或预设路径)",
+ )
+ parser.add_argument("--no-build", action="store_true", help="跳过本地构建,直接上传现有 dist")
+ return parser
- script_dir = os.path.dirname(os.path.abspath(__file__))
- if os.path.isfile(os.path.join(script_dir, "package.json")):
- root = script_dir
- else:
- root = os.path.dirname(script_dir)
- cfg = get_cfg()
+def main(argv=None, default_profile="prod"):
+ default_profile = resolve_profile(default_profile)
+ parser = create_parser(default_profile=default_profile)
+ args = parser.parse_args(argv)
+
+ profile = resolve_profile(args.profile)
+ root = resolve_project_root()
+ cfg = get_cfg(profile=profile, cli_base_path=(args.base_path or "").strip())
+
print("=" * 60)
- print(" soul-admin 部署(dist/dist2 无缝切换)")
+ print(" %s 部署(dist/dist2 无缝切换)" % cfg["title"])
print("=" * 60)
- print(" 服务器: %s@%s 站点目录: %s" % (cfg["user"], cfg["host"], cfg["dist_path"]))
+ print(" profile: %s" % cfg["profile"])
+ print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
+ print(" 站点目录: %s" % cfg["dist_path"])
print("=" * 60)
if not args.no_build:
- print("[1/4] 本地构建 pnpm build ...")
- if not run_build(root):
+ if not run_build(root, cfg):
return 1
else:
- if not os.path.isdir(os.path.join(root, "dist")) or not os.path.isfile(
- os.path.join(root, "dist", "index.html")
- ):
- print("[错误] 未找到 dist/index.html,请先执行 pnpm build 或去掉 --no-build")
+ if not ensure_dist_ready(root):
+ print("[错误] 未找到 dist/index.html,请先执行构建或去掉 --no-build")
return 1
print("[1/4] 跳过本地构建")
- zip_path = pack_dist_zip(root)
+ zip_path = pack_dist_zip(root, profile=profile)
if not zip_path:
return 1
- if not upload_zip_and_extract_to_dist2(cfg, zip_path):
- return 1
+
try:
- os.remove(zip_path)
- except Exception:
- pass
+ if not upload_zip_and_extract_to_dist2(cfg, zip_path):
+ return 1
+ finally:
+ try:
+ if zip_path and os.path.isfile(zip_path):
+ os.remove(zip_path)
+ except Exception:
+ pass
+
if not remote_swap_dist(cfg):
return 1
+
print("")
print(" 部署完成!站点目录: %s" % cfg["dist_path"])
return 0
diff --git a/soul-api/__pycache__/devloy.cpython-311.pyc b/soul-api/__pycache__/devloy.cpython-311.pyc
index 10af9ba3..22e4fead 100644
Binary files a/soul-api/__pycache__/devloy.cpython-311.pyc and b/soul-api/__pycache__/devloy.cpython-311.pyc differ
diff --git a/soul-api/devloy.py b/soul-api/devloy.py
index 20d0b891..7b172445 100644
--- a/soul-api/devloy.py
+++ b/soul-api/devloy.py
@@ -1,997 +1,17 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
-soul-api 一键部署到宝塔【测试环境】
+兼容入口:保留 devloy.py 命令,实际复用 master.py 的部署实现。
-打包原则:优先使用本地已有资源,不边打包边下载(省时)。
-- 默认:本地 go build → Dockerfile.local 打镜像(--pull=false 不拉 base 镜像)
-- 首次部署前请本地先拉好:alpine:3.19、redis:7-alpine,后续一律用本地缓存
-
-三种模式:
-
-- 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
-
-环境变量:DEPLOY_DOCKER_PATH、DEPLOY_NGINX_CONF、DEPLOY_HOST、DEPLOY_RUNNER_CONTAINER 等
+这样可以确保旧命令仍可用,同时避免同一套部署逻辑维护两份。
"""
from __future__ import print_function
-import hashlib
-import os
import sys
-import tempfile
-import argparse
-import subprocess
-import shutil
-import tarfile
-import time
-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-dev"
-DEPLOY_DOCKER_PATH = os.environ.get("DEPLOY_DOCKER_PATH", "/www/wwwroot/self/soul-dev")
-DEPLOY_NGINX_CONF = os.environ.get("DEPLOY_NGINX_CONF", "") # 如 /www/server/panel/vhost/nginx/soulapi.quwanzhi.com.conf
-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
- 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).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"),
- }
-
-
-# ==================== 本地构建 ====================
-
-
-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"
- 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
-
-
-# ==================== 打包 ====================
-
-DEPLOY_PORT = 9001
-
-
-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 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"):
- p = os.path.join(root, name)
- if os.path.isfile(p):
- return p, name
- 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] 打包部署文件 ...")
- staging = tempfile.mkdtemp(prefix="soul_api_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, DEPLOY_PORT)
- set_env_mini_program_state(staging_env, "developer")
- print(" [已设置] PORT=%s, WECHAT_MINI_PROGRAM_STATE=developer(测试环境)" % 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 restart_via_bt_api(cfg):
- """通过宝塔 API 重启 Go 项目"""
- url = cfg.get("bt_panel_url") or ""
- key = cfg.get("bt_api_key") or ""
- name = cfg.get("bt_go_project_name", "soulDev")
- if not url or not key:
- return False
- if not requests:
- print(" [提示] 未安装 requests,pip install requests")
- return False
- try:
- req_time = int(time.time())
- sk_md5 = hashlib.md5(key.encode()).hexdigest()
- req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
- base = url.rstrip("/")
- params = {"request_time": req_time, "request_token": req_token}
- for action in ("stop_go_project", "start_go_project"):
- data = dict(params)
- data["action"] = action
- data["project_name"] = name
- data["name"] = name
- r = requests.post(base + "/plugin?name=go_project", data=data, timeout=15, verify=False)
- if r.status_code != 200:
- continue
- j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
- if action == "stop_go_project":
- time.sleep(2)
- if j.get("status") is False and j.get("msg"):
- print(" [宝塔API] %s: %s" % (action, j.get("msg", "")))
- data = dict(params)
- data["action"] = "start_go_project"
- data["project_name"] = name
- data["name"] = name
- r = requests.post(base + "/plugin?name=go_project", data=data, timeout=15, verify=False)
- if r.status_code == 200:
- j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
- if 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 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
- client = paramiko.SSHClient()
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
- try:
- 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)
- sftp = client.open_sftp()
- project_path = cfg["project_path"]
- remote_tar = project_path + "/soul_api_deploy.tar.gz"
- sftp.put(tarball_path, remote_tar)
- sftp.close()
-
- 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=60)
- out = stdout.read().decode("utf-8", errors="replace").strip()
- 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] 重启 soulDev 服务 ...")
- 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"):
- restart_cmd = (
- "cd %s && T=$(readlink -f .) && for p in $(pgrep -f soul-api 2>/dev/null); do "
- '[ "$(readlink -f /proc/$p/cwd 2>/dev/null)" = "$T" ] && kill $p 2>/dev/null; done; '
- "sleep 2; setsid nohup ./soul-api >> soul-api.log 2>&1 /dev/null); do "
- '[ "$(readlink -f /proc/$p/cwd 2>/dev/null)" = "$T" ] && echo RESTART_OK && exit 0; done; echo RESTART_FAIL'
- ) % project_path
- stdin, stdout, stderr = client.exec_command(restart_cmd, timeout=20)
- out = stdout.read().decode("utf-8", errors="replace").strip()
- err = (stderr.read().decode("utf-8", errors="replace") or "").strip()
- if err:
- print(" [stderr] %s" % err[:200])
- ok = "RESTART_OK" in out
- if ok:
- print(" [成功] soulDev 已通过 SSH 重启")
- else:
- print(" [警告] 请手动启动: cd %s && ./soul-api" % project_path)
- else:
- print("[4/4] 跳过重启 (--no-restart)")
-
- return True
- except Exception as e:
- print(" [失败] SSH 错误:", str(e))
- return False
- finally:
- client.close()
-
-
-# ==================== 宝塔 API - Nginx 配置与重载 ====================
-
-
-def _bt_request(cfg, endpoint, data):
- """宝塔 API 通用请求(request_time + request_token 签名)"""
- if not requests:
- return None, None
- url = (cfg.get("bt_panel_url") or "").rstrip("/")
- key = cfg.get("bt_api_key") or ""
- if not url or not key:
- return None, None
- try:
- req_time = int(time.time())
- sk_md5 = hashlib.md5(key.encode()).hexdigest()
- req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
- payload = dict(data)
- payload["request_time"] = req_time
- payload["request_token"] = req_token
- r = requests.post(url + endpoint, data=payload, timeout=15, verify=False)
- if r.status_code != 200:
- return None, r
- j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
- return j, r
- except Exception as e:
- print(" [宝塔API] 请求异常:", str(e))
- return None, None
-
-
-def deploy_nginx_via_bt_api(cfg, nginx_conf_path, new_port):
- """
- 通过宝塔 API 更新 Nginx 配置并重载。
- - 使用 files.GetFileBody 读取配置
- - 替换 proxy_pass 端口
- - 使用 files.SaveFileBody 保存
- - 尝试 service 插件重载 nginx
- """
- if not nginx_conf_path or not new_port:
- return False
- if not requests:
- print(" [提示] 未安装 requests,无法使用宝塔 Nginx API。pip install requests")
- return False
- # 1. 读取配置
- j, _ = _bt_request(cfg, "/files?action=GetFileBody", {"path": nginx_conf_path})
- if not j or "status" in j and j.get("status") is False:
- print(" [宝塔API] 读取 Nginx 配置失败:", j.get("msg", "未知错误") if j else "")
- return False
- content = j.get("data") or j.get("content") or ""
- if isinstance(content, bytes):
- content = content.decode("utf-8", errors="replace")
- # 2. 替换 proxy_pass 端口
- import re
- new_content = re.sub(
- r"proxy_pass\s+http://127\.0\.0\.1:\d+",
- "proxy_pass http://127.0.0.1:%s" % new_port,
- content,
- flags=re.IGNORECASE,
- )
- if new_content == content:
- print(" [宝塔API] 未找到 proxy_pass,可能已是目标端口或格式不符")
- # 3. 保存配置
- j, _ = _bt_request(cfg, "/files?action=SaveFileBody", {
- "path": nginx_conf_path,
- "data": new_content,
- "encoding": "utf-8",
- })
- if not j or "status" in j and j.get("status") is False:
- print(" [宝塔API] 保存 Nginx 配置失败:", j.get("msg", "未知错误") if j else "")
- return False
- # 4. 重载 Nginx(尝试 service 插件)
- for try_action, try_name in [
- ("reload", "nginx"),
- ("RestartService", "nginx"),
- ]:
- j, _ = _bt_request(cfg, "/service?action=%s" % try_action, {"name": try_name})
- if j and j.get("status") is True:
- print(" [成功] 已通过宝塔 API 重载 Nginx (端口 %s)" % new_port)
- return True
- # 部分面板无 service 接口,配置已保存,需手动重载
- print(" [提示] Nginx 配置已通过宝塔 API 更新,重载请到面板操作或使用 SSH: nginx -s reload")
- return True
-
-
-# ==================== Docker 部署(蓝绿无缝切换) ====================
-
-
-def resolve_docker_env_file(root, explicit=None):
- """
- 选择打入镜像的环境文件(相对 soul-api 根目录,须能被 Docker 构建上下文包含)。
- Dockerfile: COPY ${ENV_FILE} /app/.env;certs/ 由 COPY certs/ 一并打入。
- 优先级:explicit → DOCKER_ENV_FILE → 自动 .env.development > .env.production > .env(与测试环境默认一致)
- """
- if explicit:
- name = os.path.basename(explicit.replace("\\", "/"))
- path = os.path.join(root, name)
- if os.path.isfile(path):
- print(" [镜像配置] 打入镜像的环境文件: %s(--env-file)" % name)
- return name
- print(" [失败] --env-file 不存在: %s" % path)
- return None
- override = (os.environ.get("DOCKER_ENV_FILE") or "").strip()
- if override:
- name = os.path.basename(override.replace("\\", "/"))
- path = os.path.join(root, name)
- if os.path.isfile(path):
- print(" [镜像配置] 打入镜像的环境文件: %s(DOCKER_ENV_FILE)" % name)
- return name
- print(" [失败] DOCKER_ENV_FILE 指向的文件不存在: %s" % path)
- return None
- for name in (".env.development", ".env.production", ".env"):
- if os.path.isfile(os.path.join(root, name)):
- print(" [镜像配置] 打入镜像的环境文件: %s(自动选择)" % name)
- return name
- print(" [失败] 未找到 .env.development / .env.production / .env,无法 COPY 进镜像")
- return None
-
-
-def run_docker_build(root, env_file=".env.development"):
- """本地构建 Docker 镜像(使用 Docker 内的 golang 镜像)"""
- print("[1/5] 构建 Docker 镜像 ...(进度见下方 Docker 输出)")
- try:
- cmd = ["docker", "build", "--pull=false", "-f", "deploy/Dockerfile", "-t", "soul-api:latest", "--build-arg", "ENV_FILE=%s" % env_file, "--progress=plain", "."]
- r = subprocess.run(cmd, cwd=root, shell=False, timeout=300)
- if r.returncode != 0:
- print(" [失败] docker build 失败,退出码:", r.returncode)
- return None
- print(" [成功] 镜像构建完成 soul-api:latest")
- return True
- except FileNotFoundError:
- print(" [失败] 未找到 docker 命令,请安装 Docker")
- return None
- except subprocess.TimeoutExpired:
- print(" [失败] 构建超时")
- return None
- except Exception as e:
- print(" [失败] 构建异常:", str(e))
- return None
-
-
-def run_docker_build_local(root, env_file=".env.development"):
- """使用本地 Go 交叉编译后构建 Docker 镜像(不拉取 golang 镜像,--pull=false 不拉 base 镜像)"""
- print("[1/5] 使用本地 Go 交叉编译 ...")
- binary_path = run_build(root)
- if not binary_path:
- return None
- print("[2/5] 使用 Dockerfile.local 构建镜像 ...(--pull=false 仅用本地缓存)")
- try:
- cmd = ["docker", "build", "--pull=false", "-f", "deploy/Dockerfile.local", "-t", "soul-api:latest",
- "--build-arg", "ENV_FILE=%s" % env_file, "--progress=plain", "."]
- r = subprocess.run(cmd, cwd=root, shell=False, timeout=120)
- if r.returncode != 0:
- print(" [失败] docker build 失败,退出码:", r.returncode)
- return None
- print(" [成功] 镜像构建完成 soul-api:latest(本地 Go 参与构建)")
- return True
- except FileNotFoundError:
- print(" [失败] 未找到 docker 命令,请安装 Docker")
- return None
- except subprocess.TimeoutExpired:
- print(" [失败] 构建超时")
- return None
- except Exception as e:
- print(" [失败] 构建异常:", str(e))
- return None
-
-
-def pack_docker_image(root):
- """仅导出 soul-api 镜像为 tar.gz(线上 Redis 已在运行,不再打包/加载)"""
- import gzip
- print("[3/5] 导出镜像为 tar.gz(soul-api only)...")
- out_tar = os.path.join(tempfile.gettempdir(), "soul_api_image.tar.gz")
- try:
- r = subprocess.run(
- ["docker", "save", "soul-api:latest"],
- capture_output=True,
- timeout=180,
- cwd=root,
- )
- if r.returncode != 0:
- stderr = (r.stderr or b"").decode("utf-8", errors="replace")[:300]
- print(" [失败] docker save 失败:", stderr)
- print(" [提示] 请确保本地有 redis 镜像,执行: docker images | findstr redis 查看名称")
- return None
- with gzip.open(out_tar, "wb") as f:
- f.write(r.stdout)
- if not os.path.isfile(out_tar) or os.path.getsize(out_tar) < 1000:
- print(" [失败] 导出文件异常")
- return None
- print(" [成功] 导出完成: %.2f MB(soul-api only)" % (os.path.getsize(out_tar) / 1024 / 1024))
- return out_tar
- except subprocess.TimeoutExpired:
- print(" [失败] docker save 超时")
- return None
- except Exception as e:
- print(" [失败] 导出异常:", str(e))
- return None
-
-
-def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_method="ssh"):
- """上传镜像与配置到服务器,执行蓝绿部署。deploy_method: ssh=脚本内 Nginx 切换, btapi=宝塔 API 更新 Nginx"""
- 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__))
-
- print("[4/5] SSH 上传镜像与配置 ...")
- if not cfg.get("password") and not cfg.get("ssh_key"):
- print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
- return False
-
- client = paramiko.SSHClient()
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
- try:
- 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=30)
- else:
- client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=30)
- sftp = client.open_sftp()
-
- remote_tar = deploy_path + "/soul_api_image.tar.gz"
- sftp.put(image_tar_path, remote_tar)
- print(" [已上传] 镜像 tar.gz")
-
- compose_local = os.path.join(script_dir, "deploy", "docker-compose.bluegreen.yml")
- deploy_local = os.path.join(script_dir, "deploy", "docker-deploy-remote.sh")
- env_local = os.path.join(script_dir, ".env.production")
- if os.path.isfile(compose_local):
- sftp.put(compose_local, deploy_path + "/docker-compose.bluegreen.yml")
- print(" [已上传] docker-compose.bluegreen.yml")
- if os.path.isfile(deploy_local):
- sftp.put(deploy_local, deploy_path + "/docker-deploy-remote.sh")
- print(" [已上传] docker-deploy-remote.sh")
- # 注意:docker-compose.bluegreen.yml 未配置 env_file,容器实际以镜像内 /app/.env 为准;
- # 此处上传仅供服务器目录备份或手工改 compose 后使用。
- if include_env and os.path.isfile(env_local):
- sftp.put(env_local, deploy_path.rstrip("/") + "/.env")
- print(" [已上传] .env.production -> 服务器 %s/.env(可选;默认不挂载进容器)" % deploy_path.rstrip("/"))
-
- # btapi 模式:需先读取 .active 计算新端口,脚本内跳过 Nginx
- current_active = "blue"
- if deploy_method == "btapi" and nginx_conf:
- try:
- active_file = deploy_path.rstrip("/") + "/.active"
- with sftp.open(active_file, "r") as f:
- current_active = (f.read().decode("utf-8", errors="replace") or "blue").strip() or "blue"
- except Exception:
- pass
- new_port = 9002 if current_active == "blue" else 9001
-
- sftp.close()
-
- print("[5/5] 执行蓝绿部署 ...")
- env_exports = ""
- if nginx_conf:
- env_exports += "export DEPLOY_NGINX_CONF='%s'; " % nginx_conf.replace("'", "'\\''")
- env_exports += "export DEPLOY_DOCKER_PATH='%s'; " % deploy_path.replace("'", "'\\''")
- script_args = remote_tar
- if deploy_method == "btapi" and nginx_conf:
- script_args += " --skip-nginx"
- cmd = "mkdir -p %s && %s cd %s && chmod +x docker-deploy-remote.sh && ./docker-deploy-remote.sh %s" % (deploy_path, env_exports, deploy_path, script_args)
- stdin, stdout, stderr = client.exec_command(cmd, 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[:500])
- if exit_status != 0:
- print(" [失败] 远程部署脚本退出码:", exit_status)
- return False
-
- # btapi 模式:通过宝塔 API 更新 Nginx 配置并重载(new_port 已在上方计算)
- if deploy_method == "btapi" and nginx_conf:
- try:
- print(" [宝塔 API] 更新 Nginx ...")
- deploy_nginx_via_bt_api(cfg, nginx_conf, new_port)
- except Exception as e:
- print(" [警告] 宝塔 Nginx API 失败:", str(e))
-
- print(" 部署完成,蓝绿无缝切换")
- return True
- except Exception as e:
- print(" [失败] SSH 错误:", str(e))
- return False
- finally:
- 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", "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(镜像内配置不变)")
- parser.add_argument("--no-restart", action="store_true", help="[binary] 上传后不重启")
- parser.add_argument("--restart-method", choices=("auto", "btapi", "ssh"), default="auto",
- help="[binary] 重启方式: auto/btapi/ssh")
- parser.add_argument("--docker-in-go", action="store_true",
- help="[docker] 在 Docker 内用 golang 镜像编译(默认:本地 go build → 再打镜像)")
- parser.add_argument("--deploy-method", choices=("ssh", "btapi"), default="ssh",
- 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)
- print(" soul-api 测试环境 Docker 蓝绿部署(无缝切换)")
- print("=" * 60)
- print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
- print(" 目标目录: %s" % docker_path)
- print("=" * 60)
-
- if not args.no_build:
- env_for_image = resolve_docker_env_file(root, explicit=args.env_file)
- if env_for_image is None:
- return 1
- # 默认:本地 go build → Dockerfile.local 打镜像;--docker-in-go 时在容器内编译
- ok = (
- run_docker_build(root, env_file=env_for_image)
- if args.docker_in_go
- else run_docker_build_local(root, env_file=env_for_image)
- )
- if not ok:
- return 1
- else:
- print("[1/5] 跳过构建,使用现有 soul-api:latest(无需本地环境文件)")
-
- image_tar = pack_docker_image(root)
- if not image_tar:
- return 1
-
- if not upload_and_deploy_docker(cfg, image_tar, include_env=not args.no_env, deploy_method=args.deploy_method):
- return 1
-
- try:
- os.remove(image_tar)
- except Exception:
- pass
-
- print("")
- print(" 部署完成!测试环境蓝绿无缝切换")
- return 0
-
- # ===== Binary 模式 =====
- print("=" * 60)
- print(" soul-api 测试环境 部署到宝塔,重启 soulDev")
- 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 二进制")
- return 1
- print("[1/4] 跳过编译")
-
- 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
+from master import main as master_main
if __name__ == "__main__":
- sys.exit(main())
+ sys.exit(master_main())
diff --git a/soul-api/internal/handler/autolink.go b/soul-api/internal/handler/autolink.go
index 2c66d1c8..db1aca6d 100644
--- a/soul-api/internal/handler/autolink.go
+++ b/soul-api/internal/handler/autolink.go
@@ -150,10 +150,16 @@ func ensurePersonByName(db *gorm.DB, name string, aliasToken map[string]string)
return tok, nil
}
}
- created, err := createPersonMinimal(db, clean, "")
+ // 无匹配人物:先建占位 users(无 open_id),再建 Person 并绑定 user_id,便于超级个体/会员体系与 @ 一致
+ u, err := createUserPlaceholderForArticleMention(db, clean)
if err != nil {
return "", err
}
+ created, err := createPersonMinimal(db, clean, u.ID)
+ if err != nil {
+ _ = db.Unscoped().Where("id = ?", u.ID).Delete(&model.User{})
+ return "", err
+ }
return created.Token, nil
}
diff --git a/soul-api/internal/handler/balance.go b/soul-api/internal/handler/balance.go
index 403a721c..bd970c6b 100644
--- a/soul-api/internal/handler/balance.go
+++ b/soul-api/internal/handler/balance.go
@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"strconv"
+ "strings"
"time"
"soul-api/internal/database"
@@ -160,7 +161,7 @@ func BalanceConsumePost(c *gin.Context) {
}
db := database.DB()
// 后端价格校验
- standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID)
+ standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID, strings.TrimSpace(req.UserID))
if priceErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
return
@@ -188,6 +189,9 @@ func BalanceConsumePost(c *gin.Context) {
referrerID = &binding.ReferrerID
}
}
+ if referrerID != nil && isSelfReferralBuyer(req.UserID, *referrerID) {
+ referrerID = nil
+ }
productID := req.ProductID
if productID == "" {
diff --git a/soul-api/internal/handler/ckb.go b/soul-api/internal/handler/ckb.go
index 1eed77e4..0d99ea1b 100644
--- a/soul-api/internal/handler/ckb.go
+++ b/soul-api/internal/handler/ckb.go
@@ -512,10 +512,10 @@ func CKBJoin(c *gin.Context) {
}
if body.Type == "investor" && body.UserID != "" {
if !userHasContentPurchase(database.DB(), body.UserID) {
- // 交互原则:用户侧友好提示(不暴露存客宝/规则细节);后台可通过规则引导再完善
c.JSON(http.StatusOK, gin.H{
- "success": true,
- "message": "提交成功,我们会尽快联系您",
+ "success": false,
+ "error": "请先购买任意章节后再申请资源对接",
+ "errorCode": "CONTENT_PURCHASE_REQUIRED",
})
return
}
diff --git a/soul-api/internal/handler/db.go b/soul-api/internal/handler/db.go
index ccc319c9..36e4878a 100644
--- a/soul-api/internal/handler/db.go
+++ b/soul-api/internal/handler/db.go
@@ -180,6 +180,8 @@ func defaultMpUi() gin.H {
"readStatLabel": "已读章节", "recentReadTitle": "最近阅读",
"readStatPath": "/pages/reading-records/reading-records?focus=all",
"recentReadPath": "/pages/reading-records/reading-records?focus=recent",
+ // 我的页 MBTI 标签跳转:与 read-extras linkTags 某条 label 一致时优先匹配;空则自动匹配 label/mpKey/pagePath 含 mbti 的「小程序」类标签
+ "mbtiLinkLabel": "",
},
// 弹窗文案:管理端按 pagePath + key 维护,见 mpUi.pagePopupItems(memberDetailPage/readPage 已废弃,由迁移合并)
"pagePopupItems": []interface{}{
diff --git a/soul-api/internal/handler/db_person.go b/soul-api/internal/handler/db_person.go
index c6509792..11a23cfc 100644
--- a/soul-api/internal/handler/db_person.go
+++ b/soul-api/internal/handler/db_person.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"encoding/base64"
+ "encoding/hex"
"encoding/json"
"fmt"
"log"
@@ -514,6 +515,67 @@ func DBPersonSave(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
+// UserSourceArticleMentionAuto users.source:文章保存时 @ 无匹配人物自动创建的占位账号(无 open_id,与 persons.user_id 绑定)
+const UserSourceArticleMentionAuto = "article_mention_auto"
+
+func genMentionAutoReferralCode() string {
+ b := make([]byte, 9)
+ if _, err := rand.Read(b); err != nil {
+ return "M" + fmt.Sprintf("%016X", time.Now().UnixNano())[:19]
+ }
+ s := "M" + hex.EncodeToString(b)
+ if len(s) > 20 {
+ return s[:20]
+ }
+ return s
+}
+
+// createUserPlaceholderForArticleMention 创建无微信 open_id 的占位用户,供 ParseAutoLink 新建 @ 人物时与 Person 绑定
+func createUserPlaceholderForArticleMention(db *gorm.DB, displayName string) (*model.User, error) {
+ displayName = strings.TrimSpace(displayName)
+ if displayName == "" {
+ return nil, fmt.Errorf("displayName 必填")
+ }
+ runes := []rune(displayName)
+ if len(runes) > 80 {
+ displayName = string(runes[:80])
+ }
+ nick := displayName
+ hasFullBook := false
+ earnings := 0.0
+ pendingEarnings := 0.0
+ referralCount := 0
+ purchasedSections := "[]"
+ avatar := ""
+ src := UserSourceArticleMentionAuto
+
+ for attempt := 0; attempt < 8; attempt++ {
+ userID := "user_mention_" + randomSuffix()
+ referralCode := genMentionAutoReferralCode()
+ u := model.User{
+ ID: userID,
+ OpenID: nil,
+ Nickname: &nick,
+ Avatar: &avatar,
+ ReferralCode: &referralCode,
+ HasFullBook: &hasFullBook,
+ PurchasedSections: &purchasedSections,
+ Earnings: &earnings,
+ PendingEarnings: &pendingEarnings,
+ ReferralCount: &referralCount,
+ Source: &src,
+ }
+ if err := db.Create(&u).Error; err != nil {
+ if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
+ continue
+ }
+ return nil, err
+ }
+ return &u, nil
+ }
+ return nil, fmt.Errorf("创建占位用户失败,请重试")
+}
+
// createPersonMinimal 仅按 name 创建 Person(含存客宝计划),供 autolink 复用
// userID 可为空;用于“绑定用户 → 幂等创建”的场景
func createPersonMinimal(db *gorm.DB, name string, userID string) (*model.Person, error) {
diff --git a/soul-api/internal/handler/gift_pay.go b/soul-api/internal/handler/gift_pay.go
index 6ec89106..e766d594 100644
--- a/soul-api/internal/handler/gift_pay.go
+++ b/soul-api/internal/handler/gift_pay.go
@@ -102,7 +102,7 @@ func GiftPayCreate(c *gin.Context) {
productID = "fullbook"
}
}
- unitPrice, priceErr := getStandardPrice(db, req.ProductType, productID)
+ unitPrice, priceErr := getStandardPrice(db, req.ProductType, productID, strings.TrimSpace(req.UserID))
if priceErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()})
return
@@ -120,7 +120,7 @@ func GiftPayCreate(c *gin.Context) {
SELECT referrer_id FROM referral_bindings
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
ORDER BY binding_date DESC LIMIT 1
- `, req.UserID).Scan(&binding).Error; err == nil && binding.ReferrerID != "" {
+ `, req.UserID).Scan(&binding).Error; err == nil && binding.ReferrerID != "" && !isSelfReferralBuyer(req.UserID, binding.ReferrerID) {
referrerID = &binding.ReferrerID
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
@@ -323,7 +323,7 @@ func GiftPayDetail(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请先登录"})
return
}
- unitPrice, priceErr := getStandardPrice(db, "section", sectionId)
+ unitPrice, priceErr := getStandardPrice(db, "section", sectionId, callerUserID)
if priceErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()})
return
@@ -336,7 +336,7 @@ func GiftPayDetail(c *gin.Context) {
SELECT referrer_id FROM referral_bindings
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
ORDER BY binding_date DESC LIMIT 1
- `, callerUserID).Scan(&binding).Error; err == nil && binding.ReferrerID != "" {
+ `, callerUserID).Scan(&binding).Error; err == nil && binding.ReferrerID != "" && !isSelfReferralBuyer(callerUserID, binding.ReferrerID) {
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
diff --git a/soul-api/internal/handler/miniprogram.go b/soul-api/internal/handler/miniprogram.go
index 67d48d02..99c486d7 100644
--- a/soul-api/internal/handler/miniprogram.go
+++ b/soul-api/internal/handler/miniprogram.go
@@ -432,6 +432,15 @@ func miniprogramPayPost(c *gin.Context) {
db := database.DB()
+ // 尽早解析 userId,便于 mentor_consultation 等按用户校验预约单与标准价
+ userIDForPrice := strings.TrimSpace(req.UserID)
+ if userIDForPrice == "" && strings.TrimSpace(req.OpenID) != "" {
+ var u model.User
+ if err := db.Where("open_id = ?", strings.TrimSpace(req.OpenID)).First(&u).Error; err == nil {
+ userIDForPrice = u.ID
+ }
+ }
+
productID := strings.TrimSpace(req.ProductID)
if req.ProductType == "link_karuo_tip" && strings.TrimSpace(req.TipSource) == "live_mic" && productID != "" && !strings.HasPrefix(productID, "live_mic|") {
productID = "live_mic|" + productID
@@ -440,6 +449,8 @@ func miniprogramPayPost(c *gin.Context) {
var finalAmount float64
var orderSn string
var referrerID *string
+ // 非充值单记录标准价:若最终发现自推,需撤销误用的好友折扣
+ var standardPriceSnapshot float64
if req.ProductType == "balance_recharge" {
// 充值:从已创建的订单取金额,productId=orderSn
@@ -460,7 +471,7 @@ func miniprogramPayPost(c *gin.Context) {
if pricePID == "" {
pricePID = strings.TrimSpace(req.ProductID)
}
- standardPrice, priceErr := getStandardPrice(db, req.ProductType, pricePID, req.TipSource)
+ standardPrice, priceErr := getStandardPrice(db, req.ProductType, pricePID, userIDForPrice, req.TipSource)
if priceErr != nil && req.ProductType == "link_karuo_tip" {
if fb, _, canonID, ferr := linkKaruoTipFallbackByAmount(db, pricePID, req.Amount, req.TipSource); ferr == nil {
standardPrice = fb
@@ -481,6 +492,7 @@ func miniprogramPayPost(c *gin.Context) {
return
}
finalAmount = standardPrice
+ standardPriceSnapshot = standardPrice
// 打赏不参与分销好友折扣(实付=标准价)
if req.ProductType != "link_karuo_tip" {
@@ -528,8 +540,6 @@ func miniprogramPayPost(c *gin.Context) {
orderSn = wechat.GenerateOrderSn()
}
- totalFee := int(finalAmount * 100) // 转为分
-
// 获取客户端 IP
clientIP := c.ClientIP()
if clientIP == "" {
@@ -537,7 +547,10 @@ func miniprogramPayPost(c *gin.Context) {
}
// userID:优先用客户端传入;为空时按 openid 查用户(排除软删除,避免订单归属到旧账号)
- userID := req.UserID
+ userID := strings.TrimSpace(req.UserID)
+ if userID == "" && userIDForPrice != "" {
+ userID = userIDForPrice
+ }
if userID == "" && req.OpenID != "" {
var u model.User
if err := db.Where("open_id = ?", req.OpenID).First(&u).Error; err == nil {
@@ -548,6 +561,18 @@ func miniprogramPayPost(c *gin.Context) {
}
}
+ // 自购自推:不写 referrer、不按好友价(兼容脏绑定或误传自己的推荐码;userID 以最终解析为准)
+ if req.ProductType != "balance_recharge" && req.ProductType != "link_karuo_tip" && referrerID != nil && userID != "" &&
+ isSelfReferralBuyer(userID, *referrerID) {
+ fmt.Printf("[MiniprogramPay] 自推无效: userId=%s 忽略 referrer\n", userID)
+ referrerID = nil
+ if standardPriceSnapshot > 0 {
+ finalAmount = standardPriceSnapshot
+ }
+ }
+
+ totalFee := int(finalAmount * 100) // 转为分(须在自推纠正 finalAmount 之后)
+
if req.ProductType != "balance_recharge" {
if productID == "" {
switch req.ProductType {
@@ -558,6 +583,9 @@ func miniprogramPayPost(c *gin.Context) {
case "link_karuo_tip":
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "打赏缺少礼物标识"})
return
+ case "mentor_consultation":
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少预约单 ID(productId)"})
+ return
default:
productID = "fullbook"
}
@@ -575,6 +603,8 @@ func miniprogramPayPost(c *gin.Context) {
description = "卡若创业派对VIP年度会员(365天)"
case "match":
description = "购买匹配次数"
+ case "mentor_consultation":
+ description = "导师咨询服务"
case "link_karuo_tip":
giftID := productID
if strings.HasPrefix(giftID, "live_mic|") {
@@ -912,6 +942,13 @@ func MiniprogramPayNotify(c *gin.Context) {
}
} else if attach.ProductType == "link_karuo_tip" {
fmt.Printf("[PayNotify] 打赏/上麦礼遇订单完成: user=%s order=%s\n", beneficiaryUserID, orderSn)
+ } else if attach.ProductType == "mentor_consultation" && attach.ProductID != "" {
+ if cid, err := strconv.Atoi(strings.TrimSpace(attach.ProductID)); err == nil && cid > 0 {
+ _ = db.Model(&model.MentorConsultation{}).
+ Where("id = ? AND user_id = ?", cid, beneficiaryUserID).
+ Updates(map[string]interface{}{"status": "paid", "updated_at": time.Now()})
+ fmt.Printf("[PayNotify] 导师预约已支付: user=%s consultId=%d order=%s\n", beneficiaryUserID, cid, orderSn)
+ }
}
productID := attach.ProductID
if productID == "" {
@@ -953,8 +990,12 @@ func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64,
fmt.Printf("[PayNotify] 打赏订单跳过分销佣金: orderSn=%s\n", orderSn)
return
}
+ if order != nil && order.ProductType == "mentor_consultation" {
+ fmt.Printf("[PayNotify] 导师咨询订单跳过分销佣金: orderSn=%s\n", orderSn)
+ return
+ }
type Binding struct {
- ID int `gorm:"column:id"`
+ ID string `gorm:"column:id"`
ReferrerID string `gorm:"column:referrer_id"`
ExpiryDate time.Time `gorm:"column:expiry_date"`
PurchaseCount int `gorm:"column:purchase_count"`
@@ -972,6 +1013,10 @@ func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64,
fmt.Printf("[PayNotify] 用户无有效推广绑定,跳过分佣: %s\n", buyerUserID)
return
}
+ if isSelfReferralBuyer(buyerUserID, binding.ReferrerID) {
+ fmt.Printf("[PayNotify] 自购自推,跳过分佣: buyer=%s\n", buyerUserID)
+ return
+ }
if time.Now().After(binding.ExpiryDate) {
fmt.Printf("[PayNotify] 绑定已过期,跳过分佣: %s\n", buyerUserID)
return
@@ -1326,6 +1371,17 @@ func activateOrderBenefits(db *gorm.DB, order *model.Order, payTime time.Time) {
ConfirmBalanceRechargeByOrder(db, order)
case "link_karuo_tip":
// 首页打赏 / 上麦礼遇:仅收款,无额外会员或章节权益
+ case "mentor_consultation":
+ if order.ProductID == nil || strings.TrimSpace(*order.ProductID) == "" {
+ return
+ }
+ cid, err := strconv.Atoi(strings.TrimSpace(*order.ProductID))
+ if err != nil || cid <= 0 {
+ return
+ }
+ _ = db.Model(&model.MentorConsultation{}).
+ Where("id = ? AND user_id = ?", cid, userID).
+ Updates(map[string]interface{}{"status": "paid", "updated_at": payTime})
}
}
@@ -1671,10 +1727,11 @@ func linkKaruoTipPriceTotal(db *gorm.DB, productID string, tipSource string) (fl
}
// getStandardPrice 从 DB 读取商品标准价(后端校验用),防止客户端篡改金额
-// productType: fullbook / vip / section / match / link_karuo_tip
-// productId: 章节购买时为章节 ID;打赏为 giftId|qty 或 live_mic|giftId|qty
+// productType: fullbook / vip / section / match / link_karuo_tip / mentor_consultation
+// productId: 章节购买时为章节 ID;打赏为 giftId|qty 或 live_mic|giftId|qty;导师咨询为 mentor_consultations.id
+// buyerUserID: mentor_consultation 时校验预约归属;其他类型可传空字符串
// tipSource 可选:live_mic / home_reward 时 link_karuo_tip 计价与置顶人物礼物配置合并
-func getStandardPrice(db *gorm.DB, productType, productID string, tipSource ...string) (float64, error) {
+func getStandardPrice(db *gorm.DB, productType, productID, buyerUserID string, tipSource ...string) (float64, error) {
productType = normalizePayProductType(productType)
ts := ""
if len(tipSource) > 0 {
@@ -1767,6 +1824,29 @@ func getStandardPrice(db *gorm.DB, productType, productID string, tipSource ...s
}
return *ch.Price, nil
+ case "mentor_consultation":
+ if productID == "" {
+ return 0, fmt.Errorf("导师咨询缺少预约单 ID")
+ }
+ cid, err := strconv.Atoi(strings.TrimSpace(productID))
+ if err != nil || cid <= 0 {
+ return 0, fmt.Errorf("无效的预约单 ID")
+ }
+ var mc model.MentorConsultation
+ if err := db.Where("id = ?", cid).First(&mc).Error; err != nil {
+ return 0, fmt.Errorf("预约单不存在")
+ }
+ if mc.Status != "" && mc.Status != "created" {
+ return 0, fmt.Errorf("预约单状态不可支付")
+ }
+ if strings.TrimSpace(buyerUserID) != "" && mc.UserID != buyerUserID {
+ return 0, fmt.Errorf("预约单与当前用户不匹配")
+ }
+ if mc.Amount <= 0 {
+ return 0, fmt.Errorf("预约金额无效")
+ }
+ return mc.Amount, nil
+
default:
return 0, fmt.Errorf("未知商品类型: %s", productType)
}
diff --git a/soul-api/internal/handler/referral.go b/soul-api/internal/handler/referral.go
index b9822fc0..fb2e5d0e 100644
--- a/soul-api/internal/handler/referral.go
+++ b/soul-api/internal/handler/referral.go
@@ -65,10 +65,6 @@ func ReferralBind(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码无效"})
return
}
- if referrer.ID == effectiveUserID {
- c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不能使用自己的推荐码"})
- return
- }
var user model.User
if err := db.Where("id = ?", effectiveUserID).First(&user).Error; err != nil {
@@ -82,6 +78,11 @@ func ReferralBind(c *gin.Context) {
return
}
}
+ // 以解析后的用户主键为准(effectiveUserId 可能与真实 user.id 不一致,曾导致可绑到自己)
+ if referrer.ID == user.ID {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不能使用自己的推荐码"})
+ return
+ }
expiryDate := time.Now().AddDate(0, 0, bindingDays)
var existing model.ReferralBinding
diff --git a/soul-api/internal/handler/referral_commission.go b/soul-api/internal/handler/referral_commission.go
index 8c67e7fb..f3a00e19 100644
--- a/soul-api/internal/handler/referral_commission.go
+++ b/soul-api/internal/handler/referral_commission.go
@@ -2,6 +2,7 @@ package handler
import (
"encoding/json"
+ "strings"
"time"
"soul-api/internal/model"
@@ -9,6 +10,13 @@ import (
"gorm.io/gorm"
)
+// isSelfReferralBuyer 买方与推广者为同一用户:不得享受好友价、不得分佣(防自绑脏数据、支付参数误传自己的推荐码)
+func isSelfReferralBuyer(buyerUserID, referrerUserID string) bool {
+ b := strings.TrimSpace(buyerUserID)
+ r := strings.TrimSpace(referrerUserID)
+ return b != "" && r != "" && b == r
+}
+
// computeOrderCommission 按订单计算应付给推广者的佣金
// 会员订单:推广者会员 20%、非会员 10%;内容订单:90%(好友优惠 5% 仅针对内容)
// order: 已支付订单,需有 product_type、amount、referrer_id
@@ -18,9 +26,15 @@ func computeOrderCommission(db *gorm.DB, order *model.Order, referrerUser *model
if order == nil || order.ReferrerID == nil || *order.ReferrerID == "" {
return 0
}
+ if order.UserID != "" && isSelfReferralBuyer(order.UserID, *order.ReferrerID) {
+ return 0
+ }
if order.ProductType == "link_karuo_tip" {
return 0
}
+ if order.ProductType == "mentor_consultation" {
+ return 0
+ }
// 读取推广配置
distributorShare := 0.9
userDiscount := 0.0
diff --git a/soul-api/internal/handler/wechat.go b/soul-api/internal/handler/wechat.go
index 789fd047..130fe227 100644
--- a/soul-api/internal/handler/wechat.go
+++ b/soul-api/internal/handler/wechat.go
@@ -1,6 +1,7 @@
package handler
import (
+ "errors"
"fmt"
"net/http"
"strings"
@@ -11,6 +12,7 @@ import (
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
+ "gorm.io/gorm"
)
// WechatLogin POST /api/wechat/login
@@ -51,13 +53,25 @@ func WechatPhoneLogin(c *gin.Context) {
db := database.DB()
var user model.User
result := db.Where("open_id = ?", openID).First(&user)
- isNewUser := result.Error != nil
+ isNewUser := errors.Is(result.Error, gorm.ErrRecordNotFound)
+ if result.Error != nil && !isNewUser {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "查询用户失败"})
+ return
+ }
if isNewUser {
// 软删除后再次登录:旧记录 id=openid 仍存在,需用新 id 避免主键冲突
- userID := "user_" + randomSuffix()
- referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
- nickname := "微信用户" + openID[len(openID)-4:]
+ // openID 可能为空/短值(微信异常回包),统一走尾部安全截断避免 panic
+ openIDLast6 := tail(openID, 6)
+ openIDLast4 := tail(openID, 4)
+ if openIDLast6 == "" {
+ openIDLast6 = strings.ToUpper(randomSuffix())
+ }
+ if openIDLast4 == "" {
+ openIDLast4 = strings.ToUpper(randomSuffix())
+ }
+ referralCode := "SOUL" + strings.ToUpper(openIDLast6)
+ nickname := "微信用户" + openIDLast4
avatar := ""
hasFullBook := false
earnings := 0.0
@@ -68,22 +82,48 @@ func WechatPhoneLogin(c *gin.Context) {
if countryCode != "" && countryCode != "86" {
phone = "+" + countryCode + " " + phoneNumber
}
- user = model.User{
- ID: userID,
- OpenID: &openID,
- SessionKey: &sessionKey,
- Nickname: &nickname,
- Avatar: &avatar,
- Phone: &phone,
- ReferralCode: &referralCode,
- HasFullBook: &hasFullBook,
- PurchasedSections: &purchasedSections,
- Earnings: &earnings,
- PendingEarnings: &pendingEarnings,
- ReferralCount: &referralCount,
+ created := false
+ for i := 0; i < 5; i++ {
+ userID := "user_" + randomSuffix()
+ curReferralCode := referralCode
+ if i > 0 {
+ suffix := strings.ToUpper(tail(randomSuffix(), 3))
+ curReferralCode = "SOUL" + strings.ToUpper(tail(openIDLast6+suffix, 6))
+ }
+ user = model.User{
+ ID: userID,
+ OpenID: &openID,
+ SessionKey: &sessionKey,
+ Nickname: &nickname,
+ Avatar: &avatar,
+ Phone: &phone,
+ ReferralCode: &curReferralCode,
+ HasFullBook: &hasFullBook,
+ PurchasedSections: &purchasedSections,
+ Earnings: &earnings,
+ PendingEarnings: &pendingEarnings,
+ ReferralCount: &referralCount,
+ }
+ if err := db.Create(&user).Error; err != nil {
+ // 并发场景:若另一请求已创建同 open_id,直接回查复用,避免 500
+ if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
+ var existed model.User
+ if qErr := db.Where("open_id = ?", openID).First(&existed).Error; qErr == nil {
+ user = existed
+ isNewUser = false
+ created = true
+ break
+ }
+ continue
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
+ return
+ }
+ created = true
+ break
}
- if err := db.Create(&user).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
+ if !created {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败,请稍后重试"})
return
}
} else {
@@ -91,7 +131,10 @@ func WechatPhoneLogin(c *gin.Context) {
if countryCode != "" && countryCode != "86" {
phone = "+" + countryCode + " " + phoneNumber
}
- db.Model(&user).Updates(map[string]interface{}{"session_key": sessionKey, "phone": phone})
+ if err := db.Model(&user).Updates(map[string]interface{}{"session_key": sessionKey, "phone": phone}).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "更新用户失败"})
+ return
+ }
user.Phone = &phone
}
@@ -130,7 +173,11 @@ func WechatPhoneLogin(c *gin.Context) {
if user.VipExpireDate != nil {
responseUser["vipExpireDate"] = user.VipExpireDate.Format("2006-01-02")
}
- token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix())
+ tokenSeed := tail(openID, 8)
+ if tokenSeed == "" {
+ tokenSeed = tail(randomSuffix(), 8)
+ }
+ token := fmt.Sprintf("tk_%s_%d", tokenSeed, time.Now().Unix())
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -143,6 +190,17 @@ func WechatPhoneLogin(c *gin.Context) {
})
}
+func tail(s string, n int) string {
+ if n <= 0 {
+ return ""
+ }
+ r := []rune(strings.TrimSpace(s))
+ if len(r) <= n {
+ return string(r)
+ }
+ return string(r[len(r)-n:])
+}
+
func strVal(p *string) string {
if p == nil {
return ""