feat: integrate analytics and fragment receipt improvements
This commit is contained in:
@@ -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 } },
|
||||
|
||||
@@ -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('开始日期不能晚于结束日期');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'],
|
||||
['括号区号和分机', '致电(010)8888-8888 转 123', 'landline'],
|
||||
])('%s', (_name, content, category) => {
|
||||
const result = detectDrainageContentWithRules(content, rules);
|
||||
expect(result.hasDrainageContent).toBe(true);
|
||||
expect((result.drainageDetection as { matches: Array<{ category: string }> }).matches.some((item) => item.category === category)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not classify an email address as drainage information', () => {
|
||||
expect(detectDrainageContentWithRules('联系邮箱 service@example.com,谢谢', rules).hasDrainageContent).toBe(false);
|
||||
expect(detectDrainageContentWithRules('邮箱 13800138000 @ example . com', rules).hasDrainageContent).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps original offsets for record-page highlighting', () => {
|
||||
const content = '📨详情请看 example。com/path,谢谢';
|
||||
const result = detectDrainageContentWithRules(content, rules);
|
||||
const [match] = (result.drainageDetection as { matches: Array<{ start: number; end: number }> }).matches;
|
||||
expect(content.slice(match.start, match.end)).toContain('example。com/path');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' }]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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([
|
||||
'【带 空格】',
|
||||
' 【外部空格】',
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user