Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ jobs:
-e EATSSU_AWS_SECRET_KEY_PROD="${{ secrets.EATSSU_AWS_SECRET_KEY_PROD }}" \
-e EATSSU_SLACK_TOKEN="${{ secrets.EATSSU_SLACK_TOKEN }}" \
-e EATSSU_PROD_DOMAIN="${{ secrets.EATSSU_PROD_DOMAIN }}" \
-e EATSSU_DEEPL_API_KEY="${{ secrets.EATSSU_DEEPL_API_KEY }}" \
${{ secrets.DOCKER_REPO }}/eatssu-prod
sudo docker image prune -f

Expand Down Expand Up @@ -174,5 +175,6 @@ jobs:
-e EATSSU_AWS_SECRET_KEY_DEV="${{ secrets.EATSSU_AWS_SECRET_KEY_DEV }}" \
-e EATSSU_SLACK_TOKEN="${{ secrets.EATSSU_SLACK_TOKEN }}" \
-e EATSSU_DEV_DOMAIN="${{ secrets.EATSSU_DEV_DOMAIN }}" \
-e EATSSU_DEEPL_API_KEY="${{ secrets.EATSSU_DEEPL_API_KEY }}" \
${{ secrets.DOCKER_REPO }}/eatssu-dev
sudo docker image prune -f
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
EATSSU_AWS_ACCESS_KEY_DEV: dummy-access-key
EATSSU_AWS_SECRET_KEY_DEV: dummy-secret-key
EATSSU_SLACK_TOKEN: dummy-slack-token
EATSSU_DEEPL_API_KEY: dummy-deepl-key
steps:
- uses: actions/checkout@v3

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package ssu.eatssu.domain.review.dto;

import ssu.eatssu.domain.user.entity.Language;

public record ReviewTranslationResponse(
Long reviewId,
Language language,
String translatedContent,
boolean cached
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package ssu.eatssu.domain.review.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import ssu.eatssu.domain.user.entity.BaseTimeEntity;
import ssu.eatssu.domain.user.entity.Language;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@AllArgsConstructor
public class ReviewTranslation extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "review_id")
private Review review;

@Enumerated(EnumType.STRING)
private Language language;

private String translatedContent;

private Integer charCount;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package ssu.eatssu.domain.review.infrastructure;

import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;

