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

+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;
}