feat: improve operations diagnostics and channel management

This commit is contained in:
hectorzhao
2026-08-09 14:27:19 +08:00
parent 44352aeb2f
commit 4724b9db6a
65 changed files with 1211 additions and 293 deletions
@@ -0,0 +1,2 @@
ALTER TABLE "ProtocolInteractionLog"
ADD COLUMN "phoneNumber" TEXT;
+1
View File
@@ -203,6 +203,7 @@ model ProtocolInteractionLog {
traceId String?
requestId String?
phoneMasked String?
phoneNumber String?
resultCode String?
durationMs Int?
payloadBytes Int?
@@ -15,6 +15,7 @@ export class ChannelGroupRoutingService {
listGroups() {
return this.prisma.smsChannelGroup.findMany({
where: { status: { not: 'deleted' } },
include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
orderBy: { createdAt: 'desc' },
});
@@ -158,23 +159,78 @@ export class ChannelGroupRoutingService {
});
}
async deleteGroup(groupId: string) {
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } });
async getGroupDeletionImpact(groupId: string) {
const group = await this.prisma.smsChannelGroup.findUnique({
where: { id: groupId },
select: { id: true, name: true, items: { select: { id: true } } },
});
if (!group) {
throw new NotFoundException('Channel group not found');
}
const boundRoute = await this.prisma.channelRouteRule.findFirst({
where: {
groupId,
status: 'active',
},
select: { id: true },
const routes = await this.prisma.channelRouteRule.findMany({
where: { groupId, applicationId: { not: null }, status: { not: 'deleted' } },
select: { applicationId: true },
});
if (boundRoute) {
throw new BadRequestException('Channel group is used by application route rules and cannot be deleted');
const applicationIds = [...new Set(routes.flatMap((route) => route.applicationId ? [route.applicationId] : []))];
const [applications, pendingSupplierSubmitCount] = await Promise.all([
this.prisma.smsApplication.findMany({
where: { id: { in: applicationIds } },
select: { id: true, status: true },
}),
this.prisma.smsSubmitRecord.count({
where: { channelGroupId: groupId, submitStatus: 'queued' },
}),
]);
const applicationStatusById = new Map(applications.map((application) => [application.id, application.status]));
const deletedApplicationCount = applicationIds.filter((applicationId) => {
const status = applicationStatusById.get(applicationId);
return status === undefined || status === 'deleted';
}).length;
return {
groupId: group.id,
groupName: group.name,
normalApplicationCount: applicationIds.length - deletedApplicationCount,
deletedApplicationCount,
channelCount: group.items.length,
pendingSupplierSubmitCount,
};
}
async deleteGroup(groupId: string) {
const group = await this.prisma.smsChannelGroup.findUnique({
where: { id: groupId },
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
});
if (!group) {
throw new NotFoundException('Channel group not found');
}
await this.prisma.smsChannelGroupItem.deleteMany({ where: { groupId } });
return this.prisma.smsChannelGroup.delete({ where: { id: groupId } });
if (group.status === 'deleted') {
return group;
}
const impact = await this.getGroupDeletionImpact(groupId);
// Logical deletion keeps group items and route bindings available for historical
// receipts and uplink access-number matching; new submits already require an active group.
return this.prisma.$transaction(async (tx) => {
const deleted = await tx.smsChannelGroup.update({
where: { id: groupId },
data: { status: 'deleted' },
});
await tx.operationLog.create({
data: {
action: 'sms_channel_group.delete',
resource: 'sms_channel_group',
resourceId: groupId,
detail: {
before: channelGroupAuditSnapshot(group),
impact,
deletionMode: 'soft_delete',
} as Prisma.InputJsonValue,
},
});
return deleted;
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
listRouteRules() {
+5
View File
@@ -119,6 +119,11 @@ export class ChannelsController {
return this.channels.updateGroup(groupId, body);
}
@Get('channel-groups/:id/deletion-impact')
getGroupDeletionImpact(@Param('id') groupId: string) {
return this.channels.getGroupDeletionImpact(groupId);
}
@Delete('channel-groups/:id')
@RequireRecentAuthentication()
deleteGroup(@Param('id') groupId: string) {
+5 -11
View File
@@ -1,8 +1,13 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { summarizeReportStatuses as summarizeCommonReportStatuses } from '../common/report-status';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
export function summarizeReportStatuses(statuses: string[]) {
return summarizeCommonReportStatuses(statuses);
}
/** Constants and pure validation/normalization helpers shared by R5 domains. */
export const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands';
@@ -654,17 +659,6 @@ export function normalizeReportType(value?: string) {
throw new BadRequestException('reportType must be signature, drainage or both');
}
export function summarizeReportStatuses(statuses: string[]) {
if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 };
const approved = statuses.filter((status) => status === 'approved').length;
let status = 'pending';
if (approved === statuses.length) status = 'approved';
else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed';
else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting';
else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material';
return { status, approved, total: statuses.length };
}
export function normalizeLinkEvent(action: string) {
if (action.includes('connect_requested')) {
return '连接请求';
+53 -7
View File
@@ -81,7 +81,7 @@ function createPrismaMock() {
channelHealthMetric: { findMany: jest.fn() },
smsChannelGroup: {
findMany: jest.fn(),
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320 }),
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [] }),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
},
@@ -137,6 +137,7 @@ function createPrismaMock() {
},
smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
findMany: jest.fn().mockResolvedValue([]),
},
tenant: {
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', createdAt: new Date('2026-07-09T00:00:00.000Z') }),
@@ -153,6 +154,7 @@ function createPrismaMock() {
},
smsSubmitRecord: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'submit-record-1', ...data })),
count: jest.fn().mockResolvedValue(0),
},
cmppConnectionState: {
findMany: jest.fn(),
@@ -866,16 +868,60 @@ describe('ChannelsService', () => {
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
});
it('deletes channel groups only when no active route rule is bound', async () => {
it('counts distinct normal and deleted applications, channels, and queued supplier submits before deletion', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
prisma.smsChannelGroup.findUnique.mockResolvedValueOnce({ id: 'group-1', name: '移动主通道组', items: [{ id: 'item-1' }, { id: 'item-2' }] });
prisma.channelRouteRule.findMany.mockResolvedValueOnce([
{ applicationId: 'app-active' },
{ applicationId: 'app-active' },
{ applicationId: 'app-deleted' },
{ applicationId: 'app-missing' },
]);
prisma.smsApplication.findMany.mockResolvedValueOnce([
{ id: 'app-active', status: 'active' },
{ id: 'app-deleted', status: 'deleted' },
]);
prisma.smsSubmitRecord.count.mockResolvedValueOnce(2);
await service.deleteGroup('group-1');
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'group-1' } });
expect(prisma.smsChannelGroup.delete).toHaveBeenCalledWith({ where: { id: 'group-1' } });
await expect(service.getGroupDeletionImpact('group-1')).resolves.toEqual({
groupId: 'group-1',
groupName: '移动主通道组',
normalApplicationCount: 1,
deletedApplicationCount: 2,
channelCount: 2,
pendingSupplierSubmitCount: 2,
});
expect(prisma.smsSubmitRecord.count).toHaveBeenCalledWith({
where: { channelGroupId: 'group-1', submitStatus: 'queued' },
});
});
prisma.channelRouteRule.findFirst.mockResolvedValueOnce({ id: 'route-1' });
await expect(service.deleteGroup('group-1')).rejects.toThrow('Channel group is used by application route rules');
it('logically deletes channel groups without removing application bindings or group items', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
const groupUpdate = jest.fn().mockResolvedValue({ id: 'group-1', status: 'deleted' });
const operationLogCreate = jest.fn();
prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }]);
prisma.smsApplication.findMany.mockResolvedValue([{ id: 'app-1', status: 'active' }]);
prisma.$transaction.mockImplementationOnce((callback) => callback({
smsChannelGroup: { update: groupUpdate },
operationLog: { create: operationLogCreate },
}));
await expect(service.deleteGroup('group-1')).resolves.toEqual({ id: 'group-1', status: 'deleted' });
expect(groupUpdate).toHaveBeenCalledWith({ where: { id: 'group-1' }, data: { status: 'deleted' } });
expect(prisma.smsChannelGroupItem.deleteMany).not.toHaveBeenCalled();
expect(prisma.smsChannelGroup.delete).not.toHaveBeenCalled();
expect(operationLogCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'sms_channel_group.delete',
detail: expect.objectContaining({
deletionMode: 'soft_delete',
impact: expect.objectContaining({ normalApplicationCount: 1 }),
}),
}),
});
});
it('upserts signature report material per channel field', async () => {
+4
View File
@@ -114,6 +114,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
return this.groups.deleteGroup(groupId);
}
getGroupDeletionImpact(groupId: string) {
return this.groups.getGroupDeletionImpact(groupId);
}
listRouteRules() {
return this.groups.listRouteRules();
}
+14
View File
@@ -0,0 +1,14 @@
import { summarizeReportStatuses } from './report-status';
describe('summarizeReportStatuses', () => {
it.each([
[[], { status: 'not_applicable', approved: 0, total: 0 }],
[['approved', 'approved'], { status: 'approved', approved: 2, total: 2 }],
[['failed', 'rejected'], { status: 'failed', approved: 0, total: 2 }],
[['approved', 'failed'], { status: 'partial_success', approved: 1, total: 2 }],
[['failed', 'pending'], { status: 'reporting', approved: 0, total: 2 }],
[['waiting_material', 'pending'], { status: 'waiting_material', approved: 0, total: 2 }],
])('summarizes %j without allowing one failure to override other targets', (statuses, expected) => {
expect(summarizeReportStatuses(statuses)).toEqual(expected);
});
});
+29
View File
@@ -0,0 +1,29 @@
export type ReportStatusSummary = {
status: string;
approved: number;
total: number;
};
const FAILED_REPORT_STATUSES = new Set(['failed', 'rejected']);
export function summarizeReportStatuses(statuses: string[]): ReportStatusSummary {
if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 };
const approved = statuses.filter((status) => status === 'approved').length;
const failed = statuses.filter((status) => FAILED_REPORT_STATUSES.has(status)).length;
if (approved === statuses.length) return { status: 'approved', approved, total: statuses.length };
// Overall failure means every current target failed. A single failed channel must not
// erase successful channels or targets that can still finish reporting.
if (failed === statuses.length) return { status: 'failed', approved, total: statuses.length };
if (approved > 0) return { status: 'partial_success', approved, total: statuses.length };
if (failed > 0) return { status: 'reporting', approved, total: statuses.length };
if (statuses.some((status) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(status))) {
return { status: 'reporting', approved, total: statuses.length };
}
if (statuses.some((status) => status === 'waiting_material')) {
return { status: 'waiting_material', approved, total: statuses.length };
}
return { status: 'pending', approved, total: statuses.length };
}
@@ -68,6 +68,29 @@ describe('DeletionGovernanceService', () => {
]));
});
it('does not classify approved or abandoned report history as unfinished', async () => {
const { service, prisma } = setup();
prisma.smsSignature.findFirst.mockResolvedValue({
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
templates: [], drainageItems: [], reportTasks: [],
});
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith(expect.objectContaining({
include: expect.objectContaining({
reportTasks: expect.objectContaining({
where: { status: { notIn: expect.arrayContaining(['approved', 'abandoned']) } },
}),
}),
}));
expect(result.dependencies).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: 'report_tasks', count: 0 }),
]));
expect(result.allowedActions).toEqual(['delete']);
});
it('requires version, idempotency key and a meaningful reason', async () => {
const { service } = setup();
await expect(service.delete('template', 'template-1', {})).rejects.toBeInstanceOf(BadRequestException);
@@ -13,6 +13,10 @@ export type DeleteTargetDto = {
type Dependency = { kind: string; label: string; count: number; items: string[] };
// Report tasks use more terminal values than generic send tasks. Keep this explicit so
// completed approval and deliberately abandoned history do not block signature deletion.
const TERMINAL_REPORT_TASK_STATUSES = ['approved', 'completed', 'failed', 'cancelled', 'rejected', 'abandoned', 'partial', 'partial_success'];
export type DeletionPreflight = {
type: DeletionTargetType;
id: string;
@@ -114,7 +118,7 @@ export class DeletionGovernanceService {
tenant: { select: { name: true } }, application: { select: { name: true } },
templates: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, name: true } },
drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, siteName: true } },
reportTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, status: true } },
},
});
if (!item) throw new NotFoundException('签名不存在或无权访问');
@@ -19,6 +19,11 @@ import {
export class DictionariesController {
constructor(private readonly dictionaries: DictionariesService) {}
@Get('administrative-regions')
listAdministrativeRegions() {
return this.dictionaries.listAdministrativeRegions();
}
@Get('phone-segments')
listPhoneSegments(
@Query('keyword') keyword?: string,
@@ -58,6 +58,28 @@ function createPrismaMock() {
}
describe('DictionariesService', () => {
it('builds the enterprise province and city library from distinct real phone segment regions', async () => {
const prisma = createPrismaMock();
prisma.phoneSegment.findMany.mockResolvedValue([
{ province: '山东', city: '青岛' },
{ province: '山东', city: '济南' },
{ province: '山东', city: '济南' },
{ province: '江苏', city: '苏州' },
{ province: ' ', city: '无效' },
]);
const service = new DictionariesService(prisma as never);
await expect(service.listAdministrativeRegions()).resolves.toEqual([
{ province: '江苏', cities: ['苏州'] },
{ province: '山东', cities: ['济南', '青岛'] },
]);
expect(prisma.phoneSegment.findMany).toHaveBeenCalledWith({
where: { province: { not: null } },
select: { province: true, city: true },
distinct: ['province', 'city'],
});
});
it('deletes a phone segment from the real dictionary table', async () => {
const prisma = createPrismaMock();
const service = new DictionariesService(prisma as never);
@@ -111,6 +111,27 @@ export class DictionariesService {
@Optional() private readonly phoneRoutingLookup?: PhoneRoutingLookupService,
) {}
async listAdministrativeRegions() {
const rows = await this.prisma.phoneSegment.findMany({
where: { province: { not: null } },
select: { province: true, city: true },
distinct: ['province', 'city'],
});
const citiesByProvince = new Map<string, Set<string>>();
for (const row of rows) {
const province = row.province?.trim();
if (!province) continue;
const cities = citiesByProvince.get(province) ?? new Set<string>();
const city = row.city?.trim();
if (city) cities.add(city);
citiesByProvince.set(province, cities);
}
return Array.from(citiesByProvince, ([province, cities]) => ({
province,
cities: Array.from(cities).sort((left, right) => left.localeCompare(right, 'zh-CN')),
})).sort((left, right) => left.province.localeCompare(right.province, 'zh-CN'));
}
async listPhoneSegments(query: PhoneSegmentListQuery = {}) {
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25)));
@@ -20,7 +20,7 @@ describe('ProtocolLogsService', () => {
prisma.protocolInteractionLog.groupBy.mockResolvedValue([]);
});
it('buffers a masked and secret-free business event', async () => {
it('buffers a full-phone and secret-free business event', async () => {
const service = new ProtocolLogsService(prisma as never);
service.record({
protocol: 'cmpp',
@@ -36,7 +36,7 @@ describe('ProtocolLogsService', () => {
expect(prisma.protocolInteractionLog.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({
phoneMasked: '188****3795',
phoneNumber: '18821203795',
gatewayMessageId: '123',
detail: { sequenceId: 7 },
})],
@@ -65,4 +65,15 @@ describe('ProtocolLogsService', () => {
}),
}));
});
it('queries the full phone number field', async () => {
const service = new ProtocolLogsService(prisma as never);
await service.list({ keyword: '18821203795' });
expect(prisma.protocolInteractionLog.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
OR: expect.arrayContaining([{ phoneNumber: { contains: '18821203795' } }]),
}),
}));
});
});
@@ -77,7 +77,7 @@ export class ProtocolLogsService implements OnModuleInit, OnModuleDestroy {
gatewayMessageId: input.gatewayMessageId == null ? null : String(input.gatewayMessageId).slice(0, 128),
traceId: clean(input.traceId, 128),
requestId: clean(input.requestId, 128),
phoneMasked: maskPhone(input.phone),
phoneNumber: clean(input.phone, 32),
resultCode: input.resultCode == null ? null : String(input.resultCode).slice(0, 64),
durationMs: safeInteger(input.durationMs),
payloadBytes: safeInteger(input.payloadBytes),
@@ -102,7 +102,7 @@ export class ProtocolLogsService implements OnModuleInit, OnModuleDestroy {
{ requestId: { contains: query.keyword } },
{ traceId: { contains: query.keyword } },
{ account: { contains: query.keyword } },
{ phoneMasked: { contains: query.keyword } },
{ phoneNumber: { contains: query.keyword } },
{ resultCode: { contains: query.keyword } },
] : undefined,
};
@@ -151,12 +151,6 @@ function clean(value: unknown, max = 191) {
return text ? text.slice(0, max) : null;
}
function maskPhone(value: unknown) {
const text = String(value ?? '').replace(/\D/g, '');
if (!text) return null;
return text.length >= 7 ? `${text.slice(0, 3)}****${text.slice(-4)}` : `***${text.slice(-2)}`;
}
function safeInteger(value: unknown) {
const number = Number(value);
return Number.isSafeInteger(number) && number >= 0 ? number : null;
+24 -5
View File
@@ -8,9 +8,9 @@ describe('ReportsService', () => {
$executeRaw: jest.fn(),
};
const prisma = {
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn() },
dailyProfitReport: { findMany: jest.fn(), count: jest.fn() },
dailyQualityReport: { findMany: jest.fn(), count: jest.fn() },
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
dailyProfitReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
dailyQualityReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
};
let service: ReportsService;
@@ -23,10 +23,13 @@ describe('ReportsService', () => {
tx.$executeRaw.mockResolvedValue(0);
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
prisma.dailyReconciliationReport.count.mockResolvedValue(1);
prisma.dailyReconciliationReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1' }]);
prisma.dailyProfitReport.count.mockResolvedValue(1);
prisma.dailyProfitReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, revenueCents: BigInt(1000), refundCents: BigInt(100), costCents: BigInt(600), profitCents: BigInt(400) } });
prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]);
prisma.dailyQualityReport.count.mockResolvedValue(1);
prisma.dailyQualityReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
service = new ReportsService(prisma as never);
});
@@ -62,7 +65,7 @@ describe('ReportsService', () => {
applicationId: 'app-1',
page: 2,
pageSize: 500,
})).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100 });
})).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100, summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
skip: 100,
@@ -71,7 +74,9 @@ describe('ReportsService', () => {
});
it('keeps application and channel profit filters separate', async () => {
await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' });
await expect(service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' })).resolves.toEqual(expect.objectContaining({
summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
}));
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
dimensionType: 'channel',
@@ -82,9 +87,23 @@ describe('ReportsService', () => {
}));
});
it('returns zero full-result totals and rates when a filtered report has no rows', async () => {
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]);
prisma.dailyProfitReport.count.mockResolvedValueOnce(0);
prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({
_sum: { submittedUnits: null, sentUnits: null, unknownUnits: null, successUnits: null, failedUnits: null, revenueCents: null, refundCents: null, costCents: null, profitCents: null },
});
await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(expect.objectContaining({
total: 0,
summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, refundCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 },
}));
});
it('sorts quality reports by send volume and keeps the selected dimension', async () => {
await expect(service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 })).resolves.toEqual({
items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage',
summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, successRateBps: 7000 },
});
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
+48 -6
View File
@@ -45,31 +45,51 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
async listReconciliation(query: ReportListQuery) {
const { page, pageSize, skip } = pagination(query);
const where = reconciliationWhere(query);
const [items, total] = await Promise.all([
const [items, total, aggregate] = await Promise.all([
this.prisma.dailyReconciliationReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyReconciliationReport.count({ where }),
this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }),
]);
return { items, total, page, pageSize };
return { items, total, page, pageSize, summary: volumeSummary(aggregate._sum) };
}
async listProfit(query: ReportListQuery) {
const { page, pageSize, skip } = pagination(query);
const { dimensionType, where } = profitWhere(query);
const [items, total] = await Promise.all([
const [items, total, aggregate] = await Promise.all([
this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyProfitReport.count({ where }),
this.prisma.dailyProfitReport.aggregate({
where,
_sum: { ...reportVolumeSumSelection, revenueCents: true, refundCents: true, costCents: true, profitCents: true },
}),
]);
return { items, total, page, pageSize, dimensionType };
const summary = {
...volumeSummary(aggregate._sum),
revenueCents: Number(aggregate._sum.revenueCents ?? 0),
refundCents: Number(aggregate._sum.refundCents ?? 0),
costCents: Number(aggregate._sum.costCents ?? 0),
profitCents: Number(aggregate._sum.profitCents ?? 0),
// 利润率必须用全量筛选结果的合计利润/合计收入重新计算,不能对每日百分比求和或简单平均。
profitRateBps: ratioBps(Number(aggregate._sum.profitCents ?? 0), Number(aggregate._sum.revenueCents ?? 0)),
};
return { items, total, page, pageSize, dimensionType, summary };
}
async listQuality(query: ReportListQuery) {
const { page, pageSize, skip } = pagination(query);
const { dimensionType, where } = qualityWhere(query);
const [items, total] = await Promise.all([
const [items, total, aggregate] = await Promise.all([
this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyQualityReport.count({ where }),
this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }),
]);
return { items, total, page, pageSize, dimensionType };
const summary = {
...volumeSummary(aggregate._sum),
// 成功率按全量筛选结果的成功量/发送量重新计算,避免分页和分组大小导致失真。
successRateBps: ratioBps(Number(aggregate._sum.successUnits ?? 0), Number(aggregate._sum.sentUnits ?? 0)),
};
return { items, total, page, pageSize, dimensionType, summary };
}
async exportReconciliation(query: ReportListQuery) {
@@ -516,6 +536,28 @@ function pagination(query: ReportListQuery) {
return { page, pageSize, skip: (page - 1) * pageSize };
}
const reportVolumeSumSelection = {
submittedUnits: true,
sentUnits: true,
unknownUnits: true,
successUnits: true,
failedUnits: true,
} as const;
function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number | null; unknownUnits?: number | null; successUnits?: number | null; failedUnits?: number | null }) {
return {
submittedUnits: Number(sum.submittedUnits ?? 0),
sentUnits: Number(sum.sentUnits ?? 0),
unknownUnits: Number(sum.unknownUnits ?? 0),
successUnits: Number(sum.successUnits ?? 0),
failedUnits: Number(sum.failedUnits ?? 0),
};
}
function ratioBps(numerator: number, denominator: number) {
return denominator === 0 ? 0 : Math.round(numerator * 10_000 / denominator);
}
function reconciliationWhere(query: ReportListQuery): Prisma.DailyReconciliationReportWhereInput {
return { reportDate: dateFilter(query.dateFrom, query.dateTo), tenantId: query.tenantId || undefined, applicationId: query.applicationId || undefined };
}
@@ -11,7 +11,6 @@ describe('drainage content detection', () => {
['裸域名', '访问 t.cn/a1 查看详情', 'url'],
['IP 链接', '入口 192.168.1.10:8080/path。', 'url'],
['中文句号拆分域名', '请访问 example。com 领取', 'url'],
['空格拆分域名', '请访问 ex ample . com 领取', 'url'],
['+86 和空格手机号', '电话 +86 138 0013 8000', 'mobile'],
['短横线手机号', '电话 138-0013-8000', 'mobile'],
['括号区号和分机', '致电(0108888-8888 转 123', 'landline'],
@@ -26,6 +25,23 @@ describe('drainage content detection', () => {
expect(detectDrainageContentWithRules('邮箱 13800138000 @ example . com', rules).hasDrainageContent).toBe(false);
});
it.each([' ', '\t', '\n', '\u3000'])('stops a URL match at whitespace %p', (separator) => {
const url = 'https://example.com/path';
const suffix = '后续字符不属于链接';
const content = `详情 ${url}${separator}${suffix}`;
const result = detectDrainageContentWithRules(content, rules);
const urlMatches = (result.drainageDetection as { matches: Array<{ category: string; text: string; normalizedText: string }> })
.matches.filter((item) => item.category === 'url');
expect(urlMatches).toHaveLength(1);
expect(urlMatches[0]).toMatchObject({ text: url, normalizedText: url });
});
it('does not join a domain split by spaces into one URL', () => {
const result = detectDrainageContentWithRules('请访问 ex ample . com 领取', rules);
expect(result.hasDrainageContent).toBe(false);
});
it('keeps original offsets for record-page highlighting', () => {
const content = '📨详情请看 example。com/path,谢谢';
const result = detectDrainageContentWithRules(content, rules);
@@ -83,7 +83,10 @@ function normalizeContent(content: string, category: DrainageDetectionCategory):
.replace(/[()]/g, (char) => char === '' ? '(' : ')')
.replace(/[]/g, '+');
if (category === 'url') {
// 链接常被空格或中文句号拆开;句末中文句号也安全地成为正则边界。
// Whitespace is a URL boundary: removing it would incorrectly join the suffix into the link.
normalized = normalized.replace(/。/g, '.');
} else if (category === 'email') {
// Email exclusion keeps its broader normalization so spaced emails cannot leak into phone/URL matches.
normalized = normalized.replace(/\s+/gu, '').replace(//g, '.');
} else if (category === 'mobile' || category === 'landline') {
// 电话号码仅在检测副本中去除常见规避分隔符,绝不改写实际发送内容。
@@ -131,7 +134,7 @@ export function detectDrainageContentWithRules(
): DrainageDetectionResult {
const matches: DrainageDetectionMatch[] = [];
const normalizedByCategory = new Map<string, NormalizedContent>();
const emailNormalized = normalizeContent(content, 'url');
const emailNormalized = normalizeContent(content, 'email');
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);
@@ -25,6 +25,8 @@ export interface GatewayInboundAuthDto {
authSource?: string;
timestamp?: number;
remoteIp?: string;
version?: string;
requestedVersion?: number;
}
export interface GatewayInboundSubmitDto {
+51 -2
View File
@@ -983,19 +983,34 @@ describe('SendChainService', () => {
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
});
it('returns the application enterprise code after Gateway authentication', async () => {
const { service } = createService();
it('returns the application enterprise code and audits the inbound parameters after Gateway authentication', async () => {
const { service, prisma } = createService();
await expect(service.authenticateInboundApplication({
account: '100001',
password: 'secret-hash',
remoteIp: '127.0.0.1',
version: 'cmpp30',
requestedVersion: 48,
})).resolves.toEqual(expect.objectContaining({
account: '100001',
enterpriseCode: 'SP0001',
maxConnections: 2,
status: 'authenticated',
}));
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
action: 'cmpp_connection.connect_requested',
resource: 'cmpp_downstream_connection',
resourceId: 'app-1',
ipAddress: '127.0.0.1',
detail: expect.objectContaining({
result: 'authenticated',
request: expect.objectContaining({ account: '100001', password: 'secret-hash', version: 'cmpp30', requestedVersion: 48 }),
}),
}),
});
});
it('rejects Gateway authentication when application interface is disabled', async () => {
@@ -1015,6 +1030,38 @@ describe('SendChainService', () => {
password: 'secret-hash',
remoteIp: '127.0.0.1',
})).rejects.toThrow('CMPP interface is disabled for this application');
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
ipAddress: '127.0.0.1',
detail: expect.objectContaining({ result: 'failed', error: 'CMPP interface is disabled for this application' }),
}),
});
});
it('audits an unknown Gateway authentication account with its source IP', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue(null);
await expect(service.authenticateInboundApplication({
account: 'ATTACKER',
authSource: 'invalid-auth-source',
timestamp: 120000000,
remoteIp: '203.0.113.9',
version: 'cmpp30',
requestedVersion: 48,
})).rejects.toThrow('CMPP account is invalid or disabled');
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: undefined,
resourceId: 'ATTACKER',
ipAddress: '203.0.113.9',
detail: expect.objectContaining({
result: 'failed',
request: expect.objectContaining({ account: 'ATTACKER', authSource: 'invalid-auth-source' }),
}),
}),
});
});
it('rejects new submissions synchronously when the application interface was disabled after bind', async () => {
@@ -3308,6 +3355,8 @@ describe('SendChainService', () => {
account: '100001',
password: 'secret-hash',
remoteIp: '127.0.0.1',
version: 'cmpp30',
requestedVersion: 48,
})).resolves.toEqual(expect.objectContaining({ status: 'authenticated' }));
await expect(service.submitInboundMessage({
account: '100001',
@@ -59,31 +59,77 @@ export class SendInboundEntryService {
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
const application = await this.facade.findInboundApplication(data.account);
if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') {
throw new BadRequestException('CMPP account is invalid or disabled');
let tenantId: string | undefined;
let applicationId: string | undefined;
try {
const application = await this.facade.findInboundApplication(data.account);
tenantId = application?.tenantId;
applicationId = application?.id;
if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') {
throw new BadRequestException('CMPP account is invalid or disabled');
}
if (!application.interfaceEnabled) {
throw new BadRequestException('CMPP interface is disabled for this application');
}
if (application.tenant.certificationStatus !== 'approved') {
throw new BadRequestException('Enterprise certification is not approved');
}
if (!matchesApplicationSecret(data, application.secretHash)) {
throw new BadRequestException('CMPP account or password is invalid');
}
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
await this.recordInboundConnectRequest(data, { tenantId, applicationId, result: 'authenticated' });
return {
applicationId: application.id,
tenantId: application.tenantId,
account: application.cmppAccount,
enterpriseCode: application.cmppEnterpriseCode,
passwordCipher: application.secretHash,
maxConnections: application.cmppMaxConnections,
status: 'authenticated',
};
} catch (error) {
await this.recordInboundConnectRequest(data, {
tenantId,
applicationId,
result: 'failed',
error: error instanceof Error ? error.message : 'unknown error',
});
throw error;
}
if (!application.interfaceEnabled) {
throw new BadRequestException('CMPP interface is disabled for this application');
}
if (application.tenant.certificationStatus !== 'approved') {
throw new BadRequestException('Enterprise certification is not approved');
}
if (!matchesApplicationSecret(data, application.secretHash)) {
throw new BadRequestException('CMPP account or password is invalid');
}
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
return {
applicationId: application.id,
tenantId: application.tenantId,
account: application.cmppAccount,
enterpriseCode: application.cmppEnterpriseCode,
passwordCipher: application.secretHash,
maxConnections: application.cmppMaxConnections,
status: 'authenticated',
};
}
private recordInboundConnectRequest(
data: GatewayInboundAuthDto,
outcome: { tenantId?: string; applicationId?: string; result: 'authenticated' | 'failed'; error?: string },
) {
return this.prisma.operationLog.create({
data: {
tenantId: outcome.tenantId,
action: 'cmpp_connection.connect_requested',
resource: 'cmpp_downstream_connection',
resourceId: outcome.applicationId ?? data.account,
ipAddress: data.remoteIp?.trim() || undefined,
detail: {
direction: 'client_to_platform',
result: outcome.result,
applicationId: outcome.applicationId ?? null,
request: {
remoteIp: data.remoteIp?.trim() || null,
account: data.account,
// Standard CMPP sends AuthenticatorSource rather than a plaintext password; keep both fields truthful.
password: data.password ?? null,
authSource: data.authSource ?? null,
timestamp: data.timestamp ?? null,
version: data.version ?? null,
requestedVersion: data.requestedVersion ?? null,
},
error: outcome.error ?? null,
} as Prisma.InputJsonValue,
},
});
}
async submitInboundMessage(data: GatewayInboundSubmitDto) {
+3 -6
View File
@@ -10,6 +10,7 @@ import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL
import { SmsReportValidationService } from './report-validation.service';
import { SmsAuditService } from './audit.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
import { summarizeReportStatuses } from '../common/report-status';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsSignatureService {
@@ -109,9 +110,7 @@ export class SmsSignatureService {
return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const carrierTargets = targets.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []);
const approved = statuses.filter((status) => status === 'approved').length;
const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
return [carrier, { status, approved, total: statuses.length }];
return [carrier, summarizeReportStatuses(statuses)];
}))];
})),
carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
@@ -119,9 +118,7 @@ export class SmsSignatureService {
const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()];
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
const approved = statuses.filter((status) => status === 'approved').length;
const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
return [carrier, { status, approved, total: targets.length }];
return [carrier, summarizeReportStatuses(statuses)];
})),
};
});