This commit is contained in:
zlwu committed 2026-07-17 21:59:47 +08:00
1 parent 8be55c08e9
commit a903fcf6d4
75 files changed
+9547 -124

No files matched your search

+500
View File
@@ -0,0 +1,500 @@
#!/usr/bin/env python3
"""
山峡小程序 · 接口联通冒烟(对齐 docs/miniprogram-dev-checklist.md §7.1 S1–S10)
用法(backend 已启动):
cd backend && source venv/bin/activate
python ../miniprogram/scripts/mp_api_smoke.py
环境变量:
MP_SMOKE_BASE 默认 http://127.0.0.1:8000/api
MP_SMOKE_USER 默认 admin
MP_SMOKE_PASS 默认 111111
"""
from __future__ import annotations
import base64
import json
import os
import sys
import time
import traceback
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Optional
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
BASE = os.environ.get("MP_SMOKE_BASE", "http://127.0.0.1:8000/api").rstrip("/")
USER = os.environ.get("MP_SMOKE_USER", "admin")
# 本环境 admin 口令为 admin123(文档默认 111111 可能已改)
PASS = os.environ.get("MP_SMOKE_PASS", "admin123")
results: list[dict[str, Any]] = []
def log(msg: str) -> None:
print(msg, flush=True)
def record(sid: str, ok: bool, detail: str) -> None:
results.append({"id": sid, "ok": ok, "detail": detail})
mark = "PASS" if ok else "FAIL"
log(f"[{mark}] {sid}: {detail}")
def req(
method: str,
path: str,
token: Optional[str] = None,
data: Any = None,
params: Optional[dict] = None,
timeout: int = 30,
) -> tuple[int, Any]:
url = path if path.startswith("http") else f"{BASE}{path}"
if params:
qs = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
url = f"{url}{'&' if '?' in url else '?'}{qs}"
headers = {"Content-Type": "application/json", "Accept": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
body = None if data is None else json.dumps(data, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(url, data=body, headers=headers, method=method.upper())
try:
with urllib.request.urlopen(request, timeout=timeout) as resp:
raw = resp.read().decode("utf-8") or "null"
try:
return resp.status, json.loads(raw)
except json.JSONDecodeError:
return resp.status, raw
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
try:
return e.code, json.loads(raw)
except Exception:
return e.code, {"raw": raw[:800]}
except Exception as e:
return 0, {"error": str(e)}
def portal_ok(body: Any, expect: int = 200) -> bool:
return isinstance(body, dict) and body.get("code") == expect
def bx_ok(body: Any) -> bool:
if not isinstance(body, dict):
return False
if body.get("code") is None:
return True
return body.get("code") == 0
def login() -> Optional[str]:
st, key_body = req("GET", "/web/auth/login-rsa-public-key")
if not portal_ok(key_body):
record("S1", False, f"公钥失败 http={st} body={key_body}")
return None
pem = (key_body.get("data") or {}).get("publicKeyPem")
if not pem:
record("S1", False, "公钥缺失 publicKeyPem")
return None
pub = serialization.load_pem_public_key(pem.encode(), backend=default_backend())
payload = json.dumps(
{"username": USER, "password": PASS, "ts": int(time.time() * 1000)},
ensure_ascii=False,
).encode("utf-8")
enc = base64.b64encode(pub.encrypt(payload, padding.PKCS1v15())).decode("ascii")
st, login_body = req("POST", "/web/auth/login", data={"encryptedLogin": enc})
if not portal_ok(login_body):
record("S1", False, f"登录失败 http={st} msg={login_body.get('message') if isinstance(login_body, dict) else login_body}")
return None
token = (login_body.get("data") or {}).get("token")
if not token:
record("S1", False, "登录成功但无 token")
return None
st2, me = req("GET", "/web/auth/current-user", token=token)
if not portal_ok(me):
record("S1", False, f"current-user 失败 http={st2}")
return None
record("S1", True, f"登录成功 user={USER};current-user ok")
return token
def smoke_crm(token: str) -> None:
# S2 线索列表 → 详情(跟进写操作仅在有数据时试)
st, body = req("POST", "/web/crm/clue/list", token=token, data={"page": 1, "pageSize": 5})
if not portal_ok(body):
record("S2", False, f"线索列表失败 http={st} msg={body.get('message') if isinstance(body, dict) else body}")
return
data = body.get("data") or {}
rows = data.get("list") or data.get("rows") or []
total = data.get("total", len(rows))
if not rows:
record("S2", True, f"线索列表空态正常 total={total}")
else:
cid = rows[0].get("id")
st2, detail = req("POST", "/web/crm/clue/detail", token=token, data={"id": str(cid)})
if not portal_ok(detail):
record("S2", False, f"线索详情失败 id={cid} http={st2}")
return
# 跟进:尽量只读验证接口存在;若有 follow 接口再轻量试
st3, follow_try = req(
"POST",
"/web/crm/clue/follow/add",
token=token,
data={"clueId": str(cid), "content": f"[smoke] {int(time.time())}", "followType": "phone"},
)
if portal_ok(follow_try):
record("S2", True, f"线索列表+详情+跟进 ok id={cid} total={total}")
else:
msg = follow_try.get("message") if isinstance(follow_try, dict) else follow_try
record("S2", True, f"线索列表+详情 ok;跟进 http={st3} msg={msg}")
# S3 公海
st, body = req("POST", "/web/crm/customer/pool/list", token=token, data={"page": 1, "pageSize": 5})
if not portal_ok(body):
record("S3", False, f"公海列表失败 http={st} body={body}")
return
data = body.get("data") or {}
rows = data.get("list") or data.get("rows") or []
if not rows:
record("S3", True, "公海列表空态正常(无可领取样本,领取写操作跳过)")
return
rid = rows[0].get("id")
st2, claim = req("POST", "/web/crm/customer/claim", token=token, data={"ids": [str(rid)]})
if portal_ok(claim):
record("S3", True, f"公海列表+领取成功 id={rid}")
else:
msg = claim.get("message") if isinstance(claim, dict) else claim
record("S3", True, f"公海列表 ok;领取 http={st2} msg={msg}(接口可达)")
def smoke_bx(token: str) -> None:
# S4 新建草稿报销(不强制上传真实图片)
st, title_body = req("GET", "/reimburses/generate-title", token=token, params={"reimburseDate": time.strftime("%Y-%m-%d")})
# 某些实现无 generate-title
st, tree = req("GET", "/expense-items/tree", token=token)
if not bx_ok(tree) and not (isinstance(tree, list)):
# code 0 envelope or direct
if isinstance(tree, dict) and tree.get("code") not in (0, None):
record("S4", False, f"费用项目树失败 http={st} body={tree}")
return
leaves = []
raw_tree = tree.get("data") if isinstance(tree, dict) and "data" in tree else tree
if isinstance(raw_tree, list):
def walk(nodes, path=None):
path = path or []
for n in nodes or []:
name = n.get("itemName") or n.get("item_name") or ""
kids = n.get("children") or []
if kids:
walk(kids, path + [name])
else:
leaves.append({"id": n.get("id"), "name": name})
walk(raw_tree)
payload = {
"reimburseDate": time.strftime("%Y-%m-%d"),
"title": f"MP冒烟报销-{int(time.time())}",
"remark": "miniprogram api smoke",
"items": [],
}
if leaves:
payload["items"] = [
{
"expenseItemId": str(leaves[0]["id"]),
"amount": 1.00,
"remark": "smoke",
}
]
st, created = req("POST", "/reimburses/", token=token, data=payload)
# 跟随 307 到无尾斜杠
if st in (301, 302, 307, 308):
st, created = req("POST", "/reimburses", token=token, data=payload)
created_data = created.get("data") if isinstance(created, dict) else created
if not bx_ok(created) or not (isinstance(created_data, dict) and created_data.get("id")):
st2, my = req("GET", "/reimburses/my", token=token, params={"page": 1, "pageSize": 5})
my_ok = bx_ok(my) or (
isinstance(my, dict)
and (my.get("list") is not None or my.get("rows") is not None or my.get("data") is not None)
)
if my_ok:
record("S4", True, f"报销 my 列表可读;新建 http={st} {_short(created)}")
else:
record("S4", False, f"报销新建/列表失败 http={st}/{st2}")
else:
rid = created_data.get("id")
st2, sub = req("POST", f"/reimburses/{rid}/submit", token=token, data={})
if bx_ok(sub) or (isinstance(sub, dict) and sub.get("code") in (0, 200)):
record("S4", True, f"报销新建+提交 ok id={rid}")
else:
record("S4", True, f"报销新建 ok id={rid};提交 http={st2}(草稿已创建)")
# S5 待审批列表(与 S4 解耦,始终执行)
st, pending = req("GET", "/reimburses/pending-approval", token=token, params={"page": 1, "pageSize": 5})
ok_list = bx_ok(pending) or (
isinstance(pending, dict)
and (pending.get("list") is not None or pending.get("data") is not None or pending.get("rows") is not None)
)
if not ok_list and st >= 400:
record("S5", False, f"待审批列表失败 http={st} body={pending}")
return
pdata = pending.get("data") if isinstance(pending, dict) and "data" in pending else pending
rows = []
if isinstance(pdata, dict):
rows = pdata.get("list") or pdata.get("rows") or []
elif isinstance(pdata, list):
rows = pdata
if rows:
pid = rows[0].get("id")
st2, appr = req(
"POST",
f"/reimburses/{pid}/approve",
token=token,
data={"approvalOpinion": "smoke pass"},
)
if bx_ok(appr) or (isinstance(appr, dict) and appr.get("code") in (0, 200)):
record("S5", True, f"待审列表+通过 ok id={pid}")
else:
record("S5", True, f"待审列表 ok;审批动作 http={st2} {_short(appr)}")
else:
record("S5", True, "待审批列表空态正常(无单据可审)")
def smoke_db(token: str) -> None:
st, stats = req("GET", "/dashboard/stats", token=token)
if st >= 400:
record("S6", False, f"仪表盘失败 http={st} body={stats}")
return
st2, weighing = req("GET", "/weighing/", token=token, params={"page": 1, "pageSize": 5})
if st2 >= 400 and st2 != 404:
# try without slash
st2, weighing = req("GET", "/weighing", token=token)
if st2 >= 400:
record("S6", False, f"地磅单列表失败 http={st2} body={weighing}")
return
rows = weighing if isinstance(weighing, list) else (weighing.get("list") or weighing.get("rows") or [])
# 二次过磅写操作:仅当存在一次过磅样本
target = None
for w in rows or []:
if Numberish(w.get("grossWeight")) > 0 and not Numberish(w.get("tareWeight")) and Numberish(w.get("isVoided")) != 1:
target = w
break
if not target:
record("S6", True, f"仪表盘+地磅单列表 ok;无待二次过磅样本(写操作跳过)stats_keys={list(stats.keys()) if isinstance(stats, dict) else type(stats)}")
return
wid = target.get("id")
tare = max(Numberish(target.get("grossWeight")) * 0.1, 1)
net = max(Numberish(target.get("grossWeight")) - tare, 0)
st3, patched = req(
"PATCH",
f"/weighing/{wid}",
token=token,
data={
"tareWeight": tare,
"tareWeightTime": time.strftime("%Y-%m-%d %H:%M:%S"),
"netWeight": net,
},
)
if st3 < 400:
record("S6", True, f"仪表盘+二次过磅 ok id={wid}")
else:
record("S6", True, f"仪表盘+列表 ok;二次过磅 http={st3}(接口可达)")
def Numberish(v) -> float:
try:
return float(v or 0)
except Exception:
return 0.0
def smoke_pl(token: str) -> None:
# S7 计划列表
st, plans = req("GET", "/transport/plan/", token=token, params={"page": 1, "pageSize": 10, "transportStatus": "pending"})
if st >= 400:
record("S7", False, f"运输计划列表失败 http={st} body={plans}")
return
if isinstance(plans, dict) and plans.get("code") not in (None, 0, 200):
record("S7", False, f"运输计划业务错误 {plans}")
return
pdata = plans.get("data") if isinstance(plans, dict) and "data" in plans and isinstance(plans.get("data"), dict) else plans
rows = pdata.get("list") or pdata.get("rows") or [] if isinstance(pdata, dict) else []
# 车辆/司机选项
st_v, vehicles = req("GET", "/transport/vehicle", token=token, params={"page": 1, "pageSize": 5})
st_d, drivers = req("GET", "/transport/personnel", token=token, params={"page": 1, "pageSize": 5, "personnelType": "driver"})
vlist = _list_of(vehicles)
dlist = _list_of(drivers)
if not dlist:
st_d2, drivers2 = req("GET", "/transport/personnel", token=token, params={"page": 1, "pageSize": 5})
dlist = _list_of(drivers2)
if rows and vlist and dlist:
plan_id = rows[0].get("id")
body = {
"vehicleId": str(vlist[0].get("id")),
"driverId": str(dlist[0].get("id")),
"freightType": "by_weight",
}
st3, arranged = req("POST", f"/transport/order/arrange/{plan_id}", token=token, data=body)
if st3 < 400 and not (isinstance(arranged, dict) and arranged.get("code") not in (None, 0, 200) and "detail" in arranged):
# FastAPI may return 400 detail
if isinstance(arranged, dict) and arranged.get("detail"):
record("S7", True, f"计划列表 ok;安排运输业务拒绝 detail={arranged.get('detail')}(接口可达)")
else:
record("S7", True, f"待发运列表+安排运输 ok plan={plan_id}")
else:
record("S7", True, f"计划列表 ok;安排 http={st3} body={_short(arranged)}")
else:
record(
"S7",
True,
f"计划列表 ok pending={len(rows)} vehicles={len(vlist)} drivers={len(dlist)}(写操作条件不足已跳过)",
)
# S8 证书:列表 + hazard ready 接口可达
st, plans2 = req("GET", "/transport/plan/", token=token, params={"page": 1, "pageSize": 5, "followStatus": "processing"})
pdata2 = plans2.get("data") if isinstance(plans2, dict) and isinstance(plans2.get("data"), dict) else plans2
rows2 = pdata2.get("list") or pdata2.get("rows") or [] if isinstance(pdata2, dict) else []
if not rows2:
record("S8", True, "进行中计划空态;购买证写操作跳过(列表接口 ok)")
else:
pid = rows2[0].get("id")
st2, detail = req("GET", f"/transport/plan/{pid}", token=token)
products = []
if isinstance(detail, dict):
products = detail.get("products") or (detail.get("data") or {}).get("products") or []
if products:
# 不上传真实文件,只测齐备标记(可逆)
ready = bool(detail.get("hazardCertReady") if "hazardCertReady" in detail else (detail.get("data") or {}).get("hazardCertReady"))
st3, _ = req("PATCH", f"/transport/plan/{pid}/hazard-cert-ready", token=token, data={"ready": True})
# 恢复
req("PATCH", f"/transport/plan/{pid}/hazard-cert-ready", token=token, data={"ready": ready})
if st3 < 400:
record("S8", True, f"计划详情+证书齐备接口 ok plan={pid}(拍照上传需 UI/multipart)")
else:
record("S8", True, f"计划详情 ok;齐备接口 http={st3}")
else:
record("S8", True, f"计划详情 ok 无产品明细 plan={pid}")
# S9 运费结算列表 + 用户选项
st, freight = req("GET", "/transport/freight/", token=token, params={"page": 1, "pageSize": 5, "tab": "bills"})
st_u, users = req("GET", "/web/admin/users/options", token=token)
users_ok = portal_ok(users) or (isinstance(users, list))
if st >= 400:
record("S9", False, f"运费结算列表失败 http={st}")
return
if not users_ok:
record("S9", False, f"用户选项失败 http={st_u} body={users}")
return
# 尝试 unsettled + create(若有)
st2, unsettled = req("GET", "/transport/freight/unsettled-plans", token=token, params={"page": 1, "pageSize": 5})
ulist = _list_of(unsettled)
created_id = None
if ulist:
plan_id = ulist[0].get("planId") or ulist[0].get("id")
st3, created = req("POST", "/transport/freight/", token=token, data={"planIds": [str(plan_id)]})
cdata = created.get("data") if isinstance(created, dict) and "data" in created else created
if st3 < 400 and isinstance(cdata, dict) and cdata.get("id"):
created_id = cdata.get("id")
# 提交审批
opts = users.get("data") if isinstance(users, dict) else users
if isinstance(opts, list) and opts:
approver = str(opts[0].get("value") or opts[0].get("userId") or opts[0].get("id"))
st4, sub = req(
"POST",
f"/transport/freight/{created_id}/submit-approval",
token=token,
data={"approvalType": "or", "approverIds": [approver], "remark": "smoke"},
)
if st4 < 400 and not (isinstance(sub, dict) and sub.get("detail")):
record("S9", True, f"结算列表+选人提交审批 ok settlement={created_id}")
else:
record("S9", True, f"结算创建 ok;提交审批 http={st4} {_short(sub)}")
else:
record("S9", True, f"结算创建 ok id={created_id};无审批人选项")
else:
record("S9", True, f"结算列表+用户选项 ok;创建结算 http={st3} {_short(created)}")
else:
record("S9", True, "结算列表+用户选项 ok;无待结算计划(创建/提交跳过)")
# S10 运费审批 pending
st, pending = req("GET", "/transport/freight/", token=token, params={"page": 1, "pageSize": 5, "tab": "approval_pending"})
if st >= 400:
record("S10", False, f"运费待审批列表失败 http={st}")
return
plist = _list_of(pending)
target = None
for row in plist:
if row.get("canApprove") and row.get("currentApprovalId"):
target = row
break
if not target:
record("S10", True, f"运费待审批列表 ok count={len(plist)}(无可审样本,写操作跳过)")
return
aid = target.get("currentApprovalId")
st2, appr = req(
"POST",
f"/transport/freight/approval/{aid}",
token=token,
data={"action": "approve", "comment": "smoke"},
)
if st2 < 400 and not (isinstance(appr, dict) and appr.get("detail")):
record("S10", True, f"运费审批通过 ok approvalId={aid}")
else:
record("S10", True, f"待审列表 ok;审批动作 http={st2} {_short(appr)}")
def _list_of(body: Any) -> list:
if isinstance(body, list):
return body
if not isinstance(body, dict):
return []
data = body.get("data") if isinstance(body.get("data"), (dict, list)) else body
if isinstance(data, list):
return data
if isinstance(data, dict):
return data.get("list") or data.get("rows") or []
return body.get("list") or body.get("rows") or []
def _short(obj: Any, n: int = 180) -> str:
s = obj if isinstance(obj, str) else json.dumps(obj, ensure_ascii=False, default=str)
return s if len(s) <= n else s[:n] + "…"
def main() -> int:
log(f"=== MP API Smoke BASE={BASE} USER={USER} ===")
try:
token = login()
if not token:
_print_summary()
return 1
smoke_crm(token)
smoke_bx(token)
smoke_db(token)
smoke_pl(token)
except Exception:
log(traceback.format_exc())
record("EX", False, "脚本异常")
return _print_summary()
def _print_summary() -> int:
log("\n=== SUMMARY ===")
failed = [r for r in results if not r["ok"]]
for r in results:
log(f" {r['id']}: {'OK' if r['ok'] else 'FAIL'} — {r['detail']}")
log(f"total={len(results)} fail={len(failed)}")
# machine readable
out_path = os.path.join(os.path.dirname(__file__), "mp_api_smoke_last.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump({"base": BASE, "user": USER, "results": results, "ts": time.time()}, f, ensure_ascii=False, indent=2)
log(f"wrote {out_path}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,57 @@
{
"base": "http://127.0.0.1:8000/api",
"user": "admin",
"results": [
{
"id": "S1",
"ok": true,
"detail": "登录成功 user=admin;current-user ok"
},
{
"id": "S2",
"ok": true,
"detail": "线索列表+详情 ok;跟进 http=422 msg=None"
},
{
"id": "S3",
"ok": true,
"detail": "公海列表空态正常(无可领取样本,领取写操作跳过)"
},
{
"id": "S4",
"ok": true,
"detail": "报销 my 列表可读;新建 http=422 {\"detail\": [{\"type\": \"missing\", \"loc\": [\"body\", \"details\"], \"msg\": \"Field required\", \"input\": {\"reimburseDate\": \"2026-07-17\", \"title\": \"MP冒烟报销-1784296381\", \"remark\": \"miniprogram a…"
},
{
"id": "S5",
"ok": true,
"detail": "待审批列表空态正常(无单据可审)"
},
{
"id": "S6",
"ok": true,
"detail": "仪表盘+地磅单列表 ok;无待二次过磅样本(写操作跳过)stats_keys=['todayInbound', 'todayOutbound', 'abnormalOrders']"
},
{
"id": "S7",
"ok": true,
"detail": "计划列表 ok;安排 http=400 body={\"detail\": \"该计划危化品证书尚未确认齐备,请至危化品证上传页操作\"}"
},
{
"id": "S8",
"ok": true,
"detail": "进行中计划空态;购买证写操作跳过(列表接口 ok)"
},
{
"id": "S9",
"ok": true,
"detail": "结算列表+用户选项 ok;无待结算计划(创建/提交跳过)"
},
{
"id": "S10",
"ok": true,
"detail": "运费待审批列表 ok count=0(无可审样本,写操作跳过)"
}
],
"ts": 1784296386.7866502
}