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:
@@ -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<string, unknown> | null
|
||||
@@ -489,11 +497,16 @@ async function loadFiles() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 选文件 → 上传 → 立刻处理。
|
||||
* 选文件 → (前端压)→ 上传。
|
||||
*
|
||||
* <p>**为什么上传后马上处理**:学生要能看到"压到了多大、叫什么名字"才算完成。
|
||||
* 若把处理留到提交时,学生上传时会以为"传上去了",提交时才发现体积超标 ——
|
||||
* 而那时他可能已经在回家的路上。
|
||||
* <p>**为什么要在前端压**:上传这一步发生在服务端压缩**之前** —— 服务端压得再好,
|
||||
* 学生的 3MB 也已经从流量里走完了。前端先压到几百 KB,上传时间大致降到 1/4。
|
||||
* 压不了的(证件照、截图、需要转正的小程序照片)就原样上传,由服务端兜底 ——
|
||||
* **前端压缩只是优化,不该成为新的失败点**(见 utils/image-compress.ts)。
|
||||
*
|
||||
* <p>**不再调 `/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<void>({
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user