diff --git a/public/banners/shipping-an-ai-photo-editor-as-a-wordpress-plugin.png b/public/banners/shipping-an-ai-photo-editor-as-a-wordpress-plugin.png
new file mode 100644
index 0000000..8241e09
Binary files /dev/null and b/public/banners/shipping-an-ai-photo-editor-as-a-wordpress-plugin.png differ
diff --git a/public/og/shipping-an-ai-photo-editor-as-a-wordpress-plugin.png b/public/og/shipping-an-ai-photo-editor-as-a-wordpress-plugin.png
new file mode 100644
index 0000000..c4ffaa5
Binary files /dev/null and b/public/og/shipping-an-ai-photo-editor-as-a-wordpress-plugin.png differ
diff --git a/scripts/banner-gen/generate.mjs b/scripts/banner-gen/generate.mjs
index e7084c4..d3e5351 100644
--- a/scripts/banner-gen/generate.mjs
+++ b/scripts/banner-gen/generate.mjs
@@ -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: [
diff --git a/scripts/og-gen/generate.mjs b/scripts/og-gen/generate.mjs
index c7fc581..8bf88f2 100644
--- a/scripts/og-gen/generate.mjs
+++ b/scripts/og-gen/generate.mjs
@@ -118,6 +118,11 @@ const TERMINALS = {
$curl -I https://pass.hoelee.com
504 · ERR_TOO_MANY_REDIRECTS · fingerprint null
$mount passbolt.php · full fingerprint→ fixed ✓
`,
+
+ 'shipping-an-ai-photo-editor-as-a-wordpress-plugin': `
+ $wp plugin list · hre-ai-remix 0.0.1 → 0.0.22
+ 404 …/wp-json/hre/v1admin/photos — rest_url() has no trailing slash
+ $fix = '/admin/…' · 58 commits · 4 were the AI→ shipped ✓
`,
};
const DEFAULT_TERMINAL = `
diff --git a/src/content/posts/shipping-an-ai-photo-editor-as-a-wordpress-plugin.md b/src/content/posts/shipping-an-ai-photo-editor-as-a-wordpress-plugin.md
new file mode 100644
index 0000000..2e8a537
--- /dev/null
+++ b/src/content/posts/shipping-an-ai-photo-editor-as-a-wordpress-plugin.md
@@ -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:** [me@hoelee.com](mailto:me@hoelee.com?subject=WordPress%20AI%20plugin%20project)
+- 🌐 **Website:** [hoelee.com](https://hoelee.com)
diff --git a/src/content/posts/zh/shipping-an-ai-photo-editor-as-a-wordpress-plugin.md b/src/content/posts/zh/shipping-an-ai-photo-editor-as-a-wordpress-plugin.md
new file mode 100644
index 0000000..80d1e11
--- /dev/null
+++ b/src/content/posts/zh/shipping-an-ai-photo-editor-as-a-wordpress-plugin.md
@@ -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)
+- 📧 **邮箱:** [me@hoelee.com](mailto:me@hoelee.com?subject=WordPress%20AI%20plugin%20project)
+- 🌐 **网站:** [hoelee.com](https://hoelee.com)