Files
xiaotutongxue/scripts/e2e-m3-file.py
T
lovife d8463a051f verify(storage): 七牛云接通并验证通过 —— 对象存储这块完成
根因就是那个"多出来的一个字符":secret-key 原来是 **41** 字符,
而七牛的 AK/SK 都是 40 字符的 URL 安全 base64。用户重新复制后(SK 40 字符)一切正常。

排查方式值得记:没有凭一次失败下结论,而是做了三次互相独立的验证 ——
  ① 我手写的管理 API 签名(可能是我的 bug,不能作数)
  ② 应用启动自检里的**七牛官方 SDK** 的 uploadToken
  ③ 一个**能把原因分开**的调用:BucketManager.stat(bucket, 一个一定不存在的键)
     612 = 鉴权通过(只是文件不存在)/ 401 = 密钥无效 / 631 = bucket 名不对
三次一致指向"密钥无效",所以给用户的结论是确定的,而不是"可能是配置问题"。

验证证据:
  启动自检通过:写入 → 存在 → 读回 → 删除 全部正常(provider=qiniu)
  材料存储:七牛云(bucket=tools-matrix domain=https://file.tools.coding.design)
  e2e-m1-storage.py 16/16:
    · 签名链接形如 .../xiaotutongxue/std/1/17/%E5%BC%A0%E5%90%8C%E5%AD%A6-…?e=…&token=…
    · **中文对象键逐段编码正确**(就是设计时专门防的那个坑)
    · 签名链接下载回来是真 JPEG、非空
    · **不带签名的裸地址被拒(401/403)→ 空间确实私有,签名在起作用**
    · 对象键带 xiaotutongxue/ 前缀
    · **原始件字节与上传的一模一样**
  全量回归 370 条断言、0 失败(此时 provider=qiniu,文件真的进七牛)

顺带修掉一条"写死了存储位置"的 E2E 断言:e2e-m3-file.py 原来断言
  「原始文件真的存在磁盘上」,切到七牛后整片变红,而那看起来像代码坏了。
改成通过产品接口把文件取回来比字节(GET /mp/files/{id}/url → 下载):
  对两种 provider 都成立,而且比原来更强(顺带验了签名链接、私有访问、内容完整性)。
教训:**在"可替换的实现"上写断言,等于把测试绑死在一种实现上。**

状态:backend/application-local.yml 已是 provider: qiniu(密钥 gitignore、不进 jar);
生产用 XT_STORAGE_PROVIDER=qiniu + QINIU_ACCESS_KEY/QINIU_SECRET_KEY。
2026-09-29 14:52:51 +08:00

357 lines
17 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
材料上传与规范化 验收测试(《03-接口设计》§9.5~§9.7 的核心)。
[x] 上传原图(后端代理接收)
[x] 处理:**压到体积上限内**(这是认定机构的硬要求,无例外)
[x] 处理:**按命名模板改名**(规范名要能唯一归属到人)
[x] 档位生效:P2 证件翻拍 → 长边被限到 1600
[x] 原始件保留(退回时要能拿出原图核对)
[x] 格式白名单 / 份数上限
[x] 幂等:重复处理不产生第二份规范文件
⚠️ 为什么这个 E2E 特别有价值:
压缩与改名是**产品的核心资产**(实测 52.7% 的原始文件名违规),
而它同时是"最容易被以为做对了"的地方 —— 尺寸小了、名字变了,
但**体积没降到 1M 以内**,或者名字里少了证件号。
只有拿真实图片跑一遍、逐项断言,才知道到底行不行。
跑法:python scripts/e2e-m3-file.py
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import io
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEV_YML = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources", "application-dev.yml")
DATA_DIR = os.path.join(ROOT, "backend", "data", "uploads")
PG = os.environ.get("XT_PG_CONTAINER", "xt-pg")
OPENID = os.environ.get("XT_TEST_OPENID", "e2e_file_openid")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CREATED_FILES: list[int] = []
CREATED_SUBS: list[int] = []
def ok(n: str) -> None:
PASSED.append(n)
print(f" [OK] {n}")
def fail(n: str, d: str) -> None:
FAILED.append((n, d))
print(f" [FAIL] {n}\n {d}")
def check(n: str, cond: bool, d: str = "") -> bool:
if cond:
ok(n)
else:
fail(n, d or "断言不成立")
return cond
def sql(q: str) -> str:
r = subprocess.run(
["docker", "exec", "-e", "PGPASSWORD=fill_dev_pwd", PG, "psql", "-U", "fill",
"-d", "filldb", "-t", "-A", "-c", q], capture_output=True, text=True, timeout=40)
return (r.stdout or r.stderr).strip()
def read_cfg() -> dict:
# issuer 在主配置 application.yml 里,secret 在 application-dev.yml 里 ——
# 只读一个会漏(而症状是 401,完全指不到"配置没读到")
files = [DEV_YML, os.path.join(os.path.dirname(DEV_YML), "application.yml")]
text = "\n".join(open(f, encoding="utf-8").read() for f in files if os.path.exists(f))
out = {}
for k, p in [("jwt", r"^\s*secret:\s*(\S+)\s*$"), ("issuer", r"^\s*issuer:\s*(\S+)\s*$")]:
m = re.search(p, text, re.M)
if not m:
raise SystemExit(f"找不到 {k}")
out[k] = m.group(1)
return out
def b64u(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def mint(secret: str, issuer: str, uid: int, tenant: int) -> str:
key = base64.b64decode(secret)
now = int(time.time())
signing = (b64u(json.dumps({"alg": "HS512"}, separators=(",", ":")).encode()) + "."
+ b64u(json.dumps({"iss": issuer, "sub": str(uid), "tenantId": tenant,
"orgIds": [], "roles": ["STUDENT"], "userType": 4,
"iat": now, "exp": now + 3600}, separators=(",", ":")).encode()))
sig = hmac.new(key, signing.encode(), hashlib.sha512).digest()
return signing + "." + b64u(sig)
def call(method: str, path: str, token: str | None = None, body: dict | None = None) -> dict:
h = {"Content-Type": "application/json; charset=utf-8"}
if token:
h["Authorization"] = f"Bearer {token}"
data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
req = urllib.request.Request("http://localhost:8080" + path, data=data, headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=60) as x:
return json.loads(x.read().decode("utf-8"))
except urllib.error.HTTPError as e:
try:
return json.loads(e.read().decode("utf-8"))
except Exception:
return {"_http": e.code}
def http_get(url: str) -> tuple[int, bytes]:
"""
下载一个 URL 的内容。
⚠️ 七牛的签名链接在**响应体**里也是文件内容,但错误时不是(403 会回一段 JSON)——
所以调用方要按状态码分支,不要盲目当文件用。
"""
try:
with urllib.request.urlopen(url, timeout=60) as x:
return x.status, x.read()
except urllib.error.HTTPError as e:
return e.code, b""
except Exception:
return 0, b""
def upload(path: str, token: str, submission_id: int, material_code: str,
filename: str, data: bytes, ctype: str) -> dict:
boundary = "----xte2e" + uuid.uuid4().hex[:12]
parts = []
for k, v in [("submissionId", str(submission_id)), ("materialCode", material_code)]:
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode())
parts.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\n'
f"Content-Type: {ctype}\r\n\r\n".encode())
parts.append(data)
parts.append(f"\r\n--{boundary}--\r\n".encode())
body = b"".join(parts)
req = urllib.request.Request(
"http://localhost:8080" + path, data=body, method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}",
"Authorization": f"Bearer {token}"})
try:
with urllib.request.urlopen(req, timeout=90) as x:
return json.loads(x.read().decode("utf-8"))
except urllib.error.HTTPError as e:
try:
return json.loads(e.read().decode("utf-8"))
except Exception:
return {"_http": e.code}
def make_big_photo(w: int = 2400, h: int = 3200) -> bytes:
"""造一张"大且难压"的图:随机噪点。纯色图会被压到几十 KB,测不出压缩能力。"""
from PIL import Image
import random
random.seed(42)
img = Image.new("RGB", (w, h))
px = img.load()
# 每 4 个像素填一次,四两拨千斤地造出高频噪点(全填太慢)
for y in range(0, h, 2):
for x in range(0, w, 2):
c = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
px[x, y] = c
if x + 1 < w:
px[x + 1, y] = c
if y + 1 < h:
px[x, y + 1] = c
if x + 1 < w:
px[x + 1, y + 1] = c
buf = io.BytesIO()
img.save(buf, "JPEG", quality=98)
return buf.getvalue()
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--task", type=int, default=17)
ap.add_argument("--tenant", type=int, default=1)
args = ap.parse_args()
task_id, tenant = args.task, args.tenant
print("=" * 72)
print(f"材料上传与规范化 E2E task={task_id}")
print("=" * 72)
cfg = read_cfg()
# 用一个固定的测试账号(不必是真实微信账号):先过门禁,再传材料
uid = 992001
tok = mint(cfg["jwt"], cfg["issuer"], uid, tenant)
# 名册绑定到这个测试账号
rid = sql(f"SELECT id FROM collect_roster WHERE task_id={task_id} LIMIT 1")
if not rid.isdigit():
fail("取名册行", "任务下没有名册")
return 1
sql(f"UPDATE collect_roster SET bind_user_id={uid}, bind_type=1 WHERE id={rid}")
r = call("POST", "/api/v1/mp/submissions", tok, {"taskId": task_id})
if r.get("code") != 0:
fail("建草稿", f"{r}")
return 1
sid = r["data"]["id"]
CREATED_SUBS.append(sid)
ok(f"已过门禁并建草稿(submission={sid})")
# ⚠️ 夹具必须**显式**把材料项配成目录规定的那样。
# 用裸 SQL 插入会绕过 MaterialCatalog 的默认值(DDL 的列默认是 AUTO / 1 份),
# 于是"档位没生效""份数不对"—— 而这两条都会被误当成代码 bug。
# 实测就踩了:IDCARD 期望 P2 + 2 份,裸 SQL 插出来是 AUTO + 1 份,
# 压缩结果 1252x1669(AUTO 先降质量、不行才缩尺寸)而被当成"P2 档位没生效"。
sql(f"""UPDATE collect_material_def SET compress_profile='P2', max_count=2, min_quality=70
WHERE task_id={task_id} AND material_code='IDCARD'""")
prof = sql(f"SELECT compress_profile || '/' || max_count FROM collect_material_def "
f"WHERE task_id={task_id} AND material_code='IDCARD'")
check("夹具:IDCARD 已配成目录规定的 P2 + 2 份", prof == "P2/2", f"实际 {prof}")
# ── 1. 上传一张真实的大图 ─────────────────────────
print("\n[1] 上传原始大图")
raw = make_big_photo()
print(f" 原始图:2400x3200,{len(raw) / 1024:.0f} KB")
check("原图确实超过 1M(否则测不出压缩能力)", len(raw) > 1024 * 1024, f"{len(raw) / 1024:.0f} KB")
r = upload("/api/v1/mp/files/upload", tok, sid, "IDCARD", "身份证正面.jpg", raw, "image/jpeg")
check("上传成功", r.get("code") == 0, f"code={r.get('code')} msg={r.get('msg')}")
fid = (r.get("data") or {}).get("id")
CREATED_FILES.append(fid)
check("返回文件 id", isinstance(fid, int), f"{fid}")
row = sql(f"SELECT raw_size || '|' || coalesce(raw_path,'') FROM collect_file WHERE id={fid}")
raw_size_db = row.split("|")[0]
check("原始件已落盘记录(raw_size 是原图大小)", raw_size_db == str(len(raw)),
f"库里 {raw_size_db} vs 上传 {len(raw)}")
# ── 2. 处理:压缩 + 改名 ──────────────────────────
print("\n[2] 规范化(压缩 + 按规则改名)")
r = call("POST", f"/api/v1/mp/files/{fid}/process", tok)
if not check("处理成功", r.get("code") == 0, f"code={r.get('code')} msg={r.get('msg')}"):
return 1
d = r.get("data") or {}
print(f" {d.get('originalName')} → {d.get('standardName')}")
print(f" {d.get('rawSize')} → {d.get('finalSize')} 字节,"
f"{d.get('width')}x{d.get('height')} q{d.get('quality')}")
check("**体积压到 1024KB 以内**(认定机构的硬要求)",
(d.get("finalSize") or 0) <= 1024 * 1024, f"finalSize={d.get('finalSize')}")
check("体积确实降下来了(不是原样返回)",
(d.get("finalSize") or 0) < (d.get("rawSize") or 0), f"{d.get('rawSize')} → {d.get('finalSize')}")
# IDCARD 的默认档位是 P2(证件翻拍)→ 长边限到 1600
long_side = max(d.get("width") or 0, d.get("height") or 0)
check("P2 档位生效:长边被限到 1600", long_side <= 1600, f"长边={long_side}")
check("短边按比例缩放(不是被压扁)",
abs((d.get("width") or 1) / (d.get("height") or 1) - 2400 / 3200) < 0.02,
f"宽高比 {d.get('width')}x{d.get('height')}")
# ── 3. 命名规则 ───────────────────────────────────
print("\n[3] 规范文件名")
name = d.get("standardName") or ""
check("名字里有姓名(能归属到人)", "张同学" in name, f"name={name}")
check("名字里有证件号(脱敏,不是完整号)",
"361029" in name and "361029200912100015" not in name, f"name={name}")
check("脱敏号在文件名里用 X 而不是下划线(* 是 Windows 非法字符,"
"直接替换成下划线会让人看不出那是脱敏号)",
"XXXXXX" in name.upper() or "X" in name.upper(), f"name={name}")
check("名字里有材料名", "身份证" in name, f"name={name}")
check("后缀正确(压成 jpg)", name.lower().endswith(".jpg"), f"name={name}")
check("名字里没有非法字符(/ : * ? \" < > |)",
not re.search(r'[\\/:*?"<>|]', name), f"name={name}")
check("长度不超过 90(Windows 路径总长限制)", len(name) <= 90, f"len={len(name)}")
# ── 4. 原始件保留 ─────────────────────────────────
print("\n[4] 原始件必须保留")
rawpath = sql(f"SELECT coalesce(raw_path,'') FROM collect_file WHERE id={fid}")
check("raw_path 还在(没有被规范文件覆盖)", rawpath != "", f"raw_path={rawpath}")
# ⚠️ **不要断言"文件在磁盘上"** —— 存储是可换的(本地磁盘 / 七牛云)。
# 写死磁盘路径的断言,在切到对象存储后会整片变红,而那看起来像代码坏了。
# 正确做法是**通过产品接口把文件取回来比字节** —— 它对两种存储都成立,
# 而且比"文件在不在盘上"更强:它顺带验证了签名链接、私有空间访问、内容完整性。
uraw = call("GET", f"/api/v1/mp/files/{fid}/url?original=true", tok)
url_raw = ((uraw.get("data") or {}).get("url")) or ""
check("能取到原始件的访问链接", url_raw != "", f"{uraw}")
status_raw, body_raw = http_get(url_raw)
check("原始件取得回来(HTTP 200)", status_raw == 200, f"HTTP {status_raw}")
if status_raw == 200:
check("原始件字节与上传的**一模一样**(原始件绝不能被动过)",
body_raw == raw, f"上传 {len(raw)} 字节 / 取回 {len(body_raw)} 字节")
stdpath = sql(f"SELECT coalesce(std_path,'') FROM collect_file WHERE id={fid}")
check("std_path 已写入", stdpath != "", f"std_path={stdpath}")
ustd = call("GET", f"/api/v1/mp/files/{fid}/url", tok)
url_std = ((ustd.get("data") or {}).get("url")) or ""
status_std, body_std = http_get(url_std)
check("规范件取得回来(HTTP 200)", status_std == 200, f"HTTP {status_std}")
if status_std == 200:
check("规范件是张真图片(JPEG 开头)", body_std[:2] == b"\xff\xd8", f"{body_std[:2]!r}")
# ── 5. 幂等 ───────────────────────────────────────
print("\n[5] 幂等:重复处理")
r2 = call("POST", f"/api/v1/mp/files/{fid}/process", tok)
check("重复处理成功", r2.get("code") == 0, f"code={r2.get('code')}")
cnt = sql(f"SELECT count(*) FROM collect_file WHERE submission_id={sid} AND material_code='IDCARD'")
check("没有产生第二份记录", cnt == "1", f"{cnt}")
check("规范文件名稳定(同一次处理结果一致)",
(r2.get("data") or {}).get("standardName") == name,
f"{(r2.get('data') or {}).get('standardName')}")
# ── 6. 白名单与份数 ───────────────────────────────
print("\n[6] 格式白名单与份数上限")
r = upload("/api/v1/mp/files/upload", tok, sid, "IDCARD", "身份证.exe", b"MZfake", "application/octet-stream")
check("白名单外的格式被拒(40002)", r.get("code") == 40002, f"code={r.get('code')} msg={r.get('msg')}")
r = upload("/api/v1/mp/files/upload", tok, sid, "IDCARD", "身份证反面.jpg", raw[:50000], "image/jpeg")
check("超过份数上限被拒(IDCARD 默认 2 份,现在已有 1 份 → 这次应成功)",
r.get("code") == 0, f"code={r.get('code')} msg={r.get('msg')}")
if r.get("code") == 0:
CREATED_FILES.append((r.get("data") or {}).get("id"))
r = upload("/api/v1/mp/files/upload", tok, sid, "IDCARD", "身份证再来一份.jpg", raw[:50000], "image/jpeg")
check("第 3 份被拒(且提示先删上一份)",
r.get("code") == 40001 and "最多" in (r.get("msg") or ""),
f"code={r.get('code')} msg={r.get('msg')}")
# ── 7. 清理 ───────────────────────────────────────
print("\n[7] 清理")
sql(f"DELETE FROM collect_file WHERE submission_id IN ({','.join(str(x) for x in CREATED_SUBS)})")
sql(f"DELETE FROM collect_submission WHERE id IN ({','.join(str(x) for x in CREATED_SUBS)})")
sql(f"UPDATE collect_roster SET bind_user_id=NULL, bind_time=NULL, bind_type=NULL WHERE id={rid}")
import shutil
for sub in ("raw", "std"):
d2 = os.path.join(DATA_DIR, sub, "1", str(task_id))
if os.path.exists(d2):
shutil.rmtree(d2, ignore_errors=True)
ok("已清理草稿 / 文件记录 / 磁盘文件 / 名册绑定")
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
for n, dd in FAILED:
print(f" · {n}\n {dd}")
print("=" * 72)
return 1 if FAILED else 0
if __name__ == "__main__":
raise SystemExit(main())