diff --git a/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java b/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java index ffc7fc9..6d012da 100644 --- a/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java +++ b/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); + } else if (failCount > 0) { + logger.warn("【设备】本轮有{}条录音失败,不推进同步时间,下轮将重新查询", failCount); } logger.info("【设备】同步完成: ID={}, 成功{}条, 失败{}条", deviceId, successCount, failCount); @@ -226,7 +229,7 @@ public class VaaSyncService { * 处理单条录音记录 * * OSS存储路径: voice/{cityCode}/{yyyy-MM-dd}/{fileName} - * 上传失败不阻塞:记录仍保存到数据库,文件路径留空 + * 下载、校验、OSS上传全部成功后才保存数据库记录。 * * @return 通话开始时间(成功时),null表示跳过 */ @@ -277,24 +280,15 @@ public class VaaSyncService { (int) (endTime - begTime), channelPhone, phone, isOutgoing, isAnswered); // 下载录音文件(无重试) - // 文件不存在、大小为0、或小于1KB(可能损坏)时重新下载 - boolean needDownload = !Files.exists(localFile) || Files.size(localFile) < 1024; - // 额外校验:已存在文件也可能是HTML错误页面,检查文件头 + // 文件不存在或完整性校验失败时重新下载。 + boolean needDownload = !Files.exists(localFile); if (!needDownload) { try { - byte[] header = new byte[16]; - 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(" {}", fileUrl, savePath); - // 确保目录存在 File saveFile = new File(savePath); - if (!saveFile.getParentFile().exists()) { - saveFile.getParentFile().mkdirs(); + File parentDir = saveFile.getParentFile(); + 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; - InputStream inputStream = null; - FileOutputStream outputStream = null; - try { URL url = new URL(fileUrl); conn = (HttpURLConnection) url.openConnection(); @@ -147,59 +151,91 @@ public class VaaHttpUtil { // 获取服务器声明的文件大小,用于完整性校验 long expectedSize = conn.getContentLengthLong(); - 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; + try (InputStream inputStream = conn.getInputStream(); + FileOutputStream outputStream = new FileOutputStream(partFile)) { + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + totalBytes += bytesRead; + } + outputStream.getFD().sync(); } - - outputStream.flush(); logger.info("录音文件下载完成: {}, 大小: {} bytes ({} MB)", - savePath, totalBytes, totalBytes / 1024 / 1024); + partFile.getAbsolutePath(), totalBytes, totalBytes / 1024 / 1024); // 完整性校验 if (expectedSize > 0 && totalBytes != expectedSize) { - saveFile.delete(); throw new RuntimeException(String.format( "文件下载不完整: 期望 %d bytes, 实际 %d bytes", expectedSize, totalBytes)); } - // 录音文件至少应有1KB,过小说明下载异常 - if (totalBytes < 1024) { - 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(" 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); } }