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 206ace4..dfabd31 100644 --- a/scripts/banner-gen/generate.mjs +++ b/scripts/banner-gen/generate.mjs @@ -342,6 +342,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 f92fb39..a25ee21 100644 --- a/scripts/og-gen/generate.mjs +++ b/scripts/og-gen/generate.mjs @@ -139,7 +139,12 @@ const TERMINALS = {
$curl -X POST :20015/memories · X-Api-Key
 infer=true → LLM hop · slow write
$infer=false · pgvector · LiteLLM gateway→ remembers across chats ✓
`, - }; + + '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 = `
$engineering · devops · self-hosting
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)