diff --git a/README.md b/README.md index 8129080..982cab3 100644 --- a/README.md +++ b/README.md @@ -1,213 +1,381 @@ -# 语音数据同步服务 (dataservice-yy) +# 语音数据FTP同步服务 + +> 基于淮南 yydc-tb-server 老代码改造的 Spring Boot 服务 +> 用于从FTP服务器读取录音数据,上传至OSS并入库到人大金仓数据库 ## 项目简介 -语音数据同步服务用于从 EBOX 录音盒拉取录音文件,上传到 OSS,并保存通话记录到数据库。 +本服务定时从FTP服务器读取通话记录txt文件,下载对应的mp3录音文件,上传到OSS对象存储,并将通话记录保存到人大金仓数据库。**每个地市单独部署一份服务**。 + +参考老代码(淮南):`src/main/java/淮南/MineService.java`(已废弃,新代码基于其重写) + +## 技术栈 + +| 技术 | 版本 | +|-----|------| +| Spring Boot | 2.6.13 | +| MyBatis | 2.3.1 | +| 数据库 | 人大金仓 (KingbaseES) | +| 工具库 | Apache Commons Net (FTP) | +| JDK | 1.8+ | ## 目录结构 ``` dataservice-yy/ -├── dataservice-yy-0.0.1-SNAPSHOT.jar # 主程序 JAR 包 +├── src/main/java/com/threecloud/dataserviceyy/ +│ ├── DataserviceYyApplication.java # 启动类 +│ ├── config/ +│ │ ├── DataSourceConfig.java # 数据源配置 +│ │ └── FtpSyncProperties.java # FTP配置 +│ ├── entity/ +│ │ └── MidVoiceCallRecord.java # 通话记录实体 +│ ├── mapper/ +│ │ └── MidVoiceCallRecordMapper.java # MyBatis Mapper +│ ├── service/ +│ │ └── FtpSyncService.java # 核心同步服务 +│ └── util/ +│ ├── FilePathUtil.java # 路径工具 +│ ├── FileUploadUtil.java # OSS上传工具 +│ ├── FtpUtil.java # FTP工具 +│ └── DateUtil.java # 日期工具 +├── src/main/resources/ +│ ├── application.yml # 内部配置 +│ ├── logback-spring.xml # 日志配置 +│ └── mapper/ +│ └── MidVoiceCallRecordMapper.xml # SQL映射 ├── config/ -│ └── application-external.yml # 外部配置文件(部署时修改) -├── vaa-recordings/ # 本地录音文件存储目录 -│ ├── 340100/ # 地市编码 -│ │ ├── 20240101/ # 日期目录 -│ │ │ └── / # 设备目录 -│ │ │ └── xxx.wav -│ │ └── 20240102/ -│ └── ... -├── logs/ -│ └── app.log # 日志文件 -└── README.md # 本说明文档 +│ └── application-external.yml # 外部配置(部署时修改) +├── sql/ +│ └── mid_voice_call_record.sql # 建表SQL +├── pom.xml # Maven配置 +└── README.md # 本文档 ``` -## 部署步骤 +## 数据流程 -### 1. 准备环境 +``` +┌──────────────┐ 1.连接 ┌──────────────┐ +│ 本服务 │◀───────────▶│ FTP服务器 │ +│ │ 2.下载txt │ (录音盒) │ +│ │ 3.下载mp3 │ │ +└──────┬───────┘ └──────────────┘ + │ + │ 4.上传 + ▼ +┌──────────────┐ +│ OSS存储 │ http://oss地址/voice/合肥/340100/20240101/xxx.mp3 +└──────────────┘ + │ + │ 5.入库 + ▼ +┌──────────────┐ +│ 人大金仓 │ mid_voice_call_record 表 +└──────────────┘ +``` -- JDK 1.8+ -- 人大金仓数据库(已创建 mid_voice schema) -- OSS 服务(已部署并配置好上传接口) +## 数据库表 + +### mid_voice_call_record(通话记录表) + +```sql +CREATE TABLE "mid_voice"."mid_voice_call_record" ( + "id" int8 NOT NULL GENERATED ALWAYS AS IDENTITY, + "city_code" varchar(20), + "city_name" varchar(100), + "call_record_id" varchar(100) NOT NULL, -- 唯一标识,建议加唯一索引 + "call_tel" varchar(50) NOT NULL, -- 主叫 + "called_tel" varchar(200) NOT NULL, -- 被叫 + "call_start_time" timestamp(6) NOT NULL, + "call_end_time" timestamp(6) NOT NULL, + "call_duration" int4 NOT NULL, -- 秒 + "call_direction" varchar(50) NOT NULL, -- 1呼入/2呼出 + "device_no" varchar(100) NOT NULL, -- 设备编码(FTP_地市编码) + "business_scenario" varchar(100), + "recording_file_name" varchar(200) NOT NULL, + "recording_file_path" varchar(500) NOT NULL, -- OSS URL + "recording_file_size" int4, + "call_status" varchar(20), -- 1正常/2未接通 + "fail_reason" text, + "remarks" text, + "sync_time" timestamp(6) DEFAULT CURRENT_TIMESTAMP, + "create_time" timestamp(6) DEFAULT CURRENT_TIMESTAMP, + "org_code" varchar(64), + PRIMARY KEY ("id") +) +PARTITION BY LIST ("city_code"); +``` -### 2. 创建目录结构 +**建议加唯一索引**(防重最后一道防线): -```bash -mkdir -p /opt/dataservice-yy/config -mkdir -p /opt/dataservice-yy/logs +```sql +CREATE UNIQUE INDEX idx_call_record_id_unique +ON mid_voice.mid_voice_call_record (call_record_id); ``` -### 3. 复制文件 +## 部署说明 + +### 1. 准备JAR包 ```bash -# 复制 JAR 包 -cp dataservice-yy-0.0.1-SNAPSHOT.jar /opt/dataservice-yy/ +cd /Users/wang/sanduoyun/developspace/dataservice-yy +mvn clean package -DskipTests +``` + +产物:`target/dataservice-yy-0.0.1-SNAPSHOT.jar` + +### 2. 部署目录结构 -# 复制配置文件模板 -cp config/application-external.yml /opt/dataservice-yy/config/ +每台服务器(每地市): + +``` +/opt/dataservice-yy/ +├── dataservice-yy-0.0.1-SNAPSHOT.jar +├── config/ +│ └── application-external.yml # 修改此文件配置FTP +├── vaa-ftp-temp/ # 自动创建(临时文件) +└── logs/ # 自动创建(运行日志) + └── app.log ``` -### 4. 修改配置 +### 3. 修改外部配置 -编辑 `/opt/dataservice-yy/config/application-external.yml`,修改以下配置项: +编辑 `config/application-external.yml`: -#### 数据库配置 ```yaml spring: datasource: - url: jdbc:kingbase8://{数据库IP}:{端口}/kingbase?currentSchema=mid_voice&clientEncoding=utf8 - username: {数据库用户名} - password: {数据库密码} -``` + url: jdbc:kingbase8://你的数据库IP:54321/kingbase?currentSchema=mid_voice + username: dcms_dev + password: 你的密码 + +ftp-sync: + cities: + - city-code: "340100" # 你的地市编码 + city-name: "合肥" + ftp-host: 10.126.129.7 # FTP服务器 + ftp-port: 9979 + ftp-username: ftpuser + ftp-password: ftppass + ftp-source-dir: /340100/record/ # txt目录 + ftp-record-dir: /340100/recordfile/ # mp3目录 + ftp-archive-dir: /340100/processed/ # 归档目录 -#### OSS 配置 -```yaml vaa-sync: oss: - base-url: http://{OSS服务器IP}:9090 - upload-url: http://{OSS服务器IP}:9090/apiOss/oss/fileUpload + base-url: http://你的OSS地址:9090 + upload-url: http://你的OSS地址:9090/apiOss/oss/fileUpload appcode: dataservice-yy - appid: {appid} - appsecret: {appsecret} + appid: 你的appid + appsecret: 你的appsecret ``` -#### 其他配置 -```yaml -vaa-sync: - download-path: ./vaa-recordings # 本地录音文件存储路径 - sync-interval-cron: "0 0 0/2 * * ?" # 同步间隔(默认每2小时) - device-username: admin # 录音盒登录账号 - device-password: admin # 录音盒登录密码 - -server: - port: 8088 # 服务端口 -``` - -### 5. 启动服务 +### 4. 启动服务 ```bash cd /opt/dataservice-yy nohup java -jar dataservice-yy-0.0.1-SNAPSHOT.jar > /dev/null 2>&1 & ``` -### 6. 查看日志 +### 5. 验证启动 ```bash tail -f logs/app.log ``` -## 配置文件说明 +看到如下日志说明启动成功: +``` +【项目启动】开始执行首次FTP录音同步 +【项目启动】首次同步完成,后续将在每2小时自动执行 +``` -### application-external.yml +## 配置项说明 + +### 数据库配置(spring.datasource) + +| 配置项 | 必填 | 说明 | +|-------|-----|------| +| `url` | ✓ | JDBC URL(人大金仓:jdbc:kingbase8://IP:port/db) | +| `username` | ✓ | 数据库用户 | +| `password` | ✓ | 数据库密码 | +| `driver-class-name` | ✓ | com.kingbase8.Driver | + +### FTP配置(ftp-sync.cities[]) + +| 配置项 | 必填 | 说明 | +|-------|-----|------| +| `city-code` | ✓ | 地市编码(如340100),与数据库分区对应 | +| `city-name` | ✓ | 地市名称(用于OSS路径) | +| `ftp-host` | ✓ | FTP服务器IP | +| `ftp-port` | ✓ | FTP端口 | +| `ftp-username` | ✓ | FTP用户名 | +| `ftp-password` | ✓ | FTP密码 | +| `ftp-source-dir` | ✓ | txt源目录(处理后归档) | +| `ftp-record-dir` | ✓ | mp3/wav文件目录 | +| `ftp-archive-dir` | ✓ | 处理完的txt移动到此目录 | + +### FTP全局配置(ftp-sync.*) + +| 配置项 | 默认值 | 说明 | +|-------|-------|------| +| `enabled` | true | 是否启用FTP同步 | +| `sync-interval-cron` | 0 0 0/2 * * ? | 定时任务(每2小时) | +| `temp-path` | ./vaa-ftp-temp | 本地临时文件目录 | +| `retain-days` | 10 | 临时文件保留天数 | +| `txt-encoding` | GBK | txt文件编码(老系统用GBK) | +| `field-separator` | ※ | 字段分隔符(老系统用※) | + +### OSS配置(vaa-sync.oss) + +| 配置项 | 必填 | 说明 | +|-------|-----|------| +| `base-url` | ✓ | OSS访问基础URL | +| `upload-url` | ✓ | OSS上传接口URL | +| `appcode` | ✓ | 应用编码 | +| `appid` | ✓ | 应用ID | +| `appsecret` | ✓ | 应用密钥 | + +## txt文件格式 + +**编码**:GBK +**分隔符**:※ +**字段顺序**: +``` +kssj※jssj※hjls※hjzls※zjhm※bjhm※thsc※...※thfx +``` -此文件包含所有需要部署时调整的参数,**无需重新打包 JAR**。 +| 序号 | 字段 | 说明 | 示例 | +|-----|------|------|------| +| 1 | kssj | 开始时间 | 20240101120000 | +| 2 | jssj | 结束时间 | 20240101120030 | +| 3 | hjls | 呼叫流水 | 12345 | +| 4 | hjzls | 呼叫主/被叫流水 | 67890 | +| 5 | zjhm | 主叫号码 | 055312345678 | +| 6 | bjhm | 被叫号码 | 12345678 | +| 7 | thsc | 通话时长(秒) | 30 | +| ... | ... | 中间字段 | ... | +| 10 | thfx | 通话方向 | 1=呼入/0=呼出 | -| 配置项 | 说明 | 示例 | -|-------|------|------| -| `spring.datasource.url` | 数据库连接URL | `jdbc:kingbase8://53.1.194.60:54321/kingbase?currentSchema=mid_voice&clientEncoding=utf8` | -| `spring.datasource.username` | 数据库用户名 | `dcms_dev` | -| `spring.datasource.password` | 数据库密码 | `sdy@2025#dc$ks` | -| `vaa-sync.oss.base-url` | OSS基础地址 | `http://53.1.194.59:9090` | -| `vaa-sync.oss.upload-url` | OSS上传接口 | `http://53.1.194.59:9090/apiOss/oss/fileUpload` | -| `vaa-sync.oss.appcode` | OSS应用编码 | `dataservice-yy` | -| `vaa-sync.oss.appid` | OSS应用ID | `371a3368-e28e-4ba3-95a3-c31c19cf0ad0` | -| `vaa-sync.oss.appsecret` | OSS应用密钥 | `06a6a80e-f9d2-4b3b-acc0-8d182c876074` | -| `vaa-sync.download-path` | 本地录音存储路径 | `./vaa-recordings` | -| `vaa-sync.sync-interval-cron` | 同步定时任务 | `0 0 0/2 * * ?`(每2小时) | -| `vaa-sync.device-username` | 录音盒登录账号 | `admin` | -| `vaa-sync.device-password` | 录音盒登录密码 | `admin` | -| `server.port` | 服务端口 | `8088` | +**录音文件**:`{ftpRecordDir}/{yyyyMMdd}/{hjls}_{hjzls}.mp3` -## 定时任务说明 +## 防重复机制 -同步任务默认每2小时执行一次,可通过修改 `sync-interval-cron` 调整: +| 层级 | 方式 | 说明 | +|-----|------|------| +| 1 | FTP归档 | 处理完的txt移到 `archive/` | +| 2 | 数据库前缀查重 | `call_record_id LIKE '340100_FTP_%'` | +| 3 | 内存Set去重 | 同批内不重复 | +| 4 | 数据库唯一索引 | 建议在 `call_record_id` 上加 | -| 表达式 | 说明 | -|-------|------| -| `0 0 0/2 * * ?` | 每2小时执行一次(默认) | -| `0 0/30 * * * ?` | 每30分钟执行一次 | -| `0 0 2 * * ?` | 每天凌晨2点执行 | -| `0 0/1 * * * ?` | 每分钟执行(测试用) | +## 定时任务 -## 数据表说明 +默认 `0 0 0/2 * * ?`(每2小时执行一次)。 -### mid_voice_device_config +可在 `application-external.yml` 中修改: -设备基础配置表,存储录音盒设备信息。 +```yaml +ftp-sync: + sync-interval-cron: "0 0 0/2 * * ?" +``` -| 字段 | 说明 | -|-----|------| -| device_no | 设备编号(UUID) | -| city_code | 地市编码 | -| city_name | 地市名称 | -| ip_address | IP地址 | -| device_port | 端口号 | -| device_status | 设备状态(0在线) | +**常用 Cron 表达式**: +- 每分钟:`0 0/1 * * * ?` +- 每30分钟:`0 0/30 * * * ?` +- 每小时:`0 0 * * * ?` +- 每2小时:`0 0 0/2 * * ?` +- 每天凌晨2点:`0 0 2 * * ?` -### mid_voice_channel_config +## 日志 -通道配置表,存储录音盒通道绑定的电话号码。 +### 日志位置 -| 字段 | 说明 | -|-----|------| -| device_no | 设备编号 | -| channel_no | 通道号(1-8) | -| phone_number | 绑定电话号码 | +`logs/app.log`(按天滚动,保留30天) -### mid_voice_call_record +### 日志前缀 -通话记录表,存储录音文件信息和OSS地址。 +| 前缀 | 含义 | +|------|------| +| 【FTP定时】 | 定时任务开始/结束 | +| 【FTP主流程】 | 同步主流程 | +| 【FTP地市】 | 单地市处理 | +| 【FTP文件】 | 单txt文件处理 | +| 【FTP行】 | 单行记录处理 | +| 【FTP防重】 | 数据库防重查询 | +| 【异常】 | 错误信息 | -| 字段 | 说明 | -|-----|------| -| call_record_id | 通话记录ID(device_no + record_id) | -| device_no | 设备编号 | -| city_code | 地市编码 | -| city_name | 地市名称 | -| call_tel | 主叫号码 | -| called_tel | 被叫号码 | -| call_start_time | 通话开始时间 | -| call_end_time | 通话结束时间 | -| call_duration | 通话时长(秒) | -| call_direction | 通话方向(1呼入,2呼出) | -| recording_file_name | 录音文件名 | -| recording_file_path | OSS完整访问URL | -| recording_file_size | 文件大小(字节) | - -## 日志说明 - -日志文件位于 `logs/app.log`,可通过以下命令查看: +### 常用查看命令 ```bash -# 查看实时日志 +# 实时日志 tail -f logs/app.log -# 只看错误日志 -tail -f logs/app.log | grep "【异常】" +# 只看错误 +grep "【异常】" logs/app.log -# 只看设备同步日志 -tail -f logs/app.log | grep "【设备】" +# 只看单地市 +grep "【FTP地市】" logs/app.log | grep "合肥" -# 只看录音处理日志 -tail -f logs/app.log | grep "【录音】" +# 同步成功数 +grep "保存成功" logs/app.log | wc -l ``` ## 常见问题 -### 1. 配置文件读取失败 +### Q1: 数据库连接失败 + +检查 `application-external.yml` 的 `spring.datasource.*` 配置,确保: +- IP和端口正确 +- 用户名密码正确 +- 数据库服务已启动 + +### Q2: FTP连接失败 + +检查 `ftp-sync.cities[0].ftp-*` 配置: +- FTP服务可达:`telnet 10.126.129.7 9979` +- 用户名密码正确 +- 目录路径有读写权限 + +### Q3: 录音文件下载失败 + +检查 FTP 上是否存在对应路径的文件: +- 路径格式:`{ftp-record-dir}/{yyyyMMdd}/{hjls}_{hjzls}.mp3` +- 文件名大小写是否一致 + +### Q4: OSS上传失败 + +检查 `vaa-sync.oss.*` 配置: +- `upload-url` 接口是否可达 +- `appid` / `appsecret` 是否正确 -检查 `config/application-external.yml` 是否在 JAR 包同级目录的 `config/` 文件夹下。 +### Q5: 数据没有入库 -### 2. 数据库连接失败 +检查: +- 数据库 `mid_voice_call_record` 表是否存在 +- 日志中是否有 `保存成功` 字样 +- 数据库表分区是否覆盖了该地市编码 -检查数据库URL、用户名、密码是否正确,网络是否连通。 +## 与老代码(淮南 yydc-tb-server)的差异 -### 3. OSS上传失败 +| 项 | 老代码 | 新代码 | +|---|-------|-------| +| 数据源 | Oracle | 人大金仓 | +| 配置 | Spring XML | application.yml | +| 同步方式 | 手动运行 | 定时任务 | +| mp3处理 | 只存URL | 下载+上传OSS | +| 防重 | 不防重 | 数据库+FTP双层防重 | +| 部署 | 单机单实例 | 多地市独立部署 | +| 状态 | 已废弃 | 当前生产 | -检查 OSS 地址、认证信息是否正确,OSS服务是否正常。 +## 维护 -### 4. 录音盒连接失败 +| 项 | 频率 | +|---|------| +| 检查日志 | 每天 | +| 清理磁盘 | 自动(保留10天) | +| 数据库备份 | 由DBA负责 | +| 配置文件变更 | 部署时修改外部配置 | -检查录音盒IP、端口、账号密码是否正确,网络是否连通。 +## 联系 +- 项目维护:xxx +- 内部文档:xxx diff --git a/config/application-external.yml b/config/application-external.yml index 243c6cc..14142c6 100644 --- a/config/application-external.yml +++ b/config/application-external.yml @@ -38,6 +38,36 @@ vaa-sync: appid: your_appid_here appsecret: your_appsecret_here +# ==================== FTP同步配置(单地市部署) ==================== +# 【说明】每个地市单独部署本服务,配置只需要写一个地市 +ftp-sync: + # 是否启用FTP同步 + enabled: true + + # 本地临时文件目录 + temp-path: ./vaa-ftp-temp + + # 文件保留天数 + retain-days: 10 + + # txt文件编码(老系统用GBK) + txt-encoding: GBK + + # 字段分隔符(老系统用※) + field-separator: "※" + + # 当前部署的地市FTP配置(每个地市独立部署一份) + cities: + - city-code: "340100" + city-name: "合肥" + ftp-host: 10.126.129.7 + ftp-port: 9979 + ftp-username: ftpuser + ftp-password: ftppass + ftp-source-dir: /340100/record/ + ftp-record-dir: /340100/recordfile/ + ftp-archive-dir: /340100/processed/ + # ==================== 服务端口 ==================== server: port: 8088 diff --git a/logs/app.log b/logs/app.log deleted file mode 100644 index 9da34a8..0000000 --- a/logs/app.log +++ /dev/null @@ -1,76 +0,0 @@ -2026-05-26 16:26:25.778 [main] INFO c.t.d.DataserviceYyApplication - Starting DataserviceYyApplication using Java 1.8.0_492 on MswangdeMacBook-Air.local with PID 89751 (/Users/wang/sanduoyun/developspace/dataservice-yy/target/classes started by wang in /Users/wang/sanduoyun/developspace/dataservice-yy) -2026-05-26 16:26:25.782 [main] DEBUG c.t.d.DataserviceYyApplication - Running with Spring Boot v2.6.13, Spring v5.3.23 -2026-05-26 16:26:25.783 [main] INFO c.t.d.DataserviceYyApplication - No active profile set, falling back to 1 default profile: "default" -2026-05-26 16:26:26.305 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port(s): 8088 (http) -2026-05-26 16:26:26.310 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat] -2026-05-26 16:26:26.310 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/9.0.68] -2026-05-26 16:26:26.357 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext -2026-05-26 16:26:26.357 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 546 ms -2026-05-26 16:26:26.601 [main] INFO c.t.d.util.FileUploadUtil - 文件上传工具初始化完成, 上传接口: http://127.0.0.1/apiOss/oss/fileUpload -2026-05-26 16:26:26.877 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port(s): 8088 (http) with context path '' -2026-05-26 16:26:26.886 [main] INFO c.t.d.DataserviceYyApplication - Started DataserviceYyApplication in 1.294 seconds (JVM running for 2.135) -2026-05-26 16:26:26.888 [main] INFO c.t.d.service.VaaSyncService - 开始执行VAA录音盒同步任务 -2026-05-26 16:26:26.906 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting... -2026-05-26 16:26:26.913 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed. -2026-05-26 16:26:50.548 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... -2026-05-26 16:26:55.382 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed. -2026-05-26 16:32:47.381 [main] INFO c.t.d.DataserviceYyApplication - Starting DataserviceYyApplication using Java 1.8.0_492 on MswangdeMacBook-Air.local with PID 90117 (/Users/wang/sanduoyun/developspace/dataservice-yy/target/classes started by wang in /Users/wang/sanduoyun/developspace/dataservice-yy) -2026-05-26 16:32:47.382 [main] DEBUG c.t.d.DataserviceYyApplication - Running with Spring Boot v2.6.13, Spring v5.3.23 -2026-05-26 16:32:47.383 [main] INFO c.t.d.DataserviceYyApplication - No active profile set, falling back to 1 default profile: "default" -2026-05-26 16:32:47.870 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port(s): 8088 (http) -2026-05-26 16:32:47.876 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat] -2026-05-26 16:32:47.876 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/9.0.68] -2026-05-26 16:32:47.929 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext -2026-05-26 16:32:47.929 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 519 ms -2026-05-26 16:32:48.166 [main] INFO c.t.d.util.FileUploadUtil - 文件上传工具初始化完成, 上传接口: http://127.0.0.1/apiOss/oss/fileUpload -2026-05-26 16:32:48.431 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port(s): 8088 (http) with context path '' -2026-05-26 16:32:48.440 [main] INFO c.t.d.DataserviceYyApplication - Started DataserviceYyApplication in 1.277 seconds (JVM running for 1.712) -2026-05-26 16:32:48.441 [main] INFO c.t.d.service.VaaSyncService - 开始执行VAA录音盒同步任务 -2026-05-26 16:32:48.459 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting... -2026-05-26 16:32:48.465 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed. -2026-05-26 16:32:56.383 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... -2026-05-26 16:32:59.216 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed. -2026-05-26 16:35:12.104 [main] INFO c.t.d.DataserviceYyApplication - Starting DataserviceYyApplication using Java 1.8.0_492 on MswangdeMacBook-Air.local with PID 90436 (/Users/wang/sanduoyun/developspace/dataservice-yy/target/classes started by wang in /Users/wang/sanduoyun/developspace/dataservice-yy) -2026-05-26 16:35:12.108 [main] DEBUG c.t.d.DataserviceYyApplication - Running with Spring Boot v2.6.13, Spring v5.3.23 -2026-05-26 16:35:12.108 [main] INFO c.t.d.DataserviceYyApplication - No active profile set, falling back to 1 default profile: "default" -2026-05-26 16:35:12.935 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port(s): 8088 (http) -2026-05-26 16:35:12.945 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat] -2026-05-26 16:35:12.946 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/9.0.68] -2026-05-26 16:35:13.022 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext -2026-05-26 16:35:13.023 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 849 ms -2026-05-26 16:35:13.428 [main] INFO c.t.d.util.FileUploadUtil - 文件上传工具初始化完成, 上传接口: http://127.0.0.1/apiOss/oss/fileUpload -2026-05-26 16:35:13.831 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port(s): 8088 (http) with context path '' -2026-05-26 16:35:13.842 [main] INFO c.t.d.DataserviceYyApplication - Started DataserviceYyApplication in 2.227 seconds (JVM running for 3.504) -2026-05-26 16:35:13.843 [main] INFO c.t.d.service.VaaSyncService - 开始执行VAA录音盒同步任务 -2026-05-26 16:35:13.864 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting... -2026-05-26 16:35:13.873 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed. -2026-05-26 16:35:14.560 [main] INFO c.t.d.service.VaaSyncService - 查询到 0 个语音设备 -2026-05-26 16:35:14.560 [main] INFO c.t.d.service.VaaSyncService - ========== VAA录音盒同步任务完成 ========== -2026-05-26 16:40:10.590 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... -2026-05-26 16:40:10.591 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed. -2026-05-26 16:40:15.223 [main] INFO c.t.d.DataserviceYyApplication - Starting DataserviceYyApplication using Java 1.8.0_492 on MswangdeMacBook-Air.local with PID 90706 (/Users/wang/sanduoyun/developspace/dataservice-yy/target/classes started by wang in /Users/wang/sanduoyun/developspace/dataservice-yy) -2026-05-26 16:40:15.225 [main] DEBUG c.t.d.DataserviceYyApplication - Running with Spring Boot v2.6.13, Spring v5.3.23 -2026-05-26 16:40:15.226 [main] INFO c.t.d.DataserviceYyApplication - No active profile set, falling back to 1 default profile: "default" -2026-05-26 16:40:15.760 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port(s): 8088 (http) -2026-05-26 16:40:15.766 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat] -2026-05-26 16:40:15.766 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/9.0.68] -2026-05-26 16:40:15.818 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext -2026-05-26 16:40:15.818 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 563 ms -2026-05-26 16:40:16.062 [main] INFO c.t.d.util.FileUploadUtil - 文件上传工具初始化完成, 上传接口: http://127.0.0.1/apiOss/oss/fileUpload -2026-05-26 16:40:16.347 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port(s): 8088 (http) with context path '' -2026-05-26 16:40:16.356 [main] INFO c.t.d.DataserviceYyApplication - Started DataserviceYyApplication in 1.395 seconds (JVM running for 1.897) -2026-05-26 16:40:16.358 [main] INFO c.t.d.service.VaaSyncService - 开始执行VAA录音盒同步任务 -2026-05-26 16:40:16.375 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting... -2026-05-26 16:40:16.381 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed. -2026-05-26 16:40:17.269 [main] INFO c.t.d.service.VaaSyncService - 查询到 216 个语音设备 -2026-05-26 16:40:17.269 [main] INFO c.t.d.service.VaaSyncService - ──────────────────────────────────────── -2026-05-26 16:40:17.269 [main] INFO c.t.d.service.VaaSyncService - 开始同步设备: ID=15, 机构=阜阳市, IP=53.162.212.190:80 -2026-05-26 16:40:17.271 [main] INFO c.t.d.service.VaaSyncService - 正在登录设备... -2026-05-26 16:40:17.271 [main] DEBUG c.t.dataserviceyy.util.VaaHttpUtil - 正在登录录音盒: http://53.162.212.190/authorize?username=admin&password=admin -2026-05-26 16:40:32.779 [main] ERROR c.t.d.service.VaaSyncService - 设备登录失败: IP=53.162.212.190, url=http://53.162.212.190/authorize?username=admin&password=admin, 原因=登录失败,HTTP状态码: 502 -2026-05-26 16:40:32.780 [main] INFO c.t.d.service.VaaSyncService - ──────────────────────────────────────── -2026-05-26 16:40:32.780 [main] INFO c.t.d.service.VaaSyncService - 开始同步设备: ID=16, 机构=阜阳市, IP=10.127.89.93:80 -2026-05-26 16:40:32.780 [main] INFO c.t.d.service.VaaSyncService - 正在登录设备... -2026-05-26 16:40:32.780 [main] DEBUG c.t.dataserviceyy.util.VaaHttpUtil - 正在登录录音盒: http://10.127.89.93/authorize?username=admin&password=admin -2026-05-26 16:40:33.777 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... -2026-05-26 16:40:33.778 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed. diff --git a/logs/app.log.2026-05-21.0.gz b/logs/app.log.2026-05-21.0.gz deleted file mode 100644 index 2756216..0000000 Binary files a/logs/app.log.2026-05-21.0.gz and /dev/null differ diff --git a/logs/app.log.2026-05-22.0.gz b/logs/app.log.2026-05-22.0.gz deleted file mode 100644 index cfdf38e..0000000 Binary files a/logs/app.log.2026-05-22.0.gz and /dev/null differ diff --git a/logs/app.log.2026-05-23.0.gz b/logs/app.log.2026-05-23.0.gz deleted file mode 100644 index 71b99c6..0000000 Binary files a/logs/app.log.2026-05-23.0.gz and /dev/null differ diff --git a/logs/app.log.2026-05-25.0.gz b/logs/app.log.2026-05-25.0.gz deleted file mode 100644 index f8ebb5b..0000000 Binary files a/logs/app.log.2026-05-25.0.gz and /dev/null differ diff --git a/logs/server.jar b/logs/server.jar deleted file mode 100755 index e698840..0000000 Binary files a/logs/server.jar and /dev/null differ diff --git a/sql/mid_voice_device_log.sql b/sql/mid_voice_device_log.sql new file mode 100644 index 0000000..8a09ff7 --- /dev/null +++ b/sql/mid_voice_device_log.sql @@ -0,0 +1,77 @@ +-- ============================================ +-- 设备连接日志表 +-- 用于记录设备连接失败信息,便于排查问题 +-- ============================================ + +CREATE TABLE "mid_voice"."mid_voice_device_log" ( + "id" int8 NOT NULL GENERATED ALWAYS AS IDENTITY ( + INCREMENT 1 + MINVALUE 1 + MAXVALUE 9223372036854775807 + START 1 + CACHE 1 + ), + "device_id" varchar(50) COLLATE "pg_catalog"."default" NOT NULL, + "device_no" varchar(100) COLLATE "pg_catalog"."default", + "city_code" varchar(20) COLLATE "pg_catalog"."default", + "city_name" varchar(100) COLLATE "pg_catalog"."default", + "ip_address" varchar(50) COLLATE "pg_catalog"."default", + "device_port" int4, + "connect_type" varchar(10) COLLATE "pg_catalog"."default" NOT NULL, + "connect_status" varchar(10) COLLATE "pg_catalog"."default" NOT NULL, + "fail_reason" text COLLATE "pg_catalog"."default", + "create_time" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "mid_voice_device_log_pkey" PRIMARY KEY ("id") +) +PARTITION BY LIST ( + "city_code" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" +); + +ALTER TABLE "mid_voice"."mid_voice_device_log" + OWNER TO "dcms_dev"; + +-- 创建索引 +CREATE INDEX "idx_device_log_device_id" ON "mid_voice"."mid_voice_device_log" USING btree ( + "device_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST +); + +CREATE INDEX "idx_device_log_create_time" ON "mid_voice"."mid_voice_device_log" USING btree ( + "create_time" "pg_catalog"."timestamp_ops" ASC NULLS LAST +); + +CREATE INDEX "idx_device_log_status" ON "mid_voice"."mid_voice_device_log" USING btree ( + "connect_status" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST +); + +-- 创建分区表(16个地市) +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3401" FOR VALUES IN ('340100'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3402" FOR VALUES IN ('340200'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3403" FOR VALUES IN ('340300'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3404" FOR VALUES IN ('340400'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3405" FOR VALUES IN ('340500'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3406" FOR VALUES IN ('340600'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3407" FOR VALUES IN ('340700'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3408" FOR VALUES IN ('340800'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3410" FOR VALUES IN ('341000'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3411" FOR VALUES IN ('341100'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3412" FOR VALUES IN ('341200'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3413" FOR VALUES IN ('341300'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3415" FOR VALUES IN ('341500'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3416" FOR VALUES IN ('341600'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3417" FOR VALUES IN ('341700'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_3418" FOR VALUES IN ('341800'); +ALTER TABLE "mid_voice"."mid_voice_device_log" ATTACH PARTITION "mid_voice"."mid_voice_device_log_default" DEFAULT; + +-- 添加注释 +COMMENT ON TABLE "mid_voice"."mid_voice_device_log" IS '设备连接日志表'; +COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."id" IS '自增ID主键'; +COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."device_id" IS '设备ID'; +COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."device_no" IS '设备编号(UUID)'; +COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."city_code" IS '地市编码'; +COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."city_name" IS '地市名称'; +COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."ip_address" IS 'IP地址'; +COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."device_port" IS '端口号'; +COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."connect_type" IS '连接类型:1登录,2查询录音列表,3下载文件'; +COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."connect_status" IS '连接状态:0失败,1成功'; +COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."fail_reason" IS '失败原因'; +COMMENT ON COLUMN "mid_voice"."mid_voice_device_log"."create_time" IS '创建时间'; diff --git a/src/main/java/com/threecloud/dataserviceyy/DataserviceYyApplication.java b/src/main/java/com/threecloud/dataserviceyy/DataserviceYyApplication.java index c31678c..da8ab2b 100644 --- a/src/main/java/com/threecloud/dataserviceyy/DataserviceYyApplication.java +++ b/src/main/java/com/threecloud/dataserviceyy/DataserviceYyApplication.java @@ -1,6 +1,6 @@ package com.threecloud.dataserviceyy; -import com.threecloud.dataserviceyy.service.VaaSyncService; +import com.threecloud.dataserviceyy.service.FtpSyncService; import org.mybatis.spring.annotation.MapperScan; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.CommandLineRunner; @@ -18,7 +18,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; public class DataserviceYyApplication { @Autowired - private VaaSyncService vaaSyncService; + private FtpSyncService ftpSyncService; public static void main(String[] args) { SpringApplication.run(DataserviceYyApplication.class, args); @@ -28,10 +28,10 @@ public class DataserviceYyApplication { public CommandLineRunner startupRunner() { return args -> { System.out.println("========================================"); - System.out.println("【项目启动】开始执行首次语音数据同步"); + System.out.println("【项目启动】开始执行首次FTP录音同步"); System.out.println("========================================"); try { - vaaSyncService.executeSync(); + ftpSyncService.executeSync(); } catch (Exception e) { System.err.println("【项目启动】首次同步失败: " + e.getMessage()); e.printStackTrace(); diff --git a/src/main/java/com/threecloud/dataserviceyy/config/DynamicDataSourceConfig.java b/src/main/java/com/threecloud/dataserviceyy/config/DynamicDataSourceConfig.java deleted file mode 100644 index c3bf7f7..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/config/DynamicDataSourceConfig.java +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 8d4dab7..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/config/FileUploadConfig.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.threecloud.dataserviceyy.config; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.HashMap; -import java.util.Map; - -@Data -@Component -@ConfigurationProperties(prefix = "file-upload") -public class FileUploadConfig { - private String uploadUrl; - private Map extraParams = new HashMap<>(); -} diff --git a/src/main/java/com/threecloud/dataserviceyy/config/FtpSyncProperties.java b/src/main/java/com/threecloud/dataserviceyy/config/FtpSyncProperties.java new file mode 100644 index 0000000..abf3d99 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/config/FtpSyncProperties.java @@ -0,0 +1,119 @@ +package com.threecloud.dataserviceyy.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.ArrayList; +import java.util.List; + +/** + * FTP同步配置 + * 对应 application-external.yml 中的 ftp-sync 配置节点 + * + * 示例: + * ftp-sync: + * enabled: true + * sync-interval-cron: "0 0 0/2 * * ?" + * cities: + * - city-code: "340100" + * city-name: "合肥" + * ftp-host: 10.126.129.7 + * ... + */ +@Configuration +@ConfigurationProperties(prefix = "ftp-sync") +public class FtpSyncProperties { + + /** 是否启用FTP同步 */ + private boolean enabled = true; + + /** 本地临时文件目录(存放下载的mp3/wav) */ + private String tempPath = "./vaa-ftp-temp"; + + /** 文件保留天数 */ + private int retainDays = 10; + + /** txt文件编码(老系统用GBK) */ + private String txtEncoding = "GBK"; + + /** 字段分隔符(老系统用※) */ + private String fieldSeparator = "※"; + + /** OSS服务基础地址(用于mp3直接URL拼接) */ + private String ossBaseUrl; + + /** 地市FTP配置列表 */ + private List cities = new ArrayList<>(); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public String getTempPath() { return tempPath; } + public void setTempPath(String tempPath) { this.tempPath = tempPath; } + + public int getRetainDays() { return retainDays; } + public void setRetainDays(int retainDays) { this.retainDays = retainDays; } + + public String getTxtEncoding() { return txtEncoding; } + public void setTxtEncoding(String txtEncoding) { this.txtEncoding = txtEncoding; } + + public String getFieldSeparator() { return fieldSeparator; } + public void setFieldSeparator(String fieldSeparator) { this.fieldSeparator = fieldSeparator; } + + public String getOssBaseUrl() { return ossBaseUrl; } + public void setOssBaseUrl(String ossBaseUrl) { this.ossBaseUrl = ossBaseUrl; } + + public List getCities() { return cities; } + public void setCities(List cities) { this.cities = cities; } + + /** + * 单个地市的FTP配置 + */ + public static class FtpCityConfig { + /** 地市编码(与mid_voice_device_config.city_code对应) */ + private String cityCode; + /** 地市名称 */ + private String cityName; + /** FTP服务器IP */ + private String ftpHost; + /** FTP服务器端口 */ + private int ftpPort = 21; + /** FTP用户名 */ + private String ftpUsername; + /** FTP密码 */ + private String ftpPassword; + /** 录音数据源目录(txt文件目录) */ + private String ftpSourceDir; + /** 录音文件存储目录(mp3/wav文件目录) */ + private String ftpRecordDir; + /** 归档目录(处理完的txt移到这里) */ + private String ftpArchiveDir; + + public String getCityCode() { return cityCode; } + public void setCityCode(String cityCode) { this.cityCode = cityCode; } + + public String getCityName() { return cityName; } + public void setCityName(String cityName) { this.cityName = cityName; } + + public String getFtpHost() { return ftpHost; } + public void setFtpHost(String ftpHost) { this.ftpHost = ftpHost; } + + public int getFtpPort() { return ftpPort; } + public void setFtpPort(int ftpPort) { this.ftpPort = ftpPort; } + + public String getFtpUsername() { return ftpUsername; } + public void setFtpUsername(String ftpUsername) { this.ftpUsername = ftpUsername; } + + public String getFtpPassword() { return ftpPassword; } + public void setFtpPassword(String ftpPassword) { this.ftpPassword = ftpPassword; } + + public String getFtpSourceDir() { return ftpSourceDir; } + public void setFtpSourceDir(String ftpSourceDir) { this.ftpSourceDir = ftpSourceDir; } + + public String getFtpRecordDir() { return ftpRecordDir; } + public void setFtpRecordDir(String ftpRecordDir) { this.ftpRecordDir = ftpRecordDir; } + + public String getFtpArchiveDir() { return ftpArchiveDir; } + public void setFtpArchiveDir(String ftpArchiveDir) { this.ftpArchiveDir = ftpArchiveDir; } + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/config/SyncTargetConfig.java b/src/main/java/com/threecloud/dataserviceyy/config/SyncTargetConfig.java deleted file mode 100644 index 4c70de8..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/config/SyncTargetConfig.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.threecloud.dataserviceyy.config; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -@Data -@Component -@ConfigurationProperties(prefix = "server.target") -public class SyncTargetConfig { - private String syncLogCode; - private String syncLogTable; -} diff --git a/src/main/java/com/threecloud/dataserviceyy/controller/VoiceBoxController.java b/src/main/java/com/threecloud/dataserviceyy/controller/VoiceBoxController.java deleted file mode 100644 index 5afe851..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/controller/VoiceBoxController.java +++ /dev/null @@ -1,270 +0,0 @@ -package com.threecloud.dataserviceyy.controller; - -import com.threecloud.dataserviceyy.util.FileUploadUtil; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.io.File; -import java.io.IOException; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.HashMap; -import java.util.Map; - -@RestController -@RequestMapping("/api") -public class VoiceBoxController { - - private static final Logger logger = LoggerFactory.getLogger(VoiceBoxController.class); - private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); - - @Autowired - private FileUploadUtil fileUploadUtil; - - /** - * 事件上传接口 - GET方式 - * 语音盒来电/去电时主动推送事件信息 - * - * 参数说明(来自VAA录音仪开发文档): - * - event_type: 事件类型 (3=状态提机, 4=提机, 6=开始去电录音, 7=来电接听开始录音, - * 8=去电完成挂机, 9=新的去电录音产生, 10=新的来电录音产生, 11=未接来电, 14=发送来电号码) - * - line: 设备端口 - * - device_id: 设备编号 - * - duration: 录音时长(秒) - * - TimeLong: 通话时长(秒) - * - date: 来电日期(格式: 2022-04-21 15:48:40) - * - caller: 来电号码(9,10,14事件中有值) - * - FilePath: 录音文件名称(9,10事件中有值) - * - voltage: 电压 - * - RingCnt: 振铃次数 - * - TotalStore: 设备存储容量 - * - TotalFreeStore: 设备剩余存储容量 - * - TotalMem: 内存使用率 - * - CPU: CPU使用率 - * - calloutId: websocket回传的calloutId - * - * 返回值:成功返回 "0000",否则录音仪客户端日志会显示失败 - */ - @GetMapping("/event") - public String handleEvent( - @RequestParam(required = false) String event_type, - @RequestParam(required = false) String line, - @RequestParam(required = false) String device_id, - @RequestParam(required = false) String duration, - @RequestParam(required = false) String TimeLong, - @RequestParam(required = false) String date, - @RequestParam(required = false) String caller, - @RequestParam(required = false) String FilePath, - @RequestParam(required = false) String voltage, - @RequestParam(required = false) String RingCnt, - @RequestParam(required = false) String TotalStore, - @RequestParam(required = false) String TotalFreeStore, - @RequestParam(required = false) String TotalMem, - @RequestParam(required = false) String CPU, - @RequestParam(required = false) String calloutId) { - - try { - logger.info("========================================"); - logger.info("[事件上传] 收到语音盒事件推送"); - logger.info("[事件类型] event_type={} ({})", event_type, getEventTypeDesc(event_type)); - logger.info("[设备信息] device_id={}, line={}", device_id, line); - - if (date != null) { - logger.info("[通话时间] date={}", date); - } - if (caller != null && !caller.isEmpty()) { - logger.info("[来电号码] caller={}", caller); - } - if (FilePath != null && !FilePath.isEmpty()) { - logger.info("[文件路径] FilePath={}", FilePath); - } - if (duration != null) { - logger.info("[录音时长] duration={}秒", duration); - } - if (TimeLong != null) { - logger.info("[通话时长] TimeLong={}秒", TimeLong); - } - - Map 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 deleted file mode 100644 index f8d9353..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/entity/CallRecord.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.threecloud.dataserviceyy.entity; - -/** - * 通话记录实体类 - * - * 封装从FTP数据文件中解析出的单条通话记录信息。 - * 数据来源:FTP服务器上的.txt文件,字段间用"※"分隔 - * - * 字段说明: - * - kssj: 开始时间 (格式: yyyy-MM-dd HH:mm:ss) - * - jssj: 结束时间 (格式: yyyy-MM-dd HH:mm:ss) - * - hjls: 呼机流水号 - * - hjzls: 呼机总流水号 - * - zjhm: 主叫号码 - * - bjhm: 被叫号码 - * - thsc: 通话时长(秒) - * - thfx: 通话方向(1=主叫方向, 0=被叫方向) - * - dateDir: 录音文件所在日期目录(yyyyMMdd) - * - wavFileName: 对应的WAV录音文件名(格式: 呼机流水号_呼机总流水号.wav) - */ -public class CallRecord { - - /** 开始时间 */ - private String kssj; - - /** 结束时间 */ - private String jssj; - - /** 呼机流水号 */ - private String hjls; - - /** 呼机总流水号 */ - private String hjzls; - - /** 主叫号码(可能包含区号前缀如0553) */ - private String zjhm; - - /** 被叫号码(可能包含区号前缀如0553) */ - private String bjhm; - - /** 通话时长(秒) */ - private Long thsc; - - /** 通话方向(1=主叫, 0=被叫) */ - private String thfx; - - /** 日期目录(yyyyMMdd格式,从kssj中提取) */ - private String dateDir; - - /** WAV录音文件名(格式: 呼机流水号_呼机总流水号.wav) */ - private String wavFileName; - - public CallRecord() { - } - - /** - * 获取通话记录摘要信息 - * 用于日志输出,显示主被叫号码 - * @return 格式化的摘要字符串,如:主叫=13800138000, 被叫=13900139000 - */ - public String getSummary() { - return "主叫=" + zjhm + ", 被叫=" + bjhm; - } - - public String getKssj() { - return kssj; - } - - public void setKssj(String kssj) { - this.kssj = kssj; - } - - public String getJssj() { - return jssj; - } - - public void setJssj(String jssj) { - this.jssj = jssj; - } - - public String getHjls() { - return hjls; - } - - public void setHjls(String hjls) { - this.hjls = hjls; - } - - public String getHjzls() { - return hjzls; - } - - public void setHjzls(String hjzls) { - this.hjzls = hjzls; - } - - public String getZjhm() { - return zjhm; - } - - public void setZjhm(String zjhm) { - this.zjhm = zjhm; - } - - public String getBjhm() { - return bjhm; - } - - public void setBjhm(String bjhm) { - this.bjhm = bjhm; - } - - public Long getThsc() { - return thsc; - } - - public void setThsc(Long thsc) { - this.thsc = thsc; - } - - public String getThfx() { - return thfx; - } - - public void setThfx(String thfx) { - this.thfx = thfx; - } - - public String getDateDir() { - return dateDir; - } - - public void setDateDir(String dateDir) { - this.dateDir = dateDir; - } - - public String getWavFileName() { - return wavFileName; - } - - public void setWavFileName(String wavFileName) { - this.wavFileName = wavFileName; - } - - @Override - public String toString() { - return "CallRecord{" + - "kssj='" + kssj + '\'' + - ", jssj='" + jssj + '\'' + - ", zjhm='" + zjhm + '\'' + - ", bjhm='" + bjhm + '\'' + - ", thsc=" + thsc + - ", thfx='" + thfx + '\'' + - ", wavFileName='" + wavFileName + '\'' + - '}'; - } -} diff --git a/src/main/java/com/threecloud/dataserviceyy/entity/DeviceMatchResult.java b/src/main/java/com/threecloud/dataserviceyy/entity/DeviceMatchResult.java deleted file mode 100644 index 7adfa27..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/entity/DeviceMatchResult.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.threecloud.dataserviceyy.entity; - -import java.util.Map; - -/** - * 设备匹配结果实体类 - * - * 封装通话记录与设备通道表匹配后的结果信息。 - * 匹配流程: - * 1. 根据电话号码在设备通道表(YYDC_SBTD)中查找对应设备 - * 2. 根据设备的UUID在语音盒表(YYDC_YYSB)中查找所属地市信息 - * 3. 返回完整的匹配结果 - */ -public class DeviceMatchResult { - - /** 是否匹配成功 */ - private boolean success; - - /** 设备通道信息(ID, UUID, PHONE, THFX等字段) */ - private Map 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/MidVoiceDeviceLog.java b/src/main/java/com/threecloud/dataserviceyy/entity/MidVoiceDeviceLog.java deleted file mode 100644 index 1179e7e..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/entity/MidVoiceDeviceLog.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.threecloud.dataserviceyy.entity; - -import java.util.Date; - -/** - * 设备连接日志表 - * 对应 mid_voice.mid_voice_device_log - * - * 用于记录设备连接失败信息,便于排查问题 - */ -public class MidVoiceDeviceLog { - - /** 自增ID主键 */ - private Long id; - - /** 设备ID */ - private String deviceId; - - /** 设备编号 */ - private String deviceNo; - - /** 地市编码 */ - private String cityCode; - - /** 地市名称 */ - private String cityName; - - /** IP地址 */ - private String ipAddress; - - /** 端口号 */ - private Integer devicePort; - - /** 连接类型:1登录,2查询录音列表,3下载文件 */ - private String connectType; - - /** 连接状态:0失败,1成功 */ - private String connectStatus; - - /** 失败原因 */ - private String failReason; - - /** 创建时间 */ - private Date createTime; - - // Getters and Setters - public Long getId() { return id; } - public void setId(Long id) { this.id = id; } - - public String getDeviceId() { return deviceId; } - public void setDeviceId(String deviceId) { this.deviceId = deviceId; } - - public String getDeviceNo() { return deviceNo; } - public void setDeviceNo(String deviceNo) { this.deviceNo = deviceNo; } - - public String getCityCode() { return cityCode; } - public void setCityCode(String cityCode) { this.cityCode = cityCode; } - - public String getCityName() { return cityName; } - public void setCityName(String cityName) { this.cityName = cityName; } - - public String getIpAddress() { return ipAddress; } - public void setIpAddress(String ipAddress) { this.ipAddress = ipAddress; } - - public Integer getDevicePort() { return devicePort; } - public void setDevicePort(Integer devicePort) { this.devicePort = devicePort; } - - public String getConnectType() { return connectType; } - public void setConnectType(String connectType) { this.connectType = connectType; } - - public String getConnectStatus() { return connectStatus; } - public void setConnectStatus(String connectStatus) { this.connectStatus = connectStatus; } - - public String getFailReason() { return failReason; } - public void setFailReason(String failReason) { this.failReason = failReason; } - - public Date getCreateTime() { return createTime; } - public void setCreateTime(Date createTime) { this.createTime = createTime; } -} diff --git a/src/main/java/com/threecloud/dataserviceyy/entity/SyncLog.java b/src/main/java/com/threecloud/dataserviceyy/entity/SyncLog.java deleted file mode 100644 index f6fc89f..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/entity/SyncLog.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.threecloud.dataserviceyy.entity; - -import java.util.Date; - -/** - * 同步日志表 - * 记录每次同步任务的执行情况 - */ -public class SyncLog { - - /** 主键ID */ - private Long id; - - /** 设备ID */ - private String deviceId; - - /** 设备名称 */ - private String deviceName; - - /** 同步开始时间 */ - private Date startTime; - - /** 同步结束时间 */ - private Date endTime; - - /** 查询到的记录数 */ - private Integer totalCount; - - /** 成功处理数 */ - private Integer successCount; - - /** 失败数 */ - private Integer failCount; - - /** 同步状态:0失败,1成功,2部分成功 */ - private Integer status; - - /** 错误信息 */ - private String errorMsg; - - /** 创建时间 */ - private Date createTime; - - // Getters and Setters - public Long getId() { return id; } - public void setId(Long id) { this.id = id; } - - public String getDeviceId() { return deviceId; } - public void setDeviceId(String deviceId) { this.deviceId = deviceId; } - - public String getDeviceName() { return deviceName; } - public void setDeviceName(String deviceName) { this.deviceName = deviceName; } - - public Date getStartTime() { return startTime; } - public void setStartTime(Date startTime) { this.startTime = startTime; } - - public Date getEndTime() { return endTime; } - public void setEndTime(Date endTime) { this.endTime = endTime; } - - public Integer getTotalCount() { return totalCount; } - public void setTotalCount(Integer totalCount) { this.totalCount = totalCount; } - - public Integer getSuccessCount() { return successCount; } - public void setSuccessCount(Integer successCount) { this.successCount = successCount; } - - public Integer getFailCount() { return failCount; } - public void setFailCount(Integer failCount) { this.failCount = failCount; } - - public Integer getStatus() { return status; } - public void setStatus(Integer status) { this.status = status; } - - public String getErrorMsg() { return errorMsg; } - public void setErrorMsg(String errorMsg) { this.errorMsg = errorMsg; } - - public Date getCreateTime() { return createTime; } - public void setCreateTime(Date createTime) { this.createTime = createTime; } -} diff --git a/src/main/java/com/threecloud/dataserviceyy/entity/VoiceRecord.java b/src/main/java/com/threecloud/dataserviceyy/entity/VoiceRecord.java deleted file mode 100644 index 7d32813..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/entity/VoiceRecord.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.threecloud.dataserviceyy.entity; - -import java.util.Date; - -/** - * 录音记录表 - * 保存每次从录音盒下载并上传到OSS的录音文件信息 - */ -public class VoiceRecord { - - /** 主键ID */ - private Long id; - - /** 设备ID */ - private String deviceId; - - /** 设备UUID */ - private String deviceUuid; - - /** 设备名称/机构名称 */ - private String deviceName; - - /** 录音盒原始记录ID */ - private String recordId; - - /** 录音文件名 */ - private String fileName; - - /** 录音文件在录音盒中的路径 */ - private String filePath; - - /** OSS访问地址 */ - private String ossUrl; - - /** 本地存储路径 */ - private String localPath; - - /** 通话开始时间 */ - private Date callStartTime; - - /** 通话结束时间 */ - private Date callEndTime; - - /** 通话时长(秒) */ - private Integer duration; - - /** 通道号 1-8 */ - private Integer channel; - - /** 对方电话号码 */ - private String phone; - - /** 通话方向:1呼出,2呼入 */ - private Integer direction; - - /** 上传状态:0失败,1成功 */ - private Integer status; - - /** 失败原因 */ - private String failReason; - - /** 创建时间 */ - private Date createTime; - - /** 更新时间 */ - private Date updateTime; - - // Getters and Setters - public Long getId() { return id; } - public void setId(Long id) { this.id = id; } - - public String getDeviceId() { return deviceId; } - public void setDeviceId(String deviceId) { this.deviceId = deviceId; } - - public String getDeviceUuid() { return deviceUuid; } - public void setDeviceUuid(String deviceUuid) { this.deviceUuid = deviceUuid; } - - public String getDeviceName() { return deviceName; } - public void setDeviceName(String deviceName) { this.deviceName = deviceName; } - - public String getRecordId() { return recordId; } - public void setRecordId(String recordId) { this.recordId = recordId; } - - public String getFileName() { return fileName; } - public void setFileName(String fileName) { this.fileName = fileName; } - - public String getFilePath() { return filePath; } - public void setFilePath(String filePath) { this.filePath = filePath; } - - public String getOssUrl() { return ossUrl; } - public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; } - - public String getLocalPath() { return localPath; } - public void setLocalPath(String localPath) { this.localPath = localPath; } - - public Date getCallStartTime() { return callStartTime; } - public void setCallStartTime(Date callStartTime) { this.callStartTime = callStartTime; } - - public Date getCallEndTime() { return callEndTime; } - public void setCallEndTime(Date callEndTime) { this.callEndTime = callEndTime; } - - public Integer getDuration() { return duration; } - public void setDuration(Integer duration) { this.duration = duration; } - - public Integer getChannel() { return channel; } - public void setChannel(Integer channel) { this.channel = channel; } - - public String getPhone() { return phone; } - public void setPhone(String phone) { this.phone = phone; } - - public Integer getDirection() { return direction; } - public void setDirection(Integer direction) { this.direction = direction; } - - public Integer getStatus() { return status; } - public void setStatus(Integer status) { this.status = status; } - - public String getFailReason() { return failReason; } - public void setFailReason(String failReason) { this.failReason = failReason; } - - public Date getCreateTime() { return createTime; } - public void setCreateTime(Date createTime) { this.createTime = createTime; } - - public Date getUpdateTime() { return updateTime; } - public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; } -} diff --git a/src/main/java/com/threecloud/dataserviceyy/enums/SyncStatus.java b/src/main/java/com/threecloud/dataserviceyy/enums/SyncStatus.java deleted file mode 100644 index 1e489ab..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/enums/SyncStatus.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.threecloud.dataserviceyy.enums; - -public enum SyncStatus { - ERR("0", "失败"), - SUC("1", "成功"); - - private final String code; - private final String name; - - SyncStatus(String code, String name) { - this.code = code; - this.name = name; - } - - public String getCode() { - return code; - } - - public String getName() { - return name; - } -} diff --git a/src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceCallRecordMapper.java b/src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceCallRecordMapper.java index ac68277..eff3705 100644 --- a/src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceCallRecordMapper.java +++ b/src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceCallRecordMapper.java @@ -34,6 +34,12 @@ public interface MidVoiceCallRecordMapper { List selectExistingCallRecordIds(@Param("deviceNo") String deviceNo, @Param("startTime") String startTime, @Param("endTime") String endTime); + + /** + * 按前缀查询call_record_id(FTP防重专用) + * 例如:prefix="340100_FTP_",返回所有以该前缀开头的记录ID + */ + List selectCallRecordIdsByPrefix(@Param("prefix") String prefix); /** * 根据设备编码查询 diff --git a/src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceDeviceLogMapper.java b/src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceDeviceLogMapper.java deleted file mode 100644 index d41fd32..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/mapper/MidVoiceDeviceLogMapper.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.threecloud.dataserviceyy.mapper; - -import com.threecloud.dataserviceyy.entity.MidVoiceDeviceLog; -import org.apache.ibatis.annotations.Insert; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - -import java.util.List; - -/** - * 设备连接日志 Mapper - */ -@Mapper -public interface MidVoiceDeviceLogMapper { - - /** - * 插入设备连接日志 - */ - @Insert("INSERT INTO mid_voice_device_log (" + - "device_id, device_no, city_code, city_name, ip_address, device_port, " + - "connect_type, connect_status, fail_reason, create_time" + - ") VALUES (" + - "#{deviceId}, #{deviceNo}, #{cityCode}, #{cityName}, #{ipAddress}, #{devicePort}, " + - "#{connectType}, #{connectStatus}, #{failReason}, #{createTime}" + - ")") - int insert(MidVoiceDeviceLog log); - - /** - * 查询设备的最近连接日志 - */ - @Select("SELECT * FROM mid_voice_device_log " + - "WHERE device_id = #{deviceId} " + - "ORDER BY create_time DESC " + - "LIMIT #{limit}") - List selectRecentByDeviceId(@Param("deviceId") String deviceId, - @Param("limit") int limit); - - /** - * 查询失败的连接记录 - */ - @Select("SELECT * FROM mid_voice_device_log " + - "WHERE connect_status = '0' " + - "AND create_time >= #{startTime} " + - "ORDER BY create_time DESC") - List selectFailedLogs(@Param("startTime") String startTime); -} diff --git a/src/main/java/com/threecloud/dataserviceyy/mapper/SyncLogMapper.java b/src/main/java/com/threecloud/dataserviceyy/mapper/SyncLogMapper.java deleted file mode 100644 index 0d7108d..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/mapper/SyncLogMapper.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.threecloud.dataserviceyy.mapper; - -import com.threecloud.dataserviceyy.entity.SyncLog; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; - -import java.util.List; - -/** - * 同步日志Mapper - */ -@Mapper -public interface SyncLogMapper { - - /** - * 插入同步日志 - */ - int insert(SyncLog log); - - /** - * 根据ID查询 - */ - SyncLog selectById(Long id); - - /** - * 查询设备的同步日志 - */ - List 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 deleted file mode 100644 index 056825c..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/mapper/VoiceRecordMapper.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.threecloud.dataserviceyy.mapper; - -import com.threecloud.dataserviceyy.entity.VoiceRecord; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; - -import java.util.List; - -/** - * 录音记录Mapper - */ -@Mapper -public interface VoiceRecordMapper { - - /** - * 插入录音记录 - */ - int insert(VoiceRecord record); - - /** - * 根据ID查询 - */ - VoiceRecord selectById(Long id); - - /** - * 根据设备ID和录音ID查询(防止重复插入) - */ - VoiceRecord selectByDeviceAndRecordId(@Param("deviceId") String deviceId, - @Param("recordId") String recordId); - - /** - * 查询设备的所有录音记录 - */ - List 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 deleted file mode 100644 index ea0942b..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/mapper/VoiceSyncMapper.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.threecloud.dataserviceyy.mapper; - -import org.apache.ibatis.annotations.Mapper; - -import java.util.List; -import java.util.Map; - -/** - * 语音设备 Mapper - * 查询 mid_voice_device_config 表 - */ -@Mapper -public interface VoiceSyncMapper { - - /** - * 查询所有在线语音设备(含每个设备的账号密码) - */ - List> getAllYysb(); -} diff --git a/src/main/java/com/threecloud/dataserviceyy/service/FtpSyncService.java b/src/main/java/com/threecloud/dataserviceyy/service/FtpSyncService.java new file mode 100644 index 0000000..763236a --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/service/FtpSyncService.java @@ -0,0 +1,392 @@ +package com.threecloud.dataserviceyy.service; + +import com.threecloud.dataserviceyy.config.FtpSyncProperties; +import com.threecloud.dataserviceyy.config.FtpSyncProperties.FtpCityConfig; +import com.threecloud.dataserviceyy.entity.MidVoiceCallRecord; +import com.threecloud.dataserviceyy.mapper.MidVoiceCallRecordMapper; +import com.threecloud.dataserviceyy.util.FilePathUtil; +import com.threecloud.dataserviceyy.util.FileUploadUtil; +import com.threecloud.dataserviceyy.util.FtpUtil; +import org.apache.commons.net.ftp.FTPClient; +import org.apache.commons.net.ftp.FTPFile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.InputStreamReader; +import java.text.SimpleDateFormat; +import java.util.*; + +/** + * FTP 录音同步服务(参考淮南 yydc-tb-server 的 MineService 重写) + * + * 【功能】从FTP读取录音数据txt → 解析 → 下载录音文件 → 上传OSS → 入库 + * 【策略】增量同步、无重试、数据库批量防重、txt处理完归档 + * 【部署】每个地市单独部署一份,每个服务只处理一个地市的FTP + * + * txt文件格式(GBK编码,※分隔): + * kssj※jssj※hjls※hjzls※zjhm※bjhm※thsc※...※thfx + * + * kssj : 开始时间 yyyyMMddHHmmss + * jssj : 结束时间 yyyyMMddHHmmss + * hjls : 呼叫流水(录音文件主名) + * hjzls : 呼叫主/被叫流水 + * zjhm : 主叫号码 + * bjhm : 被叫号码 + * thsc : 通话时长(秒) + * thfx : 通话方向 1=呼入 0=呼出 + * + * 录音文件命名:{hjls}_{hjzls}.mp3 + * 录音文件路径:{ftpRecordDir}/{yyyyMMdd}/{hjls}_{hjzls}.mp3 + */ +@Service +public class FtpSyncService { + + private static final Logger logger = LoggerFactory.getLogger(FtpSyncService.class); + + @Autowired + private FtpSyncProperties properties; + @Autowired + private MidVoiceCallRecordMapper callRecordMapper; + @Autowired + private FileUploadUtil fileUploadUtil; + + /** + * 定时同步任务 + */ + @Scheduled(cron = "${ftp-sync.sync-interval-cron:0 0 0/2 * * ?}") + public void scheduledSync() { + logger.info("【FTP定时】========== FTP录音同步开始 =========="); + long startTime = System.currentTimeMillis(); + try { + executeSync(); + } catch (Exception e) { + logger.error("【FTP定时】同步任务异常: {}", e.getMessage(), e); + } + long costTime = System.currentTimeMillis() - startTime; + logger.info("【FTP定时】========== FTP录音同步结束,耗时 {} 秒 ==========", costTime / 1000); + } + + /** + * 执行同步(主入口) + */ + public void executeSync() { + // 清理过期临时文件 + cleanOldFiles(); + + List cities = properties.getCities(); + if (cities == null || cities.isEmpty()) { + logger.warn("【FTP主流程】未配置任何地市FTP,跳过同步"); + return; + } + logger.info("【FTP主流程】配置了 {} 个地市", cities.size()); + + for (FtpCityConfig city : cities) { + try { + syncCity(city); + } catch (Exception e) { + logger.error("【FTP主流程】地市 {} 同步失败: {}", city.getCityName(), e.getMessage(), e); + } + } + } + + /** + * 同步单个地市 + */ + private void syncCity(FtpCityConfig city) throws Exception { + logger.info("【FTP地市】═══════════════════════════════════════"); + logger.info("【FTP地市】开始同步: {}({}), FTP={}:{}", + city.getCityName(), city.getCityCode(), city.getFtpHost(), city.getFtpPort()); + + // 参数校验 + if (!StringUtils.hasText(city.getFtpHost()) || !StringUtils.hasText(city.getFtpUsername())) { + logger.warn("【FTP地市】{} FTP未配置完整,跳过", city.getCityName()); + return; + } + + // 批量查询数据库已存在的call_record_id(防重) + String prefix = city.getCityCode() + "_FTP_"; + Set existingIds = loadExistingCallRecordIds(prefix); + + FTPClient ftp = null; + try { + // 连接FTP + ftp = FtpUtil.connect(city.getFtpHost(), city.getFtpPort(), + city.getFtpUsername(), city.getFtpPassword()); + logger.info("【FTP地市】FTP连接成功"); + + // 列出txt源目录所有文件 + FTPFile[] files = FtpUtil.listFiles(ftp, city.getFtpSourceDir()); + int txtCount = countTxtFiles(files); + logger.info("【FTP地市】目录 {} 下有 {} 个txt文件", city.getFtpSourceDir(), txtCount); + + int fileSuccess = 0; + int fileFail = 0; + + for (FTPFile file : files) { + if (!file.isFile() || !file.getName().toLowerCase().endsWith(".txt")) { + continue; + } + try { + boolean ok = processTxtFile(ftp, file, city, existingIds); + if (ok) fileSuccess++; + else fileFail++; + } catch (Exception e) { + fileFail++; + logger.error("【FTP地市】处理文件失败: {}, 原因={}", file.getName(), e.getMessage()); + } + } + logger.info("【FTP地市】{} 同步完成: 成功{}个文件, 失败{}个文件", + city.getCityName(), fileSuccess, fileFail); + } finally { + FtpUtil.disconnect(ftp); + } + } + + /** + * 处理单个txt文件 + * + * @return true=成功,false=失败 + */ + private boolean processTxtFile(FTPClient ftp, FTPFile file, FtpCityConfig city, + Set existingIds) throws Exception { + String fileName = file.getName(); + String sourcePath = city.getFtpSourceDir() + "/" + fileName; + logger.info("【FTP文件】开始处理: {}", fileName); + + // 下载txt到内存 + byte[] txtBytes = FtpUtil.downloadFile(ftp, sourcePath); + int lineCount = 0; + int successCount = 0; + int skipCount = 0; + int failCount = 0; + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(new ByteArrayInputStream(txtBytes), properties.getTxtEncoding()))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.indexOf(properties.getFieldSeparator()) <= 0) { + continue; + } + lineCount++; + try { + boolean processed = processLine(line, city, existingIds, ftp); + if (processed) successCount++; + else skipCount++; + } catch (Exception e) { + failCount++; + logger.error("【FTP文件】解析单行失败: {}, 原因={}", line, e.getMessage()); + } + } + } + + logger.info("【FTP文件】{} 解析完成: 共{}行, 新增{}条, 跳过{}条, 失败{}条", + fileName, lineCount, successCount, skipCount, failCount); + + // 移动到归档目录 + try { + String archivePath = city.getFtpArchiveDir() + "/" + fileName; + FtpUtil.ensureDir(ftp, city.getFtpArchiveDir()); + FtpUtil.rename(ftp, sourcePath, archivePath); + logger.info("【FTP文件】已归档: {} -> {}", sourcePath, archivePath); + } catch (Exception e) { + logger.error("【FTP文件】归档失败: {}, 原因={}", fileName, e.getMessage()); + } + + return true; + } + + /** + * 处理单行记录 + * + * @return true=成功入库,false=跳过 + */ + private boolean processLine(String line, FtpCityConfig city, Set existingIds, + FTPClient ftp) throws Exception { + String[] fields = line.split(properties.getFieldSeparator(), -1); + if (fields.length < 10) { + logger.debug("【FTP行】字段数不足({}<10),跳过: {}", fields.length, line); + return false; + } + + String kssj = trimToNull(fields[0]); // 开始时间 yyyyMMddHHmmss + String jssj = trimToNull(fields[1]); // 结束时间 + String hjls = trimToNull(fields[2]); // 呼叫流水(录音主名) + String hjzls = trimToNull(fields[3]); // 呼叫主/被叫流水 + String zjhm = trimToNull(fields[4]); // 主叫号码 + String bjhm = trimToNull(fields[5]); // 被叫号码 + String thscStr = trimToNull(fields[6]); // 通话时长 + String thfx = trimToNull(fields[9]); // 通话方向 + + if (kssj == null || jssj == null || hjls == null || hjzls == null) { + return false; + } + + int thsc = 0; + try { thsc = thscStr == null ? 0 : Integer.parseInt(thscStr); } catch (NumberFormatException ignored) {} + + // 解析时间 + Date callStartTime = parseDate(kssj); + Date callEndTime = parseDate(jssj); + if (callStartTime == null) { + return false; + } + + // 通话方向:1=呼入 0=呼出(老系统thfx字段) + boolean isOutgoing = thfx != null && thfx.contains("0"); + String callDirection = isOutgoing ? "2" : "1"; // 1呼入 2呼出(数据库规范) + + // 录音文件名 + String recordFileName = hjls + "_" + hjzls + ".mp3"; + + // callRecordId 唯一标识 + String callRecordId = city.getCityCode() + "_FTP_" + hjls; + + // 防重复检查 + if (existingIds.contains(callRecordId)) { + logger.debug("【FTP行】已存在,跳过: {}", callRecordId); + return false; + } + + // 主叫/被叫处理(去除 0553 区号前缀 - 参考老代码) + if (zjhm != null && zjhm.startsWith("0553")) { + zjhm = zjhm.substring(4); + } + if (bjhm != null && bjhm.startsWith("0553")) { + bjhm = bjhm.substring(4); + } + + // 通话状态:1正常接通 + String callStatus = "1"; + + // 构建本地和OSS路径 + SimpleDateFormat yyyyMMdd = new SimpleDateFormat("yyyyMMdd"); + String dateStr = yyyyMMdd.format(callStartTime); + String localPath = FilePathUtil.buildLocalPath( + properties.getTempPath(), city.getCityCode(), callStartTime, "ftp", recordFileName); + String ossPath = FilePathUtil.buildOssPath(city.getCityCode(), callStartTime, recordFileName); + + // 下载录音文件 + String remoteRecordPath = city.getFtpRecordDir() + "/" + dateStr + "/" + recordFileName; + byte[] recordBytes = FtpUtil.downloadFile(ftp, remoteRecordPath); + logger.info("【FTP行】下载录音: {} ({} 字节)", recordFileName, recordBytes.length); + + // 上传OSS + String ossUrl = fileUploadUtil.uploadWav(city.getCityName(), ossPath, recordFileName, recordBytes); + logger.info("【FTP行】上传OSS: {}", ossUrl); + + // 构建并保存实体 + MidVoiceCallRecord rec = new MidVoiceCallRecord(); + rec.setCallRecordId(callRecordId); + rec.setDeviceNo("FTP_" + city.getCityCode()); + rec.setCityCode(city.getCityCode()); + rec.setCityName(city.getCityName()); + rec.setOrgCode(city.getCityCode()); + rec.setRecordingFileName(recordFileName); + rec.setCallStartTime(callStartTime); + rec.setCallEndTime(callEndTime); + rec.setCallDuration(thsc); + rec.setCallDirection(callDirection); + + if (isOutgoing) { + rec.setCallTel(zjhm != null ? zjhm : ""); + rec.setCalledTel(bjhm != null ? bjhm : ""); + } else { + rec.setCallTel(bjhm != null ? bjhm : ""); + rec.setCalledTel(zjhm != null ? zjhm : ""); + } + rec.setCallStatus(callStatus); + rec.setRecordingFileSize(recordBytes.length); + rec.setRecordingFilePath(ossUrl); + + callRecordMapper.insert(rec); + existingIds.add(callRecordId); // 防止同批内重复 + logger.info("【FTP行】保存成功: id={}, callRecordId={}", rec.getId(), callRecordId); + + return true; + } + + /** + * 批量加载已存在的call_record_id(防重) + */ + private Set loadExistingCallRecordIds(String prefix) { + try { + List ids = callRecordMapper.selectCallRecordIdsByPrefix(prefix); + logger.info("【FTP防重】已加载 {} 条历史记录(prefix={})", ids.size(), prefix); + return new HashSet<>(ids); + } catch (Exception e) { + logger.error("【FTP防重】加载历史记录失败,将全部当作新数据: {}", e.getMessage()); + return new HashSet<>(); + } + } + + /** + * 清理过期临时文件 + */ + private void cleanOldFiles() { + File tempDir = new File(properties.getTempPath()); + if (!tempDir.exists()) { + return; + } + long retainMillis = (long) properties.getRetainDays() * 24 * 60 * 60 * 1000; + long now = System.currentTimeMillis(); + deleteOldFilesRecursive(tempDir, now - retainMillis); + } + + private void deleteOldFilesRecursive(File dir, long threshold) { + if (!dir.isDirectory()) { + if (dir.lastModified() < threshold) { + dir.delete(); + } + return; + } + File[] files = dir.listFiles(); + if (files == null) return; + for (File f : files) { + deleteOldFilesRecursive(f, threshold); + } + // 删除空目录 + File[] remainFiles = dir.listFiles(); + if (remainFiles == null || remainFiles.length == 0) { + dir.delete(); + } + } + + /** + * 统计txt文件数 + */ + private int countTxtFiles(FTPFile[] files) { + int count = 0; + if (files == null) return 0; + for (FTPFile f : files) { + if (f.isFile() && f.getName().toLowerCase().endsWith(".txt")) { + count++; + } + } + return count; + } + + /** + * 解析日期 yyyyMMddHHmmss + */ + private Date parseDate(String s) { + if (s == null) return null; + try { + return new SimpleDateFormat("yyyyMMddHHmmss").parse(s); + } catch (Exception e) { + return null; + } + } + + private String trimToNull(String s) { + if (s == null) return null; + s = s.trim(); + return s.isEmpty() ? null : s; + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java b/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java deleted file mode 100644 index 7559f9e..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java +++ /dev/null @@ -1,390 +0,0 @@ -package com.threecloud.dataserviceyy.service; - -import com.alibaba.fastjson2.JSONArray; -import com.alibaba.fastjson2.JSONObject; -import com.threecloud.dataserviceyy.entity.MidVoiceCallRecord; -import com.threecloud.dataserviceyy.entity.MidVoiceDeviceLog; -import com.threecloud.dataserviceyy.mapper.MidVoiceCallRecordMapper; -import com.threecloud.dataserviceyy.mapper.MidVoiceDeviceLogMapper; -import com.threecloud.dataserviceyy.mapper.VoiceSyncMapper; -import com.threecloud.dataserviceyy.util.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -import java.net.URLEncoder; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.*; - -/** - * VAA录音盒定时同步服务 - * - * 【功能】从EBOX录音盒拉取录音 → 下载文件 → 上传OSS → 保存通话记录 - * 【策略】增量同步、无重试、防重复、失败记录到日志表 - * 【账户】每个设备的账号密码从 mid_voice_device_config 表读取 - */ -@Service -public class VaaSyncService { - - private static final Logger logger = LoggerFactory.getLogger(VaaSyncService.class); - - @Autowired - private VoiceSyncMapper voiceSyncMapper; - @Autowired - private MidVoiceCallRecordMapper callRecordMapper; - @Autowired - private MidVoiceDeviceLogMapper deviceLogMapper; - @Autowired - private VaaHttpUtil vaaHttpUtil; - @Autowired - private FileUploadUtil fileUploadUtil; - - @Value("${vaa-sync.download-path:./vaa-recordings}") - private String downloadPath; - @Value("${vaa-sync.retain-days:10}") - private int retainDays; - - /** - * 定时同步任务 - */ - @Scheduled(cron = "${vaa-sync.sync-interval-cron:0 0 0/2 * * ?}") - public void scheduledSync() { - logger.info("【定时任务】========== VAA录音盒同步开始 =========="); - long startTime = System.currentTimeMillis(); - executeSync(); - long costTime = System.currentTimeMillis() - startTime; - logger.info("【定时任务】========== VAA录音盒同步结束,耗时 {} 秒 ==========", costTime / 1000); - } - - /** - * 执行同步任务(主入口) - */ - public void executeSync() { - try { - // 清理过期本地文件 - FileCleaner.cleanOldFiles(downloadPath, retainDays); - - // 查询在线设备列表(含每个设备的账号密码) - List> deviceList = voiceSyncMapper.getAllYysb(); - logger.info("【主流程】查询到 {} 个在线语音设备", deviceList.size()); - - int successCount = 0; - int failCount = 0; - for (int i = 0; i < deviceList.size(); i++) { - Map device = deviceList.get(i); - String deviceId = getStr(device, "ID"); - try { - syncSingleDevice(device); - successCount++; - } catch (Exception e) { - failCount++; - logger.error("【异常】设备同步失败: ID={}, 原因={}", deviceId, e.getMessage()); - } - } - logger.info("【主流程】同步完成,成功 {} 个,失败 {} 个", successCount, failCount); - } catch (Exception e) { - logger.error("【异常】同步任务执行失败: {}", e.getMessage(), e); - } - } - - /** - * 同步单个设备 - * - * 流程:登录 → 获取分机号码 → 查录音列表 → 逐条下载上传保存 - * 任何步骤失败直接记录日志,跳过该设备 - */ - private void syncSingleDevice(Map device) throws Exception { - String deviceId = getStr(device, "ID"); - String deviceNo = getStr(device, "UUID"); - String cityName = getStr(device, "ORGAN_NAME"); - String cityCode = getStr(device, "ORGAN_ID"); - String ip = getStr(device, "IP"); - Integer port = getInt(device, "PORT", 80); - String orgCode = getStr(device, "ORG_CODE"); - // 每个设备有自己的账号密码,从配置表读取 - String username = getStr(device, "USERNAME"); - String password = getStr(device, "PASSWORD"); - - logger.info("【设备】同步设备: ID={}, 编号={}, 机构={}, IP={}:{}", deviceId, deviceNo, cityName, ip, port); - - // 参数校验 - if (!StringUtils.hasText(ip)) { - logger.warn("【设备】IP为空,跳过: ID={}", deviceId); - saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "0", "设备IP为空"); - return; - } - if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) { - logger.warn("【设备】账号密码为空,跳过: ID={}", deviceId); - saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "0", "设备账号密码未配置"); - return; - } - if (!StringUtils.hasText(cityName)) { - cityName = "unknown_" + deviceId; - } - - String deviceHost = buildHost(ip, port); - - // 步骤1:登录(无重试) - String authToken; - try { - String loginUrl = String.format("http://%s/authorize?username=%s&password=%s", - deviceHost, urlEncode(username), urlEncode(password)); - authToken = vaaHttpUtil.httpLogin(loginUrl); - saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "1", null); - } catch (Exception e) { - saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "1", "0", "登录失败:" + e.getMessage()); - logger.error("【设备】登录失败,跳过: IP={}, 原因={}", ip, e.getMessage()); - return; - } - - // 步骤2:获取分机号码(通道→电话号码映射),用于解析主叫/被叫 - Map extNumbers = getExtensionNumbers(deviceHost, authToken); - - // 步骤3:计算同步时间范围(增量同步) - Date lastSyncTime = SyncTimeUtil.readLastSyncTime(downloadPath, deviceId); - Date now = new Date(); - if (lastSyncTime == null || DateUtil.getDateDoubleDiff(now, lastSyncTime) > 1.0) { - lastSyncTime = DateUtil.addDayByDate(now, -1); - } - long startEpoch = lastSyncTime.getTime() / 1000; - long endEpoch = now.getTime() / 1000; - - // 步骤4:获取录音列表(无重试) - JSONArray records; - try { - String recordUrl = String.format("http://%s/service/record/~/time[%d,%d]", - deviceHost, startEpoch, endEpoch); - String recordData = vaaHttpUtil.httpVisit(recordUrl, authToken); - records = vaaHttpUtil.parseRecordData(recordData); - saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "2", "1", null); - } catch (Exception e) { - saveDeviceLog(deviceId, deviceNo, cityCode, cityName, ip, port, "2", "0", "查询录音列表失败:" + e.getMessage()); - logger.error("【设备】获取录音列表失败: {}", e.getMessage()); - return; - } - - if (records == null || records.isEmpty()) { - logger.info("【设备】无新录音: ID={}", deviceId); - return; - } - logger.info("【设备】获取到 {} 条录音记录", records.size()); - - // 批量查询已存在的call_record_id(一次查询代替N次,防重复性能优化) - String startTimeStr = DateUtil.formatDate(lastSyncTime, "yyyy-MM-dd HH:mm:ss"); - String endTimeStr = DateUtil.formatDate(now, "yyyy-MM-dd HH:mm:ss"); - Set existingIds = new HashSet<>(callRecordMapper.selectExistingCallRecordIds( - deviceNo, startTimeStr, endTimeStr)); - logger.debug("【设备】已存在 {} 条通话记录,将跳过", existingIds.size()); - - // 步骤5:逐条处理录音 - int successCount = 0; - int failCount = 0; - Date latestCallTime = null; - - for (int i = 0; i < records.size(); i++) { - JSONObject rec = records.getJSONObject(i); - try { - Date callTime = processSingleRecord(rec, deviceNo, cityName, cityCode, orgCode, - deviceHost, authToken, extNumbers, existingIds); - if (callTime != null) { - successCount++; - if (latestCallTime == null || callTime.after(latestCallTime)) { - latestCallTime = callTime; - } - } - } catch (Exception e) { - failCount++; - logger.error("【录音】处理失败: {}, 原因={}", rec.getString("id"), e.getMessage()); - } - } - - // 步骤6:保存同步时间 - if (latestCallTime != null) { - SyncTimeUtil.writeLastSyncTime(downloadPath, deviceId, latestCallTime); - } - - logger.info("【设备】同步完成: ID={}, 成功{}条, 失败{}条", deviceId, successCount, failCount); - } - - /** - * 处理单条录音记录 - * - * @return 通话开始时间(成功时),null表示跳过 - */ - private Date processSingleRecord(JSONObject rec, String deviceNo, String cityName, - String cityCode, String orgCode, String deviceHost, - String authToken, Map extNumbers, - Set existingIds) throws Exception { - // 解析录音信息 - String recordId = RecordParser.parseRecordId(rec); - String filePath = RecordParser.parseFilePath(rec); - Integer channel = RecordParser.parseChannel(rec); - String phone = RecordParser.parsePhone(rec); - boolean isOutgoing = RecordParser.isOutgoing(rec); - boolean isAnswered = RecordParser.isAnswered(rec); - Long begTime = RecordParser.parseBegTime(rec); - Long endTime = RecordParser.parseEndTime(rec); - - // 校验必要字段 - if (filePath == null || filePath.isEmpty()) { - return null; - } - if (begTime == null || endTime == null) { - return null; - } - - // 防重复:内存中比对已存在的call_record_id(一次查询代替N次) - String callRecordId = deviceNo + "_" + recordId; - if (existingIds.contains(callRecordId)) { - logger.debug("【录音】已存在,跳过: {}", callRecordId); - return null; - } - - // 准备文件路径 - Date callStartTime = new Date(begTime * 1000); - String fileName = FilePathUtil.extractFileName(filePath); - String localPath = FilePathUtil.buildLocalPath(downloadPath, cityCode, callStartTime, deviceNo, fileName); - Path localFile = Paths.get(localPath); - String ossPath = FilePathUtil.buildOssPath(cityCode, callStartTime, fileName); - - // 获取通道绑定的电话号码(从EBOX分机号码配置) - String channelPhone = ""; - if (channel != null && extNumbers != null) { - channelPhone = extNumbers.getOrDefault(String.valueOf(channel), ""); - } - - // 构建通话记录 - MidVoiceCallRecord callRecord = buildCallRecord( - callRecordId, deviceNo, cityCode, cityName, orgCode, - fileName, callStartTime, new Date(endTime * 1000), - (int) (endTime - begTime), channelPhone, phone, isOutgoing, isAnswered); - - // 下载录音文件(无重试) - if (!Files.exists(localFile) || Files.size(localFile) == 0) { - String fileUrl = "http://" + deviceHost + filePath; - vaaHttpUtil.httpDown(fileUrl, localPath, authToken); - logger.info("【录音】下载完成: {} ({} 字节)", fileName, Files.size(localFile)); - } else { - logger.debug("【录音】文件已存在,跳过下载: {}", fileName); - } - callRecord.setRecordingFileSize((int) Files.size(localFile)); - - // 上传到OSS - byte[] wavData = Files.readAllBytes(localFile); - String ossUrl = fileUploadUtil.uploadWav(cityName, ossPath, fileName, wavData); - callRecord.setRecordingFilePath(ossUrl); - logger.info("【录音】上传OSS成功: {}", ossUrl); - - // 保存到数据库 - callRecordMapper.insert(callRecord); - logger.info("【录音】保存成功: callRecordId={}", callRecordId); - - return callStartTime; - } - - /** - * 构建通话记录实体 - * - * 号码解析规则: - * - 呼出(state=1):主叫=本机号码(通道绑定号码),被叫=对方号码(phone字段) - * - 呼入(state=2):主叫=对方号码(phone字段),被叫=本机号码(通道绑定号码) - */ - private MidVoiceCallRecord buildCallRecord(String callRecordId, String deviceNo, String cityCode, - String cityName, String orgCode, String fileName, - Date callStartTime, Date callEndTime, - int duration, String channelPhone, String remotePhone, - boolean isOutgoing, boolean isAnswered) { - MidVoiceCallRecord record = new MidVoiceCallRecord(); - record.setCallRecordId(callRecordId); - record.setDeviceNo(deviceNo); - record.setCityCode(cityCode); - record.setCityName(cityName); - record.setOrgCode(orgCode); - record.setRecordingFileName(fileName); - record.setCallStartTime(callStartTime); - record.setCallEndTime(callEndTime); - record.setCallDuration(duration); - record.setCallDirection(isOutgoing ? "2" : "1"); // 1呼入,2呼出 - - String localNum = channelPhone != null ? channelPhone : ""; - String remoteNum = remotePhone != null ? remotePhone : ""; - - if (isOutgoing) { - record.setCallTel(localNum); // 主叫:本机 - record.setCalledTel(remoteNum); // 被叫:对方 - } else { - record.setCallTel(remoteNum); // 主叫:对方 - record.setCalledTel(localNum); // 被叫:本机 - } - - record.setCallStatus(isAnswered ? "1" : "2"); // 1正常接通,2未接通 - return record; - } - - /** - * 获取分机号码配置 - * 调用 EBOX 接口 GET /service/ext/number - * 返回 Map<通道号, 电话号码>,如 {"1":"8001","2":"8002"} - */ - private Map getExtensionNumbers(String deviceHost, String authToken) { - try { - String extUrl = "http://" + deviceHost + "/service/ext/number"; - return vaaHttpUtil.getExtensionNumbers(extUrl, authToken); - } catch (Exception e) { - logger.warn("【设备】获取分机号码失败,将无法解析本机号码: {}", e.getMessage()); - return Collections.emptyMap(); - } - } - - /** - * 保存设备连接日志到 mid_voice_device_log 表 - */ - private void saveDeviceLog(String deviceId, String deviceNo, String cityCode, String cityName, - String ipAddress, Integer devicePort, String connectType, - String connectStatus, String failReason) { - try { - MidVoiceDeviceLog log = new MidVoiceDeviceLog(); - log.setDeviceId(deviceId); - log.setDeviceNo(deviceNo); - log.setCityCode(cityCode); - log.setCityName(cityName); - log.setIpAddress(ipAddress); - log.setDevicePort(devicePort); - log.setConnectType(connectType); - log.setConnectStatus(connectStatus); - log.setFailReason(failReason); - log.setCreateTime(new Date()); - deviceLogMapper.insert(log); - } catch (Exception e) { - logger.error("保存设备连接日志失败: {}", e.getMessage()); - } - } - - // ==================== 工具方法 ==================== - - private String buildHost(String ip, Integer port) { - return ip + (port != null && port != 80 ? ":" + port : ""); - } - - private String getStr(Map map, String key) { - Object v = map.get(key); - return v != null ? v.toString() : null; - } - - private Integer getInt(Map map, String key, Integer defaultVal) { - Object v = map.get(key); - if (v == null) return defaultVal; - try { return Integer.parseInt(v.toString()); } - catch (NumberFormatException e) { return defaultVal; } - } - - private String urlEncode(String value) throws Exception { - return URLEncoder.encode(value, "UTF-8"); - } -} diff --git a/src/main/java/com/threecloud/dataserviceyy/util/DataUtil.java b/src/main/java/com/threecloud/dataserviceyy/util/DataUtil.java deleted file mode 100644 index b0f37e5..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/util/DataUtil.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.threecloud.dataserviceyy.util; - -import java.util.Collection; -import java.util.Map; - -public class DataUtil { - - public static boolean isNotNull(Object obj) { - return obj != null; - } - - public static boolean isNotNull(Map map) { - return map != null && !map.isEmpty(); - } - - public static boolean isNotNull(Collection collection) { - return collection != null && !collection.isEmpty(); - } - - public static boolean isNotNull(String str) { - return str != null && !str.trim().isEmpty(); - } - - public static boolean isNumber(Object obj) { - if (obj == null) return false; - try { - Double.parseDouble(obj.toString()); - return true; - } catch (NumberFormatException e) { - return false; - } - } - - public static Long objToLong(Object obj) { - if (obj == null) return null; - if (obj instanceof Long) return (Long) obj; - if (obj instanceof Number) return ((Number) obj).longValue(); - if (isNumber(obj)) return Long.parseLong(obj.toString()); - return null; - } - - public static String objToValue(Object obj) { - if (obj == null) return null; - return obj.toString(); - } - - public static Integer objToInteger(Object obj) { - if (obj == null) return null; - if (obj instanceof Integer) return (Integer) obj; - if (obj instanceof Number) return ((Number) obj).intValue(); - if (isNumber(obj)) return Integer.parseInt(obj.toString()); - return null; - } - - public static Float objToFloat(Object obj) { - if (obj == null) return null; - if (obj instanceof Float) return (Float) obj; - if (obj instanceof Number) return ((Number) obj).floatValue(); - if (isNumber(obj)) return Float.parseFloat(obj.toString()); - return null; - } - - public static Double objToDouble(Object obj) { - if (obj == null) return null; - if (obj instanceof Double) return (Double) obj; - if (obj instanceof Number) return ((Number) obj).doubleValue(); - if (isNumber(obj)) return Double.parseDouble(obj.toString()); - return null; - } - - public static boolean objToBoolean(Object obj) { - if (obj == null) return false; - if (obj instanceof Boolean) return (Boolean) obj; - return Boolean.parseBoolean(obj.toString()); - } -} diff --git a/src/main/java/com/threecloud/dataserviceyy/util/FileCleaner.java b/src/main/java/com/threecloud/dataserviceyy/util/FileCleaner.java deleted file mode 100644 index 1644d1e..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/util/FileCleaner.java +++ /dev/null @@ -1,177 +0,0 @@ -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/FtpUtil.java b/src/main/java/com/threecloud/dataserviceyy/util/FtpUtil.java new file mode 100644 index 0000000..f72e651 --- /dev/null +++ b/src/main/java/com/threecloud/dataserviceyy/util/FtpUtil.java @@ -0,0 +1,163 @@ +package com.threecloud.dataserviceyy.util; + +import org.apache.commons.net.ftp.FTP; +import org.apache.commons.net.ftp.FTPClient; +import org.apache.commons.net.ftp.FTPFile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * FTP 工具类 + * 封装FTP连接、文件列表、下载、删除、重命名等操作 + * 基于 Apache Commons Net FTPClient + */ +public class FtpUtil { + + private static final Logger logger = LoggerFactory.getLogger(FtpUtil.class); + + /** FTP连接超时(毫秒) */ + private static final int CONNECT_TIMEOUT = 15000; + /** FTP读取超时(毫秒) */ + private static final int READ_TIMEOUT = 60000; + + /** + * 连接FTP服务器(一次性连接处理所有操作,最后调用 disconnect 关闭) + * + * @param host FTP服务器IP + * @param port FTP服务器端口 + * @param username 用户名 + * @param password 密码 + * @return FTPClient 连接成功返回客户端,失败抛异常 + */ + public static FTPClient connect(String host, int port, String username, String password) throws IOException { + FTPClient ftp = new FTPClient(); + ftp.setConnectTimeout(CONNECT_TIMEOUT); + ftp.setDataTimeout(READ_TIMEOUT); + ftp.connect(host, port); + boolean loginOk = ftp.login(username, password); + if (!loginOk) { + ftp.disconnect(); + throw new IOException("FTP登录失败,请检查用户名密码"); + } + // 设置被动模式(必须,避开防火墙) + ftp.enterLocalPassiveMode(); + // 设置二进制传输(mp3/wav必须用二进制) + ftp.setFileType(FTP.BINARY_FILE_TYPE); + // 设置UTF-8编码(文件名有中文) + ftp.setControlEncoding("UTF-8"); + logger.info("FTP连接成功: {}:{}", host, port); + return ftp; + } + + /** + * 断开FTP连接 + */ + public static void disconnect(FTPClient ftp) { + if (ftp != null && ftp.isConnected()) { + try { + ftp.logout(); + } catch (IOException ignored) { + } + try { + ftp.disconnect(); + } catch (IOException ignored) { + } + } + } + + /** + * 列出指定目录下的所有文件 + * + * @param ftp 已连接的FTPClient + * @param dir 目录路径(相对或绝对) + * @return 文件列表,失败抛异常 + */ + public static FTPFile[] listFiles(FTPClient ftp, String dir) throws IOException { + // 切换到目标目录 + boolean cdOk = ftp.changeWorkingDirectory(dir); + if (!cdOk) { + throw new IOException("切换FTP目录失败: " + dir); + } + FTPFile[] files = ftp.listFiles(); + logger.debug("FTP目录 [{}] 下有 {} 个文件", dir, files.length); + return files; + } + + /** + * 下载文件到本地字节数组 + * + * @param ftp 已连接的FTPClient + * @param remotePath 远程文件路径(绝对或相对于当前工作目录) + * @return 文件字节内容 + */ + public static byte[] downloadFile(FTPClient ftp, String remotePath) throws IOException { + try (InputStream in = ftp.retrieveFileStream(remotePath); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + if (in == null) { + throw new IOException("下载文件失败(无输入流): " + remotePath); + } + byte[] buffer = new byte[8192]; + int len; + while ((len = in.read(buffer)) != -1) { + out.write(buffer, 0, len); + } + // 必须调用 completePendingCommand,否则后续操作会失败 + boolean ok = ftp.completePendingCommand(); + if (!ok) { + throw new IOException("FTP下载未完成: " + remotePath); + } + return out.toByteArray(); + } + } + + /** + * 重命名/移动FTP文件(用于把处理完的txt移到归档目录) + * + * @param ftp 已连接的FTPClient + * @param fromPath 原文件路径 + * @param toPath 目标文件路径 + */ + public static void rename(FTPClient ftp, String fromPath, String toPath) throws IOException { + boolean ok = ftp.rename(fromPath, toPath); + if (!ok) { + throw new IOException("FTP重命名失败: " + fromPath + " -> " + toPath); + } + } + + /** + * 删除FTP文件 + */ + public static void delete(FTPClient ftp, String path) throws IOException { + boolean ok = ftp.deleteFile(path); + if (!ok) { + throw new IOException("FTP删除文件失败: " + path); + } + } + + /** + * 确保归档目录存在,不存在则创建 + */ + public static void ensureDir(FTPClient ftp, String dir) throws IOException { + if (!ftp.changeWorkingDirectory(dir)) { + // 尝试创建目录 + boolean ok = ftp.makeDirectory(dir); + if (!ok) { + throw new IOException("创建FTP目录失败: " + dir); + } + } + } + + /** + * 切换工作目录(不存在则创建) + */ + public static void changeDir(FTPClient ftp, String dir) throws IOException { + if (!ftp.changeWorkingDirectory(dir)) { + throw new IOException("切换FTP目录失败: " + dir); + } + } +} diff --git a/src/main/java/com/threecloud/dataserviceyy/util/RecordParser.java b/src/main/java/com/threecloud/dataserviceyy/util/RecordParser.java deleted file mode 100644 index 80a6572..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/util/RecordParser.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.threecloud.dataserviceyy.util; - -import com.alibaba.fastjson2.JSONObject; - -/** - * 录音记录解析工具类 - * 封装从 EBOX API 返回的 JSON 中提取字段的逻辑 - */ -public class RecordParser { - - /** - * 解析录音记录ID - */ - public static String parseRecordId(JSONObject record) { - return record.getString("id"); - } - - /** - * 解析文件路径 - */ - public static String parseFilePath(JSONObject record) { - return record.getString("file"); - } - - /** - * 解析通道号 - */ - public static Integer parseChannel(JSONObject record) { - Object channelObj = record.get("channel"); - return channelObj instanceof Number ? ((Number) channelObj).intValue() : null; - } - - /** - * 解析对方电话号码 - */ - public static String parsePhone(JSONObject record) { - return record.getString("phone"); - } - - /** - * 解析通话状态(方向) - * 1=呼出,其他=呼入 - */ - public static Integer parseState(JSONObject record) { - Object stateObj = record.get("state"); - return stateObj instanceof Number ? ((Number) stateObj).intValue() : null; - } - - /** - * 解析开始时间(Unix时间戳,秒) - */ - public static Long parseBegTime(JSONObject record) { - Object begtimeObj = record.get("begtime"); - return begtimeObj instanceof Number ? ((Number) begtimeObj).longValue() : null; - } - - /** - * 解析结束时间(Unix时间戳,秒) - */ - public static Long parseEndTime(JSONObject record) { - Object endtimeObj = record.get("endtime"); - return endtimeObj instanceof Number ? ((Number) endtimeObj).longValue() : null; - } - - /** - * 解析是否接听 - * 1=接听,0=未接听 - */ - public static Integer parseAnswer(JSONObject record) { - Object answerObj = record.get("answer"); - return answerObj instanceof Number ? ((Number) answerObj).intValue() : 0; - } - - /** - * 判断是否为呼出 - */ - public static boolean isOutgoing(JSONObject record) { - Integer state = parseState(record); - return state != null && state == 1; - } - - /** - * 判断是否已接听 - */ - public static boolean isAnswered(JSONObject record) { - return parseAnswer(record) == 1; - } -} diff --git a/src/main/java/com/threecloud/dataserviceyy/util/SyncTimeUtil.java b/src/main/java/com/threecloud/dataserviceyy/util/SyncTimeUtil.java deleted file mode 100644 index b145ec2..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/util/SyncTimeUtil.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.threecloud.dataserviceyy.util; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.File; -import java.nio.file.Files; -import java.util.Date; - -/** - * 同步时间工具类 - * 管理设备上次同步时间的本地文件存储 - */ -public class SyncTimeUtil { - - private static final Logger logger = LoggerFactory.getLogger(SyncTimeUtil.class); - - /** - * 从本地文件读取上次同步时间 - * - * @param basePath 基础路径 - * @param deviceId 设备ID - * @return 上次同步时间,不存在返回null - */ - public static Date readLastSyncTime(String basePath, String deviceId) { - String markerPath = FilePathUtil.buildSyncMarkerPath(basePath, deviceId); - File markerFile = new File(markerPath); - - if (!markerFile.exists()) { - logger.debug("同步标记文件不存在: deviceId={}", deviceId); - return null; - } - - try { - byte[] bytes = Files.readAllBytes(markerFile.toPath()); - long millis = Long.parseLong(new String(bytes).trim()); - Date syncTime = new Date(millis); - logger.debug("读取到上次同步时间: deviceId={}, time={}", deviceId, syncTime); - return syncTime; - } catch (Exception e) { - logger.warn("读取同步时间失败: deviceId={}, error={}", deviceId, e.getMessage()); - return null; - } - } - - /** - * 保存同步时间到本地文件 - * - * @param basePath 基础路径 - * @param deviceId 设备ID - * @param time 同步时间 - */ - public static void writeLastSyncTime(String basePath, String deviceId, Date time) { - String markerDir = FilePathUtil.buildSyncMarkerDir(basePath); - File dir = new File(markerDir); - if (!dir.exists()) { - dir.mkdirs(); - } - - String markerPath = FilePathUtil.buildSyncMarkerPath(basePath, deviceId); - File markerFile = new File(markerPath); - - try { - Files.write(markerFile.toPath(), String.valueOf(time.getTime()).getBytes()); - logger.debug("保存同步时间成功: deviceId={}, time={}", deviceId, time); - } catch (Exception e) { - logger.warn("保存同步时间失败: deviceId={}, error={}", deviceId, e.getMessage()); - } - } -} diff --git a/src/main/java/com/threecloud/dataserviceyy/util/VaaHttpUtil.java b/src/main/java/com/threecloud/dataserviceyy/util/VaaHttpUtil.java deleted file mode 100644 index a290b0f..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/util/VaaHttpUtil.java +++ /dev/null @@ -1,271 +0,0 @@ -package com.threecloud.dataserviceyy.util; - -import com.alibaba.fastjson2.JSON; -import com.alibaba.fastjson2.JSONArray; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; - -import java.io.*; -import java.net.HttpURLConnection; -import java.net.URL; -import java.util.List; -import java.util.Map; - -/** - * VAA录音盒HTTP工具类 - * 用于与录音盒设备进行HTTP通信 - * 参考文档: ebox_developer_guide.html (EBOX-8108 电话录音仪开发手册) - * - * 【说明】 - * 所有方法都不重试,一次失败直接抛出异常,由调用方处理 - */ -@Component -public class VaaHttpUtil { - - private static final Logger logger = LoggerFactory.getLogger(VaaHttpUtil.class); - - /** 连接超时:10秒 */ - private static final int CONNECT_TIMEOUT = 10000; - /** API读取超时:30秒 */ - private static final int READ_TIMEOUT = 30000; - /** 文件下载读取超时:2分钟 */ - private static final int DOWNLOAD_READ_TIMEOUT = 120000; - - /** - * HTTP登录认证(无重试) - * @param loginUrl 登录URL - * @return 登录成功后返回Authorization Cookie值 - * @throws Exception 登录失败直接抛出异常 - */ - public String httpLogin(String loginUrl) throws Exception { - logger.debug("正在登录录音盒: {}", loginUrl); - - HttpURLConnection conn = null; - try { - URL url = new URL(loginUrl); - conn = (HttpURLConnection) url.openConnection(); - conn.setInstanceFollowRedirects(false); - conn.setConnectTimeout(CONNECT_TIMEOUT); - conn.setReadTimeout(READ_TIMEOUT); - conn.setRequestMethod("GET"); - - int responseCode = conn.getResponseCode(); - if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_MOVED_TEMP) { - String authorization = getAuthorizationCookie(conn); - if (authorization == null || authorization.isEmpty()) { - throw new RuntimeException("登录失败,录音盒未返回Authorization Cookie"); - } - logger.info("录音盒登录成功"); - return authorization; - } - throw new RuntimeException("登录失败,HTTP状态码: " + responseCode); - } finally { - if (conn != null) { - conn.disconnect(); - } - } - } - - /** - * HTTP访问API获取数据(无重试) - * @param apiUrl API地址 - * @param authorization 登录后返回的Authorization Cookie值 - * @return 返回JSON字符串 - * @throws Exception 访问失败直接抛出异常 - */ - public String httpVisit(String apiUrl, String authorization) throws Exception { - logger.debug("访问API: {}", apiUrl); - - HttpURLConnection conn = null; - try { - URL url = new URL(apiUrl); - conn = (HttpURLConnection) url.openConnection(); - conn.setConnectTimeout(CONNECT_TIMEOUT); - conn.setReadTimeout(READ_TIMEOUT); - conn.setRequestMethod("GET"); - addAuthorization(conn, authorization); - - int responseCode = conn.getResponseCode(); - if (responseCode == 200) { - BufferedReader reader = new BufferedReader( - new InputStreamReader(conn.getInputStream(), "UTF-8") - ); - StringBuilder result = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - result.append(line); - } - reader.close(); - - String jsonResult = result.toString(); - logger.debug("API响应: {}", jsonResult); - if (isLoginPage(jsonResult)) { - throw new RuntimeException("录音盒认证失效,返回登录页面"); - } - return jsonResult; - } else { - throw new RuntimeException("API访问失败,HTTP状态码: " + responseCode); - } - } finally { - if (conn != null) { - conn.disconnect(); - } - } - } - - /** - * 下载录音文件(无重试) - * @param fileUrl 文件URL - * @param savePath 保存路径 - * @param authorization 登录后返回的Authorization Cookie值 - * @throws Exception 下载失败直接抛出异常 - */ - public void httpDown(String fileUrl, String savePath, String authorization) throws Exception { - logger.info("开始下载录音文件: {} -> {}", fileUrl, savePath); - - // 确保目录存在 - File saveFile = new File(savePath); - if (!saveFile.getParentFile().exists()) { - saveFile.getParentFile().mkdirs(); - } - - HttpURLConnection conn = null; - InputStream inputStream = null; - FileOutputStream outputStream = null; - - try { - URL url = new URL(fileUrl); - conn = (HttpURLConnection) url.openConnection(); - conn.setConnectTimeout(CONNECT_TIMEOUT); - conn.setReadTimeout(DOWNLOAD_READ_TIMEOUT); - conn.setRequestMethod("GET"); - addAuthorization(conn, authorization); - - int responseCode = conn.getResponseCode(); - if (responseCode == 200) { - inputStream = conn.getInputStream(); - outputStream = new FileOutputStream(savePath); - - byte[] buffer = new byte[8192]; - int bytesRead; - long totalBytes = 0; - - while ((bytesRead = inputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, bytesRead); - totalBytes += bytesRead; - } - - outputStream.flush(); - logger.info("录音文件下载完成: {}, 大小: {} bytes ({} MB)", - savePath, totalBytes, totalBytes / 1024 / 1024); - } else { - throw new RuntimeException("文件下载失败,HTTP状态码: " + responseCode); - } - } finally { - closeQuietly(inputStream); - closeQuietly(outputStream); - if (conn != null) { - conn.disconnect(); - } - } - } - - private void closeQuietly(Closeable c) { - if (c != null) { - try { c.close(); } catch (IOException ignored) {} - } - } - - private void addAuthorization(HttpURLConnection conn, String authorization) { - if (authorization != null && !authorization.isEmpty()) { - conn.setRequestProperty("Cookie", "Authorization=" + authorization); - } - } - - private boolean isLoginPage(String responseBody) { - if (responseBody == null) { - return false; - } - String body = responseBody.trim().toLowerCase(); - return body.startsWith("> 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 - * @param extUrl 接口地址 - * @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 { - 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<>(); - } - } -} diff --git a/src/main/java/com/threecloud/dataserviceyy/util/fileUtil.java b/src/main/java/com/threecloud/dataserviceyy/util/fileUtil.java deleted file mode 100644 index b6dffe9..0000000 --- a/src/main/java/com/threecloud/dataserviceyy/util/fileUtil.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.threecloud.dataserviceyy.util; - -import com.threecloud.dataserviceyy.entity.ResultEntity; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.http.*; -import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.multipart.MultipartFile; - -import javax.net.ssl.HttpsURLConnection; -import java.io.IOException; -import java.net.HttpURLConnection; - -public class fileUtil { - - /** - * 通过oss上传文件 - * @param mf - * @return ResultEntity - * @throws IOException - */ - private ResultEntity uploadFilesByOss(MultipartFile mf) throws IOException{ - String ossUrl = "http://127.0.0.1/apiOss";//baseOssConfigService.readOSsUrl();// OSS管理服务地址 - String ossPreviewUrl = "http://127.0.0.1/apiOssPreview/onlinePreview";//baseOssConfigService.readOSsPreviewUrl();// OSS预览服务地址 - String ossAppid = "371a3368-e28e-4ba3-95a3-c31c19cf0ad0";//baseOssConfigService.readOSsAppid();// OSS应用ID - String ossAppsecret = "06a6a80e-f9d2-4b3b-acc0-8d182c876074";//baseOssConfigService.readOSsAppsecret();// OSS应用密钥 - String ossAppcode = "dataservice-yy";//baseOssConfigService.readOSsAppcode();// OSS应用别名 - ResponseEntity 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/mapper/MidVoiceCallRecordMapper.xml b/src/main/resources/mapper/MidVoiceCallRecordMapper.xml index 0032322..5feb9fc 100644 --- a/src/main/resources/mapper/MidVoiceCallRecordMapper.xml +++ b/src/main/resources/mapper/MidVoiceCallRecordMapper.xml @@ -61,6 +61,12 @@ AND call_start_time <= TO_TIMESTAMP(#{endTime}, 'YYYY-MM-DD HH24:MI:SS') + + + - SELECT * FROM sync_log WHERE id = #{id} - - - - - - - - - - - - - - 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 deleted file mode 100644 index ae4198c..0000000 --- a/src/main/resources/mapper/VoiceRecordMapper.xml +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 deleted file mode 100644 index eb49c32..0000000 --- a/src/main/resources/mapper/VoiceSyncMapper.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - -