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 { 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(); 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: [] }; }