2026-06-25 09:27:06 +08:00
|
|
|
|
"""用户端 Web 自助入口核心测试。"""
|
2026-06-14 05:26:27 +08:00
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-06-18 10:21:38 +08:00
|
|
|
|
import pytest
|
2026-06-25 09:27:06 +08:00
|
|
|
|
from fastapi import HTTPException, Request
|
|
|
|
|
|
|
|
|
|
|
|
from admin.routes.web_public import (
|
|
|
|
|
|
alipay_sim_payment_page,
|
|
|
|
|
|
complete_alipay_sim_payment,
|
|
|
|
|
|
complete_mock_payment,
|
|
|
|
|
|
mock_payment_page,
|
|
|
|
|
|
mock_payment_webhook,
|
|
|
|
|
|
)
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
2026-06-14 23:12:18 +08:00
|
|
|
|
from data.orders.dao import OrdersDAO
|
|
|
|
|
|
|
2026-06-14 05:26:27 +08:00
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
def test_public_landing_page_served(client):
|
|
|
|
|
|
resp = client.get("/")
|
2026-06-14 05:26:27 +08:00
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
|
assert "text/html" in resp.headers["content-type"]
|
|
|
|
|
|
body = resp.text
|
2026-06-16 15:02:01 +08:00
|
|
|
|
assert "高考志愿填报智能规划服务" in body
|
2026-06-16 17:14:51 +08:00
|
|
|
|
assert "新高考志愿填报 · 志愿决策支持" in body
|
|
|
|
|
|
assert "湖南新高考志愿填报" not in body
|
|
|
|
|
|
assert "为什么选择我们" in body
|
2026-06-14 05:26:27 +08:00
|
|
|
|
assert 'href="/pricing"' in body
|
2026-06-17 21:50:06 +08:00
|
|
|
|
assert "先做快速审核" not in body
|
2026-06-25 09:27:06 +08:00
|
|
|
|
assert "先做方案复核" in body
|
2026-06-17 21:50:06 +08:00
|
|
|
|
assert "查看套餐" in body
|
2026-06-25 09:27:06 +08:00
|
|
|
|
assert 'href="/review/start?source=home"' in body
|
2026-06-17 21:50:06 +08:00
|
|
|
|
assert 'btn-primary" href="/pricing"' in body
|
fix(landing): align copy to '复核免费 / 方案付费' (no 免费咨询 promise)
用户最新反馈: 首页不应承诺'免费咨询'; 复核现有方案是免费的, 完整方案
生成和深度辅导需在支付后启动。咨询入口只用于快速问询, 不是承诺
免费生成方案。
文案改动 (admin/routes/web_public.py):
- hero-note: '咨询本身免费' → '复核现有方案本身免费; 新方案生成与深度辅导在支付后启动'
- 咨询表单标题: '先告诉我们你的基本情况' → '告诉我们你的基本情况'
- 咨询表单副标题: '判断 + 适合路径' → '判断 + 说明后续可走步骤, 复核本身免费; 新方案生成与深度辅导在支付后启动'
- 当前目标 placeholder: '先审计现有方案' → '先复核现有方案'
- 补充说明 placeholder: '想先看风险' → '想先看有没有明显风险'
- 主按钮: '获取推荐路径' → '获取复核与推荐'
- 次按钮: '直接看套餐' → '直接看付费套餐'
- hero-trust 1 号卡: '先审计后规划' → '复核免费 / 方案付费'
- hero-points lead: '先做审核' → '先做免费复核'; '再决定是否进入完整规划' → '再决定是否进入付费的完整规划'
- 服务流程 01 步: '选择服务深度' → '先判断入口' ('方案复核(免费) vs 完整方案/深度辅导(付费)')
- 服务流程 02 步: '避免在支付前被长表单劝退' → '选完整方案或深度辅导时, 先填最小信息后再支付'
- 服务流程导语: '比空泛地说智能系统已接入更重要' → '复核免费, 方案生成和深度辅导需付费'
- 底部 CTA 标题: '先看套餐, 再决定是否立即下单' → '已有方案? 先免费复核; 明确要做? 看付费套餐'
- 底部 CTA 副标题: '如果你已经明确要做完整方案, 可以直接进入 99 元方案页' → '如果已经拿到一版方案, 可先做一次免费复核; 明确要完整方案或深度辅导, 可直接进入套餐页'
测试更新 (admin/tests/test_web_public.py):
- 移除对'咨询本身免费' / '先审计后规划' / '先告诉我们你的基本情况' / '获取推荐路径' / '先看套餐' 的正向断言
- 改为反向断言 (确保旧文案已清除) + 新口径的正向断言
- 新增 9 条新口径断言: '复核现有方案本身免费' / '新方案生成与深度辅导在支付后启动' /
'复核免费 / 方案付费' / '获取复核与推荐' / '直接看付费套餐' / '方案复核(免费)' /
'深度辅导(付费)' / '已有方案? 先免费复核' / '告诉我们你的基本情况'
验证:
- pytest 22 passed (test_web_public + test_order_status_page + test_web_public_alipay_sim_e2e)
- 真实 HTTP 19/19 文案断言全部 OK (旧文案已清除, 新文案已上线)
- Playwright 截图 vision 验证: 5/5 触点对齐口径 (hero CTA / hero-note / 咨询表单 /
hero-trust 1 号卡 / 服务流程 + 底部 CTA)
2026-06-17 22:11:31 +08:00
|
|
|
|
assert "咨询本身免费" not in body
|
2026-06-25 09:27:06 +08:00
|
|
|
|
assert "咨询入口" not in body
|
fix(landing): align copy to '复核免费 / 方案付费' (no 免费咨询 promise)
用户最新反馈: 首页不应承诺'免费咨询'; 复核现有方案是免费的, 完整方案
生成和深度辅导需在支付后启动。咨询入口只用于快速问询, 不是承诺
免费生成方案。
文案改动 (admin/routes/web_public.py):
- hero-note: '咨询本身免费' → '复核现有方案本身免费; 新方案生成与深度辅导在支付后启动'
- 咨询表单标题: '先告诉我们你的基本情况' → '告诉我们你的基本情况'
- 咨询表单副标题: '判断 + 适合路径' → '判断 + 说明后续可走步骤, 复核本身免费; 新方案生成与深度辅导在支付后启动'
- 当前目标 placeholder: '先审计现有方案' → '先复核现有方案'
- 补充说明 placeholder: '想先看风险' → '想先看有没有明显风险'
- 主按钮: '获取推荐路径' → '获取复核与推荐'
- 次按钮: '直接看套餐' → '直接看付费套餐'
- hero-trust 1 号卡: '先审计后规划' → '复核免费 / 方案付费'
- hero-points lead: '先做审核' → '先做免费复核'; '再决定是否进入完整规划' → '再决定是否进入付费的完整规划'
- 服务流程 01 步: '选择服务深度' → '先判断入口' ('方案复核(免费) vs 完整方案/深度辅导(付费)')
- 服务流程 02 步: '避免在支付前被长表单劝退' → '选完整方案或深度辅导时, 先填最小信息后再支付'
- 服务流程导语: '比空泛地说智能系统已接入更重要' → '复核免费, 方案生成和深度辅导需付费'
- 底部 CTA 标题: '先看套餐, 再决定是否立即下单' → '已有方案? 先免费复核; 明确要做? 看付费套餐'
- 底部 CTA 副标题: '如果你已经明确要做完整方案, 可以直接进入 99 元方案页' → '如果已经拿到一版方案, 可先做一次免费复核; 明确要完整方案或深度辅导, 可直接进入套餐页'
测试更新 (admin/tests/test_web_public.py):
- 移除对'咨询本身免费' / '先审计后规划' / '先告诉我们你的基本情况' / '获取推荐路径' / '先看套餐' 的正向断言
- 改为反向断言 (确保旧文案已清除) + 新口径的正向断言
- 新增 9 条新口径断言: '复核现有方案本身免费' / '新方案生成与深度辅导在支付后启动' /
'复核免费 / 方案付费' / '获取复核与推荐' / '直接看付费套餐' / '方案复核(免费)' /
'深度辅导(付费)' / '已有方案? 先免费复核' / '告诉我们你的基本情况'
验证:
- pytest 22 passed (test_web_public + test_order_status_page + test_web_public_alipay_sim_e2e)
- 真实 HTTP 19/19 文案断言全部 OK (旧文案已清除, 新文案已上线)
- Playwright 截图 vision 验证: 5/5 触点对齐口径 (hero CTA / hero-note / 咨询表单 /
hero-trust 1 号卡 / 服务流程 + 底部 CTA)
2026-06-17 22:11:31 +08:00
|
|
|
|
assert "复核现有方案本身免费" in body
|
|
|
|
|
|
assert "新方案生成与深度辅导在支付后启动" in body
|
2026-06-26 12:30:43 +08:00
|
|
|
|
assert "先免费复核一次你的现有方案" in body
|
|
|
|
|
|
# hero 不再保留"了解服务流程"文字链
|
|
|
|
|
|
assert "了解服务流程" not in body
|
fix(landing): align copy to '复核免费 / 方案付费' (no 免费咨询 promise)
用户最新反馈: 首页不应承诺'免费咨询'; 复核现有方案是免费的, 完整方案
生成和深度辅导需在支付后启动。咨询入口只用于快速问询, 不是承诺
免费生成方案。
文案改动 (admin/routes/web_public.py):
- hero-note: '咨询本身免费' → '复核现有方案本身免费; 新方案生成与深度辅导在支付后启动'
- 咨询表单标题: '先告诉我们你的基本情况' → '告诉我们你的基本情况'
- 咨询表单副标题: '判断 + 适合路径' → '判断 + 说明后续可走步骤, 复核本身免费; 新方案生成与深度辅导在支付后启动'
- 当前目标 placeholder: '先审计现有方案' → '先复核现有方案'
- 补充说明 placeholder: '想先看风险' → '想先看有没有明显风险'
- 主按钮: '获取推荐路径' → '获取复核与推荐'
- 次按钮: '直接看套餐' → '直接看付费套餐'
- hero-trust 1 号卡: '先审计后规划' → '复核免费 / 方案付费'
- hero-points lead: '先做审核' → '先做免费复核'; '再决定是否进入完整规划' → '再决定是否进入付费的完整规划'
- 服务流程 01 步: '选择服务深度' → '先判断入口' ('方案复核(免费) vs 完整方案/深度辅导(付费)')
- 服务流程 02 步: '避免在支付前被长表单劝退' → '选完整方案或深度辅导时, 先填最小信息后再支付'
- 服务流程导语: '比空泛地说智能系统已接入更重要' → '复核免费, 方案生成和深度辅导需付费'
- 底部 CTA 标题: '先看套餐, 再决定是否立即下单' → '已有方案? 先免费复核; 明确要做? 看付费套餐'
- 底部 CTA 副标题: '如果你已经明确要做完整方案, 可以直接进入 99 元方案页' → '如果已经拿到一版方案, 可先做一次免费复核; 明确要完整方案或深度辅导, 可直接进入套餐页'
测试更新 (admin/tests/test_web_public.py):
- 移除对'咨询本身免费' / '先审计后规划' / '先告诉我们你的基本情况' / '获取推荐路径' / '先看套餐' 的正向断言
- 改为反向断言 (确保旧文案已清除) + 新口径的正向断言
- 新增 9 条新口径断言: '复核现有方案本身免费' / '新方案生成与深度辅导在支付后启动' /
'复核免费 / 方案付费' / '获取复核与推荐' / '直接看付费套餐' / '方案复核(免费)' /
'深度辅导(付费)' / '已有方案? 先免费复核' / '告诉我们你的基本情况'
验证:
- pytest 22 passed (test_web_public + test_order_status_page + test_web_public_alipay_sim_e2e)
- 真实 HTTP 19/19 文案断言全部 OK (旧文案已清除, 新文案已上线)
- Playwright 截图 vision 验证: 5/5 触点对齐口径 (hero CTA / hero-note / 咨询表单 /
hero-trust 1 号卡 / 服务流程 + 底部 CTA)
2026-06-17 22:11:31 +08:00
|
|
|
|
assert "获取复核与推荐" in body
|
|
|
|
|
|
assert "方案复核(免费)" in body
|
|
|
|
|
|
assert "深度辅导(付费)" in body
|
|
|
|
|
|
assert "已有方案?先免费复核" in body
|
2026-06-25 09:27:06 +08:00
|
|
|
|
assert "先告诉我们你的基本情况" not in body
|
fix(landing): align copy to '复核免费 / 方案付费' (no 免费咨询 promise)
用户最新反馈: 首页不应承诺'免费咨询'; 复核现有方案是免费的, 完整方案
生成和深度辅导需在支付后启动。咨询入口只用于快速问询, 不是承诺
免费生成方案。
文案改动 (admin/routes/web_public.py):
- hero-note: '咨询本身免费' → '复核现有方案本身免费; 新方案生成与深度辅导在支付后启动'
- 咨询表单标题: '先告诉我们你的基本情况' → '告诉我们你的基本情况'
- 咨询表单副标题: '判断 + 适合路径' → '判断 + 说明后续可走步骤, 复核本身免费; 新方案生成与深度辅导在支付后启动'
- 当前目标 placeholder: '先审计现有方案' → '先复核现有方案'
- 补充说明 placeholder: '想先看风险' → '想先看有没有明显风险'
- 主按钮: '获取推荐路径' → '获取复核与推荐'
- 次按钮: '直接看套餐' → '直接看付费套餐'
- hero-trust 1 号卡: '先审计后规划' → '复核免费 / 方案付费'
- hero-points lead: '先做审核' → '先做免费复核'; '再决定是否进入完整规划' → '再决定是否进入付费的完整规划'
- 服务流程 01 步: '选择服务深度' → '先判断入口' ('方案复核(免费) vs 完整方案/深度辅导(付费)')
- 服务流程 02 步: '避免在支付前被长表单劝退' → '选完整方案或深度辅导时, 先填最小信息后再支付'
- 服务流程导语: '比空泛地说智能系统已接入更重要' → '复核免费, 方案生成和深度辅导需付费'
- 底部 CTA 标题: '先看套餐, 再决定是否立即下单' → '已有方案? 先免费复核; 明确要做? 看付费套餐'
- 底部 CTA 副标题: '如果你已经明确要做完整方案, 可以直接进入 99 元方案页' → '如果已经拿到一版方案, 可先做一次免费复核; 明确要完整方案或深度辅导, 可直接进入套餐页'
测试更新 (admin/tests/test_web_public.py):
- 移除对'咨询本身免费' / '先审计后规划' / '先告诉我们你的基本情况' / '获取推荐路径' / '先看套餐' 的正向断言
- 改为反向断言 (确保旧文案已清除) + 新口径的正向断言
- 新增 9 条新口径断言: '复核现有方案本身免费' / '新方案生成与深度辅导在支付后启动' /
'复核免费 / 方案付费' / '获取复核与推荐' / '直接看付费套餐' / '方案复核(免费)' /
'深度辅导(付费)' / '已有方案? 先免费复核' / '告诉我们你的基本情况'
验证:
- pytest 22 passed (test_web_public + test_order_status_page + test_web_public_alipay_sim_e2e)
- 真实 HTTP 19/19 文案断言全部 OK (旧文案已清除, 新文案已上线)
- Playwright 截图 vision 验证: 5/5 触点对齐口径 (hero CTA / hero-note / 咨询表单 /
hero-trust 1 号卡 / 服务流程 + 底部 CTA)
2026-06-17 22:11:31 +08:00
|
|
|
|
assert "先审计后规划" not in body
|
fix(landing+pricing): A) consult form privacy note + B) pricing page copy sync
A) 首页咨询表单加隐私说明 (admin/routes/web_public.py):
- 在表单标题下方加 .consult-privacy 框:
'🔒 这些输入只用于判断要不要复核你的方案, 不会留底、不会用于生成方案、不会发邮件推销。
如果你决定不进入付费方案, 提交的资料不会保存到我们的数据库。'
- 在表单下方加 .consult-privacy-tail:
'不会收到营销短信, 提交后你也可以随时要求删除已填资料。'
- 新增 CSS: .consult-privacy (薄荷绿底+边框) / .consult-privacy-tail (灰色脚注)
- 设计意图: 不抢眼但能看见, 用浅色背景与表单区分, 不影响主 CTA 视觉权重
B) 套餐页 /pricing 同步'复核免费 / 方案付费' 口径:
- lead 文案加 <strong>复核现有方案本身免费</strong> (放在最前面)
- consult-summary 内加 consult-reassure 框:
'💡 如果你还没决定, 先做一次免费复核, 再决定要不要进入付费方案。'
- summary 副卡加 summary-reassure:
'🔒 还没下单前, 提交的基本情况仅用于判断是否需要复核, 不会留底。'
- trust-proof 1 号卡: '先审计再决定' → '复核免费 / 方案付费'
- trust-band 3 号卡: '合规入口可见' → '复核免费 / 方案付费' (新增回流入口)
- 49元档 eyebrow: '快速校验' → '复核 / 风险' (强调只审不改)
- 99元档 eyebrow: '完整规划' → '生成 / 完整' (强调生成动作)
- 199元档 eyebrow: '深度辅导' → '生成 / 深度'
- 49元档 desc: 加'不会重新生成志愿表 (生成需选 99 元)' 边界
- 99元档 desc: 加'方案生成与详细报告在支付后启动' 边界
- 199元档 desc: 加'在 99 元完整方案基础上提供多轮修订和深度解释'
- 49元档 CTA: '先做快速审核' → '先做付费审核' (用'付费'反衬'免费复核')
- 99元档 CTA: '立即开始完整规划' → '支付并启动方案生成'
- 199元档 CTA: '了解深度辅导' (保持)
- 49元档 features: 加'不重新生成志愿表(生成需选 99 元)' 边界
- 199元档 features: 加'包含 99 元完整方案的所有交付'
- FAQ 加 '复核是免费的吗?包含什么?' (明确边界: 复核免费/审核报告 49/完整方案 99起)
- 99元 FAQ 加 '99 元是生成一份完整方案的价格, 不是查看价格' 避免误解
- notice 提示: '先做一次免费复核' 引导 + 保留 49/99 路径建议
新增测试断言:
- 首页: '不会留底' / '不会用于生成方案' / '不会发邮件推销' / '不会收到营销短信' (4 条)
- 套餐页: '复核现有方案本身免费' / '复核免费 / 方案付费' / 'href="/#consult-box"' /
'先做一次免费复核' / '先做付费审核' / '支付并启动方案生成' / '复核 / 风险' /
'生成 / 完整' / '生成 / 深度' / '不会重新生成志愿表' / '支付后启动' /
'复核是免费的吗?包含什么?' / '还没决定' (13 条)
- 套餐页反向断言: '先做快速审核' / '立即开始完整规划' / '先审计再决定' / '快速校验' (4 条)
验证:
- pytest 22 passed (test_web_public + test_order_status_page + test_web_public_alipay_sim_e2e)
- ruff check: All checks passed
- 真实 HTTP 端到端 17/17 断言 OK (A 6 条 + B 20 条旧移除+新命中)
- Playwright vision 验证:
A) 首页隐私说明: 上下双向布局, 浅色克制, 覆盖'用途/营销/删除'三要素 ✅
B) 套餐页: lead/eyebrow/CTA/FAQ 5 处引导回首页 全部对齐'复核免费/方案付费' ✅
2026-06-17 22:38:42 +08:00
|
|
|
|
assert "不会留底" in body
|
|
|
|
|
|
assert "不会用于生成方案" in body
|
|
|
|
|
|
assert "不会发邮件推销" in body
|
|
|
|
|
|
assert "不会收到营销短信" in body
|
2026-06-26 12:30:43 +08:00
|
|
|
|
assert "了解服务流程" not in body
|
|
|
|
|
|
# hero 压缩后的新文案
|
2026-06-16 20:12:58 +08:00
|
|
|
|
assert "最常见的不是“不会选”,而是先选错方向" in body
|
2026-06-26 12:30:43 +08:00
|
|
|
|
assert "先看你的方案值不值得继续" in body
|
2026-06-16 15:02:01 +08:00
|
|
|
|
assert "服务流程" in body
|
2026-06-16 17:14:51 +08:00
|
|
|
|
assert "把风险解释清楚" in body
|
|
|
|
|
|
assert "家长决策支持" not in body
|
|
|
|
|
|
assert "家长联系方式" not in body
|
|
|
|
|
|
assert "把风险解释给家长听懂" not in body
|
2026-06-26 12:30:43 +08:00
|
|
|
|
# 首页省份必须使用下拉,不再是自由文本
|
2026-06-27 17:11:09 +08:00
|
|
|
|
assert 'name="province"' in body
|
2026-06-26 12:30:43 +08:00
|
|
|
|
assert 'placeholder="例如:湖南"' not in body
|
|
|
|
|
|
# hero 已移除三张说明卡
|
|
|
|
|
|
assert "复核免费 / 方案付费" not in body
|
|
|
|
|
|
assert "风险重点可解释" not in body
|
|
|
|
|
|
assert "资料与交付站内可追踪" not in body
|
|
|
|
|
|
# 流程编号已改为文字版
|
|
|
|
|
|
assert ">01<" not in body
|
|
|
|
|
|
assert ">04<" not in body
|
|
|
|
|
|
assert "/static/portal-ui.css" in body
|
2026-06-14 05:26:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
def test_public_pricing_page_served(client):
|
|
|
|
|
|
resp = client.get("/pricing")
|
2026-06-14 05:26:27 +08:00
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
|
assert "text/html" in resp.headers["content-type"]
|
|
|
|
|
|
body = resp.text
|
|
|
|
|
|
assert "服务套餐" in body
|
|
|
|
|
|
assert "49元 AI方案审核" in body
|
|
|
|
|
|
assert "99元 完整志愿方案" in body
|
|
|
|
|
|
assert "199元 深度辅导版" in body
|
|
|
|
|
|
assert 'data-package="audit"' in body
|
|
|
|
|
|
assert 'data-package="standard"' in body
|
|
|
|
|
|
assert 'data-package="premium"' in body
|
2026-06-16 15:02:01 +08:00
|
|
|
|
assert "推荐方案" in body
|
2026-06-17 18:50:08 +08:00
|
|
|
|
assert "你可以先从 99 元完整志愿方案开始" in body
|
2026-06-16 15:02:01 +08:00
|
|
|
|
assert "支付接入建设中" not in body
|
fix(landing+pricing): A) consult form privacy note + B) pricing page copy sync
A) 首页咨询表单加隐私说明 (admin/routes/web_public.py):
- 在表单标题下方加 .consult-privacy 框:
'🔒 这些输入只用于判断要不要复核你的方案, 不会留底、不会用于生成方案、不会发邮件推销。
如果你决定不进入付费方案, 提交的资料不会保存到我们的数据库。'
- 在表单下方加 .consult-privacy-tail:
'不会收到营销短信, 提交后你也可以随时要求删除已填资料。'
- 新增 CSS: .consult-privacy (薄荷绿底+边框) / .consult-privacy-tail (灰色脚注)
- 设计意图: 不抢眼但能看见, 用浅色背景与表单区分, 不影响主 CTA 视觉权重
B) 套餐页 /pricing 同步'复核免费 / 方案付费' 口径:
- lead 文案加 <strong>复核现有方案本身免费</strong> (放在最前面)
- consult-summary 内加 consult-reassure 框:
'💡 如果你还没决定, 先做一次免费复核, 再决定要不要进入付费方案。'
- summary 副卡加 summary-reassure:
'🔒 还没下单前, 提交的基本情况仅用于判断是否需要复核, 不会留底。'
- trust-proof 1 号卡: '先审计再决定' → '复核免费 / 方案付费'
- trust-band 3 号卡: '合规入口可见' → '复核免费 / 方案付费' (新增回流入口)
- 49元档 eyebrow: '快速校验' → '复核 / 风险' (强调只审不改)
- 99元档 eyebrow: '完整规划' → '生成 / 完整' (强调生成动作)
- 199元档 eyebrow: '深度辅导' → '生成 / 深度'
- 49元档 desc: 加'不会重新生成志愿表 (生成需选 99 元)' 边界
- 99元档 desc: 加'方案生成与详细报告在支付后启动' 边界
- 199元档 desc: 加'在 99 元完整方案基础上提供多轮修订和深度解释'
- 49元档 CTA: '先做快速审核' → '先做付费审核' (用'付费'反衬'免费复核')
- 99元档 CTA: '立即开始完整规划' → '支付并启动方案生成'
- 199元档 CTA: '了解深度辅导' (保持)
- 49元档 features: 加'不重新生成志愿表(生成需选 99 元)' 边界
- 199元档 features: 加'包含 99 元完整方案的所有交付'
- FAQ 加 '复核是免费的吗?包含什么?' (明确边界: 复核免费/审核报告 49/完整方案 99起)
- 99元 FAQ 加 '99 元是生成一份完整方案的价格, 不是查看价格' 避免误解
- notice 提示: '先做一次免费复核' 引导 + 保留 49/99 路径建议
新增测试断言:
- 首页: '不会留底' / '不会用于生成方案' / '不会发邮件推销' / '不会收到营销短信' (4 条)
- 套餐页: '复核现有方案本身免费' / '复核免费 / 方案付费' / 'href="/#consult-box"' /
'先做一次免费复核' / '先做付费审核' / '支付并启动方案生成' / '复核 / 风险' /
'生成 / 完整' / '生成 / 深度' / '不会重新生成志愿表' / '支付后启动' /
'复核是免费的吗?包含什么?' / '还没决定' (13 条)
- 套餐页反向断言: '先做快速审核' / '立即开始完整规划' / '先审计再决定' / '快速校验' (4 条)
验证:
- pytest 22 passed (test_web_public + test_order_status_page + test_web_public_alipay_sim_e2e)
- ruff check: All checks passed
- 真实 HTTP 端到端 17/17 断言 OK (A 6 条 + B 20 条旧移除+新命中)
- Playwright vision 验证:
A) 首页隐私说明: 上下双向布局, 浅色克制, 覆盖'用途/营销/删除'三要素 ✅
B) 套餐页: lead/eyebrow/CTA/FAQ 5 处引导回首页 全部对齐'复核免费/方案付费' ✅
2026-06-17 22:38:42 +08:00
|
|
|
|
assert "复核现有方案本身免费" in body
|
|
|
|
|
|
assert "复核免费 / 方案付费" in body
|
|
|
|
|
|
assert 'href="/#consult-box"' in body
|
|
|
|
|
|
assert "先做一次免费复核" in body
|
2026-06-27 23:16:26 +08:00
|
|
|
|
assert "选择付费审核" in body
|
2026-06-25 09:27:06 +08:00
|
|
|
|
assert "支付并启动方案生成" in body
|
2026-06-27 23:16:26 +08:00
|
|
|
|
assert "选择深度辅导" in body
|
fix(landing+pricing): A) consult form privacy note + B) pricing page copy sync
A) 首页咨询表单加隐私说明 (admin/routes/web_public.py):
- 在表单标题下方加 .consult-privacy 框:
'🔒 这些输入只用于判断要不要复核你的方案, 不会留底、不会用于生成方案、不会发邮件推销。
如果你决定不进入付费方案, 提交的资料不会保存到我们的数据库。'
- 在表单下方加 .consult-privacy-tail:
'不会收到营销短信, 提交后你也可以随时要求删除已填资料。'
- 新增 CSS: .consult-privacy (薄荷绿底+边框) / .consult-privacy-tail (灰色脚注)
- 设计意图: 不抢眼但能看见, 用浅色背景与表单区分, 不影响主 CTA 视觉权重
B) 套餐页 /pricing 同步'复核免费 / 方案付费' 口径:
- lead 文案加 <strong>复核现有方案本身免费</strong> (放在最前面)
- consult-summary 内加 consult-reassure 框:
'💡 如果你还没决定, 先做一次免费复核, 再决定要不要进入付费方案。'
- summary 副卡加 summary-reassure:
'🔒 还没下单前, 提交的基本情况仅用于判断是否需要复核, 不会留底。'
- trust-proof 1 号卡: '先审计再决定' → '复核免费 / 方案付费'
- trust-band 3 号卡: '合规入口可见' → '复核免费 / 方案付费' (新增回流入口)
- 49元档 eyebrow: '快速校验' → '复核 / 风险' (强调只审不改)
- 99元档 eyebrow: '完整规划' → '生成 / 完整' (强调生成动作)
- 199元档 eyebrow: '深度辅导' → '生成 / 深度'
- 49元档 desc: 加'不会重新生成志愿表 (生成需选 99 元)' 边界
- 99元档 desc: 加'方案生成与详细报告在支付后启动' 边界
- 199元档 desc: 加'在 99 元完整方案基础上提供多轮修订和深度解释'
- 49元档 CTA: '先做快速审核' → '先做付费审核' (用'付费'反衬'免费复核')
- 99元档 CTA: '立即开始完整规划' → '支付并启动方案生成'
- 199元档 CTA: '了解深度辅导' (保持)
- 49元档 features: 加'不重新生成志愿表(生成需选 99 元)' 边界
- 199元档 features: 加'包含 99 元完整方案的所有交付'
- FAQ 加 '复核是免费的吗?包含什么?' (明确边界: 复核免费/审核报告 49/完整方案 99起)
- 99元 FAQ 加 '99 元是生成一份完整方案的价格, 不是查看价格' 避免误解
- notice 提示: '先做一次免费复核' 引导 + 保留 49/99 路径建议
新增测试断言:
- 首页: '不会留底' / '不会用于生成方案' / '不会发邮件推销' / '不会收到营销短信' (4 条)
- 套餐页: '复核现有方案本身免费' / '复核免费 / 方案付费' / 'href="/#consult-box"' /
'先做一次免费复核' / '先做付费审核' / '支付并启动方案生成' / '复核 / 风险' /
'生成 / 完整' / '生成 / 深度' / '不会重新生成志愿表' / '支付后启动' /
'复核是免费的吗?包含什么?' / '还没决定' (13 条)
- 套餐页反向断言: '先做快速审核' / '立即开始完整规划' / '先审计再决定' / '快速校验' (4 条)
验证:
- pytest 22 passed (test_web_public + test_order_status_page + test_web_public_alipay_sim_e2e)
- ruff check: All checks passed
- 真实 HTTP 端到端 17/17 断言 OK (A 6 条 + B 20 条旧移除+新命中)
- Playwright vision 验证:
A) 首页隐私说明: 上下双向布局, 浅色克制, 覆盖'用途/营销/删除'三要素 ✅
B) 套餐页: lead/eyebrow/CTA/FAQ 5 处引导回首页 全部对齐'复核免费/方案付费' ✅
2026-06-17 22:38:42 +08:00
|
|
|
|
assert "先做快速审核" not in body
|
|
|
|
|
|
assert "立即开始完整规划" not in body
|
|
|
|
|
|
assert "先审计再决定" not in body
|
|
|
|
|
|
assert "快速校验" not in body
|
|
|
|
|
|
assert "复核是免费的吗?包含什么?" in body
|
|
|
|
|
|
assert "还没决定" in body
|
2026-06-26 12:30:43 +08:00
|
|
|
|
assert "/static/portal-ui.css" in body
|
2026-06-16 15:02:01 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
def test_public_checkout_page_served(client):
|
|
|
|
|
|
resp = client.get("/checkout/standard")
|
2026-06-16 15:02:01 +08:00
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
|
assert "text/html" in resp.headers["content-type"]
|
|
|
|
|
|
body = resp.text
|
|
|
|
|
|
assert "完整志愿方案" in body
|
|
|
|
|
|
assert "订单摘要" in body
|
2026-06-27 23:16:26 +08:00
|
|
|
|
assert "退款政策" in body
|
2026-06-16 15:02:01 +08:00
|
|
|
|
assert "立即支付" in body
|
2026-06-27 23:16:26 +08:00
|
|
|
|
assert "先下单再补资料" in body
|
2026-06-16 17:14:51 +08:00
|
|
|
|
assert "当前建议" in body
|
|
|
|
|
|
assert "交付方式" in body
|
2026-06-16 15:02:01 +08:00
|
|
|
|
assert "最小下单" not in body
|
2026-06-26 12:30:43 +08:00
|
|
|
|
assert "/static/portal-ui.css" in body
|
2026-06-14 05:26:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
def test_public_create_order_endpoint(client, app):
|
|
|
|
|
|
resp = client.post(
|
2026-06-14 05:26:27 +08:00
|
|
|
|
"/api/public/orders",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"service_version": "standard",
|
|
|
|
|
|
"amount_cents": 9900,
|
|
|
|
|
|
"customer_phone": "13800138000",
|
2026-06-15 11:39:31 +08:00
|
|
|
|
"customer_email": "parent@example.com",
|
2026-06-14 05:26:27 +08:00
|
|
|
|
"candidate_name": "张三",
|
|
|
|
|
|
"candidate_province": "湖南",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 201, resp.text
|
|
|
|
|
|
body = resp.json()
|
|
|
|
|
|
assert body["order_id"].startswith("GKO-")
|
|
|
|
|
|
assert body["source"] == "web"
|
|
|
|
|
|
assert body["status"] == "pending"
|
|
|
|
|
|
assert body["service_version"] == "standard"
|
|
|
|
|
|
assert body["amount_cents"] == 9900
|
|
|
|
|
|
assert body["next_step"] == "payment"
|
|
|
|
|
|
assert body["checkout_url"].startswith("/pay/mock/")
|
|
|
|
|
|
assert "/portal/" in body["portal_status_url"]
|
2026-06-25 09:27:06 +08:00
|
|
|
|
with OrdersDAO.connect(app.state.settings.orders_db_path) as dao:
|
2026-06-15 11:39:31 +08:00
|
|
|
|
created = dao.get(body["order_id"])
|
|
|
|
|
|
assert created.customer_email == "parent@example.com"
|
2026-06-16 12:16:45 +08:00
|
|
|
|
assert created.candidate_name == "张三"
|
2026-06-14 05:26:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 12:30:43 +08:00
|
|
|
|
def test_public_create_order_returns_503_with_friendly_message_when_encryption_key_missing(
|
|
|
|
|
|
tmp_path, monkeypatch
|
|
|
|
|
|
):
|
2026-06-16 15:02:01 +08:00
|
|
|
|
admin_db = tmp_path / "admin.db"
|
|
|
|
|
|
orders_db = tmp_path / "orders.db"
|
|
|
|
|
|
share_db = tmp_path / "share.db"
|
|
|
|
|
|
share_reports = tmp_path / "share_reports"
|
|
|
|
|
|
share_reports.mkdir()
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ENV", "dev")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_DB_PATH", str(admin_db))
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", str(orders_db))
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_SHARE_DB_PATH", str(share_db))
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_SHARE_REPORT_DIR", str(share_reports))
|
|
|
|
|
|
monkeypatch.delenv("GAOKAO_ORDERS_FERNET_KEY", raising=False)
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64)
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
|
|
|
|
|
|
|
|
|
|
|
|
from admin.app import create_app
|
|
|
|
|
|
from admin.config import load_settings
|
|
|
|
|
|
from data.orders import crypto
|
|
|
|
|
|
|
|
|
|
|
|
crypto.get_fernet.cache_clear()
|
|
|
|
|
|
|
|
|
|
|
|
settings = load_settings()
|
|
|
|
|
|
app = create_app(settings)
|
|
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
with TestClient(app) as client:
|
|
|
|
|
|
resp = client.post(
|
2026-06-16 15:02:01 +08:00
|
|
|
|
"/api/public/orders",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"service_version": "standard",
|
|
|
|
|
|
"amount_cents": 9900,
|
|
|
|
|
|
"customer_phone": "13800138000",
|
|
|
|
|
|
"candidate_name": "张三",
|
|
|
|
|
|
"candidate_province": "湖南",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert resp.status_code == 503, resp.text
|
|
|
|
|
|
body = resp.json()
|
|
|
|
|
|
assert "当前暂时无法创建订单" in body["detail"]["reason"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
def test_public_create_order_rejects_missing_minimal_fields(client):
|
|
|
|
|
|
resp = client.post(
|
2026-06-14 05:26:27 +08:00
|
|
|
|
"/api/public/orders",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"service_version": "audit",
|
|
|
|
|
|
"amount_cents": 4900,
|
|
|
|
|
|
"customer_name": "李家长",
|
|
|
|
|
|
"candidate_province": "广东",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 422
|
|
|
|
|
|
body = resp.json()
|
|
|
|
|
|
assert body["message"] == "请求数据未通过校验"
|
2026-06-16 12:16:45 +08:00
|
|
|
|
assert any(
|
|
|
|
|
|
field["field"] in {"body.customer_phone", "body.candidate_name"}
|
|
|
|
|
|
for field in body["detail"]["fields"]
|
2026-06-14 05:26:27 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
def test_public_create_order_rejects_price_tampering(client):
|
|
|
|
|
|
resp = client.post(
|
2026-06-14 05:26:27 +08:00
|
|
|
|
"/api/public/orders",
|
|
|
|
|
|
json={
|
2026-06-25 09:27:06 +08:00
|
|
|
|
"service_version": "audit",
|
|
|
|
|
|
"amount_cents": 4999,
|
2026-06-14 05:26:27 +08:00
|
|
|
|
"customer_name": "张家长",
|
|
|
|
|
|
"customer_phone": "13800138000",
|
2026-06-16 12:16:45 +08:00
|
|
|
|
"candidate_name": "张三",
|
2026-06-14 05:26:27 +08:00
|
|
|
|
"candidate_province": "湖南",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 422
|
|
|
|
|
|
body = resp.json()
|
|
|
|
|
|
assert body["message"] == "请求数据未通过校验"
|
2026-06-25 09:27:06 +08:00
|
|
|
|
assert any(
|
|
|
|
|
|
"amount_cents 与套餐价格不一致" in field["reason"]
|
|
|
|
|
|
for field in body["detail"]["fields"]
|
2026-06-14 05:26:27 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
def test_public_create_order_persists_candidate_province(client, app):
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
"/api/public/orders",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"service_version": "audit",
|
|
|
|
|
|
"amount_cents": 4900,
|
|
|
|
|
|
"customer_name": "李家长",
|
|
|
|
|
|
"customer_phone": "13800138000",
|
|
|
|
|
|
"candidate_name": "张三",
|
|
|
|
|
|
"candidate_province": "广东",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 201, resp.text
|
|
|
|
|
|
body = resp.json()
|
|
|
|
|
|
with OrdersDAO.connect(app.state.settings.orders_db_path) as dao:
|
|
|
|
|
|
created = dao.get(body["order_id"])
|
|
|
|
|
|
assert created.candidate_province == "广东"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_public_create_order_rejects_unsupported_province(client):
|
|
|
|
|
|
resp = client.post(
|
2026-06-14 05:26:27 +08:00
|
|
|
|
"/api/public/orders",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"service_version": "standard",
|
|
|
|
|
|
"amount_cents": 9900,
|
2026-06-25 09:27:06 +08:00
|
|
|
|
"customer_name": "李家长",
|
2026-06-14 05:26:27 +08:00
|
|
|
|
"customer_phone": "13800138000",
|
2026-06-16 12:16:45 +08:00
|
|
|
|
"candidate_name": "张三",
|
2026-06-25 09:27:06 +08:00
|
|
|
|
"candidate_province": "内蒙古",
|
2026-06-14 05:26:27 +08:00
|
|
|
|
},
|
|
|
|
|
|
)
|
2026-06-25 09:27:06 +08:00
|
|
|
|
assert resp.status_code == 422
|
|
|
|
|
|
body = resp.json()
|
|
|
|
|
|
assert body["message"] == "请求数据未通过校验"
|
|
|
|
|
|
assert "candidate_province" in resp.text
|
|
|
|
|
|
|
2026-06-14 05:26:27 +08:00
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
def test_public_create_order_supports_optional_contact_email(client, app):
|
|
|
|
|
|
resp = client.post(
|
2026-06-14 19:40:02 +08:00
|
|
|
|
"/api/public/orders",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"service_version": "standard",
|
|
|
|
|
|
"amount_cents": 9900,
|
2026-06-25 09:27:06 +08:00
|
|
|
|
"customer_name": "李家长",
|
2026-06-14 19:40:02 +08:00
|
|
|
|
"customer_phone": "13800138000",
|
2026-06-25 09:27:06 +08:00
|
|
|
|
"customer_email": "guardian@example.com",
|
2026-06-16 12:16:45 +08:00
|
|
|
|
"candidate_name": "张三",
|
2026-06-14 19:40:02 +08:00
|
|
|
|
"candidate_province": "湖南",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
2026-06-25 09:27:06 +08:00
|
|
|
|
assert resp.status_code == 201, resp.text
|
|
|
|
|
|
body = resp.json()
|
|
|
|
|
|
with OrdersDAO.connect(app.state.settings.orders_db_path) as dao:
|
|
|
|
|
|
created = dao.get(body["order_id"])
|
|
|
|
|
|
assert created.customer_email == "guardian@example.com"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_public_checkout_page_renders_audit_package(client):
|
|
|
|
|
|
resp = client.get("/checkout/audit")
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
body = resp.text
|
|
|
|
|
|
assert "49元 AI方案审核" in body
|
|
|
|
|
|
assert "适合已经拿到其他方案、希望先快速校验风险的家庭。" in body
|
|
|
|
|
|
assert "当前建议" in body
|
|
|
|
|
|
assert "当前这一步只收会影响下单与后续联系的必要信息" in body
|
|
|
|
|
|
assert "支付成功后,再进入资料向导补充分数、位次、偏好和已有方案附件。" in body
|
|
|
|
|
|
assert "service_version: 'audit'" in body
|
|
|
|
|
|
assert "amount_cents: 4900" in body
|
2026-06-14 19:40:02 +08:00
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
|
|
|
|
|
|
def test_public_checkout_page_renders_premium_package(client):
|
|
|
|
|
|
resp = client.get("/checkout/premium")
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
body = resp.text
|
|
|
|
|
|
assert "199元 深度辅导版" in body
|
|
|
|
|
|
assert "适合需要更多沟通、补充说明与深度修订支持的家庭。" in body
|
|
|
|
|
|
assert "199元 深度辅导版" in body
|
|
|
|
|
|
assert "service_version: 'premium'" in body
|
|
|
|
|
|
assert "amount_cents: 19900" in body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_public_create_order_rejects_unknown_service_version(client):
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
"/api/public/orders",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"service_version": "unknown",
|
|
|
|
|
|
"amount_cents": 9999,
|
|
|
|
|
|
"customer_name": "李家长",
|
|
|
|
|
|
"customer_phone": "13800138000",
|
|
|
|
|
|
"candidate_name": "张三",
|
|
|
|
|
|
"candidate_province": "湖南",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 422
|
|
|
|
|
|
body = resp.json()
|
|
|
|
|
|
assert body["message"] == "请求数据未通过校验"
|
|
|
|
|
|
assert any(
|
2026-06-26 12:30:43 +08:00
|
|
|
|
field["field"] == "body.service_version" for field in body["detail"]["fields"]
|
2026-06-18 10:21:38 +08:00
|
|
|
|
)
|
2026-06-17 18:50:08 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
def test_public_payment_flow_returns_payment_success_page(client, settings):
|
|
|
|
|
|
create_resp = client.post(
|
2026-06-17 18:50:08 +08:00
|
|
|
|
"/api/public/orders",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"service_version": "standard",
|
|
|
|
|
|
"amount_cents": 9900,
|
|
|
|
|
|
"customer_name": "张家长",
|
|
|
|
|
|
"customer_phone": "13800138000",
|
|
|
|
|
|
"candidate_name": "张三",
|
|
|
|
|
|
"candidate_province": "湖南",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
|
|
body = create_resp.json()
|
|
|
|
|
|
payment_id = body["checkout_url"].split("/pay/mock/")[1].split("?")[0]
|
|
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
complete = client.post(f"/pay/mock/{payment_id}/complete", follow_redirects=False)
|
2026-06-17 18:50:08 +08:00
|
|
|
|
assert complete.status_code == 303, complete.text
|
2026-06-25 09:27:06 +08:00
|
|
|
|
assert complete.headers["location"].endswith("/payment-success")
|
2026-06-26 12:30:43 +08:00
|
|
|
|
token = (
|
|
|
|
|
|
complete.headers["location"].split("/portal/")[1].split("/payment-success")[0]
|
|
|
|
|
|
)
|
2026-06-17 18:50:08 +08:00
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
page = client.get(f"/portal/{token}/payment-success")
|
2026-06-17 18:50:08 +08:00
|
|
|
|
assert page.status_code == 200, page.text
|
|
|
|
|
|
assert "订单已创建,下一步继续补资料" in page.text
|
2026-06-25 09:27:06 +08:00
|
|
|
|
assert "支付状态:paid" in page.text
|
2026-06-17 18:50:08 +08:00
|
|
|
|
assert "立即补充资料" in page.text
|
2026-06-25 09:27:06 +08:00
|
|
|
|
assert "查看订单进度" in page.text
|
2026-06-14 23:12:18 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
def test_payment_success_page_shows_audit_copy_for_audit_orders(client, settings):
|
|
|
|
|
|
create_resp = client.post(
|
2026-06-18 10:21:38 +08:00
|
|
|
|
"/api/public/orders",
|
|
|
|
|
|
json={
|
2026-06-25 09:27:06 +08:00
|
|
|
|
"service_version": "audit",
|
|
|
|
|
|
"amount_cents": 4900,
|
2026-06-18 10:21:38 +08:00
|
|
|
|
"customer_phone": "13800138000",
|
2026-06-25 09:27:06 +08:00
|
|
|
|
"candidate_name": "李同学",
|
2026-06-18 10:21:38 +08:00
|
|
|
|
"candidate_province": "湖南",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
|
|
body = create_resp.json()
|
|
|
|
|
|
payment_id = body["checkout_url"].split("/pay/mock/")[1].split("?")[0]
|
2026-06-25 09:27:06 +08:00
|
|
|
|
complete = client.post(f"/pay/mock/{payment_id}/complete", follow_redirects=False)
|
2026-06-18 10:21:38 +08:00
|
|
|
|
assert complete.status_code == 303, complete.text
|
2026-06-25 09:27:06 +08:00
|
|
|
|
assert complete.headers["location"].endswith("/payment-success")
|
2026-06-26 12:30:43 +08:00
|
|
|
|
token = (
|
|
|
|
|
|
complete.headers["location"].split("/portal/")[1].split("/payment-success")[0]
|
|
|
|
|
|
)
|
2026-06-18 10:21:38 +08:00
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
page = client.get(f"/portal/{token}/payment-success")
|
2026-06-18 10:21:38 +08:00
|
|
|
|
assert page.status_code == 200, page.text
|
2026-06-25 09:27:06 +08:00
|
|
|
|
assert "订单已创建,下一步继续补资料" in page.text
|
|
|
|
|
|
assert "补充基础信息" in page.text
|
|
|
|
|
|
assert "填写偏好目标" in page.text
|
|
|
|
|
|
assert "持续查看进度" in page.text
|
|
|
|
|
|
|
2026-06-18 10:21:38 +08:00
|
|
|
|
|
|
|
|
|
|
def test_prod_hides_simulated_payment_entrypoints(tmp_path, monkeypatch):
|
|
|
|
|
|
admin_db = tmp_path / "admin.db"
|
|
|
|
|
|
orders_db = tmp_path / "orders.db"
|
|
|
|
|
|
share_db = tmp_path / "share.db"
|
|
|
|
|
|
share_reports = tmp_path / "share_reports"
|
|
|
|
|
|
share_reports.mkdir()
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ENV", "prod")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_DB_PATH", str(admin_db))
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", str(orders_db))
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_SHARE_DB_PATH", str(share_db))
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_SHARE_REPORT_DIR", str(share_reports))
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-web-self-service")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64)
|
2026-06-25 09:27:06 +08:00
|
|
|
|
monkeypatch.setenv("GAOKAO_PAYMENT_WEBHOOK_SECRET", "prod-secret-ok-123456")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_PORTAL_TOKEN_SECRET", "prod-portal-secret-ok-1234567890")
|
2026-06-18 10:21:38 +08:00
|
|
|
|
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
|
2026-06-25 09:27:06 +08:00
|
|
|
|
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
|
2026-06-18 10:21:38 +08:00
|
|
|
|
monkeypatch.setenv("GAOKAO_PAYMENT_PROVIDER", "alipay")
|
2026-06-25 09:27:06 +08:00
|
|
|
|
monkeypatch.setenv("GAOKAO_PAYMENT_BASE_URL", "http://testserver")
|
2026-06-18 10:21:38 +08:00
|
|
|
|
|
|
|
|
|
|
from admin.config import load_settings
|
|
|
|
|
|
|
|
|
|
|
|
settings = load_settings()
|
2026-06-27 13:23:08 +08:00
|
|
|
|
request = Request({
|
|
|
|
|
|
"type": "http",
|
|
|
|
|
|
"method": "POST",
|
|
|
|
|
|
"path": "/api/public/payments/mock/webhook",
|
|
|
|
|
|
"headers": [],
|
|
|
|
|
|
"query_string": b"",
|
|
|
|
|
|
"server": ("testserver", 80),
|
|
|
|
|
|
"client": ("127.0.0.1", 12345),
|
|
|
|
|
|
"scheme": "http",
|
|
|
|
|
|
})
|
2026-06-18 10:21:38 +08:00
|
|
|
|
|
|
|
|
|
|
for route_call in (
|
2026-06-18 15:04:46 +08:00
|
|
|
|
lambda: mock_payment_page("pay_123", settings),
|
|
|
|
|
|
lambda: complete_mock_payment("pay_123", settings),
|
|
|
|
|
|
lambda: alipay_sim_payment_page("pay_123", settings),
|
|
|
|
|
|
lambda: complete_alipay_sim_payment("pay_123", settings),
|
2026-06-18 10:21:38 +08:00
|
|
|
|
lambda: mock_payment_webhook({}, request, settings),
|
|
|
|
|
|
):
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
|
route_call()
|
|
|
|
|
|
assert exc_info.value.status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 12:30:43 +08:00
|
|
|
|
def test_public_create_order_returns_503_without_creating_orphan_order_when_provider_unavailable(
|
|
|
|
|
|
tmp_path, monkeypatch
|
|
|
|
|
|
):
|
2026-06-14 23:12:18 +08:00
|
|
|
|
admin_db = tmp_path / "admin.db"
|
|
|
|
|
|
orders_db = tmp_path / "orders.db"
|
|
|
|
|
|
share_db = tmp_path / "share.db"
|
|
|
|
|
|
share_reports = tmp_path / "share_reports"
|
|
|
|
|
|
share_reports.mkdir()
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ENV", "dev")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_DB_PATH", str(admin_db))
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", str(orders_db))
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_SHARE_DB_PATH", str(share_db))
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_SHARE_REPORT_DIR", str(share_reports))
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-web-self-service")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64)
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_PAYMENT_PROVIDER", "alipay")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_PAYMENT_BASE_URL", "http://testserver")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_PAYMENT_WEBHOOK_SECRET", "missing-real-provider-env")
|
|
|
|
|
|
|
|
|
|
|
|
from admin.app import create_app
|
|
|
|
|
|
from admin.config import load_settings
|
|
|
|
|
|
|
|
|
|
|
|
settings = load_settings()
|
|
|
|
|
|
app = create_app(settings)
|
|
|
|
|
|
|
2026-06-25 09:27:06 +08:00
|
|
|
|
with TestClient(app) as client:
|
|
|
|
|
|
resp = client.post(
|
2026-06-14 23:12:18 +08:00
|
|
|
|
"/api/public/orders",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"service_version": "standard",
|
|
|
|
|
|
"amount_cents": 9900,
|
|
|
|
|
|
"customer_name": "张家长",
|
|
|
|
|
|
"customer_phone": "13800138000",
|
2026-06-16 12:16:45 +08:00
|
|
|
|
"candidate_name": "张三",
|
2026-06-14 23:12:18 +08:00
|
|
|
|
"candidate_province": "湖南",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert resp.status_code == 503, resp.text
|
|
|
|
|
|
assert "payment provider unavailable" in resp.text
|
|
|
|
|
|
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
|
|
|
|
|
assert dao.count() == 0
|
2026-06-26 18:02:10 +08:00
|
|
|
|
|
2026-06-27 13:23:08 +08:00
|
|
|
|
|
2026-06-26 18:02:10 +08:00
|
|
|
|
def test_my_orders_page_served(client):
|
|
|
|
|
|
"""我的订单页必须存在且可访问。"""
|
|
|
|
|
|
resp = client.get("/my-orders")
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
assert "我的订单" in resp.text
|
|
|
|
|
|
assert "手机号" in resp.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_my_orders_lookup_by_phone(client, settings):
|
2026-06-27 13:23:08 +08:00
|
|
|
|
"""C 方案:手机号直查已废弃,凭手机号访问 /my-orders 应显示引导说明而非订单列表。"""
|
2026-06-26 18:02:10 +08:00
|
|
|
|
create_resp = client.post(
|
|
|
|
|
|
"/api/public/orders",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"service_version": "standard",
|
|
|
|
|
|
"amount_cents": 9900,
|
|
|
|
|
|
"customer_phone": "13800138000",
|
|
|
|
|
|
"candidate_name": "张三",
|
|
|
|
|
|
"candidate_province": "湖南",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
|
|
|
2026-06-27 13:23:08 +08:00
|
|
|
|
# 手机号查询不再返回订单列表,而是显示引导说明
|
2026-06-26 18:02:10 +08:00
|
|
|
|
resp = client.get("/my-orders?phone=13800138000")
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
2026-06-27 13:23:08 +08:00
|
|
|
|
assert "请通过订单链接进入" in resp.text
|
|
|
|
|
|
|
2026-06-26 19:18:14 +08:00
|
|
|
|
|
|
|
|
|
|
def test_my_reports_page_served(client):
|
2026-06-27 13:23:08 +08:00
|
|
|
|
"""C 方案:我的报告页存在但不再显示手机号查询表单。"""
|
2026-06-26 19:18:14 +08:00
|
|
|
|
resp = client.get("/my-reports")
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
assert "我的报告" in resp.text
|
2026-06-27 13:23:08 +08:00
|
|
|
|
assert "请通过订单链接进入" in resp.text
|