From e4457e3da434c94d7ea49d4da6df3a039c025d6d Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 15 Oct 2026 11:41:19 +0800 Subject: [PATCH] webp fix v1 --- .workbuddy/memory/2026-09-27.md | 30 +++ .workbuddy/memory/2026-09-28.md | 13 + .workbuddy/memory/2026-09-29.md | 9 + .workbuddy/memory/2026-10-15.md | 134 +++++++++++ .workbuddy/memory/MEMORY.md | 25 ++ dev/s3stub/s3_stub.go | 13 +- dev/webpstub/webp.go | 13 +- internal/storage/encode_spec_test.go | 48 ++++ internal/storage/exif.go | 173 ++++++++++++++ internal/storage/exif_test.go | 222 ++++++++++++++++++ internal/storage/gif_test.go | 86 +++++++ internal/storage/s3.go | 180 ++++++++++++-- ...erless-migration-feasibility-2026-09-27.md | 159 +++++++++++++ outputs/webp-compression-review-2026-10-15.md | 186 +++++++++++++++ route/question/page.go | 45 +++- 15 files changed, 1297 insertions(+), 39 deletions(-) create mode 100644 .workbuddy/memory/2026-09-27.md create mode 100644 .workbuddy/memory/2026-09-28.md create mode 100644 .workbuddy/memory/2026-09-29.md create mode 100644 .workbuddy/memory/2026-10-15.md create mode 100644 internal/storage/encode_spec_test.go create mode 100644 internal/storage/exif.go create mode 100644 internal/storage/exif_test.go create mode 100644 internal/storage/gif_test.go create mode 100644 outputs/serverless-migration-feasibility-2026-09-27.md create mode 100644 outputs/webp-compression-review-2026-10-15.md diff --git a/.workbuddy/memory/2026-09-27.md b/.workbuddy/memory/2026-09-27.md new file mode 100644 index 0000000..d2ef08e --- /dev/null +++ b/.workbuddy/memory/2026-09-27.md @@ -0,0 +1,30 @@ +# 2026-09-27 工作日志 + +## Serverless 迁移可行性评估(只读,未改源码) + +用户问:TamaBox-v4 能否像 OpenListTeam/OpenList-Worker 那样改造成 TypeScript + Cloudflare Workers + D1。 + +**产出**:`outputs/serverless-migration-feasibility-2026-09-27.md` + +**结论:技术可行,但工程性价比低——是「整体重写」不是「改造」。** + +关键判据(两个项目形态根本不同): +- OpenList = **无状态文件列表 + 反代**(无会话、无可变状态、存储可降级 map/KV、无图像、无服务端模板)→ 边缘天然匹配。 +- TamaBox = **有状态多用户 Web 应用**(Session/CSRF/Redis cache、PG 关系模型、cgo 图像管线、42 个服务端模板)。 + +标定数据: +- TamaBox:34,664 行 Go(route 7645 / db 6950 / i18n 3689 / security 2675 / internal-route 1653 / template 825)。 +- OpenList 原版 9.8MB Go / 24.8k star;OpenList-Worker 21.7MB TS / 416 个 .ts(约 2.6MB 源码)/ 官方团队 / 2025-08-23 立项 → 约一年,且**代码量反超原版**。 + +P0 硬阻断(11 项)要点: +1. `internal/storage/s3.go` cgo WebP(chai2010/webp)+ `cwebp` 子进程 + `os.CreateTemp` → Workers 无 cgo/无子进程/无磁盘。 +2. `route/question/share.go` freetype + `os.ReadFile(font_path)` 渲染中文分享图 → 需整体重写,且 CPU 密集易超 Workers 限额。 +3. Session/缓存/限流/自动封禁/去重/统计全在进程内(`internal/cache`、`autoban`、`shared/duplicate.go`、`ratelimit.go`)→ 必须迁 Durable Objects。 +4. `internal/route/route.go:386-395` cache 中间件**无条件硬编码 Redis**(不判空配置)。 +5. D1=SQLite:无 PG 序列(`db.go:211-234`)、无 `FOR UPDATE`(`follow_ups.go:68`)、无 JSONB(`datatypes.JSON`)。 +6. SMTP(`internal/mail/mail.go:129-147`)Workers 禁裸 TCP → 换 HTTP 邮件 API。 +7. OTel/Uptrace gRPC 上报不可用。 + +顺带确认(对后续有用):`internal/template/minify.go` 当前**未被路由装配使用**(`route.go:99` 用的是 flamego template.EmbedFS),属死代码。`internal/security/ipinfo` 是离线签名、零 DNS。 + +建议方案:A 不迁移(推荐);B 只把静态资源+只读接口下沉边缘(EdgeOne/Workers),写路径与图像处理留 Go 源站——项目已有 EdgeOne 回源白名单脚本,该形态已在用;C 完全重写(需 2-3 个月专职 + 功能裁剪,优先 EdgeOne 因其有大陆节点)。 diff --git a/.workbuddy/memory/2026-09-28.md b/.workbuddy/memory/2026-09-28.md new file mode 100644 index 0000000..e0e7024 --- /dev/null +++ b/.workbuddy/memory/2026-09-28.md @@ -0,0 +1,13 @@ +# 2026-09-28 + +本会话的工作实际发生在**另一个仓库** `C:/OtherFiles/Github/rond-with-you`(用户指定的 data 文件夹与该备份都在那边), +TamaBox 仓库这次没动。 + +- 任务:用真机新导出的 `data/20260928_183908.rondbackup` 在本地测试环境(`conf/app.ini`)实测「rondbackup 合并」。 +- 结论:主链路生效(新增 70 地点 / 1 活动 / 134 到访,953 条到访 UUID 全命中、时间零误差,孤儿位移 0); + 查出 3 个问题(重复地点不幂等、到访冲突被活动改名放大且不收敛、21 个长沙地点坐标系差 ~660m)。 +- 笔记与报告落在该仓库:`.workbuddy/memory/2026-09-28.md`、`.workbuddy/memory/MEMORY.md`、 + `storage/.mergetest/报告-rondbackup合并实测-20260928.md`。 +- 随后按用户要求**回档数据库 → 修复前两个问题 → 复测**:改 `internal/rond/diff.go`(`DiffPlaces` 多值索引、 + 到访活动按 `ZUID_` 身份比)与 `internal/web/backup_diff.go`(多取活动 UUID),共 5 文件 +172/−22。 + 复测:到访冲突 253→6,地点新增 70→67 且合并后归零,重复合并不再产生重复地点。细节见该仓库记忆。 diff --git a/.workbuddy/memory/2026-09-29.md b/.workbuddy/memory/2026-09-29.md new file mode 100644 index 0000000..fe917f8 --- /dev/null +++ b/.workbuddy/memory/2026-09-29.md @@ -0,0 +1,9 @@ +# 2026-09-29 + +工作仍发生在 `C:/OtherFiles/Github/rond-with-you`(TamaBox 仓库未动)。 + +- 站长手动合并后追问差异页「地点 X → X」为何算冲突;复现定性:`地点` 比的是**坐标**、页面只印**名称**, + 同名不同坐标(连锁店重名 236.8km/8.3km,坐标系 750m)就渲染成看似没变化的一行。 +- 顺带发现默认「用实机」已把站内 `中国体育彩票`(src_pk=71) 从贺州搬到佛山(236.8km); + 提出 3 处待修方案(名称兜底加距离上限、地点字段补坐标、mergePlaces 的单槽位映射),等批准。 +- 细节见该仓库 `.workbuddy/memory/2026-09-29.md`。 diff --git a/.workbuddy/memory/2026-10-15.md b/.workbuddy/memory/2026-10-15.md new file mode 100644 index 0000000..75c95b9 --- /dev/null +++ b/.workbuddy/memory/2026-10-15.md @@ -0,0 +1,134 @@ +# 2026-10-15 + +## WebP 压缩链路评审(只读,未改代码) + +产出:`outputs/webp-compression-review-2026-10-15.md` + +### 关键事实(已实证) +- `internal/storage/s3.go:186` 的 `maxWidth=1920` + `Quality:80` 对**所有用途一刀切**; + 前端 `profile.html:1390` 头像 cropper 只出 512,服务端却按1920 存 → 头像实测多传 ~7倍。 +- `chai2010/webp v1.4.0` 的 `Options` **只有 Lossless/Quality/Exact**; + C 层是裸 `WebPEncodeRGB/RGBA`(`internal/src/webp.c:194-215`)= cwebp method4 默认档。 + → method/sns/filter/alpha_q/near_lossless 全都不可控。实测 method6 再省 3~6%。 +- 🔴 **`Dockerfile:16` 用 Alpine apk 但未装 `webp` 包** → 容器内无 `cwebp` + → `s3.go:201` LookPath 必失败 → GIF 动画 WebP 分支是**死代码**,线上一直原图 GIF,且无日志。 +- 🔴 `s3.go:99` / `page.go:707` 用 `image.Decode` **不读 EXIF Orientation**; + `templates/question/item.html:206,238` 的 `accept="image/*"` 是原图直传(无 canvas 归一) + → 手机竖拍照片转 WebP 后横躺。 +- 缺 `len(webp) >= len(orig)` 回退兜底(小图/线框图可能越转越大)。 +- `format=="webp"` 会二次有损重编(实测 160KB→140KB,体积收益不确定但画质丢一代)。 +- `s3.go` 对同一张图**解码两遍**(99 行取format + 181 行完整解码),4096² 下峰值 ~64MB/张。 +- `route/question/page.go:740` PutObject **缺 CacheControl**(`s3.go:158` 有 immutable)。 +- `s3.go:187` 只判 `Dx()>1920`,不限制长边 → 竖图 1080×10000(10.8M 像素 < 16.7M 上限)不缩放。 + +### 实测方法 +本机无 libwebp/GCC,Go 侧未编译。体积基线用 Pillow(绑定同一份 libwebp 1.4.0) +对 `outputs/*.png` 真实截图测 m4/m6、q75/80/85、lossless。 +`imaging.Lanczos` vs Pillow LANCZOS 的差异未纳入。 +**method=6 的编码耗时未实测** —— 这是决定是否采用的关键约束(m6 约为 m4 的 5~10 倍耗时,1C1G 需实测)。 + +### 方案分批 +- 批次 A(低风险):按用途分档(头像 512/q78、背景 1920/q80、通用 1600/q80)、 + 加大小回退、webp 透传、限长边、补 CacheControl、GIF 缺 cwebp 补 Warn。 +- 批次 B:method=6(需先Dockerfile 装 webp + 实测耗时)、EXIF 归一、统一 GIF 实现、降内存。 + +--- + +## 批次 A 已落地(同日) + +用户确认**不使用 Docker**(webp 包/cwebp 相关影响可忽略),故批次 A 全部走 Go 侧实现。 + +### 改动文件 +1. `internal/storage/s3.go`(LF) + - 新增导出 `EncodeSpec{MaxSide, Quality}` + 档位 `avatarEncodeSpec(512/78)`、 + `backgroundEncodeSpec(1920/80)`、**导出** `GeneralEncodeSpec(1600/80)` + (导出是因为 `route/question/page.go` 要传参给 `ConvertStaticImageToWebP`)。 + - `uploadPictureToS3` 新增第 4 参 `spec EncodeSpec`;3 个 Upload* 入口各自传档位。 + - `ConvertStaticImageToWebP(r, spec)`:签名变了,用 `imaging.Fit` **同时限宽高** + (原 `Resize(img, maxWidth, 0, ...)` 只限宽,1080×10000 竖图不缩放)。 + - 新增 `case "webp"`:已是 WebP 且尺寸合规 → 原样透传,不再二次有损。 + - 所有转换分支加 `len(webpData) >= len(原图)` 回退兜底。 + - `cwebp` 缺失时 `logrus.Warn`(原先完全静默);新增 `sirupsen/logrus` 导入。 +2. `route/question/page.go`(CRLF,792→794 行) + - 同款 webp 透传 + 体积兜底;用 `image.DecodeConfig` 拿尺寸避免多余全量解码。 + - PutObject 补 `CacheControl: public, max-age=31536000, immutable`。 +3. `dev/s3stub/s3_stub.go`、`dev/webpstub/webp.go`:桩签名同步。 +4. 新增 `internal/storage/encode_spec_test.go`(`fitsWithin` 边界 + 档位值断言)。 + +### 验证结果(全部实测) +- **编译**:本机无 libwebp/GCC,用**临时副本 + `go mod edit -replace github.com/chai2010/webp=../webpstub`** + 让**真实 s3.go** 参与编译(比 `s3stub` overlay 强,后者会把 s3.go 换成桩、等于没检查它)。 + `go build ./...` EXIT=0,`go vet ./...` EXIT=0。 +- **测试**:`go test ./internal/...`(带 PG)全绿,含 `internal/storage`。 +- **兜底逻辑有效性实测**(Pillow,同 libwebp 1.4.0): + - 头像真实路径(前端 cropper 出 512×512 **PNG**,quality:null 无损)→ WebP 10,486 vs PNG 103,054,用 WebP。 + - 线框图 200×200 → WebP 908 vs PNG 652,**回退 PNG**(兜底生效,这就是不加兜底会踩的坑)。 + - 纯色 1600×900 → WebP 2,672 vs PNG 6,390,用 WebP。 + +### 关键坑 +- **`go build -overlay` 不能替换 GOMODCACHE 下的文件**(Go 明确报错)。 + 唯一可行的本机方案是**复制项目到临时目录 + `go mod edit -replace`** 到本地 stub 模块。 +- 前端头像 cropper 输出 PNG(`profile.html:1391` `fileType:'image/png', quality:null`), + 背景是 jpeg q0.92 —— 服务端转 WebP 的收益主要来自这里。 +- 站点模板实际展示尺寸:头像 120px(`.tb-frame-avatar`),背景铺满宽度, + 私聊图 `max-width:75%`,提问图 100×100 缩略。→ 头像档 512 有据可依。 + +### 批次 A 未覆盖 +- method 仍锁在 4(`chai2010/webp` 只暴露 Quality/Lossless/Exact)。 +- EXIF 方向未处理、GIF 双实现未统一、同一张图仍解码两遍 —— 见下方批次 B。 + +--- + +## 批次 B1+B2 已落地(同日,用户批准) + +**用户澄清:生产是裸 Linux(不用 Docker),且能确保部署机装了 `cwebp`。** + +### 🔴 重要:原报告的 method=6 结论被实测推翻 +原报告写「m6 耗时是 m4 的 5~10 倍」——**未实测,错的**。实测(取中位数、颠倒顺序防干扰): + +| 场景 | m4 | m6 | 倍数 | 体积差 | +|---|---|---|---|---| +| 1600x4830 (7.7MP) | 1867ms | 2229ms | 1.2x | -2.9% | +| 1920x1080 背景 | 464ms | 655ms | 1.4x | -3.3% | +| 1200x800 配图 | 253ms | 492ms | 1.9x | -3.0% | +| 512x512 头像 | 60ms | 81ms | 1.4x | -3.5% | + +**结论:代价远小于预估,但收益只有 3%——1.2~1.9x CPU 换 3% 不划算,决定不做 method=6。** +(另注:Pillow 绑的是 libwebp 1.6.0,项目是 1.4.0,耗时数值仅供相对比较。) + +### B1 EXIF 方向归一 +- 新增 `internal/storage/exif.go`:`jpegOrientation`(手工解析 APP1/Exif + TIFF IFD0, + **不引新依赖**,项目原本无 EXIF 库)、`applyJPEGOrientation`(走 `orientedImage` + + `draw.Draw` 一次性拷贝,支持全部 8 种方向)。 +- 接入 `ConvertStaticImageToWebP`,**顺序必须是「先归一方向,再限边缩放」**—— + 归一会交换宽高,顺序反了会按错误的边长做缩放判断。 +- 为何不可事后修:**实测转码成 WebP 后 EXIF 被整体丢弃**(字节流无 `Exif` 标记), + 方向信息永久消失。且 `image.Decode` 与 Pillow 默认都不读 EXIF,已实证。 + +### B2 GIF 统一 +- `convertGifToAnimatedWebP` 导出为 `ConvertGifToAnimatedWebP`,`page.go` 的 GIF 分支 + 改为复用同一实现(此前 `page.go` 完全跳过转换)。 +- 新增 `maxGIFFrames = 200` + `gifFrameCount`(遍历数据块统计 Image Descriptor, + **不解码像素**,常数级开销)。`CheckPixelLimit` 只看 GIF 逻辑屏尺寸, + 500 帧 2000x2000 的 GIF 体积小但会让 cwebp 长时间吃 CPU。 +- cwebp 参数补 `-metadata none`。 +- 两处 GIF 分支都加 `len(webp) >= len(原图)` 兜底。 + +### 验证 +- `go build ./...` EXIT=0、`go vet ./...` EXIT=0、 + `go test ./internal/... ./route/...`(带 PG)**全绿**。 +- 新增测试 `exif_test.go`(JPEG 手工构造 APP1 段,覆盖大小端/越界/截断/非 JPEG; + 8 种方向逐像素校验)、`gif_test.go`(1/12/200/201 帧 + 垃圾输入)。 + +### 踩坑记录(都是我自己写错的) +1. **`gifFrameCount` 恒返回 0**:漏跳 7 字节 Logical Screen Descriptor, + `pos` 停在 LSD 首字节(宽度低位)→ 首轮命中 `default` 返回 0。 + LSD 的 packed 在 `data[10]`(=pos+4),**必须无条件 pos += 7 再按 flag 加色表**。 +2. **`buildJPEGWithOrientation` panic**:`entry[10:12]` 只有 2 字节却调 `PutUint32`。 + IFD 条目固定 12 字节,next-IFD 偏移占最后 2 字节,保持 0 即可。 +3. **`image.Config` 没有 `Dx()/Dy()`**,是 `Width`/`Height` 字段。 +4. **`color.RGBA().RGBA()` 返回 alpha 预乘值**,不能简单和 `v<<8` 比较, + 要四个通道一起比。 +5. **WebP 桩不产出字节** → 依赖真实编码器的端到端断言在本机无法成立, + 已改为 `t.Skip`(桩环境跳过),像素级映射由单测覆盖。 + diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index bc268f0..3db74d0 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -25,6 +25,31 @@ 编辑前用 Python 数 `b'\r\n'` 确认(`grep -c $'\r'` 不可靠)。`gofmt -l` 会把 CRLF 文件误报为未格式化。 格式化统一走 `dev/gofmt_keep_crlf.py `。 - `internal/security/censor/censor.go`、`text_test.go` 有升级前就存在的空格缩进,别顺手 gofmt。 +- ⚠️ **`go build -overlay` 不能替换 GOMODCACHE 下的文件**(Go 直接报错拒绝)。 + 要在本机类型检查依赖 cgo 的真实代码(如 `internal/storage/s3.go`),唯一可行方案: + **`tar` 复制项目到临时目录 → 在副本里 `go mod edit -replace =../本地stub` → 编译/测试**。 + 副本约 15MB、编译约 2~7 分钟。**别用 `s3stub` overlay 检查 s3.go 本身**——overlay 会把 s3.go + 换成桩,等于根本没检查它(只能检查调用方)。见 2026-10-15 的批次 A 落地记录。 +- 行尾确认:`internal/storage/s3.go` = LF,`route/question/page.go` = CRLF。 +- ⚠️ **依赖真实编码器的端到端断言在本机不成立**(webp 被 replace 成桩,Encode 不产出字节)。 + 这类测试要写成「检测到桩就 `t.Skip`」,否则 CI/本机表现不一致。 + +## 图片上传与编码(`internal/storage/s3.go`) + +- 三档编码规格 `EncodeSpec{MaxSide, Quality}`:头像 512/q78、背景 1920/q80、通用 1600/q80。 + 头像依据:模板最大展示 120px(`.tb-frame-avatar`),前端 cropper 输出上限 512。 + ⚠️ 档位只作用于**新上传**,线上已有图片不会被重压。 +- 所有转换分支都有 `len(webp) >= len(原图)` 回退——线框图/小图标上有损 WebP 反而更大 + (实测 200x200 线框图 PNG 652B vs WebP 908B),不加兜底会越转越大。 +- **EXIF 方向必须在转码前归一**(`exif.go` 手工解析 APP1,无外部依赖)。 + `image.Decode` 与 Pillow 默认都不读 EXIF;**转成 WebP 后 EXIF 被丢弃、方向永久丢失**。 + 顺序铁律:**先归一方向 → 再限边缩放**(归一会交换宽高)。 +- GIF 帧数上限 `maxGIFFrames = 200`:`CheckPixelLimit` 只看逻辑屏尺寸, + 几百帧的大 GIF 体积小但能让 `cwebp` 长时间吃 CPU。 +- **method=6 已评估并否决**:实测耗时只增 1.2~1.9x(不是原以为的 5~10x), + 但体积只省 ~3%,1C1G 上不划算。 +- `cwebp` 缺失时的 GIF 静默回退已加 `logrus.Warn`;生产机(裸 Linux)已确认装有 `cwebp`。 + ## 内容审核 diff --git a/dev/s3stub/s3_stub.go b/dev/s3stub/s3_stub.go index 2bc65ae..fe293d5 100644 --- a/dev/s3stub/s3_stub.go +++ b/dev/s3stub/s3_stub.go @@ -14,6 +14,15 @@ import ( // MaxUploadBytes 与真实实现保持一致(8MB 单图上限,bodylimit 中间件依赖此值语义)。 const MaxUploadBytes = 8 * 1024 * 1024 +// EncodeSpec 与真实实现的编码档位结构保持字段一致。 +type EncodeSpec struct { + MaxSide int + Quality float32 +} + +// GeneralEncodeSpec 与真实实现保持一致(route/question 引用)。 +var GeneralEncodeSpec = EncodeSpec{MaxSide: 1600, Quality: 80} + func UploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) { return "", nil } @@ -26,6 +35,8 @@ func UploadBackgroundToS3(file multipart.File, fileHeader *multipart.FileHeader) return "", nil } -func ConvertStaticImageToWebP(r io.Reader) ([]byte, error) { return nil, nil } +func ConvertStaticImageToWebP(r io.Reader, spec EncodeSpec) ([]byte, error) { return nil, nil } + +func ConvertGifToAnimatedWebP(gifData []byte) ([]byte, error) { return nil, nil } func isSupportedImage(data []byte) bool { return true } diff --git a/dev/webpstub/webp.go b/dev/webpstub/webp.go index a6a46d6..2855904 100644 --- a/dev/webpstub/webp.go +++ b/dev/webpstub/webp.go @@ -1,6 +1,7 @@ // webpstub(package webp)是 chai2010/webp 的本地编译桩:仅用于在缺少 libwebp 的 -// Windows 沙箱中通过 `go build -overlay` 对依赖 internal/storage 的包做完整 -// 类型检查。API 只覆盖项目实际用到的 Encode/Options。 +// Windows 沙箱中通过 `go build -overlay` 替换 chai2010/webp 的全部源文件, +// 从而让真实的 internal/storage/s3.go(而非桩)参与完整类型检查。 +// 仅覆盖项目实际用到的 API。 package webp import ( @@ -8,13 +9,19 @@ import ( "io" ) -// Options 与 github.com/chai2010/webp 的 Options 字段保持兼容(项目仅用 Quality)。 +// Options 与 github.com/chai2010/webp 的 Options 字段保持兼容。 type Options struct { Lossless bool Quality float32 + Exact bool } // Encode 是 webp.Encode 的签名兼容桩(类型检查期间不会被真正调用)。 func Encode(w io.Writer, m image.Image, o *Options) error { return nil } + +// DecodeConfig / Decode 保留解码入口签名,供 s3.go 的 image.Decode 分派路径类型检查。 +func DecodeConfig(r io.Reader) (image.Config, error) { return image.Config{}, nil } + +func Decode(r io.Reader) (image.Image, error) { return nil, nil } diff --git a/internal/storage/encode_spec_test.go b/internal/storage/encode_spec_test.go new file mode 100644 index 0000000..5800e10 --- /dev/null +++ b/internal/storage/encode_spec_test.go @@ -0,0 +1,48 @@ +package storage + +import "testing" + +func TestFitsWithin(t *testing.T) { + cases := []struct { + name string + w, h, maxSide int + want bool + }{ + {"未设置上限时恒为真", 8000, 8000, 0, true}, + {"负上限等同于不限制", 8000, 8000, -1, true}, + {"宽高均在限内", 512, 512, 512, true}, + {"刚好等于上限", 1600, 1200, 1600, true}, + {"仅超宽", 1921, 1080, 1920, false}, + {"仅超高(竖图长边此前不受约束)", 1080, 10000, 1920, false}, + {"双向超限", 4000, 5000, 1600, false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := fitsWithin(c.w, c.h, c.maxSide); got != c.want { + t.Fatalf("fitsWithin(%d, %d, %d) = %v, want %v", c.w, c.h, c.maxSide, got, c.want) + } + }) + } +} + +// 各用途档位必须与其展示场景相称:头像按前端 cropper 输出上限兜底, +// 背景保留铺满宽度所需尺寸,通用图取折中值。 +func TestEncodeSpecTiers(t *testing.T) { + if avatarEncodeSpec.MaxSide != 512 { + t.Errorf("avatar MaxSide = %d, want 512", avatarEncodeSpec.MaxSide) + } + if backgroundEncodeSpec.MaxSide != 1920 { + t.Errorf("background MaxSide = %d, want 1920", backgroundEncodeSpec.MaxSide) + } + if GeneralEncodeSpec.MaxSide != 1600 { + t.Errorf("general MaxSide = %d, want 1600", GeneralEncodeSpec.MaxSide) + } + for name, s := range map[string]EncodeSpec{ + "avatar": avatarEncodeSpec, "background": backgroundEncodeSpec, "general": GeneralEncodeSpec, + } { + if s.Quality <= 0 || s.Quality > 100 { + t.Errorf("%s Quality = %v, out of range (0,100]", name, s.Quality) + } + } +} diff --git a/internal/storage/exif.go b/internal/storage/exif.go new file mode 100644 index 0000000..26da465 --- /dev/null +++ b/internal/storage/exif.go @@ -0,0 +1,173 @@ +package storage + +import ( + "encoding/binary" + "image" + "image/color" + "image/draw" +) + +// EXIF Orientation(TIFF tag 0x0112)的取值含义:描述「存储的像素」要如何变换 +// 才是用户肉眼看到的方向。手机竖拍照片几乎都是 6 或 8。 +const ( + exifOrientTopLeft = 1 // 正常 + exifOrientTopRight = 2 // 水平镜像 + exifOrientBottomRight = 3 // 旋转 180 + exifOrientBottomLeft = 4 // 垂直镜像 + exifOrientLeftTop = 5 // 转置 + exifOrientRightTop = 6 // 顺时针 90 + exifOrientRightBottom = 7 // 反转置 + exifOrientLeftBottom = 8 // 顺时针 270 +) + +// jpegOrientation 解析 JPEG 的 EXIF Orientation,返回 1~8。 +// +// Go 标准库 image.Decode 不读 EXIF(Pillow 默认也不读,需显式 exif_transpose), +// 因此手机竖拍照片解出来的像素是横躺的。更严重的是转码成 WebP 时 EXIF 会被 +// 整体丢弃,方向信息随之永久消失、无法事后补救,故必须在转码前归一。 +// +// 未找到 Orientation、格式异常或非 JPEG 时返回 1(identity),调用方无需区分 +// 「没有方向信息」和「解析失败」。 +func jpegOrientation(data []byte) int { + if len(data) < 4 || data[0] != 0xFF || data[1] != 0xD8 || data[2] != 0xFF { + return exifOrientTopLeft + } + for i := 2; i+4 <= len(data); { + if data[i] != 0xFF { + return exifOrientTopLeft // 段结构损坏,放弃解析 + } + marker := data[i+1] + // 0xFF 填充字节、SOI、Standalone 标记、RSTn 均无长度字段。 + if marker == 0xFF || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD9) { + i += 2 + continue + } + segLen := int(binary.BigEndian.Uint16(data[i+2 : i+4])) + if segLen < 2 || i+2+segLen > len(data) { + return exifOrientTopLeft + } + // APP1 (0xE1),载荷形如 "Exif\x00\x00" + TIFF 块。 + if marker == 0xE1 && segLen >= 10 { + payload := data[i+4 : i+2+segLen] + if len(payload) >= 6 && string(payload[:6]) == "Exif\x00\x00" { + if o := tiffOrientation(payload[6:]); o != 0 { + return o + } + } + } + // SOS (0xDA) 之后是熵编码的像素数据,不再有元数据段。 + if marker == 0xDA { + return exifOrientTopLeft + } + i += 2 + segLen + } + return exifOrientTopLeft +} + +// tiffOrientation 从 TIFF 块的 IFD0 中取出 Orientation;取不到返回 0。 +func tiffOrientation(tiff []byte) int { + if len(tiff) < 8 { + return 0 + } + var bo binary.ByteOrder + switch { + case tiff[0] == 'I' && tiff[1] == 'I': + bo = binary.LittleEndian + case tiff[0] == 'M' && tiff[1] == 'M': + bo = binary.BigEndian + default: + return 0 + } + if bo.Uint16(tiff[2:4]) != 42 { // 42 是 TIFF 魔数 + return 0 + } + ifd := int(bo.Uint32(tiff[4:8])) + if ifd < 8 || ifd+2 > len(tiff) { + return 0 + } + entries := int(bo.Uint16(tiff[ifd : ifd+2])) + pos := ifd + 2 + for i := 0; i < entries; i++ { + if pos+12 > len(tiff) { + return 0 + } + // SHORT(3) 且 count==1 时,值内联在 4 字节 value 字段的前 2 字节。 + if bo.Uint16(tiff[pos:pos+2]) == 0x0112 && + bo.Uint16(tiff[pos+2:pos+4]) == 3 && + bo.Uint32(tiff[pos+4:pos+8]) == 1 { + v := int(bo.Uint16(tiff[pos+8 : pos+10])) + if v >= exifOrientTopLeft && v <= exifOrientLeftBottom { + return v + } + return 0 + } + pos += 12 + } + return 0 +} + +// orientedImage 把源图按 EXIF Orientation 重映射为目标坐标,供 draw.Draw +// 一次性拷贝,避免逐像素重复计算源矩形。 +type orientedImage struct { + src image.Image + sx0, sy0 int // 源矩形左上角(源图 Bounds().Min 通常为 0,但不作假设) + w, h int // 源宽高 + dstW, dstH int // 目标宽高 + ori int +} + +func (o *orientedImage) ColorModel() color.Model { return o.src.ColorModel() } + +func (o *orientedImage) Bounds() image.Rectangle { return image.Rect(0, 0, o.dstW, o.dstH) } + +// At 按 Orientation 反查源像素坐标。目标 (x,y) 命中源 (sx,sy)。 +func (o *orientedImage) At(x, y int) color.Color { + sx, sy := 0, 0 + switch o.ori { + case exifOrientTopRight: // 水平镜像 + sx, sy = o.w-1-x, y + case exifOrientBottomRight: // 旋转 180 + sx, sy = o.w-1-x, o.h-1-y + case exifOrientBottomLeft: // 垂直镜像 + sx, sy = x, o.h-1-y + case exifOrientLeftTop: // 转置 + sx, sy = y, x + case exifOrientRightTop: // 顺时针 90 + sx, sy = y, o.h-1-x + case exifOrientRightBottom: // 反转置 + sx, sy = o.w-1-y, o.h-1-x + case exifOrientLeftBottom: // 顺时针 270 + sx, sy = o.w-1-y, x + default: // exifOrientTopLeft + sx, sy = x, y + } + return o.src.At(o.sx0+sx, o.sy0+sy) +} + +// applyJPEGOrientation 按 EXIF Orientation 重绘像素;orientation 为 1 时原样返回。 +// +// 归一后方向信息已烘进像素,尺寸在 5/6/7/8 时宽高互换——调用方必须在此之后再 +// 做尺寸上限判断,否则限边会作用在横躺的像素上。 +func applyJPEGOrientation(img image.Image, orientation int) image.Image { + if orientation <= exifOrientTopLeft || orientation > exifOrientLeftBottom { + return img + } + b := img.Bounds() + sw, sh := b.Dx(), b.Dy() + if sw == 0 || sh == 0 { + return img + } + dstW, dstH := sw, sh + switch orientation { + case exifOrientLeftTop, exifOrientRightTop, exifOrientRightBottom, exifOrientLeftBottom: + dstW, dstH = sh, sw // 这四种会交换宽高 + } + + dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) + draw.Draw(dst, dst.Bounds(), &orientedImage{ + src: img, sx0: b.Min.X, sy0: b.Min.Y, + w: sw, h: sh, dstW: dstW, dstH: dstH, + ori: orientation, + }, image.Point{}, draw.Src) + return dst +} diff --git a/internal/storage/exif_test.go b/internal/storage/exif_test.go new file mode 100644 index 0000000..23f7565 --- /dev/null +++ b/internal/storage/exif_test.go @@ -0,0 +1,222 @@ +package storage + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/jpeg" + "strconv" + "testing" +) + +// itoa 避免为一个断言引入 strconv 到主逻辑。 +func itoa(n int) string { return strconv.Itoa(n) } + +// webpDimensions 直接解析 WebP 容器头(VP8/VP8L/VP8X 三种 chunk), +// 避免依赖 libwebp 桩。 +func webpDimensions(t *testing.T, data []byte) (int, int) { + t.Helper() + if len(data) < 30 { + t.Fatalf("webp too short: %d", len(data)) + } + switch string(data[12:16]) { + case "VP8 ": + // 有损:帧头内含 0x9d012a 起始码,宽高各 14 位紧跟其后。 + b := data[23:26] + if b[0] != 0x9d || b[1] != 0x01 || b[2] != 0x2a { + t.Fatalf("unexpected VP8 keyframe signature %x", b) + } + return int(binary.LittleEndian.Uint16(data[26:28]) & 0x3FFF), + int(binary.LittleEndian.Uint16(data[28:30]) & 0x3FFF) + case "VP8L": + // 无损:1 字节签名 + 14 位宽 - 1 + 14 位高 - 1 + if data[20] != 0x2f { + t.Fatalf("unexpected VP8L signature %x", data[20]) + } + bits := binary.LittleEndian.Uint32(data[21:25]) + return int(bits&0x3FFF) + 1, int((bits>>14)&0x3FFF) + 1 + case "VP8X": + w := int(data[24]) | int(data[25])<<8 | int(data[26])<<16 + h := int(data[27]) | int(data[28])<<8 | int(data[29])<<16 + return w + 1, h + 1 + default: + t.Fatalf("unknown webp chunk %q", data[12:16]) + return 0, 0 + } +} + +// buildJPEGWithOrientation 生成一张 JPEG,并在 SOI 之后插入带指定 +// Orientation 的 APP1/Exif 段。byteOrder 为 true 时用小端(II),false 用大端(MM)。 +func buildJPEGWithOrientation(t *testing.T, orientation int, littleEndian bool, w, h int) []byte { + t.Helper() + + var raw bytes.Buffer + if err := jpeg.Encode(&raw, image.NewGray(image.Rect(0, 0, w, h)), nil); err != nil { + t.Fatalf("encode base jpeg: %v", err) + } + body := raw.Bytes() + + // TIFF: header(8) + IFD0(2 + 12 + 4) + tiff := make([]byte, 0, 26) + bo := binary.ByteOrder(binary.BigEndian) + if littleEndian { + tiff = append(tiff, 'I', 'I') + bo = binary.LittleEndian + } else { + tiff = append(tiff, 'M', 'M') + } + magic := make([]byte, 2) + bo.PutUint16(magic, 42) + tiff = append(tiff, magic...) + off := make([]byte, 4) + bo.PutUint32(off, 8) // IFD0 紧跟 TIFF header + tiff = append(tiff, off...) + tiff = append(tiff, 0x01, 0x00) // IFD0 条目数 = 1 + + entry := make([]byte, 12) + bo.PutUint16(entry[0:2], 0x0112) // Orientation + bo.PutUint16(entry[2:4], 3) // SHORT + bo.PutUint32(entry[4:8], 1) // count = 1 -> 值内联在前 2 字节 + bo.PutUint16(entry[8:10], uint16(orientation)) + // entry[10:12] 是 next IFD 偏移,保持为 0 + tiff = append(tiff, entry...) + + payload := append([]byte("Exif\x00\x00"), tiff...) + seg := []byte{0xFF, 0xE1, 0, 0} + binary.BigEndian.PutUint16(seg[2:4], uint16(len(payload)+2)) + seg = append(seg, payload...) + + out := make([]byte, 0, len(body)+len(seg)) + out = append(out, body[:2]...) // SOI + out = append(out, seg...) + out = append(out, body[2:]...) + return out +} + +func TestJPEGOrientation(t *testing.T) { + cases := []struct { + name string + data []byte + want int + }{ + {"无 EXIF 的裸 JPEG 回落到 1", mustJPEG(t, 4, 4), 1}, + {"小端 Orientation=6", buildJPEGWithOrientation(t, 6, true, 4, 4), 6}, + {"大端 Orientation=8", buildJPEGWithOrientation(t, 8, false, 4, 4), 8}, + {"Orientation=1 视为无需变换", buildJPEGWithOrientation(t, 1, true, 4, 4), 1}, + {"越界值不采信", buildJPEGWithOrientation(t, 99, true, 4, 4), 1}, + {"非 JPEG 数据", []byte("not an image at all"), 1}, + {"空数据", nil, 1}, + {"截断的 JPEG", []byte{0xFF, 0xD8, 0xFF}, 1}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := jpegOrientation(c.data); got != c.want { + t.Fatalf("jpegOrientation() = %d, want %d", got, c.want) + } + }) + } +} + +func mustJPEG(t *testing.T, w, h int) []byte { + t.Helper() + var b bytes.Buffer + if err := jpeg.Encode(&b, image.NewGray(image.Rect(0, 0, w, h)), nil); err != nil { + t.Fatalf("encode: %v", err) + } + return b.Bytes() +} + +// TestApplyJPEGOrientation 用 2x3 的彩色图逐像素校验 8 种方向的映射关系, +// 并确认 5/6/7/8 会交换宽高。 +func TestApplyJPEGOrientation(t *testing.T) { + // 源图 2 宽 3 高,标记为 (x,y) 的颜色值 = y*10+x,便于追踪。 + const sw, sh = 2, 3 + src := image.NewRGBA(image.Rect(0, 0, sw, sh)) + for y := 0; y < sh; y++ { + for x := 0; x < sw; x++ { + src.Set(x, y, color.RGBA{R: uint8(y*10 + x), A: 255}) + } + } + r := func(x, y int) uint8 { return uint8(y*10 + x) } + + // 期望:目标 (dx,dy) 处的像素值 + cases := []struct { + ori int + dstW, dstH int + expect func(dx, dy int) uint8 + }{ + {exifOrientTopLeft, sw, sh, func(dx, dy int) uint8 { return r(dx, dy) }}, + {exifOrientTopRight, sw, sh, func(dx, dy int) uint8 { return r(sw-1-dx, dy) }}, + {exifOrientBottomRight, sw, sh, func(dx, dy int) uint8 { return r(sw-1-dx, sh-1-dy) }}, + {exifOrientBottomLeft, sw, sh, func(dx, dy int) uint8 { return r(dx, sh-1-dy) }}, + {exifOrientLeftTop, sh, sw, func(dx, dy int) uint8 { return r(dy, dx) }}, + {exifOrientRightTop, sh, sw, func(dx, dy int) uint8 { return r(dy, sh-1-dx) }}, + {exifOrientRightBottom, sh, sw, func(dx, dy int) uint8 { return r(sw-1-dy, sh-1-dx) }}, + {exifOrientLeftBottom, sh, sw, func(dx, dy int) uint8 { return r(sw-1-dy, dx) }}, + } + + for _, c := range cases { + t.Run(itoa(c.ori), func(t *testing.T) { + got := applyJPEGOrientation(src, c.ori) + if b := got.Bounds(); b.Dx() != c.dstW || b.Dy() != c.dstH { + t.Fatalf("bounds = %dx%d, want %dx%d", b.Dx(), b.Dy(), c.dstW, c.dstH) + } + for dy := 0; dy < c.dstH; dy++ { + for dx := 0; dx < c.dstW; dx++ { + want := color.RGBA{R: c.expect(dx, dy), A: 255} + gotR, gotG, gotB, gotA := got.At(dx, dy).RGBA() + wantR, wantG, wantB, wantA := want.RGBA() + if gotR != wantR || gotG != wantG || gotB != wantB || gotA != wantA { + t.Fatalf("ori=%d pixel(%d,%d) = RGBA(%d,%d,%d,%d), want R=%d", + c.ori, dx, dy, gotR>>8, gotG>>8, gotB>>8, gotA>>8, c.expect(dx, dy)) + } + } + } + }) + } +} + +// Orientation=1 / 越界值必须原样返回同一对象,不做无谓的整图重绘。 +func TestApplyJPEGOrientationNoop(t *testing.T) { + src := image.NewRGBA(image.Rect(0, 0, 4, 4)) + for _, ori := range []int{0, 1, 9, -1} { + if got := applyJPEGOrientation(src, ori); got != src { + t.Fatalf("orientation %d: expected same instance back", ori) + } + } +} + +// 端到端:带 Orientation=6 的 JPEG 走完整转换后,输出必须是竖图。 +// +// 依赖真实 libwebp(cgo)。在无 libwebp 的环境(webp 被替换为编译桩)下 +// Encode 不产出字节,此时跳过——像素级映射由 TestApplyJPEGOrientation 覆盖。 +func TestConvertStaticImageToWebPAppliesEXIFOrientation(t *testing.T) { + // 存成 40x30 像素但 EXIF 声明 Orientation=6,实际应显示为 30x40。 + data := buildJPEGWithOrientation(t, exifOrientRightTop, true, 40, 30) + + cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + t.Fatalf("DecodeConfig: %v", err) + } + if format != "jpeg" { + t.Fatalf("format = %s, want jpeg", format) + } + // 先确认前提:标准库读到的就是横躺的像素,不应用 EXIF。 + if cfg.Width != 40 || cfg.Height != 30 { + t.Fatalf("raw jpeg dims = %dx%d, want 40x30", cfg.Width, cfg.Height) + } + + out, err := ConvertStaticImageToWebP(bytes.NewReader(data), EncodeSpec{MaxSide: 1600, Quality: 80}) + if err != nil { + t.Fatalf("ConvertStaticImageToWebP: %v", err) + } + if len(out) == 0 || !bytes.HasPrefix(out, []byte("RIFF")) { + t.Skip("webp encoder is stubbed (no libwebp); pixel mapping is covered by TestApplyJPEGOrientation") + } + w, h := webpDimensions(t, out) + if w != 30 || h != 40 { + t.Fatalf("webp dims = %dx%d, want 30x40 (orientation 6 applied)", w, h) + } +} diff --git a/internal/storage/gif_test.go b/internal/storage/gif_test.go new file mode 100644 index 0000000..1d936e9 --- /dev/null +++ b/internal/storage/gif_test.go @@ -0,0 +1,86 @@ +package storage + +import ( + "bytes" + "image" + "image/color" + "image/gif" + "testing" +) + +// buildGIF 生成指定帧数的动画 GIF。 +func buildGIF(t *testing.T, frames int) []byte { + t.Helper() + g := &gif.GIF{} + for i := 0; i < frames; i++ { + pal := color.Palette{color.Black, color.White} + img := image.NewPaletted(image.Rect(0, 0, 4, 4), pal) + if i%2 == 1 { + for y := 0; y < 4; y++ { + for x := 0; x < 4; x++ { + img.SetColorIndex(x, y, 1) + } + } + } + g.Image = append(g.Image, img) + g.Delay = append(g.Delay, 10) + } + var b bytes.Buffer + if err := gif.EncodeAll(&b, g); err != nil { + t.Fatalf("encode gif: %v", err) + } + return b.Bytes() +} + +func TestGIFFrameCount(t *testing.T) { + cases := []struct { + name string + frames int + }{ + {"单帧", 1}, + {"多帧", 12}, + {"恰好等于上限", maxGIFFrames}, + {"超过上限", maxGIFFrames + 1}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + data := buildGIF(t, c.frames) + if got := gifFrameCount(data); got != c.frames { + t.Fatalf("gifFrameCount = %d, want %d", got, c.frames) + } + }) + } +} + +// 帧数统计必须能承受构造超大 GIF 的场景而不过度消耗内存。 +func TestGIFFrameCount_RejectsGarbage(t *testing.T) { + cases := map[string][]byte{ + "空数据": nil, + "非 GIF": []byte("not a gif at all, just some bytes"), + "仅有文件头": []byte("GIF89a"), + "截断的 LSD": []byte("GIF89a\x01\x00"), + "截断的色表": append([]byte("GIF89a"), bytes.Repeat([]byte{0xFF}, 8)...), + } + for name, data := range cases { + t.Run(name, func(t *testing.T) { + if n := gifFrameCount(data); n < 0 || n > 3 { + t.Fatalf("gifFrameCount = %d, out of sane range", n) + } + }) + } +} + +// 转换入口在帧数超限时必须返回错误(而不是把超大 GIF 丢给 cwebp)。 +// 注意本机无 cwebp 时该测试只能覆盖「帧数检查发生在 LookPath 之后」这条路径, +// 故直接针对判定逻辑断言,不依赖外部二进制。 +func TestGIFFrameLimitConstant(t *testing.T) { + if maxGIFFrames <= 0 { + t.Fatalf("maxGIFFrames = %d, must be positive", maxGIFFrames) + } + if gifFrameCount(buildGIF(t, maxGIFFrames+1)) <= maxGIFFrames { + t.Fatal("oversized GIF should be detected as over limit") + } + if gifFrameCount(buildGIF(t, 3)) > maxGIFFrames { + t.Fatal("small GIF should not be flagged as over limit") + } +} diff --git a/internal/storage/s3.go b/internal/storage/s3.go index b9de464..4e55640 100644 --- a/internal/storage/s3.go +++ b/internal/storage/s3.go @@ -23,6 +23,7 @@ import ( "github.com/chai2010/webp" "github.com/disintegration/imaging" "github.com/pkg/errors" + "github.com/sirupsen/logrus" "github.com/thanhpk/randstr" "github.com/wuhan005/gadget" @@ -49,25 +50,47 @@ func (a *aspect) check(data []byte) error { return imagecheck.CheckAspectRatio(bytes.NewReader(data), a.W, a.H, imagecheck.DefaultAspectTolerance) } +// EncodeSpec 描述一次上传的 WebP 编码档位。 +// +// 此前所有用途共用「最长边 1920 + q80」,头像这种实际只展示 120px 的用途 +// 也要按 1920 存盘,实测体积差约 7 倍(83KB vs 11KB)。故按用途分档: +// 头像跟随前端 cropper 的输出上限,背景铺满宽度,通用图取折中值。 +type EncodeSpec struct { + // MaxSide 是允许的最大边长(宽高均不超过),<=0 表示不缩放。 + MaxSide int + // Quality 是有损质量(0~100)。 + Quality float32 +} + +var ( + // avatarEncodeSpec 头像 1:1:模板中最大展示 120px, + // 前端 cropper 输出上限为 512,服务端取同值兜底直传的大图。 + avatarEncodeSpec = EncodeSpec{MaxSide: 512, Quality: 78} + // backgroundEncodeSpec 页面背景 16:9:作为整页头部铺满宽度,保留 1920。 + backgroundEncodeSpec = EncodeSpec{MaxSide: 1920, Quality: 80} + // GeneralEncodeSpec 通用图(提问配图、私聊图片):正文内展示,取 1600 折中。 + GeneralEncodeSpec = EncodeSpec{MaxSide: 1600, Quality: 80} +) + // UploadAvatarToS3 上传头像:除通用校验/转换外强制 1:1 宽高比。 func UploadAvatarToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) { - return uploadPictureToS3(file, fileHeader, &aspect{W: 1, H: 1}) + return uploadPictureToS3(file, fileHeader, &aspect{W: 1, H: 1}, avatarEncodeSpec) } // UploadBackgroundToS3 上传页面背景:除通用校验/转换外强制 16:9 宽高比。 func UploadBackgroundToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) { - return uploadPictureToS3(file, fileHeader, &aspect{W: 16, H: 9}) + return uploadPictureToS3(file, fileHeader, &aspect{W: 16, H: 9}, backgroundEncodeSpec) } // UploadPictureToS3 上传通用图片到 Cloudflare R2,自动转换为 WebP(动画 GIF 可选转换) func UploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) { - return uploadPictureToS3(file, fileHeader, nil) + return uploadPictureToS3(file, fileHeader, nil, GeneralEncodeSpec) } // uploadPictureToS3 是上传管线实现:读入 → 魔数 → (可选)比例校验 → 像素总量校验 -// → 解码 → WebP 转换 → 落盘 R2。avatar/background 的固定比例校验复用同一管线, -// 仅通过 aspect 参数区分,避免为一种用途复制一套上传代码。 -func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, asp *aspect) (string, error) { +// → 解码 → WebP 转换 → 落盘 R2。avatar/background 的固定比例校验与各自的 +// 编码档位复用同一管线,仅通过 asp/spec 参数区分,避免为一种用途复制一套上传代码。 +func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, asp *aspect, spec EncodeSpec) (string, error) { // 1. 限制读取大小,防止超大上传撑爆内存(DoS)。 // 先用 LimitReader 截断到 MaxUploadBytes+1 字节;若实际读到的字节数超过上限则直接拒绝。 limited := io.LimitReader(file, MaxUploadBytes+1) @@ -112,19 +135,38 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as // 4. 按格式转换或直接使用原图 switch format { case "gif": - webpData, err := convertGifToAnimatedWebP(fileData) - if err != nil { - uploadData = fileData // 转换失败回退原始 GIF + webpData, err := ConvertGifToAnimatedWebP(fileData) + if err != nil || len(webpData) >= len(fileData) { + uploadData = fileData // 转换失败、或转换后并不更小 → 回退原始 GIF contentType = "image/gif" } else { uploadData = webpData contentType = "image/webp" } + case "webp": + // 已是 WebP:仅当超尺寸上限时才重编(重编会再丢一代画质), + // 尺寸合规则原样上传。 + cfg, _, err := image.DecodeConfig(bytes.NewReader(fileData)) + if err == nil && fitsWithin(cfg.Width, cfg.Height, spec.MaxSide) { + uploadData = fileData + contentType = "image/webp" + break + } + webpData, err := ConvertStaticImageToWebP(fileReader, spec) + if err != nil || len(webpData) >= len(fileData) { + uploadData = fileData + contentType = mimeTypeFromFormat(format) + } else { + uploadData = webpData + contentType = "image/webp" + } default: - // 静态图片(JPEG / PNG / WebP 等)转静态 WebP - webpData, err := ConvertStaticImageToWebP(fileReader) - if err != nil { - uploadData = fileData // 转换失败回退原图 + // 静态图片(JPEG / PNG 等)转静态 WebP + webpData, err := ConvertStaticImageToWebP(fileReader, spec) + if err != nil || len(webpData) >= len(fileData) { + // 转换失败、或转换后并不比原图小(小图标/线框图/已优化 JPEG + // 上有损 WebP 可能反而更大)→ 一律沿用原图。 + uploadData = fileData contentType = mimeTypeFromFormat(format) } else { uploadData = webpData @@ -166,9 +208,10 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as return fmt.Sprintf("https://%s/%s", conf.Upload.ImageBucketCDNHost, key), nil } -// ConvertStaticImageToWebP 将静态图片(JPEG/PNG等)转换为 WebP 格式 -// 限制最大宽度为 1920px,保持比例 -func ConvertStaticImageToWebP(r io.Reader) ([]byte, error) { +// ConvertStaticImageToWebP 将静态图片(JPEG/PNG 等)按给定档位转换为 WebP 格式。 +// spec.MaxSide 同时约束宽与高(此前只限宽,竖图长边不受约束)。 +// 调用方负责比较转换前后的体积,决定是否采用转换结果。 +func ConvertStaticImageToWebP(r io.Reader, spec EncodeSpec) ([]byte, error) { // 先整体读入并校验像素总量(防解压炸弹),再完整解码。 data, err := io.ReadAll(r) if err != nil { @@ -183,26 +226,49 @@ func ConvertStaticImageToWebP(r io.Reader) ([]byte, error) { return nil, errors.Wrap(err, "decode image") } - maxWidth := 1920 - if img.Bounds().Dx() > maxWidth { - img = imaging.Resize(img, maxWidth, 0, imaging.Lanczos) + // 必须先按 EXIF 归一方向再限边缩放:手机竖拍照片解出来的像素是横躺的, + // 归一过程会交换宽高,顺序反了就会按错误的边长做缩放判断。 + img = applyJPEGOrientation(img, jpegOrientation(data)) + + b := img.Bounds() + if !fitsWithin(b.Dx(), b.Dy(), spec.MaxSide) { + img = imaging.Fit(img, spec.MaxSide, spec.MaxSide, imaging.Lanczos) } buf := new(bytes.Buffer) - if err := webp.Encode(buf, img, &webp.Options{Quality: 80}); err != nil { + if err := webp.Encode(buf, img, &webp.Options{Quality: spec.Quality}); err != nil { return nil, errors.Wrap(err, "webp encode") } return buf.Bytes(), nil } -// convertGifToAnimatedWebP 将 GIF 文件数据转换为动画 WebP(需要系统安装 cwebp 工具) -// 如果 cwebp 不可用,返回错误,调用方应回退到原始 GIF -func convertGifToAnimatedWebP(gifData []byte) ([]byte, error) { - _, err := exec.LookPath("cwebp") - if err != nil { +// fitsWithin 判断 w×h 是否已在 maxSide 的边长限制内(maxSide <= 0 时恒为 true)。 +func fitsWithin(w, h, maxSide int) bool { + if maxSide <= 0 { + return true + } + return w <= maxSide && h <= maxSide +} + +// maxGIFFrames 是允许转换的 GIF 最大帧数。imagecheck.CheckPixelLimit 只看 GIF 的 +// 逻辑屏幕尺寸(不解码帧),一个 2000×2000、500 帧的 GIF 体积可能很小却会让 +// cwebp 长时间占用 CPU 与内存,故在此显式设限。 +const maxGIFFrames = 200 + +// ConvertGifToAnimatedWebP 将 GIF 转换为动画 WebP(需要系统安装 cwebp)。 +// cwebp 不可用、GIF 损坏或帧数超限时返回错误,调用方应回退到原始 GIF。 +func ConvertGifToAnimatedWebP(gifData []byte) ([]byte, error) { + if _, err := exec.LookPath("cwebp"); err != nil { + // 回退是静默的(线上表现为动画图一直以原 GIF 存盘、体积异常偏高), + // 故在此显式告警,否则线上无法察觉 cwebp 缺失。 + logrus.Warn("cwebp not found in PATH, animated GIF will be stored as-is (install the webp package to enable animated WebP)") return nil, errors.New("cwebp not found in PATH") } + if n := gifFrameCount(gifData); n > maxGIFFrames { + return nil, errors.Errorf("too many GIF frames: %d > %d", n, maxGIFFrames) + } + tmpGif, err := os.CreateTemp("", "input_*.gif") if err != nil { return nil, errors.Wrap(err, "create temp gif") @@ -220,7 +286,10 @@ func convertGifToAnimatedWebP(gifData []byte) ([]byte, error) { defer os.Remove(tmpWebp.Name()) tmpWebp.Close() - cmd := exec.Command("cwebp", "-q", "80", "-mixed", "-mt", "-quiet", tmpGif.Name(), "-o", tmpWebp.Name()) + // -mixed:对透明帧用无损、其余有损,是动画的推荐组合。 + // -metadata none:剥离调色板外的元数据,进一步减小体积。 + cmd := exec.Command("cwebp", "-q", "80", "-mixed", "-metadata", "none", + "-mt", "-quiet", tmpGif.Name(), "-o", tmpWebp.Name()) if err := cmd.Run(); err != nil { return nil, errors.Wrap(err, "run cwebp") } @@ -232,6 +301,65 @@ func convertGifToAnimatedWebP(gifData []byte) ([]byte, error) { return webpData, nil } +// gifFrameCount 通过遍历 GIF 数据块统计 Image Descriptor 数量, +// 不解码像素,因此对超大 GIF 也是常数级开销。 +func gifFrameCount(data []byte) int { + if len(data) < 13 || string(data[:3]) != "GIF" { + return 0 + } + pos := 6 + // Logical Screen Descriptor 固定 7 字节,其 packed 字段在 data[10](即 pos+4)。 + // 无条件跳过这 7 字节后再按 Global Color Table Flag 追加调色板长度—— + // 漏跳会让 pos 停在宽度低字节上,块遍历立即失效。 + if pos+7 > len(data) { + return 0 + } + packed := data[pos+4] + pos += 7 + if packed&0x80 != 0 { + pos += 3 * (1 << ((packed & 0x07) + 1)) + } + frames := 0 + for pos < len(data) { + switch data[pos] { + case 0x21: // Extension Introducer:跳过标签与子块 + pos += 2 + pos = skipSubBlocks(data, pos) + case 0x2C: // Image Descriptor:计一帧,再跳过局部色表与 LZW 子块 + if pos+10 > len(data) { + return frames + } + frames++ + if data[pos+9]&0x80 != 0 { // Local Color Table Flag + pos += 3 * (1 << ((data[pos+9] & 0x07) + 1)) + } + pos += 10 // Image Descriptor 固定 10 字节 + if pos < len(data) { + pos++ // LZW minimum code size + pos = skipSubBlocks(data, pos) + } + case 0x3B: // Trailer + return frames + default: + return frames + } + } + return frames +} + +// skipSubBlocks 返回子块链结束后的偏移(terminator 0x00 之后的位置)。 +func skipSubBlocks(data []byte, pos int) int { + for pos < len(data) { + size := int(data[pos]) + pos++ + if size == 0 { + return pos + } + pos += size + } + return pos +} + // mimeTypeFromFormat 根据图片格式返回 MIME 类型 func mimeTypeFromFormat(format string) string { switch format { diff --git a/outputs/serverless-migration-feasibility-2026-09-27.md b/outputs/serverless-migration-feasibility-2026-09-27.md new file mode 100644 index 0000000..56b0378 --- /dev/null +++ b/outputs/serverless-migration-feasibility-2026-09-27.md @@ -0,0 +1,159 @@ +# TamaBox-v4 迁移 Cloudflare Workers + D1 可行性评估 + +- 日期:2026-09-27 +- 评估对象:将 TamaBox-v4(Go + Flamego + GORM + PG/Redis)改造为 **TypeScript + Cloudflare Workers + D1** 的 serverless 形态 +- 参照项目:[OpenListTeam/OpenList-Worker](https://github.com/OpenListTeam/OpenList-Worker) +- 范围:**只读评估,未改动任何源码** + +--- + +## 0. 结论速览 + +| 项 | 结论 | +|---|---| +| 技术可行性 | 可行 —— 没有理论上的不可能 | +| 工程性价比 | 🔴 **低** —— 是「整体重写」,不是「改造」 | +| 主要阻碍 | ① 图像管线(cgo WebP / cwebp 子进程 / freetype 中文渲染)② 进程内有状态子系统 ③ PG 专有 SQL | +| 建议 | **方案 A(不迁移)**;若必须上边缘,走**方案 B(静态+只读下沉)** | + +一句话:**OpenList 是「无状态代理」,TamaBox 是「有状态 Web 应用」,两者的边缘适配成本差一个数量级。** + +--- + +## 1. 先看清参照物:OpenList-Worker 为什么能做成 + +| 维度 | OpenList(Go 原版) | OpenList-Worker(TS) | +|---|---|---| +| 仓库体积 | 9.8 MB | 21.7 MB(**比原版还大**) | +| 语言 | Go | TypeScript | +| 代码量 | — | 416 个 `.ts`,约 2.6 MB 源码 | +| Star | 24,800 | 372 | +| 立项 | — | 2025-08-23(约一年) | +| 维护方 | 官方团队 | 官方团队 | + +即使是**官方团队 + 24.8k star** 的项目,重写一年后 Worker 版代码量反而超过原版。而更关键的是**应用形态**: + +**OpenList 的本质是无状态的文件列表 + 反代:** + +- 没有多用户会话状态(只有一个管理员) +- 没有服务端可变状态(限流 / 封禁 / 去重全都不需要) +- 存储是「可插拔后端」,官方甚至支持降级到 `map` / KV,并明说 *serverless 下禁止回退到内存* +- 没有图像处理、没有服务端模板渲染 —— 前端是现成 SPA,Worker 只负责注入 CDN 地址 + +所以它干的其实是「**把无状态代理逻辑搬到边缘**」—— 这正是边缘计算最擅长的形态。 + +**TamaBox 恰恰相反,它是一个有状态的多用户 Web 应用。** + +--- + +## 2. TamaBox 的形态画像 + +代码规模:**34,664 行 Go** + +| 模块 | LOC | 说明 | +|---|---|---| +| `route/` | 7,645 | 业务逻辑(vote 1297 / share 970 / profile 606 …) | +| `internal/db` | 6,950 | GORM 模型 + 仓储,15 张表 | +| `internal/i18n` | 3,689 | 四语言字典(以数据为主) | +| `internal/security` | 2,675 | 审核、自动封禁、IP 情报 | +| `internal/route` | 1,653 | 中间件与路由装配 | +| `internal/template` | 825 | 模板函数 / Markdown / minify | +| `internal/shared` | 758 | IP、去重、并发工具 | +| `internal/conf` | 587 | 配置(ini + 单环境变量) | +| `internal/cmd` | 482 | CLI 子命令(web/censor/uid/ban/restore) | +| `internal/storage` | 440 | R2 / OSS 上传 + WebP 转换 | +| 模板 | 42 个 html | 服务端 `html/template` 渲染 | +| 中间件 | — | Session / CSRF / Redis cache / reCAPTCHA | + +--- + +## 3. 分层可行性矩阵 + +### P0 —— 硬阻断(必须重构,无法直搬) + +| # | 阻断点 | 位置 | 为什么在 Workers 上不行 | +|---|---|---|---| +| 1 | **WebP 编码是 cgo** | `internal/storage/s3.go:16,23,192` | `chai2010/webp` 依赖 libwebp 的 C 代码(`//go:build cgo`)。Workers 是 V8 isolate,跑不了 cgo → 只能换 `@jsquash/webp`(WASM) | +| 2 | **GIF→WebP 走 `cwebp` 子进程** | `internal/storage/s3.go:200-233` | `exec.LookPath("cwebp")` + `os.CreateTemp`。Workers 无子进程、无可写磁盘 → **完全不可实现** | +| 3 | **分享图 freetype 渲染中文** | `route/question/share.go:121-131, 387-720` | `os.ReadFile(font_path)` 读磁盘 OTF + freetype 光栅化 + jpeg 编码。必须整体重写;且大图中文字形光栅化是 CPU 密集,易撞 Workers CPU 上限 | +| 4 | **进程内状态**(Session / 缓存 / 限流 / 封禁 / 去重 / 统计) | `internal/route/route.go:386-395`、`internal/cache`、`internal/security/autoban`、`internal/shared/duplicate.go`、`internal/route/ratelimit.go` | Workers 请求间无共享可变状态 → 全部要迁 Durable Objects / KV / D1,成本明显上升 | +| 5 | **cache 中间件硬编码 Redis** | `internal/route/route.go:386-395` | 无条件走 `cacheRedis.Initer()`,不判断配置是否为空。Workers 无法用原生 TCP Redis | +| 6 | **启动期副作用** | `internal/cmd/web.go:57-105`、`internal/db/db.go:58-127` | `ListenAndServe` 常驻监听 + `AutoMigrate` + `fixPostgresSequences` + backfill。serverless 无常驻启动流程 | +| 7 | **常驻 goroutine 定时任务** | `ratelimit.go:41`、`autoban.go:250`、`duplicate.go:34`、`share.go:207` | `for { sleep; cleanup }` 模式 → 需改 Cron Triggers + DO alarm | +| 8 | **PG 专有 SQL** | `db.go:211-234`(序列 `nextval/setval`)、`follow_ups.go:68`(`FOR UPDATE`)、各处 `datatypes.JSON`(JSONB) | D1 是 SQLite:**无序列、无 `FOR UPDATE`、无 JSONB** | +| 9 | **SMTP 发信** | `internal/mail/mail.go:129-147` | Workers 禁止裸 TCP 25/465/587 → 须换 Resend / SendGrid 等 HTTP 邮件 API | +| 10 | **服务端 Go 模板** | `templates/` 42 个 + `internal/template/template.go:29-229` | `html/template` 的**上下文敏感转义**是安全边界,迁到 JS 模板引擎需谨慎等价实现 | +| 11 | **OTel/Uptrace gRPC 上报** | `internal/tracing/tracing.go:15-27`、`internal/cmd/web.go:41-48` | Workers 无 gRPC、无后台 batch 上报协程 | + +### P1 —— 需换库 / 改写(有明确对应物) + +- **S3/R2 上传** → `@aws-sdk/client-s3`(R2 兼容 S3 协议,可行) +- **七牛 / 阿里云文本审核** → `fetch` 直调或 JS SDK(阿里云 `tea`/`darabonba` 运行时需重做) +- **Excel 导出 `excelize`** → `exceljs` +- **CSV 导出** `encoding/csv` → 手写(简单) +- **QR `skip2/go-qrcode`** → `qrcode` JS 库 +- **Markdown `blackfriday` + 自研 XSS 白名单** → `marked` + `DOMPurify`(**白名单规则需逐条复刻**,属安全关键) +- **i18n 的 `fmt` 风格 `%s` 占位** → 需实现等价格式化 +- **软删除 + 唯一索引语义** → SQLite 下槽位行为需重新设计 + +### P2 —— 基本可直接移植 + +- `internal/imagecheck`(纯标准库像素校验) +- `internal/captcha/math.go`(**JSON 算数题,非图片、无字体**) +- `internal/i18n` 字典(纯数据) +- CSRF(无状态 HMAC,本身可移植) +- `internal/security/ipinfo`(离线签名,零网络、零 DNS) +- `internal/shared/ip.go`(IP 解析) +- `bodylimit` / `ipblacklist` +- `embed.FS`(转 Assets / 模块) + +> 补充:`internal/template/minify.go` 当前**未被路由装配实际使用**,迁移时可直接丢弃。 + +--- + +## 4. 成本量化 + +- 源码 **34.6k 行 Go** → 等价重写预计 **40k+ 行 TS** +- `templates/` **42 个模板 + 25 个模板函数**全部重写 +- 全部测试重写(含 `route/vote/vote_stats_test.go` 这类带 DB 的门控测试) +- 参考标定:OpenList 官方团队 + 24.8k star,重写约 **一年**,产出 416 个 TS 文件 / 2.6 MB +- TamaBox 的形态比 OpenList **更「重」**(有状态 + 关系型 DB + 图像 + 模板),且为**单人维护** + +--- + +## 5. 容易被忽略的坑 + +1. **中国大陆可用性**:Workers 是海外节点。文本审核走七牛/阿里云(国内 API),海外边缘回源国内延迟高;面向大陆用户还有备案/合规问题(cf. 项目已有 `edgeone-noicp-cf-tunnel-guide`、`edgeone-origin-whitelist-guide`) +2. **D1 限额**:SQLite 单库上限(付费 10 GB)、写并发受限;`questions` / `vote_submissions` 属写密集 +3. **Workers CPU 限额**:免费 10 ms、付费 30 s/请求 且**按 CPU 计费**。分享图渲染、Excel 导出都吃 CPU +4. **Durable Objects 是付费项**,按请求 + 时长计费 → 把 Session / 限流 / 封禁搬过去后,成本模型从「一台 1C1G」变成「按量」 +5. **当前生产是 1C1G 小服务器**:迁移的收益(免运维 / 弹性)对这个体量并不显著 + +--- + +## 6. 建议方案 + +### 方案 A(推荐):不迁移 + +形态不匹配 + 单人维护 + 当前 1C1G 成本已很低。收益(免运维、弹性)不足以覆盖重写成本与风险。 + +### 方案 B(若确要上边缘,渐进式) + +只把**无状态**的部分下沉边缘: + +- **下沉**:静态资源(`static/`、模板产物)+ 只读接口(公开提问页、投票公开页、`/explore`)→ EdgeOne Pages / Workers +- **保留源站**:写路径、图像处理、审核、邮件、导出 → 继续跑 Go + +项目已有 EdgeOne 回源白名单脚本,这套「边缘 + 源站」形态其实已在用。**风险最低、可随时回退。** + +### 方案 C(完全重写) + +仅当能接受**功能裁剪**(砍分享图 / Excel 导出 / 聊天 / 投票表单)且投入 2–3 个月专职。且建议优先 **EdgeOne(腾讯云,有大陆节点)** 而非纯 Cloudflare Workers。 + +--- + +## 附:评估依据 + +- 全部源码只读探查:`internal/**`(110 个 Go 文件)、`route/**`(22 个)、`templates/**`(42 个模板) +- 依赖清单:`go.mod`(含 `chai2010/webp`、`disintegration/imaging`、`golang/freetype`、`xuri/excelize`、`uptrace/uptrace-go` 等) +- 对照数据来自 GitHub API 与 OpenList-Worker `README` / 提交历史 diff --git a/outputs/webp-compression-review-2026-10-15.md b/outputs/webp-compression-review-2026-10-15.md new file mode 100644 index 0000000..a7941be --- /dev/null +++ b/outputs/webp-compression-review-2026-10-15.md @@ -0,0 +1,186 @@ +# WebP 压缩链路评审(2026-10-15) + +范围:`internal/storage/s3.go`(上传管线)、`route/question/page.go`(提问/回答图片)、 +依赖 `github.com/chai2010/webp v1.4.0` + `disintegration/imaging v1.6.2`。 + +结论:**提升空间很大,且主要不在「调 quality」上,而在「按用途分档 + 修方向 + 兜底回退 + 编码器能力上限」四件事。** +最肥的一块:头像服务端不缩到实际展示尺寸,实测多传 ~7 倍字节。 + +--- + +## 一、现状事实(已核对代码) + +| 位置 | 现状 | +|---|---| +| `s3.go:186-195` | `maxWidth=1920` 对**所有**用途统一生效;`webp.Options{Quality: 80}`,无 `Lossless`、无 `Exact` | +| `s3.go:113-133` | GIF 走 `cwebp -q 80 -mixed -mt`;静态走 `ConvertStaticImageToWebP`;失败回退原图 | +| `s3.go:200-203` | `cwebp` 不在 PATH 时静默回退原始 GIF(无日志) | +| `route/question/page.go:713-734` | GIF **完全不转换**,直接原样上传 —— 与 `s3.go` 行为不一致 | +| `route/question/page.go:740-748` | 该分支 PutObject **未设 CacheControl**(`s3.go` 有 immutable) | +| `route/question/page.go:721` | 提问图片与头像共用同一函数,同样 1920/80 | + +`chai2010/webp` 的 `Options` 只有三个字段: + +```go +type Options struct { Lossless bool; Quality float32; Exact bool } +``` + +即 **method(0-6)、sns_strength、filter_strength、alpha_quality、near_lossless 全部不可控**。 +C 层是裸 `WebPEncodeRGB` / `WebPEncodeRGBA`(`internal/src/webp.c:194-215`),等价于 cwebp 默认档 +(method=4)。 + +--- + +## 二、体积实测(本机 Pillow 绑定同一份libwebp 1.4.0,样本取自 outputs/ 真实截图) + +### 2.1 method 的影响(q80 固定) + +| 样本 | 原 PNG | m4 q80(=现状) | m6 q80 | m6 q75 | m6 q85 | +|---|---|---|---|---|---| +| tb-prod-test-qr.png | 343,674 | 31,856 | **29,916** (-6.1%) | 24,274 (-23.8%) | 37,448 | +| prod-tamakyi-snrat-check.png | 635,502 | 151,888 | **147,498** (-2.9%) | 126,766 (-16.5%) | 172,434 | +| admin6-cdn-check.png | 375,873 | 51,202 | **48,664** (-5.0%) | 41,122 (-19.7%) | 59,222 | + +→ **method=6 平均再省 3~6%**,纯 CPU 换体积,无画质损失。 + +### 2.2 按用途分档的收益(当前完全没分档) + +模拟一张典型手机照片: + +| 用途 | 现状(1920/q80/m4) | 建议档位 | 建议档实测 | 节省 | +|---|---|---|---|---| +| **头像**(前端 cropper 输出512) | 83,366 | 512 / q78 / m6 | 11,390 | **-86%** | +| 头像(列表缩略) | — | 320 / q78 | 4,966 | -94% | +| 背景16:9 | 26,222 | 1920 / q80 | 26,222 | — | +| 正文配图 | — | 1600 / q80 | — | -20~25% | + +注意:前端 `profile.html:1390` 头像 `maxWidth:512`,但**服务端把 1920 传给编码器**, +意味着直传(绕过前端 cropper)的大图会被以 1920 全尺寸存下,而头像展示最大也就 256px。 + +### 2.3 有损 vs 无损(透明图) + +| 类型 | 体积 | +|---|---| +| 带 alpha 图 lossy q80 | 49,636 | +| 带 alpha 图 lossless | 253,128 | + +→ 无脑lossless 是灾难(5x),**不要**走这条路。但存在反向问题: + +### 2.4 「编码后不一定更小」 + +对已经压得很狠的小 PNG/图标,WebP 有损可能**比原图还大**(WebP 对纯色/线框图有损效率差)。 +**当前代码没有任何 `len(webpData) > len(fileData)` 的兜底判断** —— 转换失败会回退,但转换「成功却更大」不会回退。 + +### 2.5 重复编码(代际损失) + +已是 WebP 的图再走一次管线 → 二次有损。实测 160,896 → 139,866(体积反而小了 13%, +因为 q82→q80),**画质白白损失一代,体积收益不确定**。 + +--- + +## 三、问题清单 + +### P0 🔴 EXIF 方向未处理 —— 手机竖拍 JPEG 会横躺 + +`image.Decode`(`s3.go:99`、`page.go:707`)**不读 EXIF Orientation**。 +前端 cropper 走 canvas 会正确应用方向,但 `templates/question/item.html:206,238` 的 +`accept="image/*"` 是**原始文件直传**,没有 canvas 归一化 → 手机竖拍照片转 WebP 后躺倒。 + +这不是压缩问题,但和转换管线同源,且用户可见。修EXIF 时顺带可在转码前 +`image.NewRGBA` 重绘归一,比让 libwebp 处理更可控。 + +### P1 🟠 按用途分档缺失(最大体积收益) + +`s3.go:186` 的 `maxWidth=1920` + `Quality:80` 一刀切。已有 `aspect` 参数做比例分档的先例 +(`UploadAvatarToS3` / `UploadBackgroundToS3`),扩展成 `{maxW, maxH, quality}` 成本极低。 +预期头像场景存储与流量降 **~85%**。 + +### P1 🟠 method 不可控,白丢 3~6% + +`chai2010/webp` 不暴露 method。三条路: +- (a) `cwebp -m 6`(GIF 分支已经在用 `cwebp`,静态图也可统一走 CLI,还能顺带拿到 + `-alpha_q`/`-sns`/`-near_lossless`/`-pass`)—— 需保证 Dockerfile 装 webp 包(**待核**); +- (b) 换 `gen2brain/go-webp`,API 直接暴露完整 `WebPConfig`; +- (c) 维持现状。 + +⚠️ method=6 编码耗时约为 method=4 的 **5~10 倍**。1C1G 生产机上批量上传场景 +需实测编码耗时再定,不能只看体积。 + +### P1 🟠 无「转换后更大」的兜底 + +`s3.go:125-132`、`page.go:721-733` 都缺`if len(webp) >= len(orig) { 用原图 }`。 +小图标/线框图/已优化 JPEG 场景会越转越大。 + +### P1 🟠 已是 WebP 还再编一代 + +`format == "webp"` 走 default 分支二次有损。建议:已是 WebP 且宽度合规 → 直接透传, +只在超宽时才 resize 后重编。 + +### P2 🟠 GIF 转换在生产上从未生效(已实证) + +`Dockerfile:16` 是 Alpine `apk add tzdata`,**未安装 `webp` 包** → 生产容器内没有 `cwebp`。 +`s3.go:201` 的 `exec.LookPath("cwebp")` 必然失败 → GIF 全部走 `uploadData = fileData` 原图上传, +且**无任何日志**。即`s3.go:115` 的动画 WebP 分支是死代码,线上动画图一直是原 GIF。 + +(另外 `page.go:714-718` GIF 不转,与 `s3.go` 不一致;但因为上述原因,两条路实际殊途同归。) + +若要启用:Dockerfile 加 `RUN apk add --no-cache webp`(Alpine 包名即 `webp`,含 `cwebp`/`img2webp`/`gif2webp`), +并在 `LookPath` 失败时补 Warn。 + +### P2 🟠 GIF 分支其余问题 + +`s3.go:223` `cwebp -q 80 -mixed -mt` 缺 `-m 6`、`-lossless`、`-metadata none`, +也缺 GIF 帧数/总像素上限(`imagecheck.CheckPixelLimit` 只看逻辑屏幕宽高, +500 帧 2000×2000 的 GIF 不会被拦)。 + +### P2 走 cwebp CLI 需先改 Dockerfile + +若采纳批次 B 的 method=6 方案 (a)(静态图统一走 CLI),必须先在 Dockerfile 装 webp 包, +否则会把「能用但慢」变成「不能用的静默回退」。 + +### P2 🟠 峰值内存 + +`s3.go:95-99` 解码一次拿format,`s3.go:181` 又完整解码一次,**同一张图解码两遍**; +`imaging.Resize` 再全量分配。4096×4096 上限下 RGBA 峰值约 64MB/张, +并发上传时 1C1G 有 OOM 风险。可合并为「一次解码 → 校验 → resize → 编码」。 + +### P2 提问图片分支缺 CacheControl + +`page.go:740-748` 未设 `CacheControl`,与 `s3.go:158` 不一致(后者 immutable 一年)。 + +### P2 maxWidth 只限宽不限高 + +`s3.go:187` 只判 `Dx() > 1920`。竖图 1080×10000(像素总量在4096² 内?1080*10000 += 10.8M < 16.7M,通过)→ 不缩放,长边 10000px 下发,首屏极慢。 + +--- + +## 四、建议的落地方案(待批准,未动代码) + +### 批次 A(低风险,先做) +1. `ConvertStaticImageToWebP` 增加参数:`maxWidth int, quality float32`; + `uploadPictureToS3` 按 `aspect` 传入不同档位(头像 512/q78、背景 1920/q80、通用 1600/q80)。 +2. 加 `len(webp) >= len(fileData) && format != "gif"` 回退原图。 +3. `format == "webp"` 且尺寸合规 → 直接透传,不重编。 +4. maxWidth 改为同时约束长边(`maxSide`)。 +5. `page.go` PutObject 补 `CacheControl`。 +6. GIF 分支加`cwebp` 缺失时的 Warn 日志。 + +### 批次 B(需实测/依赖改动) +7. method=6:先在 dev 环境用真实图测编码耗时,确认 1C1G 可接受再上; + 路线倾向 (a) 统一走 `cwebp`(Dockerfile 需确认 webp 包,见下)。 +8. EXIF 方向归一(转码前重绘)。 +9. 统一 GIF 双实现。 +10. 合并重复解码降内存。 + +### 待核实(已全部核实完毕) +- ✅ `Dockerfile` 用 Alpine apk,**未装 `webp`** → `cwebp` 线上不存在,GIF 转换是死代码。 + +--- + +## 五、当前无法验证的部分 + +- 本机无 libwebp/GCC,**未编译运行 Go 侧实测**。体积数据来自 Pillow 绑定同一份 + libwebp 1.4.0、同样 quality/method 组合,作为体积基线可信;但 + **`imaging.Resize` 的 Lanczos 与 Pillow LANCZOS 的输出差异未纳入实测**,实际体积会有小幅出入。 +- method=6 的**编码耗时**未实测(这是决定是否采用的关键约束)。 \ No newline at end of file diff --git a/route/question/page.go b/route/question/page.go index fac4247..fa7b366 100644 --- a/route/question/page.go +++ b/route/question/page.go @@ -712,16 +712,41 @@ func uploadImage(ctx context.Context, opts uploadImageOptions) error { now := time.Now() switch format { case "gif": - // GIF 保持原样上传,不转换(动画 GIF 需要 cwebp 工具支持) - uploadData = originalData - contentType = "image/gif" - uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt) + // 与 storage.UploadPictureToS3 走同一实现,避免同一张 GIF 在两个入口 + // 得到不同格式/体积(此前此处完全跳过转换)。 + webpData, err := storage.ConvertGifToAnimatedWebP(originalData) + if err != nil || len(webpData) >= len(originalData) { + if err != nil { + logrus.WithError(err).Warn("animated WebP conversion failed, falling back to original GIF") + } + uploadData = originalData + contentType = "image/gif" + uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt) + } else { + uploadData = webpData + contentType = "image/webp" + uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), ".webp") + } default: - // JPEG / PNG 等静态图片转换为 WebP - webpData, err := storage.ConvertStaticImageToWebP(bytes.NewReader(originalData)) - if err != nil { - // 转换失败时回退为原始数据 - logrus.WithError(err).Warn("WebP conversion failed, falling back to original format") + // JPEG / PNG 等静态图片转换为 WebP。这里只取 DecodeConfig(不波纹解像素), + // 避免在像素量已经 CheckPixelLimit 校验过的超大图上再解码一遍。 + cfg, _, cfgErr := image.DecodeConfig(bytes.NewReader(originalData)) + // 已是 WebP 且尺寸合规 -> 原样上传:再编码会白丢一代有损画质。 + if format == "webp" && cfgErr == nil && + cfg.Width <= storage.GeneralEncodeSpec.MaxSide && cfg.Height <= storage.GeneralEncodeSpec.MaxSide { + uploadData = originalData + contentType = "image/webp" + uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt) + break + } + + webpData, err := storage.ConvertStaticImageToWebP(bytes.NewReader(originalData), storage.GeneralEncodeSpec) + if err != nil || len(webpData) >= len(originalData) { + // 转换失败,或转换后并不比原图小(小图标/线框图/已优化 JPEG 上有损 WebP + // 可能反而更大)—— 一律沿用原图。 + if err != nil { + logrus.WithError(err).Warn("WebP conversion failed, falling back to original format") + } uploadData = originalData contentType = contentTypeFromFormat(format) uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt) @@ -743,6 +768,8 @@ func uploadImage(ctx context.Context, opts uploadImageOptions) error { Body: bytes.NewReader(uploadData), ContentLength: aws.Int64(int64(len(uploadData))), ContentType: aws.String(contentType), + // key 含纳秒时间戳且内容不可变,与 storage.uploadPictureToS3 保持一致的 immutable 强缓存。 + CacheControl: aws.String("public, max-age=31536000, immutable"), }); err != nil { return errors.Wrap(err, "put object") }