chore: 同步阅读页、脚本、API 配置与小程序管理文档

Made-with: Cursor
This commit is contained in:
卡若
2026-04-15 19:07:02 +08:00
parent 3dd75746e6
commit d44a02b818
15 changed files with 1733 additions and 160 deletions

View File

@@ -309,6 +309,9 @@ Page({
// 好友从代付分享进入:待自动领取的 requestSn
pendingGiftRequestSn: '',
/** H5 / URL Link 带 openPay=1全屏小程序内自动调起本章微信支付非单页 */
pendingAutoSectionPay: false,
// 朋友圈单页模式scene 1154 / systemInfo.mode无法登录与支付仅引导「前往小程序」
readSinglePageMode: false,
momentsPaywallExpanded: false,
@@ -589,6 +592,7 @@ Page({
readSinglePageMode: this._detectReadSinglePage(),
momentsPaywallExpanded: false,
showMomentsLaunchGuideModal: false,
pendingAutoSectionPay: parseDatasetBool(options && options.openPay),
})
if (ref) {
@@ -652,6 +656,8 @@ Page({
this._applyPrevNext(chapterRes)
this.computeShowFullBookCta()
this.refreshPay365MarketingFromRules()
this._tryAutoPayAfterH5Launch()
} catch (e) {
console.error('[Read] 初始化失败:', e)
@@ -662,6 +668,41 @@ Page({
}
},
/** H5 落地 path 含 openPay=1与外链一致进入后尽快调起微信支付须全屏小程序、非免费、未解锁 */
_tryAutoPayAfterH5Launch() {
if (!this.data.pendingAutoSectionPay) return
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
this.setData({ pendingAutoSectionPay: false })
return
}
const section = this.data.section
if (
section &&
(section.isFree === true || (section.price !== undefined && Number(section.price) === 0))
) {
this.setData({ pendingAutoSectionPay: false })
wx.showToast({ title: '本章免费', icon: 'none' })
return
}
if (this.data.canAccess || accessManager.canAccessFullContent(this.data.accessState)) {
this.setData({ pendingAutoSectionPay: false })
return
}
const st = this.data.accessState
if (st === 'locked_not_purchased') {
this.setData({ pendingAutoSectionPay: false })
setTimeout(() => {
this.handlePurchaseSection()
}, 120)
return
}
if (st === 'locked_not_login') {
this.setData({ showLoginModal: true })
return
}
this.setData({ pendingAutoSectionPay: false })
},
_getGiftUnitPrice() {
const p = this.data.section?.price
const cfg = this.data.sectionPrice
@@ -1397,7 +1438,8 @@ Page({
copyLink() {
const userInfo = app.globalData.userInfo
const referralCode = userInfo?.referralCode || ''
const shareUrl = `https://soul.quwanzhi.com/read/${this.data.sectionId}${referralCode ? '?ref=' + referralCode : ''}`
const base = String((app.globalData && app.globalData.baseUrl) || 'https://soulapi.quwanzhi.com').replace(/\/$/, '')
const shareUrl = `${base}/read/${this.data.sectionId}${referralCode ? '?ref=' + referralCode : ''}`
wx.setClipboardData({
data: shareUrl,
@@ -1595,6 +1637,18 @@ Page({
}
wx.hideLoading()
if (this.data.pendingAutoSectionPay) {
if (canAccess) {
this.setData({ pendingAutoSectionPay: false })
} else if (accessManager.isFreeFromChapterData(chapterRes)) {
this.setData({ pendingAutoSectionPay: false })
wx.showToast({ title: '本章免费', icon: 'none' })
} else if (newAccessState === 'locked_not_purchased') {
this.setData({ pendingAutoSectionPay: false })
setTimeout(() => this.handlePurchaseSection(), 120)
}
}
} catch (e) {
wx.hideLoading()

View File

@@ -0,0 +1,78 @@
# 微信小程序 URL Link / Short Link 生成工具包
纯 Python 3 标准库,**零 pip 依赖**。用于生成可粘贴到微信(含朋友圈文案)的官方外链。
## 重要说明(能力与边界)
| 项目 | 说明 |
|------|------|
| 本工具做什么 | 调用微信官方接口,生成 **`https://wxaurl.cn/...`**URL Link或 Short Link。 |
| 本工具不做什么 | **不能**代替你在微信里点击「发表朋友圈」;微信不提供第三方「全自动发帖到朋友圈」的公开 API。使用方式运行脚本 → 复制输出的链接 → 在微信朋友圈发文字时粘贴该链接。 |
| 谁可以调接口 | 须持有该小程序的 **AppSecret**(与 AppID 对应)。个人/企业类目若接口被拒,以微信返回 `errcode` 为准。 |
| 打开环境 | 链接一般在 **微信内** 打开才能拉起小程序;站外浏览器可能看到提示页。 |
## 运行条件
1. **Python 3.9+**(推荐 3.10+),系统已安装 `python3`
2. 能访问公网 **`https://api.weixin.qq.com`**(公司网络若拦截需放行)。
3. 小程序 **AppID**、**AppSecret**(微信公众平台 → 开发设置)。
## 凭证配置(三选一)
**方式 A — 本目录 `.env`(推荐解压独立使用)**
```bash
cp .env.example .env
# 编辑 .env填入 WECHAT_APPID 与 WECHAT_APPSECRET不要加中文说明句
```
**方式 B — 环境变量**
```bash
export WECHAT_APPID=wx…………
export WECHAT_APPSECRET=…………
```
**方式 C — 指定任意 .env 路径**
```bash
export WECHAT_ENV_FILE=/绝对路径/含WECHAT键的.env
```
若把整个文件夹仍放在「一场soul的创业实验-永平」仓库内、且未配置以上项,脚本会尝试读取 **`../soul-api/.env`**(与仓库原脚本行为兼容)。
## 命令示例
**URL Link适合复制到朋友圈/聊天)**
```bash
python3 generate_share_link.py --mode urllink --path pages/read/read --query "id=1.1" --expire-days 30
```
成功时标准输出**仅一行**链接,可直接复制。
**首页**
```bash
python3 generate_share_link.py --mode urllink --path pages/index/index --query ""
```
**Short Link偏微信内场景**
```bash
python3 generate_share_link.py --mode shortlink --page-url "pages/read/read?id=1.1" --page-title "荷包"
```
## 官方文档
- [获取加密 URL Linkgenerate_urllink](https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/qrcode-link/url-link/qrcode-link/url-link/api_generateurllink.html)
- [获取 Short Linkgenwxashortlink](https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/qrcode-link/url-link/qrcode-link/short-link/api_generateshortlink.html)
## 安全
- **不要**把 `.env`、AppSecret 提交到 Git 或发在公开群。
- 本包内**不包含**任何真实密钥。
## 与仓库内脚本的关系
永平仓库中 `scripts/wechat_generate_share_link.py` 与本包 `generate_share_link.py` 逻辑等价;本包增强了**任意目录**下读取本文件夹 `.env``WECHAT_ENV_FILE`,便于拷贝到其它电脑使用。

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
微信小程序:生成 URL Link / Short Link官方 HTTPS 接口,无第三方依赖)。
放置位置任意;凭证读取顺序:
1) 环境变量 WECHAT_APPID、WECHAT_APPSECRET已设置则直接用
2) 环境变量 WECHAT_ENV_FILE 指向的 .env 文件
3) 与本脚本同目录的 .env 或 .env.local
4) 若本脚本仍在「一场soul的创业实验-永平」目录结构下:上一级/soul-api/.env 或 .env.production
官方文档:
URL Link: https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/qrcode-link/url-link/qrcode-link/url-link/api_generateurllink.html
Short Link: https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/qrcode-link/url-link/qrcode-link/short-link/api_generateshortlink.html
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
def _script_dir() -> Path:
return Path(__file__).resolve().parent
def _load_partial_env_file(path: Path, keys: tuple[str, ...]) -> bool:
if not path.is_file():
return False
for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k, v = k.strip(), v.strip().strip('"').strip("'")
if k in keys and not os.environ.get(k):
os.environ[k] = v
return bool(os.environ.get("WECHAT_APPID") and os.environ.get("WECHAT_APPSECRET"))
def ensure_wechat_env() -> None:
if os.environ.get("WECHAT_APPID") and os.environ.get("WECHAT_APPSECRET"):
return
ef = (os.environ.get("WECHAT_ENV_FILE") or "").strip()
if ef and _load_partial_env_file(Path(ef), ("WECHAT_APPID", "WECHAT_APPSECRET")):
return
base = _script_dir()
for name in (".env", ".env.local"):
if _load_partial_env_file(base / name, ("WECHAT_APPID", "WECHAT_APPSECRET")):
return
# 兼容:脚本在「仓库根/soul-api」的上一级目录下任意子目录时尝试读 soul-api/.env
repo_like = base.parent.parent
if (repo_like / "soul-api").is_dir():
for name in (".env.production", ".env"):
if _load_partial_env_file(
repo_like / "soul-api" / name, ("WECHAT_APPID", "WECHAT_APPSECRET")
):
return
def _get(url: str) -> dict:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode())
def _post_json(url: str, body: dict) -> dict:
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
method="POST",
headers={"Content-Type": "application/json; charset=utf-8"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode())
def _wechat_appsecret_hint(secret: str) -> str | None:
s = (secret or "").strip().strip("'").strip('"')
if re.search(r"[\u4e00-\u9fff]", s):
return (
"WECHAT_APPSECRET 中含汉字,说明把「说明文字」当成了密钥。\n"
"请到 mp.weixin.qq.com → 该小程序 → 开发管理 → 开发设置 →\n"
"「AppSecret(小程序密钥)」查看/重置后复制约 32 位密钥。"
)
if len(s) < 28:
return "WECHAT_APPSECRET 长度过短,请从公众平台复制完整 AppSecret。"
return None
def _urllink_args_placeholder_hint(path: str, query: str) -> str | None:
p, q = (path or "").strip(), (query or "").strip()
for frag in (
"真实路径",
"真实参数",
"你的文章",
"你的参数",
"pages/真实",
"id=真实",
"这里粘贴",
):
if frag in p or frag in q:
return "检测到 path/query 中含教程占位词,请换成 app.json 真实路径与 onLoad 参数。"
return None
def get_access_token(appid: str, secret: str) -> str:
q = urllib.parse.urlencode(
{"grant_type": "client_credential", "appid": appid, "secret": secret}
)
url = f"https://api.weixin.qq.com/cgi-bin/token?{q}"
data = _get(url)
if data.get("errcode"):
raise SystemExit(f"获取 access_token 失败: {data}")
token = data.get("access_token")
if not token:
raise SystemExit(f"无 access_token: {data}")
return token
def normalize_path(path: str) -> str:
p = (path or "").strip()
if p.startswith("/"):
p = p[1:]
return p
def cmd_urllink(token: str, path: str, query: str, env_version: str, expire_days: int) -> None:
path = normalize_path(path)
body: dict = {
"jump_wxa": {
"path": path,
"query": query or "",
"env_version": env_version,
},
"expire_type": 1,
"expire_interval": max(1, min(expire_days, 365)),
}
url = f"https://api.weixin.qq.com/wxa/generate_urllink?access_token={urllib.parse.quote(token)}"
try:
data = _post_json(url, body)
except urllib.error.HTTPError as e:
raw = e.read().decode(errors="ignore")
raise SystemExit(f"HTTP {e.code}: {raw}") from e
if data.get("errcode"):
flat = {
"path": path,
"query": query or "",
"env_version": env_version,
"expire_type": 1,
"expire_interval": max(1, min(expire_days, 365)),
}
data = _post_json(url, flat)
if data.get("errcode"):
raise SystemExit(f"generate_urllink 失败: {data}")
link = data.get("url_link")
if not link:
raise SystemExit(f"响应无 url_link: {data}")
print(link)
def cmd_shortlink(token: str, page_url: str, page_title: str, permanent: bool) -> None:
page_url_norm = page_url.strip()
if page_url_norm.startswith("/"):
page_url_norm = page_url_norm[1:]
body = {
"page_url": page_url_norm,
"page_title": page_title or "分享",
"is_permanent": permanent,
}
url = f"https://api.weixin.qq.com/wxa/genwxashortlink?access_token={urllib.parse.quote(token)}"
try:
data = _post_json(url, body)
except urllib.error.HTTPError as e:
raw = e.read().decode(errors="ignore")
raise SystemExit(f"HTTP {e.code}: {raw}") from e
if data.get("errcode"):
raise SystemExit(f"genwxashortlink 失败: {data}")
link = data.get("link")
if not link:
raise SystemExit(f"响应无 link: {data}")
print(link)
def main() -> None:
ensure_wechat_env()
ap = argparse.ArgumentParser(description="微信小程序 URL Link / Short Link 生成器")
ap.add_argument("--mode", choices=("urllink", "shortlink"), default="urllink")
ap.add_argument("--path", default="", help="urllink页面路径")
ap.add_argument("--query", default="", help="urllinkquery无则传空字符串")
ap.add_argument("--page-url", default="", help="shortlinkpage?query")
ap.add_argument("--page-title", default="", help="shortlink标题")
ap.add_argument("--env-version", choices=("release", "trial", "develop"), default="release")
ap.add_argument("--expire-days", type=int, default=30)
ap.add_argument("--permanent", action="store_true")
args = ap.parse_args()
appid = (os.environ.get("WECHAT_APPID") or "").strip()
secret = (os.environ.get("WECHAT_APPSECRET") or "").strip()
if not appid or not secret:
print(
"缺少 WECHAT_APPID / WECHAT_APPSECRET。\n"
"做法:在本目录放置 .env见 .env.example或 export 两个变量,\n"
"或设置 WECHAT_ENV_FILE=/绝对路径/你的.env",
file=sys.stderr,
)
raise SystemExit(2)
hint = _wechat_appsecret_hint(secret)
if hint:
print(hint, file=sys.stderr)
raise SystemExit(2)
if args.mode == "urllink":
if not args.path.strip():
raise SystemExit("urllink 须提供 --path")
ph = _urllink_args_placeholder_hint(args.path, args.query)
if ph:
print(ph, file=sys.stderr)
raise SystemExit(2)
token = get_access_token(appid, secret)
if args.mode == "urllink":
cmd_urllink(token, args.path, args.query, args.env_version, args.expire_days)
else:
if not args.page_url.strip():
raise SystemExit("shortlink 须提供 --page-url")
cmd_shortlink(token, args.page_url, args.page_title, args.permanent)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,4 @@
#!/bin/sh
# 在解压目录执行:先配置 .env 或 export WECHAT_APPID / WECHAT_APPSECRET
cd "$(dirname "$0")" || exit 1
exec python3 generate_share_link.py "$@"

View File

@@ -0,0 +1,4 @@
@echo off
REM 使用前先 set WECHAT_APPID / WECHAT_APPSECRET 或在本目录放置 .env
cd /d "%~dp0"
python generate_share_link.py %*

View File

@@ -4,7 +4,8 @@
kr 宝塔:正式环境发布 + 宝塔面板 API 重启 Go 项目 + 线上冒烟验证。
说明(与现状一致):
- soul-api 的**文件上传与解压**仍通过 SSH/SFTP稳定**进程重启**使用宝塔「Go 项目」插件 APImaster.py --restart-method btapi 一致)。
- soul-api 默认 **全程宝塔 API**`/files?action=upload` 上传、`UnZip` 解压、`DeleteFile` 删包、`SiteStop/SiteStart` 重启(见 soul-api/master.py `--deploy-method btapi`,无需 SSH)。
- 若需旧版 SSH 上传:设置环境变量 `DEPLOY_METHOD=ssh` 或 `python master.py --deploy-method ssh`(需服务器允许密码/密钥)。
- soul-admin 静态资源仍通过 soul-admin/master.pySSH宝塔无统一「站点文件 API」封装。
- 环境变量与 soul-api/master.py 相同DEPLOY_HOST、DEPLOY_PASSWORD、BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME 等。
@@ -160,7 +161,11 @@ def main():
return 0 if verify_public() else 1
py = sys.executable
if not run_cmd(SOUL_API, [py, "master.py", "--restart-method", args.restart_method]):
# master.py 默认也会做 GetDiskInfo 探活;本脚本已探活则跳过,避免重复请求
master_args = [py, "master.py", "--restart-method", args.restart_method]
if not args.skip_bt_ping:
master_args.append("--skip-bt-ping")
if not run_cmd(SOUL_API, master_args):
print("[失败] soul-api 部署失败")
return 1

View File

@@ -15,7 +15,7 @@ CONFIG_PATH = Path(__file__).resolve().parent / "feishu_publish_config.json"
WEBHOOK_ENV = "FEISHU_KARUO_LOG_WEBHOOK"
DEFAULT_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/34b762fc-5b9b-4abb-a05a-96c8fb9599f1"
WIKI_URL = "https://cunkebao.feishu.cn/wiki/FNP6wdvNKij7yMkb3xCce0CYnpd"
MINIPROGRAM_BASE = "https://soul.quwanzhi.com/read"
MINIPROGRAM_BASE = "https://soulapi.quwanzhi.com/read"
MATERIAL_HINT = "材料找卡若AI拿"

View File

@@ -36,7 +36,7 @@ WEBHOOK = os.environ.get(
"https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494",
)
BACKEND_QRCODE_URL = "https://soulapi.quwanzhi.com/api/miniprogram/qrcode"
MINIPROGRAM_READ_BASE = "https://soul.quwanzhi.com/read"
MINIPROGRAM_READ_BASE = "https://soulapi.quwanzhi.com/read"
# 海报尺寸2x 放大,提升二维码识别率)
POSTER_W = 600

View File

@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""
生成可粘贴到微信含朋友圈文案里的小程序官方外链URL Link 或 Short Link。
说明(非逆向):
- 仅调用微信开放平台文档中的 HTTPS 接口,需该小程序的 AppSecret 换 access_token。
- 「F12 抓包」只能辅助你自己后台调这些接口,不能从他人小程序里抠出可复用的签名链。
- URL Link 文档https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/qrcode-link/url-link/qrcode-link/url-link/api_generateurllink.html
- Short Link 文档https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/qrcode-link/url-link/qrcode-link/short-link/api_generateshortlink.html
环境变量(与 wechat_miniprogram_release.py 一致,可读 soul-api/.env
WECHAT_APPID / WECHAT_APPSECRET
用法示例:
python3 scripts/wechat_generate_share_link.py --mode urllink --path pages/index/index --query "id=1"
python3 scripts/wechat_generate_share_link.py --mode shortlink --page-url "pages/foo/bar?x=1" --page-title "标题"
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
def _repo_root() -> Path:
return Path(__file__).resolve().parent.parent
def _load_partial_env_file(path: Path, keys: tuple[str, ...]) -> None:
if not path.is_file():
return
for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k, v = k.strip(), v.strip().strip('"').strip("'")
if k in keys and not os.environ.get(k):
os.environ[k] = v
def ensure_wechat_env_from_soul_api() -> None:
if os.environ.get("WECHAT_APPID") and os.environ.get("WECHAT_APPSECRET"):
return
root = _repo_root()
for name in (".env.production", ".env"):
_load_partial_env_file(
root / "soul-api" / name, ("WECHAT_APPID", "WECHAT_APPSECRET")
)
if os.environ.get("WECHAT_APPID") and os.environ.get("WECHAT_APPSECRET"):
return
def _get(url: str) -> dict:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode())
def _post_json(url: str, body: dict) -> dict:
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
method="POST",
headers={"Content-Type": "application/json; charset=utf-8"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode())
def _wechat_appsecret_hint(secret: str) -> str | None:
"""若明显是占位说明或误粘贴,提前报错,避免只看到 40125。"""
s = (secret or "").strip().strip("'").strip('"')
if re.search(r"[\u4e00-\u9fff]", s):
return (
"WECHAT_APPSECRET 中含汉字,说明把「说明文字」当成了密钥。\n"
"请到 mp.weixin.qq.com → 该小程序 → 开发管理 → 开发设置 →\n"
"「AppSecret(小程序密钥)」点「重置」或「查看」后复制那一串(约 32 位),\n"
"整串粘贴到 export勿把教程里的整句说明当作值例如勿写\n"
" export WECHAT_APPSECRET='这里粘贴真实 AppSecret'"
)
if len(s) < 28:
return (
"WECHAT_APPSECRET 长度过短。真实 AppSecret 一般为 32 位字符;\n"
"请从公众平台复制完整密钥,勿留空格或括号说明。"
)
return None
def _urllink_args_placeholder_hint(path: str, query: str) -> str | None:
"""--path / --query 仍是教程占位符时提示,避免带着假路径去调微信。"""
p, q = (path or "").strip(), (query or "").strip()
bad_fragments = (
"真实路径",
"真实参数",
"你的文章",
"你的参数",
"pages/真实",
"id=真实",
"这里粘贴",
)
for frag in bad_fragments:
if frag in p or frag in q:
return (
"检测到 --path 或 --query 里仍是「教程占位词」(如「真实路径」「真实参数」)。\n"
"请改成该小程序 app.json 里真实存在的页面路径,例如 pages/index/index\n"
"以及该页 onLoad 里会解析的真实 query没有就传空字符串--query '')。"
)
return None
def get_access_token(appid: str, secret: str) -> str:
q = urllib.parse.urlencode(
{"grant_type": "client_credential", "appid": appid, "secret": secret}
)
url = f"https://api.weixin.qq.com/cgi-bin/token?{q}"
data = _get(url)
if data.get("errcode"):
raise SystemExit(f"获取 access_token 失败: {data}")
token = data.get("access_token")
if not token:
raise SystemExit(f"无 access_token: {data}")
return token
def normalize_path(path: str) -> str:
p = (path or "").strip()
if p.startswith("/"):
p = p[1:]
return p
def cmd_urllink(token: str, path: str, query: str, env_version: str, expire_days: int) -> None:
path = normalize_path(path)
# 与当前开放文档一致的 jump_wxa 结构(若微信侧变更,以官方为准)
body: dict = {
"jump_wxa": {
"path": path,
"query": query or "",
"env_version": env_version,
},
"expire_type": 1,
"expire_interval": max(1, min(expire_days, 365)),
}
url = f"https://api.weixin.qq.com/wxa/generate_urllink?access_token={urllib.parse.quote(token)}"
try:
data = _post_json(url, body)
except urllib.error.HTTPError as e:
raw = e.read().decode(errors="ignore")
raise SystemExit(f"HTTP {e.code}: {raw}") from e
if data.get("errcode"):
# 部分历史文档为顶层 path/query失败时再试一次
flat = {
"path": path,
"query": query or "",
"env_version": env_version,
"expire_type": 1,
"expire_interval": max(1, min(expire_days, 365)),
}
data = _post_json(url, flat)
if data.get("errcode"):
raise SystemExit(f"generate_urllink 失败: {data}")
link = data.get("url_link")
if not link:
raise SystemExit(f"响应无 url_link: {data}")
print(link)
def cmd_shortlink(token: str, page_url: str, page_title: str, permanent: bool) -> None:
page_url_norm = page_url.strip()
if page_url_norm.startswith("/"):
page_url_norm = page_url_norm[1:]
body = {
"page_url": page_url_norm,
"page_title": page_title or "分享",
"is_permanent": permanent,
}
url = f"https://api.weixin.qq.com/wxa/genwxashortlink?access_token={urllib.parse.quote(token)}"
try:
data = _post_json(url, body)
except urllib.error.HTTPError as e:
raw = e.read().decode(errors="ignore")
raise SystemExit(f"HTTP {e.code}: {raw}") from e
if data.get("errcode"):
raise SystemExit(f"genwxashortlink 失败: {data}")
link = data.get("link")
if not link:
raise SystemExit(f"响应无 link: {data}")
print(link)
def main() -> None:
ensure_wechat_env_from_soul_api()
ap = argparse.ArgumentParser(description="生成小程序 URL Link / Short Link官方接口")
ap.add_argument(
"--mode",
choices=("urllink", "shortlink"),
default="urllink",
help="urllink=HTTPS 外链(短信/网页/微信内shortlink=微信内短链",
)
ap.add_argument("--path", default="", help="urllink页面路径如 pages/index/index")
ap.add_argument("--query", default="", help="urllinkquery 字符串,不含问号")
ap.add_argument(
"--page-url",
default="",
help="shortlink含路径与 query如 pages/foo/bar?id=1",
)
ap.add_argument("--page-title", default="", help="shortlink页面标题")
ap.add_argument(
"--env-version",
choices=("release", "trial", "develop"),
default="release",
help="urllink 打开的小程序版本",
)
ap.add_argument("--expire-days", type=int, default=30, help="urllink 有效天数1~365")
ap.add_argument(
"--permanent",
action="store_true",
help="shortlink是否申请永久有效受类目/额度限制)",
)
args = ap.parse_args()
appid = (os.environ.get("WECHAT_APPID") or "").strip()
secret = (os.environ.get("WECHAT_APPSECRET") or "").strip()
if not appid or not secret:
print(
"缺少 WECHAT_APPID / WECHAT_APPSECRET。\n"
"请在 shell 中 export或写入 soul-api/.env或 .env.production\n"
"注意:须与要分享的小程序一致;卡若派对仓库内默认是 soul 小程序,不是 wx3d15ed… 等其它 AppID。",
file=sys.stderr,
)
raise SystemExit(2)
hint = _wechat_appsecret_hint(secret)
if hint:
print(hint, file=sys.stderr)
raise SystemExit(2)
if args.mode == "urllink":
if not args.path.strip():
raise SystemExit("urllink 模式必须提供 --path")
ph = _urllink_args_placeholder_hint(args.path, args.query)
if ph:
print(ph, file=sys.stderr)
raise SystemExit(2)
token = get_access_token(appid, secret)
if args.mode == "urllink":
cmd_urllink(token, args.path, args.query, args.env_version, args.expire_days)
else:
if not args.page_url.strip():
raise SystemExit("shortlink 模式必须提供 --page-url")
cmd_shortlink(token, args.page_url, args.page_title, args.permanent)
if __name__ == "__main__":
main()

View File

@@ -23,6 +23,11 @@ type Config struct {
// 统一 API 域名字段支付回调、转账回调、apiDomain 等均由 BaseURL 拼接
BaseURL string // API_BASE_URL如 https://soulapi.quwanzhi.com无尾部斜杠
// H5 阅读落地页GET /read/:id朋友圈外链 og:url 使用的公网域名,须与反向代理一致
H5ReadPublicBaseURL string // H5_READ_PUBLIC_BASE默认 https://soul.quwanzhi.com
// 章节正文内无图时朋友圈分享卡片的兜底图HTTPS 绝对地址);空则省略 og:image
H5ReadDefaultOgImage string // H5_READ_DEFAULT_OG_IMAGE
// 存客宝配置
CkbLeadAPIKey string // CKB_LEAD_API_KEY请求到存客宝添加好友使用的 apiKey内部 /v1/api/scenarios
CkbOpenAPIKey string // CKB_OPEN_API_KEY开放 API 鉴权使用的 apiKey/v1/open/auth/token
@@ -278,6 +283,17 @@ func Load() (*Config, error) {
// RedisREDIS_URL 配置后启用;不配置则跳过。本地开发可设 REDIS_URL=redis://localhost:6379/0
redisURL := strings.TrimSpace(os.Getenv("REDIS_URL"))
// H5 /read/:id 由 soul-api 本机提供,公网须与 API 同域或反代;默认与 API_BASE_URL 一致(避免 soul 前端站无 /read/ 路由导致 404
h5ReadPublic := strings.TrimSpace(os.Getenv("H5_READ_PUBLIC_BASE"))
if h5ReadPublic == "" {
h5ReadPublic = baseURL
}
if h5ReadPublic == "" {
h5ReadPublic = "https://soulapi.quwanzhi.com"
}
h5ReadPublic = strings.TrimSuffix(h5ReadPublic, "/")
h5ReadDefaultOg := strings.TrimSpace(os.Getenv("H5_READ_DEFAULT_OG_IMAGE"))
cfg := &Config{
Port: port,
Mode: mode,
@@ -286,6 +302,8 @@ func Load() (*Config, error) {
CORSOrigins: parseCORSOrigins(),
Version: version,
BaseURL: baseURL,
H5ReadPublicBaseURL: h5ReadPublic,
H5ReadDefaultOgImage: h5ReadDefaultOg,
CkbLeadAPIKey: ckbLeadAPIKey,
CkbOpenAPIKey: ckbOpenAPIKey,
CkbOpenAccount: ckbOpenAccount,

View File

@@ -1,19 +1,62 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"html"
"net/http"
"net/url"
"regexp"
"strings"
"unicode/utf8"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var (
reOgImgHTML = regexp.MustCompile(`(?is)<img[^>]+src\s*=\s*["']([^"']+)["']`)
reOgMdImg = regexp.MustCompile(`!\[[^\]]*\]\(\s*([^)]+?)\s*\)`)
)
// h5ReadShareRate 与小程序 getConfig.shareRate 同源referral_config.distributorShare
func h5ReadShareRate(db *gorm.DB) int {
rate := 90
var refRow model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refRow).Error; err != nil || len(refRow.ConfigValue) == 0 {
return rate
}
var refVal map[string]interface{}
if json.Unmarshal(refRow.ConfigValue, &refVal) != nil {
return rate
}
if v, ok := refVal["distributorShare"].(float64); ok {
return int(v)
}
return rate
}
// h5EffectiveSectionUnit 与小程序章节 isFree / price 判断一致
func h5EffectiveSectionUnit(ch *model.Chapter) (isFree bool, unit float64) {
if ch.IsFree != nil && *ch.IsFree {
return true, 0
}
if ch.Price != nil {
p := *ch.Price
if p == 0 {
return true, 0
}
return false, p
}
return false, 1
}
// H5ReadPage GET /read/:id 朋友圈/外部链接落地页
// 渲染文章预览内容(按 unpaid_preview_percent 截取),底部显示「打开小程序继续阅读」按钮
// 支持 ?ref=xxx 分销参数透传
@@ -23,7 +66,10 @@ func H5ReadPage(c *gin.Context) {
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(h5Error("缺少文章 ID")))
return
}
ref := c.Query("ref")
ref := strings.TrimSpace(c.Query("ref"))
midQ := strings.TrimSpace(c.Query("mid"))
giftQ := strings.TrimSpace(c.Query("gift"))
reqSn := strings.TrimSpace(c.Query("requestSn"))
db := database.DB()
var ch model.Chapter
@@ -54,25 +100,179 @@ func H5ReadPage(c *gin.Context) {
cfg := config.Get()
appID := cfg.WechatAppID
mpPath := fmt.Sprintf("pages/read/read?id=%s&action=pay", sectionID)
shareRate := h5ReadShareRate(db)
isFree, priceUnit := h5EffectiveSectionUnit(&ch)
priceYuan := ""
if !isFree {
priceYuan = fmt.Sprintf("%.2f", priceUnit)
}
// 与小程序 read 页 onLoad 对齐id、ref、mid、gift、requestSn付费章带 openPay=1 便于落地后自动调起微信支付
qv := url.Values{}
qv.Set("id", sectionID)
if ref != "" {
mpPath += "&ref=" + ref
qv.Set("ref", ref)
}
if midQ != "" {
qv.Set("mid", midQ)
}
if giftQ != "" {
qv.Set("gift", giftQ)
}
if reqSn != "" {
qv.Set("requestSn", reqSn)
}
if !isFree {
qv.Set("openPay", "1")
}
mpPath := "pages/read/read?" + qv.Encode()
publicBase := strings.TrimSuffix(strings.TrimSpace(cfg.H5ReadPublicBaseURL), "/")
if publicBase == "" {
publicBase = strings.TrimSuffix(strings.TrimSpace(cfg.BaseURL), "/")
}
if publicBase == "" {
publicBase = "https://soulapi.quwanzhi.com"
}
canonical := fmt.Sprintf("%s/read/%s", publicBase, url.PathEscape(sectionID))
if ref != "" || midQ != "" || giftQ != "" || reqSn != "" {
qc := url.Values{}
if ref != "" {
qc.Set("ref", ref)
}
if midQ != "" {
qc.Set("mid", midQ)
}
if giftQ != "" {
qc.Set("gift", giftQ)
}
if reqSn != "" {
qc.Set("requestSn", reqSn)
}
canonical += "?" + qc.Encode()
}
ogDesc := strings.TrimSpace(preview)
if ogDesc == "" {
ogDesc = title
}
if n := utf8.RuneCountInString(ogDesc); n > 120 {
ogDesc = string([]rune(ogDesc)[:120]) + "…"
}
ogImage := h5PickShareImage(ch.Content, cfg.BaseURL, cfg.H5ReadDefaultOgImage)
shareLinkJSON, _ := json.Marshal(canonical)
signURL := h5RequestSignURL(c)
jssdk := wechat.BuildJSSDKOpenLaunchPayload(context.Background(), signURL)
jssdkBytes, jerr := json.Marshal(jssdk)
if jerr != nil {
jssdkBytes = []byte(`{"ok":false}`)
}
pageHTML := h5BuildPage(h5PageData{
Title: title,
PartTitle: partTitle,
ChapterTitle: chapterTitle,
Preview: preview,
Percent: percent,
SectionID: sectionID,
Ref: ref,
AppID: appID,
MpPath: mpPath,
Title: title,
PartTitle: partTitle,
ChapterTitle: chapterTitle,
Preview: preview,
Percent: percent,
SectionID: sectionID,
Ref: ref,
AppID: appID,
MpPath: mpPath,
OgTagsHTML: h5BuildOgTags(canonical, title, ogDesc, ogImage),
ShareLinkJSON: string(shareLinkJSON),
SharePageDisplayURL: canonical,
ShareRate: shareRate,
IsFree: isFree,
PriceYuan: priceYuan,
JSSDKJSON: string(jssdkBytes),
})
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(pageHTML))
}
// h5RequestSignURL 生成与浏览器地址栏一致的 URL供 wx.config 签名;须含 path 与 query无 #
func h5RequestSignURL(c *gin.Context) string {
proto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
if proto == "" {
if c.Request.TLS != nil {
proto = "https"
} else {
proto = "http"
}
} else {
proto = strings.TrimSpace(strings.Split(proto, ",")[0])
}
host := c.Request.Host
if xh := c.GetHeader("X-Forwarded-Host"); xh != "" {
host = strings.TrimSpace(strings.Split(xh, ",")[0])
}
path := c.Request.URL.Path
u := proto + "://" + host + path
if rq := c.Request.URL.RawQuery; rq != "" {
u += "?" + rq
}
if i := strings.IndexByte(u, '#'); i >= 0 {
u = u[:i]
}
return u
}
func h5AbsolutizeImage(raw, apiBase string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
low := strings.ToLower(raw)
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
return raw
}
if strings.HasPrefix(raw, "//") {
return "https:" + raw
}
ab := strings.TrimSuffix(strings.TrimSpace(apiBase), "/")
if ab == "" {
return raw
}
if strings.HasPrefix(raw, "/") {
return ab + raw
}
return ab + "/" + raw
}
func h5PickShareImage(content, apiBase, fallback string) string {
s := strings.TrimSpace(content)
if s != "" {
if m := reOgImgHTML.FindStringSubmatch(s); len(m) > 1 {
if u := h5AbsolutizeImage(m[1], apiBase); u != "" {
return u
}
}
if m := reOgMdImg.FindStringSubmatch(s); len(m) > 1 {
if u := h5AbsolutizeImage(m[1], apiBase); u != "" {
return u
}
}
}
return strings.TrimSpace(fallback)
}
func h5BuildOgTags(canonical, title, desc, ogImage string) string {
et := html.EscapeString
var b strings.Builder
b.WriteString(fmt.Sprintf(`<link rel="canonical" href="%s">`, et(canonical)))
b.WriteString(`<meta property="og:type" content="article">`)
b.WriteString(fmt.Sprintf(`<meta property="og:title" content="%s">`, et(title)))
b.WriteString(fmt.Sprintf(`<meta property="og:description" content="%s">`, et(desc)))
b.WriteString(fmt.Sprintf(`<meta property="og:url" content="%s">`, et(canonical)))
if img := strings.TrimSpace(ogImage); img != "" {
e := et(img)
b.WriteString(fmt.Sprintf(`<meta property="og:image" content="%s">`, e))
b.WriteString(fmt.Sprintf(`<meta name="twitter:image" content="%s">`, e))
}
b.WriteString(`<meta name="twitter:card" content="summary_large_image">`)
b.WriteString(fmt.Sprintf(`<meta name="twitter:title" content="%s">`, et(title)))
b.WriteString(fmt.Sprintf(`<meta name="twitter:description" content="%s">`, et(desc)))
return b.String()
}
func h5Error(msg string) string {
return fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>提示</title><style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#0f1923;color:#ccc;}</style>
@@ -85,12 +285,23 @@ type h5PageData struct {
Percent int
SectionID, Ref string
AppID, MpPath string
OgTagsHTML string
ShareLinkJSON string // 已 json.Marshal 的完整分享 URL供 JS 一行赋值
SharePageDisplayURL string // 与 ShareLinkJSON 对应明文,页内展示+复制(发朋友圈用)
ShareRate int
IsFree bool
PriceYuan string // 付费时如 "1.00";免费为空
JSSDKJSON string // wx.config 用 JSON含 openTagList
}
func h5BuildPage(d h5PageData) string {
escapedTitle := html.EscapeString(d.Title)
escapedPart := html.EscapeString(d.PartTitle)
escapedChapter := html.EscapeString(d.ChapterTitle)
escapedSectionID := html.EscapeString(d.SectionID)
escapedAppID := html.EscapeString(d.AppID)
escapedMpPath := html.EscapeString(d.MpPath)
escapedSharePageURL := html.EscapeString(d.SharePageDisplayURL)
contentHTML := h5ContentToHTML(d.Preview)
@@ -106,128 +317,174 @@ func h5BuildPage(d h5PageData) string {
subtitle = fmt.Sprintf(`<p class="sub">%s</p>`, strings.Join(parts, " · "))
}
// 与 read.wxml 付费墙同一套营销句:解锁完整内容,分享得到 {rate}% 收益
marketingHTML := fmt.Sprintf(
`<div class="pw-mbox"><span class="pw-line">解锁完整内容,分享得到</span><span class="pw-pct">%d</span><span class="pw-line">%% 收益</span></div>`,
d.ShareRate,
)
// 付费:与小程序 read 单页模式一致readUi.singlePagePayButtonText + purchase-section 灰底药丸)
var primaryHTML string
if d.IsFree {
primaryHTML = `<div class="pw-primary pw-primary--full"><span class="pw-lab pw-lab--block">进入小程序阅读全文</span></div>`
} else {
btnText := html.EscapeString(fmt.Sprintf("支付 ¥%s 解锁全文", strings.TrimSpace(d.PriceYuan)))
primaryHTML = fmt.Sprintf(
`<div class="pw-primary pw-primary--sp"><span class="pw-lab pw-lab--block">%s</span></div>`,
btnText,
)
}
footerTipEsc := html.EscapeString("转发给需要的人,一起学习还能赚佣金")
lockSVG := `<div class="pw-icon-wrap" aria-hidden="true"><svg class="pw-lock" width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 11V8a5 5 0 0 1 10 0v3" stroke="#00CED1" stroke-width="2" stroke-linecap="round" fill="none"/><rect x="5" y="11" width="14" height="11" rx="2" stroke="#00CED1" stroke-width="2" fill="none"/></svg></div>`
mpBtn := fmt.Sprintf(
`<wx-open-launch-weapp id="launch-btn" appid="%s" path="%s" style="display:block;width:100%%">
<script type="text/wxtag-template">
<style>.btn{display:block;width:100%%;padding:14px 0;text-align:center;background:#07c160;color:#fff;border-radius:8px;font-size:16px;font-weight:600;border:none;letter-spacing:1px;}</style>
<button class="btn">打开小程序 购买全文</button>
</script>
</wx-open-launch-weapp>`,
html.EscapeString(d.AppID), html.EscapeString(d.MpPath))
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>%s - 一场Soul的创业实验</title>
<meta name="description" content="%s">
<script type="text/wxtag-template">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;background:#0b1220;color:#d1d5db;line-height:1.8;-webkit-font-smoothing:antialiased}
.wrap{max-width:680px;margin:0 auto;padding:20px 16px 120px}
.hdr{padding:16px 0;border-bottom:1px solid rgba(255,255,255,.08);margin-bottom:20px}
.hdr h1{font-size:22px;color:#fff;line-height:1.4;font-weight:700}
.hdr .sub{font-size:13px;color:#6b7280;margin-top:6px}
.content{font-size:15px;color:#c9d1d9;line-height:1.9;word-break:break-word}
.pw-shell{width:100%%;box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;}
.pw-card{margin:0;padding:16px 14px 18px;border-radius:16px;background:linear-gradient(135deg,#1c1c1e 0%%,#2c2c2e 100%%);border:1px solid rgba(0,206,209,0.2);}
.pw-icon-wrap{display:flex;justify-content:center;margin:0 auto 12px;}
.pw-lock{display:block;}
.pw-mbox{margin-bottom:12px;padding:14px 16px;border-radius:12px;background:#2c2c2e;border:1px solid rgba(255,255,255,0.1);text-align:center;line-height:1.55;}
.pw-line{font-size:17px;font-weight:600;color:#ffffff;display:inline;}
.pw-pct{font-size:23px;font-weight:800;color:#FFD700;display:inline;margin:0 2px;}
.pw-primary{display:flex;width:100%%;align-items:center;justify-content:center;padding:13px 14px;border-radius:999px;background:#2c2c2e;border:1px solid rgba(255,255,255,0.1);margin-bottom:0;box-sizing:border-box;}
.pw-primary--sp{/* 与 .purchase-section + .purchase-btn--compact-sp 一致 */ }
.pw-primary--full{background:linear-gradient(135deg,#00CED1 0%%,#20B2AA 100%%);border:none;box-shadow:0 4px 16px rgba(0,206,209,0.3);}
.pw-lab{font-size:15px;font-weight:600;color:#fff;}
.pw-lab--block{font-size:13px;font-weight:500;color:rgba(255,255,255,0.88);text-align:center;width:100%%;line-height:1.45;}
.pw-primary--full .pw-lab--block{font-size:15px;font-weight:600;color:#fff;}
.pw-foot{margin-top:14px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.08);text-align:center;font-size:12px;color:rgba(255,255,255,0.42);line-height:1.45;}
</style>
<div class="pw-shell"><div class="pw-card">%s%s%s<p class="pw-foot">%s</p></div></div>
</script>
</wx-open-launch-weapp>`,
escapedAppID, escapedMpPath, lockSVG, marketingHTML, primaryHTML, footerTipEsc)
ctaHint := ""
if d.IsFree {
ctaHint = "微信内点下方整块区域进入小程序阅读全文"
} else {
ctaHint = fmt.Sprintf("已试读 %d%% · 点下方「支付解锁」区域进小程序,将自动调起微信支付", d.Percent)
}
var b strings.Builder
b.WriteString("<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n")
b.WriteString(`<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">` + "\n")
b.WriteString(d.OgTagsHTML)
b.WriteString("\n<title>")
b.WriteString(escapedTitle)
b.WriteString(" - 一场Soul的创业实验</title>\n<meta name=\"description\" content=\"")
b.WriteString(escapedTitle)
b.WriteString("\">\n<style>\n")
b.WriteString(`*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",sans-serif;background:#000;color:rgba(255,255,255,0.85);line-height:1.75;-webkit-font-smoothing:antialiased;padding-bottom:calc(280px + env(safe-area-inset-bottom))}
.wrap{max-width:680px;margin:0 auto;padding:20px 16px 32px}
.badge-row{margin-bottom:12px}
.chapter-badge{display:inline-block;padding:4px 10px;border-radius:8px;font-size:12px;font-weight:600;color:#00CED1;background:rgba(0,206,209,0.12);border:1px solid rgba(0,206,209,0.35)}
.hdr{padding:8px 0 20px;border-bottom:1px solid rgba(255,255,255,0.08);margin-bottom:20px}
.hdr h1{font-size:22px;color:#fff;line-height:1.45;font-weight:700}
.hdr .sub{font-size:13px;color:rgba(255,255,255,0.45);margin-top:8px}
.content{font-size:15px;color:rgba(255,255,255,0.78);line-height:1.9;word-break:break-word}
.content p{margin-bottom:12px}
.content h1,.content h2,.content h3{color:#fff;margin:20px 0 10px;font-weight:600}
.content h1{font-size:20px}
.content h2{font-size:18px}
.content h3{font-size:16px}
.content strong{color:#fff}
.content blockquote{border-left:3px solid #38bdac;padding-left:12px;margin:12px 0;color:#9ca3af}
.content code{background:#1f2937;padding:2px 6px;border-radius:3px;font-size:13px;color:#38bdac}
.content img{max-width:100%%;border-radius:6px;margin:8px 0}
.content blockquote{border-left:3px solid #00CED1;padding-left:12px;margin:12px 0;color:rgba(255,255,255,0.5)}
.content code{background:#2c2c2e;padding:2px 6px;border-radius:4px;font-size:13px;color:#00CED1}
.content img{max-width:100%;border-radius:8px;margin:8px 0}
.fade{position:relative;overflow:hidden;max-height:none}
.fade::after{content:"";position:absolute;bottom:0;left:0;right:0;height:120px;background:linear-gradient(transparent,#0b1220);pointer-events:none}
.cta{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(transparent,#0b1220 20%%);padding:16px 16px 24px;z-index:100}
.fade::after{content:"";position:absolute;bottom:0;left:0;right:0;height:140px;background:linear-gradient(to top,rgba(0,0,0,1) 0%,transparent 100%);pointer-events:none}
.cta{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(to top,rgba(0,0,0,0.98) 40%,transparent);padding:10px 16px calc(16px + env(safe-area-inset-bottom));z-index:100}
.cta-inner{max-width:680px;margin:0 auto}
.cta-hint{text-align:center;font-size:12px;color:#6b7280;margin-bottom:8px}
.btn-fallback{display:block;width:100%%;padding:14px 0;text-align:center;background:#07c160;color:#fff;border-radius:8px;font-size:16px;font-weight:600;border:none;cursor:pointer;letter-spacing:1px;text-decoration:none}
.btn-fallback:active{opacity:.8}
.copy-toast{display:none;position:fixed;top:50%%;left:50%%;transform:translate(-50%%,-50%%);background:rgba(0,0,0,.8);color:#fff;padding:12px 24px;border-radius:8px;font-size:14px;z-index:999}
.btn-unlock{display:block;width:100%%;padding:14px 0;text-align:center;background:linear-gradient(135deg,#f59e0b,#f97316);color:#fff;border-radius:8px;font-size:16px;font-weight:700;border:none;cursor:pointer;letter-spacing:1px;margin-bottom:10px;box-shadow:0 2px 12px rgba(245,158,11,.35)}
.btn-unlock:active{opacity:.85}
.guide-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:200;flex-direction:column;align-items:center;justify-content:flex-end;padding-bottom:80px}
.guide-overlay.visible{display:flex}
.guide-text{color:#fff;font-size:17px;font-weight:600;text-align:center;margin-bottom:16px;text-shadow:0 1px 4px rgba(0,0,0,.5)}
.guide-arrow-wrap{position:absolute;bottom:24px;right:36px;display:flex;flex-direction:column;align-items:center;gap:6px}
.guide-arrow-label{color:#fff;font-size:13px;white-space:nowrap;opacity:.9}
.guide-arrow{width:36px;height:36px;animation:bounceArrow 1s ease-in-out infinite}
@keyframes bounceArrow{0%%,100%%{transform:translateY(0)}50%%{transform:translateY(10px)}}
.guide-close{position:absolute;top:20px;right:20px;width:36px;height:36px;border-radius:50%%;background:rgba(255,255,255,.15);border:none;color:#fff;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center}
.guide-close:active{background:rgba(255,255,255,.3)}
</style>
</head>
<body>
<div class="wrap">
<div class="hdr">
<h1>%s</h1>
%s
</div>
<div class="content fade" id="article-content">
%s
</div>
</div>
<div class="cta">
<div class="cta-inner">
<p class="cta-hint">已预览 %d%% · 打开小程序购买并阅读全文</p>
<button class="btn-unlock" id="unlock-btn" onclick="showGuide()">¥1 解锁全文</button>
<div id="mp-btn-area">%s</div>
<button class="btn-fallback" id="fallback-btn" style="display:none" onclick="copyAndOpen()">
复制链接并打开微信
</button>
</div>
</div>
<div class="guide-overlay" id="guide-overlay" onclick="hideGuide()">
<button class="guide-close" onclick="hideGuide()">×</button>
<p class="guide-text">在小程序内完成购买,点击下方前往</p>
<div class="guide-arrow-wrap">
<span class="guide-arrow-label">点击右下角前往小程序</span>
<svg class="guide-arrow" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4v12m0 0l-5-5m5 5l5-5" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
<div class="copy-toast" id="toast">已复制,请打开微信</div>
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>
.cta-hint{text-align:center;font-size:12px;color:rgba(255,255,255,0.42);margin-bottom:10px;line-height:1.45}
.share-link-bar{margin-bottom:10px;padding:10px 12px;border-radius:10px;background:rgba(255,255,255,0.05);border:1px solid rgba(0,206,209,0.25)}
.share-link-label{display:block;font-size:11px;color:rgba(255,255,255,0.5);margin-bottom:6px;text-align:center}
.share-url-text{font-size:12px;color:#00CED1;word-break:break-all;line-height:1.45;text-align:left;margin-bottom:8px}
.btn-copy-url{display:block;width:100%;padding:9px 0;text-align:center;font-size:13px;font-weight:600;color:#00CED1;background:rgba(0,206,209,0.12);border:1px solid rgba(0,206,209,0.45);border-radius:10px;cursor:pointer}
.btn-copy-url:active{opacity:0.88}
.btn-fallback{display:block;width:100%;margin-top:10px;padding:12px 0;text-align:center;background:transparent;color:rgba(255,255,255,0.88);border-radius:12px;font-size:14px;font-weight:500;border:1px solid rgba(255,255,255,0.22);cursor:pointer}
.btn-fallback:active{opacity:0.85}
.copy-toast{display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.85);color:#fff;padding:12px 24px;border-radius:8px;font-size:14px;z-index:999}
`)
b.WriteString("</style>\n</head>\n<body>\n<div class=\"wrap\">\n")
b.WriteString(`<div class="badge-row"><span class="chapter-badge">`)
b.WriteString(escapedSectionID)
b.WriteString(`</span></div>` + "\n")
b.WriteString("<div class=\"hdr\">\n<h1>")
b.WriteString(escapedTitle)
b.WriteString("</h1>\n")
b.WriteString(subtitle)
b.WriteString("\n</div>\n<div class=\"content fade\" id=\"article-content\">\n")
b.WriteString(contentHTML)
b.WriteString("\n</div></div>\n")
b.WriteString(`<div class="cta"><div class="cta-inner">` + "\n")
b.WriteString(`<p class="cta-hint">`)
b.WriteString(html.EscapeString(ctaHint))
b.WriteString(`</p>` + "\n")
b.WriteString(`<div class="share-link-bar">` + "\n")
b.WriteString(`<span class="share-link-label">发朋友圈请复制本链接(微信内打开 → 点下方「支付解锁全文」区域 → 进小程序付款)</span>` + "\n")
b.WriteString(`<p class="share-url-text" id="page-url-text">`)
b.WriteString(escapedSharePageURL)
b.WriteString(`</p>` + "\n")
b.WriteString(`<button type="button" class="btn-copy-url" onclick="copyPageUrl()">复制本页链接</button>` + "\n")
b.WriteString(`</div>` + "\n")
b.WriteString(mpBtn)
b.WriteString("\n")
b.WriteString(`<button class="btn-fallback" id="fallback-btn" style="display:none" onclick="copyAndOpen()">复制链接并打开微信</button>` + "\n")
b.WriteString(`</div></div>` + "\n")
b.WriteString(`<script type="application/json" id="wx-jssdk-data">`)
b.WriteString(d.JSSDKJSON)
b.WriteString(`</script>` + "\n")
b.WriteString(`<div class="copy-toast" id="toast">已复制,请打开微信</div>` + "\n")
b.WriteString(`<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>` + "\n<script>\n")
b.WriteString(`document.addEventListener('WeixinOpenTagsError', function (e) { console.error('[WeixinOpenTagsError]', e && e.detail); });
(function(){
var ua = navigator.userAgent.toLowerCase();
var isWx = ua.indexOf('micromessenger') !== -1;
var launchBtn = document.getElementById('launch-btn');
var fallbackBtn = document.getElementById('fallback-btn');
if (!isWx) {
if (launchBtn) launchBtn.style.display = 'none';
fallbackBtn.style.display = 'block';
} else {
if (launchBtn) {
launchBtn.addEventListener('error', function(e) {
console.log('launch-btn error', e.detail);
launchBtn.style.display = 'none';
fallbackBtn.style.display = 'block';
});
try {
var el = document.getElementById('wx-jssdk-data');
if (el && el.textContent && typeof wx !== 'undefined') {
var j = JSON.parse(el.textContent);
if (j && j.ok) {
wx.config({
debug: false,
appId: j.appId,
timestamp: j.timestamp,
nonceStr: j.nonceStr,
signature: j.signature,
jsApiList: [],
openTagList: ['wx-open-launch-weapp']
});
wx.ready(function () {});
wx.error(function (err) { console.warn('[wx.config]', err); });
}
}
}
} catch (e) { console.warn('[wx-jssdk]', e); }
})();
function showGuide() {
document.getElementById('guide-overlay').classList.add('visible');
}
function hideGuide() {
document.getElementById('guide-overlay').classList.remove('visible');
}
function copyAndOpen() {
var link = 'https://soul.quwanzhi.com/read/%s';
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(link).then(showToast);
`)
b.WriteString("(function(){\n")
b.WriteString(" var ua = navigator.userAgent.toLowerCase();\n")
b.WriteString(" var isWx = ua.indexOf('micromessenger') !== -1;\n")
b.WriteString(" var launchBtn = document.getElementById('launch-btn');\n")
b.WriteString(" var fallbackBtn = document.getElementById('fallback-btn');\n")
b.WriteString(" if (!isWx) {\n")
b.WriteString(" if (launchBtn) launchBtn.style.display = 'none';\n")
b.WriteString(" if (fallbackBtn) fallbackBtn.style.display = 'block';\n")
b.WriteString(" } else if (launchBtn) {\n")
b.WriteString(" launchBtn.addEventListener('error', function(e) {\n")
b.WriteString(" console.log('launch-btn error', e.detail);\n")
b.WriteString(" launchBtn.style.display = 'none';\n")
b.WriteString(" if (fallbackBtn) fallbackBtn.style.display = 'block';\n")
b.WriteString(" });\n")
b.WriteString(" }\n")
b.WriteString("})();\n")
b.WriteString("function copyPageUrl() {\n")
b.WriteString(" var link = ")
b.WriteString(d.ShareLinkJSON)
b.WriteString(";\n")
b.WriteString(` if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(link).then(function(){ showToast('链接已复制,可到朋友圈粘贴'); });
} else {
var ta = document.createElement('textarea');
ta.value = link;
@@ -237,22 +494,37 @@ function copyAndOpen() {
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast();
showToast('链接已复制,可到朋友圈粘贴');
}
}
function showToast() {
var t = document.getElementById('toast');
t.style.display = 'block';
setTimeout(function(){ t.style.display = 'none'; }, 2000);
`)
b.WriteString("function copyAndOpen() {\n")
b.WriteString(" var link = ")
b.WriteString(d.ShareLinkJSON)
b.WriteString(";\n")
b.WriteString(` if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(link).then(function(){ showToast('已复制,请打开微信'); });
} else {
var ta = document.createElement('textarea');
ta.value = link;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast('已复制,请打开微信');
}
}
</script>
</body>
</html>`,
escapedTitle, escapedTitle,
escapedTitle, subtitle,
contentHTML,
d.Percent, mpBtn,
html.EscapeString(d.SectionID))
function showToast(msg) {
var t = document.getElementById('toast');
if (msg) t.textContent = msg;
t.style.display = 'block';
setTimeout(function(){ t.style.display = 'none'; t.textContent = '已复制,请打开微信'; }, 2200);
}
`)
b.WriteString("</script>\n</body>\n</html>")
return b.String()
}
func h5ContentToHTML(content string) string {

View File

@@ -0,0 +1,182 @@
package wechat
import (
"context"
"crypto/rand"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// 小程序 access_token + jsapi_ticket 缓存(用于 H5 wx-open-launch-weapp 的 wx.config
var (
jssdkMu sync.Mutex
accessToken string
accessExpiry time.Time
jsapiTicket string
ticketExpiry time.Time
)
type wxTokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
type wxTicketResp struct {
Ticket string `json:"ticket"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
func nonceStr16() string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
for i := range b {
b[i] = chars[int(b[i])%len(chars)]
}
return string(b)
}
func fetchAccessToken(ctx context.Context) (string, time.Time, error) {
if cfg == nil || cfg.WechatAppID == "" || cfg.WechatAppSecret == "" {
return "", time.Time{}, fmt.Errorf("wechat not configured")
}
u := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
escapeQuery(cfg.WechatAppID), escapeQuery(cfg.WechatAppSecret))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", time.Time{}, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", time.Time{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", time.Time{}, err
}
var tr wxTokenResp
if err := json.Unmarshal(body, &tr); err != nil {
return "", time.Time{}, err
}
if tr.ErrCode != 0 {
return "", time.Time{}, fmt.Errorf("wechat token errcode=%d %s", tr.ErrCode, tr.ErrMsg)
}
if tr.AccessToken == "" {
return "", time.Time{}, fmt.Errorf("wechat token empty")
}
exp := time.Now().Add(time.Duration(tr.ExpiresIn-120) * time.Second)
if tr.ExpiresIn <= 120 {
exp = time.Now().Add(30 * time.Minute)
}
return tr.AccessToken, exp, nil
}
func escapeQuery(s string) string { return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") }
func fetchJSAPITicket(ctx context.Context, accessTok string) (string, time.Time, error) {
u := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=%s&type=jsapi", escapeQuery(accessTok))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", time.Time{}, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", time.Time{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", time.Time{}, err
}
var tr wxTicketResp
if err := json.Unmarshal(body, &tr); err != nil {
return "", time.Time{}, err
}
if tr.ErrCode != 0 {
return "", time.Time{}, fmt.Errorf("wechat ticket errcode=%d %s", tr.ErrCode, tr.ErrMsg)
}
if tr.Ticket == "" {
return "", time.Time{}, fmt.Errorf("wechat ticket empty")
}
exp := time.Now().Add(time.Duration(tr.ExpiresIn-120) * time.Second)
if tr.ExpiresIn <= 120 {
exp = time.Now().Add(30 * time.Minute)
}
return tr.Ticket, exp, nil
}
func ensureJSAPITicket(ctx context.Context) (string, error) {
jssdkMu.Lock()
defer jssdkMu.Unlock()
now := time.Now()
if jsapiTicket != "" && now.Before(ticketExpiry) {
return jsapiTicket, nil
}
if accessToken == "" || now.After(accessExpiry) {
tok, exp, err := fetchAccessToken(ctx)
if err != nil {
return "", err
}
accessToken, accessExpiry = tok, exp
}
ticket, exp, err := fetchJSAPITicket(ctx, accessToken)
if err != nil {
return "", err
}
jsapiTicket, ticketExpiry = ticket, exp
return jsapiTicket, nil
}
// JSSDKOpenLaunchPayload 供 H5 页 wx.config须含 openTagList: wx-open-launch-weapp
type JSSDKOpenLaunchPayload struct {
OK bool `json:"ok"`
AppID string `json:"appId,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
NonceStr string `json:"nonceStr,omitempty"`
Signature string `json:"signature,omitempty"`
}
// BuildJSSDKOpenLaunchPayload 为当前页面 URL 生成签名;失败时 ok=false页面仍可降级展示
func BuildJSSDKOpenLaunchPayload(ctx context.Context, pageURL string) JSSDKOpenLaunchPayload {
out := JSSDKOpenLaunchPayload{OK: false}
if cfg == nil {
return out
}
pageURL = strings.TrimSpace(pageURL)
if pageURL == "" {
return out
}
if i := strings.IndexByte(pageURL, '#'); i >= 0 {
pageURL = pageURL[:i]
}
ticket, err := ensureJSAPITicket(ctx)
if err != nil {
return out
}
nonce := nonceStr16()
ts := time.Now().Unix()
plain := fmt.Sprintf("jsapi_ticket=%s&noncestr=%s&timestamp=%d&url=%s", ticket, nonce, ts, pageURL)
sum := sha1.Sum([]byte(plain))
sig := hex.EncodeToString(sum[:])
out.OK = true
out.AppID = cfg.WechatAppID
out.Timestamp = ts
out.NonceStr = nonce
out.Signature = sig
return out
}

View File

@@ -2,17 +2,23 @@
# -*- coding: utf-8 -*-
"""
soulApisoul-api 后端Go 项目一键部署到宝塔(正式环境)
- 打包使用 .env.production 作为服务器 .env
- 本地交叉编译 Linux 二进制
- 上传到 /www/wwwroot/self/soul-api
- 重启:优先宝塔 API需配置否则 SSH 下 setsid nohup 启动
默认全程走宝塔面板 API无需 SSH探活 → 本机编译打包 → 面板「上传文件」→「解压」→「删包」→ Site 启停重启。
宝塔 API 重启(可选):在环境变量或 .env 中设置
BT_PANEL_URL = https://你的面板地址:9988
BT_API_KEY = 面板 设置 -> API 接口 中的密钥
BT_GO_PROJECT_NAME = soulApi (与宝塔「网站」里 Go 站点名一致)
BT_GO_SITE_ID = 可选,网站 id不设则从 sites 表自动匹配 Go 站点
并安装 requests: pip install requests
1.(默认)宝塔 API 探活:/system?action=GetDiskInfo
2. 本地交叉编译 → tar.gz包内 soul-api 强制 755避免 Windows 打包丢执行位)
3. /files?action=upload 分片上传到 DEPLOY_PROJECT_PATH
4. /files?action=UnZip 解压到同目录;/files?action=DeleteFile 删除 tar
5. SiteStop/SiteStart 重启 Go 站点auto 模式下自动执行第二轮启停(无 SSH 时尽量换新进程
命令行:
python3 master.py # 默认 --deploy-method btapi
python3 master.py --deploy-method ssh # 仅当服务器允许密码/密钥 SSH 时使用旧逻辑
python3 master.py --skip-bt-ping
python3 master.py --verify
python3 master.py --restart-method btapi
环境变量BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME、BT_GO_SITE_ID可选 BT_UPLOAD_CHUNK_MB默认 4
依赖requests 必选paramiko 仅在 --deploy-method ssh 时需要
"""
from __future__ import print_function
@@ -33,9 +39,7 @@ import shlex
try:
import paramiko
except ImportError:
print("错误: 请先安装 paramiko")
print(" pip install paramiko")
sys.exit(1)
paramiko = None
try:
import requests
@@ -193,7 +197,14 @@ def pack_deploy(root, binary_path, include_env=True):
tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz")
with tarfile.open(tarball, "w:gz") as tf:
for name in os.listdir(staging):
tf.add(os.path.join(staging, name), arcname=name)
full = os.path.join(staging, name)
if name == "soul-api":
ti = tf.gettarinfo(full, arcname=name)
ti.mode = 0o755
with open(full, "rb") as bf:
tf.addfile(ti, bf)
else:
tf.add(full, arcname=name)
print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, os.path.getsize(tarball) / 1024 / 1024))
return tarball
except Exception as e:
@@ -206,14 +217,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, 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, timeout=timeout, verify=False)
def _bt_parse_json_response(r):
@@ -315,7 +326,7 @@ def restart_via_bt_api(cfg):
if not url or not key:
return False
if not requests:
print(" [提示] 未安装 requests无法使用宝塔 API,将用 SSH 重启。pip install requests")
print(" [提示] 未安装 requests无法使用宝塔 API。pip install requests")
return False
try:
base = url.rstrip("/")
@@ -358,11 +369,271 @@ def restart_via_bt_api(cfg):
return False
# ==================== SSH 上传 ====================
def bt_panel_ping(cfg):
"""
与 scripts/deploy_kr_btapi_verify.py 一致GetDiskInfo 校验面板 API 密钥与可达性。
返回 True=通过;无 url/key 或未装 requests 时返回 True不阻断由后续重启阶段再报错
"""
if not requests:
return True
url = (cfg.get("bt_panel_url") or "").rstrip("/")
key = cfg.get("bt_api_key") or ""
if not url or not key:
return True
req_time = int(time.time())
sk_md5 = hashlib.md5(key.encode()).hexdigest()
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
try:
r = requests.post(
url + "/system?action=GetDiskInfo",
data={"request_time": req_time, "request_token": req_token},
timeout=20,
verify=False,
)
if r.status_code != 200:
print("[宝塔API 探活] HTTP %s" % r.status_code)
return False
ct = (r.headers.get("content-type") or "").lower()
j = {}
if "json" in ct:
try:
j = r.json()
except Exception:
j = {}
else:
t = (r.text or "").lstrip()
if t.startswith("{"):
try:
j = json.loads(r.text)
except Exception:
j = {}
if isinstance(j, dict) and j.get("status") is False:
print("[宝塔API 探活] %s" % (j.get("msg") or j))
return False
print("[宝塔API 探活] 成功GetDiskInfo", flush=True)
return True
except Exception as e:
print("[宝塔API 探活] 异常: %s" % e)
return False
def verify_public_api_only(base_api=None):
"""公网只读冒烟(仅 soul-api与 deploy_kr_btapi_verify 中 API 三项一致。"""
if not requests:
print(" [跳过] --verify 需要 requests")
return False
base = (base_api or os.environ.get("VERIFY_API_BASE", "https://soulapi.quwanzhi.com")).rstrip("/")
checks = [
("GET %s/health" % base, "%s/health" % base, lambda r: r.status_code == 200 and '"status"' in r.text),
("GET %s/api/book/parts" % base, "%s/api/book/parts" % base, lambda r: r.status_code == 200),
("GET %s/api/config" % base, "%s/api/config" % base, lambda r: r.status_code == 200),
]
print("")
print("=" * 60)
print(" 线上冒烟验证(仅 API")
print("=" * 60)
all_ok = True
for name, url, pred in checks:
try:
r = requests.get(url, timeout=20, verify=True)
ok = pred(r)
print(" [%s] %s" % ("OK" if ok else "FAIL", name))
if not ok:
print(" status=%s len=%s" % (r.status_code, len(r.text or "")))
all_ok = False
except Exception as ex:
print(" [FAIL] %s%s" % (name, ex))
all_ok = False
return all_ok
# ==================== 宝塔 API上传 / 解压 / 删文件(无 SSH====================
def _bt_upload_headers():
return {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}
def _bt_parse_upload_response(r):
"""解析 /files?action=upload 的返回:成功 dict、进行中为纯数字偏移、失败 dict。"""
if r is None or r.status_code != 200:
return "err", None, "HTTP %s" % (r.status_code if r else "?")
raw = (r.text or "").strip()
if raw.isdigit():
return "progress", int(raw), None
j = _bt_parse_json_response(r)
if isinstance(j, dict):
if j.get("status") is True:
return "ok", None, None
return "err", None, j.get("msg") or str(j)
return "err", None, raw[:300] if raw else "empty body"
def bt_upload_file_resumable(cfg, local_path, remote_dir, remote_name):
"""
宝塔 /files?action=uploadmultipartblob + f_path/f_name/f_size/f_start + 签名)。
大文件按块续传与面板逻辑一致f_start 须等于服务器上 .upload.tmp 当前大小)。
"""
if not requests:
print(" [失败] 需要 requestspip install requests")
return False
url = (cfg.get("bt_panel_url") or "").rstrip("/")
key = cfg.get("bt_api_key") or ""
if not url or not key:
print(" [失败] 缺少 BT_PANEL_URL 或 BT_API_KEY")
return False
total = os.path.getsize(local_path)
chunk_mb = int(os.environ.get("BT_UPLOAD_CHUNK_MB", "4"))
chunk_size = max(1, chunk_mb) * 1024 * 1024
f_start = 0
last_start = -1
upload_url = url + "/files?action=upload"
headers = _bt_upload_headers()
print(" [宝塔API] 上传 %s%s/%s(共 %.2f MB每块 %d MB" % (
os.path.basename(local_path), remote_dir, remote_name, total / 1024 / 1024, chunk_mb))
try:
with open(local_path, "rb") as fp:
while f_start < total:
if f_start == last_start:
print(" [失败] 上传停滞 offset=%s(请删服务器上同名 .upload.tmp 后重试)" % f_start)
return False
last_start = f_start
fp.seek(f_start)
buf = fp.read(min(chunk_size, total - f_start))
if not buf:
print(" [失败] 读取本地文件失败 offset=%s" % f_start)
return False
req_time = int(time.time())
sk_md5 = hashlib.md5(key.encode()).hexdigest()
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
data = {
"request_time": str(req_time),
"request_token": req_token,
"f_path": remote_dir,
"f_name": remote_name,
"f_size": str(total),
"f_start": str(f_start),
}
files = {"blob": ("blob", buf, "application/octet-stream")}
r = requests.post(
upload_url,
data=data,
files=files,
headers=headers,
timeout=600,
verify=False,
)
kind, prog, err = _bt_parse_upload_response(r)
if kind == "ok":
print(" [成功] 宝塔上传完成")
return True
if kind == "progress":
if prog <= f_start:
print(" [失败] 上传偏移未前进: server=%s local=%s" % (prog, f_start))
return False
f_start = prog
continue
print(" [失败] 上传接口: %s" % (err or kind))
return False
print(" [失败] 上传未正常结束offset=%s size=%s" % (f_start, total))
return False
except Exception as e:
print(" [失败] 上传异常: %s" % e)
return False
def bt_delete_file(cfg, remote_path):
"""宝塔 /files?action=DeleteFile"""
url = (cfg.get("bt_panel_url") or "").rstrip("/")
key = cfg.get("bt_api_key") or ""
if not url or not key or not remote_path:
return False
j = _bt_parse_json_response(
_bt_signed_post(url, key, "/files?action=DeleteFile", {"path": remote_path})
)
if isinstance(j, dict) and j.get("status") is True:
return True
if isinstance(j, dict) and j.get("msg"):
print(" [宝塔API] DeleteFile: %s" % j.get("msg"))
return False
def bt_unzip_remote(cfg, sfile, dfile):
"""宝塔 /files?action=UnZipsfile=压缩包绝对路径dfile=解压目标目录)。"""
url = (cfg.get("bt_panel_url") or "").rstrip("/")
key = cfg.get("bt_api_key") or ""
coding = "UTF-8"
for type1 in ("tar.gz", "tgz", "tar"):
j = _bt_parse_json_response(
_bt_signed_post(
url,
key,
"/files?action=UnZip",
{"sfile": sfile, "dfile": dfile, "type1": type1, "coding": coding},
timeout=180,
)
)
if isinstance(j, dict) and j.get("status") is True:
print(" [成功] 宝塔解压完成 (type1=%s)" % type1)
return True
if isinstance(j, dict) and j.get("msg"):
print(" [宝塔API] UnZip type1=%s: %s" % (type1, j.get("msg")))
return False
def upload_and_extract_via_btapi(cfg, tarball_path, no_restart=False, restart_method="auto"):
"""仅宝塔 API上传 tar → 解压到项目目录 → 删 tar → Site 启停。"""
print("[3/4] 宝塔 API 上传并解压 …")
if not requests:
print(" [失败] 需要 requestspip install requests")
return False
project_path = cfg["project_path"].rstrip("/")
tar_name = "soul_api_deploy.tar.gz"
remote_tar = project_path + "/" + tar_name
if not bt_upload_file_resumable(cfg, tarball_path, project_path, tar_name):
return False
ddir = project_path.rstrip("/") + "/"
print(" [宝塔API] 解压 %s%s" % (remote_tar, ddir))
if not bt_unzip_remote(cfg, remote_tar, ddir):
print(" [提示] 解压失败时可检查面板「文件」中是否存在同名目录占用或 type 不支持")
return False
if bt_delete_file(cfg, remote_tar):
print(" [已清理] 远程 %s" % remote_tar)
else:
print(" [提示] 未能删除远程压缩包,可在面板手动删: %s" % remote_tar)
if not no_restart:
print("[4/4] 重启 soulApi宝塔 Site API")
if restart_method == "ssh":
print(" [提示] --deploy-method btapi 时不支持仅 SSH 重启;已改为宝塔 API")
restart_method = "btapi"
ok = False
if restart_method in ("auto", "btapi"):
ok = restart_via_bt_api(cfg)
if ok and restart_method == "auto":
print(" [宝塔API] 第二轮 SiteStop/SiteStart无 SSH 时提高换新进程概率)…")
time.sleep(4)
ok = restart_via_bt_api(cfg) or ok
if not ok:
print(" [失败] 宝塔重启未成功,请到面板「网站」手动启停 Go 站点或核对 BT_GO_SITE_ID / API 白名单")
return False
else:
print("[4/4] 跳过重启 (--no-restart)")
print(" [成功] 已解压到: %s" % project_path)
return True
# ==================== SSH 上传(可选,仅 --deploy-method ssh====================
def _connect_ssh(cfg):
"""建立 SSH 连接,启用 keepalive 防大文件上传时断连"""
if paramiko is None:
raise RuntimeError("未安装 paramiko无法使用 SSH 部署pip install paramiko")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
@@ -404,8 +675,8 @@ def _get_port_pids(client, port):
return set()
def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto"):
"""上传 tar.gz 到服务器并解压、重启"""
def upload_and_extract_via_ssh(cfg, tarball_path, no_restart=False, restart_method="auto"):
"""SSH/SFTP 上传 tar 并远程解压(仅当服务器允许密码或公钥登录时使用)。"""
print("[3/4] SSH 上传并解压 ...")
if not cfg.get("password") and not cfg.get("ssh_key"):
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
@@ -526,6 +797,21 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
pass
def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto", deploy_method="btapi"):
"""
deploy_method:
btapi — 仅宝塔 API上传/解压/删包/启停),不要求 SSH。
ssh — 旧逻辑SFTP + 远程 tar需 paramiko 且服务器允许对应认证方式)。
"""
dm = (deploy_method or "btapi").strip().lower()
if dm == "ssh":
return upload_and_extract_via_ssh(cfg, tarball_path, no_restart=no_restart, restart_method=restart_method)
if dm != "btapi":
print(" [失败] 未知 --deploy-method: %s(仅支持 btapi / ssh" % deploy_method)
return False
return upload_and_extract_via_btapi(cfg, tarball_path, no_restart=no_restart, restart_method=restart_method)
# ==================== 主函数 ====================
@@ -543,6 +829,25 @@ def main():
default="auto",
help="重启方式: auto=先试宝塔API再SSH, btapi=仅宝塔API, ssh=仅SSH (默认 auto)",
)
parser.add_argument(
"--skip-bt-ping",
action="store_true",
help="跳过宝塔面板 API 探活(与 deploy_kr_btapi_verify.py --skip-bt-ping 一致)",
)
parser.add_argument(
"--verify",
action="store_true",
help="部署成功后对 VERIFY_API_BASE默认 https://soulapi.quwanzhi.com做 health/book/config 冒烟",
)
_deploy_default = (os.environ.get("DEPLOY_METHOD") or "btapi").strip().lower()
if _deploy_default not in ("btapi", "ssh"):
_deploy_default = "btapi"
parser.add_argument(
"--deploy-method",
choices=("btapi", "ssh"),
default=_deploy_default,
help="btapi=仅宝塔API上传/解压/重启默认ssh=SFTP+远程命令(需 SSH 可用)",
)
args = parser.parse_args()
script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -552,10 +857,22 @@ def main():
print("=" * 60)
print(" soulApisoul-api一键部署到宝塔")
print("=" * 60)
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
print(" 部署方式: %s" % args.deploy_method)
if args.deploy_method == "ssh":
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
print(" 目标目录: %s" % cfg["project_path"])
site_hint = (os.environ.get("BT_GO_SITE_ID") or "").strip()
if site_hint:
print(" 宝塔站点: BT_GO_SITE_ID=%s, BT_GO_PROJECT_NAME=%s" % (site_hint, cfg.get("bt_go_project_name", "")))
else:
print(" 宝塔站点: 自动匹配 Go 站点名 %s(可设 BT_GO_SITE_ID" % cfg.get("bt_go_project_name", ""))
print("=" * 60)
if not args.skip_bt_ping:
if not bt_panel_ping(cfg):
print("[失败] 宝塔 API 探活未通过,已中止(可用 --skip-bt-ping 跳过)")
return 1
binary_path = os.path.join(root, "soul-api")
if not args.no_build:
p = run_build(root)
@@ -571,7 +888,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,
deploy_method=args.deploy_method,
):
return 1
try:
@@ -581,6 +904,16 @@ def main():
print("")
print(" 部署完成!目录: %s" % cfg["project_path"])
if args.verify:
time.sleep(2)
if not verify_public_api_only():
print("")
print("[失败] 线上 API 冒烟存在未通过项(部署已执行,请排查 Nginx/证书/业务)")
return 1
print("")
print(" 冒烟通过(仅 API")
return 0

View File

@@ -0,0 +1,3 @@
# 朋友圈 / H5 对照截图
将验收截图放在本目录,文件名与上级文档《朋友圈与外链完整方案.md》**第八节**表格一致即可在文档中预览。

View File

@@ -0,0 +1,119 @@
# 朋友圈与外链完整方案Soul / 卡若创业派对小程序)
> 目标:在**微信内**把用户带到 `pages/read/read`(或其它业务页),并兼顾「站外复制链接」「朋友圈卡片预览」「少跳转」。
---
## 一、三种方式怎么选(决策)
| 场景 | 推荐方案 | 说明 |
|------|-----------|------|
| 用户**已在小程序里**看文章,要发朋友圈 | **方案 A原生「分享到朋友圈」** | 由 `onShareTimeline` 提供标题、query、可选 `imageUrl`,单页模式由微信渲染,**体验最好**。 |
| 从**聊天/短信/其它 App** 贴一条 **HTTPS 链接**,点进小程序 | **方案 BURL Link`https://wxaurl.cn/...`** | 调微信官方 `generate_urllink`**无自建 H5**;是否还有极短系统过渡由**微信客户端**决定。 |
| 需要 **朋友圈链接卡片** 上出现**自定义标题 + 大图**,且可接受自建一页 | **方案 CH5 落地页 `/read/:id` + `wx-open-launch-weapp`** | 服务端 `GET /read/:id`(见 `soul-api/internal/handler/h5_read.go`),带 **Open Graph**;页内按钮拉起小程序。 |
**不存在**「任意粘贴一条非微信域名的链接、又完全零过渡、又 100% 由业务方写死保证」的通用能力——**最后一步打开小程序**由微信内核完成。
**域名与 404**`/read/:id`**soul-api** 提供,正确公网地址为 **`https://soulapi.quwanzhi.com/read/...`**(与小程序 `baseUrl`、默认 `API_BASE_URL` 一致)。**`https://soul.quwanzhi.com/read/...`** 指向 **Web 前端**,未配置反代时会 **404**。若必须用 `soul.quwanzhi.com` 展示,请在 Nginx/网关增加 **`location /read/` → soul-api** 反代。
---
## 二、方案 A小程序内直接分享到朋友圈首选
1. 阅读页已 `wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] })`
2. 用户在文章页点右上角 **··· → 分享到朋友圈**。
3. 逻辑在 `miniprogram/pages/read/read.js`**`onShareTimeline`**:返回 `title``query``id` / `mid` / `ref`)、可选 **`imageUrl`**`readUi.timelineImageUrl`)。
**优点**:卡片样式、单页模式、合规路径最全。
**局限**:必须从**小程序内**发起,不能替代「站外只发一条 URL」。
---
## 三、方案 BURL Link官方 HTTPS无自建中间业务页
1. 使用小程序 **AppID + AppSecret**`access_token`
2. `POST https://api.weixin.qq.com/wxa/generate_urllink`
3. 请求体里 `jump_wxa.path``jump_wxa.query``env_version`(通常 `release`)。
**仓库工具**`scripts/wechat_generate_share_link.py`(或 `packs/wechat-miniprogram-urllink-tool/generate_share_link.py`)。
示例(阅读页 `id=1.1`
```bash
cd 一场soul的创业实验-永平
python3 scripts/wechat_generate_share_link.py --mode urllink --path pages/read/read --query 'id=1.1' --env-version release --expire-days 30
```
输出一行 `https://wxaurl.cn/...` 即可复制到微信。
文档:<https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/qrcode-link/url-link/qrcode-link/url-link/api_generateurllink.html>
---
## 四、方案 CH5 `/read/:id`(要 OG 卡片 + 页内拉起小程序)
- **URL**`https://soulapi.quwanzhi.com/read/{章节业务 id}`(或由 `H5_READ_PUBLIC_BASE` 指定已反代到 soul-api 的域名)
- **示例(第 1.1 节《荷包》等)****`https://soulapi.quwanzhi.com/read/1.1`**(页底固定区含 **「复制本页链接」**,便于发朋友圈;好友打开后点 **整块小程序卡片** 进包并自动走支付。)
- **想「朋友圈能付款」**:请发 **本 H5 的 https 链接**,不要只依赖「小程序分享到朋友圈的单页」——单页里微信 **限制直接调起支付**,须用户点 **前往小程序** 再付H5 链路是 **链接 → 网页 → 点开放标签 → 全屏小程序 → `openPay` 调支付**
- 分销:`?ref=邀请码` → 例:`https://soulapi.quwanzhi.com/read/1.1?ref=你的邀请码`
- 与 read 页一致的可选参数:`mid``gift``requestSn`H5 会拼进打开小程序的 `path` 的 query
- **服务端**`soul-api` 路由 **`GET /read/:id`**`router.go` 注册)。
- **能力**:试读正文 + **`wx-open-launch-weapp`**;页面视觉与小程序 `read` 付费墙对齐(黑底、`#00CED1` 角标、付费墙卡片、主按钮渐变「购买本章 / ¥价」);**付费章节**打开小程序的 path 会带 **`openPay=1`**,全屏小程序内自动走「购买本章」→调起 **微信支付**(单页预览模式除外)。非微信内展示「复制链接」兜底。
- **环境变量**(可选):
- `H5_READ_PUBLIC_BASE`:朋友圈分享用的落地域名(**默认与 `API_BASE_URL` 一致**,一般为 `https://soulapi.quwanzhi.com`)。若坚持用 `soul.quwanzhi.com`,须在网关把 **`/read/`** 反代到 soul-api。
- `H5_READ_DEFAULT_OG_IMAGE`正文无图时朋友圈卡片兜底图HTTPS
**优点**:朋友圈**链接预览**可控(`og:title` / `og:image` 等)。
**局限**:用户会先打开 **H5 页**,再点按钮进小程序(这是设计如此,不是 URL Link
---
## 五、参数与小程序 read 页对齐
`pages/read/read``onLoad` 支持(与 H5 透传一致):`id``ref``mid``gift``requestSn``scene`(扫码场景另说)。
H5 已按 `url.Values` 拼接;**付费章节**在拉起 path 上追加 **`openPay=1`**(与 `read.js``pendingAutoSectionPay` 联动),用于 H5 → 小程序后尽快调起微信支付;**免费章节**不带 `openPay`
---
## 六、运维检查清单(上线前)
- [ ] 正式版已发布含 `pages/read/read` 的代码包(含 **`openPay` 自动拉起支付** 与 H5 对齐样式)。
- [ ] `soul-api` 已部署且 **`GET /read/:id`** 在公网可访问(若用方案 C
- [ ] **小程序管理后台 → 开发管理 → 开发设置 → 业务域名**:将 H5 落地域名(如 `soulapi.quwanzhi.com`)加入 **request 合法域名 / 业务域名**`wx-open-launch-weapp` + `jweixin` 在微信内依赖该配置)。
- [ ] H5 页已注入 **`wx.config` + `openTagList: ['wx-open-launch-weapp']`**(由 soul-api 用小程序 `AppID` + `Secret``jsapi_ticket` 对**当前页完整 URL**签名);若仍无法点标签,请核对 **签名 URL 与微信地址栏是否完全一致**(含 `https`、path、query`#`)。
- [ ] 业务域名、**JS 安全域名**、**downloadFile 合法域名** 等已在公众平台配置(涉及 H5 拉微信 JS、图片域等时按微信报错逐项补
- [ ] URL Link 过期前重新生成(或缩短 `--expire-days` 并定期轮换)。
---
## 七、相关代码索引
| 模块 | 路径 |
|------|------|
| H5 阅读落地 + OG + 拉起小程序 | `soul-api/internal/handler/h5_read.go` |
| H5 公网域名、默认分享图 | `soul-api/internal/config/config.go``H5_READ_*` |
| 路由 `/read/:id` | `soul-api/internal/router/router.go` |
| 朋友圈单页分享 | `miniprogram/pages/read/read.js``onShareTimeline` |
| URL Link 脚本 | `scripts/wechat_generate_share_link.py` |
| H5 对齐样式 + `openPay` 落地支付 | `soul-api/internal/handler/h5_read.go` + `miniprogram/pages/read/read.js``_tryAutoPayAfterH5Launch` |
---
## 八、截图对照验收H5 ↔ 小程序 read 页)
将截图放入本目录 **`images/`** 后,下列引用即可在 Markdown 中预览(文件名可自定,与下表一致最省事)。
| 序号 | 验收点 | 建议文件名 | 说明 |
|------|--------|------------|------|
| 1 | 小程序付费墙主按钮 | `images/对照01-小程序-付费墙购买本章.png` | `pages/read/read` 未解锁态:渐变主按钮「购买本章」+ 价格 |
| 2 | H5 底部付费墙 + 主按钮 | `images/对照02-H5-付费墙与开放标签按钮.png` | 微信内打开 `https://soulapi.quwanzhi.com/read/1.1` 底部卡片与主按钮视觉对齐 |
| 3 | 点击后进小程序 | `images/对照03-小程序-自动拉起支付或登录.png` | 付费章:全屏小程序内约 0.4s 后出现支付/或先登录弹窗 |
| 4 | 分润文案 | `images/对照04-分润百分比一致.png` | H5 与小程序展示同一 `shareRate`(来自 `referral_config` |
**直链(第 1.1 节)**
- H5方案 C`https://soulapi.quwanzhi.com/read/1.1`
- 小程序路径(方案 A/B 的 path/query`pages/read/read?id=1.1`URL Link 用脚本生成,无固定永久 HTTPS
---
**结论**:要**少跳转、无自建业务中间页** → 用 **方案 BURL Link**;要**朋友圈卡片大图可控** → 用 **方案 CH5**;用户在小程序里发圈 → **方案 A**。三者可并存,按场景选用即可。