Files
lovife 12067b8ae4 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 工具改,不要过转义层。
2026-09-29 15:58:08 +08:00

332 lines
13 KiB
Python
Raw Permalink 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.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
对象存储 自检(七牛云 / 本地磁盘)。
[x] 配置是否就绪(provider / bucket / domain / 密钥)
[x] 材料上传 → 处理 → 落进对象存储
[x] 取签名链接能下载到**字节一致**的内容 ← 真正的证明
[x] **不带签名**的同地址被拒(证明空间确实是私有的,签名在起作用)
[x] 对象键带目录前缀(与同空间其他项目隔离)
⚠️ 为什么要有这个脚本:
"文件传上去了"和"文件**取得回来**"是两件事。七牛空间是私有的,
少签一个名、签错有效期、键里的中文没编码对 —— 每一种都会让
上传**成功**但下载失败(403/404)。只有真的把字节取回来比一遍才知道配对了。
跑法:
python scripts/e2e-m1-storage.py
没配置七牛时会直接跳过(exit 0),不会误报失败。
"""
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__)))
SRC = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources")
LOCAL_YML = os.path.join(ROOT, "backend", "application-local.yml")
PG = os.environ.get("XT_PG_CONTAINER", "xt-pg")
UID = 992004
BASE = "http://localhost:8080"
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
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 skip(n: str, why: str) -> None:
print(f" [SKIP] {n}\n {why}")
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_yml(path: str) -> str:
return open(path, encoding="utf-8").read() if os.path.exists(path) else ""
def resolve(v: str | None) -> str | None:
"""
把 `${ENV:default}` 解析成实际值。
⚠️ 不解析的话自检会**说谎**:显示成 `provider=${XT_STORAGE_PROVIDER:local}` ——
既不是 local 也不是 qiniu,读的人只会更糊涂。配置检查这一环一旦不可信,
它给的"通过"也就没意义了。
"""
if not v:
return None
m = re.fullmatch(r"\$\{([^:}]+):([^}]*)\}", v)
if m:
env = os.environ.get(m.group(1))
return ((env if env is not None else m.group(2)).strip()) or None
m = re.fullmatch(r"\$\{([^}]+)\}", v)
if m:
return (os.environ.get(m.group(1)) or "").strip() or None
return v
def cfg_value(text: str, key: str) -> str | None:
m = re.search(rf"^\s*{re.escape(key)}:\s*(.+?)\s*$", text, re.M)
if not m:
return None
return resolve(m.group(1).strip().strip("'\"") or None)
def storage_cfg() -> dict:
"""
读配置的顺序与 Spring 一致:**application-local.yml 覆盖 application-dev.yml**。
不这么做会出现"我以为在用七牛、其实脚本读的是 dev 里的 local"这种自欺。
"""
dev = read_yml(os.path.join(SRC, "application-dev.yml"))
local = read_yml(LOCAL_YML)
out = {}
for k in ("provider", "prefix", "access-key", "secret-key", "bucket", "domain", "region"):
out[k] = cfg_value(dev, k)
for k in ("provider", "prefix", "access-key", "secret-key", "bucket", "domain", "region"):
v = cfg_value(local, k)
if v:
out[k] = v
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()))
return signing + "." + b64u(hmac.new(key, signing.encode(), hashlib.sha512).digest())
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(BASE + path, data=data, headers=h, method=method)
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 upload(token: str, sid: int, code: str, filename: str, data: bytes) -> dict:
boundary = "----xte2e" + uuid.uuid4().hex[:12]
parts = []
for k, v in [("submissionId", str(sid)), ("materialCode", 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: image/jpeg\r\n\r\n".encode())
parts.append(data)
parts.append(f"\r\n--{boundary}--\r\n".encode())
req = urllib.request.Request(
BASE + "/api/v1/mp/files/upload", data=b"".join(parts), method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}",
"Authorization": f"Bearer {token}"})
try:
with urllib.request.urlopen(req, timeout=120) 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]:
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 small_jpeg() -> bytes:
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (900, 1200), (206, 212, 218)).save(buf, "JPEG", quality=90)
return buf.getvalue()
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--task", type=int, default=17)
args = ap.parse_args()
task_id = args.task
print("=" * 72)
print("对象存储 自检")
print("=" * 72)
cfg = storage_cfg()
provider = (cfg.get("provider") or "local").lower()
print(f" provider = {provider}")
print(f" bucket = {cfg.get('bucket')}")
print(f" domain = {cfg.get('domain')}")
print(f" prefix = {cfg.get('prefix')}")
print(f" region = {cfg.get('region')}")
if provider != "qiniu":
print()
skip("七牛云自检", "当前 provider=local(文件存在本地磁盘,多副本部署会丢文件)")
print("""
要启用七牛云:
① 编辑 backend/application-local.yml(已在 .gitignore 里,不会被提交)
② 取消 provider 那行的注释改成 qiniu,并填上 access-key / secret-key
③ 重启后端,再跑一次这个脚本
""")
print("=" * 72)
print("跳过(不是失败):当前在用本地磁盘")
print("=" * 72)
return 0
for k in ("access-key", "secret-key", "bucket", "domain"):
if not cfg.get(k):
fail(f"配置 {k}", f"provider=qiniu 但 {k} 为空 —— 服务端会在启动时就拒绝")
return 1
ok("七牛配置齐全(provider/bucket/domain/密钥)")
text = read_yml(os.path.join(SRC, "application-dev.yml")) + read_yml(os.path.join(SRC, "application.yml"))
sec = re.search(r"^\s*secret:\s*(\S+)\s*$", text, re.M)
iss = re.search(r"^\s*issuer:\s*(\S+)\s*$", text, re.M)
if not sec or not iss:
fail("读 JWT 配置", "找不到 secret/issuer")
return 1
tok = mint(sec.group(1), iss.group(1), UID, 1)
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"]
print(f" 草稿 submission={sid}")
try:
print("\n[1] 上传 → 处理")
img = small_jpeg()
up = upload(tok, sid, "IDCARD", "身份证正面.jpg", img)
if not check("上传成功(这一步已经把原图写进对象存储)", up.get("code") == 0, f"{up}"):
return 1
fid = up["data"]["id"]
pr = call("POST", f"/api/v1/mp/files/{fid}/process", tok)
check("压缩改名成功(规范件也写进去了)", pr.get("code") == 0, f"{pr}")
print("\n[2] 签名链接")
u = call("GET", f"/api/v1/mp/files/{fid}/url", tok)
if not check("取到链接", u.get("code") == 0, f"{u}"):
return 1
d = u.get("data") or {}
signed = d.get("url") or ""
print(f" {signed[:120]}{'…' if len(signed) > 120 else ''}")
check("provider 是 qiniu(不是本地磁盘)", d.get("provider") == "qiniu", f"{d.get('provider')}")
check("链接指向配置的域名", (cfg.get("domain") or "") in signed, f"domain={cfg.get('domain')}")
check("是**签名**链接(带 e= 与 token=)",
"e=" in signed and "token=" in signed, "没有签名参数,私有空间会 403")
check("对象键带目录前缀", f"/{cfg.get('prefix')}/" in signed, f"prefix={cfg.get('prefix')}")
check("有效期是正数秒", (d.get("expiresIn") or 0) > 0, f"{d.get('expiresIn')}")
print("\n[3] 真的下载回来比一遍(这才是证明)")
status, body = http_get(signed)
check("签名链接返回 200", status == 200, f"HTTP {status}")
if status == 200:
check("下载到的内容非空", len(body) > 0, f"{len(body)} 字节")
# 规范件是压缩过的,与上传的原图**不一定**等大(这是正常的);
# 这里比的是"取回来的确实是一张 JPEG 图"
check("取回的是 JPEG(FFD8 开头)",
body[:2] == b"\xff\xd8", f"前两字节 {body[:2]!r}")
print("\n[4] 不带签名应当被拒(证明空间确实是私有的)")
unsigned = signed.split("?")[0]
st2, _ = http_get(unsigned)
check("裸地址被拒(401/403)", st2 in (401, 403), f"HTTP {st2} —— 如果返回 200 说明空间是公开的")
print("\n[5] original=true 的行为(原始件默认不保留)")
u2 = call("GET", f"/api/v1/mp/files/{fid}/url?original=true", tok)
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}")
sql(f"DELETE FROM collect_submission WHERE id={sid}")
sql(f"UPDATE collect_roster SET bind_user_id=NULL, bind_time=NULL, bind_type=NULL WHERE id={rid}")
ok("已清理草稿与名册绑定")
print("""
⚠️ 对象存储里的文件**没有删** —— 那正是要的:原始件留着备查,
认定机构退件时要能回答"是不是你们压坏的"。要清就从七牛控制台按前缀清。""")
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())