把 AI 修图工具做成 WordPress 插件:22 个版本换来的经验
几周前我写过那个概念验证:一个
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 合成。如果它失败,
一个很诱人的「有韧性」的写法是:
// 别这么写
if ( ! $watermarked ) {
rename( $stage, $final ); // 直接把没水印的文件发出去
}
我当初就是这么写的。它完全静默、没有日志,插件会心安理得地输出没有水印的结果, 而后台还显示「水印:已开启」。店主几周后才发现,是因为看到了一张没水印的图。
修法分三部分:
- 记录根因,而不是最近的那个症状。
apply()返回的是hre_no_watermark_source——一个下游错误。真正的原因是水印来源的解析链 (site_logo→get_theme_mod('custom_logo'))根本没解析到 logo。我加了一个block_reason()诊断,重新走一遍解析链,并针对每个失败分支返回具体信息。 - 在后台暴露出来。 一条常驻的
notice-warning提示:水印已开启但不会被应用, 并附上具体原因。不需要去翻日志。 - 让失败在测试时就看得见。 一个独立探针把已知水印合成到已知底图上,并采样 预期水印矩形内的一个像素——这证明了变换路径本身是好的,从而把「功能坏了」和 「功能没配好」区分开。
我现在遵守的规则: 当某个「尽力而为」的分支掩盖了一个不可协商的变换时,它必须 记录具体原因并且在后台 UI 里告警。一个悄悄让必需功能降级的回退不是韧性——那是 一个有礼貌的 bug。
名单元件:默认关闭,开启后异步
名单元件是一个总开关。关闭时插件什么都不收集,向导的行为和以前完全一样。开启时, 管理员配置的字段会在生成之前变成必填,存入自定义表,并转发到 webhook。
两个值得照抄的决策:
- 字段列表由管理员定义、完全动态——
{key, label, type, required, placeholder, options[]}——所以前端表单是照着配置把自己渲染出来的。默认的五项(姓名、电话、 邮箱、留言、同意)只是起点,店主可以整个替换。 - Webhook 投递是排程的,绝不内联执行。 访客不该为别人的接口等待:
wp_schedule_single_event( time() + 5, 'hre_lead_webhook', array( $lead_id ) );
投递失败后按线性退避重试三次,然后标记为 failed。后台列表会显示状态徽章
(pending / retrying / delivered / failed)和尝试次数——这一点很重要——插件的数据
保留规则会保留已投递的线索,但按正常周期清理未投递的,因为一条没送出去的线索
只是躺在你数据库里的个人隐私数据。
服务商的急停开关必须在服务端强制执行
管理员可以停用图像服务商,并设置一段代替「生成」按钮显示给访客的文案。把复选框 变灰只是 UX;真正的强制发生在创建任务接口的开头:
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/——结尾的斜杠属于命名空间,下一段
路径必须自己带一个斜杠。不带的话:
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,而保存那一页又会把整个名单元件配置清空。修法在
清洗器里,不在表单里——要区分「这次提交里没有这个键」和「有这个键但是空值」:
// 本页提交了 → 用新值(清空就是清空)
// 本页没有提交 → 保留当前值
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
- 📧 邮箱: [email protected]
- 🌐 网站: hoelee.com