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

+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { checkDatabaseHealth } from "@/lib/db";
import { ErrorCode, ok, withErrorHandling } from "@/lib/api/response";
/**
* GET /api/health —— 健康检查
*
* 用于部署后的可用性探测与监控。返回:
* - status: "ok" | "degraded"
* - database: 连通性与往返延迟
* - timestamp: 服务端时间
*
* 数据库不可用时返回 503,便于负载均衡/监控直接判定为不健康。
*/
export const dynamic = "force-dynamic";
export const GET = withErrorHandling(async () => {
const database = await checkDatabaseHealth();
const payload = {
status: database.ok ? ("ok" as const) : ("degraded" as const),
database: {
ok: database.ok,
latencyMs: database.latencyMs,
...(database.error ? { error: database.error } : {}),
},
timestamp: new Date().toISOString(),
};
if (!database.ok) {
return NextResponse.json(
{
error: {
code: ErrorCode.SERVICE_UNAVAILABLE,
message: "数据库不可用",
},
...payload,
},
{ status: 503 },
);
}
return ok(payload);
});
+101
View File
@@ -0,0 +1,101 @@
import { NextResponse } from "next/server";
import {
ok,
noContent,
withErrorHandling,
parseJsonBody,
ApiError,
} from "@/lib/api/response";
import { updatePostSchema } from "@/lib/validation/post";
import {
getPostBySlug,
getPostBySlugIncludingDrafts,
updatePost,
deletePost,
assertSlugAvailable,
} from "@/lib/posts/repository";
/**
* /api/posts/[slug] —— 单篇文章的读取、更新与删除
*
* 注意:GET 默认只返回已发布文章;传入 ?includeDrafts=true 可读到草稿,
* 供后续接入后台管理时使用(v0.1 尚无鉴权,接入后台前应加上权限校验)。
*/
type RouteContext = { params: Promise<{ slug: string }> };
/** GET /api/posts/[slug] —— 读取单篇文章(含正文) */
export const GET = withErrorHandling(
async (request: Request, context: RouteContext) => {
const { slug } = await context.params;
const { searchParams } = new URL(request.url);
const includeDrafts = searchParams.get("includeDrafts") === "true";
const post = includeDrafts
? await getPostBySlugIncludingDrafts(slug)
: await getPostBySlug(slug);
if (!post) {
throw ApiError.notFound(`文章 "${slug}" 不存在或尚未发布`);
}
return ok(post);
},
);
/**
* PATCH /api/posts/[slug] —— 更新文章(部分更新)
*
* 支持更新 slug 本身;此时会校验新 slug 未被占用。
*/
export const PATCH = withErrorHandling(
async (request: Request, context: RouteContext) => {
const { slug } = await context.params;
const body = await parseJsonBody(request);
const input = updatePostSchema.parse(body);
// 目标文章必须存在(含草稿,便于把草稿改为已发布)
const existing = await getPostBySlugIncludingDrafts(slug);
if (!existing) {
throw ApiError.notFound(`文章 "${slug}" 不存在`);
}
// 改 slug 时需要判重(排除自身)
if (input.slug && input.slug !== slug) {
await assertSlugAvailable(input.slug, slug);
}
const updated = await updatePost(slug, input);
if (!updated) {
throw ApiError.notFound(`文章 "${slug}" 不存在`);
}
return ok(updated);
},
);
/** PUT 与 PATCH 同义,便于不支持 PATCH 的客户端使用 */
export const PUT = PATCH;
/** DELETE /api/posts/[slug] —— 删除文章 */
export const DELETE = withErrorHandling(
async (_request: Request, context: RouteContext) => {
const { slug } = await context.params;
const removed = await deletePost(slug);
if (!removed) {
throw ApiError.notFound(`文章 "${slug}" 不存在`);
}
// 204 No Content:删除成功且无响应体
return noContent();
},
);
/** 列出该资源支持的方法 */
export function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: { Allow: "GET, PATCH, PUT, DELETE, OPTIONS" },
});
}
+77
View File
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { ok, created, withErrorHandling, parseJsonBody } from "@/lib/api/response";
import { createPostSchema, listPostsQuerySchema } from "@/lib/validation/post";
import { createPost, listPosts } from "@/lib/posts/repository";
/**
* GET /api/posts —— 文章列表
*
* 查询参数:
* - limit 1–100,默认 20
* - offset 默认 0
* - tag 按标签过滤
* - status draft | published(不传则只返回已发布)
* - includeDrafts true 时包含草稿(内部/管理用途)
*
* 响应:{ data: PostMeta[], meta: { total, limit, offset, hasMore } }
*/
export const GET = withErrorHandling(async (request: Request) => {
const { searchParams } = new URL(request.url);
// 校验查询参数:非法值直接返回 400 + 字段级错误
const query = listPostsQuerySchema.parse(
Object.fromEntries(searchParams.entries()),
);
const result = await listPosts({
limit: query.limit,
offset: query.offset,
tag: query.tag,
includeDrafts: query.includeDrafts,
status: query.status,
});
return ok(result.items, {
total: result.total,
limit: result.limit,
offset: result.offset,
hasMore: result.hasMore,
});
});
/**
* POST /api/posts —— 新建文章
*
* 请求体(JSON):
* - slug 必填,^[a-z0-9]+(-[a-z0-9]+)*$
* - title 必填
* - summary 必填
* - content 必填(Markdown / MDX 源码)
* - tags 可选,字符串数组
* - status draft | published,默认 draft
* - publishedAt 可选,ISO 日期;status=published 时缺省为当前时间
* - cover 可选
* - description 可选,覆盖 SEO 描述
*
* 响应:201 { data: Post }
*/
export const POST = withErrorHandling(async (request: Request) => {
const body = await parseJsonBody(request);
const input = createPostSchema.parse(body);
// slug 唯一性在应用层先查一次,给出友好的字段级报错;
// 数据库唯一索引仍作为最终防线(并发下可能拦截,返回 409)
const post = await createPost(input);
return created(post);
});
/** 其他方法统一返回 405 */
export function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
Allow: "GET, POST, OPTIONS",
},
});
}
+52
View File
@@ -0,0 +1,52 @@
import { ok, withErrorHandling } from "@/lib/api/response";
import { siteConfig } from "@/lib/site-config";
/**
* GET /api —— 接口索引
*
* 列出当前可用的接口,方便开发与调试时快速查看。
* 生产环境可按需移除或限制访问。
*/
export const GET = withErrorHandling(async () => {
return ok({
name: siteConfig.name,
version: "v0.1",
endpoints: [
{
method: "GET",
path: "/api/health",
description: "健康检查(含数据库连通性与延迟)",
},
{
method: "GET",
path: "/api/posts",
description: "文章列表,支持 limit / offset / tag / status / includeDrafts",
},
{
method: "POST",
path: "/api/posts",
description: "新建文章",
},
{
method: "GET",
path: "/api/posts/[slug]",
description: "文章详情(含正文)",
},
{
method: "PATCH",
path: "/api/posts/[slug]",
description: "更新文章(部分更新;PUT 同义)",
},
{
method: "DELETE",
path: "/api/posts/[slug]",
description: "删除文章",
},
{
method: "GET",
path: "/api/tags",
description: "标签聚合(含各标签文章数)",
},
],
});
});
+14
View File
@@ -0,0 +1,14 @@
import { ok, withErrorHandling } from "@/lib/api/response";
import { listTags } from "@/lib/posts/repository";
/**
* GET /api/tags —— 标签聚合
*
* 返回全部已发布文章使用到的标签及各自文章数,按数量倒序。
* 响应:{ data: [{ tag: string, count: number }], meta: { total } }
*/
export const GET = withErrorHandling(async () => {
const tags = await listTags();
return ok(tags, { total: tags.length });
});