同步数据

This commit is contained in:
乘风
2026-04-02 19:20:19 +08:00
parent 76965adb23
commit 9ec4e0f9bd
903 changed files with 130902 additions and 15896 deletions

View File

@@ -0,0 +1,47 @@
# Soul 运营全链路技能包(精简打包)
## 一键打包(推荐:体积小、可重装)
**本机终端** 执行:
```bash
python3 "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/scripts/pack_soul_operation_skills.py"
```
- 输出:**`~/Downloads/Soul运营全链路技能包_精简_YYYYMMDD.zip`**(日期为**打包当天**)。
- 临时目录:`一场soul的创业实验-永平/.tmp_skill_bundle/`,打完可删。
卡若AI不在默认路径时编辑脚本内
```python
KARUO_AI = Path("/Users/karuo/Documents/个人/卡若AI")
```
## 精简包策略(大文件不进包)
| 类型 | 处理 |
|:---|:---|
| 单文件 **> 512KB** | 跳过 |
| 视频/音频/压缩包/模型权重等扩展名 | 跳过 |
| `cookies/``node_modules``.browser_state``venv` 等 | 整目录跳过 |
| `publish_log.json``.feishu_tokens.json` | 跳过(到新机按脚本重新授权;若要迁移凭证请**单独**安全拷贝) |
包内另有 **`重装依赖说明.md`**、**`_pack_stats.json`**(本次打入/跳过统计)。
## 压缩包里有什么(链路齐全 = SKILL + 脚本 + 小配置)
- `.cursor/skills/``soul-operation-report``soul-party-project`
- `卡若AI/02_卡人/水岸_项目管理/`
- `卡若AI/.../水桥_平台对接/飞书管理/``智能纪要/``Soul创业实验/`
- `卡若AI/03_卡木/木叶_视频内容/``视频切片``多平台分发`、各平台发布(仅小文件)
- `卡若AI/运营中枢/工作台/00_账号与API索引.md`(若存在且小于体积限制)
## 另一台电脑
1. 解压 → 合并 `卡若AI/` → 安装 Cursor skills。
2.**`重装依赖说明.md`**`pip`/`conda`/`ffmpeg`/`playwright` 等。
3. 重新配置飞书 Token、妙记 Cookie、各平台 Cookie、永平 `.env`
## 安全
勿将含密钥的压缩包上传公开网盘;用 U 盘或加密渠道传输。

