特性:在内容展示中增加视频支持

- 在小程序阅读页面中实现了视频渲染,支持单个视频项目和内嵌视频片段。
- 更新了内容解析器,以处理视频标签并提取视频源。
- 增强了视频元素的样式,以确保其正确显示和响应性。

制造材料:Cursor
This commit is contained in:
乘风
2026-04-14 16:14:21 +08:00
parent 35e18a7ccb
commit 5010e2dfba
4 changed files with 347 additions and 98 deletions

View File

@@ -78,10 +78,14 @@
<view wx:elif="{{item.length === 1 && item[0].type === 'image'}}" class="paragraph">
<image class="content-image" src="{{item[0].src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{item[0].src}}"></image>
</view>
<view wx:elif="{{item.length === 1 && item[0].type === 'video'}}" class="paragraph content-video-wrap">
<video class="content-video" src="{{item[0].src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video>
</view>
<view wx:else class="paragraph">
<text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text>
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
<image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
<video wx:elif="{{seg.type === 'video'}}" class="content-video content-video--inline" src="{{seg.src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video>
</block>
</view>
</block>
@@ -153,7 +157,8 @@
<scroll-view wx:elif="{{item.length === 1 && item[0].type === 'table'}}" scroll-x class="table-scroll"><view class="content-table"><view class="table-row table-header-row" wx:if="{{item[0].headers.length}}"><text class="table-cell table-header-cell" wx:for="{{item[0].headers}}" wx:key="index" wx:for-item="hdr">{{hdr}}</text></view><view class="table-row" wx:for="{{item[0].rows}}" wx:key="index" wx:for-item="row"><text class="table-cell" wx:for="{{row}}" wx:key="index" wx:for-item="cell">{{cell}}</text></view></view></scroll-view>
<view wx:elif="{{item.length === 1 && item[0].type === 'listItem'}}" class="seg-list-item"><text class="seg-list-marker" wx:if="{{item[0].ordered}}">{{item[0].number}}.</text><text class="seg-list-marker" wx:else>•</text><text class="seg-list-text" user-select>{{item[0].text}}</text></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'image'}}" class="paragraph"><image class="content-image" src="{{item[0].src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{item[0].src}}"></image></view>
<view wx:else class="paragraph"><text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image></block></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'video'}}" class="paragraph content-video-wrap"><video class="content-video" src="{{item[0].src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></view>
<view wx:else class="paragraph"><text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image><video wx:elif="{{seg.type === 'video'}}" class="content-video content-video--inline" src="{{seg.src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></block></view>
</block>
</view>
<view class="fade-mask"></view>
@@ -245,7 +250,8 @@
<scroll-view wx:elif="{{item.length === 1 && item[0].type === 'table'}}" scroll-x class="table-scroll"><view class="content-table"><view class="table-row table-header-row" wx:if="{{item[0].headers.length}}"><text class="table-cell table-header-cell" wx:for="{{item[0].headers}}" wx:key="index" wx:for-item="hdr">{{hdr}}</text></view><view class="table-row" wx:for="{{item[0].rows}}" wx:key="index" wx:for-item="row"><text class="table-cell" wx:for="{{row}}" wx:key="index" wx:for-item="cell">{{cell}}</text></view></view></scroll-view>
<view wx:elif="{{item.length === 1 && item[0].type === 'listItem'}}" class="seg-list-item"><text class="seg-list-marker" wx:if="{{item[0].ordered}}">{{item[0].number}}.</text><text class="seg-list-marker" wx:else>•</text><text class="seg-list-text" user-select>{{item[0].text}}</text></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'image'}}" class="paragraph"><image class="content-image" src="{{item[0].src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{item[0].src}}"></image></view>
<view wx:else class="paragraph"><text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image></block></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'video'}}" class="paragraph content-video-wrap"><video class="content-video" src="{{item[0].src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></view>
<view wx:else class="paragraph"><text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image><video wx:elif="{{seg.type === 'video'}}" class="content-video content-video--inline" src="{{seg.src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></block></view>
</block>
</view>
<view class="fade-mask"></view>

View File

@@ -290,6 +290,22 @@
display: block;
}
.content-video-wrap {
width: 100%;
margin: 24rpx 0;
}
.content-video {
width: 100%;
display: block;
border-radius: 12rpx;
background: #111;
}
.content-video--inline {
margin: 24rpx 0;
}
/* 正文内表格 */
.table-scroll {
margin: 24rpx 0 36rpx;

View File

@@ -7,6 +7,7 @@
* { type: 'mention', userId, nickname } — @某人,点击加好友(提交存客宝见 utils/soulBridge.submitCkbLead
* { type: 'linkTag', label, url, ... } — #链接标签,点击跳转(阅读页 onLinkTagTap外链→link-preview、小程序→navigateToMiniProgram
* { type: 'image', src, alt } — 图片
* { type: 'video', src } — 内嵌视频(管理端 rich-video-wrap / <video>
*/
/** 判断内容是否为 HTML */
@@ -56,6 +57,24 @@ function extractImgSrcFromTag(tag) {
return ''
}
/** 从 <video ...> 或包含 video 的 HTML 片段取出首个 src */
function extractVideoSrcFromTag(tag) {
const vm = tag.match(/<video[^>]*>/i)
const chunk = vm ? vm[0] : tag
return extractImgSrcFromTag(chunk)
}
function pushResolvedVideoSrc(videos, rawSrc, config) {
if (!rawSrc || typeof rawSrc !== 'string') return
const decoded = decodeEntities(rawSrc.trim())
if (!decoded) return
const src =
config && config.assetBase
? resolveArticleImageSrc(decoded, config.assetBase)
: resolveArticleImageSrc(decoded, '')
videos.push({ src })
}
/**
* 单行展示用:昵称、#标签文案、章节外标题类字段 — 合并换行、<br>、连续空白(避免 TipTap/粘贴带入异常断行)
*/
@@ -81,7 +100,7 @@ function stripTrailingAtForMention(before) {
/**
* 将一个 HTML block 字符串解析为 segments 数组
* 处理三种内联元素mention / linkTag(span) / linkTag(a) / img
* 处理内联元素mention / linkTag(span) / linkTag(a) / img / video
*/
function parseBlockToSegments(block, config) {
const segs = []
@@ -92,7 +111,7 @@ function parseBlockToSegments(block, config) {
if (token) personTokenSet.add(token)
}
// 合并匹配所有内联元素
const tokenRe = /<span[^>]*data-type="mention"[^>]*>[\s\S]*?<\/span>|<span[^>]*data-type="linkTag"[^>]*>[\s\S]*?<\/span>|<a[^>]*href="([^"]*)"[^>]*>(#[^<]*)<\/a>|<img[^>]*\/?>/gi
const tokenRe = /<span[^>]*data-type="mention"[^>]*>[\s\S]*?<\/span>|<span[^>]*data-type="linkTag"[^>]*>[\s\S]*?<\/span>|<a[^>]*href="([^"]*)"[^>]*>(#[^<]*)<\/a>|<video[^>]*>[\s\S]*?<\/video>|<video[^>]*\/>|<img[^>]*\/?>/gi
let lastEnd = 0
let m
@@ -150,6 +169,16 @@ function parseBlockToSegments(block, config) {
// 旧格式没有 tagType在 onLinkTagTap 中会按 label 匹配缓存的 linkTags 配置降级处理
segs.push({ type: 'linkTag', label: label || '#', url, tagType: '', pagePath: '', tagId: '' })
} else if (/^<video/i.test(tag)) {
const rawSrc = extractVideoSrcFromTag(tag)
if (rawSrc) {
const decoded = decodeEntities(rawSrc)
const src =
config && config.assetBase
? resolveArticleImageSrc(decoded, config.assetBase)
: resolveArticleImageSrc(decoded, '')
segs.push({ type: 'video', src })
}
} else if (/^<img /i.test(tag)) {
// 图片src 可能为相对路径,需结合 config.assetBase
const rawSrc = extractImgSrcFromTag(tag)
@@ -233,6 +262,33 @@ function parseHtmlToSegments(html, config) {
return '\n__TABLE_' + idx + '__\n'
})
// 0.5 管理端 TipTapdiv.rich-video-wrap > video +可选caption须在剥离 div 前整体替换
const videos = []
const videoWrapRe =
/<div[^>]*(?:class="[^"]*rich-video-wrap[^"]*"|class='[^']*rich-video-wrap[^']*')[^>]*>\s*<video[^>]*>\s*<\/video>\s*(?:<div[^>]*(?:class="[^"]*rich-video-caption[^"]*"|class='[^']*rich-video-caption[^']*')[^>]*>[\s\S]*?<\/div>)?\s*<\/div>/gi
text = text.replace(videoWrapRe, (match) => {
const rawSrc = extractVideoSrcFromTag(match)
if (!rawSrc) return match
const idx = videos.length
pushResolvedVideoSrc(videos, rawSrc, config)
return '\n__VIDEO_' + idx + '__\n'
})
// 未包在 rich-video-wrap 内的裸 <video>(兼容粘贴或其它导出)
text = text.replace(/<video[^>]*\/>/gi, (match) => {
const rawSrc = extractVideoSrcFromTag(match)
if (!rawSrc) return match
const idx = videos.length
pushResolvedVideoSrc(videos, rawSrc, config)
return '\n__VIDEO_' + idx + '__\n'
})
text = text.replace(/<video[^>]*>\s*<\/video>/gi, (match) => {
const rawSrc = extractVideoSrcFromTag(match)
if (!rawSrc) return match
const idx = videos.length
pushResolvedVideoSrc(videos, rawSrc, config)
return '\n__VIDEO_' + idx + '__\n'
})
// 1. 提取 <h2>/<h3> → heading 占位
const headings = []
text = text.replace(/<h([2-6])[^>]*>([\s\S]*?)<\/h\1>/gi, function (_, lvl, inner) {
@@ -292,6 +348,17 @@ function parseHtmlToSegments(html, config) {
continue
}
// video整块占位
var vidM = block.trim().match(/^__VIDEO_(\d+)__$/)
if (vidM) {
var v = videos[parseInt(vidM[1], 10)]
if (v && v.src) {
lines.push('')
segments.push([{ type: 'video', src: v.src }])
}
continue
}
// heading
var hM = block.trim().match(/^__H_(\d+)__$/)
if (hM) {
@@ -345,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

View File

@@ -65,7 +65,7 @@ def get_cfg():
return {
"host": host,
"user": os.environ.get("DEPLOY_USER", "root"),
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
"password": os.environ.get("DEPLOY_PASSWORD", ""),
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
"bt_panel_url": bt_url,
@@ -206,14 +206,14 @@ def pack_deploy(root, binary_path, include_env=True):
# ==================== 宝塔 API 重启 ====================
def _bt_signed_post(base_url, key, path, extra_data):
def _bt_signed_post(base_url, key, path, extra_data, files=None, timeout=20):
"""单次宝塔签名 POST每请求独立 request_time/token"""
req_time = int(time.time())
sk_md5 = hashlib.md5(key.encode()).hexdigest()
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
data = {"request_time": req_time, "request_token": req_token}
data.update(extra_data or {})
return requests.post(base_url + path, data=data, timeout=20, verify=False)
return requests.post(base_url + path, data=data, files=files, timeout=timeout, verify=False)
def _bt_parse_json_response(r):
@@ -361,22 +361,141 @@ def restart_via_bt_api(cfg):
# ==================== SSH 上传 ====================
def _bt_upload_tarball(cfg, tarball_path, remote_dir, remote_name):
"""通过宝塔 API 上传文件到指定目录。"""
if not requests:
print(" [失败] 未安装 requests无法使用宝塔 API 上传。pip install requests")
return False
url = (cfg.get("bt_panel_url") or "").rstrip("/")
key = cfg.get("bt_api_key") or ""
if not url or not key:
print(" [失败] 未配置 BT_PANEL_URL / BT_API_KEY无法使用宝塔 API 上传")
return False
if not os.path.isfile(tarball_path):
print(" [失败] 本地文件不存在: %s" % tarball_path)
return False
filename = remote_name or os.path.basename(tarball_path)
with open(tarball_path, "rb") as f:
files = {"file": (filename, f, "application/gzip")}
for path, payload in (
("/files?action=UploadFile", {"path": remote_dir}),
("/files?action=upload", {"path": remote_dir}),
("/files?action=Upload", {"path": remote_dir}),
("/files?action=UploadFile", {"f_path": remote_dir}),
("/files?action=upload", {"f_path": remote_dir}),
("/files?action=Upload", {"f_path": remote_dir}),
):
try:
r = _bt_signed_post(url, key, path, payload, files=files, timeout=120)
j = _bt_parse_json_response(r)
if isinstance(j, dict):
if j.get("status") is True:
return True
msg = str(j.get("msg", "") or j.get("message", ""))
if msg and ("success" in msg.lower() or "ok" in msg.lower()):
return True
elif r is not None and r.status_code == 200:
text = (r.text or "").lower()
if "success" in text or "\"status\":true" in text:
return True
except Exception:
continue
return False
def _bt_exec_shell(cfg, shell_cmd):
"""通过宝塔 API 执行 shell 命令。"""
url = (cfg.get("bt_panel_url") or "").rstrip("/")
key = cfg.get("bt_api_key") or ""
if not url or not key:
return False, ""
for path in ("/system?action=ExecShell", "/ajax?action=ExecShell"):
try:
r = _bt_signed_post(url, key, path, {"shell": shell_cmd}, timeout=120)
j = _bt_parse_json_response(r)
if isinstance(j, dict):
if j.get("status") is True:
out = j.get("msg") or j.get("data") or ""
return True, str(out)
msg = str(j.get("msg", "") or j.get("message", ""))
if msg and "denied" not in msg.lower():
return False, msg
if r is not None and r.status_code == 200:
text = (r.text or "").strip()
if text:
return True, text
except Exception:
continue
return False, ""
def upload_and_extract_via_bt_api(cfg, tarball_path, project_path, remote_tar):
"""通过宝塔 API 上传 tar.gz 并在服务器解压。"""
print("[3/4] 宝塔API 上传并解压 ...")
remote_dir = os.path.dirname(remote_tar) or "/tmp"
remote_name = os.path.basename(remote_tar) or "soul_api_deploy.tar.gz"
if not _bt_upload_tarball(cfg, tarball_path, remote_dir, remote_name):
print(" [失败] 宝塔 API 上传失败")
return False
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)
ok, out = _bt_exec_shell(cfg, cmd)
if (not ok) or ("OK" not in (out or "")):
print(" [失败] 宝塔 API 解压失败")
if out:
print(" [宝塔API] %s" % str(out)[:300])
return False
print(" [成功] 已通过宝塔 API 解压到: %s" % project_path)
return True
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"]):
host = cfg["host"]
user = cfg["user"]
port = DEFAULT_SSH_PORT
password = (cfg.get("password") or "").strip()
ssh_key = (cfg.get("ssh_key") or "").strip()
# 优先使用显式配置的私钥文件
if ssh_key and os.path.isfile(ssh_key):
client.connect(
cfg["host"], port=DEFAULT_SSH_PORT,
username=cfg["user"], key_filename=cfg["ssh_key"],
host,
port=port,
username=user,
key_filename=ssh_key,
timeout=15,
look_for_keys=False,
allow_agent=False,
)
else:
client.connect(
cfg["host"], port=DEFAULT_SSH_PORT,
username=cfg["user"], password=cfg["password"],
timeout=15,
)
# 未显式指定 key 时,先尝试 ssh-agent / 默认 ~/.ssh 密钥
try:
client.connect(
host,
port=port,
username=user,
timeout=15,
look_for_keys=True,
allow_agent=True,
)
except Exception:
if not password:
raise
# 公钥不可用时,最后回退密码认证
client.connect(
host,
port=port,
username=user,
password=password,
timeout=15,
look_for_keys=False,
allow_agent=False,
)
transport = client.get_transport()
if transport:
transport.set_keepalive(15)
@@ -404,107 +523,133 @@ def _get_port_pids(client, port):
return set()
def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto"):
def _restart_via_ssh(client, project_path):
"""通过 SSH 重启并健康检查。"""
start_cmd = (
"cd %s && (fuser -k %d/tcp 2>/dev/null || true) && sleep 2 && "
"( setsid nohup ./soul-api >> soul-api.log 2>&1 </dev/null & ) && "
"sleep 1 && echo START_OK"
) % (project_path, DEPLOY_PORT)
stdin, stdout, stderr = client.exec_command(
"timeout 45 bash -c " + shlex.quote(start_cmd),
timeout=55,
get_pty=True,
)
start_out = stdout.read().decode("utf-8", errors="replace").strip()
if "START_OK" not in start_out:
print(" [stderr] 起进程输出: %s" % start_out[:300])
time.sleep(12)
health_cmd = (
"curl -sf --connect-timeout 5 --max-time 15 "
"http://127.0.0.1:%d/health 2>/dev/null | grep -q '\"status\"' "
"&& echo RESTART_OK || echo RESTART_FAIL"
) % DEPLOY_PORT
stdin, stdout, stderr = client.exec_command(
"timeout 25 bash -c " + shlex.quote(health_cmd),
timeout=35,
get_pty=True,
)
out = stdout.read().decode("utf-8", errors="replace").strip()
ok = "RESTART_OK" in out
if ok:
print(" [成功] soulApi 已通过 SSH 重启")
else:
print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path)
return ok
def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto", upload_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")
if upload_method not in ("auto", "ssh", "btapi"):
print(" [失败] upload_method 仅支持 auto/ssh/btapi")
return False
remote_tar = "/tmp/soul_api_deploy.tar.gz"
project_path = cfg["project_path"]
client = None
try:
# SFTP 上传易因网络抖动 EOF失败时重连并重试最多 3 次
for attempt in range(1, 4):
uploaded = False
if upload_method in ("auto", "btapi"):
uploaded = upload_and_extract_via_bt_api(cfg, tarball_path, project_path, remote_tar)
if (not uploaded) and upload_method == "btapi":
print(" [失败] 已指定 --upload-method btapi但宝塔 API 上传/解压未成功")
return False
if not uploaded:
print("[3/4] SSH 上传并解压 ...")
# 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 && "
"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=120)
ex_err = []
def _drain_tar_stderr():
try:
ex_err.append(stderr.read().decode("utf-8", errors="replace"))
except Exception:
ex_err.append("")
t_tar = threading.Thread(target=_drain_tar_stderr)
t_tar.daemon = True
t_tar.start()
out = stdout.read().decode("utf-8", errors="replace").strip()
t_tar.join(timeout=10)
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)
else:
print(" [提示] 上传方式: 宝塔 API")
if not client and restart_method in ("auto", "ssh"):
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 && "
"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=120)
ex_err = []
def _drain_tar_stderr():
try:
ex_err.append(stderr.read().decode("utf-8", errors="replace"))
except Exception:
ex_err.append("")
t_tar = threading.Thread(target=_drain_tar_stderr)
t_tar.daemon = True
t_tar.start()
out = stdout.read().decode("utf-8", errors="replace").strip()
t_tar.join(timeout=10)
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)
except Exception as e:
if restart_method == "ssh":
print(" [失败] 指定 SSH 重启,但无法建立 SSH 连接: %s" % str(e))
return False
if not no_restart:
print("[4/4] 重启 soulApi 服务 ...")
ok = False
pids_before = _get_port_pids(client, DEPLOY_PORT)
pids_before = _get_port_pids(client, DEPLOY_PORT) if client else set()
if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")):
ok = restart_via_bt_api(cfg)
# 宝塔接口有时返回成功但进程未真正重启:若 PID 未变化,视为 btapi 未生效
if ok:
if ok and client:
time.sleep(2)
pids_after = _get_port_pids(client, DEPLOY_PORT)
if pids_before and pids_after and pids_before == pids_after:
print(" [宝塔API] 检测到监听 %d 的 PID 未变化(%s),判定 btapi 未真正重启,转 SSH 兜底。" % (DEPLOY_PORT, ",".join(sorted(pids_after))))
ok = False
if not ok and restart_method in ("auto", "ssh"):
# SSH正式环境固定监听 DEPLOY_PORT默认 8080。用 fuser 释放端口,避免宝塔守护
# 启动的进程 cwd 与项目目录不一致导致 pgrep+cwd 校验永远失败。
# 拆成两次 exec先短命令起进程本机 sleep 后再 curl避免单条远程命令+管道偶发拖死 Paramiko。
start_cmd = (
"cd %s && (fuser -k %d/tcp 2>/dev/null || true) && sleep 2 && "
"( setsid nohup ./soul-api >> soul-api.log 2>&1 </dev/null & ) && "
"sleep 1 && echo START_OK"
) % (project_path, DEPLOY_PORT)
stdin, stdout, stderr = client.exec_command(
"timeout 45 bash -c " + shlex.quote(start_cmd),
timeout=55,
get_pty=True,
)
start_out = stdout.read().decode("utf-8", errors="replace").strip()
if "START_OK" not in start_out:
print(" [stderr] 起进程输出: %s" % start_out[:300])
time.sleep(12)
health_cmd = (
"curl -sf --connect-timeout 5 --max-time 15 "
"http://127.0.0.1:%d/health 2>/dev/null | grep -q '\"status\"' "
"&& echo RESTART_OK || echo RESTART_FAIL"
) % DEPLOY_PORT
stdin, stdout, stderr = client.exec_command(
"timeout 25 bash -c " + shlex.quote(health_cmd),
timeout=35,
get_pty=True,
)
out = stdout.read().decode("utf-8", errors="replace").strip()
ok = "RESTART_OK" in out
if ok:
print(" [成功] soulApi 已通过 SSH 重启")
else:
print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path)
if client:
ok = _restart_via_ssh(client, project_path)
elif restart_method == "ssh":
print(" [失败] 未能建立 SSH 连接,无法执行 SSH 重启")
return False
if restart_method == "btapi" and not ok:
print(" [失败] 已指定 --restart-method btapi但宝塔 API 重启未成功(请核对 API 白名单含本机出口 IP、BT_GO_PROJECT_NAME/BT_GO_SITE_ID")
return False
@@ -514,7 +659,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
return True
except Exception as e:
err_msg = str(e) or repr(e) or type(e).__name__
print(" [失败] SSH 错误:", err_msg)
print(" [失败] 部署错误:", err_msg)
import traceback
traceback.print_exc()
return False
@@ -543,6 +688,12 @@ def main():
default="auto",
help="重启方式: auto=先试宝塔API再SSH, btapi=仅宝塔API, ssh=仅SSH (默认 auto)",
)
parser.add_argument(
"--upload-method",
choices=("auto", "btapi", "ssh"),
default="auto",
help="上传方式: auto=先试宝塔API再SSH, btapi=仅宝塔API, ssh=仅SSH (默认 auto)",
)
args = parser.parse_args()
script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -571,7 +722,13 @@ def main():
if not tarball:
return 1
if not upload_and_extract(cfg, tarball, no_restart=args.no_restart, restart_method=args.restart_method):
if not upload_and_extract(
cfg,
tarball,
no_restart=args.no_restart,
restart_method=args.restart_method,
upload_method=args.upload_method,
):
return 1
try: