commit 20252cfba3089519541df79fff6ed1dd36729da0 Author: wang Date: Wed May 27 15:58:47 2026 +0800 first commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..4726319 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..717a49a --- /dev/null +++ b/README.md @@ -0,0 +1,216 @@ +# 语音数据同步服务 (dataservice-yy) + +## 项目简介 + +语音数据同步服务用于从 EBOX 录音盒拉取录音文件,上传到 OSS,并保存通话记录到数据库。 + +## 目录结构 + +``` +dataservice-yy/ +├── dataservice-yy-0.0.1-SNAPSHOT.jar # 主程序 JAR 包 +├── config/ +│ └── application-external.yml # 外部配置文件(部署时修改) +├── vaa-recordings/ # 本地录音文件存储目录 +│ ├── 340100/ # 地市编码 +│ │ ├── 20240101/ # 日期目录 +│ │ │ └── / # 设备目录 +│ │ │ └── xxx.wav +│ │ └── 20240102/ +│ └── ... +├── logs/ +│ └── app.log # 日志文件 +└── README.md # 本说明文档 +``` + +## 部署步骤 + +### 1. 准备环境 + +- JDK 1.8+ +- 人大金仓数据库(已创建 mid_voice schema) +- OSS 服务(已部署并配置好上传接口) + +### 2. 创建目录结构 + +```bash +mkdir -p /opt/dataservice-yy/config +mkdir -p /opt/dataservice-yy/logs +``` + +### 3. 复制文件 + +```bash +# 复制 JAR 包 +cp dataservice-yy-0.0.1-SNAPSHOT.jar /opt/dataservice-yy/ + +# 复制配置文件模板 +cp config/application-external.yml /opt/dataservice-yy/config/ +``` + +### 4. 修改配置 + +编辑 `/opt/dataservice-yy/config/application-external.yml`,修改以下配置项: + +#### 数据库配置 +```yaml +spring: + datasource: + url: jdbc:kingbase8://{数据库IP}:{端口}/kingbase?currentSchema=mid_voice&clientEncoding=utf8 + username: {数据库用户名} + password: {数据库密码} +``` + +#### OSS 配置 +```yaml +vaa-sync: + oss: + base-url: http://{OSS服务器IP}:9090 + upload-url: http://{OSS服务器IP}:9090/apiOss/oss/fileUpload + appcode: dataservice-yy + 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. 启动服务 + +```bash +cd /opt/dataservice-yy +nohup java -jar dataservice-yy-0.0.1-SNAPSHOT.jar > /dev/null 2>&1 & +``` + +### 6. 查看日志 + +```bash +tail -f logs/app.log +``` + +## 配置文件说明 + +### application-external.yml + +此文件包含所有需要部署时调整的参数,**无需重新打包 JAR**。 + +| 配置项 | 说明 | 示例 | +|-------|------|------| +| `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` | + +## 定时任务说明 + +同步任务默认每2小时执行一次,可通过修改 `sync-interval-cron` 调整: + +| 表达式 | 说明 | +|-------|------| +| `0 0 0/2 * * ?` | 每2小时执行一次(默认) | +| `0 0/30 * * * ?` | 每30分钟执行一次 | +| `0 0 2 * * ?` | 每天凌晨2点执行 | +| `0 0/1 * * * ?` | 每分钟执行(测试用) | + +## 数据表说明 + +### mid_voice_device_config + +设备基础配置表,存储录音盒设备信息。 + +| 字段 | 说明 | +|-----|------| +| device_no | 设备编号(UUID) | +| city_code | 地市编码 | +| city_name | 地市名称 | +| ip_address | IP地址 | +| device_port | 端口号 | +| device_status | 设备状态(0在线) | + +### mid_voice_channel_config + +通道配置表,存储录音盒通道绑定的电话号码。 + +| 字段 | 说明 | +|-----|------| +| device_no | 设备编号 | +| channel_no | 通道号(1-8) | +| phone_number | 绑定电话号码 | + +### mid_voice_call_record + +通话记录表,存储录音文件信息和OSS地址。 + +| 字段 | 说明 | +|-----|------| +| 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 "【异常】" + +# 只看设备同步日志 +tail -f logs/app.log | grep "【设备】" + +# 只看录音处理日志 +tail -f logs/app.log | grep "【录音】" +``` + +## 常见问题 + +### 1. 配置文件读取失败 + +检查 `config/application-external.yml` 是否在 JAR 包同级目录的 `config/` 文件夹下。 + +### 2. 数据库连接失败 + +检查数据库URL、用户名、密码是否正确,网络是否连通。 + +### 3. OSS上传失败 + +检查 OSS 地址、认证信息是否正确,OSS服务是否正常。 + +### 4. 录音盒连接失败 + +检查录音盒IP、端口、账号密码是否正确,网络是否连通。 + +## 技术支持 + +如有问题,请联系开发人员。 diff --git a/VAA_SYNC_README.md b/VAA_SYNC_README.md new file mode 100644 index 0000000..0344ef1 --- /dev/null +++ b/VAA_SYNC_README.md @@ -0,0 +1,244 @@ +# VAA录音盒HTTP同步服务 + +## 📋 概述 + +基于合肥实现方案,通过HTTP API从VAA录音盒设备定时拉取录音数据并上传到OSS。 + +## 🔧 核心特性 + +1. **HTTP API通信** - 使用录音盒提供的RESTful API +2. **定时同步** - 每2小时自动执行一次同步任务 +3. **增量同步** - 根据上次同步时间,只拉取新增录音 +4. **自动登录** - 每次同步前自动认证 +5. **通道状态更新** - 同步时更新设备通道状态 +6. **OSS上传** - 录音文件自动上传到对象存储 +7. **完整日志** - 记录每次同步的结果 + +## 📊 数据流程 + +``` +┌──────────────┐ +│ 定时触发 │ (每2小时) +└──────┬───────┘ + ↓ +┌──────────────┐ +│ 查询设备列表 │ (YYDC_YYSB表) +└──────┬───────┘ + ↓ +┌──────────────┐ +│ HTTP登录认证 │ (/authorize) +└──────┬───────┘ + ↓ +┌──────────────┐ +│ 获取通道状态 │ (/service/running/channel) +└──────┬───────┘ + ↓ +┌──────────────┐ +│ 获取录音列表 │ (/service/record/~/time[开始,结束]) +└──────┬───────┘ + ↓ +┌──────────────┐ +│ 下载录音文件 │ (HTTP下载) +└──────┬───────┘ + ↓ +┌──────────────┐ +│ 上传到OSS │ (FileUploadUtil) +└──────┬───────┘ + ↓ +┌──────────────┐ +│ 保存到数据库 │ (YYDC_YYTH表) +└──────┬───────┘ + ↓ +┌──────────────┐ +│ 记录同步日志 │ (YYDC_TBRZ表) +└──────────────┘ +``` + +## 🗄️ 数据库表要求 + +### YYDC_YYSB (语音设备表) + +必需字段: +- `ID` - 设备ID +- `UUID` - 设备UUID +- `ORGAN_NAME` - 机构名称 +- `ORGAN_ID` - 机构ID +- `IP` - 设备IP地址 +- `PORT` - 设备端口(默认80) +- `SBYH` - 设备用户名 +- `SBMM` - 设备密码 +- `SFYX_ST` - 是否有效('1':有效) + +### YYDC_SBTD (设备通道表) + +必需字段: +- `ID` - 通道ID +- `UUID` - 关联设备UUID +- `TDHM` - 通道号码 +- `PHONE` - 电话号码 +- `TDZT` - 通道状态 +- `SFYX_ST` - 是否有效 + +### YYDC_YYTH (通话记录表) + +必需字段: +- `ID` - 通话ID(格式:设备ID_录音ID_开始时间戳) +- `ORGAN_NAME`, `ORGAN_ID` - 机构信息 +- `YYSB_ID` - 语音设备ID +- `SBTD_ID` - 设备通道ID +- `THFX` - 通话方向('1':呼出, '0':呼入) +- `PHONE`, `ZJHM`, `BJHM` - 号码信息 +- `THSC` - 通话时长(秒) +- `KSSJ`, `JSSJ` - 开始/结束时间 +- `LYDZ` - 录音地址(OSS URL) +- `LYMC` - 录音文件名 + +### YYDC_TBRZ (同步日志表) + +必需字段: +- `CODE` - 同步代码(格式:YYDC.AUDIO(设备ID)) +- `KSSJ` - 开始时间 +- `JSSJ` - 结束时间 +- `GXSJ` - 更新时间 +- `TBBZ` - 同步标志('1':成功, '0':失败) +- `TBSM` - 同步说明 + +## ⚙️ 配置说明 + +### application.yml + +```yaml +# 本地IP(用于生成录音访问URL) +server: + local-ip: 127.0.0.1 + +# VAA同步配置 +vaa-sync: + download-path: /tmp/vaa-recordings # 临时下载目录 + sync-interval-cron: "0 0 0/2 * * ?" # 每2小时同步 +``` + +## 🚀 使用方法 + +### 1. 配置设备信息 + +在数据库中插入设备信息: + +```sql +INSERT INTO YYDC_YYSB (ID, UUID, ORGAN_NAME, ORGAN_ID, IP, PORT, SBYH, SBMM, SFYX_ST) +VALUES ('device-001', 'uuid-001', '黄山市局', 1001, '192.168.1.100', 80, 'admin', 'admin123', '1'); +``` + +### 2. 配置通道信息 + +```sql +INSERT INTO YYDC_SBTD (ID, UUID, TDHM, PHONE, SFYX_ST) +VALUES ('channel-001', 'uuid-001', '1', '13800138000', '1'); +``` + +### 3. 启动应用 + +```bash +mvn spring-boot:run +``` + +应用启动后,定时任务会自动执行。 + +### 4. 手动触发(可选) + +可以通过注入 `VaaSyncService` 并调用 `executeSync()` 方法手动触发同步。 + +## 📝 VAA录音盒API说明 + +### 1. 登录认证 + +``` +GET http://{IP}/authorize?username={用户}&password={密码} +``` + +### 2. 获取通道状态 + +``` +GET http://{IP}/service/running/channel +``` + +返回示例: +```json +[ + {"ch": "1", "state": "1"}, + {"ch": "2", "state": "0"} +] +``` + +### 3. 获取录音列表 + +``` +GET http://{IP}/service/record/~/time[开始时间戳,结束时间戳] +``` + +返回示例: +```json +[ + { + "id": "12345", + "channel": "1", + "phone": "13800138000", + "file": "/recordings/20260520/xxx.wav", + "begtime": 1716192000, + "endtime": 1716192120, + "state": "1" + } +] +``` + +### 4. 下载录音文件 + +``` +GET http://{IP}{文件路径} +``` + +## 🔍 日志说明 + +同步过程会输出详细日志: + +``` +========== 定时任务触发: VAA录音盒同步 ========== +开始同步设备: ID=device-001, 机构=黄山市局, IP=192.168.1.100:80 +正在登录设备... +登录成功 +获取录音列表: http://192.168.1.100/service/record/~/time[1716105600,1716192000] +获取到 5 条录音记录 +开始下载录音: /recordings/20260520/xxx.wav +录音下载完成: /tmp/vaa-recordings/20260520/uuid-001/xxx.wav +录音上传OSS: https://oss.example.com/voice/黄山市局/20260520/xxx.wav +插入通话记录: device-001_12345_1716192000 +设备同步完成: ID=device-001, 成功5条 +========== VAA录音盒同步任务完成 ========== +``` + +## ⚠️ 注意事项 + +1. **网络连接** - 确保服务器能访问录音盒设备的IP和端口 +2. **存储空间** - 确保 `/tmp/vaa-recordings` 有足够的空间 +3. **时间同步** - 确保服务器时间与录音盒时间一致 +4. **并发控制** - 每个设备独立执行,不会相互阻塞 +5. **错误处理** - 单个设备失败不影响其他设备 + +## 🔄 与FTP方式的对比 + +| 特性 | HTTP API方式 | FTP方式 | +|------|-------------|---------| +| 连接方式 | HTTP RESTful API | FTP协议 | +| 认证方式 | URL参数认证 | 用户名密码 | +| 数据获取 | JSON API | 解析.txt索引文件 | +| 文件下载 | HTTP GET | FTP RETR | +| 实时性 | 更好(API直接查询) | 依赖文件生成 | +| 复杂度 | 较低 | 较高 | + +## 📞 技术支持 + +如有问题,请检查: +1. 设备IP、端口、用户名、密码是否正确 +2. 网络是否能访问设备 +3. 数据库表结构是否正确 +4. 日志中的错误信息 diff --git a/config/application-external.yml b/config/application-external.yml new file mode 100644 index 0000000..c220ce8 --- /dev/null +++ b/config/application-external.yml @@ -0,0 +1,76 @@ +# ============================================ +# 语音数据同步服务 - 外部配置文件 +# ============================================ +# 【说明】 +# 1. 此文件用于部署时覆盖默认配置,无需重新打包 JAR +# 2. 将此文件放在 JAR 包同级目录的 config/ 文件夹下 +# 3. 修改此文件后重启服务即可生效 +# ============================================ + +spring: + # ==================== 数据库配置 ==================== + datasource: + # 人大金仓数据库驱动(一般无需修改) + driver-class-name: com.kingbase8.Driver + + # 数据库连接URL + # 格式: jdbc:kingbase8://{host}:{port}/{database}?currentSchema={schema}&clientEncoding=utf8 + # 示例: jdbc:kingbase8://53.1.194.60:54321/kingbase?currentSchema=mid_voice&clientEncoding=utf8 + url: jdbc:kingbase8://127.0.0.1:54321/kingbase?currentSchema=mid_voice&clientEncoding=utf8 + + # 数据库用户名 + username: dcms_dev + + # 数据库密码 + password: your_password_here + +# ==================== 语音同步配置 ==================== +vaa-sync: + # 本地录音文件存储路径(相对路径或绝对路径) + # 示例: ./vaa-recordings 或 /opt/dataservice-yy/vaa-recordings + download-path: ./vaa-recordings + + # 同步定时任务 Cron 表达式 + # 默认每2小时执行一次: 0 0 0/2 * * ? + # 每30分钟执行: 0 0/30 * * * ? + # 每天凌晨2点执行: 0 0 2 * * ? + # 每分钟执行(测试用): 0 0/1 * * * ? + sync-interval-cron: "0 0 0/2 * * ?" + + # 录音盒登录账号密码 + device-username: admin + device-password: admin + + # OSS 文件上传配置 + oss: + # OSS 服务基础地址(用于拼接完整URL) + # 示例: http://53.1.194.59:9090 + base-url: http://127.0.0.1:9090 + + # OSS 上传接口完整地址 + # 示例: http://53.1.194.59:9090/apiOss/oss/fileUpload + upload-url: http://127.0.0.1:9090/apiOss/oss/fileUpload + + # OSS 认证信息(请向管理员索取) + appcode: dataservice-yy + appid: your_appid_here + appsecret: your_appsecret_here + +# ==================== 服务端口配置 ==================== +server: + # 服务端口 + port: 8088 + +# ==================== 日志配置 ==================== +logging: + # 日志文件路径 + file: + name: logs/app.log + + # 日志级别 + level: + # 根日志级别: INFO(生产) / DEBUG(测试) + root: INFO + + # 本项目代码日志级别 + com.threecloud.dataserviceyy: DEBUG diff --git a/logs/.DS_Store b/logs/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/logs/.DS_Store differ diff --git a/logs/app.log b/logs/app.log new file mode 100644 index 0000000..9da34a8 --- /dev/null +++ b/logs/app.log @@ -0,0 +1,76 @@ +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. diff --git a/logs/app.log.2026-05-21.0.gz b/logs/app.log.2026-05-21.0.gz new file mode 100644 index 0000000..2756216 Binary files /dev/null and b/logs/app.log.2026-05-21.0.gz differ diff --git a/logs/app.log.2026-05-22.0.gz b/logs/app.log.2026-05-22.0.gz new file mode 100644 index 0000000..cfdf38e Binary files /dev/null and b/logs/app.log.2026-05-22.0.gz differ diff --git a/logs/app.log.2026-05-23.0.gz b/logs/app.log.2026-05-23.0.gz new file mode 100644 index 0000000..71b99c6 Binary files /dev/null and b/logs/app.log.2026-05-23.0.gz differ diff --git a/logs/app.log.2026-05-25.0.gz b/logs/app.log.2026-05-25.0.gz new file mode 100644 index 0000000..f8ebb5b Binary files /dev/null and b/logs/app.log.2026-05-25.0.gz differ diff --git a/logs/server.jar b/logs/server.jar new file mode 100755 index 0000000..e698840 Binary files /dev/null and b/logs/server.jar differ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6014e24 --- /dev/null +++ b/pom.xml @@ -0,0 +1,192 @@ + + + 4.0.0 + + com.threecloud + dataservice-yy + 0.0.1-SNAPSHOT + dataservice-yy + 数据同步服务 + + + 8 + UTF-8 + UTF-8 + + 2.6.13 + + 8.0.28 + 2.2.2 + 1.4.6 + 8.6.0 + 21.9.0.0 + + 1.18.24 + 31.1-jre + 5.8.18 + 2.0.25 + 1.15 + 2.11.0 + 2.11.1 + 3.9.0 + + 5.8.2 + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.springframework.boot + spring-boot-configuration-processor + true + + + cn.com.kingbase + kingbase8 + ${kingbase.version} + + + com.oracle.database.jdbc + ojdbc8 + ${ojdbc.version} + + + + org.mybatis.spring.boot + mybatis-spring-boot-starter + ${mybatis.version} + + + com.github.pagehelper + pagehelper-spring-boot-starter + ${pagehelper.version} + + + mysql + mysql-connector-java + ${mysql.version} + + + + org.projectlombok + lombok + ${lombok.version} + true + + + com.google.guava + guava + ${guava.version} + + + cn.hutool + hutool-all + ${hutool.version} + + + com.alibaba.fastjson2 + fastjson2 + ${fastjson2.version} + + + commons-codec + commons-codec + ${commons-codec.version} + + + commons-io + commons-io + ${commons-io.version} + + + org.apache.commons + commons-pool2 + ${commons-pool2.version} + + + commons-net + commons-net + ${commons-net.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 8 + 8 + UTF-8 + + **/老代码/** + + + + org.projectlombok + lombok + ${lombok.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + com.threecloud.dataserviceyy.DataserviceYyApplication + false + + + + + repackage + + + + + + + diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..28d25b7 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/main/.DS_Store b/src/main/.DS_Store new file mode 100644 index 0000000..85372e9 Binary files /dev/null and b/src/main/.DS_Store differ diff --git a/src/main/java/.DS_Store b/src/main/java/.DS_Store new file mode 100644 index 0000000..308f50b Binary files /dev/null and b/src/main/java/.DS_Store differ diff --git a/src/main/java/com/threecloud/dataserviceyy/DataserviceYyApplication.java b/src/main/java/com/threecloud/dataserviceyy/DataserviceYyApplication.java new file mode 100644 index 0000000..c31678c --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/DataserviceYyApplication.java @@ -0,0 +1,44 @@ +package com.threecloud.dataserviceyy; + +import com.threecloud.dataserviceyy.service.VaaSyncService; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication(exclude = { + org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration.class, + org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration.class +}) +@MapperScan("com.threecloud.dataserviceyy.mapper") +@EnableScheduling +public class DataserviceYyApplication { + + @Autowired + private VaaSyncService vaaSyncService; + + public static void main(String[] args) { + SpringApplication.run(DataserviceYyApplication.class, args); + } + + @Bean + public CommandLineRunner startupRunner() { + return args -> { + System.out.println("========================================"); + System.out.println("【项目启动】开始执行首次语音数据同步"); + System.out.println("========================================"); + try { + vaaSyncService.executeSync(); + } catch (Exception e) { + System.err.println("【项目启动】首次同步失败: " + e.getMessage()); + e.printStackTrace(); + } + System.out.println("========================================"); + System.out.println("【项目启动】首次同步完成,后续将在每2小时自动执行"); + System.out.println("========================================"); + }; + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/config/DataSourceConfig.java b/src/main/java/com/threecloud/dataserviceyy/config/DataSourceConfig.java new file mode 100644 index 0000000..e7560f0 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/config/DataSourceConfig.java @@ -0,0 +1,63 @@ +package com.threecloud.dataserviceyy.config; + +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +import javax.sql.DataSource; + +@Configuration +public class DataSourceConfig { + + @Value("${spring.datasource.url}") + private String url; + + @Value("${spring.datasource.username}") + private String username; + + @Value("${spring.datasource.password}") + private String password; + + @Value("${spring.datasource.driver-class-name}") + private String driverClassName; + + @Bean + public DataSource dataSource() { + System.out.println("========================================"); + System.out.println("【DataSourceConfig】创建数据源"); + System.out.println("Driver: " + driverClassName); + System.out.println("URL: " + url); + System.out.println("Username: " + username); + System.out.println("Password length: " + (password != null ? password.length() : 0)); + System.out.println("========================================"); + + HikariDataSource datasource = new HikariDataSource(); + datasource.setJdbcUrl(url); + datasource.setDriverClassName(driverClassName); + datasource.setUsername(username); + datasource.setPassword(password); + datasource.setMinimumIdle(0); + datasource.setMaximumPoolSize(10); + datasource.setConnectionTimeout(30000); + datasource.setValidationTimeout(5000); + + // 根据数据库类型设置不同的验证SQL + if (url.contains("kingbase")) { + datasource.setConnectionTestQuery("SELECT 1"); + } else { + datasource.setConnectionTestQuery("SELECT 1 FROM DUAL"); + datasource.addDataSourceProperty("oracle.jdbc.timezoneAsRegion", "false"); + datasource.addDataSourceProperty("oracle.net.CONNECT_TIMEOUT", "10000"); + datasource.addDataSourceProperty("oracle.net.READ_TIMEOUT", "60000"); + } + + // 关键:初始化时不测试连接,即使数据库暂时连不上也能启动 + datasource.setInitializationFailTimeout(-1); + + System.out.println("【DataSourceConfig】数据源创建完成(允许延迟连接)"); + + return datasource; + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/config/DynamicDataSourceConfig.java b/src/main/java/com/threecloud/dataserviceyy/config/DynamicDataSourceConfig.java new file mode 100644 index 0000000..c3bf7f7 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/config/DynamicDataSourceConfig.java @@ -0,0 +1,12 @@ +package com.threecloud.dataserviceyy.config; + +/** + * 多数据源配置 - 【方案A阶段: 已完全禁用】 + * + * 此类在方案B(多数据源模式)时会重新启用。 + * 当前阶段使用Spring Boot默认的DataSource自动配置,仿照老系统 yydc-tb-server。 + */ +// @Configuration // 【方案A: 已禁用】 +public class DynamicDataSourceConfig { + // 所有方法已禁用,不再干扰Spring Boot的自动配置 +} diff --git a/src/main/java/com/threecloud/dataserviceyy/config/FileUploadConfig.java b/src/main/java/com/threecloud/dataserviceyy/config/FileUploadConfig.java new file mode 100644 index 0000000..8d4dab7 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/config/FileUploadConfig.java @@ -0,0 +1,16 @@ +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 extraParams = new HashMap<>(); +} diff --git a/src/main/java/com/threecloud/dataserviceyy/config/SyncTargetConfig.java b/src/main/java/com/threecloud/dataserviceyy/config/SyncTargetConfig.java new file mode 100644 index 0000000..4c70de8 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/config/SyncTargetConfig.java @@ -0,0 +1,13 @@ +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; +} diff --git a/src/main/java/com/threecloud/dataserviceyy/controller/VoiceBoxController.java b/src/main/java/com/threecloud/dataserviceyy/controller/VoiceBoxController.java new file mode 100644 index 0000000..5afe851 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/controller/VoiceBoxController.java @@ -0,0 +1,270 @@ +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 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 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); + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/entity/CallRecord.java b/src/main/java/com/threecloud/dataserviceyy/entity/CallRecord.java new file mode 100644 index 0000000..f8d9353 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/entity/CallRecord.java @@ -0,0 +1,157 @@ +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 + '\'' + + '}'; + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/entity/DeviceMatchResult.java b/src/main/java/com/threecloud/dataserviceyy/entity/DeviceMatchResult.java new file mode 100644 index 0000000..7adfa27 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/entity/DeviceMatchResult.java @@ -0,0 +1,93 @@ +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 deviceChannel; + + /** 语音盒信息(ID, ORGAN_NAME, ORGAN_ID等字段) */ + private Map voiceBox; + + /** 匹配时使用的电话号码(可能是主叫或被叫) */ + private String phone; + + /** 失败时的错误描述信息 */ + private String errorMessage; + + public DeviceMatchResult() { + } + + /** + * 创建匹配成功的结果对象 + * @param deviceChannel 设备通道表中的记录信息 + * @param voiceBox 语音盒表中的记录信息 + * @param phone 匹配到的有效电话号码 + * @return 成功状态的DeviceMatchResult实例 + */ + public static DeviceMatchResult success(Map deviceChannel, + Map 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 getDeviceChannel() { + return deviceChannel; + } + + public Map 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 + "'}"; + } + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/entity/MidVoiceCallRecord.java b/src/main/java/com/threecloud/dataserviceyy/entity/MidVoiceCallRecord.java new file mode 100644 index 0000000..46e97a8 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/entity/MidVoiceCallRecord.java @@ -0,0 +1,143 @@ +package com.threecloud.dataserviceyy.entity; + +import java.util.Date; + +/** + * 语音通话记录表 + * 对应 mid_voice.mid_voice_call_record + */ +public class MidVoiceCallRecord { + + /** 自增ID主键 */ + private Long id; + + /** 地市编码 */ + private String cityCode; + + /** 地市名称 */ + private String cityName; + + /** 通话记录ID */ + private String callRecordId; + + /** 主叫号码 */ + private String callTel; + + /** 被叫号码 */ + private String calledTel; + + /** 通话开始时间 */ + private Date callStartTime; + + /** 通话结束时间 */ + private Date callEndTime; + + /** 通话时长(秒) */ + private Integer callDuration; + + /** 通话类型: 1呼入,2呼出 */ + private String callDirection; + + /** 设备编码 */ + private String deviceNo; + + /** 场景:110接报警、执法办案沟通、窗口服务咨询 */ + private String businessScenario; + + /** 文件名称 */ + private String recordingFileName; + + /** 文件地址(OSS路径) */ + private String recordingFilePath; + + /** 文件大小 */ + private Integer recordingFileSize; + + /** 通话状态:1正常接通,2未接通,3中途挂断,4通话失败 */ + private String callStatus; + + /** 失败原因 */ + private String failReason; + + /** 备注 */ + private String remarks; + + /** 数据同步时间 */ + private Date syncTime; + + /** 创建时间 */ + private Date createTime; + + /** 单位代码 */ + private String orgCode; + + /** 本地存储路径(非数据库字段,仅用于业务处理) */ + private String localPath; + + // 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 getCallRecordId() { return callRecordId; } + public void setCallRecordId(String callRecordId) { this.callRecordId = callRecordId; } + + public String getCallTel() { return callTel; } + public void setCallTel(String callTel) { this.callTel = callTel; } + + public String getCalledTel() { return calledTel; } + public void setCalledTel(String calledTel) { this.calledTel = calledTel; } + + 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 getCallDuration() { return callDuration; } + public void setCallDuration(Integer callDuration) { this.callDuration = callDuration; } + + public String getCallDirection() { return callDirection; } + public void setCallDirection(String callDirection) { this.callDirection = callDirection; } + + public String getDeviceNo() { return deviceNo; } + public void setDeviceNo(String deviceNo) { this.deviceNo = deviceNo; } + + public String getBusinessScenario() { return businessScenario; } + public void setBusinessScenario(String businessScenario) { this.businessScenario = businessScenario; } + + public String getRecordingFileName() { return recordingFileName; } + public void setRecordingFileName(String recordingFileName) { this.recordingFileName = recordingFileName; } + + public String getRecordingFilePath() { return recordingFilePath; } + public void setRecordingFilePath(String recordingFilePath) { this.recordingFilePath = recordingFilePath; } + + public Integer getRecordingFileSize() { return recordingFileSize; } + public void setRecordingFileSize(Integer recordingFileSize) { this.recordingFileSize = recordingFileSize; } + + public String getCallStatus() { return callStatus; } + public void setCallStatus(String callStatus) { this.callStatus = callStatus; } + + public String getFailReason() { return failReason; } + public void setFailReason(String failReason) { this.failReason = failReason; } + + public String getRemarks() { return remarks; } + public void setRemarks(String remarks) { this.remarks = remarks; } + + public Date getSyncTime() { return syncTime; } + public void setSyncTime(Date syncTime) { this.syncTime = syncTime; } + + public Date getCreateTime() { return createTime; } + public void setCreateTime(Date createTime) { this.createTime = createTime; } + + public String getOrgCode() { return orgCode; } + public void setOrgCode(String orgCode) { this.orgCode = orgCode; } + + public String getLocalPath() { return localPath; } + public void setLocalPath(String localPath) { this.localPath = localPath; } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/entity/MidVoiceChannelConfig.java b/src/main/java/com/threecloud/dataserviceyy/entity/MidVoiceChannelConfig.java new file mode 100644 index 0000000..b8d7515 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/entity/MidVoiceChannelConfig.java @@ -0,0 +1,78 @@ +package com.threecloud.dataserviceyy.entity; + +import java.util.Date; + +/** + * 语音设备通道配置表 + * 对应 mid_voice.mid_voice_channel_config + * 用于维护每个录音盒的通道信息(通道号、绑定号码等) + */ +public class MidVoiceChannelConfig { + + /** 自增ID主键 */ + private Long id; + + /** 地市编码 */ + private String cityCode; + + /** 地市名称 */ + private String cityName; + + /** 设备编码(关联 mid_voice_device_config.device_no) */ + private String deviceNo; + + /** 通道号 1-8 */ + private Integer channelNo; + + /** 通道绑定的电话号码 */ + private String phoneNumber; + + /** 通道名称/描述 */ + private String channelName; + + /** 通道状态:0离线,1在线,2故障 */ + private String channelStatus; + + /** 创建时间 */ + private Date createTime; + + /** 更新时间 */ + private Date updateTime; + + /** 备注 */ + private String remarks; + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getCityCode() { return cityCode; } + public void setCityCode(String cityCode) { this.cityCode = cityCode; } + + public String getCityName() { return cityName; } + public void setCityName(String cityName) { this.cityName = cityName; } + + public String getDeviceNo() { return deviceNo; } + public void setDeviceNo(String deviceNo) { this.deviceNo = deviceNo; } + + public Integer getChannelNo() { return channelNo; } + public void setChannelNo(Integer channelNo) { this.channelNo = channelNo; } + + public String getPhoneNumber() { return phoneNumber; } + public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; } + + public String getChannelName() { return channelName; } + public void setChannelName(String channelName) { this.channelName = channelName; } + + public String getChannelStatus() { return channelStatus; } + public void setChannelStatus(String channelStatus) { this.channelStatus = channelStatus; } + + public Date getCreateTime() { return createTime; } + public void setCreateTime(Date createTime) { this.createTime = createTime; } + + public Date getUpdateTime() { return updateTime; } + public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; } + + public String getRemarks() { return remarks; } + public void setRemarks(String remarks) { this.remarks = remarks; } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/entity/ResultEntity.java b/src/main/java/com/threecloud/dataserviceyy/entity/ResultEntity.java new file mode 100644 index 0000000..eae4546 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/entity/ResultEntity.java @@ -0,0 +1,140 @@ +package com.threecloud.dataserviceyy.entity; + +import java.io.Serializable; + +/** + * 接口请求返回实体类 + * + * @author Jonny + * @version 1.0.0 + */ +public class ResultEntity implements Serializable { + + /** + * + */ + private static final long serialVersionUID = 1L; + /**状态(1:成功,0:失败)*/ + private Integer code; + /**返回信息码*/ + private String msgno; + /**返回信息*/ + private String msg; + /**总数*/ + private Long totalcount; + /**当前记录数*/ + private Integer datacount; + /**是否存在下一页*/ + private Integer pagestate; + /**每页记录数*/ + private Integer pagesize; + /**返回内容*/ + private Object content; + + public ResultEntity() {} + public ResultEntity(Integer code, Object content) { + super(); + this.code = code; + this.content = content; + } + public ResultEntity(Integer code, String msg, Object content) { + super(); + this.code = code; + this.msg = msg; + this.content = content; + } + public ResultEntity(Integer code, String msgno, String msg, Object content) { + super(); + this.code = code; + this.msgno = msgno; + this.msg = msg; + this.content = content; + } + public ResultEntity(Integer code, String msg, Object content, Long totalcount, Integer pagesize) { + super(); + this.code = code; + this.msg = msg; + this.content = content; + this.totalcount = totalcount; + this.pagesize = pagesize; + } + public ResultEntity(Integer code, String msgno, String msg, Long totalcount, Integer datacount, Integer pagestate, + Integer pagesize, Object content) { + super(); + this.code = code; + this.msgno = msgno; + this.msg = msg; + this.totalcount = totalcount; + this.datacount = datacount; + this.pagestate = pagestate; + this.pagesize = pagesize; + this.content = content; + } + + public Integer getCode() { + return code; + } + public void setCode(Integer code) { + this.code = code; + } + public String getMsgno() { + return msgno; + } + public void setMsgno(String msgno) { + this.msgno = msgno; + } + public String getMsg() { + return msg; + } + public void setMsg(String msg) { + this.msg = msg; + } + public Long getTotalcount() { + return totalcount; + } + public void setTotalcount(Long totalcount) { + this.totalcount = totalcount; + } + public Integer getDatacount() { + return datacount; + } + public void setDatacount(Integer datacount) { + this.datacount = datacount; + } + public Integer getPagestate() { + return pagestate; + } + public void setPagestate(Integer pagestate) { + this.pagestate = pagestate; + } + public Integer getPagesize() { + return pagesize; + } + public void setPagesize(Integer pagesize) { + this.pagesize = pagesize; + } + public Object getContent() { + return content; + } + public void setContent(Object content) { + this.content = content; + } + + + public enum StatusCode{ + /**失败*/ + FAILURE(1), + /**成功*/ + SUCCESS(0); + private Integer code; + private StatusCode(Integer code) { + this.code = code; + } + public Integer getCode() { + return code; + } + public void setCode(Integer code) { + this.code = code; + } + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/entity/SyncLog.java b/src/main/java/com/threecloud/dataserviceyy/entity/SyncLog.java new file mode 100644 index 0000000..f6fc89f --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/entity/SyncLog.java @@ -0,0 +1,77 @@ +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; } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/entity/VoiceRecord.java b/src/main/java/com/threecloud/dataserviceyy/entity/VoiceRecord.java new file mode 100644 index 0000000..7d32813 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/entity/VoiceRecord.java @@ -0,0 +1,125 @@ +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; } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/enums/SyncStatus.java b/src/main/java/com/threecloud/dataserviceyy/enums/SyncStatus.java new file mode 100644 index 0000000..1e489ab --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/enums/SyncStatus.java @@ -0,0 +1,22 @@ +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; + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceCallRecordMapper.java b/src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceCallRecordMapper.java new file mode 100644 index 0000000..9b29032 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceCallRecordMapper.java @@ -0,0 +1,69 @@ +package com.threecloud.dataserviceyy.mapper; + +import com.threecloud.dataserviceyy.entity.MidVoiceCallRecord; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 语音通话记录Mapper + */ +@Mapper +public interface MidVoiceCallRecordMapper { + + /** + * 插入通话记录 + */ + int insert(MidVoiceCallRecord record); + + /** + * 根据ID查询 + */ + MidVoiceCallRecord selectById(Long id); + + /** + * 根据通话记录ID查询(防止重复插入) + */ + MidVoiceCallRecord selectByCallRecordId(@Param("callRecordId") String callRecordId); + + /** + * 根据设备编码查询 + */ + List selectByDeviceNo(@Param("deviceNo") String deviceNo); + + /** + * 分页查询 + */ + List selectList(@Param("cityCode") String cityCode, + @Param("deviceNo") String deviceNo, + @Param("callTel") String callTel, + @Param("calledTel") String calledTel, + @Param("startTime") String startTime, + @Param("endTime") String endTime, + @Param("offset") Integer offset, + @Param("limit") Integer limit); + + /** + * 统计总数 + */ + int count(@Param("cityCode") String cityCode, + @Param("deviceNo") String deviceNo, + @Param("callTel") String callTel, + @Param("calledTel") String calledTel, + @Param("startTime") String startTime, + @Param("endTime") String endTime); + + /** + * 更新文件路径 + */ + int updateFilePath(@Param("id") Long id, + @Param("recordingFilePath") String recordingFilePath); + + /** + * 更新状态和失败原因 + */ + int updateStatus(@Param("id") Long id, + @Param("callStatus") String callStatus, + @Param("failReason") String failReason); +} diff --git a/src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceChannelConfigMapper.java b/src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceChannelConfigMapper.java new file mode 100644 index 0000000..643d080 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceChannelConfigMapper.java @@ -0,0 +1,66 @@ +package com.threecloud.dataserviceyy.mapper; + +import com.threecloud.dataserviceyy.entity.MidVoiceChannelConfig; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 语音设备通道配置Mapper + */ +@Mapper +public interface MidVoiceChannelConfigMapper { + + /** + * 插入通道配置 + */ + int insert(MidVoiceChannelConfig config); + + /** + * 根据ID查询 + */ + MidVoiceChannelConfig selectById(Long id); + + /** + * 根据设备编码和通道号查询 + */ + MidVoiceChannelConfig selectByDeviceAndChannel(@Param("deviceNo") String deviceNo, + @Param("channelNo") Integer channelNo); + + /** + * 查询设备的所有通道 + */ + List selectByDeviceNo(@Param("deviceNo") String deviceNo); + + /** + * 根据电话号码查询通道 + */ + MidVoiceChannelConfig selectByPhoneNumber(@Param("phoneNumber") String phoneNumber); + + /** + * 查询所有通道配置 + */ + List selectAll(); + + /** + * 更新通道配置 + */ + int update(MidVoiceChannelConfig config); + + /** + * 更新通道状态 + */ + int updateStatus(@Param("id") Long id, + @Param("channelStatus") String channelStatus); + + /** + * 删除通道配置 + */ + int deleteById(Long id); + + /** + * 批量插入 + */ + int batchInsert(@Param("list") List list); +} diff --git a/src/main/java/com/threecloud/dataserviceyy/mapper/SyncLogMapper.java b/src/main/java/com/threecloud/dataserviceyy/mapper/SyncLogMapper.java new file mode 100644 index 0000000..0d7108d --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/mapper/SyncLogMapper.java @@ -0,0 +1,55 @@ +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 selectByDeviceId(@Param("deviceId") String deviceId); + + /** + * 分页查询同步日志 + */ + List 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); +} diff --git a/src/main/java/com/threecloud/dataserviceyy/mapper/VoiceRecordMapper.java b/src/main/java/com/threecloud/dataserviceyy/mapper/VoiceRecordMapper.java new file mode 100644 index 0000000..056825c --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/mapper/VoiceRecordMapper.java @@ -0,0 +1,67 @@ +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 selectByDeviceId(@Param("deviceId") String deviceId); + + /** + * 分页查询录音记录 + */ + List 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); +} diff --git a/src/main/java/com/threecloud/dataserviceyy/mapper/VoiceSyncMapper.java b/src/main/java/com/threecloud/dataserviceyy/mapper/VoiceSyncMapper.java new file mode 100644 index 0000000..a9fc1bc --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/mapper/VoiceSyncMapper.java @@ -0,0 +1,46 @@ +package com.threecloud.dataserviceyy.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface VoiceSyncMapper { + List> getAllYysb(); + + Map getTdByPhone(@Param("phone") String phone); + + Map getTdByPhone2(@Param("zjhm") String zjhm, @Param("bjhm") String bjhm); + + Map getYysbByUuid(@Param("uuid") String uuid); + + void saveThjl(@Param("sbtdId") String sbtdId, + @Param("yysbId") String yysbId, + @Param("organId") Long organId, + @Param("organName") String organName, + @Param("phone") String phone, + @Param("kssj") String kssj, + @Param("jssj") String jssj, + @Param("lydz") String lydz, + @Param("zjhm") String zjhm, + @Param("bjhm") String bjhm, + @Param("thfx") String thfx, + @Param("thsc") Long thsc); + + // VAA同步相关方法 + Map getChannelByNumberAndUuid(@Param("channel") String channel, @Param("uuid") String uuid); + + void updateChannelStatus(Map params); + + Object getLastSyncTime(@Param("code") String code); + + void saveSyncLog(Map params); + + Object checkThjlExists(@Param("thid") String thid); + + void insertThjl(Map params); + + void updateThjl(Map params); +} diff --git a/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java b/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java new file mode 100644 index 0000000..a1ecbe2 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java @@ -0,0 +1,547 @@ +package com.threecloud.dataserviceyy.service; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.threecloud.dataserviceyy.entity.MidVoiceCallRecord; +import com.threecloud.dataserviceyy.entity.MidVoiceChannelConfig; +import com.threecloud.dataserviceyy.mapper.MidVoiceCallRecordMapper; +import com.threecloud.dataserviceyy.mapper.VoiceSyncMapper; +import com.threecloud.dataserviceyy.service.channel.ChannelConfigService; +import com.threecloud.dataserviceyy.util.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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.io.File; +import java.net.URLEncoder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * VAA录音盒定时同步服务 + * + * 【功能说明】 + * 1. 定时从 EBOX 录音盒拉取录音记录 + * 2. 下载录音文件到本地(保留10天) + * 3. 上传录音文件到 OSS + * 4. 保存通话记录到 mid_voice_call_record 表 + * + * 【数据来源】 + * - 设备列表:mid_voice_device_config 表 + * - 通道配置:优先从 EBOX API 获取,失败则从数据库读取 + * + * 【同步策略】 + * - 每2小时执行一次(cron: 0 0 0/2 * * ?) + * - 增量同步:根据上次同步时间只拉取新录音 + * - 防重复:根据 device_no + record_id 判断 + * + * 【目录结构】 + * vaa-recordings/ + * ├── 20240101/ # 按日期分目录 + * │ └── / # 按设备分目录 + * │ └── xxx.wav + * └── .sync-marker/ # 同步时间标记 + * └── .time + */ +@Service +public class VaaSyncService { + + private static final Logger logger = LoggerFactory.getLogger(VaaSyncService.class); + + // ==================== 依赖注入 ==================== + + @Autowired + private VoiceSyncMapper voiceSyncMapper; + + @Autowired + private MidVoiceCallRecordMapper callRecordMapper; + + @Autowired + private ChannelConfigService channelConfigService; + + @Autowired + private VaaHttpUtil vaaHttpUtil; + + @Autowired + private FileUploadUtil fileUploadUtil; + + // ==================== 配置参数 ==================== + + /** 本地录音文件存储路径 */ + @Value("${vaa-sync.download-path:./vaa-recordings}") + private String downloadPath; + + /** 录音盒登录用户名 */ + @Value("${vaa-sync.device-username:admin}") + private String deviceUsername; + + /** 录音盒登录密码 */ + @Value("${vaa-sync.device-password:admin}") + private String devicePassword; + + /** 本地文件保留天数 */ + @Value("${vaa-sync.retain-days:10}") + private int retainDays; + + // ==================== 定时任务 ==================== + + /** + * 定时同步任务 - 每2小时执行一次 + * + * cron表达式说明:0 0 0/2 * * ? + * - 秒:0 + * - 分:0 + * - 时:0/2 表示从0点开始,每2小时 + * - 日:* 每天 + * - 月:* 每月 + * - 周:? 不指定 + */ + @Scheduled(cron = "${vaa-sync.sync-interval-cron:0 0 0/2 * * ?}") + public void scheduledSync() { + logger.info("【定时任务】========== VAA录音盒同步开始 =========="); + long startTime = System.currentTimeMillis(); + executeSync(); + long costTime = System.currentTimeMillis() - startTime; + logger.info("【定时任务】========== VAA录音盒同步结束,耗时 {} 秒 ==========", costTime / 1000); + } + + // ==================== 核心同步逻辑 ==================== + + /** + * 执行同步任务(主入口) + * + * 执行流程: + * 1. 清理过期本地文件 + * 2. 查询所有在线设备 + * 3. 逐个设备同步 + */ + public void executeSync() { + logger.info("【主流程】开始执行VAA录音盒同步任务"); + + try { + // 步骤1:清理过期本地文件 + logger.debug("【步骤1】清理 {} 天前的本地录音文件", retainDays); + FileCleaner.cleanOldFiles(downloadPath, retainDays); + + // 步骤2:查询设备列表 + logger.debug("【步骤2】查询在线设备列表"); + List> deviceList = voiceSyncMapper.getAllYysb(); + logger.info("【主流程】查询到 {} 个语音设备", deviceList.size()); + + // 步骤3:逐个设备同步 + int successCount = 0; + int failCount = 0; + for (int i = 0; i < deviceList.size(); i++) { + Map device = deviceList.get(i); + String deviceId = getStringValue(device, "ID"); + logger.debug("【步骤3】处理第 {}/{} 个设备: ID={}", i + 1, deviceList.size(), deviceId); + 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); + } + } + + /** + * 同步单个设备 + * + * @param device 设备信息Map,包含: + * - ID: 设备ID + * - UUID: 设备编号(device_no) + * - ORGAN_NAME: 地市名称 + * - ORGAN_ID: 地市编码 + * - IP: IP地址 + * - PORT: 端口号 + * - ORG_CODE: 单位代码 + */ + private void syncSingleDevice(Map device) throws Exception { + // 提取设备信息 + String deviceId = getStringValue(device, "ID"); + String deviceNo = getStringValue(device, "UUID"); + String cityName = getStringValue(device, "ORGAN_NAME"); + String cityCode = getStringValue(device, "ORGAN_ID"); + String ip = getStringValue(device, "IP"); + Integer port = getIntValue(device, "PORT", 80); + String orgCode = getStringValue(device, "ORG_CODE"); + + logger.info("【设备】────────────────────────────────────────"); + logger.info("【设备】开始同步设备: ID={}, 编号={}, 机构={}, IP={}:{}", + deviceId, deviceNo, cityName, ip, port); + + // 参数校验 + if (!StringUtils.hasText(ip)) { + logger.warn("【设备】设备IP为空,跳过同步: ID={}", deviceId); + return; + } + + // 机构名称兜底 + if (!StringUtils.hasText(cityName)) { + cityName = "unknown_" + deviceId; + } + + // 构造设备访问地址 + String deviceHost = buildDeviceHost(ip, port); + + // 步骤1:登录认证 + logger.debug("【设备-步骤1】登录设备: {}", deviceHost); + String authToken = loginDevice(deviceHost); + if (authToken == null) { + logger.error("【设备-异常】设备登录失败,跳过同步: IP={}", ip); + return; + } + logger.debug("【设备-步骤1】登录成功,获取到认证令牌"); + + // 步骤2:获取时间范围 + TimeRange timeRange = calculateSyncTimeRange(deviceId); + logger.debug("【设备-步骤2】同步时间范围: {} 至 {}", timeRange.startTime, timeRange.endTime); + + // 步骤3:获取录音列表 + logger.debug("【设备-步骤3】获取录音列表..."); + JSONArray records = fetchRecordList(deviceHost, authToken, timeRange); + if (records == null || records.isEmpty()) { + logger.info("【设备】设备 {} 没有新的录音记录", deviceId); + return; + } + logger.info("【设备】获取到 {} 条录音记录", records.size()); + + // 步骤4:处理每条录音 + SyncResult result = processRecords(records, deviceNo, cityName, cityCode, orgCode, deviceHost, authToken); + + // 步骤5:保存同步时间 + if (result.latestCallTime != null) { + SyncTimeUtil.writeLastSyncTime(downloadPath, deviceId, result.latestCallTime); + logger.debug("【设备-步骤5】保存同步时间: {}", result.latestCallTime); + } + + logger.info("【设备】同步完成: ID={}, 成功{}条, 失败{}条", deviceId, result.successCount, result.failCount); + } + + // ==================== 私有辅助方法 ==================== + + /** + * 登录设备获取认证令牌 + * + * @param deviceHost 设备访问地址(如 192.168.1.100:80) + * @return 认证令牌(Cookie),失败返回null + */ + private String loginDevice(String deviceHost) { + try { + String loginUrl = String.format("http://%s/authorize?username=%s&password=%s", + deviceHost, urlEncode(deviceUsername), urlEncode(devicePassword)); + + logger.info("正在登录设备: {}", deviceHost); + String authToken = vaaHttpUtil.httpLogin(loginUrl); + logger.info("登录成功"); + return authToken; + } catch (Exception e) { + logger.error("设备登录失败: {}, 原因={}", deviceHost, e.getMessage()); + return null; + } + } + + /** + * 计算同步时间范围 + * + * @param deviceId 设备ID + * @return 时间范围(Unix时间戳,秒) + */ + private TimeRange calculateSyncTimeRange(String deviceId) { + Date lastSyncTime = SyncTimeUtil.readLastSyncTime(downloadPath, deviceId); + Date now = new Date(); + + // 如果没有上次同步时间,或超过1天,则从昨天开始 + if (lastSyncTime == null || DateUtil.getDateDoubleDiff(now, lastSyncTime) > 1.0) { + lastSyncTime = DateUtil.addDayByDate(now, -1); + } + + return new TimeRange(lastSyncTime.getTime() / 1000, now.getTime() / 1000); + } + + /** + * 获取录音列表 + * + * @param deviceHost 设备访问地址 + * @param authToken 认证令牌 + * @param timeRange 时间范围 + * @return 录音记录JSON数组 + */ + private JSONArray fetchRecordList(String deviceHost, String authToken, TimeRange timeRange) { + try { + String recordUrl = String.format("http://%s/service/record/~/time[%d,%d]", + deviceHost, timeRange.startTime, timeRange.endTime); + + logger.debug("获取录音列表: {}", recordUrl); + String recordData = vaaHttpUtil.httpVisit(recordUrl, authToken); + return vaaHttpUtil.parseRecordData(recordData); + } catch (Exception e) { + logger.error("获取录音列表失败: {}", e.getMessage()); + return null; + } + } + + /** + * 批量处理录音记录 + */ + private SyncResult processRecords(JSONArray records, String deviceNo, String cityName, + String cityCode, String orgCode, String deviceHost, String authToken) { + SyncResult result = new SyncResult(); + + for (int i = 0; i < records.size(); i++) { + JSONObject record = records.getJSONObject(i); + try { + boolean success = processSingleRecord(record, deviceNo, cityName, cityCode, orgCode, deviceHost, authToken); + if (success) { + result.successCount++; + // 跟踪最新的通话时间 + Date callTime = parseCallTime(record); + if (callTime != null && (result.latestCallTime == null || callTime.after(result.latestCallTime))) { + result.latestCallTime = callTime; + } + } else { + result.failCount++; + } + } catch (Exception e) { + logger.error("处理录音记录失败[{}]: {}", i, e.getMessage()); + result.failCount++; + } + } + + return result; + } + + /** + * 处理单条录音记录 + * + * 处理流程: + * 1. 解析录音信息 + * 2. 检查是否已存在(防重复) + * 3. 查询通道配置(获取本机号码) + * 4. 下载录音文件(如不存在) + * 5. 上传到OSS + * 6. 保存到数据库 + */ + private boolean processSingleRecord(JSONObject record, String deviceNo, String cityName, + String cityCode, String orgCode, String deviceHost, String authToken) throws Exception { + // ========== 步骤1:解析录音信息 ========== + String recordId = RecordParser.parseRecordId(record); + String filePath = RecordParser.parseFilePath(record); + Integer channel = RecordParser.parseChannel(record); + String phone = RecordParser.parsePhone(record); + boolean isOutgoing = RecordParser.isOutgoing(record); + boolean isAnswered = RecordParser.isAnswered(record); + Long begTime = RecordParser.parseBegTime(record); + Long endTime = RecordParser.parseEndTime(record); + + logger.debug("【录音】处理记录: recordId={}, channel={}, phone={}, direction={}", + recordId, channel, phone, isOutgoing ? "呼出" : "呼入"); + + // 参数校验 + if (filePath == null || filePath.isEmpty()) { + logger.debug("【录音】录音文件路径为空,跳过: recordId={}", recordId); + return false; + } + if (begTime == null || endTime == null) { + logger.warn("【录音-异常】录音时间信息缺失,跳过: recordId={}", recordId); + return false; + } + + // ========== 步骤2:防重复检查 ========== + String callRecordId = deviceNo + "_" + recordId; + if (callRecordMapper.selectByCallRecordId(callRecordId) != null) { + logger.debug("【录音】通话记录已存在,跳过: {}", callRecordId); + return true; + } + + // ========== 步骤3:准备文件路径(按地市分文件夹) ========== + Date callStartTime = new Date(begTime * 1000); + String fileName = FilePathUtil.extractFileName(filePath); + // 本地路径: {basePath}/{cityCode}/{date}/{uuid}/{fileName} + String localPath = FilePathUtil.buildLocalPath(downloadPath, cityCode, callStartTime, deviceNo, fileName); + Path localFile = Paths.get(localPath); + // OSS路径: {cityCode}/{date}/{fileName} + String ossPath = FilePathUtil.buildOssPath(cityCode, callStartTime, fileName); + logger.debug("【录音-步骤3】路径信息: localPath={}, ossPath={}", localPath, ossPath); + + logger.debug("【录音】文件信息: fileName={}, localPath={}", fileName, localPath); + + // ========== 步骤4:查询通道配置 ========== + logger.debug("【录音-步骤4】查询通道配置: deviceNo={}, channel={}", deviceNo, channel); + MidVoiceChannelConfig channelConfig = channelConfigService.getChannelConfig( + deviceNo, channel, deviceHost, "EBOX-8108", authToken); + String channelPhone = channelConfig != null ? channelConfig.getPhoneNumber() : null; + logger.debug("【录音-步骤4】通道配置: channelPhone={}", channelPhone); + + // ========== 步骤5:构建通话记录实体 ========== + MidVoiceCallRecord callRecord = buildCallRecord( + callRecordId, deviceNo, cityCode, cityName, orgCode, + fileName, localPath, callStartTime, new Date(endTime * 1000), + (int) (endTime - begTime), channelPhone, phone, isOutgoing, isAnswered); + + // ========== 步骤6:下载录音文件 ========== + if (!Files.exists(localFile) || Files.size(localFile) == 0) { + logger.info("【录音-步骤6】开始下载录音: {}", fileName); + String fileUrl = "http://" + deviceHost + filePath; + vaaHttpUtil.httpDown(fileUrl, localPath, authToken); + logger.info("【录音-步骤6】录音下载完成: {} ({} 字节)", localPath, Files.size(localFile)); + } else { + logger.debug("【录音-步骤6】录音文件已存在本地,跳过下载: {} ({} 字节)", + fileName, Files.size(localFile)); + } + callRecord.setRecordingFileSize((int) Files.size(localFile)); + + // ========== 步骤7:上传到OSS ========== + logger.debug("【录音-步骤7】开始上传OSS: cityName={}, ossPath={}", cityName, ossPath); + byte[] wavData = Files.readAllBytes(localFile); + String ossUrl = fileUploadUtil.uploadWav(cityName, ossPath, fileName, wavData); + logger.info("【录音-步骤7】录音上传OSS成功: {}", ossUrl); + callRecord.setRecordingFilePath(ossUrl); + + // ========== 步骤8:保存到数据库 ========== + logger.debug("【录音-步骤8】保存通话记录到数据库..."); + callRecordMapper.insert(callRecord); + logger.info("【录音-步骤8】通话记录保存成功: id={}, callRecordId={}", callRecord.getId(), callRecordId); + + return true; + } + + /** + * 构建通话记录实体 + */ + private MidVoiceCallRecord buildCallRecord(String callRecordId, String deviceNo, String cityCode, + String cityName, String orgCode, String fileName, + String localPath, Date callStartTime, Date callEndTime, + int duration, String channelPhone, String remotePhone, + boolean isOutgoing, boolean isAnswered) { + MidVoiceCallRecord record = new MidVoiceCallRecord(); + + // 基础信息 + record.setCallRecordId(callRecordId); + record.setDeviceNo(deviceNo); + record.setCityCode(cityCode); + record.setCityName(cityName); + record.setOrgCode(orgCode); + + // 文件信息 + record.setRecordingFileName(fileName); + record.setLocalPath(localPath); + + // 时间信息 + record.setCallStartTime(callStartTime); + record.setCallEndTime(callEndTime); + record.setCallDuration(duration); + + // 通话方向:1呼入,2呼出 + record.setCallDirection(isOutgoing ? "2" : "1"); + + // 主叫/被叫号码 + String localNumber = channelPhone != null && !channelPhone.isEmpty() ? channelPhone : ""; + String remoteNumber = remotePhone != null ? remotePhone : ""; + + if (isOutgoing) { + // 呼出:本机打给对方 + record.setCallTel(localNumber); // 主叫:本机号码 + record.setCalledTel(remoteNumber); // 被叫:对方号码 + } else { + // 呼入:对方打给本机 + record.setCallTel(remoteNumber); // 主叫:对方号码 + record.setCalledTel(localNumber); // 被叫:本机号码 + } + + // 通话状态 + record.setCallStatus(isAnswered ? "1" : "2"); // 1正常接通,2未接通 + + return record; + } + + // ==================== 工具方法 ==================== + + /** + * 构建设备访问地址 + */ + private String buildDeviceHost(String ip, Integer port) { + return ip + (port != null && port != 80 ? ":" + port : ""); + } + + /** + * 解析通话时间 + */ + private Date parseCallTime(JSONObject record) { + Long begTime = RecordParser.parseBegTime(record); + return begTime != null ? new Date(begTime * 1000) : null; + } + + /** + * 从Map中获取字符串值 + */ + private String getStringValue(Map map, String key) { + Object value = map.get(key); + return value != null ? value.toString() : null; + } + + /** + * 从Map中获取整数值 + */ + private Integer getIntValue(Map map, String key, Integer defaultValue) { + Object value = map.get(key); + if (value == null) { + return defaultValue; + } + try { + return Integer.parseInt(value.toString()); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /** + * URL编码 + */ + private String urlEncode(String value) throws Exception { + return URLEncoder.encode(value, "UTF-8"); + } + + // ==================== 内部类 ==================== + + /** + * 时间范围 + */ + private static class TimeRange { + final long startTime; + final long endTime; + + TimeRange(long startTime, long endTime) { + this.startTime = startTime; + this.endTime = endTime; + } + + @Override + public String toString() { + return String.format("[%d, %d]", startTime, endTime); + } + } + + /** + * 同步结果 + */ + private static class SyncResult { + int successCount = 0; + int failCount = 0; + Date latestCallTime = null; + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/service/channel/ChannelConfigProvider.java b/src/main/java/com/threecloud/dataserviceyy/service/channel/ChannelConfigProvider.java new file mode 100644 index 0000000..073653e --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/service/channel/ChannelConfigProvider.java @@ -0,0 +1,48 @@ +package com.threecloud.dataserviceyy.service.channel; + +import com.threecloud.dataserviceyy.entity.MidVoiceChannelConfig; + +import java.util.List; + +/** + * 通道配置提供者接口 + * 定义获取通道配置的通用方法,支持多种实现(EBOX API、数据库、手动配置等) + */ +public interface ChannelConfigProvider { + + /** + * 获取指定设备的所有通道配置 + * + * @param deviceNo 设备编码 + * @param deviceIp 设备IP地址 + * @param authToken 认证令牌(部分实现需要) + * @return 通道配置列表 + */ + List getChannelConfigs(String deviceNo, String deviceIp, String authToken); + + /** + * 获取指定设备的单个通道配置 + * + * @param deviceNo 设备编码 + * @param channelNo 通道号 + * @param deviceIp 设备IP地址 + * @param authToken 认证令牌 + * @return 通道配置,不存在返回null + */ + MidVoiceChannelConfig getChannelConfig(String deviceNo, Integer channelNo, String deviceIp, String authToken); + + /** + * 是否支持该设备类型 + * + * @param deviceModel 设备型号 + * @return true表示支持 + */ + boolean supports(String deviceModel); + + /** + * 提供者名称 + * + * @return 名称标识 + */ + String getProviderName(); +} diff --git a/src/main/java/com/threecloud/dataserviceyy/service/channel/ChannelConfigService.java b/src/main/java/com/threecloud/dataserviceyy/service/channel/ChannelConfigService.java new file mode 100644 index 0000000..4fa9855 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/service/channel/ChannelConfigService.java @@ -0,0 +1,115 @@ +package com.threecloud.dataserviceyy.service.channel; + +import com.threecloud.dataserviceyy.entity.MidVoiceChannelConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.List; + +/** + * 通道配置服务 + * 管理多个 ChannelConfigProvider,根据设备类型选择合适的提供者 + */ +@Service +public class ChannelConfigService { + + private static final Logger logger = LoggerFactory.getLogger(ChannelConfigService.class); + + @Autowired + private List providers = new ArrayList<>(); + + @Autowired + private DatabaseChannelConfigProvider databaseProvider; + + @PostConstruct + public void init() { + logger.info("初始化通道配置服务,注册 {} 个提供者", providers.size()); + for (ChannelConfigProvider provider : providers) { + logger.info("注册通道配置提供者: {}", provider.getProviderName()); + } + } + + /** + * 获取通道配置 + * 优先使用设备特定的提供者,如果没有则使用数据库提供者 + * + * @param deviceNo 设备编码 + * @param channelNo 通道号 + * @param deviceIp 设备IP + * @param deviceModel 设备型号 + * @param authToken 认证令牌 + * @return 通道配置 + */ + public MidVoiceChannelConfig getChannelConfig(String deviceNo, Integer channelNo, + String deviceIp, String deviceModel, String authToken) { + // 1. 先尝试使用设备特定的提供者(如 EBOX API) + ChannelConfigProvider specificProvider = findProvider(deviceModel); + if (specificProvider != null && !specificProvider.getProviderName().equals("DATABASE")) { + try { + MidVoiceChannelConfig config = specificProvider.getChannelConfig(deviceNo, channelNo, deviceIp, authToken); + if (config != null) { + logger.debug("使用 {} 获取到通道配置", specificProvider.getProviderName()); + return config; + } + } catch (Exception e) { + logger.warn("{} 获取通道配置失败,尝试使用数据库: {}", specificProvider.getProviderName(), e.getMessage()); + } + } + + // 2. 使用数据库提供者(兜底) + return databaseProvider.getChannelConfig(deviceNo, channelNo, deviceIp, authToken); + } + + /** + * 获取设备的所有通道配置 + * 优先从 API 获取并同步到数据库,失败则从数据库读取 + * + * @param deviceNo 设备编码 + * @param deviceIp 设备IP + * @param deviceModel 设备型号 + * @param authToken 认证令牌 + * @return 通道配置列表 + */ + public List getChannelConfigs(String deviceNo, String deviceIp, + String deviceModel, String authToken) { + // 1. 先尝试使用设备特定的提供者(如 EBOX API) + ChannelConfigProvider specificProvider = findProvider(deviceModel); + if (specificProvider != null && !specificProvider.getProviderName().equals("DATABASE")) { + try { + List configs = specificProvider.getChannelConfigs(deviceNo, deviceIp, authToken); + if (!configs.isEmpty()) { + logger.info("从 {} 获取到 {} 条通道配置", specificProvider.getProviderName(), configs.size()); + return configs; + } + } catch (Exception e) { + logger.warn("{} 获取通道配置失败,尝试使用数据库: {}", specificProvider.getProviderName(), e.getMessage()); + } + } + + // 2. 使用数据库提供者(兜底) + return databaseProvider.getChannelConfigs(deviceNo, deviceIp, authToken); + } + + /** + * 根据设备型号查找合适的提供者 + */ + private ChannelConfigProvider findProvider(String deviceModel) { + if (deviceModel == null) { + return databaseProvider; + } + + // 优先找特定的提供者 + for (ChannelConfigProvider provider : providers) { + if (provider.supports(deviceModel)) { + return provider; + } + } + + // 默认使用数据库提供者 + return databaseProvider; + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/service/channel/DatabaseChannelConfigProvider.java b/src/main/java/com/threecloud/dataserviceyy/service/channel/DatabaseChannelConfigProvider.java new file mode 100644 index 0000000..5a3d3d2 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/service/channel/DatabaseChannelConfigProvider.java @@ -0,0 +1,46 @@ +package com.threecloud.dataserviceyy.service.channel; + +import com.threecloud.dataserviceyy.entity.MidVoiceChannelConfig; +import com.threecloud.dataserviceyy.mapper.MidVoiceChannelConfigMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 数据库通道配置提供者 + * 从数据库读取通道配置,作为兜底方案 + */ +@Component +public class DatabaseChannelConfigProvider implements ChannelConfigProvider { + + private static final Logger logger = LoggerFactory.getLogger(DatabaseChannelConfigProvider.class); + + @Autowired + private MidVoiceChannelConfigMapper channelConfigMapper; + + @Override + public List getChannelConfigs(String deviceNo, String deviceIp, String authToken) { + List configs = channelConfigMapper.selectByDeviceNo(deviceNo); + logger.debug("从数据库获取到 {} 条通道配置: deviceNo={}", configs.size(), deviceNo); + return configs; + } + + @Override + public MidVoiceChannelConfig getChannelConfig(String deviceNo, Integer channelNo, String deviceIp, String authToken) { + return channelConfigMapper.selectByDeviceAndChannel(deviceNo, channelNo); + } + + @Override + public boolean supports(String deviceModel) { + // 支持所有设备类型(兜底方案) + return true; + } + + @Override + public String getProviderName() { + return "DATABASE"; + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/service/channel/EboxChannelConfigProvider.java b/src/main/java/com/threecloud/dataserviceyy/service/channel/EboxChannelConfigProvider.java new file mode 100644 index 0000000..045f6df --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/service/channel/EboxChannelConfigProvider.java @@ -0,0 +1,138 @@ +package com.threecloud.dataserviceyy.service.channel; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.threecloud.dataserviceyy.entity.MidVoiceChannelConfig; +import com.threecloud.dataserviceyy.mapper.MidVoiceChannelConfigMapper; +import com.threecloud.dataserviceyy.util.VaaHttpUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * EBOX 录音盒通道配置提供者 + * 通过 EBOX HTTP API 获取分机号码配置 + */ +@Component +public class EboxChannelConfigProvider implements ChannelConfigProvider { + + private static final Logger logger = LoggerFactory.getLogger(EboxChannelConfigProvider.class); + + @Autowired + private VaaHttpUtil vaaHttpUtil; + + @Autowired + private MidVoiceChannelConfigMapper channelConfigMapper; + + @Override + public List getChannelConfigs(String deviceNo, String deviceIp, String authToken) { + List configs = new ArrayList<>(); + + try { + // 调用 EBOX API 获取分机号码 + String extUrl = "http://" + deviceIp + "/service/ext/number"; + String response = vaaHttpUtil.httpVisit(extUrl, authToken); + + if (response == null || response.isEmpty()) { + logger.warn("EBOX 返回空的分机号码配置: deviceNo={}", deviceNo); + return configs; + } + + // 解析 JSON 响应 + JSONObject jsonObj = JSON.parseObject(response); + + for (String key : jsonObj.keySet()) { + try { + Integer channelNo = Integer.parseInt(key); + String phoneNumber = jsonObj.getString(key); + + if (phoneNumber != null && !phoneNumber.isEmpty()) { + MidVoiceChannelConfig config = new MidVoiceChannelConfig(); + config.setDeviceNo(deviceNo); + config.setChannelNo(channelNo); + config.setPhoneNumber(phoneNumber); + config.setChannelName("通道" + channelNo); + config.setChannelStatus("1"); // 在线 + + configs.add(config); + } + } catch (NumberFormatException e) { + logger.warn("解析通道号失败: key={}", key); + } + } + + logger.info("从 EBOX 获取到 {} 条通道配置: deviceNo={}", configs.size(), deviceNo); + + // 同步到数据库(更新或插入) + syncToDatabase(configs); + + } catch (Exception e) { + logger.error("从 EBOX 获取通道配置失败: deviceNo={}, error={}", deviceNo, e.getMessage()); + } + + return configs; + } + + @Override + public MidVoiceChannelConfig getChannelConfig(String deviceNo, Integer channelNo, String deviceIp, String authToken) { + // 先查数据库 + MidVoiceChannelConfig config = channelConfigMapper.selectByDeviceAndChannel(deviceNo, channelNo); + if (config != null) { + return config; + } + + // 数据库没有,从 EBOX 获取 + List configs = getChannelConfigs(deviceNo, deviceIp, authToken); + for (MidVoiceChannelConfig c : configs) { + if (c.getChannelNo().equals(channelNo)) { + return c; + } + } + + return null; + } + + @Override + public boolean supports(String deviceModel) { + // 支持 EBOX 系列设备 + return deviceModel != null && deviceModel.toUpperCase().contains("EBOX"); + } + + @Override + public String getProviderName() { + return "EBOX_API"; + } + + /** + * 将获取到的配置同步到数据库 + */ + private void syncToDatabase(List configs) { + for (MidVoiceChannelConfig config : configs) { + try { + MidVoiceChannelConfig existing = channelConfigMapper.selectByDeviceAndChannel( + config.getDeviceNo(), config.getChannelNo()); + + if (existing != null) { + // 更新电话号码(如果变化了) + if (!config.getPhoneNumber().equals(existing.getPhoneNumber())) { + existing.setPhoneNumber(config.getPhoneNumber()); + channelConfigMapper.update(existing); + logger.info("更新通道配置: deviceNo={}, channelNo={}, phone={}", + config.getDeviceNo(), config.getChannelNo(), config.getPhoneNumber()); + } + } else { + // 插入新配置 + channelConfigMapper.insert(config); + logger.info("新增通道配置: deviceNo={}, channelNo={}, phone={}", + config.getDeviceNo(), config.getChannelNo(), config.getPhoneNumber()); + } + } catch (Exception e) { + logger.error("同步通道配置到数据库失败: {}", e.getMessage()); + } + } + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/util/DataUtil.java b/src/main/java/com/threecloud/dataserviceyy/util/DataUtil.java new file mode 100644 index 0000000..b0f37e5 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/util/DataUtil.java @@ -0,0 +1,76 @@ +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()); + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/util/DateUtil.java b/src/main/java/com/threecloud/dataserviceyy/util/DateUtil.java new file mode 100644 index 0000000..3d97b0f --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/util/DateUtil.java @@ -0,0 +1,57 @@ +package com.threecloud.dataserviceyy.util; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; + +/** + * 日期工具类 + */ +public class DateUtil { + + public static final SimpleDateFormat dateFmt4 = new SimpleDateFormat("yyyyMMdd"); + + /** + * 计算两个日期之间的天数差 + * @param date1 日期1 + * @param date2 日期2 + * @return 天数差 + */ + public static double getDateDoubleDiff(Date date1, Date date2) { + if (date1 == null || date2 == null) { + return 0; + } + long diff = date1.getTime() - date2.getTime(); + return diff / (1000.0 * 60 * 60 * 24); + } + + /** + * 日期加减天数 + * @param date 基准日期 + * @param days 天数(正数加,负数减) + * @return 计算后的日期 + */ + public static Date addDayByDate(Date date, int days) { + if (date == null) { + return null; + } + Calendar calendar = Calendar.getInstance(); + calendar.setTime(date); + calendar.add(Calendar.DAY_OF_MONTH, days); + return calendar.getTime(); + } + + /** + * 格式化日期 + * @param date 日期 + * @param pattern 格式 + * @return 格式化后的字符串 + */ + public static String formatDate(Date date, String pattern) { + if (date == null) { + return null; + } + SimpleDateFormat sdf = new SimpleDateFormat(pattern); + return sdf.format(date); + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/util/FileCleaner.java b/src/main/java/com/threecloud/dataserviceyy/util/FileCleaner.java new file mode 100644 index 0000000..1644d1e --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/util/FileCleaner.java @@ -0,0 +1,177 @@ +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/ # 日期目录 + * │ │ └── / # 设备目录 + * │ │ └── 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 保留天数 + */ + public static void cleanOldFiles(String basePath, int retainDays) { + File baseDir = new File(basePath); + if (!baseDir.exists() || !baseDir.isDirectory()) { + logger.debug("基础目录不存在或不是目录: {}", basePath); + 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("没有需要清理的过期文件"); + } + } + + /** + * 清理指定地市目录下的过期日期目录 + * + * @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); + } + } + } + + 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(); + } + } + } + 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); + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/util/FilePathUtil.java b/src/main/java/com/threecloud/dataserviceyy/util/FilePathUtil.java new file mode 100644 index 0000000..3b28145 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/util/FilePathUtil.java @@ -0,0 +1,97 @@ +package com.threecloud.dataserviceyy.util; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * 文件路径工具类 + * 统一管理本地文件存储路径生成逻辑 + */ +public class FilePathUtil { + + /** + * 日期格式:yyyyMMdd + */ + private static final String DATE_FORMAT = "yyyyMMdd"; + + /** + * 生成本地文件存储路径(按地市分文件夹) + * + * 路径格式: {basePath}/{cityCode}/{date}/{uuid}/{fileName} + * 示例: ./vaa-recordings/340100/20240101/uuid-xxx/xxx.wav + * + * @param basePath 基础路径(如 ./vaa-recordings) + * @param cityCode 地市编码(如 340100) + * @param date 日期 + * @param uuid 设备UUID + * @param fileName 文件名 + * @return 完整路径 + */ + public static String buildLocalPath(String basePath, String cityCode, Date date, String uuid, String fileName) { + String dateDir = new SimpleDateFormat(DATE_FORMAT).format(date); + return basePath + File.separator + cityCode + File.separator + dateDir + File.separator + uuid + File.separator + fileName; + } + + /** + * 从文件路径中提取文件名 + * + * @param filePath 文件路径(如 /record/2024/01/01/test.wav) + * @return 文件名(如 test.wav) + */ + public static String extractFileName(String filePath) { + if (filePath == null || filePath.isEmpty()) { + return ""; + } + int lastSlash = filePath.lastIndexOf("/"); + return lastSlash >= 0 ? filePath.substring(lastSlash + 1) : filePath; + } + + /** + * 生成OSS存储路径(按地市分文件夹) + * + * 路径格式: {cityCode}/{date}/{fileName} + * 示例: 340100/20240101/xxx.wav + * + * @param cityCode 地市编码(如 340100) + * @param date 日期 + * @param fileName 文件名 + * @return OSS存储路径 + */ + public static String buildOssPath(String cityCode, Date date, String fileName) { + String dateDir = new SimpleDateFormat(DATE_FORMAT).format(date); + return cityCode + "/" + dateDir + "/" + fileName; + } + + /** + * 生成OSS存储的日期目录(已废弃,请使用 buildOssPath) + * + * @param date 日期 + * @return 日期目录(如 20240101) + */ + @Deprecated + public static String formatDateDir(Date date) { + return new SimpleDateFormat(DATE_FORMAT).format(date); + } + + /** + * 构建同步标记文件路径 + * + * @param basePath 基础路径 + * @param deviceId 设备ID + * @return 标记文件路径 + */ + public static String buildSyncMarkerPath(String basePath, String deviceId) { + return basePath + File.separator + ".sync-marker" + File.separator + deviceId + ".time"; + } + + /** + * 构建同步标记目录路径 + * + * @param basePath 基础路径 + * @return 标记目录路径 + */ + public static String buildSyncMarkerDir(String basePath) { + return basePath + File.separator + ".sync-marker"; + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/util/FileUploadUtil.java b/src/main/java/com/threecloud/dataserviceyy/util/FileUploadUtil.java new file mode 100644 index 0000000..eefc216 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/util/FileUploadUtil.java @@ -0,0 +1,290 @@ +package com.threecloud.dataserviceyy.util; + +import com.threecloud.dataserviceyy.entity.ResultEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import javax.annotation.PostConstruct; +import javax.net.ssl.HttpsURLConnection; +import java.io.IOException; +import java.net.HttpURLConnection; + +/** + * 文件上传工具类 + * 负责上传录音文件到 OSS,并返回完整的访问 URL + * + * 【配置说明】 + * vaa-sync.oss.base-url: OSS服务基础地址,用于拼接完整URL + * vaa-sync.oss.upload-url: OSS上传接口地址 + * vaa-sync.oss.appcode/appid/appsecret: OSS认证信息 + */ +@Component +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}") + private String ossBaseUrl; + + @Value("${vaa-sync.oss.upload-url:http://53.1.194.59:9090/apiOss/oss/fileUpload}") + private String ossUploadUrl; + + @Value("${vaa-sync.oss.appcode:dataservice-yy}") + private String ossAppcode; + + @Value("${vaa-sync.oss.appid:371a3368-e28e-4ba3-95a3-c31c19cf0ad0}") + private String ossAppid; + + @Value("${vaa-sync.oss.appsecret:06a6a80e-f9d2-4b3b-acc0-8d182c876074}") + private String ossAppsecret; + + private RestTemplate restTemplate; + + @PostConstruct + public void init() { + restTemplate = createRestTemplate(); + logger.info("文件上传工具初始化完成"); + logger.info(" OSS基础地址: {}", ossBaseUrl); + logger.info(" 上传接口: {}", ossUploadUrl); + } + + /** + * 上传WAV录音文件到OSS + * + * 存储路径格式: voice/{cityName}/{cityCode}/{date}/{filename} + * 示例: voice/合肥/340100/20240101/IN-xxx.wav + * + * 返回完整URL格式: {ossBaseUrl}/voice/{cityName}/{cityCode}/{date}/{filename} + * 示例: http://53.1.194.59:9090/voice/合肥/340100/20240101/IN-xxx.wav + * + * @param cityName 地市名称(如 合肥) + * @param ossPath OSS存储路径(格式: cityCode/date/filename) + * @param wavFileName WAV文件名 + * @param wavData 文件字节数组 + * @return 完整的文件访问URL + */ + public String uploadWav(String cityName, String ossPath, String wavFileName, byte[] wavData) { + // 构建OSS对象名: voice/{cityName}/{cityCode}/{date}/{filename} + String objectName = "voice/" + cityName + "/" + ossPath; + logger.debug("构建OSS对象名: {}", objectName); + + // 上传文件 + String relativeUrl = uploadFile(objectName, wavData); + + // 返回完整URL + String fullUrl = buildFullUrl(relativeUrl); + logger.info("文件上传完成,完整URL: {}", fullUrl); + + return fullUrl; + } + + /** + * 构建完整的文件访问URL + * + * 如果返回的是相对路径,拼接 base-url + * 如果返回的已经是完整URL,直接返回 + * + * @param url 从OSS返回的URL(可能是相对路径或完整URL) + * @return 完整的访问URL + */ + private String buildFullUrl(String url) { + if (url == null || url.isEmpty()) { + return ""; + } + + // 如果已经是完整URL,直接返回 + if (url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + + // 拼接基础URL + String baseUrl = ossBaseUrl.endsWith("/") ? ossBaseUrl.substring(0, ossBaseUrl.length() - 1) : ossBaseUrl; + String relativePath = url.startsWith("/") ? url : "/" + url; + + return baseUrl + relativePath; + } + + /** + * 上传文件到OSS + * + * @param fileName 文件在OSS中的路径(如 voice/合肥/340100/...) + * @param fileData 文件字节数组 + * @return OSS返回的文件路径或URL + */ + private String uploadFile(String fileName, byte[] fileData) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + ByteArrayResource byteResource = new ByteArrayResource(fileData) { + @Override + public String getFilename() { + return fileName; + } + }; + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("files", byteResource); + body.set("appcode", ossAppcode); + body.set("appid", ossAppid); + body.set("appsecret", ossAppsecret); + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + logger.debug("开始上传文件到OSS: {}, 大小: {} bytes", fileName, fileData.length); + ResponseEntity response = restTemplate.exchange( + ossUploadUrl, + HttpMethod.POST, + requestEntity, + ResultEntity.class + ); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + ResultEntity result = response.getBody(); + if (result.getCode() == ResultEntity.StatusCode.SUCCESS.getCode()) { + String url = parseUrlFromContent(result.getContent()); + logger.debug("OSS返回URL: {}", url); + return url; + } else { + throw new RuntimeException("上传失败, code=" + result.getCode() + ", msg=" + result.getMsg()); + } + } else { + throw new RuntimeException("上传失败, HTTP状态码: " + response.getStatusCode()); + } + } catch (Exception e) { + logger.error("文件上传异常: {}", fileName, e); + throw new RuntimeException("文件上传失败: " + e.getMessage(), e); + } + } + + /** + * 从 ResultEntity.content 中解析文件URL + * + * 支持多种返回格式: + * 1. Map 对象: {"url": "http://xxx", "fileName": "xxx.wav"} + * 2. JSON 字符串: "{\"url\":\"http://xxx\"}" + * 3. 纯 URL 字符串: "http://xxx/xxx.wav" + * 4. 数组格式: [{"url": "http://xxx"}] + * 5. 相对路径: "voice/xxx/xxx.wav" + * + * @param content OSS 返回的内容 + * @return 解析后的文件路径或URL + */ + private String parseUrlFromContent(Object content) { + if (content == null) { + logger.warn("OSS 返回 content 为空"); + return ""; + } + + // 情况1:Map 对象 + if (content instanceof java.util.Map) { + java.util.Map map = (java.util.Map) content; + Object url = map.get("url"); + if (url != null) { + logger.debug("从 Map 解析到 URL: {}", url); + return url.toString(); + } + // 尝试其他可能的 key + Object fileUrl = map.get("fileUrl"); + if (fileUrl != null) { + logger.debug("从 Map 解析到 fileUrl: {}", fileUrl); + return fileUrl.toString(); + } + Object path = map.get("path"); + if (path != null) { + logger.debug("从 Map 解析到 path: {}", path); + return path.toString(); + } + } + + // 情况2:List/Array 对象(批量上传返回) + if (content instanceof java.util.List) { + java.util.List list = (java.util.List) content; + if (!list.isEmpty()) { + Object first = list.get(0); + if (first instanceof java.util.Map) { + Object url = ((java.util.Map) first).get("url"); + if (url != null) { + logger.debug("从 List 第一个元素解析到 URL: {}", url); + return url.toString(); + } + } + } + } + + // 情况3:JSON 字符串 + String contentStr = content.toString().trim(); + if (contentStr.startsWith("{") || contentStr.startsWith("[")) { + try { + // 尝试解析为 JSON + Object parsed = com.alibaba.fastjson2.JSON.parse(contentStr); + if (parsed instanceof java.util.Map) { + return parseUrlFromContent(parsed); // 递归解析 + } + if (parsed instanceof java.util.List) { + return parseUrlFromContent(parsed); // 递归解析 + } + } catch (Exception e) { + logger.debug("解析 JSON 失败: {}", e.getMessage()); + } + } + + // 情况4:直接是 URL 字符串(以 http:// 或 https:// 开头) + if (contentStr.startsWith("http://") || contentStr.startsWith("https://")) { + logger.debug("直接返回 URL 字符串: {}", contentStr); + return contentStr; + } + + // 情况5:相对路径(以 voice/ 开头) + if (contentStr.startsWith("voice/")) { + logger.debug("返回相对路径: {}", contentStr); + return contentStr; + } + + // 情况6:尝试从字符串中提取 URL + if (contentStr.contains("\"url\"")) { + try { + int start = contentStr.indexOf("\"url\":\"") + 7; + int end = contentStr.indexOf("\"", start); + if (start > 6 && end > start) { + String url = contentStr.substring(start, end); + logger.debug("从字符串提取 URL: {}", url); + return url; + } + } catch (Exception e) { + logger.warn("从字符串提取 URL 失败: {}", e.getMessage()); + } + } + + // 兜底:直接返回字符串 + logger.warn("无法解析 URL,直接返回 content: {}", contentStr); + return contentStr; + } + + /** + * 创建支持自定义SSL配置的RestTemplate + * 针对HTTPS请求,配置信任所有证书并关闭Hostname验证,以兼容自签名证书 + */ + private RestTemplate createRestTemplate() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory() { + @Override + protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException { + if (ossUploadUrl.startsWith("https") && connection instanceof HttpsURLConnection) { + // HTTPS 特殊配置(如需要) + } + super.prepareConnection(connection, httpMethod); + } + }; + requestFactory.setBufferRequestBody(false); + return new RestTemplate(requestFactory); + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/util/RecordParser.java b/src/main/java/com/threecloud/dataserviceyy/util/RecordParser.java new file mode 100644 index 0000000..80a6572 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/util/RecordParser.java @@ -0,0 +1,88 @@ +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; + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/util/SyncTimeUtil.java b/src/main/java/com/threecloud/dataserviceyy/util/SyncTimeUtil.java new file mode 100644 index 0000000..b145ec2 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/util/SyncTimeUtil.java @@ -0,0 +1,70 @@ +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()); + } + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/util/VaaHttpUtil.java b/src/main/java/com/threecloud/dataserviceyy/util/VaaHttpUtil.java new file mode 100644 index 0000000..9b03521 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/util/VaaHttpUtil.java @@ -0,0 +1,332 @@ +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); + + /** 连接超时:30秒,设备可能在远端网络 */ + private static final int CONNECT_TIMEOUT = 30000; + /** API读取超时:60秒 */ + private static final int READ_TIMEOUT = 60000; + /** 文件下载读取超时:5分钟,音频文件可能较大 */ + private static final int DOWNLOAD_READ_TIMEOUT = 300000; + /** 重试次数 */ + private static final int MAX_RETRY = 3; + /** 重试间隔基数(毫秒) */ + private static final int RETRY_BASE_DELAY = 2000; + + /** + * HTTP登录认证 + * @param loginUrl 登录URL,例如:http://192.168.1.100/authorize?username=admin&password=admin123 + * @return 登录成功后返回Authorization Cookie值 + */ + public String httpLogin(String loginUrl) throws Exception { + return executeWithRetry(() -> { + 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地址,例如:http://192.168.1.100/service/running/channel + * @param authorization 登录后返回的Authorization Cookie值 + * @return 返回JSON字符串 + */ + public String httpVisit(String apiUrl, String authorization) throws Exception { + return executeWithRetry(() -> { + 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(); + } + } + }, "API访问"); + } + + /** + * 下载录音文件(带重试) + * @param fileUrl 文件URL,例如:http://192.168.1.100/record/2026/05/20/OUT-xxx.wav + * @param savePath 保存路径 + * @param authorization 登录后返回的Authorization Cookie值 + */ + public void httpDown(String fileUrl, String savePath, String authorization) throws Exception { + executeWithRetry(() -> { + 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; + long lastLogTime = System.currentTimeMillis(); + + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + totalBytes += bytesRead; + // 每10秒打印一次进度 + long now = System.currentTimeMillis(); + if (now - lastLogTime > 10000) { + logger.info("下载进度: {} MB", totalBytes / 1024 / 1024); + lastLogTime = now; + } + } + + outputStream.flush(); + 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(); + } + } + return null; + }, "文件下载"); + } + + /** + * 带重试的执行器 + */ + private T executeWithRetry(RetryableTask task, String operationName) throws Exception { + Exception lastException = null; + for (int attempt = 1; attempt <= MAX_RETRY; attempt++) { + try { + return task.execute(); + } catch (java.net.SocketTimeoutException e) { + lastException = e; + if (attempt < MAX_RETRY) { + long delay = RETRY_BASE_DELAY * attempt; + logger.warn("{}(第{}次)超时,{}ms后重试: {}", operationName, attempt, delay, e.getMessage()); + Thread.sleep(delay); + } + } catch (java.net.ConnectException e) { + lastException = e; + if (attempt < MAX_RETRY) { + long delay = RETRY_BASE_DELAY * attempt; + logger.warn("{}(第{}次)连接失败,{}ms后重试: {}", operationName, attempt, delay, e.getMessage()); + Thread.sleep(delay); + } + } catch (IOException e) { + lastException = e; + if (attempt < MAX_RETRY && isRetryable(e)) { + long delay = RETRY_BASE_DELAY * attempt; + logger.warn("{}(第{}次)IO异常,{}ms后重试: {}", operationName, attempt, delay, e.getMessage()); + Thread.sleep(delay); + } else { + throw e; + } + } + } + throw new RuntimeException(operationName + "失败,已重试" + MAX_RETRY + "次", lastException); + } + + private boolean isRetryable(IOException e) { + String msg = e.getMessage(); + if (msg == null) return false; + return msg.contains("timed out") || msg.contains("connection") || msg.contains("reset"); + } + + @FunctionalInterface + private interface RetryableTask { + T execute() throws Exception; + } + + 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("> 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 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 + * 返回每条线路的分机号码,如: {"1":"8001","2":"8002",...} + * @param extUrl 接口地址,例如:http://192.168.1.100/service/ext/number + * @param authorization 登录后返回的Authorization Cookie值 + * @return 通道-分机号映射 Map + */ + public Map 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 { + // EBOX返回的是JSON对象,key是通道号(1-8),value是分机号码 + com.alibaba.fastjson2.JSONObject jsonObj = JSON.parseObject(jsonData); + Map 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<>(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/threecloud/dataserviceyy/util/fileUtil.java b/src/main/java/com/threecloud/dataserviceyy/util/fileUtil.java new file mode 100644 index 0000000..b6dffe9 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/util/fileUtil.java @@ -0,0 +1,72 @@ +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 responseEntity = null; + RestTemplate restTemplate = createRestTemplate(ossUrl); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + MultiValueMap files = new LinkedMultiValueMap(); + //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> httpEntity = + new HttpEntity>(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); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..0c30310 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,70 @@ +spring: + application: + name: dataservice-yy + # 引入外部配置文件(部署时修改 config/application-external.yml) + # 注意:路径是相对于 JAR 包所在目录的 + config: + import: optional:file:./config/application-external.yml + +mybatis: + mapper-locations: classpath:mapper/*.xml + type-aliases-package: com.threecloud.dataserviceyy + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + +# ==================== 日志配置 ==================== +# 【内网测试专用】详细日志级别配置 +logging: + level: + # 根日志级别:INFO(生产环境建议WARN) + root: INFO + + # 本项目代码:DEBUG(内网测试时开启,生产建议INFO) + com.threecloud.dataserviceyy: DEBUG + + # 同步服务:DEBUG(关键业务流程) + com.threecloud.dataserviceyy.service.VaaSyncService: DEBUG + + # 通道配置服务:DEBUG + com.threecloud.dataserviceyy.service.channel: DEBUG + + # HTTP工具:DEBUG(查看API请求响应) + com.threecloud.dataserviceyy.util.VaaHttpUtil: DEBUG + + # 文件上传:INFO + com.threecloud.dataserviceyy.util.FileUploadUtil: INFO + + # MyBatis SQL日志:DEBUG(查看执行的SQL) + com.threecloud.dataserviceyy.mapper: DEBUG + + # Spring框架:WARN(减少框架日志噪音) + org.springframework: WARN + org.springframework.web: WARN + + # Hikari连接池:INFO + com.zaxxer.hikari: INFO + + # Tomcat:INFO + org.apache.catalina: INFO + + # 日志文件配置 + file: + name: logs/app.log + + # 日志格式 + pattern: + # 文件格式:带时间、线程、级别、类名 + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" + # 控制台格式:简化版,方便开发查看 + console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - %msg%n" + + # 日志文件滚动配置 + logback: + rollingpolicy: + # 单个文件最大10MB + max-file-size: 10MB + # 保留30天 + max-history: 30 + # 总大小不超过1GB + total-size-cap: 1GB diff --git a/src/main/resources/mapper/MidVoiceCallRecordMapper.xml b/src/main/resources/mapper/MidVoiceCallRecordMapper.xml new file mode 100644 index 0000000..6be0d39 --- /dev/null +++ b/src/main/resources/mapper/MidVoiceCallRecordMapper.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mid_voice_call_record ( + city_code, city_name, call_record_id, call_tel, called_tel, + call_start_time, call_end_time, call_duration, call_direction, + device_no, business_scenario, recording_file_name, recording_file_path, + recording_file_size, call_status, fail_reason, remarks, sync_time, create_time, org_code + ) VALUES ( + #{cityCode}, #{cityName}, #{callRecordId}, #{callTel}, #{calledTel}, + #{callStartTime}, #{callEndTime}, #{callDuration}, #{callDirection}, + #{deviceNo}, #{businessScenario}, #{recordingFileName}, #{recordingFilePath}, + #{recordingFileSize}, #{callStatus}, #{failReason}, #{remarks}, NOW(), NOW(), #{orgCode} + ) + + + + + + + + + + + + + + + + + + + + UPDATE mid_voice_call_record + SET recording_file_path = #{recordingFilePath}, sync_time = NOW() + WHERE id = #{id} + + + + + UPDATE mid_voice_call_record + SET call_status = #{callStatus}, fail_reason = #{failReason}, sync_time = NOW() + WHERE id = #{id} + + + diff --git a/src/main/resources/mapper/MidVoiceChannelConfigMapper.xml b/src/main/resources/mapper/MidVoiceChannelConfigMapper.xml new file mode 100644 index 0000000..e4703ab --- /dev/null +++ b/src/main/resources/mapper/MidVoiceChannelConfigMapper.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + INSERT INTO mid_voice_channel_config ( + city_code, city_name, device_no, channel_no, phone_number, + channel_name, channel_status, create_time, update_time, remarks + ) VALUES ( + #{cityCode}, #{cityName}, #{deviceNo}, #{channelNo}, #{phoneNumber}, + #{channelName}, #{channelStatus}, NOW(), NOW(), #{remarks} + ) + + + + + + + + + + + + + + + + + + + + UPDATE mid_voice_channel_config + SET city_code = #{cityCode}, + city_name = #{cityName}, + device_no = #{deviceNo}, + channel_no = #{channelNo}, + phone_number = #{phoneNumber}, + channel_name = #{channelName}, + channel_status = #{channelStatus}, + remarks = #{remarks}, + update_time = NOW() + WHERE id = #{id} + + + + + UPDATE mid_voice_channel_config + SET channel_status = #{channelStatus}, + update_time = NOW() + WHERE id = #{id} + + + + + DELETE FROM mid_voice_channel_config WHERE id = #{id} + + + + + INSERT INTO mid_voice_channel_config ( + city_code, city_name, device_no, channel_no, phone_number, + channel_name, channel_status, create_time, update_time, remarks + ) VALUES + + ( + #{item.cityCode}, #{item.cityName}, #{item.deviceNo}, #{item.channelNo}, #{item.phoneNumber}, + #{item.channelName}, #{item.channelStatus}, NOW(), NOW(), #{item.remarks} + ) + + + + diff --git a/src/main/resources/mapper/SyncLogMapper.xml b/src/main/resources/mapper/SyncLogMapper.xml new file mode 100644 index 0000000..aa520e2 --- /dev/null +++ b/src/main/resources/mapper/SyncLogMapper.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + 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() + ) + + + + + + + + + + + + + + + + + UPDATE sync_log + SET end_time = #{endTime}, + success_count = #{successCount}, + fail_count = #{failCount}, + status = #{status}, + error_msg = #{errorMsg} + WHERE id = #{id} + + + diff --git a/src/main/resources/mapper/VoiceRecordMapper.xml b/src/main/resources/mapper/VoiceRecordMapper.xml new file mode 100644 index 0000000..ae4198c --- /dev/null +++ b/src/main/resources/mapper/VoiceRecordMapper.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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() + ) + + + + + + + + + + + + + + + + + + + + UPDATE voice_record + SET oss_url = #{ossUrl}, update_time = NOW() + WHERE id = #{id} + + + + + UPDATE voice_record + SET status = #{status}, fail_reason = #{failReason}, update_time = NOW() + WHERE id = #{id} + + + diff --git a/src/main/resources/mapper/VoiceSyncMapper.xml b/src/main/resources/mapper/VoiceSyncMapper.xml new file mode 100644 index 0000000..2b6130b --- /dev/null +++ b/src/main/resources/mapper/VoiceSyncMapper.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + INSERT INTO YYDC_YYTH(ID, ORGAN_NAME, ORGAN_ID, YYSB_ID, SBTD_ID, THFX, PHONE, ZJHM, + BJHM, THSC, KSSJ, JSSJ, LYZT, LYDZ, TBSJ, CJSJ, CJR_ID, XGSJ, XGR_ID, SFYX_ST) + VALUES (SYS_GUID(), #{organName}, #{organId}, #{yysbId}, #{sbtdId}, #{thfx}, #{phone}, #{zjhm}, #{bjhm}, + #{thsc}, TO_DATE(#{kssj}, 'YYYY-MM-DD HH24:MI:SS'), TO_DATE(#{jssj}, 'YYYY-MM-DD HH24:MI:SS'), '1', + #{lydz}, SYSDATE, SYSDATE, 0, SYSDATE, 0, '1') + + + + + + + UPDATE YYDC_SBTD + SET TDZT = #{TDZT}, + TBSJ = #{TBSJ}, + XGR_ID = 0, + XGSJ = #{TBSJ} + WHERE SFYX_ST = '1' + AND TDHM = #{TDHM} + AND UUID = #{UUID} + + + + + + INSERT INTO YYDC_TBRZ (CODE, KSSJ, JSSJ, GXSJ, TBBZ, TBSM) + VALUES (#{CODE}, #{KSSJ}, #{JSSJ}, #{GXSJ}, #{TBBZ}, #{TBSM}) + + + + + + INSERT INTO YYDC_YYTH + (ID, ORGAN_NAME, ORGAN_ID, YYSB_ID, SBTD_ID, THFX, PHONE, ZJHM, BJHM, + THSC, KSSJ, JSSJ, LYZT, LYDZ, LYMC, YJZT, YCZT, XCZT, DBZT, + TBSJ, CJR_ID, CJSJ, XGR_ID, XGSJ, SFYX_ST) + VALUES + (#{THID}, #{ORGAN_NAME}, #{ORGAN_ID}, #{YYSB_ID}, #{SBTD_ID}, #{THFX}, #{PHONE}, + #{ZJHM}, #{BJHM}, #{THSC}, TO_DATE(#{KSSJ}, 'YYYY-MM-DD HH24:MI:SS'), + TO_DATE(#{JSSJ}, 'YYYY-MM-DD HH24:MI:SS'), '1', #{LYDZ}, #{LYMC}, + '0', '0', '0', '0', #{TBSJ}, 0, #{TBSJ}, 0, #{TBSJ}, '1') + + + + UPDATE YYDC_YYTH + SET ORGAN_NAME = #{ORGAN_NAME}, + ORGAN_ID = #{ORGAN_ID}, + YYSB_ID = #{YYSB_ID}, + SBTD_ID = #{SBTD_ID}, + THFX = #{THFX}, + PHONE = #{PHONE}, + ZJHM = #{ZJHM}, + BJHM = #{BJHM}, + THSC = #{THSC}, + KSSJ = TO_DATE(#{KSSJ}, 'YYYY-MM-DD HH24:MI:SS'), + JSSJ = TO_DATE(#{JSSJ}, 'YYYY-MM-DD HH24:MI:SS'), + LYZT = '1', + LYDZ = #{LYDZ}, + LYMC = #{LYMC}, + TBSJ = #{TBSJ}, + XGR_ID = 0, + XGSJ = #{TBSJ} + WHERE ID = #{THID} + + diff --git a/src/test/java/com/threecloud/dataserviceyy/DataserviceYyApplicationTests.java b/src/test/java/com/threecloud/dataserviceyy/DataserviceYyApplicationTests.java new file mode 100644 index 0000000..be6a634 --- /dev/null +++ b/src/test/java/com/threecloud/dataserviceyy/DataserviceYyApplicationTests.java @@ -0,0 +1,16 @@ +package com.threecloud.dataserviceyy; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@Disabled // 【方案A: 暂时禁用测试, 因为Oracle在当前环境不可达] +class DataserviceYyApplicationTests { + + @Test + @Disabled // 禁用单个测试方法 + void contextLoads() { + } + +}