Files
xiaotutongxue/scripts/e2e-m8-open.py
T
lovife 0bbac7d228 feat(pc,platform): 验收走查修复(平台按学校代管 + 菜单/403/日志范围/分页)
客户在验收里点出来的问题,一次改完。两段改动:

一、平台运营与租户范围(后端为主)
- 新增请求头 X-Tenant-Id:平台运营是唯一跨租户身份,租户拦截器对它整体关掉了过滤,
  于是「组织架构 / 账号管理 / 工作台」拿到的是所有租户混在一起的数据 —— 而只有一所
  学校有数据时,它和学校管理员看到的完全一样,看不出错(这是最危险的一点)。
  现在这些页面必须先选定学校,服务端再把本次请求的租户上下文换成目标租户:
  只认平台身份(其它身份带头一律忽略)、目标租户必须存在且启用。
- OrgService.tree / UserService.list 补显式 tenant_id 条件(原来只靠拦截器,而拦截器对
  平台不生效);TaskService.seesWholeTenant 改用新的 LoginUser.isPlatformStaff() ——
  isPlatformAdmin 回答"当前租户是不是平台租户",代管时它为 false,会让平台退化成普通
  用户(任务列表掉进"班主任只看自己带的班"分支,得到空列表);PLATFORM_ADMIN 补
  TASK_VIEW(PRD §3.2「查看填报进度看板:平台运营 ✅」)。
- 操作日志对平台永远是空的:SQL 写死 tenant_id = 当前租户,而平台的 tenantId 是 0。
  按契约 §13.4 补 tenantId 参数与 log:viewAll:平台默认看全量、可指定某所学校;
  学校管理员恒为本租户,传别家返回 40301(不静默忽略,否则会让人以为"筛过了")。
- 开放接口"推送重试"从未生效(F-112):定时扫描缺 @InterceptorIgnore,异常被 log.warn
  吞掉;同一方法里"接入方已停用"那条 update 也没有租户上下文,会每 10 秒重试到永远。
  E2E 补了"真的重试了"的断言(原来只断言"安排了下次重试")。

二、PC 端走查体验
- 去掉 3 条无用菜单(名单管理 / 表单设计器 / 材料规则):功能都在「收集任务」行内入口,
  菜单里那一条点进去只有指路;后端菜单键同步删除,entryAt 那套机制一并删掉。
- 班主任点工作台必弹 403:工作台无条件调了要 org:view / progress:viewAll 的两个接口。
  403 走 HTTP 状态码,try/catch 拦不住它的弹窗 —— 改成按权限决定发不发请求。
- 操作日志:分页器被裁在可视区外(新增 .g-list-card 让表体自己滚、表头钉住、分页器留在
  卡片底部)+ 去掉页面上那段免责声明;收集任务的状态切换改成单独一行 tab;去掉顶栏假数据
  铃铛;配色按客户要求还原。

顺带修两处"检查器自己不严":check-wiring 新增第 ⑦ 项(PC 端引用的权限码必须存在于
PermissionDef —— 本轮我自己把 progress:viewAll 写成 progress:view:all,判断恒为 false,
而编译 / typecheck / lint / E2E 全都不红);verify-docs 的表格列数检查不再把 GFM 转义
竖线当成列分隔符。两个检查器都拿"故意写错的样例"试过,确认真的会红。

验证:typecheck / mvn package / check-wiring(未接线声明 0 条)/ verify-docs(22 项)全过;
e2e-m0 36、e2e-m1-orgs 48、e2e-m0-user 55、e2e-m5-logs 44、e2e-m5-notify-wiring 43、
e2e-m1-tenant-domain 6、e2e-m8-open 61 全绿;浏览器按角色逐个真点复验。
详见 docs/交接摘要.md §一·三十三 与 §一·三十四。
2026-10-09 17:29:20 +08:00

