chore: sync marketing pages, CMS extensions, tests and project docs
同步工作区剩余变更,主要包括: - 营销页面组件与布局持续优化(about/news/services/solutions/team 等) - 详情页四层叙事组件、布局组件、UI 组件调整 - CMS 数据模型、API 路由、权限、工作流、站内通知、媒体管理扩展 - 新增/补充单元测试与 E2E 测试(cms-workflow.spec.ts 等) - ESLint 9 迁移、jest/tsconfig 配置更新、依赖调整 - 新增 ADR、CMS 评估文档、Release Review / Acceptance 报告 - 移除水墨装饰组件与大体积未使用字体文件
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock 权限中间件 ──────────────────────────────────────────────────────
|
||||
const mockRequirePermission = jest.fn() as jest.Mock<any>;
|
||||
|
||||
jest.mock('@/lib/permissions', () => ({
|
||||
requirePermission: mockRequirePermission,
|
||||
}));
|
||||
|
||||
// ─── Mock 媒体服务 ────────────────────────────────────────────────────────
|
||||
const mockUploadMedia = jest.fn() as jest.Mock<any>;
|
||||
const mockDeleteMedia = jest.fn() as jest.Mock<any>;
|
||||
const mockGetMediaById = jest.fn() as jest.Mock<any>;
|
||||
const mockListMedia = jest.fn() as jest.Mock<any>;
|
||||
|
||||
jest.mock('@/lib/media/media-service', () => ({
|
||||
uploadMedia: mockUploadMedia,
|
||||
deleteMedia: mockDeleteMedia,
|
||||
getMediaById: mockGetMediaById,
|
||||
listMedia: mockListMedia,
|
||||
}));
|
||||
|
||||
import { GET, POST, DELETE } from './route';
|
||||
|
||||
function createMockRequest(options: {
|
||||
url?: string;
|
||||
formData?: () => Promise<FormData>;
|
||||
}): NextRequest {
|
||||
return {
|
||||
url: options.url || 'http://localhost/api/admin/media',
|
||||
formData: options.formData || (async () => new FormData()),
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom 的 File 未实现 arrayBuffer(),此处构造一个保留 File 实例身份
|
||||
* 但 arrayBuffer() 返回确定内容的测试文件。
|
||||
*/
|
||||
function createTestFile(
|
||||
content: string,
|
||||
name: string,
|
||||
type: string,
|
||||
declaredSize?: number
|
||||
): File {
|
||||
const file = new File([content], name, { type });
|
||||
const encoder = new TextEncoder();
|
||||
Object.defineProperty(file, 'arrayBuffer', {
|
||||
value: async () => encoder.encode(content).buffer,
|
||||
configurable: true,
|
||||
});
|
||||
if (declaredSize !== undefined) {
|
||||
Object.defineProperty(file, 'size', { value: declaredSize });
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function mockAuthorized() {
|
||||
mockRequirePermission.mockResolvedValue({ user: { username: 'editor' } });
|
||||
}
|
||||
|
||||
function mockUnauthorized() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({ error: '未授权' }), { status: 401 }),
|
||||
});
|
||||
}
|
||||
|
||||
function mockForbidden() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({ error: '无权限' }), { status: 403 }),
|
||||
});
|
||||
}
|
||||
|
||||
describe('/api/admin/media', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockUnauthorized();
|
||||
const request = createMockRequest({});
|
||||
const response = await GET(request);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 when authenticated but unauthorized', async () => {
|
||||
mockForbidden();
|
||||
const request = createMockRequest({});
|
||||
const response = await GET(request);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns media list when authorized', async () => {
|
||||
mockAuthorized();
|
||||
mockListMedia.mockResolvedValue({
|
||||
items: [{ id: 'a1' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
const request = createMockRequest({ url: 'http://localhost/api/admin/media?page=1&pageSize=20' });
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(mockRequirePermission).toHaveBeenCalledWith(request, 'media', 'read');
|
||||
});
|
||||
|
||||
it('returns single media by id', async () => {
|
||||
mockAuthorized();
|
||||
mockGetMediaById.mockResolvedValue({ id: 'a1', name: 'test.png' });
|
||||
|
||||
const request = createMockRequest({ url: 'http://localhost/api/admin/media?id=a1' });
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.id).toBe('a1');
|
||||
expect(mockGetMediaById).toHaveBeenCalledWith('a1');
|
||||
});
|
||||
|
||||
it('returns 404 when media by id not found', async () => {
|
||||
mockAuthorized();
|
||||
mockGetMediaById.mockResolvedValue(null);
|
||||
|
||||
const request = createMockRequest({ url: 'http://localhost/api/admin/media?id=missing' });
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
it('returns 400 when no file provided', async () => {
|
||||
mockAuthorized();
|
||||
const request = createMockRequest({
|
||||
formData: async () => new FormData(),
|
||||
});
|
||||
const response = await POST(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain('请选择文件');
|
||||
});
|
||||
|
||||
it('uploads single file and returns created asset', async () => {
|
||||
mockAuthorized();
|
||||
mockUploadMedia.mockResolvedValue({ id: 'asset-1' });
|
||||
|
||||
const formData = new FormData();
|
||||
const file = createTestFile('content', 'test.png', 'image/png');
|
||||
formData.append('file', file);
|
||||
|
||||
const request = createMockRequest({ formData: async () => formData });
|
||||
const response = await POST(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(body.id).toBe('asset-1');
|
||||
expect(mockUploadMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'test.png',
|
||||
mimeType: 'image/png',
|
||||
size: 7,
|
||||
}),
|
||||
'editor'
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads multiple files and returns array', async () => {
|
||||
mockAuthorized();
|
||||
mockUploadMedia
|
||||
.mockResolvedValueOnce({ id: 'asset-1' })
|
||||
.mockResolvedValueOnce({ id: 'asset-2' });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('files', createTestFile('a', 'a.png', 'image/png'));
|
||||
formData.append('files', createTestFile('b', 'b.png', 'image/png'));
|
||||
|
||||
const request = createMockRequest({ formData: async () => formData });
|
||||
const response = await POST(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rejects file larger than 10MB', async () => {
|
||||
mockAuthorized();
|
||||
|
||||
const formData = new FormData();
|
||||
const largeFile = createTestFile('x', 'large.png', 'image/png', 10 * 1024 * 1024 + 1);
|
||||
formData.append('file', largeFile);
|
||||
|
||||
const request = createMockRequest({ formData: async () => formData });
|
||||
const response = await POST(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain('10MB');
|
||||
expect(mockUploadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('returns 400 when id missing', async () => {
|
||||
mockAuthorized();
|
||||
const request = createMockRequest({ url: 'http://localhost/api/admin/media' });
|
||||
const response = await DELETE(request);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('deletes media by id', async () => {
|
||||
mockAuthorized();
|
||||
mockDeleteMedia.mockResolvedValue(undefined);
|
||||
|
||||
const request = createMockRequest({ url: 'http://localhost/api/admin/media?id=a1' });
|
||||
const response = await DELETE(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.message).toBe('删除成功');
|
||||
expect(mockDeleteMedia).toHaveBeenCalledWith('a1');
|
||||
});
|
||||
|
||||
it('returns 404 when media not found', async () => {
|
||||
mockAuthorized();
|
||||
mockDeleteMedia.mockRejectedValue(new Error('文件不存在'));
|
||||
|
||||
const request = createMockRequest({ url: 'http://localhost/api/admin/media?id=missing' });
|
||||
const response = await DELETE(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,156 +1,118 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { requirePermission } from '@/lib/permissions';
|
||||
import {
|
||||
uploadMedia,
|
||||
deleteMedia,
|
||||
getMediaById,
|
||||
listMedia,
|
||||
} from '@/lib/media/media-service';
|
||||
import {
|
||||
success,
|
||||
unauthorized,
|
||||
notFound,
|
||||
validationError,
|
||||
internalError,
|
||||
} from '@/lib/api-response';
|
||||
|
||||
// GET /api/admin/media - 获取媒体列表
|
||||
const MODEL_CODE = 'media';
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
// GET /api/admin/media - 获取媒体列表或按 ID 查询
|
||||
export async function GET(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const permission = await requirePermission(request, MODEL_CODE, 'read');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '20');
|
||||
const mimeType = searchParams.get('mimeType');
|
||||
const id = searchParams.get('id');
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (mimeType) {
|
||||
where.mimeType = { startsWith: mimeType };
|
||||
if (id) {
|
||||
const asset = await getMediaById(id);
|
||||
if (!asset) return notFound('文件不存在');
|
||||
return success(asset);
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.mediaAsset.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.mediaAsset.count({ where }),
|
||||
]);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '20');
|
||||
const mimeType = searchParams.get('mimeType') || undefined;
|
||||
|
||||
return success({
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
});
|
||||
const result = await listMedia({ page, pageSize, mimeType });
|
||||
return success(result);
|
||||
} catch (error) {
|
||||
console.error('Get media error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/media - 上传媒体文件
|
||||
// POST /api/admin/media - 上传媒体文件(支持单文件/多文件)
|
||||
export async function POST(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const permission = await requirePermission(request, MODEL_CODE, 'create');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const files: File[] = [];
|
||||
|
||||
if (!file) {
|
||||
// 收集所有文件字段:支持 file(单文件)和 files(多文件)
|
||||
const singleFile = formData.get('file');
|
||||
if (singleFile instanceof File) {
|
||||
files.push(singleFile);
|
||||
}
|
||||
|
||||
const multiFiles = formData.getAll('files');
|
||||
for (const f of multiFiles) {
|
||||
if (f instanceof File) files.push(f);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return validationError('请选择文件');
|
||||
}
|
||||
|
||||
// 文件大小限制 10MB
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
return validationError('文件大小不能超过 10MB');
|
||||
const results = [];
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return validationError(`文件 "${file.name}" 大小不能超过 10MB`);
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
const asset = await uploadMedia(
|
||||
{
|
||||
name: file.name,
|
||||
buffer,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
},
|
||||
permission.user.username
|
||||
);
|
||||
|
||||
results.push(asset);
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// 生成唯一文件名
|
||||
const ext = file.name.split('.').pop() || '';
|
||||
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
const uploadDir = 'public/uploads';
|
||||
const filePath = `${uploadDir}/${fileName}`;
|
||||
|
||||
// 写入文件
|
||||
const fs = await import('fs/promises');
|
||||
const path = await import('path');
|
||||
const dirPath = path.join(process.cwd(), uploadDir);
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
await fs.writeFile(path.join(process.cwd(), filePath), buffer);
|
||||
|
||||
const asset = await prisma.mediaAsset.create({
|
||||
data: {
|
||||
name: file.name,
|
||||
path: filePath,
|
||||
url: `/uploads/${fileName}`,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
width: 0,
|
||||
height: 0,
|
||||
alt: file.name,
|
||||
storageType: 'local',
|
||||
createdBy: payload.username,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
module: 'media',
|
||||
targetId: asset.id,
|
||||
action: 'create',
|
||||
operator: payload.username,
|
||||
afterData: JSON.stringify(asset),
|
||||
},
|
||||
});
|
||||
|
||||
return success(asset, 201);
|
||||
return success(files.length === 1 ? results[0] : results, 201);
|
||||
} catch (error) {
|
||||
console.error('Upload media error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/admin/media/[id] - 删除媒体文件
|
||||
// DELETE /api/admin/media?id=xxx - 删除媒体文件
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const permission = await requirePermission(request, MODEL_CODE, 'delete');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
if (!id) return validationError('缺少 ID');
|
||||
|
||||
const existing = await prisma.mediaAsset.findUnique({ where: { id } });
|
||||
if (!existing) return notFound('文件不存在');
|
||||
|
||||
// 删除物理文件
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const path = await import('path');
|
||||
await fs.unlink(path.join(process.cwd(), existing.path));
|
||||
} catch {
|
||||
// 文件可能已被删除,忽略
|
||||
}
|
||||
|
||||
await prisma.mediaAsset.delete({ where: { id } });
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
module: 'media',
|
||||
targetId: id,
|
||||
action: 'delete',
|
||||
operator: payload.username,
|
||||
beforeData: JSON.stringify(existing),
|
||||
},
|
||||
});
|
||||
|
||||
await deleteMedia(id);
|
||||
return success({ message: '删除成功' });
|
||||
} catch (error) {
|
||||
console.error('Delete media error:', error);
|
||||
if (error instanceof Error && error.message === '文件不存在') {
|
||||
return notFound('文件不存在');
|
||||
}
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
|
||||
const mockContentModelFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
contentModel: {
|
||||
findMany: mockContentModelFindMany,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Mock permissions ─────────────────────────────────────────────────────
|
||||
const mockRequirePermission = jest.fn<
|
||||
(request: NextRequest, modelCode: string, action: string) => Promise<unknown>
|
||||
>();
|
||||
|
||||
jest.mock('@/lib/permissions', () => ({
|
||||
requirePermission: mockRequirePermission,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { GET } from './route';
|
||||
|
||||
function createMockRequest(): NextRequest {
|
||||
return {
|
||||
url: 'http://localhost/api/admin/models',
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
function mockAuthorized() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
user: { userId: 'user-1', username: 'admin', role: 'content_admin' },
|
||||
});
|
||||
}
|
||||
|
||||
function mockUnauthorized() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({ error: '未授权' }), { status: 401 }),
|
||||
});
|
||||
}
|
||||
|
||||
function mockForbidden() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({ error: '无权限' }), { status: 403 }),
|
||||
});
|
||||
}
|
||||
|
||||
const mockModels = [
|
||||
{
|
||||
id: 'model-1',
|
||||
code: 'news',
|
||||
name: '新闻资讯',
|
||||
description: '新闻内容模型',
|
||||
fields: JSON.stringify([
|
||||
{ code: 'title', name: '标题', type: 'text', required: true },
|
||||
{ code: 'body', name: '正文', type: 'richtext', required: false },
|
||||
]),
|
||||
sortOrder: 1,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
},
|
||||
{
|
||||
id: 'model-2',
|
||||
code: 'product',
|
||||
name: '产品管理',
|
||||
description: '产品内容模型',
|
||||
fields: JSON.stringify([{ code: 'name', name: '产品名称', type: 'text', required: true }]),
|
||||
sortOrder: 2,
|
||||
createdAt: new Date('2026-01-02'),
|
||||
updatedAt: new Date('2026-01-02'),
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockContentModelFindMany.mockResolvedValue(mockModels);
|
||||
});
|
||||
|
||||
describe('GET /api/admin/models', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockUnauthorized();
|
||||
const response = await GET(createMockRequest());
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 when authenticated but unauthorized', async () => {
|
||||
mockForbidden();
|
||||
const response = await GET(createMockRequest());
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns parsed model list ordered by sortOrder', async () => {
|
||||
mockAuthorized();
|
||||
const response = await GET(createMockRequest());
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body[0]).toMatchObject({
|
||||
code: 'news',
|
||||
name: '新闻资讯',
|
||||
fields: [
|
||||
{ code: 'title', name: '标题', type: 'text', required: true },
|
||||
{ code: 'body', name: '正文', type: 'richtext', required: false },
|
||||
],
|
||||
});
|
||||
expect(body[1]).toMatchObject({
|
||||
code: 'product',
|
||||
name: '产品管理',
|
||||
});
|
||||
expect(mockContentModelFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
mockAuthorized();
|
||||
mockContentModelFindMany.mockRejectedValue(new Error('db error'));
|
||||
|
||||
const response = await GET(createMockRequest());
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { success, unauthorized, internalError } from '@/lib/api-response';
|
||||
import { requirePermission } from '@/lib/permissions';
|
||||
import { success, internalError } from '@/lib/api-response';
|
||||
|
||||
// GET /api/admin/models - 获取所有内容模型
|
||||
export async function GET(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const permission = await requirePermission(request, 'content-model', 'read');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const models = await prisma.contentModel.findMany({
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock auth ────────────────────────────────────────────────────────────
|
||||
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => { userId: string; username: string; role: string } | null>();
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}));
|
||||
|
||||
// ─── Mock notification service ────────────────────────────────────────────
|
||||
const mockMarkNotificationAsRead = jest.fn<(id: string, userId: string) => Promise<unknown>>();
|
||||
|
||||
jest.mock('@/lib/cms/notifications', () => ({
|
||||
markNotificationAsRead: mockMarkNotificationAsRead,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { PATCH } from './route';
|
||||
|
||||
function createMockRequest(): NextRequest {
|
||||
return {} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
|
||||
mockMarkNotificationAsRead.mockResolvedValue({ id: 'notif-1', read: true });
|
||||
});
|
||||
|
||||
describe('PATCH /api/admin/notifications/[id]/read', () => {
|
||||
it('marks notification as read for authenticated owner', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await PATCH(request, { params: Promise.resolve({ id: 'notif-1' }) });
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.read).toBe(true);
|
||||
expect(mockMarkNotificationAsRead).toHaveBeenCalledWith('notif-1', 'user-1');
|
||||
});
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
const request = createMockRequest();
|
||||
const response = await PATCH(request, { params: Promise.resolve({ id: 'notif-1' }) });
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('returns 404 when notification not found or not owned', async () => {
|
||||
mockMarkNotificationAsRead.mockRejectedValue(new Error('Record to update not found'));
|
||||
const request = createMockRequest();
|
||||
const response = await PATCH(request, { params: Promise.resolve({ id: 'missing' }) });
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toBe('通知不存在');
|
||||
});
|
||||
|
||||
it('returns 500 on unexpected error', async () => {
|
||||
mockMarkNotificationAsRead.mockRejectedValue(new Error('db error'));
|
||||
const request = createMockRequest();
|
||||
const response = await PATCH(request, { params: Promise.resolve({ id: 'notif-1' }) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { markNotificationAsRead } from '@/lib/cms/notifications';
|
||||
import { success, unauthorized, notFound, internalError } from '@/lib/api-response';
|
||||
|
||||
// PATCH /api/admin/notifications/[id]/read - 将单条通知标记为已读
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const notification = await markNotificationAsRead(id, user.userId);
|
||||
return success(notification);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.toLowerCase().includes('not found')) {
|
||||
return notFound('通知不存在');
|
||||
}
|
||||
console.error('Mark notification read error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock auth ────────────────────────────────────────────────────────────
|
||||
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => { userId: string; username: string; role: string } | null>();
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}));
|
||||
|
||||
// ─── Mock notification service ────────────────────────────────────────────
|
||||
const mockMarkAllNotificationsAsRead = jest.fn<(userId: string) => Promise<number>>();
|
||||
|
||||
jest.mock('@/lib/cms/notifications', () => ({
|
||||
markAllNotificationsAsRead: mockMarkAllNotificationsAsRead,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { PATCH } from './route';
|
||||
|
||||
function createMockRequest(): NextRequest {
|
||||
return {} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
|
||||
mockMarkAllNotificationsAsRead.mockResolvedValue(5);
|
||||
});
|
||||
|
||||
describe('PATCH /api/admin/notifications/read-all', () => {
|
||||
it('marks all notifications as read for authenticated user', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await PATCH(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.count).toBe(5);
|
||||
expect(mockMarkAllNotificationsAsRead).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
const request = createMockRequest();
|
||||
const response = await PATCH(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('returns 500 on unexpected error', async () => {
|
||||
mockMarkAllNotificationsAsRead.mockRejectedValue(new Error('db error'));
|
||||
const request = createMockRequest();
|
||||
const response = await PATCH(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { markAllNotificationsAsRead } from '@/lib/cms/notifications';
|
||||
import { success, unauthorized, internalError } from '@/lib/api-response';
|
||||
|
||||
// PATCH /api/admin/notifications/read-all - 将当前用户全部未读通知标记为已读
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
try {
|
||||
const count = await markAllNotificationsAsRead(user.userId);
|
||||
return success({ count });
|
||||
} catch (error) {
|
||||
console.error('Mark all notifications read error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock auth ────────────────────────────────────────────────────────────
|
||||
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => { userId: string; username: string; role: string } | null>();
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}));
|
||||
|
||||
// ─── Mock notification service ────────────────────────────────────────────
|
||||
const mockGetUserNotifications = jest.fn<(userId: string, options: unknown) => Promise<unknown>>();
|
||||
|
||||
jest.mock('@/lib/cms/notifications', () => ({
|
||||
getUserNotifications: mockGetUserNotifications,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { GET } from './route';
|
||||
|
||||
function createMockRequest(url: string): NextRequest {
|
||||
return { url } as unknown as NextRequest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
|
||||
mockGetUserNotifications.mockResolvedValue({
|
||||
items: [{ id: 'notif-1', read: false }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 1,
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/notifications', () => {
|
||||
it('returns notifications for authenticated user', async () => {
|
||||
const request = createMockRequest('http://localhost/api/admin/notifications');
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(mockGetUserNotifications).toHaveBeenCalledWith('user-1', { page: 1, pageSize: 20, unreadOnly: false });
|
||||
});
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
const request = createMockRequest('http://localhost/api/admin/notifications');
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('parses pagination and unreadOnly query params', async () => {
|
||||
const request = createMockRequest(
|
||||
'http://localhost/api/admin/notifications?page=2&pageSize=10&unreadOnly=true'
|
||||
);
|
||||
await GET(request);
|
||||
|
||||
expect(mockGetUserNotifications).toHaveBeenCalledWith('user-1', { page: 2, pageSize: 10, unreadOnly: true });
|
||||
});
|
||||
|
||||
it('returns 400 for invalid page parameter', async () => {
|
||||
const request = createMockRequest('http://localhost/api/admin/notifications?page=0');
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe('page 参数非法');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid pageSize parameter', async () => {
|
||||
const request = createMockRequest('http://localhost/api/admin/notifications?pageSize=101');
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe('pageSize 参数非法');
|
||||
});
|
||||
|
||||
it('returns 500 on service error', async () => {
|
||||
mockGetUserNotifications.mockRejectedValue(new Error('db error'));
|
||||
const request = createMockRequest('http://localhost/api/admin/notifications');
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { getUserNotifications } from '@/lib/cms/notifications';
|
||||
import { success, unauthorized, validationError, internalError } from '@/lib/api-response';
|
||||
|
||||
// GET /api/admin/notifications - 获取当前用户通知列表
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '20', 10);
|
||||
const unreadOnly = searchParams.get('unreadOnly') === 'true';
|
||||
|
||||
if (Number.isNaN(page) || page < 1) {
|
||||
return validationError('page 参数非法');
|
||||
}
|
||||
if (Number.isNaN(pageSize) || pageSize < 1 || pageSize > 100) {
|
||||
return validationError('pageSize 参数非法');
|
||||
}
|
||||
|
||||
const result = await getUserNotifications(user.userId, { page, pageSize, unreadOnly });
|
||||
return success(result);
|
||||
} catch (error) {
|
||||
console.error('Get notifications error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock auth ────────────────────────────────────────────────────────────
|
||||
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => { userId: string; username: string; role: string } | null>();
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}));
|
||||
|
||||
// ─── Mock notification service ────────────────────────────────────────────
|
||||
const mockGetUnreadCount = jest.fn<(userId: string) => Promise<number>>();
|
||||
|
||||
jest.mock('@/lib/cms/notifications', () => ({
|
||||
getUnreadCount: mockGetUnreadCount,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { GET } from './route';
|
||||
|
||||
function createMockRequest(): NextRequest {
|
||||
return {} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
|
||||
mockGetUnreadCount.mockResolvedValue(3);
|
||||
});
|
||||
|
||||
describe('GET /api/admin/notifications/unread-count', () => {
|
||||
it('returns unread count for authenticated user', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.count).toBe(3);
|
||||
expect(mockGetUnreadCount).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('returns 500 on unexpected error', async () => {
|
||||
mockGetUnreadCount.mockRejectedValue(new Error('db error'));
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { getUnreadCount } from '@/lib/cms/notifications';
|
||||
import { success, unauthorized, internalError } from '@/lib/api-response';
|
||||
|
||||
// GET /api/admin/notifications/unread-count - 获取当前用户未读通知数量
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
try {
|
||||
const count = await getUnreadCount(user.userId);
|
||||
return success({ count });
|
||||
} catch (error) {
|
||||
console.error('Get unread count error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
|
||||
const mockRoleFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
|
||||
const mockPermissionFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
|
||||
const mockPermissionDeleteMany = jest.fn<(args?: unknown) => Promise<unknown>>();
|
||||
const mockPermissionCreate = jest.fn<(args?: unknown) => Promise<unknown>>();
|
||||
const mockContentModelFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
|
||||
const mockUserRoleFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
|
||||
const mockRoleFindUnique = jest.fn<(args?: unknown) => Promise<unknown | null>>();
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
role: {
|
||||
findMany: mockRoleFindMany,
|
||||
findUnique: mockRoleFindUnique,
|
||||
},
|
||||
permission: {
|
||||
findMany: mockPermissionFindMany,
|
||||
deleteMany: mockPermissionDeleteMany,
|
||||
create: mockPermissionCreate,
|
||||
},
|
||||
contentModel: {
|
||||
findMany: mockContentModelFindMany,
|
||||
},
|
||||
userRole: {
|
||||
findMany: mockUserRoleFindMany,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Mock @/lib/auth ──────────────────────────────────────────────────────
|
||||
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => unknown>();
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { GET, PUT } from './route';
|
||||
|
||||
function createMockRequest(body?: Record<string, unknown>): NextRequest {
|
||||
return {
|
||||
json: async () => body ?? {},
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
const mockRoles = [
|
||||
{ code: 'super_admin', name: '超级管理员', createdAt: new Date('2026-01-01') },
|
||||
{ code: 'content_admin', name: '内容管理员', createdAt: new Date('2026-01-02') },
|
||||
{ code: 'content_editor', name: '内容编辑', createdAt: new Date('2026-01-03') },
|
||||
];
|
||||
|
||||
const mockModels = [
|
||||
{ code: 'news', name: '新闻资讯', sortOrder: 1 },
|
||||
{ code: 'product', name: '产品管理', sortOrder: 2 },
|
||||
];
|
||||
|
||||
const mockPermissions = [
|
||||
{ roleCode: 'content_editor', modelCode: 'news', action: 'read' },
|
||||
{ roleCode: 'content_editor', modelCode: 'news', action: 'update' },
|
||||
];
|
||||
|
||||
function mockAuthenticated(roleCodes: string[]) {
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'admin' });
|
||||
mockUserRoleFindMany.mockResolvedValue(roleCodes.map((code) => ({ roleCode: code })));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/admin/roles', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
|
||||
const response = await GET(createMockRequest());
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('returns 403 for non-super_admin users', async () => {
|
||||
mockAuthenticated(['content_admin']);
|
||||
|
||||
const response = await GET(createMockRequest());
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(body.error).toContain('超级管理员');
|
||||
});
|
||||
|
||||
it('returns roles, permissions and models for super_admin', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
mockRoleFindMany.mockResolvedValue(mockRoles);
|
||||
mockPermissionFindMany.mockResolvedValue(mockPermissions);
|
||||
mockContentModelFindMany.mockResolvedValue(mockModels);
|
||||
|
||||
const response = await GET(createMockRequest());
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.roles).toHaveLength(3);
|
||||
expect(body.roles[0]).toEqual({ code: 'super_admin', name: '超级管理员', builtin: true });
|
||||
expect(body.permissions).toEqual(mockPermissions);
|
||||
expect(body.models).toEqual([
|
||||
{ code: 'news', name: '新闻资讯' },
|
||||
{ code: 'product', name: '产品管理' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/admin/roles', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
|
||||
const response = await PUT(createMockRequest({ roleCode: 'content_editor', permissions: [] }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('returns 403 for non-super_admin users', async () => {
|
||||
mockAuthenticated(['content_admin']);
|
||||
|
||||
const response = await PUT(createMockRequest({ roleCode: 'content_editor', permissions: [] }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(body.error).toContain('超级管理员');
|
||||
});
|
||||
|
||||
it('returns 400 when roleCode is missing', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
|
||||
const response = await PUT(createMockRequest({ permissions: [] }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe('roleCode 必填');
|
||||
});
|
||||
|
||||
it('returns 403 when modifying super_admin', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
|
||||
const response = await PUT(createMockRequest({ roleCode: 'super_admin', permissions: [] }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(body.error).toContain('不可修改');
|
||||
expect(mockPermissionDeleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 when role does not exist', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
mockRoleFindUnique.mockResolvedValue(null);
|
||||
|
||||
const response = await PUT(createMockRequest({ roleCode: 'ghost', permissions: [] }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe('角色不存在');
|
||||
});
|
||||
|
||||
it('replaces permissions and filters invalid actions', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
mockRoleFindUnique.mockResolvedValue({ code: 'content_editor', name: '内容编辑' });
|
||||
mockPermissionDeleteMany.mockResolvedValue({ count: 2 });
|
||||
mockPermissionCreate.mockResolvedValue({});
|
||||
|
||||
const response = await PUT(
|
||||
createMockRequest({
|
||||
roleCode: 'content_editor',
|
||||
permissions: [
|
||||
{ modelCode: 'news', action: 'read' },
|
||||
{ modelCode: 'news', action: 'update' },
|
||||
{ modelCode: 'news', action: 'hack' },
|
||||
{ modelCode: '', action: 'read' },
|
||||
],
|
||||
})
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockPermissionDeleteMany).toHaveBeenCalledWith({ where: { roleCode: 'content_editor' } });
|
||||
expect(mockPermissionCreate).toHaveBeenCalledTimes(2);
|
||||
expect(mockPermissionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { roleCode: 'content_editor', modelCode: 'news', action: 'read' },
|
||||
})
|
||||
);
|
||||
expect(mockPermissionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { roleCode: 'content_editor', modelCode: 'news', action: 'update' },
|
||||
})
|
||||
);
|
||||
expect(body.roleCode).toBe('content_editor');
|
||||
expect(body.permissions).toContain('news:read');
|
||||
expect(body.permissions).toContain('news:update');
|
||||
expect(body.permissions).not.toContain('news:hack');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response';
|
||||
|
||||
// 内置角色,不允许删除
|
||||
const BUILTIN_ROLES = new Set(['super_admin', 'content_admin', 'content_editor', 'reviewer', 'readonly']);
|
||||
|
||||
// GET /api/admin/roles - 获取角色列表及其权限
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
// 仅管理员可查看角色权限
|
||||
const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } });
|
||||
const roleCodes = userRoles.map((ur) => (ur as { roleCode: string }).roleCode);
|
||||
if (!roleCodes.includes('super_admin')) {
|
||||
return forbidden('仅超级管理员可管理角色权限');
|
||||
}
|
||||
|
||||
try {
|
||||
const roles = await prisma.role.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const permissions = await prisma.permission.findMany();
|
||||
const contentModels = await prisma.contentModel.findMany({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { code: true, name: true },
|
||||
});
|
||||
|
||||
return success({
|
||||
roles: roles.map((r) => ({
|
||||
code: r.code,
|
||||
name: r.name,
|
||||
builtin: BUILTIN_ROLES.has(r.code),
|
||||
})),
|
||||
permissions: permissions.map((p) => ({
|
||||
roleCode: (p as { roleCode: string }).roleCode,
|
||||
modelCode: (p as { modelCode: string }).modelCode,
|
||||
action: (p as { action: string }).action,
|
||||
})),
|
||||
models: contentModels.map((m) => ({ code: m.code, name: m.name })),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get roles error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/admin/roles/:roleCode - 更新角色权限
|
||||
export async function PUT(request: NextRequest) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } });
|
||||
const roleCodes = userRoles.map((ur) => (ur as { roleCode: string }).roleCode);
|
||||
if (!roleCodes.includes('super_admin')) {
|
||||
return forbidden('仅超级管理员可管理角色权限');
|
||||
}
|
||||
|
||||
try {
|
||||
const { roleCode, permissions } = (await request.json()) as {
|
||||
roleCode: string;
|
||||
permissions: Array<{ modelCode: string; action: string }>;
|
||||
};
|
||||
|
||||
if (!roleCode || typeof roleCode !== 'string') {
|
||||
return validationError('roleCode 必填');
|
||||
}
|
||||
|
||||
if (roleCode === 'super_admin') {
|
||||
return forbidden('super_admin 权限不可修改');
|
||||
}
|
||||
|
||||
const role = await prisma.role.findUnique({ where: { code: roleCode } });
|
||||
if (!role) return validationError('角色不存在');
|
||||
|
||||
// 先删除该角色所有权限,再重新写入
|
||||
await prisma.permission.deleteMany({ where: { roleCode } });
|
||||
|
||||
const validActions = new Set(['create', 'read', 'update', 'delete', 'publish']);
|
||||
const uniqueKeys = new Set<string>();
|
||||
for (const perm of permissions || []) {
|
||||
if (!perm.modelCode || !validActions.has(perm.action)) continue;
|
||||
const key = `${perm.modelCode}:${perm.action}`;
|
||||
if (uniqueKeys.has(key)) continue;
|
||||
uniqueKeys.add(key);
|
||||
await prisma.permission.create({
|
||||
data: {
|
||||
roleCode,
|
||||
modelCode: perm.modelCode,
|
||||
action: perm.action,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return success({ roleCode, permissions: Array.from(uniqueKeys) });
|
||||
} catch (error) {
|
||||
console.error('Update roles error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
|
||||
const mockContentZoneFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
|
||||
const mockContentZoneFindUnique = jest.fn<(args: unknown) => Promise<unknown | null>>();
|
||||
const mockContentZoneCreate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockContentZoneUpdate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockContentZoneDelete = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockAuditLogCreate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
contentZone: {
|
||||
findMany: mockContentZoneFindMany,
|
||||
findUnique: mockContentZoneFindUnique,
|
||||
create: mockContentZoneCreate,
|
||||
update: mockContentZoneUpdate,
|
||||
delete: mockContentZoneDelete,
|
||||
},
|
||||
auditLog: {
|
||||
create: mockAuditLogCreate,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Mock permissions ─────────────────────────────────────────────────────
|
||||
const mockRequirePermission = jest.fn<
|
||||
(request: NextRequest, modelCode: string, action: string) => Promise<unknown>
|
||||
>();
|
||||
|
||||
jest.mock('@/lib/permissions', () => ({
|
||||
requirePermission: mockRequirePermission,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { GET, POST, PUT, DELETE } from './route';
|
||||
|
||||
function createMockRequest(options: {
|
||||
url?: string;
|
||||
json?: () => Promise<Record<string, unknown>>;
|
||||
}): NextRequest {
|
||||
return {
|
||||
url: options.url || 'http://localhost/api/admin/zones',
|
||||
json: options.json || (async () => ({})),
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
function mockAuthorized() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
user: { userId: 'user-1', username: 'admin', role: 'content_admin' },
|
||||
});
|
||||
}
|
||||
|
||||
function mockUnauthorized() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({ error: '未授权' }), { status: 401 }),
|
||||
});
|
||||
}
|
||||
|
||||
function mockForbidden() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({ error: '无权限' }), { status: 403 }),
|
||||
});
|
||||
}
|
||||
|
||||
const mockZone = {
|
||||
id: 'zone-1',
|
||||
code: 'home-hero',
|
||||
name: '首页 Hero',
|
||||
description: '',
|
||||
pageCode: 'home',
|
||||
zoneKey: 'hero',
|
||||
allowedModels: JSON.stringify(['hero-banner']),
|
||||
items: JSON.stringify([{ itemId: 'item-1', sortOrder: 1 }]),
|
||||
settings: JSON.stringify({ layout: 'carousel' }),
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockContentZoneFindMany.mockResolvedValue([mockZone]);
|
||||
mockContentZoneFindUnique.mockResolvedValue(mockZone);
|
||||
mockContentZoneCreate.mockResolvedValue(mockZone);
|
||||
mockContentZoneUpdate.mockResolvedValue(mockZone);
|
||||
mockContentZoneDelete.mockResolvedValue(undefined);
|
||||
mockAuditLogCreate.mockResolvedValue({ id: 'log-1' });
|
||||
});
|
||||
|
||||
describe('/api/admin/zones', () => {
|
||||
describe('GET', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockUnauthorized();
|
||||
const response = await GET(createMockRequest({}));
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 when authenticated but unauthorized', async () => {
|
||||
mockForbidden();
|
||||
const response = await GET(createMockRequest({}));
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns parsed zone list', async () => {
|
||||
mockAuthorized();
|
||||
const response = await GET(createMockRequest({}));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]).toMatchObject({
|
||||
id: 'zone-1',
|
||||
code: 'home-hero',
|
||||
allowedModels: ['hero-banner'],
|
||||
items: [{ itemId: 'item-1', sortOrder: 1 }],
|
||||
settings: { layout: 'carousel' },
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by pageCode', async () => {
|
||||
mockAuthorized();
|
||||
await GET(createMockRequest({ url: 'http://localhost/api/admin/zones?pageCode=home' }));
|
||||
|
||||
expect(mockContentZoneFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { pageCode: 'home' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
it('returns 400 when code missing', async () => {
|
||||
mockAuthorized();
|
||||
const response = await POST(createMockRequest({ json: async () => ({ name: '无编码' }) }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain('code');
|
||||
});
|
||||
|
||||
it('creates new zone and audit log', async () => {
|
||||
mockAuthorized();
|
||||
const response = await POST(
|
||||
createMockRequest({
|
||||
json: async () => ({
|
||||
code: 'home-stats',
|
||||
name: '首页数据',
|
||||
pageCode: 'home',
|
||||
zoneKey: 'stats',
|
||||
allowedModels: ['stat-item'],
|
||||
items: [],
|
||||
settings: {},
|
||||
}),
|
||||
})
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockContentZoneCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
code: 'home-stats',
|
||||
allowedModels: JSON.stringify(['stat-item']),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockAuditLogCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
module: 'zone',
|
||||
action: 'create',
|
||||
operator: 'admin',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(body.code).toBe('home-hero');
|
||||
});
|
||||
|
||||
it('updates existing zone and audit log', async () => {
|
||||
mockAuthorized();
|
||||
const response = await POST(
|
||||
createMockRequest({
|
||||
json: async () => ({
|
||||
id: 'zone-1',
|
||||
code: 'home-hero',
|
||||
name: '首页 Hero 更新',
|
||||
items: [{ itemId: 'item-2', sortOrder: 2 }],
|
||||
}),
|
||||
})
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockContentZoneUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'zone-1' },
|
||||
})
|
||||
);
|
||||
expect(mockAuditLogCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
module: 'zone',
|
||||
action: 'update',
|
||||
operator: 'admin',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(body.name).toBe('首页 Hero');
|
||||
});
|
||||
|
||||
it('returns 404 when updating non-existent zone', async () => {
|
||||
mockAuthorized();
|
||||
mockContentZoneFindUnique.mockResolvedValue(null);
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest({
|
||||
json: async () => ({ id: 'missing', code: 'home-hero' }),
|
||||
})
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toBe('区域不存在');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT', () => {
|
||||
it('delegates to POST', async () => {
|
||||
mockAuthorized();
|
||||
const response = await PUT(
|
||||
createMockRequest({
|
||||
json: async () => ({
|
||||
id: 'zone-1',
|
||||
code: 'home-hero',
|
||||
name: 'PUT 更新',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockContentZoneUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('returns 400 when id missing', async () => {
|
||||
mockAuthorized();
|
||||
const response = await DELETE(createMockRequest({}));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe('缺少 ID');
|
||||
});
|
||||
|
||||
it('returns 404 when zone not found', async () => {
|
||||
mockAuthorized();
|
||||
mockContentZoneFindUnique.mockResolvedValue(null);
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest({ url: 'http://localhost/api/admin/zones?id=missing' })
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toBe('区域不存在');
|
||||
});
|
||||
|
||||
it('deletes zone and audit log', async () => {
|
||||
mockAuthorized();
|
||||
const response = await DELETE(
|
||||
createMockRequest({ url: 'http://localhost/api/admin/zones?id=zone-1' })
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.message).toBe('删除成功');
|
||||
expect(mockContentZoneDelete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'zone-1' } })
|
||||
);
|
||||
expect(mockAuditLogCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
module: 'zone',
|
||||
action: 'delete',
|
||||
operator: 'admin',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { requirePermission } from '@/lib/permissions';
|
||||
import {
|
||||
success,
|
||||
unauthorized,
|
||||
notFound,
|
||||
validationError,
|
||||
internalError,
|
||||
@@ -11,8 +10,8 @@ import {
|
||||
|
||||
// GET /api/admin/zones - 获取所有内容区域
|
||||
export async function GET(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const permission = await requirePermission(request, 'content-zone', 'read');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -35,8 +34,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// POST /api/admin/zones - 创建/更新内容区域
|
||||
export async function POST(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const permission = await requirePermission(request, 'content-zone', 'update');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
@@ -67,7 +66,7 @@ export async function POST(request: NextRequest) {
|
||||
module: 'zone',
|
||||
targetId: id,
|
||||
action: 'update',
|
||||
operator: payload.username,
|
||||
operator: permission.user.username,
|
||||
beforeData: JSON.stringify(existing),
|
||||
afterData: JSON.stringify(zone),
|
||||
},
|
||||
@@ -80,7 +79,7 @@ export async function POST(request: NextRequest) {
|
||||
module: 'zone',
|
||||
targetId: zone.id,
|
||||
action: 'create',
|
||||
operator: payload.username,
|
||||
operator: permission.user.username,
|
||||
afterData: JSON.stringify(zone),
|
||||
},
|
||||
});
|
||||
@@ -105,8 +104,8 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
// DELETE /api/admin/zones/[id] - 删除内容区域
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const permission = await requirePermission(request, 'content-zone', 'delete');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -123,7 +122,7 @@ export async function DELETE(request: NextRequest) {
|
||||
module: 'zone',
|
||||
targetId: id,
|
||||
action: 'delete',
|
||||
operator: payload.username,
|
||||
operator: permission.user.username,
|
||||
beforeData: JSON.stringify(existing),
|
||||
},
|
||||
});
|
||||
@@ -133,4 +132,4 @@ export async function DELETE(request: NextRequest) {
|
||||
console.error('Delete zone error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,10 @@ export async function POST(request: NextRequest) {
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
setTokenCookie(response, accessToken, refreshToken);
|
||||
// 仅在实际 https 请求时设置 Secure 标志,本地 http 预览/开发环境不设置,
|
||||
// 否则浏览器会拒绝保存 HttpOnly cookie,导致中间件无法读取认证状态。
|
||||
const secure = request.nextUrl.protocol === 'https:';
|
||||
setTokenCookie(response, accessToken, refreshToken, secure);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { clearTokenCookie } from '@/lib/auth';
|
||||
import { success } from '@/lib/api-response';
|
||||
|
||||
export async function POST() {
|
||||
export async function POST(request: NextRequest) {
|
||||
const response = success({ message: '已登出' });
|
||||
clearTokenCookie(response);
|
||||
const secure = request.nextUrl.protocol === 'https:';
|
||||
clearTokenCookie(response, secure);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ export async function POST(request: NextRequest) {
|
||||
refreshToken: tokens.refreshToken,
|
||||
});
|
||||
|
||||
setTokenCookie(response, tokens.accessToken, tokens.refreshToken);
|
||||
const secure = request.nextUrl.protocol === 'https:';
|
||||
setTokenCookie(response, tokens.accessToken, tokens.refreshToken, secure);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
||||
@@ -8,7 +8,8 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const redirect = typeof body.redirect === 'string' ? body.redirect : null;
|
||||
|
||||
draftMode().disable();
|
||||
const draft = await draftMode();
|
||||
draft.disable();
|
||||
|
||||
if (redirect) {
|
||||
return NextResponse.redirect(new URL(redirect, request.url));
|
||||
|
||||
@@ -19,7 +19,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
draftMode().enable();
|
||||
const draft = await draftMode();
|
||||
draft.enable();
|
||||
|
||||
if (redirect) {
|
||||
return NextResponse.redirect(new URL(redirect, request.url));
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const mockRevalidatePath = jest.fn();
|
||||
const mockRevalidateTag = jest.fn();
|
||||
|
||||
jest.mock('next/cache', () => ({
|
||||
revalidatePath: mockRevalidatePath,
|
||||
revalidateTag: mockRevalidateTag,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { POST } from './route';
|
||||
|
||||
function createMockRequest(body: Record<string, unknown>): NextRequest {
|
||||
return {
|
||||
json: async () => body,
|
||||
headers: new Headers({ 'x-revalidate-secret': 'test-secret' }),
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.CMS_REVALIDATE_SECRET = 'test-secret';
|
||||
});
|
||||
|
||||
describe('POST /api/cms/revalidate', () => {
|
||||
it('returns 401 when secret is invalid', async () => {
|
||||
const request = createMockRequest({});
|
||||
request.headers.set('x-revalidate-secret', 'wrong-secret');
|
||||
|
||||
const response = await POST(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body.message).toBe('Invalid secret');
|
||||
});
|
||||
|
||||
it('returns 500 when secret is not configured', async () => {
|
||||
delete process.env.CMS_REVALIDATE_SECRET;
|
||||
|
||||
const response = await POST(createMockRequest({}));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(body.message).toBe('Revalidation secret is not configured');
|
||||
});
|
||||
|
||||
it('revalidates explicit paths and tags', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest({ paths: ['/privacy'], tags: ['cms:legal-page'] }),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/privacy');
|
||||
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:legal-page', {});
|
||||
expect(body.data.revalidatedPaths).toContain('/privacy');
|
||||
expect(body.data.revalidatedTags).toContain('cms:legal-page');
|
||||
});
|
||||
|
||||
it('revalidates /privacy and /terms for legal-page model without slug', async () => {
|
||||
const response = await POST(createMockRequest({ modelCode: 'legal-page' }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/privacy');
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/terms');
|
||||
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:legal-page', {});
|
||||
expect(body.data.revalidatedPaths).toEqual(
|
||||
expect.arrayContaining(['/privacy', '/terms']),
|
||||
);
|
||||
});
|
||||
|
||||
it('revalidates only /terms for legal-page model when slug is terms', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest({ modelCode: 'legal-page', slug: 'terms' }),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/terms');
|
||||
expect(body.data.revalidatedPaths).toContain('/terms');
|
||||
expect(body.data.revalidatedPaths).not.toContain('/privacy');
|
||||
});
|
||||
|
||||
it('revalidates only /privacy for legal-page model when slug is privacy', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest({ modelCode: 'legal-page', slug: 'privacy' }),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/privacy');
|
||||
expect(body.data.revalidatedPaths).toContain('/privacy');
|
||||
expect(body.data.revalidatedPaths).not.toContain('/terms');
|
||||
});
|
||||
|
||||
it('revalidates all list pages on content.published event', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest({ event: 'content.published', modelCode: 'news' }),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:all', {});
|
||||
expect(body.data.revalidatedTags).toContain('cms:all');
|
||||
});
|
||||
|
||||
it('revalidates news list and detail pages for news model', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest({ modelCode: 'news', slug: 'company-founded' }),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/news');
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/news/company-founded');
|
||||
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:news', {});
|
||||
expect(body.data.revalidatedPaths).toEqual(
|
||||
expect.arrayContaining(['/news', '/news/company-founded']),
|
||||
);
|
||||
});
|
||||
|
||||
it('revalidates /team for team-page model', async () => {
|
||||
const response = await POST(createMockRequest({ modelCode: 'team-page' }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/team');
|
||||
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:team-page', {});
|
||||
expect(body.data.revalidatedPaths).toContain('/team');
|
||||
});
|
||||
|
||||
it('revalidates /cases list and detail pages for case-study model', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest({ modelCode: 'case-study', slug: 'manufacturing-erp-upgrade' }),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/cases');
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/cases/manufacturing-erp-upgrade');
|
||||
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:case-study', {});
|
||||
expect(body.data.revalidatedPaths).toEqual(
|
||||
expect.arrayContaining(['/cases', '/cases/manufacturing-erp-upgrade']),
|
||||
);
|
||||
});
|
||||
|
||||
it('revalidates only /cases list page for case-study model without slug', async () => {
|
||||
const response = await POST(createMockRequest({ modelCode: 'case-study' }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/cases');
|
||||
expect(body.data.revalidatedPaths).toContain('/cases');
|
||||
expect(body.data.revalidatedPaths).not.toContain('/cases/manufacturing-erp-upgrade');
|
||||
});
|
||||
|
||||
it('revalidates /services list and detail pages for service model', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest({ modelCode: 'service', slug: 'consulting' }),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/services');
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/services/consulting');
|
||||
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:service', {});
|
||||
expect(body.data.revalidatedPaths).toEqual(
|
||||
expect.arrayContaining(['/services', '/services/consulting']),
|
||||
);
|
||||
});
|
||||
|
||||
it('revalidates only /services list page for service model without slug', async () => {
|
||||
const response = await POST(createMockRequest({ modelCode: 'service' }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/services');
|
||||
expect(body.data.revalidatedPaths).toContain('/services');
|
||||
expect(body.data.revalidatedPaths).not.toContain('/services/consulting');
|
||||
});
|
||||
|
||||
it('revalidates /solutions list and detail pages for solution model', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest({ modelCode: 'solution', slug: 'manufacturing' }),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/solutions');
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/solutions/manufacturing');
|
||||
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:solution', {});
|
||||
expect(body.data.revalidatedPaths).toEqual(
|
||||
expect.arrayContaining(['/solutions', '/solutions/manufacturing']),
|
||||
);
|
||||
});
|
||||
|
||||
it('revalidates only /solutions list page for solution model without slug', async () => {
|
||||
const response = await POST(createMockRequest({ modelCode: 'solution' }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/solutions');
|
||||
expect(body.data.revalidatedPaths).toContain('/solutions');
|
||||
expect(body.data.revalidatedPaths).not.toContain('/solutions/manufacturing');
|
||||
});
|
||||
|
||||
it('revalidates /products list and detail pages for product model', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest({ modelCode: 'product', slug: 'erp' }),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/products');
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/products/erp');
|
||||
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:product', {});
|
||||
expect(body.data.revalidatedPaths).toEqual(
|
||||
expect.arrayContaining(['/products', '/products/erp']),
|
||||
);
|
||||
});
|
||||
|
||||
it('revalidates only /products list page for product model without slug', async () => {
|
||||
const response = await POST(createMockRequest({ modelCode: 'product' }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/products');
|
||||
expect(body.data.revalidatedPaths).toContain('/products');
|
||||
expect(body.data.revalidatedPaths).not.toContain('/products/erp');
|
||||
});
|
||||
|
||||
it('revalidates /products list and standalone detail pages for standalone-product model', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest({ modelCode: 'standalone-product', slug: 'novavis' }),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/products');
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/products/standalone/novavis');
|
||||
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:standalone-product', {});
|
||||
expect(body.data.revalidatedPaths).toEqual(
|
||||
expect.arrayContaining(['/products', '/products/standalone/novavis']),
|
||||
);
|
||||
});
|
||||
|
||||
it('revalidates only /products list page for standalone-product model without slug', async () => {
|
||||
const response = await POST(createMockRequest({ modelCode: 'standalone-product' }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/products');
|
||||
expect(body.data.revalidatedPaths).toContain('/products');
|
||||
expect(body.data.revalidatedPaths).not.toContain('/products/standalone/novavis');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,16 @@ function getAllContentTypeConfigs() {
|
||||
return Object.values(CONTENT_TYPE_CONFIGS);
|
||||
}
|
||||
|
||||
// 首页受影响的模型:更新后需要同时刷新 /
|
||||
const HOME_AFFECTED_MODELS = new Set([
|
||||
'hero-banner',
|
||||
'stat-item',
|
||||
'service',
|
||||
'case-study',
|
||||
'solution',
|
||||
'news',
|
||||
]);
|
||||
|
||||
interface RevalidateRequestBody {
|
||||
event?: string;
|
||||
modelCode?: string;
|
||||
@@ -27,7 +37,14 @@ export async function POST(request: NextRequest) {
|
||||
const secret = body.secret ?? request.headers.get('x-revalidate-secret');
|
||||
const expectedSecret = process.env.CMS_REVALIDATE_SECRET;
|
||||
|
||||
if (expectedSecret && secret !== expectedSecret) {
|
||||
if (!expectedSecret) {
|
||||
return NextResponse.json(
|
||||
{ code: 500, message: 'Revalidation secret is not configured', data: null },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (secret !== expectedSecret) {
|
||||
return NextResponse.json(
|
||||
{ code: 401, message: 'Invalid secret', data: null },
|
||||
{ status: 401 }
|
||||
@@ -46,7 +63,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (body.tags && body.tags.length > 0) {
|
||||
for (const tag of body.tags) {
|
||||
revalidateTag(tag);
|
||||
revalidateTag(tag, {});
|
||||
revalidatedTags.push(tag);
|
||||
}
|
||||
}
|
||||
@@ -66,9 +83,40 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const tag = `cms:${body.modelCode}`;
|
||||
revalidateTag(tag);
|
||||
revalidateTag(tag, {});
|
||||
revalidatedTags.push(tag);
|
||||
}
|
||||
|
||||
// legal-page 为特殊页面类型,固定映射到 /privacy 和 /terms
|
||||
if (body.modelCode === 'legal-page') {
|
||||
const legalPaths = body.slug
|
||||
? [body.slug === 'terms' ? '/terms' : '/privacy']
|
||||
: ['/privacy', '/terms'];
|
||||
for (const path of legalPaths) {
|
||||
if (!revalidatedPaths.includes(path)) {
|
||||
revalidatePath(path);
|
||||
revalidatedPaths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// team-page 为单页模型,固定映射到 /team
|
||||
if (body.modelCode === 'team-page' && !revalidatedPaths.includes('/team')) {
|
||||
revalidatePath('/team');
|
||||
revalidatedPaths.push('/team');
|
||||
}
|
||||
|
||||
// 首页相关模型变更时同步刷新 /
|
||||
if (HOME_AFFECTED_MODELS.has(body.modelCode) && !revalidatedPaths.includes('/')) {
|
||||
revalidatePath('/');
|
||||
revalidatedPaths.push('/');
|
||||
}
|
||||
|
||||
// ContentZone 变更时刷新关联页面
|
||||
if (body.modelCode === 'content-zone' && !revalidatedPaths.includes('/')) {
|
||||
revalidatePath('/');
|
||||
revalidatedPaths.push('/');
|
||||
}
|
||||
}
|
||||
|
||||
if (body.event === 'content.published' || body.event === 'content.updated') {
|
||||
@@ -81,7 +129,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
}
|
||||
revalidateTag('cms:all');
|
||||
revalidateTag('cms:all', {});
|
||||
revalidatedTags.push('cms:all');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,83 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { COMPANY_INFO } from '@/lib/constants';
|
||||
|
||||
const contactSchema = z.object({
|
||||
name: z.string().min(2, '姓名至少需要2个字符').max(50, '姓名不能超过50个字符'),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号码'),
|
||||
email: z.string().email('请输入有效的邮箱地址').max(100, '邮箱地址过长'),
|
||||
subject: z.string().min(2, '主题至少需要2个字符').max(120, '主题不能超过120个字符'),
|
||||
message: z.string().min(10, '留言内容至少需要10个字符').max(2000, '留言内容不能超过2000个字符'),
|
||||
});
|
||||
|
||||
// 简单内存限流:按 IP 地址限制提交频率
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 小时
|
||||
const RATE_LIMIT_MAX_REQUESTS = 5;
|
||||
|
||||
function getClientIp(request: NextRequest): string {
|
||||
const forwarded = request.headers.get('x-forwarded-for');
|
||||
if (forwarded) {
|
||||
return forwarded.split(',')[0]?.trim() ?? 'unknown';
|
||||
}
|
||||
return request.headers.get('x-real-ip') ?? 'unknown';
|
||||
}
|
||||
|
||||
function isRateLimited(clientIp: string): boolean {
|
||||
const now = Date.now();
|
||||
const record = rateLimitMap.get(clientIp);
|
||||
|
||||
if (!record || now > record.resetAt) {
|
||||
rateLimitMap.set(clientIp, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
||||
return false;
|
||||
}
|
||||
|
||||
record.count += 1;
|
||||
return record.count > RATE_LIMIT_MAX_REQUESTS;
|
||||
}
|
||||
|
||||
function sanitizeInput(input: string): string {
|
||||
// 去除首尾空白并过滤潜在危险标签
|
||||
return input.trim().replace(/[<>]/g, '');
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const clientIp = getClientIp(request);
|
||||
if (isRateLimited(clientIp)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '提交过于频繁,请稍后再试' },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.formData();
|
||||
|
||||
const parseResult = contactSchema.safeParse({
|
||||
name: body.get('name'),
|
||||
phone: body.get('phone'),
|
||||
email: body.get('email'),
|
||||
subject: body.get('subject'),
|
||||
message: body.get('message'),
|
||||
});
|
||||
|
||||
if (!parseResult.success) {
|
||||
const firstError = parseResult.error.issues[0];
|
||||
return NextResponse.json(
|
||||
{ success: false, message: firstError?.message ?? '表单数据无效' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { name, phone, email, subject, message } = parseResult.data;
|
||||
|
||||
const formBody = new URLSearchParams();
|
||||
formBody.append('name', body.get('name') as string);
|
||||
formBody.append('phone', body.get('phone') as string);
|
||||
formBody.append('email', body.get('email') as string);
|
||||
formBody.append('subject', body.get('subject') as string);
|
||||
formBody.append('message', body.get('message') as string);
|
||||
formBody.append('_subject', `网站留言: ${body.get('subject')}`);
|
||||
formBody.append('name', sanitizeInput(name));
|
||||
formBody.append('phone', sanitizeInput(phone));
|
||||
formBody.append('email', sanitizeInput(email));
|
||||
formBody.append('subject', sanitizeInput(subject));
|
||||
formBody.append('message', sanitizeInput(message));
|
||||
formBody.append('_subject', `网站留言: ${sanitizeInput(subject)}`);
|
||||
formBody.append('_captcha', 'false');
|
||||
formBody.append('_template', 'table');
|
||||
|
||||
@@ -24,7 +90,8 @@ export async function POST(request: NextRequest) {
|
||||
const data = await response.json();
|
||||
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
|
||||
Reference in New Issue
Block a user