删除过时的批处理脚本和部署Python文件

- 删除了macOS虚拟机的安装和迁移批处理脚本,因为它们已不再需要。
- 删除了macOS虚拟机的导出脚本,以简化项目流程。
- 删除了soul-admin项目的部署Python脚本,以简化代码库。
- 更新了小程序,以反映环境配置的变化并提升用户体验。
This commit is contained in:
Alex-larget
2026-03-16 16:10:30 +08:00
parent e75092eaad
commit 219ae3b843
19 changed files with 708 additions and 322 deletions

View File

@@ -1,20 +0,0 @@
$ErrorActionPreference = "Stop"
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$src = "C:\Users\29195\Mycontent\macos-vm\OneClick-macOS-Simple-KVM"
$dst = "C:\Users\29195\Mycontent\macos-vm\macos-vm-snapshot-$timestamp.zip"
Write-Host "打包目录: $src"
Write-Host "目标文件: $dst"
if (-not (Test-Path $src)) {
Write-Error "源目录不存在:$src"
}
if (Test-Path $dst) {
Remove-Item $dst -Force
}
Compress-Archive -Path $src -DestinationPath $dst -Force
Write-Host '打包完成.'

View File

@@ -1,37 +0,0 @@
@echo off
chcp 65001 >nul
title 一键启动 macOS 虚拟机 + TightVNC
echo ========================================
echo 一键启动 macOS 虚拟机(龙虾方案)
echo ========================================
echo.
set "VM_DIR=%USERPROFILE%\Mycontent\macos-vm\OneClick-macOS-Simple-KVM"
if not exist "%VM_DIR%\basic.sh" (
echo [错误] 未找到虚拟机目录: %VM_DIR%
echo 请先运行龙虾安装流程完成首次部署。
pause
exit /b 1
)
echo [1] 在新窗口启动 macOS 虚拟机8G 内存 / 4 核)...
start "macOS 虚拟机 - 勿关此窗口" wsl -d Ubuntu-24.04 -e bash -lc "cd /mnt/c/Users/%USERNAME%/Mycontent/macos-vm/OneClick-macOS-Simple-KVM && sudo HEADLESS=1 ./basic.sh"
echo [2] 等待约 10 秒后自动打开 TightVNC Viewer 连接 localhost:5900 ...
timeout /t 10 /nobreak >nul
set "TVN=%ProgramFiles%\TightVNC\tvnviewer.exe"
if not exist "%TVN%" set "TVN=%ProgramFiles(x86)%\TightVNC\tvnviewer.exe"
if exist "%TVN%" (
start "" "%TVN%" localhost::5900
echo [3] 已启动 TightVNC Viewer连接 localhost:5900
) else (
echo [3] 未找到 TightVNC请手动打开 VNC 客户端连接: localhost:5900
)
echo.
echo 虚拟机在「macOS 虚拟机 - 勿关此窗口」中运行,关闭该窗口会关闭虚拟机。
echo 本窗口可以关闭。
echo ========================================
pause

View File

@@ -1,62 +0,0 @@
@echo off
chcp 65001 >nul
title 安装 macOS 虚拟机(龙虾方案)
setlocal
echo ============================================
echo 安装 macOS 虚拟机WSL2 + QEMU + OneClick
echo ============================================
echo.
rem 1. 定位 Python 安装脚本(仓库内已有)
set "ROOT=%~dp0.."
set "PY_SCRIPT=%ROOT%开发文档\服务器管理\scripts\lobster_macos_vm.py"
if not exist "%PY_SCRIPT%" (
echo [错误] 找不到 Python 安装脚本:
echo %PY_SCRIPT%
echo 请确认仓库已完整同步。
pause
exit /b 1
)
rem 2. 检查 Python
where python >nul 2>&1
if errorlevel 1 (
where py >nul 2>&1
if errorlevel 1 (
echo [错误] 未检测到 Python 3请先安装 Python 3 再运行本脚本。
echo 将打开 Python 官网下載頁面,請安裝完後重新運行本腳本。
start "" "https://www.python.org/downloads/windows/"
pause
exit /b 1
)
)
rem 3. 检查 / 引导安装 TightVNC
echo [1] 检查 TightVNC Viewer ...
set "TVN=%ProgramFiles%\TightVNC\tvnviewer.exe"
if not exist "%TVN%" set "TVN=%ProgramFiles(x86)%\TightVNC\tvnviewer.exe"
if exist "%TVN%" (
echo 已检测到 TightVNC: %TVN%
) else (
echo 未检测到 TightVNC將打開官網請手動下載並安裝。
start "" "https://www.tightvnc.com/download.php"
)
echo.
echo [2] 開始調用 Python 腳本,一鍵安裝 macOS 虛擬機 ...
echo 腳本將檢查 WSL/Ubuntu、下載 OneClick、安裝 QEMU、下載 macOS 恢復鏡像等。
echo.
python "%PY_SCRIPT%" || py -3 "%PY_SCRIPT%"
echo.
echo [3] 安裝流程結束(若上方無紅色錯誤信息,即表示完成)。
echo 之後可使用同目錄下的「一键启动-macOS虚拟机.bat」啟動虛擬機。
echo ============================================
echo.
pause

