chore: 同步阅读页、脚本、API 配置与小程序管理文档
Made-with: Cursor
This commit is contained in:
@@ -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()
|
||||
|
||||
78
packs/wechat-miniprogram-urllink-tool/README.md
Normal file
78
packs/wechat-miniprogram-urllink-tool/README.md
Normal 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 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`,便于拷贝到其它电脑使用。
|
||||
236
packs/wechat-miniprogram-urllink-tool/generate_share_link.py
Normal file
236
packs/wechat-miniprogram-urllink-tool/generate_share_link.py
Normal 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="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()
|
||||
4
packs/wechat-miniprogram-urllink-tool/run_mac_linux.sh
Executable file
4
packs/wechat-miniprogram-urllink-tool/run_mac_linux.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
# 在解压目录执行:先配置 .env 或 export WECHAT_APPID / WECHAT_APPSECRET
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
exec python3 generate_share_link.py "$@"
|
||||
4
packs/wechat-miniprogram-urllink-tool/run_windows.cmd
Normal file
4
packs/wechat-miniprogram-urllink-tool/run_windows.cmd
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
REM 使用前先 set WECHAT_APPID / WECHAT_APPSECRET 或在本目录放置 .env
|
||||
cd /d "%~dp0"
|
||||
python generate_share_link.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
|
||||
|
||||
|
||||
@@ -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拿"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
265
scripts/wechat_generate_share_link.py
Normal file
265
scripts/wechat_generate_share_link.py
Normal 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="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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
182
soul-api/internal/wechat/jssdk_open_launch.go
Normal file
182
soul-api/internal/wechat/jssdk_open_launch.go
Normal 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×tamp=%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
|
||||
}
|
||||
@@ -2,17 +2,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
soulApi(soul-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=upload(multipart:blob + f_path/f_name/f_size/f_start + 签名)。
|
||||
大文件按块续传(与面板逻辑一致:f_start 须等于服务器上 .upload.tmp 当前大小)。
|
||||
"""
|
||||
if not requests:
|
||||
print(" [失败] 需要 requests:pip install requests")
|
||||
return False
|
||||
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||
key = cfg.get("bt_api_key") or ""
|
||||
if not url or not key:
|
||||
print(" [失败] 缺少 BT_PANEL_URL 或 BT_API_KEY")
|
||||
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=UnZip(sfile=压缩包绝对路径,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(" [失败] 需要 requests:pip 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(" soulApi(soul-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
|
||||
|
||||
|
||||
|
||||
3
开发文档/小程序管理/images/README.md
Normal file
3
开发文档/小程序管理/images/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 朋友圈 / H5 对照截图
|
||||
|
||||
将验收截图放在本目录,文件名与上级文档《朋友圈与外链完整方案.md》**第八节**表格一致即可在文档中预览。
|
||||
119
开发文档/小程序管理/朋友圈与外链完整方案.md
Normal file
119
开发文档/小程序管理/朋友圈与外链完整方案.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 朋友圈与外链:完整方案(Soul / 卡若创业派对小程序)
|
||||
|
||||
> 目标:在**微信内**把用户带到 `pages/read/read`(或其它业务页),并兼顾「站外复制链接」「朋友圈卡片预览」「少跳转」。
|
||||
|
||||
---
|
||||
|
||||
## 一、三种方式怎么选(决策)
|
||||
|
||||
| 场景 | 推荐方案 | 说明 |
|
||||
|------|-----------|------|
|
||||
| 用户**已在小程序里**看文章,要发朋友圈 | **方案 A:原生「分享到朋友圈」** | 由 `onShareTimeline` 提供标题、query、可选 `imageUrl`,单页模式由微信渲染,**体验最好**。 |
|
||||
| 从**聊天/短信/其它 App** 贴一条 **HTTPS 链接**,点进小程序 | **方案 B:URL Link(`https://wxaurl.cn/...`)** | 调微信官方 `generate_urllink`,**无自建 H5**;是否还有极短系统过渡由**微信客户端**决定。 |
|
||||
| 需要 **朋友圈链接卡片** 上出现**自定义标题 + 大图**,且可接受自建一页 | **方案 C:H5 落地页 `/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」。
|
||||
|
||||
---
|
||||
|
||||
## 三、方案 B:URL 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>
|
||||
|
||||
---
|
||||
|
||||
## 四、方案 C:H5 `/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)
|
||||
|
||||
---
|
||||
|
||||
**结论**:要**少跳转、无自建业务中间页** → 用 **方案 B(URL Link)**;要**朋友圈卡片大图可控** → 用 **方案 C(H5)**;用户在小程序里发圈 → **方案 A**。三者可并存,按场景选用即可。
|
||||
Reference in New Issue
Block a user