fix(file): 修掉 EXIF 朝向 bug(竖拍照片会躺倒)+ 上传改一步 + 默认不留原始件
一、客户决定:不用保持原件,只要压缩满足效果
fill.storage.keep-raw 开关,默认 false。交付物就是压缩后的规范件。
顺带把上传从两步变一步:
原来 upload(存原图)→ process(读原图→压缩→存规范件)
现在 upload(收上来→立刻压缩→只存规范件)
留原始件是两步存在的**唯一理由**;不要它之后这步只剩代价
(多一个"传了但没处理"的中间态、多一次往返、多一份存储)。
process 保留为"重新规范化",但没有原始件时**直接返回现状而不报错**。
二、🐛 抓到一个会命中绝大多数真实照片的 bug:EXIF 朝向
手机竖拍时**像素其实是横的** + 一个 Orientation=6(显示时转 90°)。
浏览器认这个标记所以看着是正的;而 ImageIO **不读 EXIF**、重编码又丢掉它 →
输出成了"像素横着、也没有标记"的图 → **机构看到的是一张转了 90° 的身份证**。
实测(修前):输入 3200×2400(横)+ O=6 → 输出 1600×1200(仍横)、EXIF=None ❌
修后:1200×1600(竖)✓ 且输出不再带 EXIF(否则会被转两次)
**为什么 E2E 一直没抓到**:测试图都是程序生成的、不带 EXIF;
而真实学生手机拍的竖拍照片几乎全带 —— 这个 bug 会命中绝大多数真实材料,
是靠"想到这一层"发现的,不是靠测试。
实现:module/file/service/ExifOrientation.java
· 自己解析 JPEG 的 APP1/EXIF(只用一个 tag,三十行,不引新依赖)
· **读不出来时一律"不转"** —— 宁可保持原样,也不凭猜测把图转错
· 八种朝向用**逐像素索引映射**,不用 AffineTransform:
索引一看就懂、容易对着规范核;AffineTransform 写错了很难发现(图还是能出,只是方向不对)
· **转正必须在任何缩放之前** —— 转置会互换宽高,尺寸计算得基于转正后的
三、为"前端压缩"铺路的关键改动
compress() 现在会判断"是不是已经达标了":
没触发缩放 && 没有 EXIF 需转正 && 体积本来就在上限内 → **原样返回,不重编码**
因为前端马上会按材料项目标先压一遍(会转正并去掉 EXIF),服务端再压就是
**JPEG 二次编码** —— 白掉一次画质。quality 用 NOT_REENCODED=0 标记"没重编码过",
排查"这张图为什么有点糊"时能一眼看出被压过几次。
这条对 P4 截图尤其合适:原样保持 PNG,表格线一点不糊。
四、顺手修的
· url/content 的 original=true:没有原始件时**优雅退回到交付件**并如实标注
(FileUrl 加 original 字段)—— 学生点"看原图"只是想看自己传的那份,
报"文件不存在"是纯粹的困惑
· sniffExt():按**字节幻数**判类型,不信文件名后缀(学生传的 .jpg 里装 PNG 是常事,
而这个结论会写进数据库、决定导出包的扩展名)
五、E2E
e2e-m3-file 37/37,新增第 7 节专守 EXIF:
输入 3200×2400(横)+ O=6 → 输出 1200x1600
✓ 输出是竖的 ✓ 长边仍受 P2 约束 ✓ 不再带 EXIF
原始件相关断言改成**按策略断言**(开/关两种都对),而不是写死"必须保留"——
那会让"改配置"看起来像"代码坏了"。
全量 467 条断言、0 失败。
⏳ 还没做:**前端压缩**("传 800KB 而不是 3MB")。本轮做的是为它铺路:
服务端不再二次编码 + EXIF 转正机制已就位(前端要用 JS 做同样的事)。
前端要做:照片类读 EXIF→canvas 旋正→长边 1600→q85;P1/P4 不压直传;
压完仍超就回退原图让服务端兜底。
⚠️ 流程坑(本轮白花三次编译):**不要把 Java 字符串字面量放进 PowerShell here-string
传给 Python** —— @"..."@ 会把 " 吃掉(return "bin"; 变成 return bin;)。
带引号的代码一律用 edit 工具改,不要过转义层。
This commit is contained in:
@@ -294,14 +294,21 @@ def main() -> int:
|
||||
st2, _ = http_get(unsigned)
|
||||
check("裸地址被拒(401/403)", st2 in (401, 403), f"HTTP {st2} —— 如果返回 200 说明空间是公开的")
|
||||
|
||||
print("\n[5] 原始件也取得回来(原始件绝不能丢)")
|
||||
print("\n[5] original=true 的行为(原始件默认不保留)")
|
||||
u2 = call("GET", f"/api/v1/mp/files/{fid}/url?original=true", tok)
|
||||
signed2 = ((u2.get("data") or {}).get("url")) or ""
|
||||
st3, body3 = http_get(signed2)
|
||||
check("原始件签名链接返回 200", st3 == 200, f"HTTP {st3}")
|
||||
if st3 == 200:
|
||||
check("原始件字节与上传的**一模一样**",
|
||||
body3 == img, f"上传 {len(img)} 字节 / 取回 {len(body3)} 字节")
|
||||
d2 = u2.get("data") or {}
|
||||
# 「原始件默认不保留」是客户的明确决定,所以这里**不该断言原始件存在** ——
|
||||
# 两种策略都是对的。要断言的是:**即使没有原始件,这个调用也不能报错**,
|
||||
# 而且要如实告诉我们取到的是哪一份。
|
||||
check("original=true 不报错", u2.get("code") == 0, f"{u2}")
|
||||
st3, body3 = http_get(d2.get("url") or "")
|
||||
check("取到的链接能下载", st3 == 200, f"HTTP {st3}")
|
||||
if d2.get("original") is True:
|
||||
check("开着 keep-raw:取回的就是原始件(字节一致)",
|
||||
body3 == img, f"上传 {len(img)} / 取回 {len(body3)}")
|
||||
else:
|
||||
check("默认不留原始件:如实标注并退回到交付件",
|
||||
len(body3) > 0 and body3[:2] == b"\xff\xd8", f"取了 {len(body3)} 字节")
|
||||
finally:
|
||||
print("\n[6] 清理")
|
||||
sql(f"DELETE FROM collect_file WHERE submission_id={sid}")
|
||||
|
||||
+82
-21
@@ -163,6 +163,27 @@ def upload(path: str, token: str, submission_id: int, material_code: str,
|
||||
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_big_photo(w: int = 2400, h: int = 3200) -> bytes:
|
||||
"""造一张"大且难压"的图:随机噪点。纯色图会被压到几十 KB,测不出压缩能力。"""
|
||||
from PIL import Image
|
||||
@@ -282,31 +303,41 @@ def main() -> int:
|
||||
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)} 字节")
|
||||
# ── 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}")
|
||||
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}")
|
||||
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}")
|
||||
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] 幂等:重复处理")
|
||||
@@ -332,8 +363,38 @@ def main() -> int:
|
||||
r.get("code") == 40001 and "最多" in (r.get("msg") or ""),
|
||||
f"code={r.get('code')} msg={r.get('msg')}")
|
||||
|
||||
# ── 7. 清理 ───────────────────────────────────────
|
||||
print("\n[7] 清理")
|
||||
# ── 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] 清理")
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user