fix: 修复上行归属并实现签名质量日报优化

This commit is contained in:
hectorzhao
2026-09-17 11:12:58 +08:00
parent 4eb7b16d12
commit 572290308c
40 changed files with 2854 additions and 778 deletions
@@ -1,4 +1,5 @@
import { completionContext } from './completion-context';
import { resolveUplinkMatch } from './uplink-matching';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto';
@@ -32,7 +33,19 @@ export class SendDownstreamDeliveryService {
) {}
async handleUplink(data: GatewayUplinkEventDto) {
if (!completionContext.getStore()) {
return this.prisma.$transaction(
(tx) => completionContext.run({ tx, messageRecordId: '' }, () => this.persistUplink(data)),
{ timeout: 15_000 },
);
}
return this.persistUplink(data);
}
private async persistUplink(data: GatewayUplinkEventDto) {
if (data.eventId) {
await this.prisma
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`uplink-event:${data.eventId}`},0))`;
const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } });
if (existing) return existing;
}
@@ -48,7 +61,7 @@ export class SendDownstreamDeliveryService {
applicationId: match.applicationId,
messageRecordId: match.messageRecordId,
channelId: data.channelId,
messageId: data.messageId,
messageId: match.messageId,
gatewayMessageId: data.gatewayMessageId,
sequenceId: data.sequenceId,
phoneNumber: data.phoneNumber,
@@ -78,10 +91,10 @@ export class SendDownstreamDeliveryService {
tenantId: match.tenantId,
applicationId: match.applicationId,
messageRecordId: match.messageRecordId,
messageId: data.messageId,
messageId: match.messageId,
deliveryType: 'uplink',
payload: {
messageId: data.messageId,
messageId: match.messageId,
applicationId: match.applicationId,
phoneNumber: data.phoneNumber,
destId: data.destId,
@@ -95,6 +108,21 @@ export class SendDownstreamDeliveryService {
}
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
if (!completionContext.getStore()) {
return this.prisma.$transaction(
(tx) =>
completionContext.run({ tx, messageRecordId: '' }, () =>
this.persistUplinkClaim(uplinkMessageId, candidateId, operatorId),
),
{ timeout: 15_000 },
);
}
return this.persistUplinkClaim(uplinkMessageId, candidateId, operatorId);
}
private async persistUplinkClaim(uplinkMessageId: string, candidateId: string, operatorId?: string) {
await this.prisma
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`uplink-claim:${uplinkMessageId}`},0))`;
const candidate = await this.prisma.smsUplinkMatchCandidate.findFirst({
where: { id: candidateId, uplinkMessageId },
include: {
@@ -112,56 +140,55 @@ export class SendDownstreamDeliveryService {
if (candidate.uplinkMessage.matchStatus === 'matched' && candidate.status !== 'claimed') {
throw new BadRequestException('该上行记录已完成匹配,不能重复认领');
}
if (candidate.status === 'claimed') return candidate.uplinkMessage;
const claimedAt = new Date();
const messageId = candidate.uplinkMessage.messageId ?? candidate.messageRecord?.messageId ?? null;
const [updatedUplink] = await this.prisma.$transaction([
this.prisma.smsUplinkMessage.update({
where: { id: uplinkMessageId },
data: {
tenantId: candidate.tenantId,
const messageId = candidate.messageRecord?.messageId ?? null;
const updatedUplink = await this.prisma.smsUplinkMessage.update({
where: { id: uplinkMessageId },
data: {
tenantId: candidate.tenantId,
applicationId: candidate.applicationId,
messageRecordId: candidate.messageRecordId,
messageId,
matchStatus: 'matched',
matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`,
},
});
await this.prisma.smsUplinkMatchCandidate.updateMany({
where: {
uplinkMessageId,
id: { not: candidate.id },
status: 'pending',
},
data: { status: 'rejected' },
});
await this.prisma.smsUplinkMatchCandidate.update({
where: { id: candidate.id },
data: {
status: 'claimed',
claimedAt,
claimedById: operatorId,
},
});
await this.prisma.operationLog.create({
data: {
tenantId: candidate.tenantId,
userId: operatorId,
action: 'gateway.uplink_manual_claim',
resource: 'sms_uplink_message',
resourceId: uplinkMessageId,
detail: {
candidateId: candidate.id,
applicationId: candidate.applicationId,
applicationName: candidate.application.name,
messageRecordId: candidate.messageRecordId,
messageId,
matchStatus: 'matched',
matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`,
matchSource: candidate.matchSource,
phoneNumber: candidate.uplinkMessage.phoneNumber,
destId: candidate.uplinkMessage.destId,
},
}),
this.prisma.smsUplinkMatchCandidate.updateMany({
where: {
uplinkMessageId,
id: { not: candidate.id },
status: 'pending',
},
data: { status: 'rejected' },
}),
this.prisma.smsUplinkMatchCandidate.update({
where: { id: candidate.id },
data: {
status: 'claimed',
claimedAt,
claimedById: operatorId,
},
}),
this.prisma.operationLog.create({
data: {
tenantId: candidate.tenantId,
userId: operatorId,
action: 'gateway.uplink_manual_claim',
resource: 'sms_uplink_message',
resourceId: uplinkMessageId,
detail: {
candidateId: candidate.id,
applicationId: candidate.applicationId,
applicationName: candidate.application.name,
messageRecordId: candidate.messageRecordId,
messageId,
matchSource: candidate.matchSource,
phoneNumber: candidate.uplinkMessage.phoneNumber,
destId: candidate.uplinkMessage.destId,
},
},
}),
]);
},
});
await this.facade.queueAndTryDownstreamDelivery({
tenantId: candidate.tenantId,
@@ -278,7 +305,10 @@ export class SendDownstreamDeliveryService {
skipDuplicates: true,
});
const retained = await this.prisma.cmppDownstreamDelivery.findUniqueOrThrow({ where: { dedupeKey } });
if (retained.messageRecordId !== data.messageRecordId || retained.applicationId !== data.applicationId)
if (
(retained.messageRecordId ?? null) !== (data.messageRecordId ?? null) ||
retained.applicationId !== data.applicationId
)
throw new Error('completion_notification_identity_mismatch');
return retained;
}
@@ -377,107 +407,12 @@ export class SendDownstreamDeliveryService {
tenantId?: string;
applicationId?: string;
messageRecordId?: string;
messageId?: string;
matchStatus: string;
matchReason: string;
candidates: UplinkMatchCandidateInput[];
}> {
if (data.messageId) {
const message = await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } });
if (message?.tenantId) {
return {
tenantId: message.tenantId,
applicationId: message.applicationId ?? undefined,
messageRecordId: message.id,
matchStatus: message.applicationId ? 'matched' : 'unmatched',
matchReason: message.applicationId ? 'messageId 精确匹配' : 'messageId 匹配到下发记录但无应用',
candidates: [],
};
}
}
const accessNumber = data.destId || channel.srcId || '';
const accessRoutes = accessNumber
? await this.prisma.channelRouteRule.findMany({
where: {
applicationId: { not: null },
status: 'active',
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
},
select: { applicationId: true },
take: 10,
})
: [];
const accessApplicationIds = [
...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value))),
];
const accessApplications =
accessApplicationIds.length > 0
? await this.prisma.smsApplication.findMany({
where: { id: { in: accessApplicationIds }, status: 'active' },
select: { id: true, tenantId: true, name: true },
})
: [];
if (accessApplications.length === 1) {
return {
tenantId: accessApplications[0].tenantId,
applicationId: accessApplications[0].id,
matchStatus: 'matched',
matchReason: '接入号唯一匹配应用',
candidates: [],
};
}
if (accessApplications.length > 1) {
return {
matchStatus: 'ambiguous',
matchReason: '接入号匹配多个应用',
candidates: accessApplications.map((application) => ({
tenantId: application.tenantId,
applicationId: application.id,
matchSource: 'access_number',
confidence: 70,
reason: `接入号 ${accessNumber} 可匹配应用 ${application.name}`,
})),
};
}
const windowHours = Number(process.env.UPLINK_MATCH_WINDOW_HOURS ?? 72);
const since = new Date(Date.now() - Math.max(1, windowHours) * 60 * 60 * 1000);
const recentMessages = await this.prisma.smsMessageRecord.findMany({
where: {
phoneNumber: data.phoneNumber,
tenantId: { not: null },
applicationId: { not: null },
submittedAt: { gte: since },
},
orderBy: { submittedAt: 'desc' },
take: 2,
});
const matchableRecentMessages = recentMessages.filter((message) => message.tenantId && message.applicationId);
if (matchableRecentMessages.length === 1) {
return {
tenantId: matchableRecentMessages[0].tenantId ?? undefined,
applicationId: matchableRecentMessages[0].applicationId ?? undefined,
messageRecordId: matchableRecentMessages[0].id,
matchStatus: 'matched',
matchReason: `手机号 ${windowHours} 小时窗口唯一匹配`,
candidates: [],
};
}
if (matchableRecentMessages.length > 1) {
return {
matchStatus: 'ambiguous',
matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`,
candidates: matchableRecentMessages.map((message) => ({
tenantId: String(message.tenantId),
applicationId: String(message.applicationId),
messageRecordId: message.id,
matchSource: 'phone_window',
confidence: 55,
reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`,
})),
};
}
return { matchStatus: 'unmatched', matchReason: '未匹配到应用或下发记录', candidates: [] };
return resolveUplinkMatch(this.prisma, data, channel);
}
async recordCmppFailureReceipt(