feat: add reconciliation and quality reporting
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user