#!/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())