From 530a65de809a1d2dee7000642bc99c1e483c15f6 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Mon, 3 Aug 2026 15:34:41 +0800 Subject: [PATCH] feat: integrate analytics and fragment receipt improvements --- .../migration.sql | 34 +++ .../migration.sql | 23 ++ .../migration.sql | 12 + api/prisma/schema.prisma | 41 +++- .../certification/certification.controller.ts | 4 +- .../certification.service.spec.ts | 16 ++ .../certification/certification.service.ts | 4 +- api/src/channels/channel-test.service.ts | 3 + api/src/channels/channels.service.spec.ts | 3 + api/src/common/shanghai-date-range.spec.ts | 17 ++ api/src/common/shanghai-date-range.ts | 24 ++ .../dictionaries/dictionaries.controller.ts | 30 ++- api/src/dictionaries/dictionaries.service.ts | 113 ++++++++++ .../operations/admin-operations.controller.ts | 4 + api/src/operations/operations.contracts.ts | 1 + api/src/operations/operations.helpers.ts | 4 + api/src/operations/operations.service.spec.ts | 9 + .../operations/queries/messages.queries.ts | 4 +- api/src/operations/queries/quality.queries.ts | 111 +++++++++- .../admin-risk-review.controller.ts | 4 +- .../risk-review/risk-review.service.spec.ts | 17 ++ api/src/risk-review/risk-review.service.ts | 4 +- .../downstream-receipt-targets.spec.ts | 73 ++++++ .../send-chain/downstream-receipt-targets.ts | 131 +++++++++++ .../drainage-content-detection.spec.ts | 35 +++ .../send-chain/drainage-content-detection.ts | 208 ++++++++++++++++++ .../send-chain/send-batch-entry.service.ts | 12 +- api/src/send-chain/send-chain.contracts.ts | 1 + api/src/send-chain/send-chain.helpers.ts | 6 +- api/src/send-chain/send-chain.service.spec.ts | 159 +++++++++++-- api/src/send-chain/send-chain.service.ts | 10 +- api/src/send-chain/send-completion.service.ts | 11 +- .../send-downstream-delivery.service.ts | 37 ++-- .../send-chain/send-inbound-entry.service.ts | 45 ++-- api/src/send-chain/send-receipt.service.ts | 48 ++-- api/src/send-chain/send-submission.service.ts | 1 + api/src/send-chain/send-timeout.service.ts | 72 +++++- .../sms-config/admin-sms-config.controller.ts | 12 +- api/src/sms-config/drainage.service.ts | 2 + api/src/sms-config/signature.service.ts | 3 + api/src/sms-config/sms-config.contracts.ts | 6 + api/src/sms-config/sms-config.service.spec.ts | 18 ++ api/src/sms-config/template.service.ts | 3 + docs/contracts/admin-api-r1-methods.json | 36 ++- docs/contracts/channels-r5-methods.json | 4 +- docs/contracts/foundation-styles-r11.json | 3 + docs/contracts/operations-r2-methods.json | 12 +- docs/contracts/send-chain-r10-completion.json | 12 +- docs/contracts/send-chain-r8-pure-logic.json | 4 +- docs/contracts/send-chain-r9-submission.json | 12 +- docs/contracts/shared-components-r11.json | 31 +++ docs/contracts/sms-config-r3-methods.json | 28 +-- .../first-version-development-requirements.md | 37 +++- docs/system-functional-test-cases.md | 55 ++++- docs/testing-progress.md | 49 +++++ gateway/internal/inbound/delivery.go | 19 +- gateway/internal/inbound/server_test.go | 19 ++ gateway/internal/inbound/sessions.go | 7 +- gateway/internal/inbound/submit.go | 63 +++--- src/api/admin/governance.api.ts | 26 ++- src/api/admin/operations.api.ts | 4 +- src/api/types/governance.ts | 26 +++ src/api/types/operations.ts | 14 ++ src/apps/admin/AdminAnalyticsPage.tsx | 20 +- .../admin/AdminDownstreamDeliveriesPage.tsx | 4 +- .../AdminDownstreamRecoveryStatusesPage.tsx | 4 +- src/apps/admin/AdminDrainageAuditPage.tsx | 14 +- .../admin/AdminDrainageDetectionRulesPage.tsx | 71 ++++++ src/apps/admin/AdminEnterpriseAuditPage.tsx | 14 +- src/apps/admin/AdminHome.tsx | 12 +- src/apps/admin/AdminRiskRulesPage.tsx | 24 +- src/apps/admin/AdminSignatureAuditPage.tsx | 14 +- src/apps/admin/AdminSmsAuditPage.tsx | 32 +-- src/apps/admin/AdminSmsRecordsPage.tsx | 5 + src/apps/admin/AdminTemplateAuditPage.tsx | 18 +- src/apps/admin/AdminUsersPage.tsx | 10 +- src/apps/admin/ReportImportAuditPanel.tsx | 14 +- .../admin/sms-records/AdminSmsRecordsPage.css | 33 +++ .../admin/sms-records/SmsRecordFilter.tsx | 12 + src/apps/admin/sms-records/SmsRecordList.tsx | 25 ++- src/apps/admin/sms-records/smsRecordTypes.ts | 1 + src/apps/client/ClientHome.tsx | 7 +- src/components/ui/DateRangeInput.tsx | 5 +- src/layouts/AdminLayout.tsx | 2 + src/routes/AppRoutes.tsx | 2 + src/styles/components.css | 61 ++++- src/styles/global.css | 42 +++- src/styles/tokens.css | 3 + tools/quality/verify-refactor-r0.mjs | 3 +- 89 files changed, 1964 insertions(+), 324 deletions(-) create mode 100644 api/prisma/migrations/20260803113000_add_drainage_content_detection/migration.sql create mode 100644 api/prisma/migrations/20260803140000_fix_default_drainage_detection_rule_patterns/migration.sql create mode 100644 api/prisma/migrations/20260803190000_downstream_fragment_receipts/migration.sql create mode 100644 api/src/common/shanghai-date-range.spec.ts create mode 100644 api/src/common/shanghai-date-range.ts create mode 100644 api/src/send-chain/downstream-receipt-targets.spec.ts create mode 100644 api/src/send-chain/downstream-receipt-targets.ts create mode 100644 api/src/send-chain/drainage-content-detection.spec.ts create mode 100644 api/src/send-chain/drainage-content-detection.ts create mode 100644 src/apps/admin/AdminDrainageDetectionRulesPage.tsx diff --git a/api/prisma/migrations/20260803113000_add_drainage_content_detection/migration.sql b/api/prisma/migrations/20260803113000_add_drainage_content_detection/migration.sql new file mode 100644 index 0000000..048fc1e --- /dev/null +++ b/api/prisma/migrations/20260803113000_add_drainage_content_detection/migration.sql @@ -0,0 +1,34 @@ +CREATE TABLE "DrainageDetectionRule" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "category" TEXT NOT NULL, + "pattern" TEXT NOT NULL, + "flags" TEXT NOT NULL DEFAULT 'giu', + "priority" INTEGER NOT NULL DEFAULT 100, + "status" TEXT NOT NULL DEFAULT 'active', + "description" TEXT, + "version" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "DrainageDetectionRule_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "DrainageDetectionRule_code_key" ON "DrainageDetectionRule"("code"); +CREATE INDEX "DrainageDetectionRule_status_priority_idx" ON "DrainageDetectionRule"("status", "priority"); + +ALTER TABLE "SmsMessageRecord" + ADD COLUMN "hasDrainageContent" BOOLEAN, + ADD COLUMN "drainageDetection" JSONB, + ADD COLUMN "drainageDetectionVersion" TEXT, + ADD COLUMN "drainageEvaluatedAt" TIMESTAMP(3); + +CREATE INDEX "SmsMessageRecord_hasDrainageContent_queuedAt_idx" + ON "SmsMessageRecord"("hasDrainageContent", "queuedAt"); + +INSERT INTO "DrainageDetectionRule" + ("id", "code", "name", "category", "pattern", "flags", "priority", "status", "description", "version", "updatedAt") +VALUES + ('drainage-rule-url', 'URL', 'URL及裸域名', 'url', $regex$(?:https?:\/\/)?(?:www\.)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:\/[^\s,,;;!!??<>《》]*)?$regex$, 'giu', 10, 'active', '识别协议链接、裸域名、短链接及IP地址链接;邮箱区间由检测器排除', 1, CURRENT_TIMESTAMP), + ('drainage-rule-mobile', 'MOBILE', '手机号码', 'mobile', $regex$(?:^|[^0-9])((?:\+?86)?1[3-9][0-9]{9})(?:$|[^0-9])$regex$, 'giu', 20, 'active', '规范化后识别+86、空格、短横线及中文标点拆分手机号', 1, CURRENT_TIMESTAMP), + ('drainage-rule-landline', 'LANDLINE', '固定电话号码', 'landline', $regex$(?:^|[^0-9])((?:\+?86)?(?:\(0[0-9]{2,3}\)|0[0-9]{2,3})-?[0-9]{7,8}(?:(?:转|分机|ext)[0-9]{1,6})?)(?:$|[^0-9])$regex$, 'giu', 30, 'active', '识别区号括号、分隔符和分机号', 1, CURRENT_TIMESTAMP); diff --git a/api/prisma/migrations/20260803140000_fix_default_drainage_detection_rule_patterns/migration.sql b/api/prisma/migrations/20260803140000_fix_default_drainage_detection_rule_patterns/migration.sql new file mode 100644 index 0000000..3da2cc0 --- /dev/null +++ b/api/prisma/migrations/20260803140000_fix_default_drainage_detection_rule_patterns/migration.sql @@ -0,0 +1,23 @@ +-- PostgreSQL standard-conforming strings preserve backslashes literally. The initial +-- seed used JavaScript-style escaping, so already-migrated databases need their three +-- built-in patterns normalized to the single backslashes expected by RegExp. +UPDATE "DrainageDetectionRule" +SET + "pattern" = $regex$(?:https?:\/\/)?(?:www\.)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:\/[^\s,,;;!!??<>《》]*)?$regex$, + "version" = "version" + 1, + "updatedAt" = CURRENT_TIMESTAMP +WHERE "code" = 'URL'; + +UPDATE "DrainageDetectionRule" +SET + "pattern" = $regex$(?:^|[^0-9])((?:\+?86)?1[3-9][0-9]{9})(?:$|[^0-9])$regex$, + "version" = "version" + 1, + "updatedAt" = CURRENT_TIMESTAMP +WHERE "code" = 'MOBILE'; + +UPDATE "DrainageDetectionRule" +SET + "pattern" = $regex$(?:^|[^0-9])((?:\+?86)?(?:\(0[0-9]{2,3}\)|0[0-9]{2,3})-?[0-9]{7,8}(?:(?:转|分机|ext)[0-9]{1,6})?)(?:$|[^0-9])$regex$, + "version" = "version" + 1, + "updatedAt" = CURRENT_TIMESTAMP +WHERE "code" = 'LANDLINE'; diff --git a/api/prisma/migrations/20260803190000_downstream_fragment_receipts/migration.sql b/api/prisma/migrations/20260803190000_downstream_fragment_receipts/migration.sql new file mode 100644 index 0000000..6f53b33 --- /dev/null +++ b/api/prisma/migrations/20260803190000_downstream_fragment_receipts/migration.sql @@ -0,0 +1,12 @@ +ALTER TABLE "SmsMessageRecord" +ADD COLUMN "cmppRegisteredDelivery" BOOLEAN, +ADD COLUMN "timeoutReceiptQueuedAt" TIMESTAMP(3); + +ALTER TABLE "CmppInboundLongMessageSegment" +ADD COLUMN "registeredDelivery" BOOLEAN NOT NULL DEFAULT true; + +-- Historical CMPP submissions were accepted before Registered_Delivery was +-- persisted. Preserve their existing receipt-enabled behavior. +UPDATE "SmsMessageRecord" +SET "cmppRegisteredDelivery" = true +WHERE "cmppSubmitSequenceId" IS NOT NULL; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index fac5998..1a760c9 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -305,6 +305,23 @@ model DrainageField { commonReportFields CommonReportField[] } +model DrainageDetectionRule { + id String @id @default(cuid()) + code String @unique + name String + category String + pattern String + flags String @default("giu") + priority Int @default(100) + status String @default("active") + description String? + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, priority]) +} + model TenantAccount { id String @id @default(cuid()) tenantId String @@ -1502,6 +1519,10 @@ model SmsMessageRecord { carrier String? province String? content String + hasDrainageContent Boolean? + drainageDetection Json? + drainageDetectionVersion String? + drainageEvaluatedAt DateTime? billingUnits Int @default(1) unitPrice BigInt @default(0) amountCents BigInt @default(0) @@ -1511,6 +1532,7 @@ model SmsMessageRecord { gatewayMessageId String? cmppSubmitSequenceId String? cmppSubmitGroupMessageId String? + cmppRegisteredDelivery Boolean? clientSrcId String? applicationExtension String? status String @default("queued") @@ -1523,6 +1545,7 @@ model SmsMessageRecord { submittedAt DateTime? deliveredAt DateTime? timeoutAt DateTime? + timeoutReceiptQueuedAt DateTime? updatedAt DateTime @updatedAt tenant Tenant? @relation(fields: [tenantId], references: [id]) @@ -1548,6 +1571,7 @@ model SmsMessageRecord { @@index([phoneNumber]) @@index([gatewayMessageId]) @@index([drainageInfoId, queuedAt]) + @@index([hasDrainageContent, queuedAt]) } model CmppSubmitSession { @@ -1754,14 +1778,15 @@ model CmppInboundLongMessage { } model CmppInboundLongMessageSegment { - id String @id @default(cuid()) - groupId String - segmentIndex Int - sequenceId String? - content String - contentHash String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + groupId String + segmentIndex Int + sequenceId String? + registeredDelivery Boolean @default(true) + content String + contentHash String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt group CmppInboundLongMessage @relation(fields: [groupId], references: [id], onDelete: Cascade) diff --git a/api/src/certification/certification.controller.ts b/api/src/certification/certification.controller.ts index fa9c676..f643f9e 100644 --- a/api/src/certification/certification.controller.ts +++ b/api/src/certification/certification.controller.ts @@ -26,8 +26,8 @@ export class AdminCertificationController { constructor(private readonly certifications: CertificationService) {} @Get() - list(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) { - return this.certifications.list(tenantId, status, keyword); + list(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string) { + return this.certifications.list(tenantId, status, keyword, submittedAtFrom, submittedAtTo); } @Get(':id') diff --git a/api/src/certification/certification.service.spec.ts b/api/src/certification/certification.service.spec.ts index d5c41b9..c6bd1c7 100644 --- a/api/src/certification/certification.service.spec.ts +++ b/api/src/certification/certification.service.spec.ts @@ -38,6 +38,22 @@ describe('CertificationService', () => { ]); }); + it('filters enterprise certification submissions by Shanghai date range', async () => { + const prisma = createPrismaMock(); + const service = new CertificationService(prisma as never); + + await service.list(undefined, 'pending', undefined, '2026-08-01', '2026-08-03'); + + expect(prisma.enterpriseCertification.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + submittedAt: { + gte: new Date('2026-08-01T00:00:00+08:00'), + lte: new Date('2026-08-03T23:59:59.999+08:00'), + }, + }), + })); + }); + it('submits certification and marks tenant pending', async () => { const prisma = createPrismaMock(); const service = new CertificationService(prisma as never); diff --git a/api/src/certification/certification.service.ts b/api/src/certification/certification.service.ts index 5570cd9..b03302d 100644 --- a/api/src/certification/certification.service.ts +++ b/api/src/certification/certification.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { shanghaiDateRange } from '../common/shanghai-date-range'; export interface SubmitCertificationDto { tenantId: string; @@ -20,11 +21,12 @@ export interface ReviewCertificationDto { export class CertificationService { constructor(private readonly prisma: PrismaService) {} - async list(tenantId?: string, status?: string, keyword?: string) { + async list(tenantId?: string, status?: string, keyword?: string, submittedAtFrom?: string, submittedAtTo?: string) { const records = await this.prisma.enterpriseCertification.findMany({ where: { tenantId, status: status && status !== 'all' ? status : undefined, + submittedAt: shanghaiDateRange(submittedAtFrom, submittedAtTo), OR: keyword ? [ { companyName: { contains: keyword } }, { licenseNo: { contains: keyword } }, diff --git a/api/src/channels/channel-test.service.ts b/api/src/channels/channel-test.service.ts index e22ec10..2a0d899 100644 --- a/api/src/channels/channel-test.service.ts +++ b/api/src/channels/channel-test.service.ts @@ -8,6 +8,7 @@ import { PrismaService } from '../prisma/prisma.service'; import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; import { ChannelConnectionService } from './channel-connection.service'; +import { detectDrainageContent } from '../send-chain/drainage-content-detection'; /** R5 channel domain service composed behind ChannelsService. */ export class ChannelTestService { @@ -35,6 +36,7 @@ export class ChannelTestService { const createdAt = new Date(); const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`; + const drainageDetection = await detectDrainageContent(this.prisma, content); const results = []; for (const [index, phoneNumber] of phoneNumbers.entries()) { const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`; @@ -49,6 +51,7 @@ export class ChannelTestService { messageId, phoneNumber, content, + ...drainageDetection, billingUnits: calculateBillingUnits(content), unitPrice: 0, amountCents: 0, diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 623ab8d..f983644 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -104,6 +104,9 @@ function createPrismaMock() { findUnique: jest.fn().mockResolvedValue({ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }), findMany: jest.fn().mockResolvedValue([{ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }]), }, + drainageDetectionRule: { + findMany: jest.fn().mockResolvedValue([]), + }, signatureReportMaterial: { findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]), createMany: jest.fn(), diff --git a/api/src/common/shanghai-date-range.spec.ts b/api/src/common/shanghai-date-range.spec.ts new file mode 100644 index 0000000..4e0c7cf --- /dev/null +++ b/api/src/common/shanghai-date-range.spec.ts @@ -0,0 +1,17 @@ +import { BadRequestException } from '@nestjs/common'; +import { shanghaiDateRange } from './shanghai-date-range'; + +describe('shanghaiDateRange', () => { + it('builds an inclusive Asia/Shanghai day range', () => { + expect(shanghaiDateRange('2026-08-01', '2026-08-03')).toEqual({ + gte: new Date('2026-08-01T00:00:00+08:00'), + lte: new Date('2026-08-03T23:59:59.999+08:00'), + }); + }); + + it('rejects malformed or reversed ranges', () => { + expect(() => shanghaiDateRange('2026/08/01', undefined)).toThrow(BadRequestException); + expect(() => shanghaiDateRange('2026-02-31', undefined)).toThrow('日期无效'); + expect(() => shanghaiDateRange('2026-08-03', '2026-08-01')).toThrow('开始日期不能晚于结束日期'); + }); +}); diff --git a/api/src/common/shanghai-date-range.ts b/api/src/common/shanghai-date-range.ts new file mode 100644 index 0000000..d45f7b4 --- /dev/null +++ b/api/src/common/shanghai-date-range.ts @@ -0,0 +1,24 @@ +import { BadRequestException } from '@nestjs/common'; + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function parseBoundary(value: string | undefined, endOfDay: boolean) { + if (!value) return undefined; + if (!DATE_PATTERN.test(value)) throw new BadRequestException('日期格式必须为 YYYY-MM-DD'); + const [year, month, day] = value.split('-').map(Number); + const calendarDate = new Date(Date.UTC(year, month - 1, day)); + if (calendarDate.getUTCFullYear() !== year || calendarDate.getUTCMonth() !== month - 1 || calendarDate.getUTCDate() !== day) { + throw new BadRequestException('日期无效'); + } + const parsed = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00'}+08:00`); + if (Number.isNaN(parsed.getTime())) throw new BadRequestException('日期无效'); + return parsed; +} + +/** Converts UI calendar dates to an inclusive Asia/Shanghai database range. */ +export function shanghaiDateRange(from?: string, to?: string) { + const gte = parseBoundary(from, false); + const lte = parseBoundary(to, true); + if (gte && lte && gte > lte) throw new BadRequestException('开始日期不能晚于结束日期'); + return gte || lte ? { gte, lte } : undefined; +} diff --git a/api/src/dictionaries/dictionaries.controller.ts b/api/src/dictionaries/dictionaries.controller.ts index e3dbc86..55473bb 100644 --- a/api/src/dictionaries/dictionaries.controller.ts +++ b/api/src/dictionaries/dictionaries.controller.ts @@ -1,9 +1,12 @@ -import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { CreateCommonReportFieldDto, CreateBlacklistDto, CreateDrainageFieldDto, + UpsertDrainageDetectionRuleDto, + TestDrainageDetectionDto, CreatePhoneCarrierRuleDto, CreatePhoneSegmentDto, CreateSensitiveWordDto, @@ -125,6 +128,31 @@ export class DictionariesController { return this.dictionaries.deleteDrainageField(id); } + @Get('drainage-detection-rules') + listDrainageDetectionRules(@Query('keyword') keyword?: string, @Query('status') status?: string) { + return this.dictionaries.listDrainageDetectionRules({ keyword, status }); + } + + @Post('drainage-detection-rules/test') + testDrainageDetection(@Body() body: TestDrainageDetectionDto) { + return this.dictionaries.testDrainageDetection(body); + } + + @Post('drainage-detection-rules') + createDrainageDetectionRule(@Body() body: UpsertDrainageDetectionRuleDto, @CurrentSessionUserId() operatorId?: string) { + return this.dictionaries.createDrainageDetectionRule({ ...body, operatorId }); + } + + @Put('drainage-detection-rules/:id') + updateDrainageDetectionRule(@Param('id') id: string, @Body() body: UpsertDrainageDetectionRuleDto, @CurrentSessionUserId() operatorId?: string) { + return this.dictionaries.updateDrainageDetectionRule(id, { ...body, operatorId }); + } + + @Post('drainage-detection-rules/:id/status') + changeDrainageDetectionRuleStatus(@Param('id') id: string, @Body() body: DictionaryStatusDto, @CurrentSessionUserId() operatorId?: string) { + return this.dictionaries.changeDrainageDetectionRuleStatus(id, { ...body, operatorId }); + } + @Get('common-report-fields') listCommonReportFields() { return this.dictionaries.listCommonReportFields(); diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index 54574db..e101d0b 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -2,6 +2,11 @@ import { BadRequestException, ConflictException, Injectable, Optional } from '@n import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { PhoneRoutingLookupService } from './phone-routing-lookup.service'; +import { + detectDrainageContentWithRules, + invalidateDrainageDetectionRuleCache, + validateDrainageDetectionPattern, +} from '../send-chain/drainage-content-detection'; export interface CreatePhoneSegmentDto { prefix: string; @@ -58,6 +63,23 @@ export interface CreateDrainageFieldDto { description?: string; } +export interface UpsertDrainageDetectionRuleDto { + code: string; + name: string; + category: 'url' | 'mobile' | 'landline'; + pattern: string; + flags?: string; + priority?: number; + status?: string; + description?: string; + operatorId?: string; +} + +export interface TestDrainageDetectionDto { + content: string; + rule?: UpsertDrainageDetectionRuleDto; +} + export interface CreateCommonReportFieldDto { drainageFieldId: string; reportType: 'signature' | 'drainage'; @@ -374,6 +396,97 @@ export class DictionariesService { }); } + listDrainageDetectionRules(query: { keyword?: string; status?: string } = {}) { + const keyword = query.keyword?.trim(); + return this.prisma.drainageDetectionRule.findMany({ + where: { + status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + OR: keyword ? [ + { code: { contains: keyword, mode: 'insensitive' } }, + { name: { contains: keyword, mode: 'insensitive' } }, + { description: { contains: keyword, mode: 'insensitive' } }, + ] : undefined, + }, + orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }], + }); + } + + async createDrainageDetectionRule(data: UpsertDrainageDetectionRuleDto) { + this.validateDrainageDetectionRule(data); + const created = await this.prisma.drainageDetectionRule.create({ + data: { + code: data.code.trim().toUpperCase(), + name: data.name.trim(), + category: data.category, + pattern: data.pattern, + flags: data.flags ?? 'giu', + priority: data.priority ?? 100, + status: data.status ?? 'active', + description: data.description?.trim() || null, + }, + }); + invalidateDrainageDetectionRuleCache(); + await this.writeOperationLog(data.operatorId, 'drainage_detection_rule.create', 'drainage_detection_rule', created.id, { code: created.code }); + return created; + } + + async updateDrainageDetectionRule(id: string, data: UpsertDrainageDetectionRuleDto) { + this.validateDrainageDetectionRule(data); + const updated = await this.prisma.drainageDetectionRule.update({ + where: { id }, + data: { + code: data.code.trim().toUpperCase(), + name: data.name.trim(), + category: data.category, + pattern: data.pattern, + flags: data.flags ?? 'giu', + priority: data.priority ?? 100, + status: data.status ?? 'active', + description: data.description?.trim() || null, + version: { increment: 1 }, + }, + }); + invalidateDrainageDetectionRuleCache(); + await this.writeOperationLog(data.operatorId, 'drainage_detection_rule.update', 'drainage_detection_rule', id, { code: updated.code, version: updated.version }); + return updated; + } + + async changeDrainageDetectionRuleStatus(id: string, data: DictionaryStatusDto) { + const status = data.status === 'inactive' ? 'inactive' : 'active'; + const updated = await this.prisma.drainageDetectionRule.update({ + where: { id }, + data: { status, version: { increment: 1 } }, + }); + invalidateDrainageDetectionRuleCache(); + await this.writeOperationLog(data.operatorId, `drainage_detection_rule.${status}`, 'drainage_detection_rule', id, { reason: data.reason }); + return updated; + } + + async testDrainageDetection(data: TestDrainageDetectionDto) { + if (!data.content?.trim()) throw new BadRequestException('测试短信内容不能为空'); + const rules = data.rule + ? [{ + id: 'preview', + code: data.rule.code?.trim().toUpperCase() || 'PREVIEW', + name: data.rule.name?.trim() || '预览规则', + category: data.rule.category, + pattern: data.rule.pattern, + flags: data.rule.flags ?? 'giu', + priority: data.rule.priority ?? 100, + version: 1, + }] + : await this.prisma.drainageDetectionRule.findMany({ where: { status: 'active' }, orderBy: { priority: 'asc' } }); + if (data.rule) this.validateDrainageDetectionRule(data.rule); + return detectDrainageContentWithRules(data.content, rules); + } + + private validateDrainageDetectionRule(data: UpsertDrainageDetectionRuleDto) { + if (!data.code?.trim() || !data.name?.trim()) throw new BadRequestException('规则编码和名称不能为空'); + if (!['url', 'mobile', 'landline'].includes(data.category)) throw new BadRequestException('规则类型仅支持 URL、手机号或固话'); + if (data.status && !['active', 'inactive'].includes(data.status)) throw new BadRequestException('规则状态不正确'); + validateDrainageDetectionPattern(data.pattern, data.flags ?? 'giu'); + } + listCommonReportFields() { return this.prisma.commonReportField.findMany({ include: { drainageField: true }, diff --git a/api/src/operations/admin-operations.controller.ts b/api/src/operations/admin-operations.controller.ts index e659c39..0eee339 100644 --- a/api/src/operations/admin-operations.controller.ts +++ b/api/src/operations/admin-operations.controller.ts @@ -43,6 +43,7 @@ export class AdminOperationsController { @Query('queuedAtFrom') queuedAtFrom?: string, @Query('queuedAtTo') queuedAtTo?: string, @Query('status') status?: string, + @Query('hasDrainage') hasDrainage?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { @@ -59,6 +60,7 @@ export class AdminOperationsController { queuedAtFrom, queuedAtTo, status, + hasDrainage, page: Number(page), pageSize: Number(pageSize), }); @@ -76,6 +78,7 @@ export class AdminOperationsController { @Query('queuedAtFrom') queuedAtFrom: string | undefined, @Query('queuedAtTo') queuedAtTo: string | undefined, @Query('status') status: string | undefined, + @Query('hasDrainage') hasDrainage: string | undefined, @Res() response: DownloadResponse, ) { const exported = await this.operations.exportMessages({ @@ -89,6 +92,7 @@ export class AdminOperationsController { queuedAtFrom, queuedAtTo, status, + hasDrainage, }); response.setHeader('Content-Type', 'text/csv; charset=utf-8'); response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`); diff --git a/api/src/operations/operations.contracts.ts b/api/src/operations/operations.contracts.ts index d0d2bc8..250e290 100644 --- a/api/src/operations/operations.contracts.ts +++ b/api/src/operations/operations.contracts.ts @@ -11,6 +11,7 @@ export interface MessageQuery { contentKeyword?: string; carrier?: string; status?: string; + hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; diff --git a/api/src/operations/operations.helpers.ts b/api/src/operations/operations.helpers.ts index 01d6897..df37a45 100644 --- a/api/src/operations/operations.helpers.ts +++ b/api/src/operations/operations.helpers.ts @@ -21,6 +21,10 @@ export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereI phoneNumber: query.phoneNumber, ...carrierWhere(query.carrier), ...statusWhere, + ...(query.hasDrainage === 'true' ? { hasDrainageContent: true } + : query.hasDrainage === 'false' ? { hasDrainageContent: false } + : query.hasDrainage === 'unknown' ? { hasDrainageContent: null } + : {}), ...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}), ...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}), ...(query.queuedAtFrom || query.queuedAtTo ? { diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index b200c91..48423ba 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -210,6 +210,7 @@ describe('OperationsService', () => { queuedAtFrom: '2026-07-01', queuedAtTo: '2026-07-02', status: 'delivered', + hasDrainage: 'true', }); expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({ @@ -222,6 +223,7 @@ describe('OperationsService', () => { phoneNumber: '13800000001', carrier: { in: ['mobile', 'cmcc', '移动', '中国移动'] }, status: 'delivered', + hasDrainageContent: true, content: { contains: '验证码', mode: 'insensitive' }, channel: { name: { contains: '移动通道', mode: 'insensitive' } }, queuedAt: { @@ -582,6 +584,7 @@ describe('OperationsService', () => { await expect(service.sendQuality('2026-07-24')).resolves.toEqual({ date: '2026-07-24', + drainageSignatures: [], summary: { total: 5, successCount: 3, @@ -639,6 +642,7 @@ describe('OperationsService', () => { channelId: 'channel-1', channelName: '通道一', carrier: 'mobile', + drainageState: 'with', total: 4, acceptedCount: 4, submitFailureCount: 0, @@ -653,6 +657,7 @@ describe('OperationsService', () => { channelId: 'channel-2', channelName: '通道二', carrier: 'telecom', + drainageState: 'without', total: 2, acceptedCount: 1, submitFailureCount: 1, @@ -713,6 +718,10 @@ describe('OperationsService', () => { expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', total: 4 }), expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', total: 2 }), ], + drainageBreakdowns: [ + expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', drainageState: 'with', total: 4 }), + expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', drainageState: 'without', total: 2 }), + ], })], total: 12, page: 2, diff --git a/api/src/operations/queries/messages.queries.ts b/api/src/operations/queries/messages.queries.ts index 26eb8fe..a60bda4 100644 --- a/api/src/operations/queries/messages.queries.ts +++ b/api/src/operations/queries/messages.queries.ts @@ -107,6 +107,7 @@ async exportMessages(query: MessageQuery) { submitStatus: true, deliveredAt: true, content: true, + hasDrainageContent: true, tenant: { select: { name: true } }, application: { select: { name: true } }, channel: { select: { name: true } }, @@ -114,7 +115,7 @@ async exportMessages(query: MessageQuery) { orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], }); const rows = [ - ['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '回执时间', '短信内容'], + ['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '是否含引流', '回执时间', '短信内容'], ...items.map((item) => [ item.messageId, item.tenant?.name ?? '', @@ -127,6 +128,7 @@ async exportMessages(query: MessageQuery) { String(moneyToNumber(item.amountCents)), item.channel?.name ?? '', item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '') ? 'submit_failed' : item.status, + item.hasDrainageContent === true ? '是' : item.hasDrainageContent === false ? '否' : '未检测', item.deliveredAt?.toISOString() ?? '', item.content, ]), diff --git a/api/src/operations/queries/quality.queries.ts b/api/src/operations/queries/quality.queries.ts index 8ae6eaf..4352543 100644 --- a/api/src/operations/queries/quality.queries.ts +++ b/api/src/operations/queries/quality.queries.ts @@ -37,7 +37,7 @@ async statistics(query: { tenantId?: string; groupBy?: string }) { } async sendQuality(date?: string) { const day = qualityBusinessDay(date); - const [channels, signatures, summaryRows, applications] = await Promise.all([ + const [channels, signatureSplits, summaryRows, applications] = await Promise.all([ this.prisma.$queryRaw item.hasDrainage); const summary = summaryRows[0] ?? { total: 0, successCount: 0, @@ -310,7 +312,7 @@ async sendQuality(date?: string) { failureCount: 0, successRate: 0, }; - return { date: day.key, summary, channels, signatures, applications }; + return { date: day.key, summary, channels, signatures, drainageSignatures, applications }; } async signatureQuality(query: SignatureQualityQuery) { const day = qualityBusinessDay(query.date); @@ -413,13 +415,14 @@ async signatureQuality(query: SignatureQualityQuery) { OFFSET ${(page - 1) * pageSize} `); const signatureIds = summaries.map((item) => item.signatureId); - const breakdowns = signatureIds.length === 0 + const drainageBreakdowns = signatureIds.length === 0 ? [] : await this.prisma.$queryRaw { - const signatureBreakdowns = breakdowns.filter((item) => item.signatureId === summary.signatureId); + const signatureDrainageBreakdowns = drainageBreakdowns.filter((item) => item.signatureId === summary.signatureId); + const signatureBreakdowns = aggregateChannelCarrierRows(signatureDrainageBreakdowns); return { ...summary, channelSubmitTotal: signatureBreakdowns.reduce((sum, item) => sum + item.total, 0), carrierOverview: carrierOverview.filter((item) => item.signatureId === summary.signatureId), breakdowns: signatureBreakdowns, + drainageBreakdowns: signatureDrainageBreakdowns, }; }); return { @@ -578,3 +589,89 @@ async signatureQuality(query: SignatureQualityQuery) { }; } } + +type SignatureSplitRow = { + id: string; + signatureId: string; + signatureName: string; + tenantId: string; + tenantName: string; + hasDrainage: boolean; + total: number; + acceptedCount: number; + submitFailureCount: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; + averageArrivalMs: number | null; +}; + +function aggregateSignatureRows(rows: SignatureSplitRow[]) { + const grouped = new Map(); + rows.forEach((row) => grouped.set(row.signatureId, [...(grouped.get(row.signatureId) ?? []), row])); + return [...grouped.values()].map((parts) => { + const first = parts[0]; + const total = parts.reduce((sum, item) => sum + item.total, 0); + const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0); + const successCount = parts.reduce((sum, item) => sum + item.successCount, 0); + const arrivalWeight = parts.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0); + return { + ...first, + id: first.signatureId, + hasDrainage: false, + total, + acceptedCount, + submitFailureCount: parts.reduce((sum, item) => sum + item.submitFailureCount, 0), + successCount, + unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0), + failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0), + successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10, + averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight), + }; + }).sort((left, right) => right.successCount - left.successCount || right.total - left.total || left.signatureName.localeCompare(right.signatureName)); +} + +type DrainageBreakdownRow = { + signatureId: string; + channelId: string; + channelName: string; + carrier: string; + drainageState: 'with' | 'without' | 'unknown'; + total: number; + acceptedCount: number; + submitFailureCount: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; + averageArrivalMs: number | null; +}; + +function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) { + const grouped = new Map(); + rows.forEach((row) => { + const key = `${row.signatureId}\u0000${row.channelId}\u0000${row.carrier}`; + grouped.set(key, [...(grouped.get(key) ?? []), row]); + }); + return [...grouped.values()].map((parts) => { + const first = parts[0]; + const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0); + const successCount = parts.reduce((sum, item) => sum + item.successCount, 0); + const arrivalWeight = parts.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0); + return { + signatureId: first.signatureId, + channelId: first.channelId, + channelName: first.channelName, + carrier: first.carrier, + total: parts.reduce((sum, item) => sum + item.total, 0), + acceptedCount, + submitFailureCount: parts.reduce((sum, item) => sum + item.submitFailureCount, 0), + successCount, + unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0), + failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0), + successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10, + averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight), + }; + }); +} diff --git a/api/src/risk-review/admin-risk-review.controller.ts b/api/src/risk-review/admin-risk-review.controller.ts index 8852ab5..282b0c9 100644 --- a/api/src/risk-review/admin-risk-review.controller.ts +++ b/api/src/risk-review/admin-risk-review.controller.ts @@ -123,8 +123,8 @@ export class AdminRiskReviewController { } @Get('tasks') - listTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string) { - return this.riskReview.listTasks(tenantId, status); + listTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string) { + return this.riskReview.listTasks(tenantId, status, submittedAtFrom, submittedAtTo); } @Get('tasks/pending') diff --git a/api/src/risk-review/risk-review.service.spec.ts b/api/src/risk-review/risk-review.service.spec.ts index 7a0d82a..45b6579 100644 --- a/api/src/risk-review/risk-review.service.spec.ts +++ b/api/src/risk-review/risk-review.service.spec.ts @@ -88,6 +88,23 @@ describe('RiskReviewService', () => { })); }); + it('filters SMS review tasks by their submission time', async () => { + const prisma = createPrismaMock(); + prisma.smsSendTask.findMany.mockResolvedValue([]); + const service = new RiskReviewService(prisma as never); + + await service.listTasks(undefined, 'pending_review', '2026-08-01', '2026-08-03'); + + expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + createdAt: { + gte: new Date('2026-08-01T00:00:00+08:00'), + lte: new Date('2026-08-03T23:59:59.999+08:00'), + }, + }), + })); + }); + it('groups identical CMPP template mismatches into a deterministic short review window', async () => { const prisma = createPrismaMock(); prisma.smsSendTask.findUnique.mockResolvedValue({ diff --git a/api/src/risk-review/risk-review.service.ts b/api/src/risk-review/risk-review.service.ts index 35fdc15..abf28bf 100644 --- a/api/src/risk-review/risk-review.service.ts +++ b/api/src/risk-review/risk-review.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { Prisma } from '@prisma/client'; import { createHash, randomUUID } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; +import { shanghaiDateRange } from '../common/shanghai-date-range'; export interface CreateRiskRuleDto { tenantId?: string; @@ -199,11 +200,12 @@ export class RiskReviewService { }); } - listTasks(tenantId?: string, status?: string) { + listTasks(tenantId?: string, status?: string, submittedAtFrom?: string, submittedAtTo?: string) { return this.prisma.smsSendTask.findMany({ where: { tenantId, status, + createdAt: shanghaiDateRange(submittedAtFrom, submittedAtTo), ...(status === 'approved' || status === 'rejected' ? { reviewedById: { not: null } } : {}), ...(!status ? { OR: [ diff --git a/api/src/send-chain/downstream-receipt-targets.spec.ts b/api/src/send-chain/downstream-receipt-targets.spec.ts new file mode 100644 index 0000000..58ec052 --- /dev/null +++ b/api/src/send-chain/downstream-receipt-targets.spec.ts @@ -0,0 +1,73 @@ +import { queueFinalReceiptDeliveries } from './downstream-receipt-targets'; + +describe('queueFinalReceiptDeliveries', () => { + it('queues one HTTP event and one CMPP receipt for each registered client fragment', async () => { + const prisma = { + cmppInboundLongMessage: { + findFirst: jest.fn().mockResolvedValue({ + messageId: 'MSG-GROUP', + segmentTotal: 3, + segments: [ + { segmentIndex: 1, sequenceId: '101', registeredDelivery: true }, + { segmentIndex: 2, sequenceId: '102', registeredDelivery: false }, + { segmentIndex: 3, sequenceId: '103', registeredDelivery: true }, + ], + }), + }, + }; + const queue = jest.fn().mockResolvedValue({ id: 'queued' }); + + await queueFinalReceiptDeliveries(prisma as never, queue, { + message: { + id: 'record-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + messageId: 'MSG-1', + phoneNumber: '13800000001', + cmppSubmitGroupMessageId: 'MSG-GROUP', + }, + payload: { receiptStatus: 'delivered', rawStatus: 'DELIVRD' }, + segmentPayloads: { + 1: { receiptStatus: 'delivered', rawStatus: 'DELIVRD' }, + 3: { receiptStatus: 'undelivered', rawStatus: 'REJECTD' }, + }, + }); + + expect(queue).toHaveBeenCalledTimes(3); + expect(queue).toHaveBeenNthCalledWith(1, expect.objectContaining({ + queueHttpWebhook: true, + queueCmppDelivery: false, + })); + expect(queue).toHaveBeenNthCalledWith(2, expect.objectContaining({ + receiptDedupeKey: 'receipt:record-1:segment:1', + queueHttpWebhook: false, + payload: expect.objectContaining({ submitSequenceId: 101, clientSegmentIndex: 1 }), + })); + expect(queue).toHaveBeenNthCalledWith(3, expect.objectContaining({ + receiptDedupeKey: 'receipt:record-1:segment:3', + payload: expect.objectContaining({ submitSequenceId: 103, clientSegmentIndex: 3, receiptStatus: 'undelivered', rawStatus: 'REJECTD' }), + })); + }); + + it('queues only the message-level HTTP event when the submission did not originate from CMPP', async () => { + const prisma = { cmppInboundLongMessage: { findFirst: jest.fn() } }; + const queue = jest.fn().mockResolvedValue({ id: 'queued' }); + + await queueFinalReceiptDeliveries(prisma as never, queue, { + message: { + id: 'record-http', + tenantId: 'tenant-1', + applicationId: 'app-1', + messageId: 'MSG-HTTP', + phoneNumber: '13800000001', + }, + payload: { receiptStatus: 'undelivered', rawStatus: 'EXPIRED' }, + }); + + expect(queue).toHaveBeenCalledTimes(1); + expect(queue).toHaveBeenCalledWith(expect.objectContaining({ + queueHttpWebhook: true, + queueCmppDelivery: false, + })); + }); +}); diff --git a/api/src/send-chain/downstream-receipt-targets.ts b/api/src/send-chain/downstream-receipt-targets.ts new file mode 100644 index 0000000..d00acde --- /dev/null +++ b/api/src/send-chain/downstream-receipt-targets.ts @@ -0,0 +1,131 @@ +import type { PrismaService } from '../prisma/prisma.service'; + +export type FinalReceiptMessage = { + id: string; + tenantId?: string | null; + applicationId?: string | null; + messageId: string; + phoneNumber: string; + cmppSubmitSequenceId?: string | null; + cmppSubmitGroupMessageId?: string | null; + cmppRegisteredDelivery?: boolean | null; +}; + +export type DownstreamDeliveryQueueRequest = { + tenantId: string; + applicationId?: string | null; + messageRecordId?: string | null; + messageId?: string | null; + deliveryType: 'receipt' | 'uplink'; + payload: Record; + receiptDedupeKey?: string; + queueHttpWebhook?: boolean; + queueCmppDelivery?: boolean; + propagateHttpQueueError?: boolean; +}; + +type ClientReceiptTarget = { + segmentIndex: number; + segmentTotal: number; + submitSequenceId: number; + submitGroupMessageId: string; + registeredDelivery: boolean; +}; + +async function resolveClientReceiptTargets( + prisma: PrismaService, + message: FinalReceiptMessage, +): Promise { + if (message.cmppSubmitGroupMessageId) { + const group = await prisma.cmppInboundLongMessage.findFirst({ + where: { messageId: message.cmppSubmitGroupMessageId }, + include: { segments: { orderBy: { segmentIndex: 'asc' } } }, + }); + if (group?.segments.length) { + return group.segments.flatMap((segment) => { + const submitSequenceId = Number(segment.sequenceId); + if (!Number.isInteger(submitSequenceId) || submitSequenceId <= 0) return []; + return [{ + segmentIndex: segment.segmentIndex, + segmentTotal: group.segmentTotal, + submitSequenceId, + submitGroupMessageId: group.messageId, + registeredDelivery: segment.registeredDelivery, + }]; + }); + } + } + + const submitSequenceId = Number(message.cmppSubmitSequenceId); + if (!Number.isInteger(submitSequenceId) || submitSequenceId <= 0) return []; + return [{ + segmentIndex: 1, + segmentTotal: 1, + submitSequenceId, + submitGroupMessageId: message.cmppSubmitGroupMessageId ?? message.messageId, + // Null means a historical CMPP record created before this field existed. + registeredDelivery: message.cmppRegisteredDelivery !== false, + }]; +} + +/** + * Queue one business-level HTTP callback and one CMPP status report for every + * original client fragment that requested Registered_Delivery. Internal retry, + * refund and billing remain message-level; only protocol delivery is expanded. + */ +export async function queueFinalReceiptDeliveries( + prisma: PrismaService, + queue: (request: DownstreamDeliveryQueueRequest) => Promise, + data: { + message: FinalReceiptMessage; + payload: Record; + segmentPayloads?: Record>; + propagateHttpQueueError?: boolean; + }, +) { + const { message } = data; + if (!message.tenantId || !message.applicationId) { + return { queued: false, cmppTargetCount: 0 }; + } + + // HTTP submissions have one client message identity, so their webhook stays + // message-level even when the carrier internally split the SMS into segments. + await queue({ + tenantId: message.tenantId, + applicationId: message.applicationId, + messageRecordId: message.id, + messageId: message.messageId, + deliveryType: 'receipt', + payload: data.payload, + queueHttpWebhook: true, + queueCmppDelivery: false, + propagateHttpQueueError: data.propagateHttpQueueError, + }); + + const targets = (await resolveClientReceiptTargets(prisma, message)) + .filter((target) => target.registeredDelivery); + for (const target of targets) { + const isSingleFragment = target.segmentTotal === 1; + await queue({ + tenantId: message.tenantId, + applicationId: message.applicationId, + messageRecordId: message.id, + messageId: message.messageId, + deliveryType: 'receipt', + payload: { + ...data.payload, + ...data.segmentPayloads?.[target.segmentIndex], + submitSequenceId: target.submitSequenceId, + submitGroupMessageId: target.submitGroupMessageId, + clientSegmentIndex: target.segmentIndex, + clientSegmentTotal: target.segmentTotal, + }, + receiptDedupeKey: isSingleFragment + ? `receipt:${message.id}` + : `receipt:${message.id}:segment:${target.segmentIndex}`, + queueHttpWebhook: false, + queueCmppDelivery: true, + }); + } + return { queued: true, cmppTargetCount: targets.length }; +} diff --git a/api/src/send-chain/drainage-content-detection.spec.ts b/api/src/send-chain/drainage-content-detection.spec.ts new file mode 100644 index 0000000..daf3051 --- /dev/null +++ b/api/src/send-chain/drainage-content-detection.spec.ts @@ -0,0 +1,35 @@ +import { detectDrainageContentWithRules, type DrainageDetectionRuleSnapshot } from './drainage-content-detection'; + +const rules: DrainageDetectionRuleSnapshot[] = [ + { id: 'url', code: 'URL', name: 'URL', category: 'url', priority: 10, version: 1, flags: 'giu', pattern: '(?:https?:\\/\\/)?(?:www\\.)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,24}|(?:\\d{1,3}\\.){3}\\d{1,3})(?::\\d{1,5})?(?:\\/[^\\s,,;;!!??<>《》]*)?' }, + { id: 'mobile', code: 'MOBILE', name: '手机', category: 'mobile', priority: 20, version: 1, flags: 'giu', pattern: '(?:^|[^0-9])((?:\\+?86)?1[3-9][0-9]{9})(?:$|[^0-9])' }, + { id: 'landline', code: 'LANDLINE', name: '固话', category: 'landline', priority: 30, version: 1, flags: 'giu', pattern: '(?:^|[^0-9])((?:\\+?86)?(?:\\(0[0-9]{2,3}\\)|0[0-9]{2,3})-?[0-9]{7,8}(?:(?:转|分机|ext)[0-9]{1,6})?)(?:$|[^0-9])' }, +]; + +describe('drainage content detection', () => { + test.each([ + ['裸域名', '访问 t.cn/a1 查看详情', 'url'], + ['IP 链接', '入口 192.168.1.10:8080/path。', 'url'], + ['中文句号拆分域名', '请访问 example。com 领取', 'url'], + ['空格拆分域名', '请访问 ex ample . com 领取', 'url'], + ['+86 和空格手机号', '电话 +86 138 0013 8000', 'mobile'], + ['短横线手机号', '电话 138-0013-8000', 'mobile'], + ['括号区号和分机', '致电(010)8888-8888 转 123', 'landline'], + ])('%s', (_name, content, category) => { + const result = detectDrainageContentWithRules(content, rules); + expect(result.hasDrainageContent).toBe(true); + expect((result.drainageDetection as { matches: Array<{ category: string }> }).matches.some((item) => item.category === category)).toBe(true); + }); + + it('does not classify an email address as drainage information', () => { + expect(detectDrainageContentWithRules('联系邮箱 service@example.com,谢谢', rules).hasDrainageContent).toBe(false); + expect(detectDrainageContentWithRules('邮箱 13800138000 @ example . com', rules).hasDrainageContent).toBe(false); + }); + + it('keeps original offsets for record-page highlighting', () => { + const content = '📨详情请看 example。com/path,谢谢'; + const result = detectDrainageContentWithRules(content, rules); + const [match] = (result.drainageDetection as { matches: Array<{ start: number; end: number }> }).matches; + expect(content.slice(match.start, match.end)).toContain('example。com/path'); + }); +}); diff --git a/api/src/send-chain/drainage-content-detection.ts b/api/src/send-chain/drainage-content-detection.ts new file mode 100644 index 0000000..da7f93b --- /dev/null +++ b/api/src/send-chain/drainage-content-detection.ts @@ -0,0 +1,208 @@ +import { BadRequestException } from '@nestjs/common'; +import { createHash } from 'node:crypto'; +import type { Prisma } from '@prisma/client'; +import type { PrismaService } from '../prisma/prisma.service'; + +export type DrainageDetectionCategory = 'url' | 'mobile' | 'landline' | string; + +export type DrainageDetectionRuleSnapshot = { + id: string; + code: string; + name: string; + category: DrainageDetectionCategory; + pattern: string; + flags: string; + priority: number; + version: number; +}; + +export type DrainageDetectionMatch = { + ruleId: string; + ruleCode: string; + ruleName: string; + category: DrainageDetectionCategory; + text: string; + normalizedText: string; + start: number; + end: number; +}; + +export type DrainageDetectionResult = { + hasDrainageContent: boolean; + drainageDetection: Prisma.InputJsonValue; + drainageDetectionVersion: string; + drainageEvaluatedAt: Date; +}; + +type NormalizedContent = { + text: string; + sourceStarts: number[]; + sourceEnds: number[]; +}; + +const RULE_CACHE_TTL_MS = 30_000; +const MAX_PATTERN_LENGTH = 1_000; +const MAX_CONTENT_LENGTH = 20_000; +const MAX_MATCHES = 50; + +let cachedRules: { expiresAt: number; rules: DrainageDetectionRuleSnapshot[] } | undefined; + +export function invalidateDrainageDetectionRuleCache() { + cachedRules = undefined; +} + +export function validateDrainageDetectionPattern(pattern: string, flags = 'giu') { + if (!pattern.trim()) throw new BadRequestException('识别表达式不能为空'); + if (pattern.length > MAX_PATTERN_LENGTH) throw new BadRequestException(`识别表达式不能超过 ${MAX_PATTERN_LENGTH} 个字符`); + if (!/^[giu]*$/.test(flags) || new Set(flags).size !== flags.length) { + throw new BadRequestException('表达式标志仅支持 g、i、u,且不能重复'); + } + // 可配置规则会运行在发送入口,禁止容易造成灾难性回溯或跨文本引用的结构。 + if (/\\[1-9]/.test(pattern) || /\(\?<([=!])/.test(pattern) || /\([^)]*[+*][^)]*\)[+*{]/.test(pattern)) { + throw new BadRequestException('表达式包含不安全的回溯、后行断言或嵌套量词'); + } + try { + // 强制全局匹配,避免配置遗漏 g 后只能识别首个命中。 + new RegExp(pattern, flags.includes('g') ? flags : `${flags}g`); + } catch { + throw new BadRequestException('识别表达式格式不正确'); + } +} + +function normalizeContent(content: string, category: DrainageDetectionCategory): NormalizedContent { + let text = ''; + const sourceStarts: number[] = []; + const sourceEnds: number[] = []; + let sourceIndex = 0; + for (const sourceChar of content.slice(0, MAX_CONTENT_LENGTH)) { + const sourceEnd = sourceIndex + sourceChar.length; + let normalized = sourceChar.normalize('NFKC') + .replace(/[.。]/g, '.') + .replace(/[:﹕]/g, ':') + .replace(/[/]/g, '/') + .replace(/[()]/g, (char) => char === '(' ? '(' : ')') + .replace(/[+]/g, '+'); + if (category === 'url') { + // 链接常被空格或中文句号拆开;句末中文句号也安全地成为正则边界。 + normalized = normalized.replace(/\s+/gu, '').replace(/。/g, '.'); + } else if (category === 'mobile' || category === 'landline') { + // 电话号码仅在检测副本中去除常见规避分隔符,绝不改写实际发送内容。 + normalized = normalized.replace(/[\s\-‐‑‒–—―.。·,,、]/gu, ''); + } + for (const char of normalized) { + text += char; + // RegExp.index 使用 UTF-16 code unit,映射数组必须采用相同计数方式,避免表情符号导致高亮偏移。 + for (let codeUnit = 0; codeUnit < char.length; codeUnit += 1) { + sourceStarts.push(sourceIndex); + sourceEnds.push(sourceEnd); + } + } + sourceIndex = sourceEnd; + } + return { text, sourceStarts, sourceEnds }; +} + +function sourceRange(normalized: NormalizedContent, start: number, end: number) { + const safeStart = Math.max(0, Math.min(start, normalized.sourceStarts.length - 1)); + const safeEnd = Math.max(safeStart, Math.min(end - 1, normalized.sourceEnds.length - 1)); + return { + start: normalized.sourceStarts[safeStart] ?? 0, + end: normalized.sourceEnds[safeEnd] ?? 0, + }; +} + +function emailRanges(normalized: NormalizedContent) { + const ranges: Array<{ start: number; end: number }> = []; + const email = /[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+/giu; + for (const match of normalized.text.matchAll(email)) { + ranges.push({ start: match.index, end: match.index + match[0].length }); + } + return ranges; +} + +function overlaps(start: number, end: number, range: { start: number; end: number }) { + return start < range.end && end > range.start; +} + +export function detectDrainageContentWithRules( + content: string, + rules: DrainageDetectionRuleSnapshot[], + evaluatedAt = new Date(), +): DrainageDetectionResult { + const matches: DrainageDetectionMatch[] = []; + const normalizedByCategory = new Map(); + const emailNormalized = normalizeContent(content, 'url'); + const originalEmailRanges = emailRanges(emailNormalized).map((range) => sourceRange(emailNormalized, range.start, range.end)); + for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) { + validateDrainageDetectionPattern(rule.pattern, rule.flags); + const normalized = normalizedByCategory.get(rule.category) ?? normalizeContent(content, rule.category); + normalizedByCategory.set(rule.category, normalized); + const regex = new RegExp(rule.pattern, rule.flags.includes('g') ? rule.flags : `${rule.flags}g`); + for (const match of normalized.text.matchAll(regex)) { + const captured = match[1] || match[0]; + const capturedOffset = match[0].indexOf(captured); + const normalizedStart = match.index + Math.max(0, capturedOffset); + const normalizedEnd = normalizedStart + captured.length; + const range = sourceRange(normalized, normalizedStart, normalizedEnd); + if (range.end <= range.start) continue; + // 邮箱整体不是引流信息;不仅排除其中的域名,也排除数字本地部分被电话规则误识别。 + if (originalEmailRanges.some((emailRange) => overlaps(range.start, range.end, emailRange))) continue; + const candidate: DrainageDetectionMatch = { + ruleId: rule.id, + ruleCode: rule.code, + ruleName: rule.name, + category: rule.category, + text: content.slice(range.start, range.end), + normalizedText: captured, + start: range.start, + end: range.end, + }; + if (!matches.some((item) => item.category === candidate.category && item.start === candidate.start && item.end === candidate.end)) { + matches.push(candidate); + } + if (matches.length >= MAX_MATCHES) break; + } + if (matches.length >= MAX_MATCHES) break; + } + matches.sort((a, b) => a.start - b.start || a.end - b.end); + const versionSource = rules + .map((rule) => `${rule.code}:${rule.version}`) + .sort() + .join('|'); + const drainageDetectionVersion = createHash('sha256').update(versionSource).digest('hex').slice(0, 16); + return { + hasDrainageContent: matches.length > 0, + drainageDetection: { + matches, + categories: [...new Set(matches.map((item) => item.category))], + ruleCount: rules.length, + truncated: content.length > MAX_CONTENT_LENGTH || matches.length >= MAX_MATCHES, + } as Prisma.InputJsonValue, + drainageDetectionVersion, + drainageEvaluatedAt: evaluatedAt, + }; +} + +async function activeRules(prisma: PrismaService) { + if (cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules; + const rules = await prisma.drainageDetectionRule.findMany({ + where: { status: 'active' }, + orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }], + select: { + id: true, + code: true, + name: true, + category: true, + pattern: true, + flags: true, + priority: true, + version: true, + }, + }); + cachedRules = { rules, expiresAt: Date.now() + RULE_CACHE_TTL_MS }; + return rules; +} + +export async function detectDrainageContent(prisma: PrismaService, content: string) { + return detectDrainageContentWithRules(content, await activeRules(prisma)); +} diff --git a/api/src/send-chain/send-batch-entry.service.ts b/api/src/send-chain/send-batch-entry.service.ts index 240133c..f0d09d5 100644 --- a/api/src/send-chain/send-batch-entry.service.ts +++ b/api/src/send-chain/send-batch-entry.service.ts @@ -12,7 +12,8 @@ import { PrismaService } from '../prisma/prisma.service'; import { RiskReviewService } from '../risk-review/risk-review.service'; import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; -import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers'; +import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers'; +import { detectDrainageContent } from './drainage-content-detection'; import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service'; /** @@ -62,11 +63,12 @@ async createBatchTask(data: CreateBatchTaskDto) { await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId); const phoneRejections = await this.facade.classifyRejectedPhones(data.tenantId, data.applicationId, phones); let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone)); - const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([ + const [messageClassification, unitPrice, queuePriority, accessNumber, drainageDetection] = await Promise.all([ this.facade.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content), this.facade.resolveUnitPrice(data.tenantId, data.applicationId), this.facade.resolveQueuePriority(data.tenantId, data.applicationId), this.facade.resolveApplicationAccessNumber(data.tenantId, data.applicationId), + detectDrainageContent(this.prisma, data.content), ]); const risk = messageClassification.rejectionReason ? { status: 'rejected', reason: messageClassification.rejectionReason, task: null } @@ -205,6 +207,7 @@ async createBatchTask(data: CreateBatchTaskDto) { clientMessageId: data.clientMessageId, phoneNumber: phone, content: data.content, + ...drainageDetection, billingUnits: billing.billingUnitsPerMessage, unitPrice: rejection ? 0 : billing.unitPrice, amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice, @@ -395,7 +398,8 @@ async resolveTemplateMessageClassification( signatureId: template.signatureId, drainageInfoId: drainage?.id, variables, - rejectionReason: drainageRejectionReason(drainage), + // 引流资料只做关联与监控,报备审核状态不参与本期发送决策。 + rejectionReason: undefined, }; } @@ -423,7 +427,7 @@ async resolveTemplateMessageClassification( signatureId: signature.id, drainageInfoId: drainage?.id, variables: undefined, - rejectionReason: drainageRejectionReason(drainage), + rejectionReason: undefined, }; } diff --git a/api/src/send-chain/send-chain.contracts.ts b/api/src/send-chain/send-chain.contracts.ts index d2135a6..61839e1 100644 --- a/api/src/send-chain/send-chain.contracts.ts +++ b/api/src/send-chain/send-chain.contracts.ts @@ -35,6 +35,7 @@ export interface GatewayInboundSubmitDto { srcId?: string; destId?: string; sequenceId?: number; + registeredDelivery?: number; remoteIp?: string; longMessage?: { reference: number; diff --git a/api/src/send-chain/send-chain.helpers.ts b/api/src/send-chain/send-chain.helpers.ts index ee92b12..3fea43e 100644 --- a/api/src/send-chain/send-chain.helpers.ts +++ b/api/src/send-chain/send-chain.helpers.ts @@ -61,9 +61,9 @@ export function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) { return `gateway:submit:requeue:${deadLetterId}:${attempt}`; } -export function drainageRejectionReason(drainage?: { id: string; auditStatus: string }) { - if (!drainage || drainage.auditStatus === 'approved') return undefined; - return `短信内容匹配的引流资料 ${drainage.id} 当前为 ${drainage.auditStatus},必须审核通过后才能发送`; +/** @deprecated 引流资料审核状态自本期起只用于监控,不得产生发送拒绝。 */ +export function drainageRejectionReason(_drainage?: { id: string; auditStatus: string }) { + return undefined; } export function statusFromRisk(status: string, scheduled: boolean) { diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 7d1dec9..8ccba0b 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -23,6 +23,9 @@ function createPrismaMock() { submitId: 'SUB-1', gatewayMessageId: 'GW-1', channelId: 'channel-1', + cmppSubmitSequenceId: '101', + cmppSubmitGroupMessageId: null, + cmppRegisteredDelivery: true, template: { signature: { id: 'sig-1', name: '签名' } }, }; const channel = { @@ -109,6 +112,9 @@ function createPrismaMock() { smsDrainageInfo: { findMany: jest.fn().mockResolvedValue([]), }, + drainageDetectionRule: { + findMany: jest.fn().mockResolvedValue([]), + }, smsSendTask: { findUnique: jest.fn().mockResolvedValue(null), update: jest.fn().mockResolvedValue({ id: 'review-task-1', status: 'rejected' }), @@ -688,7 +694,7 @@ describe('SendChainService', () => { }); }); - it.each(['pending', 'rejected'])('blocks a matched %s drainage URL and preserves the matched resource on rejected records', async (auditStatus) => { + it.each(['pending', 'rejected'])('does not block a matched %s drainage URL and still preserves the matched resource', async (auditStatus) => { const { service, prisma, riskReview } = createService(); prisma.smsTemplate.findUnique.mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', @@ -705,14 +711,14 @@ describe('SendChainService', () => { })).resolves.toBeDefined(); expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ status: 'rejected', rejectReason: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`) }), + data: expect.objectContaining({ status: 'ready', rejectReason: null }), }); expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ - drainageInfoId: 'drain-blocked', status: 'rejected', errorMessage: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`), + drainageInfoId: 'drain-blocked', status: 'queued', errorMessage: undefined, })], }); - expect(riskReview.evaluateTask).not.toHaveBeenCalled(); + expect(riskReview.evaluateTask).toHaveBeenCalled(); }); it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => { @@ -2576,6 +2582,18 @@ describe('SendChainService', () => { gatewayMessageId: 'GW-SEG-1', status: 'submitted', billingUnits: 2, + cmppSubmitSequenceId: '501', + cmppSubmitGroupMessageId: 'MSG-LONG-GROUP-1', + cmppRegisteredDelivery: true, + }); + prisma.cmppInboundLongMessage.findFirst.mockResolvedValue({ + id: 'long-group-receipt-1', + messageId: 'MSG-LONG-GROUP-1', + segmentTotal: 2, + segments: [ + { segmentIndex: 1, sequenceId: '501', registeredDelivery: true }, + { segmentIndex: 2, sequenceId: '502', registeredDelivery: true }, + ], }); prisma.smsSubmitRecord.findFirst.mockResolvedValue({ id: 'submit-long', @@ -2621,10 +2639,22 @@ describe('SendChainService', () => { where: { id: 'record-long' }, data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }), })); - expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1); + expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2); + expect(prisma.cmppDownstreamDelivery.create).toHaveBeenNthCalledWith(1, { + data: expect.objectContaining({ + dedupeKey: 'receipt:record-long:segment:1', + payload: expect.objectContaining({ submitSequenceId: 501, clientSegmentIndex: 1, clientSegmentTotal: 2 }), + }), + }); + expect(prisma.cmppDownstreamDelivery.create).toHaveBeenNthCalledWith(2, { + data: expect.objectContaining({ + dedupeKey: 'receipt:record-long:segment:2', + payload: expect.objectContaining({ submitSequenceId: 502, clientSegmentIndex: 2, clientSegmentTotal: 2 }), + }), + }); }); - it('creates and sends only one downstream final receipt under concurrent completion', async () => { + it('creates and sends only one downstream receipt for the same fragment dedupe key', async () => { const { service, prisma } = createService(); let claimedDelivery: Record | null = null; prisma.cmppDownstreamDelivery.create.mockImplementation(async ({ data }) => { @@ -2690,6 +2720,18 @@ describe('SendChainService', () => { billingUnits: 2, amountCents: 6, unitPrice: 3, + cmppSubmitSequenceId: '601', + cmppSubmitGroupMessageId: 'MSG-LONG-GROUP-FAIL', + cmppRegisteredDelivery: true, + }); + prisma.cmppInboundLongMessage.findFirst.mockResolvedValue({ + id: 'long-group-receipt-fail', + messageId: 'MSG-LONG-GROUP-FAIL', + segmentTotal: 2, + segments: [ + { segmentIndex: 1, sequenceId: '601', registeredDelivery: true }, + { segmentIndex: 2, sequenceId: '602', registeredDelivery: true }, + ], }); prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({ id: 'segment-2', @@ -2754,12 +2796,9 @@ describe('SendChainService', () => { }), }); expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' })); + expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2); expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - messageRecordId: 'record-long', - deliveryType: 'receipt', - status: 'pending', - }), + data: expect.objectContaining({ messageRecordId: 'record-long', deliveryType: 'receipt', status: 'pending' }), }); }); @@ -3801,8 +3840,8 @@ describe('SendChainService', () => { it('marks submitted or unknown messages without a final receipt for 72 hours as timeout and refunds them', async () => { const { service, prisma, billing } = createService(); prisma.smsMessageRecord.findMany.mockResolvedValue([ - { id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-1', amountCents: 3, billingUnits: 1 }, - { id: 'record-2', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-2', amountCents: 3, billingUnits: 1 }, + { id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-1', phoneNumber: '13800000001', amountCents: 3, billingUnits: 1, status: 'submitted', cmppSubmitSequenceId: '701', cmppRegisteredDelivery: true, timeoutAt: null }, + { id: 'record-2', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-2', phoneNumber: '13900000002', amountCents: 3, billingUnits: 1, status: 'unknown', cmppSubmitSequenceId: '702', cmppRegisteredDelivery: true, timeoutAt: null }, ]); prisma.smsBillingRecord.findFirst .mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' }) @@ -3812,10 +3851,12 @@ describe('SendChainService', () => { expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({ where: { tenantId: { not: null }, - status: { in: ['submitted', 'unknown'] }, - submittedAt: { lte: expect.any(Date) }, + OR: [ + { status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: expect.any(Date) } }, + { status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null }, + ], }, - select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true }, + select: expect.objectContaining({ id: true, applicationId: true, cmppSubmitSequenceId: true, timeoutAt: true }), take: 10000, }); expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ @@ -3823,9 +3864,95 @@ describe('SendChainService', () => { data: expect.objectContaining({ status: 'timeout', errorMessage: '72小时未收到明确回执,自动转超时' }), }); expect(billing.refund).toHaveBeenCalledTimes(2); + expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2); + expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + dedupeKey: 'receipt:record-1', + payload: expect.objectContaining({ rawStatus: 'EXPIRED', submitSequenceId: 701 }), + }), + }); + expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ + where: { id: 'record-1', status: 'timeout', timeoutReceiptQueuedAt: null }, + data: { timeoutReceiptQueuedAt: expect.any(Date) }, + }); expect(prisma.smsBatchTask.update).toHaveBeenCalled(); }); + it('queues an explicit HTTP failure webhook when a receipt times out', async () => { + const prisma = createPrismaMock(); + const openApi = { queueWebhookEvent: jest.fn().mockResolvedValue({ id: 'http-timeout-delivery' }) }; + const { service } = createService(prisma, openApi); + prisma.smsMessageRecord.findMany.mockResolvedValue([{ + id: 'record-http-timeout', + tenantId: 'tenant-1', + batchTaskId: null, + applicationId: 'app-1', + messageId: 'MSG-HTTP-TIMEOUT', + phoneNumber: '13800000001', + amountCents: 0, + billingUnits: 1, + status: 'submitted', + cmppSubmitSequenceId: null, + cmppSubmitGroupMessageId: null, + cmppRegisteredDelivery: null, + timeoutAt: null, + }]); + + await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 1 }); + + expect(openApi.queueWebhookEvent).toHaveBeenCalledWith(expect.objectContaining({ + applicationId: 'app-1', + messageRecordId: 'record-http-timeout', + eventType: 'receipt', + payload: expect.objectContaining({ + receiptStatus: 'undelivered', + rawStatus: 'EXPIRED', + errorCode: 'RECEIPT_TIMEOUT', + }), + })); + expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); + expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ + where: { id: 'record-http-timeout', status: 'timeout', timeoutReceiptQueuedAt: null }, + data: { timeoutReceiptQueuedAt: expect.any(Date) }, + }); + }); + + it('recovers timeout refund and downstream queueing when the prior scan stopped before setting the outbox marker', async () => { + const { service, prisma, billing } = createService(); + prisma.smsBillingRecord.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'billing-timeout-recovery', billingStatus: 'charged' }); + prisma.smsMessageRecord.findMany.mockResolvedValue([{ + id: 'record-timeout-recovery', + tenantId: 'tenant-1', + batchTaskId: null, + applicationId: 'app-1', + messageId: 'MSG-TIMEOUT-RECOVERY', + phoneNumber: '13800000001', + amountCents: 3, + billingUnits: 1, + status: 'timeout', + cmppSubmitSequenceId: '703', + cmppSubmitGroupMessageId: null, + cmppRegisteredDelivery: true, + timeoutAt: new Date('2026-08-01T00:00:00.000Z'), + }]); + + await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 0 }); + + expect(billing.refund).toHaveBeenCalledTimes(1); + expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + dedupeKey: 'receipt:record-timeout-recovery', + payload: expect.objectContaining({ rawStatus: 'EXPIRED', submitSequenceId: 703 }), + }), + }); + expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ + where: { id: 'record-timeout-recovery', status: 'timeout', timeoutReceiptQueuedAt: null }, + data: { timeoutReceiptQueuedAt: expect.any(Date) }, + }); + }); + it('terminates downstream deliveries that remain pending for 72 hours after the latest manual retry', async () => { const { service, prisma } = createService(); prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-expired' }]); diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index ce44582..a34f913 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -16,6 +16,7 @@ import { OpenApiService } from '../open-api/open-api.service'; import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, RECEIPT_TIMEOUT_INITIAL_DELAY_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, SCHEDULED_DISPATCH_INITIAL_DELAY_MS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, gatewaySubmitRequeueKey, drainageRejectionReason, statusFromRisk, parseSchedule, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, normalizeRegion, matchTemplateContent, escapeRegularExpression, isNationalChannel, isProvinceChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, normalizeSubmitStatus, normalizeReceiptStatus, downstreamDeliveryAttemptKey, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory } from './send-chain.helpers'; import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; +import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets'; import { SendSubmissionService } from './send-submission.service'; import { SendCompletionService, type SendCompletionFacade } from './send-completion.service'; @@ -500,14 +501,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return this.completion.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId); } - private async queueAndTryDownstreamDelivery(data: { - tenantId: string; - applicationId?: string | null; - messageRecordId?: string | null; - messageId?: string | null; - deliveryType: 'receipt' | 'uplink'; - payload: Record; - }) { + private async queueAndTryDownstreamDelivery(data: DownstreamDeliveryQueueRequest) { return this.completion.queueAndTryDownstreamDelivery(data); } diff --git a/api/src/send-chain/send-completion.service.ts b/api/src/send-chain/send-completion.service.ts index 4301cb2..bf1da4f 100644 --- a/api/src/send-chain/send-completion.service.ts +++ b/api/src/send-chain/send-completion.service.ts @@ -11,6 +11,7 @@ import { SendGatewayResultService } from './send-gateway-result.service'; import { SendReceiptService } from './send-receipt.service'; import { SendRetryService } from './send-retry.service'; import { SendTimeoutService } from './send-timeout.service'; +import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets'; export type SendCompletionCallbacks = Record; @@ -263,14 +264,7 @@ export class SendCompletionService { return this.downstreamDelivery.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId); } - async queueAndTryDownstreamDelivery(data: { - tenantId: string; - applicationId?: string | null; - messageRecordId?: string | null; - messageId?: string | null; - deliveryType: 'receipt' | 'uplink'; - payload: Record; - }) { + async queueAndTryDownstreamDelivery(data: DownstreamDeliveryQueueRequest) { return this.downstreamDelivery.queueAndTryDownstreamDelivery(data); } @@ -298,6 +292,7 @@ export class SendCompletionService { phoneNumber: string; cmppSubmitSequenceId?: string | null; cmppSubmitGroupMessageId?: string | null; + cmppRegisteredDelivery?: boolean | null; }, errorCode: string, reason: string, diff --git a/api/src/send-chain/send-downstream-delivery.service.ts b/api/src/send-chain/send-downstream-delivery.service.ts index 6206cb9..e208b1e 100644 --- a/api/src/send-chain/send-downstream-delivery.service.ts +++ b/api/src/send-chain/send-downstream-delivery.service.ts @@ -9,6 +9,7 @@ import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayRece import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; import type { SendSubmissionService } from './send-submission.service'; import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; +import { queueFinalReceiptDeliveries, type DownstreamDeliveryQueueRequest } from './downstream-receipt-targets'; /** @@ -188,14 +189,7 @@ export class SendDownstreamDeliveryService { }); } - async queueAndTryDownstreamDelivery(data: { - tenantId: string; - applicationId?: string | null; - messageRecordId?: string | null; - messageId?: string | null; - deliveryType: 'receipt' | 'uplink'; - payload: Record; - }) { + async queueAndTryDownstreamDelivery(data: DownstreamDeliveryQueueRequest) { if (!data.applicationId) { return null; } @@ -211,7 +205,7 @@ export class SendDownstreamDeliveryService { }, }); const deliveryAllowed = application?.status === 'active' || application?.status === 'disabling'; - if (deliveryAllowed) { + if (deliveryAllowed && data.queueHttpWebhook !== false) { try { await this.openApi?.queueWebhookEvent({ tenantId: data.tenantId, @@ -224,14 +218,18 @@ export class SendDownstreamDeliveryService { }); } catch (error) { this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`); + if (data.propagateHttpQueueError) throw error; } } + if (data.queueCmppDelivery === false) { + return null; + } if (application?.interfaceEnabled !== true) { return null; } const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload }; const dedupeKey = data.deliveryType === 'receipt' && data.messageRecordId - ? `receipt:${data.messageRecordId}` + ? data.receiptDedupeKey ?? `receipt:${data.messageRecordId}` : data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string' ? `uplink:${data.payload.uplinkMessageId}` : null; @@ -419,6 +417,7 @@ export class SendDownstreamDeliveryService { phoneNumber: string; cmppSubmitSequenceId?: string | null; cmppSubmitGroupMessageId?: string | null; + cmppRegisteredDelivery?: boolean | null; }, errorCode: string, reason: string, @@ -450,13 +449,12 @@ export class SendDownstreamDeliveryService { deliveredAt, }, }); - await this.facade.queueAndTryDownstreamDelivery({ - tenantId: message.tenantId, - applicationId: message.applicationId, - messageRecordId: message.id, - messageId: message.messageId, - deliveryType: 'receipt', - payload: { + await queueFinalReceiptDeliveries( + this.prisma, + (request) => this.facade.queueAndTryDownstreamDelivery(request), + { + message, + payload: { messageId: message.messageId, gatewayMessageId: `PLATFORM:${message.messageId}`, phoneNumber: message.phoneNumber, @@ -464,11 +462,10 @@ export class SendDownstreamDeliveryService { rawStatus: 'REJECTD', errorCode, errorMessage: reason, - submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined, - submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined, deliveredAt: deliveredAt.toISOString(), + }, }, - }); + ); if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId); return receipt; } diff --git a/api/src/send-chain/send-inbound-entry.service.ts b/api/src/send-chain/send-inbound-entry.service.ts index 29f0c55..ba9e8c3 100644 --- a/api/src/send-chain/send-inbound-entry.service.ts +++ b/api/src/send-chain/send-inbound-entry.service.ts @@ -12,8 +12,9 @@ import { PrismaService } from '../prisma/prisma.service'; import { RiskReviewService } from '../risk-review/risk-review.service'; import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; -import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers'; +import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers'; import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service'; +import { detectDrainageContent } from './drainage-content-detection'; /** * R9 inboundEntry implementation. Cross-method calls return through the stable SendChainService seam. @@ -48,6 +49,7 @@ export class SendInboundEntryService { phoneNumber: string; cmppSubmitSequenceId?: string | null; cmppSubmitGroupMessageId?: string | null; + cmppRegisteredDelivery?: boolean | null; }, errorCode: string, reason: string, @@ -85,6 +87,9 @@ async authenticateInboundApplication(data: GatewayInboundAuthDto) { } async submitInboundMessage(data: GatewayInboundSubmitDto) { + if (data.registeredDelivery != null && ![0, 1].includes(data.registeredDelivery)) { + throw new BadRequestException('CMPP Registered_Delivery must be 0 or 1'); + } const phoneNumbers = data.phoneNumbers?.length ? data.phoneNumbers.map((phoneNumber) => phoneNumber.trim()) : data.phoneNumber @@ -136,6 +141,7 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) { ...data, content: collection.content, sequenceId: collection.sequenceId, + registeredDelivery: collection.registeredDelivery ? 1 : 0, longMessage: undefined, }, phoneNumbers, application, collection.messageId); await this.prisma.cmppInboundLongMessage.update({ @@ -350,6 +356,7 @@ async collectInboundLongMessageFragment( response: recent.response as any, content: recent.segments.map((item) => item.content).join(''), sequenceId: parseOptionalSequenceId(recent.segments[0]?.sequenceId), + registeredDelivery: recent.segments[0]?.registeredDelivery ?? true, }; } @@ -392,6 +399,7 @@ async collectInboundLongMessageFragment( response: null, content: group.segments.map((item) => item.content).join(''), sequenceId: parseOptionalSequenceId(group.segments[0]?.sequenceId), + registeredDelivery: group.segments[0]?.registeredDelivery ?? true, }; } return { @@ -402,12 +410,14 @@ async collectInboundLongMessageFragment( response: group.response as any, content: '', sequenceId: undefined, + registeredDelivery: true, }; } const existing = group.segments.find((item) => item.segmentIndex === fragment.index); if (existing && (existing.contentHash !== contentHash - || existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId)))) { + || existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId)) + || existing.registeredDelivery !== (data.registeredDelivery !== 0))) { throw new BadRequestException(`CMPP long message fragment ${fragment.index} conflicts with the stored fragment`); } if (!existing) { @@ -416,6 +426,7 @@ async collectInboundLongMessageFragment( groupId: group.id, segmentIndex: fragment.index, sequenceId: data.sequenceId == null ? null : String(data.sequenceId), + registeredDelivery: data.registeredDelivery !== 0, content: data.content, contentHash, }, @@ -441,6 +452,7 @@ async collectInboundLongMessageFragment( response: null, content: complete ? segments.map((item) => item.content).join('') : '', sequenceId: parseOptionalSequenceId(segments[0]?.sequenceId), + registeredDelivery: segments[0]?.registeredDelivery ?? true, }; }); } @@ -510,6 +522,7 @@ async submitInboundSingleMessage( status: synchronousRejection ? 'rejected' : 'accepted', }, }); + const drainageDetection = await detectDrainageContent(this.prisma, data.content); const message = await this.prisma.smsMessageRecord.create({ data: { tenantId: application.tenantId, @@ -519,12 +532,14 @@ async submitInboundSingleMessage( messageId, phoneNumber: data.phoneNumber, content: data.content, + ...drainageDetection, billingUnits: billing.billingUnitsPerMessage, unitPrice: receiptRejection ? 0 : billing.unitPrice, amountCents: receiptRejection ? 0 : billing.amountCents, queuePriority, cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId), cmppSubmitGroupMessageId: submitGroupMessageId, + cmppRegisteredDelivery: data.registeredDelivery !== 0, clientSrcId, applicationExtension: application.cmppApplicationExtension, status: synchronousRejection ? 'rejected' : 'validating', @@ -555,15 +570,6 @@ async submitInboundSingleMessage( const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => { const drainage = await this.facade.resolveDrainageInfoMatch(options.signatureId, data.content); const drainageInfoId = drainage?.id; - const drainageReason = drainageRejectionReason(drainage); - if (drainageReason) { - await this.prisma.smsMessageRecord.update({ - where: { id: message.id }, - data: { drainageInfoId, signatureId: options.signatureId }, - }); - await reject('DRAINAGE_NOT_APPROVED', drainageReason); - return; - } const risk = await this.facade.evaluateRiskWithPhoneFrequency({ tenantId: application.tenantId, applicationId: application.id, @@ -634,23 +640,6 @@ async submitInboundSingleMessage( await reject('SIGNATURE', '短信内容未识别到已审核通过的签名'); } else { const drainage = await this.facade.resolveDrainageInfoMatch(signature.id, data.content); - const drainageReason = drainageRejectionReason(drainage); - if (drainageReason) { - await this.prisma.smsMessageRecord.update({ - where: { id: message.id }, - data: { drainageInfoId: drainage?.id, signatureId: signature.id }, - }); - await reject('DRAINAGE_NOT_APPROVED', drainageReason); - return { - accepted: true, - tenantId: application.tenantId, - applicationId: application.id, - messageId, - messageRecordId: message.id, - taskId: task.id, - status: 'rejected', - }; - } const risk = await this.facade.evaluateRiskWithPhoneFrequency({ tenantId: application.tenantId, applicationId: application.id, diff --git a/api/src/send-chain/send-receipt.service.ts b/api/src/send-chain/send-receipt.service.ts index d855ce9..dabf75f 100644 --- a/api/src/send-chain/send-receipt.service.ts +++ b/api/src/send-chain/send-receipt.service.ts @@ -9,6 +9,7 @@ import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayRece import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; import type { SendSubmissionService } from './send-submission.service'; import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; +import { queueFinalReceiptDeliveries } from './downstream-receipt-targets'; /** @@ -309,24 +310,32 @@ export class SendReceiptService { }, }); if (!isStandaloneChannelTest && message.tenantId && message.applicationId) { - await this.facade.queueAndTryDownstreamDelivery({ - tenantId: message.tenantId, - applicationId: message.applicationId, - messageRecordId: message.id, - messageId: message.messageId, - deliveryType: 'receipt', - payload: { - messageId: message.messageId, - gatewayMessageId: data.gatewayMessageId, - phoneNumber: message.phoneNumber, - receiptStatus: aggregate.receiptStatus, - rawStatus: aggregate.rawStatus, - errorCode: aggregate.errorCode, - submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined, - submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined, - deliveredAt: aggregate.deliveredAt.toISOString(), + await queueFinalReceiptDeliveries( + this.prisma, + (request) => this.facade.queueAndTryDownstreamDelivery(request), + { + message, + payload: { + messageId: message.messageId, + gatewayMessageId: data.gatewayMessageId, + phoneNumber: message.phoneNumber, + receiptStatus: aggregate.receiptStatus, + rawStatus: aggregate.rawStatus, + errorCode: aggregate.errorCode, + deliveredAt: aggregate.deliveredAt.toISOString(), + }, + segmentPayloads: Object.fromEntries( + aggregate.segments + .filter((segment) => segment.receiptStatus) + .map((segment) => [segment.segmentIndex, { + receiptStatus: segment.receiptStatus, + rawStatus: segment.rawStatus, + errorCode: segment.errorCode, + deliveredAt: segment.deliveredAt?.toISOString() ?? aggregate.deliveredAt.toISOString(), + }]), + ), }, - }); + ); } if (message.batchTaskId) { await this.facade.refreshTaskProgress(message.batchTaskId); @@ -427,7 +436,10 @@ export class SendReceiptService { : { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId }, orderBy: { segmentIndex: 'asc' }, }); - return aggregateReceiptSegmentState(audits, message.billingUnits, data, deliveredAt); + return { + ...aggregateReceiptSegmentState(audits, message.billingUnits, data, deliveredAt), + segments: audits, + }; } async resolveReceiptMessage( diff --git a/api/src/send-chain/send-submission.service.ts b/api/src/send-chain/send-submission.service.ts index 58b4f8a..e5ad0bb 100644 --- a/api/src/send-chain/send-submission.service.ts +++ b/api/src/send-chain/send-submission.service.ts @@ -27,6 +27,7 @@ export type SendSubmissionCallbacks = { phoneNumber: string; cmppSubmitSequenceId?: string | null; cmppSubmitGroupMessageId?: string | null; + cmppRegisteredDelivery?: boolean | null; }, errorCode: string, reason: string, diff --git a/api/src/send-chain/send-timeout.service.ts b/api/src/send-chain/send-timeout.service.ts index 22de455..9b40f00 100644 --- a/api/src/send-chain/send-timeout.service.ts +++ b/api/src/send-chain/send-timeout.service.ts @@ -9,6 +9,7 @@ import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayRece import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; import type { SendSubmissionService } from './send-submission.service'; import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; +import { queueFinalReceiptDeliveries } from './downstream-receipt-targets'; /** @@ -33,23 +34,76 @@ export class SendTimeoutService { const candidates = await this.prisma.smsMessageRecord.findMany({ where: { tenantId: { not: null }, - status: { in: ['submitted', 'unknown'] }, - submittedAt: { lte: cutoff }, + OR: [ + { status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: cutoff } }, + { status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null }, + ], + }, + select: { + id: true, + tenantId: true, + batchTaskId: true, + applicationId: true, + messageId: true, + phoneNumber: true, + amountCents: true, + billingUnits: true, + status: true, + cmppSubmitSequenceId: true, + cmppSubmitGroupMessageId: true, + cmppRegisteredDelivery: true, + timeoutAt: true, }, - select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true }, take: 10000, }); const timedOutTaskIds = new Set(); let timeout = 0; for (const candidate of candidates) { if (!candidate.tenantId) continue; - const transitioned = await this.prisma.smsMessageRecord.updateMany({ - where: { id: candidate.id, status: { in: ['submitted', 'unknown'] } }, - data: { status: 'timeout', timeoutAt: new Date(), errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时` }, - }); - if (transitioned.count !== 1) continue; - timeout += 1; + const timedOutAt = candidate.timeoutAt ?? new Date(); + if (candidate.status !== 'timeout') { + const transitioned = await this.prisma.smsMessageRecord.updateMany({ + where: { id: candidate.id, status: { in: ['submitted', 'unknown'] } }, + data: { + status: 'timeout', + receiptStatus: 'undelivered', + receiptRawStatus: 'EXPIRED', + errorCode: 'RECEIPT_TIMEOUT', + errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时`, + timeoutAt: timedOutAt, + }, + }); + if (transitioned.count !== 1) continue; + timeout += 1; + } + // Refund uses the platform-message idempotency key. Re-running it for a + // timeout whose downstream outbox was not fully queued also recovers a + // crash between the state transition and the original refund call. await this.facade.refundMessage(candidate as typeof candidate & { tenantId: string }, `${olderThanHours}小时未收到明确回执,自动超时退款`); + const queued = await queueFinalReceiptDeliveries( + this.prisma, + (request) => this.facade.queueAndTryDownstreamDelivery(request), + { + message: candidate, + payload: { + messageId: candidate.messageId, + gatewayMessageId: `PLATFORM_TIMEOUT:${candidate.messageId}`, + phoneNumber: candidate.phoneNumber, + receiptStatus: 'undelivered', + rawStatus: 'EXPIRED', + errorCode: 'RECEIPT_TIMEOUT', + errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时`, + deliveredAt: timedOutAt.toISOString(), + }, + propagateHttpQueueError: true, + }, + ); + if (queued.queued) { + await this.prisma.smsMessageRecord.updateMany({ + where: { id: candidate.id, status: 'timeout', timeoutReceiptQueuedAt: null }, + data: { timeoutReceiptQueuedAt: new Date() }, + }); + } if (candidate.batchTaskId) timedOutTaskIds.add(candidate.batchTaskId); } for (const batchTaskId of timedOutTaskIds) { diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index 70f639a..a26b28d 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -72,8 +72,8 @@ export class AdminSmsConfigController { } @Get('enterprise-signatures') - listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { - const query = { tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword, page: Number(page), pageSize: Number(pageSize) }; + listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + const query = { tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword, submittedAtFrom, submittedAtTo, page: Number(page), pageSize: Number(pageSize) }; return page || pageSize ? this.smsConfig.listSignaturesPage(query) : this.smsConfig.listSignatures(query); } @@ -93,8 +93,8 @@ export class AdminSmsConfigController { } @Get('drainage-infos') - listDrainageInfos(@Query('tenantId') tenantId?: string, @Query('signatureId') signatureId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) { - return this.smsConfig.listDrainageInfos({ tenantId, signatureId, status, keyword }); + listDrainageInfos(@Query('tenantId') tenantId?: string, @Query('signatureId') signatureId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string) { + return this.smsConfig.listDrainageInfos({ tenantId, signatureId, status, keyword, submittedAtFrom, submittedAtTo }); } @Post('enterprise-signatures/:id/drainage-infos') @@ -126,8 +126,8 @@ export class AdminSmsConfigController { } @Get('enterprise-templates') - listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('nameKeyword') nameKeyword?: string, @Query('contentKeyword') contentKeyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { - const query = { tenantId, status, keyword, enterpriseKeyword, applicationKeyword, nameKeyword, contentKeyword, page: Number(page), pageSize: Number(pageSize) }; + listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('nameKeyword') nameKeyword?: string, @Query('contentKeyword') contentKeyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + const query = { tenantId, status, keyword, enterpriseKeyword, applicationKeyword, nameKeyword, contentKeyword, submittedAtFrom, submittedAtTo, page: Number(page), pageSize: Number(pageSize) }; return page || pageSize ? this.smsConfig.listTemplatesPage(query) : this.smsConfig.listTemplates(query); } diff --git a/api/src/sms-config/drainage.service.ts b/api/src/sms-config/drainage.service.ts index 09d00e1..e8f01b6 100644 --- a/api/src/sms-config/drainage.service.ts +++ b/api/src/sms-config/drainage.service.ts @@ -9,6 +9,7 @@ import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplica import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers'; import { SmsReportValidationService } from './report-validation.service'; import { SmsAuditService } from './audit.service'; +import { shanghaiDateRange } from '../common/shanghai-date-range'; /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsDrainageService { @@ -50,6 +51,7 @@ export class SmsDrainageService { tenantId: query.tenantId, signatureId: query.signatureId, auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + submittedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), OR: query.keyword ? [ { siteName: { contains: query.keyword } }, { url: { contains: query.keyword } }, diff --git a/api/src/sms-config/signature.service.ts b/api/src/sms-config/signature.service.ts index 73e6870..473cf27 100644 --- a/api/src/sms-config/signature.service.ts +++ b/api/src/sms-config/signature.service.ts @@ -9,6 +9,7 @@ import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplica import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers'; import { SmsReportValidationService } from './report-validation.service'; import { SmsAuditService } from './audit.service'; +import { shanghaiDateRange } from '../common/shanghai-date-range'; /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsSignatureService { @@ -22,6 +23,7 @@ export class SmsSignatureService { tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, + updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), drainageItems: query.drainageKeyword ? { some: { auditStatus: { not: 'deleted' }, @@ -134,6 +136,7 @@ export class SmsSignatureService { tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, + updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), drainageItems: query.drainageKeyword ? { some: { auditStatus: { not: 'deleted' }, diff --git a/api/src/sms-config/sms-config.contracts.ts b/api/src/sms-config/sms-config.contracts.ts index 374b988..297642f 100644 --- a/api/src/sms-config/sms-config.contracts.ts +++ b/api/src/sms-config/sms-config.contracts.ts @@ -67,6 +67,8 @@ export interface DrainageInfoListQuery { signatureId?: string; status?: string; keyword?: string; + submittedAtFrom?: string; + submittedAtTo?: string; } export interface CreateSignatureMaterialDto { @@ -116,6 +118,8 @@ export interface TemplateListQuery { applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string; + submittedAtFrom?: string; + submittedAtTo?: string; page?: number; pageSize?: number; } @@ -139,6 +143,8 @@ export interface SignatureListQuery { applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; + submittedAtFrom?: string; + submittedAtTo?: string; page?: number; pageSize?: number; } diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 6942c41..84fd7fb 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -1103,6 +1103,24 @@ describe('SmsConfigService', () => { expect(prisma.smsSignature.update).not.toHaveBeenCalled(); }); + it('applies the audit submission range to signatures, templates and drainage records', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + const query = { submittedAtFrom: '2026-08-01', submittedAtTo: '2026-08-03' }; + const expectedRange = { + gte: new Date('2026-08-01T00:00:00+08:00'), + lte: new Date('2026-08-03T23:59:59.999+08:00'), + }; + + await service.listSignatures(query); + await service.listTemplates(query); + await service.listDrainageInfos(query); + + expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ updatedAt: expectedRange }) })); + expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ createdAt: expectedRange }) })); + expect(prisma.smsDrainageInfo.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ submittedAt: expectedRange }) })); + }); + it.each([ '【带 空格】', ' 【外部空格】', diff --git a/api/src/sms-config/template.service.ts b/api/src/sms-config/template.service.ts index 75fbfa7..55249cc 100644 --- a/api/src/sms-config/template.service.ts +++ b/api/src/sms-config/template.service.ts @@ -8,6 +8,7 @@ import { automaticDeliveryMode } from '../open-api/delivery-mode'; import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts'; import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers'; import { SmsAuditService } from './audit.service'; +import { shanghaiDateRange } from '../common/shanghai-date-range'; /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsTemplateService { @@ -22,6 +23,7 @@ export class SmsTemplateService { application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, name: query.nameKeyword ? { contains: query.nameKeyword } : undefined, content: query.contentKeyword ? { contains: query.contentKeyword } : undefined, + createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), OR: query.keyword ? [ { name: { contains: query.keyword } }, { content: { contains: query.keyword } }, @@ -49,6 +51,7 @@ export class SmsTemplateService { application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, name: query.nameKeyword ? { contains: query.nameKeyword } : undefined, content: query.contentKeyword ? { contains: query.contentKeyword } : undefined, + createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), OR: query.keyword ? [ { name: { contains: query.keyword } }, { content: { contains: query.keyword } }, diff --git a/docs/contracts/admin-api-r1-methods.json b/docs/contracts/admin-api-r1-methods.json index e0024e0..de9193c 100644 --- a/docs/contracts/admin-api-r1-methods.json +++ b/docs/contracts/admin-api-r1-methods.json @@ -1,7 +1,7 @@ { "version": "r1", "baselineCommit": "0af671b4ed4713912e703defd08791f164d4eb25", - "generatedAt": "2026-07-31", + "generatedAt": "2026-08-03", "coreFunctions": { "readErrorBody": "a12b96623f266784d928784ab83fe172fea7470f14b8e2be6684f776119b50dd", "requestPortal": "0b19b95d4ece5b775f2b62951368b1a8b19d7e74f4c2644b40b02866783b49ea", @@ -311,7 +311,7 @@ }, { "name": "listTemplateAudits", - "implementationSha256": "cd52a1a4ce1d7a64468089f5cb7597904c246963665b1174e01ec6fce673760d" + "implementationSha256": "2d1a9f1441b5ab75fd464f29673e4534b8bc8737ac5b92425094af82d2489531" }, { "name": "approveTemplate", @@ -339,7 +339,7 @@ }, { "name": "listEnterpriseSignatures", - "implementationSha256": "e60e04452f022052a5aa21159b1b69828c7a0c7de731ce723c7d5742751ecb74" + "implementationSha256": "132c04125b1d2ae39fa21f8a71ecfe0d3e12e64cce3c8fab9d089e2423e329ee" }, { "name": "listEnterpriseSignaturesPage", @@ -363,7 +363,7 @@ }, { "name": "listDrainageInfos", - "implementationSha256": "e75bafd3e0bb991f853f9cb195b8043c2ab180383e14ae0bd195cba441c70dbe" + "implementationSha256": "9ff26a607e03786eaefd96f8e138509d01f14be71258c75c063b1555b1947960" }, { "name": "listAuditRecords", @@ -411,7 +411,7 @@ }, { "name": "listEnterpriseCertifications", - "implementationSha256": "dde64635239c428de705e832356bcf39195ae5ce3af93bccd08d0be0ca436d23" + "implementationSha256": "a02638b588d6c53d3b51df33336c012f1d9f3d0d4bb5ed9f7b550dd114d4e96c" }, { "name": "getEnterpriseCertification", @@ -571,11 +571,11 @@ }, { "name": "listOperationMessages", - "implementationSha256": "d89407edcb6942fe8bc5fd618c2fcc17abb151a1d395d76c512a1b8412a0cb71" + "implementationSha256": "ca21bae1df2e2df8d51ee8b3bc0e5d0cddf056cfa3e00dbb839ec0382b01b821" }, { "name": "exportOperationMessages", - "implementationSha256": "01c49d9ca8e071c085244a9a2dd66e5f27d271ef9a746f18769956647a62cbd2" + "implementationSha256": "e42feb6aa6eacc571a2bc52315f24f4cdd54d673ce71ac49902893fc816ab62f" }, { "name": "listAdminUplinkMessages", @@ -635,7 +635,7 @@ }, { "name": "listRiskReviewTasks", - "implementationSha256": "1040e5775f210a00a26df88b3aac01523ca61c29409edd5f30e6e1c98fb3e5cc" + "implementationSha256": "8be7604b795105e51d3301a0e9c2f3a7a4b83a65981070fd64aafed27df67125" }, { "name": "listRiskRules", @@ -773,6 +773,26 @@ "name": "deleteCommonReportField", "implementationSha256": "e305aff8b3eabaa020c2044d1993bbd8e5556d2b5b74451c2d97df44a91a96a9" }, + { + "name": "listDrainageDetectionRules", + "implementationSha256": "f64be5ee02acc626a6cae581604a9a8400cfb6f4110913ab7a5d30e0518f8001" + }, + { + "name": "createDrainageDetectionRule", + "implementationSha256": "165e44de881102c91f04076d3c5a34fc81fa5ac9d1dc55d5c5167d30f43f4944" + }, + { + "name": "updateDrainageDetectionRule", + "implementationSha256": "6e7c079dd3511acedac00b7aa530c9568c7c066538667e253d685ba202af28de" + }, + { + "name": "changeDrainageDetectionRuleStatus", + "implementationSha256": "950d2bfcf817375996d3160e96f48f89de15cee07b6545cbb1fe4c013b9fbb92" + }, + { + "name": "testDrainageDetectionRule", + "implementationSha256": "908309fed077f6fa165715a8ee90ae1bcf1132b470fc4af013784aaae61a38a7" + }, { "name": "uploadFileObject", "implementationSha256": "e4e12f112fdb40274b08c8109dc0bb585cd23ab62dfed0ce9e8e11ca7e508482" diff --git a/docs/contracts/channels-r5-methods.json b/docs/contracts/channels-r5-methods.json index 069a5ef..6556456 100644 --- a/docs/contracts/channels-r5-methods.json +++ b/docs/contracts/channels-r5-methods.json @@ -1,7 +1,7 @@ { "version": "R5", "source": "api/src/channels/channels.service.ts", - "generatedAt": "2026-07-31T02:40:10.189Z", + "generatedAt": "2026-08-03T15:27:00+08:00", "publicMethods": [ { "name": "onModuleInit", @@ -96,7 +96,7 @@ { "name": "testChannel", "signature": "async testChannel(channelId: string, data: TestChannelDto = {})", - "canonicalBodySha256": "e01b10f3981c66c5fab6cfd6d1b886abbb45e1fe53852b57c0903fcd826815d3", + "canonicalBodySha256": "6336a3d1ecfb3c9fb838a9a198c35ab20009677e34b6cf23e7ac21f7d4b09f97", "originalLines": [ 536, 636 diff --git a/docs/contracts/foundation-styles-r11.json b/docs/contracts/foundation-styles-r11.json index d0ed574..01a9635 100644 --- a/docs/contracts/foundation-styles-r11.json +++ b/docs/contracts/foundation-styles-r11.json @@ -32,6 +32,9 @@ "--sidebar-width", "--topbar-height", "--control-height-md", + "--query-control-width", + "--query-date-range-width", + "--query-action-width", "--z-modal" ], "resetFile": "src/styles/reset.css", diff --git a/docs/contracts/operations-r2-methods.json b/docs/contracts/operations-r2-methods.json index ba52892..598c111 100644 --- a/docs/contracts/operations-r2-methods.json +++ b/docs/contracts/operations-r2-methods.json @@ -1,9 +1,9 @@ { "version": "r2", "baselineCommit": "0af671b4ed4713912e703defd08791f164d4eb25", - "generatedAt": "2026-07-31", + "generatedAt": "2026-08-03", "contracts": { - "MessageQuery": "07530ba61641758c96b5cd91dcdaba8692d5da4d9657d66e71009e7abf338f13", + "MessageQuery": "b2713cdb6dedf7d6d9c3aa23235b595a15a8f0c82c045a0bd9b39151a40cf00c", "TraceQuery": "636138d9593d61b2936eb32231d071b4682ca1d36cbf6a2b07d3b71b4f5d8453", "OperationLogQuery": "084d60f6487a2b38264211bf030950baacd2bc9b218e992867fb0e429dae7ece", "GatewaySubmitDeadLetterQuery": "d40dea1cc7d5ee369ab16a49cada5c6bcfae2b21fc66459c49d9377374aa1445", @@ -47,7 +47,7 @@ "group": "messages", "isPrivate": false, "signatureSha256": "37075fd6448d81f6acc25efe9a3670c8d6ec962add6a0b1e3115a31c05f8d7a6", - "bodySha256": "649439bf58c411100dcc24d634c7f2c87c6323c74cfc76539f853485c3c59461" + "bodySha256": "44fb733c7f072fb69a85eba97a9b896e0fc93b6daf240870ce595611725e26b9" }, { "name": "listClientMessages", @@ -117,14 +117,14 @@ "group": "quality", "isPrivate": false, "signatureSha256": "7fa4cd6bcee4092390a38d693aa30c7980781ca1baa73dd07a3a61f097774e9c", - "bodySha256": "730b291f2fa96a9bcb054589a1341bc21f8ade161d232141d74f77eb426c4522" + "bodySha256": "e1334241997093de755a8e31ca8655694635b014d9016d6180dfd42ee283c231" }, { "name": "signatureQuality", "group": "quality", "isPrivate": false, "signatureSha256": "6b9c3761674cb0d01000caca5e12cf91a6e526757592ffb783fc673c178b204c", - "bodySha256": "c85644b03f607339c2eae5390b01fb520264e87f16d0439c81a0c101b4277fa2" + "bodySha256": "a7203deb5a8c7c3b6f6afaf83a55bb1ff45a0bf198f86a2158868c4b8c759b2d" }, { "name": "auditLogs", @@ -240,7 +240,7 @@ } ], "helpers": { - "messageWhere": "8d73104a7035970fceee92d95f2f76960c64aa5e1862be3441879d666d57a947", + "messageWhere": "072069862f457a9ed100e76fefe664c6c7b9a977ca21cd51eb6bfcf548dcb2a5", "recognizedCarrierValues": "d60957621e921bf9f5df8fdef616f4e2a0b16be78623c85316a65bae83025bdb", "carrierWhere": "1c7947c02a1a5cd3aaeda4a586421f6d7d63ae2f912cb11292831af2ffa4c379", "startOfShanghaiDay": "12eb964ff5ab3680837cf50bccabe5e8e7af733c248d06cb57f20c0c65ce0b4e", diff --git a/docs/contracts/send-chain-r10-completion.json b/docs/contracts/send-chain-r10-completion.json index 99d908a..3c539b6 100644 --- a/docs/contracts/send-chain-r10-completion.json +++ b/docs/contracts/send-chain-r10-completion.json @@ -1,6 +1,6 @@ { "version": "R10", - "generatedAt": "2026-07-31", + "generatedAt": "2026-08-03", "source": "api/src/send-chain/send-chain.service.ts at R9 local baseline", "facade": "api/src/send-chain/send-completion.service.ts", "methods": [ @@ -67,7 +67,7 @@ { "name": "handleReceipt", "file": "send-receipt.service.ts", - "bodySha256": "e3b63dc74af50db96d899c805124c7974067ca729e44a1ec8a558569e81fbd6c" + "bodySha256": "7302c72f2f3f3e374f38c365234f1a9705e80150de750d6fee73c22856efee46" }, { "name": "recordReceiptSegment", @@ -77,7 +77,7 @@ { "name": "aggregateReceiptSegments", "file": "send-receipt.service.ts", - "bodySha256": "7dc565bd0847462bd0e23b5f072a4f82fd6daa88f164485138ce653d8cf936b8" + "bodySha256": "865a66025c8320096fac36c827b34bd612ccb34a11d523fd8590cdd4491da523" }, { "name": "resolveReceiptMessage", @@ -177,7 +177,7 @@ { "name": "queueAndTryDownstreamDelivery", "file": "send-downstream-delivery.service.ts", - "bodySha256": "0b5f6f5e9ffebad5ddda003962efdabb0daeb2ec5942a246bdf9d922f274d4ac" + "bodySha256": "190b4c332e5913ef5ec44a25694270ef6320b3203df347bd25ef40f22cc0d7d9" }, { "name": "resolveUplinkMatch", @@ -187,7 +187,7 @@ { "name": "recordCmppFailureReceipt", "file": "send-downstream-delivery.service.ts", - "bodySha256": "d48ecabfcce62787668486a8f14b3ea7afc935d5d5d910651fc1ddf8d8ac3952" + "bodySha256": "030175c6265820e62350a1527270627f90404df343ace9faf42e929fc00db790" }, { "name": "postGatewayControl", @@ -197,7 +197,7 @@ { "name": "markUnknownTimeout", "file": "send-timeout.service.ts", - "bodySha256": "6f254ffbf05067483801cdd2c131f32cb9aaaf755d19f3aff065fe99b8d7753b" + "bodySha256": "21a83514324e383e11c0c3c369f0478d3d09dba7c52e479685141a4d9a1fd9d2" }, { "name": "markExpiredDownstreamDeliveries", diff --git a/docs/contracts/send-chain-r8-pure-logic.json b/docs/contracts/send-chain-r8-pure-logic.json index a415d77..2964d73 100644 --- a/docs/contracts/send-chain-r8-pure-logic.json +++ b/docs/contracts/send-chain-r8-pure-logic.json @@ -20,7 +20,7 @@ { "name": "GatewayInboundSubmitDto", "kind": "interface", - "sha256": "28f99a1e29385b64c3a6b4eab2e5befa3fc6cd8a6995090e0865f5af6871246a" + "sha256": "9662a33869bb44dacf7a56d1b907e31a7f6c1b9129e536cae2f234f1087074e3" }, { "name": "GatewayInboundSingleSubmitResult", @@ -257,7 +257,7 @@ { "name": "drainageRejectionReason", "kind": "function", - "sha256": "4bc0aa5e5c7f96549cd8bad93d3b25fd5e65e6b017ae9bf83baa5c47cab08c02" + "sha256": "98308cc1e9cb4d8979da0484992d0ba272046e122912b68cfc13b33ac80bf7f2" }, { "name": "statusFromRisk", diff --git a/docs/contracts/send-chain-r9-submission.json b/docs/contracts/send-chain-r9-submission.json index bb705a5..41db7a0 100644 --- a/docs/contracts/send-chain-r9-submission.json +++ b/docs/contracts/send-chain-r9-submission.json @@ -1,12 +1,12 @@ { "version": "R9", - "generatedAt": "2026-07-31", + "generatedAt": "2026-08-03", "source": "api/src/send-chain/send-chain.service.ts at R8 local baseline", "target": "api/src/send-chain/send-*.service.ts via send-submission.service.ts compatibility facade", "methods": [ { "name": "createBatchTask", - "bodySha256": "c7baccb2b49a95ef4b36a97f6f56c987f0ac60c45ed634cd050201f2a9bce8b5", + "bodySha256": "7ba5573e99f0616a7e57f53c242c60f3ec96d66a5164bbb170f177805ddfe9b0", "file": "send-batch-entry.service.ts" }, { @@ -66,7 +66,7 @@ }, { "name": "submitInboundMessage", - "bodySha256": "281e33a732f80e4ede6cc9d04a5b206a9348e17e66dee33d53f2d8d1d9bb9472", + "bodySha256": "dfe4a7516f9472870957e40acbe74fb7c8f76dd2113dda9ce06ea338241d1dba", "file": "send-inbound-entry.service.ts" }, { @@ -81,7 +81,7 @@ }, { "name": "collectInboundLongMessageFragment", - "bodySha256": "5b4e7f795966462fccb8c4c7a2cfae649ef19ee1b8176e7356eb2d621526ba41", + "bodySha256": "ac157c4848e12d46b98d46a8fbb13b65c6f18ae6dd2de3340d68f8bfa6908f63", "file": "send-inbound-entry.service.ts" }, { @@ -91,7 +91,7 @@ }, { "name": "submitInboundSingleMessage", - "bodySha256": "df97548b1710b217f118cc297104d6c5cc845acc40947df4180036b8d284d847", + "bodySha256": "6fa4c577848eb06f635a8f186d1e1510b1213f0ba6d439d032ea08c593725d14", "file": "send-inbound-entry.service.ts" }, { @@ -156,7 +156,7 @@ }, { "name": "resolveTemplateMessageClassification", - "bodySha256": "f2b99e2e9d7fa00172b3cca72f080493fbf6f090f378784611388320254575f7", + "bodySha256": "33c02992cc542862382456ffc4674cf0359e3b3e9271cabb23bd4d6f041f17a9", "file": "send-batch-entry.service.ts" }, { diff --git a/docs/contracts/shared-components-r11.json b/docs/contracts/shared-components-r11.json index 2db3531..00b9900 100644 --- a/docs/contracts/shared-components-r11.json +++ b/docs/contracts/shared-components-r11.json @@ -52,6 +52,9 @@ "ui-modal__header", "ui-modal__body", "ui-modal__footer" + ], + "src/components/ui/DateRangeInput.tsx": [ + "ui-date-range-field" ] }, "requiredRuleFragments": [ @@ -105,6 +108,19 @@ "gap:var(--space-6)" ] }, + { + "selector": ".ui-filter-row", + "includes": [ + "display:flex", + "flex-wrap:wrap" + ] + }, + { + "selector": ".ui-filter-actions .ui-button", + "includes": [ + "width:var(--query-action-width)" + ] + }, { "selector": ".form-grid--two", "media": "(max-width: 780px)", @@ -112,6 +128,21 @@ "grid-template-columns:1fr" ] }, + { + "selector": ".ui-filter-actions", + "media": "(max-width: 780px)", + "includes": [ + "grid-template-columns:repeat(2, minmax(0, 1fr))", + "width:100%" + ] + }, + { + "selector": ".ui-filter-actions", + "media": "(max-width: 360px)", + "includes": [ + "grid-template-columns:minmax(0, 1fr)" + ] + }, { "selector": ".table-actions", "media": "(max-width: 780px)", diff --git a/docs/contracts/sms-config-r3-methods.json b/docs/contracts/sms-config-r3-methods.json index 1b0d54b..0e955fe 100644 --- a/docs/contracts/sms-config-r3-methods.json +++ b/docs/contracts/sms-config-r3-methods.json @@ -1,7 +1,7 @@ { "version": "R3", "source": "api/src/sms-config/sms-config.service.ts", - "generatedAt": "2026-07-31T01:56:36.226Z", + "generatedAt": "2026-08-03T15:32:00+08:00", "publicMethods": [ { "name": "onModuleInit", @@ -204,8 +204,8 @@ { "name": "listSignatures", "signature": "async listSignatures(queryOrTenantId?: string | SignatureListQuery)", - "bodySha256": "9e8d363c2ee1022f429a9fa16e5e933f334a528e640f49ccd83e0cfc7686aea6", - "canonicalBodySha256": "91e26f503140018b00afe5563a4f86d22117c2805ac86532559ac44f1360d8a2", + "bodySha256": "bb4d8d983ed5cda59235b39396d09a568a554ad72c564ef4daab2d92dac6633a", + "canonicalBodySha256": "bb4d8d983ed5cda59235b39396d09a568a554ad72c564ef4daab2d92dac6633a", "originalLines": [ 915, 1025 @@ -215,8 +215,8 @@ { "name": "listSignaturesPage", "signature": "async listSignaturesPage(query: SignatureListQuery)", - "bodySha256": "24e161ae5049af0689d3a9d0e4ab802964b87b1f2d407d8309b8e6cbe8afdd56", - "canonicalBodySha256": "34ee94d016bdc986d98b01612f4c64fdc03b0e921abedeff8fe84bd64dec958c", + "bodySha256": "b3554a30debe1457938985f211c9107a4ca26b28581509b523810a45bb6f432a", + "canonicalBodySha256": "b3554a30debe1457938985f211c9107a4ca26b28581509b523810a45bb6f432a", "originalLines": [ 1027, 1058 @@ -325,8 +325,8 @@ { "name": "listDrainageInfos", "signature": "listDrainageInfos(query: DrainageInfoListQuery = {})", - "bodySha256": "2841e83c70180cdce83943d9d6ae212a9d29c6eae701ecf928bdbaedbdc93457", - "canonicalBodySha256": "0de53393ca5f91da7a521c13a96813b1f8211ded00f0fa797b752b6a87b73148", + "bodySha256": "99f53b52b01b3f75fe85886a1b62c98d1ae7fa72afb11eae98bbdc88103616c8", + "canonicalBodySha256": "99f53b52b01b3f75fe85886a1b62c98d1ae7fa72afb11eae98bbdc88103616c8", "originalLines": [ 1302, 1319 @@ -413,8 +413,8 @@ { "name": "listTemplates", "signature": "listTemplates(queryOrTenantId?: string | TemplateListQuery)", - "bodySha256": "c7c9b9b6f03c046c94459d021604dcce3b5d9d2ce192d547bce9d31310b27a7f", - "canonicalBodySha256": "e276e9f029b2cb5d5cf9551959f0525a9c501fefc97bf7a9300ba9d6554aa714", + "bodySha256": "0cd3e2ab2856057243722f323915cc4e905e3af888f8f70d8a9f164a07ebb234", + "canonicalBodySha256": "0cd3e2ab2856057243722f323915cc4e905e3af888f8f70d8a9f164a07ebb234", "originalLines": [ 1558, 1583 @@ -424,8 +424,8 @@ { "name": "listTemplatesPage", "signature": "async listTemplatesPage(query: TemplateListQuery)", - "bodySha256": "a0a7baafc68189e2daf93b970826f7d60ee60aa35662f48eeb7b4cb4e119b021", - "canonicalBodySha256": "2e836e44a397557b95cb0d978745df82c9c86cf77360157e78382ca71c0156a0", + "bodySha256": "c4b8553dd957e8471cd6ccb970162694f2db62141c66b5b3014220e0eeb866cc", + "canonicalBodySha256": "c4b8553dd957e8471cd6ccb970162694f2db62141c66b5b3014220e0eeb866cc", "originalLines": [ 1585, 1608 @@ -833,7 +833,7 @@ }, { "name": "DrainageInfoListQuery", - "sha256": "9cd9cfe5af9422ed64d5d00071c9b4c58b0ae820c43eeb7dad604b67fd5f6fc1" + "sha256": "40708e60bfa39481897d00a8b2e99583effdffdd588efd99ee9b73687eae82ae" }, { "name": "CreateSignatureMaterialDto", @@ -861,7 +861,7 @@ }, { "name": "TemplateListQuery", - "sha256": "06c34488ffaa2d870d781fc2f666e294c408eefce1acb1ece11727f6aabc76c2" + "sha256": "9d38ad4689f0cda80c3d921a3a05e93cdf0b8a7696c782d5d9541966347bf78a" }, { "name": "ApplicationListQuery", @@ -869,7 +869,7 @@ }, { "name": "SignatureListQuery", - "sha256": "619cbff41cf63f18e5732ebfc8f899c5fd5c3394f7c54d9788e0561bc25eb027" + "sha256": "04cd16b62df3c381cd6c0ef873fb192e56fb745dd1adf1bdf550a8800aad2256" }, { "name": "GatewayDownstreamConnectionEventDto", diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 6e1fc4e..6d72af3 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -16,6 +16,7 @@ - 企业应用连接详情只展示当前已连接会话;断开或心跳超时会话从活跃连接表删除,不保留为连接历史。 - 短信审核批量通过必须基于明确勾选,只处理已选待审核任务;不得默认通过当前筛选结果或全部数据。 - 下游投递记录支持创建日期范围筛选,并将日期条件下推 PostgreSQL 列表与 Dashboard 聚合。 +- 下游投递记录和恢复状态管理的多条件筛选区必须遵循共享查询控件规范:普通条件、日期范围和操作按钮使用统一宽度,空间不足时按完整控件自动换行,不允许为维持单行而压缩、重叠或截断控件;移动端条件整行展示,查询与重置按钮保持清晰的独立操作区。 - 短信模板变量插入到文本框当前选区或光标位置,插入后光标移动到变量之后。 - 运营端短信记录使用自适应信息卡片,发送详情按概览、短信内容、通道回执、状态和分片审计分组,失败原因使用独立警示区域突出;该项只调整前端展示,不改变短信记录后端语义。 - 人工充值弹窗不要求填写操作人,操作身份从当前登录会话和后端操作日志取得。 @@ -1691,7 +1692,7 @@ 4. 通讯日志不得保存短信正文、密码、密钥、Token、签名鉴权值或完整HTTP请求体;手机号只保存脱敏值。CMPP心跳不得逐包写入数据库,连接健康仍使用连接状态和聚合指标。 5. 通讯日志写入不能阻塞短信主链路,默认批量异步写入,缓冲区应有上限和溢出告警;热数据默认保留30天,保留期允许通过环境变量配置。 6. 通讯日志方向固定使用“企业应用 → 平台、平台 → 供应商通道、供应商通道 → 平台、平台 → 企业应用”。供应商长短信每个真实 `SUBMIT` 和 `SUBMIT_RESP` 分片各记一条,企业应用每个真实 `SUBMIT_RESP` 也必须记录;内部 `submit-result` 聚合回调不是协议报文,不得重复生成通讯日志。 -7. 供应商长短信回执必须先写入对应 `SmsMessageSegmentAudit`。仅当同一提交尝试的全部分片均为 `delivered` 时,主记录才转 `delivered` 并向企业应用投递一次最终回执;任一分片明确失败可进入最终失败/补发状态,分片尚未齐全时主记录保持 `submitted`,不得由首片成功提前聚合。 +7. 供应商长短信回执必须先写入对应 `SmsMessageSegmentAudit`。仅当同一提交尝试的全部分片均为 `delivered` 时,主记录才转 `delivered`;任一分片明确失败可进入最终失败/补发状态,分片尚未齐全时主记录保持 `submitted`,不得由首片成功提前聚合。内部业务终态只聚合一次,但对企业应用的CMPP状态报告必须按其原始Submit分片逐片投递,并分别使用平台当初为该分片返回的`CMPP_SUBMIT_RESP.Msg_Id`;HTTP Webhook仍按原HTTP消息投递一个最终事件。 8. 长短信任一分片返回非成功终态时,系统必须通过该分片审计关联的提交记录识别当前发送尝试,不得仅以主记录保存的首片上游消息号判断;确认属于当前尝试后,整条短信立即进入失败/补发或退款终态,无需等待其余分片回执。 9. 回执和上行投递方式不得由运营人员选择。企业应用开通CMPP接口即按CMPP投递,开通HTTP接口且对应Webhook地址非空即按HTTP投递,两者同时满足时双投;任一地址为空时只跳过该类HTTP事件。运营端企业应用HTTP参数页必须始终可编辑回执和上行Webhook地址,不因HTTP接口开关关闭而隐藏。 10. Gateway向企业应用发送真实 `CMPP_DELIVER` 以及收到企业应用真实 `CMPP_DELIVER_RESP` 时,都必须各写一条通讯交互日志,分别使用“平台→企业应用”和“企业应用→平台”方向;下游投递记录继续承担排队、重试和ACK业务状态,不得以通讯日志替代。 @@ -1709,7 +1710,8 @@ 1. 用户逻辑删除后,原用户记录、用户主键及历史审计关联必须继续保留;用户名、邮箱和手机号仅在未删除用户范围内唯一。新建用户可以复用逻辑删除记录曾使用的登录标识,但必须生成新的用户主键,不得继承旧用户的角色、企业归属、密码、会话或权限。 2. 用户登录和按用户名查找必须显式排除逻辑删除记录。活动用户之间的登录标识并发冲突继续由PostgreSQL唯一索引保证,并返回HTTP 409、冲突字段及明确中文提示。 3. 运营端用户管理将用户姓名、登录账号、所属企业、用户角色和状态拆分为独立条件;客户端将用户姓名、登录账号和状态拆分。点击查询或重置时调用真实后端组合查询,条件之间使用AND,登录账号内部对用户名、邮箱和手机号使用OR;不得加载全量数据后仅在浏览器过滤。 -4. 删除、禁用或降权最后一个平台管理员时,后端实时拦截继续作为权威判断;企业管理员不设“最后一名”保护,可删除或停用至零人。前端必须在当前确认弹窗内以`role=alert`显示平台管理员保护失败原因和处理建议,保持弹窗打开,禁止只向浏览器控制台输出未处理Promise;请求期间确认按钮必须禁用。 +4. 运营端用户管理的五组查询条件与查询/重置操作必须使用共享查询控件宽度并允许按完整控件自然换行;不得通过缩窄字段把所有条件强塞在一行。“新增用户”作为业务操作与查询操作保持独立,在窄屏下不得挤入查询字段之间。 +5. 删除、禁用或降权最后一个平台管理员时,后端实时拦截继续作为权威判断;企业管理员不设“最后一名”保护,可删除或停用至零人。前端必须在当前确认弹窗内以`role=alert`显示平台管理员保护失败原因和处理建议,保持弹窗打开,禁止只向浏览器控制台输出未处理Promise;请求期间确认按钮必须禁用。 ## 2026-07-26 企业应用停用与回执清算补充要求 - 删除企业前必须检查其企业应用;只要存在`active`或`disabling`应用就阻止删除,并提示先完成应用停用。 @@ -1768,7 +1770,7 @@ 1. 同一`SmsSubmitRecord`无论收到多少个分片失败回执、重复聚合结果或并发工作线程,只允许创建一个下一跳补发记录。数据库必须以来源提交记录建立唯一补发关系,不能只依靠进程内锁、先查后建或短信主记录当前状态判断。 2. 任一分片明确失败仍可及时判定本次长短信尝试失败,不要求等待其余失败回执;后续分片回执继续完整落库和写通讯日志,但只能复用已取得的补发决定,不得再次向Gateway发布提交命令。 3. 短信扣费、冻结释放和最终失败退款必须使用稳定业务幂等键。企业账户余额变更在数据库事务内按企业串行并使用原子增量,禁止读取旧余额后覆盖写入;相同幂等键重复调用必须返回原交易,不得再次改变余额。 -4. 同一短信记录只允许生成一条最终回执投递。CMPP下游投递使用数据库唯一去重键,HTTP Webhook使用稳定事件号和事件/端点唯一关系;重复终态处理返回原投递,不得再次向客户发送。 +4. 同一短信记录只允许形成一个内部最终业务结论、一笔最终退款和一个HTTP最终回执事件。CMPP下游必须按原始客户Submit分片分别生成最终状态报告,使用“短信记录+原始客户分片序号”数据库唯一键;同一分片重复终态处理返回原投递,不得再次发送。HTTP Webhook继续使用短信记录级稳定事件号和事件/端点唯一关系。 5. 补发抢占成功、并发复用及下游投递去重必须写结构化日志,至少包含平台消息号、短信记录、来源提交记录、下一跳`submitId`、通道和复用的投递记录。 6. 历史重复提交、通讯报文、回执、退款及客户ACK属于事故审计证据,不得在功能migration中删除或覆盖。历史余额修正必须先完成账户与流水专项对账,再通过可审计冲正处理。 @@ -1873,3 +1875,32 @@ - 短信发送页的字数和预计条数必须随编辑后的实际短信内容即时重新计算;单条短信不超过70个Unicode字符计1条,长短信按每67个字符计费,预计条数为单号码计费条数乘有效号码数。单价单位显示为“元/条”。 - 提交发送任务失败时使用页面中央弹窗展示真实后端错误;定时发送控件提供按北京时间计算的“今天”按钮,并以浅色背景标出北京时间当天。提交给后端的定时时间必须显式携带`+08:00`时区。 - 提交任务成功后弹窗展示真实任务编号和发送号码数。“继续发送短信”清空当前发送表单和导入内容;“查看任务进度”进入批量任务页面并按任务编号定位本次任务。 + +## 工作台金额与审核查询控件统一(2026-08-03) + +- 客户端短信服务工作台在“今日返还金额”前展示“今日消费金额”,金额取当前企业 Dashboard API 按北京时间当天汇总的真实消费值,与运营端账务口径一致,不在浏览器重新估算。 +- 审核中心的企业认证、短信、短信模板、短信签名和引流信息审核页面均提供“提交时间”日期区间筛选;短信签名和引流信息的“导入批次审核”页签也按导入批次提交时间筛选。 +- 提交时间筛选必须传入真实后端并由 PostgreSQL 执行。日期边界按 `Asia/Shanghai` 自然日解释,开始日包含 `00:00:00`,结束日包含 `23:59:59.999`;格式非法、日历日期不存在或开始日晚于结束日时,API 返回受控参数错误。 +- 运营端查询区建立全局统一尺寸:普通输入框/下拉框使用统一标准宽度,日期区间控件使用更宽的统一宽度,查询/重置按钮使用统一固定宽度;窄屏下控件和按钮自适应整行,不产生页面级横向溢出。 +- 风控规则页的规则范围、平台白名单和号码频次触发记录三组查询条件均使用上述统一布局。白名单和触发记录提供查询、重置,重置后以明确的默认条件重新请求真实后端。 + +## 短信内容引流信息识别与统计(2026-08-03) + +- 本期只识别、记录、查询和统计短信内容是否含引流信息。引流资料是否已报备、审核状态及报备进度均不得拦截或转人工审核;所有发送入口移除`DRAINAGE_NOT_APPROVED`决策,既有模板、签名、余额、黑名单和其他风控规则保持不变。 +- 运营端“系统管理”新增“引流识别规则”页面,规则存储在真实数据库,支持 URL、手机号码、固定电话三类表达式的新增、编辑、启停、优先级和测试。变更保留版本并写操作日志,发送入口按当前启用规则生成识别快照和规则版本。 +- URL 识别覆盖带协议链接、无`http://`的裸域名、短链接、IP 地址及端口/路径,并支持中文标点相邻、空格或中文句号拆分等规避写法;手机号码支持`+86`、空格、短横线和中文标点拆分;固定电话支持区号括号、分隔符和分机号。邮箱地址不属于引流信息。 +- 识别规范化只作用于检测副本,不得修改真实短信发送内容。消息记录持久化是否含引流、命中类型、原文位置、规则版本和检测时间;历史未检测数据保留为“未检测”,不得伪造为不含引流。 +- 运营端短信记录提供“是否含引流信息”筛选,支持含引流、不含引流和未检测;含引流记录使用提示色底色并高亮原文命中片段,CSV 同步导出该维度。 +- 数据统计的签名发送质量明细保留原“通道 × 运营商”整体矩阵,并提供按含引流、不含引流、未检测切分的矩阵视图;整体统计必须直接反映全部提交,不得用分组平均值替代。 +- 运营看板“今日签名发送统计”按签名统计全部短信,不区分是否含引流;“今日签名发送统计 - 含引流”只统计内容识别结果为含引流的消息。 +- 未来可在识别结果之上增加发送、拒绝或人工审核决策,但必须作为独立迭代启用,不能在本期以隐式状态或既有报备审核逻辑提前拦截。 + +## CMPP逐分片最终回执与72小时超时回执(2026-08-03) + +- CMPP协议状态值中的`ACCEPTD`、`UNKNOWN`不得在产品和代码中扩展解释为协议规定的“临时状态生命周期”;平台只依据明确业务规则判断是否继续等待,不能臆造协议阶段。 +- Gateway接收每个客户`CMPP_SUBMIT`时必须保存该包的`Registered_Delivery`和`Sequence_Id`。长短信每个原始分片的身份独立保存,不得只保留第一片;历史未保存`Registered_Delivery`的CMPP记录按原有“请求回执”行为兼容。 +- 内部仍以一条`SmsMessageRecord`聚合长短信业务终态、重投、计费和退款;对外CMPP状态报告按原始客户分片逐片建立。每片使用`SubmitGroupMessageId + 原Sequence_Id`重建平台当初返回的`SUBMIT_RESP.Msg_Id`,并以“短信记录+客户分片序号”独立幂等。 +- 已取得真实分片回执时,下游对应分片必须使用该片自己的状态、原始状态码、错误码和到达时间,不得把另一片的结果复制过来;整条短信明确最终失败而部分片仍无结果时,缺失片使用整条短信的明确最终失败状态,已成功片不得改写为失败。 +- `Registered_Delivery=0`的客户分片不生成CMPP状态报告;同一HTTP提交仍只生成一个消息级最终Webhook,不因供应商内部计费分片数而重复回调。 +- 提交或未知状态满72小时仍无明确最终回执时,主记录转为`timeout`并写入`undelivered/EXPIRED/RECEIPT_TIMEOUT`。CMPP对每个请求状态报告的原始分片建立失败回执,HTTP建立一个明确失败Webhook;退款保持消息级一次。 +- 超时状态变更与下游回执建单之间必须可恢复:仅在HTTP事件及全部应建CMPP分片投递均成功持久化后写`timeoutReceiptQueuedAt`;中途失败保留空标记,由后续定时扫描按稳定幂等键补齐,禁止出现“已转超时但永久没有下游回执”。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index c3552c5..a2279b5 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -1447,6 +1447,7 @@ - `awaiting_ack` 记录在前端不可选且后端拒绝并发重投,不能仅依赖按钮禁用。 - 确认弹窗明确展示投递类型、消息 ID 和重复处理风险;取消时不得请求后端,确认提交期间操作按钮禁用并显示处理中状态。 - 单条成功弹窗展示真实返回的当前状态和 `manualRetryCount`;接口失败时弹窗展示错误,并提醒先刷新核对人工次数再决定是否重试,不能静默失败或诱导重复提交。 + - 筛选区使用平台共享查询控件宽度;桌面端空间不足时条件按完整控件自然换行,不得把关键字、日期、状态、类型、应用和操作按钮挤压在同一行;移动端条件整行展示,查询与重置按钮清晰可操作。 ### TC-GW-015 下游投递指数退避 @@ -1598,6 +1599,7 @@ - 页面刷新后恢复状态仍然存在,可继续用于生产排查。 - 页面解释恢复状态与逐条下游投递记录的用途差异;恢复状态和下游投递记录默认均选择近 7 天。 - 恢复状态按更新时间区间筛选,摘要、失败分类、列表和 CSV 导出使用同一时间口径;列表标题与外框保持正常内边距,最后错误/跳过原因列具备可读宽度。 + - 筛选区复用平台共享查询控件宽度;关键字、日期、状态、失败分类、应用和操作按钮在桌面端按可用空间自然换行,移动端条件整行展示,不出现控件压缩、重叠、截断或按钮混入字段的问题。 ### TC-GW-025 多 Gateway 恢复抢占协调 @@ -3445,7 +3447,7 @@ npm run verify:phase8 | TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 | | TC-BILLING-011 | 分别准备 `余额+授信` 为正数、0 和负数的账户,使用相同短信费用发起发送。 | 和为正数时允许发送;和为 0 或负数时提示余额不足。判断公式为 `balanceCents + creditCents > 0`,与本次费用和套餐无关。 | | TC-BILLING-012 | 已扣费短信收到最终失败回执;另一个消息在提交前失败并释放冻结;另准备一笔任务冻结转扣费时的批次级释放。 | 最终失败只生成一条 `refunded` 并计入“今日返还”,重复回执不重复退款;提交前失败生成 `released + relatedType=sms_message_record` 并计入“今日返还”;冻结转扣费的 `released + relatedType=sms_batch_task` 属于内部转换,不计入“今日返还”;客户端和运营端当日金额一致且保留三位小数。 | -| TC-BILLING-013 | 准备已提交扣费但 72 小时完全无回执的 `submitted` 短信,以及有 `UNKNOWN` 回执且超过 72 小时的短信;启动 API 定时扫描并模拟重复扫描。 | 两类短信都转为 timeout 并退款;任务进度刷新;同一短信只退款一次;定时扫描默认启用且每 5 分钟执行。 | +| TC-BILLING-013 | 准备已提交扣费但 72 小时完全无回执的 `submitted` 短信,以及有 `UNKNOWN` 回执且超过 72 小时的短信;分别覆盖HTTP提交、CMPP短短信和多分片长短信,启动 API 定时扫描并模拟投递建单失败后重复扫描。 | 两类短信都转为 timeout、写入`undelivered/EXPIRED/RECEIPT_TIMEOUT`并只退款一次;HTTP产生一个明确失败Webhook,CMPP对每个请求回执的原始分片产生失败状态报告且使用各自SubmitResp Msg_Id;建单未完成时`timeoutReceiptQueuedAt`保持空并由后续扫描补齐,成功建单后不重复;任务进度刷新。 | | TC-BILLING-014 | 在运营端充值记录中分别打开整数金额、含1至4位有效小数、负数冲正以及缺少可追溯余额的真实订单回执。 | 每行提供“查看回执”;弹窗左上只使用系统真实Logo;企业、订单号、时间、备注与数据库订单一致;可追溯订单的入账前余额等于入账后余额减本次变动;无快照时前后余额不得伪造;主金额整数不显示小数,非整数仅显示有效小数,余额仍显示四位精度;正数显示已入账,负数显示已冲正。 | | TC-SEC-006 | 安装 API 生产依赖并执行 `npm audit`;使用缺文件、多文件、超大文件、超量字段和正常单文件调用认证后的 multipart 上传接口。 | NestJS/Multer/Hono 已升级或锁定到修复版本,生产依赖 audit 为 0;接口只接受一个不超过 20MB 的文件,并限制字段、part、字段名、字段值和 header pair 数量;异常请求返回受控 4xx,正常文件仍写入真实 MinIO 和 `FileObject`。 | @@ -3706,7 +3708,7 @@ npm run verify:phase8 | --- | --- | --- | | TC-USER-REUSE-001 | 新建用户名`zhaohui`,逻辑删除后再次使用同一用户名、邮箱或手机号新建用户。 | 新用户创建成功且主键与旧用户不同;旧用户及其OperationLog、审核关联保持原用户主键;登录只命中新用户。 | | TC-USER-REUSE-002 | 两个未删除用户并发提交相同用户名、邮箱或手机号。 | PostgreSQL仅允许一个请求成功,另一个返回HTTP 409、`USER_DUPLICATE`、冲突字段和中文提示,不产生两个活动账号。 | -| TC-USER-FILTER-001 | 在运营端分别及组合填写用户姓名、登录账号、所属企业、用户角色和状态,点击查询,再点击重置。 | 每次操作请求真实`GET /api/admin/users`;条件分别生效,组合使用AND,登录账号匹配用户名/邮箱/手机号;重置返回全部未删除用户。 | +| TC-USER-FILTER-001 | 在运营端分别及组合填写用户姓名、登录账号、所属企业、用户角色和状态,点击查询,再点击重置;分别使用桌面和移动视口检查筛选布局。 | 每次操作请求真实`GET /api/admin/users`;条件分别生效,组合使用AND,登录账号匹配用户名/邮箱/手机号;重置返回全部未删除用户。五组条件按共享宽度自然换行,不被压缩或截断;移动端条件整行展示,查询/重置与“新增用户”分区清晰且均可操作。 | | TC-USER-FILTER-002 | 在客户端分别及组合填写用户姓名、登录账号和状态。 | 请求真实`GET /api/client/users`;只返回当前企业管理员,无法通过查询参数跨租户或查询平台管理员。 | | TC-USER-CONTINUITY-UI-001 | 删除、禁用或降权最后一个平台管理员,再删除或停用某企业最后一个启用管理员。 | 平台管理员操作由后端权威拦截并在确认弹窗显示建议;企业管理员操作成功且可归零;按钮结束忙碌状态,浏览器无未处理Promise。 | | TC-USER-CONTINUITY-UI-002 | 为相同范围增加另一名启用管理员后重复删除或禁用。 | 操作成功、弹窗关闭、列表按当前已应用查询条件刷新,并写入对应OperationLog。 | @@ -3851,9 +3853,9 @@ npm run verify:phase8 - `TC-PROTOCOL-LOG-008`:一条真实短短信取得成功状态报告后,按同一平台消息号查询应恰好看到四个供应商侧真实业务报文:`平台→通道/CMPP_SUBMIT`、`通道→平台/CMPP_SUBMIT_RESP`、`通道→平台/CMPP_DELIVER`、`平台→通道/CMPP_DELIVER_RESP`;每个报文只出现一条,箭头与抓包传输方向一致,长短信则按实际分片分别记录Submit/SubmitResp。 - `TC-PROTOCOL-LOG-009`:企业应用提交短信时,入站Submit显示“企业应用→平台”,每个实际返回的SubmitResp显示“平台→企业应用”;供应商侧统一显示“平台→供应商通道/供应商通道→平台”,不得再使用含义模糊的客户/通道箭头。 - `TC-RECEIPT-SHARED-010`:供应商账号、Gateway主机、端口、协议和CMPP版本均相同的两个物理通道连接中,回执从副连接进入、原连接存在唯一`gatewayMessageId + DestTerminalId`分片候选时,应写入原提交逻辑通道;账号或端点任一不同、或候选超过一条时不得自动匹配。 -- `TC-RECEIPT-LONG-011`:两分片长短信仅收到第一片`DELIVRD`时,`SmsMessageRecord`保持`submitted`且不创建企业应用最终回执;第二片到达后两条分片审计均为`delivered`,主记录只聚合一次为`delivered`,重复回执不得重复投递、扣费或退款。 +- `TC-RECEIPT-LONG-011`:两分片长短信仅收到第一片`DELIVRD`时,`SmsMessageRecord`保持`submitted`且不创建企业应用最终回执;第二片到达后两条分片审计均为`delivered`,主记录只聚合一次为`delivered`,CMPP按两个原始分片各创建一条`DELIVRD`且分别使用两个SubmitResp Msg_Id,HTTP只创建一个最终事件;重复回执不得重复投递、扣费或退款。 - `TC-PROTOCOL-LOG-012`:供应商长短信每个真实分片分别产生一条`平台→供应商通道/CMPP_SUBMIT`和一条`供应商通道→平台/CMPP_SUBMIT_RESP`;内部`submit-result`聚合回调不得额外落协议日志。 -- `TC-RECEIPT-LONG-013`:两分片长短信主记录保存首片上游消息号,第二片返回`YL:1014`等任意非成功状态且首片未回执;系统通过第二片审计识别当前提交尝试,整条短信进入失败/补发或退款终态并只投递一次最终失败回执,不再卡在`submitted`。 +- `TC-RECEIPT-LONG-013`:两分片长短信主记录保存首片上游消息号,第二片返回`YL:1014`等任意非成功状态且首片未回执;系统通过第二片审计识别当前提交尝试,整条短信进入失败/补发或退款终态,不再卡在`submitted`;最终不再补发时,对两个请求回执的原始客户分片分别投递失败状态报告,Msg_Id与各自SubmitResp一致。 - `TC-DELIVERY-AUTO-014`:分别配置仅CMPP、仅HTTP、CMPP+HTTP、两者均关闭四种应用状态;回执与上行分别只产生CMPP下游记录、HTTP Webhook事件、两者各一条、均不产生。修改历史手工投递模式不得改变自动计算结果。 - `TC-HTTP-WEBHOOK-015`:运营端关闭HTTP接口后,回执和上行Webhook地址输入框仍显示且可保存;任一地址保存为空时删除对应有效端点,后续不推送该类HTTP事件,另一非空地址不受影响。 - `TC-PROTOCOL-LOG-016`:在线企业应用收到回执或上行 `CMPP_DELIVER` 并返回 `CMPP_DELIVER_RESP`;通讯日志各出现一条“平台→企业应用/DELIVER”和“企业应用→平台/DELIVER_RESP”,结果、消息号、序列号和投递记录一致,下游投递记录仍独立展示发送、ACK和重试状态。 @@ -3934,10 +3936,10 @@ npm run verify:phase8 |---|---|---| | TC-RETRY-RACE-001 | 三分片长短信的三个失败回执并发进入API,备用通道可用 | 三个回执和通讯报文全部保存;来源提交记录只关联一个补发记录,只发布一个Gateway命令、三个补发分片 | | TC-RETRY-RACE-002 | 三个线程在唯一补发记录提交前后交错执行 | 只有一个线程取得`retryOfSubmitRecordId`唯一关系;其他线程返回同一下一跳`submitId`并写复用日志,不退款、不生成最终回执 | -| TC-RETRY-RACE-003 | 三个失败回执并发处理且没有可用备用通道 | 主记录最终失败;只产生一笔退款交易和一次余额增量,只生成一个CMPP最终失败回执及一个HTTP回调事件 | +| TC-RETRY-RACE-003 | 三个失败回执并发处理且没有可用备用通道 | 主记录最终失败;只产生一笔退款交易和一次余额增量;HTTP只生成一个最终失败事件,CMPP对三个原始客户分片各生成一条失败回执且每片只生成一次 | | TC-BILLING-IDEM-004 | 三个线程使用同一短信退款幂等键并发退款 | 三次调用返回同一交易ID,`AccountTransaction`只有一条,账户余额只增加一次 | | TC-BILLING-ATOMIC-005 | 同一企业同时发生扣费、退款和充值 | 账户级事务锁串行化余额变更,使用数据库原子增量;每条流水`balanceAfter`连续且最终余额与流水一致 | -| TC-DOWNSTREAM-IDEM-006 | 同一短信终态被重复处理,企业同时启用CMPP和HTTP | CMPP只有一条`CmppDownstreamDelivery`且只发送一次;HTTP只有一个稳定事件和一条端点投递 | +| TC-DOWNSTREAM-IDEM-006 | 同一长短信终态被重复处理,企业同时启用CMPP和HTTP | CMPP每个请求回执的原始客户分片各有一条稳定`CmppDownstreamDelivery`且各只发送一次;HTTP只有一个稳定事件和一条端点投递 | | TC-MIGRATION-IDEM-007 | 在含历史重复最终回执的预生产数据上执行migration | 历史行全部保留;每个短信只给最早一条历史回执设置唯一键,其余保持空键;新数据开始强制唯一 | ## 2026-07-27 通道报备发送统计用例 @@ -4251,9 +4253,9 @@ npm run verify:phase8 | TC-REFACTOR-R10-005 | 分片未收齐、全部成功、明确失败或全部未知 | 未收齐不提前成功;全部成功才最终成功;明确失败优先;未知按既有超时和失败口径处理 | | TC-REFACTOR-R10-006 | 两个执行者并发抢占同一失败短信补发 | 来源提交唯一关系、事务条件更新和P2002唯一冲突处理保证最多创建一个新提交尝试 | | TC-REFACTOR-R10-007 | 重复执行成功扣费、失败退款或预占释放 | 账务使用原稳定幂等键,同一业务事件只产生一次账户流水,余额和预占不重复变化 | -| TC-REFACTOR-R10-008 | 创建、认领、发送并ACK最终CMPP/HTTP回执 | 每短信只有一条最终回执语义;下游去重、attempt记录、ACK确认、超时恢复和失败分类保持 | +| TC-REFACTOR-R10-008 | 创建、认领、发送并ACK最终CMPP/HTTP回执 | 内部每短信只有一个最终业务结论;CMPP按原始客户分片投递并逐片去重,HTTP按消息投递并去重;attempt记录、ACK确认、超时恢复和失败分类保持 | | TC-REFACTOR-R10-009 | 人工重排失败下游投递或恢复陈旧认领 | 使用稳定重排键和原状态条件,已完成或正由其他执行者处理的记录不得重复投递 | -| TC-REFACTOR-R10-010 | 执行回执超时扫描 | 仅处理满足既有时间和状态条件的当前记录;扫描并发保护和终态聚合保持 | +| TC-REFACTOR-R10-010 | 执行回执超时扫描 | 仅处理满足既有时间和状态条件的当前记录;转为明确`EXPIRED`失败并为CMPP逐分片、HTTP逐消息建立可重试下游回执,建单标记未完成时后续扫描可恢复;扫描并发保护和内部终态聚合保持 | | TC-REFACTOR-R10-011 | 检查七个完成链领域的持久化操作 | 不存在删除历史事故记录的`deleteMany`路径;提交、回执、attempt、死信和账务历史继续保留 | | TC-REFACTOR-R10-012 | 对真实本地PostgreSQL查询R10八类表 | 查询前后计数完全一致;无分片或attempt样本时如实记为0,不造数、不发短信、不触发补发或重投 | | TC-REFACTOR-R10-013 | 执行SendChain定向、API全量、前后端构建、Gateway及R0~R10门禁 | 112项定向和389项API测试通过;schema、migration、事务语义、Redis契约、CMPP协议及其他业务不因R10改变 | @@ -4348,3 +4350,40 @@ npm run verify:phase8 | TC-REFACTOR-R11-084 | 打开签名、发送、企业认证、发送详情和模板代表页面 | 各单页面样式仍由原global规则托管,页面结构、真实接口和交互不因第九步改变 | | TC-REFACTOR-R11-085 | 在375×812视口检查首页、账单及一个单页面代表 | 标题、卡片、表格和操作区无新增横向溢出,控制台无相关warning/error | | TC-REFACTOR-R11-086 | 执行前端生产构建、API/Gateway全量、Prisma、安全门禁、全部R0~R11结构门禁和`git diff --check` | 29个API套件/389项测试、API构建、Gateway测试/vet及全部门禁通过;R11九步完成且业务行为不因client共享CSS迁移改变 | + +## 2026-08-03 工作台金额与审核筛选回归用例 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-CLIENT-DASHBOARD-012 | 使用客户端企业登录短信服务工作台,对比 Dashboard API、数据库当天消费聚合和页面金额卡 | “今日消费金额”位于“今日返还金额”之前,展示当前企业北京时间当天真实消费金额;刷新后与 API/数据库一致,不使用静态值或浏览器缓存计算 | +| TC-AUDIT-FILTER-014 | 依次打开企业认证、短信、模板、签名、引流信息五个审核页面,选择同一提交日期区间并查询 | 五页均显示日期区间控件;请求携带开始/结束日期,列表只返回 PostgreSQL 中提交时间位于北京时间闭区间内的记录 | +| TC-AUDIT-FILTER-015 | 在签名和引流信息审核页切换到“导入批次审核”,按提交时间查询并翻页 | 导入批次真实分页 API 同时携带日期条件,当前页和总数均受日期范围约束,不在前端仅过滤当前页 | +| TC-AUDIT-FILTER-016 | 分别只选择开始日、只选择结束日、选择跨日范围,再输入非法日期或开始日晚于结束日直接调用 API | 单边范围可正确查询;完整范围包含开始日 00:00:00 和结束日 23:59:59.999;非法或倒置范围返回受控 400,不执行无界误查询 | +| TC-QUERY-LAYOUT-009 | 在风控规则页检查规则范围、平台白名单和号码频次触发记录查询区,并执行查询与重置 | 普通控件使用统一标准宽度,条件可换行但不占满整行;查询/重置按钮等宽;重置后白名单恢复全部有效记录,触发记录恢复“拦截中”并重新请求真实后端 | +| TC-QUERY-LAYOUT-010 | 在五个审核页面及导入审核页签检查普通控件、日期区间和查询/重置按钮,并切换桌面与375×812视口 | 普通控件宽度一致,日期区间明显更宽,查询/重置按钮宽度一致;窄屏转为整行且无横向溢出、遮挡或弹层裁切 | + +## 2026-08-03 引流信息识别与统计用例 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-DRAINAGE-DETECT-001 | 在引流识别规则页新增、编辑、停用规则并刷新 | 所有操作调用真实后端并持久化到 PostgreSQL;版本递增、状态生效并留下操作日志,刷新后不丢失 | +| TC-DRAINAGE-DETECT-002 | 分别测试协议 URL、裸域名、短链接、IP:端口/路径及中文标点相邻链接 | 均识别为含引流,命中类型为 URL,保存原始内容位置;检测不会改写短信原文 | +| TC-DRAINAGE-DETECT-003 | 输入被空格、中文句号拆开的域名以及普通邮箱地址 | 规避域名仍命中;完整或带空格规避的邮箱不被当作引流 URL | +| TC-DRAINAGE-DETECT-004 | 测试`+86 138 0013 8000`、`138-0013-8000`及中文标点拆分手机号 | 均识别为手机号引流,原文片段可在短信记录中正确高亮 | +| TC-DRAINAGE-DETECT-005 | 测试`(010)8888-8888 转 123`等固话 | 区号括号、分隔符和分机号均可识别为固定电话引流 | +| TC-DRAINAGE-SEND-001 | 使用待审核、驳回或未报备的既有引流资料分别创建客户端批次和 CMPP 入站任务 | 不产生`DRAINAGE_NOT_APPROVED`,不因引流资料状态拒绝或转人工;其他发送校验仍正常执行,禁止用真实短信完成自动测试 | +| TC-DRAINAGE-RECORD-001 | 查询含引流、不含引流和未检测三类短信记录并导出 CSV | PostgreSQL 分页和总数按筛选值返回;含引流原文使用提示色和片段高亮;CSV“是否含引流”与数据库一致 | +| TC-DRAINAGE-STATS-001 | 打开签名发送质量明细并切换“整体统计/按引流切分” | 整体矩阵显示全部通道×运营商真实提交;切分矩阵分别显示含引流、不含引流、未检测,分组计数之和等于整体计数 | +| TC-DRAINAGE-DASHBOARD-001 | 对比运营看板两个签名统计表与数据库当天数据 | “今日签名发送统计”包含全部短信;“含引流”表只包含`hasDrainageContent=true`,历史 null 不计入含引流 | +| TC-DRAINAGE-SAFETY-001 | 保存超长、非法标志、后行断言、反向引用或嵌套量词规则 | API 返回受控参数错误,不保存可能在发送入口造成灾难性回溯的规则 | + +## 2026-08-03 CMPP逐分片回执与超时失败回执用例 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-RECEIPT-FRAGMENT-001 | 客户提交三分片长短信,每片`Registered_Delivery=1`,供应商三片分别返回成功、成功、失败 | 内部主记录只形成一个最终业务结论;下游产生3条CMPP状态报告,各自Msg_Id等于对应SubmitResp Msg_Id,状态/原始码/时间来自对应上游分片;HTTP只产生1个消息级最终事件 | +| TC-RECEIPT-FRAGMENT-002 | 三分片中第二片`Registered_Delivery=0`,其余两片为1 | 数据库保留三片的真实请求值;下游只为第一、三片建立CMPP状态报告,第二片不生成;不影响内部整条短信状态和HTTP最终事件 | +| TC-RECEIPT-FRAGMENT-003 | 对同一长短信终态并发处理三次并重放重复上游回执 | 每个原始客户分片最多一条`CmppDownstreamDelivery`,唯一键分别含分片序号;已建立分片不重复发,HTTP稳定事件不重复,退款和补发仍只有一次 | +| TC-RECEIPT-FRAGMENT-004 | 客户在线时分别向同一长短信的两个分片推送回执 | Gateway发送的两个`CMPP_DELIVER`回执内容分别携带两个不同的原SubmitResp Msg_Id,不因在线会话按业务消息查找而都复用第一片Msg_Id | +| TC-RECEIPT-TIMEOUT-005 | HTTP消息超过72小时无明确回执,首次Webhook建单失败,下一轮扫描恢复 | 主记录只转一次`timeout`并只退款一次;失败时`timeoutReceiptQueuedAt`为空,后续扫描补建`undelivered/EXPIRED/RECEIPT_TIMEOUT`事件后写入标记,HTTP最终事件只有一个 | +| TC-RECEIPT-TIMEOUT-006 | CMPP长短信超过72小时无明确回执,其中部分片已有真实回执,其余片缺失 | 已有结果的分片保留自己的最终状态;缺失片收到明确`EXPIRED`失败回执;每片使用各自原SubmitResp Msg_Id并可分别ACK,重复扫描不重复发送 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 5f84d0e..325e458 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -3110,3 +3110,52 @@ git diff --check - Redis返回`PONG`;`gateway.submit.commands`消费者组为`cmpp-gateway`,消费者1、pending=0、lag=0、entries-read=3047。部署后约95秒内API/Gateway error级journal均为0,关键字检查无`panic/fatal/unhandled/exception/error`;120秒内活跃下游客户连接为0。 - 4条active供应商通道中3条稳定connected 1/1:`会员营销-铁布衫`、`赛邮行业-王斯评中转`、`赛邮行业-王斯评中转副本`。`会员营销-富泷`在部署前为connected 1/1,Gateway重启后持续约95秒为failed 0/1,数据库原因为`authentication / connect response status: auth failed`;保留系统自动重连,没有修改其账号、密码或启停状态。 - 依赖缓解安全门禁通过。npm audit仍报告既有前端2项high(未使用的React Router RSC路径)和API 3项moderate(Prisma工具链)告警,未执行可能引入破坏性升级的`audit fix --force`。本次没有发送、重投或补发真实短信,没有修改企业余额、客户连接或真实通道配置。 + +## 2026-08-03 客户端今日消费金额、审核提交时间与查询控件统一(本地未提交) + +- 客户端短信服务工作台在“今日返还金额”前增加“今日消费金额”,直接展示当前企业真实 Dashboard API 的`today.spendCents`;该字段由后端按当前租户及北京时间当天`SmsMessageRecord`汇总,不新增浏览器估算、静态数据或本地缓存。四张金额/发送指标卡在桌面端四列、1180px以下两列、780px以下单列展示。 +- 新增全局查询尺寸令牌:普通条件220px、日期区间320px、查询/重置按钮88px;新增共享`.ui-filter-row/.ui-filter-actions`布局并保留R11既有页面类兼容。风控规则页的规则范围、平台白名单和号码频次触发记录不再整行拉伸,白名单与触发记录增加等宽重置按钮并按明确默认值重新请求真实接口。 +- 企业认证、短信、模板、签名、引流信息五个审核页面均增加“提交时间”日期区间;签名和引流的导入批次审核Tab也使用已有真实分页日期参数。前端分别传递`submittedAtFrom/submittedAtTo`或导入批次`startAt/endAt`,不是仅过滤当前页面数组。 +- 后端新增共享北京时间日期边界解析,校验`YYYY-MM-DD`、真实日历日期和范围顺序,并生成包含开始日00:00:00及结束日23:59:59.999的闭区间。数据库查询分别落到企业认证`submittedAt`、短信审核任务`createdAt`、模板`createdAt`、签名当前提交口径`updatedAt`和引流信息`submittedAt`;没有新增schema或migration。 +- 新增/补充日期筛选单元测试。定向结果为日期边界与企业认证2 suites / 6 tests、风控审核1 suite / 16 tests、短信配置1 suite / 63 tests全部通过;本地Redis和PostgreSQL端口均监听时,API全量30 suites / 394 tests全部通过,保留既有`--forceExit`提示及测试场景内预期的错误/告警日志。 +- API TypeScript正式构建、前端TypeScript与Vite v8.1.5生产构建通过(2532 modules,CSS 239.86kB/gzip 35.11kB,JS 2007.30kB/gzip 597.78kB),仅保留既有大chunk提示。R11 foundation/shared components/admin/client四项样式门禁和`git diff --check`通过;契约同步锁定3个查询尺寸令牌、共享筛选布局和DateRangeInput绑定。 +- 应用内浏览器访问本地客户端工作台和运营端风控路由时,均被真实鉴权守卫正确重定向到对应图形验证码登录页;页面身份正确、无框架错误覆盖,控制台0条warning/error。没有可复用登录态,未解验证码或伪造会话,因此登录后金额卡、审核筛选和风控布局的真实交互视觉验收保留为人工登录复核项。 +- 已同步首版需求、系统功能测试用例和本进度文档。本轮按用户要求保持全部业务代码未提交、未推送、未部署;既有`api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo`、`outputs/`和空文件`=`继续保留且不纳入本轮归因,未发送短信、修改余额、通道配置或客户连接。 + +## 2026-08-03 引流信息识别与统计(本地开发中,未提交) + +- 本期范围确定为只识别、记录、查询和统计是否含引流信息;不根据报备/审核状态做发送、拒绝或人工审核。客户端批量发送和 CMPP 入站中的`DRAINAGE_NOT_APPROVED`分支已移除,既有引流资料匹配只保留关联信息,不参与发送决策。 +- 新增`DrainageDetectionRule`真实数据库模型、消息记录识别快照字段及 migration;检测器按规则类型对副本规范化,覆盖裸域名/短链/IP、中文标点和空格拆分 URL、`+86`/空格/短横线手机号、括号区号/分机固话,并排除邮箱。批量发送、CMPP 入站和通道测试记录写入同一识别快照。 +- 运营端新增“系统管理 → 引流识别规则”页面及真实 CRUD、启停、测试接口;短信记录新增含引流/不含引流/未检测筛选、命中片段提示色高亮和 CSV 列。 +- 发送质量明细的通道×运营商矩阵保留整体聚合,并新增含引流、不含引流、未检测切分结果;运营看板第一张签名表改为全部短信,第二张只取识别为含引流的短信。 +- 当前验证:Prisma Client 已基于新 schema 生成且 schema validate 通过;API TypeScript 构建、前端 TypeScript检查和 Vite v8.1.5 生产构建通过(2533 modules,CSS 241.13kB/gzip 35.30kB,JS 2016.72kB/gzip 600.04kB),仅保留既有大 chunk 提示。识别器及发送链路/通道/运营统计定向回归 4 suites / 180 tests 通过,API 全量 31 suites / 403 tests 通过;Jest 仍保留既有开放句柄`--forceExit`提示。未在本地或预生产执行 migration,未执行 Gateway 回归或登录后浏览器验收。 +- 全部修改仅保留本地,未提交、未推送、未部署;未发送、补发、重投真实短信,未修改真实通道账号、密码、启停状态、企业余额或客户连接。工作区中原有认证、风控、审核样式、日期筛选、构建缓存和临时文件继续保留,不归因于本需求。 +- 本地页面联调时发现3000端口仍由2026-08-02启动的旧API进程占用,当前源码进程因`EADDRINUSE`未实际接管,导致登录后访问新接口返回`Cannot GET /api/admin/dictionaries/drainage-detection-rules`。已仅重启本工作区本地API,确认新路由完成挂载、API health与4173前端预览均为HTTP 200。 +- 初始migration中的默认正则使用了JavaScript字符串式双反斜杠,而PostgreSQL标准字符串会原样保存,导致数据库内3条默认规则无法命中。初始种子已改用美元引用的单反斜杠表达式,并新增`20260803140000_fix_default_drainage_detection_rule_patterns`向前修复migration;本地库已应用至80条migration。真实本地数据库现有URL/裸域名、手机号、固话3条active默认规则;组合样例正确命中中文句号裸域名、`+86`短横线手机号和括号区号带分机固话,同时排除邮箱。 + +## 2026-08-03 下游投递与恢复状态筛选布局优化(本地未提交) + +- 设计规范核对确认R11已有共享`.ui-filter-row/.ui-filter-actions`:普通查询控件使用`--query-control-width: 220px`,日期范围使用`--query-date-range-width: 320px`,查询/重置按钮使用`--query-action-width: 88px`;容器允许按完整控件换行,780px以下条件整行、按钮两列。下游投递记录和恢复状态管理仍使用旧`admin-task-filter`自适应网格,5组条件及按钮会为维持单行而被压缩。 +- 两页筛选区已改为直接复用共享R11布局和操作区,不新增页面私有宽度、不改变筛选状态、查询接口、日期口径、导出或重投行为。已同步首版需求和`TC-GW-014/TC-GW-024`的桌面换行、移动端整行及无压缩/重叠验收要求。 +- 同轮纳入运营端用户管理:原页面在外层工具栏内再用`repeat(auto-fit, minmax(160px, 1fr))`压缩五组查询条件,现改为与查询/重置共同使用共享筛选流式布局;“新增用户”继续作为独立业务操作。查询参数、真实`GET /api/admin/users`组合查询及新增用户行为均未改变,并同步`TC-USER-FILTER-001`布局验收。 +- Node.js v24.16.0下前端TypeScript检查和Vite v8.1.5生产构建通过(2533 modules,CSS 241.00kB/gzip 35.28kB,JS 2016.78kB/gzip 600.05kB),仅保留既有大chunk提示;R11 foundation/shared components两项样式门禁及`git diff --check`通过。系统PATH旧Node首次执行Vite时因不支持`??=`产生未处理Promise警告但错误返回0,已明确判定无效并用Node.js v24重新完成全部门禁。 +- 本地深链访问下游投递页被真实鉴权守卫正确重定向至运营端登录页,页面身份正常、无框架覆盖、控制台0条warning/error;没有可复用登录态且未解图形验证码或伪造会话,因此三页登录后桌面/移动实际截图与查询交互仍需人工登录复核。修改仅保留本地,未提交、未推送、未部署,也未触发任何下游重投或真实短信操作。 + +## 2026-08-03 CMPP逐分片回执与72小时超时失败回执(本地未提交) + +- 协议和历史代码复核纠正了“临时状态”和“长短信聚合回执”两项不严谨结论:CMPP只有状态值,没有规定临时状态生命周期,也没有长短信聚合回执报文。历史版本曾按上游分片产生多条下游投递,但全部复用第一片客户Msg_Id;后续为修复并发重复补发/退款改成每条业务短信一条回执,两种实现都不满足逐个原始客户分片精确关联。 +- Gateway入站契约现传递每包真实`Registered_Delivery`;短短信写入`SmsMessageRecord.cmppRegisteredDelivery`,长短信逐片写入`CmppInboundLongMessageSegment.registeredDelivery/sequenceId`。migration`20260803190000_downstream_fragment_receipts`同时增加`timeoutReceiptQueuedAt`,历史已有CMPP记录按原行为回填为请求回执;本地PostgreSQL已成功应用至81条migration。 +- 内部长短信仍只形成一个业务终态、一次重投决定、一次退款和一个HTTP最终事件。CMPP下游改用`receipt:{messageRecordId}:segment:{segmentIndex}`逐片幂等;Gateway根据每片原始`SubmitGroupMessageId + Sequence_Id`重建对应SubmitResp Msg_Id,在线会话不再导致所有分片回执复用第一片Msg_Id。`Registered_Delivery=0`分片不建CMPP回执。 +- 下游逐片payload优先使用对应`SmsMessageSegmentAudit`的真实状态、原始码、错误码和到达时间;已成功分片不会因另一片失败被改写。业务已明确最终失败而个别片尚无状态时,缺失片使用整条短信的明确失败结果,保证每个请求回执的原始分片都有最终答复。 +- 72小时扫描将`submitted/unknown`明确改为`timeout + undelivered + EXPIRED + RECEIPT_TIMEOUT`,HTTP建立一个失败Webhook,CMPP为每个请求回执的原始分片建立失败状态报告。只有全部应建投递持久化后才写`timeoutReceiptQueuedAt`;建单中断时下一轮扫描继续补齐,退款仍由原消息级幂等键保证一次。 +- 验证结果:Prisma format、validate、generate和API TypeScript正式构建通过;新增逐片目标/Registered_Delivery/HTTP超时及中断恢复测试后,定向3 suites / 117 tests、API全量32 suites / 407 tests通过,保留既有Jest开放句柄`--forceExit`提示及预期场景日志。Gateway`go test ./... -count=1`与`go vet ./...`通过,并新增不同原Sequence_Id生成不同回执Msg_Id的专项测试。 +- 本轮没有修改或清理工作区中既有的引流识别、审核筛选、查询布局和其他会话修改;构建缓存、`outputs/`和空文件`=`继续保留。代码按用户要求未提交、未推送、未部署;没有发送、补发或重投短信,没有修改预生产数据库、企业余额、通道配置或客户连接。 + +## 2026-08-03 跨会话工作区整合与提交前完整回归 + +- 汇总当前工作区全部有效修改后,组合范围确认为四组:客户端今日消费与运营统计、审核日期和共享查询布局、引流信息识别与统计、CMPP逐分片回执及72小时超时失败回执。3条新增migration按`20260803113000`、`20260803140000`、`20260803190000`顺序衔接;本地真实PostgreSQL共81条migration且schema up to date。 +- 完整回归发现拆分阶段的结构契约未同步业务演进:R1新增5个引流识别规则API且7个查询实现更新,R5通道测试加入引流识别,R2短信导出/质量统计查询变化,R8/R9/R10的引流识别、Registered_Delivery、逐片回执及超时回执实现变化,R3审核提交时间查询变化。已按实际组合实现更新对应契约哈希;R0将已失效的“单条聚合最终回执”特征替换为“同一分片幂等一次”和“HTTP单事件+CMPP逐请求分片”两项真实特征,没有恢复错误的聚合回执行为。 +- Node.js v24.16.0下Prisma format、validate、generate、migrate status通过;API全量32 suites / 407 tests全部通过,API TypeScript正式构建通过;前端TypeScript与Vite v8.1.5生产构建通过(2533 modules),仅保留既有约2.02MB单chunk和插件耗时提示。 +- Gateway`go test ./... -count=1`和`go vet ./...`通过;19个R0-R11结构门禁全部通过;依赖缓解安全门禁通过;`git diff --check`和合并冲突标记扫描通过。Jest仍保留既有`--forceExit`开放句柄提示及测试场景内预期日志。 +- 依赖审计仍有已知告警:前端2项high来自项目未启用的React Router RSC路径,专用门禁已验证RSC未使用;API 3项moderate来自Prisma开发工具链的Valibot间接依赖。未执行可能改变依赖或引入破坏性升级的自动修复。 +- `api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo`、`outputs/`和空文件`=`继续作为构建缓存或临时产物排除,不提交、不删除、不错误归因。本轮未部署、未发送/补发/重投短信,也未修改预生产数据库、企业余额、通道配置或客户连接。 diff --git a/gateway/internal/inbound/delivery.go b/gateway/internal/inbound/delivery.go index a83e607..adee28e 100644 --- a/gateway/internal/inbound/delivery.go +++ b/gateway/internal/inbound/delivery.go @@ -142,8 +142,13 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr when = parsed } } + // Every inbound CMPP_SUBMIT fragment receives its own SUBMIT_RESP Msg_Id. + // Rebuild that exact client Msg_Id from the fragment's original Sequence_Id; + // the live session stores only connection state and must not collapse a long + // message back to the first fragment's Msg_Id. + receiptMessageID := downstreamReceiptMessageID(event, session) receipt := &cmpp.CmppReceiptPkt{ - MsgId: session.gatewayMsgID, + MsgId: receiptMessageID, Stat: stat, SubmitTime: when.Format("0601021504"), DoneTime: when.Format("0601021504"), @@ -154,10 +159,20 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr if err != nil { return DownstreamSendResult{}, err } - deliver := downstreamDeliverPacket(session, session.gatewayMsgID, session.srcID, defaultString(event.PhoneNumber, session.phoneNumber), 0, 1, string(receiptBytes)) + deliver := downstreamDeliverPacket(session, receiptMessageID, session.srcID, defaultString(event.PhoneNumber, session.phoneNumber), 0, 1, string(receiptBytes)) return sendDownstream(session, deliver, event.DeliveryID) } +func downstreamReceiptMessageID(event DownstreamReceipt, session *downstreamSession) uint64 { + if event.SubmitSequenceID != 0 { + return messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), event.SubmitSequenceID) + } + if session == nil { + return 0 + } + return session.gatewayMsgID +} + func findReceiptSession(messageID string, account string) *downstreamSession { downstreamRegistry.RLock() defer downstreamRegistry.RUnlock() diff --git a/gateway/internal/inbound/server_test.go b/gateway/internal/inbound/server_test.go index 4d01dab..ab06b4f 100644 --- a/gateway/internal/inbound/server_test.go +++ b/gateway/internal/inbound/server_test.go @@ -364,6 +364,9 @@ func TestInboundServerForwardsLongMessageFragmentsWithoutUDHAndAcknowledgesEachS if submit.Content != parts[index] { t.Fatalf("fragment %d content = %q", index+1, submit.Content) } + if submit.RegisteredDelivery != 1 { + t.Fatalf("fragment %d Registered_Delivery = %d, want 1", index+1, submit.RegisteredDelivery) + } if submit.LongMessage == nil || submit.LongMessage.Reference != 0x22 || submit.LongMessage.Total != 2 || submit.LongMessage.Index != index+1 || submit.LongMessage.Format != 8 { t.Fatalf("fragment %d metadata = %+v", index+1, submit.LongMessage) @@ -1015,6 +1018,22 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi } } +func TestLongMessageReceiptsUseEachOriginalFragmentMsgID(t *testing.T) { + session := &downstreamSession{gatewayMsgID: messageIDFrom("MSG-GROUP", 101)} + first := downstreamReceiptMessageID(DownstreamReceipt{ + MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: 101, + }, session) + second := downstreamReceiptMessageID(DownstreamReceipt{ + MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: 102, + }, session) + if first != messageIDFrom("MSG-GROUP", 101) || second != messageIDFrom("MSG-GROUP", 102) { + t.Fatalf("fragment receipt Msg_Id mismatch: first=%d second=%d", first, second) + } + if first == second { + t.Fatalf("different fragment Sequence_Id values produced the same Msg_Id: %d", first) + } +} + func TestSendDownstreamRejectsZeroMessageID(t *testing.T) { _, err := sendDownstream( &downstreamSession{mu: &sync.Mutex{}}, diff --git a/gateway/internal/inbound/sessions.go b/gateway/internal/inbound/sessions.go index 0f93d7d..c3388b0 100644 --- a/gateway/internal/inbound/sessions.go +++ b/gateway/internal/inbound/sessions.go @@ -130,10 +130,9 @@ func rememberDownstream(session downstreamSession) { session.touchPresence("connected", true, false) downstreamRegistry.Lock() if existing := downstreamRegistry.byMessageID[session.messageID]; existing != nil && existing.conn == session.conn { - // A downstream long message returns one SUBMIT_RESP per fragment but is - // persisted as one platform message. Keep the first fragment Msg_Id so - // online delivery and restart recovery (which persists the first - // Sequence_Id) address the same client-side message. + // Keep a stable message-level lookup for the live connection. Receipt + // delivery no longer uses this value for long-message fragments; it + // reconstructs every fragment Msg_Id from that fragment's Sequence_Id. session.gatewayMsgID = existing.gatewayMsgID } downstreamRegistry.byMessageID[session.messageID] = &session diff --git a/gateway/internal/inbound/submit.go b/gateway/internal/inbound/submit.go index b1545f0..838cb42 100644 --- a/gateway/internal/inbound/submit.go +++ b/gateway/internal/inbound/submit.go @@ -18,15 +18,16 @@ import ( // one internal message mapping per destination while CMPP receives one response. type submitRequest struct { - Account string `json:"account"` - PhoneNumber string `json:"phoneNumber,omitempty"` - PhoneNumbers []string `json:"phoneNumbers,omitempty"` - Content string `json:"content"` - SrcID string `json:"srcId,omitempty"` - DestID string `json:"destId,omitempty"` - SequenceID uint32 `json:"sequenceId,omitempty"` - RemoteIP string `json:"remoteIp,omitempty"` - LongMessage *inboundLongMessageFragment `json:"longMessage,omitempty"` + Account string `json:"account"` + PhoneNumber string `json:"phoneNumber,omitempty"` + PhoneNumbers []string `json:"phoneNumbers,omitempty"` + Content string `json:"content"` + SrcID string `json:"srcId,omitempty"` + DestID string `json:"destId,omitempty"` + SequenceID uint32 `json:"sequenceId,omitempty"` + RegisteredDelivery uint8 `json:"registeredDelivery"` + RemoteIP string `json:"remoteIp,omitempty"` + LongMessage *inboundLongMessageFragment `json:"longMessage,omitempty"` } type inboundLongMessageFragment struct { @@ -106,15 +107,16 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge startedAt := time.Now() releaseSubmitBarrier := beginDownstreamSubmitBarrier(packet.Conn) result, err := s.submit(remote, submitRequest{ - Account: account, - PhoneNumber: phone, - PhoneNumbers: phones, - Content: content, - SrcID: req.srcID, - DestID: phone, - SequenceID: req.sequenceID, - RemoteIP: remoteIP(remote), - LongMessage: longMessage, + Account: account, + PhoneNumber: phone, + PhoneNumbers: phones, + Content: content, + SrcID: req.srcID, + DestID: phone, + SequenceID: req.sequenceID, + RegisteredDelivery: req.registeredDelivery, + RemoteIP: remoteIP(remote), + LongMessage: longMessage, }) if err != nil || !result.Accepted { reason := "api returned accepted=false" @@ -206,16 +208,17 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge } type inboundSubmitPacket struct { - protocol string - pkTotal uint8 - pkNumber uint8 - tpUdhi uint8 - msgFmt uint8 - msgSrc string - srcID string - destTerminalIDs []string - msgContent string - sequenceID uint32 + protocol string + pkTotal uint8 + pkNumber uint8 + tpUdhi uint8 + msgFmt uint8 + msgSrc string + srcID string + destTerminalIDs []string + msgContent string + sequenceID uint32 + registeredDelivery uint8 } func normalizeInboundSubmit(packet any) (inboundSubmitPacket, bool) { @@ -224,13 +227,13 @@ func normalizeInboundSubmit(packet any) (inboundSubmitPacket, bool) { return inboundSubmitPacket{ protocol: "cmpp20", pkTotal: req.PkTotal, pkNumber: req.PkNumber, tpUdhi: req.TpUdhi, msgFmt: req.MsgFmt, msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId, - msgContent: req.MsgContent, sequenceID: req.SeqId, + msgContent: req.MsgContent, sequenceID: req.SeqId, registeredDelivery: req.RegisteredDelivery, }, true case *cmpp.Cmpp3SubmitReqPkt: return inboundSubmitPacket{ protocol: "cmpp30", pkTotal: req.PkTotal, pkNumber: req.PkNumber, tpUdhi: req.TpUdhi, msgFmt: req.MsgFmt, msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId, - msgContent: req.MsgContent, sequenceID: req.SeqId, + msgContent: req.MsgContent, sequenceID: req.SeqId, registeredDelivery: req.RegisteredDelivery, }, true default: return inboundSubmitPacket{}, false diff --git a/src/api/admin/governance.api.ts b/src/api/admin/governance.api.ts index 55a7a2c..7cc7da2 100644 --- a/src/api/admin/governance.api.ts +++ b/src/api/admin/governance.api.ts @@ -1,5 +1,5 @@ import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; -import type { AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types'; +import type { AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, DrainageDetectionResult, DrainageDetectionRule, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types'; // Review, risk and billing mutations keep their original URLs, payloads and // response types behind one governance boundary. @@ -14,10 +14,12 @@ export const adminGovernanceApi = { request('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }), createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) => request('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), - listTemplateAudits: (query: { keyword?: string; status?: string }) => { + listTemplateAudits: (query: { keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string }) => { const params = new URLSearchParams(); if (query.keyword) params.set('keyword', query.keyword); if (query.status && query.status !== 'all') params.set('status', query.status); + if (query.submittedAtFrom) params.set('submittedAtFrom', query.submittedAtFrom); + if (query.submittedAtTo) params.set('submittedAtTo', query.submittedAtTo); const suffix = params.toString() ? `?${params}` : ''; return request(`/admin/enterprise-templates${suffix}`); }, @@ -35,7 +37,7 @@ export const adminGovernanceApi = { request(`/admin/reviews/${type}/${id}/preflight`), submitReviewDecision: (type: 'signature' | 'template', id: string, body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string }) => request(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }), - listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) => + listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) => request(withQuery('/admin/enterprise-signatures', query)), listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; page: number; pageSize: number }) => request>(withQuery('/admin/enterprise-signatures', query)), @@ -47,7 +49,7 @@ export const adminGovernanceApi = { request(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) => request(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), - listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string } = {}) => + listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) => request(withQuery('/admin/drainage-infos', query)), listAuditRecords: (query: { targetType?: string; targetId?: string } = {}) => request(withQuery('/admin/audit-records', query)), @@ -71,10 +73,12 @@ export const adminGovernanceApi = { request(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeEnterpriseTemplateStatus: (id: string, status: string, reason?: string) => request(`/admin/enterprise-templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), - listEnterpriseCertifications: (query: { keyword?: string; status?: string }) => { + listEnterpriseCertifications: (query: { keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string }) => { const params = new URLSearchParams(); if (query.keyword) params.set('keyword', query.keyword); if (query.status && query.status !== 'all') params.set('status', query.status); + if (query.submittedAtFrom) params.set('submittedAtFrom', query.submittedAtFrom); + if (query.submittedAtTo) params.set('submittedAtTo', query.submittedAtTo); const suffix = params.toString() ? `?${params}` : ''; return request(`/admin/enterprise-certifications${suffix}`); }, @@ -87,7 +91,7 @@ export const adminGovernanceApi = { method: 'POST', body: JSON.stringify({ reason }), }), - listRiskReviewTasks: (query: { tenantId?: string; status?: string } = {}) => request(withQuery('/admin/risk-review/tasks', query)), + listRiskReviewTasks: (query: { tenantId?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) => request(withQuery('/admin/risk-review/tasks', query)), listRiskRules: (applicationId?: string) => request(withQuery('/admin/risk-review/rules', { applicationId })), createRiskRule: (body: { @@ -187,6 +191,16 @@ export const adminGovernanceApi = { 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' }), + listDrainageDetectionRules: (query: { keyword?: string; status?: string } = {}) => + request(withQuery('/admin/dictionaries/drainage-detection-rules', query)), + createDrainageDetectionRule: (body: Omit) => + request('/admin/dictionaries/drainage-detection-rules', { method: 'POST', body: JSON.stringify(body) }), + updateDrainageDetectionRule: (id: string, body: Omit) => + request(`/admin/dictionaries/drainage-detection-rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + changeDrainageDetectionRuleStatus: (id: string, status: 'active' | 'inactive') => + request(`/admin/dictionaries/drainage-detection-rules/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }), + testDrainageDetectionRule: (body: { content: string; rule?: Omit }) => + request('/admin/dictionaries/drainage-detection-rules/test', { method: 'POST', body: JSON.stringify(body) }), listCommonReportFields: () => request('/admin/dictionaries/common-report-fields'), createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) => request('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }), diff --git a/src/api/admin/operations.api.ts b/src/api/admin/operations.api.ts index d2df273..7ee81b3 100644 --- a/src/api/admin/operations.api.ts +++ b/src/api/admin/operations.api.ts @@ -37,9 +37,9 @@ export const adminOperationsApi = { request(withQuery('/admin/send/messages', query)), listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) => request(withQuery('/admin/operations/message-segment-audits', query)), - listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) => + listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) => request>(withQuery('/admin/operations/messages', query)), - exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) => + exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) => requestBlob(withQuery('/admin/operations/messages/export', query)), listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) => request(withQuery('/admin/operations/uplink-messages', query)), diff --git a/src/api/types/governance.ts b/src/api/types/governance.ts index 18a4bb4..e01180b 100644 --- a/src/api/types/governance.ts +++ b/src/api/types/governance.ts @@ -94,3 +94,29 @@ export type RiskTaskMessagePage = { }; export type BatchTaskMessagePage = RiskTaskMessagePage; + +export type DrainageDetectionRule = { + id: string; + code: string; + name: string; + category: 'url' | 'mobile' | 'landline'; + pattern: string; + flags: string; + priority: number; + status: 'active' | 'inactive'; + description?: string | null; + version: number; + createdAt: string; + updatedAt: string; +}; + +export type DrainageDetectionResult = { + hasDrainageContent: boolean; + drainageDetection: { + matches: Array<{ ruleCode: string; ruleName: string; category: string; text: string; normalizedText: string; start: number; end: number }>; + categories: string[]; + truncated: boolean; + }; + drainageDetectionVersion: string; + drainageEvaluatedAt: string; +}; diff --git a/src/api/types/operations.ts b/src/api/types/operations.ts index 319816e..a037326 100644 --- a/src/api/types/operations.ts +++ b/src/api/types/operations.ts @@ -52,6 +52,10 @@ export type SignatureChannelCarrierQualityStat = { averageArrivalMs?: number | null; }; +export type SignatureChannelCarrierDrainageQualityStat = SignatureChannelCarrierQualityStat & { + drainageState: 'with' | 'without' | 'unknown'; +}; + export type SignatureCarrierBusinessQualityStat = { signatureId: string; carrier: string; @@ -78,6 +82,7 @@ export type SignatureChannelQualityItem = { channelSubmitTotal: number; carrierOverview: SignatureCarrierBusinessQualityStat[]; breakdowns: SignatureChannelCarrierQualityStat[]; + drainageBreakdowns: SignatureChannelCarrierDrainageQualityStat[]; }; export type SignatureChannelQualityResponse = { @@ -108,6 +113,7 @@ export type SendQualityResponse = { summary: DailySendSummary; channels: ChannelQualityStat[]; signatures: SignatureQualityStat[]; + drainageSignatures: SignatureQualityStat[]; applications: ApplicationQualityStat[]; }; @@ -122,6 +128,14 @@ export type SmsMessageRecord = { carrier?: string | null; province?: string | null; content: string; + hasDrainageContent?: boolean | null; + drainageDetection?: { + matches?: Array<{ category: string; text: string; normalizedText: string; start: number; end: number }>; + categories?: string[]; + truncated?: boolean; + } | null; + drainageDetectionVersion?: string | null; + drainageEvaluatedAt?: string | null; clientSrcId?: string | null; applicationExtension?: string | null; billingUnits: number; diff --git a/src/apps/admin/AdminAnalyticsPage.tsx b/src/apps/admin/AdminAnalyticsPage.tsx index 86295e7..e01c8e2 100644 --- a/src/apps/admin/AdminAnalyticsPage.tsx +++ b/src/apps/admin/AdminAnalyticsPage.tsx @@ -4,6 +4,7 @@ import { adminApi, type SendQualityResponse, type SignatureChannelCarrierQualityStat, + type SignatureChannelCarrierDrainageQualityStat, type SignatureChannelQualityItem, type SignatureChannelQualityResponse, } from '@/api/adminApi'; @@ -290,6 +291,7 @@ function SignatureQualityDrawer({ item: SignatureChannelQualityItem; onClose: () => void; }) { + const [matrixMode, setMatrixMode] = useState<'overall' | 'drainage'>('overall'); const carriers = item.carrierOverview .map((carrier) => ({ ...carrier, @@ -360,8 +362,9 @@ function SignatureQualityDrawer({

