From 66e4ccade95e7346b81efc636bbbc7bfdbc46952 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Tue, 29 Sep 2026 16:25:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(mp):=20=E5=89=8D=E7=AB=AF=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E5=81=9A=E5=AE=8C=20=E2=80=94=E2=80=94=20=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E4=BD=93=E7=A7=AF=E9=99=8D=E5=88=B0=201/8=EF=BC=8C?= =?UTF-8?q?=E9=A1=BA=E5=B8=A6=E4=BF=AE=E6=8E=89=E4=B8=A4=E4=B8=AA=E7=9C=9F?= =?UTF-8?q?=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、效果(浏览器实测,真存储、真接口) 手机竖拍 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° 顺时针,不只是宽高换了位置)。 --- .../controller/MaterialFileController.java | 10 +- .../file/service/MaterialFileService.java | 22 +- docs/交接摘要.md | 136 ++++ frontend-mp/src/pages/fill/fill.vue | 62 +- frontend-mp/src/utils/image-compress.ts | 608 ++++++++++++++++++ scripts/e2e-m3-file.py | 39 +- 6 files changed, 856 insertions(+), 21 deletions(-) create mode 100644 frontend-mp/src/utils/image-compress.ts diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java index a7b17d8..0515e3a 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java @@ -50,15 +50,21 @@ public class MaterialFileController { @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "上传材料(后端代理)", - description = "先原样存下来再处理 —— 上传中断是最常见的失败,先落盘学生就不必重传。" + description = "收上来即规范化(压缩 + 按规则改名),只存规范件。" + "格式白名单与份数上限在这里校验") public ApiResult> 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 out = new LinkedHashMap<>(); out.put("id", id); out.put("rawSize", file.getSize()); 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 c3c417c..030ebd3 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 @@ -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); + } // 份数上限。超过就明确拒绝,而不是"覆盖上一份" —— // 学生传错了要能自己删,静默覆盖会让他以为传上去了 diff --git a/docs/交接摘要.md b/docs/交接摘要.md index 6de1f4f..af8b4ac 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -1492,3 +1492,139 @@ m1-materials 68 / m1-form 48 / m1-task 71 / m1-orgs 48 `@"..."@` 会把 `"` 吃掉,于是 `return "bin";` 变成 `return bin;`、 `throw new BizException(..., "文案")` 变成语法错误。 本轮为这个坑白花了三次编译。**带引号的代码一律用 edit 工具改,不要过转义层。** + +--- + +## §一·二十一 前端压缩做完了 —— 顺带又抓到两个真 bug + +### 效果(浏览器实测,真存储、真接口) + +``` +输入:手机竖拍 3200×2400(像素是横的)+ EXIF Orientation=6,3699KB +输出:1200×1600(竖的,转正正确)· 438KB · q88 · 73ms + —— 上传体积降到 1/8,服务端**原样收下**(quality=0 NOT_REENCODED) +``` + +数据库里那条权威记录(`collect_file`): +``` +original_name 压缩测试.jpg ← 客户端声明的文件名生效 +standard_name 张同学-361029XXXXXX0015-身份证.jpg +raw_size 448940 ← 上传上来的 +final_size 448940 ← 存下来的,**字节完全相同** → 服务端没有二次编码 +width×height 1200×1600 +quality 0 ← NOT_REENCODED +``` + +### 🐛 bug 一:浏览器对 EXIF 的处理**不一致**,猜必错一半 + +写完前端旋正后实测,输出是 **1600×1200(横的)** —— 又躺倒了。量出来的原因是: + +``` +文件里的像素 3200×2400(横)+ Orientation=6 +img.naturalWidth/Height 2400×3200 ← 浏览器**已经自己按 EXIF 转正了** +createImageBitmap('none') 2400×3200 ← 连"明确要求不给转"也被忽略 +``` + +也就是说:**现代浏览器(Chromium/WebKit)会把 EXIF 先转正再交给 `drawImage`**, +我又转了一次 → 转了两下 = 又躺倒了。而**老实现(旧 X5 / 旧 WKWebView)给的是原始像素**, +不转就是躺倒的。两端行为相反,**任何写死的做法都必错一半**。 + +解法:**不猜,量出来再决定**(`utils/image-compress.ts`)—— +自己从 JPEG 的 SOF 段读**文件里的原始像素**(`readJpegSize`,与 EXIF 无关), +再和 `naturalWidth/Height` 比: +- 与原始尺寸**互换** → 浏览器已经转过了 → **不要再转** +- 两者**相同** → 给的是原始像素 → 我们自己转 +- 都对不上(非 JPEG 等)→ 按现代浏览器的默认(已转正)处理 + +> 正方形图两种情况分辨不出来(此时按"已转正"处理)——现代浏览器下这是对的, +> 而正方形带朝向标记的图本身极罕见(一般是裁剪图)。这条已知边界写进注释了。 + +**这是这轮最有价值的一条**:它和第 §一·二十 的 EXIF bug 是同一件事的两面 —— +后端不转是躺倒,前端多转一次也是躺倒。 + +### 🐛 bug 二:按文件名的后缀判格式 → 前端压缩一上线,学生就传不了材料 + +浏览器验证时上传被拒:**「身份证」只接受 jpg/jpeg/png,你传的是 .bin」**。 + +原因:H5 用 **blob 地址**调 `uni.uploadFile`,multipart 里带过去的文件名是一串 UUID、 +**没有后缀**;而后端当时是 `extOf(originalName)` 判白名单 → 判成 `.bin` → 拒。 + +**这不是脚手架的问题,是真 bug**:前端压缩产出的就是 blob 地址, +一上线所有压缩过的照片都会被拒 —— 也就是"学生压根传不了材料"。 + +两处一起修: +- **后端**(`MaterialFileService.upload`):格式按**字节幻数**判(`sniffExt`), + 不信后缀;幻数认不出来(HEIC 等)再退回看后缀,保持原有行为。 + > 顺带变强了:学生把 PNG 改名成 `.jpg` 交上来,现在能按 PNG 正确识别 + > (白名单里 png 允许,所以收下;原来是"看着是 jpg 就收")。 +- **接口**:新增可选 `fileName` 表单字段,客户端显式声明叫什么; + `original_name` 只用于展示与留痕,**不进任何路径拼接**,并截断防超长。 + +### 档位 → 前端行为的映射(**不按材料码猜**) + +档位来自后端配置(`collect_material_def.compress_profile`,管理员可改), +前端按档位推导,材料码一个都不写死 —— 否则管理员一改配置前端就错,而且错得悄无声息。 + +| 档位 | 前端行为 | 为什么 | +|---|---|---| +| **P2**(证件翻拍) | 压:长边 1600 + q88 起阶梯 | 与服务端 P2 同口径,压完正好被服务端原样收下 | +| **P3**(扫描件) | 压:长边 3508 + 降质量 | 长边上限很宽(A4@300dpi),收益主要在降质量;**千万别套 P2 的 1600**,扫描件缩到 1600 就看不清字 | +| **AUTO/P5** | 只降质量,**不缩尺寸** | 与服务端同口径;未知档位下擅自缩尺寸是危险的(万一是文档类) | +| **P1**(证件照) | **不压**,直传 | 服务端要按 295×413 裁切 + **补白底**,前端做不了;硬压只会让裁出来的头像更糊 | +| **P4**(截图/文档) | **不压**,直传 | 关键是**保持 PNG**(转 JPG 会把表格线糊掉),而 canvas 只能出 JPEG | + +### 小程序端(**刻意不做 canvas 旋正**,真机未验证) + +- `getImageInfo().orientation === 'up'` → `compressImage` 压(无需转正,重编码不改朝向) +- **其它朝向 → 不压,直传原图,交服务端**(服务端已经会按 EXIF 正确转正) + +理由:小程序 canvas 的旋正行为**我在真机上无法验证**,而"转错了"的后果是 +机构收到躺倒的身份证。宁可慢一点,也不能赌。等有真机环境再补。 + +### 与服务端配合的关键点(决定了参数怎么取) + +服务端会判断"是不是已经达标"(没触发缩放 && 没有 EXIF 需转正 && 体积在上限内)→ +**原样收下不重编码**。所以前端必须压成**正好落在服务端那套目标里**: + +- 压得比服务端狠 → 白掉一次画质 +- 压得比服务端轻 → 服务端再压一次,前端白干 + +实测已确认落在这条路上(`quality=0`)。 + +### E2E + +- `e2e-m3-file.py` **39/39**,新增第 8 节守这次的两个 bug: + ``` + ✓ **没有后缀**的真 JPEG 被接受(按字节判格式,不看后缀) + ✓ 字节是 PNG、后缀写着 .txt → 按字节认作 png 并接受 + ``` + (原有的"`身份证.exe` + `MZfake` 字节被拒"仍通过:幻数认不出 → 退回看后缀 → 拒) +- **全量 469 条断言、0 失败** + +### 验证脚手架(已拆,别留在仓库里) + +`pages/dev/compress-check.vue` + `static/__devcheck/exif6-portrait.jpg` + 造图脚本 +是一次性的浏览器端验证脚手架 —— **已全部删除**,H5 产物也重新构建过 +(自查过产物里不再有 `compress-check` / `__devcheck` 字样)。 + +它当时的作用值得记一笔:**文件选择框没法自动化**(`uni.chooseImage` 会弹系统对话框), +所以那页绕开"选文件",直接拿一张真构造的竖拍照片喂给**真正的压缩模块**、 +再走**真正的上传通道** —— 除了"手指点选"那一步,其余全是生产代码。 +它验的东西比"尺寸对不对"更硬:**定位色块落在右上角**(证明转的是 90° 顺时针, +而不只是"宽高换了个位置")。 + +### ⏳ 还没做 / 待办 + +- **小程序端真机验证**(`compressImage` 那条路;顺带确认 `getImageInfo().orientation` 的实际取值) +- 七牛**实时样式做缩略图**(审核列表 200 张 × 700KB ≈ 140MB → 缩略图 ≈ 2MB) +- D6 微信三通道体积上限真机实测(现在前端压过了,这条的紧迫性下降了) + +### 流程备注(本轮又踩的) + +- **PowerShell 不支持 heredoc**(`python - <<'PY'`)—— 会报 + "Missing file specification after redirection operator"。改中文文档/改代码 + 一律写成 `.py` 脚本文件再跑,别在 shell 里内联。 +- 浏览器验证可以用 `browser_evaluate` 读页面状态 + `browser_wait(condition:'textGone')` + 等结果,比"睡一会儿再看"可靠;`browser.navigate` 带个 `?v=N` 就能绕过缓存拿新产物。 +- 改完 `pages.json` **别忘重建 H5**:产物里的旧页面不会自己消失 + (这轮就出现过"临时页文件删了、`pages.json` 还留着条目 → 构建直接失败")。 diff --git a/frontend-mp/src/pages/fill/fill.vue b/frontend-mp/src/pages/fill/fill.vue index f9f51f8..5f29dc8 100644 --- a/frontend-mp/src/pages/fill/fill.vue +++ b/frontend-mp/src/pages/fill/fill.vue @@ -175,6 +175,7 @@ import { computed, ref } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { request } from '@/utils/request' +import { compressForUpload, planFor, releaseCompressedPath, type CompressOutcome } from '@/utils/image-compress' import { API_BASE_URL as API_BASE } from '@/config' /** @@ -395,6 +396,13 @@ interface Material { fileTypes?: string[] maxSizeKb?: number maxCount?: number + /** + * 压缩档位(`AUTO`/`P1`~`P5`)。**前端压缩目标由它推导**,不按材料码猜 —— + * 档位是管理员可改的配置,猜材料码的话管理员一改前端就错,而且错得悄无声息。 + */ + compressProfile?: string | null + /** 质量下限。低于它就不再降质量(与服务端同口径)。 */ + minQuality?: number | null tipText?: string | null /** 条件显示规则;null = 无条件显示。**必须与字段一样求值** —— 详见 visibleMaterials */ visibleWhen?: Record | null @@ -489,11 +497,16 @@ async function loadFiles() { } /** - * 选文件 → 上传 → 立刻处理。 + * 选文件 → (前端压)→ 上传。 * - *

