feat(mp): 前端压缩做完 —— 上传体积降到 1/8,顺带修掉两个真 bug

一、效果(浏览器实测,真存储、真接口)
   手机竖拍 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° 顺时针,不只是宽高换了位置)。
This commit is contained in:
2026-09-29 16:25:51 +08:00
parent 12067b8ae4
commit 66e4ccade9
6 changed files with 856 additions and 21 deletions
@@ -50,15 +50,21 @@ public class MaterialFileController {
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "上传材料(后端代理)",
description = "先原样存下来再处理 —— 上传中断是最常见的失败,先落盘学生就不必重传。"
description = "收上来即规范化(压缩 + 按规则改名),只存规范件。"
+ "格式白名单与份数上限在这里校验")
public ApiResult<Map<String, Object>> upload(
@RequestParam("file") MultipartFile file,
@RequestParam Long submissionId,
@RequestParam String materialCode,
@RequestParam(required = false) String fileName,
HttpServletRequest request) throws IOException {
// 文件名优先用客户端显式声明的:UniApp 在 H5 端用 blob 地址上传,
// multipart 里带过来的名字是一串 UUID、**没有后缀**,
// 拿它去判格式会把一张好端端的照片拒掉(见 MaterialFileService.upload 的说明)
String name = (fileName != null && !fileName.isBlank())
? fileName : file.getOriginalFilename();
Long id = fileService.upload(submissionId, materialCode,
file.getOriginalFilename(), file.getBytes(), ClientIp.of(request));
name, file.getBytes(), ClientIp.of(request));
Map<String, Object> out = new LinkedHashMap<>();
out.put("id", id);
out.put("rawSize", file.getSize());
@@ -85,14 +85,32 @@ public class MaterialFileService {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件是空的");
}
// 格式白名单:按后缀判。要判 MIME 需要读幻数,属下一段(识别)
String ext = extOf(originalName);
// 格式白名单:**按字节幻数判,不信文件名的后缀**。
//
// 两个都必须处理的真实情况:
// · 学生上传的 `.jpg` 里装一张 PNG 是常事(截图改名、微信转发都会这样)
// —— 只看后缀,等导出给机构时才发现格式不对就晚了;
// · **客户端压缩后上传时,文件名可能根本没有后缀**:UniApp 在 H5 端
// 用 blob 地址调 uploadFile,multipart 里的名字是一串 UUID。
// 修复前这种照片会被白名单直接拒掉("你传的是 .bin")——
// 也就是**前端压缩一上线,学生就传不了材料**。
//
// 幻数认不出来时(HEIC 等 ImageIO 也不认的格式)再退回看后缀,
// 保持与原来一致的行为。
String sniffed = FileProcessService.sniffExt(bytes);
String ext = "bin".equals(sniffed) ? extOf(originalName) : sniffed;
if (def.getFileTypes() != null && !def.getFileTypes().isEmpty()
&& !def.getFileTypes().contains(ext)) {
throw new BizException(ErrorCode.FILE_FORMAT_NOT_ALLOWED,
"「" + def.getMaterialName() + "」只接受 " + String.join("/", def.getFileTypes())
+ ",你传的是 ." + ext);
}
// 原始文件名只用于展示与留痕,不进任何路径拼接;截断防超长(客户端可能给一串 UUID)
if (originalName == null || originalName.isBlank()) {
originalName = def.getMaterialName() + "." + ext;
} else if (originalName.length() > 120) {
originalName = originalName.substring(0, 120);
}
// 份数上限。超过就明确拒绝,而不是"覆盖上一份" ——
// 学生传错了要能自己删,静默覆盖会让他以为传上去了