@Configuration
public class DeepLConfig {

private static final int MAX_CONN_PER_ROUTE = 15;
private static final int MAX_CONN_TOTAL = 15;
private static final int CONNECT_TIMEOUT_MS = 2000;
private static final int CONNECTION_REQUEST_TIMEOUT_MS = 3000;
private static final int READ_TIMEOUT_MS = 3000;

@Bean
public RestTemplate deeplRestTemplate() {
PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnPerRoute(MAX_CONN_PER_ROUTE)
.setMaxConnTotal(MAX_CONN_TOTAL)
.build();
CloseableHttpClient client = HttpClientBuilder.create()
.setConnectionManager(connectionManager)
.build();

HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(client);
factory.setConnectTimeout(CONNECT_TIMEOUT_MS);
factory.setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT_MS);
factory.setReadTimeout(READ_TIMEOUT_MS);
return new RestTemplate(factory);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package ssu.eatssu.domain.review.infrastructure;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;

public record DeepLTranslateResponse(List<Translation> translations) {

public record Translation(
@JsonProperty("detected_source_language") String detectedSourceLanguage,
String text
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package ssu.eatssu.domain.review.infrastructure;

import lombok.RequiredArgsConstructor;
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.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import ssu.eatssu.domain.user.entity.Language;
import ssu.eatssu.global.handler.response.BaseException;

import static ssu.eatssu.global.handler.response.BaseResponseStatus.TRANSLATION_FAILED;
import static ssu.eatssu.global.handler.response.BaseResponseStatus.TRANSLATION_QUOTA_EXCEEDED;
import static ssu.eatssu.global.handler.response.BaseResponseStatus.TRANSLATION_TIMEOUT;

@Component
@RequiredArgsConstructor
public class DeepLTranslationClient {

private static final int QUOTA_EXCEEDED_STATUS = 456;

private final RestTemplate deeplRestTemplate;

@Value("${deepl.api-key}")
private String apiKey;

@Value("${deepl.base-url:https://api-free.deepl.com}")
private String baseUrl;
Comment thread
sjinssun marked this conversation as resolved.

public String translate(String text, Language targetLanguage) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.set("Authorization", "DeepL-Auth-Key " + apiKey);

MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("text", text);
body.add("target_lang", targetLanguage.name());

HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);

try {
DeepLTranslateResponse response = deeplRestTemplate.postForObject(
baseUrl + "/v2/translate", request, DeepLTranslateResponse.class);

if (response == null || response.translations().isEmpty()) {
throw new BaseException(TRANSLATION_FAILED);
}
return response.translations().get(0).text();
} catch (HttpClientErrorException | HttpServerErrorException e) {
int statusCode = e.getStatusCode().value();
if (statusCode == 429 || statusCode == QUOTA_EXCEEDED_STATUS) {
throw new BaseException(TRANSLATION_QUOTA_EXCEEDED);
}
throw new BaseException(TRANSLATION_FAILED);
} catch (ResourceAccessException e) {
throw new BaseException(TRANSLATION_TIMEOUT);
} catch (RestClientException e) {
throw new BaseException(TRANSLATION_FAILED);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@
import ssu.eatssu.domain.review.dto.MenuReviewsV2Response;
import ssu.eatssu.domain.review.dto.RestaurantReviewResponse;
import ssu.eatssu.domain.review.dto.ReviewDetail;
import ssu.eatssu.domain.review.dto.ReviewTranslationResponse;
import ssu.eatssu.domain.review.dto.UpdateMealReviewRequest;
import ssu.eatssu.domain.review.dto.ValidMenuForViewResponse;
import ssu.eatssu.domain.review.service.ReviewServiceV2;
import ssu.eatssu.domain.review.service.ReviewTranslationService;
import ssu.eatssu.domain.slice.dto.SliceResponse;
import ssu.eatssu.domain.user.dto.MyMealReviewResponse;
import ssu.eatssu.domain.user.entity.Language;
import ssu.eatssu.global.handler.response.BaseResponse;

@RestController
Expand All @@ -47,6 +50,7 @@
@Tag(name = "Review V2", description = "리뷰 V2 API")
public class ReviewControllerV2 {
private final ReviewServiceV2 reviewServiceV2;
private final ReviewTranslationService reviewTranslationService;

@Operation(summary = "meal(식단)에 대한 리뷰 작성", description = "리뷰를 작성하는 API 입니다.")
@ApiResponses(value = {
Expand Down Expand Up @@ -139,6 +143,25 @@ public BaseResponse<?> deleteReview(
return BaseResponse.success();
}

@Operation(summary = "리뷰 번역", description = """
리뷰 내용을 DeepL로 번역하는 API 입니다.<br><br>
같은 리뷰/언어 조합은 캐시된 결과를 반환합니다.<br><br>
현재는 language=EN만 지원합니다.
""")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "리뷰 번역 성공"),
@ApiResponse(responseCode = "400", description = "지원하지 않는 언어이거나 번역할 내용이 없음", content = @Content(schema = @Schema(implementation = BaseResponse.class))),
@ApiResponse(responseCode = "404", description = "존재하지 않는 리뷰", content = @Content(schema = @Schema(implementation = BaseResponse.class))),
@ApiResponse(responseCode = "429", description = "번역 API 사용량 한도 초과", content = @Content(schema = @Schema(implementation = BaseResponse.class))),
@ApiResponse(responseCode = "503", description = "번역 시간 초과 또는 실패", content = @Content(schema = @Schema(implementation = BaseResponse.class)))
})
@PostMapping("/{reviewId}/translate")
public BaseResponse<ReviewTranslationResponse> translateReview(
@Parameter(description = "reviewId") @PathVariable("reviewId") Long reviewId,
@Parameter(description = "번역 대상 언어(현재 EN만 지원)") @RequestParam Language language) {
return BaseResponse.success(reviewTranslationService.translateReview(reviewId, language));
}
Comment on lines +158 to +163

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[작성자: 리뷰어, 날짜: 2025-05-15] 컨트롤러 메서드에서 사용되지 않는 @AuthenticationPrincipal CustomUserDetails 매개변수는 제거하여 코드를 깔끔하게 유지하는 것이 좋습니다.

    @PostMapping("/{reviewId}/translate")
    public BaseResponse<ReviewTranslationResponse> translateReview(
            @Parameter(description = "reviewId") @PathVariable("reviewId") Long reviewId,
            @Parameter(description = "번역 대상 언어(현재 EN만 지원)") @RequestParam Language language) {
        return BaseResponse.success(reviewTranslationService.translateReview(reviewId, language));
    }


@Operation(summary = "식단(변동 메뉴) 리뷰 정보 조회 V2(메뉴명, 평점 등등) [인증 토큰 필요 X]", description = """
식단 리뷰 정보를 조회하는 API 입니다.<br><br>
메뉴명 리스트, 리뷰 수, 메인 평점, 좋아요 개수, 싫어요 개수, 각 평점의 개수를 조회합니다.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package ssu.eatssu.domain.review.repository;

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import ssu.eatssu.domain.review.entity.ReviewTranslation;
import ssu.eatssu.domain.user.entity.Language;

public interface ReviewTranslationRepository extends JpaRepository<ReviewTranslation, Long> {

Optional<ReviewTranslation> findByReview_IdAndLanguage(Long reviewId, Language language);

void deleteAllByReview_Id(Long reviewId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@
import ssu.eatssu.domain.review.entity.ReviewImage;
import ssu.eatssu.domain.review.repository.ReviewImageRepository;
import ssu.eatssu.domain.review.repository.ReviewRepository;
import ssu.eatssu.domain.review.repository.ReviewTranslationRepository;
import ssu.eatssu.domain.user.entity.User;
import ssu.eatssu.domain.user.repository.UserRepository;
import ssu.eatssu.global.handler.response.BaseException;
import ssu.eatssu.global.util.S3Uploader;

import java.io.IOException;
import java.util.List;
import java.util.Objects;

import static ssu.eatssu.global.handler.response.BaseResponseStatus.FAIL_IMAGE_UPLOAD;
import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_MEAL;
Expand All @@ -47,6 +49,7 @@ public class ReviewService {
private final UserRepository userRepository;
private final ReviewRepository reviewRepository;
private final ReviewImageRepository reviewImageRepository;
private final ReviewTranslationRepository reviewTranslationRepository;
private final MenuRepository menuRepository;
private final MealRepository mealRepository;
private final RatingCalculator ratingCalculator;
Expand Down Expand Up @@ -132,7 +135,12 @@ public void updateReview(CustomUserDetails userDetails, Long reviewId,
throw new BaseException(REVIEW_PERMISSION_DENIED);
}

String oldContent = review.getContent();
review.update(request.content(), request.mainRating(),request.amountRating(),request.tasteRating());

if (!Objects.equals(oldContent, request.content())) {
reviewTranslationRepository.deleteAllByReview_Id(reviewId);
}
}

public void deleteReview(CustomUserDetails userDetails, Long reviewId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import ssu.eatssu.domain.review.dto.ValidMenuForViewResponse;
import ssu.eatssu.domain.review.entity.Review;
import ssu.eatssu.domain.review.repository.ReviewRepository;
import ssu.eatssu.domain.review.repository.ReviewTranslationRepository;
import ssu.eatssu.domain.review.utils.MenuFilterUtil;
import ssu.eatssu.domain.slice.dto.SliceResponse;
import ssu.eatssu.domain.user.dto.MyMealReviewResponse;
Expand Down Expand Up @@ -60,6 +61,7 @@
public class ReviewServiceV2 {
private final UserRepository userRepository;
private final ReviewRepository reviewRepository;
private final ReviewTranslationRepository reviewTranslationRepository;
private final MenuRepository menuRepository;
private final MealRepository mealRepository;
private final MealMenuRepository mealMenuRepository;
Expand Down Expand Up @@ -415,9 +417,14 @@ public void updateReview(CustomUserDetails userDetails, Long reviewId, UpdateMea
},
menuLike -> Boolean.TRUE.equals(menuLike.getIsLike())));

String oldContent = review.getContent();
review.update(request.getContent(), request.getRating(), menuLikes);
reviewRepository.save(review);

if (!Objects.equals(oldContent, request.getContent())) {
reviewTranslationRepository.deleteAllByReview_Id(reviewId);
}

eventPublisher.publishEvent(LogEvent.of(
String.format("Review updated: reviewId=%d, userId=%d, newRating=%d",
review.getId(), user.getId(), request.getRating())
Expand Down
Loading
Loading