Add Zod validation layer and AI blog generation

Introduce a schema-first validation layer and an AI blog generation
feature, plus one-command Cloudflare provisioning.

- src/schemas/ holds Zod input schemas for post, content, case,
  settings, AI, and keywords; parseForm() in src/lib/form.ts validates
  FormData and returns per-field errors.
- Migrate all admin POST handlers to parseForm, showing field-level
  errors and only redirecting once validation passes.
- Add blog_keywords table and posts.focus_keyword (migration 0002);
  uniqueSlug() centralised in src/data/content.ts.
- Add src/lib/ai.ts (OpenAI-compatible chat completions + optional
  Tavily research) and /admin/ai for AI settings, keyword queue, and
  draft generation.
- Add scripts/setup.mjs (npm run setup) to provision D1/KV, set
  secrets, and optionally migrate, seed, and deploy.
- Document AI secrets, Workers Builds deploy flow, and new schemas
  across README and AGENTS docs.
This commit is contained in:
2026-09-11 23:49:18 +08:00
parent 5b69bc818a
commit fdee1aabd7
41 changed files with 3240 additions and 296 deletions
@@ -0,0 +1,249 @@
# 盈豐太陽能 — 結構優化 + AI Blog + 部署便利 (Design Spec)
- **日期**2026-09-11
- **狀態**:已與客戶確認方向
- **背景**:現有網站(見 `2026-09-11-ying-fung-solar-design.md`)功能完整,但想改善三樣嘢:
1. 資料/表單處理散落手砌,欠型別化驗證層
2. 想有 AI 生成 Blog 文章功能(參考 webtemplate,但簡化、唔要 Google keyword search
3. 部署到 Cloudflare 嘅流程手續多,想更方便
- **本 spec 取代**先前 spec 嘅部署一節;其餘架構決策不變。
---
## 1. 目標與非目標
### 目標
1. **輕量 schema-first 驗證層**:用 Zod 做「admin 表單輸入 + DB 寫入」嘅單一真相來源,得到 typed contract 好處,但零 codegen、零 API layer。
2. **AI Blog 生成**:後台一鍵生成完整 Markdown 草稿(DeepSeek via OpenAI 相容端點),可選 Tavily 上網研究,支援關鍵字佇列。
3. **部署便利**:一鍵本機 setup script + Cloudflare Workers BuildsGit push 自動 deploy)。
### 非目標 (Out of scope)
- 唔引入真 OpenAPI spec / codegen / 前後端分離 SPA(已評估唔啱一個一頁式官網)。
- AI **封面圖**生成(唔做)。
- **定時/排程**自動生成(唔做;只做後台手動觸發)。
- **Google Ads keyword discovery**(唔做)。
- 圖片上傳(維持以 URL 貼圖)。
- 雙語、聯絡表單(維持現狀)。
---
## 2. Thread 1 — 輕量 schema-first 驗證層
### 2.1 依賴
新增 `zod`v4`^4`)。
### 2.2 結構
```
src/schemas/
├─ post.ts postInput
├─ content.ts contentItemInputkind enum 用 CONTENT_KINDS
├─ case.ts caseInput
├─ settings.ts 由 SETTINGS_GROUPS 動態砌出可選 key 驗證
├─ ai.ts aiSettingsInput + AI 輸出格式 schema
├─ keyword.ts keywordInput
└─ index.ts barrel(集中 re-export schema 同 z.infer 型別)
src/lib/form.ts parseForm(formData, schema)
```
- Schema 係「表單輸入 + DB 寫入」嘅唯一真相來源;型別一律 `z.infer`
- `settings-fields.ts` 繼續做 **UI metadata**(標籤、分組),Zod 只負責 **驗證**,唔重複定義。
- `parseForm` 介面:
```ts
type ParseResult<T> =
| { ok: true; data: T }
| { ok: false; errors: Record<string, string> };
function parseForm<T>(form: FormData, schema: ZodType<T>): ParseResult<T>;
```
錯誤以 `field -> 訊息` 形式回傳,admin 表單喺對應欄位下顯示;未對應欄位嘅錯誤顯示喺表單頂。
### 2.3 改動
- `pages/admin/post/[id].astro`、`content/[kind].astro`、`cases.astro`、`settings.astro`POST handler 改用 `parseForm` 取代手砌 `String(form.get(...))` 同手寫 `if (!title)`。
- `data/content.ts`:改用 `src/schemas` 匯出嘅型別(如 `NewPost` 形狀),與 schema 對齊。
- 排序(`up`/`down`)、`delete` 等非表單欄位動作維持現狀(唔屬 schema 範圍)。
### 2.4 唔做
- 唔改前台元件介面、唔改 DB schema(純驗證層)。
- 唔引入 `openapi.json`、唔引入 client codegen。
---
## 3. Thread 2 — AI Blog 生成
### 3.1 資料模型(改 `src/db/schema.ts` → `npm run db:generate`
**`posts` 加欄位:**
| 欄位 | 型別 | 說明 |
|---|---|---|
| `focusKeyword` | text nullable | 生成時帶入嘅焦點關鍵字(SEO 用) |
**新表 `blog_keywords`**
| 欄位 | 型別 | 說明 |
|---|---|---|
| `id` | text PK | UUID |
| `keyword` | text notNull | 關鍵字 / 主題 |
| `status` | text enum `pending` \| `generated` \| `skipped`,預設 `pending` | 狀態 |
| `createdAt` | integer (timestamp_ms) | |
| `usedAt` | integer (timestamp_ms) nullable | 生成時間 |
| `postId` | text nullable | 生成出嚟嘅文章 id(可選連結) |
索引:`idx_keywords_status(status)`。
> 比 webtemplate 減去 Google Ads 專屬欄位(`avg_monthly_searches`、`competition`、`source`)。
### 3.2 Secrets 與設定
**Secrets`wrangler secret` / `.dev.vars`,加入 `AppEnv`):**
- `AI_API_KEY`
- `TAVILY_API_KEY`
**設定(D1 `site_settings`,後台可改):**
| Key | 預設 | 說明 |
|---|---|---|
| `ai_enabled` | `1` | 總開關 |
| `ai_base_url` | `https://api.deepinfra.com/v1/openai` | OpenAI 相容端點 |
| `ai_chat_model` | `deepseek-ai/DeepSeek-V3-0324` | 模型 |
| `ai_context_prompt` | `""` | 寫作風格 / 語氣 |
| `ai_business_context` | seed 由公司資料砌 | 公司背景(grounding |
| `ai_web_search_enabled` | `0` | Tavily 研究開關 |
| `ai_web_search_max_results` | `5` | Tavily 結果數 |
### 3.3 生成流程(`src/lib/ai.ts`
```
POST /admin/ai
action = "generate-topic"(自由輸入 topic
| "generate-next"(攞下一個 pending keyword
generateBlogPost({ db, env, topic })
1. 讀 ai_* 設定;檢查 ai_enabled + AI_API_KEY
2. 若係 generate-next → 攞最舊一個 pending keywordmark 佢 "generated" + usedAt + postId
generate-topic(自由輸入)唔會寫入佇列
3. 若 ai_web_search_enabled + TAVILY_API_KEY → Tavily search → research_brief
4. 組 system prompt
ai_context_prompt + ai_business_context + research_brief
+ SEO/GEO 硬規則 + 輸出格式合約
5. 呼叫 DeepSeek chat completionsOpenAI 相容 /chat/completions
6. 解析輸出(TITLE / CONTENT / META_DESC),用 Zod 驗證格式;失敗回錯誤
7. slugify + uniqueSlugautoExcerpt 補 excerpt
8. 插入 postsstatus = "draft"、focusKeyword = topic)→ 回 post id
redirect 去 /admin/post/<id> 覆核
```
**執行方式:同步 await。** POST handler 直接等生成完成(約 1040 秒)再 redirect。原因:最簡單、admin 流程直觀,符合「唔使太複雜」;Workers 對 outbound fetch 等待唔計 CPU limit。若日後遇到超時,先改 `Astro.locals.cfContext.waitUntil` + 狀態欄(已知 adapter 有注入 `locals.cfContext`)。
**提示合約:**
- 輸出用固定分隔格式(TITLE / CONTENT / META_DESC),比純 JSON 容忍度更高。
- 內建規則:繁體中文(廣東話書面)、800–1200 字、H2/H3 結構、關鍵字放標題及首段、meta ≤ 160 字、GEOsummary-first + Q&A)。
### 3.4 Admin UI(新頁 `/admin/ai`
一頁過包含:
1. **AI 設定表單**`ai_*` keys,密碼類 secret 只顯示「已設定 / 未設定」,唔顯示值)
2. **關鍵字佇列**:新增、刪除、標記 skipped;顯示狀態
3. **生成掣**`generate-topic`(自由輸入)+ `generate-next`(下一個 pending
- 生成掣用 POST 表單 + 提交時 JS 顯示「生成中…」(純漸進增強,唔引入框架)。
- `/admin` 首頁加入口連結去 `/admin/ai`。
### 3.5 唔做
- AI 圖、排程、Google Ads、多語。
---
## 4. Thread 3 — 部署便利
### 4.1 一鍵本機 setup — `scripts/setup.mjs``npm run setup`
互動式 Node script,逐步做:
1. 檢查 `wrangler whoami`(未登入提示 `npm run login`)。
2. `wrangler d1 create yingfung-solar-db`(若已存在/已填 id 則略過)→ 解析 output 拎 `database_id`。
3. `wrangler kv namespace create CACHE` → 解析 id。
4. 將 id 寫入 `wrangler.jsonc`:以字串替換 `PASTE_D1_DATABASE_ID_HERE` / `PASTE_KV_NAMESPACE_ID_HERE`**保留 JSONC 註解同行距**,唔用 JSON.parse/stringify)。
5. 提示輸入後台密碼 → `wrangler secret put ADMIN_PASSWORD`。
6. 套 migration + seedremote)。
7. 可選:`npm run build` + `npx wrangler deploy`。
- 每一步 idempotent(重跑唔會整爛已設定嘅嘢)。
- 唔會自動改 `astro.config.mjs` 嘅 `site`(提示用戶手動改)。
### 4.2 Cloudflare Workers BuildsGit push 自動 deploy
- 喺 Cloudflare Dashboard → Workers & Pages → 連接 Git repo。
- **Build command**`npm run build`
- **Deploy command**`npx wrangler deploy`
- Secrets`ADMIN_PASSWORD`、`AI_API_KEY`、`TAVILY_API_KEY`)喺 Dashboard 設定。
- **Migration 唔入 CI**:改 schema 時手動跑 `npm run db:migrate`(避免每次 push 亂郁 DB)。README 寫明呢個分工。
### 4.3 唔做
- 唔加 GitHub Actions。
- 唔自動化 Cloudflare Dashboard 連 repo(需要人手授權)。
---
## 5. 分階段實作
| 階段 | 內容 | 完成標準 |
|---|---|---|
| **Phase 1** | Zod schema-first 驗證層 | `npm run build` 過;admin 表單驗證錯誤正常顯示 |
| **Phase 2** | AI Blog + 關鍵字佇列 | `npm run db:generate` 出 migration`npm run dev` 實測生成草稿 |
| **Phase 3** | setup script + Workers Builds 文件 | 新 clone 行 `npm run setup` 可完成 provisionREADME 更新 |
三階段相對獨立,逐階段完成並 `npm run build`。
---
## 6. 風險與對策
| 風險 | 對策 |
|---|---|
| Zod 加入 admin POST 改動面大 | 逐個 admin POST handler 遷移;`parseForm` 提供一致 fallbackbuild 驗證 |
| LLM 輸出格式唔穩定 | 用容錯分隔格式 + Zod 驗證,失敗回明確錯誤、唔插入壞資料 |
| 同步生成請求時間長 | Workers I/O 等待唔計 CPU;若超時先改 `cfContext.waitUntil` + 狀態欄 |
| `wrangler.jsonc` 有註解,程式改寫易壞 | 只做精準字串替換,唔 parse 整個檔 |
| Workers Builds 跑 migration 風險 | 明確唔放 CImigration 手動執行 |
| 外部 AI 供應商停机 | 生成失敗只回錯誤,唔影響網站;設定可改 `ai_base_url`/model |
---
## 7. 驗證
- 每階段:`npm run build`(唯一 build/type 檢查)。
- Phase 2`npm run db:generate``npm run dev` 手動生成一篇草稿並覆核。
- Phase 3`npm run setup` 喺未 provision 環境 dry-run(或已有環境重跑,確認 idempotent)。
---
## 8. 文件同步
- `AGENTS.md`(root):指令、部署流程、AI secret。
- `src/AGENTS.md`:新 `schemas/`、`/admin/ai` 路由、AI 執行方式。
- `src/db/AGENTS.md`:新 `blog_keywords` 表、`posts.focusKeyword`。
- `src/data/AGENTS.md`:關鍵字查詢、型別來源改為 schemas。
- `src/lib/AGENTS.md``ai.ts`、`form.ts`、`env.ts` 新 secret。
- `README.md`setup script、Workers Builds、AI 設定步驟。
- `migrations/AGENTS.md`:新 migration。
---
## 9. 待客戶提供
- `AI_API_KEY`DeepInfra 或同類 OpenAI 相容供應商)。
- `TAVILY_API_KEY`(若用上網研究)。