fix: 修复上行归属并实现签名质量日报优化
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewayUplinkEventDto, UplinkMatchCandidateInput } from './send-chain.contracts';
|
||||
|
||||
export type UplinkMatch = {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
messageRecordId?: string;
|
||||
messageId?: string;
|
||||
matchStatus: string;
|
||||
matchReason: string;
|
||||
candidates: UplinkMatchCandidateInput[];
|
||||
};
|
||||
|
||||
/** Attribution is application-level; a reply need not identify one original SMS. */
|
||||
export async function resolveUplinkMatch(
|
||||
db: PrismaService,
|
||||
data: GatewayUplinkEventDto,
|
||||
channel: { id: string; srcId?: string | null },
|
||||
): Promise<UplinkMatch> {
|
||||
const receivedAt = data.receivedAt ? new Date(data.receivedAt) : new Date();
|
||||
if (!Number.isFinite(receivedAt.getTime())) throw new BadRequestException('上行接收时间无效');
|
||||
const configuredHours = Number(process.env.UPLINK_MATCH_WINDOW_HOURS ?? 72);
|
||||
const hours =
|
||||
Number.isFinite(configuredHours) && configuredHours >= 1 && configuredHours <= 8760 ? configuredHours : 72;
|
||||
const since = new Date(receivedAt.getTime() - hours * 3_600_000);
|
||||
const channelEvidence = {
|
||||
channelId: channel.id,
|
||||
submitStatus: 'accepted',
|
||||
submittedAt: { gte: since, lte: receivedAt },
|
||||
};
|
||||
if (data.messageId) {
|
||||
const message = await db.smsMessageRecord.findFirst({
|
||||
where: {
|
||||
messageId: data.messageId,
|
||||
phoneNumber: data.phoneNumber,
|
||||
tenantId: { not: null },
|
||||
applicationId: { not: null },
|
||||
submitRecords: { some: channelEvidence },
|
||||
},
|
||||
select: { id: true, messageId: true, tenantId: true, applicationId: true },
|
||||
});
|
||||
if (message?.tenantId && message.applicationId)
|
||||
return {
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId,
|
||||
messageRecordId: message.id,
|
||||
messageId: message.messageId,
|
||||
matchStatus: 'matched',
|
||||
matchReason: 'messageId 与手机号、通道发送事实一致',
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
const accessNumber = data.destId || channel.srcId || '';
|
||||
// Do not truncate routes before deduplicating applications: it can manufacture uniqueness.
|
||||
const routes = accessNumber
|
||||
? await db.channelRouteRule.findMany({
|
||||
where: {
|
||||
applicationId: { not: null },
|
||||
status: 'active',
|
||||
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
|
||||
},
|
||||
select: { applicationId: true },
|
||||
distinct: ['applicationId'],
|
||||
})
|
||||
: [];
|
||||
const ids = routes.flatMap((r) => (r.applicationId ? [r.applicationId] : []));
|
||||
const applications = ids.length
|
||||
? await db.smsApplication.findMany({
|
||||
where: { id: { in: ids }, status: 'active' },
|
||||
select: { id: true, tenantId: true, name: true },
|
||||
})
|
||||
: [];
|
||||
// Read only attribution columns, but inspect the complete window, not its last two SMS.
|
||||
const messages = await db.smsMessageRecord.findMany({
|
||||
where: {
|
||||
phoneNumber: data.phoneNumber,
|
||||
tenantId: { not: null },
|
||||
applicationId: { not: null },
|
||||
submitRecords: { some: channelEvidence },
|
||||
},
|
||||
select: { id: true, messageId: true, tenantId: true, applicationId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const groups = new Map<string, typeof messages>();
|
||||
for (const message of messages) {
|
||||
if (!message.tenantId || !message.applicationId) continue;
|
||||
const key = JSON.stringify([message.tenantId, message.applicationId]);
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(message);
|
||||
groups.set(key, group);
|
||||
}
|
||||
const accessCandidates: UplinkMatchCandidateInput[] = applications.map((a) => ({
|
||||
tenantId: a.tenantId,
|
||||
applicationId: a.id,
|
||||
matchSource: 'access_number',
|
||||
confidence: 70,
|
||||
reason: '共享接入号应用候选,尚无唯一发送证据',
|
||||
}));
|
||||
if (groups.size === 1) {
|
||||
const records = [...groups.values()][0];
|
||||
const message = records[0];
|
||||
// A conflicting configured access number is evidence against automatic assignment.
|
||||
if (!applications.length || applications.some((a) => a.id === message.applicationId))
|
||||
return {
|
||||
tenantId: message.tenantId!,
|
||||
applicationId: message.applicationId!,
|
||||
messageRecordId: records.length === 1 ? message.id : undefined,
|
||||
messageId: records.length === 1 ? message.messageId : undefined,
|
||||
matchStatus: 'matched',
|
||||
matchReason:
|
||||
records.length === 1
|
||||
? `手机号、通道及接收前 ${hours} 小时唯一匹配`
|
||||
: `手机号、通道及接收前 ${hours} 小时应用唯一;原短信不唯一`,
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
// No sending evidence: retain the existing unique-access application attribution.
|
||||
if (!groups.size && applications.length === 1)
|
||||
return {
|
||||
tenantId: applications[0].tenantId,
|
||||
applicationId: applications[0].id,
|
||||
matchStatus: 'matched',
|
||||
matchReason: '接入号唯一匹配应用,无唯一原短信',
|
||||
candidates: [],
|
||||
};
|
||||
const candidates = new Map(accessCandidates.map((c) => [c.applicationId, c]));
|
||||
for (const records of groups.values()) {
|
||||
const m = records[0];
|
||||
candidates.set(m.applicationId!, {
|
||||
tenantId: m.tenantId!,
|
||||
applicationId: m.applicationId!,
|
||||
messageRecordId: records.length === 1 ? m.id : undefined,
|
||||
matchSource: 'phone_window',
|
||||
confidence: 70,
|
||||
reason: `同通道接收前 ${hours} 小时有 ${records.length} 条下发;须确认应用归属`,
|
||||
});
|
||||
}
|
||||
return candidates.size
|
||||
? {
|
||||
matchStatus: 'ambiguous',
|
||||
matchReason: '应用归属仍有多候选或接入号与发送事实冲突',
|
||||
candidates: [...candidates.values()],
|
||||
}
|
||||
: { matchStatus: 'unmatched', matchReason: '未匹配到接入号应用或时间窗内同通道发送事实', candidates: [] };
|
||||
}
|
||||
Reference in New Issue
Block a user