chore: 清理敏感与开发文档,仅同步代码
- 永久忽略并从仓库移除 开发文档/ - 移除并忽略 .env 与小程序私有配置 - 同步小程序/管理端/API与脚本改动 Made-with: Cursor
This commit is contained in:
75
scripts/fix_duplicate_title_upload.py
Normal file
75
scripts/fix_duplicate_title_upload.py
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""批量去掉重复标题并重新上传到小程序。content_upload 已内置 strip 首行 # 标题。"""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
BASE_2026 = Path("/Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/2026每日派对干货")
|
||||
BASE_9 = Path("/Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例")
|
||||
|
||||
# (id, title, md_file_path relative to BASE_2026 or "9" for BASE_9)
|
||||
CHAPTERS_2026 = [
|
||||
("10.01", "第102场|今年第一个红包你发给谁", BASE_2026 / "第102场|今年第一个红包你发给谁.md"),
|
||||
("10.02", "第103场|号商、某客与炸房", BASE_2026 / "第103场|号商、某客与炸房.md"),
|
||||
("10.03", "第105场|创业社群、直播带货与程序员", BASE_2026 / "第105场|创业社群、直播带货与程序员.md"),
|
||||
("10.04", "第104场|婚恋、AI客服与一个微信", BASE_2026 / "第104场|婚恋、AI客服与一个微信.md"),
|
||||
("10.05", "第107场|性格、陪伴经济与本地AI", BASE_2026 / "第107场|性格、陪伴经济与本地AI.md"),
|
||||
("10.06", "第108场|Soul场观400等于抖音1万", BASE_2026 / "第108场|Soul场观400等于抖音1万.md"),
|
||||
("10.07", "第111场|平台规则变了怎么办", BASE_2026 / "第111场|平台规则变了怎么办.md"),
|
||||
("10.08", "第110场|Soul变现逻辑全程公开", BASE_2026 / "第110场|Soul变现逻辑全程公开.md"),
|
||||
("10.09", "第112场|一个人起头,维权挣了大半套房", BASE_2026 / "第112场|一个人起头,维权挣了大半套房.md"),
|
||||
("10.10", "第113场|不会选择怎么办?", BASE_2026 / "第113场|不会选择怎么办?.md"),
|
||||
("10.11", "第114场|人跟人差别,以前没 AI 差 100 倍,有 AI 差 1 万倍。", BASE_2026 / "第114场-人跟人差别,以前没 AI 差 100 倍,有 AI 差 1 万倍。.md"),
|
||||
("10.12", "第115场|一天改变,可控的事先做", BASE_2026 / "第115场|一天改变,可控的事先做.md"),
|
||||
("10.13", "第116场|钱是大风刮来的,怎么抓住?", BASE_2026 / "第116场|钱是大风刮来的,怎么抓住?.md"),
|
||||
("10.14", "第117场|流水百万挣八千,你还干不干?", BASE_2026 / "第117场|流水百万挣八千,你还干不干?.md"),
|
||||
("10.15", "第118场|运气是选出来的,不是等出来的", BASE_2026 / "第118场|运气是选出来的,不是等出来的.md"),
|
||||
("10.16", "第119场|开派对的初心是早上不影响老婆睡觉", BASE_2026 / "第119场|开派对的初心是早上不影响老婆睡觉.md"),
|
||||
("10.17", "第120场|发视频就有钱,这才是最低门槛的AI副业", BASE_2026 / "第120场|发视频就有钱,这才是最低门槛的AI副业.md"),
|
||||
]
|
||||
|
||||
CHAPTERS_9 = [
|
||||
("9.15", "派对副业|做切片分发和副业分发的具体步骤与收益", BASE_9 / "9.15 派对副业.md"),
|
||||
("9.16", "如何开Soul派对|房主避坑、流量、变现与封号", BASE_9 / "9.16 如何开Soul派对|房主避坑、流量、变现与封号.md"),
|
||||
]
|
||||
|
||||
|
||||
def run_upload(section_id: str, title: str, content_file: Path, part: str, chapter: str) -> bool:
|
||||
if not content_file.exists():
|
||||
print(f" 跳过 {section_id}: 文件不存在 {content_file}")
|
||||
return False
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(PROJECT_ROOT / "content_upload.py"),
|
||||
"--id", section_id,
|
||||
"--title", title,
|
||||
"--content-file", str(content_file),
|
||||
"--part", part,
|
||||
"--chapter", chapter,
|
||||
"--price", "1.0",
|
||||
]
|
||||
r = subprocess.run(cmd, cwd=str(PROJECT_ROOT))
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
def main():
|
||||
ok, fail = 0, 0
|
||||
for sid, title, fpath in CHAPTERS_2026:
|
||||
print(f"上传 {sid} {title[:30]}...")
|
||||
if run_upload(sid, title, fpath, "part-2026-daily", "chapter-2026-daily"):
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
for sid, title, fpath in CHAPTERS_9:
|
||||
print(f"上传 {sid} {title[:30]}...")
|
||||
if run_upload(sid, title, fpath, "part-4", "chapter-9"):
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
print(f"\n完成: 成功 {ok}, 失败 {fail}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
151
scripts/migrate_2026_sections.py
Normal file
151
scripts/migrate_2026_sections.py
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
将第102场及以后的派对场次迁移到「2026每日派对干货」目录
|
||||
|
||||
用法:
|
||||
python3 scripts/migrate_2026_sections.py # 仅预览,不执行
|
||||
python3 scripts/migrate_2026_sections.py --execute # 执行迁移
|
||||
|
||||
迁移规则:
|
||||
- 从章节中筛选 section_title 包含「第102场」「第103场」... 的条目
|
||||
- 按场次号排序,依次赋 id 10.01, 10.02, 10.03, ...
|
||||
- 更新 part_id=part-2026-daily, part_title=2026每日派对干货
|
||||
- 更新 chapter_id=chapter-2026-daily, chapter_title=2026每日派对干货
|
||||
|
||||
依赖: pip install pymysql
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根目录
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError:
|
||||
print("需要安装 pymysql: pip3 install pymysql")
|
||||
sys.exit(1)
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": "56b4c23f6853c.gz.cdb.myqcloud.com",
|
||||
"port": 14413,
|
||||
"user": "cdb_outerroot",
|
||||
"password": "Zhiqun1984",
|
||||
"database": "soul_miniprogram",
|
||||
"charset": "utf8mb4",
|
||||
}
|
||||
|
||||
PART_2026 = "part-2026-daily"
|
||||
CHAPTER_2026 = "chapter-2026-daily"
|
||||
TITLE_2026 = "2026每日派对干货"
|
||||
|
||||
|
||||
def extract_session_num(section_title: str) -> int | None:
|
||||
"""从 section_title 解析场次号,如 第102场 -> 102"""
|
||||
m = re.search(r"第(\d+)场", section_title)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def get_connection():
|
||||
return pymysql.connect(**DB_CONFIG)
|
||||
|
||||
|
||||
def get_max_10_section(cur) -> int:
|
||||
"""获取当前 10.xx 最大序号"""
|
||||
cur.execute(
|
||||
"SELECT id FROM chapters WHERE id REGEXP '^10\\.[0-9]+$' ORDER BY CAST(SUBSTRING_INDEX(id, '.', -1) AS UNSIGNED) DESC LIMIT 1"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return int(row[0].split(".")[-1])
|
||||
return 0
|
||||
|
||||
|
||||
def find_sections_to_migrate(cur) -> list[tuple]:
|
||||
"""查找需要迁移的章节:第102场及以后,且不在 part-2026-daily 的"""
|
||||
cur.execute("""
|
||||
SELECT id, section_title, part_id, chapter_id, sort_order
|
||||
FROM chapters
|
||||
WHERE section_title REGEXP '第[0-9]+场'
|
||||
ORDER BY sort_order, id
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
to_migrate = []
|
||||
for row in rows:
|
||||
sid, title, part_id, ch_id, order = row
|
||||
num = extract_session_num(title)
|
||||
if num is not None and num >= 102 and part_id != PART_2026:
|
||||
to_migrate.append((sid, title, part_id, ch_id, order, num))
|
||||
to_migrate.sort(key=lambda x: (x[5], x[4])) # 按场次号、sort_order
|
||||
return to_migrate
|
||||
|
||||
|
||||
def run(dry_run: bool):
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
rows = find_sections_to_migrate(cur)
|
||||
max_10 = get_max_10_section(cur)
|
||||
conn.close()
|
||||
|
||||
if not rows:
|
||||
print("未找到需要迁移的章节(第102场及以后、且不在 2026每日派对干货 中)")
|
||||
return
|
||||
|
||||
print(f"当前 10.xx 最大序号: {max_10}")
|
||||
print(f"找到 {len(rows)} 节待迁移到「2026每日派对干货」:\n")
|
||||
plan = []
|
||||
for i, (old_id, title, part_id, ch_id, order, num) in enumerate(rows, 1):
|
||||
new_id = f"10.{max_10 + i:02d}"
|
||||
plan.append((old_id, new_id, title, part_id))
|
||||
print(f" {old_id} -> {new_id} {title}")
|
||||
|
||||
if dry_run:
|
||||
print("\n[预览模式] 未执行写入,使用 --execute 执行迁移")
|
||||
return
|
||||
|
||||
print("\n执行迁移...")
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# 先全部改为临时 id(避免与已有 10.xx 冲突)
|
||||
for i, (old_id, new_id, title, part_id) in enumerate(plan, 1):
|
||||
tmp_id = f"tmp-migrate-{old_id.replace('.', '-')}"
|
||||
cur.execute(
|
||||
"UPDATE chapters SET id = %s WHERE id = %s",
|
||||
(tmp_id, old_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# 再改为最终 id 并更新 part/chapter
|
||||
for i, (old_id, new_id, title, part_id) in enumerate(plan, 1):
|
||||
tmp_id = f"tmp-migrate-{old_id.replace('.', '-')}"
|
||||
cur.execute("""
|
||||
UPDATE chapters SET
|
||||
id = %s, part_id = %s, part_title = %s,
|
||||
chapter_id = %s, chapter_title = %s
|
||||
WHERE id = %s
|
||||
""", (new_id, PART_2026, TITLE_2026, CHAPTER_2026, TITLE_2026, tmp_id))
|
||||
conn.commit()
|
||||
print(f"已迁移 {len(plan)} 节到 part-2026-daily,id 为 10.01 ~ 10.{len(plan):02d}")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"迁移失败: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="将第102场及以后的场次迁移到 2026每日派对干货")
|
||||
parser.add_argument("--execute", action="store_true", help="执行迁移(默认仅预览)")
|
||||
args = parser.parse_args()
|
||||
run(dry_run=not args.execute)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -13,7 +13,7 @@ from urllib.request import Request, urlopen
|
||||
|
||||
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/8b7f996e-2892-4075-989f-aa5593ea4fbc"
|
||||
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"
|
||||
MATERIAL_HINT = "材料找卡若AI拿"
|
||||
|
||||
@@ -31,13 +31,13 @@ except ImportError:
|
||||
# 与 post_to_feishu 保持一致
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
# 默认发到 Soul 彩民团队飞书群
|
||||
WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/14a7e0d3-864d-4709-ad40-0def6edba566"
|
||||
BACKEND_QRCODE_URL = "https://soul.quwanzhi.com/api/miniprogram/qrcode"
|
||||
WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/34b762fc-5b9b-4abb-a05a-96c8fb9599f1"
|
||||
BACKEND_QRCODE_URL = "https://soulapi.quwanzhi.com/api/miniprogram/qrcode"
|
||||
MINIPROGRAM_READ_BASE = "https://soul.quwanzhi.com/read"
|
||||
|
||||
# 海报尺寸(与小程序内一致,便于统一风格)
|
||||
POSTER_W = 300
|
||||
POSTER_H = 450
|
||||
# 海报尺寸(2x 放大,提升二维码识别率)
|
||||
POSTER_W = 600
|
||||
POSTER_H = 900
|
||||
# 高级感配色:深蓝渐变 + 青金点缀
|
||||
COLOR_BG_TOP = (26, 26, 46) # #1a1a2e
|
||||
COLOR_BG_BOTTOM = (22, 33, 62) # #16213e
|
||||
@@ -167,11 +167,10 @@ def _excerpt_to_feishu_text(excerpt: str) -> str:
|
||||
|
||||
|
||||
def build_poster(title: str, excerpt: str, qrcode_bytes: bytes) -> bytes:
|
||||
"""绘制海报图(PNG bytes)。标题=章节标题,样式与小程序内一致并略提升质感。"""
|
||||
"""绘制海报图(PNG bytes)。600x900 大图确保二维码可扫。"""
|
||||
img = Image.new("RGB", (POSTER_W, POSTER_H), COLOR_BG_TOP)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 背景渐变(从上到下)
|
||||
for y in range(POSTER_H):
|
||||
t = y / POSTER_H
|
||||
r = int(COLOR_BG_TOP[0] * (1 - t) + COLOR_BG_BOTTOM[0] * t)
|
||||
@@ -179,37 +178,31 @@ def build_poster(title: str, excerpt: str, qrcode_bytes: bytes) -> bytes:
|
||||
b = int(COLOR_BG_TOP[2] * (1 - t) + COLOR_BG_BOTTOM[2] * t)
|
||||
draw.line([(0, y), (POSTER_W, y)], fill=(r, g, b))
|
||||
|
||||
# 顶部装饰条(加高一点更醒目)
|
||||
draw.rectangle([0, 0, POSTER_W, 5], fill=COLOR_ACCENT)
|
||||
draw.rectangle([0, 0, POSTER_W, 8], fill=COLOR_ACCENT)
|
||||
|
||||
# 小品牌标识(可选,弱化)
|
||||
font_small = _font(11)
|
||||
draw.text((20, 22), "Soul创业派对", fill=(150, 160, 180), font=font_small)
|
||||
font_small = _font(20)
|
||||
draw.text((40, 40), "Soul创业派对", fill=(150, 160, 180), font=font_small)
|
||||
|
||||
# 主标题:章节标题(字体加粗、略大,更易读)
|
||||
font_title = _font(19, bold=True)
|
||||
title_lines = _wrap_text(draw, title, font_title, POSTER_W - 40)
|
||||
y = 50
|
||||
for line in title_lines[:2]:
|
||||
draw.text((20, y), line, fill=COLOR_TITLE, font=font_title)
|
||||
y += 26
|
||||
font_title = _font(36, bold=True)
|
||||
title_lines = _wrap_text(draw, title, font_title, POSTER_W - 80)
|
||||
y = 90
|
||||
for line in title_lines[:3]:
|
||||
draw.text((40, y), line, fill=COLOR_TITLE, font=font_title)
|
||||
y += 48
|
||||
|
||||
# 分隔线
|
||||
y += 8
|
||||
draw.line([(20, y), (POSTER_W - 20, y)], fill=(100, 110, 130), width=1)
|
||||
y += 12
|
||||
draw.line([(40, y), (POSTER_W - 40, y)], fill=(100, 110, 130), width=1)
|
||||
|
||||
# 摘要(严格限制在摘要框内;每句空一行,避免挤/溢出)
|
||||
y += 28
|
||||
font_summary = _font(13)
|
||||
y += 36
|
||||
font_summary = _font(24)
|
||||
excerpt = (excerpt or "扫码阅读全文").strip()
|
||||
# 摘要框(增加边界感,但不抢视觉)
|
||||
bottom_h = 100
|
||||
box_x1, box_y1 = 16, y - 6
|
||||
box_x2, box_y2 = POSTER_W - 16, POSTER_H - bottom_h - 14
|
||||
bottom_h = 200
|
||||
box_x1, box_y1 = 32, y - 10
|
||||
box_x2, box_y2 = POSTER_W - 32, POSTER_H - bottom_h - 20
|
||||
try:
|
||||
draw.rounded_rectangle(
|
||||
[box_x1, box_y1, box_x2, box_y2],
|
||||
radius=10,
|
||||
radius=16,
|
||||
fill=(18, 26, 46),
|
||||
outline=(40, 55, 80),
|
||||
width=1,
|
||||
@@ -222,10 +215,10 @@ def build_poster(title: str, excerpt: str, qrcode_bytes: bytes) -> bytes:
|
||||
width=1,
|
||||
)
|
||||
|
||||
text_x = 20
|
||||
text_y = y
|
||||
max_w = POSTER_W - 40
|
||||
line_h = 18 # 12px 字体更舒服的行高
|
||||
text_x = 44
|
||||
text_y = y + 4
|
||||
max_w = POSTER_W - 88
|
||||
line_h = 34
|
||||
max_lines = max(1, int((box_y2 - text_y) / line_h))
|
||||
summary_lines = _excerpt_to_poster_lines(draw, excerpt, font_summary, max_w, max_lines)
|
||||
for line in summary_lines:
|
||||
@@ -233,26 +226,23 @@ def build_poster(title: str, excerpt: str, qrcode_bytes: bytes) -> bytes:
|
||||
draw.text((text_x, text_y), line, fill=COLOR_SUMMARY, font=font_summary)
|
||||
text_y += line_h
|
||||
|
||||
# 底部区域背景(深色块)
|
||||
draw.rectangle([0, POSTER_H - bottom_h, POSTER_W, POSTER_H], fill=COLOR_BOTTOM_BG)
|
||||
|
||||
# 底部文字
|
||||
font_hint = _font(13)
|
||||
draw.text((20, POSTER_H - 62), "长按识别小程序码", fill=COLOR_TITLE, font=font_hint)
|
||||
font_hint2 = _font(11)
|
||||
draw.text((20, POSTER_H - 40), "长按小程序码阅读全文", fill=COLOR_HINT, font=font_hint2)
|
||||
font_hint = _font(24)
|
||||
draw.text((40, POSTER_H - 130), "长按识别小程序码", fill=COLOR_TITLE, font=font_hint)
|
||||
font_hint2 = _font(20)
|
||||
draw.text((40, POSTER_H - 90), "长按小程序码阅读全文", fill=COLOR_HINT, font=font_hint2)
|
||||
|
||||
# 二维码(右下角,略留白;不放置手指图标)
|
||||
qr_size = 72
|
||||
qr_x = POSTER_W - 20 - qr_size
|
||||
qr_y = POSTER_H - 20 - qr_size
|
||||
qr_size = 160
|
||||
qr_x = POSTER_W - 40 - qr_size
|
||||
qr_y = POSTER_H - 30 - qr_size
|
||||
try:
|
||||
qr_img = Image.open(io.BytesIO(qrcode_bytes)).convert("RGB")
|
||||
qr_img = qr_img.resize((qr_size, qr_size), Image.Resampling.LANCZOS)
|
||||
img.paste(qr_img, (qr_x, qr_y))
|
||||
except Exception:
|
||||
draw.rectangle([qr_x, qr_y, qr_x + qr_size, qr_y + qr_size], outline=COLOR_ACCENT, width=2)
|
||||
draw.text((qr_x + 18, qr_y + 26), "扫码", fill=COLOR_ACCENT, font=_font(12))
|
||||
draw.text((qr_x + 40, qr_y + 60), "扫码", fill=COLOR_ACCENT, font=_font(24))
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
@@ -284,7 +274,7 @@ def fetch_qrcode_image(section_id: str) -> bytes | None:
|
||||
scene = f"id={section_id}"
|
||||
payload = {"scene": scene, "page": "pages/read/read", "width": 280}
|
||||
try:
|
||||
r = requests.post(BACKEND_QRCODE_URL, json=payload, timeout=15)
|
||||
r = requests.post(BACKEND_QRCODE_URL, json=payload, timeout=15, verify=False)
|
||||
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else None
|
||||
if body and body.get("success") and body.get("image"):
|
||||
raw = body["image"]
|
||||
@@ -371,8 +361,14 @@ def main():
|
||||
|
||||
qrcode_bytes = fetch_qrcode_image(section_id)
|
||||
if not qrcode_bytes:
|
||||
print("无法获取小程序码,请确认后端 soul.quwanzhi.com 可访问且已配置小程序码接口")
|
||||
sys.exit(1)
|
||||
print("⚠️ 小程序码获取失败,使用占位图继续生成海报")
|
||||
# 生成一个简单的占位 PNG(白底+提示文字)
|
||||
_ph = Image.new("RGB", (280, 280), (240, 240, 240))
|
||||
_d = ImageDraw.Draw(_ph)
|
||||
_d.text((60, 120), "扫码阅读全文", fill=(100, 100, 100), font=_font(18))
|
||||
_buf = io.BytesIO()
|
||||
_ph.save(_buf, format="PNG")
|
||||
qrcode_bytes = _buf.getvalue()
|
||||
|
||||
poster_bytes = build_poster(title, excerpt, qrcode_bytes)
|
||||
|
||||
|
||||
18
scripts/test/.env.test.example
Normal file
18
scripts/test/.env.test.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# 测试环境配置示例。复制为 .env.test 后按需修改。
|
||||
# 运行 pytest 前必须明确指定测试环境,避免误测正式库。
|
||||
|
||||
# 测试环境(必填其一)
|
||||
# local = 本地 http://localhost:8080
|
||||
# souldev = 测试 https://souldev.quwanzhi.com
|
||||
# soulapi = 正式 https://soulapi.quwanzhi.com(慎用)
|
||||
SOUL_TEST_ENV=local
|
||||
|
||||
# 或直接指定 API 地址(覆盖 SOUL_TEST_ENV)
|
||||
# SOUL_API_BASE=http://localhost:8080
|
||||
|
||||
# 管理端登录(默认 admin/admin123)
|
||||
# SOUL_ADMIN_USERNAME=admin
|
||||
# SOUL_ADMIN_PASSWORD=admin123
|
||||
|
||||
# 小程序开发登录 userId(仅 APP_ENV=development 时可用)
|
||||
# SOUL_MINIPROGRAM_DEV_USER_ID=ogpTW5fmXRGNpoUbXB3UEqnVe5Tg
|
||||
4
scripts/test/.gitignore
vendored
Normal file
4
scripts/test/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.env.test
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
63
scripts/test/README.md
Normal file
63
scripts/test/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Soul 创业派对 - 测试用例目录
|
||||
|
||||
> 测试工程师在此编写与维护测试用例。使用 pytest + requests 架构。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
| 子目录 | 用途 | 对应端 | API 路径 |
|
||||
|--------|------|--------|----------|
|
||||
| **miniapp/** | 小程序接口测试 | miniprogram | /api/miniprogram/* |
|
||||
| **web/** | 管理端测试 | soul-admin | /api/admin/*、/api/db/* |
|
||||
| **process/** | 流程测试 | 跨端 | 多接口串联 |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cd scripts/test
|
||||
pip install -r requirements-test.txt
|
||||
pytest -v
|
||||
```
|
||||
|
||||
联调前请先执行 `scripts/本地启动.sh` 启动 soul-api 与 soul-admin。
|
||||
|
||||
---
|
||||
|
||||
## 环境变量(必须明确指定测试环境)
|
||||
|
||||
| 变量 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| **SOUL_TEST_ENV** | 测试环境 | local / souldev / soulapi |
|
||||
| **SOUL_API_BASE** | 或直接指定 API 地址 | http://localhost:8080 |
|
||||
| SOUL_ADMIN_USERNAME | 管理端账号 | admin |
|
||||
| SOUL_ADMIN_PASSWORD | 管理端密码 | admin123 |
|
||||
| SOUL_MINIPROGRAM_DEV_USER_ID | 小程序开发登录 userId | 空(需 APP_ENV=development) |
|
||||
|
||||
可复制 `.env.test.example` 为 `.env.test` 配置(`.env.test` 含账号等,勿提交)。
|
||||
|
||||
**运行前会在报告头部显示「测试环境: xxx」**,避免误测正式库。
|
||||
|
||||
---
|
||||
|
||||
## 运行方式
|
||||
|
||||
```bash
|
||||
pytest miniapp/ -v # 只跑小程序
|
||||
pytest web/ -v # 只跑管理端
|
||||
pytest process/ -v # 只跑流程
|
||||
pytest -v # 全量
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| config.py | 配置(API_BASE、登录账号等) |
|
||||
| conftest.py | 共享 fixtures(base_url、admin_token、miniapp_token) |
|
||||
| util.py | 工具函数(admin_headers、miniapp_headers) |
|
||||
| requirements-test.txt | pytest、requests |
|
||||
122
scripts/test/check-catalog-api.py
Normal file
122
scripts/test/check-catalog-api.py
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
目录接口诊断脚本 - 检查正式环境 soulapi 的 /api/miniprogram/book/* 是否正常
|
||||
用法:
|
||||
python scripts/test/check-catalog-api.py
|
||||
SOUL_TEST_ENV=soulapi python scripts/test/check-catalog-api.py
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 加载测试配置
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from config import API_BASE, ENV_LABEL, get_env_banner
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
_urlopen = urllib.request.urlopen
|
||||
_Request = urllib.request.Request
|
||||
_HTTPError = urllib.error.HTTPError
|
||||
_URLError = urllib.error.URLError
|
||||
except ImportError:
|
||||
import urllib2
|
||||
_urlopen = urllib2.urlopen
|
||||
_Request = urllib2.Request
|
||||
_HTTPError = urllib2.HTTPError
|
||||
_URLError = urllib2.URLError
|
||||
|
||||
def fetch(url, timeout=10):
|
||||
"""GET 请求,返回 (parsed_json, status_code, error_msg)"""
|
||||
try:
|
||||
req = _Request(url)
|
||||
req.get_method = lambda: "GET"
|
||||
req.add_header("Content-Type", "application/json")
|
||||
resp = _urlopen(req, timeout=timeout)
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
code = getattr(resp, "status", resp.getcode() if hasattr(resp, "getcode") else 200)
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return None, code, "非 JSON 响应: " + body[:200]
|
||||
return data, code, None
|
||||
except _HTTPError as e:
|
||||
try:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
data = json.loads(body) if body else {}
|
||||
except Exception:
|
||||
data = {}
|
||||
return data, e.code, str(e)
|
||||
except _URLError as e:
|
||||
return None, None, str(e.reason) if hasattr(e, "reason") else str(e)
|
||||
except Exception as e:
|
||||
return None, None, str(e)
|
||||
|
||||
|
||||
def main():
|
||||
print(get_env_banner())
|
||||
base = API_BASE.rstrip("/")
|
||||
|
||||
endpoints = [
|
||||
("/api/miniprogram/book/parts", "目录-篇章列表(目录页主接口)"),
|
||||
("/api/miniprogram/book/all-chapters", "全书章节(app.loadBookData)"),
|
||||
("/health", "健康检查"),
|
||||
]
|
||||
|
||||
all_ok = True
|
||||
for path, desc in endpoints:
|
||||
url = base + path
|
||||
print(f"\n--- {desc} ---")
|
||||
print(f"URL: {url}")
|
||||
data, code, err = fetch(url)
|
||||
if err:
|
||||
print("[FAIL] 请求失败:", err)
|
||||
all_ok = False
|
||||
continue
|
||||
if code and code != 200:
|
||||
print("[FAIL] HTTP", code)
|
||||
if data:
|
||||
print(f" 响应: {json.dumps(data, ensure_ascii=False)[:300]}")
|
||||
all_ok = False
|
||||
continue
|
||||
if not data:
|
||||
print("[FAIL] 无响应体")
|
||||
all_ok = False
|
||||
continue
|
||||
|
||||
success = data.get("success")
|
||||
if path == "/health":
|
||||
status = data.get("status", "?")
|
||||
print("[OK] status=%s, version=%s" % (status, data.get("version", "?")))
|
||||
elif path == "/api/miniprogram/book/parts":
|
||||
parts = data.get("parts") or []
|
||||
total = data.get("totalSections", 0)
|
||||
fixed = data.get("fixedSections") or []
|
||||
print("[OK] success=%s, parts=%d, totalSections=%d, fixedSections=%d" % (success, len(parts), total, len(fixed)))
|
||||
if not parts and total == 0:
|
||||
print(" [WARN] 篇章为空! 请检查正式环境数据库 chapters 表")
|
||||
elif parts:
|
||||
print(" 首篇章: id=%s, title=%s" % (parts[0].get("id"), parts[0].get("title")))
|
||||
elif path == "/api/miniprogram/book/all-chapters":
|
||||
arr = data.get("data") or data.get("chapters") or []
|
||||
print("[OK] success=%s, data=%d 条" % (success, len(arr)))
|
||||
if not arr:
|
||||
print(" [WARN] 章节列表为空! 请检查 chapters 表")
|
||||
elif arr:
|
||||
first = arr[0] if isinstance(arr[0], dict) else {}
|
||||
print(" 首条: id=%s, partTitle=%s" % (first.get("id"), first.get("partTitle")))
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
if all_ok:
|
||||
print("[OK] 所有接口正常,若小程序仍无法加载,请检查:")
|
||||
print(" 1. 微信公众平台 → 服务器域名 → request 合法域名 是否包含 soulapi.quwanzhi.com")
|
||||
print(" 2. 正式版小程序 baseUrl 是否为 https://soulapi.quwanzhi.com")
|
||||
else:
|
||||
print("[FAIL] 存在异常,请根据上述输出排查后端或数据库")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
119
scripts/test/config.py
Normal file
119
scripts/test/config.py
Normal file
@@ -0,0 +1,119 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
测试配置。从项目 soul-api/.env* 或 scripts/test/.env.test 或环境变量读取。
|
||||
必须明确指定测试环境,避免误测正式库。
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根目录(scripts/test 的上级的上级)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
SOUL_API_ENV = PROJECT_ROOT / "soul-api"
|
||||
TEST_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _apply_env_file(path: Path) -> None:
|
||||
"""将 .env 文件中的变量加载到 os.environ(仅当未设置时)"""
|
||||
if not path.exists():
|
||||
return
|
||||
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k and k not in os.environ:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
# 优先加载 scripts/test/.env.test(本地覆盖)
|
||||
_apply_env_file(TEST_DIR / ".env.test")
|
||||
|
||||
# 环境与 API 地址映射(与 miniprogram/app.js、soul-api/.env* 一致)
|
||||
ENV_PROFILES = {
|
||||
"local": "http://localhost:8080",
|
||||
"souldev": "https://souldev.quwanzhi.com",
|
||||
"soulapi": "https://soulapi.quwanzhi.com",
|
||||
}
|
||||
|
||||
# 环境中文名(用于提示)
|
||||
ENV_LABELS = {
|
||||
"local": "本地",
|
||||
"souldev": "测试",
|
||||
"soulapi": "正式",
|
||||
}
|
||||
|
||||
|
||||
def _load_env_file(path: Path) -> dict:
|
||||
"""解析 .env 文件为 dict"""
|
||||
out = {}
|
||||
if not path.exists():
|
||||
return out
|
||||
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
out[k.strip()] = v.strip().strip('"').strip("'")
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_api_base() -> tuple[str, str]:
|
||||
"""
|
||||
解析 API 地址与当前环境标签。
|
||||
优先级:SOUL_TEST_ENV > SOUL_API_BASE > 从 soul-api/.env 读取 > 默认 local
|
||||
"""
|
||||
env_val = os.environ.get("SOUL_TEST_ENV", "").strip().lower()
|
||||
explicit_base = os.environ.get("SOUL_API_BASE", "").strip().rstrip("/")
|
||||
|
||||
if explicit_base:
|
||||
# 显式指定了地址,根据地址推断环境标签
|
||||
label = "自定义"
|
||||
for k, v in ENV_PROFILES.items():
|
||||
if v.rstrip("/") == explicit_base:
|
||||
label = ENV_LABELS.get(k, k)
|
||||
break
|
||||
return explicit_base, label
|
||||
|
||||
if env_val in ENV_PROFILES:
|
||||
return ENV_PROFILES[env_val], ENV_LABELS.get(env_val, env_val)
|
||||
|
||||
# 尝试从 soul-api/.env 读取 API_BASE_URL
|
||||
env_path = SOUL_API_ENV / ".env"
|
||||
env_dev = SOUL_API_ENV / ".env.development"
|
||||
env_prod = SOUL_API_ENV / ".env.production"
|
||||
for p in [env_path, env_dev, env_prod]:
|
||||
loaded = _load_env_file(p)
|
||||
if loaded.get("API_BASE_URL"):
|
||||
base = loaded["API_BASE_URL"].rstrip("/")
|
||||
for k, v in ENV_PROFILES.items():
|
||||
if v.rstrip("/") == base:
|
||||
return base, ENV_LABELS.get(k, k)
|
||||
return base, "项目配置"
|
||||
|
||||
# 默认本地,并提示未显式指定
|
||||
return ENV_PROFILES["local"], ENV_LABELS["local"]
|
||||
|
||||
|
||||
API_BASE, ENV_LABEL = _resolve_api_base()
|
||||
|
||||
# 管理端登录(与 scripts/本地启动.sh 一致;不同环境账号可能不同)
|
||||
ADMIN_USERNAME = os.environ.get("SOUL_ADMIN_USERNAME", "admin")
|
||||
ADMIN_PASSWORD = os.environ.get("SOUL_ADMIN_PASSWORD", "admin123")
|
||||
|
||||
# 小程序开发环境登录用 userId(仅 local/souldev 且 APP_ENV=development 时可用)
|
||||
MINIAPP_DEV_USER_ID = os.environ.get("SOUL_MINIPROGRAM_DEV_USER_ID", "")
|
||||
|
||||
|
||||
def get_env_banner() -> str:
|
||||
"""返回测试环境提示横幅"""
|
||||
return (
|
||||
"\n"
|
||||
"========================================\n"
|
||||
f" 测试环境: {ENV_LABEL} ({API_BASE})\n"
|
||||
"========================================\n"
|
||||
" 若需切换,请设置: SOUL_TEST_ENV=local|souldev|soulapi\n"
|
||||
" 或: SOUL_API_BASE=<完整 API 地址>\n"
|
||||
"========================================\n"
|
||||
)
|
||||
67
scripts/test/conftest.py
Normal file
67
scripts/test/conftest.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
共享 fixtures:base_url、admin_token、miniapp_token
|
||||
"""
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from config import (
|
||||
API_BASE,
|
||||
ADMIN_USERNAME,
|
||||
ADMIN_PASSWORD,
|
||||
MINIAPP_DEV_USER_ID,
|
||||
get_env_banner,
|
||||
)
|
||||
|
||||
|
||||
def pytest_report_header(config):
|
||||
"""pytest 报告头部显示测试环境,避免误测"""
|
||||
return get_env_banner().strip().split("\n")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def base_url():
|
||||
"""API 基础地址"""
|
||||
return API_BASE
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def admin_token(base_url):
|
||||
"""
|
||||
管理端 JWT。通过 POST /api/admin 登录获取。
|
||||
失败时返回空字符串,用例可 skip。
|
||||
"""
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{base_url}/api/admin",
|
||||
json={"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD},
|
||||
timeout=10,
|
||||
)
|
||||
data = r.json()
|
||||
if data.get("success") and data.get("token"):
|
||||
return data["token"]
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def miniapp_token(base_url):
|
||||
"""
|
||||
小程序 token。通过 POST /api/miniprogram/dev/login-as 获取(仅 APP_ENV=development)。
|
||||
若 MINIAPP_DEV_USER_ID 未配置或接口不可用,返回空字符串。
|
||||
"""
|
||||
if not MINIAPP_DEV_USER_ID:
|
||||
return ""
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{base_url}/api/miniprogram/dev/login-as",
|
||||
json={"userId": MINIAPP_DEV_USER_ID},
|
||||
timeout=10,
|
||||
)
|
||||
data = r.json()
|
||||
if data.get("success") and data.get("data", {}).get("token"):
|
||||
return data["data"]["token"]
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
19
scripts/test/miniapp/README.md
Normal file
19
scripts/test/miniapp/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 小程序接口测试 (miniapp)
|
||||
|
||||
> 小程序 C 端接口测试用例。对应 miniprogram,API 路径:`/api/miniprogram/*`
|
||||
|
||||
---
|
||||
|
||||
## 测试范围
|
||||
|
||||
- 登录(微信登录、手机号、token 持久化)
|
||||
- 购买与支付(下单、微信支付、回调、购买状态)
|
||||
- 推荐与分润(扫码/分享带 ref、绑定、分润计算)
|
||||
- VIP 功能(开通、资料、头像上传、排行展示)
|
||||
- 阅读(文章列表、详情、预览、全文)
|
||||
|
||||
---
|
||||
|
||||
## 用例编写
|
||||
|
||||
在此目录下新增 `.md` 或测试脚本,按场景组织用例。
|
||||
2
scripts/test/miniapp/conftest.py
Normal file
2
scripts/test/miniapp/conftest.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""miniapp 专用 fixtures,继承 scripts/test/conftest.py"""
|
||||
17
scripts/test/miniapp/test_config.py
Normal file
17
scripts/test/miniapp/test_config.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序公开接口测试。GET /api/miniprogram/config 无需鉴权。
|
||||
"""
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
def test_config_public(base_url):
|
||||
"""GET /api/miniprogram/config 返回配置"""
|
||||
r = requests.get(f"{base_url}/api/miniprogram/config", timeout=10)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("success") is True
|
||||
assert "prices" in data
|
||||
assert "features" in data
|
||||
assert "mpConfig" in data or "mp_config" in data
|
||||
28
scripts/test/miniapp/test_dev_login.py
Normal file
28
scripts/test/miniapp/test_dev_login.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序开发环境登录。POST /api/miniprogram/dev/login-as(仅 APP_ENV=development)
|
||||
需配置 SOUL_MINIPROGRAM_DEV_USER_ID 环境变量。
|
||||
"""
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from config import MINIAPP_DEV_USER_ID
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not MINIAPP_DEV_USER_ID,
|
||||
reason="SOUL_MINIPROGRAM_DEV_USER_ID 未配置,跳过开发登录测试",
|
||||
)
|
||||
def test_dev_login_as(base_url):
|
||||
"""开发环境按 userId 登录"""
|
||||
r = requests.post(
|
||||
f"{base_url}/api/miniprogram/dev/login-as",
|
||||
json={"userId": MINIAPP_DEV_USER_ID},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("success") is True
|
||||
assert "data" in data
|
||||
assert "token" in data["data"]
|
||||
assert "user" in data["data"]
|
||||
38
scripts/test/process/2026-03-16-文章@某人自动创建-测试报告.md
Normal file
38
scripts/test/process/2026-03-16-文章@某人自动创建-测试报告.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 测试报告 - 文章 @某人 自动创建存客宝获客计划
|
||||
|
||||
**日期**:2026-03-16
|
||||
**测试环境**:local (http://localhost:8080)
|
||||
**用例位置**:`scripts/test/process/test_article_mention_ckb_flow.py`
|
||||
|
||||
---
|
||||
|
||||
## 测试结论
|
||||
|
||||
| 用例 | 结果 | 说明 |
|
||||
|------|------|------|
|
||||
| test_person_ensure_creates_ckb_plan_when_not_exists | ❌ 失败 | 后端会调用存客宝创建计划,但存客宝 API 返回 400 |
|
||||
| test_person_ensure_returns_existing_when_name_exists | ❌ 失败 | 依赖上一条(需先创建成功) |
|
||||
| test_article_mention_flow_persons_list_contains_new | ❌ 失败 | 同上 |
|
||||
| test_person_ensure_rejects_empty_name | ✅ 通过 | name 为空时正确返回错误 |
|
||||
|
||||
---
|
||||
|
||||
## 失败原因
|
||||
|
||||
```
|
||||
error: 创建存客宝计划失败: 创建计划失败
|
||||
ckbResponse: {"code": 400, "data": [], "message": ""}
|
||||
```
|
||||
|
||||
后端逻辑正确:POST /api/db/persons 仅传 name 时,会按 name 查找,不存在则创建 Person 并调用存客宝创建获客计划。
|
||||
存客宝开放 API 返回 400,可能原因:
|
||||
- CKB_OPEN_API_KEY / CKB_OPEN_ACCOUNT 配置有误
|
||||
- deviceGroups 为空时存客宝不允许创建(需求文档有提及)
|
||||
- 存客宝 API 参数格式变更
|
||||
|
||||
---
|
||||
|
||||
## 建议
|
||||
|
||||
1. 后端:排查存客宝 create plan 400 原因,确认 deviceGroups 空时是否允许
|
||||
2. 测试:存客宝可连通后重新跑 `pytest process/test_article_mention_ckb_flow.py -v`
|
||||
20
scripts/test/process/README.md
Normal file
20
scripts/test/process/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 流程测试 (process)
|
||||
|
||||
> 跨端业务流程测试用例。验证多步骤、多接口串联的完整流程。
|
||||
|
||||
---
|
||||
|
||||
## 测试范围
|
||||
|
||||
- **下单→支付→回调→分润**:购买全链路
|
||||
- **推荐码绑定→访问记录→分润计算**:推广流程
|
||||
- **VIP 开通→资料填写→排行展示**:会员流程
|
||||
- **提现申请→审核→到账**:提现流程
|
||||
- **内容发布→审核→上架→用户可见**:内容流转
|
||||
- **文章 @某人 自动创建**:编辑文章输入 @新人物(不存在)→ 自动创建 Person + 存客宝获客计划(`test_article_mention_ckb_flow.py`)
|
||||
|
||||
---
|
||||
|
||||
## 用例编写
|
||||
|
||||
在此目录下新增 `.md` 或测试脚本,按业务流程组织用例。流程测试通常涉及 miniprogram + admin + API 多端联动。
|
||||
75
scripts/test/process/cleanup_test_data.py
Normal file
75
scripts/test/process/cleanup_test_data.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
清理流程测试产生的数据:persons(测试自动创建_、测试新人物_)、chapters(t 开头的 6 位数字 id)
|
||||
用法:cd scripts/test && python process/cleanup_test_data.py
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import requests
|
||||
|
||||
API_BASE = os.environ.get("SOUL_API_BASE", "http://localhost:8080").rstrip("/")
|
||||
ADMIN_USER = os.environ.get("SOUL_ADMIN_USERNAME", "admin")
|
||||
ADMIN_PASS = os.environ.get("SOUL_ADMIN_PASSWORD", "admin123")
|
||||
|
||||
|
||||
def main():
|
||||
r = requests.post(
|
||||
f"{API_BASE}/api/admin",
|
||||
json={"username": ADMIN_USER, "password": ADMIN_PASS},
|
||||
timeout=10,
|
||||
)
|
||||
if not r.json().get("success") or not r.json().get("token"):
|
||||
print("登录失败")
|
||||
return 1
|
||||
token = r.json()["token"]
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
# 1. 删除测试 Person
|
||||
rp = requests.get(f"{API_BASE}/api/db/persons", headers=headers, timeout=10)
|
||||
if rp.json().get("success"):
|
||||
persons = rp.json().get("persons") or []
|
||||
for p in persons:
|
||||
name = p.get("name") or ""
|
||||
if name.startswith("测试自动创建_") or name.startswith("测试新人物_"):
|
||||
pid = p.get("personId")
|
||||
rd = requests.delete(
|
||||
f"{API_BASE}/api/db/persons?personId={pid}",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if rd.json().get("success"):
|
||||
print(f" 已删除 Person: {name} ({pid})")
|
||||
else:
|
||||
print(f" 删除 Person 失败: {name} - {rd.json()}")
|
||||
|
||||
# 2. 删除测试 Chapter(id 形如 t123456)
|
||||
rl = requests.get(
|
||||
f"{API_BASE}/api/db/book?action=list",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if rl.json().get("success"):
|
||||
sections = rl.json().get("sections") or []
|
||||
for s in sections:
|
||||
sid = s.get("id") or ""
|
||||
if re.match(r"^t\d{6}$", sid):
|
||||
rd = requests.delete(
|
||||
f"{API_BASE}/api/db/book?id={sid}",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if rd.json().get("success"):
|
||||
print(f" 已删除 Chapter: {sid} ({s.get('title', '')})")
|
||||
else:
|
||||
print(f" 删除 Chapter 失败: {sid} - {rd.json()}")
|
||||
|
||||
print("清理完成")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
2
scripts/test/process/conftest.py
Normal file
2
scripts/test/process/conftest.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""process 专用 fixtures,继承 scripts/test/conftest.py"""
|
||||
218
scripts/test/process/test_article_mention_ckb_flow.py
Normal file
218
scripts/test/process/test_article_mention_ckb_flow.py
Normal file
@@ -0,0 +1,218 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
流程测试:文章编辑 @某人 不存在时自动创建 Person + 存客宝获客计划
|
||||
|
||||
需求来源:临时需求池/2026-03-16-文章编辑自动创建@和#.md
|
||||
验收:编辑文章输入 @新人物(链接人与事中无)→ 保存 → 链接人与事列表出现「新人物」,存客宝有对应计划
|
||||
|
||||
流程:管理端 ensureMentionsAndTags 对 content 中 @name 调用 POST /api/db/persons {name}
|
||||
→ 后端按 name 查找,不存在则创建 Person + 调用存客宝创建获客计划
|
||||
|
||||
前置条件:存客宝 API 可连通(CKB_OPEN_API_KEY 等配置正确),且存在名为 soul 的设备;否则创建新 Person 会失败
|
||||
"""
|
||||
import random
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from util import admin_headers
|
||||
|
||||
|
||||
def _unique_name():
|
||||
"""生成唯一名称,避免与已有 Person 冲突"""
|
||||
return f"测试自动创建_{int(time.time() * 1000)}"
|
||||
|
||||
|
||||
def test_person_ensure_creates_ckb_plan_when_not_exists(admin_token, base_url):
|
||||
"""
|
||||
@某人 不存在时:POST /api/db/persons 仅传 name → 应创建 Person 并自动创建存客宝获客计划
|
||||
"""
|
||||
if not admin_token:
|
||||
pytest.skip("admin 登录失败,跳过")
|
||||
name = _unique_name()
|
||||
r = requests.post(
|
||||
f"{base_url}/api/db/persons",
|
||||
headers=admin_headers(admin_token),
|
||||
json={"name": name},
|
||||
timeout=15,
|
||||
)
|
||||
assert r.status_code == 200, f"响应: {r.text}"
|
||||
data = r.json()
|
||||
assert data.get("success") is True, f"success 应为 true: {data}"
|
||||
person = data.get("person")
|
||||
assert person is not None, "应返回 person"
|
||||
assert person.get("name") == name
|
||||
assert person.get("personId"), "应有 personId"
|
||||
assert person.get("token"), "应有 token(小程序 @ 点击时兑换密钥)"
|
||||
# 存客宝获客计划应已创建
|
||||
ckb_plan_id = person.get("ckbPlanId") or 0
|
||||
assert ckb_plan_id > 0, f"应自动创建存客宝计划,ckbPlanId 应 > 0,实际: {ckb_plan_id}"
|
||||
assert person.get("ckbApiKey"), "应有 ckbApiKey"
|
||||
|
||||
|
||||
def test_person_ensure_returns_existing_when_name_exists(admin_token, base_url):
|
||||
"""
|
||||
@某人 已存在时:POST /api/db/persons 仅传 name → 应返回已有 Person,不重复创建
|
||||
"""
|
||||
if not admin_token:
|
||||
pytest.skip("admin 登录失败,跳过")
|
||||
name = _unique_name()
|
||||
# 第一次创建
|
||||
r1 = requests.post(
|
||||
f"{base_url}/api/db/persons",
|
||||
headers=admin_headers(admin_token),
|
||||
json={"name": name},
|
||||
timeout=15,
|
||||
)
|
||||
assert r1.status_code == 200 and r1.json().get("success")
|
||||
first_id = r1.json()["person"]["personId"]
|
||||
# 第二次相同 name,应返回已有
|
||||
r2 = requests.post(
|
||||
f"{base_url}/api/db/persons",
|
||||
headers=admin_headers(admin_token),
|
||||
json={"name": name},
|
||||
timeout=15,
|
||||
)
|
||||
assert r2.status_code == 200 and r2.json().get("success")
|
||||
second = r2.json()["person"]
|
||||
assert second["personId"] == first_id, "相同 name 应返回同一 Person"
|
||||
|
||||
|
||||
def test_person_ensure_rejects_empty_name(admin_token, base_url):
|
||||
"""name 为空时 POST /api/db/persons 应返回错误(不依赖存客宝)"""
|
||||
if not admin_token:
|
||||
pytest.skip("admin 登录失败,跳过")
|
||||
r = requests.post(
|
||||
f"{base_url}/api/db/persons",
|
||||
headers=admin_headers(admin_token),
|
||||
json={"name": ""},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("success") is False
|
||||
assert "name" in (data.get("error") or "").lower() or "必填" in (data.get("error") or "")
|
||||
|
||||
|
||||
def test_article_mention_flow_persons_list_contains_new(admin_token, base_url):
|
||||
"""
|
||||
流程:创建新 Person 后,GET /api/db/persons 列表应包含该人
|
||||
"""
|
||||
if not admin_token:
|
||||
pytest.skip("admin 登录失败,跳过")
|
||||
name = _unique_name()
|
||||
r_create = requests.post(
|
||||
f"{base_url}/api/db/persons",
|
||||
headers=admin_headers(admin_token),
|
||||
json={"name": name},
|
||||
timeout=15,
|
||||
)
|
||||
assert r_create.status_code == 200 and r_create.json().get("success")
|
||||
person_id = r_create.json()["person"]["personId"]
|
||||
# 拉列表
|
||||
r_list = requests.get(
|
||||
f"{base_url}/api/db/persons",
|
||||
headers=admin_headers(admin_token),
|
||||
timeout=10,
|
||||
)
|
||||
assert r_list.status_code == 200 and r_list.json().get("success")
|
||||
persons = r_list.json().get("persons") or []
|
||||
found = [p for p in persons if p.get("personId") == person_id]
|
||||
assert len(found) == 1, f"列表应包含新建的 Person {person_id}"
|
||||
assert found[0].get("ckbPlanId", 0) > 0, "列表中应有 ckbPlanId"
|
||||
|
||||
|
||||
def test_new_article_save_auto_creates_person_and_ckb(admin_token, base_url):
|
||||
"""
|
||||
完整流程:新建文章,content 含 @新人物 → 保存时 ensureMentionsAndTags 自动 POST persons
|
||||
→ 创建 Person + 存客宝获客计划 → 再 PUT book 保存文章
|
||||
"""
|
||||
if not admin_token:
|
||||
pytest.skip("admin 登录失败,跳过")
|
||||
ts = int(time.time() * 1000)
|
||||
rnd = random.randint(100000, 999999)
|
||||
name = f"测试新人物_{ts}_{rnd}"
|
||||
# chapters.id 限制 size:20,用短 id
|
||||
section_id = f"t{rnd}"
|
||||
|
||||
# 1. 获取 book 结构,取第一个 part/chapter
|
||||
r_list = requests.get(
|
||||
f"{base_url}/api/db/book?action=list",
|
||||
headers=admin_headers(admin_token),
|
||||
timeout=10,
|
||||
)
|
||||
assert r_list.status_code == 200 and r_list.json().get("success")
|
||||
sections = r_list.json().get("sections") or []
|
||||
part_id = "part-1"
|
||||
chapter_id = "chapter-1"
|
||||
part_title = "未分类"
|
||||
chapter_title = "未分类"
|
||||
if sections:
|
||||
first = sections[0]
|
||||
part_id = first.get("partId") or part_id
|
||||
chapter_id = first.get("chapterId") or chapter_id
|
||||
part_title = first.get("partTitle") or part_title
|
||||
chapter_title = first.get("chapterTitle") or chapter_title
|
||||
|
||||
# 2. 模拟 ensureMentionsAndTags:content 含 @name 时先 POST persons
|
||||
content = f"这是一篇测试文章,@{name} 会被自动创建并同步存客宝。"
|
||||
r_person = requests.post(
|
||||
f"{base_url}/api/db/persons",
|
||||
headers=admin_headers(admin_token),
|
||||
json={"name": name},
|
||||
timeout=15,
|
||||
)
|
||||
assert r_person.status_code == 200, f"创建 Person 失败: {r_person.text}"
|
||||
person_data = r_person.json()
|
||||
assert person_data.get("success") is True, f"Person 创建失败: {person_data}"
|
||||
person = person_data.get("person")
|
||||
assert person and person.get("ckbPlanId", 0) > 0, "应自动创建存客宝获客计划"
|
||||
|
||||
# 3. 新建文章(PUT /api/db/book)
|
||||
payload = {
|
||||
"id": section_id,
|
||||
"title": f"测试自动创建_{ts}",
|
||||
"content": content,
|
||||
"price": 1,
|
||||
"isFree": False,
|
||||
"partId": part_id,
|
||||
"partTitle": part_title,
|
||||
"chapterId": chapter_id,
|
||||
"chapterTitle": chapter_title,
|
||||
"editionStandard": True,
|
||||
"editionPremium": False,
|
||||
"isNew": False,
|
||||
"hotScore": 0,
|
||||
}
|
||||
r_put = requests.put(
|
||||
f"{base_url}/api/db/book",
|
||||
headers=admin_headers(admin_token),
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
assert r_put.status_code == 200, f"保存文章失败: {r_put.text}"
|
||||
put_data = r_put.json()
|
||||
assert put_data.get("success") is True, f"保存文章失败: {put_data}"
|
||||
|
||||
# 4. 验证 persons 列表包含新人物
|
||||
r_persons = requests.get(
|
||||
f"{base_url}/api/db/persons",
|
||||
headers=admin_headers(admin_token),
|
||||
timeout=10,
|
||||
)
|
||||
assert r_persons.status_code == 200
|
||||
persons = r_persons.json().get("persons") or []
|
||||
found = [p for p in persons if p.get("name") == name]
|
||||
assert len(found) == 1, f"链接人与事列表应包含「{name}」"
|
||||
assert found[0].get("ckbPlanId", 0) > 0, "应有存客宝获客计划"
|
||||
|
||||
# 5. 清理:删除测试文章(避免重复运行冲突)
|
||||
try:
|
||||
requests.delete(
|
||||
f"{base_url}/api/db/book?id={section_id}",
|
||||
headers=admin_headers(admin_token),
|
||||
timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
166
scripts/test/process/test_backfill_persons_ckb_api_key.py
Normal file
166
scripts/test/process/test_backfill_persons_ckb_api_key.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
流程测试:从存客宝获取所有计划的 apiKey,补齐本地 persons.ckb_api_key
|
||||
|
||||
场景:persons 表有 ckb_plan_id 但 ckb_api_key 为空时,调用存客宝 plan/detail 获取 apiKey 并更新本地
|
||||
|
||||
前置条件:
|
||||
- 测试环境(SOUL_TEST_ENV=souldev 或 local)
|
||||
- soul-api 可连通,CKB_OPEN_API_KEY、CKB_OPEN_ACCOUNT 已配置(soul-api/.env)
|
||||
"""
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from config import SOUL_API_ENV
|
||||
from util import admin_headers
|
||||
|
||||
CKB_OPEN_BASE = "https://ckbapi.quwanzhi.com"
|
||||
|
||||
|
||||
def _ckb_open_sign(account: str, ts: int, api_key: str) -> str:
|
||||
"""存客宝开放 API 签名:sign = MD5(MD5(account+timestamp) + apiKey)"""
|
||||
plain = account + str(ts)
|
||||
first = hashlib.md5(plain.encode()).hexdigest()
|
||||
return hashlib.md5((first + api_key).encode()).hexdigest()
|
||||
|
||||
|
||||
def _ckb_get_token(api_key: str, account: str) -> str:
|
||||
"""获取存客宝开放 API JWT"""
|
||||
ts = int(time.time())
|
||||
sign = _ckb_open_sign(account, ts, api_key)
|
||||
r = requests.post(
|
||||
f"{CKB_OPEN_BASE}/v1/open/auth/token",
|
||||
json={"apiKey": api_key, "account": account, "timestamp": ts, "sign": sign},
|
||||
timeout=15,
|
||||
)
|
||||
data = r.json()
|
||||
if data.get("code") != 200:
|
||||
raise RuntimeError(f"存客宝鉴权失败: {data.get('message', r.text)}")
|
||||
token = (data.get("data") or {}).get("token")
|
||||
if not token:
|
||||
raise RuntimeError("存客宝返回无 token")
|
||||
return token
|
||||
|
||||
|
||||
def _ckb_get_plan_api_key(token: str, plan_id: int) -> str:
|
||||
"""调用 plan/detail 获取计划级 apiKey"""
|
||||
r = requests.get(
|
||||
f"{CKB_OPEN_BASE}/v1/plan/detail",
|
||||
params={"planId": plan_id},
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
timeout=15,
|
||||
)
|
||||
data = r.json()
|
||||
if data.get("code") != 200:
|
||||
raise RuntimeError(f"获取计划详情失败 planId={plan_id}: {data.get('message', r.text)}")
|
||||
api_key = (data.get("data") or {}).get("apiKey")
|
||||
if not api_key:
|
||||
raise RuntimeError(f"计划 {plan_id} 详情中无 apiKey")
|
||||
return api_key
|
||||
|
||||
|
||||
def _load_ckb_config() -> tuple[str, str]:
|
||||
"""从 soul-api/.env 加载 CKB 配置"""
|
||||
def _parse_env(path):
|
||||
out = {}
|
||||
if not path.exists():
|
||||
return out
|
||||
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
out[k.strip()] = v.strip().strip('"').strip("'")
|
||||
return out
|
||||
|
||||
for name in [".env", ".env.development", ".env.production"]:
|
||||
env_path = SOUL_API_ENV / name
|
||||
loaded = _parse_env(env_path)
|
||||
api_key = (loaded.get("CKB_OPEN_API_KEY") or "").strip()
|
||||
account = (loaded.get("CKB_OPEN_ACCOUNT") or "").strip()
|
||||
if api_key and account:
|
||||
return api_key, account
|
||||
return "", ""
|
||||
|
||||
|
||||
def test_backfill_persons_ckb_api_key(admin_token, base_url):
|
||||
"""
|
||||
从存客宝获取所有计划的 apiKey,补齐本地 persons.ckb_api_key 为空的记录
|
||||
"""
|
||||
if not admin_token:
|
||||
pytest.skip("admin 登录失败,跳过")
|
||||
|
||||
ckb_api_key, ckb_account = _load_ckb_config()
|
||||
if not ckb_api_key or not ckb_account:
|
||||
pytest.skip("CKB_OPEN_API_KEY 或 CKB_OPEN_ACCOUNT 未配置,跳过")
|
||||
|
||||
# 1. 拉取 persons 列表
|
||||
r = requests.get(
|
||||
f"{base_url}/api/db/persons",
|
||||
headers=admin_headers(admin_token),
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 200, f"拉取 persons 失败: {r.text}"
|
||||
data = r.json()
|
||||
assert data.get("success") is True, f"拉取 persons 失败: {data}"
|
||||
persons = data.get("persons") or []
|
||||
|
||||
# 2. 筛选需要补全的:ckb_plan_id > 0 且 ckb_api_key 为空
|
||||
need_backfill = [
|
||||
p
|
||||
for p in persons
|
||||
if (p.get("ckbPlanId") or 0) > 0
|
||||
and not (p.get("ckbApiKey") or "").strip()
|
||||
]
|
||||
|
||||
if not need_backfill:
|
||||
pytest.skip("无需要补全 ckb_api_key 的 Person,跳过")
|
||||
|
||||
# 3. 获取存客宝 JWT
|
||||
ckb_token = _ckb_get_token(ckb_api_key, ckb_account)
|
||||
|
||||
# 4. 逐个补全
|
||||
updated = 0
|
||||
failed = []
|
||||
for p in need_backfill:
|
||||
plan_id = p.get("ckbPlanId") or 0
|
||||
person_id = p.get("personId") or ""
|
||||
name = p.get("name") or ""
|
||||
try:
|
||||
api_key = _ckb_get_plan_api_key(ckb_token, plan_id)
|
||||
except Exception as e:
|
||||
failed.append((name, str(e)))
|
||||
continue
|
||||
|
||||
# 5. 调用 soul-api 更新 Person(POST 带 personId 为更新,传完整字段避免覆盖)
|
||||
payload = {
|
||||
"personId": person_id,
|
||||
"name": name,
|
||||
"label": (p.get("label") or ""),
|
||||
"ckbApiKey": api_key,
|
||||
"greeting": (p.get("greeting") or ""),
|
||||
"tips": (p.get("tips") or ""),
|
||||
"remarkType": (p.get("remarkType") or ""),
|
||||
"remarkFormat": (p.get("remarkFormat") or ""),
|
||||
"startTime": (p.get("startTime") or "09:00"),
|
||||
"endTime": (p.get("endTime") or "18:00"),
|
||||
}
|
||||
if p.get("addFriendInterval"):
|
||||
payload["addFriendInterval"] = p["addFriendInterval"]
|
||||
r_update = requests.post(
|
||||
f"{base_url}/api/db/persons",
|
||||
headers=admin_headers(admin_token),
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
if r_update.status_code == 200 and r_update.json().get("success"):
|
||||
updated += 1
|
||||
else:
|
||||
failed.append((name, r_update.text or "更新失败"))
|
||||
|
||||
assert not failed, f"补全失败: {failed}"
|
||||
assert updated > 0, f"应至少补全 1 条,实际补全 {updated} 条"
|
||||
print(f"\n[backfill] 成功补全 {updated} 条 persons.ckb_api_key")
|
||||
11
scripts/test/process/test_health.py
Normal file
11
scripts/test/process/test_health.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
流程测试前置:健康检查。确保 soul-api 已启动。
|
||||
"""
|
||||
import requests
|
||||
|
||||
|
||||
def test_health(base_url):
|
||||
"""GET /health 健康检查"""
|
||||
r = requests.get(f"{base_url}/health", timeout=5)
|
||||
assert r.status_code == 200
|
||||
4
scripts/test/pytest.ini
Normal file
4
scripts/test/pytest.ini
Normal file
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = miniapp web process
|
||||
addopts = -v
|
||||
2
scripts/test/requirements-test.txt
Normal file
2
scripts/test/requirements-test.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
pytest>=7.0
|
||||
requests>=2.28
|
||||
12
scripts/test/util.py
Normal file
12
scripts/test/util.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""测试工具函数"""
|
||||
|
||||
|
||||
def admin_headers(token):
|
||||
"""管理端请求头"""
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def miniapp_headers(token):
|
||||
"""小程序请求头"""
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
19
scripts/test/web/README.md
Normal file
19
scripts/test/web/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 管理端测试 (web)
|
||||
|
||||
> 管理后台功能测试用例。对应 soul-admin,API 路径:`/api/admin/*`、`/api/db/*`
|
||||
|
||||
---
|
||||
|
||||
## 测试范围
|
||||
|
||||
- 内容管理(文章、章节、书籍 CRUD)
|
||||
- 用户管理
|
||||
- 订单、提现
|
||||
- VIP 角色、推广设置
|
||||
- 导师、导师预约、二维码、站点、支付配置
|
||||
|
||||
---
|
||||
|
||||
## 用例编写
|
||||
|
||||
在此目录下新增 `.md` 或测试脚本,按场景组织用例。
|
||||
2
scripts/test/web/conftest.py
Normal file
2
scripts/test/web/conftest.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""web 专用 fixtures,继承 scripts/test/conftest.py"""
|
||||
42
scripts/test/web/test_admin_auth.py
Normal file
42
scripts/test/web/test_admin_auth.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
管理端鉴权测试。POST /api/admin 登录,GET /api/admin 鉴权检查。
|
||||
"""
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from util import admin_headers
|
||||
|
||||
|
||||
def test_admin_login(base_url):
|
||||
"""POST /api/admin 登录成功"""
|
||||
r = requests.post(
|
||||
f"{base_url}/api/admin",
|
||||
json={"username": "admin", "password": "admin123"},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("success") is True
|
||||
assert "token" in data
|
||||
assert "user" in data
|
||||
|
||||
|
||||
def test_admin_check_with_token(admin_token, base_url):
|
||||
"""GET /api/admin 带 token 鉴权通过"""
|
||||
if not admin_token:
|
||||
pytest.skip("admin 登录失败,跳过鉴权测试")
|
||||
r = requests.get(
|
||||
f"{base_url}/api/admin",
|
||||
headers=admin_headers(admin_token),
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("success") is True
|
||||
|
||||
|
||||
def test_admin_check_without_token(base_url):
|
||||
"""GET /api/admin 无 token 返回 401"""
|
||||
r = requests.get(f"{base_url}/api/admin", timeout=10)
|
||||
assert r.status_code == 401
|
||||
121
scripts/test/web/test_upload.py
Normal file
121
scripts/test/web/test_upload.py
Normal file
@@ -0,0 +1,121 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
文件上传测试。POST /api/upload 上传图片,DELETE /api/upload 删除。
|
||||
验证:本地存储(OSS 未配置时)、响应格式、删除流程。
|
||||
"""
|
||||
import io
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
# 最小有效 JPEG(1x1 像素,约 100 字节)
|
||||
_MIN_JPEG = (
|
||||
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
|
||||
b"\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a\x1c\x1c"
|
||||
b" $.' \",#\x1c\x1c(7),01444\x1f'9=82<.342\xff\xc0\x00\x0b\x08\x00\x01\x00\x01\x01\x01\x11\x00\xff\xc4\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\xff\xda\x00\x08\x01\x01\x00\x00\x00?\x00\xfe\x02\x1f\xff\xd9"
|
||||
)
|
||||
|
||||
|
||||
def test_upload_post_success(base_url):
|
||||
"""POST /api/upload 上传图片成功,返回 url、fileName、size、type"""
|
||||
files = {"file": ("test_upload.jpg", io.BytesIO(_MIN_JPEG), "image/jpeg")}
|
||||
data = {"folder": "test"}
|
||||
r = requests.post(
|
||||
f"{base_url}/api/upload",
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 200, f"期望 200,实际 {r.status_code}: {r.text}"
|
||||
body = r.json()
|
||||
assert body.get("success") is True, body
|
||||
assert "url" in body, body
|
||||
assert body["url"], "url 不应为空"
|
||||
data_out = body.get("data", {})
|
||||
assert "url" in data_out
|
||||
assert "fileName" in data_out
|
||||
assert "size" in data_out
|
||||
assert data_out["size"] == len(_MIN_JPEG)
|
||||
assert "type" in data_out
|
||||
assert "image" in str(data_out.get("type", "")).lower()
|
||||
|
||||
|
||||
def test_upload_post_with_admin_token(base_url, admin_token):
|
||||
"""POST /api/upload 带管理端 token 也可上传(接口不强制鉴权)"""
|
||||
if not admin_token:
|
||||
pytest.skip("admin 登录失败")
|
||||
files = {"file": ("avatar.jpg", io.BytesIO(_MIN_JPEG), "image/jpeg")}
|
||||
# multipart 上传不设 Content-Type,让 requests 自动带 boundary
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
r = requests.post(
|
||||
f"{base_url}/api/upload",
|
||||
files=files,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body.get("success") is True
|
||||
assert body.get("url")
|
||||
|
||||
|
||||
def test_upload_post_no_file(base_url):
|
||||
"""POST /api/upload 无 file 返回 400"""
|
||||
r = requests.post(
|
||||
f"{base_url}/api/upload",
|
||||
data={"folder": "test"},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
body = r.json()
|
||||
assert body.get("success") is False
|
||||
assert "error" in body or "请选择" in body.get("error", "")
|
||||
|
||||
|
||||
def test_upload_post_invalid_type(base_url):
|
||||
"""POST /api/upload 非图片格式返回 400"""
|
||||
files = {"file": ("test.txt", io.BytesIO(b"hello"), "text/plain")}
|
||||
r = requests.post(
|
||||
f"{base_url}/api/upload",
|
||||
files=files,
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
body = r.json()
|
||||
assert body.get("success") is False
|
||||
|
||||
|
||||
def test_upload_delete_local(base_url):
|
||||
"""DELETE /api/upload 删除本地文件:先上传再删除"""
|
||||
# 1. 上传
|
||||
files = {"file": ("del_test.jpg", io.BytesIO(_MIN_JPEG), "image/jpeg")}
|
||||
r1 = requests.post(
|
||||
f"{base_url}/api/upload",
|
||||
files=files,
|
||||
data={"folder": "test"},
|
||||
timeout=10,
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
url = r1.json().get("url")
|
||||
assert url, "上传应返回 url"
|
||||
# path 支持 /uploads/xxx 或含 /uploads/ 的完整 URL
|
||||
path = url
|
||||
|
||||
# 2. 删除
|
||||
r2 = requests.delete(
|
||||
f"{base_url}/api/upload",
|
||||
params={"path": path},
|
||||
timeout=10,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
body = r2.json()
|
||||
assert body.get("success") is True
|
||||
assert "删除成功" in body.get("message", "")
|
||||
|
||||
|
||||
def test_upload_delete_no_path(base_url):
|
||||
"""DELETE /api/upload 无 path 返回 400"""
|
||||
r = requests.delete(f"{base_url}/api/upload", timeout=10)
|
||||
assert r.status_code == 400
|
||||
body = r.json()
|
||||
assert body.get("success") is False
|
||||
155
scripts/test/功能测试流程.md
Normal file
155
scripts/test/功能测试流程.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Soul 创业派对 - 功能测试流程
|
||||
|
||||
> 测试工程师产出。按流程执行,成功项打 ☑️,失败项列出问题,最后输出报告。
|
||||
|
||||
---
|
||||
|
||||
## 一、测试流程总览
|
||||
|
||||
```
|
||||
环境准备 → 自动化用例执行 → 手工功能验证 → 问题汇总 → 输出报告
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、环境准备
|
||||
|
||||
| 步骤 | 内容 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | soul-api 已启动(local 时)或测试服已部署 | ☐ / 问题:_____ |
|
||||
| 2 | 配置 `.env.test`(SOUL_TEST_ENV 或 SOUL_API_BASE) | ☐ / 问题:_____ |
|
||||
| 3 | 管理端可访问,默认账号 admin/admin123 可登录 | ☐ / 问题:_____ |
|
||||
| 4 | 小程序可访问(真机或开发者工具) | ☐ / 问题:_____ |
|
||||
|
||||
**环境信息记录**:
|
||||
- 执行日期:__________
|
||||
- 测试环境:local / souldev / soulapi
|
||||
- API 地址:__________
|
||||
|
||||
---
|
||||
|
||||
## 三、自动化用例执行
|
||||
|
||||
在 `scripts/test` 下执行:
|
||||
|
||||
```bash
|
||||
cd scripts/test
|
||||
$env:SOUL_TEST_ENV="local" # 或 souldev
|
||||
pytest -v
|
||||
```
|
||||
|
||||
### 3.1 小程序接口(miniapp)
|
||||
|
||||
| 用例 | 验证点 | 结果 | 问题(若失败) |
|
||||
|------|--------|------|----------------|
|
||||
| test_config_public | GET /api/miniprogram/config 返回 prices、features、mpConfig | ☐ | |
|
||||
| test_dev_login_as | POST /api/miniprogram/dev/login-as 按 userId 登录 | ☐ | |
|
||||
|
||||
### 3.2 管理端接口(web)
|
||||
|
||||
| 用例 | 验证点 | 结果 | 问题(若失败) |
|
||||
|------|--------|------|----------------|
|
||||
| test_admin_login | POST /api/admin 登录成功 | ☐ | |
|
||||
| test_admin_check_with_token | GET /api/admin 带 token 鉴权通过 | ☐ | |
|
||||
| test_admin_check_without_token | GET /api/admin 无 token 返回 401 | ☐ | |
|
||||
|
||||
### 3.3 流程测试(process)
|
||||
|
||||
| 用例 | 验证点 | 结果 | 问题(若失败) |
|
||||
|------|--------|------|----------------|
|
||||
| test_health | GET /health 返回 200 | ☐ | |
|
||||
| test_person_ensure_* | @某人 创建 Person + 存客宝计划 | ☐ | |
|
||||
| test_article_mention_* | 文章保存自动创建 Person | ☐ | |
|
||||
| test_backfill_persons_ckb_api_key | 从存客宝补齐 ckb_api_key | ☐ | |
|
||||
|
||||
---
|
||||
|
||||
## 四、手工功能验证
|
||||
|
||||
### 4.1 小程序端
|
||||
|
||||
| 场景 | 验证点 | 结果 | 问题(若失败) |
|
||||
|------|--------|------|----------------|
|
||||
| 登录 | 微信登录、手机号授权、token 持久化 | ☐ | |
|
||||
| 首页 | 书籍列表、搜索栏(searchEnabled 控制) | ☐ | |
|
||||
| 目录 | 章节列表、购买状态展示 | ☐ | |
|
||||
| 阅读 | 阅读页、购买章节/全书、支付流程 | ☐ | |
|
||||
| 我的 | 找伙伴、推广、搜索入口(featureConfig 控制) | ☐ | |
|
||||
| VIP | 开通、资料填写、头像上传、保存 | ☐ | |
|
||||
| 推荐 | 扫码/分享带 ref、绑定、分润展示 | ☐ | |
|
||||
|
||||
### 4.2 管理端
|
||||
|
||||
| 场景 | 验证点 | 结果 | 问题(若失败) |
|
||||
|------|--------|------|----------------|
|
||||
| 登录 | 账号密码登录、token 持久化 | ☐ | |
|
||||
| 仪表盘 | 数据统计展示 | ☐ | |
|
||||
| 用户管理 | 列表、搜索、分页、详情、行为轨迹 Tab | ☐ | |
|
||||
| 订单管理 | 列表、状态、筛选 | ☐ | |
|
||||
| 内容管理 | 书籍/章节 CRUD | ☐ | |
|
||||
| 提现 | 申请列表、审核、状态流转 | ☐ | |
|
||||
| 提现测试接口 | GIN_MODE=release 时返回 404 | ☐ | |
|
||||
|
||||
### 4.3 接口与数据流
|
||||
|
||||
| 场景 | 验证点 | 结果 | 问题(若失败) |
|
||||
|------|--------|------|----------------|
|
||||
| 路径隔离 | 小程序只调 /api/miniprogram/* | ☐ | |
|
||||
| 鉴权 | 需登录接口带 token,401 正确跳转 | ☐ | |
|
||||
| 支付回调 | 下单→支付→回调→分润 | ☐ | |
|
||||
|
||||
---
|
||||
|
||||
## 五、问题汇总(失败项)
|
||||
|
||||
将上述所有**未打 ☑️** 的项在此列出:
|
||||
|
||||
| 序号 | 模块 | 场景/用例 | 问题描述 | 严重程度 |
|
||||
|------|------|-----------|----------|----------|
|
||||
| 1 | | | | 高/中/低 |
|
||||
| 2 | | | | |
|
||||
| … | | | | |
|
||||
|
||||
---
|
||||
|
||||
## 六、测试报告(最终输出)
|
||||
|
||||
### 6.1 执行摘要
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 执行日期 | |
|
||||
| 测试环境 | |
|
||||
| 执行人 | |
|
||||
|
||||
### 6.2 结果统计
|
||||
|
||||
| 类型 | 总数 | 通过 ☑️ | 失败 | 通过率 |
|
||||
|------|------|---------|------|--------|
|
||||
| 自动化用例 | 12 | | | |
|
||||
| 手工-小程序 | 7 | | | |
|
||||
| 手工-管理端 | 7 | | | |
|
||||
| 手工-接口 | 3 | | | |
|
||||
| **合计** | **29** | | | |
|
||||
|
||||
### 6.3 结论
|
||||
|
||||
- [ ] **通过**:所有关键项 ☑️,无高/中严重问题
|
||||
- [ ] **有条件通过**:存在低严重问题,可上线但需跟进
|
||||
- [ ] **不通过**:存在高/中严重问题,需修复后复测
|
||||
|
||||
### 6.4 问题清单(若有)
|
||||
|
||||
| 序号 | 问题 | 严重程度 | 建议 |
|
||||
|------|------|----------|------|
|
||||
| 1 | | | |
|
||||
| 2 | | | |
|
||||
|
||||
---
|
||||
|
||||
## 七、使用说明
|
||||
|
||||
1. **执行前**:复制本文档或新建一份,填写环境信息。
|
||||
2. **执行中**:逐项验证,成功打 ☑️,失败在「问题」列填写。
|
||||
3. **执行后**:汇总失败项到「五、问题汇总」,填写「六、测试报告」。
|
||||
4. **归档**:报告保存为 `scripts/test/测试报告-YYYY-MM-DD.md`,便于追溯。
|
||||
102
scripts/test/测试报告-环境与用例清单.md
Normal file
102
scripts/test/测试报告-环境与用例清单.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Soul 创业派对 - 测试报告(环境与用例清单)
|
||||
|
||||
> 测试人员产出。环境就绪后执行 `pytest -v` 并填写结果。
|
||||
|
||||
---
|
||||
|
||||
## 一、测试环境
|
||||
|
||||
| 环境 | API 地址 | 说明 |
|
||||
|------|----------|------|
|
||||
| **local** | http://localhost:8080 | 本地开发,需先启动 soul-api |
|
||||
| **souldev** | https://souldev.quwanzhi.com | 测试服,需已部署 |
|
||||
| **soulapi** | https://soulapi.quwanzhi.com | 正式服,慎用 |
|
||||
|
||||
**配置方式**:
|
||||
- 复制 `scripts/test/.env.test.example` 为 `.env.test`
|
||||
- 设置 `SOUL_TEST_ENV=local|souldev|soulapi` 或 `SOUL_API_BASE=<地址>`
|
||||
|
||||
**运行命令**:
|
||||
```bash
|
||||
cd scripts/test
|
||||
# Windows PowerShell
|
||||
$env:SOUL_TEST_ENV="local" # 或 souldev
|
||||
pytest -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、用例清单(共 18 个)
|
||||
|
||||
### 2.1 小程序接口(miniapp/)
|
||||
|
||||
| 用例 | 验证点 | 前置条件 |
|
||||
|------|--------|----------|
|
||||
| **test_config_public** | GET /api/miniprogram/config 返回 200,含 prices、features、mpConfig | 无 |
|
||||
| **test_dev_login_as** | POST /api/miniprogram/dev/login-as 按 userId 登录成功 | SOUL_MINIPROGRAM_DEV_USER_ID 已配置;APP_ENV=development |
|
||||
|
||||
### 2.2 管理端接口(web/)
|
||||
|
||||
| 用例 | 验证点 | 前置条件 |
|
||||
|------|--------|----------|
|
||||
| **test_admin_login** | POST /api/admin 登录成功,返回 token、user | 无 |
|
||||
| **test_admin_check_with_token** | GET /api/admin 带 token 鉴权通过 | admin 登录成功 |
|
||||
| **test_admin_check_without_token** | GET /api/admin 无 token 返回 401 | 无 |
|
||||
| **test_upload_post_success** | POST /api/upload 上传图片成功,返回 url、data | 无 |
|
||||
| **test_upload_post_with_admin_token** | POST /api/upload 带 token 可上传 | admin 登录 |
|
||||
| **test_upload_post_no_file** | POST /api/upload 无 file 返回 400 | 无 |
|
||||
| **test_upload_post_invalid_type** | POST /api/upload 非图片格式返回 400 | 无 |
|
||||
| **test_upload_delete_local** | DELETE /api/upload 删除本地文件(先上传再删) | 无 |
|
||||
| **test_upload_delete_no_path** | DELETE /api/upload 无 path 返回 400 | 无 |
|
||||
|
||||
### 2.3 流程测试(process/)
|
||||
|
||||
| 用例 | 验证点 | 前置条件 |
|
||||
|------|--------|----------|
|
||||
| **test_health** | GET /health 返回 200 | soul-api 已启动 |
|
||||
| **test_person_ensure_creates_ckb_plan_when_not_exists** | @某人 不存在时 POST /api/db/persons 创建 Person + 存客宝计划 | admin 登录;CKB 配置正确 |
|
||||
| **test_person_ensure_returns_existing_when_name_exists** | 相同 name 返回已有 Person,不重复创建 | 同上 |
|
||||
| **test_person_ensure_rejects_empty_name** | 空 name 被拒绝 | 同上 |
|
||||
| **test_article_mention_flow_persons_list_contains_new** | 文章保存后 persons 列表含新人物 | 同上 |
|
||||
| **test_new_article_save_auto_creates_person_and_ckb** | 新文章保存自动创建 Person 与存客宝计划 | 同上 |
|
||||
| **test_backfill_persons_ckb_api_key** | 从存客宝补齐 persons.ckb_api_key | admin 登录;CKB 配置;persons 有 ckb_plan_id |
|
||||
|
||||
---
|
||||
|
||||
## 三、源码质量优化相关回归点(手工验证)
|
||||
|
||||
以下为 2026-03-17 源码质量优化后的建议回归项,自动化用例未覆盖:
|
||||
|
||||
| 场景 | 验证点 |
|
||||
|------|--------|
|
||||
| **支付流程** | read 页购买章节/全书 → 微信支付 → 回调更新购买状态 |
|
||||
| **管理端用户详情** | 用户列表点击详情 → 行为轨迹 Tab 展示(调用 /api/admin/user/track) |
|
||||
| **我的页** | 找伙伴、推广、搜索入口根据 featureConfig 显示/隐藏 |
|
||||
| **首页/目录** | 搜索栏根据 searchEnabled 显示/隐藏 |
|
||||
| **提现测试接口** | GIN_MODE=release 时 GET/POST /api/admin/withdraw-test 返回 404 |
|
||||
|
||||
---
|
||||
|
||||
## 四、测试结果记录
|
||||
|
||||
| 执行日期 | 环境 | 通过 | 失败 | 跳过 | 备注 |
|
||||
|----------|------|------|------|------|------|
|
||||
| 2026-03-17 | local | 16 | 0 | 2 | 含新增文件上传 6 用例;2 跳过(dev_login、backfill 需配置) |
|
||||
| (待填写) | local / souldev | - | - | - | - |
|
||||
|
||||
### 失败用例详情(若有)
|
||||
|
||||
| 用例 | 错误信息 | 处理 |
|
||||
|------|----------|------|
|
||||
| - | - | - |
|
||||
|
||||
---
|
||||
|
||||
## 五、环境就绪检查
|
||||
|
||||
执行测试前请确认:
|
||||
|
||||
- [ ] soul-api 已启动(local 时 `go run cmd/server/main.go` 或 `scripts/本地启动.sh`)
|
||||
- [ ] 测试环境变量已设置(SOUL_TEST_ENV 或 SOUL_API_BASE)
|
||||
- [ ] 管理端账号可登录(默认 admin/admin123)
|
||||
- [ ] 流程测试需 CKB 配置时,soul-api/.env 已配置 CKB_OPEN_API_KEY、CKB_OPEN_ACCOUNT
|
||||
Reference in New Issue
Block a user