From d74959da1fd7367125c8ffaf37e4fe0732bdcbf4 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Sat, 29 Aug 2026 21:39:09 +0800 Subject: [PATCH] feat: allow deleting notification projects with history --- src/__tests__/admin.test.ts | 94 ++++++++++++++++++++++++++++++++++- src/admin-project-registry.ts | 12 ++--- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index 584d955..b33df7e 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -743,7 +743,7 @@ test('admin API 提供稳定 contract,rotate-secret/test/deliveries/audits 可 headers: withAdminAuth(), }) assert.equal(deleteResponse.status, 200) - assert.deepEqual(await deleteResponse.json(), { projectCode: 'temp_unused', deleted: true }) + assert.deepEqual(await deleteResponse.json(), { projectCode: 'temp_unused', deleted: true, deliveryCount: 0 }) const oversizedResponse = await fetch(`http://127.0.0.1:${port}/admin/projects`, { method: 'POST', @@ -769,3 +769,95 @@ test('admin API 提供稳定 contract,rotate-secret/test/deliveries/audits 可 await prisma.$disconnect() } }) + +test('已有投递记录的项目也可删除,项目配置与 recipients 删除但历史记录保留', async () => { + const oldEncryptionKey = process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY + const oldAdminApiSecret = process.env.YYD_NOTIFICATION_ADMIN_API_SECRET + const oldAdminToken = process.env.YYD_NOTIFICATION_ADMIN_TOKEN + const oldProjectsJson = process.env.YYD_NOTIFICATION_PROJECTS_JSON + const oldPublicBaseUrl = process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL + process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY = encryptionKey + process.env.YYD_NOTIFICATION_ADMIN_API_SECRET = adminApiSecret + process.env.YYD_NOTIFICATION_ADMIN_TOKEN = '' + process.env.YYD_NOTIFICATION_PROJECTS_JSON = '' + process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL = 'https://sc.example.com/notification-center' + + await resetDatabase() + const registry = new AdminProjectRegistry() + await registry.refresh() + const server = createServer(registry, { resolveRecipientAlias }).listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + const port = (server.address() as AddressInfo).port + + try { + const createResponse = await fetch(`http://127.0.0.1:${port}/admin/projects`, { + method: 'POST', + headers: withAdminAuth(), + body: JSON.stringify({ + projectCode: 'delete_with_history', + name: '删除保留历史', + recipients: [{ alias: '海棠 新', userId: 'ding-ops-user-2' }], + }), + }) + assert.equal(createResponse.status, 201) + + await prisma.notificationDelivery.create({ + data: { + id: 'delivery-keep-after-project-delete', + projectCode: 'delete_with_history', + idempotencyKey: 'delete_with_history:1', + requestHash: 'request-hash', + recipientUserId: 'ding-ops-user-2', + businessTitle: '历史任务', + renderedTitle: '【删除保留历史】历史任务', + content: '历史正文', + actionTitle: '查看', + actionUrl: '', + status: 'failed', + lastErrorCode: 'INVALID_USER', + lastErrorMessage: '历史失败保留', + }, + }) + + const deleteResponse = await fetch(`http://127.0.0.1:${port}/admin/projects/delete_with_history`, { + method: 'DELETE', + headers: withAdminAuth(), + }) + assert.equal(deleteResponse.status, 200) + assert.deepEqual(await deleteResponse.json(), { + projectCode: 'delete_with_history', + deleted: true, + deliveryCount: 1, + }) + + assert.equal(await prisma.notificationProject.findUnique({ where: { code: 'delete_with_history' } }), null) + assert.equal(await prisma.notificationProjectRecipient.count({ where: { projectId: { not: '' } } }), 0) + + const delivery = await prisma.notificationDelivery.findUniqueOrThrow({ + where: { id: 'delivery-keep-after-project-delete' }, + }) + assert.equal(delivery.projectCode, 'delete_with_history') + assert.equal(delivery.businessTitle, '历史任务') + + const audit = await prisma.notificationAudit.findFirstOrThrow({ + where: { + action: 'project.delete', + targetProjectCode: 'delete_with_history', + }, + orderBy: { createdAt: 'desc' }, + }) + assert.equal(audit.summary, '删除项目 delete_with_history') + assert.deepEqual(JSON.parse(audit.detailsJson || '{}'), { + recipientCount: 1, + deliveryCount: 1, + }) + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY = oldEncryptionKey + process.env.YYD_NOTIFICATION_ADMIN_API_SECRET = oldAdminApiSecret + process.env.YYD_NOTIFICATION_ADMIN_TOKEN = oldAdminToken + process.env.YYD_NOTIFICATION_PROJECTS_JSON = oldProjectsJson + process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL = oldPublicBaseUrl + await prisma.$disconnect() + } +}) diff --git a/src/admin-project-registry.ts b/src/admin-project-registry.ts index a8aef1e..a93f8a1 100644 --- a/src/admin-project-registry.ts +++ b/src/admin-project-registry.ts @@ -810,11 +810,6 @@ export class AdminProjectRegistry { async deleteProject(projectCode: string, actor: AdminAuditActor) { const current = await this.loadExistingProjectForMutation(projectCode) const deliveryCount = await prisma.notificationDelivery.count({ where: { projectCode } }) - if (deliveryCount > 0) { - throw new AdminApiError(409, 'PROJECT_HAS_DELIVERIES', `项目 ${projectCode} 已存在投递记录,请先停用而不是删除`, { - deliveryCount, - }) - } await prisma.$transaction(async (tx) => { await tx.notificationProject.delete({ where: { code: projectCode } }) await tx.notificationAudit.create({ @@ -827,12 +822,15 @@ export class AdminProjectRegistry { requestId: actor.requestId || null, ipAddress: actor.ipAddress || null, summary: `删除项目 ${projectCode}`, - detailsJson: createAuditDetailsJson({ recipientCount: current.recipients.length }), + detailsJson: createAuditDetailsJson({ + recipientCount: current.recipients.length, + deliveryCount, + }), }, }) }) await this.refresh() - return { projectCode, deleted: true } + return { projectCode, deleted: true, deliveryCount } } async importFromEnvironment(actor: AdminAuditActor) {