import { PrismaClient } from "@prisma/client"; import { sanitizeHtml } from "./sanitize"; import { seedPosts, seedCategories, seedTags } from "@/data/posts"; // ── Prisma 单例 ── const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = prisma; } // ── 类型定义(保持不变) ── /** 统一的文章类型,前台与后台共用。 */ export interface Post { id: string; slug: string; title: string; excerpt: string; content: string; date: string; category: string; tags: string[]; /** 可选封面图,前台卡片可在有值时展示。 */ coverImage?: string; readingTime: number; featured: boolean; status: "draft" | "published"; createdAt: string; updatedAt: string; } export interface Category { id: string; name: string; description: string; } export interface Tag { id: string; name: string; } /** 文章可见性:前台只展示已发布。 */ export type PublicPost = Omit & { status: "published" }; // ── 内部工具 ── function generateId(): string { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); } /** 写入前对内容做净化,剥离任意可执行脚本。 */ function sanitizePostContent(data: T): T { if (typeof data.content === "string") { return { ...data, content: sanitizeHtml(data.content) }; } return data; } /** 将数据库行转为应用层 Post 类型(tags JSON → string[])。 */ function toPost(row: { id: string; slug: string; title: string; excerpt: string; content: string; date: string; category: string; tags: string; coverImage: string | null; readingTime: number; featured: boolean; status: string; createdAt: string; updatedAt: string; }): Post { return { ...row, coverImage: row.coverImage ?? undefined, tags: JSON.parse(row.tags) as string[], status: row.status as Post["status"], }; } // ── Posts ── /** 分页查询结果。 */ export interface PaginatedResult { data: T[]; total: number; page: number; pageSize: number; totalPages: number; } /** 后台用:读取全部文章(含草稿)。 */ export async function getPosts(): Promise { const rows = await prisma.post.findMany({ orderBy: { createdAt: "desc" } }); return rows.map(toPost); } /** 后台用:分页 + 搜索 + 排序查询。 */ export async function getPostsPaginated(options: { page?: number; pageSize?: number; status?: "draft" | "published"; search?: string; sortBy?: "date" | "createdAt" | "title" | "readingTime"; sortDir?: "asc" | "desc"; }): Promise> { const { page = 1, pageSize = 20, status, search, sortBy = "date", sortDir = "desc", } = options; const where: Record = {}; if (status) where.status = status; if (search) { where.OR = [ { title: { contains: search } }, { excerpt: { contains: search } }, { category: { contains: search } }, ]; } const [rows, total] = await Promise.all([ prisma.post.findMany({ where, orderBy: { [sortBy]: sortDir }, skip: (page - 1) * pageSize, take: pageSize, }), prisma.post.count({ where }), ]); return { data: rows.map(toPost), total, page, pageSize, totalPages: Math.ceil(total / pageSize), }; } /** 后台用:获取统计数据。 */ export async function getStats(): Promise<{ total: number; published: number; draft: number; featured: number; categories: number; tags: number; }> { const [total, published, draft, featured, categories, tags] = await Promise.all([ prisma.post.count(), prisma.post.count({ where: { status: "published" } }), prisma.post.count({ where: { status: "draft" } }), prisma.post.count({ where: { featured: true } }), prisma.category.count(), prisma.tag.count(), ]); return { total, published, draft, featured, categories, tags }; } /** 前台用:只返回已发布文章,按日期倒序。 */ export async function getPublishedPosts(): Promise { const rows = await prisma.post.findMany({ where: { status: "published" }, orderBy: { date: "desc" }, }); return rows.map(toPost) as PublicPost[]; } export async function getPost(id: string): Promise { const row = await prisma.post.findUnique({ where: { id } }); return row ? toPost(row) : undefined; } export async function getPostBySlug(slug: string): Promise { const row = await prisma.post.findFirst({ where: { slug, status: "published" }, }); return row ? (toPost(row) as PublicPost) : undefined; } /** 按分类名过滤已发布文章。 */ export async function getPostsByCategory(category: string): Promise { const rows = await prisma.post.findMany({ where: { category, status: "published" }, orderBy: { date: "desc" }, }); return rows.map(toPost) as PublicPost[]; } /** 按标签名过滤已发布文章。 */ export async function getPostsByTag(tag: string): Promise { // SQLite 的 tags 字段是 JSON 字符串,需要全量读取后在内存过滤 const all = await getPublishedPosts(); return all.filter((p) => p.tags.includes(tag)); } /** 全部已发布文章用到的标签及计数,按计数倒序。 */ export async function getAllTags(): Promise<{ name: string; count: number }[]> { const map = new Map(); for (const p of await getPublishedPosts()) { for (const t of p.tags) { map.set(t, (map.get(t) || 0) + 1); } } return [...map.entries()] .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "zh")); } /** 前台用分类列表(含文章计数,按计数倒序)。 */ export async function getPublicCategories(): Promise<(Category & { count: number })[]> { const cats = await getCategories(); const published = await getPublishedPosts(); return cats .map((c) => ({ ...c, count: published.filter((p) => p.category === c.name).length, })) .sort((a, b) => b.count - a.count); } export async function createPost( data: Omit ): Promise { const sanitized = sanitizePostContent(data); const now = new Date().toISOString(); const row = await prisma.post.create({ data: { id: generateId(), slug: sanitized.slug, title: sanitized.title, excerpt: sanitized.excerpt ?? "", content: sanitized.content, date: sanitized.date, category: sanitized.category, tags: JSON.stringify(sanitized.tags ?? []), coverImage: sanitized.coverImage ?? null, readingTime: sanitized.readingTime ?? 5, featured: sanitized.featured ?? false, status: sanitized.status ?? "draft", createdAt: now, updatedAt: now, }, }); return toPost(row); } export async function updatePost(id: string, data: Partial): Promise { const existing = await prisma.post.findUnique({ where: { id } }); if (!existing) return null; // 禁止通过 update 覆盖不可变字段 const { id: _id, createdAt: _createdAt, ...rest } = data; const sanitized = sanitizePostContent(rest); const updateData: Record = { updatedAt: new Date().toISOString() }; if (sanitized.slug !== undefined) updateData.slug = sanitized.slug; if (sanitized.title !== undefined) updateData.title = sanitized.title; if (sanitized.excerpt !== undefined) updateData.excerpt = sanitized.excerpt; if (sanitized.content !== undefined) updateData.content = sanitized.content; if (sanitized.date !== undefined) updateData.date = sanitized.date; if (sanitized.category !== undefined) updateData.category = sanitized.category; if (sanitized.tags !== undefined) updateData.tags = JSON.stringify(sanitized.tags); if (sanitized.coverImage !== undefined) updateData.coverImage = sanitized.coverImage; if (sanitized.readingTime !== undefined) updateData.readingTime = sanitized.readingTime; if (sanitized.featured !== undefined) updateData.featured = sanitized.featured; if (sanitized.status !== undefined) updateData.status = sanitized.status; const row = await prisma.post.update({ where: { id }, data: updateData }); return toPost(row); } export async function deletePost(id: string): Promise { try { await prisma.post.delete({ where: { id } }); return true; } catch { return false; } } // ── Categories ── export async function getCategories(): Promise { return prisma.category.findMany({ orderBy: { name: "asc" } }); } export async function createCategory(data: Omit): Promise { return prisma.category.create({ data: { id: generateId(), ...data }, }); } export async function updateCategory( id: string, data: Partial ): Promise { const existing = await prisma.category.findUnique({ where: { id } }); if (!existing) return null; const { id: _id, ...rest } = data; return prisma.category.update({ where: { id }, data: rest }); } export async function deleteCategory(id: string): Promise { try { await prisma.category.delete({ where: { id } }); return true; } catch { return false; } } // ── Tags ── export async function getTags(): Promise { return prisma.tag.findMany({ orderBy: { name: "asc" } }); } export async function createTag(data: Omit): Promise { return prisma.tag.create({ data: { id: generateId(), ...data }, }); } export async function deleteTag(id: string): Promise { try { await prisma.tag.delete({ where: { id } }); return true; } catch { return false; } } // ── Auto seed ── /** * 模块加载时确保种子数据存在。幂等:仅在数据库为空时写入。 * 这样首次运行 / 部署后无需手动执行 seed 脚本。 */ export async function ensureSeed(): Promise { const postCount = await prisma.post.count(); if (postCount === 0) { for (const p of seedPosts) { await createPost(p); } } const catCount = await prisma.category.count(); if (catCount === 0) { for (const c of seedCategories) { await createCategory(c); } } const tagCount = await prisma.tag.count(); if (tagCount === 0) { for (const t of seedTags) { await createTag({ name: t }); } } } // 模块加载时自动 seed(异步,不阻塞导入) ensureSeed().catch((err) => { console.error("[store] ensureSeed failed:", err); });