Browse Source

优化代码

main
wang 13 hours ago
parent
commit
cd54d1220b
  1. 43
      config/application-external.yml
  2. 78
      src/main/java/com/threecloud/dataserviceyy/entity/MidVoiceChannelConfig.java
  3. 66
      src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceChannelConfigMapper.java
  4. 43
      src/main/java/com/threecloud/dataserviceyy/mapper/VoiceSyncMapper.java
  5. 507
      src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java
  6. 48
      src/main/java/com/threecloud/dataserviceyy/service/channel/ChannelConfigProvider.java
  7. 115
      src/main/java/com/threecloud/dataserviceyy/service/channel/ChannelConfigService.java
  8. 46
      src/main/java/com/threecloud/dataserviceyy/service/channel/DatabaseChannelConfigProvider.java
  9. 138
      src/main/java/com/threecloud/dataserviceyy/service/channel/EboxChannelConfigProvider.java
  10. 99
      src/main/java/com/threecloud/dataserviceyy/util/VaaHttpUtil.java
  11. 36
      src/main/resources/application.yml
  12. 104
      src/main/resources/mapper/MidVoiceChannelConfigMapper.xml
  13. 116
      src/main/resources/mapper/VoiceSyncMapper.xml

43
config/application-external.yml

@ -5,72 +5,47 @@
# 1. 此文件用于部署时覆盖默认配置,无需重新打包 JAR
# 2. 将此文件放在 JAR 包同级目录的 config/ 文件夹下
# 3. 修改此文件后重启服务即可生效
# 4. 每个录音盒的账号密码在 mid_voice_device_config 表中配置
# ============================================
spring:
# ==================== 数据库配置 ====================
datasource:
# 人大金仓数据库驱动(一般无需修改)
driver-class-name: com.kingbase8.Driver
# 数据库连接URL
# 格式: jdbc:kingbase8://{host}:{port}/{database}?currentSchema={schema}&clientEncoding=utf8
# 示例: jdbc:kingbase8://53.1.194.60:54321/kingbase?currentSchema=mid_voice&clientEncoding=utf8
url: jdbc:kingbase8://127.0.0.1:54321/kingbase?currentSchema=mid_voice&clientEncoding=utf8
# 数据库用户名
username: dcms_dev
# 数据库密码
password: your_password_here
# ==================== 语音同步配置 ====================
vaa-sync:
# 本地录音文件存储路径(相对路径或绝对路径)
# 示例: ./vaa-recordings 或 /opt/dataservice-yy/vaa-recordings
# 本地录音文件存储路径
download-path: ./vaa-recordings
# 本地文件保留天数
retain-days: 10
# 同步定时任务 Cron 表达式
# 默认每2小时执行一次: 0 0 0/2 * * ?
# 每30分钟执行: 0 0/30 * * * ?
# 每天凌晨2点执行: 0 0 2 * * ?
# 每分钟执行(测试用): 0 0/1 * * * ?
# 每2小时: 0 0 0/2 * * ?
# 每30分钟: 0 0/30 * * * ?
# 每分钟(测试): 0 0/1 * * * ?
sync-interval-cron: "0 0 0/2 * * ?"
# 录音盒登录账号密码
device-username: admin
device-password: admin
# OSS 文件上传配置
oss:
# OSS 服务基础地址(用于拼接完整URL)
# 示例: http://53.1.194.59:9090
base-url: http://127.0.0.1:9090
# OSS 上传接口完整地址
# 示例: http://53.1.194.59:9090/apiOss/oss/fileUpload
upload-url: http://127.0.0.1:9090/apiOss/oss/fileUpload
# OSS 认证信息(请向管理员索取)
appcode: dataservice-yy
appid: your_appid_here
appsecret: your_appsecret_here
# ==================== 服务端口配置 ====================
# ==================== 服务端口 ====================
server:
# 服务端口
port: 8088
# ==================== 日志配置 ====================
logging:
# 日志文件路径
file:
name: logs/app.log
# 日志级别
level:
# 根日志级别: INFO(生产) / DEBUG(测试)
root: INFO
# 本项目代码日志级别
com.threecloud.dataserviceyy: DEBUG

78
src/main/java/com/threecloud/dataserviceyy/entity/MidVoiceChannelConfig.java

@ -1,78 +0,0 @@
package com.threecloud.dataserviceyy.entity;
import java.util.Date;
/**
* 语音设备通道配置表
* 对应 mid_voice.mid_voice_channel_config
* 用于维护每个录音盒的通道信息通道号绑定号码等
*/
public class MidVoiceChannelConfig {
/** 自增ID主键 */
private Long id;
/** 地市编码 */
private String cityCode;
/** 地市名称 */
private String cityName;
/** 设备编码(关联 mid_voice_device_config.device_no) */
private String deviceNo;
/** 通道号 1-8 */
private Integer channelNo;
/** 通道绑定的电话号码 */
private String phoneNumber;
/** 通道名称/描述 */
private String channelName;
/** 通道状态:0离线,1在线,2故障 */
private String channelStatus;
/** 创建时间 */
private Date createTime;
/** 更新时间 */
private Date updateTime;
/** 备注 */
private String remarks;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getCityCode() { return cityCode; }
public void setCityCode(String cityCode) { this.cityCode = cityCode; }
public String getCityName() { return cityName; }
public void setCityName(String cityName) { this.cityName = cityName; }
public String getDeviceNo() { return deviceNo; }
public void setDeviceNo(String deviceNo) { this.deviceNo = deviceNo; }
public Integer getChannelNo() { return channelNo; }
public void setChannelNo(Integer channelNo) { this.channelNo = channelNo; }
public String getPhoneNumber() { return phoneNumber; }
public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; }
public String getChannelName() { return channelName; }
public void setChannelName(String channelName) { this.channelName = channelName; }
public String getChannelStatus() { return channelStatus; }
public void setChannelStatus(String channelStatus) { this.channelStatus = channelStatus; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public Date getUpdateTime() { return updateTime; }
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
public String getRemarks() { return remarks; }
public void setRemarks(String remarks) { this.remarks = remarks; }
}

66
src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceChannelConfigMapper.java

@ -1,66 +0,0 @@
package com.threecloud.dataserviceyy.mapper;
import com.threecloud.dataserviceyy.entity.MidVoiceChannelConfig;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 语音设备通道配置Mapper
*/
@Mapper
public interface MidVoiceChannelConfigMapper {
/**
* 插入通道配置
*/
int insert(MidVoiceChannelConfig config);
/**
* 根据ID查询
*/
MidVoiceChannelConfig selectById(Long id);
/**
* 根据设备编码和通道号查询
*/
MidVoiceChannelConfig selectByDeviceAndChannel(@Param("deviceNo") String deviceNo,
@Param("channelNo") Integer channelNo);
/**
* 查询设备的所有通道
*/
List<MidVoiceChannelConfig> selectByDeviceNo(@Param("deviceNo") String deviceNo);
/**
* 根据电话号码查询通道
*/
MidVoiceChannelConfig selectByPhoneNumber(@Param("phoneNumber") String phoneNumber);
/**
* 查询所有通道配置
*/
List<MidVoiceChannelConfig> selectAll();
/**
* 更新通道配置
*/
int update(MidVoiceChannelConfig config);
/**
* 更新通道状态
*/
int updateStatus(@Param("id") Long id,
@Param("channelStatus") String channelStatus);
/**
* 删除通道配置
*/
int deleteById(Long id);
/**
* 批量插入
*/
int batchInsert(@Param("list") List<MidVoiceChannelConfig> list);
}

43
src/main/java/com/threecloud/dataserviceyy/mapper/VoiceSyncMapper.java

@ -1,46 +1,19 @@
package com.threecloud.dataserviceyy.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* 语音设备 Mapper
* 查询 mid_voice_device_config
*/
@Mapper
public interface VoiceSyncMapper {
List<Map<String, Object>> getAllYysb();
Map<String, Object> getTdByPhone(@Param("phone") String phone);
Map<String, Object> getTdByPhone2(@Param("zjhm") String zjhm, @Param("bjhm") String bjhm);
Map<String, Object> getYysbByUuid(@Param("uuid") String uuid);
void saveThjl(@Param("sbtdId") String sbtdId,
@Param("yysbId") String yysbId,
@Param("organId") Long organId,
@Param("organName") String organName,
@Param("phone") String phone,
@Param("kssj") String kssj,
@Param("jssj") String jssj,
@Param("lydz") String lydz,
@Param("zjhm") String zjhm,
@Param("bjhm") String bjhm,
@Param("thfx") String thfx,
@Param("thsc") Long thsc);
// VAA同步相关方法
Map<String, Object> getChannelByNumberAndUuid(@Param("channel") String channel, @Param("uuid") String uuid);
void updateChannelStatus(Map<String, Object> params);
Object getLastSyncTime(@Param("code") String code);
void saveSyncLog(Map<String, Object> params);
Object checkThjlExists(@Param("thid") String thid);
void insertThjl(Map<String, Object> params);
void updateThjl(Map<String, Object> params);
/**
* 查询所有在线语音设备含每个设备的账号密码
*/
List<Map<String, Object>> getAllYysb();
}

507
src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java

@ -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");
}
}

48
src/main/java/com/threecloud/dataserviceyy/service/channel/ChannelConfigProvider.java

