给Rhex论坛做了一个图片自动压缩插件

给Rhex论坛做了一个图片自动压缩插件

从小就很喜欢折腾论坛程序。

早些年接触最多的是 Discuz!、phpwind 这些经典 PHP 论坛。从下载安装到更换模板、添加插件、调整版块和用户组权限,最终也没什么人气,只是沉浸于搭建过程。

最近发现了 Rhex ,又产生了部署论坛的兴趣。项目提供 Docker 镜像,Web、Worker 和初始化任务的职责比较清楚;上传既可以使用本地目录,也能继续接入 S3、OSS 等对象存储。对已经使用 1Panel 和 Docker Compose 管理服务的人来说,部署和后续维护都比较直观。

程序本身的完成度已经很高,不过使用时很快遇到了一个比较具体的需求:用户上传的图片最好能在写入存储前自动压缩

如果只是在某个上传接口里调用一次图片处理库,这件事并不复杂。但我更希望把它做成一个独立插件:后台可以配置压缩策略、尺寸、质量和输出格式;插件关闭后恢复原有上传流程;以后升级或调整参数时,也不需要继续修改论坛业务代码。

最终,这个需求不只产生了一个插件,还顺带补上了 Rhex 上传扩展点、修复了一个 PostgreSQL 上传错误,并解决了修改版生产镜像的依赖问题。

当然,上述全程离不开 AI 的支持。

如何压缩图片

Rhex 已经有一套插件系统,upload provider 也提供了 uploadFile()。不过它的用途是让插件接管最终存储,而不是在保存之前修改图片内容。

如果直接使用 uploadFile() 做图片压缩,插件就需要同时负责文件命名、配额、哈希去重、上传记录,以及 local、S3、OSS 等存储逻辑。这样不仅重复实现宿主已有能力,以后 Rhex 调整上传流程时,插件也很容易跟不上。

另外,现有的 upload.file.before action hook 可以在上传前执行动作,但不能把新的图片 Buffer 交回宿主,因此也无法承担压缩或转码工作。

比较合适的方式,是让插件只处理图片,把最终存储继续交给 Rhex。

给 upload provider 增加 transformFile()

我在 Rhex 宿主中增加了一个可选的 transformFile() hook。处理顺序变成:

上传格式与原始大小校验
-> Rhex 水印
-> 插件压缩、缩放或转码
-> 宿主重新检测真实 MIME、文件大小和 SHA-256
-> 去重与上传配额
-> local / S3 / OSS 保存

多个 upload provider 可以按照顺序串行处理图片。插件返回新的 Uint8Array 后,宿主会重新检查真实格式、文件大小和哈希,不会直接信任插件返回的文件信息。

没有实现 transformFile() 的旧插件不受影响;provider 返回 nullundefined 时,也会继续走原有上传流程。

这套转换阶段目前接入了正文和评论图片、封面、私信图片、图片附件、远程图片本地化,以及站点图标、Markdown 表情和水印 Logo 等后台图片素材。

图片自动压缩插件

宿主扩展完成后,插件本身就可以只关注图片处理。

插件设置界面

目前的 v1.0.3 支持:

  • 在后台启用或停用自动压缩;
  • 仅调整编码质量,或限制尺寸并调整质量;
  • 保持原格式,或统一转换为 JPEG、WebP、AVIF;
  • 设置质量、最大宽度、最大高度和最小处理体积;
  • 仅在处理结果更小时采用新图片;
  • 设置 JPEG 透明区域填充颜色;
  • 按正文、评论、封面、私信、图片附件、头像及后台素材选择应用范围。

图片处理使用 Sharp 完成。GIF 和 SVG 默认原样保留;WebP 与 AVIF 如果检测到多帧,也会跳过转换,避免把动画变成静态图。

压缩采用 fail-open 方式:单张图片解码或编码失败时,插件记录错误并返回 null,让宿主继续保存原图,而不是让整个上传请求失败。禁用插件后,也会直接恢复 Rhex 原有上传行为。

