post: shipping an AI photo editor as a WordPress plugin (case study, EN+ZH, og+banner)
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -304,6 +304,24 @@ const BANNERS = {
|
||||
],
|
||||
},
|
||||
|
||||
'shipping-an-ai-photo-editor-as-a-wordpress-plugin': {
|
||||
titlebar: 'wp-admin — AI Remix Photo plugin',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'wp plugin list · hre-ai-remix 0.0.1 → 0.0.22' },
|
||||
{ t: 'err', text: '404 …/v1admin/photos — rest_url() has no trailing slash' },
|
||||
{ t: 'dim', text: 'routes in is_admin() ✗ · watermark silent-fallback ✗' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: "join REST with '/admin/…' · log root cause · warn admin" },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 22 releases · 58 commits · 4 were the AI ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'PoC → plugin' },
|
||||
{ n: '2', label: 'v1admin 404', err: true },
|
||||
{ n: '3', label: 'silent watermark', err: true },
|
||||
{ n: '4', label: 'server-side gates' },
|
||||
{ n: '5', label: '0.0.22 ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'best-ai-video-generators-2026': {
|
||||
titlebar: '~/blog — AI video free vs paid 2026',
|
||||
lines: [
|
||||
|
||||
@@ -118,6 +118,11 @@ const TERMINALS = {
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">curl -I https://pass.hoelee.com</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">504 · ERR_TOO_MANY_REDIRECTS · fingerprint null</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">mount passbolt.php · full fingerprint</span><span class="fix">→ fixed ✓</span></div>`,
|
||||
|
||||
'shipping-an-ai-photo-editor-as-a-wordpress-plugin': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">wp plugin list · hre-ai-remix 0.0.1 → 0.0.22</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">404 …/wp-json/hre/v1<span class="hl">admin</span>/photos — rest_url() has no trailing slash</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">fix = '/admin/…' · 58 commits · 4 were the AI</span><span class="fix">→ shipped ✓</span></div>`,
|
||||
};
|
||||
|
||||
const DEFAULT_TERMINAL = `
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
---
|
||||
title: "Shipping an AI Photo Editor as a WordPress Plugin: 22 Versions of Hard Lessons"
|
||||
description: "From a single HTML file to a shipped WordPress plugin: the fal.ai async queue, a watermark that must never silently fail, optional lead capture, and five bugs that only appeared in production."
|
||||
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
|
||||
---
|
||||
|
||||
A few weeks ago I [wrote about the proof of concept](/posts/ai-furniture-compositing-with-flux-kontext/): one
|
||||
`index.html`, no backend, two furniture photos in, a staged room scene out. It
|
||||
answered the question it was built to answer — *can you keep the real product and
|
||||
only generate the room around it?* — and it left one obvious question open.
|
||||
|
||||
That PoC ended with the words *"before committing to the full WordPress plugin."*
|
||||
This post is about the plugin. It went from version `0.0.1` to `0.0.22` across 58
|
||||
commits, and almost none of that work was the AI part.
|
||||
|
||||
## Why it matters (for a business owner, not a developer)
|
||||
|
||||
The PoC's limitation was that it lived in a single HTML file with the API key in
|
||||
the source. That's fine for a demo you email a client. It is not fine for
|
||||
something a furniture shop puts on their own website, where:
|
||||
|
||||
- **The API key cannot be in the browser.** Anyone can view-source and drain your
|
||||
fal.ai credit. It has to live server-side, behind a settings screen an admin
|
||||
controls.
|
||||
- **Nobody wants to babysit a generation.** The visitor uploads, waits, gets a
|
||||
picture. If the process dies halfway, the shop owner should never hear about it.
|
||||
- **Photos of customers must not leak.** An anonymous visitor's source photo must
|
||||
never end up in the public Media Library, and a result must never be public
|
||||
unless *that visitor* opted in to sharing it.
|
||||
- **Output needs a watermark.** Otherwise you're running a free AI photo service
|
||||
for the entire internet.
|
||||
- **The owner needs to capture the lead.** An AI tool that produces a pretty
|
||||
picture and no contact details is a toy. Attached to a form + webhook, it's a
|
||||
lead pipeline for a furniture business.
|
||||
|
||||
Every one of those five points is a *product* requirement, and every one of them
|
||||
cost more code than the AI call itself.
|
||||
|
||||
## Architecture: the boring parts that make it work
|
||||
|
||||
The plugin is a normal WordPress plugin — PHP prefix `hre_`, namespace `HRE`,
|
||||
custom tables created with `dbDelta()` on activation, an autoloader, no Composer
|
||||
runtime dependency. The interesting decisions are elsewhere.
|
||||
|
||||
### Generation is asynchronous, always
|
||||
|
||||
fal.ai jobs take 15–20 seconds. PHP-FPM request timeouts, `max_execution_time`,
|
||||
and impatient visitors make synchronous generation a losing bet. So the flow is a
|
||||
queue-and-poll:
|
||||
|
||||
```
|
||||
visitor uploads → POST /uploads → normalize → start vision analysis
|
||||
visitor picks → GET /prompts/{cat} → per-style prompts
|
||||
visitor confirms → POST /jobs → fal queue submit (returns uuid)
|
||||
browser polls → GET /jobs/{uuid} → status: queued | processing | done
|
||||
```
|
||||
|
||||
The visitor never waits on a provider. The job row carries the fal request id, and
|
||||
a poll either finds a finished result or shows genuine progress. The front end is
|
||||
a small state machine over a session cookie, so a mid-wizard refresh restores
|
||||
where the visitor was.
|
||||
|
||||
### Ownership is the session cookie, not an ID in the URL
|
||||
|
||||
Anonymous visitors have no accounts, which makes authorization easy to get wrong.
|
||||
The rule I settled on: **every read resolves through the caller's own session
|
||||
hash**, never through an ID accepted from the client.
|
||||
|
||||
- `GET /preview` streams the caller's *own* normalized upload. There is no path or
|
||||
URL parameter at all — a stranger requesting it gets a 404 because their session
|
||||
has no file. This also keeps private storage paths out of every JSON payload.
|
||||
- `GET /jobs/{uuid}` looks the job up *by session hash + uuid*. Guessing a UUID
|
||||
from another browser returns nothing.
|
||||
|
||||
That's one decision that removes an entire class of "I changed the id in the URL
|
||||
and saw someone else's photo" bugs.
|
||||
|
||||
### The watermark is a hard requirement, so a fallback must never be silent
|
||||
|
||||
This is the bug that taught me the most. The watermark step does a GD
|
||||
`imagecopyresampled` composite. If it ever fails, the tempting "resilient" move is:
|
||||
|
||||
```php
|
||||
// DON'T do this
|
||||
if ( ! $watermarked ) {
|
||||
rename( $stage, $final ); // ship the un-watermarked file
|
||||
}
|
||||
```
|
||||
|
||||
I did exactly that. It's silent, it has no log line, and it means the plugin
|
||||
happily serves un-watermarked results while the admin screen says "watermark: on."
|
||||
The owner found out weeks later by noticing a photo without a watermark.
|
||||
|
||||
The fix was three parts:
|
||||
|
||||
1. **Log the root cause, not the nearest symptom.** `apply()` returned
|
||||
`hre_no_watermark_source` — a downstream error. The real cause was that the
|
||||
watermark source resolution chain (`site_logo` → `get_theme_mod('custom_logo')`)
|
||||
had no logo to resolve. I added a `block_reason()` diagnostic that re-walks the
|
||||
chain and returns a *specific* message per failure branch.
|
||||
2. **Surface it in the admin.** A persistent `notice-warning` says the watermark
|
||||
is enabled but won't be applied, plus the concrete reason. No log-reading
|
||||
required.
|
||||
3. **Make the failure visible to me in testing.** A standalone probe composites a
|
||||
known watermark onto a known base and samples a pixel inside the expected
|
||||
watermark rectangle — proving the transform path itself works, which separates
|
||||
"the feature is broken" from "the feature is unconfigured."
|
||||
|
||||
**Rule I now follow:** when a best-effort branch masks a non-negotiable transform,
|
||||
it must log the concrete reason *and* warn in the admin UI. A silent fallback that
|
||||
degrades a required feature is not resilience — it's a bug with good manners.
|
||||
|
||||
### Lead capture: off by default, asynchronous when on
|
||||
|
||||
Lead capture is a master toggle. Off, the plugin collects nothing and the wizard
|
||||
behaves exactly as before. On, the visitor's configured fields are required before
|
||||
generation, stored in a custom table, and forwarded to a webhook.
|
||||
|
||||
Two decisions worth copying:
|
||||
|
||||
- **The field list is admin-defined and fully dynamic** — `{key, label, type,
|
||||
required, placeholder, options[]}` — so the front form renders itself from
|
||||
config. The defaults (name, phone, email, message, consent) are just a starting
|
||||
point the owner can replace entirely.
|
||||
- **Webhook delivery is scheduled, never inline.** The visitor must not wait on
|
||||
someone else's endpoint:
|
||||
|
||||
```php
|
||||
wp_schedule_single_event( time() + 5, 'hre_lead_webhook', array( $lead_id ) );
|
||||
```
|
||||
|
||||
Delivery retries three times with linear backoff, then marks the record `failed`.
|
||||
The admin list shows a status badge (pending / retrying / delivered / failed) plus
|
||||
the attempt count, and — this matters — the plugin's data retention rule keeps
|
||||
*delivered* leads but purges undelivered ones on the normal schedule, because an
|
||||
undelivered lead is just PII sitting in your database.
|
||||
|
||||
### The provider kill switch has to be enforced server-side
|
||||
|
||||
The admin can disable the image provider and set a message that visitors see
|
||||
instead of the generate button. Greying out the checkbox is UX; the enforcement is
|
||||
a check at the top of the job-creation endpoint:
|
||||
|
||||
```php
|
||||
if ( ! (bool) Settings::get( 'fal_provided' ) ) {
|
||||
return $this->error_response( 'hre_provider_disabled', $msg, 503 );
|
||||
}
|
||||
```
|
||||
|
||||
Same for the lead gate. The browser gate is UX. The server gate is the product.
|
||||
|
||||
## The five bugs that only showed up in the real install
|
||||
|
||||
This is the part no article about "building an AI plugin" ever includes, and it's
|
||||
where most of the 58 commits went.
|
||||
|
||||
### 1. Admin REST routes registered inside `is_admin()` silently 404
|
||||
|
||||
Registering admin routes only when `is_admin()` is true looks correct and is
|
||||
completely broken: **REST requests report `is_admin() === false`**, so the routes
|
||||
are never registered and every call returns `404 "No route was found matching the
|
||||
URL and request method"` — which reads exactly like a URL typo, not a registration
|
||||
bug. Register at plugin boot; the `permission_callback` (capability + nonce) is
|
||||
what gates access.
|
||||
|
||||
### 2. `rest_url()` has no trailing slash
|
||||
|
||||
The one that cost me the most recent afternoon. `rest_url()` returns
|
||||
`https://example.com/wp-json/my-plugin/v1/` — the trailing slash is on the
|
||||
*namespace*, and the next path segment must start with its own slash. Concatenating
|
||||
without one:
|
||||
|
||||
```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` is a 404 with no browser console error, so the
|
||||
`catch` block fires and the UI shows the generic *"Something went wrong."* It had
|
||||
quietly broken **four** endpoints — photo pagination, photo delete, the webhook
|
||||
test button, and lead resend. The fix is one character per call site; the
|
||||
diagnosis took far longer, because the failure mode is indistinguishable from a
|
||||
server error. Prove it with two curls: the correct join returns `403` (route
|
||||
exists, nonce missing), the broken one returns `404`.
|
||||
|
||||
### 3. A WordPress admin settings form can wipe a different tab's settings
|
||||
|
||||
Each admin tab posts only its own fields, but the save handler runs the *whole*
|
||||
settings map. A checkbox that isn't submitted looks identical to a checkbox that
|
||||
was unchecked, so saving the "limits" tab silently wrote `false` over the
|
||||
"share by default" option, and saving that one cleared the entire lead-capture
|
||||
configuration. The fix belongs in the sanitizer, not the forms — distinguish
|
||||
"absent from this submission" from "present and empty":
|
||||
|
||||
```php
|
||||
// present in this tab → use the new value (a cleared field clears)
|
||||
// absent from this tab → keep the current value
|
||||
if ( array_key_exists( $key, $raw ) ) {
|
||||
$clean[ $key ] = sanitize( $raw[ $key ] );
|
||||
}
|
||||
```
|
||||
|
||||
I added a regression test for exactly this (seed config → save a different tab's
|
||||
subset → assert the seed survived). It's the kind of bug that only exists once you
|
||||
have two tabs, which is why it shipped.
|
||||
|
||||
### 4. A binary endpoint must not go through a JSON fetch helper
|
||||
|
||||
The result download is a JPEG stream. The shared `api()` helper did `res.json()`
|
||||
on every response, which threw on image bytes — and a swallowing
|
||||
`.catch(function(){})` ate the error, so the symptom was *"the result never shows
|
||||
up"* with nothing in the console. Blob endpoints need their own raw fetch helper
|
||||
that checks `res.ok`, parses JSON only on error, and returns `res.blob()` on
|
||||
success.
|
||||
|
||||
### 5. A size limit that fires before the resize defeats the resize
|
||||
|
||||
The plugin downscales uploads to a working size (1920×1080 bounds). I had set the
|
||||
decompression-bomb guard too low — 1 MB / 1 MP — which meant every legitimate
|
||||
phone photo was rejected *before* it could be resized. Visitors saw the preview
|
||||
appear and instantly de-select, reported as "the resize feature is broken."
|
||||
|
||||
The real lesson is UX, not numbers: the client must reject an over-limit file
|
||||
*before* showing the local preview. Flash-then-disappear reads as a bug even when
|
||||
the message underneath is correct. The caps now sit at 15 MB / 50 MP, high enough
|
||||
that normal photos always pass and get normalized.
|
||||
|
||||
## What I'd do differently
|
||||
|
||||
- **Write the failure path first for anything the product depends on.** The
|
||||
watermark bug existed because I wrote the happy path and then hid the unhappy
|
||||
one. Now I write the "what does the owner see when this breaks" branch before
|
||||
the feature is finished.
|
||||
- **Test REST joins against the live site on day one.** A two-curl check
|
||||
(`.../v1/admin/x` → 403 vs `.../v1admin/x` → 404) would have caught four broken
|
||||
endpoints in week one instead of week four. I've added it to the project's
|
||||
`AGENTS.md` so it can't recur.
|
||||
- **Don't guess at a provider's API surface.** I lost time on `fal.ai/api/me`
|
||||
(which doesn't exist) before discovering the right key check is an authenticated
|
||||
`POST {}` to the queue endpoint: `401` means rejected, anything else means the
|
||||
key is fine. Read the real schema, then write the client.
|
||||
- **Keep the front end to two shortcodes.** I was tempted to split the wizard into
|
||||
five shortcodes and five page-builder widgets. Keeping it as one app + one
|
||||
gallery shortcode meant every UI iteration was a single-file change, and that
|
||||
iteration speed is the reason 22 versions shipped.
|
||||
|
||||
## The result
|
||||
|
||||
A production-shaped WordPress plugin: **`0.0.22` across 22 releases**, with 10
|
||||
custom database tables, an async fal.ai queue, server-side key handling, dynamic
|
||||
lead capture with a retrying webhook, a role-aware photo archive, and everything
|
||||
configurable from a 10-tab admin screen without touching code.
|
||||
|
||||
It is the difference between a demo and a product, and — the number I actually
|
||||
care about — **58 commits, of which roughly four were about the AI model.** The
|
||||
rest was the unglamorous work that decides whether a client can run the thing
|
||||
without me: validation, retention, permissions, failure visibility, and an admin
|
||||
screen that tells the truth.
|
||||
|
||||
---
|
||||
|
||||
## Want this for your business?
|
||||
|
||||
I build WordPress plugins, AI image pipelines, and self-hosted infrastructure for
|
||||
Malaysian SMEs — and I ship them as products you can actually run, not demos you
|
||||
have to babysit. If you want AI product photography, a lead-capture pipeline, or
|
||||
a custom plugin for your business, I'd love to talk:
|
||||
|
||||
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
|
||||
- 📧 **Email:** [[email protected]](mailto:[email protected]?subject=WordPress%20AI%20plugin%20project)
|
||||
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user