66e4ccade9
一、效果(浏览器实测,真存储、真接口)
手机竖拍 3200×2400 + EXIF Orientation=6,3699KB
→ 1200×1600(竖的,转正正确)· 438KB · q88 · 73ms
服务端**原样收下**:collect_file 里 raw_size=448940、final_size=448940、
quality=0(NOT_REENCODED)—— 字节完全相同,没有二次编码。
二、🐛 bug 一:浏览器对 EXIF 的处理**不一致**,猜必错一半
写完前端旋正后实测,输出是 1600×1200(横的)—— 又躺倒了。量出来:
文件里的像素 3200×2400(横)+ Orientation=6
img.naturalWidth/Height 2400×3200 ← 浏览器**已经自己按 EXIF 转正了**
createImageBitmap('none') 2400×3200 ← 连明确要求不给转也被忽略
现代浏览器会把 EXIF 先转正再交给 drawImage,我又转一次 → 转两下 = 又躺倒;
而老实现(旧 X5 / 旧 WKWebView)给的是原始像素,不转就是躺倒。
两端行为相反,**任何写死的做法都必错一半**。
解法:不猜,量出来再决定 —— 自己从 JPEG 的 SOF 段读**文件里的原始像素**
(readJpegSize,与 EXIF 无关),再和 naturalWidth/Height 比:
· 与原始尺寸互换 → 浏览器已经转过 → 不要再转
· 两者相同 → 给的是原始像素 → 我们自己转
· 都对不上 → 按现代浏览器的默认(已转正)处理
这和第 §一·二十 的 EXIF bug 是同一件事的两面:
后端不转是躺倒,前端多转一次也是躺倒。
三、🐛 bug 二:按后缀判格式 → 前端压缩一上线,学生就传不了材料
上传被拒:「身份证」只接受 jpg/jpeg/png,你传的是 .bin。
H5 用 **blob 地址**调 uni.uploadFile,multipart 里的文件名是一串 UUID、
**没有后缀**,而后端当时按 extOf(name) 判白名单 → 判成 .bin → 拒。
这不是脚手架的问题:前端压缩产出的就是 blob 地址,一上线所有压缩过的照片
都会被拒。两处一起修:
· 后端改为按**字节幻数**判格式(sniffExt),幻数认不出再退回看后缀
(顺带变强:PNG 改名成 .jpg 也能正确识别)
· 接口新增可选 fileName 字段,客户端显式声明文件名;
original_name 只用于展示留痕、不进路径拼接,并截断防超长
四、档位 → 前端行为的映射(**不按材料码猜**,档位是管理员可改的配置)
P2 证件翻拍 → 压:长边 1600 + q88 起阶梯
P3 扫描件 → 压:长边 3508 + 降质量(别套 P2 的 1600,会看不清字)
AUTO/P5 → 只降质量不缩尺寸(未知档位擅自缩尺寸是危险的)
P1 证件照 → 不压(服务端要 295×413 裁切 + 补白底)
P4 截图 → 不压(关键是保持 PNG,canvas 只能出 JPEG 会糊掉表格线)
与服务端配合的关键:必须压成**正好落在服务端那套目标里** ——
压狠了白掉一次画质,压轻了服务端再压一次、前端白干(实测 quality=0 证明走对了)。
五、小程序端:**刻意不做 canvas 旋正**(真机未验证)
orientation === 'up' → compressImage 压;其它朝向 → 不压、直传,交服务端转正。
理由:小程序 canvas 的旋正在真机上无法验证,转错了的后果是机构收到躺倒的身份证;
宁可慢一点,也不能赌。
六、E2E
e2e-m3-file 39/39,新增第 8 节守这两个 bug:
✓ **没有后缀**的真 JPEG 被接受(按字节判格式,不看后缀)
✓ 字节是 PNG、后缀写着 .txt → 按字节认作 png 并接受
全量 469 条断言、0 失败。
七、验证脚手架已拆
pages/dev/compress-check.vue + static/__devcheck/ + 造图脚本都是一次性的,
已全部删除并重建 H5(自查过产物里不再有 compress-check / __devcheck)。
它的价值在于:文件选择框没法自动化(chooseImage 会弹系统对话框),
所以那页绕开"选文件",直接拿真构造的竖拍照片喂给**真正的压缩模块**、
走**真正的上传通道** —— 验的比"尺寸对不对"更硬:
**定位色块落在右上角**(证明转的是 90° 顺时针,不只是宽高换了位置)。
453 lines
22 KiB
Python
453 lines
22 KiB
Python
#!/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 _portrait_phone_jpeg(w: int = 3200, h: int = 2400) -> bytes:
|
||
"""
|
||
造一张"手机竖拍"的照片:**像素是横的**(3200×2400)+ EXIF Orientation=6。
|
||
|
||
这跟真实情况一致:手机传感器是横的,竖着拿时记录的是横图 + 一个"转 90°"的标记。
|
||
浏览器认这个标记所以看着是正的,而服务端如果不主动转正、又把标记丢掉,
|
||
出来的就是一张躺倒的图。
|
||
"""
|
||
from PIL import Image
|
||
img = Image.new("RGB", (w, h), (205, 212, 220))
|
||
# 左上角画一个方向标记,便于人工确认
|
||
for y in range(0, 300):
|
||
for x in range(0, 300):
|
||
img.putpixel((x, y), (40, 90, 200))
|
||
exif = Image.Exif()
|
||
exif[0x0112] = 6 # 6 = Rotate 90 CW
|
||
buf = io.BytesIO()
|
||
img.save(buf, "JPEG", quality=92, exif=exif)
|
||
return buf.getvalue()
|
||
|
||
|
||
def make_png(w: int = 900, h: int = 1200) -> bytes:
|
||
"""
|
||
造一张 PNG(截图类材料的常见形态)。
|
||
|
||
用来验证"格式按**字节**判、不按后缀判":同一份字节改个后缀名,
|
||
服务端必须仍然认出它是 png。
|
||
"""
|
||
from PIL import Image
|
||
img = Image.new("RGB", (w, h), (247, 247, 247))
|
||
px = img.load()
|
||
# 画几行横线,模拟表格(也让它不是纯色,免得压出来只有几百字节)
|
||
for y in range(40, h, 60):
|
||
for x in range(30, w - 30):
|
||
px[x, y] = (30, 30, 30)
|
||
buf = io.BytesIO()
|
||
img.save(buf, "PNG")
|
||
return buf.getvalue()
|
||
|
||
|
||
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] 交付件必须取得到;原始件按策略(默认不保留)")
|
||
|
||
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 ""
|
||
check("能取到交付件的访问链接", url_std != "", f"{ustd}")
|
||
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}")
|
||
|
||
# 原始件:**默认不保留**(客户决定:交付物就是规范件)。
|
||
# 这里断言的是"策略被正确执行",而不是"原始件在不在" ——
|
||
# 因为两种策略都是对的,取决于配置。
|
||
rawpath = sql(f"SELECT coalesce(raw_path,'') FROM collect_file WHERE id={fid}")
|
||
keep_raw = os.environ.get("XT_STORAGE_KEEP_RAW", "false").lower() == "true"
|
||
if keep_raw:
|
||
check("开了 keep-raw:原始件保留在 raw/", rawpath != "", f"raw_path={rawpath}")
|
||
else:
|
||
check("默认策略:**不保留原始件**(raw_path 为空)", rawpath == "", f"raw_path={rawpath}")
|
||
# 关键:即使没有原始件,"看原图"也不该报错 —— 要优雅退回到交付件。
|
||
# 学生点"看原图"只是想看自己传的那份,报"文件不存在"是纯粹的困惑。
|
||
uraw = call("GET", f"/api/v1/mp/files/{fid}/url?original=true", tok)
|
||
d = uraw.get("data") or {}
|
||
check("没有原始件时 original=true **不报错**(优雅退到交付件)",
|
||
uraw.get("code") == 0, f"{uraw}")
|
||
check("并且如实标注取到的是哪一份", d.get("original") is False, f"original={d.get('original')}")
|
||
st_raw, body_raw = http_get(d.get("url") or "")
|
||
check("退回的那份也能下载到", st_raw == 200, f"HTTP {st_raw}")
|
||
|
||
# ── 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. EXIF 朝向(真实学生照片的头号坑)───────────────
|
||
print("\n[7] EXIF 朝向:竖拍照片不能变成躺倒的")
|
||
# 手机竖着拍时,**文件里的像素其实是横的** + 一个 Orientation=6(显示时转 90°)。
|
||
# ImageIO 不读 EXIF、重编码又丢掉它,所以服务端必须**主动按 EXIF 转正** ——
|
||
# 否则机构看到的是一张转了 90° 的身份证。
|
||
# ⚠️ 这个用例以前没有,因为测试图都是程序生成的、不带 EXIF,而**真实手机照片几乎都带**。
|
||
exif_img = _portrait_phone_jpeg()
|
||
# 前面几节已经把 IDCARD 的 2 份额度用完了,先清掉再传 ——
|
||
# 直接改这一项的额度(而不是换个材料)是为了让用例验的仍是"身份证 + P2 档"
|
||
# 这条真实组合:P1 会裁成固定尺寸、P4 会转 PNG,都验不出"有没有转正"。
|
||
sql(f"UPDATE collect_file SET deleted = 1 WHERE submission_id={sid} AND material_code='IDCARD'")
|
||
up3 = upload("/api/v1/mp/files/upload", tok, sid, "IDCARD", "竖拍身份证.jpg", exif_img, "image/jpeg")
|
||
check("带 EXIF 的竖拍照片能上传", up3.get("code") == 0, f"{up3}")
|
||
if up3.get("code") == 0:
|
||
fid3 = up3["data"]["id"]
|
||
row = sql(f"SELECT width || 'x' || height FROM collect_file WHERE id={fid3}")
|
||
w, _, h = row.partition("x")
|
||
print(f" 输入:像素 3200×2400(横)+ EXIF Orientation=6 → 应是竖的")
|
||
print(f" 输出:{row}")
|
||
check("**输出是竖的**(按 EXIF 转正了,不是躺倒的)",
|
||
int(h or 0) > int(w or 0), f"输出 {row} —— 高应大于宽")
|
||
check("长边仍受 P2 档约束(≤1600)",
|
||
max(int(w or 0), int(h or 0)) <= 1600, f"长边 {max(int(w or 0), int(h or 0))}")
|
||
# 取回来验字节里确实没有残留的 EXIF 标记(否则浏览器可能再转一次 → 反倒躺倒)
|
||
u3 = call("GET", f"/api/v1/mp/files/{fid3}/url", tok)
|
||
st3, body3 = http_get((u3.get("data") or {}).get("url") or "")
|
||
check("转正后的文件里**不再带 EXIF 朝向**(否则会被转两次)",
|
||
st3 == 200 and b"Exif\x00\x00" not in body3[:4096],
|
||
f"HTTP {st3},前 4096 字节里{'有' if b'Exif' in body3[:4096] else '没有'} EXIF")
|
||
|
||
# ── 8. 客户端压缩后的上传(文件名没有后缀 / 格式按字节判)─────────
|
||
print("\n[8] 客户端压缩后上传:文件名没后缀也要收(真 bug 的回归)")
|
||
# 客户端压缩完交给 uni.uploadFile 的是 blob 地址,multipart 里的名字是一串 UUID、
|
||
# **没有后缀** —— 而修复前服务端只看后缀,会把一张好端端的照片判成 .bin 拒掉。
|
||
# 也就是"前端压缩一上线,学生就传不了材料"。
|
||
sql(f"UPDATE collect_file SET deleted = 1 WHERE submission_id={sid} AND material_code='IDCARD'")
|
||
r = upload("/api/v1/mp/files/upload", tok, sid, "IDCARD",
|
||
"4c1e2f3a-0000-1111-2222-333344445555", raw[:60000], "image/jpeg")
|
||
check("**没有后缀**的真 JPEG 被接受(按字节判格式,不看后缀)", r.get("code") == 0, f"{r}")
|
||
|
||
# 逆过来:字节是真 PNG、但后缀写着 .txt —— 按字节认作 png(在白名单里)就该收。
|
||
# 守的是"学生把截图改名成 .txt 交上来",别等导出给机构才发现格式不对。
|
||
png = make_png(900, 1200)
|
||
r2 = upload("/api/v1/mp/files/upload", tok, sid, "IDCARD", "身份证.txt", png, "text/plain")
|
||
check("字节是 PNG、后缀写着 .txt → 按字节认作 png 并接受", r2.get("code") == 0, f"{r2}")
|
||
|
||
# ── 9. 清理 ──────────────────────────────────────
|
||
print("\n[9] 清理")
|
||
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())
|