Browse Source

语音

main
wang 3 weeks ago
parent
commit
c87d83ffbd
  1. 46
      src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java
  2. 114
      src/main/java/com/threecloud/dataserviceyy/util/VaaHttpUtil.java

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

@ -214,9 +214,12 @@ public class VaaSyncService {
} }
} }
// 步骤6:保存同步时间(用API返回的最大时间,而非仅新处理的时间) // 只有本轮全部处理成功才推进游标,避免失败录音被同步时间跨过去而永久漏拉。
if (latestCallTime != null) { // 下轮重复查询到的成功记录会通过 callRecordId 防重跳过。
if (latestCallTime != null && failCount == 0) {
SyncTimeUtil.writeLastSyncTime(downloadPath, deviceId, latestCallTime); SyncTimeUtil.writeLastSyncTime(downloadPath, deviceId, latestCallTime);
} else if (failCount > 0) {
logger.warn("【设备】本轮有{}条录音失败,不推进同步时间,下轮将重新查询", failCount);
} }
logger.info("【设备】同步完成: ID={}, 成功{}条, 失败{}条", deviceId, successCount, failCount); logger.info("【设备】同步完成: ID={}, 成功{}条, 失败{}条", deviceId, successCount, failCount);
@ -226,7 +229,7 @@ public class VaaSyncService {
* 处理单条录音记录 * 处理单条录音记录
* *
* OSS存储路径: voice/{cityCode}/{yyyy-MM-dd}/{fileName} * OSS存储路径: voice/{cityCode}/{yyyy-MM-dd}/{fileName}
* 上传失败不阻塞记录仍保存到数据库文件路径留空 * 下载校验OSS上传全部成功后才保存数据库记录
* *
* @return 通话开始时间成功时null表示跳过 * @return 通话开始时间成功时null表示跳过
*/ */
@ -277,24 +280,15 @@ public class VaaSyncService {
(int) (endTime - begTime), channelPhone, phone, isOutgoing, isAnswered); (int) (endTime - begTime), channelPhone, phone, isOutgoing, isAnswered);
// 下载录音文件(无重试) // 下载录音文件(无重试)
// 文件不存在、大小为0、或小于1KB(可能损坏)时重新下载 // 文件不存在或完整性校验失败时重新下载。
boolean needDownload = !Files.exists(localFile) || Files.size(localFile) < 1024; boolean needDownload = !Files.exists(localFile);
// 额外校验:已存在文件也可能是HTML错误页面,检查文件头
if (!needDownload) { if (!needDownload) {
try { try {
byte[] header = new byte[16]; vaaHttpUtil.validateAudioFile(localFile.toFile());
try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(localFile.toFile(), "r")) {
raf.readFully(header);
}
String headerStr = new String(header, "ASCII").trim().toLowerCase();
if (headerStr.startsWith("<!doctype") || headerStr.startsWith("<html")) {
logger.warn("【录音】本地文件为HTML页面,重新下载: {}", fileName);
needDownload = true;
Files.delete(localFile);
}
} catch (Exception e) { } catch (Exception e) {
logger.warn("【录音】校验本地文件失败,重新下载: {}", fileName); logger.warn("【录音】本地文件校验失败,重新下载: {}, 原因={}", fileName, e.getMessage());
needDownload = true; needDownload = true;
Files.deleteIfExists(localFile);
} }
} }
if (needDownload) { if (needDownload) {
@ -306,17 +300,15 @@ public class VaaSyncService {
} }
callRecord.setRecordingFileSize((int) Files.size(localFile)); callRecord.setRecordingFileSize((int) Files.size(localFile));
// 上传到OSS(地市+日期目录),上传失败不阻塞,仍保存通话记录 // 上传到OSS(地市+日期目录);上传失败时整条处理失败,下轮重新拉取。
String ossPath = "voice/" + cityCode + "/" + new SimpleDateFormat("yyyy-MM-dd").format(callStartTime) + "/"; String ossPath = "voice/" + cityCode + "/" + new SimpleDateFormat("yyyy-MM-dd").format(callStartTime) + "/";
try { OssFileResponse ossResponse = ossFileService.uploadFile(localFile.toFile(), fileName, ossPath);
OssFileResponse ossResponse = ossFileService.uploadFile(localFile.toFile(), fileName, ossPath); String previewUrl = ossResponse.getFileUrl();
String previewUrl = ossResponse.getFileUrl(); if (previewUrl == null || previewUrl.trim().isEmpty()) {
callRecord.setRecordingFilePath(previewUrl); throw new IllegalStateException("OSS上传成功但未返回文件地址: " + fileName);
logger.info("【录音】OSS上传成功: {}", previewUrl);
} catch (Exception e) {
logger.error("【录音】OSS上传失败,通话记录仍将保存: fileName={}, 原因={}", fileName, e.getMessage());
callRecord.setRecordingFilePath(null);
} }
callRecord.setRecordingFilePath(previewUrl);
logger.info("【录音】OSS上传成功: {}", previewUrl);
// 保存到数据库 // 保存到数据库
callRecordMapper.insert(callRecord); callRecordMapper.insert(callRecord);
@ -444,4 +436,4 @@ public class VaaSyncService {
try { return Integer.parseInt(v.toString()); } try { return Integer.parseInt(v.toString()); }
catch (NumberFormatException e) { return defaultVal; } catch (NumberFormatException e) { return defaultVal; }
} }
} }

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