@ -1,48 +0,0 @@
package com.threecloud.dataserviceyy.service.channel;
import com.threecloud.dataserviceyy.entity.MidVoiceChannelConfig;
import java.util.List;
/**
* 通道配置提供者接口
* 定义获取通道配置的通用方法支持多种实现EBOX API数据库手动配置等
*/
public interface ChannelConfigProvider {
/**
* 获取指定设备的所有通道配置
*
* @param deviceNo 设备编码
* @param deviceIp 设备IP地址
* @param authToken 认证令牌部分实现需要
* @return 通道配置列表
*/
List<MidVoiceChannelConfig> getChannelConfigs(String deviceNo, String deviceIp, String authToken);
/**
* 获取指定设备的单个通道配置
*
* @param deviceNo 设备编码
* @param channelNo 通道号
* @param deviceIp 设备IP地址
* @param authToken 认证令牌
* @return 通道配置不存在返回null
*/
MidVoiceChannelConfig getChannelConfig(String deviceNo, Integer channelNo, String deviceIp, String authToken);
/**
* 是否支持该设备类型
*
* @param deviceModel 设备型号
* @return true表示支持
*/
boolean supports(String deviceModel);
/**
* 提供者名称
*
* @return 名称标识
*/
String getProviderName();
}

115
src/main/java/com/threecloud/dataserviceyy/service/channel/ChannelConfigService.java

@ -1,115 +0,0 @@
package com.threecloud.dataserviceyy.service.channel;
import com.threecloud.dataserviceyy.entity.MidVoiceChannelConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
/**
* 通道配置服务
* 管理多个 ChannelConfigProvider根据设备类型选择合适的提供者
*/
@Service
public class ChannelConfigService {
private static final Logger logger = LoggerFactory.getLogger(ChannelConfigService.class);
@Autowired
private List<ChannelConfigProvider> providers = new ArrayList<>();
@Autowired
private DatabaseChannelConfigProvider databaseProvider;
@PostConstruct
public void init() {
logger.info("初始化通道配置服务,注册 {} 个提供者", providers.size());
for (ChannelConfigProvider provider : providers) {
logger.info("注册通道配置提供者: {}", provider.getProviderName());
}
}
/**
* 获取通道配置
* 优先使用设备特定的提供者如果没有则使用数据库提供者
*
* @param deviceNo 设备编码
* @param channelNo 通道号
* @param deviceIp 设备IP
* @param deviceModel 设备型号
* @param authToken 认证令牌
* @return 通道配置
*/
public MidVoiceChannelConfig getChannelConfig(String deviceNo, Integer channelNo,
String deviceIp, String deviceModel, String authToken) {
// 1. 先尝试使用设备特定的提供者(如 EBOX API)
ChannelConfigProvider specificProvider = findProvider(deviceModel);
if (specificProvider != null && !specificProvider.getProviderName().equals("DATABASE")) {
try {
MidVoiceChannelConfig config = specificProvider.getChannelConfig(deviceNo, channelNo, deviceIp, authToken);
if (config != null) {
logger.debug("使用 {} 获取到通道配置", specificProvider.getProviderName());
return config;
}
} catch (Exception e) {
logger.warn("{} 获取通道配置失败,尝试使用数据库: {}", specificProvider.getProviderName(), e.getMessage());
}
}
// 2. 使用数据库提供者(兜底)
return databaseProvider.getChannelConfig(deviceNo, channelNo, deviceIp, authToken);
}
/**
* 获取设备的所有通道配置
* 优先从 API 获取并同步到数据库失败则从数据库读取
*
* @param deviceNo 设备编码
* @param deviceIp 设备IP
* @param deviceModel 设备型号
* @param authToken 认证令牌
* @return 通道配置列表
*/
public List<MidVoiceChannelConfig> getChannelConfigs(String deviceNo, String deviceIp,
String deviceModel, String authToken) {
// 1. 先尝试使用设备特定的提供者(如 EBOX API)
ChannelConfigProvider specificProvider = findProvider(deviceModel);
if (specificProvider != null && !specificProvider.getProviderName().equals("DATABASE")) {
try {
List<MidVoiceChannelConfig> configs = specificProvider.getChannelConfigs(deviceNo, deviceIp, authToken);
if (!configs.isEmpty()) {
logger.info("从 {} 获取到 {} 条通道配置", specificProvider.getProviderName(), configs.size());
return configs;
}
} catch (Exception e) {
logger.warn("{} 获取通道配置失败,尝试使用数据库: {}", specificProvider.getProviderName(), e.getMessage());
}
}
// 2. 使用数据库提供者(兜底)
return databaseProvider.getChannelConfigs(deviceNo, deviceIp, authToken);
}
/**
* 根据设备型号查找合适的提供者
*/
private ChannelConfigProvider findProvider(String deviceModel) {
if (deviceModel == null) {
return databaseProvider;
}
// 优先找特定的提供者
for (ChannelConfigProvider provider : providers) {
if (provider.supports(deviceModel)) {
return provider;
}
}
// 默认使用数据库提供者
return databaseProvider;
}
}

