This commit is contained in:
z.ai committed 2026-10-08 11:09:37 +08:00
1 parent c1541b80df
commit b4c7908029
97 files changed
+12403 -154

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
import { NextResponse } from "next/server";
import { ZodError } from "zod";
/**
* 统一的 API 响应格式与错误码。
*
* 所有接口遵循同一契约,便于前端与脚本一致地处理:
*
* 成功:{ "data": ..., "meta"?: ... }
* 失败:{ "error": { "code": "...", "message": "...", "details"?: [...] } }
*
* skill 要求"显式优于隐式":错误码是机器可读的稳定标识,
* message 面向人,details 用于字段级校验错误。
*/
/** 机器可读的错误码 */
export const ErrorCode = {
VALIDATION_ERROR: "VALIDATION_ERROR",
NOT_FOUND: "NOT_FOUND",
CONFLICT: "CONFLICT",
BAD_REQUEST: "BAD_REQUEST",
INTERNAL_ERROR: "INTERNAL_ERROR",
METHOD_NOT_ALLOWED: "METHOD_NOT_ALLOWED",
SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
} as const;
export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode];
/** 错误码 → HTTP 状态码 */
const STATUS_BY_CODE: Record<ErrorCodeValue, number> = {
VALIDATION_ERROR: 400,
BAD_REQUEST: 400,
NOT_FOUND: 404,
CONFLICT: 409,
METHOD_NOT_ALLOWED: 405,
INTERNAL_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
};
export interface ApiErrorDetail {
/** 出错的字段路径,如 "slug" 或 "tags.0" */
field?: string;
message: string;
}
/** 业务异常:路由中抛出,由 withErrorHandling 统一转成响应 */
export class ApiError extends Error {
constructor(
readonly code: ErrorCodeValue,
message: string,
readonly details?: ApiErrorDetail[],
) {
super(message);
this.name = "ApiError";
}
static notFound(message = "资源不存在") {
return new ApiError(ErrorCode.NOT_FOUND, message);
}
static conflict(message: string, details?: ApiErrorDetail[]) {
return new ApiError(ErrorCode.CONFLICT, message, details);
}
static badRequest(message: string, details?: ApiErrorDetail[]) {
return new ApiError(ErrorCode.BAD_REQUEST, message, details);
}
static validation(message: string, details?: ApiErrorDetail[]) {
return new ApiError(ErrorCode.VALIDATION_ERROR, message, details);
}
}
/** 成功响应 */
export function ok<T>(data: T, meta?: Record<string, unknown>, status = 200) {
return NextResponse.json(meta ? { data, meta } : { data }, { status });
}
/** 创建成功(201) */
export function created<T>(data: T) {
return NextResponse.json({ data }, { status: 201 });
}
/** 无内容(删除成功) */
export function noContent() {
return new NextResponse(null, { status: 204 });
}
/** 失败响应 */
export function fail(
code: ErrorCodeValue,
message: string,
details?: ApiErrorDetail[],
) {
return NextResponse.json(
{ error: { code, message, ...(details ? { details } : {}) } },
{ status: STATUS_BY_CODE[code] },
);
}
/** 把 Zod 的校验错误转成字段级 details */
function zodToDetails(error: ZodError): ApiErrorDetail[] {
return error.issues.map((issue) => ({
field: issue.path.join(".") || undefined,
message: issue.message,
}));
}
/**
* 从错误对象中提取 Postgres 错误码。
*
* 关键点:Drizzle 会把驱动抛出的原始错误包在自己的 `DrizzleQueryError`
* 里,因此 `error.code` 是 undefined,真正的 PG 错误码在 `error.cause.code`。
* 这里沿 cause 链向下查找,最多 5 层,避免无限循环。
*/
function extractPostgresErrorCode(error: unknown): string | undefined {
let current = error;
for (let depth = 0; depth < 5 && current; depth++) {
const code = (current as { code?: unknown }).code;
// PG 错误码是 5 位数字字符串(如 23505);排除其他库的同名字段
if (typeof code === "string" && /^\d{5}$/.test(code)) {
return code;
}
current = (current as { cause?: unknown }).cause;
}
return undefined;
}
/**
* 路由处理器的错误包装器。
*
* 统一处理三类异常:
* 1. ApiError → 按错误码返回
* 2. ZodError → 400 + 字段级 details
* 3. 其他(含数据库错误)→ 500,并把细节写进服务端日志
*
* 数据库约束冲突单独识别为 4xx,这对 slug 重复是常见场景。
*/
export function withErrorHandling<Args extends unknown[]>(
handler: (...args: Args) => Promise<NextResponse>,
) {
return async (...args: Args): Promise<NextResponse> => {
try {
return await handler(...args);
} catch (error) {
if (error instanceof ApiError) {
return fail(error.code, error.message, error.details);
}
if (error instanceof ZodError) {
return fail(
ErrorCode.VALIDATION_ERROR,
"请求参数校验失败",
zodToDetails(error),
);
}
const pgCode = extractPostgresErrorCode(error);
// 唯一约束冲突
if (pgCode === "23505") {
return fail(ErrorCode.CONFLICT, "该 slug 已存在,请换一个", [
{ field: "slug", message: "该 slug 已被占用" },
]);
}
// CHECK 约束冲突(如非法 status/slug 格式)
if (pgCode === "23514") {
return fail(
ErrorCode.VALIDATION_ERROR,
"数据不满足数据库约束,请检查 status 与 slug 格式",
);
}
// 非空约束冲突
if (pgCode === "23502") {
return fail(ErrorCode.VALIDATION_ERROR, "缺少必填字段");
}
// 字符串长度超限
if (pgCode === "22001") {
return fail(ErrorCode.VALIDATION_ERROR, "字段长度超出限制");
}
console.error("[api] 未处理的异常:", error);
return fail(ErrorCode.INTERNAL_ERROR, "服务器内部错误");
}
};
}
/** 解析并校验 JSON 请求体 */
export async function parseJsonBody(request: Request): Promise<unknown> {
try {
return await request.json();
} catch {
throw ApiError.badRequest("请求体不是合法的 JSON");
}
}
+125
View File
@@ -0,0 +1,125 @@
import "server-only";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
/**
* 数据库客户端(node-postgres 连接池)。
*
* ## 为什么用 pg 而不是 neon-http
*
* 脚手架原本使用 `drizzle-orm/neon-http` + `@neondatabase/serverless`。
* 该驱动走的是 **Neon 的 HTTP 代理端点**,不是 Postgres 的 TCP 协议,
* 因此无法连接本地或自建的普通 Postgres。
*
* 本项目改用 `pg`(node-postgres),它既能连本地 Postgres,
* 也能连 Neon / Supabase / Railway 等任何标准 Postgres——
* 部署到 Neon 时只需保留其 TCP 连接串(`*.neon.tech` 的 5432 端口)。
*
* ## 连接池与 Next.js 开发模式
*
* Next.js 开发模式下模块会被热重载,若不缓存连接池,每次改动都会新建一批
* 连接,很快耗尽数据库的 max_connections。因此把 pool 挂到 globalThis 上做单例。
*/
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error(
"缺少环境变量 DATABASE_URL。请在 .env.local 中配置 Postgres 连接串," +
"例如:postgresql://user:password@localhost:5432/blog",
);
}
// 收窄为 string,供下方闭包使用(模块级已判空,但闭包内 TS 无法自动收窄)
const resolvedConnectionString: string = connectionString;
/** 在 globalThis 上缓存连接池,避免开发模式热重载导致的连接泄漏 */
const globalForDb = globalThis as unknown as {
__postgresPool?: Pool;
};
function createPool(): Pool {
const pool = new Pool({
connectionString: resolvedConnectionString,
// 本地开发(localhost)通常没配证书,强制 SSL 会直接失败;
// 托管数据库(Neon / Supabase 等)则要求 SSL。
ssl: shouldUseSsl(resolvedConnectionString)
? { rejectUnauthorized: false }
: undefined,
// 单实例默认 10 连接足够;Serverless 环境建议调小或接 Neon 的 pooler
max: Number(process.env.DATABASE_POOL_MAX ?? 10),
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 10_000,
// 语句级超时,避免慢查询长期占用连接
statement_timeout: Number(process.env.DATABASE_STATEMENT_TIMEOUT_MS ?? 15_000),
});
// 连接池错误不应导致进程崩溃(例如数据库重启期间的瞬时失败)
pool.on("error", (error) => {
console.error("[db] 空闲连接异常:", error.message);
});
return pool;
}
/** 判断是否需要启用 SSL:本地地址不启用,远端默认启用 */
function shouldUseSsl(url: string): boolean {
try {
const { hostname, searchParams } = new URL(url);
const sslmode = searchParams.get("sslmode");
// 显式声明优先
if (sslmode === "disable") {
return false;
}
if (sslmode === "require" || sslmode === "verify-full") {
return true;
}
// 未显式声明时,按主机推断
const isLocal =
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname.endsWith(".local");
return !isLocal;
} catch {
// URL 解析失败时交给 pg 自己报错,这里默认不启用 SSL
return false;
}
}
const pool = globalForDb.__postgresPool ?? createPool();
// 生产环境不需要挂载(模块不会热重载),但挂载也无副作用
globalForDb.__postgresPool = pool;
export const db = drizzle(pool, { schema });
/** 底层连接池,供健康检查与脚本使用 */
export { pool };
export type DB = typeof db;
/** 检查数据库连通性,用于健康检查接口 */
export async function checkDatabaseHealth(): Promise<{
ok: boolean;
latencyMs: number;
error?: string;
}> {
const startedAt = Date.now();
try {
await pool.query("select 1");
return { ok: true, latencyMs: Date.now() - startedAt };
} catch (error) {
return {
ok: false,
latencyMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error),
};
}
}
+107
View File
@@ -0,0 +1,107 @@
import {
pgTable,
text,
timestamp,
integer,
index,
uniqueIndex,
serial,
varchar,
} from "drizzle-orm/pg-core";
import { relations, sql } from "drizzle-orm";
/**
* 数据库模型。
*
* 设计要点:
* - `slug` 唯一且带索引:它是文章的唯一对外标识(URL 的一部分)。
* - `status` 用枚举字符串而非布尔:草稿/已发布之外,未来可能加归档。
* 这里用 varchar + CHECK 约束保证取值合法(Drizzle 的 pgEnum 也可,
* 但 enum 类型后续增删值需要 ALTER TYPE,字符串 + CHECK 更灵活)。
* - `tags` 使用 text[] 而非独立关联表:v0.1 的标签只需按包含查询,
* 数组 + GIN 索引即可满足;引入 tags 关联表会让读写都多一次 join。
* - 保留 `created_at` / `updated_at`,便于排序与增量同步。
* - `published_at` 单独存:草稿转发布时,发布时间不等于创建时间。
*/
export const posts = pgTable(
"posts",
{
id: serial("id").primaryKey(),
/** URL 标识,全局唯一,仅允许小写字母/数字/连字符 */
slug: varchar("slug", { length: 200 }).notNull(),
/** 标题 */
title: text("title").notNull(),
/** 摘要,用于卡片与 SEO description */
summary: text("summary").notNull(),
/** 正文(Markdown / MDX 源码) */
content: text("content").notNull(),
/** 标签数组 */
tags: text("tags")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
/** 状态:draft(草稿)| published(已发布) */
status: varchar("status", { length: 20 }).notNull().default("draft"),
/** 阅读时长(分钟),写入时计算并缓存,避免列表页重复解析正文 */
readingMinutes: integer("reading_minutes").notNull().default(1),
/** 封面图路径 */
cover: text("cover"),
/** 覆盖默认 SEO 描述 */
description: text("description"),
/** 发布时间;草稿为 null */
publishedAt: timestamp("published_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [
// slug 是 URL 标识,必须唯一;唯一索引同时承担查询加速
uniqueIndex("posts_slug_unique_idx").on(table.slug),
// 列表页主查询:按状态过滤 + 按发布时间倒序
index("posts_status_published_at_idx").on(
table.status,
table.publishedAt.desc(),
),
// 标签筛选:GIN 索引支持 `tags @> ARRAY['x']` 与 `'x' = ANY(tags)`
index("posts_tags_gin_idx").using("gin", table.tags),
],
);
/**
* 数据完整性约束(在迁移 SQL 中手工维护)。
*
* Drizzle 的 `pgTable` 第三个回调只支持索引类定义,`sql` 模板不会被生成为
* CHECK 约束。因此以下约束直接写在迁移文件里,并在应用层用 Zod 做同样的校验:
*
* - `posts_status_check` : status IN ('draft', 'published')
* - `posts_slug_format_check` : slug 匹配 ^[a-z0-9]+(-[a-z0-9]+)*$
*
* 注意:约束同时存在于数据库与应用层是有意为之——应用层给出友好报错,
* 数据库层保证任何写入路径(脚本、手工 SQL)都无法绕过。
*/
export const postsRelations = relations(posts, () => ({}));
/** 由 schema 推导的插入类型 */
export type NewPost = typeof posts.$inferInsert;
/** 由 schema 推导的查询类型 */
export type PostRow = typeof posts.$inferSelect;
+86
View File
@@ -0,0 +1,86 @@
import { Inter, Noto_Sans_SC, IBM_Plex_Mono } from "next/font/google";
/**
* 站点字体系统(next/font 自托管)。
*
* 设计规范(DESIGN.md)要求:
* - Inter → 英文 / UI 正文
* - Noto Sans SC → 中文
* - IBM Plex Mono → 代码(规范原文为 Geist Mono,本项目按需求改用 IBM Plex Mono)
*
* 说明:next/font/google 在 `next build` 阶段下载字体文件并自托管到
* `.next/static/media`,运行时不会向 Google 发起请求,也不产生 FOUT/CLS。
* 每个字体都显式配置 `fallback`(含 `adjustFontFallback`),保证在字体
* 尚未加载或加载失败时,回退栈与目标字体的度量尽量接近。
*/
/** 英文 / UI 主字体:Inter */
export const fontInter = Inter({
variable: "--font-inter",
subsets: ["latin", "latin-ext"],
weight: ["400", "500", "600", "700"],
style: ["normal"],
display: "swap",
preload: true,
fallback: [
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Helvetica Neue",
"Arial",
"sans-serif",
],
adjustFontFallback: true,
});
/**
* 中文字体:Noto Sans SC
*
* 该字族在 Google Fonts 上是按权重分片的(非可变字体),因此显式声明需要
* 的 4 个权重,由 next/font 生成 unicode-range 分片,浏览器只下载用到的子集。
*/
export const fontNotoSansSC = Noto_Sans_SC({
variable: "--font-noto-sans-sc",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
style: ["normal"],
display: "swap",
// 中文字体体积大、子集多,禁用预载以免抢占首屏关键资源。
preload: false,
fallback: [
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Source Han Sans SC",
"WenQuanYi Micro Hei",
"sans-serif",
],
adjustFontFallback: false,
});
/** 代码字体:IBM Plex Mono */
export const fontIbmPlexMono = IBM_Plex_Mono({
variable: "--font-ibm-plex-mono",
subsets: ["latin", "latin-ext"],
weight: ["400", "500", "600"],
style: ["normal"],
display: "swap",
preload: true,
fallback: [
"SFMono-Regular",
"SF Mono",
"Menlo",
"Consolas",
"Liberation Mono",
"Courier New",
"monospace",
],
adjustFontFallback: true,
});
/** 挂到 <html> 上的字体变量类名。 */
export const fontVariables = [
fontInter.variable,
fontNotoSansSC.variable,
fontIbmPlexMono.variable,
].join(" ");
+71
View File
@@ -0,0 +1,71 @@
import "server-only";
import { compileMDX } from "next-mdx-remote/rsc";
import rehypePrettyCode, {
type Options as PrettyCodeOptions,
} from "rehype-pretty-code";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import remarkGfm from "remark-gfm";
import { mdxComponents } from "@/components/mdx/mdx-components";
/**
* MDX 编译管线。
*
* 插件顺序(rehype 在 remark 之后执行):
* 1. remark-gfm → 表格、任务列表、删除线
* 2. rehype-slug → 为标题生成 id(TOC 锚点依赖它)
* 3. rehype-autolink-headings → 标题旁生成可点击的 # 锚点
* 4. rehype-pretty-code → 基于 Shiki 的语法高亮 + 行号/高亮行
*
* Shiki 在构建期完成高亮,输出静态 HTML,运行时零 JS 开销。
*/
/** 代码高亮主题:深色 surface-code (#1c1c1e) 上对比度良好 */
const CODE_THEME = "github-dark-default";
const prettyCodeOptions: PrettyCodeOptions = {
theme: CODE_THEME,
// 保留默认的 pre/code 结构,样式由 globals.css 的 .prose-blog 控制
keepBackground: true,
defaultLang: "plaintext",
};
export interface CompiledMdx {
content: React.ReactElement;
}
/** 编译 MDX 正文为可渲染的 React 元素树 */
export async function compilePostMdx(source: string): Promise<CompiledMdx> {
const { content } = await compileMDX({
source,
components: mdxComponents,
options: {
parseFrontmatter: false,
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: "append",
properties: {
className: "heading-anchor",
ariaLabel: "锚点链接",
tabIndex: -1,
},
content: {
type: "text",
value: "#",
},
},
],
[rehypePrettyCode, prettyCodeOptions],
],
},
},
});
return { content };
}
+48
View File
@@ -0,0 +1,48 @@
import "server-only";
import {
listAllPublishedPosts,
getPostBySlug as getPostBySlugFromDb,
getAdjacentPosts as getAdjacentPostsFromDb,
listTags,
getPostStats,
} from "./repository";
import type { Post, PostMeta } from "./types";
/**
* 内容读取门面(服务端)。
*
* 页面层只依赖本模块,不直接接触仓储实现。这样做的价值:
* 数据来源从「文件系统」切换到「PostgreSQL」时,**页面代码一行都不用改**。
*
* 所有函数默认只返回已发布文章(草稿在仓储层被过滤),
* 因此草稿既不出现在列表里,也无法通过直接访问 URL 读到。
*/
export type { Post, PostMeta } from "./types";
/** 全部已发布文章,按发布日期倒序 */
export async function getAllPosts(): Promise<PostMeta[]> {
return listAllPublishedPosts();
}
/** 按 slug 读取单篇文章(含正文);草稿与不存在的 slug 均返回 null */
export async function getPostBySlug(slug: string): Promise<Post | null> {
const post = await getPostBySlugFromDb(slug);
return post as Post | null;
}
/** 相邻文章(详情页上下篇导航) */
export async function getAdjacentPosts(slug: string) {
return getAdjacentPostsFromDb(slug);
}
/** 全部标签及其文章数 */
export async function getAllTags(): Promise<{ tag: string; count: number }[]> {
return listTags();
}
/** 站点统计(已发布数 / 草稿数 / 标签数) */
export async function getStats() {
return getPostStats();
}
+386
View File
@@ -0,0 +1,386 @@
import "server-only";
import { and, desc, eq, sql, count } from "drizzle-orm";
import { db } from "@/lib/db";
import { posts as postsTable, type PostRow } from "@/lib/db/schema";
import { estimateReadingMinutes } from "@/lib/validation/post";
import { ApiError } from "@/lib/api/response";
import type { PostFrontmatter, PostMeta } from "@/lib/posts/types";
/**
* 文章仓储层。
*
* 职责边界:
* - 只做数据访问与行↔领域对象映射,**不含** HTTP 概念(状态码、请求解析)
* - 草稿过滤规则集中在这里:默认查询永远排除草稿
*
* 页面(Server Component)与 API 路由共用本模块,保证两处行为一致。
*/
/** 数据库行 → 领域对象 */
function toPostMeta(row: PostRow): PostMeta {
return {
slug: row.slug,
title: row.title,
// 对外仍用 YYYY-MM-DD,保持与原有 frontmatter 契约一致
date: (row.publishedAt ?? row.createdAt).toISOString().slice(0, 10),
summary: row.summary,
tags: row.tags ?? [],
draft: row.status === "draft",
cover: row.cover ?? undefined,
description: row.description ?? undefined,
readingMinutes: row.readingMinutes,
};
}
export interface ListPostsOptions {
limit?: number;
offset?: number;
tag?: string;
/** 是否包含草稿;默认 false */
includeDrafts?: boolean;
/** 只看草稿 */
draftsOnly?: boolean;
/** 显式指定状态;优先级高于 includeDrafts / draftsOnly */
status?: "draft" | "published";
}
export interface ListPostsResult {
items: PostMeta[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
}
/** 构造状态过滤条件 */
function statusCondition(options: ListPostsOptions) {
// 显式 status 优先级最高
if (options.status) {
return eq(postsTable.status, options.status);
}
if (options.draftsOnly) {
return eq(postsTable.status, "draft");
}
if (options.includeDrafts) {
return undefined;
}
return eq(postsTable.status, "published");
}
/**
* 查询文章列表(不含正文),按发布时间倒序。
*
* 分页用 limit/offset:v0.1 数据量小,offset 分页足够且实现简单。
* 数据量增大后应改游标分页(基于 published_at + id),接口形状可保持不变。
*/
export async function listPosts(
options: ListPostsOptions = {},
): Promise<ListPostsResult> {
const limit = Math.min(Math.max(options.limit ?? 20, 1), 100);
const offset = Math.max(options.offset ?? 0, 0);
const conditions = [statusCondition(options)];
if (options.tag) {
// 使用 GIN 索引可加速的数组包含判断
conditions.push(sql`${postsTable.tags} @> ARRAY[${options.tag}]::text[]`);
}
const where = and(...conditions.filter(Boolean));
const [rows, totalResult] = await Promise.all([
db
.select()
.from(postsTable)
.where(where)
// NULLS LAST 保证草稿(published_at 为空)排在已发布之后
.orderBy(
sql`${postsTable.publishedAt} DESC NULLS LAST`,
desc(postsTable.createdAt),
)
.limit(limit)
.offset(offset),
db.select({ value: count() }).from(postsTable).where(where),
]);
const total = totalResult[0]?.value ?? 0;
return {
items: rows.map(toPostMeta),
total,
limit,
offset,
hasMore: offset + rows.length < total,
};
}
/** 列出全部已发布文章(用于首页与 sitemap),不分页 */
export async function listAllPublishedPosts(): Promise<PostMeta[]> {
const result = await listPosts({ limit: 100 });
return result.items;
}
/** 数据库行 → 完整文章(含正文) */
function toPost(row: PostRow): PostFrontmatter & {
slug: string;
content: string;
readingMinutes: number;
} {
const meta = toPostMeta(row);
return {
slug: row.slug,
title: meta.title,
date: meta.date,
summary: meta.summary,
tags: meta.tags,
draft: meta.draft,
cover: meta.cover,
description: meta.description,
content: row.content,
readingMinutes: row.readingMinutes,
};
}
/**
* 按 slug 读取单篇文章(含正文)。
*
* 草稿视为不存在(返回 null),因此既不出现在列表里,
* 也无法通过直接访问 URL 读到。
*/
export async function getPostBySlug(slug: string) {
const rows = await db
.select()
.from(postsTable)
.where(and(eq(postsTable.slug, slug), eq(postsTable.status, "published")))
.limit(1);
return rows.length > 0 ? toPost(rows[0]) : null;
}
/** 管理用途:按 slug 读取,包含草稿 */
export async function getPostBySlugIncludingDrafts(slug: string) {
const rows = await db
.select()
.from(postsTable)
.where(eq(postsTable.slug, slug))
.limit(1);
return rows.length > 0 ? toPost(rows[0]) : null;
}
/** 相邻文章(用于详情页上下篇导航) */
export async function getAdjacentPosts(slug: string): Promise<{
previous: PostMeta | null;
next: PostMeta | null;
}> {
const all = await listAllPublishedPosts();
const index = all.findIndex((post) => post.slug === slug);
if (index === -1) {
return { previous: null, next: null };
}
return {
// 列表按发布时间倒序:索引更小 = 更新
previous: index > 0 ? all[index - 1] : null,
next: index < all.length - 1 ? all[index + 1] : null,
};
}
/** 标签聚合:返回标签及其文章数,按出现次数倒序 */
export async function listTags(): Promise<{ tag: string; count: number }[]> {
const rows = await db
.select({
tag: sql<string>`unnest(${postsTable.tags})`,
})
.from(postsTable)
.where(eq(postsTable.status, "published"));
const counter = new Map<string, number>();
for (const row of rows) {
counter.set(row.tag, (counter.get(row.tag) ?? 0) + 1);
}
return [...counter.entries()]
.map(([tag, value]) => ({ tag, count: value }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
}
export interface CreatePostData {
slug: string;
title: string;
summary: string;
content: string;
tags: string[];
status: "draft" | "published";
publishedAt?: Date;
cover?: string | null;
description?: string | null;
}
/** 新建文章 */
export async function createPost(data: CreatePostData) {
const now = new Date();
// 发布状态必须有发布时间:未显式提供则用当前时间
const publishedAt =
data.status === "published" ? (data.publishedAt ?? now) : null;
const rows = await db
.insert(postsTable)
.values({
slug: data.slug,
title: data.title,
summary: data.summary,
content: data.content,
tags: data.tags,
status: data.status,
publishedAt,
cover: data.cover ?? null,
description: data.description ?? null,
readingMinutes: estimateReadingMinutes(data.content),
createdAt: now,
updatedAt: now,
})
.returning();
return toPost(rows[0]);
}
export interface UpdatePostData {
slug?: string;
title?: string;
summary?: string;
content?: string;
tags?: string[];
status?: "draft" | "published";
publishedAt?: Date;
cover?: string | null;
description?: string | null;
}
/**
* 更新文章。
*
* 状态流转规则(有意显式处理,避免隐式副作用):
* - draft → published:若没有 publishedAt,则补上当前时间
* - published → draft:清空 publishedAt(草稿没有发布时间)
*/
export async function updatePost(
slug: string,
data: UpdatePostData,
): Promise<ReturnType<typeof toPost> | null> {
const existing = await db
.select()
.from(postsTable)
.where(eq(postsTable.slug, slug))
.limit(1);
if (existing.length === 0) {
return null;
}
const current = existing[0];
const nextStatus = data.status ?? current.status;
let publishedAt = current.publishedAt;
if (data.publishedAt !== undefined) {
publishedAt = data.publishedAt;
} else if (nextStatus === "published" && !current.publishedAt) {
publishedAt = new Date();
} else if (nextStatus === "draft") {
publishedAt = null;
}
const rows = await db
.update(postsTable)
.set({
...(data.slug !== undefined ? { slug: data.slug } : {}),
...(data.title !== undefined ? { title: data.title } : {}),
...(data.summary !== undefined ? { summary: data.summary } : {}),
...(data.content !== undefined
? {
content: data.content,
// 正文变了,阅读时长要重算
readingMinutes: estimateReadingMinutes(data.content),
}
: {}),
...(data.tags !== undefined ? { tags: data.tags } : {}),
...(data.status !== undefined ? { status: data.status } : {}),
...(data.cover !== undefined ? { cover: data.cover } : {}),
...(data.description !== undefined
? { description: data.description }
: {}),
publishedAt,
updatedAt: new Date(),
})
.where(eq(postsTable.slug, slug))
.returning();
return rows.length > 0 ? toPost(rows[0]) : null;
}
/** 删除文章;返回是否真的删除了 */
export async function deletePost(slug: string): Promise<boolean> {
const rows = await db
.delete(postsTable)
.where(eq(postsTable.slug, slug))
.returning({ id: postsTable.id });
return rows.length > 0;
}
/** slug 是否已存在(可排除某个 slug,用于更新时判重) */
export async function slugExists(
slug: string,
excludeSlug?: string,
): Promise<boolean> {
const rows = await db
.select({ slug: postsTable.slug })
.from(postsTable)
.where(eq(postsTable.slug, slug))
.limit(1);
if (rows.length === 0) {
return false;
}
return rows[0].slug !== excludeSlug;
}
/** 统计信息(首页展示用) */
export async function getPostStats(): Promise<{
published: number;
drafts: number;
tags: number;
}> {
const [publishedRows, draftRows, tags] = await Promise.all([
db
.select({ value: count() })
.from(postsTable)
.where(eq(postsTable.status, "published")),
db
.select({ value: count() })
.from(postsTable)
.where(eq(postsTable.status, "draft")),
listTags(),
]);
return {
published: publishedRows[0]?.value ?? 0,
drafts: draftRows[0]?.value ?? 0,
tags: tags.length,
};
}
/** 确保 slug 不存在,否则抛 409 */
export async function assertSlugAvailable(
slug: string,
excludeSlug?: string,
): Promise<void> {
if (await slugExists(slug, excludeSlug)) {
throw ApiError.conflict(`slug "${slug}" 已存在,请换一个`, [
{ field: "slug", message: "该 slug 已被占用" },
]);
}
}
+40
View File
@@ -0,0 +1,40 @@
/**
* 文章领域类型。
*
* 数据源已从 `content/posts/*.mdx` 文件迁移到 PostgreSQL 的 `posts` 表。
* 为了不改动页面层与 MDX 渲染管线,这里保留了原有的字段命名
* (`date`、`draft` 等),由 `lib/posts/repository.ts` 负责
* 在数据库行与这些类型之间做映射。
*/
/** 文章的公共元信息 */
export interface PostFrontmatter {
/** 标题 */
title: string;
/** 发布日期,格式 YYYY-MM-DD */
date: string;
/** 摘要,用于卡片与 SEO description */
summary: string;
/** 标签 */
tags?: string[];
/** 是否为草稿;为 true 时不参与任何列表、详情与 sitemap 输出 */
draft?: boolean;
/** 封面图路径 */
cover?: string;
/** 覆盖默认 SEO 描述 */
description?: string;
}
/** 列表页使用的文章摘要信息(不含正文) */
export interface PostMeta extends PostFrontmatter {
/** URL 标识 */
slug: string;
/** 阅读时长(分钟,取整,最少 1) */
readingMinutes: number;
}
/** 详情页使用的完整文章 */
export interface Post extends PostMeta {
/** Markdown / MDX 正文(未编译) */
content: string;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* 站点级配置(单一事实来源)。
*
* 所有页面的 metadata、sitemap、robots、页头页脚都从这里取值,
* 避免站点名称 / 域名散落在多个文件中。
*/
export const siteConfig = {
/** 站点名称 */
name: "个人博客",
/** 站点英文标识,用于 Logo 与 SEO */
nameEn: "Inkwell",
/** 一句话简介 */
tagline: "记录工程实践与思考",
/** 站点描述(用于首页 metadata 与 OG) */
description:
"一个使用 Next.js 构建的个人博客,记录前端工程、系统设计与日常思考。支持 Markdown / MDX 写作与代码高亮。",
/** 作者信息 */
author: {
name: "站长",
email: "hello@example.com",
bio: "前端工程师,关注 Web 性能、开发者体验与设计系统。相信好的工具能让复杂的事情变得简单。",
location: "中国 · 杭州",
jobTitle: "前端工程师",
},
/** 社交 / 联系方式(关于页展示) */
social: [
{ label: "GitHub", href: "https://github.com", icon: "github" as const },
{ label: "X / Twitter", href: "https://x.com", icon: "twitter" as const },
{ label: "Email", href: "mailto:hello@example.com", icon: "mail" as const },
],
/** 部署后的站点地址,用于 sitemap / robots / 绝对 URL */
url: process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com",
/** 默认 OG 图 */
ogImage: "/og.svg",
} as const;
export type SiteConfig = typeof siteConfig;
+103
View File
@@ -0,0 +1,103 @@
import "server-only";
/**
* 从 MDX 原文中提取标题,生成目录(TOC)。
*
* 说明:这里用正则扫描 Markdown 标题而不是解析 AST——因为标题锚点 id
* 由 rehype-slug 生成,其算法是 GitHub 风格的 slugify。为了与渲染结果
* 完全一致,本模块实现了同一套规则(转小写、去标点、空格转连字符)。
*
* 只提取 h2 / h3:文档式阅读下超过三级的目录反而降低可扫读性。
*/
export interface TocItem {
/** 标题文本(已去除行内 Markdown 标记) */
title: string;
/** 与 rehype-slug 生成结果一致的锚点 id */
id: string;
/** 标题层级:2 或 3 */
level: 2 | 3;
}
/**
* GitHub 风格 slugify,与 rehype-slug 的默认行为保持一致。
*
* - 转小写
* - 移除除字母、数字、空格、连字符、下划线、CJK 之外的字符
* - 空格转连字符
*/
export function slugifyHeading(text: string): string {
return text
.trim()
.toLowerCase()
.replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, "")
.replace(/\s+/g, "-");
}
/** 去掉标题中的行内标记:**粗体**、`代码`、[链接](url) */
function stripInlineMarkdown(text: string): string {
return text
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // [文本](链接) → 文本
.replace(/[*_`~]/g, "") // 强调与行内代码标记
.trim();
}
/**
* 提取目录项。
*
* 跳过代码块内的 `#` 注释,避免把代码里的注释误当作标题。
*/
export function extractToc(source: string): TocItem[] {
const items: TocItem[] = [];
const slugCounter = new Map<string, number>();
let insideCodeFence = false;
let fenceMarker = "";
for (const rawLine of source.split("\n")) {
const line = rawLine.trimEnd();
// 跟踪代码块边界(``` 或 ~~~)
const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(line);
if (fenceMatch) {
const marker = fenceMatch[1][0];
if (!insideCodeFence) {
insideCodeFence = true;
fenceMarker = marker;
} else if (marker === fenceMarker) {
insideCodeFence = false;
fenceMarker = "";
}
continue;
}
if (insideCodeFence) {
continue;
}
// ATX 标题:## 标题 / ### 标题
const headingMatch = /^(#{2,3})\s+(.+?)\s*#*\s*$/.exec(line);
if (!headingMatch) {
continue;
}
const level = headingMatch[1].length as 2 | 3;
const title = stripInlineMarkdown(headingMatch[2]);
if (!title) {
continue;
}
// 处理重复标题:rehype-slug 会为同名标题追加 -1、-2 …
const baseId = slugifyHeading(title);
const seen = slugCounter.get(baseId) ?? 0;
slugCounter.set(baseId, seen + 1);
const id = seen === 0 ? baseId : `${baseId}-${seen}`;
items.push({ title, id, level });
}
return items;
}
+29
View File
@@ -0,0 +1,29 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** 合并 className:clsx 处理条件类,tailwind-merge 消解冲突的 Tailwind 类 */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/** 将日期格式化为中文可读形式,如 2025 年 3 月 8 日 */
export function formatDate(date: string, locale = "zh-CN"): string {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
}).format(new Date(`${date}T00:00:00Z`));
}
/** 机器可读日期,用于 <time dateTime> */
export function toDateTimeAttribute(date: string): string {
return new Date(`${date}T00:00:00Z`).toISOString();
}
/** 拼接绝对 URL,用于 canonical / OG / sitemap */
export function absoluteUrl(base: string, pathname: string): string {
const base$ = base.replace(/\/+$/, "");
const path$ = pathname.startsWith("/") ? pathname : `/${pathname}`;
return `${base$}${path$}`;
}
+139
View File
@@ -0,0 +1,139 @@
import { z } from "zod";
/**
* 文章的输入校验契约(单一事实来源)。
*
* API 路由、seed 脚本、未来的后台表单都复用这套 schema,
* 避免"接口校验一套、脚本又一套"导致的不一致。
*/
/** slug:小写字母/数字,用连字符分隔,与数据库 CHECK 约束保持一致 */
export const slugSchema = z
.string()
.trim()
.min(1, "slug 不能为空")
.max(200, "slug 最长 200 个字符")
.regex(
/^[a-z0-9]+(-[a-z0-9]+)*$/,
"slug 只能包含小写字母、数字和连字符,且不能以连字符开头或结尾",
);
/** 状态枚举,与数据库 CHECK 约束保持一致 */
export const postStatusSchema = z.enum(["draft", "published"]);
/** 标签:去空白、去重、限制数量与长度 */
export const tagsSchema = z
.array(z.string().trim().min(1).max(40, "单个标签最长 40 个字符"))
.max(10, "最多 10 个标签")
.default([])
.transform((tags) => [...new Set(tags)]);
/** 日期:接受 YYYY-MM-DD 或完整 ISO 字符串,统一转为 Date */
export const dateInputSchema = z
.string()
.trim()
.min(1)
.refine((value) => !Number.isNaN(new Date(value).getTime()), {
message: "不是合法的日期",
})
.transform((value) => new Date(value));
/** 创建文章 */
export const createPostSchema = z.object({
slug: slugSchema,
title: z.string().trim().min(1, "标题不能为空").max(200, "标题最长 200 个字符"),
summary: z
.string()
.trim()
.min(1, "摘要不能为空")
.max(500, "摘要最长 500 个字符"),
content: z.string().min(1, "正文不能为空"),
tags: tagsSchema,
status: postStatusSchema.default("draft"),
/** 仅当 status=published 时有意义;不传则用当前时间 */
publishedAt: dateInputSchema.optional(),
cover: z.string().trim().min(1).nullish(),
description: z.string().trim().max(500).nullish(),
});
/**
* 更新文章:所有字段可选,但至少要传一个。
*
* ⚠️ `.partial()` 的陷阱:它只把字段变成可选,**不会移除 `.default()`**。
* `createPostSchema` 里 `tags` 有 `.default([])`、`status` 有 `.default("draft")`,
* 于是 `PATCH {}` 会被解析成 `{ tags: [], status: "draft" }`,
* 既绕过了"至少传一个字段"的校验,又会在用户只想改标题时
* 意外把文章状态重置为草稿。
*
* 因此这里基于 **字段定义** 重建 schema:所有字段都显式声明为 `.optional()`,
* 不带任何默认值。未传 = 不修改。
*/
export const updatePostSchema = z
.object({
slug: slugSchema.optional(),
title: z
.string()
.trim()
.min(1, "标题不能为空")
.max(200, "标题最长 200 个字符")
.optional(),
summary: z
.string()
.trim()
.min(1, "摘要不能为空")
.max(500, "摘要最长 500 个字符")
.optional(),
content: z.string().min(1, "正文不能为空").optional(),
tags: z
.array(z.string().trim().min(1).max(40, "单个标签最长 40 个字符"))
.max(10, "最多 10 个标签")
.transform((tags) => [...new Set(tags)])
.optional(),
status: postStatusSchema.optional(),
publishedAt: dateInputSchema.optional(),
cover: z.string().trim().min(1).nullish(),
description: z.string().trim().max(500).nullish(),
})
// 至少要有一个"真正传了值"的键(nullish 字段传 null 也算显式修改)
.refine((data) => Object.keys(data).length > 0, {
message: "至少需要提供一个要更新的字段",
});
/** 列表查询参数 */
export const listPostsQuerySchema = z.object({
/** 返回数量,1–100 */
limit: z.coerce.number().int().min(1).max(100).default(20),
/** 偏移量,用于分页 */
offset: z.coerce.number().int().min(0).default(0),
/** 按标签过滤 */
tag: z.string().trim().min(1).optional(),
/** 按状态过滤;不传则默认只返回已发布 */
status: postStatusSchema.optional(),
/** 是否包含草稿(仅用于内部/管理场景) */
includeDrafts: z
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true"),
});
export type CreatePostInput = z.infer<typeof createPostSchema>;
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
export type ListPostsQuery = z.infer<typeof listPostsQuerySchema>;
/**
* 估算阅读时长(分钟)。
* 中文按字符计(约 350 字/分钟),英文按单词计(约 200 词/分钟)。
*
* 写入时计算并缓存到 reading_minutes 列,避免列表页反复解析正文。
* 与 `lib/posts/reading-time.ts` 共用同一实现,保证行为一致。
*/
export function estimateReadingMinutes(content: string): number {
const chineseCharacters = content.match(/[\u4e00-\u9fa5]/g)?.length ?? 0;
const latinWords =
content
.replace(/[\u4e00-\u9fa5]/g, " ")
.match(/[A-Za-z0-9]+/g)?.length ?? 0;
const minutes = chineseCharacters / 350 + latinWords / 200;
return Math.max(1, Math.round(minutes));
}