TeamTranslationService.java
package com.yumu.noveltranslator.adapter.out.translate;
import com.yumu.noveltranslator.port.dto.translation.TeamTranslateRequest;
import com.yumu.noveltranslator.port.dto.translation.TeamTranslateResponse;
import com.yumu.noveltranslator.domain.model.Glossary;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
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.time.Duration;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* AI 翻译团队服务
* 调用 Python 侧的 /translate-team 端点,使用 AI 多角色协作进行小说章节翻译
*/
@Service
@Slf4j
public class TeamTranslationService implements com.yumu.noveltranslator.port.out.TeamTranslationPort {
private final WebClient webClient;
public TeamTranslationService(
@Value("${translate-server.host:localhost}") String host,
@Value("${translate-server.port:8000}") int port,
@Value("${translate-server.api-key:}") String apiKey) {
var builder = WebClient.builder()
.baseUrl("http://%s:%d".formatted(host, port))
.defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(512 * 1024));
if (apiKey != null && !apiKey.isEmpty()) {
builder.defaultHeader("X-Service-Key", apiKey);
}
this.webClient = builder.build();
}
/**
* 调用 AI 翻译团队进行章节翻译
*
* @param text 待翻译的章节原文
* @param novelType 小说类型
* @param sourceLang 源语言
* @param targetLang 目标语言
* @param glossaryTerms 术语表词条列表
* @return 翻译后的文本
*/
@CircuitBreaker(name = "teamTranslation", fallbackMethod = "teamTranslationFallback")
public String translateChapter(String text, String novelType, String sourceLang,
String targetLang, List<Glossary> glossaryTerms) {
TeamTranslateRequest request = buildRequest(text, novelType, sourceLang, targetLang, glossaryTerms);
log.info("调用 AI 翻译团队: sourceLang={}, targetLang={}, novelType={}, textLength={}, glossaryCount={}",
sourceLang, targetLang, novelType, text != null ? text.length() : 0,
glossaryTerms != null ? glossaryTerms.size() : 0);
try {
String responseBody = webClient.post()
.uri("/translate-team")
.bodyValue(request)
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofSeconds(300));
TeamTranslateResponse response = parseResponse(responseBody);
if (response.getCode() != 200) {
log.error("AI 翻译团队返回错误码: code={}", response.getCode());
throw new RuntimeException("AI 翻译团队翻译失败,错误码: " + response.getCode());
}
log.info("AI 翻译团队调用成功: chunkCount={}, costMs={}ms",
response.getChunkCount(), response.getCostMs());
return response.getData();
} catch (WebClientResponseException e) {
log.error("AI 翻译团队调用失败,状态码:{},响应体:{}", e.getStatusCode(), e.getResponseBodyAsString(), e);
throw new RuntimeException("AI 翻译团队调用失败 (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("AI 翻译团队响应超时(>300 秒),文本长度:{}", text != null ? text.length() : 0, e);
throw new RuntimeException("AI 翻译团队响应超时:" + e.getMessage(), e);
}
if (e.getCause() instanceof java.net.ConnectException
|| errorMsg.contains("Connection refused")
|| errorMsg.contains("connect timed out")) {
log.error("AI 翻译团队连接失败,请确认服务已启动在 http://localhost:{},错误:{}",
8000, e.getMessage(), e);
throw new RuntimeException("无法连接到 AI 翻译团队服务:" + e.getMessage(), e);
}
log.error("AI 翻译团队翻译异常,文本长度:{}", text != null ? text.length() : 0, e);
throw new RuntimeException("AI 翻译团队翻译失败:" + e.getMessage(), e);
}
}
/**
* 调用 AI 翻译团队进行章节翻译(带占位符保护)
*
* @param text 待翻译的章节原文(可能包含 [{1}], [{2}] 等占位符)
* @param novelType 小说类型
* @param sourceLang 源语言
* @param targetLang 目标语言
* @param glossaryTerms 术语表词条列表
* @param placeholderMap 占位符 → 翻译后实体名的映射
* @return 翻译后的文本(占位符已还原为翻译后的实体名)
*/
@CircuitBreaker(name = "teamTranslation", fallbackMethod = "teamTranslationFallback")
public String translateChapterWithPlaceholders(String text, String novelType, String sourceLang,
String targetLang, List<Glossary> glossaryTerms,
Map<String, String> placeholderMap) {
TeamTranslateRequest request = buildRequest(text, novelType, sourceLang, targetLang, glossaryTerms);
// Attach placeholders to the request
if (placeholderMap != null && !placeholderMap.isEmpty()) {
request.setPlaceholders(placeholderMap);
log.info("占位符保护: 发送 {} 个占位符映射", placeholderMap.size());
} else {
log.info("占位符保护: 无占位符映射");
}
log.info("调用 AI 翻译团队(带占位符): sourceLang={}, targetLang={}, novelType={}, textLength={}, glossaryCount={}, placeholderCount={}",
sourceLang, targetLang, novelType, text != null ? text.length() : 0,
glossaryTerms != null ? glossaryTerms.size() : 0,
placeholderMap != null ? placeholderMap.size() : 0);
try {
String responseBody = webClient.post()
.uri("/translate-team")
.bodyValue(request)
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofSeconds(300));
TeamTranslateResponse response = parseResponse(responseBody);
if (response.getCode() != 200) {
log.error("AI 翻译团队返回错误码: code={}", response.getCode());
throw new RuntimeException("AI 翻译团队翻译失败,错误码: " + response.getCode());
}
log.info("AI 翻译团队调用成功(带占位符): chunkCount={}, costMs={}ms",
response.getChunkCount(), response.getCostMs());
String translatedText = response.getData();
// Restore placeholders in the translated text
if (placeholderMap != null && !placeholderMap.isEmpty()) {
translatedText = restorePlaceholdersInText(translatedText, placeholderMap);
}
return translatedText;
} catch (WebClientResponseException e) {
log.error("AI 翻译团队调用失败,状态码:{},响应体:{}", e.getStatusCode(), e.getResponseBodyAsString(), e);
throw new RuntimeException("AI 翻译团队调用失败 (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("AI 翻译团队响应超时(>300 秒),文本长度:{}", text != null ? text.length() : 0, e);
throw new RuntimeException("AI 翻译团队响应超时:" + e.getMessage(), e);
}
if (e.getCause() instanceof java.net.ConnectException
|| errorMsg.contains("Connection refused")
|| errorMsg.contains("connect timed out")) {
log.error("AI 翻译团队连接失败,请确认服务已启动在 http://localhost:{},错误:{}",
8000, e.getMessage(), e);
throw new RuntimeException("无法连接到 AI 翻译团队服务:" + e.getMessage(), e);
}
log.error("AI 翻译团队翻译异常,文本长度:{}", text != null ? text.length() : 0, e);
throw new RuntimeException("AI 翻译团队翻译失败:" + e.getMessage(), e);
}
}
/**
* 在翻译后的文本中还原占位符
* 按占位符字符串长度降序替换,避免 [{1}] 匹配到 [{10}] 中的部分子串
* 如果某个占位符在响应中未找到,会记录警告日志
*
* @param text 翻译后的文本
* @param placeholderMap 占位符 → 翻译后实体名的映射
* @return 还原占位符后的文本
*/
private String restorePlaceholdersInText(String text, Map<String, String> placeholderMap) {
if (text == null || text.isEmpty() || placeholderMap == null || placeholderMap.isEmpty()) {
return text;
}
String result = text;
// Sort placeholders by key length descending to avoid partial matches
List<Map.Entry<String, String>> sortedEntries = new HashMap<>(placeholderMap).entrySet().stream()
.sorted(Map.Entry.<String, String>comparingByKey(Comparator.comparingInt(String::length).reversed()))
.collect(Collectors.toList());
int restoredCount = 0;
int notFoundCount = 0;
for (Map.Entry<String, String> entry : sortedEntries) {
String placeholder = entry.getKey();
String translatedEntity = entry.getValue();
if (result.contains(placeholder)) {
result = result.replace(placeholder, translatedEntity);
restoredCount++;
log.debug("占位符还原: '{}' -> '{}'", placeholder, translatedEntity);
} else {
notFoundCount++;
log.warn("占位符还原: 响应中未找到占位符 '{}' (映射到 '{}'),AI 可能未正确翻译该实体",
placeholder, translatedEntity);
}
}
log.info("占位符还原完成: 总数={}, 已还原={}, 未找到={}",
sortedEntries.size(), restoredCount, notFoundCount);
return result;
}
/**
* 将请求参数构建为 TeamTranslateRequest
*/
private TeamTranslateRequest buildRequest(String text, String novelType, String sourceLang,
String targetLang, List<Glossary> glossaryTerms) {
TeamTranslateRequest request = new TeamTranslateRequest();
request.setText(text);
request.setNovelType(novelType);
request.setSourceLang(sourceLang);
request.setTargetLang(targetLang);
if (glossaryTerms != null && !glossaryTerms.isEmpty()) {
List<TeamTranslateRequest.GlossaryTerm> terms = glossaryTerms.stream()
.map(g -> new TeamTranslateRequest.GlossaryTerm(
g.getSourceWord(),
g.getTargetWord(),
g.getRemark()))
.collect(Collectors.toList());
request.setGlossaryTerms(terms);
}
return request;
}
/**
* 解析响应 JSON 为 TeamTranslateResponse
*/
private TeamTranslateResponse parseResponse(String responseBody) {
try {
return com.alibaba.fastjson2.JSON.parseObject(responseBody, TeamTranslateResponse.class);
} catch (Exception e) {
log.error("解析 AI 翻译团队响应失败: {}", responseBody, e);
TeamTranslateResponse fallback = new TeamTranslateResponse();
fallback.setCode(-1);
fallback.setData(responseBody);
return fallback;
}
}
private String teamTranslationFallback(String text, String novelType, String sourceLang,
String targetLang, List<Glossary> glossaryTerms, Throwable t) {
log.error("AI 翻译团队熔断降级: {}", t.getMessage());
throw new RuntimeException("AI 翻译团队服务不可用(熔断器已打开): " + t.getMessage(), t);
}
private String teamTranslationFallback(String text, String novelType, String sourceLang,
String targetLang, List<Glossary> glossaryTerms,
Map<String, String> placeholderMap, Throwable t) {
log.error("AI 翻译团队熔断降级(带占位符): {}", t.getMessage());
throw new RuntimeException("AI 翻译团队服务不可用(熔断器已打开): " + t.getMessage(), t);
}
}