46
src/main/java/com/threecloud/dataserviceyy/service/channel/DatabaseChannelConfigProvider.java

@ -1,46 +0,0 @@
package com.threecloud.dataserviceyy.service.channel;
import com.threecloud.dataserviceyy.entity.MidVoiceChannelConfig;
import com.threecloud.dataserviceyy.mapper.MidVoiceChannelConfigMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 数据库通道配置提供者
* 从数据库读取通道配置作为兜底方案
*/
@Component
public class DatabaseChannelConfigProvider implements ChannelConfigProvider {
private static final Logger logger = LoggerFactory.getLogger(DatabaseChannelConfigProvider.class);
@Autowired
private MidVoiceChannelConfigMapper channelConfigMapper;
@Override
public List<MidVoiceChannelConfig> getChannelConfigs(String deviceNo, String deviceIp, String authToken) {
List<MidVoiceChannelConfig> configs = channelConfigMapper.selectByDeviceNo(deviceNo);
logger.debug("从数据库获取到 {} 条通道配置: deviceNo={}", configs.size(), deviceNo);
return configs;
}
@Override
public MidVoiceChannelConfig getChannelConfig(String deviceNo, Integer channelNo, String deviceIp, String authToken) {
return channelConfigMapper.selectByDeviceAndChannel(deviceNo, channelNo);
}
@Override
public boolean supports(String deviceModel) {
// 支持所有设备类型(兜底方案)
return true;
}
@Override
public String getProviderName() {
return "DATABASE";
}
}

138
src/main/java/com/threecloud/dataserviceyy/service/channel/EboxChannelConfigProvider.java

@ -1,138 +0,0 @@
package com.threecloud.dataserviceyy.service.channel;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.threecloud.dataserviceyy.entity.MidVoiceChannelConfig;
import com.threecloud.dataserviceyy.mapper.MidVoiceChannelConfigMapper;
import com.threecloud.dataserviceyy.util.VaaHttpUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* EBOX 录音盒通道配置提供者
* 通过 EBOX HTTP API 获取分机号码配置
*/
@Component
public class EboxChannelConfigProvider implements ChannelConfigProvider {
private static final Logger logger = LoggerFactory.getLogger(EboxChannelConfigProvider.class);
@Autowired
private VaaHttpUtil vaaHttpUtil;
@Autowired
private MidVoiceChannelConfigMapper channelConfigMapper;
@Override
public List<MidVoiceChannelConfig> getChannelConfigs(String deviceNo, String deviceIp, String authToken) {
List<MidVoiceChannelConfig> configs = new ArrayList<>();
try {
// 调用 EBOX API 获取分机号码
String extUrl = "http://" + deviceIp + "/service/ext/number";
String response = vaaHttpUtil.httpVisit(extUrl, authToken);
if (response == null || response.isEmpty()) {
logger.warn("EBOX 返回空的分机号码配置: deviceNo={}", deviceNo);
return configs;
}
// 解析 JSON 响应
JSONObject jsonObj = JSON.parseObject(response);
for (String key : jsonObj.keySet()) {
try {
Integer channelNo = Integer.parseInt(key);
String phoneNumber = jsonObj.getString(key);
if (phoneNumber != null && !phoneNumber.isEmpty()) {
MidVoiceChannelConfig config = new MidVoiceChannelConfig();
config.setDeviceNo(deviceNo);
config.setChannelNo(channelNo);
config.setPhoneNumber(phoneNumber);
config.setChannelName("通道" + channelNo);
config.setChannelStatus("1"); // 在线
configs.add(config);
}
} catch (NumberFormatException e) {
logger.warn("解析通道号失败: key={}", key);
}
}
logger.info("从 EBOX 获取到 {} 条通道配置: deviceNo={}", configs.size(), deviceNo);
// 同步到数据库(更新或插入)
syncToDatabase(configs);
} catch (Exception e) {
logger.error("从 EBOX 获取通道配置失败: deviceNo={}, error={}", deviceNo, e.getMessage());
}
return configs;
}
@Override
public MidVoiceChannelConfig getChannelConfig(String deviceNo, Integer channelNo, String deviceIp, String authToken) {
// 先查数据库
MidVoiceChannelConfig config = channelConfigMapper.selectByDeviceAndChannel(deviceNo, channelNo);
if (config != null) {
return config;
}
// 数据库没有,从 EBOX 获取
List<MidVoiceChannelConfig> configs = getChannelConfigs(deviceNo, deviceIp, authToken);
for (MidVoiceChannelConfig c : configs) {
if (c.getChannelNo().equals(channelNo)) {
return c;
}
}
return null;
}
@Override
public boolean supports(String deviceModel) {
// 支持 EBOX 系列设备
return deviceModel != null && deviceModel.toUpperCase().contains("EBOX");
}
@Override
public String getProviderName() {
return "EBOX_API";
}
/**
* 将获取到的配置同步到数据库
*/
private void syncToDatabase(List<MidVoiceChannelConfig> configs) {
for (MidVoiceChannelConfig config : configs) {
try {
MidVoiceChannelConfig existing = channelConfigMapper.selectByDeviceAndChannel(
config.getDeviceNo(), config.getChannelNo());
if (existing != null) {
// 更新电话号码(如果变化了)
if (!config.getPhoneNumber().equals(existing.getPhoneNumber())) {
existing.setPhoneNumber(config.getPhoneNumber());
channelConfigMapper.update(existing);
logger.info("更新通道配置: deviceNo={}, channelNo={}, phone={}",
config.getDeviceNo(), config.getChannelNo(), config.getPhoneNumber());
}
} else {
// 插入新配置
channelConfigMapper.insert(config);
logger.info("新增通道配置: deviceNo={}, channelNo={}, phone={}",
config.getDeviceNo(), config.getChannelNo(), config.getPhoneNumber());
}
} catch (Exception e) {
logger.error("同步通道配置到数据库失败: {}", e.getMessage());
}
}
}
}

