Compare commits

...

6 Commits

Author SHA1 Message Date
wang 13fd7ef7c7 FTP优化上传 3 weeks ago
wang 8bbe0d2c04 优化上传 3 weeks ago
wang a83572f109 优化上传 1 month ago
wang 05d7e4cc0a 项目名修改 1 month ago
wang 050a2bea11 优化代码逻辑 2 months ago
wang c4a86c3ff5 ftp本地拉取修改 2 months ago
  1. 438
      README.md
  2. 50
      config/application-external.yml
  3. 76
      logs/app.log
  4. BIN
      logs/app.log.2026-05-21.0.gz
  5. BIN
      logs/app.log.2026-05-22.0.gz
  6. BIN
      logs/app.log.2026-05-23.0.gz
  7. BIN
      logs/app.log.2026-05-25.0.gz
  8. BIN
      logs/server.jar
  9. 54
      sql/mid_ftp_processed_file.sql
  10. 77
      sql/mid_voice_device_log.sql
  11. 8
      src/main/java/com/threecloud/dataserviceyy/DataserviceYyApplication.java
  12. 12
      src/main/java/com/threecloud/dataserviceyy/config/DynamicDataSourceConfig.java
  13. 16
      src/main/java/com/threecloud/dataserviceyy/config/FileUploadConfig.java
  14. 129
      src/main/java/com/threecloud/dataserviceyy/config/FtpSyncProperties.java
  15. 13
      src/main/java/com/threecloud/dataserviceyy/config/SyncTargetConfig.java
  16. 270
      src/main/java/com/threecloud/dataserviceyy/controller/VoiceBoxController.java
  17. 157
      src/main/java/com/threecloud/dataserviceyy/entity/CallRecord.java
  18. 93
      src/main/java/com/threecloud/dataserviceyy/entity/DeviceMatchResult.java
  19. 84
      src/main/java/com/threecloud/dataserviceyy/entity/FtpProcessedFile.java
  20. 79
      src/main/java/com/threecloud/dataserviceyy/entity/MidVoiceDeviceLog.java
  21. 77
      src/main/java/com/threecloud/dataserviceyy/entity/SyncLog.java
  22. 125
      src/main/java/com/threecloud/dataserviceyy/entity/VoiceRecord.java
  23. 22
      src/main/java/com/threecloud/dataserviceyy/enums/SyncStatus.java
  24. 48
      src/main/java/com/threecloud/dataserviceyy/mapper/FtpProcessedFileMapper.java
  25. 6
      src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceCallRecordMapper.java
  26. 47
      src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceDeviceLogMapper.java
  27. 55
      src/main/java/com/threecloud/dataserviceyy/mapper/SyncLogMapper.java
  28. 67
      src/main/java/com/threecloud/dataserviceyy/mapper/VoiceRecordMapper.java
  29. 19
      src/main/java/com/threecloud/dataserviceyy/mapper/VoiceSyncMapper.java
  30. 512
      src/main/java/com/threecloud/dataserviceyy/service/FtpSyncService.java
  31. 390
      src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java
  32. 76
      src/main/java/com/threecloud/dataserviceyy/util/DataUtil.java
  33. 20
      src/main/java/com/threecloud/dataserviceyy/util/DateUtil.java
  34. 181
      src/main/java/com/threecloud/dataserviceyy/util/FileCleaner.java
  35. 11
      src/main/java/com/threecloud/dataserviceyy/util/FileUploadUtil.java
  36. 181
      src/main/java/com/threecloud/dataserviceyy/util/FtpUtil.java
  37. 88
      src/main/java/com/threecloud/dataserviceyy/util/RecordParser.java
  38. 70
      src/main/java/com/threecloud/dataserviceyy/util/SyncTimeUtil.java
  39. 271
      src/main/java/com/threecloud/dataserviceyy/util/VaaHttpUtil.java
  40. 72
      src/main/java/com/threecloud/dataserviceyy/util/fileUtil.java
  41. 75
      src/main/resources/mapper/FtpProcessedFileMapper.xml
  42. 6
      src/main/resources/mapper/MidVoiceCallRecordMapper.xml
  43. 85
      src/main/resources/mapper/SyncLogMapper.xml
  44. 117
      src/main/resources/mapper/VoiceRecordMapper.xml
  45. 20
      src/main/resources/mapper/VoiceSyncMapper.xml

438
README.md

@ -1,213 +1,381 @@
# 语音数据同步服务 (dataservice-yy)
# 语音数据FTP同步服务
> 基于淮南 yydc-tb-server 老代码改造的 Spring Boot 服务
> 用于从FTP服务器读取录音数据,上传至OSS并入库到人大金仓数据库
## 项目简介
语音数据同步服务用于从 EBOX 录音盒拉取录音文件,上传到 OSS,并保存通话记录到数据库。
本服务定时从FTP服务器读取通话记录txt文件,下载对应的mp3录音文件,上传到OSS对象存储,并将通话记录保存到人大金仓数据库。**每个地市单独部署一份服务**。
参考老代码(淮南):`src/main/java/淮南/MineService.java`(已废弃,新代码基于其重写)
## 技术栈
| 技术 | 版本 |
|-----|------|
| Spring Boot | 2.6.13 |
| MyBatis | 2.3.1 |
| 数据库 | 人大金仓 (KingbaseES) |
| 工具库 | Apache Commons Net (FTP) |
| JDK | 1.8+ |
## 目录结构
```
dataservice-yy/
├── dataservice-yy-0.0.1-SNAPSHOT.jar # 主程序 JAR 包
├── src/main/java/com/threecloud/dataserviceyy/
│ ├── DataserviceYyApplication.java # 启动类
│ ├── config/
│ │ ├── DataSourceConfig.java # 数据源配置
│ │ └── FtpSyncProperties.java # FTP配置
│ ├── entity/
│ │ └── MidVoiceCallRecord.java # 通话记录实体
│ ├── mapper/
│ │ └── MidVoiceCallRecordMapper.java # MyBatis Mapper
│ ├── service/
│ │ └── FtpSyncService.java # 核心同步服务
│ └── util/
│ ├── FilePathUtil.java # 路径工具
│ ├── FileUploadUtil.java # OSS上传工具
│ ├── FtpUtil.java # FTP工具
│ └── DateUtil.java # 日期工具
├── src/main/resources/
│ ├── application.yml # 内部配置
│ ├── logback-spring.xml # 日志配置
│ └── mapper/
│ └── MidVoiceCallRecordMapper.xml # SQL映射
├── config/
│ └── application-external.yml # 外部配置文件(部署时修改)
├── vaa-recordings/ # 本地录音文件存储目录
│ ├── 340100/ # 地市编码
│ │ ├── 20240101/ # 日期目录
│ │ │ └── <device_uuid>/ # 设备目录
│ │ │ └── xxx.wav
│ │ └── 20240102/
│ └── ...
├── logs/
│ └── app.log # 日志文件
└── README.md # 本说明文档
│ └── application-external.yml # 外部配置(部署时修改)
├── sql/
│ └── mid_voice_call_record.sql # 建表SQL
├── pom.xml # Maven配置
└── README.md # 本文档
```
## 部署步骤
## 数据流程
### 1. 准备环境
```
┌──────────────┐ 1.连接 ┌──────────────┐
│ 本服务 │◀───────────▶│ FTP服务器 │
│ │ 2.下载txt │ (录音盒) │
│ │ 3.下载mp3 │ │
└──────┬───────┘ └──────────────┘
│ 4.上传
┌──────────────┐
│ OSS存储 │ http://oss地址/voice/合肥/340100/20240101/xxx.mp3
└──────────────┘
│ 5.入库
┌──────────────┐
│ 人大金仓 │ mid_voice_call_record 表
└──────────────┘
```
- JDK 1.8+
- 人大金仓数据库(已创建 mid_voice schema)
- OSS 服务(已部署并配置好上传接口)
## 数据库表
### mid_voice_call_record(通话记录表)
```sql
CREATE TABLE "mid_voice"."mid_voice_call_record" (
"id" int8 NOT NULL GENERATED ALWAYS AS IDENTITY,
"city_code" varchar(20),
"city_name" varchar(100),
"call_record_id" varchar(100) NOT NULL, -- 唯一标识,建议加唯一索引
"call_tel" varchar(50) NOT NULL, -- 主叫
"called_tel" varchar(200) NOT NULL, -- 被叫
"call_start_time" timestamp(6) NOT NULL,
"call_end_time" timestamp(6) NOT NULL,
"call_duration" int4 NOT NULL, -- 秒
"call_direction" varchar(50) NOT NULL, -- 1呼入/2呼出
"device_no" varchar(100) NOT NULL, -- 设备编码(FTP_地市编码)
"business_scenario" varchar(100),
"recording_file_name" varchar(200) NOT NULL,
"recording_file_path" varchar(500) NOT NULL, -- OSS URL
"recording_file_size" int4,
"call_status" varchar(20), -- 1正常/2未接通
"fail_reason" text,
"remarks" text,
"sync_time" timestamp(6) DEFAULT CURRENT_TIMESTAMP,
"create_time" timestamp(6) DEFAULT CURRENT_TIMESTAMP,
"org_code" varchar(64),
PRIMARY KEY ("id")
)
PARTITION BY LIST ("city_code");
```
### 2. 创建目录结构
**建议加唯一索引**(防重最后一道防线):
```bash
mkdir -p /opt/dataservice-yy/config
mkdir -p /opt/dataservice-yy/logs
```sql
CREATE UNIQUE INDEX idx_call_record_id_unique
ON mid_voice.mid_voice_call_record (call_record_id);
```
### 3. 复制文件
## 部署说明
### 1. 准备JAR包
```bash
# 复制 JAR 包
cp dataservice-yy-0.0.1-SNAPSHOT.jar /opt/dataservice-yy/
cd /Users/wang/sanduoyun/developspace/dataservice-yy
mvn clean package -DskipTests
```
产物:`target/dataservice-yy-0.0.1-SNAPSHOT.jar`
### 2. 部署目录结构
# 复制配置文件模板
cp config/application-external.yml /opt/dataservice-yy/config/
每台服务器(每地市):
```
/opt/dataservice-yy/
├── dataservice-yy-0.0.1-SNAPSHOT.jar
├── config/
│ └── application-external.yml # 修改此文件配置FTP
├── vaa-ftp-temp/ # 自动创建(临时文件)
└── logs/ # 自动创建(运行日志)
└── app.log
```
### 4. 修改配置
### 3. 修改外部配置
编辑 `/opt/dataservice-yy/config/application-external.yml`,修改以下配置项:
编辑 `config/application-external.yml`
#### 数据库配置
```yaml
spring:
datasource:
url: jdbc:kingbase8://{数据库IP}:{端口}/kingbase?currentSchema=mid_voice&clientEncoding=utf8
username: {数据库用户名}
password: {数据库密码}
```
url: jdbc:kingbase8://你的数据库IP:54321/kingbase?currentSchema=mid_voice
username: dcms_dev
password: 你的密码
ftp-sync:
cities:
- city-code: "340100" # 你的地市编码
city-name: "合肥"
ftp-host: 10.126.129.7 # FTP服务器
ftp-port: 9979
ftp-username: ftpuser
ftp-password: ftppass
ftp-source-dir: /record/ # txt目录
ftp-record-dir: /recordfile/ # mp3目录
ftp-archive-dir: /processed/ # 归档目录
#### OSS 配置
```yaml
vaa-sync:
oss:
base-url: http://{OSS服务器IP}:9090
upload-url: http://{OSS服务器IP}:9090/apiOss/oss/fileUpload
base-url: http://你的OSS地址:9090
upload-url: http://你的OSS地址:9090/apiOss/oss/noAuthFileUploadSingle
appcode: dataservice-yy
appid: {appid}
appsecret: {appsecret}
appid: 你的appid
appsecret: 你的appsecret
```
#### 其他配置
```yaml
vaa-sync:
download-path: ./vaa-recordings # 本地录音文件存储路径
sync-interval-cron: "0 0 0/2 * * ?" # 同步间隔(默认每2小时)
device-username: admin # 录音盒登录账号
device-password: admin # 录音盒登录密码
server:
port: 8088 # 服务端口
```
### 5. 启动服务
### 4. 启动服务
```bash
cd /opt/dataservice-yy
nohup java -jar dataservice-yy-0.0.1-SNAPSHOT.jar > /dev/null 2>&1 &
```
### 6. 查看日志
### 5. 验证启动
```bash
tail -f logs/app.log
```
## 配置文件说明
看到如下日志说明启动成功:
```
【项目启动】开始执行首次FTP录音同步
【项目启动】首次同步完成,后续将在每2小时自动执行
```
### application-external.yml
## 配置项说明
### 数据库配置(spring.datasource)
| 配置项 | 必填 | 说明 |
|-------|-----|------|
| `url` | ✓ | JDBC URL(人大金仓:jdbc:kingbase8://IP:port/db) |
| `username` | ✓ | 数据库用户 |
| `password` | ✓ | 数据库密码 |
| `driver-class-name` | ✓ | com.kingbase8.Driver |
### FTP配置(ftp-sync.cities[])
| 配置项 | 必填 | 说明 |
|-------|-----|------|
| `city-code` | ✓ | 地市编码(如340100),与数据库分区对应 |
| `city-name` | ✓ | 地市名称(用于OSS路径) |
| `ftp-host` | ✓ | FTP服务器IP |
| `ftp-port` | ✓ | FTP端口 |
| `ftp-username` | ✓ | FTP用户名 |
| `ftp-password` | ✓ | FTP密码 |
| `ftp-source-dir` | ✓ | txt源目录(处理后归档) |
| `ftp-record-dir` | ✓ | mp3/wav文件目录 |
| `ftp-archive-dir` | ✓ | 处理完的txt移动到此目录 |
### FTP全局配置(ftp-sync.*)
| 配置项 | 默认值 | 说明 |
|-------|-------|------|
| `enabled` | true | 是否启用FTP同步 |
| `sync-interval-cron` | 0 0 0/2 * * ? | 定时任务(每2小时) |
| `temp-path` | ./vaa-ftp-temp | 本地临时文件目录 |
| `retain-days` | 10 | 临时文件保留天数 |
| `txt-encoding` | GBK | txt文件编码(老系统用GBK) |
| `field-separator` | ※ | 字段分隔符(老系统用※) |
### OSS配置(vaa-sync.oss)
| 配置项 | 必填 | 说明 |
|-------|-----|------|
| `base-url` | ✓ | OSS访问基础URL |
| `upload-url` | ✓ | OSS上传接口URL |
| `appcode` | ✓ | 应用编码 |
| `appid` | ✓ | 应用ID |
| `appsecret` | ✓ | 应用密钥 |
## txt文件格式
**编码**:GBK
**分隔符**:※
**字段顺序**:
```
kssj※jssj※hjls※hjzls※zjhm※bjhm※thsc※...※thfx
```
此文件包含所有需要部署时调整的参数,**无需重新打包 JAR**。
| 序号 | 字段 | 说明 | 示例 |
|-----|------|------|------|
| 1 | kssj | 开始时间 | 20240101120000 |
| 2 | jssj | 结束时间 | 20240101120030 |
| 3 | hjls | 呼叫流水 | 12345 |
| 4 | hjzls | 呼叫主/被叫流水 | 67890 |
| 5 | zjhm | 主叫号码 | 055312345678 |
| 6 | bjhm | 被叫号码 | 12345678 |
| 7 | thsc | 通话时长(秒) | 30 |
| ... | ... | 中间字段 | ... |
| 10 | thfx | 通话方向 | 1=呼入/0=呼出 |
| 配置项 | 说明 | 示例 |
|-------|------|------|
| `spring.datasource.url` | 数据库连接URL | `jdbc:kingbase8://53.1.194.60:54321/kingbase?currentSchema=mid_voice&clientEncoding=utf8` |
| `spring.datasource.username` | 数据库用户名 | `dcms_dev` |
| `spring.datasource.password` | 数据库密码 | `sdy@2025#dc$ks` |
| `vaa-sync.oss.base-url` | OSS基础地址 | `http://53.1.194.59:9090` |
| `vaa-sync.oss.upload-url` | OSS上传接口 | `http://53.1.194.59:9090/apiOss/oss/fileUpload` |
| `vaa-sync.oss.appcode` | OSS应用编码 | `dataservice-yy` |
| `vaa-sync.oss.appid` | OSS应用ID | `371a3368-e28e-4ba3-95a3-c31c19cf0ad0` |
| `vaa-sync.oss.appsecret` | OSS应用密钥 | `06a6a80e-f9d2-4b3b-acc0-8d182c876074` |
| `vaa-sync.download-path` | 本地录音存储路径 | `./vaa-recordings` |
| `vaa-sync.sync-interval-cron` | 同步定时任务 | `0 0 0/2 * * ?`(每2小时) |
| `vaa-sync.device-username` | 录音盒登录账号 | `admin` |
| `vaa-sync.device-password` | 录音盒登录密码 | `admin` |
| `server.port` | 服务端口 | `8088` |
**录音文件**:`{ftpRecordDir}/{yyyyMMdd}/{hjls}_{hjzls}.mp3`
## 定时任务说明
## 防重复机制
同步任务默认每2小时执行一次,可通过修改 `sync-interval-cron` 调整:
| 层级 | 方式 | 说明 |
|-----|------|------|
| 1 | FTP归档 | 处理完的txt移到 `archive/` |
| 2 | 数据库前缀查重 | `call_record_id LIKE '340100_FTP_%'` |
| 3 | 内存Set去重 | 同批内不重复 |
| 4 | 数据库唯一索引 | 建议在 `call_record_id` 上加 |
| 表达式 | 说明 |
|-------|------|
| `0 0 0/2 * * ?` | 每2小时执行一次(默认) |
| `0 0/30 * * * ?` | 每30分钟执行一次 |
| `0 0 2 * * ?` | 每天凌晨2点执行 |
| `0 0/1 * * * ?` | 每分钟执行(测试用) |
## 定时任务
## 数据表说明
默认 `0 0 0/2 * * ?`(每2小时执行一次)。
### mid_voice_device_config
可在 `application-external.yml` 中修改:
设备基础配置表,存储录音盒设备信息。
```yaml
ftp-sync:
sync-interval-cron: "0 0 0/2 * * ?"
```
| 字段 | 说明 |
|-----|------|
| device_no | 设备编号(UUID) |
| city_code | 地市编码 |
| city_name | 地市名称 |
| ip_address | IP地址 |
| device_port | 端口号 |
| device_status | 设备状态(0在线) |
**常用 Cron 表达式**:
- 每分钟:`0 0/1 * * * ?`
- 每30分钟:`0 0/30 * * * ?`
- 每小时:`0 0 * * * ?`
- 每2小时:`0 0 0/2 * * ?`
- 每天凌晨2点:`0 0 2 * * ?`
### mid_voice_channel_config
## 日志
通道配置表,存储录音盒通道绑定的电话号码。
### 日志位置
| 字段 | 说明 |
|-----|------|
| device_no | 设备编号 |
| channel_no | 通道号(1-8) |
| phone_number | 绑定电话号码 |
`logs/app.log`(按天滚动,保留30天)
### mid_voice_call_record
### 日志前缀
通话记录表,存储录音文件信息和OSS地址。
| 前缀 | 含义 |
|------|------|
| 【FTP定时】 | 定时任务开始/结束 |
| 【FTP主流程】 | 同步主流程 |
| 【FTP地市】 | 单地市处理 |
| 【FTP文件】 | 单txt文件处理 |
| 【FTP行】 | 单行记录处理 |
| 【FTP防重】 | 数据库防重查询 |
| 【异常】 | 错误信息 |
| 字段 | 说明 |
|-----|------|
| call_record_id | 通话记录ID(device_no + record_id) |
| device_no | 设备编号 |
| city_code | 地市编码 |
| city_name | 地市名称 |
| call_tel | 主叫号码 |
| called_tel | 被叫号码 |
| call_start_time | 通话开始时间 |
| call_end_time | 通话结束时间 |
| call_duration | 通话时长(秒) |
| call_direction | 通话方向(1呼入,2呼出) |
| recording_file_name | 录音文件名 |
| recording_file_path | OSS完整访问URL |
| recording_file_size | 文件大小(字节) |
## 日志说明
日志文件位于 `logs/app.log`,可通过以下命令查看:
### 常用查看命令
```bash
# 查看实时日志
# 实时日志
tail -f logs/app.log
# 只看错误日志
tail -f logs/app.log | grep "【异常】"
# 只看错误
grep "【异常】" logs/app.log
# 只看设备同步日志
tail -f logs/app.log | grep "【设备】"
# 只看单地市
grep "【FTP地市】" logs/app.log | grep "合肥"
# 只看录音处理日志
tail -f logs/app.log | grep "【录音】"
# 同步成功数
grep "保存成功" logs/app.log | wc -l
```
## 常见问题
### 1. 配置文件读取失败
### Q1: 数据库连接失败
检查 `application-external.yml``spring.datasource.*` 配置,确保:
- IP和端口正确
- 用户名密码正确
- 数据库服务已启动
### Q2: FTP连接失败
检查 `ftp-sync.cities[0].ftp-*` 配置:
- FTP服务可达:`telnet 10.126.129.7 9979`
- 用户名密码正确
- 目录路径有读写权限
### Q3: 录音文件下载失败
检查 FTP 上是否存在对应路径的文件:
- 路径格式:`{ftp-record-dir}/{yyyyMMdd}/{hjls}_{hjzls}.mp3`
- 文件名大小写是否一致
### Q4: OSS上传失败
检查 `vaa-sync.oss.*` 配置:
- `upload-url` 接口是否可达
- `appid` / `appsecret` 是否正确
检查 `config/application-external.yml` 是否在 JAR 包同级目录的 `config/` 文件夹下。
### Q5: 数据没有入库
### 2. 数据库连接失败
检查:
- 数据库 `mid_voice_call_record` 表是否存在
- 日志中是否有 `保存成功` 字样
- 数据库表分区是否覆盖了该地市编码
检查数据库URL、用户名、密码是否正确,网络是否连通。
## 与老代码(淮南 yydc-tb-server)的差异
### 3. OSS上传失败
| 项 | 老代码 | 新代码 |
|---|-------|-------|
| 数据源 | Oracle | 人大金仓 |
| 配置 | Spring XML | application.yml |
| 同步方式 | 手动运行 | 定时任务 |
| mp3处理 | 只存URL | 下载+上传OSS |
| 防重 | 不防重 | 数据库+FTP双层防重 |
| 部署 | 单机单实例 | 多地市独立部署 |
| 状态 | 已废弃 | 当前生产 |
检查 OSS 地址、认证信息是否正确,OSS服务是否正常。
## 维护
### 4. 录音盒连接失败
| 项 | 频率 |
|---|------|
| 检查日志 | 每天 |
| 清理磁盘 | 自动(保留10天) |
| 数据库备份 | 由DBA负责 |
| 配置文件变更 | 部署时修改外部配置 |
检查录音盒IP、端口、账号密码是否正确,网络是否连通。
## 联系
- 项目维护:xxx
- 内部文档:xxx

50
config/application-external.yml

@ -14,30 +14,56 @@ spring:
driver-class-name: com.kingbase8.Driver
url: jdbc:kingbase8://127.0.0.1:54321/kingbase?currentSchema=mid_voice&clientEncoding=utf8
username: dcms_dev
password: your_password_here
password: your_password_hereq
# ==================== 语音同步配置 ====================
vaa-sync:
# 本地录音文件存储路径
download-path: ./vaa-recordings
# ==================== FTP同步配置(单地市部署) ====================
# 【说明】每个地市单独部署本服务,配置只需要写一个地市
ftp-sync:
# 是否启用FTP同步
enabled: true
# 本地文件保留天数
# 本地临时文件目录
temp-path: ./vaa-ftp-temp
# 文件保留天数
retain-days: 10
# 同步定时任务 Cron 表达式
# 每2小时: 0 0 0/2 * * ?
# 每30分钟: 0 0/30 * * * ?
# 每分钟(测试): 0 0/1 * * * ?
sync-interval-cron: "0 0 0/2 * * ?"
# txt文件编码(老系统用GBK)
txt-encoding: GBK
# 字段分隔符(老系统用※)
field-separator: "※"
# OSS 文件上传配置
oss:
base-url: http://127.0.0.1:9090
upload-url: http://127.0.0.1:9090/apiOss/oss/fileUpload
upload-url: http://127.0.0.1:9090/apiOss/oss/noAuthFileUploadSingle
# 应用编码可以不写
appcode: dataservice-yy
# 应用ID(部署时改为真实值)
appid: your_appid_here
# 应用密钥(部署时改为真实值)
appsecret: your_appsecret_here
# 当前部署的地市FTP配置(每个地市独立部署一份)
cities:
- city-code: "340100"
city-name: "合肥"
ftp-host: 10.126.129.7
ftp-port: 9979
ftp-username: ftpuser
ftp-password: ftppass
ftp-source-dir: /340100/record/
ftp-record-dir: /340100/recordfile/
ftp-archive-dir: /340100/processed/
# 电话区号前缀(处理主被叫号码时去除)
# - 配正确区号:号码以这个区号开头则去除
# - 配不存在的区号(如 "0000"):号码匹配不上,保留原始号码
# - 留空:保留原始号码
# 合肥: 0551, 芜湖: 0553, 蚌埠: 0552, 淮南: 0554, 马鞍山: 0555
# 当前配置 "0000" 表示不去除区号(保留原始号码)
phone-area-code: "0000"
# ==================== 服务端口 ====================
server:
port: 8088

76
logs/app.log

@ -1,76 +0,0 @@
2026-05-26 16:26:25.778 [main] INFO c.t.d.DataserviceYyApplication - Starting DataserviceYyApplication using Java 1.8.0_492 on MswangdeMacBook-Air.local with PID 89751 (/Users/wang/sanduoyun/developspace/dataservice-yy/target/classes started by wang in /Users/wang/sanduoyun/developspace/dataservice-yy)
2026-05-26 16:26:25.782 [main] DEBUG c.t.d.DataserviceYyApplication - Running with Spring Boot v2.6.13, Spring v5.3.23
2026-05-26 16:26:25.783 [main] INFO c.t.d.DataserviceYyApplication - No active profile set, falling back to 1 default profile: "default"
2026-05-26 16:26:26.305 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port(s): 8088 (http)
2026-05-26 16:26:26.310 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
2026-05-26 16:26:26.310 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/9.0.68]
2026-05-26 16:26:26.357 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
2026-05-26 16:26:26.357 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 546 ms
2026-05-26 16:26:26.601 [main] INFO c.t.d.util.FileUploadUtil - 文件上传工具初始化完成, 上传接口: http://127.0.0.1/apiOss/oss/fileUpload
2026-05-26 16:26:26.877 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port(s): 8088 (http) with context path ''
2026-05-26 16:26:26.886 [main] INFO c.t.d.DataserviceYyApplication - Started DataserviceYyApplication in 1.294 seconds (JVM running for 2.135)
2026-05-26 16:26:26.888 [main] INFO c.t.d.service.VaaSyncService - 开始执行VAA录音盒同步任务
2026-05-26 16:26:26.906 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
2026-05-26 16:26:26.913 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
2026-05-26 16:26:50.548 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated...
2026-05-26 16:26:55.382 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed.
2026-05-26 16:32:47.381 [main] INFO c.t.d.DataserviceYyApplication - Starting DataserviceYyApplication using Java 1.8.0_492 on MswangdeMacBook-Air.local with PID 90117 (/Users/wang/sanduoyun/developspace/dataservice-yy/target/classes started by wang in /Users/wang/sanduoyun/developspace/dataservice-yy)
2026-05-26 16:32:47.382 [main] DEBUG c.t.d.DataserviceYyApplication - Running with Spring Boot v2.6.13, Spring v5.3.23
2026-05-26 16:32:47.383 [main] INFO c.t.d.DataserviceYyApplication - No active profile set, falling back to 1 default profile: "default"
2026-05-26 16:32:47.870 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port(s): 8088 (http)
2026-05-26 16:32:47.876 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
2026-05-26 16:32:47.876 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/9.0.68]
2026-05-26 16:32:47.929 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
2026-05-26 16:32:47.929 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 519 ms
2026-05-26 16:32:48.166 [main] INFO c.t.d.util.FileUploadUtil - 文件上传工具初始化完成, 上传接口: http://127.0.0.1/apiOss/oss/fileUpload
2026-05-26 16:32:48.431 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port(s): 8088 (http) with context path ''
2026-05-26 16:32:48.440 [main] INFO c.t.d.DataserviceYyApplication - Started DataserviceYyApplication in 1.277 seconds (JVM running for 1.712)
2026-05-26 16:32:48.441 [main] INFO c.t.d.service.VaaSyncService - 开始执行VAA录音盒同步任务
2026-05-26 16:32:48.459 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
2026-05-26 16:32:48.465 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
2026-05-26 16:32:56.383 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated...
2026-05-26 16:32:59.216 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed.
2026-05-26 16:35:12.104 [main] INFO c.t.d.DataserviceYyApplication - Starting DataserviceYyApplication using Java 1.8.0_492 on MswangdeMacBook-Air.local with PID 90436 (/Users/wang/sanduoyun/developspace/dataservice-yy/target/classes started by wang in /Users/wang/sanduoyun/developspace/dataservice-yy)
2026-05-26 16:35:12.108 [main] DEBUG c.t.d.DataserviceYyApplication - Running with Spring Boot v2.6.13, Spring v5.3.23
2026-05-26 16:35:12.108 [main] INFO c.t.d.DataserviceYyApplication - No active profile set, falling back to 1 default profile: "default"
2026-05-26 16:35:12.935 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port(s): 8088 (http)
2026-05-26 16:35:12.945 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
2026-05-26 16:35:12.946 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/9.0.68]
2026-05-26 16:35:13.022 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
2026-05-26 16:35:13.023 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 849 ms
2026-05-26 16:35:13.428 [main] INFO c.t.d.util.FileUploadUtil - 文件上传工具初始化完成, 上传接口: http://127.0.0.1/apiOss/oss/fileUpload
2026-05-26 16:35:13.831 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port(s): 8088 (http) with context path ''
2026-05-26 16:35:13.842 [main] INFO c.t.d.DataserviceYyApplication - Started DataserviceYyApplication in 2.227 seconds (JVM running for 3.504)
2026-05-26 16:35:13.843 [main] INFO c.t.d.service.VaaSyncService - 开始执行VAA录音盒同步任务
2026-05-26 16:35:13.864 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
2026-05-26 16:35:13.873 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
2026-05-26 16:35:14.560 [main] INFO c.t.d.service.VaaSyncService - 查询到 0 个语音设备
2026-05-26 16:35:14.560 [main] INFO c.t.d.service.VaaSyncService - ========== VAA录音盒同步任务完成 ==========
2026-05-26 16:40:10.590 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated...
2026-05-26 16:40:10.591 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed.
2026-05-26 16:40:15.223 [main] INFO c.t.d.DataserviceYyApplication - Starting DataserviceYyApplication using Java 1.8.0_492 on MswangdeMacBook-Air.local with PID 90706 (/Users/wang/sanduoyun/developspace/dataservice-yy/target/classes started by wang in /Users/wang/sanduoyun/developspace/dataservice-yy)
2026-05-26 16:40:15.225 [main] DEBUG c.t.d.DataserviceYyApplication - Running with Spring Boot v2.6.13, Spring v5.3.23
2026-05-26 16:40:15.226 [main] INFO c.t.d.DataserviceYyApplication - No active profile set, falling back to 1 default profile: "default"
2026-05-26 16:40:15.760 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port(s): 8088 (http)
2026-05-26 16:40:15.766 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
2026-05-26 16:40:15.766 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/9.0.68]
2026-05-26 16:40:15.818 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
2026-05-26 16:40:15.818 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 563 ms
2026-05-26 16:40:16.062 [main] INFO c.t.d.util.FileUploadUtil - 文件上传工具初始化完成, 上传接口: http://127.0.0.1/apiOss/oss/fileUpload
2026-05-26 16:40:16.347 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port(s): 8088 (http) with context path ''
2026-05-26 16:40:16.356 [main] INFO c.t.d.DataserviceYyApplication - Started DataserviceYyApplication in 1.395 seconds (JVM running for 1.897)
2026-05-26 16:40:16.358 [main] INFO c.t.d.service.VaaSyncService - 开始执行VAA录音盒同步任务
2026-05-26 16:40:16.375 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
2026-05-26 16:40:16.381 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
2026-05-26 16:40:17.269 [main] INFO c.t.d.service.VaaSyncService - 查询到 216 个语音设备
2026-05-26 16:40:17.269 [main] INFO c.t.d.service.VaaSyncService - ────────────────────────────────────────
2026-05-26 16:40:17.269 [main] INFO c.t.d.service.VaaSyncService - 开始同步设备: ID=15, 机构=阜阳市, IP=53.162.212.190:80
2026-05-26 16:40:17.271 [main] INFO c.t.d.service.VaaSyncService - 正在登录设备...
2026-05-26 16:40:17.271 [main] DEBUG c.t.dataserviceyy.util.VaaHttpUtil - 正在登录录音盒: http://53.162.212.190/authorize?username=admin&password=admin
2026-05-26 16:40:32.779 [main] ERROR c.t.d.service.VaaSyncService - 设备登录失败: IP=53.162.212.190, url=http://53.162.212.190/authorize?username=admin&password=admin, 原因=登录失败,HTTP状态码: 502
2026-05-26 16:40:32.780 [main] INFO c.t.d.service.VaaSyncService - ────────────────────────────────────────
2026-05-26 16:40:32.780 [main] INFO c.t.d.service.VaaSyncService - 开始同步设备: ID=16, 机构=阜阳市, IP=10.127.89.93:80
2026-05-26 16:40:32.780 [main] INFO c.t.d.service.VaaSyncService - 正在登录设备...
2026-05-26 16:40:32.780 [main] DEBUG c.t.dataserviceyy.util.VaaHttpUtil - 正在登录录音盒: http://10.127.89.93/authorize?username=admin&password=admin
2026-05-26 16:40:33.777 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated...
2026-05-26 16:40:33.778 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed.

BIN
logs/app.log.2026-05-21.0.gz

Binary file not shown.

BIN
logs/app.log.2026-05-22.0.gz

Binary file not shown.

BIN
logs/app.log.2026-05-23.0.gz

Binary file not shown.

BIN
logs/app.log.2026-05-25.0.gz

Binary file not shown.

BIN
logs/server.jar

Binary file not shown.

54
sql/mid_ftp_processed_file.sql

@ -0,0 +1,54 @@
-- ============================================
-- FTP已处理文件记录表(普通表)
-- 用于记录已处理过的FTP txt文件,避免重复处理
-- ============================================
CREATE TABLE "mid_voice"."mid_ftp_processed_file" (
"id" int8 NOT NULL GENERATED ALWAYS AS IDENTITY (
INCREMENT 1
MINVALUE 1
MAXVALUE 9223372036854775807
START 1
CACHE 1
),
"city_code" varchar(20) COLLATE "pg_catalog"."default" NOT NULL,
"city_name" varchar(100) COLLATE "pg_catalog"."default",
"file_name" varchar(255) COLLATE "pg_catalog"."default" NOT NULL,
"file_size" int8,
"process_status" varchar(10) COLLATE "pg_catalog"."default" NOT NULL DEFAULT '1',
"record_count" int4 DEFAULT 0,
"success_count" int4 DEFAULT 0,
"fail_count" int4 DEFAULT 0,
"process_time" int8 DEFAULT 0,
"error_msg" text COLLATE "pg_catalog"."default",
"create_time" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "mid_ftp_processed_file_pkey" PRIMARY KEY ("id"),
CONSTRAINT "uk_ftp_file_city_name" UNIQUE ("city_code", "file_name")
);
ALTER TABLE "mid_voice"."mid_ftp_processed_file"
OWNER TO "dcms_dev";
-- 创建索引
CREATE INDEX "idx_ftp_processed_file_name" ON "mid_voice"."mid_ftp_processed_file" USING btree (
"file_name" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
);
CREATE INDEX "idx_ftp_processed_create_time" ON "mid_voice"."mid_ftp_processed_file" USING btree (
"create_time" "pg_catalog"."timestamp_ops" ASC NULLS LAST
);
-- 添加注释
COMMENT ON TABLE "mid_voice"."mid_ftp_processed_file" IS 'FTP已处理文件记录表';
COMMENT ON COLUMN "mid_voice"."mid_ftp_processed_file"."id" IS '自增ID主键';
COMMENT ON COLUMN "mid_voice"."mid_ftp_processed_file"."city_code" IS '地市编码';
COMMENT ON COLUMN "mid_voice"."mid_ftp_processed_file"."city_name" IS '地市名称';
COMMENT ON COLUMN "mid_voice"."mid_ftp_processed_file"."file_name" IS 'FTP文件名';
COMMENT ON COLUMN "mid_voice"."mid_ftp_processed_file"."file_size" IS '文件大小(字节)';
COMMENT ON COLUMN "mid_voice"."mid_ftp_processed_file"."process_status" IS '处理状态:0失败,1成功';
COMMENT ON COLUMN "mid_voice"."mid_ftp_processed_file"."record_count" IS '记录总数';
COMMENT ON COLUMN "mid_voice"."mid_ftp_processed_file"."success_count" IS '成功入库数';
COMMENT ON COLUMN "mid_voice"."mid_ftp_processed_file"."fail_count" IS '失败数';
COMMENT ON COLUMN "mid_voice"."mid_ftp_processed_file"."process_time" IS '处理耗时(毫秒)';
COMMENT ON COLUMN "mid_voice"."mid_ftp_processed_file"."error_msg" IS '错误信息';
COMMENT ON COLUMN "mid_voice"."mid_ftp_processed_file"."create_time" IS '创建时间';

77
sql/mid_voice_device_log.sql

@ -0,0 +1,77 @@
-- ============================================
-- 设备连接日志表
-- 用于记录设备连接失败信息,便于排查问题
-- ============================================
CREATE TABLE "mid_voice"."mid_voice_device_log" (
"id" int8 NOT NULL GENERATED ALWAYS AS IDENTITY (
INCREMENT 1
MINVALUE 1
MAXVALUE 9223372036854775807
START 1
CACHE 1
),
"device_id" varchar(50) COLLATE "pg_catalog"."default" NOT NULL,
"device_no" varchar(100) COLLATE "pg_catalog"."default",
"city_code" varchar(20) COLLATE "pg_catalog"."default",
"city_name" varchar(100) COLLATE "pg_catalog"."default",
"ip_address" varchar(50) COLLATE "pg_catalog"."default",
"device_port" int4,
"connect_type" varchar(10) COLLATE "pg_catalog"."default" NOT NULL,
"connect_status" varchar(10) COLLATE "pg_catalog"."default" NOT NULL,
"fail_reason" text COLLATE "pg_catalog"."default",
"create_time" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "mid_voice_device_log_pkey" PRIMARY KEY ("id")
)
PARTITION BY LIST (
"city_code" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops"
);
ALTER TABLE "mid_voice"."mid_voice_device_log"
OWNER TO "dcms_dev";
-- 创建索引
CREATE INDEX "idx_device_log_device_id" ON "mid_voice"."mid_voice_device_log" USING btree (
"device_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
);
CREATE INDEX "idx_device_log_create_time" ON "mid_voice"."mid_voice_device_log" USING btree (
"create_time" "pg_catalog"."timestamp_ops" ASC NULLS LAST
);
CREATE INDEX "idx_device_log_status" ON "mid_voice"."mid_voice_device_log" USING btree (
"connect_status" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
);
-- 创建分区表(16个地市)
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3401" FOR VALUES IN ('340100');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3402" FOR VALUES IN ('340200');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3403" FOR VALUES IN ('340300');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3404" FOR VALUES IN ('340400');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3405" FOR VALUES IN ('340500');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3406" FOR VALUES IN ('340600');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3407" FOR VALUES IN ('340700');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3408" FOR VALUES IN ('340800');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3410" FOR VALUES IN ('341000');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3411" FOR VALUES IN ('341100');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3412" FOR VALUES IN ('341200');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3413" FOR VALUES IN ('341300');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3415" FOR VALUES IN ('341500');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3416" FOR VALUES IN ('341600');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3417" FOR VALUES IN ('341700');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3418" FOR VALUES IN ('341800');
ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_default" DEFAULT;
-- 添加注释
COMMENT ON TABLE "mid_voice"."mid_voice_device_log" IS '设备连接日志表';
COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."id" IS '自增ID主键';
COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."device_id" IS '设备ID';
COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."device_no" IS '设备编号(UUID)';
COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."city_code" IS '地市编码';
COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."city_name" IS '地市名称';
COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."ip_address" IS 'IP地址';
COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."device_port" IS '端口号';
COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."connect_type" IS '连接类型:1登录,2查询录音列表,3下载文件';
COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."connect_status" IS '连接状态:0失败,1成功';
COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."fail_reason" IS '失败原因';
COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."create_time" IS '创建时间';

8
src/main/java/com/threecloud/dataserviceyy/DataserviceYyApplication.java

