fix(cms): resolve admin dogfood findings on auth, content editing, and UX
修复 CMS / Admin 后台 dogfood 专项测试发现的阻塞性与体验性问题: - 登录后无限重定向(Secure Cookie + Edge Runtime JWT 兼容) - 内容新增/编辑保存 400/500(默认不加密请求体、自动生成唯一 slug) - 编辑时提交 status 导致验证错误 - 原生 confirm 阻塞自动化测试,改为 AlertDialog - 表单字段可访问性(id/htmlFor/fieldset) - JSON 数组标签字段新增 TagInput 组件 - 操作反馈统一使用 Sonner Toast - 后台隐藏营销 Cookie 横幅 - 媒体库空状态优化 添加 dogfood-cms-regression 与 dogfood-cms-output 报告、截图及工作流路由测试。
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
|
||||
const mockContentItemFindUnique = jest.fn<(args: unknown) => Promise<unknown | null>>();
|
||||
const mockContentItemFindUniqueOrThrow = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
contentItem: {
|
||||
findUnique: mockContentItemFindUnique,
|
||||
findUniqueOrThrow: mockContentItemFindUniqueOrThrow,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Mock workflow service ────────────────────────────────────────────────
|
||||
const mockSubmitForReview = jest.fn<(itemId: string, user: unknown) => Promise<unknown>>();
|
||||
const mockApprove = jest.fn<(itemId: string, user: unknown) => Promise<unknown>>();
|
||||
const mockReject = jest.fn<(itemId: string, user: unknown, reason?: string) => Promise<unknown>>();
|
||||
const mockArchive = jest.fn<(itemId: string, user: unknown) => Promise<unknown>>();
|
||||
|
||||
jest.mock('@/lib/cms/workflow', () => ({
|
||||
submitForReview: mockSubmitForReview,
|
||||
approve: mockApprove,
|
||||
reject: mockReject,
|
||||
archive: mockArchive,
|
||||
}));
|
||||
|
||||
// ─── Mock permissions ─────────────────────────────────────────────────────
|
||||
const mockRequirePermission = jest.fn<
|
||||
(request: NextRequest, modelCode: string, action: string) => Promise<unknown>
|
||||
>();
|
||||
|
||||
jest.mock('@/lib/permissions', () => ({
|
||||
requirePermission: mockRequirePermission,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { POST } from './route';
|
||||
|
||||
function createMockRequest(body: Record<string, unknown>): NextRequest {
|
||||
return {
|
||||
json: async () => body,
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
function mockAuthorized(action: 'update' | 'publish' = 'update') {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
user: { userId: 'user-1', username: 'editor', role: action === 'publish' ? 'reviewer' : 'editor' },
|
||||
});
|
||||
}
|
||||
|
||||
const mockItem = {
|
||||
id: 'item-1',
|
||||
modelId: 'model-1',
|
||||
modelCode: 'news',
|
||||
title: '测试新闻',
|
||||
slug: 'test-news',
|
||||
locale: 'zh-CN',
|
||||
status: 'draft',
|
||||
data: '{}',
|
||||
version: 1,
|
||||
sortOrder: 0,
|
||||
createdBy: 'editor',
|
||||
updatedBy: 'editor',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
};
|
||||
|
||||
const mockUpdatedItem = { ...mockItem, status: 'review', version: 2 };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockContentItemFindUnique.mockResolvedValue(mockItem);
|
||||
mockContentItemFindUniqueOrThrow.mockResolvedValue(mockUpdatedItem);
|
||||
});
|
||||
|
||||
describe('POST /api/admin/items/[id]/workflow', () => {
|
||||
it('returns 400 when action is missing', async () => {
|
||||
mockAuthorized();
|
||||
const request = createMockRequest({});
|
||||
const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) });
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe('缺少 action 参数');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid action', async () => {
|
||||
mockAuthorized();
|
||||
const request = createMockRequest({ action: 'publish' });
|
||||
const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) });
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain('非法的 action');
|
||||
});
|
||||
|
||||
it('returns 404 when item not found', async () => {
|
||||
mockAuthorized();
|
||||
mockContentItemFindUnique.mockResolvedValue(null);
|
||||
const request = createMockRequest({ action: 'submit' });
|
||||
const response = await POST(request, { params: Promise.resolve({ id: 'missing' }) });
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toBe('内容不存在');
|
||||
});
|
||||
|
||||
it('submit requires update permission', async () => {
|
||||
mockAuthorized('update');
|
||||
const request = createMockRequest({ action: 'submit' });
|
||||
const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSubmitForReview).toHaveBeenCalledWith(
|
||||
'item-1',
|
||||
expect.objectContaining({ username: 'editor' })
|
||||
);
|
||||
expect(mockRequirePermission).toHaveBeenCalledWith(
|
||||
request,
|
||||
'news',
|
||||
'update'
|
||||
);
|
||||
});
|
||||
|
||||
it('approve requires publish permission', async () => {
|
||||
mockAuthorized('publish');
|
||||
const request = createMockRequest({ action: 'approve' });
|
||||
const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockApprove).toHaveBeenCalledWith(
|
||||
'item-1',
|
||||
expect.objectContaining({ username: 'editor' })
|
||||
);
|
||||
expect(mockRequirePermission).toHaveBeenCalledWith(
|
||||
request,
|
||||
'news',
|
||||
'publish'
|
||||
);
|
||||
});
|
||||
|
||||
it('reject passes reason to workflow service', async () => {
|
||||
mockAuthorized('publish');
|
||||
const request = createMockRequest({ action: 'reject', reason: '需要修改' });
|
||||
const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockReject).toHaveBeenCalledWith(
|
||||
'item-1',
|
||||
expect.objectContaining({ username: 'editor' }),
|
||||
'需要修改'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns validation error on illegal transition', async () => {
|
||||
mockAuthorized('publish');
|
||||
mockApprove.mockRejectedValue(new Error('当前状态 draft 不允许审核通过'));
|
||||
const request = createMockRequest({ action: 'approve' });
|
||||
const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) });
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe('当前状态 draft 不允许审核通过');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { requirePermission } from '@/lib/permissions';
|
||||
import {
|
||||
submitForReview,
|
||||
approve,
|
||||
reject,
|
||||
archive,
|
||||
type WorkflowAction,
|
||||
} from '@/lib/cms/workflow';
|
||||
import {
|
||||
success,
|
||||
notFound,
|
||||
validationError,
|
||||
internalError,
|
||||
forbidden,
|
||||
} from '@/lib/api-response';
|
||||
|
||||
function parseItem(item: { data: string; [key: string]: unknown }) {
|
||||
return { ...item, data: JSON.parse(item.data as string) };
|
||||
}
|
||||
|
||||
interface WorkflowBody {
|
||||
action: WorkflowAction;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const ACTION_PERMISSION: Record<WorkflowAction, 'update' | 'publish'> = {
|
||||
submit: 'update',
|
||||
approve: 'publish',
|
||||
reject: 'publish',
|
||||
archive: 'publish',
|
||||
};
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as WorkflowBody;
|
||||
const { action, reason } = body;
|
||||
|
||||
if (!action) {
|
||||
return validationError('缺少 action 参数');
|
||||
}
|
||||
|
||||
if (!['submit', 'approve', 'reject', 'archive'].includes(action)) {
|
||||
return validationError(`非法的 action: ${action}`);
|
||||
}
|
||||
|
||||
const existing = await prisma.contentItem.findUnique({ where: { id } });
|
||||
if (!existing) return notFound('内容不存在');
|
||||
|
||||
const requiredAction = ACTION_PERMISSION[action];
|
||||
const permission = await requirePermission(request, existing.modelCode, requiredAction);
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
const user = { userId: permission.user.userId, username: permission.user.username };
|
||||
|
||||
switch (action) {
|
||||
case 'submit':
|
||||
await submitForReview(id, user);
|
||||
break;
|
||||
case 'approve':
|
||||
await approve(id, user);
|
||||
break;
|
||||
case 'reject':
|
||||
await reject(id, user, reason);
|
||||
break;
|
||||
case 'archive':
|
||||
await archive(id, user);
|
||||
break;
|
||||
}
|
||||
|
||||
return success(parseItem(await prisma.contentItem.findUniqueOrThrow({ where: { id } })));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('内容不存在')) {
|
||||
return notFound(error.message);
|
||||
}
|
||||
if (error.message.includes('当前状态') || error.message.includes('不允许')) {
|
||||
return validationError(error.message);
|
||||
}
|
||||
if (error.message.includes('无权限')) {
|
||||
return forbidden(error.message);
|
||||
}
|
||||
}
|
||||
console.error('Workflow error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
|
||||
const mockContentItemFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
|
||||
const mockContentItemCount = jest.fn<(args: unknown) => Promise<number>>();
|
||||
const mockContentItemFindFirst = jest.fn<(args: unknown) => Promise<unknown | null>>();
|
||||
const mockContentItemFindUnique = jest.fn<(args: unknown) => Promise<unknown | null>>();
|
||||
const mockContentItemCreate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockContentItemUpdate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockContentItemDelete = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockAuditLogCreate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
contentItem: {
|
||||
findMany: mockContentItemFindMany,
|
||||
count: mockContentItemCount,
|
||||
findFirst: mockContentItemFindFirst,
|
||||
findUnique: mockContentItemFindUnique,
|
||||
create: mockContentItemCreate,
|
||||
update: mockContentItemUpdate,
|
||||
delete: mockContentItemDelete,
|
||||
},
|
||||
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/items',
|
||||
json: options.json || (async () => ({})),
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
function mockAuthorized() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
user: { userId: 'user-1', username: 'editor', role: 'content_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 }),
|
||||
});
|
||||
}
|
||||
|
||||
const mockItem = {
|
||||
id: 'item-1',
|
||||
modelId: 'model-1',
|
||||
modelCode: 'news',
|
||||
title: '测试新闻',
|
||||
slug: 'test-news',
|
||||
locale: 'zh-CN',
|
||||
status: 'draft',
|
||||
data: JSON.stringify({ body: 'hello' }),
|
||||
version: 1,
|
||||
sortOrder: 0,
|
||||
createdBy: 'editor',
|
||||
updatedBy: 'editor',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
publishedAt: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockContentItemFindMany.mockResolvedValue([mockItem]);
|
||||
mockContentItemCount.mockResolvedValue(1);
|
||||
mockContentItemFindFirst.mockResolvedValue(null);
|
||||
mockContentItemFindUnique.mockResolvedValue(mockItem);
|
||||
mockContentItemCreate.mockResolvedValue(mockItem);
|
||||
mockContentItemUpdate.mockResolvedValue(mockItem);
|
||||
mockContentItemDelete.mockResolvedValue(undefined);
|
||||
mockAuditLogCreate.mockResolvedValue({ id: 'log-1' });
|
||||
});
|
||||
|
||||
describe('/api/admin/items', () => {
|
||||
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 paginated item list', async () => {
|
||||
mockAuthorized();
|
||||
const response = await GET(createMockRequest({ url: 'http://localhost/api/admin/items?page=1&pageSize=10' }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(body.total).toBe(1);
|
||||
expect(body.page).toBe(1);
|
||||
expect(body.totalPages).toBe(1);
|
||||
expect(body.items[0]).toMatchObject({
|
||||
id: 'item-1',
|
||||
title: '测试新闻',
|
||||
data: { body: 'hello' },
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by modelCode and status', async () => {
|
||||
mockAuthorized();
|
||||
await GET(createMockRequest({ url: 'http://localhost/api/admin/items?modelCode=news&status=published' }));
|
||||
|
||||
expect(mockContentItemFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { modelCode: 'news', status: 'published' },
|
||||
})
|
||||
);
|
||||
expect(mockRequirePermission).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'news',
|
||||
'read'
|
||||
);
|
||||
});
|
||||
|
||||
it('applies search filter', async () => {
|
||||
mockAuthorized();
|
||||
await GET(createMockRequest({ url: 'http://localhost/api/admin/items?search=测试' }));
|
||||
|
||||
expect(mockContentItemFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
OR: [
|
||||
{ title: { contains: '测试' } },
|
||||
{ slug: { contains: '测试' } },
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockUnauthorized();
|
||||
const response = await POST(createMockRequest({
|
||||
json: async () => ({ modelId: 'model-1', modelCode: 'news', title: '新闻' }),
|
||||
}));
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 400 when required fields missing', async () => {
|
||||
mockAuthorized();
|
||||
const response = await POST(createMockRequest({ json: async () => ({ title: '仅标题' }) }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain('缺少必填字段');
|
||||
});
|
||||
|
||||
it('returns 400 when slug already exists', async () => {
|
||||
mockAuthorized();
|
||||
mockContentItemFindFirst.mockResolvedValue({ id: 'other', slug: 'test-news' });
|
||||
|
||||
const response = await POST(createMockRequest({
|
||||
json: async () => ({
|
||||
modelId: 'model-1',
|
||||
modelCode: 'news',
|
||||
title: '新新闻',
|
||||
slug: 'test-news',
|
||||
}),
|
||||
}));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain('已存在');
|
||||
});
|
||||
|
||||
it('creates item and audit log', async () => {
|
||||
mockAuthorized();
|
||||
const response = await POST(createMockRequest({
|
||||
json: async () => ({
|
||||
modelId: 'model-1',
|
||||
modelCode: 'news',
|
||||
title: '新新闻',
|
||||
slug: 'new-news',
|
||||
data: { body: 'content' },
|
||||
status: 'published',
|
||||
sortOrder: 1,
|
||||
}),
|
||||
}));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(body.title).toBe('测试新闻');
|
||||
expect(mockContentItemCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
modelId: 'model-1',
|
||||
modelCode: 'news',
|
||||
title: '新新闻',
|
||||
slug: 'new-news',
|
||||
status: 'published',
|
||||
sortOrder: 1,
|
||||
publishedAt: expect.any(Date),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockAuditLogCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
module: 'item',
|
||||
action: 'create',
|
||||
operator: 'editor',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT', () => {
|
||||
it('returns 400 when id missing', async () => {
|
||||
mockAuthorized();
|
||||
const response = await PUT(createMockRequest({ url: 'http://localhost/api/admin/items', json: async () => ({}) }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe('缺少 ID');
|
||||
});
|
||||
|
||||
it('returns 404 when item not found', async () => {
|
||||
mockAuthorized();
|
||||
mockContentItemFindUnique.mockResolvedValue(null);
|
||||
const response = await PUT(createMockRequest({
|
||||
url: 'http://localhost/api/admin/items?id=missing',
|
||||
json: async () => ({ title: '更新' }),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects status update through PUT', async () => {
|
||||
mockAuthorized();
|
||||
const response = await PUT(createMockRequest({
|
||||
url: 'http://localhost/api/admin/items?id=item-1',
|
||||
json: async () => ({ status: 'published' }),
|
||||
}));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain('状态变更');
|
||||
});
|
||||
|
||||
it('returns 400 when slug duplicated', async () => {
|
||||
mockAuthorized();
|
||||
mockContentItemFindFirst.mockResolvedValue({ id: 'other', slug: 'duplicated' });
|
||||
|
||||
const response = await PUT(createMockRequest({
|
||||
url: 'http://localhost/api/admin/items?id=item-1',
|
||||
json: async () => ({ slug: 'duplicated' }),
|
||||
}));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain('已存在');
|
||||
});
|
||||
|
||||
it('updates item and increments version', async () => {
|
||||
mockAuthorized();
|
||||
const response = await PUT(createMockRequest({
|
||||
url: 'http://localhost/api/admin/items?id=item-1',
|
||||
json: async () => ({ title: '更新标题', data: { body: 'new' }, sortOrder: 2 }),
|
||||
}));
|
||||
await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockContentItemUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'item-1' },
|
||||
data: expect.objectContaining({
|
||||
title: '更新标题',
|
||||
data: JSON.stringify({ body: 'new' }),
|
||||
sortOrder: 2,
|
||||
version: { increment: 1 },
|
||||
updatedBy: 'editor',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockAuditLogCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
module: 'item',
|
||||
action: 'update',
|
||||
operator: 'editor',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('returns 400 when id missing', async () => {
|
||||
mockAuthorized();
|
||||
const response = await DELETE(createMockRequest({ url: 'http://localhost/api/admin/items' }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe('缺少 ID');
|
||||
});
|
||||
|
||||
it('returns 404 when item not found', async () => {
|
||||
mockAuthorized();
|
||||
mockContentItemFindUnique.mockResolvedValue(null);
|
||||
const response = await DELETE(createMockRequest({ url: 'http://localhost/api/admin/items?id=missing' }));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('deletes item and audit log', async () => {
|
||||
mockAuthorized();
|
||||
const response = await DELETE(createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1' }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.message).toBe('删除成功');
|
||||
expect(mockContentItemDelete).toHaveBeenCalledWith({ where: { id: 'item-1' } });
|
||||
expect(mockAuditLogCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
module: 'item',
|
||||
action: 'delete',
|
||||
operator: 'editor',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -13,14 +12,49 @@ function parseItem(item: { data: string; [key: string]: unknown }) {
|
||||
return { ...item, data: JSON.parse(item.data as string) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 URL 友好的唯一 slug。
|
||||
* 优先基于标题生成;若标题无法提取有效字符,则回退到 modelCode + 时间戳。
|
||||
* 末尾追加时间戳 + 随机后缀以保证唯一性,避免并发或重复标题导致冲突。
|
||||
*/
|
||||
function generateSlug(modelCode: string, title: string): string {
|
||||
const base = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 50);
|
||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return base ? `${base}-${suffix}` : `${modelCode}-${suffix}`;
|
||||
}
|
||||
|
||||
async function ensureUniqueSlug(
|
||||
modelCode: string,
|
||||
title: string,
|
||||
excludeId?: string
|
||||
): Promise<string> {
|
||||
let slug = generateSlug(modelCode, title);
|
||||
let attempts = 0;
|
||||
while (attempts < 5) {
|
||||
const existing = await prisma.contentItem.findFirst({
|
||||
where: { modelCode, slug, ...(excludeId ? { id: { not: excludeId } } : {}) },
|
||||
});
|
||||
if (!existing) return slug;
|
||||
slug = generateSlug(modelCode, `${title}-${Math.random().toString(36).slice(2, 6)}`);
|
||||
attempts++;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
// GET /api/admin/items - 获取内容列表
|
||||
export async function GET(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const modelCode = searchParams.get('modelCode');
|
||||
|
||||
const permissionModelCode = modelCode || 'content-item';
|
||||
const permission = await requirePermission(request, permissionModelCode, 'read');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const modelCode = searchParams.get('modelCode');
|
||||
const status = searchParams.get('status');
|
||||
const search = searchParams.get('search');
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
@@ -61,9 +95,6 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// POST /api/admin/items - 创建内容
|
||||
export async function POST(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { modelId, modelCode, title, slug, data, status, sortOrder } = body;
|
||||
@@ -72,7 +103,14 @@ export async function POST(request: NextRequest) {
|
||||
return validationError('缺少必填字段:modelId, modelCode, title');
|
||||
}
|
||||
|
||||
// 检查 slug 唯一性
|
||||
const permission = await requirePermission(request, modelCode, 'create');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
// 未提供 slug 时自动生成唯一 slug;提供时检查唯一性
|
||||
const finalSlug = slug
|
||||
? slug
|
||||
: await ensureUniqueSlug(modelCode, title);
|
||||
|
||||
if (slug) {
|
||||
const existing = await prisma.contentItem.findFirst({
|
||||
where: { modelCode, slug },
|
||||
@@ -87,12 +125,12 @@ export async function POST(request: NextRequest) {
|
||||
modelId,
|
||||
modelCode,
|
||||
title,
|
||||
slug: slug || '',
|
||||
slug: finalSlug,
|
||||
status: status || 'draft',
|
||||
data: JSON.stringify(data || {}),
|
||||
sortOrder: sortOrder || 0,
|
||||
createdBy: payload.username,
|
||||
updatedBy: payload.username,
|
||||
createdBy: permission.user.username,
|
||||
updatedBy: permission.user.username,
|
||||
publishedAt: status === 'published' ? new Date() : null,
|
||||
},
|
||||
});
|
||||
@@ -103,7 +141,7 @@ export async function POST(request: NextRequest) {
|
||||
module: 'item',
|
||||
targetId: item.id,
|
||||
action: 'create',
|
||||
operator: payload.username,
|
||||
operator: permission.user.username,
|
||||
afterData: JSON.stringify(item),
|
||||
},
|
||||
});
|
||||
@@ -117,17 +155,17 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// PUT /api/admin/items/[id] - 更新内容
|
||||
export async function PUT(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
if (!id) return validationError('缺少 ID');
|
||||
|
||||
const existing = await prisma.contentItem.findUnique({ where: { id } });
|
||||
if (!existing) return notFound('内容不存在');
|
||||
|
||||
const permission = await requirePermission(request, existing.modelCode, 'update');
|
||||
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.contentItem.findUnique({ where: { id } });
|
||||
if (!existing) return notFound('内容不存在');
|
||||
|
||||
const body = await request.json();
|
||||
const { title, slug, data, status, sortOrder } = body;
|
||||
|
||||
@@ -141,19 +179,21 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// 状态流转必须通过 /api/admin/items/[id]/workflow 进行
|
||||
if (status !== undefined) {
|
||||
return validationError('状态变更请使用 /api/admin/items/[id]/workflow 接口');
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedBy: payload.username,
|
||||
updatedBy: permission.user.username,
|
||||
updatedAt: new Date(),
|
||||
// 任何内容更新都视为新版本
|
||||
version: { increment: 1 },
|
||||
};
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (slug !== undefined) updateData.slug = slug;
|
||||
// slug 为空字符串时保留原值,避免触发唯一约束冲突
|
||||
if (slug !== undefined && slug !== '') updateData.slug = slug;
|
||||
if (data !== undefined) updateData.data = JSON.stringify(data);
|
||||
if (status !== undefined) {
|
||||
updateData.status = status;
|
||||
if (status === 'published' && existing.status !== 'published') {
|
||||
updateData.publishedAt = new Date();
|
||||
}
|
||||
}
|
||||
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
|
||||
|
||||
const item = await prisma.contentItem.update({
|
||||
@@ -167,7 +207,7 @@ export async function PUT(request: NextRequest) {
|
||||
module: 'item',
|
||||
targetId: id,
|
||||
action: 'update',
|
||||
operator: payload.username,
|
||||
operator: permission.user.username,
|
||||
beforeData: JSON.stringify(existing),
|
||||
afterData: JSON.stringify(item),
|
||||
},
|
||||
@@ -182,17 +222,17 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
// DELETE /api/admin/items/[id] - 删除内容
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
if (!id) return validationError('缺少 ID');
|
||||
|
||||
const existing = await prisma.contentItem.findUnique({ where: { id } });
|
||||
if (!existing) return notFound('内容不存在');
|
||||
|
||||
const permission = await requirePermission(request, existing.modelCode, '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.contentItem.findUnique({ where: { id } });
|
||||
if (!existing) return notFound('内容不存在');
|
||||
|
||||
await prisma.contentItem.delete({ where: { id } });
|
||||
|
||||
await prisma.auditLog.create({
|
||||
@@ -200,7 +240,7 @@ export async function DELETE(request: NextRequest) {
|
||||
module: 'item',
|
||||
targetId: id,
|
||||
action: 'delete',
|
||||
operator: payload.username,
|
||||
operator: permission.user.username,
|
||||
beforeData: JSON.stringify(existing),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user