This commit is contained in:
z.ai committed 2026-10-08 11:09:37 +08:00
1 parent c1541b80df
commit b4c7908029
97 files changed
+12403 -154

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
/**
* 检查「不存在的文章 / 草稿」是否返回真正的 HTTP 404。
*
* 背景:在动态路由(无 generateStaticParams 预生成)中,
* `notFound()` 的行为依赖渲染模式。若页面被当作静态/缓存内容渲染,
* 可能只渲染 404 内容但状态码仍是 200——这对 SEO 有害,
* 搜索引擎会把不存在的 URL 当成有效页面收录。
*
* 用法:node scripts/check-404.mjs [baseUrl]
*/
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/$/, "");
const cases = [
{ path: "/zh/posts/definitely-not-exist", expect: 404, label: "不存在的文章" },
{ path: "/zh/posts/web-performance-priority", expect: 404, label: "草稿文章" },
{ path: "/zh/no-such-page", expect: 404, label: "不存在的页面" },
{ path: "/zh/posts/building-a-blog-with-nextjs", expect: 200, label: "已发布文章" },
{ path: "/api/posts/definitely-not-exist", expect: 404, label: "API:不存在的文章" },
{ path: "/api/posts/web-performance-priority", expect: 404, label: "API:草稿" },
];
let pass = 0;
let fail = 0;
for (const testCase of cases) {
const response = await fetch(`${BASE}${testCase.path}`, { redirect: "manual" });
const okStatus = response.status === testCase.expect;
if (okStatus) {
pass++;
console.log(` [PASS] ${testCase.label}: ${response.status}`);
} else {
fail++;
console.log(
` [FAIL] ${testCase.label}: 期望 ${testCase.expect},实际 ${response.status}`,
);
}
}
console.log(`\n 通过 ${pass} / ${pass + fail}`);
process.exitCode = fail > 0 ? 1 : 0;
+85
View File
@@ -0,0 +1,85 @@
/**
* 检查文章页代码块在窄屏下的滚动行为。
*
* 期望:代码块自身横向滚动(overflow-x: auto),
* 而不是把整个页面撑出横向滚动条。
*
* 用法:CDP_WS=<ws地址> node scripts/check-code-blocks.mjs <url> <width>
*/
const [, , targetUrl, widthArg = "390"] = process.argv;
const width = Number(widthArg);
const wsUrl = process.env.CDP_WS;
if (!wsUrl) {
console.error("缺少 CDP_WS 环境变量");
process.exit(1);
}
const socket = new WebSocket(wsUrl);
let messageId = 0;
const pending = new Map();
function send(method, params = {}) {
const id = ++messageId;
socket.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`CDP 超时: ${method}`));
}
}, 30000);
});
}
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.id && pending.has(message.id)) {
const { resolve } = pending.get(message.id);
pending.delete(message.id);
resolve(message.result);
}
});
socket.addEventListener("open", async () => {
await send("Page.enable");
await send("Runtime.enable");
await send("Emulation.setDeviceMetricsOverride", {
width,
height: 900,
deviceScaleFactor: 1,
mobile: true,
});
await send("Page.navigate", { url: targetUrl });
await new Promise((resolve) => setTimeout(resolve, 4000));
const result = await send("Runtime.evaluate", {
expression: `(() => {
const pres = [...document.querySelectorAll('.prose-blog pre')];
const doc = document.documentElement;
return {
documentOverflow: doc.scrollWidth - doc.clientWidth,
preCount: pres.length,
blocks: pres.map((pre, index) => {
const figure = pre.closest('figure');
return {
index,
overflowX: getComputedStyle(pre).overflowX,
scrollable: pre.scrollWidth > pre.clientWidth,
scrollWidth: pre.scrollWidth,
clientWidth: pre.clientWidth,
figureOverflow: figure ? getComputedStyle(figure).overflow : null,
};
}),
};
})()`,
returnByValue: true,
});
console.log(JSON.stringify(result.result.value, null, 2));
socket.close();
process.exit(0);
});
+68
View File
@@ -0,0 +1,68 @@
/**
* 数据库连通性探测(node-postgres 版)。
*
* 用法:node scripts/db-check.mjs
*
* 与之前的版本不同,这里直接用 pg 驱动走 TCP 协议,
* 因此能连接本地/自建的普通 Postgres。
*/
import pg from "pg";
import { config } from "dotenv";
config({ path: ".env.local" });
const url = process.env.DATABASE_URL;
if (!url) {
console.error("缺少 DATABASE_URL(检查 .env.local)");
process.exit(1);
}
const masked = url.replace(/:\/\/([^:]+):[^@]+@/, "://$1:***@");
console.log(`连接目标: ${masked}\n`);
const parsed = new URL(url);
const sslmode = parsed.searchParams.get("sslmode");
const isLocal = /^(localhost|127\.0\.0\.1|::1)$/.test(parsed.hostname);
const useSsl = sslmode === "disable" ? false : sslmode ? true : !isLocal;
console.log(`主机: ${parsed.hostname}:${parsed.port || 5432}`);
console.log(`sslmode: ${sslmode ?? "(未指定)"} → 启用 SSL: ${useSsl}\n`);
const client = new pg.Client({
connectionString: url,
ssl: useSsl ? { rejectUnauthorized: false } : undefined,
});
try {
await client.connect();
const info = await client.query(
"select version() as version, current_database() as db, current_user as usr",
);
console.log("✓ 连接成功");
console.log(` 数据库: ${info.rows[0].db}`);
console.log(` 用户: ${info.rows[0].usr}`);
console.log(` 版本: ${String(info.rows[0].version).split(",")[0]}\n`);
const tables = await client.query(
`select table_name from information_schema.tables
where table_schema = 'public' order by table_name`,
);
console.log(`public schema 下的表 (${tables.rowCount}):`);
if (tables.rowCount === 0) {
console.log(" (空 —— 尚未执行迁移)");
} else {
for (const row of tables.rows) {
const count = await client.query(
`select count(*)::int as n from "${row.table_name}"`,
);
console.log(` - ${row.table_name} (${count.rows[0].n} 行)`);
}
}
} catch (error) {
console.error("✗ 连接失败:", error.message);
process.exitCode = 1;
} finally {
await client.end().catch(() => {});
}
+120
View File
@@ -0,0 +1,120 @@
/**
* 诊断 DATABASE_URL 与驱动/SSL 配置是否匹配。
*
* 覆盖两类常见问题:
* 1. **驱动不匹配**:@neondatabase/serverless 的 neon-http 走 Neon 的 HTTP 代理,
* 无法连接普通 Postgres(需改用 pg)。
* 2. **SSL 不匹配**:本地 Postgres 默认不开 SSL,若连接串带 sslmode=require
* 会报 "The server does not support SSL connections"。
*
* 用法:node scripts/db-diagnose.mjs
*/
import { config } from "dotenv";
import net from "node:net";
import pg from "pg";
config({ path: ".env.local" });
const url = process.env.DATABASE_URL;
if (!url) {
console.error("缺少 DATABASE_URL");
process.exit(1);
}
let parsed;
try {
parsed = new URL(url);
} catch {
console.error(`DATABASE_URL 不是合法 URL: ${url}`);
process.exit(1);
}
const host = parsed.hostname;
const port = Number(parsed.port || 5432);
const database = parsed.pathname.replace(/^\//, "");
const sslmode = parsed.searchParams.get("sslmode");
const isNeonHost = /\.neon\.tech$/i.test(host);
const isLocal = /^(localhost|127\.0\.0\.1|::1)$/.test(host);
console.log("── DATABASE_URL 解析 ─────────────────");
console.log(` host: ${host}`);
console.log(` port: ${port}`);
console.log(` database: ${database}`);
console.log(` sslmode: ${sslmode ?? "(未指定)"}`);
console.log(` Neon 托管域名: ${isNeonHost ? "是" : "否"}`);
console.log(` 本地地址: ${isLocal ? "是" : "否"}\n`);
/** 探测 TCP 端口是否可连接 */
function probeTcp(hostname, tcpPort, timeoutMs = 3000) {
return new Promise((resolve) => {
const socket = net.createConnection({ host: hostname, port: tcpPort });
const done = (result) => {
socket.destroy();
resolve(result);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => done(true));
socket.once("timeout", () => done(false));
socket.once("error", () => done(false));
});
}
const tcpReachable = await probeTcp(host, port);
console.log("── 网络连通性 ────────────────────────");
console.log(` TCP ${host}:${port} 可达: ${tcpReachable ? "是" : "否"}\n`);
// 依次尝试「按 sslmode」与「不启用 SSL」,判断是否为 SSL 配置问题
async function tryConnect(useSsl) {
const client = new pg.Client({
connectionString: url,
ssl: useSsl ? { rejectUnauthorized: false } : undefined,
connectionTimeoutMillis: 5000,
});
try {
await client.connect();
await client.query("select 1");
await client.end();
return { ok: true };
} catch (error) {
await client.end().catch(() => {});
return { ok: false, error: error.message };
}
}
console.log("── 实际连接测试 ──────────────────────");
const withConfiguredSsl = await tryConnect(
sslmode === "disable" ? false : sslmode ? true : !isLocal,
);
console.log(
` 按当前 sslmode 连接: ${withConfiguredSsl.ok ? "✓ 成功" : `✗ ${withConfiguredSsl.error}`}`,
);
let oppositeResult = null;
if (!withConfiguredSsl.ok) {
oppositeResult = await tryConnect(!(sslmode === "disable"));
console.log(
` 改用相反 SSL 设置: ${oppositeResult.ok ? "✓ 成功" : `✗ ${oppositeResult.error}`}`,
);
}
console.log("");
console.log("── 结论 ──────────────────────────────");
if (withConfiguredSsl.ok) {
console.log(" ✓ 配置正确,数据库可用。");
} else if (oppositeResult?.ok) {
console.log(" ✗ SSL 配置与服务器不匹配。");
console.log(
isLocal
? " 本地 Postgres 默认不启用 SSL,请把连接串改为 ?sslmode=disable"
: " 该服务器要求 SSL,请在连接串中加上 ?sslmode=require",
);
} else if (!tcpReachable) {
console.log(" ✗ 端口不可达:请确认数据库已启动、主机与端口正确。");
} else {
console.log(" ✗ 连接失败,且与 SSL 无关,请检查用户名/密码/数据库名。");
console.log(" → 若使用 neon-http 驱动连接普通 Postgres,需改用 pg 驱动。");
}
+164
View File
@@ -0,0 +1,164 @@
/**
* 验证 posts 表的约束与索引是否按预期生效。
*
* 这里直接对数据库做"坏写入"测试,确认约束真的能拦住非法数据——
* 只检查迁移文件容易漏掉约束静默失效的情况。
*
* 用法:node scripts/db-verify-schema.mjs
*/
import pg from "pg";
import { config } from "dotenv";
config({ path: ".env.local" });
const client = new pg.Client({
connectionString: process.env.DATABASE_URL,
ssl: /localhost|127\.0\.0\.1/.test(process.env.DATABASE_URL ?? "")
? undefined
: { rejectUnauthorized: false },
});
let pass = 0;
let fail = 0;
function check(name, condition, detail = "") {
if (condition) {
pass++;
console.log(` [PASS] ${name}`);
} else {
fail++;
console.log(` [FAIL] ${name} ${detail}`);
}
}
/** 期望插入失败(被约束拦截),返回错误信息 */
async function expectReject(label, sql, params) {
try {
await client.query(sql, params);
check(label, false, "← 竟然插入成功了,约束未生效");
// 清理掉误插入的数据
await client.query("delete from posts where slug like 'zz-test-%'");
} catch (error) {
check(label, true);
return error.message;
}
}
await client.connect();
console.log("── 列结构 ────────────────────────────");
const cols = await client.query(
`select column_name, data_type, is_nullable, column_default
from information_schema.columns where table_name='posts' order by ordinal_position`,
);
const colNames = cols.rows.map((r) => r.column_name);
console.log(` 列 (${colNames.length}): ${colNames.join(", ")}`);
check("包含 slug 列", colNames.includes("slug"));
check("包含 status 列", colNames.includes("status"));
check("包含 tags 列", colNames.includes("tags"));
check("包含 published_at 列", colNames.includes("published_at"));
check("包含 reading_minutes 列", colNames.includes("reading_minutes"));
const tagsCol = cols.rows.find((r) => r.column_name === "tags");
check("tags 为数组类型", tagsCol?.data_type === "ARRAY", `got ${tagsCol?.data_type}`);
console.log("\n── 索引 ──────────────────────────────");
const idx = await client.query(
`select indexname, indexdef from pg_indexes where tablename='posts' order by indexname`,
);
for (const row of idx.rows) {
console.log(` - ${row.indexname}`);
}
const idxNames = idx.rows.map((r) => r.indexname);
check("slug 唯一索引存在", idxNames.includes("posts_slug_unique_idx"));
check("状态+发布时间索引存在", idxNames.includes("posts_status_published_at_idx"));
check("标签 GIN 索引存在", idxNames.includes("posts_tags_gin_idx"));
const ginDef = idx.rows.find((r) => r.indexname === "posts_tags_gin_idx")?.indexdef ?? "";
check("标签索引使用 gin", /using gin/i.test(ginDef), ginDef);
console.log("\n── 约束 ──────────────────────────────");
const cons = await client.query(
`select conname, pg_get_constraintdef(oid) as def
from pg_constraint where conrelid = 'posts'::regclass order by conname`,
);
for (const row of cons.rows) {
console.log(` - ${row.conname}: ${row.def}`);
}
const conNames = cons.rows.map((r) => r.conname);
check("status CHECK 约束存在", conNames.includes("posts_status_check"));
check("slug 格式 CHECK 约束存在", conNames.includes("posts_slug_format_check"));
console.log("\n── 约束实际拦截能力(坏写入测试)──");
await expectReject(
"拒绝非法 status",
`insert into posts (slug,title,summary,content,status) values ($1,$2,$3,$4,$5)`,
["zz-test-badstatus", "t", "s", "c", "archived"],
);
await expectReject(
"拒绝非法 slug(大写/下划线)",
`insert into posts (slug,title,summary,content) values ($1,$2,$3,$4)`,
["ZZ_Test_Slug", "t", "s", "c"],
);
// 唯一约束需要先插入一行,再插入同样的 slug 才能验证
await client.query(
`insert into posts (slug,title,summary,content) values ($1,$2,$3,$4)`,
["zz-test-dup", "t", "s", "c"],
);
await expectReject(
"拒绝重复 slug",
`insert into posts (slug,title,summary,content) values ($1,$2,$3,$4)`,
["zz-test-dup", "t", "s", "c"],
);
console.log("\n── 正常写入与数组读写 ───────────────");
try {
const inserted = await client.query(
`insert into posts (slug,title,summary,content,tags,status,published_at,reading_minutes)
values ($1,$2,$3,$4,$5,$6,now(),$7) returning id, tags, status`,
[
"zz-test-ok",
"正常文章",
"摘要",
"正文",
["Next.js", "数据库"],
"published",
3,
],
);
check("合法数据可写入", inserted.rowCount === 1);
check(
"tags 数组正确回读",
Array.isArray(inserted.rows[0].tags) && inserted.rows[0].tags.length === 2,
JSON.stringify(inserted.rows[0].tags),
);
// 验证 GIN 索引可用的标签查询语法
const byTag = await client.query(
`select count(*)::int as n from posts where tags @> ARRAY[$1]::text[]`,
["Next.js"],
);
check("按标签包含查询可用", byTag.rows[0].n >= 1);
const anyTag = await client.query(
`select count(*)::int as n from posts where $1 = any(tags)`,
["数据库"],
);
check("按标签 ANY 查询可用", anyTag.rows[0].n >= 1);
} finally {
// 清理测试数据
await client.query("delete from posts where slug like 'zz-test-%'");
}
const remaining = await client.query("select count(*)::int as n from posts");
console.log(`\n清理后剩余行数: ${remaining.rows[0].n}`);
console.log("\n================================");
console.log(` 通过 ${pass} / ${pass + fail}`);
console.log("================================");
await client.end();
process.exitCode = fail > 0 ? 1 : 0;
+35
View File
@@ -0,0 +1,35 @@
/**
* 隔离测试:确认 notFound() 返回 200 的根因。
*
* 依次访问三种路径,对比状态码:
* 1. 未匹配任何路由的路径(由 Next.js 自身处理)
* 2. 匹配动态路由但数据不存在(由页面内 notFound() 处理)
* 3. 正常存在的数据
*
* 用法:node scripts/diagnose-404.mjs [baseUrl]
*/
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/$/, "");
const cases = [
["/zh/this-route-does-not-exist", "未匹配路由"],
["/zh/posts/zzz-absent", "动态路由 + notFound()"],
["/zh/posts/building-a-blog-with-nextjs", "正常存在的文章"],
["/api/posts/zzz-absent", "API 路由 404"],
];
for (const [path, label] of cases) {
const response = await fetch(`${BASE}${path}`, { redirect: "manual" });
// 检查响应头,判断是否为流式响应
const contentType = response.headers.get("content-type") ?? "";
console.log(
`${String(response.status).padStart(3)} ${label.padEnd(24)} ${path}`,
);
console.log(
` content-type=${contentType.split(";")[0]} ` +
`x-nextjs-cache=${response.headers.get("x-nextjs-cache") ?? "-"} ` +
`cache-control=${(response.headers.get("cache-control") ?? "-").slice(0, 40)}`,
);
}
+145
View File
@@ -0,0 +1,145 @@
/**
* 通过 Chrome DevTools Protocol 测量页面在移动端视口下的横向溢出。
*
* 用法:node scripts/measure-overflow.mjs <url> <width> <height>
*
* 之所以单独写这个脚本:横向溢出是纯视觉问题,单看截图难以定位到
* 具体是哪个元素撑破了布局。这里直接问浏览器要每个元素的 boundingRect。
*/
const [, , targetUrl, widthArg = "390", heightArg = "900"] = process.argv;
const width = Number(widthArg);
const height = Number(heightArg);
const wsUrl = process.env.CDP_WS;
if (!wsUrl) {
console.error("缺少 CDP_WS 环境变量(WebSocket 调试地址)");
process.exit(1);
}
const socket = new WebSocket(wsUrl);
let messageId = 0;
const pending = new Map();
/** 发送 CDP 命令并等待响应 */
function send(method, params = {}) {
const id = ++messageId;
socket.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`CDP 超时: ${method}`));
}
}, 30000);
});
}
/** 在页面上下文中执行表达式并返回其值 */
async function evaluate(expression) {
const result = await send("Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: true,
});
if (result.exceptionDetails) {
throw new Error(
`页面执行异常: ${result.exceptionDetails.text} ${
result.exceptionDetails.exception?.description ?? ""
}`,
);
}
return result.result.value;
}
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.id && pending.has(message.id)) {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) {
reject(new Error(message.error.message));
} else {
resolve(message.result);
}
}
});
socket.addEventListener("error", (error) => {
console.error("WebSocket 错误:", error.message ?? error);
process.exit(1);
});
socket.addEventListener("open", async () => {
try {
await send("Page.enable");
await send("Runtime.enable");
await send("Emulation.setDeviceMetricsOverride", {
width,
height,
deviceScaleFactor: 1,
mobile: true,
});
await send("Page.navigate", { url: targetUrl });
// 等待网络空闲 + 首屏渲染完成
await new Promise((resolve) => setTimeout(resolve, 4000));
const report = await evaluate(`(() => {
const doc = document.documentElement;
const clientWidth = doc.clientWidth;
const offenders = [];
for (const el of document.querySelectorAll("body *")) {
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
const overflowRight = rect.right - clientWidth;
const overflowLeft = -rect.left;
if (overflowRight > 1 || overflowLeft > 1) {
offenders.push({
tag: el.tagName.toLowerCase(),
cls: String(el.className || "").slice(0, 90),
text: (el.textContent || "").trim().slice(0, 34),
left: Math.round(rect.left),
right: Math.round(rect.right),
width: Math.round(rect.width),
overflowRight: Math.round(overflowRight),
});
}
}
// 只保留"最外层"的溢出元素,避免父子的重复报告
const minimal = offenders.filter((candidate) => {
return !offenders.some(
(other) =>
other !== candidate &&
other.left <= candidate.left &&
other.right >= candidate.right &&
other.width > candidate.width,
);
});
return {
clientWidth,
scrollWidth: doc.scrollWidth,
documentOverflow: doc.scrollWidth - clientWidth,
culpritCount: minimal.length,
culprits: minimal.slice(0, 12),
};
})()`);
console.log(JSON.stringify(report, null, 2));
socket.close();
process.exit(0);
} catch (error) {
console.error("测量失败:", error.message);
socket.close();
process.exit(1);
}
});
+86
View File
@@ -0,0 +1,86 @@
/**
* 通过 CDP 在精确的视口尺寸下截图(含整页截图)。
*
* 为什么不直接用 `chrome --headless --screenshot --window-size`:
* 该模式下截图位图宽度与 CSS 视口宽度可能不一致,导致移动端截图
* 看起来"内容被截断",实际测量(document.scrollWidth)却是正常的。
* 这里用 Emulation.setDeviceMetricsOverride 明确指定视口,
* 保证截图与真实设备表现一致。
*
* 用法:CDP_WS=<ws地址> node scripts/screenshot.mjs <url> <width> <height> <输出路径> [--full]
*/
import { writeFileSync } from "node:fs";
const [, , targetUrl, widthArg, heightArg, outputPath, ...flags] = process.argv;
const width = Number(widthArg ?? 1440);
const height = Number(heightArg ?? 900);
const fullPage = flags.includes("--full");
const wsUrl = process.env.CDP_WS;
if (!wsUrl || !targetUrl || !outputPath) {
console.error(
"用法:CDP_WS=<ws地址> node scripts/screenshot.mjs <url> <width> <height> <输出路径> [--full]",
);
process.exit(1);
}
const socket = new WebSocket(wsUrl);
let messageId = 0;
const pending = new Map();
function send(method, params = {}) {
const id = ++messageId;
socket.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`CDP 超时: ${method}`));
}
}, 40000);
});
}
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.id && pending.has(message.id)) {
const { resolve } = pending.get(message.id);
pending.delete(message.id);
resolve(message.result);
}
});
socket.addEventListener("open", async () => {
await send("Page.enable");
await send("Runtime.enable");
await send("Emulation.setDeviceMetricsOverride", {
width,
height,
deviceScaleFactor: 1,
mobile: width < 768,
});
await send("Page.navigate", { url: targetUrl });
// 等待字体加载与首屏动画稳定
await new Promise((resolve) => setTimeout(resolve, 4500));
// 让所有延迟出现的元素(滚动动画)进入最终状态
await send("Runtime.evaluate", {
expression:
"window.scrollTo(0, document.body.scrollHeight); window.scrollTo(0, 0);",
});
await new Promise((resolve) => setTimeout(resolve, 1200));
const screenshot = await send("Page.captureScreenshot", {
format: "png",
captureBeyondViewport: fullPage,
optimizeForSpeed: false,
});
writeFileSync(outputPath, Buffer.from(screenshot.data, "base64"));
console.log(`已保存 ${outputPath} (${width}x${height}${fullPage ? ", 整页" : ""})`);
socket.close();
process.exit(0);
});
+189
View File
@@ -0,0 +1,189 @@
/**
* 把 content/posts/ 下的 Markdown 文件导入数据库。
*
* 用途:一次性把现有的文件式内容迁移进 posts 表,
* 让数据库成为唯一内容源。
*
* 行为:
* - 幂等:按 slug upsert(存在则更新,不存在则插入)
* - 解析 YAML frontmatter 并做与 API 相同的校验
* - `draft: true` 映射为 status='draft'
* - 打印每条记录的处理结果
*
* 用法:
* node scripts/seed-posts.mjs # 导入 / 更新
* node scripts/seed-posts.mjs --dry-run # 只解析并打印,不写库
*/
import { readdirSync, readFileSync, existsSync } from "node:fs";
import { join, extname } from "node:path";
import pg from "pg";
import matter from "gray-matter";
import { config } from "dotenv";
config({ path: ".env.local" });
const DRY_RUN = process.argv.includes("--dry-run");
const POSTS_DIR = join(process.cwd(), "content", "posts");
if (!existsSync(POSTS_DIR)) {
console.error(`内容目录不存在: ${POSTS_DIR}`);
process.exit(1);
}
/** 估算阅读时长(与 lib/validation/post.ts 保持一致) */
function estimateReadingMinutes(content) {
const chineseCharacters = content.match(/[\u4e00-\u9fa5]/g)?.length ?? 0;
const latinWords =
content.replace(/[\u4e00-\u9fa5]/g, " ").match(/[A-Za-z0-9]+/g)?.length ??
0;
return Math.max(1, Math.round(chineseCharacters / 350 + latinWords / 200));
}
/** 从 frontmatter 解析出可写入数据库的记录 */
function parseFile(fileName) {
const slug = fileName.replace(/\.(mdx|md)$/i, "");
const raw = readFileSync(join(POSTS_DIR, fileName), "utf8");
const { data, content } = matter(raw);
const problems = [];
const title = typeof data.title === "string" ? data.title.trim() : "";
if (!title) problems.push("缺少 title");
let date = "";
if (data.date instanceof Date && !Number.isNaN(data.date.getTime())) {
date = data.date.toISOString().slice(0, 10);
} else if (typeof data.date === "string" && data.date.trim()) {
date = data.date.trim();
}
if (!date || Number.isNaN(new Date(date).getTime())) {
problems.push("缺少或非法的 date");
}
const summary = typeof data.summary === "string" ? data.summary.trim() : "";
if (!summary) problems.push("缺少 summary");
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
problems.push(`slug "${slug}" 不符合 ^[a-z0-9]+(-[a-z0-9]+)*$(数据库 CHECK 约束会拒绝)`);
}
if (problems.length > 0) {
return { slug, error: problems.join("; ") };
}
return {
slug,
title,
summary,
content,
tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
status: data.draft === true ? "draft" : "published",
publishedAt: new Date(`${date}T00:00:00Z`),
cover: typeof data.cover === "string" ? data.cover : null,
description:
typeof data.description === "string" ? data.description : null,
readingMinutes: estimateReadingMinutes(content),
};
}
const files = readdirSync(POSTS_DIR).filter((f) =>
[".md", ".mdx"].includes(extname(f).toLowerCase()),
);
if (files.length === 0) {
console.log("content/posts/ 下没有 Markdown 文件");
process.exit(0);
}
console.log(`发现 ${files.length} 个内容文件${DRY_RUN ? "(dry-run,不写库)" : ""}\n`);
const records = [];
for (const file of files) {
const parsed = parseFile(file);
if (parsed.error) {
console.error(`✗ ${file}: ${parsed.error}`);
process.exitCode = 1;
continue;
}
records.push(parsed);
}
if (process.exitCode === 1) {
console.error("\n存在解析失败的文件,已中止导入。");
process.exit(1);
}
if (DRY_RUN) {
for (const r of records) {
console.log(
` ${r.status === "draft" ? "[草稿]" : "[已发布]"} ${r.slug} 日期=${r.publishedAt
.toISOString()
.slice(0, 10)} 标签=${r.tags.join(",") || "-"} ${r.readingMinutes}分钟`,
);
}
process.exit(0);
}
const client = new pg.Client({
connectionString: process.env.DATABASE_URL,
ssl: /localhost|127\.0\.0\.1/.test(process.env.DATABASE_URL ?? "")
? undefined
: { rejectUnauthorized: false },
});
await client.connect();
let inserted = 0;
let updated = 0;
try {
for (const r of records) {
// upsert:以 slug 为冲突键
const result = await client.query(
`insert into posts
(slug, title, summary, content, tags, status, published_at,
cover, description, reading_minutes, created_at, updated_at)
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10, now(), now())
on conflict (slug) do update set
title = excluded.title,
summary = excluded.summary,
content = excluded.content,
tags = excluded.tags,
status = excluded.status,
published_at = excluded.published_at,
cover = excluded.cover,
description = excluded.description,
reading_minutes = excluded.reading_minutes,
updated_at = now()
returning (xmax = 0) as is_insert`,
[
r.slug,
r.title,
r.summary,
r.content,
r.tags,
r.status,
r.status === "published" ? r.publishedAt : null,
r.cover,
r.description,
r.readingMinutes,
],
);
const isInsert = result.rows[0].is_insert;
if (isInsert) inserted++;
else updated++;
console.log(
` ${isInsert ? "+ 新增" : "~ 更新"} ${r.slug} (${r.status}, ${r.readingMinutes} 分钟)`,
);
}
const total = await client.query("select count(*)::int as n from posts");
console.log(`\n完成:新增 ${inserted},更新 ${updated},表内共 ${total.rows[0].n} 行`);
} catch (error) {
console.error("\n导入失败:", error.message);
process.exitCode = 1;
} finally {
await client.end().catch(() => {});
}
+306
View File
@@ -0,0 +1,306 @@
/**
* API 端到端自测。
*
* 覆盖完整 CRUD 链路 + 边界与错误分支:
* - 列表/分页/标签筛选/草稿隔离
* - 详情读取(含 404)
* - 创建(含校验失败、slug 冲突)
* - 更新(部分更新、slug 改名、草稿↔发布流转)
* - 删除(含重复删除 404)
*
* 所有测试数据以 `zz-` 前缀创建,结束后清理,不影响真实内容。
*
* 用法:node scripts/test-api.mjs [baseUrl]
* 默认 baseUrl = http://localhost:3000
*/
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/$/, "");
const PREFIX = "zz-test";
let pass = 0;
let fail = 0;
const failures = [];
function check(name, condition, detail = "") {
if (condition) {
pass++;
console.log(` [PASS] ${name}`);
} else {
fail++;
failures.push(`${name} ${detail}`);
console.log(` [FAIL] ${name} ${detail}`);
}
}
/** 发起请求并返回 { status, body } */
async function call(method, path, body) {
const response = await fetch(`${BASE}${path}`, {
method,
headers: body ? { "content-type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
return { status: response.status, body: parsed };
}
console.log(`目标: ${BASE}\n`);
// ─────────────────────────────────────────────
console.log("── 0. 健康检查 ──────────────────────");
const health = await call("GET", "/api/health");
check("GET /api/health 返回 200", health.status === 200, `got ${health.status}`);
check("健康状态为 ok", health.body?.data?.status === "ok", JSON.stringify(health.body?.data));
check(
"返回数据库延迟",
typeof health.body?.data?.database?.latencyMs === "number",
);
// ─────────────────────────────────────────────
console.log("\n── 1. 列表与分页 ────────────────────");
const list = await call("GET", "/api/posts");
check("GET /api/posts 返回 200", list.status === 200, `got ${list.status}`);
check("响应含 data 数组", Array.isArray(list.body?.data));
check("响应含 meta.total", typeof list.body?.meta?.total === "number");
check("响应含 meta.hasMore", typeof list.body?.meta?.hasMore === "boolean");
check(
"默认只返回已发布文章",
list.body.data.every((p) => p.draft === false),
JSON.stringify(list.body.data?.map((p) => [p.slug, p.draft])),
);
check(
"按日期倒序",
list.body.data.every(
(post, index, array) =>
index === 0 || new Date(array[index - 1].date) >= new Date(post.date),
),
JSON.stringify(list.body.data?.map((p) => p.date)),
);
const limited = await call("GET", "/api/posts?limit=1");
check("limit=1 只返回 1 条", limited.body?.data?.length === 1);
check("limit 反映在 meta", limited.body?.meta?.limit === 1);
const badLimit = await call("GET", "/api/posts?limit=999");
check("limit 超限返回 400", badLimit.status === 400, `got ${badLimit.status}`);
check(
"校验错误含 code",
badLimit.body?.error?.code === "VALIDATION_ERROR",
JSON.stringify(badLimit.body?.error),
);
const badOffset = await call("GET", "/api/posts?offset=-1");
check("offset 负数返回 400", badOffset.status === 400, `got ${badOffset.status}`);
// ─────────────────────────────────────────────
console.log("\n── 2. 标签筛选与聚合 ────────────────");
const tags = await call("GET", "/api/tags");
check("GET /api/tags 返回 200", tags.status === 200);
check("标签为数组", Array.isArray(tags.body?.data));
check(
"标签含 count 字段",
tags.body.data.every((t) => typeof t.tag === "string" && typeof t.count === "number"),
JSON.stringify(tags.body?.data?.[0]),
);
if (tags.body.data.length > 0) {
const firstTag = tags.body.data[0].tag;
const filtered = await call("GET", `/api/posts?tag=${encodeURIComponent(firstTag)}`);
check(`按标签 "${firstTag}" 筛选返回 200`, filtered.status === 200);
check(
"筛选结果都含该标签",
filtered.body.data.every((p) => p.tags.includes(firstTag)),
JSON.stringify(filtered.body.data?.map((p) => p.tags)),
);
check("筛选结果非空", filtered.body.data.length > 0);
}
// ─────────────────────────────────────────────
console.log("\n── 3. 创建文章 ──────────────────────");
const createPayload = {
slug: `${PREFIX}-create`,
title: "测试文章:创建",
summary: "这是一篇用于接口自测的文章摘要。",
content: "## 标题\n\n正文内容,包含 `行内代码`。\n\n```ts\nconst x: number = 1;\n```\n",
tags: ["测试", "API"],
status: "published",
};
// 先清理可能残留的同名数据
await call("DELETE", `/api/posts/${PREFIX}-create`);
const createRes = await call("POST", "/api/posts", createPayload);
check("POST /api/posts 返回 201", createRes.status === 201, `got ${createRes.status}`);
check("返回含 slug", createRes.body?.data?.slug === createPayload.slug);
check("返回含正文", typeof createRes.body?.data?.content === "string");
check("阅读时长已计算", createRes.body?.data?.readingMinutes >= 1);
check("标签已保存", Array.isArray(createRes.body?.data?.tags) && createRes.body.data.tags.length === 2);
// 重复 slug → 409
const dupRes = await call("POST", "/api/posts", createPayload);
check("重复 slug 返回 409", dupRes.status === 409, `got ${dupRes.status}`);
check("冲突错误码正确", dupRes.body?.error?.code === "CONFLICT", JSON.stringify(dupRes.body?.error));
// 校验失败 → 400
const invalidRes = await call("POST", "/api/posts", {
slug: "Invalid_Slug",
title: "",
summary: "",
content: "",
});
check("非法输入返回 400", invalidRes.status === 400, `got ${invalidRes.status}`);
check("含字段级 details", Array.isArray(invalidRes.body?.error?.details));
check(
"details 指出 slug 问题",
invalidRes.body.error.details.some((d) => d.field === "slug"),
JSON.stringify(invalidRes.body?.error?.details),
);
// 非 JSON 请求体 → 400
const badJson = await fetch(`${BASE}/api/posts`, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{ not json",
});
check("非法 JSON 返回 400", badJson.status === 400, `got ${badJson.status}`);
// ─────────────────────────────────────────────
console.log("\n── 4. 详情读取 ──────────────────────");
const detail = await call("GET", `/api/posts/${PREFIX}-create`);
check("GET 详情返回 200", detail.status === 200, `got ${detail.status}`);
check("详情含正文", typeof detail.body?.data?.content === "string");
check("详情标题正确", detail.body?.data?.title === createPayload.title);
const missing = await call("GET", "/api/posts/zz-does-not-exist");
check("不存在的 slug 返回 404", missing.status === 404, `got ${missing.status}`);
check("404 错误码正确", missing.body?.error?.code === "NOT_FOUND");
// ─────────────────────────────────────────────
console.log("\n── 5. 更新文章 ──────────────────────");
const patchRes = await call("PATCH", `/api/posts/${PREFIX}-create`, {
title: "测试文章:已更新标题",
});
check("PATCH 返回 200", patchRes.status === 200, `got ${patchRes.status}`);
check("标题已更新", patchRes.body?.data?.title === "测试文章:已更新标题");
check(
"未提供的字段保持不变",
patchRes.body?.data?.summary === createPayload.summary,
);
check("slug 未被意外修改", patchRes.body?.data?.slug === PREFIX + "-create");
// 部分更新正文 → 阅读时长应重算
const longContent = "中".repeat(1400);
const contentPatch = await call("PATCH", `/api/posts/${PREFIX}-create`, {
content: longContent,
});
check(
"更新正文后重算阅读时长",
contentPatch.body?.data?.readingMinutes >= 3,
`got ${contentPatch.body?.data?.readingMinutes}`,
);
// 空 body → 400
const emptyPatch = await call("PATCH", `/api/posts/${PREFIX}-create`, {});
check("空更新返回 400", emptyPatch.status === 400, `got ${emptyPatch.status}`);
// 更新不存在的资源 → 404
const patchMissing = await call("PATCH", "/api/posts/zz-nope", { title: "x" });
check("更新不存在的文章返回 404", patchMissing.status === 404, `got ${patchMissing.status}`);
// slug 改名
const renamed = await call("PATCH", `/api/posts/${PREFIX}-create`, {
slug: `${PREFIX}-renamed`,
});
check("可以修改 slug", renamed.status === 200 && renamed.body?.data?.slug === `${PREFIX}-renamed`);
const oldGone = await call("GET", `/api/posts/${PREFIX}-create`);
check("旧 slug 已不可访问", oldGone.status === 404, `got ${oldGone.status}`);
// ─────────────────────────────────────────────
console.log("\n── 6. 草稿流转与隔离 ────────────────");
const draftSlug = `${PREFIX}-draft`;
await call("DELETE", `/api/posts/${draftSlug}`);
const draftCreate = await call("POST", "/api/posts", {
slug: draftSlug,
title: "测试草稿",
summary: "草稿摘要",
content: "草稿正文",
status: "draft",
});
check("创建草稿返回 201", draftCreate.status === 201, `got ${draftCreate.status}`);
check("草稿 draft=true", draftCreate.body?.data?.draft === true);
const draftInList = await call("GET", "/api/posts");
check(
"草稿不出现在默认列表中",
!draftInList.body.data.some((p) => p.slug === draftSlug),
);
const draftDetail = await call("GET", `/api/posts/${draftSlug}`);
check("草稿详情返回 404", draftDetail.status === 404, `got ${draftDetail.status}`);
const draftIncluded = await call("GET", "/api/posts?includeDrafts=true");
check(
"includeDrafts=true 时可见草稿",
draftIncluded.body.data.some((p) => p.slug === draftSlug),
);
const draftOnly = await call("GET", "/api/posts?status=draft");
check(
"status=draft 只返回草稿",
draftOnly.body.data.length > 0 && draftOnly.body.data.every((p) => p.draft === true),
);
// 草稿 → 发布
const published = await call("PATCH", `/api/posts/${draftSlug}`, {
status: "published",
});
check("草稿可发布", published.status === 200 && published.body?.data?.draft === false);
check("发布后有日期", Boolean(published.body?.data?.date));
const nowVisible = await call("GET", `/api/posts/${draftSlug}`);
check("发布后可正常访问", nowVisible.status === 200, `got ${nowVisible.status}`);
// 已发布 → 草稿
const reDraft = await call("PATCH", `/api/posts/${draftSlug}`, { status: "draft" });
check("可退回草稿", reDraft.body?.data?.draft === true);
const hiddenAgain = await call("GET", `/api/posts/${draftSlug}`);
check("退回草稿后不可访问", hiddenAgain.status === 404, `got ${hiddenAgain.status}`);
// ─────────────────────────────────────────────
console.log("\n── 7. 删除 ──────────────────────────");
const del = await call("DELETE", `/api/posts/${PREFIX}-renamed`);
check("DELETE 返回 204", del.status === 204, `got ${del.status}`);
const delAgain = await call("DELETE", `/api/posts/${PREFIX}-renamed`);
check("重复删除返回 404", delAgain.status === 404, `got ${delAgain.status}`);
const afterDelete = await call("GET", `/api/posts/${PREFIX}-renamed`);
check("删除后不可访问", afterDelete.status === 404, `got ${afterDelete.status}`);
// 清理草稿
await call("DELETE", `/api/posts/${draftSlug}`);
// ─────────────────────────────────────────────
console.log("\n── 8. 清理确认 ──────────────────────");
const finalList = await call("GET", "/api/posts?includeDrafts=true&limit=100");
const leftovers = finalList.body.data.filter((p) => p.slug.startsWith(PREFIX));
check("测试数据已清理干净", leftovers.length === 0, JSON.stringify(leftovers.map((p) => p.slug)));
check("真实文章仍在", finalList.body.data.length >= 3, `剩余 ${finalList.body.data.length} 篇`);
console.log("\n================================");
console.log(` 通过 ${pass} / ${pass + fail}`);
if (failures.length > 0) {
console.log(" 失败项:");
for (const f of failures) console.log(` - ${f}`);
}
console.log("================================");
process.exitCode = fail > 0 ? 1 : 0;
+127
View File
@@ -0,0 +1,127 @@
/**
* 校验规则的单元自测(不依赖服务器与数据库)。
*
* 重点验证容易出错的地方:
* - PATCH 空对象必须被拒绝(`.partial()` 不会移除 `.default()`)
* - slug 格式、标签去重、日期转换
* - 列表查询参数的类型强制与边界
*
* 用法:node scripts/test-validation.mjs
*/
import {
createPostSchema,
updatePostSchema,
listPostsQuerySchema,
estimateReadingMinutes,
} from "../lib/validation/post.ts";
let pass = 0;
let fail = 0;
function check(name, condition, detail = "") {
if (condition) {
pass++;
console.log(` [PASS] ${name}`);
} else {
fail++;
console.log(` [FAIL] ${name} ${detail}`);
}
}
console.log("── createPostSchema ─────────────────");
const validCreate = createPostSchema.safeParse({
slug: "hello-world",
title: "标题",
summary: "摘要",
content: "正文",
});
check("合法输入通过", validCreate.success);
check("status 默认为 draft", validCreate.data?.status === "draft");
check("tags 默认为空数组", Array.isArray(validCreate.data?.tags) && validCreate.data.tags.length === 0);
const badSlug = createPostSchema.safeParse({
slug: "Hello_World",
title: "t",
summary: "s",
content: "c",
});
check("非法 slug 被拒绝", !badSlug.success);
const emptyTitle = createPostSchema.safeParse({
slug: "ok",
title: " ",
summary: "s",
content: "c",
});
check("纯空白标题被拒绝", !emptyTitle.success);
const dupTags = createPostSchema.safeParse({
slug: "ok",
title: "t",
summary: "s",
content: "c",
tags: ["a", "a", "b"],
});
check("标签自动去重", dupTags.success && dupTags.data.tags.length === 2, JSON.stringify(dupTags.data?.tags));
console.log("\n── updatePostSchema(关键回归点)──");
const emptyUpdate = updatePostSchema.safeParse({});
check("空对象被拒绝(回归:不得因 default 而通过)", !emptyUpdate.success, JSON.stringify(emptyUpdate.data));
const titleOnly = updatePostSchema.safeParse({ title: "新标题" });
check("只传 title 通过", titleOnly.success);
check("未传的字段不出现在结果中", !("summary" in (titleOnly.data ?? {})), JSON.stringify(titleOnly.data));
const tagsOnly = updatePostSchema.safeParse({ tags: ["x"] });
check("只传 tags 通过", tagsOnly.success);
const invalidStatus = updatePostSchema.safeParse({ status: "archived" });
check("非法 status 被拒绝", !invalidStatus.success);
const slugUpdate = updatePostSchema.safeParse({ slug: "new-slug" });
check("可单独更新 slug", slugUpdate.success);
console.log("\n── listPostsQuerySchema ─────────────");
const defaults = listPostsQuerySchema.safeParse({});
check("默认 limit=20", defaults.data?.limit === 20);
check("默认 offset=0", defaults.data?.offset === 0);
check("默认 includeDrafts=false", defaults.data?.includeDrafts === false);
const coerced = listPostsQuerySchema.safeParse({ limit: "5", offset: "10" });
check("字符串数字被强制转换", coerced.data?.limit === 5 && coerced.data?.offset === 10);
const tooBig = listPostsQuerySchema.safeParse({ limit: "101" });
check("limit 超上限被拒绝", !tooBig.success);
const zero = listPostsQuerySchema.safeParse({ limit: "0" });
check("limit=0 被拒绝", !zero.success);
const negative = listPostsQuerySchema.safeParse({ offset: "-5" });
check("负 offset 被拒绝", !negative.success);
const drafts = listPostsQuerySchema.safeParse({ includeDrafts: "true" });
check("includeDrafts 字符串转布尔", drafts.data?.includeDrafts === true);
const badStatus = listPostsQuerySchema.safeParse({ status: "nope" });
check("非法 status 被拒绝", !badStatus.success);
console.log("\n── estimateReadingMinutes ───────────");
check("空内容至少 1 分钟", estimateReadingMinutes("") === 1);
check("短内容为 1 分钟", estimateReadingMinutes("hello world") === 1);
check(
"长中文内容时长增加",
estimateReadingMinutes("中".repeat(1400)) >= 3,
`got ${estimateReadingMinutes("中".repeat(1400))}`,
);
check(
"长英文内容时长增加",
estimateReadingMinutes("word ".repeat(1000)) >= 4,
`got ${estimateReadingMinutes("word ".repeat(1000))}`,
);
console.log("\n================================");
console.log(` 通过 ${pass} / ${pass + fail}`);
console.log("================================");
process.exitCode = fail > 0 ? 1 : 0;