@ -1,6 +1,6 @@
package com.threecloud.dataserviceyy;
import com.threecloud.dataserviceyy.service.VaaSyncService;
import com.threecloud.dataserviceyy.service.FtpSyncService;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
@ -18,7 +18,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
public class DataserviceYyApplication {
@Autowired
private VaaSyncService vaaSyncService;
private FtpSyncService ftpSyncService;
public static void main(String[] args) {
SpringApplication.run(DataserviceYyApplication.class, args);
@ -28,10 +28,10 @@ public class DataserviceYyApplication {
public CommandLineRunner startupRunner() {
return args -> {
System.out.println("========================================");
System.out.println("【项目启动】开始执行首次语音数据同步");
System.out.println("【项目启动】开始执行首次FTP录音同步");
System.out.println("========================================");
try {
vaaSyncService.executeSync();
ftpSyncService.executeSync();
} catch (Exception e) {
System.err.println("【项目启动】首次同步失败: " + e.getMessage());
e.printStackTrace();

12
src/main/java/com/threecloud/dataserviceyy/config/DynamicDataSourceConfig.java

@ -1,12 +0,0 @@
package com.threecloud.dataserviceyy.config;
/**
* 多数据源配置 - 方案A阶段: 已完全禁用
*
* 此类在方案B(多数据源模式)时会重新启用
* 当前阶段使用Spring Boot默认的DataSource自动配置仿照老系统 yydc-tb-server
*/
// @Configuration // 【方案A: 已禁用】
public class DynamicDataSourceConfig {
// 所有方法已禁用,不再干扰Spring Boot的自动配置
}

16
src/main/java/com/threecloud/dataserviceyy/config/FileUploadConfig.java

@ -1,16 +0,0 @@
package com.threecloud.dataserviceyy.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Data
@Component
@ConfigurationProperties(prefix = "file-upload")
public class FileUploadConfig {
private String uploadUrl;
private Map<String, String> extraParams = new HashMap<>();
}

129
src/main/java/com/threecloud/dataserviceyy/config/FtpSyncProperties.java

@ -0,0 +1,129 @@
package com.threecloud.dataserviceyy.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.List;
/**
* FTP同步配置
* 对应 application-external.yml 中的 ftp-sync 配置节点
*
* 示例
* ftp-sync:
* enabled: true
* sync-interval-cron: "0 0 0/2 * * ?"
* cities:
* - city-code: "340100"
* city-name: "合肥"
* ftp-host: 10.126.129.7
* ...
*/
@Configuration
@ConfigurationProperties(prefix = "ftp-sync")
public class FtpSyncProperties {
/** 是否启用FTP同步 */
private boolean enabled = true;
/** 本地临时文件目录(存放下载的mp3/wav) */
private String tempPath = "./vaa-ftp-temp";
/** 文件保留天数 */
private int retainDays = 10;
/** txt文件编码(老系统用GBK) */
private String txtEncoding = "GBK";
/** 字段分隔符(老系统用※) */
private String fieldSeparator = "※";
/** OSS服务基础地址(用于mp3直接URL拼接) */
private String ossBaseUrl;
/** 地市FTP配置列表 */
private List<FtpCityConfig> cities = new ArrayList<>();
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getTempPath() { return tempPath; }
public void setTempPath(String tempPath) { this.tempPath = tempPath; }
public int getRetainDays() { return retainDays; }
public void setRetainDays(int retainDays) { this.retainDays = retainDays; }
public String getTxtEncoding() { return txtEncoding; }
public void setTxtEncoding(String txtEncoding) { this.txtEncoding = txtEncoding; }
public String getFieldSeparator() { return fieldSeparator; }
public void setFieldSeparator(String fieldSeparator) { this.fieldSeparator = fieldSeparator; }
public String getOssBaseUrl() { return ossBaseUrl; }
public void setOssBaseUrl(String ossBaseUrl) { this.ossBaseUrl = ossBaseUrl; }
public List<FtpCityConfig> getCities() { return cities; }
public void setCities(List<FtpCityConfig> cities) { this.cities = cities; }
/**
* 单个地市的FTP配置
*/
public static class FtpCityConfig {
/** 地市编码(与mid_voice_device_config.city_code对应) */
private String cityCode;
/** 地市名称 */
private String cityName;
/** FTP服务器IP */
private String ftpHost;
/** FTP服务器端口 */
private int ftpPort = 21;
/** FTP用户名 */
private String ftpUsername;
/** FTP密码 */
private String ftpPassword;
/** 录音数据源目录(txt文件目录) */
private String ftpSourceDir;
/** 录音文件存储目录(mp3/wav文件目录) */
private String ftpRecordDir;
/** 归档目录(处理完的txt移到这里) */
private String ftpArchiveDir;
/**
* 电话区号前缀处理主被叫号码时去除
* - 配正确区号如合肥 0551号码以 0551 开头则去除
* - 配不存在的区号 "0000"号码匹配不上保留原始号码
* - 留空保留原始号码
*/
private String phoneAreaCode;
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 getFtpHost() { return ftpHost; }
public void setFtpHost(String ftpHost) { this.ftpHost = ftpHost; }
public int getFtpPort() { return ftpPort; }
public void setFtpPort(int ftpPort) { this.ftpPort = ftpPort; }
public String getFtpUsername() { return ftpUsername; }
public void setFtpUsername(String ftpUsername) { this.ftpUsername = ftpUsername; }
public String getFtpPassword() { return ftpPassword; }
public void setFtpPassword(String ftpPassword) { this.ftpPassword = ftpPassword; }
public String getFtpSourceDir() { return ftpSourceDir; }
public void setFtpSourceDir(String ftpSourceDir) { this.ftpSourceDir = ftpSourceDir; }
public String getFtpRecordDir() { return ftpRecordDir; }
public void setFtpRecordDir(String ftpRecordDir) { this.ftpRecordDir = ftpRecordDir; }
public String getFtpArchiveDir() { return ftpArchiveDir; }
public void setFtpArchiveDir(String ftpArchiveDir) { this.ftpArchiveDir = ftpArchiveDir; }
public String getPhoneAreaCode() { return phoneAreaCode; }
public void setPhoneAreaCode(String phoneAreaCode) { this.phoneAreaCode = phoneAreaCode; }
}
}

13
src/main/java/com/threecloud/dataserviceyy/config/SyncTargetConfig.java

@ -1,13 +0,0 @@
package com.threecloud.dataserviceyy.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "server.target")
public class SyncTargetConfig {
private String syncLogCode;
private String syncLogTable;
}

270
src/main/java/com/threecloud/dataserviceyy/controller/VoiceBoxController.java

@ -1,270 +0,0 @@
package com.threecloud.dataserviceyy.controller;
import com.threecloud.dataserviceyy.util.FileUploadUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class VoiceBoxController {
private static final Logger logger = LoggerFactory.getLogger(VoiceBoxController.class);
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
@Autowired
private FileUploadUtil fileUploadUtil;
/**
* 事件上传接口 - GET方式
* 语音盒来电/去电时主动推送事件信息
*
* 参数说明来自VAA录音仪开发文档
* - event_type: 事件类型 (3=状态提机, 4=提机, 6=开始去电录音, 7=来电接听开始录音,
* 8=去电完成挂机, 9=新的去电录音产生, 10=新的来电录音产生, 11=未接来电, 14=发送来电号码)
* - line: 设备端口
* - device_id: 设备编号
* - duration: 录音时长()
* - TimeLong: 通话时长()
* - date: 来电日期(格式: 2022-04-21 15:48:40)
* - caller: 来电号码(9,10,14事件中有值)
* - FilePath: 录音文件名称(9,10事件中有值)
* - voltage: 电压
* - RingCnt: 振铃次数
* - TotalStore: 设备存储容量
* - TotalFreeStore: 设备剩余存储容量
* - TotalMem: 内存使用率
* - CPU: CPU使用率
* - calloutId: websocket回传的calloutId
*
* 返回值成功返回 "0000"否则录音仪客户端日志会显示失败
*/
@GetMapping("/event")
public String handleEvent(
@RequestParam(required = false) String event_type,
@RequestParam(required = false) String line,
@RequestParam(required = false) String device_id,
@RequestParam(required = false) String duration,
@RequestParam(required = false) String TimeLong,
@RequestParam(required = false) String date,
@RequestParam(required = false) String caller,
@RequestParam(required = false) String FilePath,
@RequestParam(required = false) String voltage,
@RequestParam(required = false) String RingCnt,
@RequestParam(required = false) String TotalStore,
@RequestParam(required = false) String TotalFreeStore,
@RequestParam(required = false) String TotalMem,
@RequestParam(required = false) String CPU,
@RequestParam(required = false) String calloutId) {
try {
logger.info("========================================");
logger.info("[事件上传] 收到语音盒事件推送");
logger.info("[事件类型] event_type={} ({})", event_type, getEventTypeDesc(event_type));
logger.info("[设备信息] device_id={}, line={}", device_id, line);
if (date != null) {
logger.info("[通话时间] date={}", date);
}
if (caller != null && !caller.isEmpty()) {
logger.info("[来电号码] caller={}", caller);
}
if (FilePath != null && !FilePath.isEmpty()) {
logger.info("[文件路径] FilePath={}", FilePath);
}
if (duration != null) {
logger.info("[录音时长] duration={}秒", duration);
}
if (TimeLong != null) {
logger.info("[通话时长] TimeLong={}秒", TimeLong);
}
Map<String, String> eventData = new HashMap<>();
eventData.put("event_type", event_type);
eventData.put("line", line);
eventData.put("device_id", device_id);
eventData.put("date", date);
eventData.put("caller", caller);
eventData.put("FilePath", FilePath);
saveEventToDatabase(eventData);
logger.info("[事件上传] 处理完成");
logger.info("========================================");
return "0000";
} catch (Exception e) {
logger.error("[事件上传] 处理异常: {}", e.getMessage(), e);
return "0000";
}
}
/**
* 文件上传接口 - POST方式
* 语音盒主动上传录音文件先保存到本地临时目录再由定时任务上传到OSS
*
* 参数说明来自VAA录音仪开发文档
* - files: 文件上传信息(文件名称与事件中的FilePath字段对应)
* 文件名称格式: 设备名称-年月日时分秒-(O|I)-L(端口号)-EN-电话号码.wav
* O=去电, I=来电
* - event_type: 事件类型(9=去电录音, 10=来电录音)
* - device_id: 设备编号
* - line: 端口编号
* - date: 来电日期
* - caller: 来电号码
* - FilePath: 录音文件名称
* - duration: 录音时长()
* - TimeLong: 通话时长()
*
* 返回值成功返回 "0000"否则录音仪客户端日志会显示失败
*/
@PostMapping("/file")
public String handleFileUpload(
@RequestParam("files") MultipartFile file,
@RequestParam(required = false) String event_type,
@RequestParam(required = false) String device_id,
@RequestParam(required = false) String line,
@RequestParam(required = false) String date,
@RequestParam(required = false) String caller,
@RequestParam(required = false) String FilePath,
@RequestParam(required = false) String duration,
@RequestParam(required = false) String TimeLong) {
try {
logger.info("========================================");
logger.info("[文件上传] 收到语音盒文件上传请求");
logger.info("[事件类型] event_type={} ({})", event_type, getEventTypeDesc(event_type));
logger.info("[设备信息] device_id={}, line={}", device_id, line);
if (file != null && !file.isEmpty()) {
String originalFilename = file.getOriginalFilename();
long fileSize = file.getSize();
logger.info("[文件信息] fileName={}, size={} bytes", originalFilename, fileSize);
}
if (FilePath != null && !FilePath.isEmpty()) {
logger.info("[文件路径] FilePath={}", FilePath);
}
if (caller != null && !caller.isEmpty()) {
logger.info("[来电号码] caller={}", caller);
}
if (file != null && !file.isEmpty()) {
String fileName = file.getOriginalFilename();
String tempDir = "/tmp/voice_upload/";
File tempDirFile = new File(tempDir);
if (!tempDirFile.exists()) {
tempDirFile.mkdirs();
}
String tempFilePath = tempDir + fileName;
file.transferTo(new File(tempFilePath));
logger.info("[保存本地] 文件已保存到临时目录: {}", tempFilePath);
saveRecordToDatabase(device_id, line, date, caller, fileName, duration, TimeLong, tempFilePath, "0");
}
logger.info("[文件上传] 处理完成");
logger.info("========================================");
return "0000";
} catch (IOException e) {
logger.error("[文件上传] 文件保存异常: {}", e.getMessage(), e);
return "0000";
} catch (Exception e) {
logger.error("[文件上传] 处理异常: {}", e.getMessage(), e);
return "0000";
}
}
/**
* 获取事件类型描述
*/
private String getEventTypeDesc(String eventType) {
if (eventType == null) return "未知";
switch (eventType) {
case "3": return "状态提机(ON HOOK)";
case "4": return "提机(OFF HOOK)";
case "6": return "开始去电录音";
case "7": return "来电接听开始录音";
case "8": return "去电完成挂机(ON HOOK)";
case "9": return "新的去电录音产生";
case "10": return "新的来电录音产生";
case "11": return "未接来电";
case "14": return "发送来电号码";
default: return "其他(" + eventType + ")";
}
}
/**
* 从文件名提取设备名称
* 文件格式: 设备名称-年月日时分秒-(O|I)-L(端口号)-EN-电话号码.wav
*/
private String extractDeviceName(String fileName) {
if (fileName == null || !fileName.contains("-")) {
return "unknown";
}
return fileName.substring(0, fileName.indexOf("-"));
}
/**
* 从文件名提取日期
* 文件格式: 设备名称-年月日时分秒-(O|I)-L(端口号)-EN-电话号码.wav
*/
private String extractDateFromFileName(String fileName) {
if (fileName == null) {
return LocalDateTime.now().format(DATE_FORMAT);
}
String datePattern = "\\d{8}";
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(datePattern);
java.util.regex.Matcher matcher = pattern.matcher(fileName);
if (matcher.find()) {
return matcher.group();
}
return LocalDateTime.now().format(DATE_FORMAT);
}
/**
* 从文件名提取通话类型
* O=去电, I=来电
*/
private String extractCallType(String fileName) {
if (fileName == null) return "0";
if (fileName.contains("-O-")) return "1";
if (fileName.contains("-I-")) return "0";
return "0";
}
/**
* 保存事件到数据库示例方法
*/
private void saveEventToDatabase(Map<String, String> eventData) {
logger.debug("[事件存储] device_id={}, event_type={}",
eventData.get("device_id"), eventData.get("event_type"));
}
/**
* 保存通话记录到数据库示例方法
*/
private void saveRecordToDatabase(String deviceId, String line, String date, String caller,
String fileName, String duration, String timeLong,
String ossUrl, String callType) {
logger.debug("[记录存储] device_id={}, fileName={}, ossUrl={}", deviceId, fileName, ossUrl);
}
}

157
src/main/java/com/threecloud/dataserviceyy/entity/CallRecord.java

@ -1,157 +0,0 @@
package com.threecloud.dataserviceyy.entity;
/**
* 通话记录实体类
*
* 封装从FTP数据文件中解析出的单条通话记录信息
* 数据来源FTP服务器上的.txt文件字段间用"※"分隔
*
* 字段说明
* - kssj: 开始时间 (格式: yyyy-MM-dd HH:mm:ss)
* - jssj: 结束时间 (格式: yyyy-MM-dd HH:mm:ss)
* - hjls: 呼机流水号
* - hjzls: 呼机总流水号
* - zjhm: 主叫号码
* - bjhm: 被叫号码
* - thsc: 通话时长()
* - thfx: 通话方向(1=主叫方向, 0=被叫方向)
* - dateDir: 录音文件所在日期目录(yyyyMMdd)
* - wavFileName: 对应的WAV录音文件名(格式: 呼机流水号_呼机总流水号.wav)
*/
public class CallRecord {
/** 开始时间 */
private String kssj;
/** 结束时间 */
private String jssj;
/** 呼机流水号 */
private String hjls;
/** 呼机总流水号 */
private String hjzls;
/** 主叫号码(可能包含区号前缀如0553) */
private String zjhm;
/** 被叫号码(可能包含区号前缀如0553) */
private String bjhm;
/** 通话时长(秒) */
private Long thsc;
/** 通话方向(1=主叫, 0=被叫) */
private String thfx;
/** 日期目录(yyyyMMdd格式,从kssj中提取) */
private String dateDir;
/** WAV录音文件名(格式: 呼机流水号_呼机总流水号.wav) */
private String wavFileName;
public CallRecord() {
}
/**
* 获取通话记录摘要信息
* 用于日志输出显示主被叫号码
* @return 格式化的摘要字符串主叫=13800138000, 被叫=13900139000
*/
public String getSummary() {
return "主叫=" + zjhm + ", 被叫=" + bjhm;
}
public String getKssj() {
return kssj;
}
public void setKssj(String kssj) {
this.kssj = kssj;
}
public String getJssj() {
return jssj;
}
public void setJssj(String jssj) {
this.jssj = jssj;
}
public String getHjls() {
return hjls;
}
public void setHjls(String hjls) {
this.hjls = hjls;
}
public String getHjzls() {
return hjzls;
}
public void setHjzls(String hjzls) {
this.hjzls = hjzls;
}
public String getZjhm() {
return zjhm;
}
public void setZjhm(String zjhm) {
this.zjhm = zjhm;
}
public String getBjhm() {
return bjhm;
}
public void setBjhm(String bjhm) {
this.bjhm = bjhm;
}
public Long getThsc() {
return thsc;
}
public void setThsc(Long thsc) {
this.thsc = thsc;
}
public String getThfx() {
return thfx;
}
public void setThfx(String thfx) {
this.thfx = thfx;
}
public String getDateDir() {
return dateDir;
}
public void setDateDir(String dateDir) {
this.dateDir = dateDir;
}
public String getWavFileName() {
return wavFileName;
}
public void setWavFileName(String wavFileName) {
this.wavFileName = wavFileName;
}
@Override
public String toString() {
return "CallRecord{" +
"kssj='" + kssj + '\'' +
", jssj='" + jssj + '\'' +
", zjhm='" + zjhm + '\'' +
", bjhm='" + bjhm + '\'' +
", thsc=" + thsc +
", thfx='" + thfx + '\'' +
", wavFileName='" + wavFileName + '\'' +
'}';
}
}

93
src/main/java/com/threecloud/dataserviceyy/entity/DeviceMatchResult.java

@ -1,93 +0,0 @@
package com.threecloud.dataserviceyy.entity;
import java.util.Map;
/**
* 设备匹配结果实体类
*
* 封装通话记录与设备通道表匹配后的结果信息
* 匹配流程
* 1. 根据电话号码在设备通道表(YYDC_SBTD)中查找对应设备
* 2. 根据设备的UUID在语音盒表(YYDC_YYSB)中查找所属地市信息
* 3. 返回完整的匹配结果
*/
public class DeviceMatchResult {
/** 是否匹配成功 */
private boolean success;
/** 设备通道信息(ID, UUID, PHONE, THFX等字段) */
private Map<String, Object> deviceChannel;
/** 语音盒信息(ID, ORGAN_NAME, ORGAN_ID等字段) */
private Map<String, Object> voiceBox;
/** 匹配时使用的电话号码(可能是主叫或被叫) */
private String phone;
/** 失败时的错误描述信息 */
private String errorMessage;
public DeviceMatchResult() {
}
/**
* 创建匹配成功的结果对象
* @param deviceChannel 设备通道表中的记录信息
* @param voiceBox 语音盒表中的记录信息
* @param phone 匹配到的有效电话号码
* @return 成功状态的DeviceMatchResult实例
*/
public static DeviceMatchResult success(Map<String, Object> deviceChannel,
Map<String, Object> voiceBox,
String phone) {
DeviceMatchResult result = new DeviceMatchResult();
result.success = true;
result.deviceChannel = deviceChannel;
result.voiceBox = voiceBox;
result.phone = phone;
return result;
}
/**
* 创建匹配失败的结果对象
* @param errorMessage 失败原因描述
* @return 失败状态的DeviceMatchResult实例
*/
public static DeviceMatchResult fail(String errorMessage) {
DeviceMatchResult result = new DeviceMatchResult();
result.success = false;
result.errorMessage = errorMessage;
return result;
}
public boolean isSuccess() {
return success;
}
public Map<String, Object> getDeviceChannel() {
return deviceChannel;
}
public Map<String, Object> getVoiceBox() {
return voiceBox;
}
public String getPhone() {
return phone;
}
public String getErrorMessage() {
return errorMessage;
}
@Override
public String toString() {
if (success) {
return "DeviceMatchResult{success=true, phone=" + phone +
", organName=" + (voiceBox != null ? voiceBox.get("ORGAN_NAME") : "null") + "}";
} else {
return "DeviceMatchResult{success=false, error='" + errorMessage + "'}";
}
}
}

84
src/main/java/com/threecloud/dataserviceyy/entity/FtpProcessedFile.java

@ -0,0 +1,84 @@
package com.threecloud.dataserviceyy.entity;
import java.util.Date;
/**
* FTP已处理文件记录表
* 对应 mid_voice.mid_ftp_processed_file
* 用于记录已处理过的FTP txt文件避免重复处理
*/
public class FtpProcessedFile {
/** 自增ID主键 */
private Long id;
/** 地市编码 */
private String cityCode;
/** 地市名称 */
private String cityName;
/** FTP文件名 */
private String fileName;
/** 文件大小(字节) */
private Long fileSize;
/** 处理状态:0失败,1成功 */
private String processStatus;
/** 记录总数 */
private Integer recordCount;
/** 成功入库数 */
private Integer successCount;
/** 失败数 */
private Integer failCount;
/** 处理耗时(毫秒) */
private Long processTime;
/** 错误信息 */
private String errorMsg;
/** 创建时间 */
private Date createTime;
// 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 getFileName() { return fileName; }
public void setFileName(String fileName) { this.fileName = fileName; }
public Long getFileSize() { return fileSize; }
public void setFileSize(Long fileSize) { this.fileSize = fileSize; }
public String getProcessStatus() { return processStatus; }
public void setProcessStatus(String processStatus) { this.processStatus = processStatus; }
public Integer getRecordCount() { return recordCount; }
public void setRecordCount(Integer recordCount) { this.recordCount = recordCount; }
public Integer getSuccessCount() { return successCount; }
public void setSuccessCount(Integer successCount) { this.successCount = successCount; }
public Integer getFailCount() { return failCount; }
public void setFailCount(Integer failCount) { this.failCount = failCount; }
public Long getProcessTime() { return processTime; }
public void setProcessTime(Long processTime) { this.processTime = processTime; }
public String getErrorMsg() { return errorMsg; }
public void setErrorMsg(String errorMsg) { this.errorMsg = errorMsg; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}

79
src/main/java/com/threecloud/dataserviceyy/entity/MidVoiceDeviceLog.java

@ -1,79 +0,0 @@
package com.threecloud.dataserviceyy.entity;
import java.util.Date;
/**
* 设备连接日志表
* 对应 mid_voice.mid_voice_device_log
*
* 用于记录设备连接失败信息便于排查问题
*/
public class MidVoiceDeviceLog {
/** 自增ID主键 */
private Long id;
/** 设备ID */
private String deviceId;
/** 设备编号 */
private String deviceNo;
/** 地市编码 */
private String cityCode;
/** 地市名称 */
private String cityName;
/** IP地址 */
private String ipAddress;
/** 端口号 */
private Integer devicePort;
/** 连接类型:1登录,2查询录音列表,3下载文件 */
private String connectType;
/** 连接状态:0失败,1成功 */
private String connectStatus;
/** 失败原因 */
private String failReason;
/** 创建时间 */
private Date createTime;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getDeviceId() { return deviceId; }
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
public String getDeviceNo() { return deviceNo; }
public void setDeviceNo(String deviceNo) { this.deviceNo = deviceNo; }
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 getIpAddress() { return ipAddress; }
public void setIpAddress(String ipAddress) { this.ipAddress = ipAddress; }
public Integer getDevicePort() { return devicePort; }
public void setDevicePort(Integer devicePort) { this.devicePort = devicePort; }
public String getConnectType() { return connectType; }
public void setConnectType(String connectType) { this.connectType = connectType; }
public String getConnectStatus() { return connectStatus; }
public void setConnectStatus(String connectStatus) { this.connectStatus = connectStatus; }
public String getFailReason() { return failReason; }
public void setFailReason(String failReason) { this.failReason = failReason; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}

77
src/main/java/com/threecloud/dataserviceyy/entity/SyncLog.java

@ -1,77 +0,0 @@
package com.threecloud.dataserviceyy.entity;
import java.util.Date;
/**
* 同步日志表
* 记录每次同步任务的执行情况
*/
public class SyncLog {
/** 主键ID */
private Long id;
/** 设备ID */
private String deviceId;
/** 设备名称 */
private String deviceName;
/** 同步开始时间 */
private Date startTime;
/** 同步结束时间 */
private Date endTime;
/** 查询到的记录数 */
private Integer totalCount;
/** 成功处理数 */
private Integer successCount;
/** 失败数 */
private Integer failCount;
/** 同步状态:0失败,1成功,2部分成功 */
private Integer status;
/** 错误信息 */
private String errorMsg;
/** 创建时间 */
private Date createTime;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getDeviceId() { return deviceId; }
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
public String getDeviceName() { return deviceName; }
public void setDeviceName(String deviceName) { this.deviceName = deviceName; }
public Date getStartTime() { return startTime; }
public void setStartTime(Date startTime) { this.startTime = startTime; }
public Date getEndTime() { return endTime; }
public void setEndTime(Date endTime) { this.endTime = endTime; }
public Integer getTotalCount() { return totalCount; }
public void setTotalCount(Integer totalCount) { this.totalCount = totalCount; }
public Integer getSuccessCount() { return successCount; }
public void setSuccessCount(Integer successCount) { this.successCount = successCount; }
public Integer getFailCount() { return failCount; }
public void setFailCount(Integer failCount) { this.failCount = failCount; }
public Integer getStatus() { return status; }
public void setStatus(Integer status) { this.status = status; }
public String getErrorMsg() { return errorMsg; }
public void setErrorMsg(String errorMsg) { this.errorMsg = errorMsg; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}

125
src/main/java/com/threecloud/dataserviceyy/entity/VoiceRecord.java

@ -1,125 +0,0 @@
package com.threecloud.dataserviceyy.entity;
import java.util.Date;
/**
* 录音记录表
* 保存每次从录音盒下载并上传到OSS的录音文件信息
*/
public class VoiceRecord {
/** 主键ID */
private Long id;
/** 设备ID */
private String deviceId;
/** 设备UUID */
private String deviceUuid;
/** 设备名称/机构名称 */
private String deviceName;
/** 录音盒原始记录ID */
private String recordId;
/** 录音文件名 */
private String fileName;
/** 录音文件在录音盒中的路径 */
private String filePath;
/** OSS访问地址 */
private String ossUrl;
/** 本地存储路径 */
private String localPath;
/** 通话开始时间 */
private Date callStartTime;
/** 通话结束时间 */
private Date callEndTime;
/** 通话时长(秒) */
private Integer duration;
/** 通道号 1-8 */
private Integer channel;
/** 对方电话号码 */
private String phone;
/** 通话方向:1呼出,2呼入 */
private Integer direction;
/** 上传状态:0失败,1成功 */
private Integer status;
/** 失败原因 */
private String failReason;
/** 创建时间 */
private Date createTime;
/** 更新时间 */
private Date updateTime;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getDeviceId() { return deviceId; }
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
public String getDeviceUuid() { return deviceUuid; }
public void setDeviceUuid(String deviceUuid) { this.deviceUuid = deviceUuid; }
public String getDeviceName() { return deviceName; }
public void setDeviceName(String deviceName) { this.deviceName = deviceName; }
public String getRecordId() { return recordId; }
public void setRecordId(String recordId) { this.recordId = recordId; }
public String getFileName() { return fileName; }
public void setFileName(String fileName) { this.fileName = fileName; }
public String getFilePath() { return filePath; }
public void setFilePath(String filePath) { this.filePath = filePath; }
public String getOssUrl() { return ossUrl; }
public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; }
public String getLocalPath() { return localPath; }
public void setLocalPath(String localPath) { this.localPath = localPath; }
public Date getCallStartTime() { return callStartTime; }
public void setCallStartTime(Date callStartTime) { this.callStartTime = callStartTime; }
public Date getCallEndTime() { return callEndTime; }
public void setCallEndTime(Date callEndTime) { this.callEndTime = callEndTime; }
public Integer getDuration() { return duration; }
public void setDuration(Integer duration) { this.duration = duration; }
public Integer getChannel() { return channel; }
public void setChannel(Integer channel) { this.channel = channel; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public Integer getDirection() { return direction; }
public void setDirection(Integer direction) { this.direction = direction; }
public Integer getStatus() { return status; }
public void setStatus(Integer status) { this.status = status; }
public String getFailReason() { return failReason; }
public void setFailReason(String failReason) { this.failReason = failReason; }
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; }
}

22
src/main/java/com/threecloud/dataserviceyy/enums/SyncStatus.java

@ -1,22 +0,0 @@
package com.threecloud.dataserviceyy.enums;
public enum SyncStatus {
ERR("0", "失败"),
SUC("1", "成功");
private final String code;
private final String name;
SyncStatus(String code, String name) {
this.code = code;
this.name = name;
}
public String getCode() {
return code;
}
public String getName() {
return name;
}
}

48
src/main/java/com/threecloud/dataserviceyy/mapper/FtpProcessedFileMapper.java

@ -0,0 +1,48 @@
package com.threecloud.dataserviceyy.mapper;
import com.threecloud.dataserviceyy.entity.FtpProcessedFile;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* FTP已处理文件记录Mapper
* 用于记录已处理过的FTP txt文件避免重复处理
*/
@Mapper
public interface FtpProcessedFileMapper {
/**
* 插入处理记录
*/
int insert(FtpProcessedFile record);
/**
* 根据地市编码和文件名查询判断是否已处理
*/
FtpProcessedFile selectByCityAndFileName(@Param("cityCode") String cityCode,
@Param("fileName") String fileName);
/**
* 查询指定地市下所有已处理的文件名列表
*/
List<String> selectProcessedFileNamesByCity(@Param("cityCode") String cityCode);
/**
* 批量查询指定地市下多个文件的处理状态
*/
List<FtpProcessedFile> selectByCityAndFileNames(@Param("cityCode") String cityCode,
@Param("fileNames") List<String> fileNames);
/**
* 根据ID更新处理结果
*/
int updateById(FtpProcessedFile record);
/**
* 删除指定时间之前的记录清理历史
*/
int deleteBeforeTime(@Param("cityCode") String cityCode,
@Param("beforeTime") String beforeTime);
}

6
src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceCallRecordMapper.java

@ -34,6 +34,12 @@ public interface MidVoiceCallRecordMapper {
List<String> selectExistingCallRecordIds(@Param("deviceNo") String deviceNo,
@Param("startTime") String startTime,
@Param("endTime") String endTime);
/**
* 按前缀查询call_record_idFTP防重专用
* 例如prefix="340100_FTP_"返回所有以该前缀开头的记录ID
*/
List<String> selectCallRecordIdsByPrefix(@Param("prefix") String prefix);
/**
* 根据设备编码查询

47
src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceDeviceLogMapper.java

@ -1,47 +0,0 @@
package com.threecloud.dataserviceyy.mapper;
import com.threecloud.dataserviceyy.entity.MidVoiceDeviceLog;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 设备连接日志 Mapper
*/
@Mapper
public interface MidVoiceDeviceLogMapper {
/**
* 插入设备连接日志
*/
@Insert("INSERT INTO mid_voice_device_log (" +
"device_id, device_no, city_code, city_name, ip_address, device_port, " +
"connect_type, connect_status, fail_reason, create_time" +
") VALUES (" +
"#{deviceId}, #{deviceNo}, #{cityCode}, #{cityName}, #{ipAddress}, #{devicePort}, " +
"#{connectType}, #{connectStatus}, #{failReason}, #{createTime}" +
")")
int insert(MidVoiceDeviceLog log);
/**
* 查询设备的最近连接日志
*/
@Select("SELECT * FROM mid_voice_device_log " +
"WHERE device_id = #{deviceId} " +
"ORDER BY create_time DESC " +
"LIMIT #{limit}")
List<MidVoiceDeviceLog> selectRecentByDeviceId(@Param("deviceId") String deviceId,
@Param("limit") int limit);
/**
* 查询失败的连接记录
*/
@Select("SELECT * FROM mid_voice_device_log " +
"WHERE connect_status = '0' " +
"AND create_time >= #{startTime} " +
"ORDER BY create_time DESC")
List<MidVoiceDeviceLog> selectFailedLogs(@Param("startTime") String startTime);
}

55
src/main/java/com/threecloud/dataserviceyy/mapper/SyncLogMapper.java

@ -1,55 +0,0 @@
package com.threecloud.dataserviceyy.mapper;
import com.threecloud.dataserviceyy.entity.SyncLog;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 同步日志Mapper
*/
@Mapper
public interface SyncLogMapper {
/**
* 插入同步日志
*/
int insert(SyncLog log);
/**
* 根据ID查询
*/
SyncLog selectById(Long id);
/**
* 查询设备的同步日志
*/
List<SyncLog> selectByDeviceId(@Param("deviceId") String deviceId);
/**
* 分页查询同步日志
*/
List<SyncLog> selectList(@Param("deviceId") String deviceId,
@Param("startTime") String startTime,
@Param("endTime") String endTime,
@Param("offset") Integer offset,
@Param("limit") Integer limit);
/**
* 统计总数
*/
int count(@Param("deviceId") String deviceId,
@Param("startTime") String startTime,
@Param("endTime") String endTime);
/**
* 更新同步结果
*/
int updateResult(@Param("id") Long id,
@Param("endTime") java.util.Date endTime,
@Param("successCount") Integer successCount,
@Param("failCount") Integer failCount,
@Param("status") Integer status,
@Param("errorMsg") String errorMsg);
}

67
src/main/java/com/threecloud/dataserviceyy/mapper/VoiceRecordMapper.java

@ -1,67 +0,0 @@
package com.threecloud.dataserviceyy.mapper;
import com.threecloud.dataserviceyy.entity.VoiceRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 录音记录Mapper
*/
@Mapper
public interface VoiceRecordMapper {
/**
* 插入录音记录
*/
int insert(VoiceRecord record);
/**
* 根据ID查询
*/
VoiceRecord selectById(Long id);
/**
* 根据设备ID和录音ID查询防止重复插入
*/
VoiceRecord selectByDeviceAndRecordId(@Param("deviceId") String deviceId,
@Param("recordId") String recordId);
/**
* 查询设备的所有录音记录
*/
List<VoiceRecord> selectByDeviceId(@Param("deviceId") String deviceId);
/**
* 分页查询录音记录
*/
List<VoiceRecord> selectList(@Param("deviceId") String deviceId,
@Param("deviceName") String deviceName,
@Param("phone") String phone,
@Param("startTime") String startTime,
@Param("endTime") String endTime,
@Param("offset") Integer offset,
@Param("limit") Integer limit);
/**
* 统计总数
*/
int count(@Param("deviceId") String deviceId,
@Param("deviceName") String deviceName,
@Param("phone") String phone,
@Param("startTime") String startTime,
@Param("endTime") String endTime);
/**
* 更新OSS地址如果上传后需要更新
*/
int updateOssUrl(@Param("id") Long id, @Param("ossUrl") String ossUrl);
/**
* 更新状态
*/
int updateStatus(@Param("id") Long id,
@Param("status") Integer status,
@Param("failReason") String failReason);
}

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

@ -1,19 +0,0 @@
package com.threecloud.dataserviceyy.mapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;
/**
* 语音设备 Mapper
* 查询 mid_voice_device_config
*/
@Mapper
public interface VoiceSyncMapper {
/**
* 查询所有在线语音设备含每个设备的账号密码
*/
List<Map<String, Object>> getAllYysb();
}

512
src/main/java/com/threecloud/dataserviceyy/service/FtpSyncService.java

@ -0,0 +1,512 @@
package com.threecloud.dataserviceyy.service;
import com.threecloud.dataserviceyy.config.FtpSyncProperties;
import com.threecloud.dataserviceyy.config.FtpSyncProperties.FtpCityConfig;
import com.threecloud.dataserviceyy.entity.FtpProcessedFile;
import com.threecloud.dataserviceyy.entity.MidVoiceCallRecord;
import com.threecloud.dataserviceyy.mapper.FtpProcessedFileMapper;
import com.threecloud.dataserviceyy.mapper.MidVoiceCallRecordMapper;
import com.threecloud.dataserviceyy.util.DateUtil;
import com.threecloud.dataserviceyy.util.FileCleaner;
import com.threecloud.dataserviceyy.util.FilePathUtil;
import com.threecloud.dataserviceyy.util.FileUploadUtil;
import com.threecloud.dataserviceyy.util.FtpUtil;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPFile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.InputStreamReader;
import java.util.*;
/**
* FTP 录音同步服务参考淮南 yydc-tb-server MineService 重写
*
* 功能从FTP读取录音数据txt 解析 下载录音文件 上传OSS 入库
* 策略增量同步无重试数据库批量防重txt处理完归档
* 部署每个地市单独部署一份每个服务只处理一个地市的FTP
*
* txt文件格式GBK编码分隔
* kssjjssjhjlshjzlszjhmbjhmthsc...thfx
*
* kssj : 开始时间 yyyyMMddHHmmss
* jssj : 结束时间 yyyyMMddHHmmss
* hjls : 呼叫流水录音文件主名
* hjzls : 呼叫主/被叫流水
* zjhm : 主叫号码
* bjhm : 被叫号码
* thsc : 通话时长
* thfx : 通话方向 1=呼入 0=呼出
*
* 录音文件命名{hjls}_{hjzls}.mp3
* 录音文件路径{ftpRecordDir}/{yyyyMMdd}/{hjls}_{hjzls}.mp3
*/
@Service
public class FtpSyncService {
private static final Logger logger = LoggerFactory.getLogger(FtpSyncService.class);
/** 通话状态:1=正常接通 */
private static final String CALL_STATUS_OK = "1";
/** 通话方向:1=呼入,2=呼出(数据库规范) */
private static final String DIR_INCOMING = "1";
private static final String DIR_OUTGOING = "2";
/** 字段数最小阈值 */
private static final int MIN_FIELD_COUNT = 10;
@Autowired
private FtpSyncProperties properties;
@Autowired
private MidVoiceCallRecordMapper callRecordMapper;
@Autowired
private FtpProcessedFileMapper processedFileMapper;
@Autowired
private FileUploadUtil fileUploadUtil;
/**
* 定时同步任务
*/
@Scheduled(cron = "${ftp-sync.sync-interval-cron:0 0 0/2 * * ?}")
public void scheduledSync() {
logger.info("【FTP定时】========== FTP录音同步开始 ==========");
long startTime = System.currentTimeMillis();
try {
executeSync();
} catch (Exception e) {
logger.error("【FTP定时】同步任务异常: {}", e.getMessage(), e);
}
long costTime = System.currentTimeMillis() - startTime;
logger.info("【FTP定时】========== FTP录音同步结束,耗时 {} 秒 ==========", costTime / 1000);
}
/**
* 执行同步主入口
*/
public void executeSync() {
// 清理过期临时文件
FileCleaner.clean(properties.getTempPath(), properties.getRetainDays());
List<FtpCityConfig> cities = properties.getCities();
if (cities == null || cities.isEmpty()) {
logger.warn("【FTP主流程】未配置任何地市FTP,跳过同步");
return;
}
logger.info("【FTP主流程】配置了 {} 个地市", cities.size());
for (FtpCityConfig city : cities) {
try {
syncCity(city);
} catch (Exception e) {
logger.error("【FTP主流程】地市 {} 同步失败: {}", city.getCityName(), e.getMessage(), e);
}
}
}
/**
* 同步单个地市
*/
private void syncCity(FtpCityConfig city) throws Exception {
logger.info("【FTP地市】═══════════════════════════════════════");
logger.info("【FTP地市】开始同步: {}({}), FTP={}:{}",
city.getCityName(), city.getCityCode(), city.getFtpHost(), city.getFtpPort());
// 参数校验
if (!StringUtils.hasText(city.getFtpHost()) || !StringUtils.hasText(city.getFtpUsername())) {
logger.warn("【FTP地市】{} FTP未配置完整,跳过", city.getCityName());
return;
}
// 批量查询数据库已存在的call_record_id(防重)
String prefix = city.getCityCode() + "_FTP_";
Set<String> existingIds = loadExistingCallRecordIds(prefix);
FTPClient ftp = null;
try {
ftp = FtpUtil.connect(city.getFtpHost(), city.getFtpPort(),
city.getFtpUsername(), city.getFtpPassword());
logger.info("【FTP地市】FTP连接成功");
FTPFile[] files = FtpUtil.listFiles(ftp, city.getFtpSourceDir());
int txtCount = FtpUtil.countTxtFiles(files);
logger.info("【FTP地市】目录 {} 下有 {} 个txt文件", city.getFtpSourceDir(), txtCount);
// 批量查询已处理的文件名,避免重复处理
Set<String> processedFiles = loadProcessedFileNames(city.getCityCode());
logger.info("【FTP地市】已处理文件记录: {} 个", processedFiles.size());
int fileSuccess = 0;
int fileFail = 0;
int fileSkipped = 0;
for (FTPFile file : files) {
if (!FtpUtil.isTxtFile(file)) {
continue;
}
String fileName = file.getName();
// 检查是否已处理过
if (processedFiles.contains(fileName)) {
logger.debug("【FTP文件】已处理过,跳过: {}", fileName);
fileSkipped++;
continue;
}
try {
if (processTxtFile(ftp, file, city, existingIds)) {
fileSuccess++;
} else {
fileFail++;
}
} catch (Exception e) {
fileFail++;
logger.error("【FTP地市】处理文件失败: {}, 原因={}", fileName, e.getMessage());
}
}
logger.info("【FTP地市】{} 同步完成: 成功{}个文件, 跳过{}个(已处理), 失败{}个文件",
city.getCityName(), fileSuccess, fileSkipped, fileFail);
} finally {
FtpUtil.disconnect(ftp);
}
}
/**
* 处理单个txt文件
*
* @return true=成功false=失败
*/
private boolean processTxtFile(FTPClient ftp, FTPFile file, FtpCityConfig city,
Set<String> existingIds) throws Exception {
String fileName = file.getName();
String sourcePath = joinPath(city.getFtpSourceDir(), fileName);
long fileSize = file.getSize();
long fileStartTime = System.currentTimeMillis();
logger.info("【FTP文件】开始处理: {} ({} 字节)", fileName, fileSize);
byte[] txtBytes = FtpUtil.downloadFile(ftp, sourcePath);
int lineCount = 0;
int successCount = 0;
int skipCount = 0;
int failCount = 0;
String errorMsg = null;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(new ByteArrayInputStream(txtBytes), properties.getTxtEncoding()))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.indexOf(properties.getFieldSeparator()) <= 0) {
continue;
}
lineCount++;
try {
if (processLine(line, city, existingIds, ftp)) {
successCount++;
} else {
skipCount++;
}
} catch (Exception e) {
failCount++;
errorMsg = e.getMessage();
logger.error("【FTP文件】解析单行失败: {}, 原因={}", line, e.getMessage());
}
}
}
long processTime = System.currentTimeMillis() - fileStartTime;
logger.info("【FTP文件】{} 解析完成: 共{}行, 新增{}条, 跳过{}条, 失败{}条, 耗时{}ms",
fileName, lineCount, successCount, skipCount, failCount, processTime);
// 记录已处理的文件(避免下次重复处理)
recordProcessedFile(city, fileName, fileSize, lineCount, successCount, failCount, processTime, errorMsg);
// 注意:不进行归档操作,文件保留在原位置
// 如需归档,请取消下面代码的注释
// try {
// String archivePath = joinPath(city.getFtpArchiveDir(), fileName);
// FtpUtil.ensureDir(ftp, city.getFtpArchiveDir());
// FtpUtil.rename(ftp, sourcePath, archivePath);
// logger.info("【FTP文件】已归档: {} -> {}", sourcePath, archivePath);
// } catch (Exception e) {
// logger.error("【FTP文件】归档失败: {}, 原因={}", fileName, e.getMessage());
// }
return failCount == 0;
}
/**
* 处理单行记录
*
* @return true=成功入库false=跳过
*/
private boolean processLine(String line, FtpCityConfig city, Set<String> existingIds,
FTPClient ftp) throws Exception {
// 按分隔符拆分字段(老淮南FTP数据格式:※分隔)
String[] fields = line.split(properties.getFieldSeparator(), -1);
if (fields.length < MIN_FIELD_COUNT) {
logger.debug("【FTP行】字段数不足({}<{}),跳过: {}", fields.length, MIN_FIELD_COUNT, line);
return false;
}
// ========== 字段解析(老淮南FTP数据格式)==========
String kssj = fields[0].trim(); // kssj : 开始时间 yyyyMMddHHmmss(如:20240101120000)
String jssj = fields[1].trim(); // jssj : 结束时间 yyyyMMddHHmmss
String hjls = fields[2].trim(); // hjls : 呼叫流水号(用于拼录音文件名主名)
String hjzls = fields[3].trim(); // hjzls : 呼叫主/被叫流水号(用于拼录音文件名辅名)
String zjhm = fields[4].trim(); // zjhm : 主叫号码(包含区号,如:055312345678)
String bjhm = fields[5].trim(); // bjhm : 被叫号码
String thscStr = fields[6].trim();// thsc : 通话时长(秒)
String thfx = fields[9].trim(); // thfx : 通话方向(1=呼入,0=呼出 - 老系统特殊编码)
// 必填字段校验
if (kssj.isEmpty() || jssj.isEmpty() || hjls.isEmpty() || hjzls.isEmpty()) {
return false;
}
// 时间字符串转 Date 对象
Date callStartTime = DateUtil.parseDate14(kssj);
Date callEndTime = DateUtil.parseDate14(jssj);
if (callStartTime == null) {
return false;
}
int thsc = parseInt(thscStr, 0);
// thfx 转换:老系统"0"=呼出,其他(含1)=呼入
// 数据库规范:1=呼入,2=呼出(所以 isOutgoing=true 时存"2")
boolean isOutgoing = thfx.contains("0");
// 录音文件名 = hjls_hjzls.mp3(如:12345_67890.mp3)
String recordFileName = hjls + "_" + hjzls + ".mp3";
// 唯一标识 = 地市编码_FTP_呼叫流水(防重键)
String callRecordId = city.getCityCode() + "_FTP_" + hjls;
// 防重复检查(内存Set比对,已存在则跳过)
if (existingIds.contains(callRecordId)) {
logger.debug("【FTP行】已存在,跳过: {}", callRecordId);
return false;
}
// 主叫/被叫处理:去除区号前缀
// 配置不存在的区号(如"0000"),因为 startsWith 匹配不上,会保留原始号码
// 后续如需去除区号,把配置改为正确的区号即可,无需重新发包
zjhm = stripAreaCode(zjhm, city.getPhoneAreaCode());
bjhm = stripAreaCode(bjhm, city.getPhoneAreaCode());
// 构建本地临时路径和OSS路径
// 本地路径示例:./vaa-ftp-temp/340100/20240101/ftp/12345_67890.mp3
// OSS路径示例:voice/340100/20240101/12345_67890.mp3
String dateStr = DateUtil.formatDate(callStartTime, "yyyyMMdd");
String ossPath = FilePathUtil.buildOssPath(city.getCityCode(), callStartTime, recordFileName);
// 下载录音文件(FTP路径:{ftp-record-dir}/{yyyyMMdd}/{filename})
// 先切换到录音文件根目录,避免工作目录在别处导致下载失败
String recordDir = city.getFtpRecordDir();
// 去掉末尾斜杠,避免双斜杠
if (recordDir != null && recordDir.endsWith("/")) {
recordDir = recordDir.substring(0, recordDir.length() - 1);
}
logger.info("【FTP行】准备切换目录: {}", recordDir);
FtpUtil.changeDir(ftp, recordDir);
String remoteRecordPath = dateStr + "/" + recordFileName;
logger.info("【FTP行】准备下载录音: {}/{}", recordDir, remoteRecordPath);
byte[] recordBytes = FtpUtil.downloadFile(ftp, remoteRecordPath);
logger.info("【FTP行】下载录音: {} ({} 字节)", recordFileName, recordBytes.length);
// 为了方便您测试验证链路,这里加上一段代码,把从FTP读到的录音直接存在本地硬盘
try {
java.nio.file.Path testSaveDir = java.nio.file.Paths.get("download_test_records", dateStr);
if (!java.nio.file.Files.exists(testSaveDir)) {
java.nio.file.Files.createDirectories(testSaveDir);
}
java.nio.file.Path testSaveFile = testSaveDir.resolve(recordFileName);
java.nio.file.Files.write(testSaveFile, recordBytes);
logger.info("【FTP测试】已成功将录音保存到本地硬盘: {}", testSaveFile.toAbsolutePath());
} catch (Exception e) {
logger.error("【FTP测试】保存本地测试文件失败: {}", e.getMessage());
}
// 下载完成后切回txt目录,继续处理下一个文件
FtpUtil.changeDir(ftp, city.getFtpSourceDir());
// 上传到OSS(获取可访问的URL)
String ossUrl = fileUploadUtil.uploadWav(city.getCityName(), ossPath, recordFileName, recordBytes);
logger.info("【FTP行】上传OSS: {}", ossUrl);
// 构建实体对象并入库
MidVoiceCallRecord rec = buildCallRecord(city, callRecordId, callStartTime, callEndTime,
thsc, isOutgoing, zjhm, bjhm, recordFileName, recordBytes.length, ossUrl);
callRecordMapper.insert(rec);
existingIds.add(callRecordId); // 防止同批内重复(本次循环内)
logger.info("【FTP行】保存成功: id={}, callRecordId={}", rec.getId(), callRecordId);
// 将成功信息记录到本地文件中,方便排查和核对
try {
java.nio.file.Path logDir = java.nio.file.Paths.get("logs");
if (!java.nio.file.Files.exists(logDir)) {
java.nio.file.Files.createDirectories(logDir);
}
java.nio.file.Path logFile = logDir.resolve("upload_success.log");
String logLine = String.format("[%s] 成功同步录音: 流水号=%s, 文件名=%s, OSS地址=%s\n",
DateUtil.formatDate(new Date(), "yyyy-MM-dd HH:mm:ss"), callRecordId, recordFileName, ossUrl);
java.nio.file.Files.write(logFile, logLine.getBytes("UTF-8"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
} catch (Exception e) {
logger.error("记录成功日志到文件失败", e);
}
return true;
}
/**
* 构建通话记录实体
*
* @param city 地市配置提供编码名称
* @param callRecordId 唯一标识地市编码_FTP_呼叫流水
* @param callStartTime 通话开始时间
* @param callEndTime 通话结束时间
* @param duration 通话时长
* @param isOutgoing 是否呼出true=呼出false=呼入
* @param zjhm 主叫号码已去区号
* @param bjhm 被叫号码已去区号
* @param fileName 录音文件名
* @param fileSize 录音文件大小字节
* @param ossUrl OSS访问URL
*/
private MidVoiceCallRecord buildCallRecord(FtpCityConfig city, String callRecordId,
Date callStartTime, Date callEndTime,
int duration, boolean isOutgoing,
String zjhm, String bjhm,
String fileName, int fileSize, String ossUrl) {
MidVoiceCallRecord rec = new MidVoiceCallRecord();
rec.setCallRecordId(callRecordId); // 唯一标识
rec.setDeviceNo("FTP_" + city.getCityCode()); // 设备编码(FTP_地市编码)
rec.setCityCode(city.getCityCode()); // 地市编码(用于数据库分区)
rec.setCityName(city.getCityName()); // 地市名称
rec.setOrgCode(city.getCityCode()); // 单位代码(用地市编码代替)
rec.setRecordingFileName(fileName); // 录音文件名
rec.setCallStartTime(callStartTime); // 通话开始时间
rec.setCallEndTime(callEndTime); // 通话结束时间
rec.setCallDuration(duration); // 通话时长(秒)
rec.setCallDirection(isOutgoing ? DIR_OUTGOING : DIR_INCOMING); // 通话方向:1呼入/2呼出
rec.setCallTel(isOutgoing ? zjhm : bjhm); // 主叫号码
rec.setCalledTel(isOutgoing ? bjhm : zjhm); // 被叫号码
rec.setCallStatus(CALL_STATUS_OK); // 通话状态:1=正常接通
rec.setRecordingFileSize(fileSize); // 文件大小(字节)
rec.setRecordingFilePath(ossUrl); // OSS访问URL
return rec;
}
/**
* 批量加载已存在的call_record_id防重
*/
private Set<String> loadExistingCallRecordIds(String prefix) {
try {
List<String> ids = callRecordMapper.selectCallRecordIdsByPrefix(prefix);
logger.info("【FTP防重】已加载 {} 条历史记录(prefix={})", ids.size(), prefix);
return new HashSet<>(ids);
} catch (Exception e) {
logger.error("【FTP防重】加载历史记录失败,将全部当作新数据: {}", e.getMessage());
return new HashSet<>();
}
}
/**
* 批量加载已处理的文件名避免重复处理txt文件
*/
private Set<String> loadProcessedFileNames(String cityCode) {
try {
List<String> fileNames = processedFileMapper.selectProcessedFileNamesByCity(cityCode);
return new HashSet<>(fileNames);
} catch (Exception e) {
logger.error("【FTP防重】加载已处理文件记录失败: {}", e.getMessage());
return new HashSet<>();
}
}
/**
* 记录已处理的txt文件
*/
private void recordProcessedFile(FtpCityConfig city, String fileName, long fileSize,
int recordCount, int successCount, int failCount,
long processTime, String errorMsg) {
try {
FtpProcessedFile record = new FtpProcessedFile();
record.setCityCode(city.getCityCode());
record.setCityName(city.getCityName());
record.setFileName(fileName);
record.setFileSize(fileSize);
record.setProcessStatus(failCount == 0 ? "1" : "0");
record.setRecordCount(recordCount);
record.setSuccessCount(successCount);
record.setFailCount(failCount);
record.setProcessTime(processTime);
record.setErrorMsg(errorMsg);
processedFileMapper.insert(record);
logger.debug("【FTP文件】已记录处理结果: {}", fileName);
} catch (Exception e) {
logger.error("【FTP文件】记录处理结果失败: {}, 原因={}", fileName, e.getMessage());
}
}
/**
* 去除电话号码的区号前缀
*
* @param phone 原始号码
* @param areaCode 区号前缀如合肥 0551为空或配置不存在的值则不去除
* @return 处理后的号码
*/
private String stripAreaCode(String phone, String areaCode) {
if (phone == null || areaCode == null || areaCode.isEmpty()) {
return phone;
}
if (phone.startsWith(areaCode)) {
return phone.substring(areaCode.length());
}
return phone;
}
/**
* 安全转 int
*/
private int parseInt(String s, int defaultValue) {
if (s == null || s.isEmpty()) return defaultValue;
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
return defaultValue;
}
}
/**
* 拼接FTP路径自动处理末尾斜杠避免双斜杠
*
* @param parts 路径片段
* @return 拼接后的路径使用正斜杠FTP标准路径分隔符
*/
private String joinPath(String... parts) {
if (parts == null || parts.length == 0) {
return "";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < parts.length; i++) {
String part = parts[i];
if (part == null || part.isEmpty()) {
continue;
}
// 去掉末尾的斜杠(除了最后一个片段且本身就是斜杠的情况)
if (part.endsWith("/") && i < parts.length - 1) {
part = part.substring(0, part.length() - 1);
}
// 去掉开头的斜杠(除了第一个片段)
if (i > 0 && part.startsWith("/")) {
part = part.substring(1);
}
if (sb.length() > 0) {
sb.append("/");
}
sb.append(part);
}
return sb.toString();
}
}

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

@ -1,390 +0,0 @@
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.MidVoiceDeviceLog;
import com.threecloud.dataserviceyy.mapper.MidVoiceCallRecordMapper;
import com.threecloud.dataserviceyy.mapper.MidVoiceDeviceLogMapper;
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;
import java.util.*;
/**
* VAA录音盒定时同步服务
*
* 功能从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 MidVoiceDeviceLogMapper deviceLogMapper;
@Autowired
private VaaHttpUtil vaaHttpUtil;
@Autowired
private FileUploadUtil fileUploadUtil;
@Value("${vaa-sync.download-path:./vaa-recordings}")
private String downloadPath;
@Value("${vaa-sync.retain-days:10}")
private int retainDays;
/**
* 定时同步任务
*/
@Scheduled(cron = "${vaa-sync.sync-interval-cron:0 0 0/2 * * ?}")
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 {
// 清理过期本地文件
FileCleaner.cleanOldFiles(downloadPath, retainDays);
// 查询在线设备列表(含每个设备的账号密码)
List<Map<String, Object>> deviceList = voiceSyncMapper.getAllYysb();
logger.info("【主流程】查询到 {} 个在线语音设备", deviceList.size());
int successCount = 0;
int failCount = 0;
for (int i = 0; i < deviceList.size(); i++) {
Map<String, Object> device = deviceList.get(i);
String deviceId = getStr(device, "ID");
try {
syncSingleDevice(device);
successCount++;
} catch (Exception e) {
failCount++;
logger.error("【异常】设备同步失败: ID={}, 原因={}", deviceId, e.getMessage());
}
}
logger.info("【主流程】同步完成,成功 {} 个,失败 {} 个", successCount, failCount);
} catch (Exception e) {
logger.error("【异常】同步任务执行失败: {}", e.getMessage(), e);
}
}
/**
* 同步单个设备
*
* 流程登录 获取分机号码 查录音列表 逐条下载上传保存
* 任何步骤失败直接记录日志跳过该设备
*/
private void syncSingleDevice(Map<String, Object> device) throws Exception {
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);
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "0", "设备IP为空");
return;
}
if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) {
logger.warn("【设备】账号密码为空,跳过: ID={}", deviceId);
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "0", "设备账号密码未配置");
return;
}
if (!StringUtils.hasText(cityName)) {
cityName = "unknown_" + deviceId;
}
String deviceHost = buildHost(ip, port);
// 步骤1:登录(无重试)
String authToken;
try {
String loginUrl = String.format("http://%s/authorize?username=%s&password=%s",
deviceHost, urlEncode(username), urlEncode(password));
authToken = vaaHttpUtil.httpLogin(loginUrl);
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "1", null);
} catch (Exception e) {
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "0", "登录失败:" + e.getMessage());
logger.error("【设备】登录失败,跳过: IP={}, 原因={}", ip, e.getMessage());
return;
}
// 步骤2:获取分机号码(通道→电话号码映射),用于解析主叫/被叫
Map<String, String> extNumbers = getExtensionNumbers(deviceHost, authToken);
// 步骤3:计算同步时间范围(增量同步)
Date lastSyncTime = SyncTimeUtil.readLastSyncTime(downloadPath, deviceId);
Date now = new Date();
if (lastSyncTime == null || DateUtil.getDateDoubleDiff(now, lastSyncTime) > 1.0) {
lastSyncTime = DateUtil.addDayByDate(now, -1);
}
long startEpoch = lastSyncTime.getTime() / 1000;
long endEpoch = now.getTime() / 1000;
// 步骤4:获取录音列表(无重试)
JSONArray records;
try {
String recordUrl = String.format("http://%s/service/record/~/time[%d,%d]",
deviceHost, startEpoch, endEpoch);
String recordData = vaaHttpUtil.httpVisit(recordUrl, authToken);
records = vaaHttpUtil.parseRecordData(recordData);
saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "2", "1", null);
} catch (Exception e) {
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());
// 批量查询已存在的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());
// 步骤5:逐条处理录音
int successCount = 0;
int failCount = 0;
Date latestCallTime = null;
for (int i = 0; i < records.size(); i++) {
JSONObject rec = records.getJSONObject(i);
try {
Date callTime = processSingleRecord(rec, deviceNo, cityName, cityCode, orgCode,
deviceHost, authToken, extNumbers, existingIds);
if (callTime != null) {
successCount++;
if (latestCallTime == null || callTime.after(latestCallTime)) {
latestCallTime = callTime;
}
}
} catch (Exception e) {
failCount++;
logger.error("【录音】处理失败: {}, 原因={}", rec.getString("id"), e.getMessage());
}
}
// 步骤6:保存同步时间
if (latestCallTime != null) {
SyncTimeUtil.writeLastSyncTime(downloadPath, deviceId, latestCallTime);
}
logger.info("【设备】同步完成: ID={}, 成功{}条, 失败{}条", deviceId, successCount, failCount);
}
/**
* 处理单条录音记录
*
* @return 通话开始时间成功时null表示跳过
*/
private Date processSingleRecord(JSONObject rec, String deviceNo, String cityName,
String cityCode, String orgCode, String deviceHost,
String authToken, Map<String, String> extNumbers,
Set<String> existingIds) 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()) {
return null;
}
if (begTime == null || endTime == null) {
return null;
}
// 防重复:内存中比对已存在的call_record_id(一次查询代替N次)
String callRecordId = deviceNo + "_" + recordId;
if (existingIds.contains(callRecordId)) {
logger.debug("【录音】已存在,跳过: {}", callRecordId);
return null;
}
// 准备文件路径
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);
String ossPath = FilePathUtil.buildOssPath(cityCode, callStartTime, fileName);
// 获取通道绑定的电话号码(从EBOX分机号码配置)
String channelPhone = "";
if (channel != null && extNumbers != null) {
channelPhone = extNumbers.getOrDefault(String.valueOf(channel), "");
}
// 构建通话记录
MidVoiceCallRecord callRecord = buildCallRecord(
callRecordId, deviceNo, cityCode, cityName, orgCode,
fileName, callStartTime, new Date(endTime * 1000),
(int) (endTime - begTime), channelPhone, phone, isOutgoing, isAnswered);
// 下载录音文件(无重试)
if (!Files.exists(localFile) || Files.size(localFile) == 0) {
String fileUrl = "http://" + deviceHost + filePath;
vaaHttpUtil.httpDown(fileUrl, localPath, authToken);
logger.info("【录音】下载完成: {} ({} 字节)", fileName, Files.size(localFile));
} else {
logger.debug("【录音】文件已存在,跳过下载: {}", fileName);
}
callRecord.setRecordingFileSize((int) Files.size(localFile));
// 上传到OSS
byte[] wavData = Files.readAllBytes(localFile);
String ossUrl = fileUploadUtil.uploadWav(cityName, ossPath, fileName, wavData);
callRecord.setRecordingFilePath(ossUrl);
logger.info("【录音】上传OSS成功: {}", ossUrl);
// 保存到数据库
callRecordMapper.insert(callRecord);
logger.info("【录音】保存成功: callRecordId={}", callRecordId);
return callStartTime;
}
/**
* 构建通话记录实体
*
* 号码解析规则
* - 呼出state=1主叫=本机号码通道绑定号码被叫=对方号码phone字段
* - 呼入state=2主叫=对方号码phone字段被叫=本机号码通道绑定号码
*/
private MidVoiceCallRecord buildCallRecord(String callRecordId, String deviceNo, String cityCode,
String cityName, String orgCode, String fileName,
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.setCallStartTime(callStartTime);
record.setCallEndTime(callEndTime);
record.setCallDuration(duration);
record.setCallDirection(isOutgoing ? "2" : "1"); // 1呼入,2呼出
String localNum = channelPhone != null ? channelPhone : "";
String remoteNum = remotePhone != null ? remotePhone : "";
if (isOutgoing) {
record.setCallTel(localNum); // 主叫:本机
record.setCalledTel(remoteNum); // 被叫:对方
} else {
record.setCallTel(remoteNum); // 主叫:对方
record.setCalledTel(localNum); // 被叫:本机
}
record.setCallStatus(isAnswered ? "1" : "2"); // 1正常接通,2未接通
return record;
}
/**
* 获取分机号码配置
* 调用 EBOX 接口 GET /service/ext/number
* 返回 Map<通道号, 电话号码> {"1":"8001","2":"8002"}
*/
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();
}
}
/**
* 保存设备连接日志到 mid_voice_device_log
*/
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());
}
}
// ==================== 工具方法 ====================
private String buildHost(String ip, Integer port) {
return ip + (port != null && port != 80 ? ":" + port : "");
}
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 String urlEncode(String value) throws Exception {
return URLEncoder.encode(value, "UTF-8");
}
}