114
scripts/content_download.py Normal file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
从小程序/正式 API 下载单章正文,保存为书稿目录下的 md 文件。
用法:
SOUL_TEST_ENV=soulapi python3 scripts/content_download.py 128
SOUL_TEST_ENV=soulapi python3 scripts/content_download.py --id 10.27
python3 scripts/content_download.py 128 --out-dir /path/to/2026每日派对干货
2026 场次第102场起对应 id 10.01、10.02、…、10.27第128场
"""
import argparse
import os
import sys
from pathlib import Path
# 项目根
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "test"))
try:
from config import API_BASE, ENV_LABEL
except Exception:
API_BASE = os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com").rstrip("/")
ENV_LABEL = "env"
# 书稿 2026 目录默认路径(与上传 README 一致)
DEFAULT_BOOK_2026 = Path(
os.environ.get(
"SOUL_BOOK_2026",
"/Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/2026每日派对干货",
)
)
def field_to_id(field: int) -> str:
"""第 N 场≥102→ 10.xx第101场及以前在第九章为 9.xx。API 上第128场可能为 9.28。"""
if field >= 102:
n = field - 102 + 1
return f"10.{n:02d}"
if field >= 1:
return f"9.{field:02d}" # 第9章 9.019.99,按场次
raise ValueError("场次请用 1999")
def main():
parser = argparse.ArgumentParser(description="从小程序 API 下载单章为 md")
parser.add_argument("field", nargs="?", type=int, help="场次号,如 128 表示第128场")
parser.add_argument("--id", type=str, help="章节 id如 10.27(与 field 二选一)")
parser.add_argument("--out-dir", type=Path, default=DEFAULT_BOOK_2026, help="输出目录,默认 2026每日派对干货")
parser.add_argument("--base", type=str, default=API_BASE, help="API 根地址")
args = parser.parse_args()
if args.id:
chapter_id = args.id
elif args.field is not None:
chapter_id = field_to_id(args.field)
else:
parser.error("请指定 field如 128或 --id如 10.27")
base = args.base.rstrip("/")
try:
import requests
except ImportError:
print("请安装: pip install requests", file=sys.stderr)
sys.exit(1)
url = f"{base}/api/miniprogram/book/chapter/by-id/{chapter_id}"
print(f"环境: {ENV_LABEL} | GET {url}")
r = requests.get(url, timeout=30)
# 2026 场次可能仍挂在第9章9.28=第128场404 时用 9.xx 再试
if r.status_code == 404 and args.field is not None and not args.id and args.field >= 1 and args.field <= 101:
fallback_id = f"9.{args.field:02d}"
url = f"{base}/api/miniprogram/book/chapter/by-id/{fallback_id}"
print(f"404尝试第9章 id: {fallback_id} | GET {url}")
r = requests.get(url, timeout=30)
if r.status_code == 200:
chapter_id = fallback_id
if r.status_code == 404 and args.field is not None and not args.id and args.field >= 102:
fallback_id = f"9.{(args.field - 100):02d}" # 128 → 9.28
url = f"{base}/api/miniprogram/book/chapter/by-id/{fallback_id}"
print(f"404尝试第9章 id: {fallback_id} | GET {url}")
r = requests.get(url, timeout=30)
if r.status_code == 200:
chapter_id = fallback_id
r.raise_for_status()
data = r.json()
if not data.get("success"):
print("API 返回失败:", data.get("error", data), file=sys.stderr)
sys.exit(2)
content = data.get("content") or (data.get("data") or {}).get("content") or ""
section_title = data.get("sectionTitle") or (data.get("data") or {}).get("sectionTitle") or f"{args.field or '?'}"
if not content:
print("未获取到正文 content", file=sys.stderr)
sys.exit(3)
# 若标题里没有「第X场」用场次补上sectionTitle 可能是「赚最多那个月…」)
if args.field and "" not in section_title and "" not in section_title:
display_title = f"{args.field}场|{section_title}"
else:
display_title = section_title
out_dir = args.out_dir
out_dir.mkdir(parents=True, exist_ok=True)
out_file = out_dir / f"{display_title}.md"
body = f"# {display_title}\n\n{content.strip()}\n"
out_file.write_text(body, encoding="utf-8")
print(f"已写入: {out_file}")
print(f"字数: {len(content)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,13 @@
{
"item_list": [
{
"address": "pages/index/index",
"tag": "阅读 创业",
"first_class": "一级类目名称",
"second_class": "二级类目名称",
"first_id": 0,
"second_id": 0,
"title": "首页"
}
]
}

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Soul 小程序:通过微信开发者工具 CLI 上传代码包(需本机已登录开发者工具)。
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MINIPROGRAM_DIR="${MINIPROGRAM_DIR:-$ROOT/miniprogram}"
CLI="${WECHAT_DEVTOOLS_CLI:-/Applications/wechatwebdevtools.app/Contents/MacOS/cli}"
LANG_OPT="${WECHAT_CLI_LANG:-zh}"
if [[ ! -x "$CLI" ]]; then
echo "未找到微信开发者工具 CLI: $CLI" >&2
echo "可设置 WECHAT_DEVTOOLS_CLI 指向 cli 可执行文件。" >&2
exit 1
fi
# 未传参时默认 1.7.2(避免手滑打成 1.17 等与展示不一致)
DEFAULT_VER="${MINIPROGRAM_DEFAULT_VERSION:-1.7.2}"
VERSION="${1:-$DEFAULT_VER}"
DESC="${2:-版本 v$VERSION}"
CLI_EXTRA=()
if [[ -n "${WECHAT_CLI_PORT:-}" ]]; then
CLI_EXTRA+=(--port "$WECHAT_CLI_PORT")
fi
if ((${#CLI_EXTRA[@]} > 0)); then
exec "$CLI" upload \
--project "$MINIPROGRAM_DIR" \
--version "$VERSION" \
--desc "$DESC" \
--lang "$LANG_OPT" \
"${CLI_EXTRA[@]}"
else
exec "$CLI" upload \
--project "$MINIPROGRAM_DIR" \
--version "$VERSION" \
--desc "$DESC" \
--lang "$LANG_OPT"
fi

View File

@@ -0,0 +1,345 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Soul 运营全链路技能包(精简版):只打包 SKILL / 脚本 / 小配置,便于另一台机 pip/conda 重装。
大文件、媒体、Cookie、日志等一律不入包。
用法:
python3 scripts/pack_soul_operation_skills.py
输出:
~/Downloads/Soul运营全链路技能包_精简_YYYYMMDD.zip
"""
from __future__ import annotations
import datetime as _dt
import json
import os
import shutil
import sys
import zipfile
from pathlib import Path
# 单文件超过此大小则跳过(字节)——非「代码/文档类」扩展名
MAX_FILE_BYTES = 512 * 1024 # 512KB
# 脚本与文档类可放宽(避免误跳过大 .py/.md仍远小于整包 200MB+
CODE_DOC_EXT = frozenset(
{
".py",
".md",
".mdc",
".sh",
".bash",
".zsh",
".txt",
".json",
".yaml",
".yml",
".toml",
".cfg",
".ini",
".sql",
".html",
".css",
".js",
".ts",
".tsx",
".jsx",
".svg",
".xml",
}
)
MAX_CODE_DOC_BYTES = 8 * 1024 * 1024 # 8MB
# 整段目录名匹配则不进包walk 时不进入)
SKIP_DIR_NAMES = frozenset(
{
"__pycache__",
".git",
".svn",
".browser_state",
"chromium_data",
"node_modules",
"venv",
".venv",
".mypy_cache",
".pytest_cache",
".tox",
"dist",
"build",
"eggs",
".eggs",
"htmlcov",
".ruff_cache",
# Cookie 到新机需重新登录导出,不入包
"cookies",
}
)
# 扩展名一律跳过(媒体/模型/压缩包等)
SKIP_EXTENSIONS = frozenset(
{
".mp4",
".mov",
".mkv",
".avi",
".webm",
".m4v",
".flv",
".wmv",
".zip",
".tar",
".gz",
".tgz",
".bz2",
".xz",
".rar",
".7z",
".dmg",
".iso",
".img",
".pt",
".pth",
".onnx",
".ckpt",
".safetensors",
".bin",
".exe",
".dll",
".so",
".dylib",
".wav",
".mp3",
".flac",
".aac",
".m4a",
".npz",
".npy",
".pkl",
".pickle",
".whl",
".parquet",
".arrow",
}
)
# 文件名(不含路径)强制跳过
SKIP_FILE_NAMES = frozenset(
{
".DS_Store",
"Thumbs.db",
"publish_log.json", # 分发日志可能巨大
".feishu_tokens.json", # 凭证,到新机用脚本重新获取更安全;若需带走可自行拷贝
}
)
# 卡若AI 根目录(按你本机实际修改)
KARUO_AI = Path("/Users/karuo/Documents/个人/卡若AI")
CURSOR_SKILLS = Path.home() / ".cursor" / "skills"
DOWNLOADS = Path.home() / "Downloads"
REPO_ROOT = Path(__file__).resolve().parents[1]
STAMP = _dt.date.today().strftime("%Y%m%d")
BUNDLE_TOP = f"Soul运营全链路技能包_精简_{STAMP}"
STAGING_PARENT = REPO_ROOT / ".tmp_skill_bundle"
STAGING = STAGING_PARENT / BUNDLE_TOP
# 统计
_stats: dict[str, int] = {"files": 0, "skipped_size": 0, "skipped_ext": 0, "skipped_dir": 0, "skipped_name": 0}
def should_skip_file(path: Path) -> tuple[bool, str]:
name = path.name
if name in SKIP_FILE_NAMES:
return True, "name"
ext = path.suffix.lower()
if ext in SKIP_EXTENSIONS:
return True, "ext"
try:
sz = path.stat().st_size
except OSError:
return True, "stat"
limit = MAX_CODE_DOC_BYTES if ext in CODE_DOC_EXT else MAX_FILE_BYTES
if sz > limit:
return True, "size"
return False, ""
def copy_tree_selective(src: Path, dst_root: Path, rel_base: Path) -> None:
"""将 src 下文件复制到 dst_root / rel_base遵守跳过规则。"""
if not src.is_dir():
print(f"SKIP 非目录: {src}", file=sys.stderr)
return
for root, dirnames, filenames in os_walk_topdown(src):
root_path = Path(root)
# 过滤要进入的子目录
for d in list(dirnames):
if d in SKIP_DIR_NAMES:
dirnames.remove(d)
_stats["skipped_dir"] += 1
rel = root_path.relative_to(src)
for fname in filenames:
fp = root_path / fname
skip, reason = should_skip_file(fp)
if skip:
if reason == "size":
_stats["skipped_size"] += 1
elif reason == "ext":
_stats["skipped_ext"] += 1
elif reason == "name":
_stats["skipped_name"] += 1
continue
dest_dir = dst_root / rel_base / rel
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / fname
shutil.copy2(fp, dest)
_stats["files"] += 1
def os_walk_topdown(src: Path):
"""与 os.walk 相同,但用 Path。"""
for r, dnames, fnames in os.walk(str(src), topdown=True):
yield Path(r), dnames, fnames
def copy_cursor_skill(name: str) -> None:
src = CURSOR_SKILLS / name
if not src.is_dir():
print(f"SKIP 无 Cursor skill: {src}", file=sys.stderr)
return
copy_tree_selective(src, STAGING, Path(".cursor") / "skills" / name)
def main() -> int:
if not KARUO_AI.is_dir():
print(f"ERROR: 未找到卡若AI目录: {KARUO_AI}", file=sys.stderr)
return 1
global _stats
_stats = {k: 0 for k in _stats}
if STAGING.exists():
shutil.rmtree(STAGING)
STAGING.mkdir(parents=True)
# Cursor 入口(通常只有 SKILL.md
for name in ("soul-operation-report", "soul-party-project"):
copy_cursor_skill(name)
kai_rel = Path("卡若AI")
def pack_sub(src_under_karuo: Path, rel_under_kai: Path) -> None:
"""src_under_karuo 为卡若AI下的绝对路径打入包内 卡若AI/rel_under_kai"""
if not src_under_karuo.exists():
print(f"SKIP 不存在: {src_under_karuo}", file=sys.stderr)
return
copy_tree_selective(src_under_karuo, STAGING, kai_rel / rel_under_kai)
pack_sub(
KARUO_AI / "02_卡人" / "水岸_项目管理",
Path("02_卡人") / "水岸_项目管理",
)
bridge = KARUO_AI / "02_卡人" / "水桥_平台对接"
for sub in ("飞书管理", "智能纪要", "Soul创业实验"):
pack_sub(bridge / sub, Path("02_卡人") / "水桥_平台对接" / sub)
wood = KARUO_AI / "03_卡木" / "木叶_视频内容"
for sub in (
"视频切片",
"多平台分发",
"抖音发布",
"B站发布",
"视频号发布",
"小红书发布",
"快手发布",
):
pack_sub(wood / sub, Path("03_卡木") / "木叶_视频内容" / sub)
idx = KARUO_AI / "运营中枢" / "工作台" / "00_账号与API索引.md"
if idx.is_file():
skip, _ = should_skip_file(idx)
if not skip:
dest = STAGING / kai_rel / "运营中枢" / "工作台" / idx.name
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(idx, dest)
_stats["files"] += 1
# 写入 requirements 汇总(若各目录有 requirements.txt只列路径提示不合并
req_hint = STAGING / "重装依赖说明.md"
req_hint.write_text(
f"""# 重装依赖说明(精简包)
本包**不含**大文件与本地状态,到新电脑请:
1. **Python**:建议 3.10+;进入各含 `requirements.txt` 的脚本目录执行 `pip install -r requirements.txt`(以各 SKILL 为准)。
2. **系统**`ffmpeg`、`ffprobe`(视频切片);视频转录见 SKILL 中的 **conda mlx-whisper** 环境说明。
3. **Playwright**(若飞书脚本需要):`playwright install` 并按脚本说明登录;**`.browser_state` 未打包**。
4. **多平台分发**:包内**不含 `cookies/` 目录**,需在新机各平台重新登录导出 Cookie见多平台分发 SKILL
5. **飞书 Token**:精简包默认**不含** `.feishu_tokens.json`,请在新机用脚本流程重新授权;若你刻意要迁移凭证请单独拷贝(注意安全)。
---
打包策略摘要(自动生成):
- 代码/文档类(`.py`、`.md` 等)单文件大于 **{MAX_CODE_DOC_BYTES // (1024 * 1024)} MB** 跳过;其它类型大于 **{MAX_FILE_BYTES // 1024} KB** 跳过
- 跳过扩展名:媒体、压缩包、模型权重等
- 跳过目录:`cookies`、`node_modules`、`.browser_state`、`venv` 等
打包日期:**{STAMP}**
""",
encoding="utf-8",
)
readme = STAGING / "解压后必读.md"
readme.write_text(
f"""# Soul 运营全链路技能包(精简版)
## 本包特点
- **体积小**:不含视频/大日志/模型/Cookie 目录等;到新机器按 `重装依赖说明.md` **重装环境与凭证**。
- **日期**{STAMP}
## 包含
- `.cursor/skills/``soul-operation-report`、`soul-party-project`
- `卡若AI/` 下水岸、飞书管理、智能纪要、Soul创业实验、视频切片、多平台分发与各平台发布目录中的 **SKILL、脚本、小配置**(受大小与类型过滤)
## 合并步骤
1. 解压后把 `卡若AI/` **合并**进你的卡若AI根目录先备份
2. 将 `.cursor/skills/` 下两个文件夹复制到 `~/.cursor/skills/`。
3. 阅读 **`重装依赖说明.md`**,安装 Python 依赖、FFmpeg、conda 环境等。
4. 配置飞书、妙记、各平台 Cookie、永平 `.env`(见各 SKILL 与 `Soul创业实验/上传/环境与TOKEN配置.md`)。
**安全**:勿将含密钥的压缩包上传公开网盘。
""",
encoding="utf-8",
)
# 打包统计写入 JSON便于核对
(STAGING / "_pack_stats.json").write_text(
json.dumps({**_stats, "max_file_bytes": MAX_FILE_BYTES, "stamp": STAMP}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
DOWNLOADS.mkdir(parents=True, exist_ok=True)
zip_path = DOWNLOADS / f"{BUNDLE_TOP}.zip"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for f in STAGING.rglob("*"):
if f.is_file():
arcname = Path(BUNDLE_TOP) / f.relative_to(STAGING)
zf.write(f, arcname.as_posix())
mb = zip_path.stat().st_size / (1024 * 1024)
print(f"完成: {zip_path}")
print(f"大小: {mb:.2f} MB | 打入文件数: {_stats['files']}")
print(
f"跳过: 超体积 {_stats['skipped_size']} | 扩展名 {_stats['skipped_ext']} | 文件名 {_stats['skipped_name']} | 目录 {_stats['skipped_dir']}"
)
print(f"临时目录(可删): {STAGING}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

153
scripts/pull_from_baota.py Normal file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
从宝塔正式机拉取线上运行目录到本地镜像(与 soul-api/master.py、soul-admin/master.py 同源 SSH 配置)。
说明:
- 服务器上一般是「二进制 + .env + 日志」与「静态 dist」不包含完整 Go/React 源码。
- 默认解压到仓库根目录 _server_live/soul-api、_server_live/soul-admin不覆盖本地工程源码。
环境变量与 master.py 一致DEPLOY_HOST、DEPLOY_USER、DEPLOY_PASSWORD、DEPLOY_SSH_KEY、
DEPLOY_PROJECT_PATH、DEPLOY_BASE_PATH。
"""
from __future__ import print_function
import argparse
import importlib.util
import os
import shutil
import sys
import tarfile
import tempfile
import threading
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
SOUL_API_DIR = os.path.join(ROOT, "soul-api")
def _load_api_master():
path = os.path.join(SOUL_API_DIR, "master.py")
spec = importlib.util.spec_from_file_location("soul_api_deploy_master", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _pull_dir_tar(client, remote_dir, local_dir, mod, timeout=600):
"""远端 tar czf 流式下载并解压到 local_dir。"""
import shlex
remote_q = shlex.quote(remote_dir)
cmd = "tar czf - -C %s . 2>/dev/null" % remote_q
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
err_holder = []
def _drain():
try:
err_holder.append(stderr.read())
except Exception:
pass
t = threading.Thread(target=_drain)
t.daemon = True
t.start()
fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz")
os.close(fd)
try:
with open(tmp_path, "wb") as out:
while True:
chunk = stdout.read(256 * 1024)
if not chunk:
break
out.write(chunk)
t.join(timeout=5)
exit_status = stdout.channel.recv_exit_status()
if exit_status != 0:
print(" [警告] 远端 tar 退出码: %s" % exit_status)
if os.path.isdir(local_dir):
shutil.rmtree(local_dir)
os.makedirs(local_dir, exist_ok=True)
with tarfile.open(tmp_path, "r:gz") as tf:
tf.extractall(local_dir)
print(" [成功] 已同步到: %s" % local_dir)
return True
finally:
try:
os.remove(tmp_path)
except OSError:
pass
def main():
parser = argparse.ArgumentParser(description="从宝塔拉取 soul-api / soul-admin 线上目录")
parser.add_argument("--api-only", action="store_true", help="仅拉 soul-api")
parser.add_argument("--admin-only", action="store_true", help="仅拉 soul-admin")
args = parser.parse_args()
mod = _load_api_master()
cfg = mod.get_cfg()
if not cfg.get("password") and not cfg.get("ssh_key"):
print("[失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return 1
pull_api = not args.admin_only
pull_admin = not args.api_only
if args.api_only and args.admin_only:
print("[失败] 不能同时指定 --api-only 与 --admin-only")
return 1
client = None
try:
client = mod._connect_ssh(cfg)
live_root = os.path.join(ROOT, "_server_live")
os.makedirs(live_root, exist_ok=True)
print("=" * 60)
print(" 从宝塔拉取线上目录 → %s" % live_root)
print(" 主机: %s@%s:%s" % (cfg["user"], cfg["host"], mod.DEFAULT_SSH_PORT))
print("=" * 60)
if pull_api:
print("[1] soul-api: %s" % cfg["project_path"])
_pull_dir_tar(
client,
cfg["project_path"],
os.path.join(live_root, "soul-api"),
mod,
)
if pull_admin:
admin_base = os.environ.get("DEPLOY_BASE_PATH", "/www/wwwroot/self/soul-admin").rstrip("/")
print("[2] soul-admin: %s" % admin_base)
# 复用同一连接;若仅拉 admin上面未开新连接也行
if not pull_api:
pass
_pull_dir_tar(
client,
admin_base,
os.path.join(live_root, "soul-admin"),
mod,
)
print("")
print(" 完成。镜像根目录: %s" % live_root)
return 0
except Exception as e:
print("[失败] %s" % e)
import traceback
traceback.print_exc()
return 1
finally:
if client:
try:
client.close()
except Exception:
pass
if __name__ == "__main__":
sys.exit(main() or 0)

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
生成章节海报(标题=章节标题、摘要+小程序码),上传到飞书并发送到 Soul 彩民团队飞书群(默认 webhook)。
生成章节海报(标题=章节标题、摘要+小程序码),上传到飞书并发送到开发群(默认 webhook见 FEISHU_DEV_GROUP_WEBHOOK)。
海报样式:深蓝背景、顶部装饰条、主标题为章节标题、摘要、底部「长按识别小程序码」+ 二维码。
用法:
python3 send_chapter_poster_to_feishu.py 9.24 "第112场一个人起头维权挣了大半套房"
@@ -30,8 +30,11 @@ except ImportError:
# 与 post_to_feishu 保持一致
SCRIPT_DIR = Path(__file__).resolve().parent
# 默认发到 Soul 彩民团队飞书群
WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/34b762fc-5b9b-4abb-a05a-96c8fb9599f1"
# 默认Soul 开发群(派对 AI / 卡若 AI 与项目复盘统一入口,见 .cursor/docs/feishu_开发群与项目复盘.md
WEBHOOK = os.environ.get(
"FEISHU_DEV_GROUP_WEBHOOK",
"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"

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
向开发群 webhook 发送一条长文本 + 若干本地 PNG先上传飞书再发 image_key
依赖:与 send_chapter_poster_to_feishu.py 相同,需 scripts/.env.feishu 内 FEISHU_APP_ID / FEISHU_APP_SECRET。
用法:
python3 send_feishu_text_and_images.py --text-file recap.txt \\
--images a.png b.png
python3 send_feishu_text_and_images.py -t "单行文本" --images x.png
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
try:
import requests
except ImportError:
print("pip install requests", file=sys.stderr)
sys.exit(1)
DEFAULT_WEBHOOK = os.environ.get(
"FEISHU_DEV_GROUP_WEBHOOK",
"https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494",
)
def load_env_feishu():
p = SCRIPT_DIR / ".env.feishu"
if not p.is_file():
return
for line in p.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
def tenant_token() -> str | None:
load_env_feishu()
app_id = os.environ.get("FEISHU_APP_ID", "")
sec = os.environ.get("FEISHU_APP_SECRET", "")
if not app_id or not sec:
print("缺少 FEISHU_APP_ID / FEISHU_APP_SECRET.env.feishu", file=sys.stderr)
return None
r = requests.post(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": sec},
timeout=15,
)
data = r.json() or {}
if data.get("code") != 0:
print("token 失败:", data, file=sys.stderr)
return None
return data.get("tenant_access_token")
def send_text(webhook: str, text: str) -> bool:
r = requests.post(webhook, json={"msg_type": "text", "content": {"text": text}}, timeout=15)
d = r.json() or {}
if d.get("code") != 0:
print("文本发送失败:", d, file=sys.stderr)
return False
return True
def upload_png(token: str, path: Path) -> str | None:
url = "https://open.feishu.cn/open-apis/im/v1/images"
headers = {"Authorization": f"Bearer {token}"}
with path.open("rb") as f:
r = requests.post(
url,
headers=headers,
files={"image": (path.name, f, "image/png")},
data={"image_type": "message"},
timeout=60,
)
out = r.json() or {}
if out.get("code") != 0:
print("上传失败", path, out, file=sys.stderr)
return None
return (out.get("data") or {}).get("image_key")
def send_image(webhook: str, image_key: str) -> bool:
r = requests.post(
webhook,
json={"msg_type": "image", "content": {"image_key": image_key}},
timeout=15,
)
d = r.json() or {}
if d.get("code") != 0:
print("图片消息失败:", d, file=sys.stderr)
return False
return True
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-t", "--text", default="", help="直接传入文本")
ap.add_argument("--text-file", type=Path, help="从文件读文本utf-8")
ap.add_argument("--webhook", "-w", default=DEFAULT_WEBHOOK)
ap.add_argument("--images", "-i", nargs="*", default=[], help="PNG 路径列表")
args = ap.parse_args()
body = args.text.strip()
if args.text_file:
body = args.text_file.read_text(encoding="utf-8").strip()
if not body:
ap.error("需要 -t 或 --text-file")
if not send_text(args.webhook, body[:20000]):
sys.exit(1)
print("已发文本")
if not args.images:
return
tok = tenant_token()
if not tok:
sys.exit(1)
for p in args.images:
path = Path(p).expanduser().resolve()
if not path.is_file():
print("跳过(不存在):", path, file=sys.stderr)
continue
key = upload_png(tok, path)
if key and send_image(args.webhook, key):
print("已发图:", path.name)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
从书稿 Markdown 中仅解析「图片」引用并上传到现网,再生成 HTML 写入 chapters.content。
- 只处理:`![](相对/绝对路径.png)` 等常见图片后缀;**不解析、不上传视频/附件**。
- 已是 `http(s)://` 的地址:不重复上传,原样写入 `<img src="...">`。
- 非图片后缀的 `![]()`:当作普通正文一行输出(不尝试上传)。
用法:
cd 一场soul的创业实验-永平
python3 scripts/sync_chapter_images_from_md.py --id 10.22 \\
--md "/path/to/第130场….md"
依赖: pip install pymysql requests
环境变量: SOUL_API_BASE 默认 https://soulapi.quwanzhi.com
"""
from __future__ import annotations
import argparse
import html
import importlib.util
import os
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"})
# 整行仅有一张图:![](...)
LINE_IMAGE_ONLY = re.compile(r"^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$")
try:
import pymysql
import requests
except ImportError as e:
print("需要: pip install pymysql requests", e, file=sys.stderr)
sys.exit(1)
def load_db_config() -> dict:
mig = ROOT / "scripts" / "migrate_2026_sections.py"
spec = importlib.util.spec_from_file_location("_mig_db", mig)
mod = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(mod)
cfg = getattr(mod, "DB_CONFIG", None)
if not isinstance(cfg, dict):
sys.exit("migrate_2026_sections.py 中无有效 DB_CONFIG")
return cfg
def resolve_local_path(md_path: Path, ref: str) -> Path | None:
ref = ref.strip()
if not ref or ref.startswith(("http://", "https://")):
return None
p = (md_path.parent / ref).expanduser().resolve()
if p.is_file():
return p
return None
def guess_mime(path: Path) -> str:
ext = path.suffix.lower()
return {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}.get(ext, "application/octet-stream")
def upload_image(local: Path, api_base: str) -> str:
url = f"{api_base.rstrip('/')}/api/upload"
mime = guess_mime(local)
with local.open("rb") as f:
files = {"file": (local.name, f, mime)}
data = {"folder": "book-images"}
r = requests.post(url, files=files, data=data, timeout=120)
r.raise_for_status()
j = r.json()
if not j.get("success"):
raise RuntimeError(j.get("error") or j.get("message") or str(j))
out = j.get("url") or (j.get("data") or {}).get("url")
if not out:
raise RuntimeError("响应无 url: " + str(j)[:500])
return str(out)
def md_to_html(md_path: Path, api_base: str) -> str:
raw = md_path.read_text(encoding="utf-8")
lines = raw.splitlines()
if lines and lines[0].lstrip().startswith("#"):
lines = lines[1:]
chunks: list[str] = []
upload_cache: dict[str, str] = {}
for line in lines:
if line.strip() == "---":
chunks.append("")
continue
m = LINE_IMAGE_ONLY.match(line)
if m:
alt, ref = m.group(1), m.group(2).strip()
if ref.startswith(("http://", "https://")):
chunks.append(
f'<p><img src="{html.escape(ref)}" alt="{html.escape(alt)}"/></p>'
)
continue
loc = resolve_local_path(md_path, ref)
if not loc:
chunks.append(f"<p>(图片路径无效:{html.escape(ref)}</p>")
continue
ext = loc.suffix.lower()
if ext not in IMAGE_EXT:
# 非图片(如视频):不解析、不上传,整行当正文
chunks.append(f"<p>{html.escape(line.strip())}</p>")
continue
key = str(loc)
if key not in upload_cache:
print(f"上传图片: {loc.name}", flush=True)
upload_cache[key] = upload_image(loc, api_base)
src = upload_cache[key]
chunks.append(
f'<p><img src="{html.escape(src)}" alt="{html.escape(alt)}"/></p>'
)
continue
if line.strip():
chunks.append(f"<p>{html.escape(line.strip())}</p>")
else:
chunks.append("")
# 合并连续空串为单个换行,避免多余空 <p>
html_parts: list[str] = []
for c in chunks:
if c == "":
if html_parts and html_parts[-1] != "":
html_parts.append("")
else:
html_parts.append(c)
return "\n".join(html_parts).strip() + "\n"
def main() -> None:
p = argparse.ArgumentParser(description="MD 内图片上传并写回 chapters仅图片")
p.add_argument("--id", required=True, help="章节 id如 10.22")
p.add_argument("--md", type=Path, required=True, help="文章 .md 路径")
p.add_argument(
"--api-base",
default=os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com"),
help="API 根地址",
)
p.add_argument("--dry-run", action="store_true", help="只打印 HTML 前 800 字,不写库")
args = p.parse_args()
md_path = args.md.expanduser().resolve()
if not md_path.is_file():
sys.exit(f"文件不存在: {md_path}")
body = md_to_html(md_path, args.api_base)
word_count = len(body)
if args.dry_run:
print(body[:800])
print("… dry-runword_count=", word_count)
return
cfg = load_db_config()
conn = pymysql.connect(**cfg)
cur = conn.cursor()
cur.execute(
"UPDATE chapters SET content = %s, word_count = %s, updated_at = NOW() WHERE id = %s",
(body, word_count, args.id),
)
if cur.rowcount != 1:
conn.rollback()
sys.exit(f"更新失败id={args.id} rowcount={cur.rowcount}")
conn.commit()
conn.close()
print(f"已更新 {args.id} | word_count={word_count}")
if __name__ == "__main__":
main()

View File

@@ -14,6 +14,18 @@
---
## 响应速度测试
`test_article_preview_speed.py`:文章阅读与界面预览 GET 接口响应速度测试。
```bash
SOUL_TEST_ENV=soulapi python scripts/test/miniapp/test_article_preview_speed.py
```
产出:控制台报表 + `开发文档/测试报告-文章阅读与界面预览响应速度-YYYYMMDD.md`
---
## 用例编写
在此目录下新增 `.md` 或测试脚本,按场景组织用例。

View File

@@ -0,0 +1,234 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
文章阅读与界面预览 GET 接口响应速度测试
测试范围:
- 界面预览config、book/parts、book/all-chapters、book/chapters-by-part
- 文章阅读book/chapter/:id、book/chapter/by-mid/:mid
用法:
SOUL_TEST_ENV=soulapi python scripts/test/miniapp/test_article_preview_speed.py
SOUL_TEST_ENV=soulapi python -m scripts.test.miniapp.test_article_preview_speed
产出:控制台报表 + 开发文档/测试报告-文章阅读与界面预览响应速度-YYYYMMDD.md
"""
import json
import sys
import time
from pathlib import Path
import requests
# 加载测试配置
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from config import API_BASE, ENV_LABEL, get_env_banner
# 每接口请求次数(取平均)
ROUNDS = 5
TIMEOUT = 30
def measure_get(url: str, desc: str) -> dict:
"""对 GET 请求测速,返回 {ok, status_code, times_ms, avg_ms, min_ms, max_ms, error}"""
times_ms = []
last_error = None
last_status = None
for _ in range(ROUNDS):
t0 = time.perf_counter()
try:
r = requests.get(url, timeout=TIMEOUT)
last_status = r.status_code
elapsed = (time.perf_counter() - t0) * 1000
times_ms.append(elapsed)
if r.status_code != 200:
last_error = f"HTTP {r.status_code}"
except requests.RequestException as e:
last_error = str(e)
times_ms.append(-1)
if not times_ms:
return {"ok": False, "error": last_error or "无响应", "status_code": last_status}
valid = [t for t in times_ms if t >= 0]
return {
"ok": len(valid) == ROUNDS and (last_status or 200) == 200,
"status_code": last_status,
"times_ms": times_ms,
"avg_ms": sum(valid) / len(valid) if valid else 0,
"min_ms": min(valid) if valid else 0,
"max_ms": max(valid) if valid else 0,
"error": last_error,
}
def main():
print(get_env_banner())
base = API_BASE.rstrip("/")
# 1. 先拉取 parts 和 all-chapters获取 partId、id、mid
parts_url = f"{base}/api/miniprogram/book/parts"
all_chapters_url = f"{base}/api/miniprogram/book/all-chapters"
parts_data = None
all_chapters_data = None
try:
r = requests.get(parts_url, timeout=TIMEOUT)
if r.status_code == 200:
parts_data = r.json()
except Exception:
pass
try:
r = requests.get(all_chapters_url, timeout=TIMEOUT)
if r.status_code == 200:
all_chapters_data = r.json()
except Exception:
pass
part_id = None
chapter_id = None
chapter_mid = None
if parts_data and parts_data.get("success"):
parts = parts_data.get("parts") or []
fixed = parts_data.get("fixedSections") or []
if parts:
part_id = parts[0].get("id")
if fixed:
chapter_mid = fixed[0].get("mid")
chapter_id = fixed[0].get("id")
if (not chapter_id or not chapter_mid) and all_chapters_data and all_chapters_data.get("success"):
arr = all_chapters_data.get("data") or all_chapters_data.get("chapters") or []
if arr:
first = arr[0] if isinstance(arr[0], dict) else {}
chapter_id = chapter_id or first.get("id")
chapter_mid = chapter_mid or first.get("mid")
if not part_id and parts_data and parts_data.get("success"):
parts = parts_data.get("parts") or []
if parts:
part_id = parts[0].get("id")
# 2. 定义测试用例(仅 GET
cases = [
("界面预览-配置", f"{base}/api/miniprogram/config", "GET /api/miniprogram/config"),
("界面预览-目录", f"{base}/api/miniprogram/book/parts", "GET /api/miniprogram/book/parts"),
("界面预览-全书章节", f"{base}/api/miniprogram/book/all-chapters", "GET /api/miniprogram/book/all-chapters"),
]
if part_id:
cases.append(
(
"界面预览-篇章内章节",
f"{base}/api/miniprogram/book/chapters-by-part?partId={part_id}",
f"GET /api/miniprogram/book/chapters-by-part?partId={part_id}",
)
)
if chapter_id:
cases.append(
(
"文章阅读-按id",
f"{base}/api/miniprogram/book/chapter/{chapter_id}",
f"GET /api/miniprogram/book/chapter/:id",
)
)
if chapter_mid:
cases.append(
(
"文章阅读-按mid",
f"{base}/api/miniprogram/book/chapter/by-mid/{chapter_mid}",
f"GET /api/miniprogram/book/chapter/by-mid/:mid",
)
)
# 3. 执行测速
results = []
for name, url, api_desc in cases:
print(f"\n测速: {name} ({api_desc})")
res = measure_get(url, name)
res["name"] = name
res["api"] = api_desc
res["url"] = url
results.append(res)
if res["ok"]:
print(f" [OK] avg={res['avg_ms']:.0f}ms (min={res['min_ms']:.0f}, max={res['max_ms']:.0f})")
else:
print(f" [FAIL] {res.get('error', res.get('status_code', '?'))}")
# 4. 生成报表
from datetime import datetime
date_str = datetime.now().strftime("%Y-%m-%d %H:%M")
date_file = datetime.now().strftime("%Y%m%d")
lines = [
"# 文章阅读与界面预览 GET 接口响应速度测试报告",
"",
f"**测试时间**: {date_str}",
f"**测试环境**: {ENV_LABEL} ({API_BASE})",
f"**每接口请求次数**: {ROUNDS}",
"",
"## 一、测试范围",
"",
"| 分类 | 接口 | 说明 |",
"|------|------|------|",
"| 界面预览 | GET /api/miniprogram/config | 配置(价格、功能开关等) |",
"| 界面预览 | GET /api/miniprogram/book/parts | 目录-篇章列表 |",
"| 界面预览 | GET /api/miniprogram/book/all-chapters | 全书章节列表 |",
"| 界面预览 | GET /api/miniprogram/book/chapters-by-part | 篇章内章节列表 |",
"| 文章阅读 | GET /api/miniprogram/book/chapter/:id | 按业务 id 获取章节内容 |",
"| 文章阅读 | GET /api/miniprogram/book/chapter/by-mid/:mid | 按 mid 获取章节内容 |",
"",
"## 二、响应速度结果",
"",
"| 接口 | 状态 | 平均(ms) | 最小(ms) | 最大(ms) |",
"|------|------|----------|----------|----------|",
]
for r in results:
status = "OK" if r["ok"] else "FAIL"
avg = f"{r['avg_ms']:.0f}" if r["ok"] else "-"
min_ms = f"{r['min_ms']:.0f}" if r["ok"] else "-"
max_ms = f"{r['max_ms']:.0f}" if r["ok"] else "-"
if not r["ok"]:
err = r.get("error", "") or f"HTTP {r.get('status_code', '?')}"
avg = err[:20] if err else "-"
lines.append(f"| {r['api']} | {status} | {avg} | {min_ms} | {max_ms} |")
# 汇总
ok_count = sum(1 for r in results if r["ok"])
total_count = len(results)
if ok_count == total_count:
avg_all = sum(r["avg_ms"] for r in results) / total_count
lines.extend([
"",
"## 三、汇总",
"",
f"- 通过: {ok_count}/{total_count}",
f"- 全部接口平均响应: {avg_all:.0f}ms",
"",
])
else:
lines.extend([
"",
"## 三、汇总",
"",
f"- 通过: {ok_count}/{total_count}",
f"- 失败: {total_count - ok_count} 个接口",
"",
])
report_content = "\n".join(lines)
# 5. 输出到控制台
print("\n" + "=" * 60)
print(report_content)
print("=" * 60)
# 6. 写入文件(项目根/开发文档)
report_dir = Path(__file__).resolve().parent.parent.parent.parent / "开发文档"
report_dir.mkdir(parents=True, exist_ok=True)
report_path = report_dir / f"测试报告-文章阅读与界面预览响应速度-{date_file}.md"
report_path.write_text(report_content, encoding="utf-8")
print(f"\n报表已保存: {report_path}")
return 0 if ok_count == total_count else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,168 @@
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import requests
PROJECT_ROOT = Path(__file__).resolve().parents[3]
ROUTER_GO = PROJECT_ROOT / "soul-api" / "internal" / "router" / "router.go"
@dataclass
class Route:
group: str # "admin" | "db" | "root"
method: str
path: str # path within the group, e.g. "/chapters" or "/admin"
full_path: str # full path appended to API_BASE_URL
def _read_text(path: Path) -> str:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
def extract_admin_and_db_routes() -> list[tuple[str, str]]:
"""
返回 [(method, full_path_template), ...]
full_path_template 已包含 /api/admin 或 /api/db 前缀,保留 :id 占位符。
"""
text = _read_text(ROUTER_GO)
routes: list[tuple[str, str]] = []
# 1) /api/admin 登录/鉴权/登出(不是 admin group 内)
# api.GET("/admin", ...) / api.POST("/admin", ...) / api.POST("/admin/logout", ...)
for m in re.finditer(r'api\.(GET|POST|PUT|DELETE)\("(/admin(?:/[^"]*)?)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api{m.group(2)}"))
# 2) admin groupapi.Group("/admin") + admin.(GET|POST|PUT|DELETE)("/xxx", ...)
for m in re.finditer(r'admin\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api/admin{m.group(2)}"))
# 3) db groupapi.Group("/db") + db.(GET|POST|PUT|DELETE)("/xxx", ...)
for m in re.finditer(r'db\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api/db{m.group(2)}"))
# 去重(同一 handler 可能存在重复注册)
seen: set[tuple[str, str]] = set()
out: list[tuple[str, str]] = []
for method, p in routes:
k = (method, p)
if k in seen:
continue
seen.add(k)
out.append((method, p))
return out
def replace_path_params(path: str) -> str:
# 仅用于 smoke把 :id 替换成一个固定占位
return path.replace(":id", "1")
def request_json(
session: requests.Session,
method: str,
url: str,
headers: dict[str, str],
payload: Any | None = None,
raw_body: str | None = None,
) -> tuple[int, dict[str, Any] | None, str]:
try:
if raw_body is not None:
resp = session.request(method, url, headers=headers, data=raw_body, timeout=10)
elif payload is None:
resp = session.request(method, url, headers=headers, timeout=10)
else:
resp = session.request(method, url, headers=headers, data=json.dumps(payload), timeout=10)
text = resp.text or ""
try:
data = resp.json()
except Exception:
data = None
return resp.status_code, data, text[:300]
except Exception as e:
return 0, None, f"EXC: {e}"
def main() -> None:
api_base = None
# 优先使用本地默认;需要对接测试环境时在 PowerShell 设置 SOUL_API_BASE
import os
api_base = (os.environ.get("SOUL_API_BASE") or "").rstrip("/")
if not api_base:
# 默认本机
api_base = "http://localhost:8080"
admin_username = os.environ.get("SOUL_ADMIN_USERNAME", "admin")
admin_password = os.environ.get("SOUL_ADMIN_PASSWORD", "admin123")
session = requests.Session()
# 本 smoke 默认不验证 TLS如果你用的是 https 且是自签证书,能跑通测试)
session.verify = False
# 登录拿 token
login_url = f"{api_base}/api/admin"
r = session.post(login_url, json={"username": admin_username, "password": admin_password}, timeout=10)
try:
login_data = r.json()
except Exception:
login_data = None
if r.status_code != 200 or not (login_data and login_data.get("success") is True and login_data.get("token")):
print("LOGIN_FAILED", r.status_code, r.text[:200])
return
token = login_data["token"]
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
routes = extract_admin_and_db_routes()
print(f"Found routes: {len(routes)}")
failures: list[dict[str, Any]] = []
unexpected_success: list[dict[str, Any]] = []
for method, path_template in routes:
path = replace_path_params(path_template)
url = f"{api_base}{path}"
payload = None
raw_body = None
if method in ("POST", "PUT", "DELETE"):
# 安全模式:发送明显非法 JSON尽量触发 ShouldBindJSON 失败,避免真实写入。
payload = None
raw_body = "{invalid_json"
status, data, preview = request_json(
session, method, url, headers, payload=payload, raw_body=raw_body
)
ok = status not in (404, 500) and status != 0
# POST/PUT/DELETE 在安全模式下不应返回 success=true
if method in ("POST", "PUT", "DELETE") and data and data.get("success") is True:
unexpected_success.append(
{"method": method, "path": path, "status": status, "data": data, "preview": preview}
)
if not ok:
failures.append({"method": method, "path": path, "status": status, "data": data, "preview": preview})
print("\n=== SMOKE_RESULT ===")
print("Failures(404/500/EXC):", len(failures))
if failures:
for it in failures:
print(f"- {it['method']} {it['path']} -> {it['status']}, preview={it.get('preview')}")
print("\nUnexpected success on write calls:", len(unexpected_success))
if unexpected_success:
for it in unexpected_success:
print(f"- {it['method']} {it['path']} -> success=true (status {it['status']}, preview={it.get('preview')})")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,134 @@
import re
from dataclasses import dataclass
from pathlib import Path
import requests
PROJECT_ROOT = Path(__file__).resolve().parents[3]
ROUTER_GO = PROJECT_ROOT / "soul-api" / "internal" / "router" / "router.go"
@dataclass
class Check:
method: str
path: str
status: int
preview: str
def _read_text(path: Path) -> str:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
def extract_routes() -> list[tuple[str, str]]:
"""
返回 [(method, full_path_template), ...]
full_path_template 保留 :id 占位符。
"""
text = _read_text(ROUTER_GO)
routes: list[tuple[str, str]] = []
# /api/admin 登录/鉴权/登出
for m in re.finditer(r'api\.(GET|POST|PUT|DELETE)\("(/admin(?:/[^"]*)?)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api{m.group(2)}"))
# /api/admin 组
for m in re.finditer(r'admin\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api/admin{m.group(2)}"))
# /api/db 组
for m in re.finditer(r'db\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api/db{m.group(2)}"))
# 去重
seen = set()
out = []
for method, p in routes:
if (method, p) in seen:
continue
seen.add((method, p))
out.append((method, p))
return out
def replace_path_params(path: str) -> str:
return path.replace(":id", "1")
def main() -> None:
import os
api_base = (os.environ.get("SOUL_API_BASE") or "http://localhost:8080").rstrip("/")
session = requests.Session()
session.verify = False # 如为 https 自签证书也可探测
routes = extract_routes()
print(f"Found routes: {len(routes)}")
failures: list[Check] = []
unexpected: list[Check] = []
headers = {"Content-Type": "application/json"}
# 先验证登录接口是否通(只对 /api/admin POST 登录做一次带凭证的检查)
admin_username = os.environ.get("SOUL_ADMIN_USERNAME", "admin")
admin_password = os.environ.get("SOUL_ADMIN_PASSWORD", "admin123")
login_url = f"{api_base}/api/admin"
r_login = session.post(
login_url,
json={"username": admin_username, "password": admin_password},
headers=headers,
timeout=10,
)
try:
login_data = r_login.json()
except Exception:
login_data = None
if r_login.status_code != 200 or not (login_data and login_data.get("success") is True and login_data.get("token")):
failures.append(Check("POST", "/api/admin", r_login.status_code, (r_login.text or "")[:200]))
print("LOGIN_CHECK_FAILED后续路由鉴权探测可能不准确。")
for method, path_template in routes:
path = replace_path_params(path_template)
url = f"{api_base}{path}"
# 仅对登录接口放行;其他都不带 token避免触发写操作
json_payload = None
if path == "/api/admin" and method == "POST":
# 已在上面验证登录;这里跳过
continue
if method in ("POST", "PUT"):
# 发空 body通常也会被 AdminAuth 在更早阶段拦截
json_payload = {}
try:
resp = session.request(method, url, headers=headers, json=json_payload, timeout=10)
status = resp.status_code
preview = (resp.text or "")[:200].replace("\n", " ")
except Exception as e:
failures.append(Check(method, path, 0, f"EXC: {e}"))
continue
# 非登录接口:预期 AdminAuth 拦截 => 401 或 403
if status not in (401, 403):
unexpected.append(Check(method, path, status, preview))
print("\n=== AUTHLESS_SMOKE_RESULT ===")
print("Failures(0/404/500 等异常/网络异常):", len(failures))
for it in failures[:30]:
print(f"- {it.method} {it.path} -> {it.status}, preview={it.preview}")
print("Unexpected (非 401/403):", len(unexpected))
for it in unexpected[:30]:
print(f"- {it.method} {it.path} -> {it.status}, preview={it.preview}")
if len(unexpected) > 30:
print("... truncated")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,165 @@
# 测试报告 - 2026-03-24本地核心功能场景
## 1. 测试概览
| 项目 | 内容 |
|------|------|
| 执行日期 | 2026-03-24 |
| 测试环境 | local |
| API 地址 | `http://localhost:8080` |
| 执行人 | 测试工程师AI |
| 覆盖范围 | 分销、支付、获客(@某人/CKB)、匹配;管理端/小程序/API 核心链路 |
---
## 2. 执行结果总览
| 测试集 | 结果 | 结论 |
|------|------|------|
| `pytest -q` 全量 | 18 total / 16 passed / 2 skipped / 0 failed | 通过 |
| 管理端路由冒烟(鉴权) | 119 路由404/500/异常=0写接口成功=16 | 有风险项 |
| 管理端路由冒烟(未鉴权) | 404/500/异常=0非 401/403=1 | 有风险项 |
---
## 3. 自动化执行明细
### 3.1 全量 pytest
```powershell
$env:SOUL_TEST_ENV='local'; pytest -q
```
- 总计18
- 通过16
- 跳过2
- 失败0
覆盖模块:
- 小程序配置/登录miniapp
- 管理端鉴权与上传web
- 流程测试:健康检查、文章 @ 某人流程、人物 key 回填(部分 skip
### 3.2 管理端路由冒烟(鉴权)
```powershell
$env:SOUL_TEST_ENV='local'; python web/admin_routes_smoke.py
```
- 路由扫描119
- 404/500/异常0
- 需关注:`Unexpected success on write calls: 16`
- 涉及:`/api/admin/content``/api/admin/payment``/api/admin/referral``/api/db/chapters``/api/db/init``/api/db/migrate`
判读:
- 非直接功能 bug但暴露“写接口防误触”偏弱空 payload 也可能 success
### 3.3 管理端路由冒烟(未鉴权)
```powershell
$env:SOUL_TEST_ENV='local'; python web/admin_routes_smoke_authless.py
```
- 404/500/异常0
- 非预期(非 401/4031
- `POST /api/admin/logout` 返回 200未鉴权可调用
判读:
- 风险较低,但权限策略不一致,建议明确“公开接口”与“鉴权接口”边界。
---
## 4. 核心功能场景评估
### 4.1 分销
- 已验证
- 推荐码链路相关接口和流程用例可运行,自动化无失败。
- 本地联调可用(见 pytest 全量)。
- 待补手工
- 小程序扫码/分享带 `ref` 后,到订单归因展示的 UI 端到端确认。
### 4.2 支付
- 已验证
- 基础链路可用:`/health`、上传等依赖接口正常。
- 流程层(文章 @ 相关)可执行通过。
- 待补手工
- 真支付、余额支付、代付的真机端到端验证(本次未覆盖真实支付动作)。
### 4.3 获客(@某人 / CKB
- 已验证
- `process/test_article_mention_ckb_flow.py` 通过。
- 已补齐规则:人物不存在时,阅读页 mention 降级为静态 `@某人`
- 超级个体开通后自动创建 `Person` 的链路存在并已被流程用例覆盖。
### 4.4 匹配
- 已验证
- 相关后端路由可达(冒烟无 404/500
- 待补手工
- 小程序找伙伴完整流程:次数扣减、购买增次、资料门槛、匹配结果、加入流程。
---
## 5. 风险清单(按优先级)
| 优先级 | 风险项 | 现象 | 建议 |
|------|------|------|------|
| P1 | 写接口防误触偏弱 | 冒烟请求下 16 个写接口 success=true | 增加必填参数校验、鉴权校验、幂等保护 |
| P2 | 鉴权策略不一致 | `POST /api/admin/logout` 未鉴权返回 200 | 明确公开策略或统一纳入鉴权组 |
| P1 | 覆盖缺口UI E2E | 自动化未覆盖真机支付/分享/匹配 UI | 补充手工用例并归档结果 |
---
## 6. 复测计划(建议)
### 6.1 手工复测清单(本地)
- 小程序
- [ ] 分享带 `ref` → 下单归因展示正确
- [ ] 真机微信支付(章节/全书/VIP成功后权益生效
- [ ] 代付分享领取链路完整
- [ ] 匹配全流程(免费次数、增次购买、资料门槛、加入)
- 管理端
- [ ] 内容写接口空 payload 时返回合理错误
- [ ] 关键写接口鉴权策略一致
### 6.2 通过标准
- P1 风险项有明确处理结果(修复或接受并记录)
- 手工高风险链路全部通过
- 无新增中高严重问题
---
## 7. 最终结论
- 自动化结果:**通过16 passed / 0 failed / 2 skipped**
- 综合判定:**有条件通过**
- 当前可继续本地开发联调;
- 上线前需完成手工高风险场景复测,并处理/确认 P1 风险项。
---
## 8. 整改跟踪(执行版)
| 序号 | 问题/任务 | 优先级 | 责任角色 | 截止日期 | 当前状态 | 备注 |
|------|-----------|--------|----------|----------|----------|------|
| 1 | 写接口防误触:为空 payload 增加参数校验/防误触策略 | P1 | 后端工程师 | 2026-03-26 | 待开始 | 涉及 `/api/admin/content``/api/admin/payment``/api/admin/referral``/api/db/*` 部分接口 |
| 2 | 明确 `POST /api/admin/logout` 鉴权策略并与规范对齐 | P2 | 后端工程师 | 2026-03-26 | 待开始 | 可选“公开接口说明”或“纳入鉴权组” |
| 3 | 小程序分享带 `ref` → 订单归因 UI 端到端手工验证 | P1 | 测试人员 + 小程序工程师 | 2026-03-25 | 待开始 | 需真机/开发者工具联测 |
| 4 | 真机支付链路回归:章节/全书/VIP/代付 | P1 | 测试人员 + 小程序工程师 + 后端工程师 | 2026-03-25 | 待开始 | 含回调后权益生效校验 |
| 5 | 匹配完整链路手工回归(次数/增次/门槛/加入) | P1 | 测试人员 + 小程序工程师 | 2026-03-25 | 待开始 | 建议录屏留档 |
状态说明:`待开始 / 进行中 / 已完成 / 已阻塞`
---
## 9. 复测记录
| 复测日期 | 复测范围 | 结果 | 剩余问题 | 结论 |
|----------|----------|------|----------|------|
| (待填) | (待填) | (待填) | (待填) | (待填) |

View File

@@ -0,0 +1,377 @@
#!/usr/bin/env python3
"""
微信小程序发布辅助:上传(调开发者工具 CLI+ 审核状态查询 + 尝试 API 提审。
重要说明(微信官方限制):
- 代码上传可用本机「微信开发者工具」CLI 或 miniprogram-ci。
- submit_audit主要为「第三方平台代调用」自有主体用 appid+secret 常返回 errcode=86000
无法在仓库内替代网页提审;`release` 默认只跑上传+接口调用,不弹浏览器、不提示手动操作。
- 「自动过审」不可能由开发者脚本保证:是否通过由微信审核决定。
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
# 登录 mp 后进入「版本管理 / 开发版本」列表(选体验版、提交审核均在此页操作;具体路由以微信后台为准)。
MP_VERSION_MANAGE_URL = (
"https://mp.weixin.qq.com/wxopen/wacodepage?action=getcodepage&lang=zh_CN"
)
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:
"""若未 export 凭证,则从 soul-api/.env.production 或 .env 读取 WECHAT_APPID/SECRET不写日志"""
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 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}"
try:
data = _get(url)
except urllib.error.HTTPError as e:
raise SystemExit(f"获取 access_token HTTP 错误: {e}") from e
if data.get("errcode"):
raise SystemExit(f"获取 access_token 失败: {data}")
token = data.get("access_token")
if not token:
raise SystemExit(f"获取 access_token 无 access_token 字段: {data}")
return token
def cmd_audit_status(appid: str, secret: str) -> None:
token = get_access_token(appid, secret)
url = f"https://api.weixin.qq.com/wxa/get_latest_auditstatus?access_token={urllib.parse.quote(token)}"
try:
data = _get(url)
except urllib.error.HTTPError as e:
raise SystemExit(f"get_latest_auditstatus HTTP 错误: {e}") from e
print(json.dumps(data, ensure_ascii=False, indent=2))
# 常见 status: 0 审核成功 1 审核被拒绝 2 审核中 3 已撤回 4 审核延后
def cmd_get_category(appid: str, secret: str) -> None:
token = get_access_token(appid, secret)
url = f"https://api.weixin.qq.com/wxa/get_category?access_token={urllib.parse.quote(token)}"
try:
data = _get(url)
except urllib.error.HTTPError as e:
raise SystemExit(f"get_category HTTP 错误: {e}") from e
print(json.dumps(data, ensure_ascii=False, indent=2))
def _first_item_from_category(data: dict) -> dict | None:
lst = data.get("category_list")
if not lst or not isinstance(lst, list):
return None
c = lst[0]
if not isinstance(c, dict):
return None
first_class = c.get("first_class") or ""
second_class = c.get("second_class") or ""
first_id = c.get("first_id")
second_id = c.get("second_id")
if first_id is None or second_id is None:
return None
item: dict = {
"address": "pages/index/index",
"tag": "阅读 创业",
"first_class": first_class,
"second_class": second_class,
"first_id": int(first_id),
"second_id": int(second_id),
"title": "首页",
}
third_class = c.get("third_class")
third_id = c.get("third_id")
if third_class and third_id:
item["third_class"] = third_class
item["third_id"] = int(third_id)
return item
def cmd_submit_audit(
appid: str,
secret: str,
version_desc: str,
item_json: Path | None,
privacy_api_not_use: bool | None,
*,
quiet: bool = False,
) -> dict:
token = get_access_token(appid, secret)
if item_json and item_json.is_file():
payload = json.loads(item_json.read_text(encoding="utf-8"))
item_list = payload.get("item_list")
if not item_list:
raise SystemExit("item_json 中缺少 item_list")
else:
cat_url = f"https://api.weixin.qq.com/wxa/get_category?access_token={urllib.parse.quote(token)}"
cat = _get(cat_url)
if cat.get("errcode"):
raise SystemExit(f"get_category 失败: {cat}")
one = _first_item_from_category(cat)
if not one:
raise SystemExit(
"无法从 get_category 构造审核项;请在公众平台配置服务类目,"
"或使用 --item-json 指定完整 item_list见 scripts/miniprogram_audit_item.example.json"
)
item_list = [one]
body: dict = {
"item_list": item_list,
"version_desc": version_desc[:400] if version_desc else "版本更新",
}
if privacy_api_not_use is True:
body["privacy_api_not_use"] = True
elif privacy_api_not_use is False:
body["privacy_api_not_use"] = False
submit_url = f"https://api.weixin.qq.com/wxa/submit_audit?access_token={urllib.parse.quote(token)}"
try:
data = _post_json(submit_url, body)
except urllib.error.HTTPError as e:
raise SystemExit(f"submit_audit HTTP 错误: {e}") from e
print(json.dumps(data, ensure_ascii=False, indent=2))
if not quiet:
if data.get("errcode") == 86000:
print(
"\n说明 errcode=86000该接口仅支持「第三方平台」代小程序调用。"
"自有主体请在浏览器打开公众平台 → 管理 → 版本管理 → 提交审核。\n"
"可先运行: python3 scripts/wechat_miniprogram_release.py open-version",
file=sys.stderr,
)
elif data.get("errcode") == 61039:
print(
"\n说明 errcode=61039上传后隐私/代码检测任务未完成,请等待数分钟后再提交审核。",
file=sys.stderr,
)
return data
def cmd_upload(version: str, desc: str) -> None:
root = Path(__file__).resolve().parent.parent
sh = root / "scripts" / "miniprogram_upload.sh"
if not sh.is_file():
raise SystemExit(f"未找到 {sh}")
r = subprocess.run([str(sh), version, desc], check=False)
if r.returncode != 0:
raise SystemExit(r.returncode)
def cmd_open_mp() -> None:
url = "https://mp.weixin.qq.com/"
try:
subprocess.run(["open", url], check=False)
except FileNotFoundError:
print(url)
def cmd_open_mp_version() -> None:
"""打开版本管理(开发版本列表,可设体验版、提交审核)。"""
try:
subprocess.run(["open", MP_VERSION_MANAGE_URL], check=False)
except FileNotFoundError:
print(MP_VERSION_MANAGE_URL)
def cmd_upload_open(version: str, desc: str) -> None:
cmd_upload(version, desc)
print(
"\n下一步微信未开放「一键设为体验版」API需在网页上点两次\n"
"1在「开发版本」列表找到刚上传的版本号\n"
"2点击「选为体验版」\n"
"3同一列表对该版本点击「提交审核」或先体验再提审\n",
file=sys.stderr,
)
cmd_open_mp_version()
def main() -> None:
p = argparse.ArgumentParser(description="微信小程序上传与审核辅助")
sub = p.add_subparsers(dest="cmd", required=True)
p_up = sub.add_parser("upload", help="调用微信开发者工具 CLI 上传(需本机已登录)")
p_up.add_argument(
"--version",
"-v",
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"),
help="版本号,默认 1.7.2 或环境变量 MINIPROGRAM_DEFAULT_VERSION",
)
p_up.add_argument(
"--desc",
"-d",
default="",
help="版本说明,默认同「版本 v<版本号>」",
)
p_st = sub.add_parser("audit-status", help="查询最近一次审核状态(需 WECHAT_APPID/SECRET")
p_cat = sub.add_parser("get-category", help="拉取已配置服务类目 JSON构造提审项用")
p_sa = sub.add_parser(
"submit-audit",
help="尝试调用 submit_audit自有主体常会 86000需改 mp 后台手动提审)",
)
p_sa.add_argument("--version-desc", default="版本更新", help="审核说明 version_desc")
p_sa.add_argument(
"--item-json",
type=Path,
help="自定义 item_list 的 JSON 文件(含 item_list 数组)",
)
p_sa.add_argument(
"--privacy-api-not-use",
action=argparse.BooleanOptionalAction,
default=None,
help="是否声明未使用检测到的隐私接口(与微信报错 61040 相关时再用)",
)
sub.add_parser("open-mp", help="在浏览器打开 mp 首页")
sub.add_parser(
"open-version",
help="打开版本管理页(开发版本:选体验版、提交审核;需已登录 mp",
)
p_uo = sub.add_parser(
"upload-open",
help="上传代码并打开版本管理页(设体验版、提审在浏览器完成)",
)
p_uo.add_argument(
"--version",
"-v",
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"),
)
p_uo.add_argument("--desc", "-d", default="", help="默认:版本 v<版本号>")
p_rel = sub.add_parser(
"release",
help="上传 → 尝试 submit_audit默认不弹浏览器、不提示手动打开可加 --open-browser",
)
p_rel.add_argument(
"--version",
"-v",
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"),
)
p_rel.add_argument("--desc", "-d", default="", help="上传说明,默认:版本 v<版本号>")
p_rel.add_argument("--version-desc", default="", help="提交审核说明,默认同上传说明")
p_rel.add_argument("--item-json", type=Path)
p_rel.add_argument(
"--privacy-api-not-use",
action=argparse.BooleanOptionalAction,
default=None,
)
p_rel.add_argument(
"--open-browser",
action="store_true",
help="完成后打开公众平台版本管理页(默认关闭)",
)
args = p.parse_args()
if args.cmd in (
"audit-status",
"get-category",
"submit-audit",
"release",
):
ensure_wechat_env_from_soul_api()
appid = os.environ.get("WECHAT_APPID", "").strip()
secret = os.environ.get("WECHAT_APPSECRET", "").strip()
if args.cmd == "upload":
desc = args.desc.strip() or f"版本 v{args.version}"
cmd_upload(args.version, desc)
return
if args.cmd == "open-mp":
cmd_open_mp()
return
if args.cmd == "open-version":
cmd_open_mp_version()
return
if args.cmd == "upload-open":
d = args.desc.strip() or f"版本 v{args.version}"
cmd_upload_open(args.version, d)
return
if args.cmd == "release":
d = args.desc.strip() or f"版本 v{args.version}"
cmd_upload(args.version, d)
if appid and secret:
vd = (args.version_desc or "").strip() or d
cmd_submit_audit(
appid,
secret,
vd,
args.item_json,
args.privacy_api_not_use,
quiet=True,
)
if getattr(args, "open_browser", False):
cmd_open_mp_version()
return
if not appid or not secret:
raise SystemExit("请设置环境变量 WECHAT_APPID、WECHAT_APPSECRET与 soul-api 一致即可)")
if args.cmd == "audit-status":
cmd_audit_status(appid, secret)
elif args.cmd == "get-category":
cmd_get_category(appid, secret)
elif args.cmd == "submit-audit":
cmd_submit_audit(
appid, secret, args.version_desc, args.item_json, args.privacy_api_not_use
)
if __name__ == "__main__":
main()