|
|
|
@ -3,10 +3,10 @@ package com.threecloud.dataserviceyy.service; |
|
|
|
import com.alibaba.fastjson2.JSONArray; |
|
|
|
import com.alibaba.fastjson2.JSONObject; |
|
|
|
import com.threecloud.dataserviceyy.entity.MidVoiceCallRecord; |
|
|
|
import com.threecloud.dataserviceyy.entity.MidVoiceChannelConfig; |
|
|
|
import com.threecloud.dataserviceyy.entity.MidVoiceDeviceLog; |
|
|
|
import com.threecloud.dataserviceyy.mapper.MidVoiceCallRecordMapper; |
|
|
|
import com.threecloud.dataserviceyy.mapper.MidVoiceDeviceLogMapper; |
|
|
|
import com.threecloud.dataserviceyy.mapper.VoiceSyncMapper; |
|
|
|
import com.threecloud.dataserviceyy.service.channel.ChannelConfigService; |
|
|
|
import com.threecloud.dataserviceyy.util.*; |
|
|
|
import org.slf4j.Logger; |
|
|
|
import org.slf4j.LoggerFactory; |
|
|
|
@ -16,93 +16,42 @@ import org.springframework.scheduling.annotation.Scheduled; |
|
|
|
import org.springframework.stereotype.Service; |
|
|
|
import org.springframework.util.StringUtils; |
|
|
|
|
|
|
|
import java.io.File; |
|
|
|
import java.net.URLEncoder; |
|
|
|
import java.nio.file.Files; |
|
|
|
import java.nio.file.Path; |
|
|
|
import java.nio.file.Paths; |
|
|
|
import java.util.Date; |
|
|
|
import java.util.List; |
|
|
|
import java.util.Map; |
|
|
|
import java.util.*; |
|
|
|
|
|
|
|
/** |
|
|
|
* VAA录音盒定时同步服务 |
|
|
|
* |
|
|
|
* 【功能说明】 |
|
|
|
* 1. 定时从 EBOX 录音盒拉取录音记录 |
|
|
|
* 2. 下载录音文件到本地(保留10天) |
|
|
|
* 3. 上传录音文件到 OSS |
|
|
|
* 4. 保存通话记录到 mid_voice_call_record 表 |
|
|
|
* |
|
|
|
* 【数据来源】 |
|
|
|
* - 设备列表:mid_voice_device_config 表 |
|
|
|
* - 通道配置:优先从 EBOX API 获取,失败则从数据库读取 |
|
|
|
* |
|
|
|
* 【同步策略】 |
|
|
|
* - 每2小时执行一次(cron: 0 0 0/2 * * ?) |
|
|
|
* - 增量同步:根据上次同步时间只拉取新录音 |
|
|
|
* - 防重复:根据 device_no + record_id 判断 |
|
|
|
* |
|
|
|
* 【目录结构】 |
|
|
|
* vaa-recordings/ |
|
|
|
* ├── 20240101/ # 按日期分目录 |
|
|
|
* │ └── <device_uuid>/ # 按设备分目录 |
|
|
|
* │ └── xxx.wav |
|
|
|
* └── .sync-marker/ # 同步时间标记 |
|
|
|
* └── <device_id>.time |
|
|
|
* 【功能】从EBOX录音盒拉取录音 → 下载文件 → 上传OSS → 保存通话记录 |
|
|
|
* 【策略】增量同步、无重试、防重复、失败记录到日志表 |
|
|
|
* 【账户】每个设备的账号密码从 mid_voice_device_config 表读取 |
|
|
|
*/ |
|
|
|
@Service |
|
|
|
public class VaaSyncService { |
|
|
|
|
|
|
|
private static final Logger logger = LoggerFactory.getLogger(VaaSyncService.class); |
|
|
|
|
|
|
|
// ==================== 依赖注入 ====================
|
|
|
|
|
|
|
|
@Autowired |
|
|
|
private VoiceSyncMapper voiceSyncMapper; |
|
|
|
|
|
|
|
@Autowired |
|
|
|
private MidVoiceCallRecordMapper callRecordMapper; |
|
|
|
|
|
|
|
@Autowired |
|
|
|
private ChannelConfigService channelConfigService; |
|
|
|
|
|
|
|
private MidVoiceDeviceLogMapper deviceLogMapper; |
|
|
|
@Autowired |
|
|
|
private VaaHttpUtil vaaHttpUtil; |
|
|
|
|
|
|
|
@Autowired |
|
|
|
private FileUploadUtil fileUploadUtil; |
|
|
|
|
|
|
|
// ==================== 配置参数 ====================
|
|
|
|
|
|
|
|
/** 本地录音文件存储路径 */ |
|
|
|
@Value("${vaa-sync.download-path:./vaa-recordings}") |
|
|
|
private String downloadPath; |
|
|
|
|
|
|
|
/** 录音盒登录用户名 */ |
|
|
|
@Value("${vaa-sync.device-username:admin}") |
|
|
|
private String deviceUsername; |
|
|
|
|
|
|
|
/** 录音盒登录密码 */ |
|
|
|
@Value("${vaa-sync.device-password:admin}") |
|
|
|
private String devicePassword; |
|
|
|
|
|
|
|
/** 本地文件保留天数 */ |
|
|
|
@Value("${vaa-sync.retain-days:10}") |
|
|
|
private int retainDays; |
|
|
|
|
|
|
|
// ==================== 定时任务 ====================
|
|
|
|
|
|
|
|
/** |
|
|
|
* 定时同步任务 - 每2小时执行一次 |
|
|
|
* |
|
|
|
* cron表达式说明:0 0 0/2 * * ? |
|
|
|
* - 秒:0 |
|
|
|
* - 分:0 |
|
|
|
* - 时:0/2 表示从0点开始,每2小时 |
|
|
|
* - 日:* 每天 |
|
|
|
* - 月:* 每月 |
|
|
|
* - 周:? 不指定 |
|
|
|
* 定时同步任务 |
|
|
|
*/ |
|
|
|
@Scheduled(cron = "${vaa-sync.sync-interval-cron:0 0 0/2 * * ?}") |
|
|
|
public void scheduledSync() { |
|
|
|
@ -113,36 +62,23 @@ public class VaaSyncService { |
|
|
|
logger.info("【定时任务】========== VAA录音盒同步结束,耗时 {} 秒 ==========", costTime / 1000); |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== 核心同步逻辑 ====================
|
|
|
|
|
|
|
|
/** |
|
|
|
* 执行同步任务(主入口) |
|
|
|
* |
|
|
|
* 执行流程: |
|
|
|
* 1. 清理过期本地文件 |
|
|
|
* 2. 查询所有在线设备 |
|
|
|
* 3. 逐个设备同步 |
|
|
|
*/ |
|
|
|
public void executeSync() { |
|
|
|
logger.info("【主流程】开始执行VAA录音盒同步任务"); |
|
|
|
|
|
|
|
try { |
|
|
|
// 步骤1:清理过期本地文件
|
|
|
|
logger.debug("【步骤1】清理 {} 天前的本地录音文件", retainDays); |
|
|
|
// 清理过期本地文件
|
|
|
|
FileCleaner.cleanOldFiles(downloadPath, retainDays); |
|
|
|
|
|
|
|
// 步骤2:查询设备列表
|
|
|
|
logger.debug("【步骤2】查询在线设备列表"); |
|
|
|
// 查询在线设备列表(含每个设备的账号密码)
|
|
|
|
List<Map<String, Object>> deviceList = voiceSyncMapper.getAllYysb(); |
|
|
|
logger.info("【主流程】查询到 {} 个语音设备", deviceList.size()); |
|
|
|
logger.info("【主流程】查询到 {} 个在线语音设备", deviceList.size()); |
|
|
|
|
|
|
|
// 步骤3:逐个设备同步
|
|
|
|
int successCount = 0; |
|
|
|
int failCount = 0; |
|
|
|
for (int i = 0; i < deviceList.size(); i++) { |
|
|
|
Map<String, Object> device = deviceList.get(i); |
|
|
|
String deviceId = getStringValue(device, "ID"); |
|
|
|
logger.debug("【步骤3】处理第 {}/{} 个设备: ID={}", i + 1, deviceList.size(), deviceId); |
|
|
|
String deviceId = getStr(device, "ID"); |
|
|
|
try { |
|
|
|
syncSingleDevice(device); |
|
|
|
successCount++; |
|
|
|
@ -151,8 +87,7 @@ public class VaaSyncService { |
|
|
|
logger.error("【异常】设备同步失败: ID={}, 原因={}", deviceId, e.getMessage()); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
logger.info("【主流程】同步完成,成功 {} 个设备,失败 {} 个设备", successCount, failCount); |
|
|
|
logger.info("【主流程】同步完成,成功 {} 个,失败 {} 个", successCount, failCount); |
|
|
|
} catch (Exception e) { |
|
|
|
logger.error("【异常】同步任务执行失败: {}", e.getMessage(), e); |
|
|
|
} |
|
|
|
@ -161,387 +96,287 @@ public class VaaSyncService { |
|
|
|
/** |
|
|
|
* 同步单个设备 |
|
|
|
* |
|
|
|
* @param device 设备信息Map,包含: |
|
|
|
* - ID: 设备ID |
|
|
|
* - UUID: 设备编号(device_no) |
|
|
|
* - ORGAN_NAME: 地市名称 |
|
|
|
* - ORGAN_ID: 地市编码 |
|
|
|
* - IP: IP地址 |
|
|
|
* - PORT: 端口号 |
|
|
|
* - ORG_CODE: 单位代码 |
|
|
|
* 流程:登录 → 获取分机号码 → 查录音列表 → 逐条下载上传保存 |
|
|
|
* 任何步骤失败直接记录日志,跳过该设备 |
|
|
|
*/ |
|
|
|
private void syncSingleDevice(Map<String, Object> device) throws Exception { |
|
|
|
// 提取设备信息
|
|
|
|
String deviceId = getStringValue(device, "ID"); |
|
|
|
String deviceNo = getStringValue(device, "UUID"); |
|
|
|
String cityName = getStringValue(device, "ORGAN_NAME"); |
|
|
|
String cityCode = getStringValue(device, "ORGAN_ID"); |
|
|
|
String ip = getStringValue(device, "IP"); |
|
|
|
Integer port = getIntValue(device, "PORT", 80); |
|
|
|
String orgCode = getStringValue(device, "ORG_CODE"); |
|
|
|
|
|
|
|
logger.info("【设备】────────────────────────────────────────"); |
|
|
|
logger.info("【设备】开始同步设备: ID={}, 编号={}, 机构={}, IP={}:{}", |
|
|
|
deviceId, deviceNo, cityName, ip, port); |
|
|
|
String deviceId = getStr(device, "ID"); |
|
|
|
String deviceNo = getStr(device, "UUID"); |
|
|
|
String cityName = getStr(device, "ORGAN_NAME"); |
|
|
|
String cityCode = getStr(device, "ORGAN_ID"); |
|
|
|
String ip = getStr(device, "IP"); |
|
|
|
Integer port = getInt(device, "PORT", 80); |
|
|
|
String orgCode = getStr(device, "ORG_CODE"); |
|
|
|
// 每个设备有自己的账号密码,从配置表读取
|
|
|
|
String username = getStr(device, "USERNAME"); |
|
|
|
String password = getStr(device, "PASSWORD"); |
|
|
|
|
|
|
|
logger.info("【设备】同步设备: ID={}, 编号={}, 机构={}, IP={}:{}", deviceId, deviceNo, cityName, ip, port); |
|
|
|
|
|
|
|
// 参数校验
|
|
|
|
if (!StringUtils.hasText(ip)) { |
|
|
|
logger.warn("【设备】设备IP为空,跳过同步: ID={}", deviceId); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// 机构名称兜底
|
|
|
|
if (!StringUtils.hasText(cityName)) { |
|
|
|
cityName = "unknown_" + deviceId; |
|
|
|
} |
|
|
|
|
|
|
|
// 构造设备访问地址
|
|
|
|
String deviceHost = buildDeviceHost(ip, port); |
|
|
|
|
|
|
|
// 步骤1:登录认证
|
|
|
|
logger.debug("【设备-步骤1】登录设备: {}", deviceHost); |
|
|
|
String authToken = loginDevice(deviceHost); |
|
|
|
if (authToken == null) { |
|
|
|
logger.error("【设备-异常】设备登录失败,跳过同步: IP={}", ip); |
|
|
|
logger.warn("【设备】IP为空,跳过: ID={}", deviceId); |
|
|
|
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "0", "设备IP为空"); |
|
|
|
return; |
|
|
|
} |
|
|
|
logger.debug("【设备-步骤1】登录成功,获取到认证令牌"); |
|
|
|
|
|
|
|
// 步骤2:获取时间范围
|
|
|
|
TimeRange timeRange = calculateSyncTimeRange(deviceId); |
|
|
|
logger.debug("【设备-步骤2】同步时间范围: {} 至 {}", timeRange.startTime, timeRange.endTime); |
|
|
|
|
|
|
|
// 步骤3:获取录音列表
|
|
|
|
logger.debug("【设备-步骤3】获取录音列表..."); |
|
|
|
JSONArray records = fetchRecordList(deviceHost, authToken, timeRange); |
|
|
|
if (records == null || records.isEmpty()) { |
|
|
|
logger.info("【设备】设备 {} 没有新的录音记录", deviceId); |
|
|
|
if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) { |
|
|
|
logger.warn("【设备】账号密码为空,跳过: ID={}", deviceId); |
|
|
|
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "0", "设备账号密码未配置"); |
|
|
|
return; |
|
|
|
} |
|
|
|
logger.info("【设备】获取到 {} 条录音记录", records.size()); |
|
|
|
|
|
|
|
// 步骤4:处理每条录音
|
|
|
|
SyncResult result = processRecords(records, deviceNo, cityName, cityCode, orgCode, deviceHost, authToken); |
|
|
|
|
|
|
|
// 步骤5:保存同步时间
|
|
|
|
if (result.latestCallTime != null) { |
|
|
|
SyncTimeUtil.writeLastSyncTime(downloadPath, deviceId, result.latestCallTime); |
|
|
|
logger.debug("【设备-步骤5】保存同步时间: {}", result.latestCallTime); |
|
|
|
} |
|
|
|
|
|
|
|
logger.info("【设备】同步完成: ID={}, 成功{}条, 失败{}条", deviceId, result.successCount, result.failCount); |
|
|
|
if (!StringUtils.hasText(cityName)) { |
|
|
|
cityName = "unknown_" + deviceId; |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== 私有辅助方法 ====================
|
|
|
|
String deviceHost = buildHost(ip, port); |
|
|
|
|
|
|
|
/** |
|
|
|
* 登录设备获取认证令牌 |
|
|
|
* |
|
|
|
* @param deviceHost 设备访问地址(如 192.168.1.100:80) |
|
|
|
* @return 认证令牌(Cookie),失败返回null |
|
|
|
*/ |
|
|
|
private String loginDevice(String deviceHost) { |
|
|
|
// 步骤1:登录(无重试)
|
|
|
|
String authToken; |
|
|
|
try { |
|
|
|
String loginUrl = String.format("http://%s/authorize?username=%s&password=%s", |
|
|
|
deviceHost, urlEncode(deviceUsername), urlEncode(devicePassword)); |
|
|
|
|
|
|
|
logger.info("正在登录设备: {}", deviceHost); |
|
|
|
String authToken = vaaHttpUtil.httpLogin(loginUrl); |
|
|
|
logger.info("登录成功"); |
|
|
|
return authToken; |
|
|
|
deviceHost, urlEncode(username), urlEncode(password)); |
|
|
|
authToken = vaaHttpUtil.httpLogin(loginUrl); |
|
|
|
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "1", null); |
|
|
|
} catch (Exception e) { |
|
|
|
logger.error("设备登录失败: {}, 原因={}", deviceHost, e.getMessage()); |
|
|
|
return null; |
|
|
|
} |
|
|
|
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "0", "登录失败:" + e.getMessage()); |
|
|
|
logger.error("【设备】登录失败,跳过: IP={}, 原因={}", ip, e.getMessage()); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 计算同步时间范围 |
|
|
|
* |
|
|
|
* @param deviceId 设备ID |
|
|
|
* @return 时间范围(Unix时间戳,秒) |
|
|
|
*/ |
|
|
|
private TimeRange calculateSyncTimeRange(String deviceId) { |
|
|
|
// 步骤2:获取分机号码(通道→电话号码映射),用于解析主叫/被叫
|
|
|
|
Map<String, String> extNumbers = getExtensionNumbers(deviceHost, authToken); |
|
|
|
|
|
|
|
// 步骤3:计算同步时间范围(增量同步)
|
|
|
|
Date lastSyncTime = SyncTimeUtil.readLastSyncTime(downloadPath, deviceId); |
|
|
|
Date now = new Date(); |
|
|
|
|
|
|
|
// 如果没有上次同步时间,或超过1天,则从昨天开始
|
|
|
|
if (lastSyncTime == null || DateUtil.getDateDoubleDiff(now, lastSyncTime) > 1.0) { |
|
|
|
lastSyncTime = DateUtil.addDayByDate(now, -1); |
|
|
|
} |
|
|
|
long startEpoch = lastSyncTime.getTime() / 1000; |
|
|
|
long endEpoch = now.getTime() / 1000; |
|
|
|
|
|
|
|
return new TimeRange(lastSyncTime.getTime() / 1000, now.getTime() / 1000); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 获取录音列表 |
|
|
|
* |
|
|
|
* @param deviceHost 设备访问地址 |
|
|
|
* @param authToken 认证令牌 |
|
|
|
* @param timeRange 时间范围 |
|
|
|
* @return 录音记录JSON数组 |
|
|
|
*/ |
|
|
|
private JSONArray fetchRecordList(String deviceHost, String authToken, TimeRange timeRange) { |
|
|
|
// 步骤4:获取录音列表(无重试)
|
|
|
|
JSONArray records; |
|
|
|
try { |
|
|
|
String recordUrl = String.format("http://%s/service/record/~/time[%d,%d]", |
|
|
|
deviceHost, timeRange.startTime, timeRange.endTime); |
|
|
|
|
|
|
|
logger.debug("获取录音列表: {}", recordUrl); |
|
|
|
deviceHost, startEpoch, endEpoch); |
|
|
|
String recordData = vaaHttpUtil.httpVisit(recordUrl, authToken); |
|
|
|
return vaaHttpUtil.parseRecordData(recordData); |
|
|
|
records = vaaHttpUtil.parseRecordData(recordData); |
|
|
|
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "2", "1", null); |
|
|
|
} catch (Exception e) { |
|
|
|
logger.error("获取录音列表失败: {}", e.getMessage()); |
|
|
|
return null; |
|
|
|
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "2", "0", "查询录音列表失败:" + e.getMessage()); |
|
|
|
logger.error("【设备】获取录音列表失败: {}", e.getMessage()); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
if (records == null || records.isEmpty()) { |
|
|
|
logger.info("【设备】无新录音: ID={}", deviceId); |
|
|
|
return; |
|
|
|
} |
|
|
|
logger.info("【设备】获取到 {} 条录音记录", records.size()); |
|
|
|
|
|
|
|
/** |
|
|
|
* 批量处理录音记录 |
|
|
|
*/ |
|
|
|
private SyncResult processRecords(JSONArray records, String deviceNo, String cityName, |
|
|
|
String cityCode, String orgCode, String deviceHost, String authToken) { |
|
|
|
SyncResult result = new SyncResult(); |
|
|
|
// 步骤5:逐条处理录音
|
|
|
|
int successCount = 0; |
|
|
|
int failCount = 0; |
|
|
|
Date latestCallTime = null; |
|
|
|
|
|
|
|
for (int i = 0; i < records.size(); i++) { |
|
|
|
JSONObject record = records.getJSONObject(i); |
|
|
|
JSONObject rec = records.getJSONObject(i); |
|
|
|
try { |
|
|
|
boolean success = processSingleRecord(record, deviceNo, cityName, cityCode, orgCode, deviceHost, authToken); |
|
|
|
if (success) { |
|
|
|
result.successCount++; |
|
|
|
// 跟踪最新的通话时间
|
|
|
|
Date callTime = parseCallTime(record); |
|
|
|
if (callTime != null && (result.latestCallTime == null || callTime.after(result.latestCallTime))) { |
|
|
|
result.latestCallTime = callTime; |
|
|
|
Date callTime = processSingleRecord(rec, deviceNo, cityName, cityCode, orgCode, |
|
|
|
deviceHost, authToken, extNumbers); |
|
|
|
if (callTime != null) { |
|
|
|
successCount++; |
|
|
|
if (latestCallTime == null || callTime.after(latestCallTime)) { |
|
|
|
latestCallTime = callTime; |
|
|
|
} |
|
|
|
} else { |
|
|
|
result.failCount++; |
|
|
|
} |
|
|
|
} catch (Exception e) { |
|
|
|
logger.error("处理录音记录失败[{}]: {}", i, e.getMessage()); |
|
|
|
result.failCount++; |
|
|
|
failCount++; |
|
|
|
logger.error("【录音】处理失败: {}, 原因={}", rec.getString("id"), e.getMessage()); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
return result; |
|
|
|
// 步骤6:保存同步时间
|
|
|
|
if (latestCallTime != null) { |
|
|
|
SyncTimeUtil.writeLastSyncTime(downloadPath, deviceId, latestCallTime); |
|
|
|
} |
|
|
|
|
|
|
|
logger.info("【设备】同步完成: ID={}, 成功{}条, 失败{}条", deviceId, successCount, failCount); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 处理单条录音记录 |
|
|
|
* |
|
|
|
* 处理流程: |
|
|
|
* 1. 解析录音信息 |
|
|
|
* 2. 检查是否已存在(防重复) |
|
|
|
* 3. 查询通道配置(获取本机号码) |
|
|
|
* 4. 下载录音文件(如不存在) |
|
|
|
* 5. 上传到OSS |
|
|
|
* 6. 保存到数据库 |
|
|
|
* @return 通话开始时间(成功时),null表示跳过 |
|
|
|
*/ |
|
|
|
private boolean processSingleRecord(JSONObject record, String deviceNo, String cityName, |
|
|
|
String cityCode, String orgCode, String deviceHost, String authToken) throws Exception { |
|
|
|
// ========== 步骤1:解析录音信息 ==========
|
|
|
|
String recordId = RecordParser.parseRecordId(record); |
|
|
|
String filePath = RecordParser.parseFilePath(record); |
|
|
|
Integer channel = RecordParser.parseChannel(record); |
|
|
|
String phone = RecordParser.parsePhone(record); |
|
|
|
boolean isOutgoing = RecordParser.isOutgoing(record); |
|
|
|
boolean isAnswered = RecordParser.isAnswered(record); |
|
|
|
Long begTime = RecordParser.parseBegTime(record); |
|
|
|
Long endTime = RecordParser.parseEndTime(record); |
|
|
|
|
|
|
|
logger.debug("【录音】处理记录: recordId={}, channel={}, phone={}, direction={}", |
|
|
|
recordId, channel, phone, isOutgoing ? "呼出" : "呼入"); |
|
|
|
|
|
|
|
// 参数校验
|
|
|
|
private Date processSingleRecord(JSONObject rec, String deviceNo, String cityName, |
|
|
|
String cityCode, String orgCode, String deviceHost, |
|
|
|
String authToken, Map<String, String> extNumbers) throws Exception { |
|
|
|
// 解析录音信息
|
|
|
|
String recordId = RecordParser.parseRecordId(rec); |
|
|
|
String filePath = RecordParser.parseFilePath(rec); |
|
|
|
Integer channel = RecordParser.parseChannel(rec); |
|
|
|
String phone = RecordParser.parsePhone(rec); |
|
|
|
boolean isOutgoing = RecordParser.isOutgoing(rec); |
|
|
|
boolean isAnswered = RecordParser.isAnswered(rec); |
|
|
|
Long begTime = RecordParser.parseBegTime(rec); |
|
|
|
Long endTime = RecordParser.parseEndTime(rec); |
|
|
|
|
|
|
|
// 校验必要字段
|
|
|
|
if (filePath == null || filePath.isEmpty()) { |
|
|
|
logger.debug("【录音】录音文件路径为空,跳过: recordId={}", recordId); |
|
|
|
return false; |
|
|
|
return null; |
|
|
|
} |
|
|
|
if (begTime == null || endTime == null) { |
|
|
|
logger.warn("【录音-异常】录音时间信息缺失,跳过: recordId={}", recordId); |
|
|
|
return false; |
|
|
|
return null; |
|
|
|
} |
|
|
|
|
|
|
|
// ========== 步骤2:防重复检查 ==========
|
|
|
|
// 防重复:根据 device_no + record_id 判断
|
|
|
|
String callRecordId = deviceNo + "_" + recordId; |
|
|
|
if (callRecordMapper.selectByCallRecordId(callRecordId) != null) { |
|
|
|
logger.debug("【录音】通话记录已存在,跳过: {}", callRecordId); |
|
|
|
return true; |
|
|
|
logger.debug("【录音】已存在,跳过: {}", callRecordId); |
|
|
|
return null; |
|
|
|
} |
|
|
|
|
|
|
|
// ========== 步骤3:准备文件路径(按地市分文件夹) ==========
|
|
|
|
// 准备文件路径
|
|
|
|
Date callStartTime = new Date(begTime * 1000); |
|
|
|
String fileName = FilePathUtil.extractFileName(filePath); |
|
|
|
// 本地路径: {basePath}/{cityCode}/{date}/{uuid}/{fileName}
|
|
|
|
String localPath = FilePathUtil.buildLocalPath(downloadPath, cityCode, callStartTime, deviceNo, fileName); |
|
|
|
Path localFile = Paths.get(localPath); |
|
|
|
// OSS路径: {cityCode}/{date}/{fileName}
|
|
|
|
String ossPath = FilePathUtil.buildOssPath(cityCode, callStartTime, fileName); |
|
|
|
logger.debug("【录音-步骤3】路径信息: localPath={}, ossPath={}", localPath, ossPath); |
|
|
|
|
|
|
|
logger.debug("【录音】文件信息: fileName={}, localPath={}", fileName, localPath); |
|
|
|
|
|
|
|
// ========== 步骤4:查询通道配置 ==========
|
|
|
|
logger.debug("【录音-步骤4】查询通道配置: deviceNo={}, channel={}", deviceNo, channel); |
|
|
|
MidVoiceChannelConfig channelConfig = channelConfigService.getChannelConfig( |
|
|
|
deviceNo, channel, deviceHost, "EBOX-8108", authToken); |
|
|
|
String channelPhone = channelConfig != null ? channelConfig.getPhoneNumber() : null; |
|
|
|
logger.debug("【录音-步骤4】通道配置: channelPhone={}", channelPhone); |
|
|
|
// 获取通道绑定的电话号码(从EBOX分机号码配置)
|
|
|
|
String channelPhone = ""; |
|
|
|
if (channel != null && extNumbers != null) { |
|
|
|
channelPhone = extNumbers.getOrDefault(String.valueOf(channel), ""); |
|
|
|
} |
|
|
|
|
|
|
|
// ========== 步骤5:构建通话记录实体 ==========
|
|
|
|
// 构建通话记录
|
|
|
|
MidVoiceCallRecord callRecord = buildCallRecord( |
|
|
|
callRecordId, deviceNo, cityCode, cityName, orgCode, |
|
|
|
fileName, localPath, callStartTime, new Date(endTime * 1000), |
|
|
|
fileName, callStartTime, new Date(endTime * 1000), |
|
|
|
(int) (endTime - begTime), channelPhone, phone, isOutgoing, isAnswered); |
|
|
|
|
|
|
|
// ========== 步骤6:下载录音文件 ==========
|
|
|
|
// 下载录音文件(无重试)
|
|
|
|
if (!Files.exists(localFile) || Files.size(localFile) == 0) { |
|
|
|
logger.info("【录音-步骤6】开始下载录音: {}", fileName); |
|
|
|
String fileUrl = "http://" + deviceHost + filePath; |
|
|
|
vaaHttpUtil.httpDown(fileUrl, localPath, authToken); |
|
|
|
logger.info("【录音-步骤6】录音下载完成: {} ({} 字节)", localPath, Files.size(localFile)); |
|
|
|
logger.info("【录音】下载完成: {} ({} 字节)", fileName, Files.size(localFile)); |
|
|
|
} else { |
|
|
|
logger.debug("【录音-步骤6】录音文件已存在本地,跳过下载: {} ({} 字节)", |
|
|
|
fileName, Files.size(localFile)); |
|
|
|
logger.debug("【录音】文件已存在,跳过下载: {}", fileName); |
|
|
|
} |
|
|
|
callRecord.setRecordingFileSize((int) Files.size(localFile)); |
|
|
|
|
|
|
|
// ========== 步骤7:上传到OSS ==========
|
|
|
|
logger.debug("【录音-步骤7】开始上传OSS: cityName={}, ossPath={}", cityName, ossPath); |
|
|
|
// 上传到OSS
|
|
|
|
byte[] wavData = Files.readAllBytes(localFile); |
|
|
|
String ossUrl = fileUploadUtil.uploadWav(cityName, ossPath, fileName, wavData); |
|
|
|
logger.info("【录音-步骤7】录音上传OSS成功: {}", ossUrl); |
|
|
|
callRecord.setRecordingFilePath(ossUrl); |
|
|
|
logger.info("【录音】上传OSS成功: {}", ossUrl); |
|
|
|
|
|
|
|
// ========== 步骤8:保存到数据库 ==========
|
|
|
|
logger.debug("【录音-步骤8】保存通话记录到数据库..."); |
|
|
|
// 保存到数据库
|
|
|
|
callRecordMapper.insert(callRecord); |
|
|
|
logger.info("【录音-步骤8】通话记录保存成功: id={}, callRecordId={}", callRecord.getId(), callRecordId); |
|
|
|
logger.info("【录音】保存成功: callRecordId={}", callRecordId); |
|
|
|
|
|
|
|
return true; |
|
|
|
return callStartTime; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 构建通话记录实体 |
|
|
|
* |
|
|
|
* 号码解析规则: |
|
|
|
* - 呼出(state=1):主叫=本机号码(通道绑定号码),被叫=对方号码(phone字段) |
|
|
|
* - 呼入(state=2):主叫=对方号码(phone字段),被叫=本机号码(通道绑定号码) |
|
|
|
*/ |
|
|
|
private MidVoiceCallRecord buildCallRecord(String callRecordId, String deviceNo, String cityCode, |
|
|
|
String cityName, String orgCode, String fileName, |
|
|
|
String localPath, Date callStartTime, Date callEndTime, |
|
|
|
Date callStartTime, Date callEndTime, |
|
|
|
int duration, String channelPhone, String remotePhone, |
|
|
|
boolean isOutgoing, boolean isAnswered) { |
|
|
|
MidVoiceCallRecord record = new MidVoiceCallRecord(); |
|
|
|
|
|
|
|
// 基础信息
|
|
|
|
record.setCallRecordId(callRecordId); |
|
|
|
record.setDeviceNo(deviceNo); |
|
|
|
record.setCityCode(cityCode); |
|
|
|
record.setCityName(cityName); |
|
|
|
record.setOrgCode(orgCode); |
|
|
|
|
|
|
|
// 文件信息
|
|
|
|
record.setRecordingFileName(fileName); |
|
|
|
record.setLocalPath(localPath); |
|
|
|
|
|
|
|
// 时间信息
|
|
|
|
record.setCallStartTime(callStartTime); |
|
|
|
record.setCallEndTime(callEndTime); |
|
|
|
record.setCallDuration(duration); |
|
|
|
record.setCallDirection(isOutgoing ? "2" : "1"); // 1呼入,2呼出
|
|
|
|
|
|
|
|
// 通话方向:1呼入,2呼出
|
|
|
|
record.setCallDirection(isOutgoing ? "2" : "1"); |
|
|
|
|
|
|
|
// 主叫/被叫号码
|
|
|
|
String localNumber = channelPhone != null && !channelPhone.isEmpty() ? channelPhone : ""; |
|
|
|
String remoteNumber = remotePhone != null ? remotePhone : ""; |
|
|
|
String localNum = channelPhone != null ? channelPhone : ""; |
|
|
|
String remoteNum = remotePhone != null ? remotePhone : ""; |
|
|
|
|
|
|
|
if (isOutgoing) { |
|
|
|
// 呼出:本机打给对方
|
|
|
|
record.setCallTel(localNumber); // 主叫:本机号码
|
|
|
|
record.setCalledTel(remoteNumber); // 被叫:对方号码
|
|
|
|
record.setCallTel(localNum); // 主叫:本机
|
|
|
|
record.setCalledTel(remoteNum); // 被叫:对方
|
|
|
|
} else { |
|
|
|
// 呼入:对方打给本机
|
|
|
|
record.setCallTel(remoteNumber); // 主叫:对方号码
|
|
|
|
record.setCalledTel(localNumber); // 被叫:本机号码
|
|
|
|
record.setCallTel(remoteNum); // 主叫:对方
|
|
|
|
record.setCalledTel(localNum); // 被叫:本机
|
|
|
|
} |
|
|
|
|
|
|
|
// 通话状态
|
|
|
|
record.setCallStatus(isAnswered ? "1" : "2"); // 1正常接通,2未接通
|
|
|
|
|
|
|
|
return record; |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== 工具方法 ====================
|
|
|
|
|
|
|
|
/** |
|
|
|
* 构建设备访问地址 |
|
|
|
*/ |
|
|
|
private String buildDeviceHost(String ip, Integer port) { |
|
|
|
return ip + (port != null && port != 80 ? ":" + port : ""); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 解析通话时间 |
|
|
|
* 获取分机号码配置 |
|
|
|
* 调用 EBOX 接口 GET /service/ext/number |
|
|
|
* 返回 Map<通道号, 电话号码>,如 {"1":"8001","2":"8002"} |
|
|
|
*/ |
|
|
|
private Date parseCallTime(JSONObject record) { |
|
|
|
Long begTime = RecordParser.parseBegTime(record); |
|
|
|
return begTime != null ? new Date(begTime * 1000) : null; |
|
|
|
private Map<String, String> getExtensionNumbers(String deviceHost, String authToken) { |
|
|
|
try { |
|
|
|
String extUrl = "http://" + deviceHost + "/service/ext/number"; |
|
|
|
return vaaHttpUtil.getExtensionNumbers(extUrl, authToken); |
|
|
|
} catch (Exception e) { |
|
|
|
logger.warn("【设备】获取分机号码失败,将无法解析本机号码: {}", e.getMessage()); |
|
|
|
return Collections.emptyMap(); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 从Map中获取字符串值 |
|
|
|
*/ |
|
|
|
private String getStringValue(Map<String, Object> map, String key) { |
|
|
|
Object value = map.get(key); |
|
|
|
return value != null ? value.toString() : null; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 从Map中获取整数值 |
|
|
|
* 保存设备连接日志到 mid_voice_device_log 表 |
|
|
|
*/ |
|
|
|
private Integer getIntValue(Map<String, Object> map, String key, Integer defaultValue) { |
|
|
|
Object value = map.get(key); |
|
|
|
if (value == null) { |
|
|
|
return defaultValue; |
|
|
|
} |
|
|
|
private void saveDeviceLog(String deviceId, String deviceNo, String cityCode, String cityName, |
|
|
|
String ipAddress, Integer devicePort, String connectType, |
|
|
|
String connectStatus, String failReason) { |
|
|
|
try { |
|
|
|
return Integer.parseInt(value.toString()); |
|
|
|
} catch (NumberFormatException e) { |
|
|
|
return defaultValue; |
|
|
|
MidVoiceDeviceLog log = new MidVoiceDeviceLog(); |
|
|
|
log.setDeviceId(deviceId); |
|
|
|
log.setDeviceNo(deviceNo); |
|
|
|
log.setCityCode(cityCode); |
|
|
|
log.setCityName(cityName); |
|
|
|
log.setIpAddress(ipAddress); |
|
|
|
log.setDevicePort(devicePort); |
|
|
|
log.setConnectType(connectType); |
|
|
|
log.setConnectStatus(connectStatus); |
|
|
|
log.setFailReason(failReason); |
|
|
|
log.setCreateTime(new Date()); |
|
|
|
deviceLogMapper.insert(log); |
|
|
|
} catch (Exception e) { |
|
|
|
logger.error("保存设备连接日志失败: {}", e.getMessage()); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* URL编码 |
|
|
|
*/ |
|
|
|
private String urlEncode(String value) throws Exception { |
|
|
|
return URLEncoder.encode(value, "UTF-8"); |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== 内部类 ====================
|
|
|
|
|
|
|
|
/** |
|
|
|
* 时间范围 |
|
|
|
*/ |
|
|
|
private static class TimeRange { |
|
|
|
final long startTime; |
|
|
|
final long endTime; |
|
|
|
// ==================== 工具方法 ====================
|
|
|
|
|
|
|
|
TimeRange(long startTime, long endTime) { |
|
|
|
this.startTime = startTime; |
|
|
|
this.endTime = endTime; |
|
|
|
private String buildHost(String ip, Integer port) { |
|
|
|
return ip + (port != null && port != 80 ? ":" + port : ""); |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
public String toString() { |
|
|
|
return String.format("[%d, %d]", startTime, endTime); |
|
|
|
private String getStr(Map<String, Object> map, String key) { |
|
|
|
Object v = map.get(key); |
|
|
|
return v != null ? v.toString() : null; |
|
|
|
} |
|
|
|
|
|
|
|
private Integer getInt(Map<String, Object> map, String key, Integer defaultVal) { |
|
|
|
Object v = map.get(key); |
|
|
|
if (v == null) return defaultVal; |
|
|
|
try { return Integer.parseInt(v.toString()); } |
|
|
|
catch (NumberFormatException e) { return defaultVal; } |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 同步结果 |
|
|
|
*/ |
|
|
|
private static class SyncResult { |
|
|
|
int successCount = 0; |
|
|
|
int failCount = 0; |
|
|
|
Date latestCallTime = null; |
|
|
|
private String urlEncode(String value) throws Exception { |
|
|
|
return URLEncoder.encode(value, "UTF-8"); |
|
|
|
} |
|
|
|
} |
|
|
|
|