refactor: 完成静态网站转换,移除所有 CMS 和动态功能
- 删除数据库相关代码 (src/db/) - 删除 API 路由 (src/app/api/) - 删除认证相关代码 (src/lib/auth/, src/providers/) - 删除监控和安全中间件 (src/lib/security/, src/lib/monitoring/) - 删除 hooks (use-news, use-products, use-services) - 更新组件为静态数据源 - 添加 nginx 静态配置和部署脚本 - 添加 static-link 组件
This commit is contained in:
@@ -1,179 +0,0 @@
|
||||
import { GET, POST, PUT } from './route';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: jest.fn(),
|
||||
getAdminUserId: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/db', () => {
|
||||
const mockSelect = jest.fn().mockReturnValue({
|
||||
from: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockResolvedValue([]),
|
||||
orderBy: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const mockUpdate = jest.fn().mockReturnValue({
|
||||
set: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
returning: jest.fn().mockResolvedValue([{
|
||||
id: 'test-id',
|
||||
key: 'test_key',
|
||||
value: 'test_value',
|
||||
category: 'general',
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
insert: jest.fn().mockReturnValue({
|
||||
values: jest.fn().mockReturnValue({
|
||||
returning: jest.fn().mockResolvedValue([{
|
||||
id: 'test-id',
|
||||
key: 'test_key',
|
||||
value: 'test_value',
|
||||
category: 'general',
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
|
||||
|
||||
describe('/api/admin/config', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return configs if authenticated and has permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.configs).toBeDefined();
|
||||
expect(Array.isArray(data.configs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: 'test', value: {} }),
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 400 if missing required fields', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockGetAdminUserId.mockResolvedValueOnce('1');
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: 'test' }),
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('缺少必要字段');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ configs: [] }),
|
||||
});
|
||||
const response = await PUT(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ configs: [] }),
|
||||
});
|
||||
const response = await PUT(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 400 if configs is not an array', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockGetAdminUserId.mockResolvedValueOnce('1');
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ configs: 'not-array' }),
|
||||
});
|
||||
const response = await PUT(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('无效的数据格式');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { siteConfig } from '@/db/schema';
|
||||
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
|
||||
import { forbidden, success, notFound, validationError, badRequest, handleApiError } from '@/lib/api-response';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const category = searchParams.get('category');
|
||||
const key = searchParams.get('key');
|
||||
|
||||
if (key) {
|
||||
const config = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(eq(siteConfig.key, key))
|
||||
.limit(1);
|
||||
|
||||
if (config.length === 0) {
|
||||
return notFound('配置不存在');
|
||||
}
|
||||
|
||||
return success(config[0]);
|
||||
}
|
||||
|
||||
const conditions = [];
|
||||
|
||||
if (category) {
|
||||
conditions.push(eq(siteConfig.category, category as 'feature' | 'style' | 'seo' | 'general'));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const configs = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(whereClause)
|
||||
.orderBy(siteConfig.key);
|
||||
|
||||
return success({
|
||||
configs: configs,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
const userId = await getAdminUserId();
|
||||
|
||||
if (!isAdmin || !userId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { key, value, category, description } = body;
|
||||
|
||||
if (!key || !value || !category) {
|
||||
return validationError('缺少必要字段', { required: ['key', 'value', 'category'] });
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(eq(siteConfig.key, key))
|
||||
.limit(1);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (existing.length > 0) {
|
||||
const updated = await db
|
||||
.update(siteConfig)
|
||||
.set({
|
||||
value,
|
||||
description: description || existing[0]!.description,
|
||||
updatedAt: now,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(siteConfig.key, key))
|
||||
.returning();
|
||||
|
||||
return success(updated[0], 200);
|
||||
}
|
||||
|
||||
const newConfig = await db
|
||||
.insert(siteConfig)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
key,
|
||||
value,
|
||||
category,
|
||||
description: description || null,
|
||||
updatedAt: now,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return success(newConfig[0], 201);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
const userId = await getAdminUserId();
|
||||
|
||||
if (!isAdmin || !userId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { configs } = body as { configs: Array<{ key: string; value: unknown; description?: string }> };
|
||||
|
||||
if (!Array.isArray(configs)) {
|
||||
return badRequest('无效的数据格式');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const results = [];
|
||||
|
||||
for (const config of configs) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(eq(siteConfig.key, config.key))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const updated = await db
|
||||
.update(siteConfig)
|
||||
.set({
|
||||
value: config.value,
|
||||
description: config.description || existing[0]!.description,
|
||||
updatedAt: now,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(siteConfig.key, config.key))
|
||||
.returning();
|
||||
results.push(updated[0]);
|
||||
} else {
|
||||
const created = await db
|
||||
.insert(siteConfig)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
key: config.key,
|
||||
value: config.value,
|
||||
category: 'general',
|
||||
description: config.description || null,
|
||||
updatedAt: now,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.returning();
|
||||
results.push(created[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return success(results);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get('key');
|
||||
|
||||
if (!key) {
|
||||
return badRequest('缺少 key 参数');
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(eq(siteConfig.key, key))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return notFound('配置不存在');
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(siteConfig)
|
||||
.where(eq(siteConfig.key, key));
|
||||
|
||||
return success({ success: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: jest.fn(),
|
||||
getAdminUserId: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/audit', () => ({
|
||||
createAuditLog: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
const { db } = require('@/db');
|
||||
const { auth } = require('@/lib/auth');
|
||||
const { hasPermission } = require('@/lib/auth/permissions');
|
||||
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
|
||||
|
||||
describe('GET /api/admin/content/[id]', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const { GET } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123');
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await GET(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const { GET } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123');
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await GET(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 404 if content not found', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
db.limit.mockResolvedValue([]);
|
||||
|
||||
const { GET } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123');
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await GET(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(data.error).toBe('内容不存在');
|
||||
});
|
||||
|
||||
it('should return content if found', async () => {
|
||||
const mockContent = {
|
||||
id: '123',
|
||||
title: 'Test Content',
|
||||
status: 'published',
|
||||
};
|
||||
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
db.limit.mockResolvedValue([mockContent]);
|
||||
db.orderBy.mockResolvedValue([]);
|
||||
|
||||
const { GET } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123');
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await GET(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.title).toBe('Test Content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/admin/content/[id]', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const { PUT } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ title: 'Updated' }),
|
||||
});
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await PUT(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const { PUT } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ title: 'Updated' }),
|
||||
});
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await PUT(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/admin/content/[id]', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const { DELETE } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await DELETE(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const { DELETE } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await DELETE(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { content, contentVersions } from '@/db/schema';
|
||||
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { forbidden, notFound, success, handleApiError } from '@/lib/api-response';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const item = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (item.length === 0) {
|
||||
return notFound('内容不存在');
|
||||
}
|
||||
|
||||
const versions = await db
|
||||
.select()
|
||||
.from(contentVersions)
|
||||
.where(eq(contentVersions.contentId, id))
|
||||
.orderBy(contentVersions.version);
|
||||
|
||||
return success({
|
||||
...item[0],
|
||||
versions,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
const userId = await getAdminUserId();
|
||||
|
||||
if (!isAdmin || !userId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
const existingContent = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (existingContent.length === 0) {
|
||||
return notFound('内容不存在');
|
||||
}
|
||||
|
||||
const current = existingContent[0]!;
|
||||
const now = new Date();
|
||||
|
||||
const maxVersion = await db
|
||||
.select({ max: contentVersions.version })
|
||||
.from(contentVersions)
|
||||
.where(eq(contentVersions.contentId, id));
|
||||
|
||||
const nextVersion = (maxVersion[0]?.max || 0) + 1;
|
||||
|
||||
await db.insert(contentVersions).values({
|
||||
id: nanoid(),
|
||||
contentId: id,
|
||||
version: nextVersion,
|
||||
title: current.title,
|
||||
content: current.content,
|
||||
changes: {
|
||||
from: {
|
||||
title: current.title,
|
||||
content: current.content,
|
||||
excerpt: current.excerpt,
|
||||
status: current.status,
|
||||
},
|
||||
to: body,
|
||||
},
|
||||
changedBy: userId,
|
||||
changedAt: now,
|
||||
});
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (body.title) updateData.title = body.title;
|
||||
if (body.slug) updateData.slug = body.slug;
|
||||
if (body.excerpt !== undefined) updateData.excerpt = body.excerpt;
|
||||
if (body.contentBody !== undefined) updateData.content = body.contentBody;
|
||||
if (body.coverImage !== undefined) updateData.coverImage = body.coverImage;
|
||||
if (body.category !== undefined) updateData.category = body.category;
|
||||
if (body.tags !== undefined) updateData.tags = body.tags;
|
||||
if (body.metadata !== undefined) updateData.metadata = body.metadata;
|
||||
|
||||
if (body.status) {
|
||||
updateData.status = body.status;
|
||||
if (body.status === 'published' && current.status !== 'published') {
|
||||
updateData.publishedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(content)
|
||||
.set(updateData)
|
||||
.where(eq(content.id, id))
|
||||
.returning();
|
||||
|
||||
await createAuditLog({
|
||||
userId,
|
||||
action: 'update',
|
||||
resourceType: 'content',
|
||||
resourceId: id,
|
||||
details: {
|
||||
changes: updateData,
|
||||
},
|
||||
});
|
||||
|
||||
return success(updated[0]);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
const userId = await getAdminUserId();
|
||||
|
||||
if (!isAdmin || !userId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existingContent = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (existingContent.length === 0) {
|
||||
return notFound('内容不存在');
|
||||
}
|
||||
|
||||
await db.delete(contentVersions).where(eq(contentVersions.contentId, id));
|
||||
await db.delete(content).where(eq(content.id, id));
|
||||
|
||||
await createAuditLog({
|
||||
userId,
|
||||
action: 'delete',
|
||||
resourceType: 'content',
|
||||
resourceId: id,
|
||||
details: {
|
||||
title: existingContent[0]!.title,
|
||||
},
|
||||
});
|
||||
|
||||
return success({ success: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { describe, it, expect, jest, beforeAll, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
const mockAuth = jest.fn();
|
||||
const mockHasPermission = jest.fn();
|
||||
const mockCheckIsAdmin = jest.fn();
|
||||
const mockGetAdminUserId = jest.fn();
|
||||
const mockDbSelect = jest.fn();
|
||||
const mockDbInsert = jest.fn();
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
auth: mockAuth,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: mockCheckIsAdmin,
|
||||
getAdminUserId: mockGetAdminUserId,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: mockHasPermission,
|
||||
}));
|
||||
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
orderBy: () => ({
|
||||
limit: () => ({
|
||||
offset: mockDbSelect,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: () => ({
|
||||
returning: mockDbInsert,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('drizzle-orm', () => ({
|
||||
eq: jest.fn(),
|
||||
desc: jest.fn(),
|
||||
and: jest.fn(),
|
||||
like: jest.fn(),
|
||||
sql: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: () => 'test-id-123',
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/audit', () => ({
|
||||
createAuditLog: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/db/schema', () => ({
|
||||
content: {},
|
||||
}));
|
||||
|
||||
import { GET, POST } from './route';
|
||||
|
||||
describe('/api/admin/content', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 when user lacks permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false, userId: '1' });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return content list when authorized', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockDbSelect.mockResolvedValueOnce([]);
|
||||
mockDbSelect.mockResolvedValueOnce([{ count: 0 }]);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.items).toEqual([]);
|
||||
expect(data.pagination).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type: 'news', title: 'Test', slug: 'test' }),
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 400 when missing required fields', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockGetAdminUserId.mockResolvedValueOnce('1');
|
||||
mockDbSelect.mockResolvedValueOnce([]);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type: 'news' }),
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('缺少必要字段');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,296 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { content } from '@/db/schema';
|
||||
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { forbidden, badRequest, success, handleApiError, validationError } from '@/lib/api-response';
|
||||
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();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type');
|
||||
const status = searchParams.get('status');
|
||||
const search = searchParams.get('search');
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '20');
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = [];
|
||||
|
||||
if (type) {
|
||||
conditions.push(eq(content.type, type as 'news' | 'product' | 'service' | 'case'));
|
||||
}
|
||||
|
||||
if (status) {
|
||||
conditions.push(eq(content.status, status as 'draft' | 'published' | 'archived'));
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(like(content.title, `%${search}%`));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(content.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(content)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
const total = countResult[0]?.count || 0;
|
||||
|
||||
return success({
|
||||
items,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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();
|
||||
const userId = await getAdminUserId();
|
||||
|
||||
if (!isAdmin || !userId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { type, title, slug, excerpt, contentBody, coverImage, category, tags, status: contentStatus, metadata } = body;
|
||||
|
||||
if (!type || !title || !slug) {
|
||||
return validationError('缺少必要字段', { required: ['type', 'title', 'slug'] });
|
||||
}
|
||||
|
||||
const existingContent = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
if (existingContent.length > 0) {
|
||||
return badRequest('Slug 已存在');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const newContent = await db
|
||||
.insert(content)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
type,
|
||||
title,
|
||||
slug,
|
||||
excerpt: excerpt || null,
|
||||
content: contentBody || '',
|
||||
coverImage: coverImage || null,
|
||||
category: category || null,
|
||||
tags: tags || [],
|
||||
status: contentStatus || 'draft',
|
||||
publishedAt: contentStatus === 'published' ? now : null,
|
||||
authorId: userId,
|
||||
metadata: metadata || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await createAuditLog({
|
||||
userId,
|
||||
action: 'create',
|
||||
resourceType: 'content',
|
||||
resourceId: newContent[0]!.id,
|
||||
details: {
|
||||
type,
|
||||
title,
|
||||
status: contentStatus || 'draft',
|
||||
},
|
||||
});
|
||||
|
||||
return success(newContent[0], 201);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { SecurityLogger } from '@/lib/security/logger';
|
||||
|
||||
const securityLogger = new SecurityLogger();
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const logs = securityLogger.getRecentLogs(100);
|
||||
const stats = securityLogger.getStats();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
logs,
|
||||
stats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching security data:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch security data'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { POST, DELETE } from './route';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: jest.fn(),
|
||||
getAdminUserId: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/audit', () => ({
|
||||
createAuditLog: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/upload', () => ({
|
||||
uploadFile: jest.fn().mockResolvedValue({
|
||||
id: 'test-id',
|
||||
name: 'test.jpg',
|
||||
type: 'image',
|
||||
size: 1024,
|
||||
url: 'https://example.com/test.jpg',
|
||||
}),
|
||||
deleteFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
|
||||
|
||||
describe('/api/admin/upload', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File(['test'], 'test.jpg', { type: 'image/jpeg' }));
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/upload', {
|
||||
method: 'POST',
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 400 if no file', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockGetAdminUserId.mockResolvedValueOnce('1');
|
||||
|
||||
const request = {
|
||||
formData: jest.fn().mockResolvedValue(new FormData()),
|
||||
} as any;
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('未找到文件');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/upload?url=test.jpg', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const response = await DELETE(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { uploadFile, deleteFile } from '@/lib/upload';
|
||||
import { forbidden, badRequest, notFound, success, handleApiError } from '@/lib/api-response';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
const userId = await getAdminUserId();
|
||||
|
||||
if (!isAdmin || !userId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const type = (formData.get('type') as 'image' | 'document') || 'image';
|
||||
|
||||
if (!file) {
|
||||
return badRequest('未找到文件');
|
||||
}
|
||||
|
||||
const result = await uploadFile(file, {
|
||||
type,
|
||||
userId,
|
||||
});
|
||||
|
||||
await createAuditLog({
|
||||
userId,
|
||||
action: 'upload',
|
||||
resourceType: 'file',
|
||||
resourceId: result.id,
|
||||
details: {
|
||||
fileName: result.name,
|
||||
fileType: result.type,
|
||||
fileSize: result.size,
|
||||
url: result.url,
|
||||
},
|
||||
});
|
||||
|
||||
return success({
|
||||
success: true,
|
||||
file: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('文件上传失败:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
return badRequest(error.message);
|
||||
}
|
||||
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
const userId = await getAdminUserId();
|
||||
|
||||
if (!isAdmin || !userId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const fileUrl = searchParams.get('url');
|
||||
|
||||
if (!fileUrl) {
|
||||
return badRequest('缺少文件 URL');
|
||||
}
|
||||
|
||||
const result = await deleteFile(fileUrl);
|
||||
|
||||
if (!result) {
|
||||
return notFound('文件不存在或删除失败');
|
||||
}
|
||||
|
||||
return success({ success: true });
|
||||
} catch (error) {
|
||||
console.error('文件删除失败:', error);
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { GET, PUT, DELETE } from './route';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
select: jest.fn().mockReturnValue({
|
||||
from: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockResolvedValue([{
|
||||
id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
isAdmin: true,
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: jest.fn().mockReturnValue({
|
||||
set: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
returning: jest.fn().mockResolvedValue([{
|
||||
id: 'test-user-id',
|
||||
email: 'updated@example.com',
|
||||
name: 'Updated User',
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
delete: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { checkIsAdmin: mockCheckIsAdmin } = require('@/lib/auth/check-permission');
|
||||
|
||||
describe('/api/admin/users/[id]', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id');
|
||||
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id');
|
||||
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return user if authenticated and has permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id');
|
||||
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.user).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name: 'Updated User' }),
|
||||
});
|
||||
const response = await PUT(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const response = await DELETE(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { users } from '@/db/schema';
|
||||
import { checkIsAdmin } from '@/lib/auth/check-permission';
|
||||
import { forbidden, notFound, success, handleApiError } from '@/lib/api-response';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const user = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
isAdmin: users.isAdmin,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (user.length === 0) {
|
||||
return notFound('用户不存在');
|
||||
}
|
||||
|
||||
return success({ user: user[0] });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const body = await request.json();
|
||||
const { email, name, password } = body;
|
||||
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length === 0) {
|
||||
return notFound('用户不存在');
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (email) updateData.email = email;
|
||||
if (name) updateData.name = name;
|
||||
if (password) {
|
||||
updateData.passwordHash = await bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(users)
|
||||
.set(updateData)
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
|
||||
return success({
|
||||
user: {
|
||||
id: updated[0]!.id,
|
||||
email: updated[0]!.email,
|
||||
name: updated[0]!.name,
|
||||
isAdmin: updated[0]!.isAdmin,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
await db
|
||||
.delete(users)
|
||||
.where(eq(users.id, id));
|
||||
|
||||
return success({ success: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, it, expect, jest, beforeAll, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
const mockAuth = jest.fn();
|
||||
const mockHasPermission = jest.fn();
|
||||
const mockCheckIsAdmin = jest.fn();
|
||||
const mockDbSelect = jest.fn();
|
||||
const mockDbInsert = jest.fn();
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
auth: mockAuth,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: mockCheckIsAdmin,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: mockHasPermission,
|
||||
}));
|
||||
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: mockDbSelect,
|
||||
}),
|
||||
orderBy: () => mockDbSelect(),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: () => ({
|
||||
returning: mockDbInsert,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('drizzle-orm', () => ({
|
||||
eq: jest.fn(),
|
||||
desc: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: () => 'test-id-123',
|
||||
}));
|
||||
|
||||
jest.mock('bcryptjs', () => ({
|
||||
hash: jest.fn().mockResolvedValue('hashed-password'),
|
||||
}));
|
||||
|
||||
jest.mock('@/db/schema', () => ({
|
||||
users: {},
|
||||
}));
|
||||
|
||||
import { GET } from './route';
|
||||
|
||||
describe('/api/admin/users', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 when user lacks permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false, userId: '1' });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return users list when authorized', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockDbSelect.mockResolvedValueOnce([
|
||||
{ id: '1', email: 'admin@example.com', name: 'Admin', isAdmin: true },
|
||||
]);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.users).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { users } from '@/db/schema';
|
||||
import { checkIsAdmin } from '@/lib/auth/check-permission';
|
||||
import { forbidden, success, handleApiError } from '@/lib/api-response';
|
||||
import { desc } from 'drizzle-orm';
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const allUsers = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
isAdmin: users.isAdmin,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
})
|
||||
.from(users)
|
||||
.orderBy(desc(users.createdAt));
|
||||
|
||||
return success({ users: allUsers });
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error);
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { GET, POST } from './route';
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
handlers: {
|
||||
GET: jest.fn(() => new Response('GET response')),
|
||||
POST: jest.fn(() => new Response('POST response')),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('/api/auth/[...nextauth]', () => {
|
||||
describe('GET handler', () => {
|
||||
it('should export GET handler', () => {
|
||||
expect(typeof GET).toBe('function');
|
||||
});
|
||||
|
||||
it('should call auth GET handler', async () => {
|
||||
const response = await GET(new Request('http://localhost/api/auth/signin'));
|
||||
expect(response).toBeDefined();
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST handler', () => {
|
||||
it('should export POST handler', () => {
|
||||
expect(typeof POST).toBe('function');
|
||||
});
|
||||
|
||||
it('should call auth POST handler', async () => {
|
||||
const response = await POST(
|
||||
new Request('http://localhost/api/auth/signin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: 'test@example.com', password: 'password' }),
|
||||
})
|
||||
);
|
||||
expect(response).toBeDefined();
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { handlers } from '@/lib/auth';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -1,25 +0,0 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
import { POST, setSecurityMiddleware } from './route';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { generateCaptcha } from '@/lib/security/captcha';
|
||||
import { SecurityMiddleware } from '@/lib/security/middleware';
|
||||
|
||||
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 {
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
emails: {
|
||||
send: mockSend,
|
||||
},
|
||||
})),
|
||||
Resend: jest.fn().mockImplementation(() => ({
|
||||
emails: {
|
||||
send: mockSend,
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('/api/contact', () => {
|
||||
let mockRequest: NextRequest;
|
||||
let mockSend: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const { default: Resend } = require('resend');
|
||||
const resendInstance = new Resend();
|
||||
mockSend = resendInstance.emails.send;
|
||||
mockSend.mockClear();
|
||||
|
||||
const securityMiddleware = new SecurityMiddleware();
|
||||
setSecurityMiddleware(securityMiddleware);
|
||||
});
|
||||
|
||||
const createMockRequest = (body: any, ip: string = '192.168.1.1'): NextRequest => {
|
||||
const headers = new Headers();
|
||||
headers.set('x-forwarded-for', ip);
|
||||
headers.set('user-agent', 'test-agent');
|
||||
|
||||
return {
|
||||
json: async () => body,
|
||||
headers,
|
||||
} as unknown as NextRequest;
|
||||
};
|
||||
|
||||
it('should handle POST request with valid data', async () => {
|
||||
mockSend.mockResolvedValue({
|
||||
data: { id: 'test-id' },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const captcha = generateCaptcha('simple');
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}, '192.168.1.2');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.message).toBe('消息已发送');
|
||||
expect(mockSend).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
}, '192.168.1.5');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('请填写必填字段');
|
||||
});
|
||||
|
||||
it('should validate email format', async () => {
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'invalid-email',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
}, '192.168.1.6');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('请输入有效的邮箱地址');
|
||||
});
|
||||
|
||||
it('should reject honeypot field', async () => {
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
website: 'spam-bot',
|
||||
}, '192.168.1.7');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(mockSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject invalid captcha', async () => {
|
||||
const captcha = generateCaptcha('simple');
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: 999,
|
||||
}, '192.168.1.3');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('Invalid captcha');
|
||||
});
|
||||
|
||||
it('should handle Resend API error', async () => {
|
||||
mockSend.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'API Error' },
|
||||
});
|
||||
|
||||
const captcha = generateCaptcha('simple');
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}, '192.168.1.4');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('邮件发送失败,请稍后重试');
|
||||
});
|
||||
|
||||
it('should handle JSON parsing error', async () => {
|
||||
mockRequest = {
|
||||
json: async () => {
|
||||
throw new Error('Invalid JSON');
|
||||
},
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('提交失败,请重试');
|
||||
});
|
||||
|
||||
it('should accept valid submission with phone', async () => {
|
||||
mockSend.mockResolvedValue({
|
||||
data: { id: 'test-id' },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const captcha = generateCaptcha('simple');
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phone: '13800138000',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}, '192.168.1.10');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept valid submission with math captcha', async () => {
|
||||
mockSend.mockResolvedValue({
|
||||
data: { id: 'test-id' },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const captcha = generateCaptcha('simple');
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}, '192.168.1.11');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block malicious content', async () => {
|
||||
const captcha = generateCaptcha('simple');
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
message: 'You won a free bitcoin lottery! Act now to claim your prize.',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}, '192.168.1.12');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toContain('malicious');
|
||||
});
|
||||
|
||||
it('should block rate limit exceeded', async () => {
|
||||
mockSend.mockResolvedValue({
|
||||
data: { id: 'test-id' },
|
||||
error: null,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const captcha = generateCaptcha('simple');
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: `test${i}@example.com`,
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}, '192.168.2.1');
|
||||
|
||||
await POST(mockRequest);
|
||||
}
|
||||
|
||||
const captcha = generateCaptcha('simple');
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}, '192.168.2.1');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toContain('Rate limit exceeded');
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { Resend } from 'resend';
|
||||
import { z } from 'zod';
|
||||
import { SecurityMiddleware } from '@/lib/security/middleware';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
const companyEmail = process.env.COMPANY_EMAIL || 'contact@novalon.cn';
|
||||
|
||||
let securityMiddleware = new SecurityMiddleware();
|
||||
|
||||
export function setSecurityMiddleware(middleware: SecurityMiddleware) {
|
||||
securityMiddleware = middleware;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const requiredFields = ['name', 'email', 'subject', 'message'];
|
||||
for (const field of requiredFields) {
|
||||
if (!body[field]) {
|
||||
return Response.json(
|
||||
{ success: false, error: '请填写必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const emailValidation = z.string().email().safeParse(body.email);
|
||||
if (!emailValidation.success) {
|
||||
return Response.json(
|
||||
{ success: false, error: '请输入有效的邮箱地址' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (body.website) {
|
||||
return Response.json({ success: true, message: '消息已发送' });
|
||||
}
|
||||
|
||||
const clientIP = request.headers.get('x-forwarded-for')?.split(',')[0] ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown';
|
||||
|
||||
const securityCheck = await securityMiddleware.checkRequest({
|
||||
ip: clientIP,
|
||||
email: body.email,
|
||||
name: body.name,
|
||||
phone: body.phone,
|
||||
subject: body.subject,
|
||||
message: body.message,
|
||||
captchaHash: body.mathHash || '',
|
||||
captchaAnswer: parseInt(body.mathAnswer) || 0,
|
||||
captchaTimestamp: parseInt(body.mathTimestamp) || 0,
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
});
|
||||
|
||||
if (!securityCheck.allowed) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: securityCheck.errors[0] || '安全验证失败',
|
||||
warnings: securityCheck.warnings
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const sanitizedData = securityMiddleware.sanitizeRequest({
|
||||
ip: clientIP,
|
||||
email: body.email,
|
||||
name: body.name,
|
||||
phone: body.phone,
|
||||
subject: body.subject,
|
||||
message: body.message,
|
||||
captchaHash: body.mathHash || '',
|
||||
captchaAnswer: parseInt(body.mathAnswer) || 0,
|
||||
captchaTimestamp: parseInt(body.mathTimestamp) || 0,
|
||||
});
|
||||
|
||||
const emailContent = `
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
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>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="info-row"><span class="info-label">姓名:</span> ${sanitizedData.name}</div>
|
||||
<div class="info-row"><span class="info-label">邮箱:</span> ${sanitizedData.email}</div>
|
||||
${sanitizedData.phone ? `<div class="info-row"><span class="info-label">电话:</span> ${sanitizedData.phone}</div>` : ''}
|
||||
<div class="info-row"><span class="info-label">主题:</span> ${sanitizedData.subject}</div>
|
||||
<div class="info-row"><span class="info-label">留言:</span> ${sanitizedData.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const result = await resend.emails.send({
|
||||
from: '睿新致远官网 <onboarding@resend.dev>',
|
||||
to: [companyEmail],
|
||||
subject: `${sanitizedData.subject} - ${sanitizedData.name}`,
|
||||
html: emailContent,
|
||||
replyTo: sanitizedData.email,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error('Resend API error:', result.error);
|
||||
return Response.json(
|
||||
{ success: false, error: '邮件发送失败,请稍后重试' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ success: true, message: '消息已发送' });
|
||||
} catch (error) {
|
||||
console.error('Contact form submission error:', error);
|
||||
return Response.json(
|
||||
{ success: false, error: '提交失败,请重试' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { content } from '@/db/schema';
|
||||
import { eq, desc, and, like } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type');
|
||||
const status = searchParams.get('status') || 'published';
|
||||
const search = searchParams.get('search');
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '100');
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = [];
|
||||
|
||||
if (type) {
|
||||
conditions.push(eq(content.type, type as 'news' | 'product' | 'service' | 'case'));
|
||||
}
|
||||
|
||||
if (status) {
|
||||
conditions.push(eq(content.status, status as 'draft' | 'published' | 'archived'));
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(like(content.title, `%${search}%`));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(content.publishedAt), desc(content.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
data: items,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch content:', error);
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch content',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
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,83 +0,0 @@
|
||||
import { GET } from './route';
|
||||
|
||||
describe('/api/health', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return health status with all required fields', async () => {
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect([200, 503]).toContain(response.status);
|
||||
expect(['healthy', 'unhealthy']).toContain(data.status);
|
||||
expect(data.timestamp).toBeDefined();
|
||||
expect(data.uptime).toBeDefined();
|
||||
expect(data.version).toBeDefined();
|
||||
expect(data.environment).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include database check', async () => {
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.checks).toBeDefined();
|
||||
expect(data.checks.database).toBeDefined();
|
||||
expect(data.checks.database.status).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include memory check', async () => {
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.checks.memory).toBeDefined();
|
||||
expect(data.checks.memory.status).toBeDefined();
|
||||
expect(data.checks.memory.used).toBeDefined();
|
||||
expect(data.checks.memory.total).toBeDefined();
|
||||
expect(data.checks.memory.percentage).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include CPU check', async () => {
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.checks.cpu).toBeDefined();
|
||||
expect(data.checks.cpu.status).toBeDefined();
|
||||
expect(data.checks.cpu.load).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 503 when a check is unhealthy', async () => {
|
||||
const originalMemoryUsage = process.memoryUsage;
|
||||
process.memoryUsage = jest.fn(() => ({
|
||||
heapUsed: 1000000000,
|
||||
heapTotal: 1000000000,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
rss: 0,
|
||||
}));
|
||||
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(data.checks.memory.status).toBe('unhealthy');
|
||||
|
||||
process.memoryUsage = originalMemoryUsage;
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const originalUptime = process.uptime;
|
||||
process.uptime = jest.fn(() => {
|
||||
throw new Error('Process error');
|
||||
});
|
||||
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(data.status).toBe('unhealthy');
|
||||
expect(data.error).toBeDefined();
|
||||
|
||||
process.uptime = originalUptime;
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const healthStatus = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
checks: {
|
||||
database: await checkDatabase(),
|
||||
memory: checkMemory(),
|
||||
cpu: checkCPU(),
|
||||
},
|
||||
};
|
||||
|
||||
const allChecksHealthy = Object.values(healthStatus.checks).every(
|
||||
(check) => check.status === 'healthy'
|
||||
);
|
||||
|
||||
return NextResponse.json(healthStatus, {
|
||||
status: allChecksHealthy ? 200 : 503,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDatabase(): Promise<{ status: string; latency?: number; error?: string }> {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 简单的数据库连接检查
|
||||
// 如果有数据库连接,可以添加实际的检查逻辑
|
||||
// const db = await getDatabaseConnection();
|
||||
// await db.execute('SELECT 1');
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
latency,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
error: error instanceof Error ? error.message : 'Database check failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkMemory(): { status: string; used?: number; total?: number; percentage?: number } {
|
||||
try {
|
||||
const memUsage = process.memoryUsage();
|
||||
const usedMB = Math.round(memUsage.heapUsed / 1024 / 1024);
|
||||
const totalMB = Math.round(memUsage.heapTotal / 1024 / 1024);
|
||||
const percentage = Math.round((usedMB / totalMB) * 100);
|
||||
|
||||
// 如果内存使用超过90%,标记为不健康
|
||||
const status = percentage > 90 ? 'unhealthy' : 'healthy';
|
||||
|
||||
return {
|
||||
status,
|
||||
used: usedMB,
|
||||
total: totalMB,
|
||||
percentage,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkCPU(): { status: string; load?: number } {
|
||||
try {
|
||||
const cpus = process.cpuUsage();
|
||||
const load = (cpus.user + cpus.system) / 1000000; // 转换为秒
|
||||
|
||||
// 简单的CPU负载检查
|
||||
const status = load < 100 ? 'healthy' : 'unhealthy';
|
||||
|
||||
return {
|
||||
status,
|
||||
load: Math.round(load * 100) / 100,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
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