数据同步服务-语音
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

388 lines
17 KiB

3 months ago
package com.threecloud.dataserviceyy.service;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.threecloud.dataserviceyy.entity.MidVoiceCallRecord;
3 months ago
import com.threecloud.dataserviceyy.entity.MidVoiceDeviceLog;
2 months ago
import com.threecloud.dataserviceyy.entity.OssFileResponse;
3 months ago
import com.threecloud.dataserviceyy.mapper.MidVoiceCallRecordMapper;
3 months ago
import com.threecloud.dataserviceyy.mapper.MidVoiceDeviceLogMapper;
3 months ago
import com.threecloud.dataserviceyy.mapper.VoiceSyncMapper;
import com.threecloud.dataserviceyy.util.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
3 months ago
import java.util.*;
3 months ago
/**
* VAA录音盒定时同步服务
3 months ago
*
* 功能从EBOX录音盒拉取录音 下载文件 上传OSS 保存通话记录
* 策略增量同步无重试防重复失败记录到日志表
* 账户每个设备的账号密码从 mid_voice_device_config 表读取
3 months ago
*/
@Service
public class VaaSyncService {
private static final Logger logger = LoggerFactory.getLogger(VaaSyncService.class);
@Autowired
private VoiceSyncMapper voiceSyncMapper;
@Autowired
private MidVoiceCallRecordMapper callRecordMapper;
@Autowired
3 months ago
private MidVoiceDeviceLogMapper deviceLogMapper;
3 months ago
@Autowired
private VaaHttpUtil vaaHttpUtil;
@Autowired
2 months ago
private OssFileService ossFileService;
3 months ago
@Value("${vaa-sync.download-path:./vaa-recordings}")
private String downloadPath;
@Value("${vaa-sync.retain-days:10}")
private int retainDays;
/**
3 months ago
* 定时同步任务
3 months ago
*/
2 months ago
@Scheduled(cron = "${vaa-sync.sync-interval-cron:0 0 0/8 * * ?}")
3 months ago
public void scheduledSync() {
logger.info("【定时任务】========== VAA录音盒同步开始 ==========");
long startTime = System.currentTimeMillis();
executeSync();
long costTime = System.currentTimeMillis() - startTime;
logger.info("【定时任务】========== VAA录音盒同步结束,耗时 {} 秒 ==========", costTime / 1000);
}
/**
* 执行同步任务主入口
*/
public void executeSync() {
try {
3 months ago
// 清理过期本地文件
3 months ago
FileCleaner.cleanOldFiles(downloadPath, retainDays);
3 months ago
// 查询在线设备列表(含每个设备的账号密码)
3 months ago
List<Map<String, Object>> deviceList = voiceSyncMapper.getAllYysb();
3 months ago
logger.info("【主流程】查询到 {} 个在线语音设备", deviceList.size());
3 months ago
int successCount = 0;
int failCount = 0;
for (int i = 0; i < deviceList.size(); i++) {
Map<String, Object> device = deviceList.get(i);
3 months ago
String deviceId = getStr(device, "ID");
3 months ago
try {
syncSingleDevice(device);
successCount++;
} catch (Exception e) {
failCount++;
logger.error("【异常】设备同步失败: ID={}, 原因={}", deviceId, e.getMessage());
}
}
3 months ago
logger.info("【主流程】同步完成,成功 {} 个,失败 {} 个", successCount, failCount);
3 months ago
} catch (Exception e) {
logger.error("【异常】同步任务执行失败: {}", e.getMessage(), e);
}
}
/**
* 同步单个设备
3 months ago
*
* 流程登录 获取分机号码 查录音列表 逐条下载上传保存
* 任何步骤失败直接记录日志跳过该设备
3 months ago
*/
private void syncSingleDevice(Map<String, Object> device) throws Exception {
3 months ago
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);
3 months ago
// 参数校验
if (!StringUtils.hasText(ip)) {
3 months ago
logger.warn("【设备】IP为空,跳过: ID={}", deviceId);
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "0", "设备IP为空");
3 months ago
return;
}
3 months ago
if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) {
logger.warn("【设备】账号密码为空,跳过: ID={}", deviceId);
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "0", "设备账号密码未配置");
3 months ago
return;
}
3 months ago
if (!StringUtils.hasText(cityName)) {
cityName = "unknown_" + deviceId;
3 months ago
}
3 months ago
String deviceHost = buildHost(ip, port);
3 months ago
3 months ago
// 步骤1:登录(无重试)
String authToken;
3 months ago
try {
String loginUrl = String.format("http://%s/authorize?username=%s&password=%s",
2 months ago
deviceHost, URLEncoder.encode(username, "UTF-8"), URLEncoder.encode(password, "UTF-8"));
3 months ago
authToken = vaaHttpUtil.httpLogin(loginUrl);
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "1", null);
3 months ago
} catch (Exception e) {
3 months ago
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "0", "登录失败:" + e.getMessage());
logger.error("【设备】登录失败,跳过: IP={}, 原因={}", ip, e.getMessage());
return;
3 months ago
}
3 months ago
// 步骤2:获取分机号码(通道→电话号码映射),用于解析主叫/被叫
Map<String, String> extNumbers = getExtensionNumbers(deviceHost, authToken);
// 步骤3:计算同步时间范围(增量同步)
3 months ago
Date lastSyncTime = SyncTimeUtil.readLastSyncTime(downloadPath, deviceId);
Date now = new Date();
if (lastSyncTime == null || DateUtil.getDateDoubleDiff(now, lastSyncTime) > 1.0) {
lastSyncTime = DateUtil.addDayByDate(now, -1);
}
3 months ago
long startEpoch = lastSyncTime.getTime() / 1000;
long endEpoch = now.getTime() / 1000;
3 months ago
3 months ago
// 步骤4:获取录音列表(无重试)
JSONArray records;
3 months ago
try {
String recordUrl = String.format("http://%s/service/record/~/time[%d,%d]",
3 months ago
deviceHost, startEpoch, endEpoch);
3 months ago
String recordData = vaaHttpUtil.httpVisit(recordUrl, authToken);
3 months ago
records = vaaHttpUtil.parseRecordData(recordData);
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "2", "1", null);
3 months ago
} catch (Exception e) {
3 months ago
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "2", "0", "查询录音列表失败:" + e.getMessage());
logger.error("【设备】获取录音列表失败: {}", e.getMessage());
return;
3 months ago
}
3 months ago
if (records == null || records.isEmpty()) {
logger.info("【设备】无新录音: ID={}", deviceId);
return;
}
logger.info("【设备】获取到 {} 条录音记录", records.size());
3 months ago
// 批量查询已存在的call_record_id(一次查询代替N次,防重复性能优化)
String startTimeStr = DateUtil.formatDate(lastSyncTime, "yyyy-MM-dd HH:mm:ss");
String endTimeStr = DateUtil.formatDate(now, "yyyy-MM-dd HH:mm:ss");
Set<String> existingIds = new HashSet<>(callRecordMapper.selectExistingCallRecordIds(
deviceNo, startTimeStr, endTimeStr));
logger.debug("【设备】已存在 {} 条通话记录,将跳过", existingIds.size());
3 months ago
// 步骤5:逐条处理录音
int successCount = 0;
int failCount = 0;
Date latestCallTime = null;
3 months ago
for (int i = 0; i < records.size(); i++) {
3 months ago
JSONObject rec = records.getJSONObject(i);
3 months ago
try {
3 months ago
Date callTime = processSingleRecord(rec, deviceNo, cityName, cityCode, orgCode,
3 months ago
deviceHost, authToken, extNumbers, existingIds);
3 months ago
if (callTime != null) {
successCount++;
if (latestCallTime == null || callTime.after(latestCallTime)) {
latestCallTime = callTime;
3 months ago
}
}
} catch (Exception e) {
3 months ago
failCount++;
logger.error("【录音】处理失败: {}, 原因={}", rec.getString("id"), e.getMessage());
3 months ago
}
}
3 months ago
// 步骤6:保存同步时间
if (latestCallTime != null) {
SyncTimeUtil.writeLastSyncTime(downloadPath, deviceId, latestCallTime);
}
logger.info("【设备】同步完成: ID={}, 成功{}条, 失败{}条", deviceId, successCount, failCount);
3 months ago
}
/**
* 处理单条录音记录
3 months ago
*
* @return 通话开始时间成功时null表示跳过
3 months ago
*/
3 months ago
private Date processSingleRecord(JSONObject rec, String deviceNo, String cityName,
String cityCode, String orgCode, String deviceHost,
3 months ago
String authToken, Map<String, String> extNumbers,
Set<String> existingIds) throws Exception {
3 months ago
// 解析录音信息
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);
// 校验必要字段
3 months ago
if (filePath == null || filePath.isEmpty()) {
3 months ago
return null;
3 months ago
}
if (begTime == null || endTime == null) {
3 months ago
return null;
3 months ago
}
3 months ago
// 防重复:内存中比对已存在的call_record_id(一次查询代替N次)
3 months ago
String callRecordId = deviceNo + "_" + recordId;
3 months ago
if (existingIds.contains(callRecordId)) {
3 months ago
logger.debug("【录音】已存在,跳过: {}", callRecordId);
return null;
3 months ago
}
3 months ago
// 准备文件路径
3 months ago
Date callStartTime = new Date(begTime * 1000);
String fileName = FilePathUtil.extractFileName(filePath);
String localPath = FilePathUtil.buildLocalPath(downloadPath, cityCode, callStartTime, deviceNo, fileName);
Path localFile = Paths.get(localPath);
3 months ago
// 获取通道绑定的电话号码(从EBOX分机号码配置)
String channelPhone = "";
if (channel != null && extNumbers != null) {
channelPhone = extNumbers.getOrDefault(String.valueOf(channel), "");
}
3 months ago
3 months ago
// 构建通话记录
3 months ago
MidVoiceCallRecord callRecord = buildCallRecord(
callRecordId, deviceNo, cityCode, cityName, orgCode,
3 months ago
fileName, callStartTime, new Date(endTime * 1000),
3 months ago
(int) (endTime - begTime), channelPhone, phone, isOutgoing, isAnswered);
3 months ago
// 下载录音文件(无重试)
3 months ago
if (!Files.exists(localFile) || Files.size(localFile) == 0) {
String fileUrl = "http://" + deviceHost + filePath;
vaaHttpUtil.httpDown(fileUrl, localPath, authToken);
3 months ago
logger.info("【录音】下载完成: {} ({} 字节)", fileName, Files.size(localFile));
3 months ago
} else {
3 months ago
logger.debug("【录音】文件已存在,跳过下载: {}", fileName);
3 months ago
}
callRecord.setRecordingFileSize((int) Files.size(localFile));
2 months ago
// 上传到OSS并获取预览URL
OssFileResponse ossResponse = ossFileService.uploadFile(
Files.readAllBytes(localFile), fileName, "voice/" + cityCode + "/");
String previewUrl = ossResponse.getFileUrl();
callRecord.setRecordingFilePath(previewUrl);
logger.info("【录音】OSS预览URL: {}", previewUrl);
3 months ago
3 months ago
// 保存到数据库
3 months ago
callRecordMapper.insert(callRecord);
3 months ago
logger.info("【录音】保存成功: callRecordId={}", callRecordId);
3 months ago
3 months ago
return callStartTime;
3 months ago
}
/**
* 构建通话记录实体
3 months ago
*
* 号码解析规则
* - 呼出state=1主叫=本机号码通道绑定号码被叫=对方号码phone字段
* - 呼入state=2主叫=对方号码phone字段被叫=本机号码通道绑定号码
3 months ago
*/
private MidVoiceCallRecord buildCallRecord(String callRecordId, String deviceNo, String cityCode,
String cityName, String orgCode, String fileName,
3 months ago
Date callStartTime, Date callEndTime,
3 months ago
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.setCallStartTime(callStartTime);
record.setCallEndTime(callEndTime);
record.setCallDuration(duration);
3 months ago
record.setCallDirection(isOutgoing ? "2" : "1"); // 1呼入,2呼出
3 months ago
3 months ago
String localNum = channelPhone != null ? channelPhone : "";
String remoteNum = remotePhone != null ? remotePhone : "";
3 months ago
if (isOutgoing) {
3 months ago
record.setCallTel(localNum); // 主叫:本机
record.setCalledTel(remoteNum); // 被叫:对方
3 months ago
} else {
3 months ago
record.setCallTel(remoteNum); // 主叫:对方
record.setCalledTel(localNum); // 被叫:本机
3 months ago
}
record.setCallStatus(isAnswered ? "1" : "2"); // 1正常接通,2未接通
return record;
}
/**
3 months ago
* 获取分机号码配置
* 调用 EBOX 接口 GET /service/ext/number
* 返回 Map<通道号, 电话号码> {"1":"8001","2":"8002"}
3 months ago
*/
3 months ago
private Map<String, String> getExtensionNumbers(String deviceHost, String authToken) {
3 months ago
try {
3 months ago
String extUrl = "http://" + deviceHost + "/service/ext/number";
return vaaHttpUtil.getExtensionNumbers(extUrl, authToken);
} catch (Exception e) {
logger.warn("【设备】获取分机号码失败,将无法解析本机号码: {}", e.getMessage());
return Collections.emptyMap();
3 months ago
}
}
/**
3 months ago
* 保存设备连接日志到 mid_voice_device_log
3 months ago
*/
3 months ago
private void saveDeviceLog(String deviceId, String deviceNo, String cityCode, String cityName,
String ipAddress, Integer devicePort, String connectType,
String connectStatus, String failReason) {
try {
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());
}
3 months ago
}
3 months ago
// ==================== 工具方法 ====================
3 months ago
3 months ago
private String buildHost(String ip, Integer port) {
return ip + (port != null && port != 80 ? ":" + port : "");
}
3 months ago
3 months ago
private String getStr(Map<String, Object> map, String key) {
Object v = map.get(key);
return v != null ? v.toString() : null;
}
3 months ago
3 months ago
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; }
3 months ago
}
}