feat: add reconciliation and quality reporting

This commit is contained in:
hectorzhao
2026-07-15 14:22:50 +08:00
parent 16311546af
commit 8c3336600e
32 changed files with 1730 additions and 200 deletions
@@ -0,0 +1,59 @@
ALTER TABLE "SmsSubmitRecord"
ADD COLUMN "costUnitPrice" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "costAmountCents" INTEGER NOT NULL DEFAULT 0;
UPDATE "SmsSubmitRecord" submit
SET
"costUnitPrice" = channel."unitPrice",
"costAmountCents" = channel."unitPrice" * message."billingUnits"
FROM "SmsChannel" channel, "SmsMessageRecord" message
WHERE submit."channelId" = channel.id
AND submit."messageRecordId" = message.id;
CREATE TABLE "DailyReconciliationReport" (
"id" TEXT NOT NULL,
"reportDate" DATE NOT NULL,
"tenantId" TEXT NOT NULL,
"tenantName" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"applicationName" TEXT NOT NULL,
"sentUnits" INTEGER NOT NULL DEFAULT 0,
"successUnits" INTEGER NOT NULL DEFAULT 0,
"generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DailyReconciliationReport_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "DailyProfitReport" (
"id" TEXT NOT NULL,
"reportDate" DATE NOT NULL,
"dimensionType" TEXT NOT NULL,
"dimensionId" TEXT NOT NULL,
"dimensionName" TEXT NOT NULL,
"tenantId" TEXT,
"tenantName" TEXT,
"applicationId" TEXT,
"channelId" TEXT,
"sentUnits" INTEGER NOT NULL DEFAULT 0,
"successUnits" INTEGER NOT NULL DEFAULT 0,
"revenueCents" INTEGER NOT NULL DEFAULT 0,
"costCents" INTEGER NOT NULL DEFAULT 0,
"profitCents" INTEGER NOT NULL DEFAULT 0,
"profitRateBps" INTEGER NOT NULL DEFAULT 0,
"generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DailyProfitReport_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "DailyReconciliationReport_reportDate_tenantId_applicationId_key"
ON "DailyReconciliationReport"("reportDate", "tenantId", "applicationId");
CREATE INDEX "DailyReconciliationReport_reportDate_idx" ON "DailyReconciliationReport"("reportDate");
CREATE INDEX "DailyReconciliationReport_tenantId_reportDate_idx" ON "DailyReconciliationReport"("tenantId", "reportDate");
CREATE INDEX "DailyReconciliationReport_applicationId_reportDate_idx" ON "DailyReconciliationReport"("applicationId", "reportDate");
CREATE UNIQUE INDEX "DailyProfitReport_reportDate_dimensionType_dimensionId_key"
ON "DailyProfitReport"("reportDate", "dimensionType", "dimensionId");
CREATE INDEX "DailyProfitReport_dimensionType_reportDate_idx" ON "DailyProfitReport"("dimensionType", "reportDate");
CREATE INDEX "DailyProfitReport_tenantId_reportDate_idx" ON "DailyProfitReport"("tenantId", "reportDate");
CREATE INDEX "DailyProfitReport_applicationId_reportDate_idx" ON "DailyProfitReport"("applicationId", "reportDate");
CREATE INDEX "DailyProfitReport_channelId_reportDate_idx" ON "DailyProfitReport"("channelId", "reportDate");
@@ -0,0 +1,23 @@
CREATE TABLE "CommonReportField" (
"id" TEXT NOT NULL,
"drainageFieldId" TEXT NOT NULL,
"reportType" TEXT NOT NULL,
"required" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 100,
"status" TEXT NOT NULL DEFAULT 'active',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CommonReportField_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "CommonReportField_drainageFieldId_reportType_key"
ON "CommonReportField"("drainageFieldId", "reportType");
CREATE INDEX "CommonReportField_reportType_status_sortOrder_idx"
ON "CommonReportField"("reportType", "status", "sortOrder");
ALTER TABLE "CommonReportField"
ADD CONSTRAINT "CommonReportField_drainageFieldId_fkey"
FOREIGN KEY ("drainageFieldId") REFERENCES "DrainageField"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,65 @@
ALTER TABLE "SmsMessageRecord" ADD COLUMN "drainageInfoId" TEXT;
ALTER TABLE "SmsMessageRecord"
ADD CONSTRAINT "SmsMessageRecord_drainageInfoId_fkey"
FOREIGN KEY ("drainageInfoId") REFERENCES "SmsDrainageInfo"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "SmsMessageRecord_drainageInfoId_queuedAt_idx"
ON "SmsMessageRecord"("drainageInfoId", "queuedAt");
WITH drainage_matches AS (
SELECT
message.id AS "messageRecordId",
drainage.id AS "drainageInfoId",
ROW_NUMBER() OVER (
PARTITION BY message.id
ORDER BY LENGTH(drainage.url) DESC, drainage."updatedAt" DESC, drainage.id
) AS match_rank,
COUNT(*) OVER (
PARTITION BY message.id, LENGTH(drainage.url)
) AS same_length_matches
FROM "SmsMessageRecord" message
JOIN "SmsDrainageInfo" drainage
ON drainage."signatureId" = message."signatureId"
AND drainage."auditStatus" = 'approved'
AND LENGTH(BTRIM(drainage.url)) > 0
AND POSITION(BTRIM(drainage.url) IN message.content) > 0
WHERE message."drainageInfoId" IS NULL
)
UPDATE "SmsMessageRecord" message
SET "drainageInfoId" = matched."drainageInfoId"
FROM drainage_matches matched
WHERE message.id = matched."messageRecordId"
AND matched.match_rank = 1
AND matched.same_length_matches = 1;
CREATE TABLE "DailyQualityReport" (
"id" TEXT NOT NULL,
"reportDate" DATE NOT NULL,
"dimensionType" TEXT NOT NULL,
"dimensionId" TEXT NOT NULL,
"dimensionName" TEXT NOT NULL,
"tenantId" TEXT,
"tenantName" TEXT,
"applicationId" TEXT,
"channelId" TEXT,
"signatureId" TEXT,
"drainageInfoId" TEXT,
"sentUnits" INTEGER NOT NULL DEFAULT 0,
"successUnits" INTEGER NOT NULL DEFAULT 0,
"successRateBps" INTEGER NOT NULL DEFAULT 0,
"avgArrivalMs" INTEGER,
"generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DailyQualityReport_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "DailyQualityReport_reportDate_dimensionType_dimensionId_key"
ON "DailyQualityReport"("reportDate", "dimensionType", "dimensionId");
CREATE INDEX "DailyQualityReport_dimensionType_reportDate_sentUnits_idx"
ON "DailyQualityReport"("dimensionType", "reportDate", "sentUnits");
CREATE INDEX "DailyQualityReport_tenantId_reportDate_idx" ON "DailyQualityReport"("tenantId", "reportDate");
CREATE INDEX "DailyQualityReport_applicationId_reportDate_idx" ON "DailyQualityReport"("applicationId", "reportDate");
CREATE INDEX "DailyQualityReport_channelId_reportDate_idx" ON "DailyQualityReport"("channelId", "reportDate");
CREATE INDEX "DailyQualityReport_signatureId_reportDate_idx" ON "DailyQualityReport"("signatureId", "reportDate");
CREATE INDEX "DailyQualityReport_drainageInfoId_reportDate_idx" ON "DailyQualityReport"("drainageInfoId", "reportDate");
+95
View File
@@ -261,6 +261,7 @@ model DrainageField {
updatedAt DateTime @updatedAt
channelReportFields ChannelReportField[]
commonReportFields CommonReportField[]
}
model TenantAccount {
@@ -449,6 +450,7 @@ model SmsDrainageInfo {
signature SmsSignature @relation(fields: [signatureId], references: [id], onDelete: Cascade)
application SmsApplication? @relation(fields: [applicationId], references: [id])
reportTasks ChannelSignatureReportTask[]
messageRecords SmsMessageRecord[]
@@index([tenantId, auditStatus, updatedAt])
@@index([signatureId, auditStatus])
@@ -716,6 +718,22 @@ model ChannelReportField {
@@index([drainageFieldId, reportType])
}
model CommonReportField {
id String @id @default(cuid())
drainageFieldId String
reportType String
required Boolean @default(false)
sortOrder Int @default(100)
status String @default("active")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
drainageField DrainageField @relation(fields: [drainageFieldId], references: [id], onDelete: Restrict)
@@unique([drainageFieldId, reportType])
@@index([reportType, status, sortOrder])
}
model SignatureReportMaterial {
id String @id @default(cuid())
signatureId String
@@ -974,6 +992,7 @@ model SmsMessageRecord {
applicationId String?
templateId String?
signatureId String?
drainageInfoId String?
reviewTaskId String?
messageId String @unique
phoneNumber String
@@ -1004,6 +1023,7 @@ model SmsMessageRecord {
application SmsApplication? @relation(fields: [applicationId], references: [id])
template SmsTemplate? @relation(fields: [templateId], references: [id])
signature SmsSignature? @relation(fields: [signatureId], references: [id])
drainageInfo SmsDrainageInfo? @relation(fields: [drainageInfoId], references: [id])
reviewTask SmsSendTask? @relation(fields: [reviewTaskId], references: [id])
channel SmsChannel? @relation(fields: [channelId], references: [id])
submitRecords SmsSubmitRecord[]
@@ -1018,6 +1038,7 @@ model SmsMessageRecord {
@@index([reviewTaskId, status])
@@index([phoneNumber])
@@index([gatewayMessageId])
@@index([drainageInfoId, queuedAt])
}
model CmppSubmitSession {
@@ -1048,6 +1069,8 @@ model SmsSubmitRecord {
sequenceId Int?
gatewayMessageId String?
submitStatus String @default("queued")
costUnitPrice Int @default(0)
costAmountCents Int @default(0)
errorCode String?
errorMessage String?
submittedAt DateTime?
@@ -1066,6 +1089,78 @@ model SmsSubmitRecord {
@@index([gatewayMessageId])
}
model DailyReconciliationReport {
id String @id @default(cuid())
reportDate DateTime @db.Date
tenantId String
tenantName String
applicationId String
applicationName String
sentUnits Int @default(0)
successUnits Int @default(0)
generatedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([reportDate, tenantId, applicationId])
@@index([reportDate])
@@index([tenantId, reportDate])
@@index([applicationId, reportDate])
}
model DailyProfitReport {
id String @id @default(cuid())
reportDate DateTime @db.Date
dimensionType String
dimensionId String
dimensionName String
tenantId String?
tenantName String?
applicationId String?
channelId String?
sentUnits Int @default(0)
successUnits Int @default(0)
revenueCents Int @default(0)
costCents Int @default(0)
profitCents Int @default(0)
profitRateBps Int @default(0)
generatedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([reportDate, dimensionType, dimensionId])
@@index([dimensionType, reportDate])
@@index([tenantId, reportDate])
@@index([applicationId, reportDate])
@@index([channelId, reportDate])
}
model DailyQualityReport {
id String @id @default(cuid())
reportDate DateTime @db.Date
dimensionType String
dimensionId String
dimensionName String
tenantId String?
tenantName String?
applicationId String?
channelId String?
signatureId String?
drainageInfoId String?
sentUnits Int @default(0)
successUnits Int @default(0)
successRateBps Int @default(0)
avgArrivalMs Int?
generatedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([reportDate, dimensionType, dimensionId])
@@index([dimensionType, reportDate, sentUnits])
@@index([tenantId, reportDate])
@@index([applicationId, reportDate])
@@index([channelId, reportDate])
@@index([signatureId, reportDate])
@@index([drainageInfoId, reportDate])
}
model SmsMessageSegmentAudit {
id String @id @default(cuid())
tenantId String?
+2
View File
@@ -13,6 +13,7 @@ import { HealthController } from './health.controller';
import { OperationsModule } from './operations/operations.module';
import { PrismaModule } from './prisma/prisma.module';
import { RiskReviewModule } from './risk-review/risk-review.module';
import { ReportsModule } from './reports/reports.module';
import { SendChainModule } from './send-chain/send-chain.module';
import { SmsConfigModule } from './sms-config/sms-config.module';
import { TenantsModule } from './tenants/tenants.module';
@@ -36,6 +37,7 @@ import { UsersModule } from './users/users.module';
SmsConfigModule,
ChannelsModule,
RiskReviewModule,
ReportsModule,
SendChainModule,
OperationsModule,
],
+2
View File
@@ -484,6 +484,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
sessionId: session.id,
submitId,
submitStatus: 'queued',
costUnitPrice: channel.unitPrice,
costAmountCents: channel.unitPrice * messageRecord.billingUnits,
},
});
const command = buildChannelTestSubmitCommand({
@@ -1,6 +1,7 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
CreateCommonReportFieldDto,
CreateBlacklistDto,
CreateDrainageFieldDto,
CreatePhoneCarrierRuleDto,
@@ -118,4 +119,19 @@ export class DictionariesController {
deleteDrainageField(@Param('id') id: string) {
return this.dictionaries.deleteDrainageField(id);
}
@Get('common-report-fields')
listCommonReportFields() {
return this.dictionaries.listCommonReportFields();
}
@Post('common-report-fields')
createCommonReportField(@Body() body: CreateCommonReportFieldDto) {
return this.dictionaries.createCommonReportField(body);
}
@Delete('common-report-fields/:id')
deleteCommonReportField(@Param('id') id: string) {
return this.dictionaries.deleteCommonReportField(id);
}
}
@@ -28,12 +28,20 @@ function createPrismaMock() {
},
drainageField: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
},
channelReportField: {
count: jest.fn().mockResolvedValue(0),
},
commonReportField: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'common-1' }),
},
smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
},
@@ -55,14 +63,38 @@ describe('DictionariesService', () => {
it('returns drainage field usage counts and blocks deleting fields used by channels', async () => {
const prisma = createPrismaMock();
prisma.drainageField.findMany.mockResolvedValue([{ id: 'field-1', code: 'license', _count: { channelReportFields: 2 } }]);
prisma.drainageField.findMany.mockResolvedValue([{ id: 'field-1', code: 'license', _count: { channelReportFields: 2, commonReportFields: 0 } }]);
prisma.channelReportField.count.mockResolvedValue(2);
const service = new DictionariesService(prisma as never);
await expect(service.listDrainageFields()).resolves.toEqual([{ id: 'field-1', code: 'license', usageCount: 2 }]);
await expect(service.listDrainageFields()).resolves.toEqual([{ id: 'field-1', code: 'license', usageCount: 2, commonUsageCount: 0 }]);
await expect(service.deleteDrainageField('field-1')).rejects.toThrow('不能删除');
expect(prisma.drainageField.delete).not.toHaveBeenCalled();
});
it('creates and deletes real common signature and drainage field configurations', async () => {
const prisma = createPrismaMock();
prisma.drainageField.findUnique = jest.fn().mockResolvedValue({ id: 'field-1', code: 'license', required: false, status: 'active' });
const service = new DictionariesService(prisma as never);
await service.createCommonReportField({ drainageFieldId: 'field-1', reportType: 'signature', required: true });
await service.deleteCommonReportField('common-1');
expect(prisma.commonReportField.create).toHaveBeenCalledWith({
data: expect.objectContaining({ drainageFieldId: 'field-1', reportType: 'signature', required: true, status: 'active' }),
include: { drainageField: true },
});
expect(prisma.commonReportField.delete).toHaveBeenCalledWith({ where: { id: 'common-1' } });
});
it('blocks deleting a field referenced by a common configuration', async () => {
const prisma = createPrismaMock();
prisma.commonReportField.count.mockResolvedValue(1);
const service = new DictionariesService(prisma as never);
await expect(service.deleteDrainageField('field-1')).rejects.toThrow('通用配置');
expect(prisma.drainageField.delete).not.toHaveBeenCalled();
});
it('paginates phone segments with a real database count', async () => {
const prisma = createPrismaMock();
prisma.phoneSegment.findMany.mockResolvedValue([
+56 -5
View File
@@ -53,6 +53,13 @@ export interface CreateDrainageFieldDto {
description?: string;
}
export interface CreateCommonReportFieldDto {
drainageFieldId: string;
reportType: 'signature' | 'drainage';
required?: boolean;
sortOrder?: number;
}
export interface DictionaryStatusDto {
status?: string;
operatorId?: string;
@@ -264,10 +271,14 @@ export class DictionariesService {
async listDrainageFields() {
const fields = await this.prisma.drainageField.findMany({
include: { _count: { select: { channelReportFields: true } } },
include: { _count: { select: { channelReportFields: true, commonReportFields: true } } },
orderBy: { createdAt: 'desc' },
});
return fields.map(({ _count, ...field }) => ({ ...field, usageCount: _count.channelReportFields }));
return fields.map(({ _count, ...field }) => ({
...field,
usageCount: _count.channelReportFields,
commonUsageCount: _count.commonReportFields,
}));
}
createDrainageField(data: CreateDrainageFieldDto) {
@@ -291,13 +302,53 @@ export class DictionariesService {
}
async deleteDrainageField(id: string) {
const usageCount = await this.prisma.channelReportField.count({ where: { drainageFieldId: id } });
if (usageCount > 0) {
throw new BadRequestException(`该字段已被 ${usageCount} 个通道使用,不能删除`);
const [usageCount, commonUsageCount] = await Promise.all([
this.prisma.channelReportField.count({ where: { drainageFieldId: id } }),
this.prisma.commonReportField.count({ where: { drainageFieldId: id } }),
]);
if (usageCount > 0 || commonUsageCount > 0) {
throw new BadRequestException(`该字段已被 ${usageCount} 个通道和 ${commonUsageCount} 个通用配置使用,不能删除`);
}
return this.prisma.drainageField.delete({ where: { id } });
}
listCommonReportFields() {
return this.prisma.commonReportField.findMany({
include: { drainageField: true },
orderBy: [{ reportType: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
});
}
async createCommonReportField(data: CreateCommonReportFieldDto) {
if (data.reportType !== 'signature' && data.reportType !== 'drainage') {
throw new BadRequestException('reportType must be signature or drainage');
}
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
if (!field || field.status !== 'active') {
throw new BadRequestException('报备字段库字段不存在或已停用');
}
const existing = await this.prisma.commonReportField.findUnique({
where: { drainageFieldId_reportType: { drainageFieldId: field.id, reportType: data.reportType } },
});
if (existing) {
throw new BadRequestException('该字段已配置为对应类型的通用字段');
}
return this.prisma.commonReportField.create({
data: {
drainageFieldId: field.id,
reportType: data.reportType,
required: data.required ?? field.required,
sortOrder: data.sortOrder ?? 100,
status: 'active',
},
include: { drainageField: true },
});
}
deleteCommonReportField(id: string) {
return this.prisma.commonReportField.delete({ where: { id } });
}
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
return this.prisma.operationLog.create({
data: {
+49
View File
@@ -0,0 +1,49 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ReportsService } from './reports.service';
@ApiTags('reports')
@Controller('admin/reports')
export class ReportsController {
constructor(private readonly reports: ReportsService) {}
@Get('reconciliation')
reconciliation(
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('tenantId') tenantId?: string,
@Query('applicationId') applicationId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.reports.listReconciliation({ dateFrom, dateTo, tenantId, applicationId, page: Number(page), pageSize: Number(pageSize) });
}
@Get('profit')
profit(
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('dimensionType') dimensionType?: string,
@Query('tenantId') tenantId?: string,
@Query('applicationId') applicationId?: string,
@Query('channelId') channelId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.reports.listProfit({ dateFrom, dateTo, dimensionType, tenantId, applicationId, channelId, page: Number(page), pageSize: Number(pageSize) });
}
@Get('quality')
quality(
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('dimensionType') dimensionType?: string,
@Query('tenantId') tenantId?: string,
@Query('applicationId') applicationId?: string,
@Query('channelId') channelId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.reports.listQuality({ dateFrom, dateTo, dimensionType, tenantId, applicationId, channelId, page: Number(page), pageSize: Number(pageSize) });
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
@Module({
imports: [PrismaModule],
controllers: [ReportsController],
providers: [ReportsService],
})
export class ReportsModule {}
+81
View File
@@ -0,0 +1,81 @@
import { ReportsService } from './reports.service';
describe('ReportsService', () => {
const tx = {
dailyReconciliationReport: { deleteMany: jest.fn() },
dailyProfitReport: { deleteMany: jest.fn() },
dailyQualityReport: { deleteMany: jest.fn() },
$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() },
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
};
let service: ReportsService;
beforeEach(() => {
jest.clearAllMocks();
tx.dailyReconciliationReport.deleteMany.mockResolvedValue({ count: 0 });
tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 });
tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 });
tx.$executeRaw.mockResolvedValue(0);
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
prisma.dailyReconciliationReport.count.mockResolvedValue(1);
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1' }]);
prisma.dailyProfitReport.count.mockResolvedValue(1);
prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]);
prisma.dailyQualityReport.count.mockResolvedValue(1);
service = new ReportsService(prisma as never);
});
it('rebuilds exactly T-4 through T-1 in independent transactions', async () => {
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).resolves.toEqual({
refreshedDates: ['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14'],
});
expect(prisma.$transaction).toHaveBeenCalledTimes(4);
expect(tx.dailyReconciliationReport.deleteMany).toHaveBeenCalledTimes(4);
expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4);
expect(tx.dailyQualityReport.deleteMany).toHaveBeenCalledTimes(4);
expect(tx.$executeRaw).toHaveBeenCalledTimes(28);
});
it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
await expect(service.listReconciliation({
dateFrom: '2026-07-01',
dateTo: '2026-07-14',
tenantId: 'tenant-1',
applicationId: 'app-1',
page: 2,
pageSize: 500,
})).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100 });
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
skip: 100,
take: 100,
}));
});
it('keeps application and channel profit filters separate', async () => {
await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' });
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
dimensionType: 'channel',
tenantId: undefined,
applicationId: undefined,
channelId: 'channel-1',
}),
}));
});
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',
});
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
}));
});
});
+409
View File
@@ -0,0 +1,409 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
const DEFAULT_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
export type ReportListQuery = {
dateFrom?: string;
dateTo?: string;
tenantId?: string;
applicationId?: string;
channelId?: string;
dimensionType?: string;
page?: number;
pageSize?: number;
};
@Injectable()
export class ReportsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(ReportsService.name);
private refreshTimer?: ReturnType<typeof setInterval>;
private refreshRunning = false;
private lastRefreshBusinessDate?: string;
constructor(private readonly prisma: PrismaService) {}
onModuleInit() {
if (process.env.REPORT_DAILY_REFRESH_ENABLED === 'false') return;
const startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000);
startupTimer.unref?.();
this.refreshTimer = setInterval(
() => void this.runScheduledRefresh(),
positiveInteger(process.env.REPORT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_INTERVAL_MS),
);
this.refreshTimer.unref?.();
}
onModuleDestroy() {
if (this.refreshTimer) clearInterval(this.refreshTimer);
}
async listReconciliation(query: ReportListQuery) {
const { page, pageSize, skip } = pagination(query);
const where: Prisma.DailyReconciliationReportWhereInput = {
reportDate: dateFilter(query.dateFrom, query.dateTo),
tenantId: query.tenantId || undefined,
applicationId: query.applicationId || undefined,
};
const [items, total] = await Promise.all([
this.prisma.dailyReconciliationReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyReconciliationReport.count({ where }),
]);
return { items, total, page, pageSize };
}
async listProfit(query: ReportListQuery) {
const { page, pageSize, skip } = pagination(query);
const dimensionType = query.dimensionType === 'channel' ? 'channel' : 'application';
const where: Prisma.DailyProfitReportWhereInput = {
dimensionType,
reportDate: dateFilter(query.dateFrom, query.dateTo),
tenantId: dimensionType === 'application' ? query.tenantId || undefined : undefined,
applicationId: dimensionType === 'application' ? query.applicationId || undefined : undefined,
channelId: dimensionType === 'channel' ? query.channelId || undefined : undefined,
};
const [items, total] = await Promise.all([
this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyProfitReport.count({ where }),
]);
return { items, total, page, pageSize, dimensionType };
}
async listQuality(query: ReportListQuery) {
const { page, pageSize, skip } = pagination(query);
const allowedDimensions = new Set(['application', 'channel', 'signature', 'drainage']);
const dimensionType = allowedDimensions.has(String(query.dimensionType)) ? String(query.dimensionType) : 'application';
const where: Prisma.DailyQualityReportWhereInput = {
dimensionType,
reportDate: dateFilter(query.dateFrom, query.dateTo),
tenantId: query.tenantId || undefined,
applicationId: query.applicationId || undefined,
channelId: query.channelId || undefined,
};
const [items, total] = await Promise.all([
this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyQualityReport.count({ where }),
]);
return { items, total, page, pageSize, dimensionType };
}
async refreshRollingWindow(now = new Date()) {
const days = completedBusinessDays(now, 4);
for (const day of days) await this.refreshBusinessDay(day);
return { refreshedDates: days.map((day) => day.key) };
}
private async runScheduledRefresh() {
const businessDate = shanghaiDateKey(new Date());
if (this.refreshRunning || this.lastRefreshBusinessDate === businessDate) return;
this.refreshRunning = true;
try {
const result = await this.refreshRollingWindow();
this.lastRefreshBusinessDate = businessDate;
this.logger.log(`Daily reports refreshed for ${result.refreshedDates.join(', ')}`);
} catch (error) {
this.logger.error('Daily report refresh failed', error instanceof Error ? error.stack : String(error));
} finally {
this.refreshRunning = false;
}
}
private async refreshBusinessDay(day: BusinessDay) {
await this.prisma.$transaction(async (tx) => {
await tx.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DailyReconciliationReport" (
"id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName",
"sentUnits", "successUnits", "generatedAt", "updatedAt"
)
SELECT
CONCAT('recon-', MD5(${day.key} || ':' || tenant.id || ':' || application.id)),
${day.reportDate}::date,
tenant.id,
tenant.name,
application.id,
application.name,
COALESCE(SUM(message."billingUnits"), 0)::integer,
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "SmsMessageRecord" message
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
JOIN "SmsApplication" application ON application.id = message."applicationId"
WHERE message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
GROUP BY tenant.id, tenant.name, application.id, application.name
`);
await tx.$executeRaw(Prisma.sql`
WITH billing AS (
SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::integer AS revenue
FROM "SmsBillingRecord"
GROUP BY "messageId"
), costs AS (
SELECT submit."messageRecordId", SUM(submit."costAmountCents")::integer AS cost
FROM "SmsSubmitRecord" submit
WHERE submit."submitStatus" = 'accepted'
GROUP BY submit."messageRecordId"
)
INSERT INTO "DailyProfitReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId",
"sentUnits", "successUnits", "revenueCents", "costCents", "profitCents", "profitRateBps",
"generatedAt", "updatedAt"
)
SELECT
CONCAT('profit-app-', MD5(${day.key} || ':' || application.id)),
${day.reportDate}::date,
'application',
application.id,
application.name,
tenant.id,
tenant.name,
application.id,
NULL,
COALESCE(SUM(message."billingUnits"), 0)::integer,
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(billing.revenue), 0)::integer,
COALESCE(SUM(costs.cost), 0)::integer,
(COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0))::integer,
CASE WHEN COALESCE(SUM(billing.revenue), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 / SUM(billing.revenue))::integer END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "SmsMessageRecord" message
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
JOIN "SmsApplication" application ON application.id = message."applicationId"
LEFT JOIN billing ON billing."messageId" = message."messageId"
LEFT JOIN costs ON costs."messageRecordId" = message.id
WHERE message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
GROUP BY tenant.id, tenant.name, application.id, application.name
`);
await tx.$executeRaw(Prisma.sql`
WITH billing AS (
SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::integer AS revenue
FROM "SmsBillingRecord"
GROUP BY "messageId"
)
INSERT INTO "DailyProfitReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId",
"sentUnits", "successUnits", "revenueCents", "costCents", "profitCents", "profitRateBps",
"generatedAt", "updatedAt"
)
SELECT
CONCAT('profit-channel-', MD5(${day.key} || ':' || channel.id)),
${day.reportDate}::date,
'channel',
channel.id,
channel.name,
NULL,
NULL,
NULL,
channel.id,
COALESCE(SUM(message."billingUnits"), 0)::integer,
COALESCE(SUM(CASE WHEN EXISTS (
SELECT 1 FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
) THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::integer,
COALESCE(SUM(submit."costAmountCents"), 0)::integer,
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0))::integer,
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0)) * 10000.0 /
SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END))::integer END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN billing ON billing."messageId" = message."messageId"
WHERE submit."submitStatus" = 'accepted'
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
GROUP BY channel.id, channel.name
`);
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'application'));
await tx.$executeRaw(qualityByChannelSql(day));
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature'));
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage'));
});
}
}
function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'application' | 'signature' | 'drainage') {
const dimensionTypeSql = Prisma.raw(`'${dimensionType}'`);
const dimensionId = dimensionType === 'application'
? Prisma.sql`application.id`
: dimensionType === 'signature'
? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))`
: Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`;
const dimensionName = dimensionType === 'application'
? Prisma.sql`application.name`
: dimensionType === 'signature'
? Prisma.sql`COALESCE(signature.name, '未关联签名')`
: Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`;
const applicationJoin = dimensionType === 'application'
? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"`
: Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`;
return Prisma.sql`
WITH base AS (
SELECT
${dimensionId} AS dimension_id,
${dimensionName} AS dimension_name,
tenant.id AS tenant_id,
tenant.name AS tenant_name,
application.id AS application_id,
message."billingUnits" AS billing_units,
CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END AS success_units,
CASE WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
AND message."submittedAt" IS NOT NULL AND message."deliveredAt" >= message."submittedAt"
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 END AS arrival_ms
FROM "SmsMessageRecord" message
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
${applicationJoin}
LEFT JOIN "SmsSignature" signature ON signature.id = message."signatureId"
LEFT JOIN "SmsDrainageInfo" drainage ON drainage.id = message."drainageInfoId"
WHERE message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
), thresholds AS (
SELECT dimension_id, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY arrival_ms) AS p95_ms
FROM base WHERE arrival_ms IS NOT NULL GROUP BY dimension_id
)
INSERT INTO "DailyQualityReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId",
"sentUnits", "successUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
)
SELECT
CONCAT('quality-', ${dimensionTypeSql}, '-', MD5(${day.key} || ':' || base.dimension_id)),
${day.reportDate}::date,
${dimensionTypeSql},
base.dimension_id,
MAX(base.dimension_name),
MAX(base.tenant_id),
MAX(base.tenant_name),
CASE WHEN ${dimensionTypeSql} = 'application' THEN base.dimension_id ELSE MAX(base.application_id) END,
NULL,
CASE WHEN ${dimensionTypeSql} = 'signature' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END,
CASE WHEN ${dimensionTypeSql} = 'drainage' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END,
SUM(base.billing_units)::integer,
SUM(base.success_units)::integer,
CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END,
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL AND base.arrival_ms <= thresholds.p95_ms))::integer,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM base
LEFT JOIN thresholds ON thresholds.dimension_id = base.dimension_id
GROUP BY base.dimension_id
`;
}
function qualityByChannelSql(day: BusinessDay) {
return Prisma.sql`
WITH base AS (
SELECT
channel.id AS dimension_id,
channel.name AS dimension_name,
message."billingUnits" AS billing_units,
CASE WHEN receipt."deliveredAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS success_units,
CASE WHEN receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 END AS arrival_ms
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
) receipt ON TRUE
WHERE submit."submitStatus" = 'accepted'
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
), thresholds AS (
SELECT dimension_id, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY arrival_ms) AS p95_ms
FROM base WHERE arrival_ms IS NOT NULL GROUP BY dimension_id
)
INSERT INTO "DailyQualityReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId",
"sentUnits", "successUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
)
SELECT
CONCAT('quality-channel-', MD5(${day.key} || ':' || base.dimension_id)),
${day.reportDate}::date,
'channel',
base.dimension_id,
MAX(base.dimension_name),
NULL, NULL, NULL, base.dimension_id, NULL, NULL,
SUM(base.billing_units)::integer,
SUM(base.success_units)::integer,
CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END,
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL AND base.arrival_ms <= thresholds.p95_ms))::integer,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM base
LEFT JOIN thresholds ON thresholds.dimension_id = base.dimension_id
GROUP BY base.dimension_id
`;
}
type BusinessDay = { key: string; reportDate: Date; startAt: Date; endAt: Date };
function completedBusinessDays(now: Date, count: number): BusinessDay[] {
const shifted = new Date(now.getTime() + SHANGHAI_OFFSET_MS);
const today = Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate());
return Array.from({ length: count }, (_, index) => businessDay(today - (count - index) * DAY_MS));
}
function businessDay(localDateUtc: number): BusinessDay {
const reportDate = new Date(localDateUtc);
return {
key: reportDate.toISOString().slice(0, 10),
reportDate,
startAt: new Date(localDateUtc - SHANGHAI_OFFSET_MS),
endAt: new Date(localDateUtc - SHANGHAI_OFFSET_MS + DAY_MS),
};
}
function shanghaiDateKey(now: Date) {
return new Date(now.getTime() + SHANGHAI_OFFSET_MS).toISOString().slice(0, 10);
}
function dateFilter(from?: string, to?: string): Prisma.DateTimeFilter | undefined {
const gte = parseDate(from);
const lte = parseDate(to);
if (!gte && !lte) return undefined;
return { gte, lte };
}
function parseDate(value?: string) {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? undefined : date;
}
function pagination(query: ReportListQuery) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 20)));
return { page, pageSize, skip: (page - 1) * pageSize };
}
function positiveInteger(value: string | undefined, fallback: number) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
+26 -1
View File
@@ -99,6 +99,9 @@ function createPrismaMock() {
smsSignature: {
findFirst: jest.fn().mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'reporting' }),
},
smsDrainageInfo: {
findMany: jest.fn().mockResolvedValue([]),
},
smsSendTask: {
findUnique: jest.fn().mockResolvedValue(null),
},
@@ -348,6 +351,28 @@ describe('SendChainService', () => {
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
});
it('persists the unique longest approved drainage URL match on new message records', async () => {
const { service, prisma } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', auditStatus: 'approved',
signature: { id: 'sig-1', auditStatus: 'approved', reportStatus: 'reporting' },
});
prisma.smsDrainageInfo.findMany.mockResolvedValue([
{ id: 'drain-short', url: 'https://a.example', updatedAt: new Date('2026-07-01') },
{ id: 'drain-long', url: 'https://a.example/landing', updatedAt: new Date('2026-07-02') },
]);
await service.createBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
content: '详情请访问 https://a.example/landing', phones: ['13800000001'],
});
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ signatureId: 'sig-1', drainageInfoId: 'drain-long' })],
});
});
it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => {
const { service, prisma, billing } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
@@ -934,7 +959,7 @@ describe('SendChainService', () => {
});
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
where: { OR: [{ submitId: 'SUB-1' }, { messageRecordId: 'record-1' }] },
where: { submitId: 'SUB-1' },
data: expect.objectContaining({ sequenceId: 7, gatewayMessageId: 'GW-1', submitStatus: 'accepted' }),
});
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
+39 -6
View File
@@ -282,6 +282,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const phones = [...new Set(data.phones ?? [])];
const schedule = parseSchedule(data);
await this.validateSendResources(data.tenantId, data.applicationId, data.templateId);
const messageClassification = await this.resolveTemplateMessageClassification(data.templateId, data.content);
const unitPrice = await this.resolveUnitPrice(data.tenantId, data.applicationId);
const queuePriority = await this.resolveQueuePriority(data.tenantId, data.applicationId);
const risk = await this.riskReview.evaluateTask({
@@ -366,6 +367,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
batchTaskId: task.id,
applicationId: data.applicationId,
templateId: data.templateId,
signatureId: messageClassification.signatureId,
drainageInfoId: messageClassification.drainageInfoId,
messageId: `MSG-${randomUUID()}`,
phoneNumber: phone,
content: data.content,
@@ -750,7 +753,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
: null;
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
await this.prisma.smsSubmitRecord.updateMany({
where: { OR: [{ submitId: data.submitId }, { messageRecordId: message.id }] },
where: data.submitId ? { submitId: data.submitId } : { messageRecordId: message.id },
data: {
sequenceId: data.sequenceId,
gatewayMessageId: data.gatewayMessageId,
@@ -1744,6 +1747,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
await this.recordCmppFailureReceipt(message, code, reason);
};
const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => {
const drainageInfoId = await this.resolveDrainageInfoId(options.signatureId, data.content);
const risk = await this.riskReview.evaluateTask({
tenantId: application.tenantId,
applicationId: application.id,
@@ -1759,7 +1763,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (risk.status === 'pending_review') {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'pending_review', signatureId: options.signatureId },
data: { status: 'pending_review', signatureId: options.signatureId, drainageInfoId },
});
await this.prisma.smsBatchTask.update({
where: { id: task.id },
@@ -1786,7 +1790,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'queued', signatureId: options.signatureId },
data: { status: 'queued', signatureId: options.signatureId, drainageInfoId },
});
await this.prisma.smsBatchTask.update({
where: { id: task.id },
@@ -1831,7 +1835,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
});
}
const reviewTask = risk.status === 'pending_review' && risk.task
? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id)
? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id, await this.resolveDrainageInfoId(signature.id, data.content))
: await this.riskReview.aggregateTemplateMismatch({
tenantId: application.tenantId,
applicationId: application.id,
@@ -1986,6 +1990,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
sessionId: session.id,
submitId,
submitStatus: 'queued',
costUnitPrice: channel.unitPrice ?? 0,
costAmountCents: (channel.unitPrice ?? 0) * Math.max(1, message.billingUnits ?? 1),
},
});
await this.prisma.smsMessageRecord.update({
@@ -2276,10 +2282,37 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
});
}
private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string) {
private async resolveTemplateMessageClassification(templateId: string | undefined, content: string) {
if (!templateId) return { signatureId: undefined, drainageInfoId: undefined };
const template = await this.prisma.smsTemplate.findUnique({
where: { id: templateId },
select: { signatureId: true },
});
const signatureId = template?.signatureId ?? undefined;
return { signatureId, drainageInfoId: await this.resolveDrainageInfoId(signatureId, content) };
}
private async resolveDrainageInfoId(signatureId: string | undefined, content: string) {
if (!signatureId) return undefined;
const candidates = await this.prisma.smsDrainageInfo.findMany({
where: { signatureId, auditStatus: 'approved' },
select: { id: true, url: true, updatedAt: true },
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
});
const matches = candidates
.map((item) => ({ ...item, normalizedUrl: item.url.trim() }))
.filter((item) => item.normalizedUrl.length > 0 && content.includes(item.normalizedUrl))
.sort((left, right) => right.normalizedUrl.length - left.normalizedUrl.length || right.updatedAt.getTime() - left.updatedAt.getTime());
if (matches.length === 0) return undefined;
const longestLength = matches[0].normalizedUrl.length;
const longestMatches = matches.filter((item) => item.normalizedUrl.length === longestLength);
return longestMatches.length === 1 ? longestMatches[0].id : undefined;
}
private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
await this.prisma.smsMessageRecord.update({
where: { id: messageRecordId },
data: { reviewTaskId, signatureId, status: 'pending_review' },
data: { reviewTaskId, signatureId, drainageInfoId, status: 'pending_review' },
});
return this.prisma.smsSendTask.findUnique({ where: { id: reviewTaskId } });
}
@@ -23,6 +23,11 @@ export class AdminSmsConfigController {
return this.smsConfig.getApplicationReportFields(applicationId, reportType);
}
@Get('report-fields/common')
getCommonReportFields(@Query('reportType') reportType?: 'signature' | 'drainage') {
return this.smsConfig.getApplicationReportFields(undefined, reportType);
}
@Post('enterprise-applications')
@RequireRecentAuthentication()
createApplication(@Body() body: CreateSmsApplicationDto) {
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
@@ -35,8 +35,13 @@ export class ClientSmsConfigController {
}
@Get('applications/:id/report-fields')
getApplicationReportFields(@Param('id') applicationId: string, @TenantId() tenantId?: string) {
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getApplicationReportFields(applicationId, 'drainage'));
getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage', @TenantId() tenantId?: string) {
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getApplicationReportFields(applicationId, reportType));
}
@Get('report-fields/common')
getCommonReportFields(@Query('reportType') reportType: 'signature' | 'drainage' = 'drainage') {
return this.smsConfig.getApplicationReportFields(undefined, reportType);
}
@Post('applications/:id/secret/reset')
@@ -52,6 +52,10 @@ function createPrismaMock() {
{ id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active' },
]),
},
commonReportField: {
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
smsSignature: {
findMany: jest.fn().mockResolvedValue([{
id: 'sig-1',
@@ -544,6 +548,59 @@ describe('SmsConfigService', () => {
]);
});
it('merges common report fields into every target channel requirement', async () => {
const prisma = createPrismaMock();
prisma.commonReportField.findMany.mockResolvedValue([{
id: 'common-1', reportType: 'signature', required: true, status: 'active',
drainageField: { id: 'field-common', code: 'creditCode', name: '统一社会信用代码', fieldType: 'string', description: null, status: 'active' },
}]);
prisma.channelRouteRule.findMany.mockResolvedValue([{
id: 'route-1', priority: 10,
group: {
id: 'group-1', name: '默认通道组',
items: [{ channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [] } }],
},
}] as never);
const service = new SmsConfigService(prisma as never);
await expect(service.getApplicationReportFields('app-1', 'signature')).resolves.toEqual([
expect.objectContaining({
id: 'field-common',
required: true,
reportTypes: ['signature'],
commonReportTypes: ['signature'],
channels: [expect.objectContaining({ id: 'channel-1', source: 'common', required: true })],
}),
]);
});
it('requires common signature fields even when a signature is not bound to an application', async () => {
const prisma = createPrismaMock();
prisma.commonReportField.findMany.mockResolvedValue([{
id: 'common-1', reportType: 'signature', required: true, status: 'active',
drainageField: { id: 'field-common', code: 'creditCode', name: '统一社会信用代码', fieldType: 'string', description: null, status: 'active' },
}]);
const service = new SmsConfigService(prisma as never);
await expect(service.createSignature({ tenantId: 'tenant-1', name: '无应用签名' }))
.rejects.toThrow('缺少必填签名报备资料:统一社会信用代码');
expect(prisma.smsSignature.create).not.toHaveBeenCalled();
});
it('requires common drainage fields when adding drainage info without an application', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: null, auditStatus: 'approved' });
prisma.commonReportField.findMany.mockResolvedValue([{
id: 'common-2', reportType: 'drainage', required: true, status: 'active',
drainageField: { id: 'field-site', code: 'siteOwner', name: '网站主体', fieldType: 'string', description: null, status: 'active' },
}]);
const service = new SmsConfigService(prisma as never);
await expect(service.createDrainageInfo('sig-1', { siteName: '官网', url: 'https://example.com', reportValues: {} }, {}, 'tenant-1'))
.rejects.toThrow('引流信息缺少必填报备资料:网站主体');
expect(prisma.smsDrainageInfo.create).not.toHaveBeenCalled();
});
it('validates and persists dynamic signature report values by channel without bypassing drainage audit', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'draft' });
+79 -25
View File
@@ -223,27 +223,38 @@ export class SmsConfigService {
return application;
}
async getApplicationReportFields(applicationId: string, reportType?: 'signature' | 'drainage') {
await this.getApplication(applicationId);
const routes = await this.prisma.channelRouteRule.findMany({
where: { applicationId, status: 'active' },
include: {
group: {
include: {
items: {
include: {
channel: {
include: {
reportFields: { include: { drainageField: true }, orderBy: { sortOrder: 'asc' } },
async getApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') {
if (applicationId) await this.getApplication(applicationId);
const [commonFields, routes] = await Promise.all([
this.prisma.commonReportField.findMany({
where: {
status: 'active',
reportType,
drainageField: { status: 'active' },
},
include: { drainageField: true },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
}),
applicationId ? this.prisma.channelRouteRule.findMany({
where: { applicationId, status: 'active' },
include: {
group: {
include: {
items: {
include: {
channel: {
include: {
reportFields: { include: { drainageField: true }, orderBy: { sortOrder: 'asc' } },
},
},
},
},
},
},
},
},
orderBy: { priority: 'asc' },
});
orderBy: { priority: 'asc' },
}) : Promise.resolve([]),
]);
type MergedReportField = {
id: string;
code: string;
@@ -252,9 +263,43 @@ export class SmsConfigService {
required: boolean;
description?: string | null;
reportTypes: string[];
channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: string }>;
commonReportTypes: string[];
channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: string; source: 'common' | 'channel' | 'both' }>;
};
const merged = new Map<string, MergedReportField>();
const routeChannels = new Map<string, { id: string; code: string; name: string; groupId: string; groupName: string }>();
for (const route of routes) {
if (!route.group) continue;
for (const item of route.group.items) {
if (!routeChannels.has(item.channel.id)) {
routeChannels.set(item.channel.id, {
id: item.channel.id,
code: item.channel.code,
name: item.channel.name,
groupId: route.group.id,
groupName: route.group.name,
});
}
}
}
for (const configured of commonFields) {
merged.set(configured.drainageField.id, {
id: configured.drainageField.id,
code: configured.drainageField.code,
name: configured.drainageField.name,
fieldType: configured.drainageField.fieldType,
required: configured.required,
description: configured.drainageField.description,
reportTypes: [configured.reportType],
commonReportTypes: [configured.reportType],
channels: Array.from(routeChannels.values()).map((channel) => ({
...channel,
required: configured.required,
reportType: configured.reportType,
source: 'common' as const,
})),
});
}
for (const route of routes) {
if (!route.group) continue;
for (const item of route.group.items) {
@@ -270,11 +315,17 @@ export class SmsConfigService {
required: false,
description: configured.drainageField.description,
reportTypes: [],
commonReportTypes: [],
channels: [],
};
current.required = current.required || configured.required;
if (!current.reportTypes.includes(configured.reportType)) current.reportTypes.push(configured.reportType);
if (!current.channels.some((channel) => channel.id === item.channel.id)) {
const existingChannel = current.channels.find((channel) => channel.id === item.channel.id);
if (existingChannel) {
existingChannel.required = existingChannel.required || configured.required;
existingChannel.reportType = configured.reportType;
existingChannel.source = existingChannel.source === 'common' ? 'both' : existingChannel.source;
} else {
current.channels.push({
id: item.channel.id,
code: item.channel.code,
@@ -283,6 +334,7 @@ export class SmsConfigService {
groupName: route.group.name,
required: configured.required,
reportType: configured.reportType,
source: 'channel',
});
}
merged.set(key, current);
@@ -653,6 +705,9 @@ export class SmsConfigService {
where: { applicationId: { in: applicationIds }, status: 'active' },
include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
}) : [];
const hasCommonDrainageFields = await this.prisma.commonReportField.count({
where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } },
}).then((count) => count > 0);
return signatures.map((signature) => {
const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
const drainageLinks = signature.drainageItems.map((item) => ({
@@ -681,7 +736,7 @@ export class SmsConfigService {
const channels = routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
.filter((channel) => channel.status !== 'deleted' && channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)));
.filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType))));
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => {
const task = taskByChannel.get(channel.id);
@@ -693,7 +748,7 @@ export class SmsConfigService {
const channels = routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
.filter((channel) => channel.status !== 'deleted' && channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)));
.filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType))));
const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
@@ -882,7 +937,7 @@ export class SmsConfigService {
}
private async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record<string, unknown>) {
if (!drainageInfo || !applicationId) return drainageInfo;
if (!drainageInfo) return drainageInfo;
const fields = await this.getApplicationReportFields(applicationId);
return {
...drainageInfo,
@@ -896,6 +951,7 @@ export class SmsConfigService {
fieldType: field.fieldType,
required: field.required,
reportTypes: field.reportTypes,
commonReportTypes: field.commonReportTypes,
channels: field.channels,
})),
},
@@ -903,7 +959,7 @@ export class SmsConfigService {
}
private async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record<string, unknown>) {
if (!applicationId || !drainageInfo) return;
if (!drainageInfo) return;
const fields = await this.getApplicationReportFields(applicationId);
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) {
@@ -919,9 +975,8 @@ export class SmsConfigService {
}
private async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record<string, unknown>) {
if (!applicationId || !drainageInfo) return;
const fields = await this.getApplicationReportFields(applicationId);
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
const fields = await this.getApplicationReportFields(applicationId, 'signature');
const signatureValues = isRecord(drainageInfo?.signatureReportValues) ? drainageInfo.signatureReportValues : {};
const missingSignature = fields
.filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both'))
.filter((field) => !hasReportValue(signatureValues[field.code]));
@@ -931,7 +986,6 @@ export class SmsConfigService {
}
private async validateDrainageReportValues(applicationId?: string, reportValues: Record<string, unknown> = {}) {
if (!applicationId) return;
const fields = await this.getApplicationReportFields(applicationId, 'drainage');
const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code]));
if (missing.length > 0) {