4 次代码提交 0dc42b8b81 ... 809efa4422

作者 SHA1 备注 提交日期
  yzx 809efa4422 Merge branch 'master' of http://1.14.104.71:10880/wushubo/easycallcenter365-gui-dev 9 小时之前
  yzx e2ead64737 1 9 小时之前
  yzx 9b6e61a79c Merge branch 'master' of http://1.14.104.71:10880/wushubo/easycallcenter365-gui-dev 6 天之前
  yzx 9306198e9e 1 6 天之前
共有 1 个文件被更改,包括 151 次插入8 次删除
  1. 151 8
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/XfVoiceCloneController.java

+ 151 - 8
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/XfVoiceCloneController.java

@@ -35,14 +35,22 @@ import javax.crypto.spec.SecretKeySpec;
 import javax.servlet.http.HttpServletRequest;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.security.MessageDigest;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.Base64;
 import java.util.LinkedHashMap;
+import java.util.Locale;
 import java.util.Map;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
@@ -68,6 +76,8 @@ public class XfVoiceCloneController extends BaseController {
     private static final String CLONE_TTS_WS_URL = "wss://cn-huabei-1.xf-yun.com/v1/private/voice_clone";
     private static final String CLONE_TTS_HOST = "cn-huabei-1.xf-yun.com";
     private static final String CLONE_TTS_PATH = "/v1/private/voice_clone";
+    private static final DateTimeFormatter CLONE_AUTH_DATE_FORMATTER =
+            DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US);
     private static final Long DEFAULT_TEXT_ID = 5001L;
     private static final OkHttpClient HTTP_CLIENT = new OkHttpClient.Builder()
             .connectTimeout(30, TimeUnit.SECONDS)