595 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""开放接口 E2E(《03-接口设计》§14)—— 供认定机构对接。
## 验收重点:鉴权是这一批接口的**全部价值所在**
开放接口把数据交给**外部系统**,所以每一个"能拿到数据"的前提都必须被证明是有门槛的:
① **签名**:`HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)`,
且 body 是**原始 JSON**(字段顺序一变签名就不一样);
② **时间窗**:偏差 > 5 分钟拒绝(挡"把昨天抓到的包今天重放");
③ **nonce 防重放**:5 分钟内同一个 nonce 只能用一次
(且必须是**原子占位** —— 先验签再占位的话,一个错误签名就能把 nonce 消耗掉);
④ **不泄露明文证件号**(契约 §14.2 明确要求)——
这条要**直接查响应里有没有 18 位数字串**,而不是看代码"应该是脱敏的";
⑤ **scope 校验**:只有 `submission:read` 的接入方拿不到文件链接;
⑥ **归属校验**:拿 A 任务的路径去拉 B 任务的填报单 → 403
(不校验的话,任何一家机构能拉走全部租户的数据)。
## 还有一件"契约没写但必须有"的事
§14 定义了"机构怎么调我们",却没定义 **appKey / appSecret 从哪来** ——
没有管理接口,§14 的四个接口在真实流程里**跑不起来**(只能靠改库,
而 appSecret 是密文列)。所以本脚本先建接入方,再验四个接口(见 F-67)。
跑法:python scripts/e2e-m8-open.py
"""
import argparse
import hashlib
import hmac
import importlib.util
import json
import os
import sys
import time
import urllib.error
import urllib.request
import uuid
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PASSED = []
FAILED = []
CREATED_TASK_IDS = []
CREATED_APP_IDS = []
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-open-roster.xlsx")
def _load(name, fn):
spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", fn))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
A = _load("e2e-m5-audit", "e2e-m5-audit.py")
BASE = "http://localhost:8080"
STU = 996201
TEACHER = 996202
APP_KEY = "e2e_jxjd_open"
APP_KEY_NOFILE = "e2e_only_submission"
def ok(name):
PASSED.append(name)
print(" [OK] " + name)
def fail(name, detail):
FAILED.append((name, detail))
print(" [FAIL] " + name + "\n " + detail)
def check(name, cond, detail=""):
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return bool(cond)
def sql(stmt):
_, out = R.sql(stmt)
return out
def body_of(method, path, token=None, payload=None):
_, b = R.call(method, BASE + path, token, payload)
return b
def mp(uid):
cfg = R.read_dev_config()
return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4)
def open_call(method, path, secret=None, app_key=None, body=None, headers=None,
token=None, skew_seconds=0, nonce=None, sign_override=None):
"""按契约发一个开放接口请求。
body 传**字符串**(原文)—— 签名覆盖的就是它,所以这里刻意不先序列化。
"""
url = BASE + path
raw = body if isinstance(body, str) else (json.dumps(body, ensure_ascii=False)
if body is not None else "")
data = raw.encode("utf-8") if raw else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json")
hdrs = dict(headers or {})
if token:
hdrs["Authorization"] = "Bearer " + token
else:
ts = str(int(time.time()) + skew_seconds)
nc = nonce or uuid.uuid4().hex[:8]
hdrs.setdefault("X-App-Key", app_key or APP_KEY)
hdrs.setdefault("X-Timestamp", ts)
hdrs.setdefault("X-Nonce", nc)
if secret is not None:
want = sign_override if sign_override is not None else hmac_sign(
secret, (app_key or APP_KEY) + ts + nc + raw)
hdrs.setdefault("X-Sign", want)
for k, v in hdrs.items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
txt = resp.read().decode("utf-8")
try:
return resp.status, json.loads(txt)
except Exception:
return resp.status, {"_raw": txt[:200]}
except urllib.error.HTTPError as e:
txt = e.read().decode("utf-8", errors="replace")
try:
return e.code, json.loads(txt)
except Exception:
return e.code, {"_raw": txt[:200]}
except Exception as e:
return -1, {"_error": str(e)[:200]}
def hmac_sign(secret, data):
return hmac.new(secret.encode("utf-8"), data.encode("utf-8"), hashlib.sha256).hexdigest()
def contains_plain_idcard(obj, ids):
"""递归找响应里有没有**明文证件号**(脱敏值里有 *,所以要找完整的 18 位)。"""
s = json.dumps(obj, ensure_ascii=False)
for i in ids:
if i in s:
return i
return None
def main():
ap = argparse.ArgumentParser(description="开放接口 E2E")
ap.parse_args()
print("=" * 74)
print(" 开放接口 E2E(§14)—— 供认定机构对接")
print("=" * 74)
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
if adm.get("code") != 0:
print(" x 管理员登录失败:" + str(adm))
return 2
at = adm["data"]["token"]
print("\n[0] 摆夹具(任务 + 名册 + 一份已终审通过的单子)")
suffix = os.urandom(3).hex()
idcard = R.with_checksum("36102920080707081")
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
"taskName": "E2E开放 " + suffix, "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
})
if b.get("code") != 0:
print(" x 建任务失败:" + str(b))
return 2
task_id = b["data"]["id"]
CREATED_TASK_IDS.append(task_id)
_, b = R.call("GET", BASE + "/api/v1/orgs/tree", at)
classes = []
def walk(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
walk(n.get("children"))
walk(b.get("data") or [])
org = classes[0]
teacher_tok = ensure_teacher(TEACHER, "openT" + suffix, "开放班主任", org["id"])
R.call("PUT", BASE + "/api/v1/tasks/%d/orgs" % task_id, at,
{"orgs": [{"orgId": org["id"], "expectedCount": 1, "headUserId": TEACHER}]})
R.call("POST", BASE + "/api/v1/tasks/%d/materials/apply-template" % task_id, at,
{"templateCode": "TPL_SCHOOL_ROLL"})
_build_roster([("开放甲", idcard)])
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
check("夹具:名册就绪", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:180])
R.call("POST", BASE + "/api/v1/tasks/%d/publish" % task_id, at, {"force": True})
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:mp:gate:user:%d > NUL 2>&1' % STU)
tok = mp(STU)
_, b = R.call("POST", BASE + "/api/v1/mp/roster/match", tok,
{"taskId": task_id, "realName": "开放甲", "idCardNo": idcard})
if b.get("code") != 0:
print(" x 门禁核验失败:" + json.dumps(b, ensure_ascii=False)[:200])
return 2
_, b = R.call("POST", BASE + "/api/v1/mp/submissions", tok, {"taskId": task_id})
sid = b["data"]["id"]
res = A.prepare_student(tok, sid, task_id, {
"work_unit": "无", "is_retake": False, "is_student": True,
"school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01",
"occupation": "车工", "worktype": "数控车工", "job_level": "四级"
}, dup_first_file=False)
check("夹具:学生已提交", ((res.get("data") or {}).get("submitted")) is True,
json.dumps(res, ensure_ascii=False)[:200])
# ⚠️ **审核放在"建接入方"之后**:推送只在"终审通过那一刻已存在的接入方"上触发,
# 而真实流程也是"机构先接入、之后审核通过才推送"。
# 我第一版把审核放在最前面,于是推送记录永远是 0 条 ——
# 而那个现象看起来像"推送功能没做"。
# ── 1. 建接入方(契约没写,但没有它 §14 跑不起来,见 F-67)──
print("\n[1] 接入方管理(契约漏了这一层,见 F-67)")
made = body_of("POST", "/api/v1/open-apps", at, {
"appName": "江西机电认定中心 " + suffix, "appKey": APP_KEY,
"callbackUrl": "http://127.0.0.1:9/callback-not-listening",
"pushSecret": "push-secret-" + suffix
})
check("能新建接入方", made.get("code") == 0, json.dumps(made, ensure_ascii=False)[:250])
app = made.get("data") or {}
if app.get("id"):
CREATED_APP_IDS.append(app["id"])
secret = app.get("appSecret")
check("⭐ 返回 appSecret(**只这一次**)", isinstance(secret, str) and len(secret) >= 20,
"长度=%s" % (len(secret) if secret else None))
check("列表**不回** appSecret", all(
x.get("appSecret") is None
for x in (body_of("GET", "/api/v1/open-apps", at).get("data") or [])),
"见列表响应")
check("⭐ 库里存的是密文(HMAC 要能还原,所以不能只存哈希)",
sql("select app_secret_cipher from collect_open_app where app_key='%s';" % APP_KEY)
.strip() != secret,
"密文前 20 位=" + sql("select app_secret_cipher from collect_open_app "
"where app_key='%s';" % APP_KEY).strip()[:20])
dup = body_of("POST", "/api/v1/open-apps", at, {"appName": "重复", "appKey": APP_KEY})
check("appKey 重复 → 明确报错", dup.get("code") != 0,
"code=%s msg=%s" % (dup.get("code"), dup.get("msg")))
# 第二个接入方:只有 submission:read(用来验 scope)
made2 = body_of("POST", "/api/v1/open-apps", at, {
"appName": "只有填报单权限 " + suffix, "appKey": APP_KEY_NOFILE,
"scopes": "submission:read"
})
app2 = made2.get("data") or {}
if app2.get("id"):
CREATED_APP_IDS.append(app2["id"])
secret2 = app2.get("appSecret")
check("能建只带 submission:read 的接入方", made2.get("code") == 0,
json.dumps(made2, ensure_ascii=False)[:200])
# ── 1b. 让这一份**在接入方已存在之后**通过终审(推送就此触发)──
print("\n[1b] 终审通过(此时接入方已存在,推送应被触发)")
a1 = body_of("POST", "/api/v1/audit/submissions/%d/approve" % sid, teacher_tok,
{"remark": "开放接口夹具:初审", "actionSource": "BUTTON"})
a2 = body_of("POST", "/api/v1/audit/submissions/%d/approve" % sid, at,
{"finalOpinion": "开放接口夹具:终审", "actionSource": "BUTTON"})
check("终审通过", a2.get("code") == 0 and (a2.get("data") or {}).get("status") == 3,
json.dumps(a2, ensure_ascii=False)[:200])
# 推送是 afterCommit 回调 + 外部 HTTP,给它一点时间(回调地址故意不通,会失败并安排重试)
time.sleep(3)
# ── 2. 签名校验 ──
print("\n[2] 签名校验(这一批接口的价值全在这里)")
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret)
check("正确签名 → 通过", r.get("code") == 0,
"http=%s %s" % (st, json.dumps(r, ensure_ascii=False)[:200]))
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id,
secret="wrong-secret")
check("⭐ 密钥不对 → 40101", r.get("code") == 40101,
"http=%s code=%s msg=%s" % (st, r.get("code"), r.get("msg")))
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id,
app_key="no-such-app", secret=secret)
check("⭐ 不存在的 appKey → 40101(且不透露它是否存在)",
r.get("code") == 40101, "code=%s msg=%s" % (r.get("code"), r.get("msg")))
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret,
skew_seconds=-400)
check("⭐ 时间戳偏差 > 5 分钟 → 拒绝(挡「昨天抓的包今天重放」)",
r.get("code") == 40101 and "分钟" in (r.get("msg") or ""),
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret,
skew_seconds=400)
check("时间戳快 6 分钟也拒(比的是**偏差绝对值**,不是「不能太旧」)",
r.get("code") == 40101, "code=%s" % r.get("code"))
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret,
headers={"X-Sign": "0" * 64})
check("签名格式对但值不对 → 拒绝", r.get("code") == 40101,
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=None,
headers={"X-Timestamp": "", "X-Nonce": "", "X-Sign": ""})
check("缺头 → 能照着做的错误消息(提示两种鉴权方式)",
r.get("code") in (40101, 40301), "code=%s msg=%s" % (r.get("code"), r.get("msg")))
# nonce 防重放
fixed_nonce = uuid.uuid4().hex[:8]
st, r1 = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret,
nonce=fixed_nonce)
check("第一次用这个 nonce → 通过", r1.get("code") == 0, json.dumps(r1, ensure_ascii=False)[:150])
st, r2 = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret,
nonce=fixed_nonce)
check("⭐ 同一个 nonce 第二次 → 拒绝(防重放)",
r2.get("code") == 40101 and "nonce" in (r2.get("msg") or ""),
"code=%s msg=%s" % (r2.get("code"), r2.get("msg")))
# 错误签名**不该**消耗 nonce(否则能被用来拒绝服务)
bad_nonce = uuid.uuid4().hex[:8]
open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret="wrong",
nonce=bad_nonce)
st, r3 = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret,
nonce=bad_nonce)
check("⭐ 错误签名不该消耗 nonce(否则能被用来拒绝服务)", r3.get("code") == 0,
"code=%s msg=%s" % (r3.get("code"), r3.get("msg")))
# ── 3. §14.1 令牌 ──
print("\n[3] §14.1 换取访问令牌")
tk = open_call("POST", "/api/v1/open/v1/auth/token", body={"appKey": APP_KEY,
"appSecret": secret})
check("换令牌成功", (tk[1] or {}).get("code") == 0,
json.dumps(tk[1], ensure_ascii=False)[:250])
tokv = (tk[1] or {}).get("data") or {}
access = tokv.get("accessToken")
check("返回 accessToken", isinstance(access, str) and len(access) > 20, str(access)[:20])
check("⭐ expireSeconds = 7200(契约 §14.1)", tokv.get("expireSeconds") == 7200,
str(tokv.get("expireSeconds")))
check("返回 scope 列表", isinstance(tokv.get("scope"), list) and len(tokv.get("scope")) >= 1,
json.dumps(tokv.get("scope"), ensure_ascii=False))
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, token=access)
check("⭐ 令牌能直接调数据接口(不用再算 HMAC)", r.get("code") == 0,
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id,
token="not-a-real-token")
check("假令牌 → 40101", r.get("code") == 40101, "code=%s" % r.get("code"))
st, r = open_call("POST", "/api/v1/open/v1/auth/token",
body={"appKey": APP_KEY, "appSecret": "wrong"})
check("appSecret 不对 → 40101(不是把令牌发出去)", r.get("code") == 40101,
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
# ── 4. §14.2 填报单 ──
print("\n[4] §14.2 拉取填报单")
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret)
data = r.get("data") or {}
check("拉到数据", (data.get("total") or 0) >= 1, json.dumps(data, ensure_ascii=False)[:250])
row = (data.get("list") or [{}])[0]
check("带姓名与脱敏证件号", row.get("realName") == "开放甲" and row.get("idCardMask"),
json.dumps(row, ensure_ascii=False)[:250])
check("⭐ **响应里没有明文证件号**(契约 §14.2 的硬要求)",
contains_plain_idcard(r, [idcard]) is None,
"找到了明文:" + str(contains_plain_idcard(r, [idcard])))
check("默认只返回已通过的(status=3)",
all(x.get("status") == 3 for x in data.get("list") or []),
json.dumps([x.get("status") for x in data.get("list") or []], ensure_ascii=False))
check("带职业/工种/等级", row.get("occupation") == "车工",
json.dumps(row, ensure_ascii=False)[:200])
check("带 taskCode / taskName", bool(data.get("taskCode")) and bool(data.get("taskName")),
"%s / %s" % (data.get("taskCode"), data.get("taskName")))
# since 增量
future = "2099-01-01T00:00:00"
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions?since=%s" % (task_id, future),
secret=secret)
check("⭐ since 之后的(未来时间)→ 0 条(增量拉取生效)",
((r.get("data") or {}).get("total") or 0) == 0,
json.dumps(r.get("data"), ensure_ascii=False)[:200])
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions?since=%d" % (task_id, 0),
secret=secret)
check("毫秒时间戳形式的 since 也认(调用方语言不同,两种都给)",
(r.get("code") == 0 and ((r.get("data") or {}).get("total") or 0) >= 1),
json.dumps(r.get("data"), ensure_ascii=False)[:200])
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions?since=不是时间" % task_id,
secret=secret)
check("⭐ since 格式不对 → 明确报错(不静默当成「不筛」,那会把全量推给对方)",
r.get("code") != 0, "code=%s msg=%s" % (r.get("code"), r.get("msg")))
st, r = open_call("GET", "/api/v1/open/v1/tasks/999999999/submissions", secret=secret)
check("不存在的任务 → 明确报错", r.get("code") != 0, "code=%s" % r.get("code"))
# ── 5. §14.3 文件 ──
print("\n[5] §14.3 拉取材料文件")
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files?submissionIds=%d" % (task_id, sid),
secret=secret)
fdata = r.get("data") or {}
check("拉到文件", len(fdata.get("files") or []) >= 1,
json.dumps(fdata, ensure_ascii=False)[:250])
f0 = (fdata.get("files") or [{}])[0]
check("带 materialCode / standardName / url / expireSeconds",
f0.get("materialCode") and f0.get("standardName") and f0.get("url")
and f0.get("expireSeconds") == 1800,
json.dumps(f0, ensure_ascii=False)[:300])
check("⭐ 文件里也没有明文证件号", contains_plain_idcard(r, [idcard]) is None,
"找到了明文")
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files" % task_id, secret=secret)
check("不传 submissionIds → 明确报错", r.get("code") != 0,
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
ids201 = ",".join(str(i) for i in range(1, 202))
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files?submissionIds=%s" % (task_id, ids201),
secret=secret)
check("⭐ 超过 200 份 → 40011(契约 §14.3 的上限)", r.get("code") == 40011,
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files?submissionIds=abc" % task_id,
secret=secret)
check("submissionIds 里不是数字 → 明确报错", r.get("code") != 0,
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
# 归属校验:用别的任务的填报单 id 去拉
st, r = open_call("GET", "/api/v1/open/v1/tasks/999999999/files?submissionIds=%d" % sid,
secret=secret)
check("⭐ 用别的任务路径拉这份文件 → 被拦住(不校验就能拉走全部数据)",
r.get("code") != 0, "code=%s msg=%s" % (r.get("code"), r.get("msg")))
# ── 6. scope ──
print("\n[6] scope 权限")
# ⚠️ 必须带**它自己的** appKey:不带的话 open_call 会用第一个接入方的 key,
# 于是签名对不上 —— 表现是"签名不通过",很容易被误判成实现有 bug
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret2,
app_key=APP_KEY_NOFILE)
check("只有 submission:read 的接入方能拉填报单", r.get("code") == 0,
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files?submissionIds=%d" % (task_id, sid),
secret=secret2, app_key=APP_KEY_NOFILE)
check("⭐ 同一个接入方拉文件 → 40301(没有 file:read)",
r.get("code") == 40301 and "file:read" in (r.get("msg") or ""),
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
# ── 7. 停用立刻生效 ──
print("\n[7] 停用立刻生效(不用等令牌过期)")
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, token=access)
check("停用前令牌可用", r.get("code") == 0, "code=%s" % r.get("code"))
tg = body_of("POST", "/api/v1/open-apps/%d/toggle" % app["id"], at)
check("停用接入方", tg.get("code") == 0 and (tg.get("data") or {}).get("enabled") is False,
json.dumps(tg, ensure_ascii=False)[:200])
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, token=access)
check("⭐ 停用后**未过期的令牌也立刻失效**(令牌校验每次回查接入方)",
r.get("code") == 40101, "code=%s msg=%s" % (r.get("code"), r.get("msg")))
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret)
check("停用后 HMAC 也不通", r.get("code") == 40101, "code=%s" % r.get("code"))
body_of("POST", "/api/v1/open-apps/%d/toggle" % app["id"], at)
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret)
check("重新启用后恢复", r.get("code") == 0, "code=%s" % r.get("code"))
# ── 8. §14.4 推送与重试 ──
print("\n[8] §14.4 推送(含重试与人工重推)")
pl = body_of("GET", "/api/v1/open-apps/push-logs", at).get("data") or []
check("⭐ 终审通过时**自动**建了推送记录(审批链路真的接上了)",
len(pl) >= 1, "共 %d 条:%s" % (len(pl), json.dumps(pl, ensure_ascii=False)[:250]))
if pl:
mine = [x for x in pl if x.get("submissionId") == sid]
check("有这份填报单的推送记录", len(mine) >= 1,
json.dumps(pl, ensure_ascii=False)[:300])
if mine:
rec = mine[0]
check("⭐ 回调地址不通 → 记了失败原因与 HTTP 结果(不是安静地什么都不做)",
rec.get("attempts") and rec.get("attempts") >= 1
and (rec.get("lastError") or rec.get("lastHttpCode")),
json.dumps(rec, ensure_ascii=False)[:300])
check("⭐ 失败后安排了下次重试(契约 §14.4 的 1/5/30/120/600 秒)",
rec.get("status") == 2 and rec.get("nextRetryAt"),
json.dumps(rec, ensure_ascii=False)[:250])
rp = open_call("POST", "/api/v1/open/v1/callback/push", secret=secret,
body={"submissionId": sid})
check("⭐ 人工重推可用(契约 §14.4 的「支持人工重推」)",
(rp[1] or {}).get("code") == 0, json.dumps(rp[1], ensure_ascii=False)[:250])
rp2 = open_call("POST", "/api/v1/open/v1/callback/push", secret=secret, body={})
check("人工重推不传 submissionId → 明确报错", (rp2[1] or {}).get("code") != 0,
json.dumps(rp2[1], ensure_ascii=False)[:200])
# ⭐ 定时扫描**真的会重试**(F-112,2026-10-09 修)
#
# 这一条与上面那条"安排了下次重试"是**两件事**:上面断言的是 next_retry_at 被写上,
# 而这里断言的是到点之后**真的有人来重试**。
#
# 修之前:`OpenPushLogMapper.dueRetries()` 少了 @InterceptorIgnore,定时任务没有租户上下文
# → 每次扫描抛 40101 → 被 retryDue 的 catch 吞成一行 warn。于是"安排了"永远不等于"发生了",
# 而 E2E 只断言了前者,所以 60/60 全绿也照样漏掉了这个 bug。
#
# 等待时间 = 定时任务间隔(fixedDelay 10s)+ 首次退避(1s)+ 余量
attempts_before = int(sql("select attempts from collect_open_push_log "
"where submission_id=%d order by id desc limit 1;" % sid) or 0)
time.sleep(16)
attempts_after = int(sql("select attempts from collect_open_push_log "
"where submission_id=%d order by id desc limit 1;" % sid) or 0)
check("⭐ 定时扫描真的重试了:attempts 增加(不是只安排了下次重试)",
attempts_after > attempts_before,
"attempts %d → %d(16 秒内至少应被扫描到一次)" % (attempts_before, attempts_after))
check("⭐ 推送报文里没有明文证件号(与 §14.2 同口径)",
"身份证" not in (sql("select coalesce(payload_json,'') from collect_open_push_log "
"where submission_id=%d limit 1;" % sid) or "")
or idcard not in (sql("select coalesce(payload_json,'') from collect_open_push_log "
"where submission_id=%d limit 1;" % sid) or ""),
"payload=" + (sql("select coalesce(payload_json,'') from collect_open_push_log "
"where submission_id=%d limit 1;" % sid) or "")[:200])
# ── 9. 权限:学生/未登录不能碰管理接口 ──
print("\n[9] 管理接口的权限")
b = body_of("GET", "/api/v1/open-apps", mp(996299))
check("学生看接入方列表 → 403", b.get("code") in (40101, 40301, 403),
"code=%s" % b.get("code"))
b = body_of("GET", "/api/v1/open-apps")
check("未登录看接入方列表 → 40101", b.get("code") == 40101, "code=%s" % b.get("code"))
# 开放接口本身是匿名的(@PublicApi),但没有凭据必须拒绝
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=None,
headers={"X-Timestamp": "", "X-Nonce": "", "X-Sign": ""})
check("⭐ 开放接口是匿名的,但**没有凭据一律拒绝**",
r.get("code") in (40101, 40301), "code=%s msg=%s" % (r.get("code"), r.get("msg")))
cleanup()
print("\n" + "=" * 74)
print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED)))
print("=" * 74)
for n, det in FAILED:
print(" x " + n + "\n " + det)
return 1 if FAILED else 0
def ensure_teacher(uid, username, name, org_id):
sql("""
INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status)
VALUES (%d, 1, '%s', '%s',
'$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', %d, 3, 1)
ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name,
org_id = EXCLUDED.org_id,
username = EXCLUDED.username,
password = EXCLUDED.password,
user_type = EXCLUDED.user_type,
status = 1
""" % (uid, username, name, org_id))
role_id = sql("select id from sys_role where tenant_id=1 and role_code='HEAD_TEACHER' "
"and deleted=0 limit 1;").strip()
sql("delete from sys_user_role where user_id=%d;" % uid)
sql("INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (%d, %s, 1);"
% (uid, role_id))
_, b = R.login(BASE, username, "Admin@123", "demo")
if b.get("code") != 0:
raise SystemExit(" 班主任 %s 登录失败:%s" % (username, b))
return b["data"]["token"]
def _build_roster(rows):
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "开放班"
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
"报考职业", "报考工种", "报考等级", "是否补考",
"补考理论", "补考实操", "补考综合", "是否在校生",
"在读学校", "所学专业", "入学时间"]
for c, t in enumerate(header, start=1):
ws.cell(row=1, column=c, value=t)
for i, (name, idc) in enumerate(rows):
vals = [i + 1, name, idc, "中专", "1380013770%d" % i, "无",
"车工", "数控车工", "四级", "否", "", "", "", "是",
"示范职业学校", "数控加工", "2024-09-01"]
for c, v in enumerate(vals, start=1):
if v != "":
ws.cell(row=i + 2, column=c, value=v)
wb.save(TMP_ROSTER)
def cleanup():
for tid in CREATED_TASK_IDS:
R.sql("delete from collect_open_push_log where task_id=%d;" % tid)
R.sql("delete from collect_validation_issue where task_id=%d;" % tid)
R.sql("delete from collect_audit_record where task_id=%d;" % tid)
R.sql("delete from collect_notify_record where task_id=%d;" % tid)
R.sql("delete from collect_file where task_id=%d;" % tid)
R.sql("delete from collect_submission where task_id=%d;" % tid)
R.sql("delete from collect_roster where task_id=%d;" % tid)
R.sql("delete from collect_task_org where task_id=%d;" % tid)
R.sql("delete from collect_material_def where task_id=%d;" % tid)
R.sql("delete from collect_task where id=%d;" % tid)
R.sql("delete from collect_open_push_log where app_id in "
"(select id from collect_open_app where app_key like 'e2e_%');")
R.sql("delete from collect_open_app where app_key like 'e2e_%';")
R.sql("delete from collect_message where user_id in (996201, 996299);")
R.sql("delete from collect_notify_record where receiver_id in (996201, 996299);")
R.sql("delete from sys_user_role where user_id = %d;" % TEACHER)
R.sql("delete from sys_user where id = %d;" % TEACHER)
if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER)
print(" 已清理:任务 / 接入方 / 推送记录 / 测试账号 / 临时 Excel")
if __name__ == "__main__":
raise SystemExit(main())