h-lab/src/main/java/com/hlab/yanalyst/service/TranslateService.java
hehihoho3@gmail.com a2934af1a3 chore(translate): 번역 서비스 URL을 h-translate.tolag.shop 으로 변경
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>
2026-06-25 14:05:26 +09:00

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