@@ -319,15 +329,53 @@ public class XfVoiceCloneController extends BaseController {
     }
 
     private String synthesizeCloneAudio(JSONObject account, String assetId, String text, String engineVersion) throws Exception {
-        String authUrl = buildCloneWebsocketUrl(account.getString("apiKey"), account.getString("apiSecret"));
+        CloneWebsocketAuth auth = buildCloneWebsocketAuth(account.getString("apiKey"), account.getString("apiSecret"));
         CountDownLatch latch = new CountDownLatch(1);
         AtomicReference<String> errRef = new AtomicReference<>();
         ByteArrayOutputStream audioOutput = new ByteArrayOutputStream();
 
-        Request request = new Request.Builder().url(authUrl).build();
+        // #region debug-point A:clone-auth-shape
+        reportDebugEvent("A", "XfVoiceCloneController.synthesizeCloneAudio",
+                "[DEBUG] clone websocket auth built",
+                new JSONObject(true)
+                        .fluentPut("url", auth.url)
+                        .fluentPut("date", auth.date)
+                        .fluentPut("authorizationLength", auth.authorization == null ? 0 : auth.authorization.length())
+                        .fluentPut("systemZone", ZoneId.systemDefault().getId())
+                        .fluentPut("systemNow", ZonedDateTime.now().toString())
+                        .fluentPut("engineVersion", engineVersion)
+                        .fluentPut("assetIdSuffix", StringUtils.right(assetId, 8)));
+        log.info("clone websocket auth built url={}, date={}, authorizationLength={}, systemZone={}, systemNow={}, engineVersion={}, assetIdSuffix={}",
+                auth.url,
+                auth.date,
+                auth.authorization == null ? 0 : auth.authorization.length(),
+                ZoneId.systemDefault().getId(),
+                ZonedDateTime.now(),
+                engineVersion,
+                StringUtils.right(assetId, 8));
+        // #endregion
+
+        Request request = new Request.Builder()
+                .url(auth.url)
+                .addHeader("Host", CLONE_TTS_HOST)
+                .addHeader("Date", auth.date)
+                .addHeader("Authorization", auth.authorization)
+                .build();
         HTTP_CLIENT.newWebSocket(request, new WebSocketListener() {
             @Override
             public void onOpen(WebSocket webSocket, Response response) {
+                // #region debug-point B:clone-ws-open
+                reportDebugEvent("B", "XfVoiceCloneController.onOpen",
+                        "[DEBUG] clone websocket opened",
+                        new JSONObject(true)
+                                .fluentPut("httpCode", response == null ? -1 : response.code())
+                                .fluentPut("serverDate", response == null ? "" : String.valueOf(response.header("Date")))
+                                .fluentPut("requestDate", auth.date));
+                log.info("clone websocket opened httpCode={}, serverDate={}, requestDate={}",
+                        response == null ? -1 : response.code(),
+                        response == null ? "" : String.valueOf(response.header("Date")),
+                        auth.date);
+                // #endregion
                 JSONObject payload = buildCloneTtsPayload(account.getString("appId"), assetId, text, engineVersion);
                 webSocket.send(payload.toJSONString());
             }
@@ -335,8 +383,10 @@ public class XfVoiceCloneController extends BaseController {
             @Override
             public void onMessage(WebSocket webSocket, String textMessage) {
                 try {
+                    log.info("clone websocket message text={}", textMessage);
                     JSONObject json = JSON.parseObject(textMessage);
-                    int code = json.getIntValue("code");
+                    JSONObject header = json.getJSONObject("header");
+                    int code = header == null ? json.getIntValue("code") : header.getIntValue("code");
                     if (code != 0) {
                         errRef.set("讯飞试音接口返回错误: " + json.toJSONString());
                         webSocket.close(1000, "error");
@@ -374,12 +424,29 @@ public class XfVoiceCloneController extends BaseController {
 
             @Override
             public void onFailure(WebSocket webSocket, Throwable t, Response response) {
+                // #region debug-point C:clone-ws-failure
+                reportDebugEvent("C", "XfVoiceCloneController.onFailure",
+                        "[DEBUG] clone websocket failure",
+                        new JSONObject(true)
+                                .fluentPut("throwable", t == null ? "" : StringUtils.defaultString(t.getMessage()))
+                                .fluentPut("httpCode", response == null ? -1 : response.code())
+                                .fluentPut("responseBody", safeReadResponseBody(response))
+                                .fluentPut("requestDate", auth.date)
+                                .fluentPut("authorizationLength", auth.authorization == null ? 0 : auth.authorization.length()));
+                log.warn("clone websocket failure throwable={}, httpCode={}, responseBody={}, requestDate={}, authorizationLength={}",
+                        t == null ? "" : StringUtils.defaultString(t.getMessage()),
+                        response == null ? -1 : response.code(),
+                        safeReadResponseBody(response),
+                        auth.date,
+                        auth.authorization == null ? 0 : auth.authorization.length());
+                // #endregion
                 errRef.set(buildWebsocketFailureMessage(t, response));
                 latch.countDown();
             }
 
             @Override
             public void onClosed(WebSocket webSocket, int code, String reason) {
+                log.info("clone websocket closed code={}, reason={}", code, reason);
                 latch.countDown();
             }
         });
@@ -430,7 +497,7 @@ public class XfVoiceCloneController extends BaseController {
         textNode.put("format", "plain");
         textNode.put("status", 2);
         textNode.put("seq", 0);
-        textNode.put("text", text);
+        textNode.put("text", Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8)));
 
         JSONObject payload = new JSONObject(true);
         payload.put("text", textNode);
@@ -438,8 +505,8 @@ public class XfVoiceCloneController extends BaseController {
         return root;
     }
 
-    private String buildCloneWebsocketUrl(String apiKey, String apiSecret) throws Exception {
-        String date = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("GMT")));
+    private CloneWebsocketAuth buildCloneWebsocketAuth(String apiKey, String apiSecret) throws Exception {
+        String date = CLONE_AUTH_DATE_FORMATTER.format(ZonedDateTime.now(ZoneId.of("GMT")));
         String signatureOrigin = "host: " + CLONE_TTS_HOST + "\n" +
                 "date: " + date + "\n" +
                 "GET " + CLONE_TTS_PATH + " HTTP/1.1";
@@ -449,9 +516,10 @@ public class XfVoiceCloneController extends BaseController {
         String authorizationOrigin = String.format("api_key=\"%s\", algorithm=\"hmac-sha256\", headers=\"host date request-line\", signature=\"%s\"",
                 apiKey, signature);
         String authorization = Base64.getEncoder().encodeToString(authorizationOrigin.getBytes(StandardCharsets.UTF_8));
-        return CLONE_TTS_WS_URL + "?authorization=" + urlEncode(authorization) +
+        String url = CLONE_TTS_WS_URL + "?authorization=" + urlEncode(authorization) +
                 "&date=" + urlEncode(date) +
                 "&host=" + urlEncode(CLONE_TTS_HOST);
+        return new CloneWebsocketAuth(url, date, authorization);
     }
 
     private String buildWebsocketFailureMessage(Throwable t, Response response) {
@@ -468,7 +536,12 @@ public class XfVoiceCloneController extends BaseController {
                 sb.append(", body=").append(bodyText);
             }
             if (response.code() == 403) {
-                sb.append("。请确认当前 app-id/api-key/api-secret 是否属于“讯飞一句话复刻”服务应用,且该应用已开通 voice_clone/一句话复刻权限;普通在线TTS应用凭证无法调用该接口。");
+                if (StringUtils.containsIgnoreCase(bodyText, "valid date or x-date")
+                        || StringUtils.containsIgnoreCase(bodyText, "HMAC signature cannot be verified")) {
+                    sb.append("。当前更像是 WebSocket 时间头/签名校验失败,请先确认部署机器系统时间准确,并已同步标准时间;本次请求已自动补充 Date/X-Date 头,如仍失败,再检查当前 app-id/api-key/api-secret 是否属于“一句话复刻/voice_clone”服务应用。");
+                } else {
+                    sb.append("。请确认当前 app-id/api-key/api-secret 是否属于“讯飞一句话复刻”服务应用,且该应用已开通 voice_clone/一句话复刻权限;普通在线TTS应用凭证无法调用该接口。");
+                }
             }
         } else if (t != null && StringUtils.isNotBlank(t.getMessage())) {
             sb.append(": ").append(t.getMessage());
@@ -476,6 +549,76 @@ public class XfVoiceCloneController extends BaseController {
         return sb.toString();
     }
 
+    // #region debug-point D:debug-report-helper
+    private void reportDebugEvent(String hypothesisId, String location, String msg, JSONObject data) {
+        HttpURLConnection connection = null;
+        try {
+            String debugUrl = "http://127.0.0.1:7777/event";
+            String sessionId = "xfvoiceclone-403";
+            Path envPath = Paths.get(System.getProperty("user.dir"), ".dbg", "xfvoiceclone-403.env");
+            if (Files.exists(envPath)) {
+                for (String line : Files.readAllLines(envPath, StandardCharsets.UTF_8)) {
+                    if (line.startsWith("DEBUG_SERVER_URL=")) {
+                        debugUrl = line.substring("DEBUG_SERVER_URL=".length()).trim();
+                    } else if (line.startsWith("DEBUG_SESSION_ID=")) {
+                        sessionId = line.substring("DEBUG_SESSION_ID=".length()).trim();
+                    }
+                }
+            }
+            JSONObject payload = new JSONObject(true);
+            payload.put("sessionId", sessionId);
+            payload.put("runId", "pre-fix");
+            payload.put("hypothesisId", hypothesisId);
+            payload.put("location", location);
+            payload.put("msg", msg);
+            payload.put("data", data == null ? new JSONObject(true) : data);
+            payload.put("ts", System.currentTimeMillis());
+
+            connection = (HttpURLConnection) new URL(debugUrl).openConnection();
+            connection.setRequestMethod("POST");
+            connection.setConnectTimeout(800);
+            connection.setReadTimeout(800);
+            connection.setDoOutput(true);
+            connection.setRequestProperty("Content-Type", "application/json");
+            try (OutputStream os = connection.getOutputStream()) {
+                os.write(payload.toJSONString().getBytes(StandardCharsets.UTF_8));
+            }
+            try (InputStream ignored = connection.getInputStream()) {
+                // no-op
+            }
+        } catch (Exception ignore) {
+            // ignore debug reporting errors
+        } finally {
+            if (connection != null) {
+                connection.disconnect();
+            }
+        }
+    }
+
+    private String safeReadResponseBody(Response response) {
+        if (response == null || response.body() == null) {
+            return "";
+        }
+        try {
+            return response.peekBody(4096).string();
+        } catch (Exception ignore) {
+            return "";
+        }
+    }
+    // #endregion
+
+    private static class CloneWebsocketAuth {
+        private final String url;
+        private final String date;
+        private final String authorization;
+
+        private CloneWebsocketAuth(String url, String date, String authorization) {
+            this.url = url;
+            this.date = date;
+            this.authorization = authorization;
+        }
+    }
+
     private Map<String, String> buildVoiceTrainHeaders(String apiKey, String appId, String token, String bodyText) {
         Map<String, String> headers = new LinkedHashMap<>();
         String requestTime = String.valueOf(System.currentTimeMillis());