diff --git a/miniprogram/pages/read/read.js b/miniprogram/pages/read/read.js
index 8f9b84c9..a6bc0e42 100644
--- a/miniprogram/pages/read/read.js
+++ b/miniprogram/pages/read/read.js
@@ -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()
diff --git a/packs/wechat-miniprogram-urllink-tool/README.md b/packs/wechat-miniprogram-urllink-tool/README.md
new file mode 100644
index 00000000..4e9c3444
--- /dev/null
+++ b/packs/wechat-miniprogram-urllink-tool/README.md
@@ -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 Link(generate_urllink)](https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/qrcode-link/url-link/qrcode-link/url-link/api_generateurllink.html)
+- [获取 Short Link(genwxashortlink)](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`,便于拷贝到其它电脑使用。
diff --git a/packs/wechat-miniprogram-urllink-tool/generate_share_link.py b/packs/wechat-miniprogram-urllink-tool/generate_share_link.py
new file mode 100644
index 00000000..9dc901fb
--- /dev/null
+++ b/packs/wechat-miniprogram-urllink-tool/generate_share_link.py
@@ -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="urllink:query,无则传空字符串")
+ ap.add_argument("--page-url", default="", help="shortlink:page?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()
diff --git a/packs/wechat-miniprogram-urllink-tool/run_mac_linux.sh b/packs/wechat-miniprogram-urllink-tool/run_mac_linux.sh
new file mode 100755
index 00000000..7a2388d5
--- /dev/null
+++ b/packs/wechat-miniprogram-urllink-tool/run_mac_linux.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+# 在解压目录执行:先配置 .env 或 export WECHAT_APPID / WECHAT_APPSECRET
+cd "$(dirname "$0")" || exit 1
+exec python3 generate_share_link.py "$@"
diff --git a/packs/wechat-miniprogram-urllink-tool/run_windows.cmd b/packs/wechat-miniprogram-urllink-tool/run_windows.cmd
new file mode 100644
index 00000000..93a9abf3
--- /dev/null
+++ b/packs/wechat-miniprogram-urllink-tool/run_windows.cmd
@@ -0,0 +1,4 @@
+@echo off
+REM 使用前先 set WECHAT_APPID / WECHAT_APPSECRET 或在本目录放置 .env
+cd /d "%~dp0"
+python generate_share_link.py %*
diff --git a/scripts/deploy_kr_btapi_verify.py b/scripts/deploy_kr_btapi_verify.py
index 01188d87..3812397d 100644
--- a/scripts/deploy_kr_btapi_verify.py
+++ b/scripts/deploy_kr_btapi_verify.py
@@ -4,7 +4,8 @@
kr 宝塔:正式环境发布 + 宝塔面板 API 重启 Go 项目 + 线上冒烟验证。
说明(与现状一致):
-- soul-api 的**文件上传与解压**仍通过 SSH/SFTP(稳定);**进程重启**使用宝塔「Go 项目」插件 API(与 master.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.py(SSH),宝塔无统一「站点文件 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
diff --git a/scripts/post_to_feishu.py b/scripts/post_to_feishu.py
index af671d72..f3bc5ad8 100644
--- a/scripts/post_to_feishu.py
+++ b/scripts/post_to_feishu.py
@@ -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拿"
diff --git a/scripts/send_chapter_poster_to_feishu.py b/scripts/send_chapter_poster_to_feishu.py
index 8d551a17..e6a45f04 100644
--- a/scripts/send_chapter_poster_to_feishu.py
+++ b/scripts/send_chapter_poster_to_feishu.py
@@ -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
diff --git a/scripts/wechat_generate_share_link.py b/scripts/wechat_generate_share_link.py
new file mode 100644
index 00000000..e151806d
--- /dev/null
+++ b/scripts/wechat_generate_share_link.py
@@ -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="urllink:query 字符串,不含问号")
+ 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()
diff --git a/soul-api/internal/config/config.go b/soul-api/internal/config/config.go
index 04c4abdc..894359f4 100644
--- a/soul-api/internal/config/config.go
+++ b/soul-api/internal/config/config.go
@@ -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) {
// Redis:REDIS_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,
diff --git a/soul-api/internal/handler/h5_read.go b/soul-api/internal/handler/h5_read.go
index d54fdbe4..e3bbc020 100644
--- a/soul-api/internal/handler/h5_read.go
+++ b/soul-api/internal/handler/h5_read.go
@@ -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)]+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(``, et(canonical)))
+ b.WriteString(``)
+ b.WriteString(fmt.Sprintf(``, et(title)))
+ b.WriteString(fmt.Sprintf(``, et(desc)))
+ b.WriteString(fmt.Sprintf(``, et(canonical)))
+ if img := strings.TrimSpace(ogImage); img != "" {
+ e := et(img)
+ b.WriteString(fmt.Sprintf(``, e))
+ b.WriteString(fmt.Sprintf(``, e))
+ }
+ b.WriteString(``)
+ b.WriteString(fmt.Sprintf(``, et(title)))
+ b.WriteString(fmt.Sprintf(``, et(desc)))
+ return b.String()
+}
+
func h5Error(msg string) string {
return fmt.Sprintf(`
%s
`, strings.Join(parts, " · ")) } + // 与 read.wxml 付费墙同一套营销句:解锁完整内容,分享得到 {rate}% 收益 + marketingHTML := fmt.Sprintf( + `已预览 %d%% · 打开小程序购买并阅读全文
- -