把 AI 修图工具做成 WordPress 插件:22 个版本换来的经验

· 3 min read · case-studies wordpressphpfal-aidockerai-imagejavascript

几周前我写过那个概念验证:一个 index.html、没有后端,两张家具照片进去,一张布置好的房间场景出来。它回答了 它本该回答的问题——能不能保住真实的产品,只把周围的房间生成出来?——同时留下 了一个显而易见的问题。

那篇 PoC 的结尾写着「在决定做成完整的 WordPress 插件之前」。这篇文章讲的就是 那个插件。它从 0.0.1 走到 0.0.22,58 个 commit,而其中几乎没有任何工作是 AI 那部分。

为什么这件事值得关心(写给老板,不是写给开发者)

PoC 的局限在于它活在一个 HTML 文件里,API key 就写在源码里。作为发给客户看的演示, 这没问题。但一家家具店要把这东西放到自己网站上,就不行了:

上面这五条都是产品需求,而每一条都比那次 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。

就这一个决策,消掉了「我改了 URL 里的 id 就看到了别人的照片」这一整类 bug。

水印是硬需求,所以回退逻辑绝不能静默

这个 bug 教给我的最多。水印那一步做的是 GD imagecopyresampled 合成。如果它失败, 一个很诱人的「有韧性」的写法是:

// 别这么写
if ( ! $watermarked ) {
    rename( $stage, $final );   // 直接把没水印的文件发出去
}

我当初就是这么写的。它完全静默、没有日志,插件会心安理得地输出没有水印的结果, 而后台还显示「水印:已开启」。店主几周后才发现,是因为看到了一张没水印的图。

修法分三部分:

  1. 记录根因,而不是最近的那个症状。 apply() 返回的是 hre_no_watermark_source——一个下游错误。真正的原因是水印来源的解析链 (site_logoget_theme_mod('custom_logo'))根本没解析到 logo。我加了一个 block_reason() 诊断,重新走一遍解析链,并针对每个失败分支返回具体信息。
  2. 在后台暴露出来。 一条常驻的 notice-warning 提示:水印已开启但不会被应用, 并附上具体原因。不需要去翻日志。
  3. 让失败在测试时就看得见。 一个独立探针把已知水印合成到已知底图上,并采样 预期水印矩形内的一个像素——这证明了变换路径本身是好的,从而把「功能坏了」和 「功能没配好」区分开。

我现在遵守的规则: 当某个「尽力而为」的分支掩盖了一个不可协商的变换时,它必须 记录具体原因并且在后台 UI 里告警。一个悄悄让必需功能降级的回退不是韧性——那是 一个有礼貌的 bug。

名单元件:默认关闭,开启后异步

名单元件是一个总开关。关闭时插件什么都不收集,向导的行为和以前完全一样。开启时, 管理员配置的字段会在生成之前变成必填,存入自定义表,并转发到 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, 高到正常照片一定能通过并被归一化。

如果重来一次我会怎么做

结果

一个具备产品形态的 WordPress 插件:0.0.22,共 22 次发布,10 张自定义数据表、 异步 fal.ai 队列、服务端密钥管理、带重试 webhook 的动态名单元件、区分分享状态的 照片归档,以及一个 10 个标签页、不用碰代码就能改完所有配置的后台。

这就是演示和产品之间的区别。而我最在意的那个数字是:58 个 commit 里,大概只有四个 和 AI 模型有关。 其余全是那些不体面的工作,却决定了客户能不能在没有我的情况下 把这东西跑起来:校验、数据保留、权限、失败的可见性,以及一个说真话的后台页面。


想给你的生意也做一套?

我为马来西亚的中小企业做 WordPress 插件、AI 图像流水线和自托管基础设施——而且是 做成你真的能跑起来的产品,不是要你天天伺候的演示。如果你想要 AI 产品图、一套线索 收集管道,或者为你的业务定制插件,欢迎直接找我:

Lee Teong Hoe

Full-stack developer & DevOps engineer. I build web apps, self-host infrastructure, and automate things — this blog is my living portfolio.