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();
}
}
}