You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
240 lines
14 KiB
240 lines
14 KiB
|
14 hours ago
|
# -*- coding: utf-8 -*-
|
||
|
|
"""票16 黑盒审计 · API 层探针(只读 + 自建数据操作,不改产品代码)
|
||
|
|
产出: probe-result.json 供 audit-defects.md 引用
|
||
|
|
"""
|
||
|
|
import io, json, sys, time, urllib.request, urllib.parse, urllib.error
|
||
|
|
|
||
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||
|
|
BASE = "http://localhost:8080"
|
||
|
|
AUD = r"E:\code\crm-backend-matt\.scratch\crm-project\audit"
|
||
|
|
DIRECTOR, SALES = "99021002", "99021003"
|
||
|
|
CUSTOMER, CARD = "99021001", "99021008"
|
||
|
|
|
||
|
|
def token(uid):
|
||
|
|
j = _req("GET", "/api/auth/debug/token?userId=" + uid)
|
||
|
|
d = j["data"]
|
||
|
|
return d if isinstance(d, str) else d["token"]
|
||
|
|
|
||
|
|
def _req(method, path, body=None, tok=None, ctype="application/x-www-form-urlencoded;charset=UTF-8"):
|
||
|
|
url = BASE + path
|
||
|
|
data = None
|
||
|
|
headers = {}
|
||
|
|
if body is not None:
|
||
|
|
data = urllib.parse.urlencode(body).encode()
|
||
|
|
headers["Content-Type"] = ctype
|
||
|
|
if tok:
|
||
|
|
headers["Authorization"] = "Bearer " + tok
|
||
|
|
r = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(r, timeout=20) as resp:
|
||
|
|
raw = resp.read().decode("utf-8", "replace")
|
||
|
|
try:
|
||
|
|
return json.loads(raw)
|
||
|
|
except Exception:
|
||
|
|
return {"__raw": raw[:300], "__status": resp.status}
|
||
|
|
except urllib.error.HTTPError as e:
|
||
|
|
raw = e.read().decode("utf-8", "replace")
|
||
|
|
try:
|
||
|
|
return json.loads(raw)
|
||
|
|
except Exception:
|
||
|
|
return {"__raw": raw[:300], "__status": e.code}
|
||
|
|
except Exception as e:
|
||
|
|
return {"__error": str(e)}
|
||
|
|
|
||
|
|
results = []
|
||
|
|
def rec(case, got, expect, note=""):
|
||
|
|
ok = got == expect if not isinstance(expect, (list, tuple, set)) else got in expect
|
||
|
|
results.append({"case": case, "got": got, "expect": str(expect), "ok": bool(ok), "note": str(note)[:400]})
|
||
|
|
print(("PASS " if ok else "!! ") + case + " | got=" + str(got)[:160] + ((" | " + note) if note else ""))
|
||
|
|
|
||
|
|
TOKD = token(DIRECTOR)
|
||
|
|
TOKS = token(SALES)
|
||
|
|
|
||
|
|
# ---------- 0. 信封形态 ----------
|
||
|
|
j = _req("GET", "/api/project/board?scope=manage", tok=TOKD)
|
||
|
|
rec("envelope keys = code/success/message/data", sorted(j.keys()), ["code", "data", "message", "success"])
|
||
|
|
rec("board.conflictPendingCount 类型", type(j["data"]["conflictPendingCount"]).__name__, ["int", "str"], "原型为数字;若str则前端依赖宽松")
|
||
|
|
|
||
|
|
# ---------- 1. 建档校验 ----------
|
||
|
|
cases = [
|
||
|
|
("缺项目名称", {"customerId": CUSTOMER, "schemeCardId": CARD}),
|
||
|
|
("缺客户", {"projectName": "A5AUD-x", "schemeCardId": CARD}),
|
||
|
|
("缺方案卡", {"projectName": "A5AUD-x", "customerId": CUSTOMER}),
|
||
|
|
("卡客户不匹配", {"projectName": "A5AUD-x", "customerId": "99999001", "schemeCardId": CARD}),
|
||
|
|
("客户不存在", {"projectName": "A5AUD-x", "customerId": "88888888", "schemeCardId": CARD}),
|
||
|
|
("方案卡不存在", {"projectName": "A5AUD-x", "customerId": CUSTOMER, "schemeCardId": "77777777"}),
|
||
|
|
]
|
||
|
|
for name, body in cases:
|
||
|
|
j = _req("POST", "/api/project/create", body, tok=TOKD)
|
||
|
|
rec("create 校验:「" + name + "」被拒", (j.get("code"), j.get("success")), ("not0", False) if j.get("code") else j,
|
||
|
|
f"code={j.get('code')} msg={j.get('message')}")
|
||
|
|
|
||
|
|
# 成功建档 → 落库形态
|
||
|
|
nm = "A5AUD-主流程-" + time.strftime("%H%M%S")
|
||
|
|
j = _req("POST", "/api/project/create", {"projectName": nm, "customerId": CUSTOMER, "schemeCardId": CARD,
|
||
|
|
"projectAmount": "1200000", "brand": "itc", "regionCode": "440113"}, tok=TOKD)
|
||
|
|
rec("create 合法入参成功", j.get("code"), 0, str(j)[:200])
|
||
|
|
PID = j.get("data")
|
||
|
|
|
||
|
|
# PROJECT 卡重复绑定:同卡同客户再建一个项目
|
||
|
|
j2 = _req("POST", "/api/project/create", {"projectName": "A5AUD-重复卡-" + time.strftime("%H%M%S"),
|
||
|
|
"customerId": CUSTOMER, "schemeCardId": CARD}, tok=TOKD)
|
||
|
|
rec("create 同方案卡重复绑定(一客户一卡铁律)", j2.get("code"), ["not0_or_0待复核"], f"code={j2.get('code')} msg={j2.get('message')}")
|
||
|
|
PID_DUP = j2.get("data") if j2.get("code") == 0 else None
|
||
|
|
|
||
|
|
# 落库形态
|
||
|
|
j = _req("GET", "/api/project/detail?id=" + str(PID), tok=TOKD)
|
||
|
|
d = j.get("data") or {}
|
||
|
|
rec("建项目落库 stage=0", d.get("projectStage"), 0)
|
||
|
|
rec("建项目落库 status=1 进行中", d.get("projectStatus"), 1)
|
||
|
|
rec("建项目落库 filingStatus=1 审核中", d.get("filingStatus"), 1)
|
||
|
|
rec("建项目落库 bidResult 未定", d.get("bidResult"), [0, None])
|
||
|
|
rec("detail 含 ownerNameSnapshot", bool(d.get("ownerNameSnapshot")), True, str(d.get("ownerNameSnapshot")))
|
||
|
|
rec("detail 含 customerName", bool(d.get("customerName")), True, str(d.get("customerName")))
|
||
|
|
print("DETAIL KEYS:", sorted(d.keys()))
|
||
|
|
|
||
|
|
# ---------- 2. 无权限/未登录 ----------
|
||
|
|
j = _req("GET", "/api/project/detail?id=" + str(PID))
|
||
|
|
rec("未登录读 detail → 401 信封", (j.get("code"), j.get("success")), ("not0", False))
|
||
|
|
j = _req("GET", "/api/project/detail/" + str(PID), tok=TOKD)
|
||
|
|
rec("@PathVariable 形态 /project/detail/{id} 不存在", j.get("code"), ["not0", 404, "not_found_404"], str(j)[:120])
|
||
|
|
j = _req("PUT", "/api/project/detail?id=" + str(PID), {"projectName": "x"}, tok=TOKD)
|
||
|
|
rec("PUT 方法被拒(只 POST 写)", j.get("__status", j.get("code")), [405, "not0"], str(j)[:120])
|
||
|
|
j = _req("DELETE", "/api/project/follow/list?projectId=" + str(PID), tok=TOKD)
|
||
|
|
rec("DELETE 方法被拒", j.get("__status", j.get("code")), [405, "not0"], str(j)[:120])
|
||
|
|
|
||
|
|
# ---------- 3. 列表/分页/筛选 ----------
|
||
|
|
j = _req("GET", "/api/project/list?scope=manage¤t=1&size=2", tok=TOKD)
|
||
|
|
p = j.get("data") or {}
|
||
|
|
rec("list 分页结构含 content/total", ("content" in p and "total" in p), True, str(sorted(p.keys())))
|
||
|
|
rec("list size=2 生效", len((p.get("content") or [])), 2)
|
||
|
|
for st in ["0", "1", "2", "3", "4", "5", "6"]:
|
||
|
|
jj = _req("GET", f"/api/project/list?scope=manage&stage={st}¤t=1&size=50", tok=TOKD)
|
||
|
|
rows = (jj.get("data") or {}).get("content") or []
|
||
|
|
bad = [r for r in rows if str(r.get("projectStage")) != st]
|
||
|
|
rec(f"list 阶段筛 stage={st} 只回该阶段", len(bad), 0, f"rows={len(rows)}")
|
||
|
|
j = _req("GET", "/api/project/list?scope=manage&stage=7", tok=TOKD)
|
||
|
|
rec("list 非法阶段 stage=7", j.get("code"), ["not0", 0], f"code={j.get('code')} msg={j.get('message')}")
|
||
|
|
j = _req("GET", "/api/project/list?scope=manage&keyword=A5AUD¤t=1&size=50", tok=TOKD)
|
||
|
|
rows = (j.get("data") or {}).get("content") or []
|
||
|
|
rec("list keyword 筛命中自建项目", any(r.get("projectName", "").startswith("A5AUD") for r in rows), True, f"rows={len(rows)}")
|
||
|
|
j = _req("GET", "/api/project/list?scope=manage&filingStatus=1¤t=1&size=50", tok=TOKD)
|
||
|
|
rows = (j.get("data") or {}).get("content") or []
|
||
|
|
bad = [r for r in rows if r.get("filingStatus") != 1]
|
||
|
|
rec("list 报备独立筛 filing=1", len(bad), 0, f"rows={len(rows)}")
|
||
|
|
j = _req("GET", "/api/project/list?scope=mine¤t=1&size=50", tok=TOKS)
|
||
|
|
rows = (j.get("data") or {}).get("content") or []
|
||
|
|
rec("普通销售 mine 空或只含本人", all((r.get("ownerUserId") in (None, int(SALES))) for r in rows), True, f"rows={len(rows)}")
|
||
|
|
|
||
|
|
# ---------- 4. 状态机主流程(在 PID 上) ----------
|
||
|
|
# 报备驳回 → filing=3 停 stage0
|
||
|
|
j = _req("POST", "/api/project/filing/review", {"id": PID, "approved": "false", "remark": "材料不齐"}, tok=TOKD)
|
||
|
|
rec("报备驳回成功", j.get("code"), 0, str(j)[:150])
|
||
|
|
d = (_req("GET", "/api/project/detail?id=" + str(PID), tok=TOKD).get("data") or {})
|
||
|
|
rec("驳回后 filing=3 且 stage 仍 0", (d.get("filingStatus"), d.get("projectStage")), (3, 0))
|
||
|
|
# 再审通过 → filing=2 + 0→1
|
||
|
|
j = _req("POST", "/api/project/filing/review", {"id": PID, "approved": "true", "remark": "复审通过"}, tok=TOKD)
|
||
|
|
rec("驳回后可再审通过", j.get("code"), 0, str(j)[:150])
|
||
|
|
d = (_req("GET", "/api/project/detail?id=" + str(PID), tok=TOKD).get("data") or {})
|
||
|
|
rec("通过后 filing=2 + stage=1 双写", (d.get("filingStatus"), d.get("projectStage")), (2, 1))
|
||
|
|
# stage0 时回退 targets 应为空
|
||
|
|
j = _req("GET", "/api/project/stage/rollback-targets?id=" + str(0) + "00", tok=TOKD)
|
||
|
|
rec("rollback-targets 非法 id 报错", j.get("code"), "not0", str(j)[:120])
|
||
|
|
|
||
|
|
# 推进 1→2→3
|
||
|
|
for st in (1, 2):
|
||
|
|
j = _req("POST", "/api/project/stage/advance", {"id": PID}, tok=TOKD)
|
||
|
|
rec(f"推进 {st}→{st+1}", j.get("code"), 0, str(j)[:150])
|
||
|
|
d = (_req("GET", "/api/project/detail?id=" + str(PID), tok=TOKD).get("data") or {})
|
||
|
|
rec("推进至 stage3", d.get("projectStage"), 3)
|
||
|
|
|
||
|
|
# stage3 推进须带 skipBidding 或普通推进 3→4
|
||
|
|
j = _req("POST", "/api/project/stage/advance", {"id": PID, "skipBidding": "true"}, tok=TOKD)
|
||
|
|
rec("3→5 连跳(无需招投标)", j.get("code"), 0, str(j)[:150])
|
||
|
|
d = (_req("GET", "/api/project/detail?id=" + str(PID), tok=TOKD).get("data") or {})
|
||
|
|
rec("连跳后 stage=5", d.get("projectStage"), 5)
|
||
|
|
# 连跳后回退可选节点 = {1,2,3}(stage4 不在列)
|
||
|
|
j = _req("GET", "/api/project/stage/rollback-targets?id=" + str(PID), tok=TOKD)
|
||
|
|
targets = [t.get("stage") for t in (j.get("data") or [])]
|
||
|
|
rec("连跳后可选节点={1,2,3} 无4", targets, [1, 2, 3], str(targets))
|
||
|
|
|
||
|
|
# 回退到 2 → 再推回 5
|
||
|
|
j = _req("POST", "/api/project/stage/rollback", {"id": PID, "targetStage": "2"}, tok=TOKD)
|
||
|
|
rec("多步回退 5→2(总监)", j.get("code"), 0, str(j)[:150])
|
||
|
|
d = (_req("GET", "/api/project/detail?id=" + str(PID), tok=TOKD).get("data") or {})
|
||
|
|
rec("回退后 stage=2", d.get("projectStage"), 2)
|
||
|
|
_req("POST", "/api/project/stage/advance", {"id": PID}, tok=TOKD) # 2→3
|
||
|
|
_req("POST", "/api/project/stage/advance", {"id": PID, "skipBidding": "true"}, tok=TOKD) # 3→5
|
||
|
|
|
||
|
|
# 回退越界:targetStage=0
|
||
|
|
j = _req("POST", "/api/project/stage/rollback", {"id": PID, "targetStage": "0"}, tok=TOKD)
|
||
|
|
rec("回退下限 stage1(target 0 被拒)", j.get("code"), "not0", f"code={j.get('code')} msg={j.get('message')}")
|
||
|
|
|
||
|
|
# 权限:普通销售回退 68005
|
||
|
|
j = _req("POST", "/api/project/stage/rollback", {"id": PID, "targetStage": "2"}, tok=TOKS)
|
||
|
|
rec("普通销售回退被拒 68005", j.get("code"), 68005, str(j.get("message")))
|
||
|
|
|
||
|
|
# stage5 未标记时先试 mark 非法值
|
||
|
|
j = _req("POST", "/api/project/bid-result/mark", {"id": PID, "bidResult": "9"}, tok=TOKD)
|
||
|
|
rec("bidResult 非法值被拒", j.get("code"), "not0", f"code={j.get('code')} msg={j.get('message')}")
|
||
|
|
|
||
|
|
# 标记赢单 → stage5 闭环 status 仍 1
|
||
|
|
j = _req("POST", "/api/project/bid-result/mark", {"id": PID, "bidResult": "1"}, tok=TOKD)
|
||
|
|
rec("标记赢单成功", j.get("code"), 0, str(j)[:150])
|
||
|
|
d = (_req("GET", "/api/project/detail?id=" + str(PID), tok=TOKD).get("data") or {})
|
||
|
|
rec("赢单: stage 仍 5 + status 仍 1 + bidResult=1", (d.get("projectStage"), d.get("projectStatus"), d.get("bidResult")), (5, 1, 1))
|
||
|
|
j = _req("POST", "/api/project/stage/rollback", {"id": PID, "targetStage": "2"}, tok=TOKD)
|
||
|
|
rec("赢单锁死回退 68003", j.get("code"), 68003, str(j.get("message")))
|
||
|
|
j = _req("POST", "/api/project/bid-result/mark", {"id": PID, "bidResult": "2"}, tok=TOKD)
|
||
|
|
rec("赢单后不可改输单(锁)", j.get("code"), "not0", f"code={j.get('code')} msg={j.get('message')}")
|
||
|
|
# 赢单项目可否关闭?
|
||
|
|
j = _req("POST", "/api/project/close", {"id": PID, "closeType": "MANUAL", "closeReason": "audit"}, tok=TOKD)
|
||
|
|
rec("赢单项目关闭(spec 未禁)", j.get("code"), ["not0", 0], f"code={j.get('code')} msg={j.get('message')}")
|
||
|
|
d = (_req("GET", "/api/project/detail?id=" + str(PID), tok=TOKD).get("data") or {})
|
||
|
|
rec("关闭后 status=2", d.get("projectStatus"), 2)
|
||
|
|
|
||
|
|
# ---------- 5. 输单分叉(用 PID_DUP 或新建) ----------
|
||
|
|
if PID_DUP:
|
||
|
|
pid2 = PID_DUP
|
||
|
|
_req("POST", "/api/project/filing/review", {"id": pid2, "approved": "true"}, tok=TOKD)
|
||
|
|
for _ in range(2):
|
||
|
|
_req("POST", "/api/project/stage/advance", {"id": pid2}, tok=TOKD)
|
||
|
|
_req("POST", "/api/project/stage/advance", {"id": pid2, "skipBidding": "true"}, tok=TOKD)
|
||
|
|
j = _req("POST", "/api/project/bid-result/mark", {"id": pid2, "bidResult": "2"}, tok=TOKD)
|
||
|
|
rec("标记输单 → stage6 结项", j.get("code"), 0, str(j)[:150])
|
||
|
|
d = (_req("GET", "/api/project/detail?id=" + str(pid2), tok=TOKD).get("data") or {})
|
||
|
|
rec("输单: stage=6 status=3 bidResult=2", (d.get("projectStage"), d.get("projectStatus"), d.get("bidResult")), (6, 3, 2))
|
||
|
|
j = _req("POST", "/api/project/stage/rollback", {"id": pid2, "targetStage": "1"}, tok=TOKD)
|
||
|
|
rec("结项终态回退被拒", j.get("code"), "not0", f"code={j.get('code')} msg={j.get('message')}")
|
||
|
|
j = _req("POST", "/api/project/close", {"id": pid2, "closeType": "MANUAL"}, tok=TOKD)
|
||
|
|
rec("已结项不可再关闭", j.get("code"), "not0", f"code={j.get('code')} msg={j.get('message')}")
|
||
|
|
|
||
|
|
# ---------- 6. 动态事件流 ----------
|
||
|
|
j = _req("GET", f"/api/project/timeline/list?projectId={PID}¤t=1&size=50", tok=TOKD)
|
||
|
|
events = [ (t.get("action") or t.get("actionName")) for t in ((j.get("data") or {}).get("content") or []) ]
|
||
|
|
print("TIMELINE:", events)
|
||
|
|
for want in ["创建", "报备"]:
|
||
|
|
rec("动态含「" + want + "」事件", any(want in str(e) for e in events), True, str(events)[:200])
|
||
|
|
|
||
|
|
# ---------- 7. 页签读写 ----------
|
||
|
|
j = _req("POST", "/api/project/follow/add", {"projectId": PID, "followContent": "审计跟进", "followTime": "2026-09-10 10:00:00"}, tok=TOKD)
|
||
|
|
rec("跟进新增", j.get("code"), 0, str(j)[:150])
|
||
|
|
j = _req("POST", "/api/project/follow/add", {"projectId": PID, "followContent": ""}, tok=TOKD)
|
||
|
|
rec("跟进缺内容被拒", j.get("code"), "not0", f"code={j.get('code')} msg={j.get('message')}")
|
||
|
|
j = _req("POST", "/api/project/attachment/add", {"projectId": PID, "fileId": "1", "bizType": "PROJECT"}, tok=TOKD)
|
||
|
|
rec("附件挂载(fileId=1)", j.get("code"), ["not0", 0], f"code={j.get('code')} msg={j.get('message')}")
|
||
|
|
aid = (j.get("data") or {}).get("id") if isinstance(j.get("data"), dict) else j.get("data")
|
||
|
|
j = _req("GET", f"/api/project/attachment/list?projectId={PID}", tok=TOKD)
|
||
|
|
rec("附件列表可读", j.get("code"), 0, str(j)[:120])
|
||
|
|
for sub in ["scheme", "quote", "bidding", "bid-open"]:
|
||
|
|
j = _req("GET", f"/api/project/{sub}?projectId={PID}", tok=TOKD)
|
||
|
|
rec(f"GET /project/{sub} 可读", j.get("code"), 0, str(j)[:120])
|
||
|
|
j = _req("GET", f"/api/project/customer/list?projectId={PID}", tok=TOKD)
|
||
|
|
cl = j.get("data") or []
|
||
|
|
rec("客户页签=单客户(一项目一客户)", (j.get("code"), len(cl)), (0, 1), str(cl)[:200])
|
||
|
|
|
||
|
|
out = {"results": results, "PID": PID, "PID_DUP": PID_DUP}
|
||
|
|
open(AUD + r"\probe-result.json", "w", encoding="utf-8").write(json.dumps(out, ensure_ascii=False, indent=1))
|
||
|
|
fails = [r for r in results if not r["ok"]]
|
||
|
|
print(f"\n==== {len(results)-len(fails)}/{len(results)} PASS, {len(fails)} 偏差 ====")
|