From e47432bc9d72f6c470fb80effa379643ad0c1971 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Wed, 15 Jul 2026 11:21:02 +0800 Subject: [PATCH] fix: harden admin and CMPP delivery workflows --- .../migration.sql | 3 + .../migration.sql | 4 + api/prisma/schema.prisma | 2 + .../dictionaries/dictionaries.controller.ts | 10 + .../dictionaries/dictionaries.service.spec.ts | 25 +++ api/src/dictionaries/dictionaries.service.ts | 20 +- .../operations/admin-operations.controller.ts | 8 + api/src/operations/operations.service.spec.ts | 46 ++++- api/src/operations/operations.service.ts | 81 ++++++-- api/src/send-chain/send-chain.service.spec.ts | 137 +++++++++++++ api/src/send-chain/send-chain.service.ts | 192 ++++++++++++------ api/src/sms-config/sms-config.service.spec.ts | 22 +- api/src/sms-config/sms-config.service.ts | 60 ++---- .../first-version-development-requirements.md | 22 +- docs/system-functional-test-cases.md | 45 +++- docs/testing-progress.md | 32 +++ src/api/adminApi.ts | 9 +- src/apps/admin/AdminCustomersPage.tsx | 5 +- .../admin/AdminDownstreamDeliveriesPage.tsx | 29 ++- src/apps/admin/AdminDrainageFieldsPage.tsx | 26 ++- .../admin/AdminEnterpriseApplicationsPage.tsx | 5 +- .../admin/AdminEnterpriseSignaturesPage.tsx | 14 +- .../admin/AdminEnterpriseTemplatesPage.tsx | 14 +- src/apps/admin/AdminPhoneSegmentsPage.tsx | 24 ++- src/apps/admin/AdminRechargeRecordsPage.tsx | 8 +- .../admin/AdminSmsApplicationFormPage.tsx | 7 +- src/apps/admin/AdminSmsAuditPage.tsx | 19 +- src/apps/admin/AdminSmsRecordsPage.tsx | 76 +++---- src/apps/client/ClientSignaturesPage.tsx | 2 +- src/apps/client/ClientTemplatesPage.tsx | 15 +- src/components/ui/Table.tsx | 2 +- src/components/ui/Textarea.tsx | 7 +- src/layouts/AppShell.tsx | 4 +- src/styles/global.css | 129 +++++++++++- 34 files changed, 878 insertions(+), 226 deletions(-) create mode 100644 api/prisma/migrations/20260715090000_track_downstream_manual_retries/migration.sql create mode 100644 api/prisma/migrations/20260715153000_remove_disconnected_downstream_sessions/migration.sql diff --git a/api/prisma/migrations/20260715090000_track_downstream_manual_retries/migration.sql b/api/prisma/migrations/20260715090000_track_downstream_manual_retries/migration.sql new file mode 100644 index 0000000..b7ff1e0 --- /dev/null +++ b/api/prisma/migrations/20260715090000_track_downstream_manual_retries/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "CmppDownstreamDelivery" + ADD COLUMN "manualRetryCount" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "lastRetriedAt" TIMESTAMP(3); diff --git a/api/prisma/migrations/20260715153000_remove_disconnected_downstream_sessions/migration.sql b/api/prisma/migrations/20260715153000_remove_disconnected_downstream_sessions/migration.sql new file mode 100644 index 0000000..55dbf9d --- /dev/null +++ b/api/prisma/migrations/20260715153000_remove_disconnected_downstream_sessions/migration.sql @@ -0,0 +1,4 @@ +-- Downstream connection rows represent current live sessions only. +-- Remove historical disconnected/timeout rows left by the previous persistence model. +DELETE FROM "CmppDownstreamConnection" +WHERE "status" <> 'connected'; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index d38f61b..5e76424 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -1197,6 +1197,8 @@ model CmppDownstreamDelivery { payload Json retryEnabled Boolean @default(true) retryCount Int @default(0) + manualRetryCount Int @default(0) + lastRetriedAt DateTime? nextRetryAt DateTime? sentAt DateTime? acknowledgedAt DateTime? diff --git a/api/src/dictionaries/dictionaries.controller.ts b/api/src/dictionaries/dictionaries.controller.ts index eb7f0ba..3468c6e 100644 --- a/api/src/dictionaries/dictionaries.controller.ts +++ b/api/src/dictionaries/dictionaries.controller.ts @@ -29,6 +29,11 @@ export class DictionariesController { return this.dictionaries.createPhoneSegment(body); } + @Delete('phone-segments/:id') + deletePhoneSegment(@Param('id') id: string) { + return this.dictionaries.deletePhoneSegment(id); + } + @Get('phone-carrier-rules') listPhoneCarrierRules(@Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { return this.dictionaries.listPhoneCarrierRules({ keyword, page: Number(page) || undefined, pageSize: Number(pageSize) || undefined }); @@ -108,4 +113,9 @@ export class DictionariesController { createDrainageField(@Body() body: CreateDrainageFieldDto) { return this.dictionaries.createDrainageField(body); } + + @Delete('drainage-fields/:id') + deleteDrainageField(@Param('id') id: string) { + return this.dictionaries.deleteDrainageField(id); + } } diff --git a/api/src/dictionaries/dictionaries.service.spec.ts b/api/src/dictionaries/dictionaries.service.spec.ts index 7b36a49..aec3cb9 100644 --- a/api/src/dictionaries/dictionaries.service.spec.ts +++ b/api/src/dictionaries/dictionaries.service.spec.ts @@ -5,6 +5,7 @@ function createPrismaMock() { phoneSegment: { findMany: jest.fn(), count: jest.fn().mockResolvedValue(3), + delete: jest.fn().mockResolvedValue({ id: 'segment-1' }), }, phoneCarrierRule: { findMany: jest.fn().mockResolvedValue([]), @@ -26,7 +27,12 @@ function createPrismaMock() { update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })), }, drainageField: { + findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })), + delete: jest.fn().mockResolvedValue({ id: 'field-1' }), + }, + channelReportField: { + count: jest.fn().mockResolvedValue(0), }, smsApplication: { findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }), @@ -38,6 +44,25 @@ function createPrismaMock() { } describe('DictionariesService', () => { + it('deletes a phone segment from the real dictionary table', async () => { + const prisma = createPrismaMock(); + const service = new DictionariesService(prisma as never); + + await service.deletePhoneSegment('segment-1'); + + expect(prisma.phoneSegment.delete).toHaveBeenCalledWith({ where: { id: 'segment-1' } }); + }); + + it('returns drainage field usage counts and blocks deleting fields used by channels', async () => { + const prisma = createPrismaMock(); + prisma.drainageField.findMany.mockResolvedValue([{ id: 'field-1', code: 'license', _count: { channelReportFields: 2 } }]); + prisma.channelReportField.count.mockResolvedValue(2); + const service = new DictionariesService(prisma as never); + + await expect(service.listDrainageFields()).resolves.toEqual([{ id: 'field-1', code: 'license', usageCount: 2 }]); + await expect(service.deleteDrainageField('field-1')).rejects.toThrow('不能删除'); + expect(prisma.drainageField.delete).not.toHaveBeenCalled(); + }); it('paginates phone segments with a real database count', async () => { const prisma = createPrismaMock(); prisma.phoneSegment.findMany.mockResolvedValue([ diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index b49c258..507f26e 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -97,6 +97,10 @@ export class DictionariesService { return this.prisma.phoneSegment.create({ data }); } + deletePhoneSegment(id: string) { + return this.prisma.phoneSegment.delete({ where: { id } }); + } + async listPhoneCarrierRules(query: PageQuery = {}) { const page = Math.max(1, Number(query.page ?? 1)); const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25))); @@ -258,8 +262,12 @@ export class DictionariesService { return updated; } - listDrainageFields() { - return this.prisma.drainageField.findMany({ orderBy: { createdAt: 'desc' } }); + async listDrainageFields() { + const fields = await this.prisma.drainageField.findMany({ + include: { _count: { select: { channelReportFields: true } } }, + orderBy: { createdAt: 'desc' }, + }); + return fields.map(({ _count, ...field }) => ({ ...field, usageCount: _count.channelReportFields })); } createDrainageField(data: CreateDrainageFieldDto) { @@ -282,6 +290,14 @@ export class DictionariesService { }); } + async deleteDrainageField(id: string) { + const usageCount = await this.prisma.channelReportField.count({ where: { drainageFieldId: id } }); + if (usageCount > 0) { + throw new BadRequestException(`该字段已被 ${usageCount} 个通道使用,不能删除`); + } + return this.prisma.drainageField.delete({ where: { id } }); + } + private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record) { return this.prisma.operationLog.create({ data: { diff --git a/api/src/operations/admin-operations.controller.ts b/api/src/operations/admin-operations.controller.ts index d9a870f..d4d8bb7 100644 --- a/api/src/operations/admin-operations.controller.ts +++ b/api/src/operations/admin-operations.controller.ts @@ -154,6 +154,8 @@ export class AdminOperationsController { @Query('tenantId') tenantId?: string, @Query('applicationId') applicationId?: string, @Query('deliveryType') deliveryType?: string, + @Query('createdAtFrom') createdAtFrom?: string, + @Query('createdAtTo') createdAtTo?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('page') page?: string, @@ -163,6 +165,8 @@ export class AdminOperationsController { tenantId, applicationId, deliveryType, + createdAtFrom, + createdAtTo, status, keyword, page: Number(page), @@ -175,11 +179,15 @@ export class AdminOperationsController { @Query('tenantId') tenantId?: string, @Query('applicationId') applicationId?: string, @Query('deliveryType') deliveryType?: string, + @Query('createdAtFrom') createdAtFrom?: string, + @Query('createdAtTo') createdAtTo?: string, ) { return this.operations.downstreamDeliveryDashboard({ tenantId, applicationId, deliveryType, + createdAtFrom, + createdAtTo, }); } diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 0551cba..605fa5c 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -252,6 +252,7 @@ describe('OperationsService', () => { .mockResolvedValueOnce(2) .mockResolvedValueOnce(8) .mockResolvedValueOnce(1) + .mockResolvedValueOnce(1) .mockResolvedValueOnce(2); const service = new OperationsService(prisma as never); @@ -274,11 +275,25 @@ describe('OperationsService', () => { failed: 2, delivered: 8, stalledPending: 1, + stalledAck: 1, recentFailed: 2, - alertCount: 3, + alertCount: 4, }), }), ); + expect(prisma.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(4, { + where: { tenantId: 'tenant-1', status: 'pending', createdAt: { lte: expect.any(Date) } }, + }); + expect(prisma.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(5, { + where: { tenantId: 'tenant-1', status: 'awaiting_ack', ackDeadlineAt: { lte: expect.any(Date) } }, + }); + expect(prisma.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(6, { + where: { + tenantId: 'tenant-1', + status: { in: ['failed', 'unconfirmed', 'rejected'] }, + updatedAt: { gte: expect.any(Date) }, + }, + }); await service.statistics({ tenantId: 'tenant-1', groupBy: 'application' }); expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith({ @@ -399,6 +414,8 @@ describe('OperationsService', () => { deliveryType: 'receipt', status: 'failed', keyword: '1380', + createdAtFrom: '2026-07-01', + createdAtTo: '2026-07-15', page: 1, pageSize: 10, })).resolves.toEqual({ @@ -413,6 +430,10 @@ describe('OperationsService', () => { tenantId: 'tenant-1', deliveryType: 'receipt', status: 'failed', + createdAt: { + gte: new Date('2026-07-01T00:00:00.000+08:00'), + lte: new Date('2026-07-15T23:59:59.999+08:00'), + }, }), include: { tenant: true, application: true, messageRecord: true }, orderBy: { createdAt: 'desc' }, @@ -451,6 +472,10 @@ describe('OperationsService', () => { { applicationId: 'app-1', status: 'delivered', _count: { _all: 5 } }, { applicationId: 'app-2', status: 'pending', _count: { _all: 1 } }, { applicationId: 'app-2', status: 'delivered', _count: { _all: 3 } }, + ]) + .mockResolvedValueOnce([ + { applicationId: 'app-1', _count: { _all: 1 } }, + { applicationId: 'app-2', _count: { _all: 1 } }, ]); const service = new OperationsService(prisma as never); @@ -482,10 +507,27 @@ describe('OperationsService', () => { { label: '4次及以上', count: 0 }, ], topApplications: [ - { applicationId: 'app-1', name: '应用A', pending: 2, awaitingAck: 0, failed: 1, unconfirmed: 0, rejected: 0, delivered: 5, alertCount: 3 }, + { applicationId: 'app-1', name: '应用A', pending: 2, awaitingAck: 0, failed: 1, unconfirmed: 0, rejected: 0, delivered: 5, alertCount: 1 }, { applicationId: 'app-2', name: '应用B', pending: 1, awaitingAck: 0, failed: 0, unconfirmed: 0, rejected: 0, delivered: 3, alertCount: 1 }, ], }); + + expect(prisma.cmppDownstreamDelivery.groupBy).toHaveBeenNthCalledWith(3, { + by: ['applicationId'], + where: { + AND: [ + { tenantId: 'tenant-1', applicationId: 'app-1', deliveryType: undefined }, + { + OR: [ + { status: 'pending', createdAt: { lte: expect.any(Date) } }, + { status: 'awaiting_ack', ackDeadlineAt: { lte: expect.any(Date) } }, + { status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: expect.any(Date) } }, + ], + }, + ], + }, + _count: { _all: true }, + }); }); it('returns paginated downstream recovery statuses', async () => { diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 2fed3eb..ef404e5 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -49,12 +49,16 @@ export interface DownstreamDeliveryQuery { keyword?: string; page?: number; pageSize?: number; + createdAtFrom?: string; + createdAtTo?: string; } export interface DownstreamDeliveryDashboardQuery { tenantId?: string; applicationId?: string; deliveryType?: string; + createdAtFrom?: string; + createdAtTo?: string; } export interface DownstreamRecoveryStatusQuery { @@ -140,6 +144,7 @@ export class OperationsService { async dashboard(query: { tenantId?: string }) { const sinceToday = startOfToday(); + const downstreamAlertWindow = downstreamAlertWindows(); const messageWhereClause = messageWhere({ tenantId: query.tenantId }); const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday } }; const [ @@ -158,6 +163,7 @@ export class OperationsService { downstreamFailedCount, downstreamDeliveredCount, downstreamStalledPendingCount, + downstreamStalledAckCount, downstreamRecentFailedCount, ] = await Promise.all([ this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }), @@ -225,19 +231,26 @@ export class OperationsService { where: { tenantId: query.tenantId, status: 'pending', - createdAt: { lte: new Date(Date.now() - downstreamAlertPendingMinutes() * 60_000) }, + createdAt: { lte: downstreamAlertWindow.stalledPendingAt }, }, }), this.prisma.cmppDownstreamDelivery.count({ where: { tenantId: query.tenantId, - status: 'failed', - updatedAt: { gte: new Date(Date.now() - downstreamAlertRecentFailedHours() * 60 * 60_000) }, + status: 'awaiting_ack', + ackDeadlineAt: { lte: downstreamAlertWindow.now }, + }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { + tenantId: query.tenantId, + status: { in: ['failed', 'unconfirmed', 'rejected'] }, + updatedAt: { gte: downstreamAlertWindow.recentFailedAt }, }, }), ]); const todayTotals = summarizeMessageGroups(todayMessageGroups); - const downstreamAlertCount = downstreamStalledPendingCount + downstreamRecentFailedCount; + const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount; return { taskCount, messageStatus: messageGroups, @@ -261,6 +274,7 @@ export class OperationsService { failed: downstreamFailedCount, delivered: downstreamDeliveredCount, stalledPending: downstreamStalledPendingCount, + stalledAck: downstreamStalledAckCount, recentFailed: downstreamRecentFailedCount, alertCount: downstreamAlertCount, }, @@ -413,9 +427,8 @@ export class OperationsService { async downstreamDeliveryDashboard(query: DownstreamDeliveryDashboardQuery) { const scopedWhere = downstreamDeliveryScopedWhere(query); - const stalledPendingAt = new Date(Date.now() - downstreamAlertPendingMinutes() * 60_000); - const recentFailedAt = new Date(Date.now() - downstreamAlertRecentFailedHours() * 60 * 60_000); - const [total, pending, awaitingAck, delivered, failed, unconfirmed, rejected, stalledPending, stalledAck, recentFailed, typeGroups, applicationGroups, retryZero, retryLow, retryHigh] = await Promise.all([ + const downstreamAlertWindow = downstreamAlertWindows(); + const [total, pending, awaitingAck, delivered, failed, unconfirmed, rejected, stalledPending, stalledAck, recentFailed, typeGroups, applicationGroups, applicationAlertGroups, retryZero, retryLow, retryHigh] = await Promise.all([ this.prisma.cmppDownstreamDelivery.count({ where: scopedWhere }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'pending' } }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'awaiting_ack' } }), @@ -427,17 +440,17 @@ export class OperationsService { where: { ...scopedWhere, status: 'pending', - createdAt: { lte: stalledPendingAt }, + createdAt: { lte: downstreamAlertWindow.stalledPendingAt }, }, }), this.prisma.cmppDownstreamDelivery.count({ - where: { ...scopedWhere, status: 'awaiting_ack', ackDeadlineAt: { lte: new Date() } }, + where: { ...scopedWhere, status: 'awaiting_ack', ackDeadlineAt: { lte: downstreamAlertWindow.now } }, }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: { in: ['failed', 'unconfirmed', 'rejected'] }, - updatedAt: { gte: recentFailedAt }, + updatedAt: { gte: downstreamAlertWindow.recentFailedAt }, }, }), this.prisma.cmppDownstreamDelivery.groupBy({ @@ -450,6 +463,11 @@ export class OperationsService { where: scopedWhere, _count: { _all: true }, }), + this.prisma.cmppDownstreamDelivery.groupBy({ + by: ['applicationId'], + where: downstreamAlertWhere(scopedWhere, downstreamAlertWindow), + _count: { _all: true }, + }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, @@ -480,8 +498,11 @@ export class OperationsService { }) : []; const applicationMap = new Map(applications.map((item) => [item.id, item.name])); + const applicationAlertMap = new Map( + applicationAlertGroups.map((item) => [item.applicationId, item._count._all]), + ); const groupedByType = groupDownstreamByType(typeGroups); - const groupedByApplication = groupDownstreamByApplication(applicationGroups, applicationMap); + const groupedByApplication = groupDownstreamByApplication(applicationGroups, applicationMap, applicationAlertMap); return { summary: { @@ -844,14 +865,49 @@ function downstreamAlertRecentFailedHours() { return Number.isFinite(value) && value > 0 ? value : 1; } +function downstreamAlertWindows(now = new Date()) { + return { + now, + stalledPendingAt: new Date(now.getTime() - downstreamAlertPendingMinutes() * 60_000), + recentFailedAt: new Date(now.getTime() - downstreamAlertRecentFailedHours() * 60 * 60_000), + }; +} + +function downstreamAlertWhere( + scopedWhere: Prisma.CmppDownstreamDeliveryWhereInput, + window: ReturnType, +): Prisma.CmppDownstreamDeliveryWhereInput { + return { + AND: [ + scopedWhere, + { + OR: [ + { status: 'pending', createdAt: { lte: window.stalledPendingAt } }, + { status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } }, + { status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } }, + ], + }, + ], + }; +} + function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput { + const createdAtFrom = parseDateBoundary(query.createdAtFrom, false); + const createdAtTo = parseDateBoundary(query.createdAtTo, true); return { tenantId: query.tenantId, applicationId: query.applicationId, deliveryType: query.deliveryType && query.deliveryType !== 'all' ? query.deliveryType : undefined, + createdAt: createdAtFrom || createdAtTo ? { gte: createdAtFrom, lte: createdAtTo } : undefined, }; } +function parseDateBoundary(value?: string, endOfDay = false) { + if (!value) return undefined; + const parsed = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}+08:00`); + return Number.isNaN(parsed.getTime()) ? undefined : parsed; +} + function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) { return { tenantId: query.tenantId, @@ -943,6 +999,7 @@ function groupDownstreamByType( function groupDownstreamByApplication( groups: Array<{ applicationId: string; status: string; _count: { _all: number } }>, applicationMap: Map, + applicationAlertMap: Map, ) { const summaryMap = new Map(); groups.forEach((item) => { @@ -970,7 +1027,7 @@ function groupDownstreamByApplication( } else if (item.status === 'delivered') { current.delivered += item._count._all; } - current.alertCount = current.pending + current.failed + current.unconfirmed + current.rejected; + current.alertCount = applicationAlertMap.get(item.applicationId) ?? 0; summaryMap.set(item.applicationId, current); }); return [...summaryMap.values()]; diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 6b80299..c288c3a 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -94,6 +94,7 @@ function createPrismaMock() { auditStatus: 'approved', signature: { auditStatus: 'approved', reportStatus: 'reporting' }, }), + findMany: jest.fn().mockResolvedValue([]), }, smsSignature: { findFirst: jest.fn().mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'reporting' }), @@ -571,6 +572,90 @@ describe('SendChainService', () => { expect(riskReview.aggregateTemplateMismatch).not.toHaveBeenCalled(); }); + it('matches an inbound CMPP message against configured template variables and passes extracted values to risk review', async () => { + const { service, prisma, riskReview } = createService(); + service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); + prisma.smsTemplate.findFirst.mockResolvedValue(null); + prisma.smsTemplate.findMany.mockResolvedValue([{ + id: 'tpl-code', + tenantId: 'tenant-1', + applicationId: 'app-1', + content: '【航天信息信诺网】您本次操作的验证码是${code},有效时间10分钟。', + auditStatus: 'approved', + signature: { id: 'sig-1', name: '【航天信息信诺网】', auditStatus: 'approved', reportStatus: 'reporting' }, + }]); + + await expect(service.submitInboundMessage({ + account: '100001', + phoneNumber: '18821203795', + content: '【航天信息信诺网】您本次操作的验证码是715021,有效时间10分钟。', + remoteIp: '127.0.0.1', + })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); + + expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith({ + where: { applicationId: 'app-1', content: { contains: '${' } }, + include: { signature: true }, + orderBy: { updatedAt: 'desc' }, + }); + expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ templateId: 'tpl-code', status: 'validating' }), + }); + expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({ + templateId: 'tpl-code', + variables: { code: '715021' }, + })); + expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); + expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); + }); + + it('queues template-mismatched CMPP content when the application uses direct send', async () => { + const { service, prisma, riskReview } = createService(); + service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); + prisma.smsApplication.findFirst.mockResolvedValue({ + id: 'app-1', + tenantId: 'tenant-1', + cmppAccount: '100001', + status: 'active', + interfaceEnabled: true, + templateMismatchMode: 'direct_send', + customerUnitPrice: 3, + queuePriority: 'normal', + ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], + tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, + }); + prisma.smsTemplate.findFirst.mockResolvedValue(null); + prisma.smsTemplate.findMany.mockResolvedValue([]); + prisma.smsSignature.findFirst.mockResolvedValue({ + id: 'sig-1', + name: '【航天信息信诺网】', + auditStatus: 'approved', + reportStatus: 'reporting', + }); + + await expect(service.submitInboundMessage({ + account: '100001', + phoneNumber: '18821203795', + content: '【航天信息信诺网】未配置模板但允许直接发送', + remoteIp: '127.0.0.1', + })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); + + expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith({ + where: { applicationId: 'app-1', name: '【航天信息信诺网】', auditStatus: 'approved' }, + orderBy: { updatedAt: 'desc' }, + }); + expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({ + applicationId: 'app-1', + content: '【航天信息信诺网】未配置模板但允许直接发送', + })); + expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.not.objectContaining({ templateId: expect.any(String) })); + expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ + where: { id: 'record-1' }, + data: { status: 'queued', signatureId: 'sig-1' }, + }); + expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); + expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); + }); + it('aggregates template-mismatched CMPP messages only when the application uses manual review', async () => { const { service, prisma, riskReview } = createService(); prisma.smsApplication.findFirst.mockResolvedValue({ @@ -1602,6 +1687,29 @@ describe('SendChainService', () => { it('requeues downstream delivery through real gateway control path', async () => { const { service, prisma } = createService(); + prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({ + id: 'delivery-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + messageId: 'MSG-1', + deliveryType: 'receipt', + status: 'failed', + retryCount: 3, + manualRetryCount: 1, + lastError: 'downstream client is not connected', + payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' }, + application: { cmppAccount: '100001' }, + }); + prisma.cmppDownstreamDelivery.update.mockResolvedValueOnce({ + id: 'delivery-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + messageId: 'MSG-1', + deliveryType: 'receipt', + status: 'pending', + retryCount: 0, + manualRetryCount: 2, + }); service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true }); await service.requeueDownstreamDelivery('delivery-1'); @@ -1624,12 +1732,41 @@ describe('SendChainService', () => { expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'pending', + retryCount: 0, + manualRetryCount: { increment: 1 }, + lastRetriedAt: expect.any(Date), acknowledgedAt: null, ackResult: null, ackMessageId: null, deliveredAt: null, }), })); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'gateway.downstream_delivery_requeue', + detail: expect.objectContaining({ + previousStatus: 'failed', + previousRetryCount: 3, + manualRetryCount: 2, + lastRetriedAt: expect.any(Date), + }), + }), + }); + }); + + it('rejects manual requeue while downstream acknowledgement is pending', async () => { + const { service, prisma } = createService(); + service['postGatewayControl'] = jest.fn(); + prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({ + id: 'delivery-1', + status: 'awaiting_ack', + payload: { account: '100001' }, + application: { cmppAccount: '100001' }, + }); + + await expect(service.requeueDownstreamDelivery('delivery-1')).rejects.toThrow('该记录正在等待客户端确认,不允许并发重投'); + expect(prisma.cmppDownstreamDelivery.update).not.toHaveBeenCalled(); + expect(service['postGatewayControl']).not.toHaveBeenCalled(); }); it('supports batch requeue of downstream deliveries', async () => { diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 0253073..24525e1 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -1263,6 +1263,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (!delivery) { throw new NotFoundException('Downstream delivery not found'); } + if (delivery.status === 'awaiting_ack') { + throw new BadRequestException('该记录正在等待客户端确认,不允许并发重投'); + } const payload = isObjectRecord(delivery.payload) ? { ...delivery.payload } : null; if (!payload) { throw new BadRequestException('下游投递记录缺少可重放 payload'); @@ -1277,30 +1280,19 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { throw new BadRequestException(`Unsupported downstream delivery type ${delivery.deliveryType}`); } - await this.prisma.operationLog.create({ - data: { - tenantId: delivery.tenantId, - action: 'gateway.downstream_delivery_requeue', - resource: 'cmpp_downstream_delivery', - resourceId: delivery.id, - detail: { - deliveryType: delivery.deliveryType, - applicationId: delivery.applicationId, - messageId: delivery.messageId, - }, - }, - }); - const requestPayload = { deliveryId: delivery.id, account: String(payload.account ?? delivery.application?.cmppAccount ?? ''), ...payload, }; - await this.prisma.cmppDownstreamDelivery.update({ + const retriedAt = new Date(); + const requeued = await this.prisma.cmppDownstreamDelivery.update({ where: { id: delivery.id }, data: { status: 'pending', retryCount: 0, + manualRetryCount: { increment: 1 }, + lastRetriedAt: retriedAt, nextRetryAt: null, sentAt: null, acknowledgedAt: null, @@ -1313,6 +1305,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { lastError: null, }, }); + await this.prisma.operationLog.create({ + data: { + tenantId: delivery.tenantId, + action: 'gateway.downstream_delivery_requeue', + resource: 'cmpp_downstream_delivery', + resourceId: delivery.id, + detail: { + deliveryType: delivery.deliveryType, + applicationId: delivery.applicationId, + messageId: delivery.messageId, + previousStatus: delivery.status, + previousRetryCount: delivery.retryCount, + manualRetryCount: requeued.manualRetryCount, + lastRetriedAt: retriedAt, + }, + }, + }); try { const result = await this.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult; if (result.sent || result.delivered) { @@ -1649,6 +1658,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { throw new BadRequestException('CMPP submit phone number is invalid'); } const template = await this.resolveInboundTemplateCandidate(application.id, data.content); + const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {}; const unitPrice = application.customerUnitPrice ?? 0; const queuePriority = normalizeQueuePriority(application.queuePriority); const billing = this.billing.estimateSmsCost({ @@ -1707,6 +1717,57 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }); await this.recordCmppFailureReceipt(message, code, reason); }; + const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => { + const risk = await this.riskReview.evaluateTask({ + tenantId: application.tenantId, + applicationId: application.id, + templateId: options.templateId, + content: data.content, + variables: options.templateId ? templateVariables : undefined, + phones: [data.phoneNumber], + }); + if (risk.status === 'rejected') { + await reject('RISK', risk.reason || '短信被风控拒绝'); + return; + } + if (risk.status === 'pending_review') { + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { status: 'pending_review', signatureId: options.signatureId }, + }); + await this.prisma.smsBatchTask.update({ + where: { id: task.id }, + data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason }, + }); + return; + } + const accountCheck = await this.billing.checkAccount({ + tenantId: application.tenantId, + amountCents: billing.amountCents, + }); + if (!accountCheck.canSend) { + await reject('BALANCE', '企业账户余额不足'); + return; + } + if (billing.amountCents > 0) { + await this.billing.freeze({ + tenantId: application.tenantId, + amountCents: billing.amountCents, + relatedType: 'sms_batch_task', + relatedId: task.id, + remark: 'CMPP 入站短信冻结', + }); + } + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { status: 'queued', signatureId: options.signatureId }, + }); + await this.prisma.smsBatchTask.update({ + where: { id: task.id }, + data: { status: 'ready', riskTaskId: risk.task?.id, auditStatus: 'approved' }, + }); + await this.enqueueBatchTask(task.id); + }; if (application.status !== 'active' || application.tenant.status !== 'active') { await reject('ACCOUNT', '企业或短信应用已停用'); } else if (!application.interfaceEnabled) { @@ -1765,6 +1826,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } } } + } else if (!template && application.templateMismatchMode === 'direct_send') { + const signature = await this.resolveInboundSignatureCandidate(application.id, data.content); + if (!signature) { + await reject('SIGNATURE', '短信内容未识别到已审核通过的签名'); + } else { + await queueAfterRiskChecks({ signatureId: signature.id }); + } } else if (!template) { await reject('TEMPLATE', '短信内容未匹配到已报备模板'); } else if (template.auditStatus !== 'approved') { @@ -1772,46 +1840,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } else if (!template.signature || template.signature.auditStatus !== 'approved') { await reject('SIGNATURE', '短信签名尚未审核通过'); } else { - const risk = await this.riskReview.evaluateTask({ - tenantId: application.tenantId, - applicationId: application.id, - templateId: template.id, - content: data.content, - phones: [data.phoneNumber], - }); - if (risk.status === 'rejected') { - await reject('RISK', risk.reason || '短信被风控拒绝'); - } else if (risk.status === 'pending_review') { - await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { status: 'pending_review' } }); - await this.prisma.smsBatchTask.update({ - where: { id: task.id }, - data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason }, - }); - } else { - const accountCheck = await this.billing.checkAccount({ - tenantId: application.tenantId, - amountCents: billing.amountCents, - }); - if (!accountCheck.canSend) { - await reject('BALANCE', '企业账户余额不足'); - } else { - if (billing.amountCents > 0) { - await this.billing.freeze({ - tenantId: application.tenantId, - amountCents: billing.amountCents, - relatedType: 'sms_batch_task', - relatedId: task.id, - remark: 'CMPP 入站短信冻结', - }); - } - await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { status: 'queued' } }); - await this.prisma.smsBatchTask.update({ - where: { id: task.id }, - data: { status: 'ready', riskTaskId: risk.task?.id, auditStatus: 'approved' }, - }); - await this.enqueueBatchTask(task.id); - } - } + await queueAfterRiskChecks({ templateId: template.id, signatureId: template.signature.id }); } return { accepted: true, @@ -2160,8 +2189,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }); } - private resolveInboundTemplateCandidate(applicationId: string, content: string) { - return this.prisma.smsTemplate.findFirst({ + private async resolveInboundTemplateCandidate(applicationId: string, content: string) { + const exact = await this.prisma.smsTemplate.findFirst({ where: { applicationId, content, @@ -2169,6 +2198,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { include: { signature: true }, orderBy: { updatedAt: 'desc' }, }); + if (exact) return exact; + const variableTemplates = await this.prisma.smsTemplate.findMany({ + where: { + applicationId, + content: { contains: '${' }, + }, + include: { signature: true }, + orderBy: { updatedAt: 'desc' }, + }); + return variableTemplates.find((template) => matchTemplateContent(template.content, content) !== null) ?? null; } private resolveInboundSignatureCandidate(applicationId: string, content: string) { @@ -2902,6 +2941,45 @@ function normalizeRegion(region?: string | null) { return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim(); } +function matchTemplateContent(templateContent: string, actualContent: string) { + if (templateContent === actualContent) { + return {} as Record; + } + const tokenPattern = /\$\{([a-zA-Z0-9_]+)\}/g; + const names: string[] = []; + let cursor = 0; + let pattern = '^'; + for (const match of templateContent.matchAll(tokenPattern)) { + const index = match.index ?? 0; + pattern += escapeRegularExpression(templateContent.slice(cursor, index)); + pattern += '([\\s\\S]+?)'; + names.push(match[1]); + cursor = index + match[0].length; + } + if (names.length === 0) { + return null; + } + pattern += `${escapeRegularExpression(templateContent.slice(cursor))}$`; + const matched = new RegExp(pattern, 'u').exec(actualContent); + if (!matched) { + return null; + } + const variables: Record = {}; + for (let index = 0; index < names.length; index += 1) { + const name = names[index]; + const value = matched[index + 1]; + if (variables[name] !== undefined && variables[name] !== value) { + return null; + } + variables[name] = value; + } + return variables; +} + +function escapeRegularExpression(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function isNationalChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }) { const itemProvince = normalizeRegion(item.province); const sendRegion = normalizeRegion(item.channel.sendRegion); diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 5ab47ef..9219a59 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -130,7 +130,8 @@ function createPrismaMock() { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })), - updateMany: jest.fn().mockResolvedValue({ count: 0 }), + delete: jest.fn().mockResolvedValue({ id: 'downstream-1' }), + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), }, smsMessageRecord: { groupBy: jest.fn().mockResolvedValue([ @@ -264,7 +265,7 @@ describe('SmsConfigService', () => { tenantId: 'tenant-1', name: '优先应用', cmppAccount: '123456', - cmppEnterpriseCode: 'CUSTOM-EC', + cmppEnterpriseCode: '123456', secretHash: '1234567890abcdef', cmppMaxConnections: 3, cmppWindowSize: 32, @@ -343,6 +344,7 @@ describe('SmsConfigService', () => { where: { id: 'app-1' }, data: expect.objectContaining({ name: '新应用', + cmppEnterpriseCode: '100001', customerUnitPrice: 300, queuePriority: 'priority', ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] }, @@ -467,6 +469,22 @@ describe('SmsConfigService', () => { expect(prisma.operationLog.create).not.toHaveBeenCalled(); }); + it('removes a disconnected downstream session instead of retaining connection history', async () => { + const prisma = createPrismaMock(); + prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ id: 'downstream-1' }); + const service = new SmsConfigService(prisma as never); + + await expect(service.recordDownstreamConnectionEvent({ + account: '100001', + connectionId: 'gateway-1-1', + status: 'disconnected', + errorMessage: 'client closed', + })).resolves.toEqual(expect.objectContaining({ status: 'disconnected', deleted: true })); + + expect(prisma.cmppDownstreamConnection.delete).toHaveBeenCalledWith({ where: { id: 'downstream-1' } }); + expect(prisma.cmppDownstreamConnection.update).not.toHaveBeenCalled(); + }); + it('lists enterprise signatures with keyword filters and real relations', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index c0e0cba..1601717 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -185,7 +185,7 @@ export class SmsConfigService { const applicationIds = applications.map((application) => application.id); const [connections, messageStats] = await Promise.all([ this.prisma.cmppDownstreamConnection.findMany({ - where: { applicationId: { in: applicationIds } }, + where: { applicationId: { in: applicationIds }, status: 'connected' }, orderBy: { updatedAt: 'desc' }, }), this.prisma.smsMessageRecord.groupBy({ @@ -297,7 +297,7 @@ export class SmsConfigService { const queuePriority = normalizeApplicationQueuePriority(data.queuePriority); const interfaceType = normalizeApplicationInterfaceType(data.interfaceType); const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount(); - const cmppEnterpriseCode = await this.resolveCmppEnterpriseCode(data.cmppEnterpriseCode, data.tenantId); + const cmppEnterpriseCode = cmppAccount; return this.prisma.smsApplication.create({ data: { tenantId: data.tenantId, @@ -337,9 +337,7 @@ export class SmsConfigService { const cmppAccount = data.cmppAccount === undefined ? undefined : await this.validateAndReserveCmppAccount(data.cmppAccount, applicationId); - const cmppEnterpriseCode = data.cmppEnterpriseCode === undefined - ? undefined - : normalizeEnterpriseCode(data.cmppEnterpriseCode); + const cmppEnterpriseCode = cmppAccount ?? application.cmppAccount; const interfaceType = data.interfaceType === undefined ? undefined : normalizeApplicationInterfaceType(data.interfaceType); @@ -552,17 +550,6 @@ export class SmsConfigService { throw new BadRequestException('Unable to generate unique CMPP account'); } - private async resolveCmppEnterpriseCode(cmppEnterpriseCode: string | undefined, tenantId: string) { - if (cmppEnterpriseCode !== undefined) { - return normalizeEnterpriseCode(cmppEnterpriseCode); - } - const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { code: true } }); - if (!tenant) { - throw new BadRequestException('tenantId does not reference an existing tenant'); - } - return normalizeEnterpriseCode(tenant.code); - } - async recordDownstreamConnectionEvent(data: GatewayDownstreamConnectionEventDto) { const application = await this.prisma.smsApplication.findUnique({ where: { cmppAccount: data.account }, @@ -574,7 +561,20 @@ export class SmsConfigService { const observedAt = parseGatewayDate(data.observedAt) ?? new Date(); const connectedAt = parseGatewayDate(data.connectedAt) ?? observedAt; const existing = await this.prisma.cmppDownstreamConnection.findUnique({ where: { connectionId: data.connectionId } }); - const status = data.status === 'disconnected' ? 'disconnected' : 'connected'; + if (data.status === 'disconnected') { + if (existing) { + await this.prisma.cmppDownstreamConnection.delete({ where: { id: existing.id } }); + } + await this.writeOperationLog(application.tenantId, undefined, 'cmpp_downstream_connection.disconnected', 'cmpp_downstream_connection', data.connectionId, { + applicationId: application.id, + account: data.account, + remoteIp: data.remoteIp, + protocol: data.protocol, + status: 'disconnected', + errorMessage: data.errorMessage, + }); + return { connectionId: data.connectionId, status: 'disconnected', deleted: Boolean(existing) }; + } const payload = { tenantId: application.tenantId, applicationId: application.id, @@ -582,18 +582,18 @@ export class SmsConfigService { enterpriseCode: application.cmppEnterpriseCode, remoteIp: data.remoteIp, protocol: data.protocol, - status, + status: 'connected', connectedAt: existing?.connectedAt ?? connectedAt, lastHeartbeatAt: data.status === 'connected' || data.status === 'heartbeat' ? observedAt : existing?.lastHeartbeatAt, lastSubmitAt: data.status === 'submit' ? observedAt : existing?.lastSubmitAt, lastDeliverAt: data.status === 'deliver' ? observedAt : existing?.lastDeliverAt, - disconnectedAt: data.status === 'disconnected' ? observedAt : null, - lastError: data.status === 'disconnected' ? data.errorMessage ?? existing?.lastError ?? null : null, + disconnectedAt: null, + lastError: null, }; const connection = existing ? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload }) : await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } }); - if (data.status === 'connected' || data.status === 'disconnected') { + if (data.status === 'connected') { await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, { applicationId: application.id, account: data.account, @@ -608,13 +608,8 @@ export class SmsConfigService { async markTimedOutDownstreamConnections(now = new Date()) { const timeoutMs = getPositiveIntegerEnv('CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS', DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS); const cutoff = new Date(now.getTime() - timeoutMs); - return this.prisma.cmppDownstreamConnection.updateMany({ + return this.prisma.cmppDownstreamConnection.deleteMany({ where: { status: 'connected', lastHeartbeatAt: { lt: cutoff } }, - data: { - status: 'heartbeat_timeout', - disconnectedAt: now, - lastError: `CMPP heartbeat timeout after ${Math.round(timeoutMs / 1000)} seconds`, - }, }); } @@ -1326,17 +1321,6 @@ interface TemplateVariableInput { required?: boolean; } -function normalizeEnterpriseCode(value: string) { - const enterpriseCode = value.trim(); - if (!enterpriseCode) { - throw new BadRequestException('cmppEnterpriseCode is required'); - } - if (enterpriseCode.length > 32) { - throw new BadRequestException('cmppEnterpriseCode must be at most 32 characters'); - } - return enterpriseCode; -} - function normalizeApplicationPassword(value: string | undefined) { const password = value?.trim() || generateApplicationPassword(); if (password.length !== 16) { diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 7f35942..b0b41a7 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -6,6 +6,18 @@ 当前确认:第一版保留短信业务,排除彩信功能;账户按现金余额和授信额度计费,人工充值和充值记录进入第一版开发范围,套餐、短信余量、账单流水页面和公开交易查询 API 不进入第一版。彩信服务、彩信应用/签名/模板 Tab,以及运营端彩信相关菜单标记为“待开发”;业务性能指标为“平台可稳定入队并调度 500 条短信/秒,实际向通道 submit 受通道限速配置控制”。 +### 0.1 运营端细节要求(2026-07-15) + +- 企业签名的站点字段统一显示为“引流信息”,列表、表单和详情不展示引流信息提交时间。 +- 企业应用的企业代码必须始终等于 CMPP 6 位账号,由系统同步且不可单独编辑;账号留空时,创建应用时自动生成二者。每任务号码数超过应用上限时拒绝整个任务并提示拆分,不允许静默截断。 +- 手机号段支持真实删除;报备字段库展示被通道报备配置引用的通道数,引用数大于 0 时前后端均禁止删除,未引用字段才允许真实删除。 +- 企业应用连接详情只展示当前已连接会话;断开或心跳超时会话从活跃连接表删除,不保留为连接历史。 +- 短信审核批量通过必须基于明确勾选,只处理已选待审核任务;不得默认通过当前筛选结果或全部数据。 +- 下游投递记录支持创建日期范围筛选,并将日期条件下推 PostgreSQL 列表与 Dashboard 聚合。 +- 短信模板变量插入到文本框当前选区或光标位置,插入后光标移动到变量之后。 +- 运营端短信记录使用自适应信息卡片,发送详情按概览、短信内容、通道回执、状态和分片审计分组,失败原因使用独立警示区域突出;该项只调整前端展示,不改变短信记录后端语义。 +- 人工充值弹窗不要求填写操作人,操作身份从当前登录会话和后端操作日志取得。 + ## 1. 项目目标 建设一个短信平台第一版,支持企业客户在客户端完成短信应用、模板、签名、号码导入、短信发送、批量任务查询、发送明细查询、上行短信查询;支持运营端完成企业管理、企业应用/签名/模板管理、审核、通道配置、通道签名报备、报备任务导出/回执导入、发送监控、任务进度、短信记录、上行记录、安全控制和系统管理。 @@ -144,7 +156,8 @@ - 客户端批量任务列表、详情、短信明细和取消操作必须同时校验当前企业和 `sourceType=client`。 10. 所有来源的短信,包括平台批量任务、API 调用、CMPP 对接发送,全部按手机号维度进入短信记录。 11. 任务进度、发送详情和短信记录实时或准实时更新。 -12. 企业应用“不符合模板的短信”配置为 `manual_review` 时,合法的 CMPP Submit 在模板不匹配后进入人工审核;配置为 `reject` 时仍直接拒绝并返回 `REJECTD` Deliver Receipt,其他模式不得被人工审核聚合逻辑误接管。 +12. 企业应用“不符合模板的短信”配置为 `manual_review` 时,合法的 CMPP Submit 在模板不匹配后进入人工审核;配置为 `reject` 时直接拒绝并返回 `REJECTD` Deliver Receipt;配置为 `direct_send` 时必须识别并绑定已审核通过的完整括号签名,继续执行风控、余额、通道组路由、具体通道签名报备和 Gateway 真实提交,不得因模板未匹配落入 `reject`,也不得绕过其他发送校验。 + - CMPP 入站模板匹配必须支持模板正文中的 `${variable}` 占位符。固定文本需完整匹配,占位符至少匹配一个字符;同名占位符重复出现时取值必须一致。匹配成功后应绑定真实 `templateId`,并将提取的变量值传入风控,不能只用整段正文数据库精确相等判断。 13. CMPP 模板不匹配审核支持短窗口内容指纹聚合:只有同一企业应用、同一 CMPP 账号、规范化后内容 SHA-256 完全一致且位于同一时间窗口的短信才能合并为一个审核任务。默认窗口 10 秒,可通过 `CMPP_TEMPLATE_REVIEW_WINDOW_MS` 调整。 14. 聚合审核不合并短信记录、计费或回执:每个手机号仍有独立 `SmsMessageRecord/messageId/sequenceId`。审核通过后逐条进入真实路由和上游提交;审核驳回后逐条释放冻结并产生客户侧 `REJECTD` 回执。 15. 人工审核只覆盖模板不匹配;签名必须以完整中文中括号前缀 `【签名】` 识别,并使用包含中括号的完整名称匹配签名库。入站候选签名只要求 `auditStatus=approved`,不得以全局 `reportStatus` 提前拒绝;报备通过状态必须在后续路由和最终提交前按具体通道校验。签名不合法、风控直接拒绝或余额不足不得因内容聚合而绕过。 @@ -263,10 +276,11 @@ - 已实现“上游可能已受理但 submit resp 丢失”场景的保守补偿第一版:Gateway 在 receipt 事件中补充手机号;NestJS 对无法按 `messageId/gatewayMessageId` 精确命中的回执,只在“同通道、同手机号、72 小时窗口内、且仅存在 1 条 `timeout + gatewayMessageId=null` 的 submit 记录”时才回填并接收该回执,避免误绑到其他短信。 - 已实现 SubmitCommand 死信治理第一版:Go Gateway 对多次处理仍失败的 `SubmitCommand` 不再无限滞留在 PEL,而是按阈值写入 NestJS 真实 `GatewaySubmitDeadLetter` 表;运营端后端接口可分页查询死信,并支持人工将原始 `SubmitCommand` 重新写回 Redis Stream。 - 已实现客户侧下游投递重试第二版:客户系统负责断线后重连;平台在客户离线或投递失败时把 Deliver Receipt/上行 Deliver 保留在 `CmppDownstreamDelivery`,客户 bind 成功后立即拉取 pending,且 Gateway 会对当前在线账号周期补投;超过重试上限后转 `failed` 并写失败审计。 -- 已实现下游投递失败审计与人工重投第一版:运营端后端与页面可分页查看 `CmppDownstreamDelivery` 的 pending/failed/delivered 记录,支持按状态、类型、应用和关键字筛选,并可对单条记录执行人工重投,真实调用 Gateway `/downstream/receipt` 或 `/downstream/uplink`。 +- 已实现下游投递失败审计与人工重投第一版:运营端后端与页面可分页查看 `CmppDownstreamDelivery` 的 pending/awaiting_ack/failed/unconfirmed/rejected/delivered 记录,支持按状态、类型、应用和关键字筛选,并可对非 `awaiting_ack` 记录执行人工重投,真实调用 Gateway `/downstream/receipt` 或 `/downstream/uplink`。主记录必须分开保存自动重试次数 `retryCount`、人工重投次数 `manualRetryCount` 和最近人工重投时间 `lastRetriedAt`,操作日志保留重投前状态与自动重试次数。 - 已实现下游投递批量重投第一版:运营端可在当前页勾选多条 `pending/failed` 下游投递记录,调用真实批量接口逐条重投并返回成功/失败汇总,不允许用前端循环假装成功。 -- 已实现下游投递告警第一版:运营看板与右上角通知基于真实 `CmppDownstreamDelivery` 聚合显示下游投递告警数,当前告警口径包括“pending 超过阈值仍未投出”和“最近失败记录数”,用于提醒运营及时进入下游投递记录页处理。 -- 已实现下游投递 Dashboard 第一版:运营端“下游投递记录”页面顶部新增真实聚合总览,直接按 `tenantId/applicationId/deliveryType` 统计投递总量、pending/delivered/failed、积压告警、按类型分布、重试压力分布和应用告警排行,数据源必须来自 `CmppDownstreamDelivery`,不能靠前端本地汇总。 +- 下游投递的 `pending` 展示必须结合真实尝试字段:自动与人工次数均为 0 时显示“待首次投递”,`retryCount > 0` 时显示“等待自动重试”,`manualRetryCount > 0` 时显示“人工重投排队中”。人工重投可重置新一轮自动重试预算,但不得把记录伪装成从未投递。 +- 已实现下游投递告警统一口径:运营看板、侧栏通知、下游投递 Dashboard 和应用告警排行必须基于同一组真实 `CmppDownstreamDelivery` 条件统计:`pending` 超过积压阈值、`awaiting_ack` 超过 `ackDeadlineAt`,以及最近失败窗口内的 `failed/unconfirmed/rejected`。默认积压阈值为 10 分钟,最近失败窗口为 1 小时,可分别通过 `CMPP_DOWNSTREAM_ALERT_PENDING_MINUTES` 和 `CMPP_DOWNSTREAM_ALERT_RECENT_FAILED_HOURS` 覆盖。 +- 已实现下游投递 Dashboard 第一版:运营端“下游投递记录”页面顶部新增真实聚合总览,直接按 `tenantId/applicationId/deliveryType` 统计投递总量、pending/awaiting_ack/delivered/failed/unconfirmed/rejected、积压告警、ACK 超时告警、按类型分布、重试压力分布和应用告警排行,数据源必须来自 `CmppDownstreamDelivery`,不能靠前端本地汇总。应用告警排行只统计满足统一告警时间窗的记录,不得将新创建的 `pending` 或超出最近窗口的历史失败永久累加为告警。 - 已实现下游连接映射持久化第一步:Gateway 在客户 CMPP 账号 bind 成功、下游 submit 建链和回执/上行下发时,会把账号在线状态、实例标识、最近活跃时间写入 Redis presence;该状态不再只保留在 Gateway 进程内存中,为后续“Gateway 重启后的 pending 恢复”提供外部状态基础。 - 已实现下游连接映射持久化第二步:Gateway 启动时会读取 Redis presence 与当前内存在线账号,形成“恢复候选视图”,并通过控制面 `GET /downstream/recovery-candidates` 暴露候选账号列表,供后续恢复逻辑与运维排查使用;本阶段仍不等同于自动恢复 pending 投递。 - 已实现下游 pending 恢复执行第一版:Gateway 启动后会立即按恢复候选账号拉取真实 `CmppDownstreamDelivery.pending`,后续每轮补投周期也会继续扫描恢复候选;若账号已有可用下游连接则继续推送回执/上行,若账号尚未重连则保持 `pending` 等待后续恢复,不能因为 Gateway 重启就把未投递记录误标成失败。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index a3bd279..e0de801 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -23,6 +23,19 @@ | 号码 | 合法号码、重复号码、非法号码、企业黑名单号码、全局黑名单号码。 | | 账户 | 现金余额与授信额度组合后的和为正数、0、负数;授信额度覆盖正数、负数和 0;不配置套餐余量。 | | 企业认证 | 未认证、待审核、已通过、已驳回四类企业认证资料。 | + +## 2.1 2026-07-15 运营端细节回归 + +| 用例编号 | 优先级 | 验证内容 | 预期结果 | +| --- | --- | --- | --- | +| TC-ADMIN-UI-0715-01 | P1 | 新建/编辑企业应用并留空或修改 CMPP 账号 | 企业代码控件不可编辑且实时跟随账号;API 最终持久化二者相等;超过每任务号码上限时整个任务被拒绝并有拆分说明。 | +| TC-ADMIN-DICT-0715-02 | P1 | 删除手机号段;分别删除引用数为 0 和大于 0 的报备字段 | 号段从 PostgreSQL 删除;字段列表显示真实使用通道数,未引用字段删除成功,已引用字段按钮禁用且直接调用 DELETE 也返回 400。 | +| TC-ADMIN-CMPP-0715-03 | P1 | 建立连接后主动断开,再制造心跳超时 | 连接详情只显示 active 连接;断开/超时行从 `CmppDownstreamConnection` 删除,操作日志仍保留断开审计。 | +| TC-ADMIN-AUDIT-0715-04 | P0 | 不勾选、勾选部分任务分别点击批量操作 | 未勾选时按钮禁用;只通过已选任务,未选任务状态不变;确认弹窗数量等于选择数。 | +| TC-ADMIN-DOWNSTREAM-0715-05 | P1 | 选择下游投递创建日期范围 | 列表及页面 Dashboard 使用同一日期范围查询真实数据库,范围外记录不计入。 | +| TC-ADMIN-TEMPLATE-0715-06 | P1 | 将光标置于模板中间并插入推荐/自定义变量 | 变量在光标或选区处插入,原选区被替换,光标停在变量后;运营端和客户端一致。 | +| TC-ADMIN-RECORD-0715-07 | P1 | 查看桌面/窄屏短信记录及失败详情 | 卡片不产生页面横向滚动,信息分组清晰;失败原因独立突出;详情仍读取真实 submit、receipt 和分片审计 API。 | +| TC-ADMIN-MISC-0715-08 | P2 | 查看签名引流信息、零待审核通知和充值弹窗 | 使用“引流信息”标题且无提交时间;0 为黑字灰底;充值弹窗无操作人字段。 | | 客户 | 正常客户、停用客户、欠费客户、未认证客户、跨租户客户、客户联系人和开票资料。 | | 导入文件 | UTF-8 CSV、GBK CSV、TXT、超 20 MB 文件、含空行/重复/非法号码/非法字符文件。 | | 非法内容 | 控制字符、emoji、换行、不可见字符、超长变量、签名外置内容、敏感词内容。 | @@ -1167,6 +1180,21 @@ - 全局 `reportStatus=reporting` 不在入站阶段触发 `SIGNATURE` 拒绝,短信进入真实人工审核聚合链路且不产生签名失败回执。 - 审核通过后只允许选择签名任务为 approved 的主通道,不能选择 pending 的备用通道;最终提交前继续执行同一通道级校验。 +### TC-SEND-039B CMPP 变量模板匹配与 direct_send 策略 + +- 优先级:P0 +- 前置条件:应用 A 存在审核通过模板 `【航天信息信诺网】您本次操作的验证码是${code},有效时间10分钟。`;应用 B 配置 `templateMismatchMode=direct_send`。两个应用均配置已审核签名、余额、真实通道组及至少一个签名报备通过且在线的通道。 +- 步骤: + 1. 应用 A 通过 CMPP 提交 `【航天信息信诺网】您本次操作的验证码是715021,有效时间10分钟。`。 + 2. 查询 `SmsMessageRecord/SmsBatchTask/SmsSendTask`,并检查进入风险评估的模板和变量。 + 3. 应用 B 提交签名合法但没有任何模板匹配的短信。 + 4. 分别将应用 B 的签名改为未审核、账户改为余额不足、具体通道签名报备改为未通过后重复提交。 +- 预期结果: + - 步骤 1 按固定正文和 `${code}` 占位符匹配模板,保存真实 `templateId`,向风控传入 `code=715021`,不得产生 `TEMPLATE/REJECTD` 失败回执。 + - 应用 B 的模板不匹配短信按 `direct_send` 继续进入风控、余额、队列和真实通道路由;消息保存识别出的 `signatureId`,不能被模板拒绝分支截断。 + - `direct_send` 只跳过模板匹配要求,不跳过企业/应用状态、签名审核、风控、余额、具体通道报备、通道在线状态和 Gateway 提交校验;任一校验失败时按真实失败原因拒绝或失败。 + - `reject` 与 `manual_review` 的既有行为不变;CMPP SubmitResp、失败 Deliver Receipt 和最终上游回执仍按既有异步语义处理。 + ### TC-GW-007 CMPP 客户到上游 SMSC 完整闭环 - 优先级:P0 @@ -1298,7 +1326,9 @@ - 页面列表来自真实 `/api/admin/operations/downstream-deliveries`,不是前端静态数组或本地状态拼装。 - 详情展示真实 payload、`retryCount/nextRetryAt/deliveredAt/lastError`。 - 人工重投调用真实 `/api/admin/operations/downstream-deliveries/{id}/requeue`,由后端实际触发 Gateway `/downstream/receipt` 或 `/downstream/uplink`。 - - 重投后记录状态、失败原因和系统日志都与真实后端处理结果一致。 + - 人工重投后 `manualRetryCount` 递增、`lastRetriedAt` 更新,新一轮 `retryCount` 从 0 开始;系统日志保留重投前状态、原自动重试次数和新人工重投次数。 + - 重投后若尚未真正写出,列表显示“人工重投排队中”,不得误显示“待首次投递”;自动失败退避中的 pending 显示“等待自动重试”。 + - `awaiting_ack` 记录在前端不可选且后端拒绝并发重投,不能仅依赖按钮禁用。 ### TC-GW-015 下游投递指数退避 @@ -1326,17 +1356,18 @@ - 后端逐条执行真实重投,返回 `total/successCount/failedCount/results`。 - 成功和失败记录都会保留真实后端状态与错误信息;空选择时接口拒绝执行。 -### TC-GW-017 下游投递告警聚合 +### TC-GW-017 下游投递告警统一聚合 - 优先级:P1 -- 前置条件:真实 `CmppDownstreamDelivery` 中准备一批 `pending` 记录,其中部分已超过告警阈值;同时准备一批最近失败的 `failed` 记录。 +- 前置条件:真实 `CmppDownstreamDelivery` 中准备阈值内/外的 `pending`、未超时/已超过 `ackDeadlineAt` 的 `awaiting_ack`、最近窗口内/外的 `failed/unconfirmed/rejected` 及正常 `delivered` 记录,且覆盖多个应用。 - 步骤: 1. 访问运营端 Dashboard 和右上角通知区域。 2. 调用真实 `/api/admin/operations/dashboard/statistics`,核对返回的下游投递告警聚合。 3. 点击“下游投递告警”通知,跳转到下游投递记录页进一步筛查。 - 预期结果: - - Dashboard 返回真实 `downstreamDeliverySummary`,至少包含 `pending/failed/delivered/stalledPending/recentFailed/alertCount`。 - - 右上角通知中的“下游投递告警”数量与真实 Dashboard 聚合一致,不是前端写死值。 + - Dashboard 返回真实 `downstreamDeliverySummary`,至少包含 `pending/failed/delivered/stalledPending/stalledAck/recentFailed/alertCount`。 + - `alertCount` 精确等于“超阈值 pending + 超时 awaiting_ack + 最近窗口内 failed/unconfirmed/rejected”,阈值内 pending、未超时 awaiting_ack、历史失败和 delivered 不计入。 + - 侧栏/首页的“下游投递告警”数量与下游投递 Dashboard 在同一筛选范围下一致,不是前端写死值。 - 点击通知后可以进入真实下游投递记录页继续处理。 ### TC-GW-018 下游投递 Dashboard 聚合视图 @@ -1349,10 +1380,10 @@ 3. 切换应用和类型筛选,确认顶部 Dashboard 与下方记录列表同时切换到同一筛选范围。 - 预期结果: - 顶部 Dashboard 必须来自真实聚合接口,不能由当前页列表条目在前端临时汇总。 - - `summary` 中 `total/pending/delivered/failed/stalledPending/recentFailed/alertCount` 与数据库真实结果一致。 + - `summary` 中 `total/pending/awaitingAck/delivered/failed/unconfirmed/rejected/stalledPending/stalledAck/recentFailed/alertCount` 与数据库真实结果一致。 - `typeBreakdown` 能正确区分 `receipt` 和 `uplink` 的状态分布。 - `retryBuckets` 真实反映 `pending/failed` 记录的重试压力分布。 - - `topApplications` 以告警量优先排序,切换筛选后结果实时刷新。 + - `topApplications` 使用与 `summary.alertCount` 相同的时间窗和状态条件统计,各应用告警数之和与同范围总告警一致,并以告警量优先排序。 ### TC-GW-019 下游在线账号 Presence 持久化 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index ef05670..0d440ee 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1,5 +1,37 @@ # 第一版系统化测试进度 +## 2026-07-15 CMPP 变量模板与 direct_send 修复(未提交、未部署) + +- 生产只读诊断确认:10:52:58 账号 `910887` 向 `18821203795` 提交验证码短信,应用已于 10:52:36 保存 `templateMismatchMode=direct_send`,且存在审核通过的 `${code}` 变量模板;原实现用正文精确相等查询,实际验证码无法匹配占位符,同时模板为空时仅识别 `manual_review`,导致 `direct_send` 错误落入 `TEMPLATE/REJECTD`。 +- `resolveInboundTemplateCandidate` 先保留精确匹配,再对同应用变量模板执行固定正文全量匹配,提取非空变量值;同名变量重复出现必须取值一致。匹配成功后保存真实 `templateId`,并将变量值传给真实风控评估。 +- 新增 `direct_send` 分支:模板不匹配时识别并保存已审核完整括号签名,继续执行风控、余额冻结、真实队列、通道组路由和具体通道签名报备校验;只跳过模板要求,不做无条件放行。`reject/manual_review` 行为保持不变。 +- 新增变量模板验证码和 `direct_send` 两项回归;SendChainService 1 suite/49 项通过,API 全量 17 suites/169 项通过,Prisma validate、API build、前端 build 和 `git diff --check` 通过,前端仅有既有 Vite chunk size warning。按用户要求未提交、未推送、未部署,未重投 10:52:58 的短信。 + +## 2026-07-15 运营端细节修复批次(未提交、未部署) + +- 企业签名引流字段统一为“引流信息”并隐藏提交时间;人工充值弹窗移除操作人;待审核通知中的 0 使用黑字灰底。 +- 企业应用表单补充每任务号码上限的整任务拒绝说明;企业代码控件不可编辑并跟随 CMPP 6 位账号,NestJS 创建/更新也强制持久化两者相等。 +- 手机号段新增真实 DELETE;报备字段 API 返回真实通道引用数,未引用可删除,引用数大于 0 时前端禁用且后端返回 400。 +- 客户 CMPP 连接详情只展示已连接会话;Gateway 上报断开时删除活跃连接行,心跳超时扫描也删除,新增 migration 清理旧非 connected 行,断开操作日志仍保留。 +- 短信审核新增逐行/全选勾选,批量按钮只处理已选任务;下游投递列表与 Dashboard 增加统一创建日期范围参数并下推 Prisma/PostgreSQL。 +- 运营端和客户端短信模板变量均插入当前光标/选区位置;短信记录前端改为自适应卡片和分组详情,失败原因独立警示,不改变短信记录后端接口与业务语义。 +- 定向 API 测试 `dictionaries/sms-config/operations` 为 3 suites、49 项通过;API 全量 17 suites、167 项通过,Prisma validate、API build、前端 build、Gateway 全量 Go 测试和 `git diff --check` 均通过,前端仅有既有 chunk size warning。本地真实 PostgreSQL 已应用连接清理 migration,43 条 migration 全部齐全。 +- 应用内浏览器已验证本地构建的运营端登录路由标题为“聆界短信管理平台”、DOM 非空且无框架错误覆盖;受 HttpOnly 会话和图形验证码限制,未绕过认证进入受保护页面。后续浏览器连接因桌面插件版本热更新失败,未以独立 Playwright 或 mock 页面替代。按用户要求保持未提交、未推送、未部署。 + +## 2026-07-15 下游人工重投状态追踪修复(未提交、未部署) + +- 根因确认:人工重投会将 `CmppDownstreamDelivery` 重置为 `pending/retryCount=0`,前端又将所有 pending 固定翻译为“待首次投递”,导致已人工重投的记录被误展示为从未投递。 +- 新增 `CmppDownstreamDelivery.manualRetryCount/lastRetriedAt` 及真实 Prisma migration;人工重投时递增人工次数、保存时间,并在 `OperationLog` 中记录重投前状态、原自动重试次数和新人工次数。自动重试次数仍可为新一轮重置为 0,但不再丢失人工重投轨迹。 +- 列表和详情改为基于真实字段显示:初始 pending 为“待首次投递”,仅自动失败为“等待自动重试”,存在人工重投时为“人工重投排队中”;分开展示自动/人工次数和最近人工时间。 +- 后端新增 `awaiting_ack` 并发重投拦截,避免绕过前端禁用直接调 API 造成重复投递。本地 PostgreSQL 42 条 migration 全部齐全,Prisma validate/status 通过,SendChainService + OperationsService 定向回归 2 suites/62 项通过,前端 build 通过(仅既有 Vite chunk size warning)。API build 曾在本次改动完成后通过;随后工作区并发出现的非本任务修改在 `sms-config.service.ts:300` 引入未定义的 `application`,当前 API 全量回归被该编译错误阻断,未覆盖或回退该并发修改。本修复未提交、未推送、未部署。 + +## 2026-07-15 下游投递告警口径统一(未提交、未部署) + +- 修复前存在三套口径:侧栏/首页只统计超阈值 pending 和最近 failed;详情页额外统计超时 awaiting_ack 与最近 unconfirmed/rejected;应用排行则将全部 pending 和所有历史失败累加为告警,造成同一时刻数量不一致。 +- 统一为“超阈值 pending + 超过 `ackDeadlineAt` 的 awaiting_ack + 最近窗口内 failed/unconfirmed/rejected”;默认积压阈值 10 分钟、最近失败窗口 1 小时,继续支持环境变量覆盖。 +- `OperationsService.dashboard()` 和 `downstreamDeliveryDashboard()` 复用同一时间窗生成逻辑,首页/侧栏补齐 `stalledAck` 与三种最终异常状态;应用告警排行改为单独按统一告警 where 聚合,不再将普通 pending 和历史失败永久累加。 +- 已补实 OperationsService 定向单元测试,覆盖首页三类告警条件和应用排行统一条件。API 完整 17 suites、163 项通过,API build 和前端 build 通过;前端仅有既有 Vite chunk size warning。本批按要求保持未提交、未推送、未部署。 + ## 2026-07-14 生产发送 Worker 配置缺失修复 - 生产号码 `18821203795` 的最新短信于 18:24:59 审核通过后恢复为 queued,BullMQ 已生成 job,但一直停留在 `bull:sms.send.queue:prioritized`,无通道、`submitId` 和 `SmsSubmitRecord`。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index eb96ee9..ec3575a 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -252,6 +252,7 @@ export type DashboardResponse = { failed: number; delivered: number; stalledPending: number; + stalledAck: number; recentFailed: number; alertCount: number; }; @@ -801,6 +802,8 @@ export type DownstreamDeliveryRecord = { status: string; payload: Record; retryCount: number; + manualRetryCount: number; + lastRetriedAt?: string | null; retryEnabled: boolean; nextRetryAt?: string | null; sentAt?: string | null; @@ -1103,7 +1106,7 @@ export const adminApi = { request(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }), listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request>(withQuery('/admin/operations/monitor', query)), listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request>>(withQuery('/admin/operations/statistics', query)), - getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string } = {}) => + getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string } = {}) => request(withQuery('/admin/operations/downstream-deliveries/dashboard', query)), listDownstreamRecoveryStatuses: (query: { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; page?: number; pageSize?: number } = {}) => request(withQuery('/admin/operations/downstream-recovery-statuses', query)), @@ -1111,7 +1114,7 @@ export const adminApi = { request(`/admin/operations/downstream-recovery-statuses/${id}`), exportDownstreamRecoveryStatuses: (query: DownstreamRecoveryStatusExportQuery = {}) => requestBlob(withQuery('/admin/operations/downstream-recovery-statuses/export', query)), - listDownstreamDeliveries: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) => + listDownstreamDeliveries: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; status?: string; keyword?: string; page?: number; pageSize?: number; createdAtFrom?: string; createdAtTo?: string } = {}) => request>(withQuery('/admin/operations/downstream-deliveries', query)), requeueDownstreamDelivery: (id: string) => request(`/admin/operations/downstream-deliveries/${id}/requeue`, { method: 'POST', body: JSON.stringify({}) }), @@ -1138,6 +1141,7 @@ export const adminApi = { request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-segments', query)), createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) => request('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }), + deletePhoneSegment: (id: string) => request(`/admin/dictionaries/phone-segments/${id}`, { method: 'DELETE' }), listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-carrier-rules', query)), createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) => @@ -1145,6 +1149,7 @@ export const adminApi = { listDrainageFields: () => request('/admin/dictionaries/drainage-fields'), createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) => request('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }), + deleteDrainageField: (id: string) => request(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }), uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => { const form = new FormData(); form.set('file', file); diff --git a/src/apps/admin/AdminCustomersPage.tsx b/src/apps/admin/AdminCustomersPage.tsx index af27a1b..2e4ed56 100644 --- a/src/apps/admin/AdminCustomersPage.tsx +++ b/src/apps/admin/AdminCustomersPage.tsx @@ -13,7 +13,6 @@ type CustomerRow = TenantManagementRow; type RechargeForm = { amount: string; - operator: string; remark: string; }; @@ -28,7 +27,6 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan function emptyRechargeForm(): RechargeForm { return { amount: '', - operator: '运营', remark: '', }; } @@ -131,7 +129,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto await adminApi.createManualRecharge({ tenantId: rechargeTarget.id, amountCents: Math.round(amount * 100), - remark: [rechargeForm.operator, rechargeForm.remark].filter(Boolean).join(' / '), + remark: rechargeForm.remark, }); setRechargeTarget(null); setRechargeForm(emptyRechargeForm()); @@ -210,7 +208,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto updateRechargeForm('amount', event.target.value)} prefix="¥" required type="number" value={rechargeForm.amount} /> - updateRechargeForm('operator', event.target.value)} value={rechargeForm.operator} />