147 lines
5.7 KiB
TypeScript
147 lines
5.7 KiB
TypeScript
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: [] };
|
|
}
|