refactor: 优化API路由和响应模型 feat(admin): 添加App用户管理接口 feat(sms): 实现阿里云短信服务集成 feat(email): 添加SMTP邮件发送功能 feat(upload): 支持文件上传接口 feat(rate-limiter): 实现手机号限流器 fix: 修复计算步骤入库问题 docs: 更新API文档和测试计划 chore: 更新依赖和配置
381 lines
18 KiB
Python
381 lines
18 KiB
Python
import os
|
||
import sys
|
||
import json
|
||
import time
|
||
import uuid
|
||
import random
|
||
from typing import Dict, Any, List, Tuple, Optional
|
||
|
||
import httpx
|
||
|
||
|
||
def now_ms() -> int:
|
||
return int(time.time() * 1000)
|
||
|
||
|
||
def make_url(base_url: str, path: str) -> str:
|
||
if base_url.endswith("/"):
|
||
base_url = base_url[:-1]
|
||
return f"{base_url}{path}"
|
||
|
||
|
||
def write_html_report(filepath: str, title: str, results: List[Dict[str, Any]]) -> None:
|
||
"""
|
||
生成HTML测试报告
|
||
参数:
|
||
filepath: 报告输出文件路径
|
||
title: 报告标题
|
||
results: 测试结果列表,包含 name/status/message/detail
|
||
返回:
|
||
None
|
||
"""
|
||
rows = []
|
||
for r in results:
|
||
color = {"PASS": "#4caf50", "FAIL": "#f44336"}.get(r.get("status"), "#9e9e9e")
|
||
rows.append(
|
||
f"<tr><td>{r.get('name')}</td><td style='color:{color};font-weight:600'>{r.get('status')}</td><td>{r.get('message','')}</td><td><pre>{json.dumps(r.get('detail', {}), ensure_ascii=False, indent=2)}</pre></td></tr>"
|
||
)
|
||
html = f"""
|
||
<!doctype html>
|
||
<html><head><meta charset='utf-8'><title>{title}</title>
|
||
<style>body{{font-family:Arial;padding:12px}} table{{border-collapse:collapse;width:100%}} td,th{{border:1px solid #ddd;padding:8px}}</style>
|
||
</head><body>
|
||
<h2>{title}</h2>
|
||
<p>生成时间: {time.strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||
<table><thead><tr><th>用例</th><th>结果</th><th>说明</th><th>详情</th></tr></thead><tbody>
|
||
{''.join(rows)}
|
||
</tbody></table>
|
||
</body></html>
|
||
"""
|
||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||
with open(filepath, "w", encoding="utf-8") as f:
|
||
f.write(html)
|
||
|
||
|
||
def _ensure_dict(obj: Any) -> Dict[str, Any]:
|
||
if isinstance(obj, dict):
|
||
return obj
|
||
return {"raw": str(obj)}
|
||
|
||
|
||
async def api_post_json(client: httpx.AsyncClient, url: str, payload: Dict[str, Any], headers: Optional[Dict[str, str]] = None) -> Tuple[int, Dict[str, Any]]:
|
||
"""
|
||
发送POST JSON请求
|
||
参数:
|
||
client: httpx异步客户端
|
||
url: 完整URL
|
||
payload: 请求体JSON
|
||
headers: 请求头
|
||
返回:
|
||
(状态码, 响应JSON)
|
||
"""
|
||
r = await client.post(url, json=payload, headers=headers or {})
|
||
try:
|
||
parsed = r.json()
|
||
except Exception:
|
||
parsed = {"raw": r.text}
|
||
if parsed is None:
|
||
parsed = {"raw": r.text}
|
||
return r.status_code, _ensure_dict(parsed)
|
||
|
||
|
||
async def api_get(client: httpx.AsyncClient, url: str, headers: Optional[Dict[str, str]] = None, params: Optional[Dict[str, Any]] = None) -> Tuple[int, Dict[str, Any]]:
|
||
"""
|
||
发送GET请求
|
||
参数:
|
||
client: httpx异步客户端
|
||
url: 完整URL
|
||
headers: 请求头
|
||
params: 查询参数
|
||
返回:
|
||
(状态码, 响应JSON)
|
||
"""
|
||
r = await client.get(url, headers=headers or {}, params=params or {})
|
||
try:
|
||
parsed = r.json()
|
||
except Exception:
|
||
parsed = {"raw": r.text}
|
||
if parsed is None:
|
||
parsed = {"raw": r.text}
|
||
return r.status_code, _ensure_dict(parsed)
|
||
|
||
|
||
async def api_put_json(client: httpx.AsyncClient, url: str, payload: Dict[str, Any], headers: Optional[Dict[str, str]] = None) -> Tuple[int, Dict[str, Any]]:
|
||
"""
|
||
发送PUT JSON请求
|
||
参数:
|
||
client: httpx异步客户端
|
||
url: 完整URL
|
||
payload: 请求体JSON
|
||
headers: 请求头
|
||
返回:
|
||
(状态码, 响应JSON)
|
||
"""
|
||
r = await client.put(url, json=payload, headers=headers or {})
|
||
try:
|
||
parsed = r.json()
|
||
except Exception:
|
||
parsed = {"raw": r.text}
|
||
if parsed is None:
|
||
parsed = {"raw": r.text}
|
||
return r.status_code, _ensure_dict(parsed)
|
||
|
||
|
||
async def user_register_flow(base_url: str, client: httpx.AsyncClient, phone: str, expect_success: bool = True) -> Dict[str, Any]:
|
||
"""
|
||
用户注册流程
|
||
参数:
|
||
base_url: 基础URL(含 /api/v1)
|
||
client: httpx客户端
|
||
phone: 手机号
|
||
返回:
|
||
测试结果字典
|
||
"""
|
||
url = make_url(base_url, "/app-user/register")
|
||
code, data = await api_post_json(client, url, {"phone": phone})
|
||
rs = {"name": f"注册-{phone}", "status": "FAIL", "message": "", "detail": {"http": code, "body": _ensure_dict(data)}}
|
||
body = _ensure_dict(data)
|
||
payload = _ensure_dict(body.get("data"))
|
||
ok = (body.get("code") == 200 and payload.get("phone") == phone)
|
||
# 期望失败场景:重复注册或无效格式
|
||
if not expect_success:
|
||
ok = (body.get("code") in (400, 422) or (isinstance(body.get("msg"), str) and "已存在" in body.get("msg")))
|
||
rs["message"] = "注册失败(符合预期)" if ok else "注册失败(不符合预期)"
|
||
else:
|
||
rs["message"] = "注册成功" if ok else "注册失败"
|
||
rs["status"] = "PASS" if ok else "FAIL"
|
||
return rs
|
||
|
||
|
||
async def user_login_flow(base_url: str, client: httpx.AsyncClient, phone: str, password: str, expect_success: bool = True) -> Tuple[Dict[str, Any], str]:
|
||
"""
|
||
用户登录流程
|
||
参数:
|
||
base_url: 基础URL(含 /api/v1)
|
||
client: httpx客户端
|
||
phone: 手机号
|
||
password: 密码
|
||
返回:
|
||
(测试结果字典, access_token字符串或空)
|
||
"""
|
||
url = make_url(base_url, "/app-user/login")
|
||
code, data = await api_post_json(client, url, {"phone": phone, "password": password})
|
||
token = ""
|
||
is_ok = (code == 200 and isinstance(data, dict) and data.get("access_token"))
|
||
if is_ok:
|
||
token = data.get("access_token", "")
|
||
if not expect_success:
|
||
ok = (code in (401, 403))
|
||
rs = {"name": f"登录-{phone}", "status": "PASS" if ok else "FAIL", "message": "登录失败(符合预期)" if ok else "登录失败(不符合预期)", "detail": {"http": code, "body": data}}
|
||
else:
|
||
rs = {"name": f"登录-{phone}", "status": "PASS" if is_ok else "FAIL", "message": "登录成功" if is_ok else "登录失败", "detail": {"http": code, "body": data}}
|
||
return rs, token
|
||
|
||
|
||
async def user_profile_flow(base_url: str, client: httpx.AsyncClient, token: str) -> Dict[str, Any]:
|
||
"""
|
||
用户资料查看与编辑
|
||
参数:
|
||
base_url: 基础URL(含 /api/v1)
|
||
client: httpx客户端
|
||
token: 用户JWT
|
||
返回:
|
||
测试结果字典
|
||
"""
|
||
headers = {"token": token}
|
||
view_url = make_url(base_url, "/app-user/profile")
|
||
v_code, v_data = await api_get(client, view_url, headers=headers)
|
||
ok_view = (v_code == 200 and isinstance(v_data, dict) and v_data.get("id"))
|
||
upd_url = make_url(base_url, "/app-user/profile")
|
||
nickname = "tester-" + uuid.uuid4().hex[:6]
|
||
u_code, u_data = await api_put_json(client, upd_url, {"nickname": nickname}, headers=headers)
|
||
ok_upd = (u_code == 200 and isinstance(u_data, dict) and u_data.get("nickname") == nickname)
|
||
is_ok = ok_view and ok_upd
|
||
return {"name": "资料查看与编辑", "status": "PASS" if is_ok else "FAIL", "message": "个人资料操作成功" if is_ok else "个人资料操作失败", "detail": {"view": {"http": v_code, "body": v_data}, "update": {"http": u_code, "body": u_data}}}
|
||
|
||
|
||
async def permission_flow(base_url: str, client: httpx.AsyncClient, admin_token: str) -> Dict[str, Any]:
|
||
"""
|
||
权限控制验证
|
||
参数:
|
||
base_url: 基础URL(含 /api/v1)
|
||
client: httpx客户端
|
||
admin_token: 管理端token头值
|
||
返回:
|
||
测试结果字典
|
||
"""
|
||
protected_url = make_url(base_url, "/user/list")
|
||
c1, d1 = await api_get(client, protected_url)
|
||
c2, d2 = await api_get(client, protected_url, headers={"token": admin_token})
|
||
ok1 = (c1 in (401, 403, 422))
|
||
ok2 = (c2 in (200, 403))
|
||
is_ok = ok1 and ok2
|
||
return {"name": "权限控制", "status": "PASS" if is_ok else "FAIL", "message": "权限校验完成", "detail": {"no_token": {"http": c1, "body": d1}, "with_token": {"http": c2, "body": d2}}}
|
||
|
||
|
||
async def main() -> None:
|
||
"""
|
||
主流程
|
||
参数:
|
||
无
|
||
返回:
|
||
None
|
||
"""
|
||
base = os.getenv("TEST_BASE_URL", "http://localhost:9991/api/v1")
|
||
admin_token = os.getenv("ADMIN_TOKEN", "dev")
|
||
results: List[Dict[str, Any]] = []
|
||
endpoint_list = [
|
||
{"path": "/app-user/register", "desc": "用户注册"},
|
||
{"path": "/app-user/login", "desc": "用户登录"},
|
||
{"path": "/app-user/profile", "desc": "获取用户信息(需token)"},
|
||
{"path": "/app-user/profile", "desc": "更新用户信息(需token) PUT"},
|
||
{"path": "/app-user/dashboard", "desc": "用户首页摘要(需token)"},
|
||
{"path": "/app-user/quota", "desc": "剩余估值次数(需token)"},
|
||
{"path": "/app-user/change-password", "desc": "修改密码(需token)"},
|
||
{"path": "/app-user/validate-token", "desc": "验证token(需token)"},
|
||
{"path": "/app-user/logout", "desc": "登出(需token)"},
|
||
{"path": "/upload/file", "desc": "上传文件"},
|
||
{"path": "/app-valuations/", "desc": "创建估值评估(需token)"},
|
||
{"path": "/app-valuations/", "desc": "获取我的估值评估列表(需token)"},
|
||
{"path": "/app-valuations/{id}", "desc": "获取估值评估详情(需token)"},
|
||
{"path": "/app-valuations/statistics/overview", "desc": "获取我的估值统计(需token)"},
|
||
{"path": "/app-valuations/{id}", "desc": "删除估值评估(需token) DELETE"},
|
||
]
|
||
async with httpx.AsyncClient(timeout=10) as client:
|
||
def gen_cn_phone() -> str:
|
||
second = str(random.choice([3,4,5,6,7,8,9]))
|
||
rest = "".join(random.choice("0123456789") for _ in range(9))
|
||
return "1" + second + rest
|
||
phone_ok = gen_cn_phone()
|
||
r1 = await user_register_flow(base, client, phone_ok, expect_success=True)
|
||
results.append(r1)
|
||
r2 = await user_register_flow(base, client, phone_ok, expect_success=False)
|
||
results.append(r2)
|
||
r3 = await user_register_flow(base, client, "abc", expect_success=False)
|
||
results.append(r3)
|
||
lr_ok, token = await user_login_flow(base, client, phone_ok, phone_ok[-6:], expect_success=True)
|
||
results.append(lr_ok)
|
||
lr_bad, _ = await user_login_flow(base, client, phone_ok, "wrong", expect_success=False)
|
||
results.append(lr_bad)
|
||
# token 场景:验证、资料、首页、配额
|
||
if token:
|
||
# 验证token
|
||
vt_code, vt_data = await api_get(client, make_url(base, "/app-user/validate-token"), headers={"token": token})
|
||
vt_ok = (vt_code == 200 and isinstance(vt_data, dict) and vt_data.get("data", {}).get("user_id"))
|
||
results.append({"name": "验证token", "status": "PASS" if vt_ok else "FAIL", "message": "token有效" if vt_ok else "token无效", "detail": {"http": vt_code, "body": vt_data}})
|
||
|
||
# 资料查看与编辑
|
||
pr = await user_profile_flow(base, client, token)
|
||
results.append(pr)
|
||
|
||
# 首页摘要
|
||
db_code, db_data = await api_get(client, make_url(base, "/app-user/dashboard"), headers={"token": token})
|
||
db_ok = (db_code == 200 and isinstance(db_data, dict))
|
||
results.append({"name": "用户首页摘要", "status": "PASS" if db_ok else "FAIL", "message": "获取成功" if db_ok else "获取失败", "detail": {"http": db_code, "body": db_data}})
|
||
|
||
# 剩余估值次数
|
||
qt_code, qt_data = await api_get(client, make_url(base, "/app-user/quota"), headers={"token": token})
|
||
qt_ok = (qt_code == 200 and isinstance(qt_data, dict))
|
||
results.append({"name": "剩余估值次数", "status": "PASS" if qt_ok else "FAIL", "message": "获取成功" if qt_ok else "获取失败", "detail": {"http": qt_code, "body": qt_data}})
|
||
|
||
# 修改密码并验证新旧密码
|
||
cp_code, cp_data = await api_post_json(client, make_url(base, "/app-user/change-password"), {"old_password": phone_ok[-6:], "new_password": "Npw" + phone_ok[-6:]}, headers={"token": token})
|
||
cp_ok = (cp_code == 200 and isinstance(cp_data, dict) and cp_data.get("code") == 200)
|
||
results.append({"name": "修改密码", "status": "PASS" if cp_ok else "FAIL", "message": "修改成功" if cp_ok else "修改失败", "detail": {"http": cp_code, "body": cp_data}})
|
||
|
||
# 旧密码登录应失败
|
||
lr_old, _ = await user_login_flow(base, client, phone_ok, phone_ok[-6:], expect_success=False)
|
||
results.append(lr_old)
|
||
# 新密码登录成功
|
||
lr_new, token2 = await user_login_flow(base, client, phone_ok, "Npw" + phone_ok[-6:], expect_success=True)
|
||
results.append(lr_new)
|
||
use_token = token2 or token
|
||
|
||
# 上传文件(pdf)
|
||
file_url = ""
|
||
try:
|
||
up_resp = await client.post(make_url(base, "/upload/file"), files={"file": ("demo.pdf", b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n", "application/pdf")})
|
||
u_code = up_resp.status_code
|
||
u_data = _ensure_dict(up_resp.json() if up_resp.headers.get("content-type", "").startswith("application/json") else {"raw": up_resp.text})
|
||
file_url = u_data.get("url", "")
|
||
u_ok = (u_code == 200 and file_url)
|
||
results.append({"name": "上传文件", "status": "PASS" if u_ok else "FAIL", "message": "上传成功" if u_ok else "上传失败", "detail": {"http": u_code, "body": u_data}})
|
||
except Exception as e:
|
||
results.append({"name": "上传文件", "status": "FAIL", "message": "上传异常", "detail": {"error": repr(e)}})
|
||
|
||
# 创建估值评估
|
||
create_payload = {
|
||
"asset_name": "测试资产",
|
||
"institution": "测试机构",
|
||
"industry": "测试行业",
|
||
"three_year_income": [100, 120, 140],
|
||
"application_coverage": "全国覆盖",
|
||
"rd_investment": "10",
|
||
"annual_revenue": "100",
|
||
"price_fluctuation": [10, 20],
|
||
"platform_accounts": {"douyin": {"likes": 1, "comments": 1, "shares": 1}},
|
||
"pattern_images": [],
|
||
"report_url": file_url or None,
|
||
"certificate_url": file_url or None,
|
||
}
|
||
cv_code, cv_data = await api_post_json(client, make_url(base, "/app-valuations/"), create_payload, headers={"token": use_token})
|
||
cv_ok = (cv_code == 200 and isinstance(cv_data, dict) and cv_data.get("data", {}).get("task_status") == "queued")
|
||
results.append({"name": "创建估值评估", "status": "PASS" if cv_ok else "FAIL", "message": "任务已提交" if cv_ok else "提交失败", "detail": {"http": cv_code, "body": cv_data}})
|
||
|
||
# 等待片刻后获取列表与详情
|
||
import asyncio
|
||
await asyncio.sleep(0.3)
|
||
gl_code, gl_data = await api_get(client, make_url(base, "/app-valuations/"), headers={"token": use_token}, params={"page": 1, "size": 10})
|
||
gl_ok = (gl_code == 200 and isinstance(gl_data, dict) and isinstance(gl_data.get("data"), list))
|
||
results.append({"name": "估值列表", "status": "PASS" if gl_ok else "FAIL", "message": "获取成功" if gl_ok else "获取失败", "detail": {"http": gl_code, "body": gl_data}})
|
||
vid = None
|
||
if gl_ok and gl_data.get("data"):
|
||
vid = gl_data["data"][0].get("id")
|
||
if vid:
|
||
gd_code, gd_data = await api_get(client, make_url(base, f"/app-valuations/{vid}"), headers={"token": use_token})
|
||
gd_ok = (gd_code == 200 and isinstance(gd_data, dict) and gd_data.get("data", {}).get("id") == vid)
|
||
results.append({"name": "估值详情", "status": "PASS" if gd_ok else "FAIL", "message": "获取成功" if gd_ok else "获取失败", "detail": {"http": gd_code, "body": gd_data}})
|
||
# 统计
|
||
st_code, st_data = await api_get(client, make_url(base, "/app-valuations/statistics/overview"), headers={"token": use_token})
|
||
st_ok = (st_code == 200 and isinstance(st_data, dict))
|
||
results.append({"name": "估值统计", "status": "PASS" if st_ok else "FAIL", "message": "获取成功" if st_ok else "获取失败", "detail": {"http": st_code, "body": st_data}})
|
||
# 删除
|
||
del_resp = await client.delete(make_url(base, f"/app-valuations/{vid}"), headers={"token": use_token})
|
||
d_code = del_resp.status_code
|
||
d_data = _ensure_dict(del_resp.json() if del_resp.headers.get("content-type", "").startswith("application/json") else {"raw": del_resp.text})
|
||
d_ok = (d_code == 200 and isinstance(d_data, dict) and d_data.get("data", {}).get("deleted"))
|
||
results.append({"name": "删除估值", "status": "PASS" if d_ok else "FAIL", "message": "删除成功" if d_ok else "删除失败", "detail": {"http": d_code, "body": d_data}})
|
||
|
||
# 登出
|
||
lo_code, lo_data = await api_post_json(client, make_url(base, "/app-user/logout"), {}, headers={"token": use_token})
|
||
lo_ok = (lo_code == 200)
|
||
results.append({"name": "登出", "status": "PASS" if lo_ok else "FAIL", "message": "登出成功" if lo_ok else "登出失败", "detail": {"http": lo_code, "body": lo_data}})
|
||
perm = await permission_flow(base, client, admin_token)
|
||
results.append(perm)
|
||
passes = sum(1 for r in results if r.get("status") == "PASS")
|
||
total = len(results)
|
||
print(json.dumps({"total": total, "passes": passes, "results": results, "endpoints": endpoint_list}, ensure_ascii=False, indent=2))
|
||
write_html_report("reports/user_flow_script_report.html", "用户维度功能测试报告(脚本)", results)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import asyncio
|
||
asyncio.run(main())
|
||
async def api_put_json(client: httpx.AsyncClient, url: str, payload: Dict[str, Any], headers: Optional[Dict[str, str]] = None) -> Tuple[int, Dict[str, Any]]:
|
||
"""
|
||
发送PUT JSON请求
|
||
参数:
|
||
client: httpx异步客户端
|
||
url: 完整URL
|
||
payload: 请求体JSON
|
||
headers: 请求头
|
||
返回:
|
||
(状态码, 响应JSON)
|
||
"""
|
||
r = await client.put(url, json=payload, headers=headers or {})
|
||
data = {}
|
||
try:
|
||
data = r.json()
|
||
except Exception:
|
||
data = {"raw": r.text}
|
||
return r.status_code, data |