Commit Graph

2 Commits

Author SHA1 Message Date
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
lovife 65ebf8de90 feat(storage): 对象存储接七牛云 —— 解除"不能上多副本"
文件以前直接写本地磁盘:两个实例各存各的,学生这次传到 A、下次请求落到 B,
文件就"不见了"。这不是性能问题,是正确性问题 —— 它一直卡着不上多副本。

新增:
  · module/file/storage/{StorageService,LocalStorageService,QiniuStorageService}
  · config/StorageConfig(装配 + 可选启动自检)
  · FillProperties 新增 fill.storage
  · MaterialFileController 新增 GET /{id}/url(签名链接)与 GET /{id}/content
  · scripts/e2e-m1-storage.py(存储自检,没配七牛时跳过而非失败)

关键设计:
  ① 业务只认 path(std/1/17/张同学-…-身份证.jpg),各实现自己映射成真实位置:
     本地 ./data/uploads/std/...、七牛 xiaotutongxue/std/...
     **换存储不用改数据库、不用改业务代码**
  ② 空间私有 → 下载必须签名。GET /{id}/url 每次现签(默认 30 分钟),
     所以这个 URL 绝不能长期保存:写进库、塞进导出的 Excel、发给学生明天再打开,都是 403
  ③ 对象键里有中文,拼 URL **必须逐段编码** —— 整串 encode 会把 / 编成 %2F,
     那在七牛那边是另一个键,表现是"传上去了却打不开",且报错什么都没有
  ④ 区域默认 auto:华东-浙江在新旧控制台对应 z0 与 cn-east-2,
     写错了的症状是**上传超时**——一个从报错完全看不出原因的失败
  ⑤ AK/SK 不进仓库也不进 jar:dev.yml 只放占位符;
     真密钥在 backend/application-local.yml(已 gitignore),
     用 spring.config.import: optional:**file**:./application-local.yml
     —— 刻意用 file: 而不是 classpath:,后者会被打进 jar,密钥就跟着构建产物走了
  ⑥ 配了 qiniu 却没密钥 → 启动就炸(fail-fast):起得来的话第一个受害的是
     正在填材料的学生,而排查要从"文件保存失败"一路追到环境变量

已验证:存储抽象改造没破坏任何现有链路 —— 本地磁盘路径下
  e2e-m3-file 29/29、m3-submit 31/31、m1-materials 68/68、m3-draft 27/27 全绿;
  启动日志打出「本地磁盘(多副本部署下会丢文件)」;git check-ignore 确认
  application-local.yml 被忽略而 .example 不被忽略。

⏳ 未验证:**七牛的真实链路没跑过** —— 用户给了域名/空间/区域/私有/目录前缀,
   但没给 AccessKey/SecretKey,没有它生成不了上传凭证。
   补上后跑 python scripts/e2e-m1-storage.py 即可(它会验:配置齐全 →
   上传处理落进七牛 → 取签名链接 → 真的下载回来比字节 → 不带签名被拒
   → 对象键带前缀 → 原始件一字不差)。没配时脚本跳过并 exit 0,不误报失败。
2026-09-29 14:09:13 +08:00