You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Asterisk呼叫转移时多CDR日志重复生成的解决方案问询

Asterisk呼叫转移场景下避免重复生成通话日志的方案

问题背景

在Asterisk系统中,呼叫转移会触发5条CDR事件:1条来自队列,其余4条为呼入事件。正常无分机转移的呼叫场景中,系统仅生成1条通话日志(对应2条CDR:队列事件+1条呼入事件);但分机间转移时,4条呼入CDR会导致系统生成4条重复的通话日志,需要实现呼叫转移全生命周期仅生成1条通话日志。

现有CDR处理代码

this.ami.on('cdr', async (evt) => {
  if (evt.source == '//somerandomtestphonenumber//') {
    console.log(evt);
  }
  var destination: string = evt.destination.toString();

  var destinationcontext: string = evt.destinationcontext.toString();
  var disposition: string = evt.disposition.toString();
  var channel: string = evt.channel.toString();
  var destinationchannel: string = evt.destinationchannel.toString();
  var source: string = evt.source.toString();
  const landline: string = '+//someLandLinePhoneNumber';
  var receiver: string = evt.destination.toString();
  var startTime: Date = new Date(evt.starttime);
  var answerTime: Date =
    evt.answertime == '' ? null : new Date(evt.answertime);
  var endTime: Date = new Date(evt.endtime);
  var totalTime: number = parseFloat(evt.duration);
  var callTime: number = parseFloat(evt.billableseconds);
  var holdTime: number = totalTime - callTime;
  var lastapplication = evt.lastapplication.toString();
  var duration: string = evt.duration.toString();
  var billableseconds: string = evt.billableseconds.toString();
  var uniqueId: string = evt.uniqueid.toString();

  // Voicemail
  if (destination == 's' && evt.lastapplication == 'VoiceMail') {
    const contextFirstPart = destinationcontext.substring(
      destinationcontext.indexOf('-') + 1,
      destinationcontext.length,
    );
    const queue = contextFirstPart.substring(
      contextFirstPart.indexOf('-') + 1,
      contextFirstPart.length,
    );
    // Note : Event is handled in voicemail service

    this.eventEmitter.emit(
      'voicemail-received',
      new VoicemailLogDto(
        queue,
        source,
        startTime,
        answerTime,
        endTime,
        duration,
        disposition,
        uniqueId,
        channel,
      ),
    );
  }

  if (
    !destinationcontext.includes('ivr') &&
    !disposition.includes('CONGESTION') &&
    !disposition.includes('FAILED')
  ) {
    var caller: string =
      source == landline
        ? evt.channel.split('/')[1].split('-')[0]
        : evt.source.toString();

    const callLogEntry = new CreateCalllogDto(
      caller,
      receiver,
      startTime,
      answerTime,
      endTime,
      evt.endtime,
      holdTime,
      totalTime,
      callTime,
      disposition,
      'unknown',
      uniqueId,
    );

    // For Outbound
    if (source == landline && destinationcontext == 'from-internal') {
      callLogEntry.type = 'outbound';
      if (process.env.NODE_ENV !== 'development') {
        await this.callLogService.create(callLogEntry);
      }

      if (
        callLogEntry.disposition == 'NO ANSWER' ||
        callLogEntry.disposition == 'BUSY'
      ) {
        this.realtimeGateway.emitData(
          'outbound-missed',
          'outbound-missed',
          [Role.ADMIN, Role.SUPERVISOR, Role.AGENT],
        );
      }
      if (callLogEntry.disposition == 'ANSWERED') {
        this.realtimeGateway.emitData(
          'outbound-success',
          'outbound-success',
          [Role.ADMIN, Role.SUPERVISOR, Role.AGENT],
        );
      }
    }

    // For inbound
    if (source != landline && destinationcontext == 'from-internal') {
      callLogEntry.type = 'inbound';
      var result = await this.callLogService.create(callLogEntry);
    }

    // For internal
    if (destinationchannel.includes('from-internal-xfer')) {
      callLogEntry.type = 'internal';
      await this.callLogService.create(callLogEntry);
    }

    // For queue
    if (source != landline && destinationcontext == 'ext-queues') {
      const queueLogEntry = new CreateQueueReceiveLogDto(
        caller,
        destinationchannel.substring(
          destinationchannel.indexOf('/') + 1,
          destinationchannel.indexOf('@'),
        ),
        receiver,
        'ANSWERED',
        uniqueId,
        evt.endtime,
      );
      if (process.env.NODE_ENV !== 'development') {
        await this.queueLogService.createQueueReceiveLog(queueLogEntry);
      }
    }
  }
});

