feat: 添加管理后台页面和功能,优化测试和性能配置
refactor: 重构页面导航和滚动逻辑,提升用户体验 test: 更新测试配置和用例,增加覆盖率和稳定性 perf: 优化性能指标和阈值,适应开发环境需求 ci: 添加Lighthouse CI工作流,集成性能测试 docs: 更新API文档和健康检查端点 fix: 修复登录页面和表单提交问题 style: 调整响应式布局和可访问性改进 chore: 更新依赖项和脚本配置
This commit is contained in:
@@ -94,7 +94,7 @@ describe('/api/admin/config', () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.configs).toBeDefined();
|
||||
expect(data.flat).toBeDefined();
|
||||
expect(Array.isArray(data.configs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,83 @@ import { forbidden, badRequest, success, handleApiError, validationError } from
|
||||
import { eq, desc, and, like, sql } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/admin/content:
|
||||
* get:
|
||||
* tags:
|
||||
* - Admin
|
||||
* - Content
|
||||
* summary: 获取内容列表
|
||||
* description: 管理员获取内容列表,支持分页、筛选和搜索
|
||||
* operationId: getAdminContent
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - name: type
|
||||
* in: query
|
||||
* description: 内容类型
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [news, product, service, case]
|
||||
* - name: status
|
||||
* in: query
|
||||
* description: 内容状态
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [draft, published, archived]
|
||||
* - name: search
|
||||
* in: query
|
||||
* description: 搜索关键词
|
||||
* schema:
|
||||
* type: string
|
||||
* - name: page
|
||||
* in: query
|
||||
* description: 页码
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 1
|
||||
* - name: limit
|
||||
* in: query
|
||||
* description: 每页数量
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 20
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 成功获取内容列表
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* items:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Content'
|
||||
* pagination:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* limit:
|
||||
* type: integer
|
||||
* total:
|
||||
* type: integer
|
||||
* totalPages:
|
||||
* type: integer
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
@@ -69,6 +146,89 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/admin/content:
|
||||
* post:
|
||||
* tags:
|
||||
* - Admin
|
||||
* - Content
|
||||
* summary: 创建新内容
|
||||
* description: 管理员创建新的内容(新闻、产品、服务、案例)
|
||||
* operationId: createContent
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - type
|
||||
* - title
|
||||
* - slug
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [news, product, service, case]
|
||||
* description: 内容类型
|
||||
* title:
|
||||
* type: string
|
||||
* description: 标题
|
||||
* slug:
|
||||
* type: string
|
||||
* description: URL别名
|
||||
* excerpt:
|
||||
* type: string
|
||||
* description: 摘要
|
||||
* contentBody:
|
||||
* type: string
|
||||
* description: 内容正文
|
||||
* coverImage:
|
||||
* type: string
|
||||
* description: 封面图片URL
|
||||
* category:
|
||||
* type: string
|
||||
* description: 分类
|
||||
* tags:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: 标签列表
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [draft, published, archived]
|
||||
* default: draft
|
||||
* description: 状态
|
||||
* metadata:
|
||||
* type: object
|
||||
* description: 元数据
|
||||
* responses:
|
||||
* 201:
|
||||
* description: 内容创建成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Content'
|
||||
* 400:
|
||||
* description: 请求参数错误
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import { POST } from './route';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
if (!global.Response) {
|
||||
global.Response = class Response {
|
||||
status: number;
|
||||
private _body: string;
|
||||
constructor(body: string, init?: { status?: number }) {
|
||||
this._body = body;
|
||||
this.status = init?.status || 200;
|
||||
}
|
||||
async json() {
|
||||
return JSON.parse(this._body);
|
||||
}
|
||||
} as any;
|
||||
}
|
||||
|
||||
if (!(global.Response as any).json) {
|
||||
(global.Response as any).json = function(data: any, init?: { status?: number }) {
|
||||
return new Response(JSON.stringify(data), init);
|
||||
};
|
||||
}
|
||||
|
||||
jest.mock('resend', () => {
|
||||
const mockSend = jest.fn();
|
||||
return {
|
||||
|
||||
+44
-183
@@ -1,54 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { Resend } from 'resend';
|
||||
import { z } from 'zod';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
const companyEmail = process.env.COMPANY_EMAIL || 'contact@novalon.cn';
|
||||
|
||||
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const { name, email, phone, subject, message, website, submitTime, mathHash, mathTimestamp, mathAnswer } = body;
|
||||
|
||||
if (!name || !email || !subject || !message) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请填写必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
const requiredFields = ['name', 'email', 'subject', 'message'];
|
||||
for (const field of requiredFields) {
|
||||
if (!body[field]) {
|
||||
return Response.json(
|
||||
{ success: false, error: '请填写必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
const emailValidation = z.string().email().safeParse(body.email);
|
||||
if (!emailValidation.success) {
|
||||
return Response.json(
|
||||
{ success: false, error: '请输入有效的邮箱地址' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (website) {
|
||||
console.log('Honeypot field filled, rejecting request');
|
||||
return NextResponse.json(
|
||||
{ success: true, message: '消息已发送' },
|
||||
{ status: 200 }
|
||||
);
|
||||
if (body.website) {
|
||||
return Response.json({ success: true, message: '消息已发送' });
|
||||
}
|
||||
|
||||
if (submitTime) {
|
||||
const timeDiff = Date.now() - parseInt(submitTime);
|
||||
if (body.submitTime) {
|
||||
const timeDiff = Date.now() - parseInt(body.submitTime);
|
||||
if (timeDiff < 2000) {
|
||||
console.log('Submission too fast:', timeDiff);
|
||||
return NextResponse.json(
|
||||
return Response.json(
|
||||
{ success: false, error: '提交过快,请稍后再试' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mathHash && mathTimestamp && mathAnswer !== undefined) {
|
||||
const expectedHash = btoa(`${mathAnswer}-${mathTimestamp}`);
|
||||
if (expectedHash !== mathHash) {
|
||||
console.log('Invalid math captcha');
|
||||
return NextResponse.json(
|
||||
if (body.mathHash && body.mathTimestamp && body.mathAnswer !== undefined) {
|
||||
const expectedHash = btoa(`${body.mathAnswer}-${body.mathTimestamp}`);
|
||||
if (expectedHash !== body.mathHash) {
|
||||
return Response.json(
|
||||
{ success: false, error: '验证码错误,请重新计算' },
|
||||
{ status: 400 }
|
||||
);
|
||||
@@ -59,189 +57,52 @@ export async function POST(request: NextRequest) {
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1C1C1C;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.header {
|
||||
background: #C41E3A;
|
||||
color: white;
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header p {
|
||||
margin: 10px 0 0 0;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.info-card {
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 25px;
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.info-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
color: #1C1C1C;
|
||||
min-width: 70px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.info-value {
|
||||
color: #5C5C5C;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
.message-box {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-left: 4px solid #C41E3A;
|
||||
margin-top: 20px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
.message-label {
|
||||
font-weight: 600;
|
||||
color: #C41E3A;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.message-content {
|
||||
color: #1C1C1C;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: #8C8C8C;
|
||||
font-size: 12px;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
.footer a {
|
||||
color: #C41E3A;
|
||||
text-decoration: none;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #e5e5e5;
|
||||
margin: 25px 0;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: #C41E3A;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
body { font-family: sans-serif; line-height: 1.6; color: #1C1C1C; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #C41E3A; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; }
|
||||
.info-row { margin-bottom: 10px; }
|
||||
.info-label { font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📬 新的客户咨询</h1>
|
||||
<p>来自 睿新致远官方网站</p>
|
||||
<h1>新的客户咨询</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<span class="badge">新消息</span>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="info-row">
|
||||
<div class="info-label">姓名</div>
|
||||
<div class="info-value">${name}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">邮箱</div>
|
||||
<div class="info-value"><a href="mailto:${email}" style="color: #C41E3A; text-decoration: none;">${email}</a></div>
|
||||
</div>
|
||||
${phone ? `
|
||||
<div class="info-row">
|
||||
<div class="info-label">电话</div>
|
||||
<div class="info-value">${phone}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="info-row">
|
||||
<div class="info-label">主题</div>
|
||||
<div class="info-value">${subject}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-box">
|
||||
<div class="message-label">咨询内容</div>
|
||||
<div class="message-content">${message}</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div style="text-align: center; color: #8C8C8C; font-size: 13px;">
|
||||
<p>💡 提示:点击邮箱地址可直接回复客户</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p style="margin-bottom: 10px;">本邮件由 睿新致远 官网联系表单自动发送,请勿直接回复此邮件</p>
|
||||
<p style="margin-bottom: 10px;">如需回复客户,请点击上方邮箱地址或直接回复客户的原始邮件</p>
|
||||
<p style="margin-bottom: 15px;">提交时间:${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</p>
|
||||
<p style="margin-top: 15px; border-top: 1px solid #e5e5e5; padding-top: 15px;">© ${new Date().getFullYear()} 四川睿新致远科技有限公司. All rights reserved.</p>
|
||||
<div class="info-row"><span class="info-label">姓名:</span> ${body.name}</div>
|
||||
<div class="info-row"><span class="info-label">邮箱:</span> ${body.email}</div>
|
||||
${body.phone ? `<div class="info-row"><span class="info-label">电话:</span> ${body.phone}</div>` : ''}
|
||||
<div class="info-row"><span class="info-label">主题:</span> ${body.subject}</div>
|
||||
<div class="info-row"><span class="info-label">留言:</span> ${body.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const { data, error } = await resend.emails.send({
|
||||
const result = await resend.emails.send({
|
||||
from: '睿新致远官网 <onboarding@resend.dev>',
|
||||
to: [companyEmail],
|
||||
subject: `📧 ${subject} - ${name}`,
|
||||
subject: `${body.subject} - ${body.name}`,
|
||||
html: emailContent,
|
||||
replyTo: email,
|
||||
replyTo: body.email,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Resend API error:', error);
|
||||
return NextResponse.json(
|
||||
if (result.error) {
|
||||
console.error('Resend API error:', result.error);
|
||||
return Response.json(
|
||||
{ success: false, error: '邮件发送失败,请稍后重试' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Email sent successfully:', data);
|
||||
return NextResponse.json({ success: true, message: '消息已发送' });
|
||||
return Response.json({ success: true, message: '消息已发送' });
|
||||
} catch (error) {
|
||||
console.error('Contact form submission error:', error);
|
||||
return NextResponse.json(
|
||||
return Response.json(
|
||||
{ success: false, error: '提交失败,请重试' },
|
||||
{ status: 500 }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: '睿新致远 API',
|
||||
version: '1.0.0',
|
||||
description: `
|
||||
## 睿新致远官方网站API文档
|
||||
|
||||
### API版本
|
||||
|
||||
当前支持以下版本:
|
||||
|
||||
- **v1** (Current): 当前推荐版本,包含所有核心功能
|
||||
- **legacy** (Deprecated): 旧版本API,已重定向到v1
|
||||
|
||||
### 版本使用
|
||||
|
||||
所有新开发应使用v1版本API:
|
||||
|
||||
\`\`\`
|
||||
GET /api/v1/health
|
||||
GET /api/v1/admin/content
|
||||
\`\`\`
|
||||
|
||||
旧版本API路径会自动重定向到v1版本:
|
||||
|
||||
\`\`\`
|
||||
GET /api/health → GET /api/v1/health
|
||||
GET /api/admin/content → GET /api/v1/admin/content
|
||||
\`\`\`
|
||||
|
||||
### 认证
|
||||
|
||||
需要认证的API使用Bearer Token:
|
||||
|
||||
\`\`\`
|
||||
Authorization: Bearer <your-access-token>
|
||||
\`\`\`
|
||||
`,
|
||||
contact: {
|
||||
name: '睿新致远',
|
||||
email: 'contact@novalon.cn',
|
||||
url: 'https://novalon.cn',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: '/api/v1',
|
||||
description: 'API v1 (Current)',
|
||||
},
|
||||
{
|
||||
url: '/api',
|
||||
description: 'Legacy API (Redirects to v1)',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: false,
|
||||
},
|
||||
error: {
|
||||
type: 'string',
|
||||
example: '错误信息',
|
||||
},
|
||||
},
|
||||
},
|
||||
Content: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
example: 1,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['news', 'case', 'product', 'service'],
|
||||
example: 'news',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
example: '文章标题',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
example: '文章内容',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['draft', 'published', 'archived'],
|
||||
example: 'published',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
},
|
||||
},
|
||||
},
|
||||
User: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
example: 1,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
example: '用户名',
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'user@example.com',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
enum: ['admin', 'editor', 'viewer'],
|
||||
example: 'admin',
|
||||
},
|
||||
},
|
||||
},
|
||||
Config: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
example: 'site_name',
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
example: '睿新致远',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
example: '网站名称',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
name: 'Content',
|
||||
description: '内容管理相关接口',
|
||||
},
|
||||
{
|
||||
name: 'Admin',
|
||||
description: '管理员相关接口',
|
||||
},
|
||||
{
|
||||
name: 'Config',
|
||||
description: '配置相关接口',
|
||||
},
|
||||
{
|
||||
name: 'Health',
|
||||
description: '健康检查接口',
|
||||
},
|
||||
],
|
||||
},
|
||||
apis: [
|
||||
'./src/app/api/v1/**/route.ts',
|
||||
'./src/app/api/**/route.ts',
|
||||
'./src/app/(marketing)/contact/actions.ts',
|
||||
],
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const specs = swaggerJsdoc(options);
|
||||
return NextResponse.json(specs);
|
||||
}
|
||||
@@ -1,6 +1,74 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { monitor } from '@/lib/monitoring';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/health:
|
||||
* get:
|
||||
* tags:
|
||||
* - Health
|
||||
* summary: 健康检查
|
||||
* description: 检查应用程序的健康状态,包括数据库连接、内存使用等
|
||||
* operationId: getHealth
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 服务健康
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: ok
|
||||
* timestamp:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* uptime:
|
||||
* type: number
|
||||
* description: 服务运行时间(秒)
|
||||
* version:
|
||||
* type: string
|
||||
* description: 应用版本
|
||||
* environment:
|
||||
* type: string
|
||||
* description: 运行环境
|
||||
* memory:
|
||||
* type: object
|
||||
* properties:
|
||||
* heapUsed:
|
||||
* type: integer
|
||||
* description: 已使用堆内存(MB)
|
||||
* heapTotal:
|
||||
* type: integer
|
||||
* description: 总堆内存(MB)
|
||||
* rss:
|
||||
* type: integer
|
||||
* description: 常驻内存集大小(MB)
|
||||
* checks:
|
||||
* type: object
|
||||
* properties:
|
||||
* database:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* latency:
|
||||
* type: integer
|
||||
* memory:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* usage:
|
||||
* type: integer
|
||||
* 503:
|
||||
* description: 服务不可用
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export async function GET() {
|
||||
const startTime = Date.now();
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { siteConfig } from '@/db/schema';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const allConfigs = await db.select().from(siteConfig);
|
||||
|
||||
const configMap = allConfigs.reduce((acc, config) => {
|
||||
acc[config.key] = config.value;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: configMap
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { monitor } from '@/lib/monitoring';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/v1/health:
|
||||
* get:
|
||||
* tags:
|
||||
* - Health
|
||||
* summary: 健康检查 (v1)
|
||||
* description: 检查应用程序的健康状态,包括数据库连接、内存使用等
|
||||
* operationId: getHealthV1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 服务健康
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: ok
|
||||
* version:
|
||||
* type: string
|
||||
* description: API版本
|
||||
* example: v1
|
||||
* timestamp:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* uptime:
|
||||
* type: number
|
||||
* description: 服务运行时间(秒)
|
||||
* memory:
|
||||
* type: object
|
||||
* properties:
|
||||
* heapUsed:
|
||||
* type: integer
|
||||
* description: 已使用堆内存(MB)
|
||||
* heapTotal:
|
||||
* type: integer
|
||||
* description: 总堆内存(MB)
|
||||
* rss:
|
||||
* type: integer
|
||||
* description: 常驻内存集大小(MB)
|
||||
* checks:
|
||||
* type: object
|
||||
* properties:
|
||||
* database:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* latency:
|
||||
* type: integer
|
||||
* memory:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* usage:
|
||||
* type: integer
|
||||
* 503:
|
||||
* description: 服务不可用
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export async function GET() {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const health = {
|
||||
status: 'ok',
|
||||
version: 'v1',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
memory: {
|
||||
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
||||
heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
|
||||
rss: Math.round(process.memoryUsage().rss / 1024 / 1024),
|
||||
},
|
||||
checks: {
|
||||
database: await checkDatabase(),
|
||||
memory: checkMemory(),
|
||||
},
|
||||
};
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
monitor.recordMetric('response_time', responseTime);
|
||||
|
||||
return NextResponse.json(health, { status: 200 });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
version: 'v1',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDatabase(): Promise<{ status: string; latency?: number }> {
|
||||
try {
|
||||
const start = Date.now();
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
latency: Date.now() - start,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkMemory(): { status: string; usage: number } {
|
||||
const memUsage = process.memoryUsage();
|
||||
const heapUsedMB = memUsage.heapUsed / 1024 / 1024;
|
||||
const heapTotalMB = memUsage.heapTotal / 1024 / 1024;
|
||||
const usagePercent = (heapUsedMB / heapTotalMB) * 100;
|
||||
|
||||
return {
|
||||
status: usagePercent > 90 ? 'warning' : 'ok',
|
||||
usage: Math.round(usagePercent),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user