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 { 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 { 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`unnest(${postsTable.tags})`, }) .from(postsTable) .where(eq(postsTable.status, "published")); const counter = new Map(); 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 | 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 { 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 { 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 { if (await slugExists(slug, excludeSlug)) { throw ApiError.conflict(`slug "${slug}" 已存在,请换一个`, [ { field: "slug", message: "该 slug 已被占用" }, ]); } }