post: shipping an AI photo editor as a WordPress plugin (case study, EN+ZH, og+banner)

This commit is contained in:
2026-09-18 01:30:04 +08:00
parent f0819eb3a3
commit 4e2e603449
6 changed files with 535 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+18
View File
@@ -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: [
+5
View File
@@ -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">&nbsp;</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">&nbsp;</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 1520 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 的任务要 1520 秒。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)