diff --git a/app/api/admin/notification-center/projects/[code]/route.ts b/app/api/admin/notification-center/projects/[code]/route.ts index 8204f9c..f5d5578 100644 --- a/app/api/admin/notification-center/projects/[code]/route.ts +++ b/app/api/admin/notification-center/projects/[code]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { + deleteNotificationCenterProject, getNotificationCenterProject, updateNotificationCenterProject, } from '@/lib/notification-center-admin' @@ -70,3 +71,23 @@ export async function PUT( ) { return handlePatch(request, context) } + +export async function DELETE( + _request: NextRequest, + { params }: { params: { code: string } }, +) { + try { + const auth = await requireNotificationCenterSuperAdmin() + if ('response' in auth) return auth.response + + const code = readProjectCode(params) + if (!code) { + return NextResponse.json({ success: false, error: '通知中心项目不存在' }, { status: 404 }) + } + + const result = await deleteNotificationCenterProject(auth.actor, code) + return NextResponse.json({ success: true, data: result.data }, { status: result.status }) + } catch (error) { + return toNotificationCenterAdminErrorResponse(error) + } +} diff --git a/components/admin/NotificationCenterPanel.test.tsx b/components/admin/NotificationCenterPanel.test.tsx index 99bfc19..c94dc19 100644 --- a/components/admin/NotificationCenterPanel.test.tsx +++ b/components/admin/NotificationCenterPanel.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import NotificationCenterPanel from './NotificationCenterPanel' @@ -35,10 +35,14 @@ const jsonResponse = (body: unknown, status = 200) => describe('NotificationCenterPanel', () => { const showToast = jest.fn() let clipboardText = '' + let projectList = [project] + let deleteShouldFail = false beforeEach(() => { jest.clearAllMocks() clipboardText = '' + projectList = [{ ...project }] + deleteShouldFail = false Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { @@ -51,7 +55,7 @@ describe('NotificationCenterPanel', () => { global.fetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input) if (url.endsWith('/api/admin/notification-center/projects') && (!init?.method || init.method === 'GET')) { - return jsonResponse({ success: true, data: { projects: [project] } }) + return jsonResponse({ success: true, data: { projects: projectList } }) } if (url.endsWith('/api/admin/notification-center/projects') && init?.method === 'POST') { return jsonResponse({ @@ -90,6 +94,13 @@ describe('NotificationCenterPanel', () => { }, }) } + if (url.endsWith('/api/admin/notification-center/projects/material_library') && init?.method === 'DELETE') { + if (deleteShouldFail) { + return jsonResponse({ error: { message: '通知中心服务异常,请稍后重试' } }, 502) + } + projectList = [] + return jsonResponse({ success: true, data: { projectCode: 'material_library', deleted: true } }) + } if (url.endsWith('/test') && init?.method === 'POST') { return jsonResponse({ deliveryId: 'delivery-1', status: 'pending' }, 202) } @@ -165,6 +176,54 @@ describe('NotificationCenterPanel', () => { expect(screen.getByRole('button', { name: '查看当前密钥' })).toBeInTheDocument() }) + test('删除项目需要单独确认,成功后刷新列表并关闭抽屉', async () => { + const user = userEvent.setup() + render() + await screen.findByText('素材平台') + + await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' })) + await user.click(screen.getByRole('button', { name: '删除项目' })) + + const dialog = await screen.findByRole('alertdialog', { name: '确认删除“素材平台”项目?' }) + expect(within(dialog).getByText('删除项目配置并使密钥立即失效,历史投递记录保留。')).toBeInTheDocument() + expect( + within(dialog).getByText('如仍需继续发送通知,请在删除后重新创建项目并同步更新调用方环境变量。'), + ).toBeInTheDocument() + + await user.click(within(dialog).getByRole('button', { name: '确认删除项目' })) + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + '/api/admin/notification-center/projects/material_library', + expect.objectContaining({ method: 'DELETE' }), + ) + }) + await waitFor(() => { + expect(screen.queryByRole('heading', { name: '查看配置 / 编辑资料' })).not.toBeInTheDocument() + }) + + expect(await screen.findByText('还没有接入项目')).toBeInTheDocument() + expect(showToast).toHaveBeenCalledWith('接入项目已删除,项目密钥已立即失效', 'success') + }) + + test('删除项目失败时保留确认弹窗并提示错误 toast', async () => { + deleteShouldFail = true + const user = userEvent.setup() + render() + await screen.findByText('素材平台') + + await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' })) + await user.click(screen.getByRole('button', { name: '删除项目' })) + + const dialog = await screen.findByRole('alertdialog', { name: '确认删除“素材平台”项目?' }) + await user.click(within(dialog).getByRole('button', { name: '确认删除项目' })) + + expect(await within(dialog).findByRole('alert')).toHaveTextContent('通知中心服务异常,请稍后重试') + expect(showToast).toHaveBeenCalledWith('通知中心服务异常,请稍后重试', 'error') + expect(screen.getByRole('alertdialog', { name: '确认删除“素材平台”项目?' })).toBeInTheDocument() + expect(screen.queryByText('还没有接入项目')).not.toBeInTheDocument() + }) + test('已接入项目可查看资料并按需查看、复制和隐藏当前密钥', async () => { const user = userEvent.setup() render() diff --git a/components/admin/NotificationCenterPanel.tsx b/components/admin/NotificationCenterPanel.tsx index d4c1d57..60a99cf 100644 --- a/components/admin/NotificationCenterPanel.tsx +++ b/components/admin/NotificationCenterPanel.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useGSAP } from '@gsap/react' import gsap from 'gsap' +import { useConfirmActionDialog } from '@/components/ConfirmActionDialog' import { Activity, AlertTriangle, @@ -16,6 +17,7 @@ import { RefreshCw, Send, Settings2, + Trash2, X, } from 'lucide-react' @@ -224,6 +226,7 @@ function useDialogKeyboard(open: boolean, close: () => void) { export default function NotificationCenterPanel({ showToast }: NotificationCenterPanelProps) { const rootRef = useRef(null) const revealRequestRef = useRef(0) + const { confirmAction, confirmActionDialog } = useConfirmActionDialog() const [activeView, setActiveView] = useState('projects') const [projects, setProjects] = useState([]) const [projectsLoading, setProjectsLoading] = useState(true) @@ -551,6 +554,40 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente } } + const confirmDeleteProject = async (project: NotificationProject) => { + await confirmAction({ + title: `确认删除“${project.name}”项目?`, + description: ( + <> +

删除项目配置并使密钥立即失效,历史投递记录保留。

+

如仍需继续发送通知,请在删除后重新创建项目并同步更新调用方环境变量。

+ + ), + confirmLabel: '确认删除项目', + loadingLabel: '删除中…', + tone: 'danger', + action: async () => { + try { + await readJsonResponse( + await fetch( + `/api/admin/notification-center/projects/${encodeURIComponent(project.projectCode)}`, + { method: 'DELETE' }, + ), + ) + closeDrawer() + setProjects((current) => current.filter((item) => item.projectCode !== project.projectCode)) + setTestProjectCode((current) => (current === project.projectCode ? '' : current)) + await loadProjects() + showToast('接入项目已删除,项目密钥已立即失效', 'success') + } catch (error) { + const message = error instanceof Error ? error.message : '接入项目删除失败' + showToast(message, 'error') + throw error instanceof Error ? error : new Error(message) + } + }, + }) + } + const preparedTestRecipient = useMemo(() => { if (!selectedTestProject || !testRecipient.trim()) return null if (testRecipientMode === 'userId') { @@ -929,18 +966,38 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente )}
- -

轮换会使旧密钥立即失效,仅在确实需要更换时使用。

+

危险操作

+

+ 轮换会使旧密钥立即失效;删除会移除项目配置,但历史投递记录仍会保留。 +

+
+ + +
)} @@ -955,6 +1012,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente {confirmTestOpen && preparedTestRecipient && selectedTestProject &&

确认发送真实测试通知

项目
{selectedTestProject.name} · {selectedTestProject.projectCode}
真实收件人
{preparedTestRecipient.label}
标题
{testTitle}

通知中心会统一在标题前加上项目名称;确认后会真实调用钉钉,不是页面预览。

} {oneTimeSecret &&

{oneTimeSecret.reason === 'created' ? '接入项目已创建' : '项目密钥已轮换'}

请把下面的 URL、projectCode 和项目密钥保存到调用方后端的安全环境变量中。关闭后仍可在“查看配置 / 编辑资料”里按需查看当前密钥。

统一通知中心 URL{oneTimeSecret.env.YYD_NOTIFICATION_CENTER_URL || '后端未返回'}
projectCode{oneTimeSecret.projectCode || oneTimeSecret.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE || '后端未返回'}
项目密钥
{oneTimeSecret.secret}
调用方环境变量
{Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n')}
} + {confirmActionDialog} ) } diff --git a/lib/__tests__/notification-center-admin-routes.test.ts b/lib/__tests__/notification-center-admin-routes.test.ts index 79fdba3..b72bd07 100644 --- a/lib/__tests__/notification-center-admin-routes.test.ts +++ b/lib/__tests__/notification-center-admin-routes.test.ts @@ -5,6 +5,7 @@ import { getServerSession } from 'next-auth' import { isSuperAdmin } from '@/lib/department-permission' import { createNotificationCenterProject, + deleteNotificationCenterProject, getNotificationCenterProject, listNotificationCenterAudits, listNotificationCenterDeliveries, @@ -18,7 +19,7 @@ import { NotificationCenterAdminError, } from '@/lib/notification-center-admin' import { GET as getProjectsRoute, POST as postProjectsRoute } from '@/app/api/admin/notification-center/projects/route' -import { GET as getProjectRoute, PATCH as patchProjectRoute } from '@/app/api/admin/notification-center/projects/[code]/route' +import { DELETE as deleteProjectRoute, GET as getProjectRoute, PATCH as patchProjectRoute } from '@/app/api/admin/notification-center/projects/[code]/route' import { POST as rotateProjectSecretRoute } from '@/app/api/admin/notification-center/projects/[code]/rotate-secret/route' import { POST as revealProjectSecretRoute } from '@/app/api/admin/notification-center/projects/[code]/reveal-secret/route' import { GET as getProjectRecipientsRoute } from '@/app/api/admin/notification-center/projects/[code]/recipients/route' @@ -57,6 +58,7 @@ jest.mock('@/lib/notification-center-admin', () => ({ createNotificationCenterProject: jest.fn(), getNotificationCenterProject: jest.fn(), updateNotificationCenterProject: jest.fn(), + deleteNotificationCenterProject: jest.fn(), rotateNotificationCenterProjectSecret: jest.fn(), listNotificationCenterProjectRecipients: jest.fn(), sendNotificationCenterProjectTest: jest.fn(), @@ -136,7 +138,7 @@ describe('notification-center admin routes', () => { expect(body.data.env).toContain('YYD_NOTIFICATION_CENTER_PROJECT_SECRET') }) - test('GET /projects/[code] 与 PATCH /projects/[code] 可用', async () => { + test('GET /projects/[code]、PATCH /projects/[code] 与 DELETE /projects/[code] 可用', async () => { ;(getNotificationCenterProject as jest.Mock).mockResolvedValue({ status: 200, data: { code: 'material_library', name: '素材库' }, @@ -145,17 +147,31 @@ describe('notification-center admin routes', () => { status: 200, data: { code: 'material_library', name: '素材库-新版' }, }) + ;(deleteNotificationCenterProject as jest.Mock).mockResolvedValue({ + status: 200, + data: { code: 'material_library', deleted: true }, + }) const getResponse = await getProjectRoute(new Request('http://localhost') as any, { params: { code: 'material_library' } } as any) const patchResponse = await patchProjectRoute( { json: jest.fn().mockResolvedValue({ name: '素材库-新版' }) } as any, { params: { code: 'material_library' } } as any, ) + const deleteResponse = await deleteProjectRoute( + new Request('http://localhost') as any, + { params: { code: 'material_library' } } as any, + ) expect(getResponse.status).toBe(200) expect((await getResponse.json()).data.code).toBe('material_library') expect(patchResponse.status).toBe(200) expect((await patchResponse.json()).data.name).toBe('素材库-新版') + expect(deleteResponse.status).toBe(200) + expect((await deleteResponse.json()).data.deleted).toBe(true) + expect(deleteNotificationCenterProject).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'admin-1' }), + 'material_library', + ) }) test('POST /projects/[code]/rotate-secret 透传一次性 secret/env', async () => { diff --git a/lib/__tests__/notification-center-admin.test.ts b/lib/__tests__/notification-center-admin.test.ts index 877896d..797675e 100644 --- a/lib/__tests__/notification-center-admin.test.ts +++ b/lib/__tests__/notification-center-admin.test.ts @@ -10,6 +10,7 @@ jest.mock('@/lib/prisma', () => ({ import { createNotificationCenterProject, + deleteNotificationCenterProject, listNotificationCenterProjectRecipients, listNotificationCenterProjects, NotificationCenterAdminError, @@ -181,7 +182,7 @@ describe('notification-center-admin client', () => { expect(JSON.stringify(result.data)).not.toContain('ding-private-user') }) - test('PATCH 项目更新与项目级 recipients/test 会命中嵌套路径', async () => { + test('PATCH/DELETE 项目更新与项目级 recipients/test 会命中嵌套路径', async () => { ;(global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: true, @@ -198,6 +199,11 @@ describe('notification-center-admin client', () => { status: 200, text: async () => JSON.stringify({ id: 'test-1' }), }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ deleted: true }), + }) await updateNotificationCenterProject(actor, 'material_library', { name: '素材库-新版' }) await listNotificationCenterProjectRecipients( @@ -209,6 +215,7 @@ describe('notification-center-admin client', () => { title: '连通性测试', projectCode: 'material_library', }) + await deleteNotificationCenterProject(actor, 'material_library') expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe( 'https://notify-admin.example.com/projects/material_library', @@ -226,6 +233,12 @@ describe('notification-center-admin client', () => { method: 'POST', body: JSON.stringify({ title: '连通性测试' }), }) + expect((global.fetch as jest.Mock).mock.calls[3][0]).toBe( + 'https://notify-admin.example.com/projects/material_library', + ) + expect((global.fetch as jest.Mock).mock.calls[3][1]).toMatchObject({ + method: 'DELETE', + }) }) test('下游 401 会转成中文配置错误且不泄漏管理员密钥', async () => { diff --git a/lib/notification-center-admin.ts b/lib/notification-center-admin.ts index 1b5d7a6..90907a5 100644 --- a/lib/notification-center-admin.ts +++ b/lib/notification-center-admin.ts @@ -1,6 +1,6 @@ import { prisma } from '@/lib/prisma' -type NotificationCenterAdminMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' +type NotificationCenterAdminMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' export type NotificationCenterAdminActor = { userId: string @@ -406,6 +406,17 @@ export async function updateNotificationCenterProject( }) } +export async function deleteNotificationCenterProject( + actor: NotificationCenterAdminActor, + code: string, +) { + return requestNotificationCenterAdmin({ + method: 'DELETE', + path: buildProjectScopedPath(code), + actor, + }) +} + export async function rotateNotificationCenterProjectSecret( actor: NotificationCenterAdminActor, code: string,