feat: add notification project deletion

This commit is contained in:
chenpei 2026-08-29 21:39:17 +08:00
parent b917c09402
commit 15b4501ffa
6 changed files with 196 additions and 18 deletions

View File

@ -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)
}
}

View File

@ -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(<NotificationCenterPanel showToast={showToast} />)
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(<NotificationCenterPanel showToast={showToast} />)
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(<NotificationCenterPanel showToast={showToast} />)

View File

@ -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<HTMLDivElement>(null)
const revealRequestRef = useRef(0)
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
const [activeView, setActiveView] = useState<PanelView>('projects')
const [projects, setProjects] = useState<NotificationProject[]>([])
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: (
<>
<p>删除项目配置并使密钥立即失效,历史投递记录保留。</p>
<p className="mt-2">如仍需继续发送通知,请在删除后重新创建项目并同步更新调用方环境变量。</p>
</>
),
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
</button>
)}
<div className="mt-4 border-t border-[#e2e8f0] pt-4">
<button
type="button"
onClick={() => {
const project = projects.find((item) => item.projectCode === editingCode)
if (project) setRotateProject(project)
}}
className="inline-flex min-h-10 items-center justify-center gap-2 rounded-[8px] border border-[#f3c7c1] px-3 text-[13px] font-medium text-[#b42318] hover:bg-[#fef6f5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
>
<KeyRound className="h-4 w-4" aria-hidden="true" />
轮换项目密钥
</button>
<p className="mt-1 text-[11px] leading-5 text-[#8f2119]">轮换会使旧密钥立即失效,仅在确实需要更换时使用。</p>
<p className="text-[13px] font-semibold text-[#8f2119]">危险操作</p>
<p className="mt-1 text-[11px] leading-5 text-[#8f2119]">
轮换会使旧密钥立即失效;删除会移除项目配置,但历史投递记录仍会保留。
</p>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<button
type="button"
onClick={() => {
const project = projects.find((item) => item.projectCode === editingCode)
if (project) setRotateProject(project)
}}
className="inline-flex min-h-10 items-center justify-center gap-2 rounded-[8px] border border-[#f3c7c1] px-3 text-[13px] font-medium text-[#b42318] hover:bg-[#fef6f5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
>
<KeyRound className="h-4 w-4" aria-hidden="true" />
轮换项目密钥
</button>
<button
type="button"
onClick={() => {
const project = projects.find((item) => item.projectCode === editingCode)
if (!project) {
showToast('当前项目不存在,请先刷新项目列表', 'error')
return
}
void confirmDeleteProject(project)
}}
className="inline-flex min-h-10 items-center justify-center gap-2 rounded-[8px] border border-[#f3c7c1] px-3 text-[13px] font-medium text-[#b42318] hover:bg-[#fef6f5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
删除项目
</button>
</div>
</div>
</div>
)}
@ -955,6 +1012,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
{confirmTestOpen && preparedTestRecipient && selectedTestProject && <div className="fixed inset-0 z-[80] flex items-center justify-center bg-[#06192d]/40 px-4"><section role="alertdialog" aria-modal="true" aria-labelledby="confirm-test-title" className="w-full max-w-lg rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.2)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#e6f4ff] text-[#1677ff]"><Send className="h-5 w-5" aria-hidden="true" /></div><h3 id="confirm-test-title" className="mt-4 text-[17px] font-semibold text-[#101828]">确认发送真实测试通知</h3><dl className="mt-4 grid gap-3 rounded-[8px] bg-[#f8fafc] p-4 text-[13px]"><div><dt className="text-[#66758b]">项目</dt><dd className="mt-1 font-medium text-[#101828]">{selectedTestProject.name} · {selectedTestProject.projectCode}</dd></div><div><dt className="text-[#66758b]">真实收件人</dt><dd className="mt-1 font-medium text-[#101828]">{preparedTestRecipient.label}</dd></div><div><dt className="text-[#66758b]">标题</dt><dd className="mt-1 font-medium text-[#101828]">{testTitle}</dd></div></dl><p className="mt-3 text-[13px] leading-6 text-[#9a4d08]">通知中心会统一在标题前加上项目名称;确认后会真实调用钉钉,不是页面预览。</p><div className="mt-5 flex justify-end gap-2"><button type="button" onClick={() => setConfirmTestOpen(false)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] px-4 text-[14px] font-medium text-[#344054]">取消</button><button type="button" onClick={() => void sendTestNotification()} disabled={sendingTest} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white disabled:opacity-50">{sendingTest ? '正在提交…' : '确认发送给该员工'}</button></div></section></div>}
{oneTimeSecret && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-[#06192d]/45 px-4"><section data-secret-reveal role="dialog" aria-modal="true" aria-labelledby="one-time-secret-title" className="max-h-[90dvh] w-full max-w-2xl overflow-y-auto rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.22)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#e8f7f2] text-[#087052]"><Check className="h-5 w-5" aria-hidden="true" /></div><h3 id="one-time-secret-title" className="mt-4 text-[18px] font-semibold text-[#101828]">{oneTimeSecret.reason === 'created' ? '接入项目已创建' : '项目密钥已轮换'}</h3><p className="mt-2 text-[13px] leading-6 text-[#66758b]">请把下面的 URL、projectCode 和项目密钥保存到调用方后端的安全环境变量中。关闭后仍可在“查看配置 / 编辑资料”里按需查看当前密钥。</p><div className="mt-5 grid gap-4 sm:grid-cols-2"><div className="sm:col-span-2"><span className="text-[13px] font-semibold text-[#344054]">统一通知中心 URL</span><code className="mt-2 block break-all rounded-[8px] border border-[#d9e0e7] bg-[#f8fafc] p-3 text-[13px] leading-6 text-[#101828]">{oneTimeSecret.env.YYD_NOTIFICATION_CENTER_URL || '后端未返回'}</code></div><div><span className="text-[13px] font-semibold text-[#344054]">projectCode</span><code className="mt-2 block break-all rounded-[8px] border border-[#d9e0e7] bg-[#f8fafc] p-3 text-[13px] leading-6 text-[#101828]">{oneTimeSecret.projectCode || oneTimeSecret.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE || '后端未返回'}</code></div><div><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">项目密钥</span><button type="button" onClick={() => void copyText(oneTimeSecret.secret, '项目密钥')} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-3 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff]"><Clipboard className="h-4 w-4" aria-hidden="true" />复制</button></div><code className="mt-2 block break-all rounded-[8px] border border-[#d9e0e7] bg-[#f8fafc] p-3 text-[13px] leading-6 text-[#101828]">{oneTimeSecret.secret}</code></div></div><div className="mt-5"><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">调用方环境变量</span><button type="button" onClick={() => void copyText(Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n'), '环境变量')} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-3 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff]"><Clipboard className="h-4 w-4" aria-hidden="true" />复制全部</button></div><pre className="mt-2 overflow-x-auto rounded-[8px] border border-[#d9e0e7] bg-[#06192d] p-3 text-[12px] leading-6 text-[#eaf1f8]">{Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n')}</pre></div><div className="mt-5 flex justify-end"><button type="button" onClick={() => setOneTimeSecret(null)} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white">关闭</button></div></section></div>}
{confirmActionDialog}
</div>
)
}

View File

@ -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 () => {

View File

@ -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 () => {

View File

@ -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,