76
src/main/java/com/threecloud/dataserviceyy/util/DataUtil.java

@ -1,76 +0,0 @@
package com.threecloud.dataserviceyy.util;
import java.util.Collection;
import java.util.Map;
public class DataUtil {
public static boolean isNotNull(Object obj) {
return obj != null;
}
public static boolean isNotNull(Map map) {
return map != null && !map.isEmpty();
}
public static boolean isNotNull(Collection collection) {
return collection != null && !collection.isEmpty();
}
public static boolean isNotNull(String str) {
return str != null && !str.trim().isEmpty();
}
public static boolean isNumber(Object obj) {
if (obj == null) return false;
try {
Double.parseDouble(obj.toString());
return true;
} catch (NumberFormatException e) {
return false;
}
}
public static Long objToLong(Object obj) {
if (obj == null) return null;
if (obj instanceof Long) return (Long) obj;
if (obj instanceof Number) return ((Number) obj).longValue();
if (isNumber(obj)) return Long.parseLong(obj.toString());
return null;
}
public static String objToValue(Object obj) {
if (obj == null) return null;
return obj.toString();
}
public static Integer objToInteger(Object obj) {
if (obj == null) return null;
if (obj instanceof Integer) return (Integer) obj;
if (obj instanceof Number) return ((Number) obj).intValue();
if (isNumber(obj)) return Integer.parseInt(obj.toString());
return null;
}
public static Float objToFloat(Object obj) {
if (obj == null) return null;
if (obj instanceof Float) return (Float) obj;
if (obj instanceof Number) return ((Number) obj).floatValue();
if (isNumber(obj)) return Float.parseFloat(obj.toString());
return null;
}
public static Double objToDouble(Object obj) {
if (obj == null) return null;
if (obj instanceof Double) return (Double) obj;
if (obj instanceof Number) return ((Number) obj).doubleValue();
if (isNumber(obj)) return Double.parseDouble(obj.toString());
return null;
}
public static boolean objToBoolean(Object obj) {
if (obj == null) return false;
if (obj instanceof Boolean) return (Boolean) obj;
return Boolean.parseBoolean(obj.toString());
}
}

