Files
2026-10-08 11:09:37 +08:00

387 lines
9.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 已被占用" },
]);
}
}