特性:在内容展示中增加视频支持
- 在小程序阅读页面中实现了视频渲染,支持单个视频项目和内嵌视频片段。 - 更新了内容解析器,以处理视频标签并提取视频源。 - 增强了视频元素的样式,以确保其正确显示和响应性。 制造材料:Cursor
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 管理端 TipTap:div.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
|
||||
|
||||
@@ -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] 上传中断: %s,5 秒后重连 ..." % (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] 上传中断: %s,5 秒后重连 ..." % (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:
|
||||
|
||||
Reference in New Issue
Block a user