20
src/main/java/com/threecloud/dataserviceyy/util/DateUtil.java

@ -1,5 +1,6 @@
package com.threecloud.dataserviceyy.util;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
@ -9,7 +10,10 @@ import java.util.Date;
*/
public class DateUtil {
public static final SimpleDateFormat dateFmt4 = new SimpleDateFormat("yyyyMMdd");
/** yyyyMMdd 格式(日期目录用) */
public static final SimpleDateFormat DATE_FMT_8 = new SimpleDateFormat("yyyyMMdd");
/** yyyyMMddHHmmss 格式(FTP时间戳用) */
public static final SimpleDateFormat DATE_FMT_14 = new SimpleDateFormat("yyyyMMddHHmmss");
/**
* 计算两个日期之间的天数差
@ -54,4 +58,18 @@ public class DateUtil {
SimpleDateFormat sdf = new SimpleDateFormat(pattern);
return sdf.format(date);
}
/**
* 解析 yyyyMMddHHmmss 格式日期
* @param s 字符串
* @return 解析失败返回 null
*/
public static Date parseDate14(String s) {
if (s == null) return null;
try {
return DATE_FMT_14.parse(s);
} catch (ParseException e) {
return null;
}
}
}

181
src/main/java/com/threecloud/dataserviceyy/util/FileCleaner.java

