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:
张翔
2026-04-21 07:53:56 +08:00
parent cd1d6aa28a
commit 6403489954
197 changed files with 654 additions and 24762 deletions
-179
View File
@@ -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('无效的数据格式');
});
});
});
-209
View File
@@ -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);
});
});
-185
View File
@@ -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);
}
}
-143
View File
@@ -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('缺少必要字段');
});
});
});
-296
View File
@@ -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);
}
}
-26
View File
@@ -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 }
);
}
}
-102
View File
@@ -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('无权限执行此操作');
});
});
});
-84
View File
@@ -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);
}
}
-119
View File
@@ -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('无权限执行此操作');
});
});
});
-121
View File
@@ -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);
}
}
-102
View File
@@ -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();
});
});
});
-33
View File
@@ -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);
});
});
});
-5
View File
@@ -1,5 +0,0 @@
import { handlers } from '@/lib/auth';
export const dynamic = 'force-dynamic';
export const { GET, POST } = handlers;
-25
View File
@@ -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 }
);
}
}
-316
View File
@@ -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');
});
});
-135
View File
@@ -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 }
);
}
}
-54
View File
@@ -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 }
);
}
}
-188
View File
@@ -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);
}
-83
View File
@@ -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;
});
});
-101
View File
@@ -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',
};
}
}
-25
View File
@@ -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 }
);
}
}
-132
View File
@@ -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),
};
}