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
+11
View File
@@ -0,0 +1,11 @@
CREATE TABLE `blog_keywords` (
`id` text PRIMARY KEY NOT NULL,
`keyword` text NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`created_at` integer NOT NULL,
`used_at` integer,
`post_id` text
);
--> statement-breakpoint
CREATE INDEX `idx_keywords_status` ON `blog_keywords` (`status`);--> statement-breakpoint
ALTER TABLE `posts` ADD `focus_keyword` text;
+3 -2
View File
@@ -11,13 +11,14 @@ Cloudflare D1 的 schema migration 同種子資料。
## Local Contracts
- **Migration 由工具產生**`0000_*.sql``0001_*.sql``npm run db:generate`drizzle-kit)依 `schema.ts` 產生;`migrations/meta/` 係產生檔。**唔好手改**呢啲檔或 meta。
- **Migration 由工具產生**`0000_*.sql``0001_*.sql``0002_*.sql``npm run db:generate`drizzle-kit)依 `schema.ts` 產生;`migrations/meta/` 係產生檔。**唔好手改**呢啲檔或 meta。`0002_*.sql` 加咗 `posts.focus_keyword``blog_keywords` 表。
- **`seed.sql` 係手寫、獨立於 migration**:全部用 `INSERT OR REPLACE`,可重複執行。`db:migrate` **唔會**執行 seed。
- **Migration 唔會喺 CI 執行**Cloudflare Workers Builds 只跑 build + deploy;改 schema 後要手動 `npm run db:migrate`(雲端)。
- **指令**(本機用 `:local`,雲端省略):
- `npm run db:generate` — 由 schema 產生 migration
- `npm run db:migrate:local` / `npm run db:migrate` — 套用 migration
- `npm run db:seed:local` / `npm run db:seed` — 匯入種子
- **Seed 內容**:公司資料(`site_settings`)、首頁內容(`content_items`service/feature/step/faq)、`cases`、一篇示範 `posts`
- **Seed 內容**:公司資料(`site_settings`)、首頁內容(`content_items`service/feature/step/faq)、`cases`、一篇示範 `posts`、三個示範 `blog_keywords``pending`);另有 7 個 `ai_*` AI 設定 keys(獨立 seed block
- DB 名稱 `yingfung-solar-db`binding 喺 `wrangler.jsonc``database_id` 仍係佔位,部署前要填)。
## Work Guidance
+391
View File
@@ -0,0 +1,391 @@
{
"version": "6",
"dialect": "sqlite",
"id": "80766b44-b986-4f68-ba33-1a0d0c811830",
"prevId": "13b1956d-d6ac-4a99-82db-372bdb300834",
"tables": {
"blog_keywords": {
"name": "blog_keywords",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"keyword": {
"name": "keyword",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"used_at": {
"name": "used_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"post_id": {
"name": "post_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"idx_keywords_status": {
"name": "idx_keywords_status",
"columns": [
"status"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"cases": {
"name": "cases",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"location": {
"name": "location",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'published'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idx_cases_status": {
"name": "idx_cases_status",
"columns": [
"status",
"sort_order"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"content_items": {
"name": "content_items",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"extra": {
"name": "extra",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'published'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idx_items_kind": {
"name": "idx_items_kind",
"columns": [
"kind",
"status",
"sort_order"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"posts": {
"name": "posts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"excerpt": {
"name": "excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cover_image": {
"name": "cover_image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"focus_keyword": {
"name": "focus_keyword",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"meta_description": {
"name": "meta_description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idx_posts_slug": {
"name": "idx_posts_slug",
"columns": [
"slug"
],
"isUnique": true
},
"idx_posts_published_at": {
"name": "idx_posts_published_at",
"columns": [
"published_at"
],
"isUnique": false
},
"idx_posts_status": {
"name": "idx_posts_status",
"columns": [
"status"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_settings": {
"name": "site_settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -15,6 +15,13 @@
"when": 1789107074198,
"tag": "0001_regular_multiple_man",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1789114640162,
"tag": "0002_red_roughhouse",
"breakpoints": true
}
]
}
+14
View File
@@ -21,6 +21,15 @@ INSERT OR REPLACE INTO site_settings (key, value, updated_at) VALUES
('seo_default_description', '專營香港村屋太陽能系統:現場評估、合法支架、代辦申請、專業安裝及驗收認證。透明報價,專人跟進,幫你善用天台賺取發電回報。', strftime('%s','now')*1000),
('og_image', 'https://images.unsplash.com/photo-1509391366360-2e959784a276?auto=format&fit=crop&w=1200&q=80', strftime('%s','now')*1000);
INSERT OR REPLACE INTO site_settings (key, value, updated_at) VALUES
('ai_enabled', '1', strftime('%s','now')*1000),
('ai_base_url', 'https://api.deepinfra.com/v1/openai', strftime('%s','now')*1000),
('ai_chat_model', 'deepseek-ai/DeepSeek-V3-0324', strftime('%s','now')*1000),
('ai_context_prompt', '用香港繁體中文(廣東話書面語)書寫,語氣專業、親切、務實,避免誇大。', strftime('%s','now')*1000),
('ai_business_context', '盈豐太陽能工程有限公司專營香港村屋太陽能系統:現場評估、合法鋁合金支架、代辦中電/港燈上網電價(FiT)申請、專業安裝及驗收認證。着重透明報價、專人跟進、安全合規。', strftime('%s','now')*1000),
('ai_web_search_enabled', '0', strftime('%s','now')*1000),
('ai_web_search_max_results', '5', strftime('%s','now')*1000);
INSERT OR REPLACE INTO content_items (id, kind, title, description, extra, sort_order, status, updated_at) VALUES
('svc-001', 'service', '村屋天台太陽能系統', '善用村屋天台空間,安裝高效太陽能板,將閒置天台變成穩定收入來源。', 'sun', 1, 'published', strftime('%s','now')*1000),
('svc-002', 'service', '合法合規支架工程', '按村屋結構同法規設計鋁合金支架,穩固耐用,確保合乎相關要求。', 'shield', 2, 'published', strftime('%s','now')*1000),
@@ -54,6 +63,11 @@ INSERT OR REPLACE INTO cases (id, title, location, completed_at, description, im
('case-002', '元朗山下村 — 香檳色鋁合金支架', '元朗屏山', '2025年7月', '香檳色支架配合村屋外牆色調,施工整齊,發電穩定。', 'https://images.unsplash.com/photo-1592833159155-c62df1b65634?auto=format&fit=crop&w=1200&q=80', 2, 'published', strftime('%s','now')*1000),
('case-003', '西貢西澳村 — 焗白色鋁合金支架', '新界西貢', '2025年5月', '焗白色支架低調美觀,善用天台每一寸可用空間。', 'https://images.unsplash.com/photo-1508514177221-188b1cf16e9d?auto=format&fit=crop&w=1200&q=80', 3, 'published', strftime('%s','now')*1000);
INSERT OR REPLACE INTO blog_keywords (id, keyword, status, created_at, used_at, post_id) VALUES
('kw-001', '村屋太陽能回本期', 'pending', strftime('%s','now')*1000, NULL, NULL),
('kw-002', '村屋天台安裝太陽能注意事項', 'pending', strftime('%s','now')*1000, NULL, NULL),
('kw-003', '上網電價 FiT 申請流程', 'pending', strftime('%s','now')*1000, NULL, NULL);
INSERT OR REPLACE INTO posts (id, slug, title, excerpt, content, cover_image, tags, meta_description, status, published_at, updated_at) VALUES
('post-001', 'village-house-solar-guide', '村屋安裝太陽能前,你要知嘅 5 件事', '想喺村屋天台裝太陽能?由天台面積、日照、支架法規到回本期,呢篇幫你一次過搞清楚。',
'村屋天台係香港少數可以合法大規模安裝太陽能嘅地方。不過落決定之前,以下 5 點你一定要知。