From 18227fe2129f5a55ba9bc71e915f28d694c8b025 Mon Sep 17 00:00:00 2001 From: Jaewon Lee <50106190+jwon0523@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:31:23 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9D=B4=EC=83=89=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=99=20=EC=A1=B0=ED=95=A9=20=EC=8B=9D=EB=B3=84=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=B6=94=EC=B2=9C=20=EC=9D=91=EB=8B=B5=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/RecommendationResponse.java | 6 +- .../entity/RecommendedTrack.java | 8 ++- .../service/RecommendationAssembler.java | 1 + .../service/RecommendationService.java | 28 +++++++- .../ai/AiEnvelopeContractTest.java | 2 + .../service/RecommendationAssemblerTest.java | 67 ++++++++++++++++++ .../service/RecommendationServiceTest.java | 70 +++++++++++++++++-- 7 files changed, 173 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/dto/RecommendationResponse.java b/src/main/java/com/hansung/tracktory/domain/recommendation/dto/RecommendationResponse.java index 15c1e5e..7d19989 100644 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/dto/RecommendationResponse.java +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/dto/RecommendationResponse.java @@ -29,13 +29,17 @@ public record TrackRecommendationView( List primary, List secondary) {} - /** 추천 트랙 한 건. {@code primary} 가 true 면 주 추천, {@code mainSubjects} 는 트랙의 주요 과목(전공필수) 목록. */ + /** + * 추천 트랙 한 건. {@code primary} 가 true 면 주 추천, {@code crossCombination} 이 true 면 학과 경계를 넘는 이색 조합(보조 + * 추천 중 별도 배지로 구분 노출 대상), {@code mainSubjects} 는 트랙의 주요 과목(전공필수) 목록. + */ public record TrackView( String code, String name, Integer score, String reasoning, boolean primary, + boolean crossCombination, List mainSubjects) {} /** 트랙의 주요 과목(전공필수) 한 건. */ diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendedTrack.java b/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendedTrack.java index 757c015..a2f7ae3 100644 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendedTrack.java +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendedTrack.java @@ -37,6 +37,10 @@ public class RecommendedTrack extends BaseEntity { @Column(name = "is_primary", nullable = false) private boolean primary; + /** 학과 경계를 넘는 이색 조합 슬롯에서 나온 트랙인지 — AI 의 cross_college 슬롯 분류를 보존한다. */ + @Column(name = "is_cross_combination", nullable = false) + private boolean crossCombination; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "recommendation_id", nullable = false) private Recommendation recommendation; @@ -46,10 +50,12 @@ public class RecommendedTrack extends BaseEntity { private Track track; @Builder - public RecommendedTrack(Integer score, String reasoning, boolean primary, Track track) { + public RecommendedTrack( + Integer score, String reasoning, boolean primary, boolean crossCombination, Track track) { this.score = score; this.reasoning = reasoning; this.primary = primary; + this.crossCombination = crossCombination; this.track = track; } diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssembler.java b/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssembler.java index 02df009..47fe524 100644 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssembler.java +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssembler.java @@ -111,6 +111,7 @@ private TrackRecommendationView tracks(Recommendation recommendation) { rt.getScore(), rt.getReasoning(), rt.isPrimary(), + rt.isCrossCombination(), mainSubjects.getOrDefault(rt.getTrack().getId(), List.of())); (rt.isPrimary() ? primary : secondary).add(view); } diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java b/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java index 0c86ca4..2988acf 100644 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java @@ -53,6 +53,9 @@ public class RecommendationService { private static final int MAX_SECONDARY_TRACKS = 5; + /** AI 가 학과 경계를 넘는 이색 조합 슬롯에 부여하는 slot_type 값. */ + private static final String SLOT_CROSS_COLLEGE = "cross_college"; + private final OnboardingProfileReader onboardingProfileReader; private final AiRecommendClient aiRecommendClient; private final RecommendationAssembler recommendationAssembler; @@ -160,15 +163,18 @@ private void addTracks(Recommendation recommendation, AiRecommendResponse ai, Ra } Optional track = trackRepository.findByCode(aiTrack.trackId()); track.ifPresent( - t -> addTrack(recommendation, t, percent(top.synergyScore()), true, reasoning)); + t -> addTrack(recommendation, t, percent(top.synergyScore()), true, false, reasoning)); } } + // AI 슬롯 예약 규칙상 한 트랙은 한 슬롯에만 속하고 이색 조합(cross_college) 슬롯이 일반 다양성(mmr) 슬롯보다 + // 먼저 오므로, seen 중복 제거로 트랙이 한 번만 저장돼도 이색 조합 분류가 보존된다. int secondaryCount = 0; for (RankedCombo combo : nullSafe(ai.secondaryCombos())) { if (combo == null || combo.combo() == null) { continue; } + boolean crossCombination = isCrossCollege(combo); for (AiRecommendResponse.Track aiTrack : pair(combo)) { if (secondaryCount >= MAX_SECONDARY_TRACKS) { break; @@ -180,18 +186,34 @@ private void addTracks(Recommendation recommendation, AiRecommendResponse ai, Ra if (track.isEmpty()) { continue; } - addTrack(recommendation, track.get(), percent(combo.synergyScore()), false, reasoning); + addTrack( + recommendation, + track.get(), + percent(combo.synergyScore()), + false, + crossCombination, + reasoning); secondaryCount++; } } } + private static boolean isCrossCollege(RankedCombo combo) { + return SLOT_CROSS_COLLEGE.equalsIgnoreCase(combo.slotType()); + } + private void addTrack( - Recommendation recommendation, Track track, int score, boolean primary, String reasoning) { + Recommendation recommendation, + Track track, + int score, + boolean primary, + boolean crossCombination, + String reasoning) { recommendation.addRecommendedTrack( RecommendedTrack.builder() .score(score) .primary(primary) + .crossCombination(crossCombination) .reasoning(reasoning) .track(track) .build()); diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/ai/AiEnvelopeContractTest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/ai/AiEnvelopeContractTest.java index d82b029..35367b1 100644 --- a/src/test/java/com/hansung/tracktory/domain/recommendation/ai/AiEnvelopeContractTest.java +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/ai/AiEnvelopeContractTest.java @@ -83,6 +83,8 @@ void deserializesRealFastApiEnvelopeWithoutFieldLoss() { assertThat(primary.combo().trackA().trackId()).isEqualTo("빅데이터트랙"); assertThat(primary.combo().trackB().trackId()).isEqualTo("AIㆍ소프트웨어학과"); assertThat(primary.synergyScore()).isEqualTo(0.0); + // slot_type 은 이색 조합(cross_college) 식별의 근거 필드이므로 역직렬화를 가드한다. + assertThat(primary.slotType()).isEqualTo("primary"); SemesterPlan plan = data.roadmap().semesters().get(0); assertThat(plan.semester()).isEqualTo(3); diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssemblerTest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssemblerTest.java index 53f5fae..2110174 100644 --- a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssemblerTest.java +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssemblerTest.java @@ -201,6 +201,7 @@ void assemble_populatesMainSubjectsForRecommendedTracksCappedAtThree() { TrackView view = response.tracks().primary().get(0); assertThat(view.code()).isEqualTo("BIGDATA"); assertThat(view.primary()).isTrue(); + assertThat(view.crossCombination()).isFalse(); assertThat(view.reasoning()).isEqualTo("이유"); // 직무 점수 보정(이슈 #45)은 트랙 점수에 적용되지 않는다: 트랙 점수는 원본 그대로 노출된다(보정 시 95→98). assertThat(view.score()).isEqualTo(95); @@ -212,6 +213,72 @@ void assemble_populatesMainSubjectsForRecommendedTracksCappedAtThree() { assertThat(mainSubjects.get(2).name()).isEqualTo("데이터베이스"); } + @Test + void assemble_mapsCrossCombinationFlagOntoTrackViews() { + Track primaryTrack = mock(Track.class); + given(primaryTrack.getId()).willReturn(20L); + given(primaryTrack.getCode()).willReturn("BIGDATA"); + given(primaryTrack.getName()).willReturn("빅데이터 트랙"); + Track crossTrack = mock(Track.class); + given(crossTrack.getId()).willReturn(21L); + given(crossTrack.getCode()).willReturn("KOREAN_EDU"); + given(crossTrack.getName()).willReturn("한국어교육 트랙"); + + Recommendation recommendation = + Recommendation.builder().status(RecommendationStatus.ACTIVE).build(); + recommendation.addRecommendedTrack( + RecommendedTrack.builder() + .score(95) + .reasoning("주 추천") + .primary(true) + .crossCombination(false) + .track(primaryTrack) + .build()); + recommendation.addRecommendedTrack( + RecommendedTrack.builder() + .score(60) + .reasoning("이색 조합") + .primary(false) + .crossCombination(true) + .track(crossTrack) + .build()); + + given(trackSubjectRepository.findByTrackInAndType(anyCollection(), eq(SubjectType.REQUIRED))) + .willReturn(List.of()); + + OnboardingProfileSnapshot profile = + new OnboardingProfileSnapshot( + 1L, + 2023, + "IT공과대학", + "컴퓨터공학부", + 3, + List.of("BIGDATA"), + List.of("IT/인터넷"), + List.of("앱"), + List.of("성장성"), + List.of("대기업"), + List.of(), + List.of()); + + RecommendationResponse response = recommendationAssembler.assemble(recommendation, profile); + + assertThat(response.tracks().primary()) + .singleElement() + .satisfies( + v -> { + assertThat(v.code()).isEqualTo("BIGDATA"); + assertThat(v.crossCombination()).isFalse(); + }); + assertThat(response.tracks().secondary()) + .singleElement() + .satisfies( + v -> { + assertThat(v.code()).isEqualTo("KOREAN_EDU"); + assertThat(v.crossCombination()).isTrue(); + }); + } + private static Subject subject(long id, String code, String name) { Subject subject = mock(Subject.class); given(subject.getId()).willReturn(id); diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java index cf78408..343b02f 100644 --- a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java @@ -119,6 +119,8 @@ void generate_freshWhenNoActive_callsAiSupersedesSavesAndMaps() { .willReturn(Optional.of(Track.builder().code("WEB").name("웹공학트랙").build())); given(trackRepository.findByCode("MOBILE")) .willReturn(Optional.of(Track.builder().code("MOBILE").name("모바일소프트웨어트랙").build())); + given(trackRepository.findByCode("SECURITY")) + .willReturn(Optional.of(Track.builder().code("SECURITY").name("융합보안트랙").build())); given(subjectRepository.findByCode("W080001")) .willReturn(Optional.of(Subject.builder().code("W080001").name("프로그래밍기초").build())); given(recommendationRepository.save(any(Recommendation.class))) @@ -145,16 +147,29 @@ void generate_freshWhenNoActive_callsAiSupersedesSavesAndMaps() { assertThat(saved.getRecommendedJobs().get(0).getJob().getCode()).isEqualTo("be_dev"); assertThat(saved.getRecommendedJobs().get(0).getReasoning()).isEqualTo("직무 설명"); - assertThat(saved.getRecommendedTracks()).hasSize(3); + assertThat(saved.getRecommendedTracks()).hasSize(4); List primaries = saved.getRecommendedTracks().stream().filter(RecommendedTrack::isPrimary).toList(); List secondaries = saved.getRecommendedTracks().stream().filter(t -> !t.isPrimary()).toList(); assertThat(primaries).extracting(t -> t.getTrack().getCode()).containsExactly("BIGDATA", "WEB"); - assertThat(secondaries).extracting(t -> t.getTrack().getCode()).containsExactly("MOBILE"); + assertThat(secondaries) + .extracting(t -> t.getTrack().getCode()) + .containsExactly("MOBILE", "SECURITY"); assertThat(saved.getRecommendedTracks()) .allSatisfy(t -> assertThat(t.getReasoning()).isEqualTo("트랙 설명")); + // 이색 조합 식별: 주 추천은 항상 false, 보조는 cross_college 슬롯만 true(mmr 은 false). + assertThat(primaries).allSatisfy(t -> assertThat(t.isCrossCombination()).isFalse()); + assertThat(secondaries) + .filteredOn(t -> t.getTrack().getCode().equals("MOBILE")) + .singleElement() + .satisfies(t -> assertThat(t.isCrossCombination()).isTrue()); + assertThat(secondaries) + .filteredOn(t -> t.getTrack().getCode().equals("SECURITY")) + .singleElement() + .satisfies(t -> assertThat(t.isCrossCombination()).isFalse()); + assertThat(saved.getRoadmap()).isNotNull(); assertThat(saved.getRoadmap().getReasoning()).isEqualTo("로드맵 설명"); assertThat(saved.getRoadmap().getSemesters()).hasSize(1); @@ -238,6 +253,47 @@ void generate_jobsFoldingToSameCatalogCode_dedupedToSingleRecommendedJob() { assertThat(saved.getRecommendedJobs().get(0).getScore()).isEqualTo(90); } + @Test + void generate_secondaryComboWithNullSlotType_notMarkedCrossCombination() { + OnboardingProfileSnapshot profile = sampleProfile(); + AiRecommendResponse.Track mobile = + new AiRecommendResponse.Track("c", "d", "MOBILE", "모바일소프트웨어트랙"); + // AI 가 slot_type 을 누락(null)한 보조 조합 — 이색 조합으로 표시되면 안 된다(널 안전 가드). + RankedCombo secondaryNull = + new RankedCombo(new TrackCombo(mobile, null, "MOBILE"), 0.5, null, 2); + AiRecommendResponse ai = + new AiRecommendResponse(List.of(), List.of(), List.of(secondaryNull), null, null); + + given(onboardingProfileReader.read(USER_ID)).willReturn(Optional.of(profile)); + given( + recommendationRepository.findFirstByUser_IdAndStatusOrderByCreatedAtDesc( + USER_ID, RecommendationStatus.ACTIVE)) + .willReturn(Optional.empty()); + given(aiRecommendClient.generate(any())).willReturn(ai); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(sampleUser())); + given(recommendationRepository.findByUser_IdAndStatus(USER_ID, RecommendationStatus.ACTIVE)) + .willReturn(List.of()); + given(trackRepository.findByCode("MOBILE")) + .willReturn(Optional.of(Track.builder().code("MOBILE").name("모바일소프트웨어트랙").build())); + given(recommendationRepository.save(any(Recommendation.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + given(recommendationAssembler.assemble(any(Recommendation.class), eq(profile))) + .willReturn(sentinelResponse()); + + recommendationService.generate(USER_ID, false); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Recommendation.class); + verify(recommendationRepository).save(captor.capture()); + Recommendation saved = captor.getValue(); + assertThat(saved.getRecommendedTracks()) + .singleElement() + .satisfies( + t -> { + assertThat(t.getTrack().getCode()).isEqualTo("MOBILE"); + assertThat(t.isCrossCombination()).isFalse(); + }); + } + private static OnboardingProfileSnapshot sampleProfile() { return new OnboardingProfileSnapshot( USER_ID, @@ -268,11 +324,17 @@ private static AiRecommendResponse sampleAiResponse() { AiRecommendResponse.Track web = new AiRecommendResponse.Track("c", "d", "WEB", "웹공학트랙"); AiRecommendResponse.Track mobile = new AiRecommendResponse.Track("c", "d", "MOBILE", "모바일소프트웨어트랙"); + AiRecommendResponse.Track security = + new AiRecommendResponse.Track("c", "d", "SECURITY", "융합보안트랙"); RankedCombo primary = new RankedCombo(new TrackCombo(bigdata, web, "BIGDATA+WEB"), 0.85, "primary", 1); - RankedCombo secondary = + // 이색 조합 슬롯(cross_college) — MOBILE 만 신규(BIGDATA 는 주 추천에서 이미 노출). + RankedCombo secondaryCross = new RankedCombo(new TrackCombo(mobile, bigdata, "MOBILE+BIGDATA"), 0.6, "cross_college", 2); + // 일반 다양성 슬롯(mmr) — SECURITY 만 신규. 이색 조합으로 표시되면 안 된다. + RankedCombo secondaryMmr = + new RankedCombo(new TrackCombo(security, bigdata, "SECURITY+BIGDATA"), 0.55, "mmr", 3); RoadmapCourse course = new RoadmapCourse("W080001", "프로그래밍기초", 0.6, 3, "foundation"); RoadmapStage stage = new RoadmapStage("foundation", List.of(course)); @@ -292,7 +354,7 @@ private static AiRecommendResponse sampleAiResponse() { return new AiRecommendResponse( List.of(new JobCandidate("be_dev", "백엔드 개발자", List.of(), List.of(), 0.9, 0.8, false)), List.of(primary), - List.of(secondary), + List.of(secondaryCross, secondaryMmr), roadmap, explanation); } From cd33c40fa2ae86fef1a528ac4a35549bde4442ba Mon Sep 17 00:00:00 2001 From: Jaewon Lee <50106190+jwon0523@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:06:38 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=ED=8A=B8=EB=9E=99=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EA=B0=80=EC=9A=B4=EB=8E=83=EC=A0=90=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=88=EC=BD=94=EB=93=9C=20=EB=B6=88=EC=9D=BC=EC=B9=98?= =?UTF-8?q?=EB=A1=9C=20=EB=88=84=EB=9D=BD=EB=90=98=EB=8D=98=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=99=20=EB=A7=A4=ED=95=91=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 중계 서버는 트랙 code 가운뎃점에 U+00B7(MIDDLE DOT)을, 본 카탈로그는 U+318D(HANGUL LETTER ARAEA)를 써 글자 모양이 같아도 코드 동등 비교가 실패해 가운뎃점 포함 트랙이 매핑에서 조용히 누락됐다. AI 식별자를 카탈로그 정규형으로 접는 anti-corruption 정규화로 매핑을 복구한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service/RecommendationService.java | 22 +++++++- .../service/TrackCodeNormalizer.java | 49 ++++++++++++++++++ .../service/RecommendationServiceTest.java | 45 ++++++++++++++++ .../service/TrackCodeNormalizerTest.java | 51 +++++++++++++++++++ 4 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/hansung/tracktory/domain/recommendation/service/TrackCodeNormalizer.java create mode 100644 src/test/java/com/hansung/tracktory/domain/recommendation/service/TrackCodeNormalizerTest.java diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java b/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java index 2988acf..ed3170e 100644 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java @@ -161,7 +161,7 @@ private void addTracks(Recommendation recommendation, AiRecommendResponse ai, Ra if (aiTrack == null || !seen.add(aiTrack.trackId())) { continue; } - Optional track = trackRepository.findByCode(aiTrack.trackId()); + Optional track = findCatalogTrack(aiTrack.trackId()); track.ifPresent( t -> addTrack(recommendation, t, percent(top.synergyScore()), true, false, reasoning)); } @@ -182,7 +182,7 @@ private void addTracks(Recommendation recommendation, AiRecommendResponse ai, Ra if (aiTrack == null || !seen.add(aiTrack.trackId())) { continue; } - Optional track = trackRepository.findByCode(aiTrack.trackId()); + Optional track = findCatalogTrack(aiTrack.trackId()); if (track.isEmpty()) { continue; } @@ -198,6 +198,24 @@ private void addTracks(Recommendation recommendation, AiRecommendResponse ai, Ra } } + // AI 카탈로그와 본 백엔드 카탈로그가 가운뎃점을 서로 다른 유니코드로 적재해(U+00B7 vs U+318D) 가운뎃점을 포함한 + // 트랙 code 의 동등 비교가 실패한다. 원본 code 로 먼저 조회하고, 못 찾으면 가운뎃점을 카탈로그 정규형으로 맞춘 code 로 한 번 더 조회한다. + // 원본이 이미 매칭되는 트랙은 동작이 바뀌지 않고, 가운뎃점 불일치로 누락되던 트랙만 구제된다. + private Optional findCatalogTrack(String aiTrackId) { + if (aiTrackId == null) { + return Optional.empty(); + } + Optional exact = trackRepository.findByCode(aiTrackId); + if (exact.isPresent()) { + return exact; + } + String normalized = TrackCodeNormalizer.toCatalogForm(aiTrackId); + if (normalized.equals(aiTrackId)) { + return exact; + } + return trackRepository.findByCode(normalized); + } + private static boolean isCrossCollege(RankedCombo combo) { return SLOT_CROSS_COLLEGE.equalsIgnoreCase(combo.slotType()); } diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/service/TrackCodeNormalizer.java b/src/main/java/com/hansung/tracktory/domain/recommendation/service/TrackCodeNormalizer.java new file mode 100644 index 0000000..d05c035 --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/service/TrackCodeNormalizer.java @@ -0,0 +1,49 @@ +package com.hansung.tracktory.domain.recommendation.service; + +/** + * AI 중계 서버가 보내는 트랙 식별자를 카탈로그가 저장한 정규 형태로 맞추는 anti-corruption 정규화. + * + *

