|
|
|
@ -9,6 +9,11 @@ import org.springframework.stereotype.Component; |
|
|
|
import java.io.*; |
|
|
|
import java.net.HttpURLConnection; |
|
|
|
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.Map; |
|
|
|
|
|
|
|
@ -124,16 +129,15 @@ public class VaaHttpUtil { |
|
|
|
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(); |
|
|
|
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; |
|
|
|
|
|
|
|
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.flush(); |
|
|
|
outputStream.getFD().sync(); |
|
|
|
} |
|
|
|
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("<!doctype") || headerStr.startsWith("<html")) { |
|
|
|
saveFile.delete(); |
|
|
|
throw new RuntimeException("下载内容为HTML页面,非音频文件"); |
|
|
|
} |
|
|
|
validateAudioFile(partFile); |
|
|
|
moveAtomically(partFile.toPath(), saveFile.toPath()); |
|
|
|
} else { |
|
|
|
throw new RuntimeException("文件下载失败,HTTP状态码: " + responseCode); |
|
|
|
} |
|
|
|
} catch (Exception e) { |
|
|
|
try { |
|
|
|
Files.deleteIfExists(partFile.toPath()); |
|
|
|
} catch (IOException cleanupError) { |
|
|
|
e.addSuppressed(cleanupError); |
|
|
|
} |
|
|
|
throw e; |
|
|
|
} finally { |
|
|
|
closeQuietly(inputStream); |
|
|
|
closeQuietly(outputStream); |
|
|
|
if (conn != null) { |
|
|
|
conn.disconnect(); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
private void closeQuietly(Closeable c) { |
|
|
|
if (c != null) { |
|
|
|
try { c.close(); } catch (IOException ignored) {} |
|
|
|
/** 校验本地文件至少完整到可识别为对应音频格式。 */ |
|
|
|
public void validateAudioFile(File file) throws IOException { |
|
|
|
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); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|