**为什么上传后马上处理**:学生要能看到"压到了多大、叫什么名字"才算完成。 - * 若把处理留到提交时,学生上传时会以为"传上去了",提交时才发现体积超标 —— - * 而那时他可能已经在回家的路上。 + *

**为什么要在前端压**:上传这一步发生在服务端压缩**之前** —— 服务端压得再好, + * 学生的 3MB 也已经从流量里走完了。前端先压到几百 KB,上传时间大致降到 1/4。 + * 压不了的(证件照、截图、需要转正的小程序照片)就原样上传,由服务端兜底 —— + * **前端压缩只是优化,不该成为新的失败点**(见 utils/image-compress.ts)。 + * + *

**不再调 `/process`**:服务端改成"收上来即压缩、只存规范件"了(见 + * docs/交接摘要.md §一·二十)。所以原来那次"上传完再让服务端处理"的往返 + * 现在没有任何意义 —— 少一次请求,学生少等一个来回。 */ function uploadFor(m: Material) { uni.chooseImage({ @@ -501,16 +514,35 @@ function uploadFor(m: Material) { success: async (res) => { const path = (res.tempFilePaths || [])[0] if (!path || !submissionId.value) return - uni.showLoading({ title: '上传中…' }) + let outcome: CompressOutcome | null = null + let sendPath = path + // 选中的原始文件名。**H5 用 blob 地址上传时 multipart 里带不过去**(是一串 UUID、 + // 没有后缀),所以下面要显式告诉服务端叫什么 —— 否则它按后缀判格式会直接拒掉。 + const pickedName = ((res.tempFiles || [])[0] as { name?: string } | undefined)?.name || '' try { - const uploaded = await new Promise<{ id: number }>((resolve, reject) => { + const plan = planFor(m.compressProfile, m.maxSizeKb, m.minQuality) + if (plan) { + uni.showLoading({ title: '压缩中…' }) + outcome = await compressForUpload(path, plan) + sendPath = outcome.path + // 结论写日志:学生说"我的图怎么糊了"时,这是唯一能回看"压过几次、压到多少"的地方 + console.log('[材料压缩]', m.materialCode, outcome.note) + } + uni.showLoading({ title: '上传中…' }) + // 压缩后内容是 JPEG,所以扩展名必须跟着改成 .jpg(原名可能是 .png/.heic) + const baseName = pickedName ? pickedName.replace(/\.[^.]+$/, '') : m.materialName + const sendName = outcome?.compressed + ? baseName + '.jpg' + : (pickedName || m.materialName + '.jpg') + await new Promise<{ id: number }>((resolve, reject) => { uni.uploadFile({ url: API_BASE + '/api/v1/mp/files/upload', - filePath: path, + filePath: sendPath, name: 'file', formData: { submissionId: String(submissionId.value), - materialCode: m.materialCode + materialCode: m.materialCode, + fileName: sendName }, header: token() ? { Authorization: `Bearer ${token()}` } : {}, success: (r) => { @@ -525,14 +557,13 @@ function uploadFor(m: Material) { fail: (e) => reject(new Error(e.errMsg || '上传失败')) }) }) - uni.showLoading({ title: '处理中…' }) - await request({ - url: `/api/v1/mp/files/${uploaded.id}/process`, - method: 'POST', - token: token() - }) await loadFiles() - uni.showToast({ title: '已压缩并改名', icon: 'success' }) + // 如实告诉学生发生了什么:压过了就说压到多大,没压就说已上传 —— + // 不说清的话,他看到"体积比原图小"会以为材料被换了 + uni.showToast({ + title: outcome?.compressed ? `已压缩 ${kb(outcome.afterBytes)}` : '已上传', + icon: 'success' + }) } catch (e) { uni.showModal({ title: '上传未完成', @@ -540,6 +571,7 @@ function uploadFor(m: Material) { showCancel: false }) } finally { + if (outcome) releaseCompressedPath(sendPath, outcome) uni.hideLoading() } } diff --git a/frontend-mp/src/utils/image-compress.ts b/frontend-mp/src/utils/image-compress.ts new file mode 100644 index 0000000..7fa44aa --- /dev/null +++ b/frontend-mp/src/utils/image-compress.ts @@ -0,0 +1,608 @@ +/** + * 上传前的图片压缩(H5 + 小程序共用)。 + * + * ── 为什么要有它 ──────────────────────────────────────── + * 学生用手机流量传一张 3MB 的原图:慢、容易失败。而且**上传这一步发生在服务端 + * 压缩之前** —— 服务端压得再好,那 3MB 也已经从学生的流量里走完了。 + * 前端先压到几百 KB,上传时间大致降到 1/4。 + * + * ── ⚠️ 最容易踩的坑:重绘必须同时旋正 ────────────────────── + * 手机竖着拍时,**文件里的像素其实是横的**,外加一个 EXIF `Orientation=6` + * (意思是"显示时顺时针转 90°")。浏览器认这个标记,所以学生自己看着是正的。 + * + * 而 canvas / `compressImage` 都会**重编码并丢掉 EXIF** —— 只压不转,出来的 + * 就是一张没有标记的躺倒图,**机构看到的是一张转了 90° 的身份证**。 + * 这个坑服务端刚踩过一次(见 docs/交接摘要.md §一·二十),前端不能再踩第二次。 + * + * 所以两端策略不同,且**这个差别是刻意的**: + * · **H5**:自己读 EXIF、自己在 canvas 里转正 —— 完整能力,且可自动化验证。 + * · **小程序**:`getImageInfo().orientation` 不是 `'up'` 时**不压**,直接传原图, + * 交给服务端(服务端已经会正确转正)。理由:小程序 canvas 的旋正行为我在 + * 真机上无法验证,**宁可慢一点,也不能赌"机构收到的身份证是正的"**。 + * + * ── 与服务端的配合(这条决定了参数怎么取)─────────────────── + * 服务端收到上传件后会判断"是不是已经达标"(没触发缩放 && 没有 EXIF 需转正 && + * 体积在上限内)→ **原样收下,不做二次编码**(见 `FileProcessService.compress`)。 + * 所以前端压出来的东西必须**正好落在服务端的那套目标里**:长边、质量阶梯、 + * 体积上限与 `FileProcessService` 同一口径 —— + * 前端压得比服务端狠 = 白掉一次画质;比服务端轻 = 服务端再压一次,等于白干。 + * + * > 后端档位常量见 `FileProcessService`:P2 长边 1600、P3 长边 3508、 + * > 质量阶梯 {88,85,80,75,70,65}、质量下限 `max(minQuality, 60)`。 + */ + +/** 压缩目标。由材料项的档位推导(见 `planFor`)。 */ +export interface CompressPlan { + /** + * 长边上限(px)。`null` = 本档位不缩尺寸,只降质量。 + * 与服务端同名常量保持一致。 + */ + longSide: number | null + /** 起始质量(JPEG)。 */ + quality: number + /** 质量下限。低于它就不再降质量,改为折中放弃前端压缩。 */ + floor: number + /** 体积上限(字节)。**硬要求**,与服务端的 `maxSizeKb` 同一口径。 */ + maxBytes: number + /** 给日志/排查用的档位说明。 */ + label: string + /** 跳过前端压缩的原因;`null` = 应该压。 */ + skip: string | null +} + +/** 压缩结果。**永远不抛异常**:压不动就如实返回原路径,由服务端兜底。 */ +export interface CompressOutcome { + /** 拿来上传的路径。压缩成功是新文件;跳过/失败时是原路径。 */ + path: string + /** 是否真的压过。 */ + compressed: boolean + /** 压前字节数(拿不到时为 0)。 */ + beforeBytes: number + /** 压后字节数(没压时等于压前;拿不到时为 0)。 */ + afterBytes: number + /** 压缩后的像素尺寸(没压时为 0)。 */ + width: number + /** 压缩后的像素尺寸(没压时为 0)。 */ + height: number + /** 源文件的 EXIF 朝向(1 = 正常)。 */ + orientation: number + /** 一句话说明发生了什么 —— 会写进日志,也用于界面上如实告知学生。 */ + note: string +} + +/** + * 是否为 H5 环境。 + * + *

用**运行期判断**而不是 `#ifdef` 条件编译:条件编译要靠重复声明变量来分叉, + * 会让 `vue-tsc` 报"重复声明"(`config/index.ts` 里就有两条这样的历史遗留报错)。 + * 代价是两个分支的实现都会进包(几 KB),换来的是类型干净、代码好读。 + */ +const IS_H5 = typeof window !== 'undefined' && typeof document !== 'undefined' + +/** + * 质量阶梯。服务端是 `{88,85,80,75,70,65}`(6 档 + 缩尺寸兜底)。 + * + *

前端取其中几档就够:每次编码学生都在等,而且**前端压不到目标时有退路** + * (传原图、由服务端压)。前端的目标是"一次压到位",不是"穷尽所有档位"。 + */ +const QUALITY_STEPS = [88, 82, 76, 68] + +/** 长边(px)。与服务端 `FileProcessService` 的 P2/P3 常量必须一致。 */ +const P2_MAX_LONG_SIDE = 1600 +const P3_MAX_LONG_SIDE = 3508 + +/** + * 按材料项的压缩档推导前端压缩目标。 + * + *

**档位来自后端配置,不写死材料码** —— 材料码对应的档位是管理员可改的 + * (`collect_material_def.compress_profile`),前端要是按 `IDCARD`/`PHOTO1` + * 之类的码去猜,管理员一改配置前端就错,而且错得悄无声息。 + * + * @param profile 材料项的 `compressProfile`(`AUTO`/`P1`~`P5`) + * @param maxSizeKb 材料项的 `maxSizeKb` + * @param minQuality 材料项的 `minQuality` + * @returns 目标是 `null` 表示**前端一律不压**(见各分支的注释) + */ +export function planFor( + profile?: string | null, + maxSizeKb?: number | null, + minQuality?: number | null +): CompressPlan | null { + const maxBytes = (maxSizeKb && maxSizeKb > 0 ? maxSizeKb : 1024) * 1024 + const floor = Math.max(minQuality && minQuality > 0 ? minQuality : 70, 60) + const p = (profile || 'AUTO').toUpperCase() + + if (p === 'P1') { + // 一寸/两寸证件照:服务端要按 295×413 裁切并**补白底**,前端做不了这件事。 + // 硬压只会把学生的原照片压小,服务端再裁出来的头像是糊的。 + return null + } + if (p === 'P4') { + // 截图/文档类:服务端的关键处理是**保持 PNG**(转 JPG 会把表格线糊掉), + // 而 canvas 只能出 JPEG —— 前端压就等于毁掉这一档的意义。 + return null + } + if (p === 'P2') { + return { longSide: P2_MAX_LONG_SIDE, quality: QUALITY_STEPS[0], floor, maxBytes, label: 'P2 证件翻拍', skip: null } + } + if (p === 'P3') { + // 扫描件:长边上限很宽(3508 ≈ A4@300dpi),前端的收益主要在"降质量", + // 尺寸几乎不动 —— 千万别拿 P2 的 1600 去套,扫描件缩到 1600 就看不清字了。 + return { longSide: P3_MAX_LONG_SIDE, quality: QUALITY_STEPS[0], floor, maxBytes, label: 'P3 扫描件', skip: null } + } + // AUTO / P5 / 其它未知值:与服务端同口径 —— **不缩尺寸**,只降质量。 + // 未知档位下擅自缩尺寸是危险的(万一它是文档类,缩完就不可读了)。 + return { longSide: null, quality: QUALITY_STEPS[0], floor, maxBytes, label: 'AUTO 通用', skip: null } +} + +// ── EXIF ──────────────────────────────────────────────── +// +// 只读一个 tag(`Orientation` = 0x0112),所以自己解析 JPEG 的 APP1 段, +// 不引第三方库。逻辑与后端 `ExifOrientation.java` **逐条对应** —— +// 两端读同一张图必须得到同一个结论,否则会出现"前端转一次、后端再转一次"。 + +/** 从 JPEG 字节里读 EXIF 朝向。读不到一律返回 1(=不转)。 */ +export function readExifOrientation(buf: ArrayBuffer): number { + try { + const b = new Uint8Array(buf) + if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return 1 + let i = 2 + while (i + 4 <= b.length) { + if (b[i] !== 0xff) return 1 + const marker = b[i + 1] + if (marker === 0xff) { + i++ + continue + } + // SOS 之后是压缩数据,不会再有元数据段 + if (marker === 0xda || marker === 0xd9) return 1 + const len = (b[i + 2] << 8) | b[i + 3] + if (len < 2) return 1 + const segStart = i + 4 + const segEnd = i + 2 + len + if (segEnd > b.length) return 1 + if ( + marker === 0xe1 && + segEnd - segStart >= 14 && + b[segStart] === 0x45 && b[segStart + 1] === 0x78 && // 'E' 'x' + b[segStart + 2] === 0x69 && b[segStart + 3] === 0x66 && // 'i' 'f' + b[segStart + 4] === 0 && b[segStart + 5] === 0 + ) { + const o = readTiffOrientation(b, segStart + 6, segEnd) + if (o >= 1 && o <= 8) return o + } + i = segEnd + } + } catch { + // 读不出来就当"正常" —— **宁可保持原样,也不要凭猜测把图转错** + } + return 1 +} + +function readTiffOrientation(b: Uint8Array, start: number, end: number): number { + if (start + 8 > end) return 1 + let little: boolean + if (b[start] === 0x49 && b[start + 1] === 0x49) little = true + else if (b[start] === 0x4d && b[start + 1] === 0x4d) little = false + else return 1 + if (u16(b, start + 2, little) !== 0x002a) return 1 + const ifd = start + u32(b, start + 4, little) + if (ifd + 2 > end) return 1 + const count = u16(b, ifd, little) + for (let k = 0; k < count; k++) { + const e = ifd + 2 + k * 12 + if (e + 12 > end) return 1 + if (u16(b, e, little) === 0x0112) { + // 类型 3 = SHORT,值直接放在前两字节 + return u16(b, e + 8, little) + } + } + return 1 +} + +function u16(b: Uint8Array, i: number, little: boolean): number { + return little ? (b[i] & 0xff) | ((b[i + 1] & 0xff) << 8) : ((b[i] & 0xff) << 8) | (b[i + 1] & 0xff) +} + +function u32(b: Uint8Array, i: number, little: boolean): number { + return little + ? (b[i] & 0xff) | ((b[i + 1] & 0xff) << 8) | ((b[i + 2] & 0xff) << 16) | ((b[i + 3] & 0xff) << 24) + : ((b[i] & 0xff) << 24) | ((b[i + 1] & 0xff) << 16) | ((b[i + 2] & 0xff) << 8) | (b[i + 3] & 0xff) +} + +/** + * 朝向会互换宽高(5~8 是转置类)。 + * + *

转正后的尺寸必须**先算对再开画布**:canvas 一旦定了尺寸就不能改, + * 而"先按原尺寸画、再转"会得到一张被裁掉一半的图。 + */ +function rotatedSize(w: number, h: number, o: number): { w: number; h: number } { + return o >= 5 && o <= 8 ? { w: h, h: w } : { w, h } +} + +/** + * 把源图画到 2D 上下文里,**同时完成"按 EXIF 转正"和"缩到目标尺寸"**。 + * + *

八种朝向用 canvas 的标准变换矩阵,与后端 `ExifOrientation.apply` 的 + * 逐像素索引映射**等价**(后端的 case 6 是 `sx=y, sy=h-1-x`, + * 这里 case 6 是 `transform(0,1,-1,0,h,0)` —— 把目标点代进去是同一件事)。 + * + *

⚠️ `o` 必须是**"还要不要再转"**,不是文件里的 EXIF 值:现代浏览器已经 + * 按 EXIF 转正过了,此时传 1(不再转)。判断逻辑见 `compressOnH5`。 + * 传错就是"转两次"或"一次没转" —— 两种都是一张躺倒的身份证。 + */ +function drawRotated( + ctx: CanvasRenderingContext2D, + img: CanvasImageSource, + w: number, + h: number, + o: number, + scale: number +): void { + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.scale(scale, scale) + switch (o) { + case 2: ctx.transform(-1, 0, 0, 1, w, 0); break + case 3: ctx.transform(-1, 0, 0, -1, w, h); break + case 4: ctx.transform(1, 0, 0, -1, 0, h); break + case 5: ctx.transform(0, 1, 1, 0, 0, 0); break + case 6: ctx.transform(0, 1, -1, 0, h, 0); break + case 7: ctx.transform(0, -1, -1, 0, h, w); break + case 8: ctx.transform(0, -1, 1, 0, 0, w); break + default: break + } + ctx.drawImage(img, 0, 0, w, h) + ctx.setTransform(1, 0, 0, 1, 0, 0) +} + +/** 由长边上限推算缩放比。`null` = 不缩。 */ +function scaleFor(w: number, h: number, longSide: number | null): number { + if (!longSide) return 1 + const cur = Math.max(w, h) + return cur <= longSide ? 1 : longSide / cur +} + +// ── H5 实现 ───────────────────────────────────────────── +// +// 用浏览器原生 canvas,而不是 `uni.compressImage`:H5 的 `uni.compressImage` +// 在部分端上不支持,而且它给的 `quality` 语义各端不一致; +// 原生 canvas 的 `toBlob(quality)` 行为是确定的,也能顺手把 EXIF 转正做掉。 + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = () => reject(new Error('图片读取失败')) + img.src = src + }) +} + +function toBlob(canvas: HTMLCanvasElement, quality: number): Promise { + return new Promise((resolve) => { + // 不用 toDataURL:base64 会比原图大 33%,上传前白白多出一截内存和体积 + canvas.toBlob((b) => resolve(b), 'image/jpeg', quality / 100) + }) +} + +/** + * 从 JPEG 的 SOF 段读**文件中原始的像素尺寸**(不含 EXIF 朝向)。读不到返回 null。 + * + *

为什么需要它:见 `compressOnH5` 里那段"浏览器到底替我们转了没有"的判断 —— + * 判断依据是"浏览器给的尺寸"与"文件里的原始尺寸"是否互换, + * 所以必须能直接读出原始尺寸,不能从浏览器给的结果反推(反推不出来)。 + */ +export function readJpegSize(buf: ArrayBuffer): { w: number; h: number } | null { + try { + const b = new Uint8Array(buf) + if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null + let i = 2 + while (i + 4 <= b.length) { + if (b[i] !== 0xff) return null + const marker = b[i + 1] + if (marker === 0xff) { + i++ + continue + } + if (marker === 0xd9 || marker === 0xda) return null + const len = (b[i + 2] << 8) | b[i + 3] + if (len < 2) return null + const segEnd = i + 2 + len + if (segEnd > b.length) return null + // SOF0~SOF15(C0~CF),但 C4=DHT、C8=JPG、CC=DAC 不是尺寸段 + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + if (i + 9 > b.length) return null + const h = (b[i + 5] << 8) | b[i + 6] + const w = (b[i + 7] << 8) | b[i + 8] + return w > 0 && h > 0 ? { w, h } : null + } + i = segEnd + } + } catch { + // 读不出来就退回"按浏览器已转正处理"(现代浏览器的默认行为) + } + return null +} + +async function compressOnH5(filePath: string, plan: CompressPlan): Promise { + const resp = await fetch(filePath) + const buf = await resp.arrayBuffer() + const beforeBytes = buf.byteLength + const orientation = readExifOrientation(buf) + + const img = await loadImage(filePath) + const dw = img.naturalWidth || img.width + const dh = img.naturalHeight || img.height + if (!dw || !dh) { + return miss(filePath, beforeBytes, orientation, '读不到图片尺寸') + } + + // ★ 浏览器到底替我们转了没有 —— **必须量,不能猜**。 + // + // 实测(Chromium,2026):文件里 3200×2400 + Orientation=6, + // 而 `img.naturalWidth/Height` 给的是 **2400×3200** —— 也就是说现代浏览器 + // **已经按 EXIF 转正**,再交给 `drawImage` 的就是转正后的像素; + // 此时我们要是再转一次,出来就是一张**躺倒的图**(这个 bug 真的出现过: + // 修复前输出 1600×1200,修复后 1200×1600)。 + // + // 而老实现(旧 X5 / 旧 WKWebView)给的是**原始像素**,不转就是躺倒的。 + // 两端行为相反,**任何写死的做法都必错一半**,所以拿尺寸对比来判定: + // · 浏览器给的尺寸与文件原始尺寸**互换** → 它已经转过了 → 不要转 + // · 两者**相同** → 它给的是原始像素 → 我们自己转 + // · 都对不上(非 JPEG、或尺寸被改过)→ 按现代浏览器的默认(已转正)处理 + // + // 注:正方形图(原始宽高相等)两种情况分辨不出来,此时同样按"已转正"处理 —— + // 现代浏览器下这是对的,而正方形带朝向标记的图本身极罕见(一般是裁剪图)。 + const raw = readJpegSize(buf) + let drawOrientation = 1 + if (orientation > 1 && raw) { + if (dw === raw.h && dh === raw.w && raw.w !== raw.h) { + drawOrientation = 1 + } else if (dw === raw.w && dh === raw.h) { + drawOrientation = orientation + } + } + + // 呈现尺寸:浏览器已转正时就是它给的尺寸;要我们自己转时是互换后的 + const shown = drawOrientation > 1 ? rotatedSize(dw, dh, drawOrientation) : { w: dw, h: dh } + const needScale = scaleFor(shown.w, shown.h, plan.longSide) < 1 + + // 已经达标 → **原样上传**,一个字节都不重编码。 + // 服务端也因此走"原样收下"那条路(见模块头注释)。 + if (orientation <= 1 && !needScale && beforeBytes <= plan.maxBytes) { + return miss(filePath, beforeBytes, orientation, '本来就达标,未改动') + } + + // 长边缩两次还不够就放弃(宁可让学生多等一次上传,也不要交一张糊图) + const scales = [scaleFor(shown.w, shown.h, plan.longSide), 0.8, 0.64] + let lastBytes = 0 + let lastSize = { w: 0, h: 0 } + + for (let si = 0; si < scales.length; si++) { + const scale = si === 0 ? scales[0] : Math.min(scales[si], scales[si - 1]) + const tw = Math.max(1, Math.round(shown.w * scale)) + const th = Math.max(1, Math.round(shown.h * scale)) + const canvas = document.createElement('canvas') + canvas.width = tw + canvas.height = th + const ctx = canvas.getContext('2d') + if (!ctx) return miss(filePath, beforeBytes, orientation, '拿不到 canvas 上下文') + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = 'high' + drawRotated(ctx, img, dw, dh, drawOrientation, scale) + + for (const q of QUALITY_STEPS) { + if (q < plan.floor) break + const blob = await toBlob(canvas, q) + if (!blob) break + lastBytes = blob.size + lastSize = { w: tw, h: th } + if (blob.size <= plan.maxBytes) { + return { + path: URL.createObjectURL(blob), + compressed: true, + beforeBytes, + afterBytes: blob.size, + width: tw, + height: th, + orientation, + note: `${plan.label}:${kb(beforeBytes)} → ${kb(blob.size)}(${tw}×${th},q${q}` + + `${drawOrientation > 1 ? ',已按 EXIF 转正' : ',浏览器已转正'})` + } + } + } + } + + // 压不动:传原图,让服务端那套"质量到底 + 缩尺寸"的重手段去处理。 + // **不能在这里报错** —— 学生什么也没做错,而服务端本来就能压下来。 + return { + path: filePath, + compressed: false, + beforeBytes, + afterBytes: beforeBytes, + width: lastSize.w, + height: lastSize.h, + orientation, + note: `压到 ${kb(lastBytes)} 仍超过 ${kb(plan.maxBytes)},交服务端处理` + } +} + +// ── 小程序实现 ─────────────────────────────────────────── +// +// 只用 `compressImage`,**不做 canvas 旋正**:真机上的行为无法验证, +// 而"转错了"的后果是机构收到躺倒的身份证。所以: +// · orientation === 'up' → 放心压(无需转正,重编码不会改变朝向,即便丢掉 EXIF) +// · 其它朝向 → 不压,传原图,由服务端转正 + 压缩 + +interface MpImageInfo { + width: number + height: number + orientation?: string +} + +function mpImageInfo(src: string): Promise { + return new Promise((resolve, reject) => { + uni.getImageInfo({ + src, + success: (r) => resolve({ width: r.width, height: r.height, orientation: (r as { orientation?: string }).orientation }), + fail: () => reject(new Error('图片读取失败')) + }) + }) +} + +function mpCompress(src: string, quality: number, longSide: number | null, w: number, h: number): Promise { + const opt: Record = { src, quality } + // 只给**长边那一维**,另一维由微信按比例算 —— 同时给两维有把图拉变形的风险 + if (longSide && Math.max(w, h) > longSide) { + if (w >= h) opt.compressedWidth = longSide + else opt.compressedHeight = longSide + } + return new Promise((resolve, reject) => { + uni.compressImage({ + ...(opt as unknown as UniApp.CompressImageOptions), + success: (r) => resolve(r.tempFilePath), + fail: () => reject(new Error('压缩失败')) + }) + }) +} + +function mpFileSize(path: string): Promise { + return new Promise((resolve) => { + try { + uni.getFileSystemManager().getFileInfo({ + filePath: path, + success: (r) => resolve(r.size || 0), + fail: () => resolve(0) + }) + } catch { + resolve(0) + } + }) +} + +async function compressOnMp(filePath: string, plan: CompressPlan): Promise { + const before = await mpFileSize(filePath) + const info = await mpImageInfo(filePath) + const o = orientationFromMp(info.orientation) + const shown = rotatedSize(info.width, info.height, o) + + if (o > 1) { + return { + path: filePath, + compressed: false, + beforeBytes: before, + afterBytes: before, + width: 0, + height: 0, + orientation: o, + note: '照片需要转正:小程序端不压,交服务端处理(服务端会按 EXIF 转正)' + } + } + if (!plan.longSide && before > 0 && before <= plan.maxBytes) { + return miss(filePath, before, o, '本来就达标,未改动') + } + + let last = filePath + let lastSize = before + for (const q of QUALITY_STEPS) { + if (q < plan.floor) break + try { + const out = await mpCompress(filePath, q, plan.longSide, info.width, info.height) + const size = await mpFileSize(out) + last = out + lastSize = size + if (size <= 0 || size <= plan.maxBytes) { + return { + path: out, + compressed: true, + beforeBytes: before, + afterBytes: size, + width: shown.w, + height: shown.h, + orientation: o, + note: `${plan.label}:${kb(before)} → ${size > 0 ? kb(size) : '未知'}(q${q})` + } + } + } catch { + break + } + } + + return { + path: filePath, + compressed: false, + beforeBytes: before, + afterBytes: before, + width: 0, + height: 0, + orientation: o, + note: `压到 ${kb(lastSize)} 仍偏大,交服务端处理` + } +} + +/** 微信 `getImageInfo().orientation` → EXIF 朝向数字(未识别按 1 = 正常)。 */ +function orientationFromMp(o?: string): number { + switch (o) { + case 'up': return 1 + case 'down': return 3 + case 'left': return 8 + case 'right': return 6 + case 'up-mirrored': return 2 + case 'down-mirrored': return 4 + case 'left-mirrored': return 5 + case 'right-mirrored': return 7 + default: return 1 + } +} + +// ── 对外入口 ──────────────────────────────────────────── + +/** + * 压到能直接上传的程度。 + * + *

**这个函数永远不抛异常**:任何一步失败都退化成"传原图、由服务端压"。 + * 学生在等着交材料,前端压缩只是优化,绝不该成为新的失败点。 + */ +export async function compressForUpload(filePath: string, plan: CompressPlan): Promise { + if (plan.skip) { + return miss(filePath, 0, 1, plan.skip) + } + try { + return IS_H5 ? await compressOnH5(filePath, plan) : await compressOnMp(filePath, plan) + } catch (e) { + return miss(filePath, 0, 1, `前端压缩没走通(${e instanceof Error ? e.message : '未知原因'}),传原图由服务端处理`) + } +} + +/** + * 释放压缩产生的临时对象地址。 + * + *

H5 的 `URL.createObjectURL` 不释放会一直占着内存(学生连传 10 张材料 + * 就是 10 份几百 KB 的 blob 留在内存里)。小程序没有这个 API,空实现。 + */ +export function releaseCompressedPath(path: string, outcome: CompressOutcome): void { + if (IS_H5 && outcome.compressed && path && path.indexOf('blob:') === 0) { + try { + URL.revokeObjectURL(path) + } catch { + // 释放失败无所谓,不能因此让上传流程报错 + } + } +} + +function miss(path: string, bytes: number, orientation: number, note: string): CompressOutcome { + return { + path, + compressed: false, + beforeBytes: bytes, + afterBytes: bytes, + width: 0, + height: 0, + orientation, + note + } +} + +function kb(n: number): string { + if (!n) return '0KB' + return n >= 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + 'MB' : Math.round(n / 1024) + 'KB' +} diff --git a/scripts/e2e-m3-file.py b/scripts/e2e-m3-file.py index 1264a3b..f01d3a1 100644 --- a/scripts/e2e-m3-file.py +++ b/scripts/e2e-m3-file.py @@ -184,6 +184,25 @@ def _portrait_phone_jpeg(w: int = 3200, h: int = 2400) -> bytes: return buf.getvalue() +def make_png(w: int = 900, h: int = 1200) -> bytes: + """ + 造一张 PNG(截图类材料的常见形态)。 + + 用来验证"格式按**字节**判、不按后缀判":同一份字节改个后缀名, + 服务端必须仍然认出它是 png。 + """ + from PIL import Image + img = Image.new("RGB", (w, h), (247, 247, 247)) + px = img.load() + # 画几行横线,模拟表格(也让它不是纯色,免得压出来只有几百字节) + for y in range(40, h, 60): + for x in range(30, w - 30): + px[x, y] = (30, 30, 30) + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + def make_big_photo(w: int = 2400, h: int = 3200) -> bytes: """造一张"大且难压"的图:随机噪点。纯色图会被压到几十 KB,测不出压缩能力。""" from PIL import Image @@ -393,8 +412,24 @@ def main() -> int: 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] 清理") + # ── 8. 客户端压缩后的上传(文件名没有后缀 / 格式按字节判)───────── + print("\n[8] 客户端压缩后上传:文件名没后缀也要收(真 bug 的回归)") + # 客户端压缩完交给 uni.uploadFile 的是 blob 地址,multipart 里的名字是一串 UUID、 + # **没有后缀** —— 而修复前服务端只看后缀,会把一张好端端的照片判成 .bin 拒掉。 + # 也就是"前端压缩一上线,学生就传不了材料"。 + sql(f"UPDATE collect_file SET deleted = 1 WHERE submission_id={sid} AND material_code='IDCARD'") + r = upload("/api/v1/mp/files/upload", tok, sid, "IDCARD", + "4c1e2f3a-0000-1111-2222-333344445555", raw[:60000], "image/jpeg") + check("**没有后缀**的真 JPEG 被接受(按字节判格式,不看后缀)", r.get("code") == 0, f"{r}") + + # 逆过来:字节是真 PNG、但后缀写着 .txt —— 按字节认作 png(在白名单里)就该收。 + # 守的是"学生把截图改名成 .txt 交上来",别等导出给机构才发现格式不对。 + png = make_png(900, 1200) + r2 = upload("/api/v1/mp/files/upload", tok, sid, "IDCARD", "身份证.txt", png, "text/plain") + check("字节是 PNG、后缀写着 .txt → 按字节认作 png 并接受", r2.get("code") == 0, f"{r2}") + + # ── 9. 清理 ────────────────────────────────────── + print("\n[9] 清理") 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}")