diff --git a/.gitignore b/.gitignore index a34550ea..6cd5667b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ src/main/resources/.env ### Querydsl ### src/main/generated/ + +### Claude Code ### +CLAUDE.md \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7517d6dd..471062fd 100644 --- a/build.gradle +++ b/build.gradle @@ -88,6 +88,7 @@ dependencies { // Prometheus implementation 'io.micrometer:micrometer-registry-prometheus' + } // Q클래스 생성 경로 설정 diff --git a/src/main/java/org/sopt/kareer/domain/member/controller/MemberController.java b/src/main/java/org/sopt/kareer/domain/member/controller/MemberController.java index 150511b5..affca4c5 100644 --- a/src/main/java/org/sopt/kareer/domain/member/controller/MemberController.java +++ b/src/main/java/org/sopt/kareer/domain/member/controller/MemberController.java @@ -1,29 +1,34 @@ package org.sopt.kareer.domain.member.controller; -import static org.sopt.kareer.global.config.swagger.SwaggerResponseDescription.*; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.sopt.kareer.domain.member.dto.request.MemberOnboardRequest; import org.sopt.kareer.domain.member.dto.request.MemberTermsRequest; import org.sopt.kareer.domain.member.dto.request.MypageRequest; import org.sopt.kareer.domain.member.dto.response.*; -import org.sopt.kareer.domain.member.service.*; +import org.sopt.kareer.domain.member.service.LocalizedOnboardQueryService; +import org.sopt.kareer.domain.member.service.MemberService; import org.sopt.kareer.domain.roadmap.dto.response.RoadmapTestResponse; -import org.sopt.kareer.domain.roadmap.service.*; +import org.sopt.kareer.domain.roadmap.service.RoadMapService; +import org.sopt.kareer.domain.roadmap.service.RoadmapTranslationService; import org.sopt.kareer.global.annotation.CustomExceptionDescription; import org.sopt.kareer.global.auth.service.AuthService; import org.sopt.kareer.global.config.swagger.SwaggerResponseDescription; import org.sopt.kareer.global.response.BaseResponse; -import org.springframework.http.*; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import static org.sopt.kareer.global.config.swagger.SwaggerResponseDescription.*; + @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/members") @@ -32,7 +37,7 @@ public class MemberController { private final MemberService memberService; private final RoadMapService roadMapService; - private final RoadmapAsyncService roadmapAsyncService; + private final RoadmapTranslationService roadmapTranslationService; private final AuthService authService; private final LocalizedOnboardQueryService localizedOnboardQueryService; @@ -97,7 +102,9 @@ public ResponseEntity> getOnboardFields() { public ResponseEntity> generateRoadmap( @AuthenticationPrincipal Long memberId) { - roadMapService.createRoadmap(memberId); + var target = roadMapService.createRoadmap(memberId); + + roadmapTranslationService.translateAllLanguages(target); return ResponseEntity.status(HttpStatus.OK) .body(BaseResponse.ok("AI 로드맵 생성에 성공하였습니다.")); diff --git a/src/main/java/org/sopt/kareer/domain/member/entity/enums/LocalizedOnboardCategoryType.java b/src/main/java/org/sopt/kareer/domain/member/entity/enums/LocalizedOnboardCategoryType.java index 871a9168..1dd2e41e 100644 --- a/src/main/java/org/sopt/kareer/domain/member/entity/enums/LocalizedOnboardCategoryType.java +++ b/src/main/java/org/sopt/kareer/domain/member/entity/enums/LocalizedOnboardCategoryType.java @@ -1,6 +1,6 @@ package org.sopt.kareer.domain.member.entity.enums; -public enum LocalizedOnboardCategoryType { +public enum LocalizedOnboardCategoryType { FIELD, MAJOR, UNIVERSITY, diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/dto/translation/RoadmapTranslationTarget.java b/src/main/java/org/sopt/kareer/domain/roadmap/dto/translation/RoadmapTranslationTarget.java new file mode 100644 index 00000000..81bbec25 --- /dev/null +++ b/src/main/java/org/sopt/kareer/domain/roadmap/dto/translation/RoadmapTranslationTarget.java @@ -0,0 +1,29 @@ +package org.sopt.kareer.domain.roadmap.dto.translation; + +import java.util.List; + +public record RoadmapTranslationTarget(List phases) { + + public record PhaseTarget( + Long phaseId, + String goal, + String description, + List actions + ) {} + + public record PhaseActionTarget( + Long phaseActionId, + String title, + String description, + String importance, + List guidelines, + List mistakes, + List actionItems + ) {} + + public record GuidelineTarget(Long guidelineId, String content) {} + + public record MistakeTarget(Long mistakeId, String content) {} + + public record ActionItemTarget(Long actionItemId, String title) {} +} diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/entity/ActionItemTranslation.java b/src/main/java/org/sopt/kareer/domain/roadmap/entity/ActionItemTranslation.java new file mode 100644 index 00000000..b6043882 --- /dev/null +++ b/src/main/java/org/sopt/kareer/domain/roadmap/entity/ActionItemTranslation.java @@ -0,0 +1,35 @@ +package org.sopt.kareer.domain.roadmap.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "action_item_translations", + uniqueConstraints = @UniqueConstraint(columnNames = {"action_item_id", "language"})) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ActionItemTranslation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "action_item_id", nullable = false) + private ActionItem actionItem; + + @Column(nullable = false, length = 10) + private String language; + + private String title; + + public static ActionItemTranslation create(ActionItem actionItem, String language, String title) { + return ActionItemTranslation.builder() + .actionItem(actionItem) + .language(language) + .title(title) + .build(); + } +} diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/entity/PhaseActionGuidelineTranslation.java b/src/main/java/org/sopt/kareer/domain/roadmap/entity/PhaseActionGuidelineTranslation.java new file mode 100644 index 00000000..ebcea0d9 --- /dev/null +++ b/src/main/java/org/sopt/kareer/domain/roadmap/entity/PhaseActionGuidelineTranslation.java @@ -0,0 +1,36 @@ +package org.sopt.kareer.domain.roadmap.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "phase_action_guideline_translations", + uniqueConstraints = @UniqueConstraint(columnNames = {"guideline_id", "language"})) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PhaseActionGuidelineTranslation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "guideline_id", nullable = false) + private PhaseActionGuideline guideline; + + @Column(nullable = false, length = 10) + private String language; + + @Column(columnDefinition = "TEXT") + private String content; + + public static PhaseActionGuidelineTranslation create(PhaseActionGuideline guideline, String language, String content) { + return PhaseActionGuidelineTranslation.builder() + .guideline(guideline) + .language(language) + .content(content) + .build(); + } +} diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/entity/PhaseActionMistakeTranslation.java b/src/main/java/org/sopt/kareer/domain/roadmap/entity/PhaseActionMistakeTranslation.java new file mode 100644 index 00000000..d3780577 --- /dev/null +++ b/src/main/java/org/sopt/kareer/domain/roadmap/entity/PhaseActionMistakeTranslation.java @@ -0,0 +1,36 @@ +package org.sopt.kareer.domain.roadmap.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "phase_action_mistake_translations", + uniqueConstraints = @UniqueConstraint(columnNames = {"mistake_id", "language"})) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PhaseActionMistakeTranslation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mistake_id", nullable = false) + private PhaseActionMistake mistake; + + @Column(nullable = false, length = 10) + private String language; + + @Column(columnDefinition = "TEXT") + private String content; + + public static PhaseActionMistakeTranslation create(PhaseActionMistake mistake, String language, String content) { + return PhaseActionMistakeTranslation.builder() + .mistake(mistake) + .language(language) + .content(content) + .build(); + } +} diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/entity/PhaseActionTranslation.java b/src/main/java/org/sopt/kareer/domain/roadmap/entity/PhaseActionTranslation.java new file mode 100644 index 00000000..1b8ae559 --- /dev/null +++ b/src/main/java/org/sopt/kareer/domain/roadmap/entity/PhaseActionTranslation.java @@ -0,0 +1,44 @@ +package org.sopt.kareer.domain.roadmap.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "phase_action_translations", + uniqueConstraints = @UniqueConstraint(columnNames = {"phase_actions_id", "language"})) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PhaseActionTranslation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "phase_actions_id", nullable = false) + private PhaseAction phaseAction; + + @Column(nullable = false, length = 10) + private String language; + + private String title; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(columnDefinition = "TEXT") + private String importance; + + public static PhaseActionTranslation create(PhaseAction phaseAction, String language, + String title, String description, String importance) { + return PhaseActionTranslation.builder() + .phaseAction(phaseAction) + .language(language) + .title(title) + .description(description) + .importance(importance) + .build(); + } +} diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/entity/PhaseTranslation.java b/src/main/java/org/sopt/kareer/domain/roadmap/entity/PhaseTranslation.java new file mode 100644 index 00000000..c95eb707 --- /dev/null +++ b/src/main/java/org/sopt/kareer/domain/roadmap/entity/PhaseTranslation.java @@ -0,0 +1,40 @@ +package org.sopt.kareer.domain.roadmap.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "phase_translations", + uniqueConstraints = @UniqueConstraint(columnNames = {"phase_id", "language"})) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PhaseTranslation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "phase_id", nullable = false) + private Phase phase; + + @Column(nullable = false, length = 10) + private String language; + + @Column(nullable = false) + private String goal; + + @Column(nullable = false, columnDefinition = "TEXT") + private String description; + + public static PhaseTranslation create(Phase phase, String language, String goal, String description) { + return PhaseTranslation.builder() + .phase(phase) + .language(language) + .goal(goal) + .description(description) + .build(); + } +} diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/repository/ActionItemTranslationRepository.java b/src/main/java/org/sopt/kareer/domain/roadmap/repository/ActionItemTranslationRepository.java new file mode 100644 index 00000000..14ef157f --- /dev/null +++ b/src/main/java/org/sopt/kareer/domain/roadmap/repository/ActionItemTranslationRepository.java @@ -0,0 +1,14 @@ +package org.sopt.kareer.domain.roadmap.repository; + +import org.sopt.kareer.domain.roadmap.entity.ActionItemTranslation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ActionItemTranslationRepository extends JpaRepository { + + List findAllByActionItem_IdInAndLanguage(List actionItemIds, String language); + + void deleteAllByActionItem_IdInAndLanguage(List actionItemIds, String language); + +} diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/repository/PhaseActionGuidelineTranslationRepository.java b/src/main/java/org/sopt/kareer/domain/roadmap/repository/PhaseActionGuidelineTranslationRepository.java new file mode 100644 index 00000000..bcbcc2c6 --- /dev/null +++ b/src/main/java/org/sopt/kareer/domain/roadmap/repository/PhaseActionGuidelineTranslationRepository.java @@ -0,0 +1,26 @@ +package org.sopt.kareer.domain.roadmap.repository; + +import org.sopt.kareer.domain.roadmap.entity.PhaseActionGuidelineTranslation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface PhaseActionGuidelineTranslationRepository extends JpaRepository { + + List findAllByGuideline_PhaseAction_IdIn(List phaseActionIds); + + @Query(""" + SELECT t.content + FROM PhaseActionGuidelineTranslation t + WHERE t.guideline.phaseAction.id = :phaseActionId + AND t.language = :language + ORDER BY t.guideline.id ASC + """) + List findContentByPhaseActionIdAndLanguage(@Param("phaseActionId") Long phaseActionId, + @Param("language") String language); + + void deleteAllByGuideline_PhaseAction_IdInAndLanguage(List phaseActionIds, String language); + +} diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/repository/PhaseActionMistakeTranslationRepository.java b/src/main/java/org/sopt/kareer/domain/roadmap/repository/PhaseActionMistakeTranslationRepository.java new file mode 100644 index 00000000..f7295073 --- /dev/null +++ b/src/main/java/org/sopt/kareer/domain/roadmap/repository/PhaseActionMistakeTranslationRepository.java @@ -0,0 +1,26 @@ +package org.sopt.kareer.domain.roadmap.repository; + +import org.sopt.kareer.domain.roadmap.entity.PhaseActionMistakeTranslation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface PhaseActionMistakeTranslationRepository extends JpaRepository { + + List findAllByMistake_PhaseAction_IdIn(List phaseActionIds); + + @Query(""" + SELECT t.content + FROM PhaseActionMistakeTranslation t + WHERE t.mistake.phaseAction.id = :phaseActionId + AND t.language = :language + ORDER BY t.mistake.id ASC + """) + List findContentByPhaseActionIdAndLanguage(@Param("phaseActionId") Long phaseActionId, + @Param("language") String language); + + void deleteAllByMistake_PhaseAction_IdInAndLanguage(List phaseActionIds, String language); + +} diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/repository/PhaseActionTranslationRepository.java b/src/main/java/org/sopt/kareer/domain/roadmap/repository/PhaseActionTranslationRepository.java new file mode 100644 index 00000000..ddf20353 --- /dev/null +++ b/src/main/java/org/sopt/kareer/domain/roadmap/repository/PhaseActionTranslationRepository.java @@ -0,0 +1,16 @@ +package org.sopt.kareer.domain.roadmap.repository; + +import org.sopt.kareer.domain.roadmap.entity.PhaseActionTranslation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PhaseActionTranslationRepository extends JpaRepository { + + List findAllByPhaseAction_IdInAndLanguage(List phaseActionIds, String language); + + Optional findByPhaseAction_IdAndLanguage(Long phaseActionId, String language); + + void deleteAllByPhaseAction_IdInAndLanguage(List phaseActionIds, String language); +} diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/repository/PhaseTranslationRepository.java b/src/main/java/org/sopt/kareer/domain/roadmap/repository/PhaseTranslationRepository.java new file mode 100644 index 00000000..a0672d7a --- /dev/null +++ b/src/main/java/org/sopt/kareer/domain/roadmap/repository/PhaseTranslationRepository.java @@ -0,0 +1,14 @@ +package org.sopt.kareer.domain.roadmap.repository; + +import org.sopt.kareer.domain.roadmap.entity.PhaseTranslation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PhaseTranslationRepository extends JpaRepository { + + List findAllByPhase_IdInAndLanguage(List phaseIds, String language); + + void deleteAllByPhase_IdInAndLanguage(List phaseIds, String language); + +} diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/service/ActionItemService.java b/src/main/java/org/sopt/kareer/domain/roadmap/service/ActionItemService.java index f29850c2..505fd3ea 100644 --- a/src/main/java/org/sopt/kareer/domain/roadmap/service/ActionItemService.java +++ b/src/main/java/org/sopt/kareer/domain/roadmap/service/ActionItemService.java @@ -2,15 +2,20 @@ import lombok.RequiredArgsConstructor; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import org.sopt.kareer.domain.roadmap.dto.response.ActionItemListResponse; import org.sopt.kareer.domain.roadmap.dto.response.ActionItemResponse; import org.sopt.kareer.domain.roadmap.entity.ActionItem; +import org.sopt.kareer.domain.roadmap.entity.ActionItemTranslation; import org.sopt.kareer.domain.roadmap.entity.PhaseAction; import org.sopt.kareer.domain.roadmap.entity.enums.ActionItemStatus; import org.sopt.kareer.domain.roadmap.entity.enums.ActionItemType; import org.sopt.kareer.domain.roadmap.exception.RoadMapException; import org.sopt.kareer.domain.roadmap.exception.RoadmapErrorCode; import org.sopt.kareer.domain.roadmap.repository.ActionItemRepository; +import org.sopt.kareer.domain.roadmap.repository.ActionItemTranslationRepository; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,6 +25,7 @@ public class ActionItemService { private final ActionItemRepository actionItemRepository; + private final ActionItemTranslationRepository actionItemTranslationRepository; @Transactional public void toggleCompletion(Long memberId, Long actionItemId) { @@ -53,16 +59,32 @@ public ActionItemListResponse getAllActionItems(Long memberId) { List activeActionItems = actionItemRepository .findAllByMemberIdAndStatus(memberId, ActionItemStatus.ACTIVE); + String language = LocaleContextHolder.getLocale().toLanguageTag(); + List itemIds = activeActionItems.stream().map(ActionItem::getId).toList(); + Map translationMap = actionItemTranslationRepository + .findAllByActionItem_IdInAndLanguage(itemIds, language) + .stream() + .collect(Collectors.toMap(t -> t.getActionItem().getId(), t -> t)); + + final Map finalTranslationMap = translationMap; + List visaActionItems = activeActionItems.stream() - .filter(actionItem -> actionItem.getActionsType() == ActionItemType.VISA) - .map(ActionItemResponse::from) + .filter(item -> item.getActionsType() == ActionItemType.VISA) + .map(item -> toResponse(item, finalTranslationMap)) .toList(); List careerActionItems = activeActionItems.stream() - .filter(actionItem -> actionItem.getActionsType() == ActionItemType.CAREER) - .map(ActionItemResponse::from) + .filter(item -> item.getActionsType() == ActionItemType.CAREER) + .map(item -> toResponse(item, finalTranslationMap)) .toList(); return new ActionItemListResponse(visaActionItems, careerActionItems); } + + private ActionItemResponse toResponse(ActionItem item, Map translationMap) { + ActionItemTranslation t = translationMap.get(item.getId()); + String title = (t != null && t.getTitle() != null) ? t.getTitle() : item.getTitle(); + return new ActionItemResponse(item.getId(), title, item.getDeadline(), + Boolean.TRUE.equals(item.getCompleted())); + } } diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/service/PhaseActionService.java b/src/main/java/org/sopt/kareer/domain/roadmap/service/PhaseActionService.java index 24f00d39..9199ea3d 100644 --- a/src/main/java/org/sopt/kareer/domain/roadmap/service/PhaseActionService.java +++ b/src/main/java/org/sopt/kareer/domain/roadmap/service/PhaseActionService.java @@ -5,12 +5,17 @@ import org.sopt.kareer.domain.roadmap.dto.response.AiGuideResponse; import org.sopt.kareer.domain.roadmap.entity.ActionItem; import org.sopt.kareer.domain.roadmap.entity.PhaseAction; +import org.sopt.kareer.domain.roadmap.entity.PhaseActionTranslation; import org.sopt.kareer.domain.roadmap.exception.RoadMapException; import org.sopt.kareer.domain.roadmap.exception.RoadmapErrorCode; import org.sopt.kareer.domain.roadmap.repository.ActionItemRepository; import org.sopt.kareer.domain.roadmap.repository.PhaseActionGuidelineRepository; +import org.sopt.kareer.domain.roadmap.repository.PhaseActionGuidelineTranslationRepository; import org.sopt.kareer.domain.roadmap.repository.PhaseActionMistakeRepository; +import org.sopt.kareer.domain.roadmap.repository.PhaseActionMistakeTranslationRepository; import org.sopt.kareer.domain.roadmap.repository.PhaseActionRepository; +import org.sopt.kareer.domain.roadmap.repository.PhaseActionTranslationRepository; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,13 +28,16 @@ public class PhaseActionService { private final ActionItemRepository actionItemRepository; private final PhaseActionMistakeRepository mistakeRepository; private final PhaseActionGuidelineRepository guidelineRepository; + private final PhaseActionTranslationRepository phaseActionTranslationRepository; + private final PhaseActionGuidelineTranslationRepository guidelineTranslationRepository; + private final PhaseActionMistakeTranslationRepository mistakeTranslationRepository; @Transactional public void createPhaseActionTodo(Long memberId, Long phaseActionId) { PhaseAction phaseAction = phaseActionRepository.findByIdAndMemberId(phaseActionId, memberId) .orElseThrow(() -> new RoadMapException(RoadmapErrorCode.PHASE_ACTION_NOT_FOUND)); - if(phaseAction.getAdded()){ + if (phaseAction.getAdded()) { throw new RoadMapException(RoadmapErrorCode.TODO_ALREADY_ADDED); } @@ -46,9 +54,24 @@ public AiGuideResponse getAiGuide(Long memberId, Long phaseActionId) { PhaseAction phaseAction = phaseActionRepository.findByIdAndMemberId(phaseActionId, memberId) .orElseThrow(() -> new RoadMapException(RoadmapErrorCode.PHASE_ACTION_NOT_FOUND)); - List mistakes = mistakeRepository.findContentByPhaseActionId(phaseActionId); + String importance = phaseAction.getImportance(); List guidelines = guidelineRepository.findContentByPhaseActionId(phaseActionId); + List mistakes = mistakeRepository.findContentByPhaseActionId(phaseActionId); + + String language = LocaleContextHolder.getLocale().toLanguageTag(); + importance = phaseActionTranslationRepository.findByPhaseAction_IdAndLanguage(phaseActionId, language) + .filter(t -> t.getImportance() != null) + .map(PhaseActionTranslation::getImportance) + .orElse(importance); + + List translatedGuidelines = + guidelineTranslationRepository.findContentByPhaseActionIdAndLanguage(phaseActionId, language); + if (!translatedGuidelines.isEmpty()) guidelines = translatedGuidelines; + + List translatedMistakes = + mistakeTranslationRepository.findContentByPhaseActionIdAndLanguage(phaseActionId, language); + if (!translatedMistakes.isEmpty()) mistakes = translatedMistakes; - return AiGuideResponse.from(phaseAction, mistakes, guidelines); + return new AiGuideResponse(importance, mistakes, guidelines); } } diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/service/PhaseService.java b/src/main/java/org/sopt/kareer/domain/roadmap/service/PhaseService.java index 8a87c4b2..433bf631 100644 --- a/src/main/java/org/sopt/kareer/domain/roadmap/service/PhaseService.java +++ b/src/main/java/org/sopt/kareer/domain/roadmap/service/PhaseService.java @@ -4,11 +4,17 @@ import org.sopt.kareer.domain.roadmap.dto.response.*; import org.sopt.kareer.domain.roadmap.dto.response.PhaseResponse; import org.sopt.kareer.domain.roadmap.dto.response.PhaseListResponse; +import org.sopt.kareer.domain.roadmap.entity.PhaseAction; +import org.sopt.kareer.domain.roadmap.entity.PhaseActionTranslation; +import org.sopt.kareer.domain.roadmap.entity.PhaseTranslation; import org.sopt.kareer.domain.roadmap.exception.RoadMapException; import org.sopt.kareer.domain.roadmap.exception.RoadmapErrorCode; import org.sopt.kareer.domain.roadmap.repository.PhaseActionRepository; +import org.sopt.kareer.domain.roadmap.repository.PhaseActionTranslationRepository; import org.sopt.kareer.domain.roadmap.dto.response.RoadmapPhaseDetailResponse; import org.sopt.kareer.domain.roadmap.repository.PhaseRepository; +import org.sopt.kareer.domain.roadmap.repository.PhaseTranslationRepository; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,6 +22,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -24,9 +31,30 @@ public class PhaseService { private final PhaseRepository phaseRepository; private final PhaseActionRepository phaseActionRepository; + private final PhaseTranslationRepository phaseTranslationRepository; + private final PhaseActionTranslationRepository phaseActionTranslationRepository; public PhaseListResponse getPhases(Long memberId) { - List responses= phaseRepository.findPhases(memberId); + List responses = phaseRepository.findPhases(memberId); + + String language = currentLanguage(); + List phaseIds = responses.stream().map(PhaseResponse::phaseId).toList(); + Map translationMap = phaseTranslationRepository + .findAllByPhase_IdInAndLanguage(phaseIds, language) + .stream() + .collect(Collectors.toMap(t -> t.getPhase().getId(), t -> t)); + + if (!translationMap.isEmpty()) { + responses = responses.stream() + .map(r -> { + PhaseTranslation t = translationMap.get(r.phaseId()); + if (t == null) return r; + return new PhaseResponse(r.phaseId(), r.phaseStatus(), r.sequence(), + t.getGoal(), t.getDescription(), r.workStatus(), r.worksCount(), + r.startDate(), r.endDate()); + }) + .toList(); + } return PhaseListResponse.from(responses); } @@ -38,19 +66,43 @@ public RoadmapPhaseDetailResponse getRoadmapPhaseDetail(Long memberId, Long phas Map> raw = phaseRepository.getRoadmapPhaseDetail(phaseId); - // Visa -> Career -> Done 순서로 순서 고정 + String language = currentLanguage(); + List actionIds = raw.values().stream() + .flatMap(List::stream) + .map(RoadmapPhaseDetailResponse.ActionGroupResponse.ActionResponse::phaseActionId) + .toList(); + Map translationMap = phaseActionTranslationRepository + .findAllByPhaseAction_IdInAndLanguage(actionIds, language) + .stream() + .collect(Collectors.toMap(t -> t.getPhaseAction().getId(), t -> t)); + + if (!translationMap.isEmpty()) { + raw = raw.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> e.getValue().stream() + .map(r -> { + PhaseActionTranslation t = translationMap.get(r.phaseActionId()); + if (t == null || t.getTitle() == null) return r; + return new RoadmapPhaseDetailResponse.ActionGroupResponse.ActionResponse( + r.phaseActionId(), t.getTitle(), t.getDescription(), + r.deadline(), r.added()); + }) + .toList(), + (a, b) -> a, + LinkedHashMap::new + )); + } + Map actions = new LinkedHashMap<>(); actions.put("Visa", wrap(raw.get("Visa"))); actions.put("Career", wrap(raw.get("Career"))); actions.put("Done", wrap(raw.get("Done"))); - // 전체 item 수 long totalCount = raw.values().stream().mapToLong(List::size).sum(); - return RoadmapPhaseDetailResponse.from(totalCount, actions); } - // items가 없는 경우 빈 리스트를 넣어주고, item 수를 count해주는 메서드 private RoadmapPhaseDetailResponse.ActionGroupResponse wrap( List items ) { @@ -69,12 +121,31 @@ public HomePhaseDetailResponse getHomePhaseDetail(Long memberId, Long phaseId) { throw new RoadMapException(RoadmapErrorCode.PHASE_NOT_FOUND); } - List actionResponses = - phaseActionRepository.findByPhaseIdAndCompletedIsFalse(phaseId) - .stream() - .map(HomePhaseDetailResponse.HomePhaseActionResponse::from) - .toList(); + List phaseActions = phaseActionRepository.findByPhaseIdAndCompletedIsFalse(phaseId); + + String language = currentLanguage(); + List actionIds = phaseActions.stream().map(PhaseAction::getId).toList(); + Map translationMap = phaseActionTranslationRepository + .findAllByPhaseAction_IdInAndLanguage(actionIds, language) + .stream() + .collect(Collectors.toMap(t -> t.getPhaseAction().getId(), t -> t)); + + final Map finalTranslationMap = translationMap; + List actionResponses = phaseActions.stream() + .map(pa -> { + PhaseActionTranslation t = finalTranslationMap.get(pa.getId()); + if (t != null && t.getTitle() != null) { + return new HomePhaseDetailResponse.HomePhaseActionResponse( + pa.getId(), pa.getType().getDisplayName(), t.getTitle(), pa.getDeadline()); + } + return HomePhaseDetailResponse.HomePhaseActionResponse.from(pa); + }) + .toList(); return HomePhaseDetailResponse.from(actionResponses); } + + private String currentLanguage() { + return LocaleContextHolder.getLocale().toLanguageTag(); + } } diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadMapPersistService.java b/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadMapPersistService.java index 563df523..83045ca9 100644 --- a/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadMapPersistService.java +++ b/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadMapPersistService.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.sopt.kareer.domain.member.entity.Member; import org.sopt.kareer.domain.roadmap.dto.response.RoadmapResponse; +import org.sopt.kareer.domain.roadmap.dto.translation.RoadmapTranslationTarget; import org.sopt.kareer.domain.roadmap.entity.*; import org.sopt.kareer.domain.roadmap.entity.enums.ActionItemType; import org.sopt.kareer.domain.roadmap.entity.enums.PhaseActionType; @@ -14,7 +15,9 @@ import java.time.LocalDate; import java.time.format.DateTimeParseException; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Optional; import static org.sopt.kareer.domain.roadmap.exception.RoadmapErrorCode.INVALID_DATE_TYPE; @@ -30,10 +33,11 @@ public class RoadMapPersistService { private final ActionItemRepository actionItemRepository; @Transactional - public void saveRoadMap(Member member, RoadmapResponse response) { + public RoadmapTranslationTarget saveRoadMap(Member member, RoadmapResponse response) { + List phaseTargets = new ArrayList<>(); - for(RoadmapResponse.PhasePlan phasePlan : Optional.ofNullable(response.phases()).orElse(Collections.emptyList())){ - Phase phase = Phase.create( + for (RoadmapResponse.PhasePlan phasePlan : Optional.ofNullable(response.phases()).orElse(Collections.emptyList())) { + Phase savedPhase = phaseRepository.save(Phase.create( member, phasePlan.sequence(), phasePlan.goal(), @@ -41,44 +45,57 @@ public void saveRoadMap(Member member, RoadmapResponse response) { PhaseStatus.from(phasePlan.status()), parseDate(phasePlan.startDate()), parseDate(phasePlan.endDate()) - ); - Phase savedPhase = phaseRepository.save(phase); + )); - for(RoadmapResponse.PhaseActionPlan phaseActionPlan : Optional.ofNullable(phasePlan.actions()).orElse(Collections.emptyList())){ - PhaseAction phaseAction = PhaseAction.create( + List actionTargets = new ArrayList<>(); + + for (RoadmapResponse.PhaseActionPlan phaseActionPlan : Optional.ofNullable(phasePlan.actions()).orElse(Collections.emptyList())) { + PhaseAction savedPhaseAction = phaseActionRepository.save(PhaseAction.create( phaseActionPlan.title(), phaseActionPlan.description(), PhaseActionType.from(phaseActionPlan.type()), parseDate(phaseActionPlan.deadline()), phaseActionPlan.importance(), savedPhase - ); - PhaseAction savedPhaseAction = phaseActionRepository.save(phaseAction); + )); - for(String g : Optional.ofNullable(phaseActionPlan.guideline()).orElse(Collections.emptyList())){ - PhaseActionGuideline phaseActionGuideline = PhaseActionGuideline.create(g, savedPhaseAction); - phaseActionGuidelineRepository.save(phaseActionGuideline); + List guidelineTargets = new ArrayList<>(); + for (String g : Optional.ofNullable(phaseActionPlan.guideline()).orElse(Collections.emptyList())) { + PhaseActionGuideline saved = phaseActionGuidelineRepository.save(PhaseActionGuideline.create(g, savedPhaseAction)); + guidelineTargets.add(new RoadmapTranslationTarget.GuidelineTarget(saved.getId(), g)); } - for(String c : Optional.ofNullable(phaseActionPlan.commonMistakes()).orElse(Collections.emptyList())){ - PhaseActionMistake phaseActionMistake = PhaseActionMistake.create(c, savedPhaseAction); - phaseActionMistakeRepository.save(phaseActionMistake); + List mistakeTargets = new ArrayList<>(); + for (String c : Optional.ofNullable(phaseActionPlan.commonMistakes()).orElse(Collections.emptyList())) { + PhaseActionMistake saved = phaseActionMistakeRepository.save(PhaseActionMistake.create(c, savedPhaseAction)); + mistakeTargets.add(new RoadmapTranslationTarget.MistakeTarget(saved.getId(), c)); } - for(RoadmapResponse.ActionItemPlan actionItemPlan : Optional.ofNullable(phaseActionPlan.actionItems()).orElse(Collections.emptyList())){ - ActionItem actionItem = ActionItem.create( + List actionItemTargets = new ArrayList<>(); + for (RoadmapResponse.ActionItemPlan actionItemPlan : Optional.ofNullable(phaseActionPlan.actionItems()).orElse(Collections.emptyList())) { + ActionItem saved = actionItemRepository.save(ActionItem.create( actionItemPlan.title(), ActionItemType.from(actionItemPlan.actionsType()), parseDate(actionItemPlan.deadline()), member, savedPhaseAction - ); - actionItemRepository.save(actionItem); + )); + actionItemTargets.add(new RoadmapTranslationTarget.ActionItemTarget(saved.getId(), actionItemPlan.title())); } + actionTargets.add(new RoadmapTranslationTarget.PhaseActionTarget( + savedPhaseAction.getId(), + phaseActionPlan.title(), + phaseActionPlan.description(), + phaseActionPlan.importance(), + guidelineTargets, mistakeTargets, actionItemTargets)); } + phaseTargets.add(new RoadmapTranslationTarget.PhaseTarget( + savedPhase.getId(), phasePlan.goal(), phasePlan.description(), actionTargets)); } + + return new RoadmapTranslationTarget(phaseTargets); } private LocalDate parseDate(String date) { diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadMapService.java b/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadMapService.java index 0a0a9f9c..f25fa41a 100644 --- a/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadMapService.java +++ b/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadMapService.java @@ -10,6 +10,7 @@ import org.sopt.kareer.domain.member.service.MemberService; import org.sopt.kareer.domain.roadmap.dto.response.RoadmapResponse; import org.sopt.kareer.domain.roadmap.dto.response.RoadmapTestResponse; +import org.sopt.kareer.domain.roadmap.dto.translation.RoadmapTranslationTarget; import org.sopt.kareer.global.external.ai.builder.context.MemberContextBuilder; import org.sopt.kareer.global.external.ai.service.OpenAiService; import org.sopt.kareer.global.external.ai.service.PolicyDocumentRetriever; @@ -35,7 +36,7 @@ public class RoadMapService { private final MemberVisaRepository memberVisaRepository; @Transactional - public void createRoadmap(Long memberId){ + public RoadmapTranslationTarget createRoadmap(Long memberId){ Member member = memberService.getById(memberId); @@ -64,14 +65,15 @@ public void createRoadmap(Long memberId){ policyDocs ); - roadMapPersistService.saveRoadMap(member, response); + RoadmapTranslationTarget target = roadMapPersistService.saveRoadMap(member, response); member.markRoadmapDone(); + + return target; } @Transactional public RoadmapTestResponse createRoadmapTest(Long memberId) { - Long startTime = System.currentTimeMillis(); Member member = memberService.getById(memberId); var memberContext = memberContextBuilder.load(memberId); @@ -98,8 +100,6 @@ public RoadmapTestResponse createRoadmapTest(Long memberId) { policyDocs ); - Long endTime = System.currentTimeMillis(); - log.info("Time : {}ms", (endTime - startTime)); List retrieved = new ArrayList<>(); retrieved.addAll(visaDocs); diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadmapAsyncService.java b/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadmapAsyncService.java deleted file mode 100644 index cbc44d71..00000000 --- a/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadmapAsyncService.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.sopt.kareer.domain.roadmap.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.sopt.kareer.domain.member.entity.Member; -import org.sopt.kareer.domain.member.exception.MemberErrorCode; -import org.sopt.kareer.domain.member.exception.MemberException; -import org.sopt.kareer.domain.member.repository.MemberRepository; -import org.sopt.kareer.domain.member.service.MemberService; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.concurrent.ExecutorService; - -@Service -@RequiredArgsConstructor -@Slf4j -public class RoadmapAsyncService { - - private final RoadMapService roadMapService; - private final ExecutorService executorService; - private final MemberService memberService; - private final MemberRepository memberRepository; - - @Transactional - public void generateRoadmapAsync(Long memberId){ - - Member member = memberService.getById(memberId); - - member.assertCanStartRoadmap(); - - int updated = memberRepository.tryMarkRoadmapInProgress(memberId); - if (updated == 0) { - throw new MemberException(MemberErrorCode.ROADMAP_IN_PROGRESS); - } - - executorService.submit(() -> { - try { - roadMapService.createRoadmap(memberId); - } catch (Exception e) { - log.error("로드맵 생성 실패, memberId = {}", memberId, e); - roadMapService.markFailed(memberId); - } - }); - - } -} diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadmapTranslationPersistService.java b/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadmapTranslationPersistService.java new file mode 100644 index 00000000..31be3f45 --- /dev/null +++ b/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadmapTranslationPersistService.java @@ -0,0 +1,78 @@ +package org.sopt.kareer.domain.roadmap.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.kareer.domain.roadmap.dto.translation.RoadmapTranslationTarget; +import org.sopt.kareer.domain.roadmap.entity.*; +import org.sopt.kareer.domain.roadmap.repository.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RoadmapTranslationPersistService { + + private final PhaseTranslationRepository phaseTranslationRepository; + private final PhaseActionTranslationRepository phaseActionTranslationRepository; + private final PhaseActionGuidelineTranslationRepository guidelineTranslationRepository; + private final PhaseActionMistakeTranslationRepository mistakeTranslationRepository; + private final ActionItemTranslationRepository actionItemTranslationRepository; + private final PhaseRepository phaseRepository; + private final PhaseActionRepository phaseActionRepository; + private final PhaseActionGuidelineRepository guidelineRepository; + private final PhaseActionMistakeRepository mistakeRepository; + private final ActionItemRepository actionItemRepository; + + @Transactional + public void saveTranslations(RoadmapTranslationTarget target, String language) { + List phaseIds = target.phases().stream() + .map(RoadmapTranslationTarget.PhaseTarget::phaseId).toList(); + List actionIds = target.phases().stream() + .flatMap(p -> p.actions().stream()) + .map(RoadmapTranslationTarget.PhaseActionTarget::phaseActionId).toList(); + List actionItemIds = target.phases().stream() + .flatMap(p -> p.actions().stream()) + .flatMap(a -> a.actionItems().stream()) + .map(RoadmapTranslationTarget.ActionItemTarget::actionItemId).toList(); + + if (!actionItemIds.isEmpty()) actionItemTranslationRepository.deleteAllByActionItem_IdInAndLanguage(actionItemIds, language); + if (!actionIds.isEmpty()) { + guidelineTranslationRepository.deleteAllByGuideline_PhaseAction_IdInAndLanguage(actionIds, language); + mistakeTranslationRepository.deleteAllByMistake_PhaseAction_IdInAndLanguage(actionIds, language); + phaseActionTranslationRepository.deleteAllByPhaseAction_IdInAndLanguage(actionIds, language); + } + if (!phaseIds.isEmpty()) phaseTranslationRepository.deleteAllByPhase_IdInAndLanguage(phaseIds, language); + + for (RoadmapTranslationTarget.PhaseTarget phaseTarget : target.phases()) { + Phase phase = phaseRepository.getReferenceById(phaseTarget.phaseId()); + phaseTranslationRepository.save( + PhaseTranslation.create(phase, language, phaseTarget.goal(), phaseTarget.description())); + + for (RoadmapTranslationTarget.PhaseActionTarget actionTarget : phaseTarget.actions()) { + PhaseAction phaseAction = phaseActionRepository.getReferenceById(actionTarget.phaseActionId()); + phaseActionTranslationRepository.save( + PhaseActionTranslation.create(phaseAction, language, + actionTarget.title(), actionTarget.description(), actionTarget.importance())); + + for (RoadmapTranslationTarget.GuidelineTarget g : actionTarget.guidelines()) { + PhaseActionGuideline guideline = guidelineRepository.getReferenceById(g.guidelineId()); + guidelineTranslationRepository.save( + PhaseActionGuidelineTranslation.create(guideline, language, g.content())); + } + + for (RoadmapTranslationTarget.MistakeTarget m : actionTarget.mistakes()) { + PhaseActionMistake mistake = mistakeRepository.getReferenceById(m.mistakeId()); + mistakeTranslationRepository.save( + PhaseActionMistakeTranslation.create(mistake, language, m.content())); + } + + for (RoadmapTranslationTarget.ActionItemTarget item : actionTarget.actionItems()) { + ActionItem actionItem = actionItemRepository.getReferenceById(item.actionItemId()); + actionItemTranslationRepository.save( + ActionItemTranslation.create(actionItem, language, item.title())); + } + } + } + } +} diff --git a/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadmapTranslationService.java b/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadmapTranslationService.java new file mode 100644 index 00000000..1cf2c1cb --- /dev/null +++ b/src/main/java/org/sopt/kareer/domain/roadmap/service/RoadmapTranslationService.java @@ -0,0 +1,40 @@ +package org.sopt.kareer.domain.roadmap.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sopt.kareer.domain.roadmap.dto.translation.RoadmapTranslationTarget; +import org.sopt.kareer.global.external.google.service.GoogleTranslationService; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RoadmapTranslationService { + + private static final List TARGET_LANGUAGES = List.of("en", "vi", "zh-CN"); + + private final GoogleTranslationService googleTranslationService; + private final RoadmapTranslationPersistService persistService; + private final ExecutorService executorService; + + public void translateAllLanguages(RoadmapTranslationTarget target) { + if (target.phases().isEmpty()) return; + + List> futures = TARGET_LANGUAGES.stream() + .map(language -> CompletableFuture.runAsync(() -> { + try { + RoadmapTranslationTarget translated = googleTranslationService.translate(target, language); + persistService.saveTranslations(translated, language); + } catch (Exception e) { + log.error("[TRANSLATION] failed: language={}", language, e); + } + }, executorService)) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); + } +} diff --git a/src/main/java/org/sopt/kareer/global/external/ai/prompt/JobPostingRecommendPrompt.java b/src/main/java/org/sopt/kareer/global/external/ai/prompt/JobPostingRecommendPrompt.java index 37d5551b..8af3fb6f 100644 --- a/src/main/java/org/sopt/kareer/global/external/ai/prompt/JobPostingRecommendPrompt.java +++ b/src/main/java/org/sopt/kareer/global/external/ai/prompt/JobPostingRecommendPrompt.java @@ -1,7 +1,7 @@ package org.sopt.kareer.global.external.ai.prompt; public class JobPostingRecommendPrompt { - public static final String JOB_POSTING_SYSTEM_PROMPT = """ + private static final String JOB_POSTING_SYSTEM_PROMPT_TEMPLATE = """ 당신은 한국에서 취업을 준비하는 외국인에게 채용공고를 추천하는 전문가입니다. 반드시 지켜야 할 규칙: @@ -30,6 +30,10 @@ public class JobPostingRecommendPrompt { } """; + public static String buildSystemPrompt() { + return JOB_POSTING_SYSTEM_PROMPT_TEMPLATE.formatted(); + } + public static final String JOB_POSTING_USER_PROMPT_FORMAT = """ [USER_CONTEXT] %s diff --git a/src/main/java/org/sopt/kareer/global/external/ai/prompt/RoadmapPrompt.java b/src/main/java/org/sopt/kareer/global/external/ai/prompt/RoadmapPrompt.java index f6e9ae0d..2f224951 100644 --- a/src/main/java/org/sopt/kareer/global/external/ai/prompt/RoadmapPrompt.java +++ b/src/main/java/org/sopt/kareer/global/external/ai/prompt/RoadmapPrompt.java @@ -2,7 +2,7 @@ public class RoadmapPrompt { - public static final String ROADMAP_SYSTEM_PROMPT = """ + private static final String ROADMAP_SYSTEM_PROMPT_TEMPLATE = """ 당신은 한국의 최신 노동 시장 동향, 산업별 채용 요구사항, 복잡한 비자 규정(특히 D-2, D-10, E-7 비자 전환 및 유지)에 대한 심도 깊은 이해를 가진 '외국인 전문 커리어 컨설턴트 및 이민 전문가'입니다. 사용자의 학력, 경력, 한국어 능력, 희망 직무, 그리고 한국에서의 장기적인 커리어 목표를 종합적으로 분석하여, 실현 가능하고 시기 적절하며, 법적 규정을 준수하는 맞춤형 로드맵을 제공해야 합니다.​ 특히, 각 단계에서 예상되는 비자 및 취업 관련 난관을 예측하고, 이를 해결하기 위한 구체적인 리소스와 전략을 제시하는 데 집중합니다. 아래 조건을 **반드시** 지켜서 응답하세요. @@ -53,7 +53,7 @@ public class RoadmapPrompt { - 비자 정책, 노동 규정, 제도적 배경 설명을 위한 컨텍스트입니다. ⚠️ 중요: - - JSON의 **모든 값(content)은 영어로 작성**하세요. + - JSON의 **모든 값(content)은 Korean (한국어)로 작성**하세요. - JSON의 모든 string 값은 간결하고 명확하며, 구체적인 정보를 담아야 합니다. 추상적이거나 모호한 표현을 피하고, 수치화 가능한 정보나 실제 기관/제도명을 적극적으로 활용합니다. 예를 들어, '한국어 능력 향상' 대신 'TOPIK 4급 목표로 주 5시간 학습'과 같이 작성합니다 - 키 이름은 아래 스키마를 **엄격히** 따르세요. - 누락된 필드가 있으면 안 됩니다. @@ -95,6 +95,10 @@ JSON schema (strict): } """; + public static String buildSystemPrompt() { + return ROADMAP_SYSTEM_PROMPT_TEMPLATE; + } + public static final String ROADMAP_USER_PROMPT_FORMAT = """ [CURRENT_DATE] %s diff --git a/src/main/java/org/sopt/kareer/global/external/ai/service/OpenAiService.java b/src/main/java/org/sopt/kareer/global/external/ai/service/OpenAiService.java index 01599e73..85739a1e 100644 --- a/src/main/java/org/sopt/kareer/global/external/ai/service/OpenAiService.java +++ b/src/main/java/org/sopt/kareer/global/external/ai/service/OpenAiService.java @@ -39,7 +39,7 @@ public RoadmapResponse generateRoadmap( String careerContext = buildSectionContext("CAREER", careerRequiredDocs); String policyContext = buildSectionContext("POLICY", policyDocs); - String systemPrompt = RoadmapPrompt.ROADMAP_SYSTEM_PROMPT; + String systemPrompt = RoadmapPrompt.buildSystemPrompt(); String userPrompt = RoadmapPrompt.ROADMAP_USER_PROMPT_FORMAT.formatted( LocalDate.now(), memberContext, @@ -51,10 +51,10 @@ public RoadmapResponse generateRoadmap( return call(systemPrompt, userPrompt, RoadmapResponse.class); } - public List recommendJobPosting(String userContext, List retrievedDocument){ + public List recommendJobPosting(String userContext, List retrievedDocument) { String ragContext = buildRagContext(retrievedDocument, RagType.JOBPOSTING); - String systemPrompt = JobPostingRecommendPrompt.JOB_POSTING_SYSTEM_PROMPT; + String systemPrompt = JobPostingRecommendPrompt.buildSystemPrompt(); String userPrompt = JobPostingRecommendPrompt.JOB_POSTING_USER_PROMPT_FORMAT.formatted(userContext, ragContext); JobPostingRecommendResults searched = call(systemPrompt, userPrompt, JobPostingRecommendResults.class); @@ -65,7 +65,6 @@ public List recommendJobPosting(String userContext, List retriev return searched.results().stream() .map(JobPostingRecommendResults.JobPostingRecommendResult::jobPostingId) .toList(); - } private String buildRagContext(List docs, RagType ragType) { diff --git a/src/main/java/org/sopt/kareer/global/external/google/exception/GoogleTranslationErrorCode.java b/src/main/java/org/sopt/kareer/global/external/google/exception/GoogleTranslationErrorCode.java new file mode 100644 index 00000000..3e6e1f6a --- /dev/null +++ b/src/main/java/org/sopt/kareer/global/external/google/exception/GoogleTranslationErrorCode.java @@ -0,0 +1,24 @@ +package org.sopt.kareer.global.external.google.exception; + +import lombok.RequiredArgsConstructor; +import org.sopt.kareer.global.exception.errorcode.ErrorCode; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum GoogleTranslationErrorCode implements ErrorCode { + TRANSLATION_API_INVALID_RESPONSE(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Google Translate API 응답이 유효하지 않습니다."), + ; + + private final int httpStatus; + private final String message; + + @Override + public int getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/org/sopt/kareer/global/external/google/exception/GoogleTranslationException.java b/src/main/java/org/sopt/kareer/global/external/google/exception/GoogleTranslationException.java new file mode 100644 index 00000000..1fb5073d --- /dev/null +++ b/src/main/java/org/sopt/kareer/global/external/google/exception/GoogleTranslationException.java @@ -0,0 +1,14 @@ +package org.sopt.kareer.global.external.google.exception; + +import org.sopt.kareer.global.exception.customexception.CustomException; + +public class GoogleTranslationException extends CustomException { + + public GoogleTranslationException(GoogleTranslationErrorCode errorCode) { + super(errorCode); + } + + public GoogleTranslationException(GoogleTranslationErrorCode errorCode, String message) { + super(errorCode, message); + } +} diff --git a/src/main/java/org/sopt/kareer/global/external/google/service/GoogleTranslationService.java b/src/main/java/org/sopt/kareer/global/external/google/service/GoogleTranslationService.java new file mode 100644 index 00000000..8ecca6bf --- /dev/null +++ b/src/main/java/org/sopt/kareer/global/external/google/service/GoogleTranslationService.java @@ -0,0 +1,122 @@ +package org.sopt.kareer.global.external.google.service; + +import org.sopt.kareer.domain.roadmap.dto.translation.RoadmapTranslationTarget; +import org.sopt.kareer.global.external.google.exception.GoogleTranslationErrorCode; +import org.sopt.kareer.global.external.google.exception.GoogleTranslationException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +public class GoogleTranslationService { + + private final RestClient restClient; + private final String translateUrl; + private final String apiKey; + + public GoogleTranslationService( + @Value("${google.translate.url}") String translateUrl, + @Value("${google.translate.api-key}") String apiKey) { + this.restClient = RestClient.create(); + this.translateUrl = translateUrl; + this.apiKey = apiKey; + } + + public RoadmapTranslationTarget translate(RoadmapTranslationTarget target, String language) { + List texts = new ArrayList<>(); + extractTexts(target, texts); + + List translated = callTranslateApi(texts, language); + + int[] idx = {0}; + return reassemble(target, translated, idx); + } + + private void extractTexts(RoadmapTranslationTarget target, List texts) { + for (RoadmapTranslationTarget.PhaseTarget phase : target.phases()) { + texts.add(phase.goal()); + texts.add(phase.description()); + for (RoadmapTranslationTarget.PhaseActionTarget action : phase.actions()) { + texts.add(action.title()); + texts.add(action.description()); + texts.add(action.importance()); + for (RoadmapTranslationTarget.GuidelineTarget g : action.guidelines()) texts.add(g.content()); + for (RoadmapTranslationTarget.MistakeTarget m : action.mistakes()) texts.add(m.content()); + for (RoadmapTranslationTarget.ActionItemTarget i : action.actionItems()) texts.add(i.title()); + } + } + } + + @SuppressWarnings("unchecked") + private List callTranslateApi(List texts, String targetLang) { + Map request = Map.of( + "q", texts, + "target", targetLang, + "format", "text" + ); + + Map response = restClient.post() + .uri(translateUrl, apiKey) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(Map.class); + + if (response == null) { + throw new GoogleTranslationException(GoogleTranslationErrorCode.TRANSLATION_API_INVALID_RESPONSE, "Translation API returned null response"); + } + + Map data = (Map) response.get("data"); + if (data == null) { + throw new GoogleTranslationException(GoogleTranslationErrorCode.TRANSLATION_API_INVALID_RESPONSE, "Translation API response missing 'data' field"); + } + + List> translations = (List>) data.get("translations"); + if (translations == null) { + throw new GoogleTranslationException(GoogleTranslationErrorCode.TRANSLATION_API_INVALID_RESPONSE, "Translation API returned invalid translations count"); + } + + return translations.stream().map(t -> t.get("translatedText")).toList(); + } + + private RoadmapTranslationTarget reassemble(RoadmapTranslationTarget original, List translated, int[] idx) { + List phases = new ArrayList<>(); + for (RoadmapTranslationTarget.PhaseTarget orig : original.phases()) { + String goal = translated.get(idx[0]++); + String description = translated.get(idx[0]++); + + List actions = new ArrayList<>(); + for (RoadmapTranslationTarget.PhaseActionTarget oa : orig.actions()) { + String title = translated.get(idx[0]++); + String actionDesc = translated.get(idx[0]++); + String importance = translated.get(idx[0]++); + + List guidelines = new ArrayList<>(); + for (RoadmapTranslationTarget.GuidelineTarget g : oa.guidelines()) { + guidelines.add(new RoadmapTranslationTarget.GuidelineTarget(g.guidelineId(), translated.get(idx[0]++))); + } + + List mistakes = new ArrayList<>(); + for (RoadmapTranslationTarget.MistakeTarget m : oa.mistakes()) { + mistakes.add(new RoadmapTranslationTarget.MistakeTarget(m.mistakeId(), translated.get(idx[0]++))); + } + + List actionItems = new ArrayList<>(); + for (RoadmapTranslationTarget.ActionItemTarget item : oa.actionItems()) { + actionItems.add(new RoadmapTranslationTarget.ActionItemTarget(item.actionItemId(), translated.get(idx[0]++))); + } + + actions.add(new RoadmapTranslationTarget.PhaseActionTarget( + oa.phaseActionId(), title, actionDesc, importance, guidelines, mistakes, actionItems)); + } + + phases.add(new RoadmapTranslationTarget.PhaseTarget(orig.phaseId(), goal, description, actions)); + } + return new RoadmapTranslationTarget(phases); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ba2e40db..7ac247a1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -114,6 +114,11 @@ cohere: rerank: model: rerank-multilingual-v3.0 top-n: 10 + +google: + translate: + url: ${GOOGLE_TRANSLATE_URL} + api-key: ${GOOGLE_TRANSLATE_API_KEY} --- spring: config: diff --git a/src/test/java/org/sopt/kareer/support/ControllerTestSupport.java b/src/test/java/org/sopt/kareer/support/ControllerTestSupport.java index 8ac1b06d..f3ab36f4 100644 --- a/src/test/java/org/sopt/kareer/support/ControllerTestSupport.java +++ b/src/test/java/org/sopt/kareer/support/ControllerTestSupport.java @@ -11,7 +11,6 @@ import org.sopt.kareer.domain.roadmap.service.PhaseActionService; import org.sopt.kareer.domain.roadmap.service.PhaseService; import org.sopt.kareer.domain.roadmap.service.RoadMapService; -import org.sopt.kareer.domain.roadmap.service.RoadmapAsyncService; import org.sopt.kareer.global.auth.service.AuthService; import org.sopt.kareer.global.external.discord.client.DiscordClient; import org.sopt.kareer.global.jwt.util.JwtTokenUtil; @@ -60,9 +59,6 @@ public abstract class ControllerTestSupport { @MockBean protected RoadMapService roadMapService; - @MockBean - protected RoadmapAsyncService roadmapAsyncService; - @MockBean protected JpaMetamodelMappingContext mappingContext;