@ -9,6 +9,11 @@ import org.springframework.stereotype.Component;
import java.io.*; import java.io.*;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Locale;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -124,16 +129,15 @@ public class VaaHttpUtil {
public void httpDown(String fileUrl, String savePath, String authorization) throws Exception { public void httpDown(String fileUrl, String savePath, String authorization) throws Exception {
logger.info("开始下载录音文件: {} -> {}", fileUrl, savePath); logger.info("开始下载录音文件: {} -> {}", fileUrl, savePath);
// 确保目录存在
File saveFile = new File(savePath); File saveFile = new File(savePath);
if (!saveFile.getParentFile().exists()) { File parentDir = saveFile.getParentFile();
saveFile.getParentFile().mkdirs(); if (!parentDir.exists() && !parentDir.mkdirs() && !parentDir.exists()) {
throw new IOException("创建录音目录失败: " + parentDir.getAbsolutePath());
} }
File partFile = new File(savePath + ".part");
Files.deleteIfExists(partFile.toPath());
HttpURLConnection conn = null; HttpURLConnection conn = null;
InputStream inputStream = null;
FileOutputStream outputStream = null;
try { try {
URL url = new URL(fileUrl); URL url = new URL(fileUrl);
conn = (HttpURLConnection) url.openConnection(); conn = (HttpURLConnection) url.openConnection();
@ -147,59 +151,91 @@ public class VaaHttpUtil {
// 获取服务器声明的文件大小,用于完整性校验 // 获取服务器声明的文件大小,用于完整性校验
long expectedSize = conn.getContentLengthLong(); long expectedSize = conn.getContentLengthLong();
inputStream = conn.getInputStream();
outputStream = new FileOutputStream(savePath);
byte[] buffer = new byte[8192]; byte[] buffer = new byte[8192];
int bytesRead;
long totalBytes = 0; long totalBytes = 0;
try (InputStream inputStream = conn.getInputStream();
while ((bytesRead = inputStream.read(buffer)) != -1) { FileOutputStream outputStream = new FileOutputStream(partFile)) {
outputStream.write(buffer, 0, bytesRead); int bytesRead;
totalBytes += bytesRead; while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
totalBytes += bytesRead;
}
outputStream.getFD().sync();
} }
outputStream.flush();
logger.info("录音文件下载完成: {}, 大小: {} bytes ({} MB)", logger.info("录音文件下载完成: {}, 大小: {} bytes ({} MB)",
savePath, totalBytes, totalBytes / 1024 / 1024); partFile.getAbsolutePath(), totalBytes, totalBytes / 1024 / 1024);
// 完整性校验 // 完整性校验
if (expectedSize > 0 && totalBytes != expectedSize) { if (expectedSize > 0 && totalBytes != expectedSize) {
saveFile.delete();
throw new RuntimeException(String.format( throw new RuntimeException(String.format(
"文件下载不完整: 期望 %d bytes, 实际 %d bytes", expectedSize, totalBytes)); "文件下载不完整: 期望 %d bytes, 实际 %d bytes", expectedSize, totalBytes));
} }
// 录音文件至少应有1KB,过小说明下载异常 validateAudioFile(partFile);
if (totalBytes < 1024) { moveAtomically(partFile.toPath(), saveFile.toPath());
saveFile.delete();
throw new RuntimeException(String.format(
"文件过小,可能下载异常: 仅 %d bytes", totalBytes));
}
// 校验文件头,确保不是HTML错误页面
byte[] header = new byte[(int) Math.min(totalBytes, 16)];
try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(saveFile, "r")) {
raf.readFully(header);
}
String headerStr = new String(header, "ASCII").trim().toLowerCase();
if (headerStr.startsWith("<!doctype") || headerStr.startsWith("<html")) {
saveFile.delete();
throw new RuntimeException("下载内容为HTML页面,非音频文件");
}
} else { } else {
throw new RuntimeException("文件下载失败,HTTP状态码: " + responseCode); throw new RuntimeException("文件下载失败,HTTP状态码: " + responseCode);
} }
} catch (Exception e) {
try {
Files.deleteIfExists(partFile.toPath());
} catch (IOException cleanupError) {
e.addSuppressed(cleanupError);
}
throw e;
} finally { } finally {
closeQuietly(inputStream);
closeQuietly(outputStream);
if (conn != null) { if (conn != null) {
conn.disconnect(); conn.disconnect();
} }
} }
} }
private void closeQuietly(Closeable c) { /** 校验本地文件至少完整到可识别为对应音频格式。 */
if (c != null) { public void validateAudioFile(File file) throws IOException {
try { c.close(); } catch (IOException ignored) {} if (file == null || !file.isFile() || file.length() < 1024) {
throw new IOException("录音文件不存在或小于1KB: " + (file == null ? "null" : file.getPath()));
}
byte[] header = new byte[12];
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
raf.readFully(header);
}
String text = new String(header, "ISO-8859-1").trim().toLowerCase(Locale.ROOT);
if (text.startsWith("<!doctype") || text.startsWith("<html")) {
throw new IOException("下载内容为HTML页面,非音频文件");
}
String name = file.getName().toLowerCase(Locale.ROOT);
if (name.endsWith(".part")) {
name = name.substring(0, name.length() - ".part".length());
}
if (name.endsWith(".wav")) {
boolean riffWave = header[0] == 'R' && header[1] == 'I' && header[2] == 'F' && header[3] == 'F'
&& header[8] == 'W' && header[9] == 'A' && header[10] == 'V' && header[11] == 'E';
if (!riffWave) {
throw new IOException("WAV文件头无效: " + file.getPath());
}
long riffDataSize = ((long) header[4] & 0xFF)
| (((long) header[5] & 0xFF) << 8)
| (((long) header[6] & 0xFF) << 16)
| (((long) header[7] & 0xFF) << 24);
long declaredFileSize = riffDataSize + 8;
if (declaredFileSize > file.length()) {
throw new IOException(String.format(
"WAV文件被截断: 声明 %d bytes, 实际 %d bytes", declaredFileSize, file.length()));
}
} else if (name.endsWith(".mp3")) {
boolean id3 = header[0] == 'I' && header[1] == 'D' && header[2] == '3';
boolean frameSync = (header[0] & 0xFF) == 0xFF && (header[1] & 0xE0) == 0xE0;
if (!id3 && !frameSync) {
throw new IOException("MP3文件头无效: " + file.getPath());
}
}
}
private void moveAtomically(Path source, Path target) throws IOException {
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
} }
} }

Loading…
Cancel
Save