두 저장소가 가운뎃점을 서로 다른 유니코드로 적재한다: AI 카탈로그는 MIDDLE DOT(U+00B7)을, 본 백엔드 카탈로그는 HANGUL LETTER + * ARAEA(U+318D)를 쓴다. 글자 모양이 사실상 같아 눈으로는 구분되지 않지만 코드 포인트가 달라 코드 동등 비교가 실패하고, 가운뎃점을 포함한 트랙이 매핑에서 조용히 + * 누락된다. 외부 식별자의 가운뎃점 변형을 카탈로그 정규형으로 접어 매핑이 성립하게 한다. + * + *

시각적으로 구분되지 않는 코드 포인트들이라 가운뎃점은 문자 리터럴 대신 정수 코드 포인트로 다룬다. + */ +final class TrackCodeNormalizer { + + /** 카탈로그가 트랙 code 에 쓰는 가운뎃점(HANGUL LETTER ARAEA, U+318D). */ + private static final char CATALOG_MIDDLE_DOT = 0x318D; + + private TrackCodeNormalizer() {} + + /** + * 트랙 식별자의 가운뎃점 변형을 카탈로그 정규형(U+318D)으로 치환한다. 변형이 없으면 입력 인스턴스를 그대로 돌려준다. + * + * @param trackId AI 중계 서버가 보낸 트랙 식별자(널 허용) + * @return 카탈로그 정규형으로 맞춘 식별자, 입력이 널이면 널 + */ + static String toCatalogForm(String trackId) { + if (trackId == null) { + return null; + } + StringBuilder normalized = null; + for (int i = 0; i < trackId.length(); i++) { + if (isMiddleDotVariant(trackId.charAt(i))) { + if (normalized == null) { + normalized = new StringBuilder(trackId); + } + normalized.setCharAt(i, CATALOG_MIDDLE_DOT); + } + } + return normalized == null ? trackId : normalized.toString(); + } + + /** 카탈로그 정규형과 같은 글리프로 렌더되는 가운뎃점 변형인지 정수 코드 포인트로 판정한다. */ + private static boolean isMiddleDotVariant(char c) { + return c == 0x00B7 // MIDDLE DOT (AI 카탈로그가 실제 사용) + || c == 0x0387 // GREEK ANO TELEIA + || c == 0x2027 // HYPHENATION POINT + || c == 0x30FB // KATAKANA MIDDLE DOT + || c == 0xFF65; // HALFWIDTH KATAKANA MIDDLE DOT + } +} diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java index 343b02f..9b98bbb 100644 --- a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java @@ -294,6 +294,51 @@ void generate_secondaryComboWithNullSlotType_notMarkedCrossCombination() { }); } + @Test + void generate_secondaryComboWithMiddleDotVariant_mappedViaNormalizedCode() { + OnboardingProfileSnapshot profile = sampleProfile(); + // AI 카탈로그는 가운뎃점에 MIDDLE DOT(U+00B7)을, 본 카탈로그는 HANGUL LETTER ARAEA(U+318D)를 쓴다. + // 글자 모양은 같지만 코드 포인트가 달라 원본 code 로는 못 찾는다 — 정규화 후에만 매핑돼야 한다(누락 회귀 가드). + String aiCode = "디지털콘텐츠" + (char) 0x00B7 + "가상현실트랙"; + String catalogCode = "디지털콘텐츠" + (char) 0x318D + "가상현실트랙"; + AiRecommendResponse.Track vr = new AiRecommendResponse.Track("c", "d", aiCode, "가상현실트랙"); + RankedCombo secondary = + new RankedCombo(new TrackCombo(vr, null, aiCode), 0.6, "cross_college", 2); + AiRecommendResponse ai = + new AiRecommendResponse(List.of(), List.of(), List.of(secondary), null, null); + + given(onboardingProfileReader.read(USER_ID)).willReturn(Optional.of(profile)); + given( + recommendationRepository.findFirstByUser_IdAndStatusOrderByCreatedAtDesc( + USER_ID, RecommendationStatus.ACTIVE)) + .willReturn(Optional.empty()); + given(aiRecommendClient.generate(any())).willReturn(ai); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(sampleUser())); + given(recommendationRepository.findByUser_IdAndStatus(USER_ID, RecommendationStatus.ACTIVE)) + .willReturn(List.of()); + given(trackRepository.findByCode(aiCode)).willReturn(Optional.empty()); + given(trackRepository.findByCode(catalogCode)) + .willReturn(Optional.of(Track.builder().code(catalogCode).name("가상현실트랙").build())); + given(recommendationRepository.save(any(Recommendation.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + given(recommendationAssembler.assemble(any(Recommendation.class), eq(profile))) + .willReturn(sentinelResponse()); + + recommendationService.generate(USER_ID, false); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Recommendation.class); + verify(recommendationRepository).save(captor.capture()); + Recommendation saved = captor.getValue(); + assertThat(saved.getRecommendedTracks()) + .singleElement() + .satisfies( + t -> { + assertThat(t.getTrack().getCode()).isEqualTo(catalogCode); + assertThat(t.isPrimary()).isFalse(); + assertThat(t.isCrossCombination()).isTrue(); + }); + } + private static OnboardingProfileSnapshot sampleProfile() { return new OnboardingProfileSnapshot( USER_ID, diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/service/TrackCodeNormalizerTest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/service/TrackCodeNormalizerTest.java new file mode 100644 index 0000000..b893c8d --- /dev/null +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/service/TrackCodeNormalizerTest.java @@ -0,0 +1,51 @@ +package com.hansung.tracktory.domain.recommendation.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class TrackCodeNormalizerTest { + + // AI 카탈로그가 쓰는 가운뎃점 / 본 백엔드 카탈로그가 쓰는 가운뎃점. 시각적으로 동일해 코드 포인트로 명시한다. + private static final char AI_MIDDLE_DOT = 0x00B7; // MIDDLE DOT + private static final char CATALOG_MIDDLE_DOT = 0x318D; // HANGUL LETTER ARAEA + + @Test + void toCatalogForm_foldsAiMiddleDotToCatalogForm() { + String aiCode = "디지털콘텐츠" + AI_MIDDLE_DOT + "가상현실트랙"; + String catalogCode = "디지털콘텐츠" + CATALOG_MIDDLE_DOT + "가상현실트랙"; + + String result = TrackCodeNormalizer.toCatalogForm(aiCode); + + assertThat(result).isEqualTo(catalogCode); + assertThat(result).doesNotContain(String.valueOf(AI_MIDDLE_DOT)); + } + + @Test + void toCatalogForm_withoutMiddleDot_returnsSameInstance() { + String plain = "빅데이터트랙"; + + assertThat(TrackCodeNormalizer.toCatalogForm(plain)).isSameAs(plain); + } + + @Test + void toCatalogForm_alreadyCatalogForm_isUnchanged() { + String catalogCode = "회계" + CATALOG_MIDDLE_DOT + "재무경영트랙"; + + assertThat(TrackCodeNormalizer.toCatalogForm(catalogCode)).isEqualTo(catalogCode); + } + + @Test + void toCatalogForm_foldsMultipleMiddleDotVariants() { + String mixed = + "A" + (char) 0x30FB + "B" + (char) 0x2027 + "C"; // KATAKANA MIDDLE DOT, HYPHENATION POINT + String expected = "A" + CATALOG_MIDDLE_DOT + "B" + CATALOG_MIDDLE_DOT + "C"; + + assertThat(TrackCodeNormalizer.toCatalogForm(mixed)).isEqualTo(expected); + } + + @Test + void toCatalogForm_null_returnsNull() { + assertThat(TrackCodeNormalizer.toCatalogForm(null)).isNull(); + } +}