-
Notifications
You must be signed in to change notification settings - Fork 2
feat: 여행 설문조사 기반 아바타 매칭 시스템 구현 #126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
- Profile 엔티티 생성 (nickname, profileImage, gender, phoneNumber, birthday, selfIntroduction) - User와 OneToOne 관계 설정 - ErrorCode에 PROFILE_NOT_FOUND 추가
- ProfileRepository 인터페이스 생성 - findByUser, findByUser_Id 메서드 추가 - existsByNickname, existsByPhoneNumber 메서드 추가
- User 엔티티에서 nickname, profileImage, gender, phoneNumber, birthday 제거 - User와 Profile을 OneToOne 관계로 연결 (mappedBy 사용) - UserRepository에서 existsByNickname, existsByPhoneNumber 제거
- UserController → ProfileController, UserApiDocs → ProfileApiDocs - UserService → NicknameService - UserUpdateNicknameRequest → NicknameUpdateRequest - UserCheckNicknameResponse → NicknameValidateResponse - Gender, AgeRange enum을 profile.domain.enums로 이동 - InvalidGenderException을 profile.exception으로 이동 - 닉네임 관련 utils, validator를 profile 패키지로 이동 - NicknameService에서 ProfileRepository 사용하도록 수정 - getProfileByUserId 메서드 버그 수정 (findById → findByUser_Id)
- createNewUser 메서드에서 User 생성 후 Profile도 함께 생성 - OAuth에서 받은 profileImage를 Profile에 저장 - nickname, gender, phoneNumber, birthday는 null로 초기화
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements a comprehensive travel survey system that analyzes user preferences through 11 questions and matches them to one of 8 avatar types based on calculated tendency scores. The implementation replaces the previous simple survey storage system with a sophisticated scoring algorithm.
Key Changes:
- Introduced CodedEnum-based API design where clients send integer codes (1, 2, 3, etc.) that are converted to strongly-typed enums
- Implemented a scoring algorithm starting from 5.0 baseline with question-specific adjustments across 4 dimensions (R/W/S/P)
- Created avatar matching system using R/W/S thresholds to categorize users into 8 distinct travel personas with detailed profiles
Reviewed changes
Copilot reviewed 43 out of 43 changed files in this pull request and generated 31 comments.
Show a summary per file
| File | Description |
|---|---|
TravelTendencyCalculator.java |
Core scoring engine that processes 11 questions and calculates tendency scores with clamping to 0.0-10.0 range |
AvatarMatcher.java |
Maps R/W/S scores to 8 avatar types using threshold-based logic (≥5.0 = high, <5.0 = low) |
AvatarProfileProvider.java |
Provides detailed personality profiles, strengths, and tips for each avatar type |
SurveyService.java |
Orchestrates survey submission, score calculation, avatar matching, and result retrieval |
Survey.java |
Domain entity storing user's 11 question responses with ElementCollection for multi-select Q7 |
TravelTendency.java |
Domain entity storing calculated R/W/S/P scores and matched avatar type |
SurveyConverter.java |
Converts integer codes from API requests to enum types using EnumUtils |
SurveyRequest.java |
DTO with validation for 11 questions, requiring exactly 3 selections for Q7 |
SurveyResponse.java |
DTO returning scores, avatar details, and profile information |
Question* enums |
11 enum types implementing CodedEnum for type-safe answer handling |
AvatarType.java |
Enum defining 8 avatar types with codes, names, and descriptions |
SurveyController.java |
REST endpoints for survey submission and result retrieval |
SurveyApiDocs.java |
Comprehensive Swagger documentation with request/response examples |
| Deleted files | Removed old survey system (TravelSurvey entities, validators, separate interest table) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (survey.getQ8Planning() == Question8Planning.DETAILED) { | ||
| r -= 2.0; | ||
| } else if (survey.getQ8Planning() == Question8Planning.FLEXIBLE) { | ||
| } else if (survey.getQ8Planning() == Question8Planning.ON_SITE) { |
Copilot
AI
Dec 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The switch statement has an empty branch for FLEXIBLE. Consider adding a comment to explicitly indicate this is intentional (no score adjustment for flexible planning) to improve code clarity.
| public AvatarProfile getProfile(AvatarType avatarType) { | ||
| return PROFILES.get(avatarType); |
Copilot
AI
Dec 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The getProfile method doesn't handle null avatarType. If a null is passed, Map.get() will return null. Consider adding a null check or returning a default profile to prevent potential NullPointerException in consuming code.
| public TendencyScores calculate(Survey survey) { | ||
| double r = INITIAL_SCORE; // 여행 리듬 | ||
| double w = INITIAL_SCORE; // 지갑 성향 | ||
| double s = INITIAL_SCORE; // 동행 스타일 | ||
| double p = INITIAL_SCORE; // 활동 에너지 | ||
|
|
||
| // Q1. 이동수단 | ||
| if (survey.getQ1Transport() == Question1Transport.WALK_BUS) { | ||
| w -= 1.0; | ||
| p += 1.0; | ||
| } else if (survey.getQ1Transport() == Question1Transport.TAXI) { | ||
| w += 1.0; | ||
| p -= 1.0; | ||
| } | ||
|
|
||
| // Q2. 웨이팅 | ||
| if (survey.getQ2Waiting() == Question2Waiting.WAIT) { | ||
| r += 1.0; | ||
| p += 1.0; | ||
| } else if (survey.getQ2Waiting() == Question2Waiting.MOVE_ELSEWHERE) { | ||
| r -= 1.0; | ||
| p -= 1.0; | ||
| } | ||
|
|
||
| // Q3. 숙소 | ||
| if (survey.getQ3Stay() == Question3Stay.HOTEL) { | ||
| r -= 1.0; | ||
| w += 1.5; | ||
| } else if (survey.getQ3Stay() == Question3Stay.JUST_SLEEP) { | ||
| r += 1.0; | ||
| w -= 1.5; | ||
| } | ||
|
|
||
| // Q4. 기상시간 | ||
| if (survey.getQ4Wakeup() == Question4Wakeup.EARLY) { | ||
| p += 2.0; | ||
| } else if (survey.getQ4Wakeup() == Question4Wakeup.RELAXED) { | ||
| p -= 2.0; | ||
| } | ||
|
|
||
| // Q5. 경비관리 | ||
| if (survey.getQ5Expense() == Question5Expense.EACH_PAYS) { | ||
| w -= 1.0; | ||
| s -= 0.5; | ||
| } else if (survey.getQ5Expense() == Question5Expense.POOLED) { | ||
| w += 1.0; | ||
| s += 0.5; | ||
| } | ||
|
|
||
| // Q6. 소비태도 | ||
| if (survey.getQ6Spend() == Question6Spend.SPLURGE) { | ||
| w += 2.0; | ||
| } else if (survey.getQ6Spend() == Question6Spend.SAVE) { | ||
| w -= 2.0; | ||
| } | ||
|
|
||
| // Q7. 선호활동 (택3 누적합산) | ||
| List<Question7Interest> interests = survey.getQ7Interests(); | ||
| if (interests != null) { | ||
| for (Question7Interest interest : interests) { | ||
| switch (interest) { | ||
| case SIGHTSEEING: | ||
| p += 0.5; | ||
| break; | ||
| case EXHIBITION: | ||
| p -= 0.5; | ||
| break; | ||
| case NATURE: | ||
| p -= 0.5; | ||
| break; | ||
| case FOOD: | ||
| w += 0.5; | ||
| break; | ||
| case SHOPPING: | ||
| w += 1.0; | ||
| break; | ||
| case RESORT: | ||
| p -= 1.0; | ||
| break; | ||
| case ACTIVITY: | ||
| r += 1.0; | ||
| p += 0.5; | ||
| break; | ||
| case THEME_PARK: | ||
| r += 0.5; | ||
| p += 0.5; | ||
| break; | ||
| case FESTIVAL: | ||
| r += 1.0; | ||
| p += 1.0; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Q8. 계획성 | ||
| if (survey.getQ8Planning() == Question8Planning.DETAILED) { | ||
| r -= 2.0; | ||
| } else if (survey.getQ8Planning() == Question8Planning.FLEXIBLE) { | ||
| } else if (survey.getQ8Planning() == Question8Planning.ON_SITE) { | ||
| r += 2.0; | ||
| } | ||
|
|
||
| // Q9. 낯선메뉴 | ||
| if (survey.getQ9Menu() == Question9Menu.SAFE) { | ||
| r -= 1.5; | ||
| } else if (survey.getQ9Menu() == Question9Menu.CHECK_REVIEW) { | ||
| r -= 0.5; | ||
| } else if (survey.getQ9Menu() == Question9Menu.CHALLENGE) { | ||
| r += 1.5; | ||
| } | ||
|
|
||
| // Q10. 동행제안 | ||
| if (survey.getQ10Companion() == Question10Companion.WELCOME) { | ||
| s += 3.0; | ||
| } else if (survey.getQ10Companion() == Question10Companion.SITUATIONAL) { | ||
| s += 1.0; | ||
| } else if (survey.getQ10Companion() == Question10Companion.US_ONLY) { | ||
| s -= 2.0; | ||
| } | ||
|
|
||
| // Q11. 사진 | ||
| if (survey.getQ11Photo() == Question11Photo.LIFETIME_SHOT) { | ||
| s += 1.0; | ||
| p += 1.0; | ||
| } else if (survey.getQ11Photo() == Question11Photo.MATCH_COMPANION) { | ||
| s += 0.5; | ||
| } else if (survey.getQ11Photo() == Question11Photo.EYES_ONLY) { | ||
| s -= 1.0; | ||
| p -= 1.0; | ||
| } | ||
|
|
||
| r = clamp(r); | ||
| w = clamp(w); | ||
| s = clamp(s); | ||
| p = clamp(p); | ||
|
|
||
| return new TendencyScores(r, w, s, p); | ||
| } |
Copilot
AI
Dec 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The TravelTendencyCalculator contains complex scoring logic with 11 questions and multiple conditions that would benefit from comprehensive unit tests. Consider adding tests to verify correct score calculations for different answer combinations and edge cases (e.g., all minimum answers, all maximum answers, boundary conditions at threshold 5.0).
| public AvatarType match(double r, double w, double s) { | ||
| // R: 5.0 미만 = 안정, 5.0 이상 = 모험 | ||
| boolean isStable = r < THRESHOLD; | ||
|
|
||
| // W: 5.0 미만 = 가성비, 5.0 이상 = 플랙스 | ||
| boolean isCostEffective = w < THRESHOLD; | ||
|
|
||
| // S: 5.0 미만 = 독립, 5.0 이상 = 사교 | ||
| boolean isSocial = s >= THRESHOLD; | ||
|
|
||
| // 8가지 조합 매칭 | ||
| if (isStable && !isSocial && isCostEffective) { | ||
| return AvatarType.TTUR_POGUNI; // 안정 x 독립 x 가성비 | ||
| } else if (isStable && !isSocial) { | ||
| return AvatarType.TTUR_MOOD; // 안정 x 독립 x 플랙스 | ||
| } else if (isStable && isCostEffective) { | ||
| return AvatarType.TTUR_MALLANGI; // 안정 x 사교 x 가성비 | ||
| } else if (isStable) { | ||
| return AvatarType.TTUR_SWEET; // 안정 x 사교 x 플랙스 | ||
| } else if (!isSocial && isCostEffective) { | ||
| return AvatarType.TTUR_POPO; // 모험 x 독립 x 가성비 | ||
| } else if (!isSocial) { | ||
| return AvatarType.TTUR_SPARKLE; // 모험 x 독립 x 플랙스 | ||
| } else if (isCostEffective) { | ||
| return AvatarType.TTUR_GLIMMING; // 모험 x 사교 x 가성비 | ||
| } else { | ||
| return AvatarType.TTUR_PADO; // 모험 x 사교 x 플랙스 | ||
| } | ||
| } |
Copilot
AI
Dec 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The AvatarMatcher.match method contains critical business logic for avatar type determination. Given that other services in the codebase have comprehensive test coverage, consider adding unit tests to verify all 8 avatar combinations are correctly matched based on r, w, s scores.
| public class TravelTendencyCalculator { | ||
|
|
||
| private static final double INITIAL_SCORE = 5.0; | ||
| private static final double MIN_SCORE = 0.0; | ||
| private static final double MAX_SCORE = 10.0; | ||
|
|
||
| public TendencyScores calculate(Survey survey) { |
Copilot
AI
Dec 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing documentation: The TravelTendencyCalculator class and its calculate method lack JavaDoc comments explaining the scoring algorithm, the meaning of r/w/s/p scores, and the score range (0.0-10.0 starting from 5.0). Consider adding comprehensive documentation for this core business logic.
| SAVE(2, "아낀다 (가성비)"); | ||
|
|
||
| private final int code; | ||
| private final String text; |
Copilot
AI
Dec 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method overrides CodedEnum.getCode; it is advisable to add an Override annotation.
| private final String text; | |
| private final String text; | |
| @Override | |
| public int getCode() { | |
| return code; | |
| } |
| ON_SITE(3, "현장 결정"); | ||
|
|
||
| private final int code; | ||
| private final String text; |
Copilot
AI
Dec 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method overrides CodedEnum.getText; it is advisable to add an Override annotation.
| ON_SITE(3, "현장 결정"); | ||
|
|
||
| private final int code; | ||
| private final String text; |
Copilot
AI
Dec 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method overrides CodedEnum.getCode; it is advisable to add an Override annotation.
| private final String text; | |
| private final String text; | |
| @Override | |
| public int getCode() { | |
| return code; | |
| } |
| CHECK_REVIEW(2, "후기 확인 후 결정"), | ||
| CHALLENGE(3, "바로 도전"); | ||
|
|
||
| private final int code; |
Copilot
AI
Dec 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method overrides CodedEnum.getText; it is advisable to add an Override annotation.
| private final int code; | |
| private final int code; | |
| @Getter(onMethod_ = {@Override}) |
| CHECK_REVIEW(2, "후기 확인 후 결정"), | ||
| CHALLENGE(3, "바로 도전"); | ||
|
|
||
| private final int code; |
Copilot
AI
Dec 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method overrides CodedEnum.getCode; it is advisable to add an Override annotation.
- JwtTokenProvider에 validateVerificationToken 메서드 추가 - verification token의 유효성 검증 및 전화번호 일치 여부 확인
- setupInitialProfile 메서드 추가 - marketingAgree 필드 제거 - 닉네임, 성별, 전화번호, 생년월일 설정 기능 구현
- ProfileSetupRequest DTO 생성 - nickname, gender, phoneNumber, birthday, verificationToken 필드 포함 - marketingAgree 필드 제거
- setupInitialProfile 메서드 구현 - 닉네임 중복 검증 - 전화번호 인증 토큰 검증 - 전화번호 중복 검증 - 생년월일 파싱 로직 추가 - marketingAgree 관련 로직 제거 - 불필요한 주석 제거
- PUT /api/v1/me/profile 엔드포인트 추가 - PATCH에서 PUT으로 변경 - ProfileSetupRequest로 프로필 정보 받아 처리
- NicknameService -> ProfileService로 변경된 것에 맞춰 테스트 코드 수정
- Profile이 항상 존재한다는 전제로 null 체크 제거
feat: 프로필 초기 설정 API 구현
# Conflicts: # src/main/java/com/dduru/gildongmu/verification/controller/PhoneVerificationApiDocs.java
…iomize docs: 에러 응답 커스터마이징 및 프로필 controller 적용
…G-MU/ddu-ru-backend into feat/#125-survey-avatar-matching # Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit.
🔎 작업 내용
➕ 이슈 링크
📸 스크린샷
😎 리뷰 요구사항
CodedEnum방식에 대해서 어떻게 생각하세요??
🧑💻 예정 작업
📝 체크리스트
feature/개발내용형식으로 작성했는가?feat: 커밋 내용형식으로 작성했는가?dev브랜치로 병합을 요청했는가?