diff --git a/.cursor/agent/开发助理/经验清单.md b/.cursor/agent/开发助理/经验清单.md index f84682a0..d8d37db9 100644 --- a/.cursor/agent/开发助理/经验清单.md +++ b/.cursor/agent/开发助理/经验清单.md @@ -42,6 +42,10 @@ | 2026-03-13 | 小程序、后端、团队 | 业务规则 | api-dev SKILL、miniprogram-dev SKILL、three-tier-arch SKILL | 文章详情预览统一由后端按 50% 截取,小程序按 accessState 使用预览/全文,外层 content 与 data.content 始终一致以避免泄露全文 | | 2025-03-14 | 小程序 | 最佳实践 | miniprogram-dev SKILL §10 | 阅读页文本长按选中复制:text 组件 user-select(selectable 已废弃),正文/标题/预览均加 user-select | | 2026-03-14 | 后端、管理端、小程序、团队 | 业务规则/bug 修复 | - | 内容排名算法修正(排名分公式);保存权重后 loadRanking 刷新;我的页设置隐藏;资料引导场景梳理 | +| 2026-03-16 | 软件测试 | 知识沉淀 | testing SKILL | scripts 目录与测试关联:本地启动.sh 联调必备、飞书脚本非回归范围、soul-api/scripts 与根 scripts 区分 | +| 2026-03-16 | 软件测试 | 目录约定 | testing SKILL §5 | scripts/test:miniapp 小程序接口测试、web 管理端测试;测试工程师在此编写用例 | +| 2026-03-16 | 软件测试 | 目录约定 | testing SKILL §5 | scripts/test/process:流程测试,跨端多接口串联(下单→支付→分润等) | +| 2026-03-16 | 软件测试 | 配置约定 | testing SKILL | pytest 架构、配置从 soul-api/.env* 读取、SOUL_TEST_ENV 必显;运行前报告头部显示测试环境,避免误测正式库 | --- diff --git a/.cursor/agent/开发助理/项目索引/测试.md b/.cursor/agent/开发助理/项目索引/测试.md index 13898c43..b7ece45f 100644 --- a/.cursor/agent/开发助理/项目索引/测试.md +++ b/.cursor/agent/开发助理/项目索引/测试.md @@ -21,6 +21,10 @@ | 2026-03-05 | 文章详情@某人加好友方案讨论:@ 展示与添加好友用例、联调与回归 | 待续 | | 2026-03-10 | 会议:管理端迁移 Mycontent-temp;回归重点为菜单一致性、隐藏路由可达性、鉴权跳转 | 待续 | | 2026-03-16 | 乘风发起例行开发进度同步 | 已完成 | +| 2026-03-16 | scripts 目录知识吸收:本地启动、飞书脚本、联调环境准备 | 已完成 | +| 2026-03-16 | scripts/test 测试用例目录约定:miniapp 小程序接口、web 管理端 | 已完成 | +| 2026-03-16 | scripts/test/process 流程测试目录:跨端业务流程 | 已完成 | +| 2026-03-16 | pytest 架构、配置从项目读取、运行前显示测试环境 | 已完成 | --- diff --git a/.cursor/agent/软件测试/evolution/2026-03-16-scripts目录与测试关联.md b/.cursor/agent/软件测试/evolution/2026-03-16-scripts目录与测试关联.md new file mode 100644 index 00000000..ed472652 --- /dev/null +++ b/.cursor/agent/软件测试/evolution/2026-03-16-scripts目录与测试关联.md @@ -0,0 +1,83 @@ +# 软件测试 经验记录 - 2026-03-16 + +## scripts 目录与测试关联 + +测试工程师需了解项目根目录 `scripts/` 下的辅助脚本,以便在联调、回归、环境准备时正确使用。 + +--- + +### 1. 本地启动脚本(联调必备) + +| 脚本 | 用途 | 测试关联 | +|------|------|----------| +| `本地启动.sh` | 一键启动 soul-api(8080)+ soul-admin(5174) | **三端联调前**:先执行此脚本,确保后端与管理端在本地运行;小程序需配置本地 API 地址 | + +**用法**:`./scripts/本地启动.sh` 或 `bash scripts/本地启动.sh` +**前置**:Mac/Linux 环境;soul-api 需能连接数据库;首次会编译 `soul-api-mac` +**验证**:访问 http://localhost:5174,默认账号 admin / admin123 + +--- + +### 2. 飞书相关脚本(非核心业务,可了解) + +| 脚本/目录 | 用途 | 测试关联 | +|-----------|------|----------| +| `feishu_export/` | 书稿导出 md,供飞书知识库同步 | 与 Soul 三端业务无直接关系,回归可不覆盖 | +| `sync_book_to_feishu_export.py` | 从书稿目录导出 md 到 feishu_export | 同上 | +| `feishu_wiki_upload.py` | 上传全书到飞书知识库 | 同上 | +| `send_chapter_poster_to_feishu.py` | 生成章节海报并推送到飞书群 | 若海报含小程序码,可顺带验证二维码可访问性 | + +--- + +### 3. Git 推送脚本 + +| 脚本 | 用途 | 测试关联 | +|------|------|----------| +| `gitea_push_once.sh` | 首次推送到 Gitea 仓库 | 与功能测试无关,部署/发布流程用 | + +--- + +### 4. 测试工程师使用建议 + +- **联调前**:优先使用 `本地启动.sh` 启动后端与管理端,再测小程序、管理端功能 +- **回归范围**:scripts 内飞书、Gitea 脚本不纳入三端功能回归清单 +- **环境依赖**:`本地启动.sh` 依赖 Go 编译、pnpm、数据库可连;测试环境需提前确认 + +--- + +### 5. 测试用例目录 `scripts/test/`(测试工程师主战场) + +| 子目录 | 用途 | 对应端 | +|--------|------|--------| +| **miniapp/** | 小程序接口测试 | miniprogram,API:/api/miniprogram/* | +| **web/** | 管理端测试 | soul-admin,API:/api/admin/*、/api/db/* | +| **process/** | 流程测试 | 跨端,多接口串联 | + +**约定**:测试工程师在此编写与维护测试用例,miniapp 放小程序接口、web 放管理端、process 放跨端业务流程。 + +**环境配置**:必须明确指定测试环境(SOUL_TEST_ENV=local|souldev|soulapi 或 SOUL_API_BASE),运行前会打印「测试环境: xxx」横幅,避免误测正式库。配置可来自 soul-api/.env* 或 scripts/test/.env.test。 + +--- + +### 6. pytest + requests 架构与配置约定 + +| 文件 | 说明 | +|------|------| +| config.py | 从项目 soul-api/.env* 或 .env.test 读取;SOUL_TEST_ENV / SOUL_API_BASE | +| conftest.py | base_url、admin_token、miniapp_token;pytest_report_header 显示环境横幅 | +| util.py | admin_headers、miniapp_headers | +| requirements-test.txt | pytest、requests | + +**配置优先级**:SOUL_TEST_ENV > SOUL_API_BASE > .env.test > soul-api/.env* > 默认 local。 + +**运行前必看**:pytest 报告头部会显示「测试环境: 本地/测试/正式 (URL)」,确认无误后再执行。 + +--- + +### 7. 与 soul-api/scripts 的区别 + +| 位置 | 内容 | 测试关联 | +|------|------|----------| +| `scripts/`(项目根) | 本地启动、飞书同步、Gitea 推送、**test/** | 见上文 | +| `scripts/test/` | **测试用例**:miniapp、web、process;pytest 架构 | 测试工程师在此写用例 | +| `soul-api/scripts/` | SQL 迁移、Python 脚本等 | 数据库迁移、后端运维;测试时若涉及表结构变更,需关注对应 SQL | diff --git a/.cursor/agent/软件测试/evolution/索引.md b/.cursor/agent/软件测试/evolution/索引.md index f37d3a03..3ddb9f3d 100644 --- a/.cursor/agent/软件测试/evolution/索引.md +++ b/.cursor/agent/软件测试/evolution/索引.md @@ -5,3 +5,7 @@ | 2026-03-05 | 分支合并后回归清单制定;三端联调验证 | [2026-03-05.md](./2026-03-05.md) | | 2026-03-05 | 文章详情@某人:@ 展示与添加好友用例、联调与回归点 | [2026-03-05.md](./2026-03-05.md) | | 2026-03-10 | 管理端迁移 Mycontent-temp:菜单一致性、隐藏路由可达性、鉴权与跳转回归 | [2026-03-10.md](./2026-03-10.md) | +| 2026-03-16 | scripts 目录与测试关联:本地启动、飞书脚本、联调前环境准备 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) | +| 2026-03-16 | scripts/test 测试用例目录:miniapp 小程序接口、web 管理端 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) | +| 2026-03-16 | scripts/test/process 流程测试:跨端多接口串联 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) | +| 2026-03-16 | pytest 架构、配置从项目读取、运行前显示测试环境 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) | diff --git a/.cursor/skills/testing/SKILL.md b/.cursor/skills/testing/SKILL.md index 6de3822a..9acca18b 100644 --- a/.cursor/skills/testing/SKILL.md +++ b/.cursor/skills/testing/SKILL.md @@ -52,11 +52,25 @@ description: Soul 创业派对测试人员。功能测试、回归测试、三 --- -## 5. 产出与协同 +## 5. 测试用例存放位置 + +| 目录 | 用途 | +|------|------| +| `scripts/test/miniapp/` | 小程序接口测试(/api/miniprogram/*) | +| `scripts/test/web/` | 管理端测试(/api/admin/*、/api/db/*) | +| `scripts/test/process/` | 流程测试(跨端多接口串联) | + +测试工程师在此编写与维护测试用例,按 miniapp / web / process 分类存放。 + +**环境配置**:必须明确指定 SOUL_TEST_ENV(local/souldev/soulapi)或 SOUL_API_BASE;配置从 soul-api/.env* 或 .env.test 读取。运行前报告头部会显示「测试环境: xxx」,确认无误后再执行,避免误测正式库。 + +--- + +## 6. 产出与协同 | 产出 | 说明 | |------|------| -| 测试用例 | 场景、步骤、期望结果 | +| 测试用例 | 场景、步骤、期望结果,存放于 scripts/test/ | | 测试报告 | 通过率、失败用例、环境信息 | | Bug 列表 | 复现步骤、关联端、严重程度 | @@ -64,7 +78,7 @@ description: Soul 创业派对测试人员。功能测试、回归测试、三 --- -## 6. 何时使用本 Skill +## 7. 何时使用本 Skill - 编写或执行测试用例时 - 做回归测试、功能验证时 diff --git a/scripts/test/.env.test.example b/scripts/test/.env.test.example new file mode 100644 index 00000000..5c1f39fb --- /dev/null +++ b/scripts/test/.env.test.example @@ -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 diff --git a/scripts/test/.gitignore b/scripts/test/.gitignore new file mode 100644 index 00000000..91b6be32 --- /dev/null +++ b/scripts/test/.gitignore @@ -0,0 +1,4 @@ +.env.test +__pycache__/ +.pytest_cache/ +*.pyc diff --git a/scripts/test/README.md b/scripts/test/README.md new file mode 100644 index 00000000..a54d1653 --- /dev/null +++ b/scripts/test/README.md @@ -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 | diff --git a/scripts/test/config.py b/scripts/test/config.py new file mode 100644 index 00000000..80b36b95 --- /dev/null +++ b/scripts/test/config.py @@ -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" + ) diff --git a/scripts/test/conftest.py b/scripts/test/conftest.py new file mode 100644 index 00000000..6887b317 --- /dev/null +++ b/scripts/test/conftest.py @@ -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 "" diff --git a/scripts/test/miniapp/README.md b/scripts/test/miniapp/README.md new file mode 100644 index 00000000..0b79fa08 --- /dev/null +++ b/scripts/test/miniapp/README.md @@ -0,0 +1,19 @@ +# 小程序接口测试 (miniapp) + +> 小程序 C 端接口测试用例。对应 miniprogram,API 路径:`/api/miniprogram/*` + +--- + +## 测试范围 + +- 登录(微信登录、手机号、token 持久化) +- 购买与支付(下单、微信支付、回调、购买状态) +- 推荐与分润(扫码/分享带 ref、绑定、分润计算) +- VIP 功能(开通、资料、头像上传、排行展示) +- 阅读(文章列表、详情、预览、全文) + +--- + +## 用例编写 + +在此目录下新增 `.md` 或测试脚本,按场景组织用例。 diff --git a/scripts/test/miniapp/conftest.py b/scripts/test/miniapp/conftest.py new file mode 100644 index 00000000..dff6381f --- /dev/null +++ b/scripts/test/miniapp/conftest.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""miniapp 专用 fixtures,继承 scripts/test/conftest.py""" diff --git a/scripts/test/miniapp/test_config.py b/scripts/test/miniapp/test_config.py new file mode 100644 index 00000000..eb2bfc6c --- /dev/null +++ b/scripts/test/miniapp/test_config.py @@ -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 diff --git a/scripts/test/miniapp/test_dev_login.py b/scripts/test/miniapp/test_dev_login.py new file mode 100644 index 00000000..4a4a14b7 --- /dev/null +++ b/scripts/test/miniapp/test_dev_login.py @@ -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"] diff --git a/scripts/test/process/README.md b/scripts/test/process/README.md new file mode 100644 index 00000000..b997a12a --- /dev/null +++ b/scripts/test/process/README.md @@ -0,0 +1,19 @@ +# 流程测试 (process) + +> 跨端业务流程测试用例。验证多步骤、多接口串联的完整流程。 + +--- + +## 测试范围 + +- **下单→支付→回调→分润**:购买全链路 +- **推荐码绑定→访问记录→分润计算**:推广流程 +- **VIP 开通→资料填写→排行展示**:会员流程 +- **提现申请→审核→到账**:提现流程 +- **内容发布→审核→上架→用户可见**:内容流转 + +--- + +## 用例编写 + +在此目录下新增 `.md` 或测试脚本,按业务流程组织用例。流程测试通常涉及 miniprogram + admin + API 多端联动。 diff --git a/scripts/test/process/conftest.py b/scripts/test/process/conftest.py new file mode 100644 index 00000000..bb4c4358 --- /dev/null +++ b/scripts/test/process/conftest.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""process 专用 fixtures,继承 scripts/test/conftest.py""" diff --git a/scripts/test/process/test_health.py b/scripts/test/process/test_health.py new file mode 100644 index 00000000..37ee41a5 --- /dev/null +++ b/scripts/test/process/test_health.py @@ -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 diff --git a/scripts/test/pytest.ini b/scripts/test/pytest.ini new file mode 100644 index 00000000..8d3066c3 --- /dev/null +++ b/scripts/test/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +pythonpath = . +testpaths = miniapp web process +addopts = -v diff --git a/scripts/test/requirements-test.txt b/scripts/test/requirements-test.txt new file mode 100644 index 00000000..1297247f --- /dev/null +++ b/scripts/test/requirements-test.txt @@ -0,0 +1,2 @@ +pytest>=7.0 +requests>=2.28 diff --git a/scripts/test/util.py b/scripts/test/util.py new file mode 100644 index 00000000..bc780f64 --- /dev/null +++ b/scripts/test/util.py @@ -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"} diff --git a/scripts/test/web/README.md b/scripts/test/web/README.md new file mode 100644 index 00000000..5cce6215 --- /dev/null +++ b/scripts/test/web/README.md @@ -0,0 +1,19 @@ +# 管理端测试 (web) + +> 管理后台功能测试用例。对应 soul-admin,API 路径:`/api/admin/*`、`/api/db/*` + +--- + +## 测试范围 + +- 内容管理(文章、章节、书籍 CRUD) +- 用户管理 +- 订单、提现 +- VIP 角色、推广设置 +- 导师、导师预约、二维码、站点、支付配置 + +--- + +## 用例编写 + +在此目录下新增 `.md` 或测试脚本,按场景组织用例。 diff --git a/scripts/test/web/conftest.py b/scripts/test/web/conftest.py new file mode 100644 index 00000000..a5a450c2 --- /dev/null +++ b/scripts/test/web/conftest.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""web 专用 fixtures,继承 scripts/test/conftest.py""" diff --git a/scripts/test/web/test_admin_auth.py b/scripts/test/web/test_admin_auth.py new file mode 100644 index 00000000..0dd4f7e2 --- /dev/null +++ b/scripts/test/web/test_admin_auth.py @@ -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 diff --git a/soul-admin/src/pages/content/ContentPage.tsx b/soul-admin/src/pages/content/ContentPage.tsx index 7c600d48..a0e1f24f 100644 --- a/soul-admin/src/pages/content/ContentPage.tsx +++ b/soul-admin/src/pages/content/ContentPage.tsx @@ -588,6 +588,72 @@ export function ContentPage() { } }, []) + /** 文章编辑时自动创建不存在的 @人物 和 #标签,返回合并后的列表供 autoLinkContent 使用 */ + const ensureMentionsAndTags = useCallback( + async (content: string): Promise<{ persons: PersonItem[]; linkTags: LinkTagItem[] }> => { + const regex = /(@[^\s@#]+|#[^\s@#]+)/g + const names = new Set() + const labels = new Set() + let m: RegExpExecArray | null + while ((m = regex.exec(content)) !== null) { + const full = m[0] + if (full.startsWith('@')) names.add(full.slice(1).trim()) + else if (full.startsWith('#')) labels.add(full.slice(1).trim()) + } + let personsCopy = [...persons] + let linkTagsCopy = [...linkTags] + for (const name of names) { + if (!name || personsCopy.some((p) => p.name === name)) continue + try { + const res = await post<{ + success?: boolean + person?: { token: string; personId: string; name: string; ckbPlanId?: number } + }>('/api/db/persons', { name }) + if (res?.success && res.person) { + personsCopy = [ + ...personsCopy, + { + id: res.person.token, + personId: res.person.personId, + name: res.person.name, + ckbPlanId: res.person.ckbPlanId, + } as PersonItem, + ] + } + } catch { + /* ignore */ + } + } + for (const label of labels) { + if (!label || linkTagsCopy.some((t) => t.label === label)) continue + try { + const res = await post<{ + success?: boolean + linkTag?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string } + }>('/api/db/link-tags', { label }) + if (res?.success && res.linkTag) { + const t = res.linkTag + linkTagsCopy = [ + ...linkTagsCopy, + { + id: t.tagId, + label: t.label, + url: t.url || '', + type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb', + appId: t.appId || '', + pagePath: t.pagePath || '', + }, + ] + } + } catch { + /* ignore */ + } + } + return { persons: personsCopy, linkTags: linkTagsCopy } + }, + [persons, linkTags], + ) + const [linkedMps, setLinkedMps] = useState<{ key: string; name: string; appId: string; path?: string }[]>([]) const [mpSearchQuery, setMpSearchQuery] = useState('') const [mpDropdownOpen, setMpDropdownOpen] = useState(false) @@ -744,7 +810,8 @@ export function ContentPage() { setIsSaving(true) try { let content = editingSection.content || '' - content = autoLinkContent(content, persons, linkTags) + const { persons: p, linkTags: lt } = await ensureMentionsAndTags(content) + content = autoLinkContent(content, p, lt) const titlePatterns = [ new RegExp(`^#+\\s*${editingSection.id.replace('.', '\\.')}\\s+.*$`, 'gm'), new RegExp(`^#+\\s*${editingSection.id.replace('.', '\\.')}[::].*$`, 'gm'), @@ -780,6 +847,8 @@ export function ContentPage() { toast.success(`已保存:${editingSection.title}`) setEditingSection(null) loadList() + loadPersons() + loadLinkTags() } else { toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误')) } @@ -801,13 +870,14 @@ export function ContentPage() { try { const currentPart = tree.find((p) => p.id === newSection.partId) const currentChapter = currentPart?.chapters.find((c) => c.id === newSection.chapterId) + const { persons: p, linkTags: lt } = await ensureMentionsAndTags(newSection.content || '') const res = await put<{ success?: boolean; error?: string }>( '/api/db/book', { id: newSection.id, title: newSection.title, price: newSection.isFree ? 0 : newSection.price, - content: autoLinkContent(newSection.content || '', persons, linkTags), + content: autoLinkContent(newSection.content || '', p, lt), partId: newSection.partId, partTitle: currentPart?.title ?? '', chapterId: newSection.chapterId, @@ -837,6 +907,8 @@ export function ContentPage() { setShowNewSectionModal(false) setNewSection({ id: '', title: '', price: 1, partId: 'part-1', chapterId: 'chapter-1', content: '', editionStandard: true, editionPremium: false, isFree: false, isNew: false, isPinned: false, hotScore: 0 }) loadList() + loadPersons() + loadLinkTags() } else { toast.error('创建失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误')) } diff --git a/soul-api/internal/handler/db_link_tag.go b/soul-api/internal/handler/db_link_tag.go index ca756e5f..9ac062df 100644 --- a/soul-api/internal/handler/db_link_tag.go +++ b/soul-api/internal/handler/db_link_tag.go @@ -33,10 +33,13 @@ func DBLinkTagSave(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"}) return } - if body.TagID == "" || body.Label == "" { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "tagId 和 label 必填"}) + if body.Label == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "label 必填"}) return } + if body.TagID == "" { + body.TagID = body.Label + } if body.Type == "" { body.Type = "url" } @@ -46,6 +49,11 @@ func DBLinkTagSave(c *gin.Context) { } db := database.DB() var existing model.LinkTag + // 按 label 查找:文章编辑自动创建场景,若已存在则直接返回 + if db.Where("label = ?", body.Label).First(&existing).Error == nil { + c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing}) + return + } if db.Where("tag_id = ?", body.TagID).First(&existing).Error == nil { existing.Label = body.Label existing.URL = body.URL diff --git a/soul-api/internal/handler/db_person.go b/soul-api/internal/handler/db_person.go index 6f058f50..2d56bfb5 100644 --- a/soul-api/internal/handler/db_person.go +++ b/soul-api/internal/handler/db_person.go @@ -64,11 +64,18 @@ func DBPersonSave(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 必填"}) return } - if body.PersonID == "" { - body.PersonID = fmt.Sprintf("%s_%d", body.Name, time.Now().UnixMilli()) - } db := database.DB() var existing model.Person + // 按 name 查找:文章编辑自动创建场景,PersonID 为空时先查是否已存在 + if body.PersonID == "" { + if db.Where("name = ?", strings.TrimSpace(body.Name)).First(&existing).Error == nil { + c.JSON(http.StatusOK, gin.H{"success": true, "person": existing}) + return + } + } + if body.PersonID == "" { + body.PersonID = fmt.Sprintf("%s_%d", strings.ToLower(strings.ReplaceAll(strings.TrimSpace(body.Name), " ", "_")), time.Now().UnixMilli()) + } if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil { existing.Name = body.Name existing.Label = body.Label diff --git a/soul-api/uploads/book-images/1773627692709518000_zogmu3.png b/soul-api/uploads/book-images/1773627692709518000_zogmu3.png new file mode 100644 index 00000000..00e3f3c3 Binary files /dev/null and b/soul-api/uploads/book-images/1773627692709518000_zogmu3.png differ diff --git a/临时需求池/2026-03-16-文章编辑自动创建@和#.md b/临时需求池/2026-03-16-文章编辑自动创建@和#.md new file mode 100644 index 00000000..13cb1305 --- /dev/null +++ b/临时需求池/2026-03-16-文章编辑自动创建@和#.md @@ -0,0 +1,75 @@ +# 需求:文章编辑时 @某人 / #标签 自动创建并绑定 + +> 加个需求:用户添加/编辑文章时,@某人 若不存在则自动新增到「链接人与事」并同步存客宝;#某个标签 若不存在则自动新增到「链接标签」并默认配置。 + +--- + +## 一、需求描述 + +| 场景 | 当前行为 | 期望行为 | +|------|----------|----------| +| 编辑文章输入 `@卡若` | 若 persons 中无「卡若」→ 保持纯文本,不解析 | 自动创建 Person「卡若」+ 同步存客宝计划 → 转为可点击的 mention | +| 编辑文章输入 `#创业资源` | 若 linkTags 中无「创业资源」→ 保持纯文本 | 自动创建 LinkTag「创业资源」→ 转为可点击的 linkTag | +| 已存在 | 正常绑定 | 不变 | + +--- + +## 二、三端分析 + +| 端 | 分析 | 任务 | +|----|------|------| +| **小程序** | 无变更,content 解析逻辑不变 | 无 | +| **管理端** | 保存前需「确保存在」:对 content 中不存在的 @name 调用创建 Person,对 #label 调用创建 LinkTag,再执行 autoLinkContent | 1. 提取 content 中所有 @xxx、#xxx
2. 对不存在的调用后端「确保存在」接口
3. 刷新 persons/linkTags 后 autoLinkContent | +| **后端** | 需提供「按名称确保 Person 存在」「按 label 确保 LinkTag 存在」能力 | 1. 新增或扩展接口支持简化创建
2. Person:无 deviceGroups 时用默认配置创建存客宝计划
3. LinkTag:默认 type=url、url 空 | + +--- + +## 三、接口契约 + +### 3.1 确保 Person 存在 + +**方案**:扩展 `POST /api/db/persons`,支持仅传 `name` 的简化创建;或新增 `POST /api/db/persons/ensure`。 + +| 请求 | 说明 | +|------|------| +| `{ "name": "卡若" }` | 按 name 查找,存在则返回;不存在则创建(personId=name_slug、无 deviceGroups、调存客宝用默认配置) | + +**默认配置**:greeting/tips 空,addInterval=1,startTime=09:00,endTime=18:00,deviceGroups 空(存客宝允许无设备时创建?需确认) + +### 3.2 确保 LinkTag 存在 + +**方案**:扩展 `POST /api/db/link-tags`,支持仅传 `label`,tagId 自动生成(label 或 slug(label))。 + +| 请求 | 说明 | +|------|------| +| `{ "label": "创业资源" }` 或 `{ "tagId": "创业资源", "label": "创业资源", "type": "url", "url": "" }` | 按 label 查找,存在则返回;不存在则创建,默认 type=url、url 空 | + +--- + +## 四、任务指派 + +| 序号 | 角色 | 任务 | +|------|------|------| +| 1 | 后端 | 扩展 DBPersonSave:支持仅 name,按 name 查 persons 表(若 persons 有 name 列唯一则用;否则需确认按 name 查逻辑);不存在时用默认配置创建 + 调存客宝(deviceGroups 空时是否允许需验证) | +| 2 | 后端 | 扩展 DBLinkTagSave 或新增 ensure:支持仅 label,按 label 查;不存在时创建,tagId=label,type=url,url="" | +| 3 | 管理端 | handleSaveSection/handleCreateSection 前:extractMentionsAndTags(content) → 对每个 @name 若 persons 无则 POST persons ensure;对每个 #label 若 linkTags 无则 POST link-tags ensure → loadPersons/loadLinkTags → autoLinkContent | + +--- + +## 五、验收标准 + +- [ ] 编辑文章输入 `@新人物`(链接人与事中无)→ 保存 → 链接人与事列表出现「新人物」,存客宝有对应计划 +- [ ] 编辑文章输入 `#新标签`(链接标签中无)→ 保存 → 链接标签列表出现「新标签」 +- [ ] 小程序阅读页点击 @新人物、#新标签 可正常跳转/加好友 +- [ ] 已存在的 @某人、#标签 行为不变 + +--- + +## 六、实施记录 + +- 2026-03-16:后端扩展 DBPersonSave(按 name 查找)、DBLinkTagSave(按 label 查找,tagId 可缺省);管理端 ensureMentionsAndTags + handleSaveSection/handleCreateSection 集成 + +## 七、风险与待确认 + +- 存客宝创建计划时 deviceGroups 为空是否允许?当前实现不传 deviceGroups,若 CKB 拒绝则需配置默认设备 +- Person 按 name 查找:取第一个匹配;若有多人同名,会复用第一个 diff --git a/开发文档/1、需求/需求汇总.md b/开发文档/1、需求/需求汇总.md index 8be19f14..44627e1a 100644 --- a/开发文档/1、需求/需求汇总.md +++ b/开发文档/1、需求/需求汇总.md @@ -49,3 +49,4 @@ IP 设定、风格、输出规范(见原卡若角色设定)。 | 2026-03-08 | 文章阅读付费规则:免费章节以 free_chapters 为准;VIP 全章免费 | 已完成 | soul-api book.go 合并 free_chapters;check-purchased 已支持 VIP | | 2026-03-10 | 小程序「我的」页阅读统计改为后端接口(真实数据) | 已完成 | my.js loadDashboardStats;soul-api GET /api/miniprogram/user/dashboard-stats | | 2026-03-10 | 富文本渲染升级(TipTap HTML → rich-text 组件,保留 @mention 交互) | 待实施 | 确认 DB 内容格式后实施;当前 contentParser.js 为纯文本剥除 | +| 2026-03-16 | 文章编辑时 @某人/#标签 自动创建:不存在则自动新增到链接人与事/链接标签并同步存客宝 | 已完成 | 临时需求池/2026-03-16-文章编辑自动创建@和#.md |