This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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: "标签聚合(含各标签文章数)",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user