This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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 已被占用" },
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user