99
src/main/java/com/threecloud/dataserviceyy/util/VaaHttpUtil.java

@ -16,30 +16,29 @@ import java.util.Map;
* VAA录音盒HTTP工具类
* 用于与录音盒设备进行HTTP通信
* 参考文档: ebox_developer_guide.html (EBOX-8108 电话录音仪开发手册)
*
* 说明
* 所有方法都不重试一次失败直接抛出异常由调用方处理
*/
@Component
public class VaaHttpUtil {
private static final Logger logger = LoggerFactory.getLogger(VaaHttpUtil.class);
/** 连接超时:30秒,设备可能在远端网络 */
private static final int CONNECT_TIMEOUT = 30000;
/** API读取超时:60秒 */
private static final int READ_TIMEOUT = 60000;
/** 文件下载读取超时:5分钟,音频文件可能较大 */
private static final int DOWNLOAD_READ_TIMEOUT = 300000;
/** 重试次数 */
private static final int MAX_RETRY = 3;
/** 重试间隔基数(毫秒) */
private static final int RETRY_BASE_DELAY = 2000;
/** 连接超时:10秒 */
private static final int CONNECT_TIMEOUT = 10000;
/** API读取超时:30秒 */
private static final int READ_TIMEOUT = 30000;
/** 文件下载读取超时:2分钟 */
private static final int DOWNLOAD_READ_TIMEOUT = 120000;
/**
* HTTP登录认证
* @param loginUrl 登录URL例如http://192.168.1.100/authorize?username=admin&password=admin123
* HTTP登录认证无重试
* @param loginUrl 登录URL
* @return 登录成功后返回Authorization Cookie值
* @throws Exception 登录失败直接抛出异常
*/
public String httpLogin(String loginUrl) throws Exception {
return executeWithRetry(() -> {
logger.debug("正在登录录音盒: {}", loginUrl);
HttpURLConnection conn = null;
@ -66,17 +65,16 @@ public class VaaHttpUtil {
conn.disconnect();
}
}
}, "登录");
}
/**
* HTTP访问API获取数据
* @param apiUrl API地址例如http://192.168.1.100/service/running/channel
* HTTP访问API获取数据无重试
* @param apiUrl API地址
* @param authorization 登录后返回的Authorization Cookie值
* @return 返回JSON字符串
* @throws Exception 访问失败直接抛出异常
*/
public String httpVisit(String apiUrl, String authorization) throws Exception {
return executeWithRetry(() -> {
logger.debug("访问API: {}", apiUrl);
HttpURLConnection conn = null;
@ -114,17 +112,16 @@ public class VaaHttpUtil {
conn.disconnect();
}
}
}, "API访问");
}
/**
* 下载录音文件重试
* @param fileUrl 文件URL例如http://192.168.1.100/record/2026/05/20/OUT-xxx.wav
* 下载录音文件重试
* @param fileUrl 文件URL
* @param savePath 保存路径
* @param authorization 登录后返回的Authorization Cookie值
* @throws Exception 下载失败直接抛出异常
*/
public void httpDown(String fileUrl, String savePath, String authorization) throws Exception {
executeWithRetry(() -> {
logger.info("开始下载录音文件: {} -> {}", fileUrl, savePath);
// 确保目录存在
@ -153,17 +150,10 @@ public class VaaHttpUtil {
byte[] buffer = new byte[8192];
int bytesRead;
long totalBytes = 0;
long lastLogTime = System.currentTimeMillis();
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
totalBytes += bytesRead;
// 每10秒打印一次进度
long now = System.currentTimeMillis();
if (now - lastLogTime > 10000) {
logger.info("下载进度: {} MB", totalBytes / 1024 / 1024);
lastLogTime = now;
}
}
outputStream.flush();
@ -179,55 +169,6 @@ public class VaaHttpUtil {
conn.disconnect();
}
}
return null;
}, "文件下载");
}
/**
* 带重试的执行器
*/
private <T> T executeWithRetry(RetryableTask<T> task, String operationName) throws Exception {
Exception lastException = null;
for (int attempt = 1; attempt <= MAX_RETRY; attempt++) {
try {
return task.execute();
} catch (java.net.SocketTimeoutException e) {
lastException = e;
if (attempt < MAX_RETRY) {
long delay = RETRY_BASE_DELAY * attempt;
logger.warn("{}(第{}次)超时,{}ms后重试: {}", operationName, attempt, delay, e.getMessage());
Thread.sleep(delay);
}
} catch (java.net.ConnectException e) {
lastException = e;
if (attempt < MAX_RETRY) {
long delay = RETRY_BASE_DELAY * attempt;
logger.warn("{}(第{}次)连接失败,{}ms后重试: {}", operationName, attempt, delay, e.getMessage());
Thread.sleep(delay);
}
} catch (IOException e) {
lastException = e;
if (attempt < MAX_RETRY && isRetryable(e)) {
long delay = RETRY_BASE_DELAY * attempt;
logger.warn("{}(第{}次)IO异常,{}ms后重试: {}", operationName, attempt, delay, e.getMessage());
Thread.sleep(delay);
} else {
throw e;
}
}
}
throw new RuntimeException(operationName + "失败,已重试" + MAX_RETRY + "次", lastException);
}
private boolean isRetryable(IOException e) {
String msg = e.getMessage();
if (msg == null) return false;
return msg.contains("timed out") || msg.contains("connection") || msg.contains("reset");
}
@FunctionalInterface
private interface RetryableTask<T> {
T execute() throws Exception;
}
private void closeQuietly(Closeable c) {
@ -304,8 +245,7 @@ public class VaaHttpUtil {
/**
* 获取分机号码配置
* EBOX接口: GET /service/ext/number
* 返回每条线路的分机号码: {"1":"8001","2":"8002",...}
* @param extUrl 接口地址例如http://192.168.1.100/service/ext/number
* @param extUrl 接口地址
* @param authorization 登录后返回的Authorization Cookie值
* @return 通道-分机号映射 Map<channel, phoneNumber>
*/
@ -315,7 +255,6 @@ public class VaaHttpUtil {
return new java.util.HashMap<>();
}
try {
// EBOX返回的是JSON对象,key是通道号(1-8),value是分机号码
com.alibaba.fastjson2.JSONObject jsonObj = JSON.parseObject(jsonData);
Map<String, String> result = new java.util.HashMap<>();
for (String key : jsonObj.keySet()) {

36
src/main/resources/application.yml

@ -2,7 +2,6 @@ spring:
application:
name: dataservice-yy
# 引入外部配置文件(部署时修改 config/application-external.yml)
# 注意:路径是相对于 JAR 包所在目录的
config:
import: optional:file:./config/application-external.yml
@ -13,58 +12,23 @@ mybatis:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# ==================== 日志配置 ====================
# 【内网测试专用】详细日志级别配置
logging:
level:
# 根日志级别:INFO(生产环境建议WARN)
root: INFO
# 本项目代码:DEBUG(内网测试时开启,生产建议INFO)
com.threecloud.dataserviceyy: DEBUG
# 同步服务:DEBUG(关键业务流程)
com.threecloud.dataserviceyy.service.VaaSyncService: DEBUG
# 通道配置服务:DEBUG
com.threecloud.dataserviceyy.service.channel: DEBUG
# HTTP工具:DEBUG(查看API请求响应)
com.threecloud.dataserviceyy.util.VaaHttpUtil: DEBUG
# 文件上传:INFO
com.threecloud.dataserviceyy.util.FileUploadUtil: INFO
# MyBatis SQL日志:DEBUG(查看执行的SQL)
com.threecloud.dataserviceyy.mapper: DEBUG
# Spring框架:WARN(减少框架日志噪音)
org.springframework: WARN
org.springframework.web: WARN
# Hikari连接池:INFO
com.zaxxer.hikari: INFO
# Tomcat:INFO
org.apache.catalina: INFO
# 日志文件配置
file:
name: logs/app.log
# 日志格式
pattern:
# 文件格式:带时间、线程、级别、类名
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
# 控制台格式:简化版,方便开发查看
console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - %msg%n"
# 日志文件滚动配置
logback:
rollingpolicy:
# 单个文件最大10MB
max-file-size: 10MB
# 保留30天
max-history: 30
# 总大小不超过1GB
total-size-cap: 1GB

104
src/main/resources/mapper/MidVoiceChannelConfigMapper.xml

@ -1,104 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.threecloud.dataserviceyy.mapper.MidVoiceChannelConfigMapper">
<resultMap id="BaseResultMap" type="com.threecloud.dataserviceyy.entity.MidVoiceChannelConfig">
<id column="id" property="id"/>
<result column="city_code" property="cityCode"/>
<result column="city_name" property="cityName"/>
<result column="device_no" property="deviceNo"/>
<result column="channel_no" property="channelNo"/>
<result column="phone_number" property="phoneNumber"/>
<result column="channel_name" property="channelName"/>
<result column="channel_status" property="channelStatus"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
<result column="remarks" property="remarks"/>
</resultMap>
<!-- 插入通道配置 -->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO mid_voice_channel_config (
city_code, city_name, device_no, channel_no, phone_number,
channel_name, channel_status, create_time, update_time, remarks
) VALUES (
#{cityCode}, #{cityName}, #{deviceNo}, #{channelNo}, #{phoneNumber},
#{channelName}, #{channelStatus}, NOW(), NOW(), #{remarks}
)
</insert>
<!-- 根据ID查询 -->
<select id="selectById" resultMap="BaseResultMap">
SELECT * FROM mid_voice_channel_config WHERE id = #{id}
</select>
<!-- 根据设备编码和通道号查询 -->
<select id="selectByDeviceAndChannel" resultMap="BaseResultMap">
SELECT * FROM mid_voice_channel_config
WHERE device_no = #{deviceNo} AND channel_no = #{channelNo}
LIMIT 1
</select>
<!-- 查询设备的所有通道 -->
<select id="selectByDeviceNo" resultMap="BaseResultMap">
SELECT * FROM mid_voice_channel_config
WHERE device_no = #{deviceNo}
ORDER BY channel_no
</select>
<!-- 根据电话号码查询通道 -->
<select id="selectByPhoneNumber" resultMap="BaseResultMap">
SELECT * FROM mid_voice_channel_config
WHERE phone_number = #{phoneNumber}
LIMIT 1
</select>
<!-- 查询所有通道配置 -->
<select id="selectAll" resultMap="BaseResultMap">
SELECT * FROM mid_voice_channel_config
ORDER BY device_no, channel_no
</select>
<!-- 更新通道配置 -->
<update id="update">
UPDATE mid_voice_channel_config
SET city_code = #{cityCode},
city_name = #{cityName},
device_no = #{deviceNo},
channel_no = #{channelNo},
phone_number = #{phoneNumber},
channel_name = #{channelName},
channel_status = #{channelStatus},
remarks = #{remarks},
update_time = NOW()
WHERE id = #{id}
</update>
<!-- 更新通道状态 -->
<update id="updateStatus">
UPDATE mid_voice_channel_config
SET channel_status = #{channelStatus},
update_time = NOW()
WHERE id = #{id}
</update>
<!-- 删除通道配置 -->
<delete id="deleteById">
DELETE FROM mid_voice_channel_config WHERE id = #{id}
</delete>
<!-- 批量插入 -->
<insert id="batchInsert">
INSERT INTO mid_voice_channel_config (
city_code, city_name, device_no, channel_no, phone_number,
channel_name, channel_status, create_time, update_time, remarks
) VALUES
<foreach collection="list" item="item" separator=",">
(
#{item.cityCode}, #{item.cityName}, #{item.deviceNo}, #{item.channelNo}, #{item.phoneNumber},
#{item.channelName}, #{item.channelStatus}, NOW(), NOW(), #{item.remarks}
)
</foreach>
</insert>
</mapper>

116
src/main/resources/mapper/VoiceSyncMapper.xml

@ -1,6 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.threecloud.dataserviceyy.mapper.VoiceSyncMapper">
<!-- 查询所有在线语音设备(含每个设备的账号密码) -->
<select id="getAllYysb" resultType="java.util.Map">
SELECT id AS ID,
device_no AS UUID,
@ -8,117 +10,11 @@
city_code AS ORGAN_ID,
ip_address AS IP,
device_port AS PORT,
org_code AS ORG_CODE
org_code AS ORG_CODE,
username AS USERNAME,
password AS PASSWORD
FROM mid_voice.mid_voice_device_config
WHERE device_status = '0' and city_code = '340100'
</select>
<select id="getTdByPhone" resultType="java.util.Map">
SELECT ID, UUID
FROM YYDC_SBTD
WHERE SFYX_ST = '1'
AND PHONE = #{phone}
AND ROWNUM = 1
</select>
<select id="getTdByPhone2" resultType="java.util.Map">
SELECT * FROM (
SELECT ID, UUID, PHONE, '1' THFX
FROM YYDC_SBTD
WHERE SFYX_ST = '1'
AND PHONE = #{zjhm}
UNION ALL
SELECT ID, UUID, PHONE, '0' THFX
FROM YYDC_SBTD
WHERE SFYX_ST = '1'
AND PHONE = #{bjhm}
) WHERE ROWNUM = 1
</select>
<select id="getYysbByUuid" resultType="java.util.Map">
SELECT ORGAN_NAME, ORGAN_ID, ID
FROM YYDC_YYSB
WHERE SFYX_ST = '1'
AND UUID = #{uuid}
</select>
<insert id="saveThjl">
INSERT INTO YYDC_YYTH(ID, ORGAN_NAME, ORGAN_ID, YYSB_ID, SBTD_ID, THFX, PHONE, ZJHM,
BJHM, THSC, KSSJ, JSSJ, LYZT, LYDZ, TBSJ, CJSJ, CJR_ID, XGSJ, XGR_ID, SFYX_ST)
VALUES (SYS_GUID(), #{organName}, #{organId}, #{yysbId}, #{sbtdId}, #{thfx}, #{phone}, #{zjhm}, #{bjhm},
#{thsc}, TO_DATE(#{kssj}, 'YYYY-MM-DD HH24:MI:SS'), TO_DATE(#{jssj}, 'YYYY-MM-DD HH24:MI:SS'), '1',
#{lydz}, SYSDATE, SYSDATE, 0, SYSDATE, 0, '1')
</insert>
<!-- VAA同步相关SQL -->
<select id="getChannelByNumberAndUuid" resultType="java.util.Map">
SELECT ID AS TDID, PHONE
FROM YYDC_SBTD
WHERE SFYX_ST = '1'
AND TDHM = #{channel}
AND UUID = #{uuid}
AND ROWNUM = 1
</select>
<update id="updateChannelStatus">
UPDATE YYDC_SBTD
SET TDZT = #{TDZT},
TBSJ = #{TBSJ},
XGR_ID = 0,
XGSJ = #{TBSJ}
WHERE SFYX_ST = '1'
AND TDHM = #{TDHM}
AND UUID = #{UUID}
</update>
<select id="getLastSyncTime" resultType="java.util.Date">
SELECT MAX(GXSJ)
FROM YYDC_TBRZ
WHERE TBBZ = '1'
AND CODE = #{code}
WHERE device_status = '0'
</select>
<insert id="saveSyncLog">
INSERT INTO YYDC_TBRZ (CODE, KSSJ, JSSJ, GXSJ, TBBZ, TBSM)
VALUES (#{CODE}, #{KSSJ}, #{JSSJ}, #{GXSJ}, #{TBBZ}, #{TBSM})
</insert>
<select id="checkThjlExists" resultType="java.lang.String">
SELECT ID FROM YYDC_YYTH WHERE ID = #{thid}
</select>
<insert id="insertThjl">
INSERT INTO YYDC_YYTH
(ID, ORGAN_NAME, ORGAN_ID, YYSB_ID, SBTD_ID, THFX, PHONE, ZJHM, BJHM,
THSC, KSSJ, JSSJ, LYZT, LYDZ, LYMC, YJZT, YCZT, XCZT, DBZT,
TBSJ, CJR_ID, CJSJ, XGR_ID, XGSJ, SFYX_ST)
VALUES
(#{THID}, #{ORGAN_NAME}, #{ORGAN_ID}, #{YYSB_ID}, #{SBTD_ID}, #{THFX}, #{PHONE},
#{ZJHM}, #{BJHM}, #{THSC}, TO_DATE(#{KSSJ}, 'YYYY-MM-DD HH24:MI:SS'),
TO_DATE(#{JSSJ}, 'YYYY-MM-DD HH24:MI:SS'), '1', #{LYDZ}, #{LYMC},
'0', '0', '0', '0', #{TBSJ}, 0, #{TBSJ}, 0, #{TBSJ}, '1')
</insert>
<update id="updateThjl">
UPDATE YYDC_YYTH
SET ORGAN_NAME = #{ORGAN_NAME},
ORGAN_ID = #{ORGAN_ID},
YYSB_ID = #{YYSB_ID},
SBTD_ID = #{SBTD_ID},
THFX = #{THFX},
PHONE = #{PHONE},
ZJHM = #{ZJHM},
BJHM = #{BJHM},
THSC = #{THSC},
KSSJ = TO_DATE(#{KSSJ}, 'YYYY-MM-DD HH24:MI:SS'),
JSSJ = TO_DATE(#{JSSJ}, 'YYYY-MM-DD HH24:MI:SS'),
LYZT = '1',
LYDZ = #{LYDZ},
LYMC = #{LYMC},
TBSJ = #{TBSJ},
XGR_ID = 0,
XGSJ = #{TBSJ}
WHERE ID = #{THID}
</update>
</mapper>

Loading…
Cancel
Save