呼叫转移时的CDR事件日志

{
  event: 'Cdr',
  privilege: 'cdr,all',
  accountcode: '',
  source: '/somerandomphonenumber/',
  destination: '4043',
  destinationcontext: 'from-internal',
  callerid: '"/somerandomphonenumber/" </somerandomphonenumber/>',
  channel: 'Local/4043@from-internal-0000032a;2',
  destinationchannel: 'SIP/4043-00000835',
  lastapplication: 'Dial',
  lastdata: 'SIP/4043,,trM(auto-blkvm)I',
  starttime: '2023-10-03 11:52:49',
  answertime: '2023-10-03 11:52:52',
  endtime: '2023-10-03 11:52:57',
  duration: '7',
  billableseconds: '4',
  disposition: 'ANSWERED',
  amaflags: 'DOCUMENTATION',
  uniqueid: '1696313269.25353',
  userfield: ''
}
{
  event: 'Cdr',
  privilege: 'cdr,all',
  accountcode: '',
  source: '/somerandomphonenumber/',
  destination: '4042',
  destinationcontext: 'from-internal',
  callerid: '"/somerandomphonenumber/" </somerandomphonenumber/>',
  channel: 'Local/4042@from-queue-00000327;2',
  destinationchannel: 'SIP/4042-0000082f',
  lastapplication: 'Dial',
  lastdata: 'SIP/4042,,trM(auto-blkvm)',
  starttime: '2023-10-03 11:52:30',
  answertime: '2023-10-03 11:52:36',
  endtime: '2023-10-03 11:52:49',
  duration: '18',
  billableseconds: '12',
  disposition: 'ANSWERED',
  amaflags: 'DOCUMENTATION',
  uniqueid: '1696313250.25287',
  userfield: ''
}
{
  event: 'Cdr',
  privilege: 'cdr,all',
  accountcode: '',
  source: '/somerandomphonenumber/',
  destination: '4042',
  destinationcontext: 'from-internal',
  callerid: '"/somerandomphonenumber/" </somerandomphonenumber/>',
  channel: 'Local/4042@from-queue-00000327;2',
  destinationchannel: 'Local/4043@from-internal-0000032a;1',
  lastapplication: 'Dial',
  lastdata: 'SIP/4042,,trM(auto-blkvm)',
  starttime: '2023-10-03 11:52:49',
  answertime: '2023-10-03 11:52:49',
  endtime: '2023-10-03 11:52:57',
  duration: '7',
  billableseconds: '7',
  disposition: 'ANSWERED',
  amaflags: 'DOCUMENTATION',
  uniqueid: '1696313250.25287',
  userfield: ''
}
{
  event: 'Cdr',
  privilege: 'cdr,all',
  accountcode: '',
  source: '/somerandomphonenumber/',
  destination: '4042',
  destinationcontext: 'from-internal',
  callerid: '"/somerandomphonenumber/" </somerandomphonenumber/>',
  channel: 'Local/4042@from-queue-00000327;2',
  destinationchannel: 'SIP/4043-00000835',
  lastapplication: 'Dial',
  lastdata: 'SIP/4042,,trM(auto-blkvm)',
  starttime: '2023-10-03 11:52:57',
  answertime: '2023-10-03 11:52:57',
  endtime: '2023-10-03 11:53:02',
  duration: '4',
  billableseconds: '4',
  disposition: 'ANSWERED',
  amaflags: 'DOCUMENTATION',
  uniqueid: '1696313250.25287',
  userfield: ''
}
{
  event: 'Cdr',
  privilege: 'cdr,all',
  accountcode: '',
  source: '/somerandomphonenumber/',
  destination: '602',
  destinationcontext: 'ext-queues',
  callerid: '"/somerandomphonenumber/" </somerandomphonenumber/>',
  channel: 'SIP/ntc_sip-0000082d',
  destinationchannel: 'Local/4042@from-queue-00000327;1',
  lastapplication: 'Queue',
  lastdata: '602,t,,,10,,,,,',
  starttime: '2023-10-03 11:52:27',
  answertime: '2023-10-03 11:52:27',
  endtime: '2023-10-03 11:53:02',
  duration: '34',
  billableseconds: '34',
  disposition: 'ANSWERED',
  amaflags: 'DOCUMENTATION',
  uniqueid: '1696313247.25273',
  userfield: ''
}

