数据同步服务-语音
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

333 lines
13 KiB

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.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;
/**
* 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);
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;
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) {
// 获取服务器声明的文件大小,用于完整性校验
long expectedSize = conn.getContentLengthLong();
byte[] buffer = new byte[8192];
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.getFD().sync();
}
logger.info("录音文件下载完成: {}, 大小: {} bytes ({} MB)",
partFile.getAbsolutePath(), totalBytes, totalBytes / 1024 / 1024);
// 完整性校验
if (expectedSize > 0 && totalBytes != expectedSize) {
throw new RuntimeException(String.format(
"文件下载不完整: 期望 %d bytes, 实际 %d bytes", expectedSize, totalBytes));
}
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 {
if (conn != null) {
conn.disconnect();
}
}
}
/** 校验本地文件至少完整到可识别为对应音频格式。 */
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);
}
}
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("<!doctype") || body.startsWith("<html")
|| (body.contains("<form") && body.contains("password"));
}
private String getAuthorizationCookie(HttpURLConnection conn) {
for (Map.Entry<String, List<String>> 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<String> 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<channel, phoneNumber>
*/
public Map<String, String> 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<String, String> 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<>();
}
}
}