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;
    }
}