From d44a02b818bc0a915bc9a5255e05a5f76eba975e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=A1=E8=8B=A5?= Date: Wed, 15 Apr 2026 19:07:02 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=90=8C=E6=AD=A5=E9=98=85=E8=AF=BB?= =?UTF-8?q?=E9=A1=B5=E3=80=81=E8=84=9A=E6=9C=AC=E3=80=81API=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8E=E5=B0=8F=E7=A8=8B=E5=BA=8F=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- miniprogram/pages/read/read.js | 56 +- .../wechat-miniprogram-urllink-tool/README.md | 78 +++ .../generate_share_link.py | 236 ++++++++ .../run_mac_linux.sh | 4 + .../run_windows.cmd | 4 + scripts/deploy_kr_btapi_verify.py | 9 +- scripts/post_to_feishu.py | 2 +- scripts/send_chapter_poster_to_feishu.py | 2 +- scripts/wechat_generate_share_link.py | 265 +++++++++ soul-api/internal/config/config.go | 18 + soul-api/internal/handler/h5_read.go | 538 +++++++++++++----- soul-api/internal/wechat/jssdk_open_launch.go | 182 ++++++ soul-api/master.py | 377 +++++++++++- 开发文档/小程序管理/images/README.md | 3 + 开发文档/小程序管理/朋友圈与外链完整方案.md | 119 ++++ 15 files changed, 1733 insertions(+), 160 deletions(-) create mode 100644 packs/wechat-miniprogram-urllink-tool/README.md create mode 100644 packs/wechat-miniprogram-urllink-tool/generate_share_link.py create mode 100755 packs/wechat-miniprogram-urllink-tool/run_mac_linux.sh create mode 100644 packs/wechat-miniprogram-urllink-tool/run_windows.cmd create mode 100644 scripts/wechat_generate_share_link.py create mode 100644 soul-api/internal/wechat/jssdk_open_launch.go create mode 100644 开发文档/小程序管理/images/README.md create mode 100644 开发文档/小程序管理/朋友圈与外链完整方案.md 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(` 提示 @@ -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(`

%s

`, strings.Join(parts, " · ")) } + // 与 read.wxml 付费墙同一套营销句:解锁完整内容,分享得到 {rate}% 收益 + marketingHTML := fmt.Sprintf( + `
解锁完整内容,分享得到%d%% 收益
`, + d.ShareRate, + ) + // 付费:与小程序 read 单页模式一致(readUi.singlePagePayButtonText + purchase-section 灰底药丸) + var primaryHTML string + if d.IsFree { + primaryHTML = `
进入小程序阅读全文
` + } else { + btnText := html.EscapeString(fmt.Sprintf("支付 ¥%s 解锁全文", strings.TrimSpace(d.PriceYuan))) + primaryHTML = fmt.Sprintf( + `
%s
`, + btnText, + ) + } + footerTipEsc := html.EscapeString("转发给需要的人,一起学习还能赚佣金") + + lockSVG := `` + mpBtn := fmt.Sprintf( ` - - `, - html.EscapeString(d.AppID), html.EscapeString(d.MpPath)) - - return fmt.Sprintf(` - - - - -%s - 一场Soul的创业实验 - + +`, + escapedAppID, escapedMpPath, lockSVG, marketingHTML, primaryHTML, footerTipEsc) + + ctaHint := "" + if d.IsFree { + ctaHint = "微信内点下方整块区域进入小程序阅读全文" + } else { + ctaHint = fmt.Sprintf("已试读 %d%% · 点下方「支付解锁」区域进小程序,将自动调起微信支付", d.Percent) + } + + var b strings.Builder + b.WriteString("\n\n\n\n") + b.WriteString(`` + "\n") + b.WriteString(d.OgTagsHTML) + b.WriteString("\n") + b.WriteString(escapedTitle) + b.WriteString(" - 一场Soul的创业实验\n\n - - -
-
-

%s

- %s -
-
- %s -
-
- -
-
-

已预览 %d%% · 打开小程序购买并阅读全文

- -
%s
- -
-
- -
- -

在小程序内完成购买,点击下方前往

-
- 点击右下角前往小程序 - - - -
-
- -
已复制,请打开微信
- - -` + "\n") + b.WriteString(`
已复制,请打开微信
` + "\n") + b.WriteString(`` + "\n - -`, - 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("\n\n") + return b.String() } func h5ContentToHTML(content string) string { diff --git a/soul-api/internal/wechat/jssdk_open_launch.go b/soul-api/internal/wechat/jssdk_open_launch.go new file mode 100644 index 00000000..99c20161 --- /dev/null +++ b/soul-api/internal/wechat/jssdk_open_launch.go @@ -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 +} diff --git a/soul-api/master.py b/soul-api/master.py index 850678af..bc6309ca 100644 --- a/soul-api/master.py +++ b/soul-api/master.py @@ -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 diff --git a/开发文档/小程序管理/images/README.md b/开发文档/小程序管理/images/README.md new file mode 100644 index 00000000..e90e2427 --- /dev/null +++ b/开发文档/小程序管理/images/README.md @@ -0,0 +1,3 @@ +# 朋友圈 / H5 对照截图 + +将验收截图放在本目录,文件名与上级文档《朋友圈与外链完整方案.md》**第八节**表格一致即可在文档中预览。 diff --git a/开发文档/小程序管理/朋友圈与外链完整方案.md b/开发文档/小程序管理/朋友圈与外链完整方案.md new file mode 100644 index 00000000..fb807b1a --- /dev/null +++ b/开发文档/小程序管理/朋友圈与外链完整方案.md @@ -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/...` 即可复制到微信。 +文档: + +--- + +## 四、方案 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**。三者可并存,按场景选用即可。