@ -1,177 +1,44 @@
package com.threecloud.dataserviceyy.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* 文件清理工具类
* 负责清理过期的本地录音文件
*
* 目录结构
* vaa-recordings/
* 340100/ # 地市编码
* 20240101/ # 日期目录
* <device_uuid>/ # 设备目录
* xxx.wav
* 20240102/
* ...
* 340200/
* .sync-marker/
* 文件清理工具
* 用于清理超过指定天数的本地临时文件
*/
public class FileCleaner {
private static final Logger logger = LoggerFactory.getLogger(FileCleaner.class);
/**
* 默认保留天数
*/
private static final int DEFAULT_RETAIN_DAYS = 10;
/**
* 日期格式
*/
private static final String DATE_FORMAT = "yyyyMMdd";
/**
* 地市编码正则6位数字
*/
private static final String CITY_CODE_PATTERN = "\\d{6}";
/**
* 清理指定天数前的本地录音文件
* 清理指定目录下的过期文件
*
* 清理逻辑
* 1. 遍历 basePath 下地市目录 340100, 340200
* 2. 遍历每个地市目录下的日期目录 20240101
* 3. 删除早于 cutoffDate 的日期目录
*
* @param basePath 基础路径
* @param retainDays 保留天数
* @param tempDir 临时目录
* @param retainDays 保留天数早于今天 retainDays 天的文件将被删除
*/
public static void cleanOldFiles(String basePath, int retainDays) {
File baseDir = new File(basePath);
if (!baseDir.exists() || !baseDir.isDirectory()) {
logger.debug("基础目录不存在或不是目录: {}", basePath);
public static void clean(String tempDir, int retainDays) {
File dir = new File(tempDir);
if (!dir.exists()) {
return;
}
// 获取地市目录列表
File[] cityDirs = baseDir.listFiles(File::isDirectory);
if (cityDirs == null || cityDirs.length == 0) {
logger.debug("目录下没有地市子目录: {}", basePath);
return;
}
String cutoffDate = calculateCutoffDate(retainDays);
int totalDeletedCount = 0;
for (File cityDir : cityDirs) {
String cityCode = cityDir.getName();
// 跳过非地市目录(如 .sync-marker)
if (!cityCode.matches(CITY_CODE_PATTERN)) {
logger.debug("跳过非地市目录: {}", cityCode);
continue;
}
// 清理该地市下的过期日期目录
int deletedCount = cleanCityDirectory(cityDir, cutoffDate);
totalDeletedCount += deletedCount;
// 如果地市目录为空,删除地市目录
if (deletedCount > 0 && isEmptyDirectory(cityDir)) {
cityDir.delete();
logger.info("已删除空地市目录: {}", cityDir.getAbsolutePath());
}
}
if (totalDeletedCount > 0) {
logger.info("清理完成,共删除 {} 个过期日期目录", totalDeletedCount);
} else {
logger.debug("没有需要清理的过期文件");
}
long threshold = System.currentTimeMillis() - (long) retainDays * 24 * 60 * 60 * 1000;
deleteRecursive(dir, threshold);
}
/**
* 清理指定地市目录下的过期日期目录
*
* @param cityDir 地市目录
* @param cutoffDate 截止日期
* @return 删除的目录数
*/
private static int cleanCityDirectory(File cityDir, String cutoffDate) {
File[] dateDirs = cityDir.listFiles(File::isDirectory);
if (dateDirs == null || dateDirs.length == 0) {
return 0;
}
int deletedCount = 0;
for (File dateDir : dateDirs) {
String dirName = dateDir.getName();
// 只处理日期格式的目录(如 20240101)
if (dirName.matches("\\d{8}") && dirName.compareTo(cutoffDate) < 0) {
if (deleteDirectory(dateDir)) {
deletedCount++;
logger.info("已清理过期录音目录: {} (早于 {})", dateDir.getAbsolutePath(), cutoffDate);
}
private static void deleteRecursive(File file, long threshold) {
if (!file.isDirectory()) {
if (file.lastModified() < threshold) {
file.delete();
}
return;
}
return deletedCount;
}
/**
* 检查目录是否为空不包含任何文件和子目录
*
* @param dir 目录
* @return 是否为空
*/
private static boolean isEmptyDirectory(File dir) {
File[] files = dir.listFiles();
return files == null || files.length == 0;
}
/**
* 清理默认天数10天前的文件
*
* @param basePath 基础路径
*/
public static void cleanOldFiles(String basePath) {
cleanOldFiles(basePath, DEFAULT_RETAIN_DAYS);
}
/**
* 递归删除目录
*
* @param dir 要删除的目录
* @return 是否成功删除
*/
private static boolean deleteDirectory(File dir) {
File[] files = dir.listFiles();
if (files != null) {
for (File file : files) {
if (file.isDirectory()) {
deleteDirectory(file);
} else {
file.delete();
}
}
File[] files = file.listFiles();
if (files == null) return;
for (File f : files) {
deleteRecursive(f, threshold);
}
// 空目录也删除
File[] remain = file.listFiles();
if (remain == null || remain.length == 0) {
file.delete();
}
return dir.delete();
}
/**
* 计算截止日期
*
* @param retainDays 保留天数
* @return 截止日期字符串yyyyMMdd
*/
private static String calculateCutoffDate(int retainDays) {
Date cutoffDate = DateUtil.addDayByDate(new Date(), -retainDays);
return new SimpleDateFormat(DATE_FORMAT).format(cutoffDate);
}
}

11
src/main/java/com/threecloud/dataserviceyy/util/FileUploadUtil.java

@ -32,19 +32,19 @@ public class FileUploadUtil {
private static final Logger logger = LoggerFactory.getLogger(FileUploadUtil.class);
// OSS配置(从配置文件读取)
@Value("${vaa-sync.oss.base-url:http://53.1.194.59:9090}")
@Value("${ftp-sync.oss.base-url:http://53.1.194.59:9090}")
private String ossBaseUrl;
@Value("${vaa-sync.oss.upload-url:http://53.1.194.59:9090/apiOss/oss/fileUpload}")
@Value("${ftp-sync.oss.upload-url:http://53.1.194.59:9090/apiOss/oss/fileUpload}")
private String ossUploadUrl;
@Value("${vaa-sync.oss.appcode:dataservice-yy}")
@Value("${ftp-sync.oss.appcode:dataservice-yy}")
private String ossAppcode;
@Value("${vaa-sync.oss.appid:371a3368-e28e-4ba3-95a3-c31c19cf0ad0}")
@Value("${ftp-sync.oss.appid:371a3368-e28e-4ba3-95a3-c31c19cf0ad0}")
private String ossAppid;
@Value("${vaa-sync.oss.appsecret:06a6a80e-f9d2-4b3b-acc0-8d182c876074}")
@Value("${ftp-sync.oss.appsecret:06a6a80e-f9d2-4b3b-acc0-8d182c876074}")
private String ossAppsecret;
private RestTemplate restTemplate;
@ -150,6 +150,7 @@ public class FileUploadUtil {
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
ResultEntity result = response.getBody();
logger.debug("OSS返回结果: {}", result.toString());
if (result.getCode() == ResultEntity.StatusCode.SUCCESS.getCode()) {
String url = parseUrlFromContent(result.getContent());
logger.debug("OSS返回URL: {}", url);

181
src/main/java/com/threecloud/dataserviceyy/util/FtpUtil.java

@ -0,0 +1,181 @@
package com.threecloud.dataserviceyy.util;
import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPFile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* FTP 工具类
* 封装FTP连接文件列表下载删除重命名等操作
* 基于 Apache Commons Net FTPClient
*/
public class FtpUtil {
private static final Logger logger = LoggerFactory.getLogger(FtpUtil.class);
/** FTP连接超时(毫秒) */
private static final int CONNECT_TIMEOUT = 15000;
/** FTP读取超时(毫秒) */
private static final int READ_TIMEOUT = 60000;
/**
* 连接FTP服务器一次性连接处理所有操作最后调用 disconnect 关闭
*
* @param host FTP服务器IP
* @param port FTP服务器端口
* @param username 用户名
* @param password 密码
* @return FTPClient 连接成功返回客户端失败抛异常
*/
public static FTPClient connect(String host, int port, String username, String password) throws IOException {
FTPClient ftp = new FTPClient();
ftp.setConnectTimeout(CONNECT_TIMEOUT);
ftp.setDataTimeout(READ_TIMEOUT);
ftp.connect(host, port);
boolean loginOk = ftp.login(username, password);
if (!loginOk) {
ftp.disconnect();
throw new IOException("FTP登录失败,请检查用户名密码");
}
// 设置被动模式(必须,避开防火墙)
ftp.enterLocalPassiveMode();
// 设置二进制传输(mp3/wav必须用二进制)
ftp.setFileType(FTP.BINARY_FILE_TYPE);
// 设置UTF-8编码(文件名有中文)
ftp.setControlEncoding("UTF-8");
logger.info("FTP连接成功: {}:{}", host, port);
return ftp;
}
/**
* 断开FTP连接
*/
public static void disconnect(FTPClient ftp) {
if (ftp != null && ftp.isConnected()) {
try {
ftp.logout();
} catch (IOException ignored) {
}
try {
ftp.disconnect();
} catch (IOException ignored) {
}
}
}
/**
* 列出指定目录下的所有文件
*
* @param ftp 已连接的FTPClient
* @param dir 目录路径相对或绝对
* @return 文件列表失败抛异常
*/
public static FTPFile[] listFiles(FTPClient ftp, String dir) throws IOException {
// 切换到目标目录
boolean cdOk = ftp.changeWorkingDirectory(dir);
if (!cdOk) {
throw new IOException("切换FTP目录失败: " + dir);
}
FTPFile[] files = ftp.listFiles();
logger.debug("FTP目录 [{}] 下有 {} 个文件", dir, files.length);
return files;
}
/**
* 下载文件到本地字节数组
*
* @param ftp 已连接的FTPClient
* @param remotePath 远程文件路径绝对或相对于当前工作目录
* @return 文件字节内容
*/
public static byte[] downloadFile(FTPClient ftp, String remotePath) throws IOException {
try (InputStream in = ftp.retrieveFileStream(remotePath);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
if (in == null) {
throw new IOException("下载文件失败(无输入流): " + remotePath);
}
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
// 必须调用 completePendingCommand,否则后续操作会失败
boolean ok = ftp.completePendingCommand();
if (!ok) {
throw new IOException("FTP下载未完成: " + remotePath);
}
return out.toByteArray();
}
}
/**
* 重命名/移动FTP文件用于把处理完的txt移到归档目录
*
* @param ftp 已连接的FTPClient
* @param fromPath 原文件路径
* @param toPath 目标文件路径
*/
public static void rename(FTPClient ftp, String fromPath, String toPath) throws IOException {
boolean ok = ftp.rename(fromPath, toPath);
if (!ok) {
throw new IOException("FTP重命名失败: " + fromPath + " -> " + toPath);
}
}
/**
* 删除FTP文件
*/
public static void delete(FTPClient ftp, String path) throws IOException {
boolean ok = ftp.deleteFile(path);
if (!ok) {
throw new IOException("FTP删除文件失败: " + path);
}
}
/**
* 确保归档目录存在不存在则创建
*/
public static void ensureDir(FTPClient ftp, String dir) throws IOException {
if (!ftp.changeWorkingDirectory(dir)) {
// 尝试创建目录
boolean ok = ftp.makeDirectory(dir);
if (!ok) {
throw new IOException("创建FTP目录失败: " + dir);
}
}
}
/**
* 切换工作目录不存在则创建
*/
public static void changeDir(FTPClient ftp, String dir) throws IOException {
if (!ftp.changeWorkingDirectory(dir)) {
throw new IOException("切换FTP目录失败: " + dir);
}
}
/**
* 判断是否为txt文件
*/
public static boolean isTxtFile(FTPFile file) {
return file != null && file.isFile() && file.getName().toLowerCase().endsWith(".txt");
}
/**
* 统计txt文件数量
*/
public static int countTxtFiles(FTPFile[] files) {
if (files == null) return 0;
int count = 0;
for (FTPFile f : files) {
if (isTxtFile(f)) count++;
}
return count;
}
}

88
src/main/java/com/threecloud/dataserviceyy/util/RecordParser.java

@ -1,88 +0,0 @@
package com.threecloud.dataserviceyy.util;
import com.alibaba.fastjson2.JSONObject;
/**
* 录音记录解析工具类
* 封装从 EBOX API 返回的 JSON 中提取字段的逻辑
*/
public class RecordParser {
/**
* 解析录音记录ID
*/
public static String parseRecordId(JSONObject record) {
return record.getString("id");
}
/**
* 解析文件路径
*/
public static String parseFilePath(JSONObject record) {
return record.getString("file");
}
/**
* 解析通道号
*/
public static Integer parseChannel(JSONObject record) {
Object channelObj = record.get("channel");
return channelObj instanceof Number ? ((Number) channelObj).intValue() : null;
}
/**
* 解析对方电话号码
*/
public static String parsePhone(JSONObject record) {
return record.getString("phone");
}
/**
* 解析通话状态方向
* 1=呼出其他=呼入
*/
public static Integer parseState(JSONObject record) {
Object stateObj = record.get("state");
return stateObj instanceof Number ? ((Number) stateObj).intValue() : null;
}
/**
* 解析开始时间Unix时间戳
*/
public static Long parseBegTime(JSONObject record) {
Object begtimeObj = record.get("begtime");
return begtimeObj instanceof Number ? ((Number) begtimeObj).longValue() : null;
}
/**
* 解析结束时间Unix时间戳
*/
public static Long parseEndTime(JSONObject record) {
Object endtimeObj = record.get("endtime");
return endtimeObj instanceof Number ? ((Number) endtimeObj).longValue() : null;
}
/**
* 解析是否接听
* 1=接听0=未接听
*/
public static Integer parseAnswer(JSONObject record) {
Object answerObj = record.get("answer");
return answerObj instanceof Number ? ((Number) answerObj).intValue() : 0;
}
/**
* 判断是否为呼出
*/
public static boolean isOutgoing(JSONObject record) {
Integer state = parseState(record);
return state != null && state == 1;
}
/**
* 判断是否已接听
*/
public static boolean isAnswered(JSONObject record) {
return parseAnswer(record) == 1;
}
}

70
src/main/java/com/threecloud/dataserviceyy/util/SyncTimeUtil.java

@ -1,70 +0,0 @@
package com.threecloud.dataserviceyy.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.nio.file.Files;
import java.util.Date;
/**
* 同步时间工具类
* 管理设备上次同步时间的本地文件存储
*/
public class SyncTimeUtil {
private static final Logger logger = LoggerFactory.getLogger(SyncTimeUtil.class);
/**
* 从本地文件读取上次同步时间
*
* @param basePath 基础路径
* @param deviceId 设备ID
* @return 上次同步时间不存在返回null
*/
public static Date readLastSyncTime(String basePath, String deviceId) {
String markerPath = FilePathUtil.buildSyncMarkerPath(basePath, deviceId);
File markerFile = new File(markerPath);
if (!markerFile.exists()) {
logger.debug("同步标记文件不存在: deviceId={}", deviceId);
return null;
}
try {
byte[] bytes = Files.readAllBytes(markerFile.toPath());
long millis = Long.parseLong(new String(bytes).trim());
Date syncTime = new Date(millis);
logger.debug("读取到上次同步时间: deviceId={}, time={}", deviceId, syncTime);
return syncTime;
} catch (Exception e) {
logger.warn("读取同步时间失败: deviceId={}, error={}", deviceId, e.getMessage());
return null;
}
}
/**
* 保存同步时间到本地文件
*
* @param basePath 基础路径
* @param deviceId 设备ID
* @param time 同步时间
*/
public static void writeLastSyncTime(String basePath, String deviceId, Date time) {
String markerDir = FilePathUtil.buildSyncMarkerDir(basePath);
File dir = new File(markerDir);
if (!dir.exists()) {
dir.mkdirs();
}
String markerPath = FilePathUtil.buildSyncMarkerPath(basePath, deviceId);
File markerFile = new File(markerPath);
try {
Files.write(markerFile.toPath(), String.valueOf(time.getTime()).getBytes());
logger.debug("保存同步时间成功: deviceId={}, time={}", deviceId, time);
} catch (Exception e) {
logger.warn("保存同步时间失败: deviceId={}, error={}", deviceId, e.getMessage());
}
}
}

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

@ -1,271 +0,0 @@
package com.threecloud.dataserviceyy.util;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.List;
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);
/** 连接超时: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
* @return 登录成功后返回Authorization Cookie值
* @throws Exception 登录失败直接抛出异常
*/
public String httpLogin(String loginUrl) throws Exception {
logger.debug("正在登录录音盒: {}", loginUrl);
HttpURLConnection conn = null;
try {
URL url = new URL(loginUrl);
conn = (HttpURLConnection) url.openConnection();
conn.setInstanceFollowRedirects(false);
conn.setConnectTimeout(CONNECT_TIMEOUT);
conn.setReadTimeout(READ_TIMEOUT);
conn.setRequestMethod("GET");
int responseCode = conn.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_MOVED_TEMP) {
String authorization = getAuthorizationCookie(conn);
if (authorization == null || authorization.isEmpty()) {
throw new RuntimeException("登录失败,录音盒未返回Authorization Cookie");
}
logger.info("录音盒登录成功");
return authorization;
}
throw new RuntimeException("登录失败,HTTP状态码: " + responseCode);
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
/**
* HTTP访问API获取数据无重试
* @param apiUrl API地址
* @param authorization 登录后返回的Authorization Cookie值
* @return 返回JSON字符串
* @throws Exception 访问失败直接抛出异常
*/
public String httpVisit(String apiUrl, String authorization) throws Exception {
logger.debug("访问API: {}", apiUrl);
HttpURLConnection conn = null;
try {
URL url = new URL(apiUrl);
conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(CONNECT_TIMEOUT);
conn.setReadTimeout(READ_TIMEOUT);
conn.setRequestMethod("GET");
addAuthorization(conn, authorization);
int responseCode = conn.getResponseCode();
if (responseCode == 200) {
BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), "UTF-8")
);
StringBuilder result = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
result.append(line);
}
reader.close();
String jsonResult = result.toString();
logger.debug("API响应: {}", jsonResult);
if (isLoginPage(jsonResult)) {
throw new RuntimeException("录音盒认证失效,返回登录页面");
}
return jsonResult;
} else {
throw new RuntimeException("API访问失败,HTTP状态码: " + responseCode);
}
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
/**
* 下载录音文件无重试
* @param fileUrl 文件URL
* @param savePath 保存路径
* @param authorization 登录后返回的Authorization Cookie值
* @throws Exception 下载失败直接抛出异常
*/
public void httpDown(String fileUrl, String savePath, String authorization) throws Exception {
logger.info("开始下载录音文件: {} -> {}", fileUrl, savePath);
// 确保目录存在
File saveFile = new File(savePath);
if (!saveFile.getParentFile().exists()) {
saveFile.getParentFile().mkdirs();
}
HttpURLConnection conn = null;
InputStream inputStream = null;
FileOutputStream outputStream = null;
try {
URL url = new URL(fileUrl);
conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(CONNECT_TIMEOUT);
conn.setReadTimeout(DOWNLOAD_READ_TIMEOUT);
conn.setRequestMethod("GET");
addAuthorization(conn, authorization);
int responseCode = conn.getResponseCode();
if (responseCode == 200) {
inputStream = conn.getInputStream();
outputStream = new FileOutputStream(savePath);
byte[] buffer = new byte[8192];
int bytesRead;
long totalBytes = 0;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
totalBytes += bytesRead;
}
outputStream.flush();
logger.info("录音文件下载完成: {}, 大小: {} bytes ({} MB)",
savePath, totalBytes, totalBytes / 1024 / 1024);
} else {
throw new RuntimeException("文件下载失败,HTTP状态码: " + responseCode);
}
} finally {
closeQuietly(inputStream);
closeQuietly(outputStream);
if (conn != null) {
conn.disconnect();
}
}
}
private void closeQuietly(Closeable c) {
if (c != null) {
try { c.close(); } catch (IOException ignored) {}
}
}
private void addAuthorization(HttpURLConnection conn, String authorization) {
if (authorization != null && !authorization.isEmpty()) {
conn.setRequestProperty("Cookie", "Authorization=" + authorization);
}
}
private boolean isLoginPage(String responseBody) {
if (responseBody == null) {
return false;
}
String body = responseBody.trim().toLowerCase();
return body.startsWith("<!doctype") || body.startsWith("<html")
|| (body.contains("<form") && body.contains("password"));
}
private String getAuthorizationCookie(HttpURLConnection conn) {
for (Map.Entry<String, List<String>> entry : conn.getHeaderFields().entrySet()) {
if (entry.getKey() == null || !"Set-Cookie".equalsIgnoreCase(entry.getKey())) {
continue;
}
String authorization = getAuthorizationValue(entry.getValue());
if (authorization != null && !authorization.isEmpty()) {
return authorization;
}
}
return null;
}
private String getAuthorizationValue(List<String> cookies) {
if (cookies == null || cookies.isEmpty()) {
return null;
}
for (String cookie : cookies) {
String prefix = "Authorization=";
int start = cookie.indexOf(prefix);
if (start < 0) {
continue;
}
start += prefix.length();
int end = cookie.indexOf(';', start);
return end >= 0 ? cookie.substring(start, end) : cookie.substring(start);
}
return null;
}
/**
* 解析通道状态JSON
*/
public JSONArray parseChannelData(String jsonData) {
if (jsonData == null || jsonData.isEmpty() || "[]".equals(jsonData)) {
return new JSONArray();
}
return JSON.parseArray(jsonData);
}
/**
* 解析录音记录JSON
*/
public JSONArray parseRecordData(String jsonData) {
if (jsonData == null || jsonData.isEmpty() || "[]".equals(jsonData)) {
return new JSONArray();
}
return JSON.parseArray(jsonData);
}
/**
* 获取分机号码配置
* EBOX接口: GET /service/ext/number
* @param extUrl 接口地址
* @param authorization 登录后返回的Authorization Cookie值
* @return 通道-分机号映射 Map<channel, phoneNumber>
*/
public Map<String, String> getExtensionNumbers(String extUrl, String authorization) throws Exception {
String jsonData = httpVisit(extUrl, authorization);
if (jsonData == null || jsonData.isEmpty() || "[]".equals(jsonData) || "{}".equals(jsonData)) {
return new java.util.HashMap<>();
}
try {
com.alibaba.fastjson2.JSONObject jsonObj = JSON.parseObject(jsonData);
Map<String, String> result = new java.util.HashMap<>();
for (String key : jsonObj.keySet()) {
String value = jsonObj.getString(key);
result.put(key, value != null ? value : "");
}
logger.info("获取到分机号码配置: {} 条", result.size());
return result;
} catch (Exception e) {
logger.warn("解析分机号码配置失败: {}", e.getMessage());
return new java.util.HashMap<>();
}
}
}

72
src/main/java/com/threecloud/dataserviceyy/util/fileUtil.java

@ -1,72 +0,0 @@
package com.threecloud.dataserviceyy.util;
import com.threecloud.dataserviceyy.entity.ResultEntity;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import javax.net.ssl.HttpsURLConnection;
import java.io.IOException;
import java.net.HttpURLConnection;
public class fileUtil {
/**
* 通过oss上传文件
* @param mf
* @return ResultEntity
* @throws IOException
*/
private ResultEntity uploadFilesByOss(MultipartFile mf) throws IOException{
String ossUrl = "http://127.0.0.1/apiOss";//baseOssConfigService.readOSsUrl();// OSS管理服务地址
String ossPreviewUrl = "http://127.0.0.1/apiOssPreview/onlinePreview";//baseOssConfigService.readOSsPreviewUrl();// OSS预览服务地址
String ossAppid = "371a3368-e28e-4ba3-95a3-c31c19cf0ad0";//baseOssConfigService.readOSsAppid();// OSS应用ID
String ossAppsecret = "06a6a80e-f9d2-4b3b-acc0-8d182c876074";//baseOssConfigService.readOSsAppsecret();// OSS应用密钥
String ossAppcode = "dataservice-yy";//baseOssConfigService.readOSsAppcode();// OSS应用别名
ResponseEntity<ResultEntity> responseEntity = null;
RestTemplate restTemplate = createRestTemplate(ossUrl);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> files = new LinkedMultiValueMap<String, Object>();
//for (MultipartFile file : multipartFiles) {
ByteArrayResource byteResource = new ByteArrayResource(mf.getBytes()){
@Override
public String getFilename() {
return mf.getOriginalFilename();
}
};
files.add("files", byteResource);//上传文件
//}
files.add("appcode", ossAppcode);//项目编号
files.set("appcode", ossAppcode);
files.set("appid", ossAppid);
files.set("appsecret", ossAppsecret);
HttpEntity<MultiValueMap<String, Object>> httpEntity =
new HttpEntity<MultiValueMap<String, Object>>(files,headers);
responseEntity = restTemplate.exchange(ossUrl + "/oss/fileUpload", HttpMethod.POST, httpEntity, ResultEntity.class);
return responseEntity.getBody();
}
/**
* 创建支持自定义SSL配置的RestTemplate
* 针对HTTPS请求配置信任所有证书并关闭Hostname验证以兼容自签名证书
*/
private RestTemplate createRestTemplate(String ossUrl) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory() {
@Override
protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException {
if (ossUrl != null && ossUrl.startsWith("https") && connection instanceof HttpsURLConnection) {
}
super.prepareConnection(connection, httpMethod);
}
};
requestFactory.setBufferRequestBody(false);
return new RestTemplate(requestFactory);
}
}

75
src/main/resources/mapper/FtpProcessedFileMapper.xml

@ -0,0 +1,75 @@
<?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.FtpProcessedFileMapper">
<resultMap id="BaseResultMap" type="com.threecloud.dataserviceyy.entity.FtpProcessedFile">
<id column="id" property="id"/>
<result column="city_code" property="cityCode"/>
<result column="city_name" property="cityName"/>
<result column="file_name" property="fileName"/>
<result column="file_size" property="fileSize"/>
<result column="process_status" property="processStatus"/>
<result column="record_count" property="recordCount"/>
<result column="success_count" property="successCount"/>
<result column="fail_count" property="failCount"/>
<result column="process_time" property="processTime"/>
<result column="error_msg" property="errorMsg"/>
<result column="create_time" property="createTime"/>
</resultMap>
<!-- 插入处理记录 -->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO mid_ftp_processed_file (
city_code, city_name, file_name, file_size, process_status,
record_count, success_count, fail_count, process_time, error_msg, create_time
) VALUES (
#{cityCode}, #{cityName}, #{fileName}, #{fileSize}, #{processStatus},
#{recordCount}, #{successCount}, #{failCount}, #{processTime}, #{errorMsg}, NOW()
)
</insert>
<!-- 根据地市编码和文件名查询 -->
<select id="selectByCityAndFileName" resultMap="BaseResultMap">
SELECT * FROM mid_ftp_processed_file
WHERE city_code = #{cityCode}
AND file_name = #{fileName}
LIMIT 1
</select>
<!-- 查询指定地市下所有已处理的文件名列表 -->
<select id="selectProcessedFileNamesByCity" resultType="java.lang.String">
SELECT file_name FROM mid_ftp_processed_file
WHERE city_code = #{cityCode}
AND process_status = '1'
</select>
<!-- 批量查询指定地市下多个文件的处理状态 -->
<select id="selectByCityAndFileNames" resultMap="BaseResultMap">
SELECT * FROM mid_ftp_processed_file
WHERE city_code = #{cityCode}
AND file_name IN
<foreach collection="fileNames" item="name" open="(" separator="," close=")">
#{name}
</foreach>
</select>
<!-- 根据ID更新处理结果 -->
<update id="updateById">
UPDATE mid_ftp_processed_file
SET process_status = #{processStatus},
record_count = #{recordCount},
success_count = #{successCount},
fail_count = #{failCount},
process_time = #{processTime},
error_msg = #{errorMsg}
WHERE id = #{id}
</update>
<!-- 删除指定时间之前的记录 -->
<delete id="deleteBeforeTime">
DELETE FROM mid_ftp_processed_file
WHERE city_code = #{cityCode}
AND create_time &lt; TO_TIMESTAMP(#{beforeTime}, 'YYYY-MM-DD HH24:MI:SS')
</delete>
</mapper>

6
src/main/resources/mapper/MidVoiceCallRecordMapper.xml

@ -61,6 +61,12 @@
AND call_start_time &lt;= TO_TIMESTAMP(#{endTime}, 'YYYY-MM-DD HH24:MI:SS')
</select>
<!-- 按前缀查询call_record_id(FTP防重专用) -->
<select id="selectCallRecordIdsByPrefix" resultType="java.lang.String">
SELECT call_record_id FROM mid_voice_call_record
WHERE call_record_id LIKE CONCAT(#{prefix}, '%')
</select>
<!-- 根据设备编码查询 -->
<select id="selectByDeviceNo" resultMap="BaseResultMap">
SELECT * FROM mid_voice_call_record

85
src/main/resources/mapper/SyncLogMapper.xml

@ -1,85 +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.SyncLogMapper">
<resultMap id="BaseResultMap" type="com.threecloud.dataserviceyy.entity.SyncLog">
<id column="id" property="id"/>
<result column="device_id" property="deviceId"/>
<result column="device_name" property="deviceName"/>
<result column="start_time" property="startTime"/>
<result column="end_time" property="endTime"/>
<result column="total_count" property="totalCount"/>
<result column="success_count" property="successCount"/>
<result column="fail_count" property="failCount"/>
<result column="status" property="status"/>
<result column="error_msg" property="errorMsg"/>
<result column="create_time" property="createTime"/>
</resultMap>
<!-- 插入同步日志 -->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO sync_log (
device_id, device_name, start_time, end_time, total_count,
success_count, fail_count, status, error_msg, create_time
) VALUES (
#{deviceId}, #{deviceName}, #{startTime}, #{endTime}, #{totalCount},
#{successCount}, #{failCount}, #{status}, #{errorMsg}, NOW()
)
</insert>
<!-- 根据ID查询 -->
<select id="selectById" resultMap="BaseResultMap">
SELECT * FROM sync_log WHERE id = #{id}
</select>
<!-- 查询设备的同步日志 -->
<select id="selectByDeviceId" resultMap="BaseResultMap">
SELECT * FROM sync_log
WHERE device_id = #{deviceId}
ORDER BY create_time DESC
</select>
<!-- 分页查询 -->
<select id="selectList" resultMap="BaseResultMap">
SELECT * FROM sync_log
WHERE 1=1
<if test="deviceId != null and deviceId != ''">
AND device_id = #{deviceId}
</if>
<if test="startTime != null and startTime != ''">
AND start_time &gt;= #{startTime}
</if>
<if test="endTime != null and endTime != ''">
AND start_time &lt;= #{endTime}
</if>
ORDER BY create_time DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<!-- 统计总数 -->
<select id="count" resultType="int">
SELECT COUNT(*) FROM sync_log
WHERE 1=1
<if test="deviceId != null and deviceId != ''">
AND device_id = #{deviceId}
</if>
<if test="startTime != null and startTime != ''">
AND start_time &gt;= #{startTime}
</if>
<if test="endTime != null and endTime != ''">
AND start_time &lt;= #{endTime}
</if>
</select>
<!-- 更新同步结果 -->
<update id="updateResult">
UPDATE sync_log
SET end_time = #{endTime},
success_count = #{successCount},
fail_count = #{failCount},
status = #{status},
error_msg = #{errorMsg}
WHERE id = #{id}
</update>
</mapper>

117
src/main/resources/mapper/VoiceRecordMapper.xml

@ -1,117 +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.VoiceRecordMapper">
<resultMap id="BaseResultMap" type="com.threecloud.dataserviceyy.entity.VoiceRecord">
<id column="id" property="id"/>
<result column="device_id" property="deviceId"/>
<result column="device_uuid" property="deviceUuid"/>
<result column="device_name" property="deviceName"/>
<result column="record_id" property="recordId"/>
<result column="file_name" property="fileName"/>
<result column="file_path" property="filePath"/>
<result column="oss_url" property="ossUrl"/>
<result column="local_path" property="localPath"/>
<result column="call_start_time" property="callStartTime"/>
<result column="call_end_time" property="callEndTime"/>
<result column="duration" property="duration"/>
<result column="channel" property="channel"/>
<result column="phone" property="phone"/>
<result column="direction" property="direction"/>
<result column="status" property="status"/>
<result column="fail_reason" property="failReason"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
</resultMap>
<!-- 插入录音记录 -->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO voice_record (
device_id, device_uuid, device_name, record_id, file_name, file_path,
oss_url, local_path, call_start_time, call_end_time, duration,
channel, phone, direction, status, fail_reason, create_time, update_time
) VALUES (
#{deviceId}, #{deviceUuid}, #{deviceName}, #{recordId}, #{fileName}, #{filePath},
#{ossUrl}, #{localPath}, #{callStartTime}, #{callEndTime}, #{duration},
#{channel}, #{phone}, #{direction}, #{status}, #{failReason}, NOW(), NOW()
)
</insert>
<!-- 根据ID查询 -->
<select id="selectById" resultMap="BaseResultMap">
SELECT * FROM voice_record WHERE id = #{id}
</select>
<!-- 根据设备ID和录音ID查询(防止重复) -->
<select id="selectByDeviceAndRecordId" resultMap="BaseResultMap">
SELECT * FROM voice_record
WHERE device_id = #{deviceId} AND record_id = #{recordId}
LIMIT 1
</select>
<!-- 查询设备的所有录音 -->
<select id="selectByDeviceId" resultMap="BaseResultMap">
SELECT * FROM voice_record
WHERE device_id = #{deviceId}
ORDER BY call_start_time DESC
</select>
<!-- 分页查询 -->
<select id="selectList" resultMap="BaseResultMap">
SELECT * FROM voice_record
WHERE 1=1
<if test="deviceId != null and deviceId != ''">
AND device_id = #{deviceId}
</if>
<if test="deviceName != null and deviceName != ''">
AND device_name LIKE CONCAT('%', #{deviceName}, '%')
</if>
<if test="phone != null and phone != ''">
AND phone LIKE CONCAT('%', #{phone}, '%')
</if>
<if test="startTime != null and startTime != ''">
AND call_start_time &gt;= #{startTime}
</if>
<if test="endTime != null and endTime != ''">
AND call_start_time &lt;= #{endTime}
</if>
ORDER BY call_start_time DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<!-- 统计总数 -->
<select id="count" resultType="int">
SELECT COUNT(*) FROM voice_record
WHERE 1=1
<if test="deviceId != null and deviceId != ''">
AND device_id = #{deviceId}
</if>
<if test="deviceName != null and deviceName != ''">
AND device_name LIKE CONCAT('%', #{deviceName}, '%')
</if>
<if test="phone != null and phone != ''">
AND phone LIKE CONCAT('%', #{phone}, '%')
</if>
<if test="startTime != null and startTime != ''">
AND call_start_time &gt;= #{startTime}
</if>
<if test="endTime != null and endTime != ''">
AND call_start_time &lt;= #{endTime}
</if>
</select>
<!-- 更新OSS地址 -->
<update id="updateOssUrl">
UPDATE voice_record
SET oss_url = #{ossUrl}, update_time = NOW()
WHERE id = #{id}
</update>
<!-- 更新状态 -->
<update id="updateStatus">
UPDATE voice_record
SET status = #{status}, fail_reason = #{failReason}, update_time = NOW()
WHERE id = #{id}
</update>
</mapper>

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

@ -1,20 +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.VoiceSyncMapper">
<!-- 查询所有在线语音设备(含每个设备的账号密码) -->
<select id="getAllYysb" resultType="java.util.Map">
SELECT id AS ID,
device_no AS UUID,
city_name AS ORGAN_NAME,
city_code AS ORGAN_ID,
ip_address AS IP,
device_port AS PORT,
org_code AS ORG_CODE,
username AS USERNAME,
password AS PASSWORD
FROM mid_voice.mid_voice_device_config
WHERE device_status = '1'
</select>
</mapper>
Loading…
Cancel
Save