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:
+11
-3
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export type AppEnv = {
|
||||
DB: D1Database;
|
||||
CACHE: KVNamespace;
|
||||
ADMIN_PASSWORD?: string;
|
||||
AI_API_KEY?: string;
|
||||
TAVILY_API_KEY?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user