校园公益平台要加一个证书模块:学生完成项目后,系统自动生成一张带编号的 PDF 证书, 走"生成 → 提交审核 → 审核 → 发布 → 撤回 → 重新生成"的全状态机。 后端是 Go,PDF 引擎选了 gopdf。开工前列风险,我把中文字体排在第一位, 事后证明它确实是第一位,而且比预想的更细。

底层方案能不能成立,要在写业务代码之前用最小程序验证;否则整套模块写完才发现,代价是一周。

01 · 坑一:字体没加载成功,它不报错

gopdf 不内置 CJK 字体。字体没加载对的时候,它不会 panic,而是安静地把中文渲染成方块—— 接口照样返回 200,测试照样全绿。这类"静默失败"最危险,因为常规验收根本验不出来。 对策写在开工回执里:PDF 引擎一做完,立刻把文件下载下来用 strings 查嵌入的字体名, 确认是 CJK 字体而不是 Helvetica,不等到最后一起验。

02 · 坑二与坑三:.ttc 与 CFF

生产镜像基于 Alpine。能通过 apk 装到的两个中文字体,font-noto-cjkfont-wqy-zenhei都是 .ttc 集合文件,而 gopdf 只吃单个 .ttf。这是坑二。 坑三更隐蔽:Noto CJK 用的是 CFF/OTF 轮廓,gopdf 根本不支持,就算把它从集合里拆出来也没用。

最终方案:文泉驿正黑是 TrueType glyf 轮廓,实测 'glyf' in font 为 True。 在 Docker 构建期用 fonttools 从 wqy-zenhei.ttc 抽出第 0 号字体存成 .ttf, 单独做成一个 font-builder 阶段,运行镜像只复制那一个文件:

FROM alpine:3.20 AS font-builder
RUN apk add --no-cache font-wqy-zenhei py3-fonttools \
 && python3 -c "from fontTools.ttLib import TTCollection; \
    TTCollection('/usr/share/fonts/wqy-zenhei/wqy-zenhei.ttc') \
      .fonts[0].save('/fonts/wqy-zenhei.ttf')"

# 运行镜像
COPY --from=font-builder /fonts/wqy-zenhei.ttf /app/fonts/wqy-zenhei.ttf

全程走 apk 镜像源,不依赖外网下载字体文件,构建在内网机器上也能复现。

03 · 为什么先写 32 行

以上三个坑,如果是在证书模块写完之后才碰到,意味着数据模型、状态机、11 个接口和两个前端页面 全部要等字体问题解决才能验收。所以我在动业务代码之前,单独开了一个目录, 用一个最小 Go 程序把"抽字体 → gopdf 渲染中文 → 转 PNG 看一眼"跑通:

pdf := gopdf.GoPdf{}
pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4Landscape})
pdf.AddPage()
if err := pdf.AddTTFFont("cn", "/work/wqy-zenhei.ttf"); err != nil {
    panic(err)
}
_ = pdf.SetFont("cn", "", 28)
pdf.SetXY(80, 120)
pdf.Cell(nil, "项目结项证书")
_ = pdf.WritePdf("/work/fonttest.pdf")

32 行,半天时间,换来的是一行 Dockerfile 和一个确定的答案。 这不是什么高深方法,只是把最不确定的一层放到最前面验证,让后面的一周不用赌。

04 · 顺带发现的三件事

  • 存储要有退路。OSS 未配置时对象存储客户端返回 nil,原代码直接 503。改成双路存储:OSS 可用走 OSS,不可用落本地目录并挂持久卷;不挂卷的话每次重建容器,已发出证书的 PDF 全没了,数据库记录还在但下载 404。
  • 撤回后旧链接仍可用。学生在证书被撤回前拿到的下载链接,在令牌有效期内还能下载——等于没撤。修法是令牌里带上证书 ID,代理下载在读文件前重新查一次当前状态,实测旧链接从 200 变 403。
  • 并发不靠业务层判断兜底。批量发证的"已存在就跳过"天然有竞态:取号走事务行锁 SELECT ... FOR UPDATE,再在数据库加条件唯一索引让重复插入被挡下。实测并发调两次生成,证书数不翻倍、编号无重复。

05 · 收尾

验收表最后一行是"打开 PDF 看中文:无方块,编号与库中一致"。 它看起来是最朴素的一项,却是整个模块里最早被验证、也最不可能翻车的一项—— 因为它是第一个被验证的。