This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
+125
@@ -0,0 +1,125 @@
|
||||
import "server-only";
|
||||
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import * as schema from "./schema";
|
||||
|
||||
/**
|
||||
* 数据库客户端(node-postgres 连接池)。
|
||||
*
|
||||
* ## 为什么用 pg 而不是 neon-http
|
||||
*
|
||||
* 脚手架原本使用 `drizzle-orm/neon-http` + `@neondatabase/serverless`。
|
||||
* 该驱动走的是 **Neon 的 HTTP 代理端点**,不是 Postgres 的 TCP 协议,
|
||||
* 因此无法连接本地或自建的普通 Postgres。
|
||||
*
|
||||
* 本项目改用 `pg`(node-postgres),它既能连本地 Postgres,
|
||||
* 也能连 Neon / Supabase / Railway 等任何标准 Postgres——
|
||||
* 部署到 Neon 时只需保留其 TCP 连接串(`*.neon.tech` 的 5432 端口)。
|
||||
*
|
||||
* ## 连接池与 Next.js 开发模式
|
||||
*
|
||||
* Next.js 开发模式下模块会被热重载,若不缓存连接池,每次改动都会新建一批
|
||||
* 连接,很快耗尽数据库的 max_connections。因此把 pool 挂到 globalThis 上做单例。
|
||||
*/
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error(
|
||||
"缺少环境变量 DATABASE_URL。请在 .env.local 中配置 Postgres 连接串," +
|
||||
"例如:postgresql://user:password@localhost:5432/blog",
|
||||
);
|
||||
}
|
||||
|
||||
// 收窄为 string,供下方闭包使用(模块级已判空,但闭包内 TS 无法自动收窄)
|
||||
const resolvedConnectionString: string = connectionString;
|
||||
|
||||
/** 在 globalThis 上缓存连接池,避免开发模式热重载导致的连接泄漏 */
|
||||
const globalForDb = globalThis as unknown as {
|
||||
__postgresPool?: Pool;
|
||||
};
|
||||
|
||||
function createPool(): Pool {
|
||||
const pool = new Pool({
|
||||
connectionString: resolvedConnectionString,
|
||||
// 本地开发(localhost)通常没配证书,强制 SSL 会直接失败;
|
||||
// 托管数据库(Neon / Supabase 等)则要求 SSL。
|
||||
ssl: shouldUseSsl(resolvedConnectionString)
|
||||
? { rejectUnauthorized: false }
|
||||
: undefined,
|
||||
// 单实例默认 10 连接足够;Serverless 环境建议调小或接 Neon 的 pooler
|
||||
max: Number(process.env.DATABASE_POOL_MAX ?? 10),
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
// 语句级超时,避免慢查询长期占用连接
|
||||
statement_timeout: Number(process.env.DATABASE_STATEMENT_TIMEOUT_MS ?? 15_000),
|
||||
});
|
||||
|
||||
// 连接池错误不应导致进程崩溃(例如数据库重启期间的瞬时失败)
|
||||
pool.on("error", (error) => {
|
||||
console.error("[db] 空闲连接异常:", error.message);
|
||||
});
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
/** 判断是否需要启用 SSL:本地地址不启用,远端默认启用 */
|
||||
function shouldUseSsl(url: string): boolean {
|
||||
try {
|
||||
const { hostname, searchParams } = new URL(url);
|
||||
const sslmode = searchParams.get("sslmode");
|
||||
|
||||
// 显式声明优先
|
||||
if (sslmode === "disable") {
|
||||
return false;
|
||||
}
|
||||
if (sslmode === "require" || sslmode === "verify-full") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 未显式声明时,按主机推断
|
||||
const isLocal =
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname.endsWith(".local");
|
||||
|
||||
return !isLocal;
|
||||
} catch {
|
||||
// URL 解析失败时交给 pg 自己报错,这里默认不启用 SSL
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const pool = globalForDb.__postgresPool ?? createPool();
|
||||
|
||||
// 生产环境不需要挂载(模块不会热重载),但挂载也无副作用
|
||||
globalForDb.__postgresPool = pool;
|
||||
|
||||
export const db = drizzle(pool, { schema });
|
||||
|
||||
/** 底层连接池,供健康检查与脚本使用 */
|
||||
export { pool };
|
||||
|
||||
export type DB = typeof db;
|
||||
|
||||
/** 检查数据库连通性,用于健康检查接口 */
|
||||
export async function checkDatabaseHealth(): Promise<{
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
await pool.query("select 1");
|
||||
return { ok: true, latencyMs: Date.now() - startedAt };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
index,
|
||||
uniqueIndex,
|
||||
serial,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* 数据库模型。
|
||||
*
|
||||
* 设计要点:
|
||||
* - `slug` 唯一且带索引:它是文章的唯一对外标识(URL 的一部分)。
|
||||
* - `status` 用枚举字符串而非布尔:草稿/已发布之外,未来可能加归档。
|
||||
* 这里用 varchar + CHECK 约束保证取值合法(Drizzle 的 pgEnum 也可,
|
||||
* 但 enum 类型后续增删值需要 ALTER TYPE,字符串 + CHECK 更灵活)。
|
||||
* - `tags` 使用 text[] 而非独立关联表:v0.1 的标签只需按包含查询,
|
||||
* 数组 + GIN 索引即可满足;引入 tags 关联表会让读写都多一次 join。
|
||||
* - 保留 `created_at` / `updated_at`,便于排序与增量同步。
|
||||
* - `published_at` 单独存:草稿转发布时,发布时间不等于创建时间。
|
||||
*/
|
||||
|
||||
export const posts = pgTable(
|
||||
"posts",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
|
||||
/** URL 标识,全局唯一,仅允许小写字母/数字/连字符 */
|
||||
slug: varchar("slug", { length: 200 }).notNull(),
|
||||
|
||||
/** 标题 */
|
||||
title: text("title").notNull(),
|
||||
|
||||
/** 摘要,用于卡片与 SEO description */
|
||||
summary: text("summary").notNull(),
|
||||
|
||||
/** 正文(Markdown / MDX 源码) */
|
||||
content: text("content").notNull(),
|
||||
|
||||
/** 标签数组 */
|
||||
tags: text("tags")
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`ARRAY[]::text[]`),
|
||||
|
||||
/** 状态:draft(草稿)| published(已发布) */
|
||||
status: varchar("status", { length: 20 }).notNull().default("draft"),
|
||||
|
||||
/** 阅读时长(分钟),写入时计算并缓存,避免列表页重复解析正文 */
|
||||
readingMinutes: integer("reading_minutes").notNull().default(1),
|
||||
|
||||
/** 封面图路径 */
|
||||
cover: text("cover"),
|
||||
|
||||
/** 覆盖默认 SEO 描述 */
|
||||
description: text("description"),
|
||||
|
||||
/** 发布时间;草稿为 null */
|
||||
publishedAt: timestamp("published_at", { withTimezone: true }),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
// slug 是 URL 标识,必须唯一;唯一索引同时承担查询加速
|
||||
uniqueIndex("posts_slug_unique_idx").on(table.slug),
|
||||
|
||||
// 列表页主查询:按状态过滤 + 按发布时间倒序
|
||||
index("posts_status_published_at_idx").on(
|
||||
table.status,
|
||||
table.publishedAt.desc(),
|
||||
),
|
||||
|
||||
// 标签筛选:GIN 索引支持 `tags @> ARRAY['x']` 与 `'x' = ANY(tags)`
|
||||
index("posts_tags_gin_idx").using("gin", table.tags),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* 数据完整性约束(在迁移 SQL 中手工维护)。
|
||||
*
|
||||
* Drizzle 的 `pgTable` 第三个回调只支持索引类定义,`sql` 模板不会被生成为
|
||||
* CHECK 约束。因此以下约束直接写在迁移文件里,并在应用层用 Zod 做同样的校验:
|
||||
*
|
||||
* - `posts_status_check` : status IN ('draft', 'published')
|
||||
* - `posts_slug_format_check` : slug 匹配 ^[a-z0-9]+(-[a-z0-9]+)*$
|
||||
*
|
||||
* 注意:约束同时存在于数据库与应用层是有意为之——应用层给出友好报错,
|
||||
* 数据库层保证任何写入路径(脚本、手工 SQL)都无法绕过。
|
||||
*/
|
||||
|
||||
export const postsRelations = relations(posts, () => ({}));
|
||||
|
||||
/** 由 schema 推导的插入类型 */
|
||||
export type NewPost = typeof posts.$inferInsert;
|
||||
|
||||
/** 由 schema 推导的查询类型 */
|
||||
export type PostRow = typeof posts.$inferSelect;
|
||||
Reference in new issue
Block a user