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 -3
View File
@@ -2,26 +2,34 @@
## Purpose
跨層基礎工具:環境變數、DB 連線、後台認證、Markdown 處理。
跨層基礎工具:環境變數、DB 連線、後台認證、表單驗證、AI 生成、Markdown 處理。
## Ownership
- 擁有 `src/lib/` 個檔案。
- 擁有 `src/lib/` 個檔案`env.ts``db.ts``auth.ts``form.ts``ai.ts``markdown.ts`
- 其他層(pages / data / middleware)只消費,唔好複製呢度嘅邏輯。
## Local Contracts
- **`env.ts`**`getEnv()``cloudflare:workers``env`,回 `AppEnv``DB``CACHE``ADMIN_PASSWORD?`)。只能喺 `prerender = false` 的頁面/endpoint 用。**唔用** `Astro.locals.runtime.env`Astro v6 起已移除)。
- **`env.ts`**`getEnv()``cloudflare:workers``env`,回 `AppEnv``DB``CACHE``ADMIN_PASSWORD?``AI_API_KEY?``TAVILY_API_KEY?`)。只能喺 `prerender = false` 的頁面/endpoint 用。**唔用** `Astro.locals.runtime.env`Astro v6 起已移除)。
- **`db.ts`**`getDb(env.DB)` → Drizzle,並 re-export `schema`
- **`auth.ts`**HMAC-SHA256 signed cookie 認證。
- 匯出 `SESSION_COOKIE``checkPassword``createSession``verifySession``sessionCookieOptions`
- Cookie 7 日、`httpOnly``secure``sameSite: "lax"``path: "/"`
- 密碼同 session 比對用 `safeEqual`constant-time),改動要保留防 timing attack 嘅做法。
- **`form.ts`**`parseForm(formData, schema)``FormData` 抽成物件交畀 Zod 驗證;回 `{ ok: true, data }``{ ok: false, errors }`field → 訊息)。admin 表單一律經呢度,schema 喺 `src/schemas/`
- **`ai.ts`**AI 生成 Blog 流程。
- `getAiSettings(db)``site_settings` 砌出供應商設定(enabled / baseUrl / model / context / web search)。
- Tavily 上網研究(失敗回空字串,唔中斷)+ OpenAI 相容 `chat/completions`(有 timeout)。
- `parseGeneratedPost(text)` 解析 `TITLE` / `CONTENT` / `META_DESC` 並用 `aiGeneratedPost` schema 驗證。
- `generateBlogPost(db, env, input)` 同步生成:`{ mode: "topic" }``{ mode: "next" }`(攞佇列下一個 pending);一律存 **draft** 並將關鍵字標記 `generated`。缺 `AI_API_KEY` / 未啟用時回 `{ ok: false, error }`,唔會 throw。
- **`markdown.ts`**`renderMarkdown``marked`,內容受信任所以**唔 sanitize**)、`slugify`(保留中文)、`autoExcerpt`
## Work Guidance
- 認證相關改動要同時檢查 `src/middleware.ts``pages/admin/login.astro``pages/admin/logout.ts`
- 新增/改 admin 表單欄位時,同步更新 `src/schemas/` 對應 schema(輸入型別用 `z.infer` 匯出)。
- AI secrets 喺 `AppEnv` 宣告:本機放 `.dev.vars`,雲端用 `wrangler secret put` 或 Workers Dashboard。
- Markdown 內容由後台輸入;如將來開放不受信任輸入,需重新評估 sanitize。
## Verification
+224
View File
@@ -0,0 +1,224 @@
import { asc, eq } from "drizzle-orm";
import type { Db } from "../data/content";
import { getSettings, uniqueSlug } from "../data/content";
import { blogKeywords, posts } from "../db/schema";
import type { AppEnv } from "./env";
import { autoExcerpt, slugify } from "./markdown";
import { aiGeneratedPost } from "../schemas";
type AiSettings = {
enabled: boolean;
baseUrl: string;
model: string;
contextPrompt: string;
businessContext: string;
webSearchEnabled: boolean;
webSearchMaxResults: number;
};
export async function getAiSettings(db: Db): Promise<AiSettings> {
const s = await getSettings(db);
return {
enabled: s.ai_enabled !== "0",
baseUrl: s.ai_base_url || "https://api.deepinfra.com/v1/openai",
model: s.ai_chat_model || "deepseek-ai/DeepSeek-V3-0324",
contextPrompt: s.ai_context_prompt ?? "",
businessContext: s.ai_business_context ?? "",
webSearchEnabled: s.ai_web_search_enabled === "1",
webSearchMaxResults: Math.min(10, Math.max(1, Number(s.ai_web_search_max_results) || 5)),
};
}
type TavilyResult = { title?: string; url?: string; content?: string };
/** Tavily 搜尋,失敗回空字串(唔中斷生成)。 */
async function tavilyResearch(query: string, apiKey: string, maxResults: number): Promise<string> {
try {
const res = await fetch("https://api.tavily.com/search", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ api_key: apiKey, query, max_results: maxResults, search_depth: "basic" }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return "";
const data = (await res.json()) as { results?: TavilyResult[] };
const results = data.results ?? [];
return results
.map((r, i) => `${i + 1}. ${r.title ?? ""}${r.url ?? ""}\n${r.content ?? ""}`)
.join("\n\n");
} catch {
return "";
}
}
function buildSystemPrompt(opts: {
contextPrompt: string;
businessContext: string;
research: string;
topic: string;
}): string {
const parts: string[] = [];
if (opts.contextPrompt) parts.push(opts.contextPrompt);
if (opts.businessContext) parts.push(`## 公司背景資料\n${opts.businessContext}`);
if (opts.research) parts.push(`## 網上研究資料(只作參考,唔好照抄)\n${opts.research}`);
parts.push(`你係香港村屋太陽能公司「盈豐太陽能」嘅 SEO 內容寫手。請就以下主題寫一篇 800–1200 字嘅繁體中文(香港廣東話書面語)博客文章。
主題:${opts.topic}
要求:
- 標題要放焦點關鍵字,首 100 字內再出現一次,關鍵字密度約 1–2%。
- 用 ## / ### 分段,段落清晰易讀。
- 開頭先畀一段總結(summary-first),再展開。
- 內容要對香港村屋太陽能實用、準確,唔好作出未經證實嘅承諾或數字。
- 適合 SEO 同 AI 搜尋(GEO):用問答式小標題、必要時用列表。
輸出格式(必須嚴格跟隨,唔要加額外說明):
TITLE: <文章標題>
CONTENT:
<Markdown 正文,唔需要重複標題>
META_DESC: <160 字以內 SEO 描述>`);
return parts.join("\n\n");
}
async function callChat(settings: AiSettings, apiKey: string, systemPrompt: string): Promise<string> {
const res = await fetch(`${settings.baseUrl.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: settings.model,
messages: [{ role: "user", content: systemPrompt }],
max_tokens: 2200,
temperature: 0.7,
}),
signal: AbortSignal.timeout(90_000),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`AI 供應商回應 ${res.status}${body.slice(0, 200)}`);
}
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const text = data.choices?.[0]?.message?.content ?? "";
if (!text) throw new Error("AI 冇回覆內容。");
return text;
}
export type ParsedGenerated =
| { ok: true; data: { title: string; content: string; metaDescription: string; excerpt: string } }
| { ok: false; error: string };
export function parseGeneratedPost(text: string): ParsedGenerated {
const normalized = text
.replace(/```[a-z]*\n?/gi, "")
.replace(/```/g, "")
.replace(/\*\*\s*(TITLE|CONTENT|META_DESC)\s*[:]\s*\*\*/gi, "$1:")
.replace(/__\s*(TITLE|CONTENT|META_DESC)\s*[:]\s*__/gi, "$1:")
.replace(/\*\*(TITLE|CONTENT|META_DESC)\*\*\s*[:]/gi, "$1:");
const titleMatch = normalized.match(/TITLE[:]\s*(.+)/i);
const contentMatch = normalized.match(/CONTENT[:]\s*([\s\S]*?)(?:\nMETA_DESC[:]|$)/i);
const metaMatch = normalized.match(/META_DESC[:]\s*([\s\S]+)/i);
const content = (contentMatch?.[1] ?? "").trim();
const metaDescription = (metaMatch?.[1] ?? "").trim().replace(/\s+/g, " ").slice(0, 160);
const candidate = {
title: (titleMatch?.[1] ?? "").trim(),
content,
metaDescription,
excerpt: metaDescription || autoExcerpt(content),
};
const parsed = aiGeneratedPost.safeParse(candidate);
if (!parsed.success) return { ok: false, error: "AI 輸出格式唔正確,請再試。" };
return { ok: true, data: parsed.data };
}
export type GenerateResult = { ok: true; postId: string } | { ok: false; error: string };
export async function generateBlogPost(
db: Db,
env: AppEnv,
input: { mode: "topic"; topic: string } | { mode: "next" },
): Promise<GenerateResult> {
const settings = await getAiSettings(db);
if (!settings.enabled) return { ok: false, error: "AI 生成未啟用。" };
const apiKey = env.AI_API_KEY;
if (!apiKey) return { ok: false, error: "未設定 AI_API_KEY。" };
let topic: string;
let keywordId: string | null = null;
if (input.mode === "next") {
const [next] = await db
.select()
.from(blogKeywords)
.where(eq(blogKeywords.status, "pending"))
.orderBy(asc(blogKeywords.createdAt))
.limit(1);
if (!next) return { ok: false, error: "關鍵字佇列冇 pending 項目。" };
topic = next.keyword;
keywordId = next.id;
} else {
topic = input.topic.trim();
if (!topic) return { ok: false, error: "請輸入主題。" };
}
let research = "";
if (settings.webSearchEnabled && env.TAVILY_API_KEY) {
research = await tavilyResearch(topic, env.TAVILY_API_KEY, settings.webSearchMaxResults);
}
let generated;
try {
const text = await callChat(
settings,
apiKey,
buildSystemPrompt({
contextPrompt: settings.contextPrompt,
businessContext: settings.businessContext,
research,
topic,
}),
);
const parsed = parseGeneratedPost(text);
if (!parsed.ok) return parsed;
generated = parsed.data;
} catch (err) {
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
return { ok: false, error: "AI 供應商回應逾時,請再試。" };
}
return { ok: false, error: err instanceof Error ? err.message : "生成失敗。" };
}
const now = new Date();
const id = crypto.randomUUID();
const excerpt = generated.excerpt || autoExcerpt(generated.content);
try {
const slug = await uniqueSlug(db, slugify(generated.title));
await db.insert(posts).values({
id,
slug,
title: generated.title,
excerpt,
content: generated.content,
coverImage: null,
tags: null,
focusKeyword: topic,
metaDescription: generated.metaDescription || excerpt.slice(0, 155),
status: "draft",
publishedAt: null,
updatedAt: now,
});
if (keywordId) {
await db
.update(blogKeywords)
.set({ status: "generated", usedAt: now, postId: id })
.where(eq(blogKeywords.id, keywordId));
}
} catch {
return { ok: false, error: "寫入草稿失敗,請再試。" };
}
return { ok: true, postId: id };
}
+2
View File
@@ -4,6 +4,8 @@ export type AppEnv = {
DB: D1Database;
CACHE: KVNamespace;
ADMIN_PASSWORD?: string;
AI_API_KEY?: string;
TAVILY_API_KEY?: string;
};
/**
+23
View File
@@ -0,0 +1,23 @@
import type { ZodType } from "zod";
export type ParseResult<T> =
| { ok: true; data: T }
| { ok: false; errors: Record<string, string> };
/** 將 FormData 抽成物件再交畀 Zod 驗證;錯誤以 field -> 訊息 回傳。 */
export function parseForm<T>(form: FormData, schema: ZodType<T>): ParseResult<T> {
const raw: Record<string, unknown> = {};
for (const [key, value] of form.entries()) {
if (typeof value === "string") raw[key] = value;
}
const result = schema.safeParse(raw);
if (result.success) return { ok: true, data: result.data };
const errors: Record<string, string> = {};
for (const issue of result.error.issues) {
const key = issue.path.join(".") || "_form";
if (!errors[key]) errors[key] = issue.message;
}
return { ok: false, errors };
}