部署时遇到的两个额外问题

插件和宿主扩展在本地检查通过后,我基于自己的 Fork 构建了生产镜像。真正部署时又遇到了两个和图片压缩本身无关的问题。

setup 容器缺少 cross-env

Rhex 的生产依赖层使用 pnpm install --prod,但 setup:prod 运行时需要的 cross-env 原本位于 devDependencies,所以修改版镜像启动 setup 容器时提示:

sh: 1: cross-env: not found

Worker 的启动命令同样依赖 tsx。最终将 cross-env 纳入生产依赖,并在 Docker 构建阶段显式检查 cross-envtsx 是否可执行。

PostgreSQL advisory lock 参数类型错误

镜像可以启动后,前台上传图片仍然返回 500。即使禁用插件也一样失败,这说明问题不在图片压缩逻辑。

日志最终指向:

function pg_advisory_xact_lock(bigint, bigint) does not exist

Rhex 在上传配额事务里使用了双参数 advisory lock。Prisma 将两个 JavaScript 数字参数绑定为 bigint,但 PostgreSQL 支持的签名是一个 bigint,或者两个 integer,并不存在两个 bigint 的版本。

将两个锁键显式转换为 integer 后,上传恢复正常。这个问题属于通用上传错误,与插件是否启用无关。

向 Rhex 上游提交了三个 PR

为了让每项修改都可以单独审核,我没有把所有改动堆在一个 PR 里,而是从 Rhex 官方 main 分支分别整理了三个独立提交:

  1. PR #23:修复上传配额 advisory lock 参数类型
  2. PR #24:补齐生产镜像运行时 CLI 依赖
  3. PR #25:为 upload provider 增加 transformFile() hook

其中 PR #25 是图片自动压缩插件的直接宿主依赖;PR #23 和 PR #24 是开发、构建及生产部署过程中发现的通用问题。

三个 PR 都附带了对应验证结果,彼此之间没有互相夹带修改。这样即使上游只接受其中一部分,也不会影响其他问题单独讨论。

开源发布

插件已经以 MIT License 开源:

安装包 SHA-256:

de3fa4fe73128ec5715a3f48d3677b2c3b4a46a5bc1a3d62962a8e9de0443975

当前未经修改的 Rhex 1.1.0 官方镜像还不能直接使用这个插件。必须先使用包含 PR #25 transformFile() 扩展的宿主构建。即使 PR 已经合并,也要等相应代码进入正式版本并重新发布官方镜像后,才能视为官方镜像原生支持。

临时需要自行构建时,可以参考我的 Rhex Fork ,插件仓库中也保留了对应基线的宿主补丁。

验证情况

这次实际完成了以下检查:

  • TypeScript 类型检查;
  • ESLint;
  • Rhex 97 项完整测试;
  • Next.js 生产构建,145 个静态页面生成成功;
  • Web、Worker、setup 容器生产部署;
  • 本地存储和 Rhex 现有对象存储流程;
  • JPEG、PNG、WebP、AVIF 压缩与转码;
  • 损坏图片处理失败后回退原图;
  • 动画 WebP / AVIF 跳过处理;
  • 插件禁用后恢复原上传行为;
  • GitHub Release 安装包公开下载与哈希校验。

这次工作同样主要是在 Codex 的协助下完成。从一开始看 Rhex 插件系统、确定宿主扩展边界,到构建镜像、分析日志、拆分 PR、整理开源仓库,过程比单纯“加一个图片压缩函数”复杂不少。

后续

插件后续的版本、兼容性变化和安装包会继续发布在 GitHub Releases。自己部署的 Rhex 实例里也会保留一个长期更新帖,用来同步新版本、宿主要求、升级注意事项和文件校验值。

如果 PR #25 后续被 Rhex 官方合并,我会优先让插件迁移到正式发布的宿主接口,并去掉临时补丁依赖。现阶段则继续观察不同图片格式、超大图片和高并发上传时的 CPU 与接口耗时,再根据实际使用情况调整默认参数。

评论