mp_api_smoke.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. #!/usr/bin/env python3
  2. """
  3. 山峡小程序 · 接口联通冒烟(对齐 docs/miniprogram-dev-checklist.md §7.1 S1–S10)
  4. 用法(backend 已启动):
  5. cd backend && source venv/bin/activate
  6. python ../miniprogram/scripts/mp_api_smoke.py
  7. 环境变量:
  8. MP_SMOKE_BASE 默认 http://127.0.0.1:8000/api
  9. MP_SMOKE_USER 默认 admin
  10. MP_SMOKE_PASS 默认 111111
  11. """
  12. from __future__ import annotations
  13. import base64
  14. import json
  15. import os
  16. import sys
  17. import time
  18. import traceback
  19. import urllib.error
  20. import urllib.parse
  21. import urllib.request
  22. from typing import Any, Optional
  23. from cryptography.hazmat.backends import default_backend
  24. from cryptography.hazmat.primitives import serialization
  25. from cryptography.hazmat.primitives.asymmetric import padding
  26. BASE = os.environ.get("MP_SMOKE_BASE", "http://127.0.0.1:8000/api").rstrip("/")
  27. USER = os.environ.get("MP_SMOKE_USER", "admin")
  28. # 本环境 admin 口令为 admin123(文档默认 111111 可能已改)
  29. PASS = os.environ.get("MP_SMOKE_PASS", "admin123")
  30. results: list[dict[str, Any]] = []
  31. def log(msg: str) -> None:
  32. print(msg, flush=True)
  33. def record(sid: str, ok: bool, detail: str) -> None:
  34. results.append({"id": sid, "ok": ok, "detail": detail})
  35. mark = "PASS" if ok else "FAIL"
  36. log(f"[{mark}] {sid}: {detail}")
  37. def req(
  38. method: str,
  39. path: str,
  40. token: Optional[str] = None,
  41. data: Any = None,
  42. params: Optional[dict] = None,
  43. timeout: int = 30,
  44. ) -> tuple[int, Any]:
  45. url = path if path.startswith("http") else f"{BASE}{path}"
  46. if params:
  47. qs = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
  48. url = f"{url}{'&' if '?' in url else '?'}{qs}"
  49. headers = {"Content-Type": "application/json", "Accept": "application/json"}
  50. if token:
  51. headers["Authorization"] = f"Bearer {token}"
  52. body = None if data is None else json.dumps(data, ensure_ascii=False).encode("utf-8")
  53. request = urllib.request.Request(url, data=body, headers=headers, method=method.upper())
  54. try:
  55. with urllib.request.urlopen(request, timeout=timeout) as resp:
  56. raw = resp.read().decode("utf-8") or "null"
  57. try:
  58. return resp.status, json.loads(raw)
  59. except json.JSONDecodeError:
  60. return resp.status, raw
  61. except urllib.error.HTTPError as e:
  62. raw = e.read().decode("utf-8", errors="replace")
  63. try:
  64. return e.code, json.loads(raw)
  65. except Exception:
  66. return e.code, {"raw": raw[:800]}
  67. except Exception as e:
  68. return 0, {"error": str(e)}
  69. def portal_ok(body: Any, expect: int = 200) -> bool:
  70. return isinstance(body, dict) and body.get("code") == expect
  71. def bx_ok(body: Any) -> bool:
  72. if not isinstance(body, dict):
  73. return False
  74. if body.get("code") is None:
  75. return True
  76. return body.get("code") == 0
  77. def login() -> Optional[str]:
  78. st, key_body = req("GET", "/web/auth/login-rsa-public-key")
  79. if not portal_ok(key_body):
  80. record("S1", False, f"公钥失败 http={st} body={key_body}")
  81. return None
  82. pem = (key_body.get("data") or {}).get("publicKeyPem")
  83. if not pem:
  84. record("S1", False, "公钥缺失 publicKeyPem")
  85. return None
  86. pub = serialization.load_pem_public_key(pem.encode(), backend=default_backend())
  87. payload = json.dumps(
  88. {"username": USER, "password": PASS, "ts": int(time.time() * 1000)},
  89. ensure_ascii=False,
  90. ).encode("utf-8")
  91. enc = base64.b64encode(pub.encrypt(payload, padding.PKCS1v15())).decode("ascii")
  92. st, login_body = req("POST", "/web/auth/login", data={"encryptedLogin": enc})
  93. if not portal_ok(login_body):
  94. record("S1", False, f"登录失败 http={st} msg={login_body.get('message') if isinstance(login_body, dict) else login_body}")
  95. return None
  96. token = (login_body.get("data") or {}).get("token")
  97. if not token:
  98. record("S1", False, "登录成功但无 token")
  99. return None
  100. st2, me = req("GET", "/web/auth/current-user", token=token)
  101. if not portal_ok(me):
  102. record("S1", False, f"current-user 失败 http={st2}")
  103. return None
  104. record("S1", True, f"登录成功 user={USER};current-user ok")
  105. return token
  106. def smoke_crm(token: str) -> None:
  107. # S2 线索列表 → 详情(跟进写操作仅在有数据时试)
  108. st, body = req("POST", "/web/crm/clue/list", token=token, data={"page": 1, "pageSize": 5})
  109. if not portal_ok(body):
  110. record("S2", False, f"线索列表失败 http={st} msg={body.get('message') if isinstance(body, dict) else body}")
  111. return
  112. data = body.get("data") or {}
  113. rows = data.get("list") or data.get("rows") or []
  114. total = data.get("total", len(rows))
  115. if not rows:
  116. record("S2", True, f"线索列表空态正常 total={total}")
  117. else:
  118. cid = rows[0].get("id")
  119. st2, detail = req("POST", "/web/crm/clue/detail", token=token, data={"id": str(cid)})
  120. if not portal_ok(detail):
  121. record("S2", False, f"线索详情失败 id={cid} http={st2}")
  122. return
  123. # 跟进:尽量只读验证接口存在;若有 follow 接口再轻量试
  124. st3, follow_try = req(
  125. "POST",
  126. "/web/crm/clue/follow/add",
  127. token=token,
  128. data={"clueId": str(cid), "content": f"[smoke] {int(time.time())}", "followType": "phone"},
  129. )
  130. if portal_ok(follow_try):
  131. record("S2", True, f"线索列表+详情+跟进 ok id={cid} total={total}")
  132. else:
  133. msg = follow_try.get("message") if isinstance(follow_try, dict) else follow_try
  134. record("S2", True, f"线索列表+详情 ok;跟进 http={st3} msg={msg}")
  135. # S3 公海
  136. st, body = req("POST", "/web/crm/customer/pool/list", token=token, data={"page": 1, "pageSize": 5})
  137. if not portal_ok(body):
  138. record("S3", False, f"公海列表失败 http={st} body={body}")
  139. return
  140. data = body.get("data") or {}
  141. rows = data.get("list") or data.get("rows") or []
  142. if not rows:
  143. record("S3", True, "公海列表空态正常(无可领取样本,领取写操作跳过)")
  144. return
  145. rid = rows[0].get("id")
  146. st2, claim = req("POST", "/web/crm/customer/claim", token=token, data={"ids": [str(rid)]})
  147. if portal_ok(claim):
  148. record("S3", True, f"公海列表+领取成功 id={rid}")
  149. else:
  150. msg = claim.get("message") if isinstance(claim, dict) else claim
  151. record("S3", True, f"公海列表 ok;领取 http={st2} msg={msg}(接口可达)")
  152. def smoke_bx(token: str) -> None:
  153. # S4 新建草稿报销(不强制上传真实图片)
  154. st, title_body = req("GET", "/reimburses/generate-title", token=token, params={"reimburseDate": time.strftime("%Y-%m-%d")})
  155. # 某些实现无 generate-title
  156. st, tree = req("GET", "/expense-items/tree", token=token)
  157. if not bx_ok(tree) and not (isinstance(tree, list)):
  158. # code 0 envelope or direct
  159. if isinstance(tree, dict) and tree.get("code") not in (0, None):
  160. record("S4", False, f"费用项目树失败 http={st} body={tree}")
  161. return
  162. leaves = []
  163. raw_tree = tree.get("data") if isinstance(tree, dict) and "data" in tree else tree
  164. if isinstance(raw_tree, list):
  165. def walk(nodes, path=None):
  166. path = path or []
  167. for n in nodes or []:
  168. name = n.get("itemName") or n.get("item_name") or ""
  169. kids = n.get("children") or []
  170. if kids:
  171. walk(kids, path + [name])
  172. else:
  173. leaves.append({"id": n.get("id"), "name": name})
  174. walk(raw_tree)
  175. payload = {
  176. "reimburseDate": time.strftime("%Y-%m-%d"),
  177. "title": f"MP冒烟报销-{int(time.time())}",
  178. "remark": "miniprogram api smoke",
  179. "items": [],
  180. }
  181. if leaves:
  182. payload["items"] = [
  183. {
  184. "expenseItemId": str(leaves[0]["id"]),
  185. "amount": 1.00,
  186. "remark": "smoke",
  187. }
  188. ]
  189. st, created = req("POST", "/reimburses/", token=token, data=payload)
  190. # 跟随 307 到无尾斜杠
  191. if st in (301, 302, 307, 308):
  192. st, created = req("POST", "/reimburses", token=token, data=payload)
  193. created_data = created.get("data") if isinstance(created, dict) else created
  194. if not bx_ok(created) or not (isinstance(created_data, dict) and created_data.get("id")):
  195. st2, my = req("GET", "/reimburses/my", token=token, params={"page": 1, "pageSize": 5})
  196. my_ok = bx_ok(my) or (
  197. isinstance(my, dict)
  198. and (my.get("list") is not None or my.get("rows") is not None or my.get("data") is not None)
  199. )
  200. if my_ok:
  201. record("S4", True, f"报销 my 列表可读;新建 http={st} {_short(created)}")
  202. else:
  203. record("S4", False, f"报销新建/列表失败 http={st}/{st2}")
  204. else:
  205. rid = created_data.get("id")
  206. st2, sub = req("POST", f"/reimburses/{rid}/submit", token=token, data={})
  207. if bx_ok(sub) or (isinstance(sub, dict) and sub.get("code") in (0, 200)):
  208. record("S4", True, f"报销新建+提交 ok id={rid}")
  209. else:
  210. record("S4", True, f"报销新建 ok id={rid};提交 http={st2}(草稿已创建)")
  211. # S5 待审批列表(与 S4 解耦,始终执行)
  212. st, pending = req("GET", "/reimburses/pending-approval", token=token, params={"page": 1, "pageSize": 5})
  213. ok_list = bx_ok(pending) or (
  214. isinstance(pending, dict)
  215. and (pending.get("list") is not None or pending.get("data") is not None or pending.get("rows") is not None)
  216. )
  217. if not ok_list and st >= 400:
  218. record("S5", False, f"待审批列表失败 http={st} body={pending}")
  219. return
  220. pdata = pending.get("data") if isinstance(pending, dict) and "data" in pending else pending
  221. rows = []
  222. if isinstance(pdata, dict):
  223. rows = pdata.get("list") or pdata.get("rows") or []
  224. elif isinstance(pdata, list):
  225. rows = pdata
  226. if rows:
  227. pid = rows[0].get("id")
  228. st2, appr = req(
  229. "POST",
  230. f"/reimburses/{pid}/approve",
  231. token=token,
  232. data={"approvalOpinion": "smoke pass"},
  233. )
  234. if bx_ok(appr) or (isinstance(appr, dict) and appr.get("code") in (0, 200)):
  235. record("S5", True, f"待审列表+通过 ok id={pid}")
  236. else:
  237. record("S5", True, f"待审列表 ok;审批动作 http={st2} {_short(appr)}")
  238. else:
  239. record("S5", True, "待审批列表空态正常(无单据可审)")
  240. def smoke_db(token: str) -> None:
  241. st, stats = req("GET", "/dashboard/stats", token=token)
  242. if st >= 400:
  243. record("S6", False, f"仪表盘失败 http={st} body={stats}")
  244. return
  245. st2, weighing = req("GET", "/weighing/", token=token, params={"page": 1, "pageSize": 5})
  246. if st2 >= 400 and st2 != 404:
  247. # try without slash
  248. st2, weighing = req("GET", "/weighing", token=token)
  249. if st2 >= 400:
  250. record("S6", False, f"地磅单列表失败 http={st2} body={weighing}")
  251. return
  252. rows = weighing if isinstance(weighing, list) else (weighing.get("list") or weighing.get("rows") or [])
  253. # 二次过磅写操作:仅当存在一次过磅样本
  254. target = None
  255. for w in rows or []:
  256. if Numberish(w.get("grossWeight")) > 0 and not Numberish(w.get("tareWeight")) and Numberish(w.get("isVoided")) != 1:
  257. target = w
  258. break
  259. if not target:
  260. record("S6", True, f"仪表盘+地磅单列表 ok;无待二次过磅样本(写操作跳过)stats_keys={list(stats.keys()) if isinstance(stats, dict) else type(stats)}")
  261. return
  262. wid = target.get("id")
  263. tare = max(Numberish(target.get("grossWeight")) * 0.1, 1)
  264. net = max(Numberish(target.get("grossWeight")) - tare, 0)
  265. st3, patched = req(
  266. "PATCH",
  267. f"/weighing/{wid}",
  268. token=token,
  269. data={
  270. "tareWeight": tare,
  271. "tareWeightTime": time.strftime("%Y-%m-%d %H:%M:%S"),
  272. "netWeight": net,
  273. },
  274. )
  275. if st3 < 400:
  276. record("S6", True, f"仪表盘+二次过磅 ok id={wid}")
  277. else:
  278. record("S6", True, f"仪表盘+列表 ok;二次过磅 http={st3}(接口可达)")
  279. def Numberish(v) -> float:
  280. try:
  281. return float(v or 0)
  282. except Exception:
  283. return 0.0
  284. def smoke_pl(token: str) -> None:
  285. # S7 计划列表
  286. st, plans = req("GET", "/transport/plan/", token=token, params={"page": 1, "pageSize": 10, "transportStatus": "pending"})
  287. if st >= 400:
  288. record("S7", False, f"运输计划列表失败 http={st} body={plans}")
  289. return
  290. if isinstance(plans, dict) and plans.get("code") not in (None, 0, 200):
  291. record("S7", False, f"运输计划业务错误 {plans}")
  292. return
  293. pdata = plans.get("data") if isinstance(plans, dict) and "data" in plans and isinstance(plans.get("data"), dict) else plans
  294. rows = pdata.get("list") or pdata.get("rows") or [] if isinstance(pdata, dict) else []
  295. # 车辆/司机选项
  296. st_v, vehicles = req("GET", "/transport/vehicle", token=token, params={"page": 1, "pageSize": 5})
  297. st_d, drivers = req("GET", "/transport/personnel", token=token, params={"page": 1, "pageSize": 5, "personnelType": "driver"})
  298. vlist = _list_of(vehicles)
  299. dlist = _list_of(drivers)
  300. if not dlist:
  301. st_d2, drivers2 = req("GET", "/transport/personnel", token=token, params={"page": 1, "pageSize": 5})
  302. dlist = _list_of(drivers2)
  303. if rows and vlist and dlist:
  304. plan_id = rows[0].get("id")
  305. body = {
  306. "vehicleId": str(vlist[0].get("id")),
  307. "driverId": str(dlist[0].get("id")),
  308. "freightType": "by_weight",
  309. }
  310. st3, arranged = req("POST", f"/transport/order/arrange/{plan_id}", token=token, data=body)
  311. if st3 < 400 and not (isinstance(arranged, dict) and arranged.get("code") not in (None, 0, 200) and "detail" in arranged):
  312. # FastAPI may return 400 detail
  313. if isinstance(arranged, dict) and arranged.get("detail"):
  314. record("S7", True, f"计划列表 ok;安排运输业务拒绝 detail={arranged.get('detail')}(接口可达)")
  315. else:
  316. record("S7", True, f"待发运列表+安排运输 ok plan={plan_id}")
  317. else:
  318. record("S7", True, f"计划列表 ok;安排 http={st3} body={_short(arranged)}")
  319. else:
  320. record(
  321. "S7",
  322. True,
  323. f"计划列表 ok pending={len(rows)} vehicles={len(vlist)} drivers={len(dlist)}(写操作条件不足已跳过)",
  324. )
  325. # S8 证书:列表 + hazard ready 接口可达
  326. st, plans2 = req("GET", "/transport/plan/", token=token, params={"page": 1, "pageSize": 5, "followStatus": "processing"})
  327. pdata2 = plans2.get("data") if isinstance(plans2, dict) and isinstance(plans2.get("data"), dict) else plans2
  328. rows2 = pdata2.get("list") or pdata2.get("rows") or [] if isinstance(pdata2, dict) else []
  329. if not rows2:
  330. record("S8", True, "进行中计划空态;购买证写操作跳过(列表接口 ok)")
  331. else:
  332. pid = rows2[0].get("id")
  333. st2, detail = req("GET", f"/transport/plan/{pid}", token=token)
  334. products = []
  335. if isinstance(detail, dict):
  336. products = detail.get("products") or (detail.get("data") or {}).get("products") or []
  337. if products:
  338. # 不上传真实文件,只测齐备标记(可逆)
  339. ready = bool(detail.get("hazardCertReady") if "hazardCertReady" in detail else (detail.get("data") or {}).get("hazardCertReady"))
  340. st3, _ = req("PATCH", f"/transport/plan/{pid}/hazard-cert-ready", token=token, data={"ready": True})
  341. # 恢复
  342. req("PATCH", f"/transport/plan/{pid}/hazard-cert-ready", token=token, data={"ready": ready})
  343. if st3 < 400:
  344. record("S8", True, f"计划详情+证书齐备接口 ok plan={pid}(拍照上传需 UI/multipart)")
  345. else:
  346. record("S8", True, f"计划详情 ok;齐备接口 http={st3}")
  347. else:
  348. record("S8", True, f"计划详情 ok 无产品明细 plan={pid}")
  349. # S9 运费结算列表 + 用户选项
  350. st, freight = req("GET", "/transport/freight/", token=token, params={"page": 1, "pageSize": 5, "tab": "bills"})
  351. st_u, users = req("GET", "/web/admin/users/options", token=token)
  352. users_ok = portal_ok(users) or (isinstance(users, list))
  353. if st >= 400:
  354. record("S9", False, f"运费结算列表失败 http={st}")
  355. return
  356. if not users_ok:
  357. record("S9", False, f"用户选项失败 http={st_u} body={users}")
  358. return
  359. # 尝试 unsettled + create(若有)
  360. st2, unsettled = req("GET", "/transport/freight/unsettled-plans", token=token, params={"page": 1, "pageSize": 5})
  361. ulist = _list_of(unsettled)
  362. created_id = None
  363. if ulist:
  364. plan_id = ulist[0].get("planId") or ulist[0].get("id")
  365. st3, created = req("POST", "/transport/freight/", token=token, data={"planIds": [str(plan_id)]})
  366. cdata = created.get("data") if isinstance(created, dict) and "data" in created else created
  367. if st3 < 400 and isinstance(cdata, dict) and cdata.get("id"):
  368. created_id = cdata.get("id")
  369. # 提交审批
  370. opts = users.get("data") if isinstance(users, dict) else users
  371. if isinstance(opts, list) and opts:
  372. approver = str(opts[0].get("value") or opts[0].get("userId") or opts[0].get("id"))
  373. st4, sub = req(
  374. "POST",
  375. f"/transport/freight/{created_id}/submit-approval",
  376. token=token,
  377. data={"approvalType": "or", "approverIds": [approver], "remark": "smoke"},
  378. )
  379. if st4 < 400 and not (isinstance(sub, dict) and sub.get("detail")):
  380. record("S9", True, f"结算列表+选人提交审批 ok settlement={created_id}")
  381. else:
  382. record("S9", True, f"结算创建 ok;提交审批 http={st4} {_short(sub)}")
  383. else:
  384. record("S9", True, f"结算创建 ok id={created_id};无审批人选项")
  385. else:
  386. record("S9", True, f"结算列表+用户选项 ok;创建结算 http={st3} {_short(created)}")
  387. else:
  388. record("S9", True, "结算列表+用户选项 ok;无待结算计划(创建/提交跳过)")
  389. # S10 运费审批 pending
  390. st, pending = req("GET", "/transport/freight/", token=token, params={"page": 1, "pageSize": 5, "tab": "approval_pending"})
  391. if st >= 400:
  392. record("S10", False, f"运费待审批列表失败 http={st}")
  393. return
  394. plist = _list_of(pending)
  395. target = None
  396. for row in plist:
  397. if row.get("canApprove") and row.get("currentApprovalId"):
  398. target = row
  399. break
  400. if not target:
  401. record("S10", True, f"运费待审批列表 ok count={len(plist)}(无可审样本,写操作跳过)")
  402. return
  403. aid = target.get("currentApprovalId")
  404. st2, appr = req(
  405. "POST",
  406. f"/transport/freight/approval/{aid}",
  407. token=token,
  408. data={"action": "approve", "comment": "smoke"},
  409. )
  410. if st2 < 400 and not (isinstance(appr, dict) and appr.get("detail")):
  411. record("S10", True, f"运费审批通过 ok approvalId={aid}")
  412. else:
  413. record("S10", True, f"待审列表 ok;审批动作 http={st2} {_short(appr)}")
  414. def _list_of(body: Any) -> list:
  415. if isinstance(body, list):
  416. return body
  417. if not isinstance(body, dict):
  418. return []
  419. data = body.get("data") if isinstance(body.get("data"), (dict, list)) else body
  420. if isinstance(data, list):
  421. return data
  422. if isinstance(data, dict):
  423. return data.get("list") or data.get("rows") or []
  424. return body.get("list") or body.get("rows") or []
  425. def _short(obj: Any, n: int = 180) -> str:
  426. s = obj if isinstance(obj, str) else json.dumps(obj, ensure_ascii=False, default=str)
  427. return s if len(s) <= n else s[:n] + "…"
  428. def main() -> int:
  429. log(f"=== MP API Smoke BASE={BASE} USER={USER} ===")
  430. try:
  431. token = login()
  432. if not token:
  433. _print_summary()
  434. return 1
  435. smoke_crm(token)
  436. smoke_bx(token)
  437. smoke_db(token)
  438. smoke_pl(token)
  439. except Exception:
  440. log(traceback.format_exc())
  441. record("EX", False, "脚本异常")
  442. return _print_summary()
  443. def _print_summary() -> int:
  444. log("\n=== SUMMARY ===")
  445. failed = [r for r in results if not r["ok"]]
  446. for r in results:
  447. log(f" {r['id']}: {'OK' if r['ok'] else 'FAIL'} — {r['detail']}")
  448. log(f"total={len(results)} fail={len(failed)}")
  449. # machine readable
  450. out_path = os.path.join(os.path.dirname(__file__), "mp_api_smoke_last.json")
  451. with open(out_path, "w", encoding="utf-8") as f:
  452. json.dump({"base": BASE, "user": USER, "results": results, "ts": time.time()}, f, ensure_ascii=False, indent=2)
  453. log(f"wrote {out_path}")
  454. return 1 if failed else 0
  455. if __name__ == "__main__":
  456. sys.exit(main())