h-etc2.tolag.shop → https://h-translate.tolag.shop (application.yml 기본값 + TranslateService @Value). 새 URL 동작 확인(/translate, /languages 정상). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
127 lines
5.6 KiB
Java
127 lines
5.6 KiB
Java
package com.hlab.yanalyst.service;
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.http.HttpEntity;
|
|
import org.springframework.http.HttpHeaders;
|
|
import org.springframework.http.MediaType;
|
|
import org.springframework.http.ResponseEntity;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.web.client.RestTemplate;
|
|
|
|
import java.util.LinkedHashMap;
|
|
import java.util.Map;
|
|
|
|
/**
|
|
* 자가호스팅 LibreTranslate 연동. 원본 스크립트를 번역해 재가공(내 버전) 초안으로 쓴다.
|
|
* API: {@code POST /translate {q, source, target}} → {@code {"translatedText": "..."}}.
|
|
*/
|
|
@Slf4j
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class TranslateService {
|
|
|
|
private final RestTemplate restTemplate;
|
|
private final ObjectMapper objectMapper;
|
|
|
|
@Value("${translate.base-url:https://h-translate.tolag.shop}")
|
|
private String baseUrl;
|
|
|
|
/** text 를 source→target 으로 번역해 평문을 반환. source 가 비면 자동감지("auto"). */
|
|
public String translate(String text, String source, String target) {
|
|
if (text == null || text.isBlank()) {
|
|
return "";
|
|
}
|
|
String apiUrl = baseUrl + "/translate";
|
|
try {
|
|
HttpHeaders headers = new HttpHeaders();
|
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
HttpEntity<Map<String, String>> request =
|
|
new HttpEntity<>(buildRequestBody(text, source, target), headers);
|
|
|
|
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, request, String.class);
|
|
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
|
throw new RuntimeException("Translate API 실패: " + response.getStatusCode());
|
|
}
|
|
return extractTranslatedText(objectMapper, response.getBody());
|
|
} catch (Exception e) {
|
|
log.error("번역 실패 (source={}, target={}, len={})", source, target,
|
|
text.length(), e);
|
|
throw new RuntimeException("번역 실패: " + e.getMessage(), e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 여러 텍스트를 한 번에 번역(LibreTranslate 는 q 를 배열로 받으면 정렬 맞춰 배열로 반환).
|
|
* 세그먼트별 번역에 사용해 입력 순서와 1:1 정렬을 보장한다.
|
|
*/
|
|
public java.util.List<String> translateBatch(java.util.List<String> texts, String source, String target) {
|
|
if (texts == null || texts.isEmpty()) {
|
|
return java.util.Collections.emptyList();
|
|
}
|
|
String apiUrl = baseUrl + "/translate";
|
|
try {
|
|
HttpHeaders headers = new HttpHeaders();
|
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
HttpEntity<Map<String, Object>> request =
|
|
new HttpEntity<>(buildBatchRequestBody(texts, source, target), headers);
|
|
|
|
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, request, String.class);
|
|
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
|
throw new RuntimeException("Translate API 실패: " + response.getStatusCode());
|
|
}
|
|
return extractTranslatedTexts(objectMapper, response.getBody());
|
|
} catch (Exception e) {
|
|
log.error("배치 번역 실패 (source={}, target={}, n={})", source, target, texts.size(), e);
|
|
throw new RuntimeException("번역 실패: " + e.getMessage(), e);
|
|
}
|
|
}
|
|
|
|
/** LibreTranslate /translate 요청 바디 조립(순수). source 빈값/null → "auto". */
|
|
static Map<String, String> buildRequestBody(String text, String source, String target) {
|
|
Map<String, String> body = new LinkedHashMap<>();
|
|
body.put("q", text);
|
|
body.put("source", (source == null || source.isBlank()) ? "auto" : source.trim());
|
|
body.put("target", target);
|
|
body.put("format", "text");
|
|
return body;
|
|
}
|
|
|
|
/** 배치 요청 바디 조립(순수). q 를 배열로 담는다. source 빈값/null → "auto". */
|
|
static Map<String, Object> buildBatchRequestBody(java.util.List<String> texts, String source, String target) {
|
|
Map<String, Object> body = new LinkedHashMap<>();
|
|
body.put("q", texts);
|
|
body.put("source", (source == null || source.isBlank()) ? "auto" : source.trim());
|
|
body.put("target", target);
|
|
body.put("format", "text");
|
|
return body;
|
|
}
|
|
|
|
/** 응답 JSON 에서 translatedText 추출(순수). 필드 없으면 빈 문자열, 파싱 실패 시 예외. */
|
|
static String extractTranslatedText(ObjectMapper objectMapper, String json) {
|
|
try {
|
|
return objectMapper.readTree(json).path("translatedText").asText("");
|
|
} catch (Exception e) {
|
|
throw new RuntimeException("번역 응답 파싱 실패", e);
|
|
}
|
|
}
|
|
|
|
/** 응답의 translatedText 를 리스트로 추출(순수). 배열이면 그대로, 문자열이면 단일 원소. */
|
|
static java.util.List<String> extractTranslatedTexts(ObjectMapper objectMapper, String json) {
|
|
try {
|
|
com.fasterxml.jackson.databind.JsonNode node = objectMapper.readTree(json).path("translatedText");
|
|
java.util.List<String> out = new java.util.ArrayList<>();
|
|
if (node.isArray()) {
|
|
node.forEach(n -> out.add(n.asText("")));
|
|
} else {
|
|
out.add(node.asText(""));
|
|
}
|
|
return out;
|
|
} catch (Exception e) {
|
|
throw new RuntimeException("번역 응답 파싱 실패", e);
|
|
}
|
|
}
|
|
}
|