387 lines
9.9 KiB
TypeScript
387 lines
9.9 KiB
TypeScript
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 已被占用" },
|
||
]);
|
||
}
|
||
}
|