webp fix v1
This commit is contained in:
@@ -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 因其有大陆节点)。
|
||||
@@ -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 且合并后归零,重复合并不再产生重复地点。细节见该仓库记忆。
|
||||
@@ -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`。
|
||||
@@ -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`(桩环境跳过),像素级映射由单测覆盖。
|
||||
|
||||
@@ -25,6 +25,31 @@
|
||||
编辑前用 Python 数 `b'\r\n'` 确认(`grep -c $'\r'` 不可靠)。`gofmt -l` 会把 CRLF 文件误报为未格式化。
|
||||
格式化统一走 `dev/gofmt_keep_crlf.py <files>`。
|
||||
- `internal/security/censor/censor.go`、`text_test.go` 有升级前就存在的空格缩进,别顺手 gofmt。
|
||||
- ⚠️ **`go build -overlay` 不能替换 GOMODCACHE 下的文件**(Go 直接报错拒绝)。
|
||||
要在本机类型检查依赖 cgo 的真实代码(如 `internal/storage/s3.go`),唯一可行方案:
|
||||
**`tar` 复制项目到临时目录 → 在副本里 `go mod edit -replace <cgo依赖>=../本地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`。
|
||||
|
||||
|
||||
## 内容审核
|
||||
|
||||
|
||||
+12
-1
@@ -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 }
|
||||
|
||||
+10
-3
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+154
-26
@@ -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 {
|
||||
|
||||
@@ -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` / 提交历史
|
||||
@@ -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 的**编码耗时**未实测(这是决定是否采用的关键约束)。
|
||||
+31
-4
@@ -712,16 +712,41 @@ func uploadImage(ctx context.Context, opts uploadImageOptions) error {
|
||||
now := time.Now()
|
||||
switch format {
|
||||
case "gif":
|
||||
// GIF 保持原样上传,不转换(动画 GIF 需要 cwebp 工具支持)
|
||||
// 与 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))
|
||||
// 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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user