View File

@@ -1,57 +0,0 @@
@echo off
chcp 65001 >nul
title 迁移 macOS 虚拟机 到 E:\Gongsi\Mycontent\macos-vm
echo ============================================
echo 迁移 macOS 虚拟机目录 到 E:\Gongsi\Mycontent\macos-vm
echo ============================================
echo.
setlocal
set "SRC=C:\Users\%USERNAME%\Mycontent\macos-vm"
set "DST=E:\Gongsi\Mycontent\macos-vm"
echo [1] 源目录: %SRC%
echo [1] 目标目录: %DST%
echo.
if not exist "%SRC%" (
echo [错误] 未找到源目录: %SRC%
echo 请确认当前用户下已存在 macos-vm 目录。
pause
exit /b 1
)
rem 尝试关闭正在运行的 QEMU通过 WSL
echo [2] 尝试关闭正在运行的 macOS 虚拟机(如有)...
wsl -d Ubuntu-24.04 -e bash -lc "ps -ef | grep qemu-system-x86_64 | grep -v grep || true" >nul 2>&1
for /f "tokens=2" %%P in ('wsl -d Ubuntu-24.04 -e bash -lc "ps -ef | grep qemu-system-x86_64 | grep -v grep || true" ^| find "qemu-system-x86_64"') do (
echo 检测到 QEMU 进程 PID=%%P正在尝试结束...
wsl -d Ubuntu-24.04 -e bash -lc "sudo kill -TERM %%P; sleep 2; ps -p %%P >/dev/null && sudo kill -KILL %%P || true" >nul 2>&1
)
echo [3] 创建目标目录(如不存在)...
mkdir "%DST%" 2>nul
echo [4] 使用 robocopy 迁移所有文件(可能需要一段时间)...
echo.
robocopy "%SRC%" "%DST%" /MIR
if errorlevel 8 (
echo.
echo [警告] robocopy 返回代码 %errorlevel%>=8可能有错误請檢查上方輸出。
echo 不會自動刪除源目錄,請手動確認。
pause
exit /b 1
)
echo.
echo [5] 迁移完成。
echo 源目录: %SRC%
echo 目标目录: %DST%
echo.
echo 如確認新位置可以正常啟動虛擬機,可手動刪除源目錄以釋放 C 盤空間。
echo ============================================
pause

View File