通道 × 运营商矩阵

-

“—”表示所选日期没有该通道与运营商组合的真实提交,并不等同于通道不支持该运营商。

+

{matrixMode === 'overall' ? '整体口径展示该组合全部真实提交。' : '引流切分口径分别展示含引流、不含引流和历史未检测数据。'}“—”表示所选日期没有真实提交。

+
@@ -379,9 +382,14 @@ function SignatureQualityDrawer({ const metric = item.breakdowns.find((entry) => ( entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier )); + const drainageMetrics = item.drainageBreakdowns.filter((entry) => ( + entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier + )); return ( ); })} @@ -423,6 +431,14 @@ function MatrixMetric({ metric }: { metric: SignatureChannelCarrierQualityStat } ); } +function DrainageMatrixMetrics({ metrics }: { metrics: SignatureChannelCarrierDrainageQualityStat[] }) { + const labels = { with: '含引流', without: '不含引流', unknown: '未检测' }; + return
{(['with', 'without', 'unknown'] as const).map((state) => { + const metric = metrics.find((item) => item.drainageState === state); + return metric ?
{labels[state]}
: null; + })}
; +} + function QualityRate({ value }: { value: number }) { return (
diff --git a/src/apps/admin/AdminDownstreamDeliveriesPage.tsx b/src/apps/admin/AdminDownstreamDeliveriesPage.tsx index 0a46a31..fb1eb54 100644 --- a/src/apps/admin/AdminDownstreamDeliveriesPage.tsx +++ b/src/apps/admin/AdminDownstreamDeliveriesPage.tsx @@ -317,7 +317,7 @@ export function AdminDownstreamDeliveriesPage() {
-
+
setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} /> { setDateRange(value); setPage(1); }} value={dateRange} /> setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} /> { setDateRange(value); setPage(1); }} value={dateRange} /> setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、站点或网址" prefix={} value={keyword} />
- {metric ? : } + {matrixMode === 'overall' + ? metric ? : + : drainageMetrics.length ? : }
}, + { label: '单条引流信息审核', value: 'single', content:
setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、站点或网址" prefix={} value={keyword} />
}, { label: '导入批次审核', value: 'import', content: }, ]} /> {detail ? setDetail(undefined)} /> : null} diff --git a/src/apps/admin/AdminDrainageDetectionRulesPage.tsx b/src/apps/admin/AdminDrainageDetectionRulesPage.tsx new file mode 100644 index 0000000..b88df91 --- /dev/null +++ b/src/apps/admin/AdminDrainageDetectionRulesPage.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import { FlaskConical, Pencil, Plus, Power, PowerOff, Search } from 'lucide-react'; +import { adminApi, type DrainageDetectionResult, type DrainageDetectionRule } from '@/api/adminApi'; +import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; + +type RuleDraft = Omit; + +const emptyDraft: RuleDraft = { code: '', name: '', category: 'url', pattern: '', flags: 'giu', priority: 100, status: 'active', description: '' }; +const categoryOptions = [ + { label: 'URL / 域名', value: 'url' }, { label: '手机号码', value: 'mobile' }, { label: '固定电话', value: 'landline' }, +]; +const categoryLabels = { url: 'URL / 域名', mobile: '手机号码', landline: '固定电话' }; + +export function AdminDrainageDetectionRulesPage() { + const [rules, setRules] = useState([]); + const [keyword, setKeyword] = useState(''); + const [draft, setDraft] = useState(emptyDraft); + const [editingId, setEditingId] = useState(); + const [open, setOpen] = useState(false); + const [testContent, setTestContent] = useState(''); + const [testResult, setTestResult] = useState(); + const [error, setError] = useState(''); + + function load(search = keyword) { + adminApi.listDrainageDetectionRules({ keyword: search.trim() || undefined }) + .then((items) => { setRules(items); setError(''); }) + .catch((failure: Error) => setError(failure.message || '引流识别规则加载失败')); + } + useEffect(() => { load(''); }, []); + + function openEditor(rule?: DrainageDetectionRule) { + setEditingId(rule?.id); + setDraft(rule ? { code: rule.code, name: rule.name, category: rule.category, pattern: rule.pattern, flags: rule.flags, priority: rule.priority, status: rule.status, description: rule.description } : emptyDraft); + setTestContent(''); setTestResult(undefined); setOpen(true); + } + async function save() { + try { + if (editingId) await adminApi.updateDrainageDetectionRule(editingId, draft); + else await adminApi.createDrainageDetectionRule(draft); + setOpen(false); load(); + } catch (failure) { setError(failure instanceof Error ? failure.message : '规则保存失败'); } + } + async function test() { + try { setTestResult(await adminApi.testDrainageDetectionRule({ content: testContent, rule: draft })); setError(''); } + catch (failure) { setError(failure instanceof Error ? failure.message : '规则测试失败'); } + } + async function toggle(rule: DrainageDetectionRule) { + try { await adminApi.changeDrainageDetectionRuleStatus(rule.id, rule.status === 'active' ? 'inactive' : 'active'); load(); } + catch (failure) { setError(failure instanceof Error ? failure.message : '规则状态修改失败'); } + } + + return
+

引流识别规则

配置 URL、手机号码和固定电话识别规则。规则只用于记录与统计,不会拦截发送。

+ {error ?

{error}

: null} +
setKeyword(event.target.value)} placeholder="搜索编码、名称或说明" prefix={} value={keyword} />
+
{rules.map((rule) => )}
规则类型优先级版本状态表达式操作
{rule.name}
{rule.code}
{categoryLabels[rule.category]}{rule.priority}v{rule.version}{rule.status === 'active' ? '启用' : '停用'}{rule.pattern}
{rules.length === 0 ?
暂无识别规则
: null}
+ } onClose={() => setOpen(false)} open={open} title={editingId ? '编辑引流识别规则' : '新增引流识别规则'}> +
+ setDraft({ ...draft, code: event.target.value })} value={draft.code} /> + setDraft({ ...draft, name: event.target.value })} value={draft.name} /> + setDraft({ ...draft, priority: Number(event.target.value) || 100 })} type="number" value={String(draft.priority)} /> +