From 12067b8ae44595fef7487a891f7c2e916b90f5e9 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Tue, 29 Sep 2026 15:58:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(file):=20=E4=BF=AE=E6=8E=89=20EXIF=20?= =?UTF-8?q?=E6=9C=9D=E5=90=91=20bug=EF=BC=88=E7=AB=96=E6=8B=8D=E7=85=A7?= =?UTF-8?q?=E7=89=87=E4=BC=9A=E8=BA=BA=E5=80=92=EF=BC=89+=20=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E6=94=B9=E4=B8=80=E6=AD=A5=20+=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E4=B8=8D=E7=95=99=E5=8E=9F=E5=A7=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、客户决定:不用保持原件,只要压缩满足效果 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 工具改,不要过转义层。 --- .../fill/config/FillProperties.java | 20 +- .../module/file/service/ExifOrientation.java | 187 ++++++++++++++++++ .../file/service/FileProcessService.java | 57 ++++++ .../file/service/MaterialFileService.java | 128 ++++++++---- .../src/main/resources/application-dev.yml | 12 ++ docs/交接摘要.md | 93 +++++++++ scripts/e2e-m1-storage.py | 21 +- scripts/e2e-m3-file.py | 103 ++++++++-- 8 files changed, 556 insertions(+), 65 deletions(-) create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/ExifOrientation.java diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java index d76010d..1a059c2 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java @@ -49,7 +49,8 @@ public record FillProperties( String provider, String prefix, Qiniu qiniu, - Boolean selfCheckOnStartup + Boolean selfCheckOnStartup, + Boolean keepRaw ) { public String providerOrDefault() { // 默认 local:这样"什么都不配"也能把项目跑起来, @@ -64,6 +65,23 @@ public record FillProperties( public boolean selfCheckEnabled() { return Boolean.TRUE.equals(selfCheckOnStartup); } + + /** + * 是否保留学生上传的**原始件**。 + * + *

默认 false(不保留) —— 这是客户明确过的决定: + * 交付物就是压缩后的规范件,不需要拿原图当退件凭证(原来保留原始件 + * 是为了"机构说看不清时能证明不是我们压坏的",这条被放弃了)。 + * + *

⚠️ 但要注意它顺带放弃的是重做的能力: + * 如果之后要改压缩参数(项目里还有两个没定的:一寸照片阈值标定、 + * 机构是否接受 300dpi 扫描件),没有原图就只能让所有学生重传一遍。 + * 所以这个开关留着 —— 需要在任务归档前留个保险时,改一行配置即可 + * (原始件会存到原始路径,不参与交付,只作审计与重处理的来源)。 + */ + public boolean keepRawEnabled() { + return Boolean.TRUE.equals(keepRaw); + } } /** diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/ExifOrientation.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/ExifOrientation.java new file mode 100644 index 0000000..c94b99f --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/ExifOrientation.java @@ -0,0 +1,187 @@ +package cn.tianbaotong.fill.module.file.service; + +import lombok.extern.slf4j.Slf4j; + +import java.awt.image.BufferedImage; + +/** + * 读 JPEG 的 EXIF 朝向,并把像素转正。 + * + *

为什么必须有这个类(这是个真 bug 的修复)

+ * + *

手机竖着拍照时,文件里的像素其实是横的,外加一个 + * {@code EXIF Orientation=6}(意思是"显示时顺时针转 90°")。浏览器认这个标记, + * 所以学生自己看着是正的。 + * + *

但 Java 的 {@code ImageIO.read()} 不读 EXIF,而重编码时又会把 EXIF 丢掉 —— + * 于是输出变成"像素横着、也没有标记"的图,机构看到的是一张转了 90° 的身份证。 + * + *

实测确认过:输入 3200×2400 + Orientation=6(应为竖的 1200×1600), + * 输出是 1600×1200 且 EXIF 为 None —— 确实躺倒。 + * + *

为什么 E2E 一直没抓到

+ * 因为测试图是程序生成的,不带 EXIF。而真实学生用手机拍的, + * 竖拍的照片几乎全都带这个标记 —— 也就是说这个 bug 会命中绝大多数真实材料。 + * + *

为什么自己解析而不引依赖

+ * 只需要一个 tag(0x0112)。为它引入 metadata-extractor 这类库, + * 要换来的是一份新的依赖 + 联网下载(本地仓库没有);而 JPEG 的 APP1/EXIF 结构 + * 是稳定的公开规范,解析它三十行就够。 + */ +@Slf4j +public final class ExifOrientation { + + private ExifOrientation() { + } + + /** EXIF 里 Orientation 的 tag 号。 */ + private static final int TAG_ORIENTATION = 0x0112; + + /** + * 从 JPEG 字节里读朝向。 + * + * @return 1~8;读不到或格式不对时返回 1(=正常,不做任何变换)—— + * 读不出来时一律"不转":宁可保持原样,也不要凭猜测把图转错 + */ + public static int read(byte[] jpeg) { + try { + if (jpeg == null || jpeg.length < 4) { + return 1; + } + // SOI + if ((jpeg[0] & 0xFF) != 0xFF || (jpeg[1] & 0xFF) != 0xD8) { + return 1; + } + int i = 2; + while (i + 4 <= jpeg.length) { + if ((jpeg[i] & 0xFF) != 0xFF) { + return 1; + } + int marker = jpeg[i + 1] & 0xFF; + // 填充字节:FF FF… 继续找 + if (marker == 0xFF) { + i++; + continue; + } + // SOS(DA) 之后是压缩数据,不再有元数据段 + if (marker == 0xDA || marker == 0xD9) { + return 1; + } + int len = ((jpeg[i + 2] & 0xFF) << 8) | (jpeg[i + 3] & 0xFF); + if (len < 2) { + return 1; + } + int segStart = i + 4; + int segEnd = i + 2 + len; + if (segEnd > jpeg.length) { + return 1; + } + // APP1 且以 "Exif\0\0" 开头 + if (marker == 0xE1 && segEnd - segStart >= 14 + && jpeg[segStart] == 'E' && jpeg[segStart + 1] == 'x' + && jpeg[segStart + 2] == 'i' && jpeg[segStart + 3] == 'f' + && jpeg[segStart + 4] == 0 && jpeg[segStart + 5] == 0) { + int o = readFromTiff(jpeg, segStart + 6, segEnd); + if (o >= 1 && o <= 8) { + return o; + } + } + i = segEnd; + } + } catch (Exception e) { + log.debug("读 EXIF 朝向失败(按不转变换处理)", e); + } + return 1; + } + + /** 解析 TIFF 头 + IFD0,找 Orientation。 */ + private static int readFromTiff(byte[] b, int start, int end) { + if (start + 8 > end) { + return 1; + } + boolean little; + if (b[start] == 'I' && b[start + 1] == 'I') { + little = true; + } else if (b[start] == 'M' && b[start + 1] == 'M') { + little = false; + } else { + return 1; + } + if (u16(b, start + 2, little) != 0x002A) { + return 1; + } + int ifd = start + (int) u32(b, start + 4, little); + if (ifd + 2 > end) { + return 1; + } + int count = u16(b, ifd, little); + for (int k = 0; k < count; k++) { + int e = ifd + 2 + k * 12; + if (e + 12 > end) { + return 1; + } + if (u16(b, e, little) == TAG_ORIENTATION) { + // 类型 3 = SHORT,值直接放在前两字节 + return u16(b, e + 8, little); + } + } + return 1; + } + + private static int u16(byte[] b, int i, boolean little) { + return little ? ((b[i] & 0xFF) | ((b[i + 1] & 0xFF) << 8)) + : (((b[i] & 0xFF) << 8) | (b[i + 1] & 0xFF)); + } + + private static long u32(byte[] b, int i, boolean little) { + return little + ? ((b[i] & 0xFFL) | ((b[i + 1] & 0xFFL) << 8) | ((b[i + 2] & 0xFFL) << 16) | ((b[i + 3] & 0xFFL) << 24)) + : (((b[i] & 0xFFL) << 24) | ((b[i + 1] & 0xFFL) << 16) | ((b[i + 2] & 0xFFL) << 8) | (b[i + 3] & 0xFFL)); + } + + /** + * 按朝向把像素转正。 + * + *

用**逐像素的索引映射**而不是 {@code AffineTransform}:八种朝向各自的 + * 坐标对应关系是确定的,写成索引一看就懂、也容易对着规范核;而 + * {@code AffineTransform} 的平移/旋转组合写错了很难看出来(图还是能出, + * 只是方向不对)。 + * + * + * + * + * + * + * + *
2水平镜像3旋转 180°
4垂直镜像5沿主对角转置
6顺时针 90°(手机竖拍最常见)8逆时针 90°
7沿副对角转置1正常,不动
+ */ + public static BufferedImage apply(BufferedImage src, int orientation) { + if (orientation <= 1 || orientation > 8) { + return src; + } + int w = src.getWidth(); + int h = src.getHeight(); + boolean swap = orientation >= 5; // 5~8 是转置类,宽高互换 + int tw = swap ? h : w; + int th = swap ? w : h; + BufferedImage out = new BufferedImage(tw, th, + src.getColorModel().hasAlpha() ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB); + for (int y = 0; y < th; y++) { + for (int x = 0; x < tw; x++) { + int sx; + int sy; + switch (orientation) { + case 2 -> { sx = w - 1 - x; sy = y; } + case 3 -> { sx = w - 1 - x; sy = h - 1 - y; } + case 4 -> { sx = x; sy = h - 1 - y; } + case 5 -> { sx = y; sy = x; } + case 6 -> { sx = y; sy = h - 1 - x; } + case 7 -> { sx = w - 1 - y; sy = h - 1 - x; } + default -> { sx = w - 1 - y; sy = x; } // 8 + } + out.setRGB(x, y, src.getRGB(sx, sy)); + } + } + return out; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/FileProcessService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/FileProcessService.java index 1d2d52f..bcbcc18 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/FileProcessService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/FileProcessService.java @@ -53,6 +53,14 @@ public class FileProcessService { private static final int P1_WIDTH = 295; private static final int P1_HEIGHT = 413; + /** + * 返回值里 quality 用这个值表示**没有重新编码**、原样存了。 + * + *

0 不是合法的 JPEG 质量,所以不会与真值混淆 —— 这一点很重要: + * 排查这张图为什么有点糊时,得能一眼看出它到底被压过几次。 + */ + public static final int NOT_REENCODED = 0; + /** 质量下探的阶梯。到 minQuality 还不够就转去缩尺寸。 */ private static final int[] QUALITY_STEPS = {88, 85, 80, 75, 70, 65}; @@ -90,6 +98,18 @@ public class FileProcessService { return new Processed(raw, 0, 0, 100, null, false, false); } + // ★ 按 EXIF 把像素转正 —— **必须在任何缩放之前**。 + // 手机竖拍的照片,像素其实是横的 + 一个"显示时转 90°"的标记; + // 而 ImageIO 不读 EXIF、重编码又会丢掉它,不在这里转正的话, + // 出来的就是一张**没有标记的躺倒图** —— 机构看到的是转了 90° 的身份证。 + // 放在缩放之前是因为:转置会互换宽高,长边/固定尺寸的计算必须基于转正后的尺寸。 + int orientation = ExifOrientation.read(raw); + if (orientation > 1) { + img = ExifOrientation.apply(img, orientation); + log.debug("按 EXIF 朝向 {} 转正({}x{} → {}x{})", + orientation, img.getWidth(), img.getHeight(), img.getWidth(), img.getHeight()); + } + String p = profile == null ? "AUTO" : profile.toUpperCase(); BufferedImage work = img; boolean scaled = false; @@ -113,6 +133,21 @@ public class FileProcessService { } } + // ★ 已经达标 → **原样返回,不做二次编码**。 + // + // 前端会按材料项的目标先压一遍(它会转正并去掉 EXIF),上传的就已经是 + // "长边够小、体积达标"的图了。再压一次就是 **JPEG 二次编码** —— 白掉一次画质, + // 而且换不回任何东西。 + // + // 三个条件缺一不可: + // · 没触发缩放(档位要求的尺寸本来就满足) + // · 没有 EXIF 需要转正(有就必须重编码,否则躺倒) + // · 体积本来就在上限内 + // P4 截图落在这里最合适:原样保持 PNG,表格线一点不糊。 + if (!scaled && orientation <= 1 && raw.length <= maxSizeKb * 1024L) { + return new Processed(raw, img.getWidth(), img.getHeight(), NOT_REENCODED, sniffExt(raw), false, false); + } + // ② P4 保持 PNG(实测学籍表是 16:9 截图,转 JPG 会把表格线糊掉) boolean keepPng = "P4".equals(p) || (img.getColorModel().hasAlpha() && "AUTO".equals(p)); if (keepPng) { @@ -299,6 +334,28 @@ public class FileProcessService { } } + /** + * 从**字节的幻数**判类型,不信文件名的后缀。 + * + *

结论要写进数据库(导出包按它定扩展名),所以不能猜错; + * 而学生上传的 {@code .jpg} 里装一张 PNG 是常事(截图改名、微信转发都会这样)。 + */ + public static String sniffExt(byte[] b) { + if (b == null || b.length < 4) { + return "bin"; + } + if ((b[0] & 0xFF) == 0xFF && (b[1] & 0xFF) == 0xD8) { + return "jpg"; + } + if ((b[0] & 0xFF) == 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G') { + return "png"; + } + if (b[0] == '%' && b[1] == 'P' && b[2] == 'D' && b[3] == 'F') { + return "pdf"; + } + return "bin"; + } + private static byte[] encodePng(BufferedImage img) { try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { ImageIO.write(img, "png", bos); diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java index 160a6ff..c3c417c 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java @@ -105,11 +105,32 @@ public class MaterialFileService { : "「" + def.getMaterialName() + "」最多传 " + maxCount + " 份"); } - // 落盘:raw/ 下按任务分目录 - String rel = String.format("raw/%d/%d/%d_%s.%s", - me.tenantId(), sub.getTaskId(), System.currentTimeMillis(), - Integer.toHexString(java.util.Arrays.hashCode(bytes)), ext); - writeFile(rel, bytes, contentTypeOf(ext)); + // ★ 收上来就**立刻规范化**(原来是"先存原图、再单独调一次 process")。 + // + // 两步存在的唯一理由是"先保住原始件" —— 客户明确不要原始件之后, + // 这一步就只剩代价了:多一个"传了但没处理"的中间状态、多一次往返、 + // 多一份存储。现在一步到位。 + int maxSizeKb = def.getMaxSizeKb() == null ? 1024 : def.getMaxSizeKb(); + int minQuality = def.getMinQuality() == null ? 70 : def.getMinQuality(); + FileProcessService.Processed p = processService.compress( + bytes, def.getCompressProfile(), maxSizeKb, minQuality); + String outExt = p.ext() != null ? p.ext() : ext; + String standardName = standardNameFor(me, sub.getTaskId(), def, outExt); + + String stdRel = String.format("std/%d/%d/%s", me.tenantId(), sub.getTaskId(), standardName); + writeFile(stdRel, p.bytes(), contentTypeOf(outExt)); + + // 原始件:默认**不留**(客户决定:交付物就是规范件,不必拿原图当退件凭证)。 + // 留的时候写到 raw/,**不参与交付**,只作审计与"改参数后重做"的来源 —— + // 项目里还有两个没定的参数(一寸照片阈值标定、机构是否接受 300dpi 扫描件), + // 它们一变,没有原图就只能让所有学生重传。 + String rawRel = null; + if (props.storage() != null && props.storage().keepRawEnabled()) { + rawRel = String.format("raw/%d/%d/%d_%s.%s", + me.tenantId(), sub.getTaskId(), System.currentTimeMillis(), + Integer.toHexString(java.util.Arrays.hashCode(bytes)), ext); + writeFile(rawRel, bytes, contentTypeOf(ext)); + } CollectFile f = new CollectFile(); f.setTaskId(sub.getTaskId()); @@ -119,27 +140,53 @@ public class MaterialFileService { f.setVersion((int) current + 1); f.setIsCurrent(CollectFile.CURRENT); f.setOriginalName(originalName); - f.setFileExt(ext); + f.setFileExt(outExt); f.setRawSize((long) bytes.length); - f.setFinalSize((long) bytes.length); + f.setFinalSize((long) p.bytes().length); f.setFileHash(sha256(bytes)); - f.setRawPath(rel); + f.setRawPath(rawRel); + f.setStandardName(standardName); + f.setStdPath(stdRel); + f.setWidth(p.width()); + f.setHeight(p.height()); + f.setQuality(p.quality()); f.setValidateStatus(CollectFile.VALIDATE_PENDING); f.setNeedManualPlace(CollectFile.NEED_MANUAL_NO); f.setUploadIp(clientIp); fileMapper.insert(f); - log.info("上传材料:task={} sub={} material={} {}({} 字节)", - sub.getTaskId(), submissionId, materialCode, originalName, bytes.length); + log.info("上传并规范化:task={} sub={} material={} {} → {}({} → {} 字节 q={})", + sub.getTaskId(), submissionId, materialCode, originalName, standardName, + bytes.length, p.bytes().length, p.quality()); return f.getId(); } + /** + * 规范文件名:材料级模板优先,否则继承任务级。 + * + *

抽出来给"上传即规范化"和"重新规范化"两条路共用 —— + * **名字必须两处一致**,否则同一个材料重处理一次就换了个名字。 + */ + private String standardNameFor(LoginUser me, Long taskId, MaterialDefVO def, String ext) { + CollectTask task = taskMapper.selectById(taskId); + String template = (def.getNamePattern() != null && !def.getNamePattern().isBlank()) + ? def.getNamePattern() + : (task == null ? null : task.getNamingTemplate()); + return processService.renderName(template, namingVars(me, taskId, def, task), "." + ext); + } + // ── 处理:压缩 + 改名 ───────────────────────── /** - * 把原始文件规范化成"可直接上报"的那一份:压到体积内 + 按模板改名。 + * 重新规范化一份材料:压到体积内 + 按模板改名。 * - *

幂等:重复调用会重新处理并覆盖上一份规范文件(原始件不动)。 - * 学生在手机上重试是常事,不该因此产生多份规范文件。 + *

正常情况下用不到它 —— 上传时已经规范化过了。 + * 留着它是为了两个场景: + *

*/ @Transactional(rollbackFor = Exception.class) public CollectFile process(Long fileId) { @@ -152,6 +199,12 @@ public class MaterialFileService { if (def == null) { throw new BizException(ErrorCode.VALIDATION_BLOCKED, "材料项已被删除,无法处理"); } + if (f.getRawPath() == null || f.getRawPath().isBlank()) { + // 原始件没留(默认策略),规范化结果已经是最终件 —— 原样返回,**不报错**。 + // 报错会让"上传后顺手再调一次 process"的前端逻辑炸掉,而那是无害的调用。 + log.debug("重新规范化跳过:fileId={} 未保留原始件(keepRaw=false),当前已是最终件", fileId); + return f; + } byte[] raw = readFile(f.getRawPath()); int maxSizeKb = def.getMaxSizeKb() == null ? 1024 : def.getMaxSizeKb(); @@ -159,17 +212,10 @@ public class MaterialFileService { FileProcessService.Processed p = processService.compress( raw, def.getCompressProfile(), maxSizeKb, minQuality); - // 规范文件名:材料级模板优先,否则继承任务级 - CollectTask task = taskMapper.selectById(f.getTaskId()); - String template = (def.getNamePattern() != null && !def.getNamePattern().isBlank()) - ? def.getNamePattern() - : (task == null ? null : task.getNamingTemplate()); - Map vars = namingVars(me, f, def, task); - String outExt = p.ext() != null ? "." + p.ext() : ("." + f.getFileExt()); - String standardName = processService.renderName(template, vars, outExt); - + String outExt = p.ext() != null ? p.ext() : f.getFileExt(); + String standardName = standardNameFor(me, f.getTaskId(), def, outExt); String rel = String.format("std/%d/%d/%s", me.tenantId(), f.getTaskId(), standardName); - writeFile(rel, p.bytes(), contentTypeOf(p.ext() == null ? f.getFileExt() : p.ext())); + writeFile(rel, p.bytes(), contentTypeOf(outExt)); fileMapper.update(null, Wrappers.lambdaUpdate() .eq(CollectFile::getId, fileId) @@ -180,7 +226,7 @@ public class MaterialFileService { .set(CollectFile::getHeight, p.height()) .set(CollectFile::getQuality, p.quality())); - log.info("规范化完成:{} → {}({}KB → {}KB, {}x{}, q{})", + log.info("重新规范化完成:{} → {}({}KB → {}KB, {}x{}, q{})", f.getOriginalName(), standardName, f.getRawSize() / 1024, p.bytes().length / 1024, p.width(), p.height(), p.quality()); @@ -231,7 +277,7 @@ public class MaterialFileService { // ── URL 与内容 ──────────────────────────────── /** 一份材料的可访问链接。 */ - public record FileUrl(String url, long expiresIn, String provider, String name) { + public record FileUrl(String url, long expiresIn, String provider, String name, boolean original) { } /** @@ -252,11 +298,15 @@ public class MaterialFileService { if (f == null) { throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你"); } + // 取哪一份:优先按参数,**取不到就退到另一份**。 + // 「原始件默认不保留」(客户决定),所以 original=true 时很可能根本没有原始件; + // 这时退回到规范件并如实标注,比报"文件不存在"有用得多 —— + // 学生点"看原图"只是想看自己传的那份,规范件就是他传的那张的压缩结果。 + boolean useRaw = original; String path = original ? f.getRawPath() : f.getStdPath(); if (path == null || path.isBlank()) { - // 规范件还没生成(上传了但没处理)时退回原始件,而不是报错 —— - // 学生看到的是"我的材料",不该因为内部有两份而困惑 - path = f.getRawPath(); + useRaw = !original; + path = useRaw ? f.getRawPath() : f.getStdPath(); } if (path == null || path.isBlank()) { throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件还没落盘,请重新上传"); @@ -268,11 +318,12 @@ public class MaterialFileService { // 本地只能由本服务流式提供,所以这里换成**按文件 id** 的下载接口 // (而不是让存储层返回一个 path 参数 —— 存储层不知道 fileId, // 而按 id 取能复用"这份文件属于你"的归属校验) - url = "/api/v1/mp/files/" + f.getId() + "/content?original=" + original; + url = "/api/v1/mp/files/" + f.getId() + "/content?original=" + useRaw; } - return new FileUrl(url, exp, storage.provider(), - original ? f.getOriginalName() : (f.getStandardName() == null - ? f.getOriginalName() : f.getStandardName())); + String name = useRaw + ? f.getOriginalName() + : (f.getStandardName() == null ? f.getOriginalName() : f.getStandardName()); + return new FileUrl(url, exp, storage.provider(), name, useRaw); } /** 一份材料的内容与其文件名(文件名用来推 Content-Type)。 */ @@ -292,11 +343,16 @@ public class MaterialFileService { if (f == null) { throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你"); } + boolean useRaw = original; String path = original ? f.getRawPath() : f.getStdPath(); if (path == null || path.isBlank()) { - path = f.getRawPath(); + useRaw = !original; + path = useRaw ? f.getRawPath() : f.getStdPath(); } - String name = original ? f.getOriginalName() + if (path == null || path.isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件还没落盘,请重新上传"); + } + String name = useRaw ? f.getOriginalName() : (f.getStandardName() == null ? f.getOriginalName() : f.getStandardName()); return new Content(readFile(path), name); } @@ -304,10 +360,10 @@ public class MaterialFileService { // ── 内部 ────────────────────────────────────── /** 命名变量。名字要能与学生对上 —— 所以姓名与证件号必须来自名册,不来自学生输入。 */ - private Map namingVars(LoginUser me, CollectFile f, MaterialDefVO def, + private Map namingVars(LoginUser me, Long taskId, MaterialDefVO def, CollectTask task) { Map v = new LinkedHashMap<>(); - CollectRoster r = rosterMapper.selectBoundByUser(me.tenantId(), f.getTaskId(), me.uid()); + CollectRoster r = rosterMapper.selectBoundByUser(me.tenantId(), taskId, me.uid()); if (r != null) { v.put("姓名", r.getRealName()); // 文件名里用**脱敏号**:规范名会出现在导出包里、会被打印、会被转发, diff --git a/backend/xt-server/src/main/resources/application-dev.yml b/backend/xt-server/src/main/resources/application-dev.yml index 6a516f4..52c71d6 100644 --- a/backend/xt-server/src/main/resources/application-dev.yml +++ b/backend/xt-server/src/main/resources/application-dev.yml @@ -72,6 +72,18 @@ fill: # **换存储、换密钥、换空间之后值得开一次**:它能在启动时就把 # "密钥错/空间名错/没有写权限"暴露出来,而不是等第一个学生上传时才失败。 # 默认关:生产启动不该依赖外部网络,云存储的瞬时抖动不该让服务起不来。 + # 是否保留学生上传的**原始件**。 + # + # **默认 false(不保留)**:客户明确过 —— 交付物就是压缩后的规范件, + # 不需要拿原图当"机构说看不清时证明不是我们压坏的"凭证。 + # 顺带把上传从"两步"变成"一步"(收上来就压,不再有"传了但没处理"的中间态)。 + # + # ⚠️ 但要注意它一起放弃的是**重做的能力**:项目里还有两个没定的参数 + # (D5 一寸照片阈值标定、A1 机构是否书面接受 300dpi 扫描件), + # 它们一变,没有原图就只能**让所有学生重传一遍**。 + # 需要在任务归档前留个保险时,把这里打开 —— + # 原始件会写到 raw/,**不参与交付**,只作审计与重处理的来源。 + keep-raw: ${XT_STORAGE_KEEP_RAW:false} self-check-on-startup: ${XT_STORAGE_SELF_CHECK:false} qiniu: access-key: ${QINIU_ACCESS_KEY:} diff --git a/docs/交接摘要.md b/docs/交接摘要.md index c52b366..6de1f4f 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -1399,3 +1399,96 @@ m1-materials 68 / m1-form 48 / m1-task 71 / m1-orgs 48 3. 学生端提交前的**图片自检提示**(现在只有校验清单,预览已经能用,可以串起来) 4. 待办(非阻塞):C1 **小程序资质**(已明确会阻塞学生真正开始填报)、 C5 隐私声明法务文案、D1 OCR 引擎选型、D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测 + + +--- + +## §一·二十 ⚠️ 抓到一个会命中"绝大多数真实照片"的 bug:EXIF 朝向 + +### 客户决定(本轮起点) +> **"不用保持原件,只要压缩可以满足效果即可。"** + +于是:**不再保留学生上传的原始件**,交付物就是压缩后的规范件。 +(顺带把上传从两步变一步,见下。)开关 `fill.storage.keep-raw` 保留,默认 false。 + +### 🐛 EXIF:一个真实学生照片几乎必然带、而测试图从不带的坑 + +**手机竖着拍时,文件里的像素其实是横的**,外加一个 `EXIF Orientation=6` +(意思是"显示时顺时针转 90°")。浏览器认这个标记,所以学生自己看着是正的。 + +但 Java 的 `ImageIO.read()` **不读 EXIF**,重编码又会把 EXIF 丢掉 → +输出成了"**像素横着、也没有标记**"的图 —— **机构看到的是一张转了 90° 的身份证**。 + +**实测确认**(修前): +``` +输入:像素 3200×2400(横)+ Orientation=6 → 应为竖的 +输出:1600×1200(仍是横的),EXIF Orientation=None ❌ 躺倒 +``` +**修后**:`1200×1600`(竖)✓ 且输出里不再带 EXIF(否则会被转两次) + +**为什么 E2E 一直没抓到**:测试图都是程序生成的、**不带 EXIF**; +而真实学生用手机拍的,**竖拍几乎全都带这个标记** —— 也就是说这个 bug +会命中绝大多数真实材料,而我们是靠"想到这一层"才发现的,不是靠测试。 + +**实现**:`module/file/service/ExifOrientation.java` +- 自己解析 JPEG 的 APP1/EXIF(只用到一个 tag,三十行,**不引新依赖**) +- **读不出来时一律"不转"**:宁可保持原样,也不要凭猜测把图转错 +- 八种朝向用**逐像素索引映射**,不用 `AffineTransform` —— 索引一看就懂、 + 容易对着规范核;AffineTransform 的平移/旋转组合写错了很难发现(图还是能出,只是方向不对) +- **转正必须在任何缩放之前**:转置会互换宽高,长边/固定尺寸的计算得基于转正后的尺寸 + +### 流程从两步变一步 +``` +原来:upload(存原图)→ process(读原图 → 压缩 → 存规范件) +现在:upload(收上来 → 立刻压缩 → 只存规范件) +``` +留原始件是两步存在的**唯一理由**;客户不要它之后,这一步就只剩代价了 +(多一个"传了但没处理"的中间态、多一次往返、多一份存储)。 +`process` 保留为"重新规范化",但**没有原始件时直接返回现状而不报错** —— +否则"上传后顺手再调一次"的无害调用会炸掉。 + +### 为"前端压缩"铺路的一个关键改动 +`FileProcessService.compress()` 现在会判断**"是不是已经达标了"**: +``` +没触发缩放 && 没有 EXIF 需要转正 && 体积本来就在上限内 → **原样返回,不重编码** +``` +因为前端马上会按材料项的目标先压一遍(它会转正并去掉 EXIF), +服务端再压一次就是 **JPEG 二次编码** —— 白掉一次画质,换不回任何东西。 +`quality` 用 `NOT_REENCODED = 0` 标记"没重编码过",排查"这张图为什么有点糊"时 +能一眼看出它被压过几次。 + +> 这条对 **P4 截图** 尤其合适:原样保持 PNG,表格线一点不糊。 + +### 顺手修的 +- `url(original=true)` / `content(original=true)`:**没有原始件时优雅退回到交付件** + 并如实标注(`FileUrl` 加了 `original` 字段)—— + 学生点"看原图"只是想看自己传的那份,报"文件不存在"是纯粹的困惑 +- `sniffExt()`:按**字节幻数**判类型,不信文件名的后缀 + (学生上传的 `.jpg` 里装一张 PNG 是常事,而这个结论会写进数据库、决定导出包的扩展名) + +### E2E +- `e2e-m3-file.py` **37/37**,新增第 7 节专门守 EXIF: + ``` + 输入:像素 3200×2400(横)+ Orientation=6 + 输出:1200x1600 + ✓ 输出是竖的 ✓ 长边仍受 P2 约束(≤1600) ✓ 不再带 EXIF(否则会被转两次) + ``` +- 原始件相关的断言改成**按策略断言**(`keep-raw` 开/关两种都是对的), + 而不是写死"原始件必须在" —— 那会让"改配置"看起来像"代码坏了" +- **全量 467 条断言、0 失败**(m0 36 / m0-user 55 / m1 系列 235 / m3 系列 141) + +### ⏳ 还没做(下一步) +**前端压缩("传 800KB 而不是 3MB")还没做** —— 本轮做的是**为它铺路**: +服务端不再二次编码 ✓ + EXIF 转正机制已就位(前端要用 JS 做同样的事)✓ + +前端要做的是: +1. **照片类**(P2/P3):读 EXIF → canvas 旋正 → 长边降到 1600 → q85 → 上传 +2. **P1 证件照 / P4 截图**:**不压**,直传原图(canvas 只能出 JPEG,会把表格线糊掉 ✗) +3. **压完仍超 / 压失败** → 回退成原图上传,让服务端兜底(不能让界面卡住) +4. `uni.compressImage`(小程序端)与 canvas(H5)行为要对齐 + +### 环境/流程备注(本轮又踩的坑) +**不要把 Java 字符串字面量放进 PowerShell here-string 里传给 Python** —— +`@"..."@` 会把 `"` 吃掉,于是 `return "bin";` 变成 `return bin;`、 +`throw new BizException(..., "文案")` 变成语法错误。 +本轮为这个坑白花了三次编译。**带引号的代码一律用 edit 工具改,不要过转义层。** diff --git a/scripts/e2e-m1-storage.py b/scripts/e2e-m1-storage.py index d883a26..2c234ee 100644 --- a/scripts/e2e-m1-storage.py +++ b/scripts/e2e-m1-storage.py @@ -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}") diff --git a/scripts/e2e-m3-file.py b/scripts/e2e-m3-file.py index b80e269..1264a3b 100644 --- a/scripts/e2e-m3-file.py +++ b/scripts/e2e-m3-file.py @@ -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}")