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:
张翔
2026-07-25 08:04:01 +08:00
parent e35090b914
commit 10404dbb36
208 changed files with 18107 additions and 8330 deletions
+241
View File
@@ -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);
});
});
});
+68 -106
View File
@@ -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();
}
}
}
+130
View File
@@ -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);
});
});
+4 -4
View File
@@ -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);
});
});
+30
View File
@@ -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();
}
}
+206
View File
@@ -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');
});
});
+103
View File
@@ -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();
}
}
+294
View File
@@ -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',
}),
})
);
});
});
});
+11 -12
View File
@@ -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();
}
}
}
+4 -1
View File
@@ -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) {
+5 -3
View File
@@ -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;
}
}
+2 -1
View File
@@ -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) {
+2 -1
View File
@@ -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));
+2 -1
View File
@@ -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));
+260
View File
@@ -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');
});
});
+52 -4
View File
@@ -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');
}
+74 -7
View File
@@ -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 }