可行解决方案

从提供的CDR日志可以看出,同一呼叫转移链的呼入CDR共享同一个uniqueid,而队列CDR拥有独立的uniqueid且包含整个呼叫的完整时长。基于这些特征,有以下两种核心解决思路:

思路1:基于uniqueid去重,仅处理一次

维护一个内存缓存记录已处理的呼叫uniqueid,当收到呼入CDR时,先检查该uniqueid是否已经处理过,跳过重复的CDR:

// 在类内部初始化缓存,用于记录已处理的呼叫ID
private processedCallIds = new Map<string, boolean>();

this.ami.on('cdr', async (evt) => {
  // ... 原有变量定义逻辑 ...

  if (
    !destinationcontext.includes('ivr') &&
    !disposition.includes('CONGESTION') &&
    !disposition.includes('FAILED')
  ) {
    // ... 原有caller、callLogEntry创建逻辑 ...

    // 修改呼入CDR处理逻辑,添加去重判断
    if (source != landline && destinationcontext == 'from-internal') {
      // 若该uniqueid已处理过,直接跳过
      if (this.processedCallIds.has(uniqueId)) {
        return;
      }
      // 标记为已处理
      this.processedCallIds.set(uniqueId, true);

      callLogEntry.type = 'inbound';
      await this.callLogService.create(callLogEntry);
    }

    // ... 原有其他类型呼叫处理逻辑 ...
  }
});

思路2:仅处理整个呼叫链的最终事件

队列CDR的endtime是整个呼叫的最终结束时间,且包含完整的通话时长,可以直接基于队列CDR生成主通话日志,同时忽略转移过程中的中间呼入CDR:

this.ami.on('cdr', async (evt) => {
  // ... 原有变量定义逻辑 ...

  if (
    !destinationcontext.includes('ivr') &&
    !disposition.includes('CONGESTION') &&
    !disposition.includes('FAILED')
  ) {
    // ... 原有caller、callLogEntry创建逻辑 ...

    // 仅保留队列CDR的处理逻辑,呼入CDR不再单独生成日志
    if (source != landline && destinationcontext == 'ext-queues') {
      // 基于队列CDR生成完整的通话日志
      const fullCallLogEntry = new CreateCalllogDto(
        caller,
        // 可以从呼叫链中提取最终接听的分机号,或保留队列号
        receiver,
        startTime,
        answerTime,
        endTime,
        evt.endtime,
        holdTime,
        totalTime,
        callTime,
        disposition,
        'inbound',
        uniqueId,
      );
      await this.callLogService.create(fullCallLogEntry);

      // 原有队列日志逻辑保留
      const queueLogEntry = new CreateQueueReceiveLogDto(
        caller,
        destinationchannel.substring(
          destinationchannel.indexOf('/') + 1,
          destinationchannel.indexOf('@'),
        ),
        receiver,
        'ANSWERED',
        uniqueId,
        evt.endtime,
      );
      if (process.env.NODE_ENV !== 'development') {
        await this.queueLogService.createQueueReceiveLog(queueLogEntry);
      }
    }

    // 注释或删除原有呼入CDR的处理逻辑
    /*
    if (source != landline && destinationcontext == 'from-internal') {
      callLogEntry.type = 'inbound';
      var result = await this.callLogService.create(callLogEntry);
    }
    */

    // ... 原有其他类型呼叫处理逻辑 ...
  }
});

额外优化:识别转移中间CDR直接跳过

通过CDR的destinationchannel字段可以识别转移的中间链路(比如包含Local/xxx@from-internal格式的记录),直接跳过这类中间CDR:

// 在处理呼入CDR前先判断是否为转移中间事件
if (source != landline && destinationcontext == 'from-internal') {
  // 如果是转移中间链路的CDR,直接跳过
  if (destinationchannel.startsWith('Local/')) {
    return;
  }
  // 正常生成日志
  callLogEntry.type = 'inbound';
  await this.callLogService.create(callLogEntry);
}

内容的提问来源于stack exchange,提问作者arpandhakal

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.09 06:27:02