feat: integrate analytics and fragment receipt improvements

This commit is contained in:
hectorzhao
2026-08-03 15:34:41 +08:00
parent 3357ace7e1
commit 530a65de80
89 changed files with 1964 additions and 324 deletions
@@ -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')
@@ -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);
@@ -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 } },
+3
View File
@@ -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,
@@ -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(),
@@ -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('开始日期不能晚于结束日期');
});
});
+24
View File
@@ -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;
}
@@ -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();
@@ -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 },
@@ -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)}`);
@@ -11,6 +11,7 @@ export interface MessageQuery {
contentKeyword?: string;
carrier?: string;
status?: string;
hasDrainage?: string;
queuedAtFrom?: string;
queuedAtTo?: string;
page?: number;
+4
View File
@@ -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 ? {
@@ -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,
@@ -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,
]),
+104 -7
View File
@@ -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<Array<{
channelId: string;
channelName: string;
@@ -150,7 +150,7 @@ async sendQuality(date?: string) {
WITH base AS (
SELECT
message."signatureId" AS signature_id,
(message."drainageInfoId" IS NOT NULL) AS has_drainage,
(message."hasDrainageContent" IS TRUE) AS has_drainage,
message.status,
message."submitStatus" AS submit_status,
message."receiptStatus" AS receipt_status,
@@ -303,6 +303,8 @@ async sendQuality(date?: string) {
ORDER BY total DESC, application.name
`),
]);
const signatures = aggregateSignatureRows(signatureSplits);
const drainageSignatures = signatureSplits.filter((item) => 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<Array<{
signatureId: string;
channelId: string;
channelName: string;
carrier: string;
drainageState: 'with' | 'without' | 'unknown';
total: number;
acceptedCount: number;
submitFailureCount: number;
@@ -435,6 +438,11 @@ async signatureQuality(query: SignatureQualityQuery) {
submit."channelId" AS channel_id,
channel.name AS channel_name,
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
CASE
WHEN message."hasDrainageContent" IS TRUE THEN 'with'
WHEN message."hasDrainageContent" IS FALSE THEN 'without'
ELSE 'unknown'
END AS drainage_state,
submit."submitStatus" AS submit_status,
receipt."deliveredAt" AS delivered_at,
failed_receipt."failedAt" AS failed_at,
@@ -498,6 +506,7 @@ async signatureQuality(query: SignatureQualityQuery) {
channel_id AS "channelId",
MAX(channel_name) AS "channelName",
carrier,
drainage_state AS "drainageState",
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount",
COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount",
@@ -514,8 +523,8 @@ async signatureQuality(query: SignatureQualityQuery) {
END AS "successRate",
ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
FROM classified
GROUP BY signature_id, channel_id, carrier
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier
GROUP BY signature_id, channel_id, carrier, drainage_state
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier, drainage_state
`);
const carrierOverview = signatureIds.length === 0
? []
@@ -561,12 +570,14 @@ async signatureQuality(query: SignatureQualityQuery) {
ORDER BY message."signatureId", COUNT(*) DESC, carrier
`);
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
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<string, SignatureSplitRow[]>();
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<string, DrainageBreakdownRow[]>();
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),
};
});
}
@@ -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')
@@ -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({
+3 -1
View File
@@ -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: [
@@ -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,
}));
});
});
@@ -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<string, unknown>;
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<ClientReceiptTarget[]> {
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<unknown>,
data: {
message: FinalReceiptMessage;
payload: Record<string, unknown>;
segmentPayloads?: Record<number, Record<string, unknown>>;
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 };
}
@@ -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'],
['括号区号和分机', '致电(0108888-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');
});
});
@@ -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<string, NormalizedContent>();
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));
}
@@ -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,
};
}
@@ -35,6 +35,7 @@ export interface GatewayInboundSubmitDto {
srcId?: string;
destId?: string;
sequenceId?: number;
registeredDelivery?: number;
remoteIp?: string;
longMessage?: {
reference: number;
+3 -3
View File
@@ -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) {
+143 -16
View File
@@ -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<string, unknown> | 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' }]);
+2 -8
View File
@@ -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<string, unknown>;
}) {
private async queueAndTryDownstreamDelivery(data: DownstreamDeliveryQueueRequest) {
return this.completion.queueAndTryDownstreamDelivery(data);
}
@@ -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<string, never>;
@@ -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<string, unknown>;
}) {
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,
@@ -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<string, unknown>;
}) {
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;
}
@@ -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,
+30 -18
View File
@@ -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(
@@ -27,6 +27,7 @@ export type SendSubmissionCallbacks = {
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
cmppSubmitGroupMessageId?: string | null;
cmppRegisteredDelivery?: boolean | null;
},
errorCode: string,
reason: string,
+63 -9
View File
@@ -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<string>();
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) {
@@ -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);
}
+2
View File
@@ -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 } },
+3
View File
@@ -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' },
@@ -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;
}
@@ -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([
'【带 空格】',
' 【外部空格】',
+3
View File
@@ -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 } },