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
+146
View File
@@ -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: [] };
}