ExternalTranslationService.java
package com.yumu.noveltranslator.adapter.out.translate;
import com.alibaba.fastjson2.JSONObject;
import com.yumu.noveltranslator.exception.MTranServerUnavailableException;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import jakarta.annotation.Nullable;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.net.ConnectException;
import java.time.Duration;
/**
* 外部翻译服务调用类
* 作为调用外部翻译引擎接口的中转站
*/
@Service
@Slf4j
public class ExternalTranslationService {
private final WebClient webClient;
private final boolean mockMode;
public ExternalTranslationService(
@Value("${mtran.server.host:localhost}") String host,
@Value("${mtran.server.port:8989}") int port,
@Value("${mtran.server.api-key:}") @Nullable String apiKey,
@Value("${mtran.server.mock:false}") boolean mockMode) {
this.mockMode = mockMode;
var builder = WebClient.builder()
.baseUrl("http://%s:%d".formatted(host, port))
.defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(256 * 1024));
// 如果配置了 API Key,添加到请求头
if (apiKey != null && !apiKey.isEmpty()) {
builder.defaultHeader("Authorization", "Bearer " + apiKey);
log.info("MTranServer 认证已启用");
}
this.webClient = builder.build();
log.info("MTranServer 模式: {}", mockMode ? "MOCK(模拟响应)" : "REAL(直连服务)");
}
/**
* 调用外部翻译引擎进行翻译
*
* @param from 源语言
* @param to 目标语言
* @param text 待翻译文本
* @return 翻译结果
* @throws RuntimeException 翻译失败时抛出异常
*/
@CircuitBreaker(name = "mtranServer", fallbackMethod = "mtranServerFallback")
public JSONObject translate(String from, String to, String text, boolean html) {
// Mock 模式:直接返回模拟响应,不发 HTTP 请求
if (mockMode) {
return buildMockResponse(from, to, text, html);
}
JSONObject requestBody = new JSONObject();
requestBody.put("from", from);
requestBody.put("to", to);
requestBody.put("text", text);
requestBody.put("html", html);
try {
// MTranServer 偶尔响应慢,设置 15 秒超时后降级到 Python 服务
String responseBody = webClient.post()
.uri("/translate")
.bodyValue(requestBody)
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofSeconds(15));
return JSONObject.parseObject(responseBody);
} catch (WebClientResponseException e) {
log.error("外部翻译引擎调用失败,状态码:{},响应体:{}", e.getStatusCode(), e.getResponseBodyAsString(), e);
throw new RuntimeException("外部翻译引擎调用失败 (HTTP " + e.getStatusCode() + "): " + e.getMessage(), e);
} catch (Exception e) {
String errorMsg = e.getMessage() != null ? e.getMessage() : "未知错误";
// 检查是否是超时错误
if (errorMsg.contains("Timeout") || errorMsg.contains("timeout") || errorMsg.contains("timed out")) {
log.error("外部翻译引擎响应超时(>15 秒),降级到 Python 服务。文本长度:{}", text != null ? text.length() : 0, e);
throw new RuntimeException("外部翻译引擎响应超时,请检查服务性能:" + e.getMessage(), e);
}
// 检查是否是连接失败
if (e.getCause() instanceof ConnectException || errorMsg.contains("Connection refused") || errorMsg.contains("connect timed out")) {
log.error("外部翻译引擎连接失败,请确认服务已启动在 http://localhost:{},错误:{}",
System.getProperty("mtran.server.port", "8989"), e.getMessage(), e);
throw new RuntimeException("无法连接到外部翻译引擎,请确认服务已启动:" + e.getMessage(), e);
}
log.error("外部翻译引擎翻译异常,待翻译文本长度:{}", text != null ? text.length() : 0, e);
throw new RuntimeException("外部翻译引擎翻译失败:" + e.getMessage(), e);
}
}
/**
* Circuit breaker fallback for MTranServer failures.
*/
private JSONObject mtranServerFallback(String from, String to, String text, boolean html, Throwable t) {
log.warn("MTranServer 熔断器触发: {}", t.getMessage());
throw new MTranServerUnavailableException("MTranServer 不可用(熔断器已打开): " + t.getMessage(), t);
}
/**
* 构建模拟翻译响应(用于开发/测试,不调用真实 MTranServer)
*/
private JSONObject buildMockResponse(String from, String to, String text, boolean html) {
String langLabel = switch (to.toLowerCase()) {
case "zh", "zh-cn" -> "简体中文";
case "zh-tw" -> "繁體中文";
case "ja" -> "日本語";
case "ko" -> "한국어";
case "fr" -> "Français";
case "de" -> "Deutsch";
case "es" -> "Español";
case "ru" -> "Русский";
default -> to;
};
String result;
if (html) {
result = "[" + langLabel + "] " + text;
} else {
String[] lines = text.split("\n");
StringBuilder sb = new StringBuilder();
for (int i = 0; i < lines.length; i++) {
if (i > 0) sb.append("\n");
sb.append(lines[i].isEmpty() ? "" : "[" + langLabel + "] " + lines[i]);
}
result = sb.toString();
}
JSONObject response = new JSONObject();
response.put("result", result);
return response;
}
}