@@ -8,8 +8,8 @@ const { parseScene } = require('./utils/scene.js')
App({
globalData: {
// API 基础地址(切换环境时注释/取消注释)
baseUrl: 'https://soulapi.quwanzhi.com',
// baseUrl: 'http://localhost:8080', // 本地调试
// baseUrl: 'https://soulapi.quwanzhi.com',
baseUrl: 'http://localhost:8080', // 本地调试
// baseUrl: 'https://souldev.quwanzhi.com', // 测试环境

View File

@@ -18,6 +18,7 @@ Page({
isVip: false,
avatar: '',
nickname: '',
shareCardPath: '', // 分享名片封面图(预生成)
mbti: '',
mbtiIndex: 0,
region: '',
@@ -40,8 +41,15 @@ Page({
showAvatarModal: false,
},
onLoad() {
onLoad(options) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
wx.showShareMenu({ withShareTimeline: true })
// 从朋友圈/分享打开且带 id跳转到名片详情member-detail
if (options?.id) {
const ref = options.ref ? `&ref=${options.ref}` : ''
wx.redirectTo({ url: `/pages/member-detail/member-detail?id=${options.id}${ref}` })
return
}
this.loadProfile()
},
@@ -85,6 +93,7 @@ Page({
projectIntro: v('projectIntro'),
loading: false,
})
setTimeout(() => this.generateShareCard(), 200)
} else {
this.setData({ loading: false })
}
@@ -95,17 +104,128 @@ Page({
goBack() { getApp().goBackOrToHome() },
// 生成分享名片封面图(头像+昵称5:4 比例)
async generateShareCard() {
const { avatar, nickname } = this.data
const userId = app.globalData.userInfo?.id
if (!userId) return
try {
const ctx = wx.createCanvasContext('shareCardCanvas', this)
const w = 500
const h = 400
// 背景渐变Soul 深色风格)
const grd = ctx.createLinearGradient(0, 0, w, h)
grd.addColorStop(0, '#0F172A')
grd.addColorStop(0.5, '#050B14')
grd.addColorStop(1, '#0F172A')
ctx.setFillStyle(grd)
ctx.fillRect(0, 0, w, h)
// 顶部装饰条
ctx.setFillStyle('#5EEAD4')
ctx.fillRect(0, 0, w, 4)
// 头像:居中偏上,圆形 120px
const avatarSize = 120
const avatarX = (w - avatarSize) / 2
const avatarY = 80
const avatarRadius = avatarSize / 2
const drawAvatar = () => new Promise((resolve) => {
if (avatar && avatar.startsWith('http')) {
wx.downloadFile({
url: avatar,
success: (res) => {
if (res.statusCode === 200) {
ctx.save()
ctx.beginPath()
ctx.arc(avatarX + avatarRadius, avatarY + avatarRadius, avatarRadius, 0, Math.PI * 2)
ctx.clip()
ctx.drawImage(res.tempFilePath, avatarX, avatarY, avatarSize, avatarSize)
ctx.restore()
} else {
this.drawAvatarPlaceholder(ctx, avatarX, avatarY, avatarSize, nickname)
}
resolve()
},
fail: () => {
this.drawAvatarPlaceholder(ctx, avatarX, avatarY, avatarSize, nickname)
resolve()
},
})
} else {
this.drawAvatarPlaceholder(ctx, avatarX, avatarY, avatarSize, nickname)
resolve()
}
})
await drawAvatar()
// 头像外圈描边
ctx.setStrokeStyle('#5EEAD4')
ctx.setLineWidth(3)
ctx.beginPath()
ctx.arc(avatarX + avatarRadius, avatarY + avatarRadius, avatarRadius, 0, Math.PI * 2)
ctx.stroke()
// 昵称
const displayName = (nickname || '').trim() || '创业者'
ctx.setFillStyle('#ffffff')
ctx.setFontSize(24)
ctx.setTextAlign('center')
ctx.fillText(displayName, w / 2, avatarY + avatarSize + 50)
// 副标题
ctx.setFillStyle('rgba(94,234,212,0.9)')
ctx.setFontSize(14)
ctx.fillText('Soul创业派对 · 名片', w / 2, avatarY + avatarSize + 78)
ctx.draw(true, () => {
wx.canvasToTempFilePath({
canvasId: 'shareCardCanvas',
destWidth: 500,
destHeight: 400,
success: (res) => {
this.setData({ shareCardPath: res.tempFilePath })
},
}, this)
})
} catch (e) {
console.warn('[ShareCard] 生成失败:', e)
}
},
drawAvatarPlaceholder(ctx, x, y, size, nickname) {
ctx.setFillStyle('rgba(94,234,212,0.25)')
ctx.beginPath()
ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2)
ctx.fill()
ctx.setFillStyle('#5EEAD4')
ctx.setFontSize(size * 0.45)
ctx.setTextAlign('center')
ctx.fillText((nickname || '?')[0], x + size / 2, y + size / 2 + size * 0.15)
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 编辑资料',
path: ref ? `/pages/profile-edit/profile-edit?ref=${ref}` : '/pages/profile-edit/profile-edit'
const userId = app.globalData.userInfo?.id
const nickname = (this.data.nickname || '').trim() || '我'
const path = userId
? (ref ? `/pages/member-detail/member-detail?id=${userId}&ref=${ref}` : `/pages/member-detail/member-detail?id=${userId}`)
: (ref ? `/pages/profile-edit/profile-edit?ref=${ref}` : '/pages/profile-edit/profile-edit')
const result = {
title: `${nickname}为您分享名片`,
path,
}
if (this.data.shareCardPath) result.imageUrl = this.data.shareCardPath
return result
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - 编辑资料', query: ref ? `ref=${ref}` : '' }
const userId = app.globalData.userInfo?.id
const nickname = (this.data.nickname || '').trim() || '我'
const query = userId
? (ref ? `id=${userId}&ref=${ref}` : `id=${userId}`)
: (ref ? `ref=${ref}` : '')
const result = {
title: `${nickname}为您分享名片`,
query: query || '',
}
if (this.data.shareCardPath) result.imageUrl = this.data.shareCardPath
return result
},
onNicknameInput(e) { this.setData({ nickname: e.detail.value }) },
@@ -186,6 +306,7 @@ Page({
}
wx.hideLoading()
wx.showToast({ title: '头像已更新', icon: 'success' })
setTimeout(() => this.generateShareCard(), 200)
} catch (e) {
wx.hideLoading()
wx.showToast({ title: e.message || '上传失败', icon: 'none' })
@@ -234,6 +355,7 @@ Page({
}
wx.hideLoading()
wx.showToast({ title: '头像已更新', icon: 'success' })
setTimeout(() => this.generateShareCard(), 200)
} catch (err) {
wx.hideLoading()
wx.showToast({ title: err.message || '上传失败,请重试', icon: 'none' })

View File

@@ -146,6 +146,9 @@
<view class="bottom-space"></view>
</scroll-view>
<!-- 分享名片 canvas隐藏用于生成分享图 5:4 -->
<canvas canvas-id="shareCardCanvas" class="share-card-canvas" style="width: 500px; height: 400px;"></canvas>
<!-- 头像弹窗:通过 button 获取微信头像 -->
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
<view class="modal-content avatar-modal" catchtap="stopPropagation">

View File

@@ -1,4 +1,9 @@
/* 资料编辑 - comprehensive_profile_editor_v1_1 | 配色 enhancedinput/textarea 用 view 包裹 */
/* 分享名片 canvas隐藏仅用于生成图片 */
.share-card-canvas {
position: fixed; left: -9999px; top: 0; width: 500px; height: 400px;
}
.page {
background: #050B14; min-height: 100vh; color: #fff;
width: 100%; box-sizing: border-box; overflow-x: hidden;

View File

@@ -23,12 +23,19 @@
"condition": {
"miniprogram": {
"list": [
{
"name": "pages/read/read",
"pathName": "pages/read/read",
"query": "mid=219",
"scene": null,
"launchMode": "default"
},
{
"name": "唤醒",
"pathName": "pages/read/read",
"query": "mid=209",
"scene": null,
"launchMode": "default"
"launchMode": "default",
"scene": null
},
{
"name": "pages/my/my",

318
soul-admin/deploy.py Normal file
View File

@@ -0,0 +1,318 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
soul-admin-dev 静态站点部署:打包 dist → 上传 → 解压到 dist2 → dist/dist2 互换实现无缝切换。
不安装依赖、不重启、不调用宝塔 API。
"""
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 → dist1dist2 → dist ====================
def remote_swap_dist(cfg):
"""服务器上dist→dist1dist2→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
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-BQTdJZ32.js"></script>
<script type="module" crossorigin src="/assets/index-C2K6IQif.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BHi-SnBy.css">
</head>
<body>

View File

@@ -48,7 +48,7 @@ def get_cfg():
def run_build(root):
"""执行本地 pnpm build"""
"""执行本地 pnpm build(使用 .env.production 正式环境配置)"""
use_shell = sys.platform == "win32"
dist_dir = os.path.join(root, "dist")
index_html = os.path.join(dist_dir, "index.html")

View File

@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:dev": "tsc -b && vite build --mode development",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx"
},

View File

@@ -125,6 +125,11 @@ interface EditingSection {
editionPremium?: boolean
}
/** 去除名字中的括号及内容,名字不带符号(如 南风(管理) → 南风) */
function sanitizeNameOrLabel(s: string): string {
return s.replace(/\s*[(][^)]*(\)|)?/g, '').trim()
}
// 在保存前自动把纯文本中的 @用户 / #标签 转成带 token / 配置的节点
function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagItem[]): string {
if (!html || (!html.includes('@') && !html.includes('#'))) return html
@@ -134,10 +139,10 @@ function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagI
container.innerHTML = html
const matchPerson = (name: string): PersonItem | undefined =>
persons.find((p) => p.name === name)
persons.find((p) => p.name === name || sanitizeNameOrLabel(p.name) === sanitizeNameOrLabel(name))
const matchTag = (label: string): LinkTagItem | undefined =>
linkTags.find((t) => t.label === label)
linkTags.find((t) => t.label === label || sanitizeNameOrLabel(t.label) === sanitizeNameOrLabel(label))
const processTextNode = (node: Text) => {
const text = node.textContent || ''
@@ -147,7 +152,8 @@ function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagI
if (!parent) return
const frag = document.createDocumentFragment()
const regex = /(@[^\s@#]+|#[^\s@#]+)/g
// 排除 <> 避免把 HTML 标签带入 @/# 匹配(如 @远志</span> 只匹配 @远志)
const regex = /(@[^\s@#<>]+|#[^\s@#<>]+)/g
let lastIndex = 0
let match: RegExpExecArray | null
@@ -160,8 +166,8 @@ function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagI
}
if (full.startsWith('@')) {
const name = full.slice(1)
const person = matchPerson(name)
const rawName = full.slice(1)
const person = matchPerson(rawName)
if (person) {
const span = document.createElement('span')
span.setAttribute('data-type', 'mention')
@@ -173,8 +179,8 @@ function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagI
frag.appendChild(document.createTextNode(full))
}
} else if (full.startsWith('#')) {
const label = full.slice(1)
const tag = matchTag(label)
const rawLabel = full.slice(1)
const tag = matchTag(rawLabel)
if (tag) {
const span = document.createElement('span')
span.setAttribute('data-type', 'linkTag')
@@ -210,8 +216,17 @@ function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagI
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement
const dataType = el.getAttribute('data-type')
// 已经是 mention/linkTag 的节点不再解析
if (dataType === 'mention' || dataType === 'linkTag') return
// mention 节点:若 data-id 为空,按昵称匹配 persons 回填(修复 TipTap 插入时 id 丢失导致小程序 data-user-id 为空)
if (dataType === 'mention') {
const existingId = el.getAttribute('data-id')
const nickname = (el.getAttribute('data-label') || el.textContent || '').replace(/^@/, '').trim()
if ((!existingId || !existingId.trim()) && nickname) {
const person = matchPerson(nickname)
if (person?.id) el.setAttribute('data-id', person.id)
}
return
}
if (dataType === 'linkTag') return
node.childNodes.forEach((child) => walk(child))
return
}
@@ -591,19 +606,20 @@ export function ContentPage() {
/** 文章编辑时自动创建不存在的 @人物 和 #标签,返回合并后的列表供 autoLinkContent 使用 */
const ensureMentionsAndTags = useCallback(
async (content: string): Promise<{ persons: PersonItem[]; linkTags: LinkTagItem[] }> => {
const regex = /(@[^\s@#]+|#[^\s@#]+)/g
// 排除 <> 避免把 HTML 标签带入 @/# 匹配
const regex = /(@[^\s@#<>]+|#[^\s@#<>]+)/g
const names = new Set<string>()
const labels = new Set<string>()
let m: RegExpExecArray | null
while ((m = regex.exec(content)) !== null) {
const full = m[0]
if (full.startsWith('@')) names.add(full.slice(1).trim())
else if (full.startsWith('#')) labels.add(full.slice(1).trim())
if (full.startsWith('@')) names.add(sanitizeNameOrLabel(full.slice(1)))
else if (full.startsWith('#')) labels.add(sanitizeNameOrLabel(full.slice(1)))
}
let personsCopy = [...persons]
let linkTagsCopy = [...linkTags]
for (const name of names) {
if (!name || personsCopy.some((p) => p.name === name)) continue
if (!name || personsCopy.some((p) => p.name === name || sanitizeNameOrLabel(p.name) === name)) continue
try {
const res = await post<{
success?: boolean
@@ -625,7 +641,7 @@ export function ContentPage() {
}
}
for (const label of labels) {
if (!label || linkTagsCopy.some((t) => t.label === label)) continue
if (!label || linkTagsCopy.some((t) => t.label === label || sanitizeNameOrLabel(t.label) === label)) continue
try {
const res = await post<{
success?: boolean
@@ -2488,6 +2504,7 @@ export function ContentPage() {
</Button>
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 h-6 px-2" title="删除(同时删除存客宝对应获客计划)" onClick={async () => {
if (!confirm(`确定删除「SOUL链接人与事-${p.name}」?将同时删除存客宝对应获客计划。`)) return
if (!confirm(`二次确认:删除后无法恢复,文章中的 @${p.name} 将无法正常跳转。确定删除?`)) return
await del(`/api/db/persons?personId=${p.personId}`)
loadPersons()
}}>

View File

@@ -38,10 +38,11 @@ func main() {
Handler: r,
}
// 预热 all-chapters 缓存,避免首请求冷启动 502
// 预热 all-chapters、book/parts 缓存,避免首请求冷启动 502
go func() {
time.Sleep(2 * time.Second) // 等 DB 完全就绪
handler.WarmAllChaptersCache()
handler.WarmBookPartsCache()
}()
go func() {

View File

@@ -1,16 +1,16 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
soulApp (soul-api) Go 项目一键部署到宝塔(正式环境)
- 打包使用 .env.production 作为服务器 .env
soul-api Go 项目一键部署到宝塔(测试环境),重启的是宝塔里的 soulDev 项目
- 打包使用 .env.development 作为服务器 .env
- 本地交叉编译 Linux 二进制
- 上传到 /www/wwwroot/self/soul-api
- 重启:优先宝塔 API需配置否则 SSH 下 setsid nohup 启动
- 上传到 /www/wwwroot/self/soul-dev
- 重启 soulDev:优先宝塔 API需配置否则 SSH 下 setsid nohup 启动
宝塔 API 重启(可选):在环境变量或 .env 中设置
BT_PANEL_URL = https://你的面板地址:9988
BT_API_KEY = 面板 设置 -> API 接口 中的密钥
BT_GO_PROJECT_NAME = soulApi (与宝塔 Go 项目列表里名称一致)
BT_GO_PROJECT_NAME = soulDev (与宝塔 Go 项目列表里名称一致)
并安装 requests: pip install requests
"""
@@ -45,7 +45,7 @@ except ImportError:
# ==================== 配置 ====================
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-api"
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-dev"
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
@@ -66,7 +66,7 @@ def get_cfg():
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
"bt_panel_url": bt_url,
"bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT),
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulApi"),
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulDev"),
}
@@ -119,7 +119,7 @@ def run_build(root):
# ==================== 打包 ====================
DEPLOY_PORT = 8080
DEPLOY_PORT = 8081
def set_env_port(env_path, port=DEPLOY_PORT):
@@ -171,11 +171,11 @@ def pack_deploy(root, binary_path, include_env=True):
staging = tempfile.mkdtemp(prefix="soul_api_deploy_")
try:
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
env_src = os.path.join(root, ".env.production")
env_src = os.path.join(root, ".env.development")
staging_env = os.path.join(staging, ".env")
if include_env and os.path.isfile(env_src):
shutil.copy2(env_src, staging_env)
print(" [已包含] .env.production -> .env")
print(" [已包含] .env.development -> .env")
else:
env_example = os.path.join(root, ".env.example")
if os.path.isfile(env_example):
@@ -183,8 +183,8 @@ def pack_deploy(root, binary_path, include_env=True):
print(" [已包含] .env.example -> .env (请服务器上检查配置)")
if os.path.isfile(staging_env):
set_env_port(staging_env, DEPLOY_PORT)
set_env_mini_program_state(staging_env, "formal")
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=formal正式环境)" % DEPLOY_PORT)
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):
@@ -205,7 +205,7 @@ def restart_via_bt_api(cfg):
"""通过宝塔 API 重启 Go 项目(需配置 BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME"""
url = cfg.get("bt_panel_url") or ""
key = cfg.get("bt_api_key") or ""
name = cfg.get("bt_go_project_name", "soulApi")
name = cfg.get("bt_go_project_name", "soulDev")
if not url or not key:
return False
if not requests:
@@ -295,7 +295,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
print(" [成功] 已解压到: %s" % project_path)
if not no_restart:
print("[4/4] 重启 soulApp 服务 ...")
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)
@@ -315,7 +315,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
print(" [stderr] %s" % err[:200])
ok = "RESTART_OK" in out
if ok:
print(" [成功] soulApp 已通过 SSH 重启")
print(" [成功] soulDev 已通过 SSH 重启")
else:
print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path)
else:
@@ -334,7 +334,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
def main():
parser = argparse.ArgumentParser(
description="soulApp (soul-api) Go 项目一键部署到宝塔",
description="soul-api 一键部署到宝塔,重启 soulDev 项目",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--no-build", action="store_true", help="跳过本地编译(使用已有 soul-api 二进制)")
@@ -353,7 +353,7 @@ def main():
cfg = get_cfg()
print("=" * 60)
print(" soulApp 一键部署到宝塔")
print(" soul-api 部署到宝塔,重启 soulDev")
print("=" * 60)
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
print(" 目标目录: %s" % cfg["project_path"])

View File

@@ -54,6 +54,30 @@ var allChaptersCache struct {
const allChaptersCacheTTL = 30 * time.Second
// bookPartsCache 目录接口内存缓存30 秒 TTL减轻 DB 压力
type cachedPartRow struct {
PartID string `json:"id"`
PartTitle string `json:"title"`
Subtitle string `json:"subtitle"`
ChapterCount int `json:"chapterCount"`
MinSortOrder int `json:"minSortOrder"`
}
type cachedFixedItem struct {
ID string `json:"id"`
MID int `json:"mid"`
SectionTitle string `json:"title"`
}
var bookPartsCache struct {
mu sync.RWMutex
parts []cachedPartRow
total int64
fixed []cachedFixedItem
expires time.Time
}
const bookPartsCacheTTL = 30 * time.Second
// WarmAllChaptersCache 启动时预热缓存,避免首请求冷启动 502
func WarmAllChaptersCache() {
db := database.DB()
@@ -79,6 +103,86 @@ func WarmAllChaptersCache() {
allChaptersCache.mu.Unlock()
}
// fetchAndCacheBookParts 执行 DB 查询并更新缓存,供 BookParts 与 WarmBookPartsCache 复用
func fetchAndCacheBookParts() (parts []cachedPartRow, total int64, fixed []cachedFixedItem) {
db := database.DB()
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
conds := make([]string, len(excludeParts))
args := make([]interface{}, len(excludeParts))
for i, p := range excludeParts {
conds[i] = "part_title LIKE ?"
args[i] = "%" + p + "%"
}
where := "(" + strings.Join(conds, " OR ") + ")"
var rows []model.Chapter
if err := db.Model(&model.Chapter{}).Select("id", "mid", "section_title", "sort_order").
Where(where, args...).
Order("COALESCE(sort_order, 999999) ASC, id ASC").
Find(&rows).Error; err == nil {
sortChaptersByNaturalID(rows)
for _, r := range rows {
fixed = append(fixed, cachedFixedItem{r.ID, r.MID, r.SectionTitle})
}
}
}()
where := "1=1"
args := []interface{}{}
for _, p := range excludeParts {
where += " AND part_title NOT LIKE ?"
args = append(args, "%"+p+"%")
}
sql := `SELECT part_id, part_title, '' as subtitle,
COUNT(DISTINCT chapter_id) as chapter_count,
MIN(COALESCE(sort_order, 999999)) as min_sort
FROM chapters WHERE ` + where + `
GROUP BY part_id, part_title ORDER BY min_sort ASC, part_id ASC`
var raw []struct {
PartID string `gorm:"column:part_id"`
PartTitle string `gorm:"column:part_title"`
Subtitle string `gorm:"column:subtitle"`
ChapterCount int `gorm:"column:chapter_count"`
MinSortOrder int `gorm:"column:min_sort"`
}
go func() {
defer wg.Done()
db.Raw(sql, args...).Scan(&raw)
}()
go func() {
defer wg.Done()
db.Model(&model.Chapter{}).Count(&total)
}()
wg.Wait()
parts = make([]cachedPartRow, len(raw))
for i, r := range raw {
parts[i] = cachedPartRow{
PartID: r.PartID, PartTitle: r.PartTitle, Subtitle: r.Subtitle,
ChapterCount: r.ChapterCount, MinSortOrder: r.MinSortOrder,
}
}
bookPartsCache.mu.Lock()
bookPartsCache.parts = parts
bookPartsCache.total = total
bookPartsCache.fixed = fixed
bookPartsCache.expires = time.Now().Add(bookPartsCacheTTL)
bookPartsCache.mu.Unlock()
return parts, total, fixed
}
// WarmBookPartsCache 启动时预热目录缓存,避免首请求慢
func WarmBookPartsCache() {
fetchAndCacheBookParts()
}
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
// 免费判断system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
@@ -144,78 +248,30 @@ func BookChapterByID(c *gin.Context) {
// BookParts GET /api/miniprogram/book/parts 目录懒加载:仅返回篇章列表,不含章节详情
// 返回 parts排除序言/尾声/附录、totalSections、fixedSectionsid, mid, title 供序言/尾声/附录跳转用 mid
// 带 30 秒内存缓存,固定模块合并为 1 次查询,三路并行执行
func BookParts(c *gin.Context) {
db := database.DB()
// 固定模块(序言、尾声、附录)的 id、mid、title供 goToRead 传 data-mid
var fixedList []struct {
ID string `json:"id"`
MID int `json:"mid"`
SectionTitle string `json:"title"`
}
for _, p := range excludeParts {
var rows []model.Chapter
if err := db.Model(&model.Chapter{}).Select("id", "mid", "section_title", "sort_order").
Where("part_title LIKE ?", "%"+p+"%").
Order("COALESCE(sort_order, 999999) ASC, id ASC").
Find(&rows).Error; err != nil {
continue
}
sortChaptersByNaturalID(rows)
for _, r := range rows {
fixedList = append(fixedList, struct {
ID string `json:"id"`
MID int `json:"mid"`
SectionTitle string `json:"title"`
}{r.ID, r.MID, r.SectionTitle})
}
}
// 中间篇章:轻量聚合,不拉取 content
type partRow struct {
PartID string `json:"id"`
PartTitle string `json:"title"`
Subtitle string `json:"subtitle"`
ChapterCount int `json:"chapterCount"`
MinSortOrder int `json:"minSortOrder"`
}
where := "1=1"
args := []interface{}{}
for _, p := range excludeParts {
where += " AND part_title NOT LIKE ?"
args = append(args, "%"+p+"%")
}
var raw []struct {
PartID string `gorm:"column:part_id"`
PartTitle string `gorm:"column:part_title"`
Subtitle string `gorm:"column:subtitle"`
ChapterCount int `gorm:"column:chapter_count"`
MinSortOrder int `gorm:"column:min_sort"`
}
sql := `SELECT part_id, part_title, '' as subtitle,
COUNT(DISTINCT chapter_id) as chapter_count,
MIN(COALESCE(sort_order, 999999)) as min_sort
FROM chapters WHERE ` + where + `
GROUP BY part_id, part_title ORDER BY min_sort ASC, part_id ASC`
if err := db.Raw(sql, args...).Scan(&raw).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "parts": []interface{}{}, "totalSections": 0, "fixedSections": fixedList})
bookPartsCache.mu.RLock()
if time.Now().Before(bookPartsCache.expires) {
parts := bookPartsCache.parts
total := bookPartsCache.total
fixed := bookPartsCache.fixed
bookPartsCache.mu.RUnlock()
c.JSON(http.StatusOK, gin.H{
"success": true,
"parts": parts,
"totalSections": total,
"fixedSections": fixed,
})
return
}
parts := make([]partRow, len(raw))
for i, r := range raw {
parts[i] = partRow{
PartID: r.PartID, PartTitle: r.PartTitle, Subtitle: r.Subtitle,
ChapterCount: r.ChapterCount, MinSortOrder: r.MinSortOrder,
}
}
var total int64
db.Model(&model.Chapter{}).Count(&total)
bookPartsCache.mu.RUnlock()
parts, total, fixed := fetchAndCacheBookParts()
c.JSON(http.StatusOK, gin.H{
"success": true,
"parts": parts,
"totalSections": total,
"fixedSections": fixedList,
"fixedSections": fixed,
})
}

View File

@@ -1,16 +1,16 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
soul-api Go 项目一键部署到宝塔测试环境重启的是宝塔里的 soulDev 项目
- 打包使用 .env.development 作为服务器 .env
soulApp (soul-api) Go 项目一键部署到宝塔正式环境
- 打包使用 .env.production 作为服务器 .env
- 本地交叉编译 Linux 二进制
- 上传到 /www/wwwroot/self/soul-dev
- 重启 soulDev优先宝塔 API需配置否则 SSH setsid nohup 启动
- 上传到 /www/wwwroot/self/soul-api
- 重启优先宝塔 API需配置否则 SSH setsid nohup 启动
宝塔 API 重启可选在环境变量或 .env 中设置
BT_PANEL_URL = https://你的面板地址:9988
BT_API_KEY = 面板 设置 -> API 接口 中的密钥
BT_GO_PROJECT_NAME = soulDev 与宝塔 Go 项目列表里名称一致
BT_GO_PROJECT_NAME = soulApi 与宝塔 Go 项目列表里名称一致
并安装 requests: pip install requests
"""
@@ -45,7 +45,7 @@ except ImportError:
# ==================== 配置 ====================
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-dev"
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-api"
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
@@ -66,7 +66,7 @@ def get_cfg():
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
"bt_panel_url": bt_url,
"bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT),
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulDev"),
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulApi"),
}
@@ -119,7 +119,7 @@ def run_build(root):
# ==================== 打包 ====================
DEPLOY_PORT = 8081
DEPLOY_PORT = 8080
def set_env_port(env_path, port=DEPLOY_PORT):
@@ -171,11 +171,11 @@ def pack_deploy(root, binary_path, include_env=True):
staging = tempfile.mkdtemp(prefix="soul_api_deploy_")
try:
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
env_src = os.path.join(root, ".env.development")
env_src = os.path.join(root, ".env.production")
staging_env = os.path.join(staging, ".env")
if include_env and os.path.isfile(env_src):
shutil.copy2(env_src, staging_env)
print(" [已包含] .env.development -> .env")
print(" [已包含] .env.production -> .env")
else:
env_example = os.path.join(root, ".env.example")
if os.path.isfile(env_example):
@@ -183,8 +183,8 @@ def pack_deploy(root, binary_path, include_env=True):
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)
set_env_mini_program_state(staging_env, "formal")
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=formal正式环境)" % DEPLOY_PORT)
tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz")
with tarfile.open(tarball, "w:gz") as tf:
for name in os.listdir(staging):
@@ -205,7 +205,7 @@ def restart_via_bt_api(cfg):
"""通过宝塔 API 重启 Go 项目(需配置 BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME"""
url = cfg.get("bt_panel_url") or ""
key = cfg.get("bt_api_key") or ""
name = cfg.get("bt_go_project_name", "soulDev")
name = cfg.get("bt_go_project_name", "soulApi")
if not url or not key:
return False
if not requests:
@@ -255,32 +255,57 @@ def restart_via_bt_api(cfg):
# ==================== SSH 上传 ====================
def _connect_ssh(cfg):
"""建立 SSH 连接,启用 keepalive 防大文件上传时断连"""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
client.connect(
cfg["host"], port=DEFAULT_SSH_PORT,
username=cfg["user"], key_filename=cfg["ssh_key"],
timeout=15,
)
else:
client.connect(
cfg["host"], port=DEFAULT_SSH_PORT,
username=cfg["user"], password=cfg["password"],
timeout=15,
)
transport = client.get_transport()
if transport:
transport.set_keepalive(15)
return client
def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto"):
"""上传 tar.gz 到服务器并解压、重启"""
print("[3/4] SSH 上传并解压 ...")
if not cfg.get("password") and not cfg.get("ssh_key"):
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return False
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
remote_tar = "/tmp/soul_api_deploy.tar.gz"
project_path = cfg["project_path"]
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,
)
sftp = client.open_sftp()
remote_tar = "/tmp/soul_api_deploy.tar.gz"
project_path = cfg["project_path"]
sftp.put(tarball_path, remote_tar)
sftp.close()
# SFTP 上传易因网络抖动 EOF失败时重连并重试最多 3 次
for attempt in range(1, 4):
try:
if client:
try:
client.close()
except Exception:
pass
client = _connect_ssh(cfg)
sftp = client.open_sftp()
sftp.put(tarball_path, remote_tar)
sftp.close()
break
except (EOFError, ConnectionResetError, OSError) as e:
if attempt < 3:
print(" [重试 %d/3] 上传中断: %s5 秒后重连 ..." % (attempt, e))
time.sleep(5)
else:
raise
cmd = (
"mkdir -p %s && cd %s && tar -xzf %s && "
@@ -295,7 +320,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
print(" [成功] 已解压到: %s" % project_path)
if not no_restart:
print("[4/4] 重启 soulDev 服务 ...")
print("[4/4] 重启 soulApp 服务 ...")
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)
@@ -315,7 +340,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
print(" [stderr] %s" % err[:200])
ok = "RESTART_OK" in out
if ok:
print(" [成功] soulDev 已通过 SSH 重启")
print(" [成功] soulApp 已通过 SSH 重启")
else:
print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path)
else:
@@ -323,10 +348,17 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
return True
except Exception as e:
print(" [失败] SSH 错误:", str(e))
err_msg = str(e) or repr(e) or type(e).__name__
print(" [失败] SSH 错误:", err_msg)
import traceback
traceback.print_exc()
return False
finally:
client.close()
if client:
try:
client.close()
except Exception:
pass
# ==================== 主函数 ====================
@@ -334,7 +366,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
def main():
parser = argparse.ArgumentParser(
description="soul-api 一键部署到宝塔,重启 soulDev 项目",
description="soulApp (soul-api) Go 项目一键部署到宝塔",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--no-build", action="store_true", help="跳过本地编译(使用已有 soul-api 二进制)")
@@ -353,7 +385,7 @@ def main():
cfg = get_cfg()
print("=" * 60)
print(" soul-api 部署到宝塔,重启 soulDev")
print(" soulApp 一键部署到宝塔")
print("=" * 60)
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
print(" 目标目录: %s" % cfg["project_path"])