post: shipping an AI photo editor as a WordPress plugin (case study, EN+ZH, og+banner)
Deploy / build (push) Successful in 20s

This commit is contained in:
2026-09-18 01:31:46 +08:00
parent cdc3475f61
commit 937a114e30
6 changed files with 536 additions and 1 deletions
@@ -0,0 +1,235 @@
---
title: "把 AI 修图工具做成 WordPress 插件:22 个版本换来的经验"
description: "从单个 HTML 文件到一个真正上线的 WordPress 插件:fal.ai 异步队列、绝不能静默失败的水印、可选的名单元件,以及五个只有在真实环境里才会出现的 bug。"
pubDate: 2026-09-18
category: case-studies
tags: [wordpress, php, fal-ai, docker, ai-image, javascript]
ogImage: /og/shipping-an-ai-photo-editor-as-a-wordpress-plugin.png
banner: /banners/shipping-an-ai-photo-editor-as-a-wordpress-plugin.png
draft: false
---
几周前我[写过那个概念验证](/posts/zh/ai-furniture-compositing-with-flux-kontext/):一个
`index.html`、没有后端,两张家具照片进去,一张布置好的房间场景出来。它回答了
它本该回答的问题——*能不能保住真实的产品,只把周围的房间生成出来?*——同时留下
了一个显而易见的问题。
那篇 PoC 的结尾写着「*在决定做成完整的 WordPress 插件之前*」。这篇文章讲的就是
那个插件。它从 `0.0.1` 走到 `0.0.22`,58 个 commit,而其中几乎没有任何工作是 AI 那部分。
## 为什么这件事值得关心(写给老板,不是写给开发者)
PoC 的局限在于它活在一个 HTML 文件里,API key 就写在源码里。作为发给客户看的演示,
这没问题。但一家家具店要把这东西放到自己网站上,就不行了:
- **API key 不能出现在浏览器里。** 任何人 view-source 就能看到,然后把你的 fal.ai
额度刷光。它必须放在服务端,由一个管理员控制的设置页面来管。
- **没人想盯着生成过程。** 访客上传、等待、拿到图。如果中途挂了,店主根本不该知道。
- **顾客的照片不能泄露。** 匿名访客的原图绝不能进公开的媒体库;生成结果除非
*那位访客本人*同意分享,否则也不能公开。
- **输出必须带水印。** 否则你就是在给整个互联网提供免费 AI 修图服务。
- **店主需要拿到线索。** 一个只会产出一张漂亮图片、拿不到联系方式的 AI 工具是个玩具。
接上表单和 webhook,它就是家具生意的线索管道。
上面这五条都是**产品**需求,而每一条都比那次 AI 调用本身花的代码更多。
## 架构:真正让它跑起来的无聊部分
这是一个正常的 WordPress 插件——PHP 前缀 `hre_`、命名空间 `HRE`、激活时用
`dbDelta()` 建自定义表、自带 autoloader、运行时不依赖 Composer。有意思的决策在别处。
### 生成永远是异步的
fal.ai 的任务要 15–20 秒。PHP-FPM 的请求超时、`max_execution_time`、以及没耐心的
访客,让同步生成注定失败。所以流程是「入队 + 轮询」:
```
访客上传 → POST /uploads → 归一化 → 启动视觉分析
访客选择 → GET /prompts/{cat} → 每个风格的提示词
访客确认 → POST /jobs → fal 入队(返回 uuid)
浏览器轮询 → GET /jobs/{uuid} → status: queued | processing | done
```
访客永远不等服务商。任务行携带 fal 的请求 id,轮询要么拿到完成的结果,要么显示
真实的进度。前端是跑在 session cookie 上的小型状态机,所以中途刷新页面会回到
访客原来所在的位置。
### 归属权靠 session cookie,不靠 URL 里的 ID
匿名访客没有账号,这让授权很容易做错。我最终定下的规则是:
**每一次读取都通过调用者自己的 session hash 来解析**,绝不接受客户端传来的 ID。
- `GET /preview` 只输出调用者**自己**归一化后的上传图。它根本没有路径或 URL 参数
——别人请求它只会得到 404,因为那个 session 里没有文件。这也让私有存储路径
不会出现在任何 JSON 响应里。
- `GET /jobs/{uuid}` 是按 *session hash + uuid* 查任务的。从别的浏览器猜一个 UUID
什么也拿不到。
就这一个决策,消掉了「我改了 URL 里的 id 就看到了别人的照片」这一整类 bug。
### 水印是硬需求,所以回退逻辑绝不能静默
这个 bug 教给我的最多。水印那一步做的是 GD `imagecopyresampled` 合成。如果它失败,
一个很诱人的「有韧性」的写法是:
```php
// 别这么写
if ( ! $watermarked ) {
rename( $stage, $final ); // 直接把没水印的文件发出去
}
```
我当初就是这么写的。它完全静默、没有日志,插件会心安理得地输出没有水印的结果,
而后台还显示「水印:已开启」。店主几周后才发现,是因为看到了一张没水印的图。
修法分三部分:
1. **记录根因,而不是最近的那个症状。** `apply()` 返回的是
`hre_no_watermark_source`——一个下游错误。真正的原因是水印来源的解析链
(`site_logo` → `get_theme_mod('custom_logo')`)根本没解析到 logo。我加了一个
`block_reason()` 诊断,重新走一遍解析链,并针对每个失败分支返回**具体**信息。
2. **在后台暴露出来。** 一条常驻的 `notice-warning` 提示:水印已开启但不会被应用,
并附上具体原因。不需要去翻日志。
3. **让失败在测试时就看得见。** 一个独立探针把已知水印合成到已知底图上,并采样
预期水印矩形内的一个像素——这证明了变换路径本身是好的,从而把「功能坏了」和
「功能没配好」区分开。
**我现在遵守的规则:** 当某个「尽力而为」的分支掩盖了一个不可协商的变换时,它必须
记录具体原因**并且**在后台 UI 里告警。一个悄悄让必需功能降级的回退不是韧性——那是
一个有礼貌的 bug。
### 名单元件:默认关闭,开启后异步
名单元件是一个总开关。关闭时插件什么都不收集,向导的行为和以前完全一样。开启时,
管理员配置的字段会在生成之前变成必填,存入自定义表,并转发到 webhook。
两个值得照抄的决策:
- **字段列表由管理员定义、完全动态**——`{key, label, type, required, placeholder,
options[]}`——所以前端表单是照着配置把自己渲染出来的。默认的五项(姓名、电话、
邮箱、留言、同意)只是起点,店主可以整个替换。
- **Webhook 投递是排程的,绝不内联执行。** 访客不该为别人的接口等待:
```php
wp_schedule_single_event( time() + 5, 'hre_lead_webhook', array( $lead_id ) );
```
投递失败后按线性退避重试三次,然后标记为 `failed`。后台列表会显示状态徽章
(pending / retrying / delivered / failed)和尝试次数——这一点很重要——插件的数据
保留规则会保留**已投递**的线索,但按正常周期清理未投递的,因为一条没送出去的线索
只是躺在你数据库里的个人隐私数据。
### 服务商的急停开关必须在服务端强制执行
管理员可以停用图像服务商,并设置一段代替「生成」按钮显示给访客的文案。把复选框
变灰只是 UX;真正的强制发生在创建任务接口的开头:
```php
if ( ! (bool) Settings::get( 'fal_provided' ) ) {
return $this->error_response( 'hre_provider_disabled', $msg, 503 );
}
```
名单元件那道闸同理。浏览器端的闸是 UX,服务端的闸才是产品。
## 只有在真实安装里才会冒出来的五个 bug
这是所有「怎么做 AI 插件」的文章都不会写的一段,而 58 个 commit 里大部分都花在这。
### 1. 把后台 REST 路由注册在 `is_admin()` 里,会静默 404
只在 `is_admin()` 为真时注册后台路由,看起来是对的,实际上完全坏了:
**REST 请求里 `is_admin() === false`**,所以路由从未被注册,每次调用都返回
`404 "No route was found matching the URL and request method"`——这读起来就像是
URL 打错了,完全不像注册 bug。要在插件启动时就注册;真正的访问控制是
`permission_callback`(权限 + nonce)。
### 2. `rest_url()` 结尾没有斜杠
最近耗掉我一个下午的就是这个。`rest_url()` 返回的是
`https://example.com/wp-json/my-plugin/v1/`——结尾的斜杠属于**命名空间**,下一段
路径必须自己带一个斜杠。不带的话:
```js
const rest = window.hreAdmin.rest; // ".../wp-json/hoelee-ai-photo-remix/v1"
fetch( rest + 'admin/photos/' + id ) // ❌ ".../v1admin/photos/123"
fetch( rest + '/admin/photos/' + id ) // ✅
```
`.../v1admin/photos/123` 是一个 404,而且浏览器控制台里没有任何报错,于是 `catch`
分支触发,界面弹出笼统的「*Something went wrong.*」。它已经悄悄弄坏了**四个**接口
——照片分页、照片删除、webhook 测试按钮、线索重发。修法是在每个调用点加一个字符;
诊断却花了长得多的时间,因为这个失败模式和服务器出错完全无法区分。用两条 curl 就能
证明:正确的拼接返回 `403`(路由存在、缺 nonce),坏的那个返回 `404`。
### 3. 一个后台设置表单能抹掉另一个标签页的设置
每个后台标签页只提交自己的字段,但保存处理函数会跑完**整个**设置表。一个没被提交
的复选框,和一个被取消勾选的复选框长得一模一样,所以保存「限制」页会静默地把
「默认允许分享」写成 `false`,而保存那一页又会把整个名单元件配置清空。修法在
清洗器里,不在表单里——要区分「这次提交里没有这个键」和「有这个键但是空值」:
```php
// 本页提交了 → 用新值(清空就是清空)
// 本页没有提交 → 保留当前值
if ( array_key_exists( $key, $raw ) ) {
$clean[ $key ] = sanitize( $raw[ $key ] );
}
```
我为这件事补了一个回归测试(写入配置 → 保存另一个标签页的子集 → 断言原配置还在)。
这类 bug 只有在你有了两个标签页之后才会存在,所以它才会被发出去。
### 4. 二进制接口不能走 JSON 解析的 fetch 助手
结果下载是一个 JPEG 流。共用的 `api()` 助手对每个响都做 `res.json()`,碰到图片字节
就抛异常——而一个吞异常的 `.catch(function(){})` 把错误吃掉了,于是症状是
*「结果一直不出现」*,控制台里什么都没有。二进制接口需要自己的原始 fetch 助手:
检查 `res.ok`、只在出错时解析 JSON、成功时返回 `res.blob()`。
### 5. 在缩放之前触发的尺寸上限,等于废掉了缩放
插件会把上传图缩到工作尺寸(1920×1080 边界)。我把防解压炸弹的限制设得太低——
1 MB / 1 MP——结果每一张正常的手机照片都在**被缩放之前**就被拒了。访客看到预览刚
出现就立刻取消选中,被反馈成「缩放功能坏了」。
真正的教训是 UX,不是数字:客户端必须在**显示本地预览之前**就拒掉超限文件。
一闪就没,读起来就是 bug,哪怕底下那句提示其实是对的。现在上限是 15 MB / 50 MP,
高到正常照片一定能通过并被归一化。
## 如果重来一次我会怎么做
- **产品依赖的东西,先写失败路径。** 水印那个 bug 之所以存在,是因为我写了成功路径,
然后把失败路径藏了起来。现在我先把「这条线断了,店主会看到什么」写完,再宣告
功能完成。
- **第一天就拿真实站点测 REST 拼接。** 两条 curl(`.../v1/admin/x` → 403 对比
`.../v1admin/x` → 404)本来能在第一周就抓出四个坏接口,而不是拖到第四周。我已经
把它写进项目的 `AGENTS.md`,让它不会再犯。
- **别去猜服务商的 API 形状。** 我在 `fal.ai/api/me`(根本不存在的接口)上浪费了
时间,之后才发现正确的 key 校验是往队列接口发一个带鉴权的 `POST {}`:`401` 表示
被拒,其他都说明 key 没问题。先读真实 schema,再写客户端。
- **前端就保持两个 shortcode。** 我曾经很想把向导拆成五个 shortcode 和五个页面构建器
组件。保持成「一个应用 + 一个画廊」意味着每次 UI 迭代都是单文件改动,而正是这个
迭代速度让 22 个版本得以发出去。
## 结果
一个具备产品形态的 WordPress 插件:**`0.0.22`,共 22 次发布**,10 张自定义数据表、
异步 fal.ai 队列、服务端密钥管理、带重试 webhook 的动态名单元件、区分分享状态的
照片归档,以及一个 10 个标签页、不用碰代码就能改完所有配置的后台。
这就是演示和产品之间的区别。而我最在意的那个数字是:**58 个 commit 里,大概只有四个
和 AI 模型有关。** 其余全是那些不体面的工作,却决定了客户能不能在没有我的情况下
把这东西跑起来:校验、数据保留、权限、失败的可见性,以及一个说真话的后台页面。
---
## 想给你的生意也做一套?
我为马来西亚的中小企业做 WordPress 插件、AI 图像流水线和自托管基础设施——而且是
做成你真的能跑起来的产品,不是要你天天伺候的演示。如果你想要 AI 产品图、一套线索
收集管道,或者为你的业务定制插件,欢迎直接找我:
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **邮箱:** [[email protected]](mailto:[email protected]?subject=WordPress%20AI%20plugin%20project)
- 🌐 **网站:** [hoelee.com](https://hoelee.com)