diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index b0bec0cf..1b90a266 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -148,6 +148,7 @@ jobs: - APPLE_CLIENT_ID=${{ secrets.APPLE_CLIENT_ID }} - KAKAO_ADMIN_KEY=${{ secrets.KAKAO_ADMIN_KEY }} - OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} + - GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }} - SWAGGER_SERVER_PRODUCTION_URL=${{ secrets.TEST_SWAGGER_SERVER_PRODUCTION_URL }} - SECURITY_CLIENT_URL_PRODUCTION=${{ secrets.SECURITY_CLIENT_URL_PRODUCTION }} - SECURITY_CLIENT_URL_DEVELOPMENT=${{ secrets.SECURITY_CLIENT_URL_DEVELOPMENT }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b683096a..41dd1c5f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -169,6 +169,7 @@ jobs: - APPLE_CLIENT_ID=${{ secrets.APPLE_CLIENT_ID }} - KAKAO_ADMIN_KEY=${{ secrets.KAKAO_ADMIN_KEY }} - OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} + - GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }} - SWAGGER_SERVER_PRODUCTION_URL=${{ secrets.SWAGGER_SERVER_PRODUCTION_URL }} - SECURITY_CLIENT_URL_PRODUCTION=${{ secrets.SECURITY_CLIENT_URL_PRODUCTION }} - SECURITY_CLIENT_URL_DEVELOPMENT=${{ secrets.SECURITY_CLIENT_URL_DEVELOPMENT }} diff --git a/README.md b/README.md index 069cd731..b890d97a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ MZ세대는 연인과의 갈등 원인으로 '의사소통 방식'과 '성향 ### 1. 애착 유형 진단 - ECR 검사 문항 기반 애착 유형 진단 -- 커플 연동으로 서로의 결과 공유 및 AI 상담에 활용 +- 사용자가 직접 입력한 커플/상대 정보 기반으로 결과를 해석하고 AI 상담에 활용 ### 2. AI 갈등 상담 @@ -57,6 +57,50 @@ MZ세대는 연인과의 갈등 원인으로 '의사소통 방식'과 '성향 +--- + +## LLM / CI-CD 설정 + +- 현재 기본 LLM 구현체는 OpenAI client이며, 기본 모델은 `gpt-5.4-mini`입니다. +- Gemini 구현체도 코드에 유지되며, 필요 시 `@Primary`를 이동해 전환할 수 있습니다. +- QA/Prod 배포 workflow는 API Key, Base URL, Model 같은 민감/배포 환경별 값만 환경변수로 주입합니다. +- `GEMINI_API_KEY` +- `GEMINI_MODEL` +- `GEMINI_BASE_URL` +- `OPENAI_API_KEY` +- `OPENAI_MODEL` +- `OPENAI_BASE_URL` +- `OPENAI_STATUS_URL` +- `reasoning-effort`는 환경변수가 아니라 profile YML에 직접 선언합니다. +- 권장 YML 예시 +```yml +openai: + api: + reasoning-effort: + default: medium + scenarios: + structured-chat: low + free-conversation: low + validation: none + summary: none + auxiliary-extraction: none + +gemini: + api: + reasoning-effort: + default: high + scenarios: + structured-chat: medium + free-conversation: low + validation: low + summary: low + auxiliary-extraction: low +``` +- 롤백 절차 +- OpenAI 구현체에 `@Primary`를 옮깁니다. +- 필요 시 `OPENAI_MODEL`만 조정합니다. +- 재배포합니다. reasoning effort 조정은 `application-*.yml` 수정으로 처리합니다. + --- ## 🚎 Architecture diff --git a/docker-compose.yml b/docker-compose.yml index f9d790e3..d91b55f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: - KAKAO_REST_API_KEY=${KAKAO_REST_API_KEY} - APPLE_REST_API_KEY=${APPLE_REST_API_KEY} - OPENAI_API_KEY=${OPENAI_API_KEY} + - GEMINI_API_KEY=${GEMINI_API_KEY} - SWAGGER_SERVER_PRODUCTION_URL=${SWAGGER_SERVER_PRODUCTION_URL} - SECURITY_CLIENT_URL_PRODUCTION=${SECURITY_CLIENT_URL_PRODUCTION} - SECURITY_CLIENT_URL_DEVELOPMENT=${SECURITY_CLIENT_URL_DEVELOPMENT} diff --git a/docs/API-CHANGES-CHAT-PROMPT-PERSONALITY-TYPE-LOVETYPE.md b/docs/API-CHANGES-CHAT-PROMPT-PERSONALITY-TYPE-LOVETYPE.md new file mode 100644 index 00000000..98b98b86 --- /dev/null +++ b/docs/API-CHANGES-CHAT-PROMPT-PERSONALITY-TYPE-LOVETYPE.md @@ -0,0 +1,141 @@ +# 채팅 프롬프트 변경 사항 - personalityType + 애착유형 조합 프롬프트 주입 + +## 개요 + +채팅 시스템 메시지의 `[사용자 메타데이터]` 블록에 사용자와 상대방의 `personalityType + 애착유형` 조합별 프롬프트를 함께 주입합니다. + +이 변경의 목적은 다음과 같습니다. + +1. 상담 모델이 사용자 성향과 상대방 성향을 더 구체적으로 이해하도록 돕기 +2. `UNKNOWN` 상태를 명시적으로 전달해 대화 문맥으로 추론하게 만들기 +3. 조합 데이터가 일부 비어 있어도 채팅을 실패시키지 않고 안전하게 계속 진행하기 + +--- + +## 데이터 소스 + +### 신규 테이블 + +`love_type_personality_type_prompt` + +| column | type | description | +| --- | --- | --- | +| `personality_type` | `VARCHAR(4)` | MBTI 4자리 문자열 | +| `lovetype` | `VARCHAR(255)` | `LoveTypeCategory` enum 문자열 | +| `prompts` | `TEXT` | 채팅 메타데이터에 삽입할 프롬프트 전문 | + +### 키 규칙 + +- `(personality_type, lovetype)` 복합 PK +- 애플리케이션은 MBTI를 대문자로 정규화해 조회합니다. +- `UNKNOWN` row는 저장하지 않습니다. + +### DDL + +```sql +CREATE TABLE love_type_personality_type_prompt ( + personality_type VARCHAR(4) NOT NULL, + lovetype VARCHAR(255) NOT NULL, + prompts TEXT, + PRIMARY KEY (personality_type, lovetype) +); +``` + +실제 배포용 SQL은 `sqls/MM-181.sql`에 있습니다. + +--- + +## 채팅 메타데이터 주입 규칙 + +프롬프트는 `ChatPromptBuilder`에서 `[사용자 메타데이터]` 문자열을 만들 때 삽입됩니다. + +### 추가되는 항목 + +```text +- 사용자 성향 프롬프트: +{사용자 조합 프롬프트 또는 폴백 문구} + +- 상대방 성향 프롬프트: +{상대방 조합 프롬프트 또는 폴백 문구} +``` + +### 사용자 본인 + +- `member.personalityType`와 `member.loveTypeCategory`가 모두 있으면 `love_type_personality_type_prompt`를 조회합니다. +- 조합 row가 존재하면 `prompts` 컬럼 값을 그대로 삽입합니다. +- 둘 중 하나라도 없거나 조합 row가 없으면 아래 폴백 문구를 삽입합니다. + +```text +UNKNOWN, 사용자와의 대화로부터 유추할 것 +``` + +### 상대방 + +- `otherPersonalityType`가 있는 경우에만 `- 상대방 성향 프롬프트:` 항목을 추가합니다. +- `partnerLoveTypeCategory`가 `UNKNOWN` 또는 `null`이면 DB 조회 없이 바로 폴백 문구를 삽입합니다. +- 다만 1단계 완료 후 생성되는 **2단계 첫 분석 메시지 직후** 내부 추론이 성공하면, 이후부터는 저장된 확정값으로 조회됩니다. +- `otherPersonalityType`와 확정된 `partnerLoveTypeCategory`가 모두 있으면 `(otherPersonalityType, partnerLoveTypeCategory)` 조합으로 조회합니다. +- 조합 row가 없으면 채팅은 실패시키지 않고 동일한 폴백 문구를 삽입합니다. + +--- + +## 런타임 동작 + +### 성공 케이스 + +- 사용자 조합 row가 있으면 사용자 성향 프롬프트에 해당 전문이 들어갑니다. +- 상대방 조합 row가 있으면 상대방 성향 프롬프트에 해당 전문이 들어갑니다. + +### 폴백 케이스 + +아래 경우에는 모두 동일한 폴백 문구를 사용합니다. + +- 사용자 personalityType 없음 +- 사용자 애착유형 없음 +- 상대방 personalityType 없음 +- 상대방 애착유형이 `UNKNOWN` +- 상대방 애착유형이 `null` +- 조합 row 없음 +- `prompts` 값이 비어 있음 + +### 로깅 + +- MBTI/애착유형 값은 존재하지만 조합 row가 없는 경우 `warn` 로그를 남깁니다. +- 이 경우에도 채팅 요청은 실패하지 않습니다. + +--- + +## 외부 API 영향 + +외부 HTTP API 스펙 변경은 없습니다. + +다만 아래 프로필 데이터가 채팅 프롬프트 생성에 직접 활용됩니다. + +- `GET /members`의 `personalityType` +- `GET /members`의 `loveTypeCategory` +- `GET /members`의 `otherPersonalityType` +- `GET /members`의 `partnerLoveTypeCategory` + +추가로, 사용자가 상대방 애착유형을 입력하지 않았더라도 1단계 완료 후 2단계 첫 분석 메시지 직후 내부 추론 결과가 `partnerLoveTypeCategory`에 저장될 수 있습니다. +관련 상세 내용은 `docs/API-CHANGES-PARTNER-LOVETYPE-INFERENCE-PERSISTENCE.md`를 참고합니다. + +--- + +## 테스트 + +검증한 항목: + +- MBTI 대소문자 무관 조회 +- 사용자 조합 프롬프트 삽입 +- 사용자 정보 없음 시 폴백 문구 삽입 +- 상대방 조합 프롬프트 삽입 +- 상대방 애착유형 `UNKNOWN` 시 폴백 문구 삽입 +- 상대방 프로필 미등록 시 상대방 성향 프롬프트 항목 생략 +- 조합 row 누락 시 예외 없이 폴백 처리 + +관련 테스트: + +- `src/test/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilderTest.java` +- `src/test/java/makeus/cmc/malmo/integration_test/LoveTypePersonalityTypePromptPersistenceAdapterTest.java` + +실제 시스템 메시지 예시는 `docs/CHAT-PROMPT-PERSONALITY-TYPE-LOVETYPE-EXAMPLE.md`를 참고합니다. diff --git a/docs/API-CHANGES-LOVETYPE-DATA.md b/docs/API-CHANGES-LOVETYPE-DATA.md new file mode 100644 index 00000000..9a8a1977 --- /dev/null +++ b/docs/API-CHANGES-LOVETYPE-DATA.md @@ -0,0 +1,87 @@ +## 신규 API 스펙 +### `POST /members/partners` — 상대방 프로필 최초 등록 +**Request** +```ts +{ + personalityType: string, + loveTypeCategory: 'STABLE_TYPE' | 'ANXIETY_TYPE' | 'AVOIDANCE_TYPE' | 'CONFUSION_TYPE' | null + // null = "모르겠어요" 선택 +} +``` +**Response** +```ts +{ + personalityType: string, + loveTypeCategory: 'STABLE_TYPE' | 'ANXIETY_TYPE' | 'AVOIDANCE_TYPE' | 'CONFUSION_TYPE' | 'UNKNOWN', + description: string +} +``` +--- +### `PATCH /members/partners` — 상대방 프로필 수정 +두 필드를 항상 함께 전송해야 하며, 전달된 값으로 기존 값을 덮어씁니다. + +**Request** +```ts +{ + personalityType: string, // 영문 4자리 (예: "INTJ") + loveTypeCategory: 'STABLE_TYPE' | 'ANXIETY_TYPE' | 'AVOIDANCE_TYPE' | 'CONFUSION_TYPE' | null + // null = "모르겠어요" 선택 +} +``` +**Response** +```ts +{ + personalityType: string, + loveTypeCategory: 'STABLE_TYPE' | 'ANXIETY_TYPE' | 'AVOIDANCE_TYPE' | 'CONFUSION_TYPE' | 'UNKNOWN', + description: string +} +``` +--- +### `PATCH /members` — 기존 수정 +기존 필드 유지, 아래 필드 추가 +```ts +{ + loveTypeCategory?: 'STABLE_TYPE' | 'ANXIETY_TYPE' | 'AVOIDANCE_TYPE' | 'CONFUSION_TYPE' +} +``` +--- +### `GET /members` 응답 필드 추가 +기존 응답 유지, 아래 필드 추가 +```ts +{ + personalityType: string, // 내 MBTI + loveTypeCategory: enum, // 내 애착유형 + otherPersonalityType: string, // 상대 MBTI + partnerLoveTypeCategory: 'STABLE_TYPE' | 'ANXIETY_TYPE' | 'AVOIDANCE_TYPE' | 'CONFUSION_TYPE' | 'UNKNOWN' + // undefined = 미입력 / UNKNOWN = "모르겠어요" 선택됨 + // 또는 1단계 완료 후 2단계 첫 분석 메시지 직후 내부 추론값이 저장될 수 있음 +} +``` + +--- + +## 채팅 프롬프트 활용 + +사용자와 상대방 프로필의 `personalityType`, `loveTypeCategory`, `otherPersonalityType`, `partnerLoveTypeCategory`는 채팅 시스템 메시지의 메타데이터 구성에도 사용됩니다. + +### 활용 규칙 + +- 사용자 본인 + - `personalityType`와 `loveTypeCategory`가 모두 있으면 `(personalityType, lovetype)` 조합으로 상세 프롬프트를 조회합니다. + - 둘 중 하나라도 없거나 매칭 row가 없으면 `UNKNOWN, 사용자와의 대화로부터 유추할 것`을 사용합니다. +- 상대방 + - `otherPersonalityType`가 있을 때만 상대방 성향 프롬프트 항목이 추가됩니다. + - `partnerLoveTypeCategory`가 `UNKNOWN` 또는 `null`이면 DB 조회 없이 `UNKNOWN, 사용자와의 대화로부터 유추할 것`을 사용합니다. + - 이후 1단계 완료 후 생성되는 2단계 첫 분석 메시지 직후 내부 추론이 성공하면, 저장된 확정값으로 프롬프트를 조회합니다. + - `otherPersonalityType`와 확정된 `partnerLoveTypeCategory`가 모두 있으면 `(otherPersonalityType, partnerLoveTypeCategory)` 조합으로 상세 프롬프트를 조회합니다. + - 매칭 row가 없으면 채팅은 실패하지 않고 동일한 폴백 문구를 사용합니다. + +### 조회 대상 테이블 + +- `love_type_personality_type_prompt` + - `personality_type`: MBTI 4자리 문자열 + - `lovetype`: `STABLE_TYPE | ANXIETY_TYPE | AVOIDANCE_TYPE | CONFUSION_TYPE` + - `prompts`: 실제 채팅 메타데이터에 삽입할 프롬프트 전문 + +상세 동작 예시는 `docs/API-CHANGES-CHAT-PROMPT-PERSONALITY-TYPE-LOVETYPE.md`, 실제 전달 예시는 `docs/CHAT-PROMPT-PERSONALITY-TYPE-LOVETYPE-EXAMPLE.md`, +영속화 동작은 `docs/API-CHANGES-PARTNER-LOVETYPE-INFERENCE-PERSISTENCE.md`를 참고합니다. diff --git a/docs/API-CHANGES-LOVETYPE-PERSONALITY-TYPE-RESULT.md b/docs/API-CHANGES-LOVETYPE-PERSONALITY-TYPE-RESULT.md new file mode 100644 index 00000000..1743bc65 --- /dev/null +++ b/docs/API-CHANGES-LOVETYPE-PERSONALITY-TYPE-RESULT.md @@ -0,0 +1,51 @@ +## 신규 API 스펙 + +### `GET /love-types/result` - personalityType + 애착유형 상세 결과 조회 + +`personalityType`와 `lovetype` 조합으로 상세 결과를 조회합니다. + +기존 API와의 차이: +- `POST /love-types/result`: 검사 답변을 제출하고 임시 결과를 생성 +- `GET /love-types/result/{loveTypeId}`: 생성된 임시 검사 결과를 조회 +- `GET /love-types/result`: `(personalityType, lovetype)` 조합에 대응하는 고정 상세 콘텐츠를 조회 + +## Request + +| name | type | required | description | +| --- | --- | --- | --- | +| `personalityType` | String | Y | 영문 4자리 MBTI. 대소문자 무관, 내부적으로 대문자로 정규화 | +| `lovetype` | String | Y | `STABLE_TYPE`, `ANXIETY_TYPE`, `AVOIDANCE_TYPE`, `CONFUSION_TYPE` 중 하나 | + +```http +GET /love-types/result?personalityType=enfp&lovetype=stable_type +``` + +## Response + +```ts +{ + personalityType: string, + loveTypeCategory: 'STABLE_TYPE' | 'ANXIETY_TYPE' | 'AVOIDANCE_TYPE' | 'CONFUSION_TYPE', + summary: string, + keywords: string[], + strengths: Array<{ title: string | null, description: string | null }>, + weaknesses: Array<{ title: string | null, description: string | null }>, + patterns: Array<{ title: string | null, description: string | null }>, + loveTypeFeatures: Array<{ title: string | null, description: string | null }>, + datingGuides: Array<{ title: string, description: string | null }>, + bestMatches: Array<{ personalityType: string | null, description: string | null }>, + worstMatches: Array<{ personalityType: string | null, description: string | null }> +} +``` + +## DB 매핑 기준 + +- 조회 대상 테이블은 `love_type_personality_type_feature`입니다. +- 복합키는 `(personality_type, lovetype)`입니다. +- 궁합 컬럼은 `best_personality_type1/2`, `worst_personality_type1/2`를 사용합니다. + +## 에러 응답 + +- `personalityType`가 영문 4자리가 아닌 경우 +- `lovetype`이 허용 enum 값이 아닌 경우 +- `personalityType + lovetype` 조합에 해당하는 row가 없는 경우 diff --git a/docs/API-CHANGES-ONBOARDING-PROFILE.md b/docs/API-CHANGES-ONBOARDING-PROFILE.md index 30182382..24da61ff 100644 --- a/docs/API-CHANGES-ONBOARDING-PROFILE.md +++ b/docs/API-CHANGES-ONBOARDING-PROFILE.md @@ -2,11 +2,14 @@ ## 개요 -온보딩 및 멤버 프로필에 다음 3개 필드가 추가되었습니다: +멤버 프로필에는 다음 3개 필드가 추가되었습니다: - `relationshipStatus` - 연애 상태 (Enum) - `personalityType` - 본인 MBTI (String) - `otherPersonalityType` - 상대방 MBTI (String) +추가로, 커플 초대 코드 기반 연동 플로우는 deprecated 되었고 신규 사용자 플로우는 직접 입력 방식으로 전환되었습니다. +단, `POST /members/onboarding`에서는 이제 `relationshipStatus`만 받고 `personalityType`, `otherPersonalityType`는 받지 않습니다. + --- ## 신규 Enum @@ -40,8 +43,6 @@ { "nickname": "닉네임", "relationshipStatus": "IN_RELATIONSHIP", - "personalityType": "INTJ", - "otherPersonalityType": "ENFP", "terms": [ { "termsId": 1, "isAgreed": true } ] @@ -52,10 +53,11 @@ |------|------|-----------|------| | `nickname` | String | 필수 | 닉네임 (1-10자, 한글/영문/숫자) | | `relationshipStatus` | Enum | 선택 | 연애 상태 | -| `personalityType` | String | 선택 | 본인 MBTI | -| `otherPersonalityType` | String | 선택 | 상대방 MBTI | | `terms` | Array | 필수 | 약관 동의 목록 | +> **Note:** 커플/상대 관련 정보는 초대 코드 연동이 아니라 사용자의 직접 입력을 기준으로 관리합니다. +> **Note:** `personalityType`, `otherPersonalityType`는 회원가입 요청에서는 제거되었고, 필요 시 `PATCH /members`에서 관리합니다. + --- ### 2. GET /members (멤버 정보 조회) @@ -67,7 +69,7 @@ "nickname": "닉네임", "email": "user@example.com", "provider": "KAKAO", - "loveTypeCategory": "SECURE", + "loveTypeCategory": "STABLE_TYPE", "anxietyRate": 0.3, "avoidanceRate": 0.2, "inviteCode": "ABC123", @@ -84,7 +86,7 @@ "nickname": "닉네임", "email": "user@example.com", "provider": "KAKAO", - "loveTypeCategory": "SECURE", + "loveTypeCategory": "STABLE_TYPE", "anxietyRate": 0.3, "avoidanceRate": 0.2, "inviteCode": "ABC123", @@ -93,7 +95,7 @@ "loveDay": 365, "relationshipStatus": "IN_RELATIONSHIP", "personalityType": "INTJ", - "otherPersonalityType": "ENFP" + "otherPersonalityType": "INTJ" } ``` @@ -104,6 +106,7 @@ | `otherPersonalityType` | String (nullable) | 상대방 MBTI | > **Note:** 기존 사용자의 경우 위 필드들이 `null`로 반환될 수 있습니다. +> **Note:** 응답의 `inviteCode`, `isCouple`, `startLoveDate`는 레거시 커플 연동 흐름과 연결된 필드이므로 신규 클라이언트 플로우의 기준으로 사용하지 않는 것을 권장합니다. --- @@ -121,7 +124,7 @@ { "nickname": "새닉네임", "relationshipStatus": "SEEING_SOMEONE", - "personalityType": "ENFP", + "personalityType": "INTJ", "otherPersonalityType": "INTJ" } ``` @@ -147,13 +150,58 @@ { "nickname": "새닉네임", "relationshipStatus": "SEEING_SOMEONE", - "personalityType": "ENFP", + "personalityType": "INTJ", "otherPersonalityType": "INTJ" } ``` --- +### 4. POST /members/partners (상대 프로필 최초 등록) + +| 필드 | 타입 | 필수 여부 | 설명 | +|------|------|-----------|------| +| `personalityType` | String | **필수** | 상대방 MBTI (영문 4자리, 대소문자 무관) | +| `loveTypeCategory` | Enum | 선택 | 상대방 애착 유형 | + +> **Note:** `loveTypeCategory`를 생략하면 `null`로 저장됩니다. `UNKNOWN`은 사용자가 명시적으로 선택한 경우에 설정됩니다. 다만 이후 채팅에서 1단계 완료 후 2단계 첫 분석 메시지 직후 내부 추론을 통해 확정값으로 갱신될 수 있습니다. + +**예시 - MBTI만 등록 (loveTypeCategory는 나중에 PATCH로 설정 가능):** +```json +{ "personalityType": "INTJ" } +``` + +**예시 - 두 필드 모두 등록:** +```json +{ + "personalityType": "INTJ", + "loveTypeCategory": "STABLE_TYPE" +} +``` + +--- + +### 5. PATCH /members/partners (상대 프로필 수정) + +| 필드 | 타입 | 필수 여부 | 설명 | +|------|------|-----------|------| +| `personalityType` | String | 선택 | 상대방 MBTI (영문 4자리, 대소문자 무관) | +| `loveTypeCategory` | Enum | 선택 | 상대방 애착 유형 | + +> **Note:** 필드를 생략하거나 `null`로 전달하면 해당 필드는 기존 값을 유지합니다. + +**예시 - personalityType만 수정:** +```json +{ "personalityType": "INTJ" } +``` + +**예시 - loveTypeCategory만 수정 (MBTI만 등록한 후 애착유형 추가 시 사용):** +```json +{ "loveTypeCategory": "STABLE_TYPE" } +``` + +--- + ## 마이그레이션 가이드 ### 기존 사용자 처리 @@ -163,7 +211,7 @@ ### 온보딩 화면 업데이트 1. 닉네임 입력 후 추가 정보 입력 화면 구성 2. 연애 상태 선택 (3가지 옵션) -3. MBTI 입력 (본인/상대방) +3. MBTI 입력은 온보딩 이후 프로필 수정 플로우로 이동 ### 프로필 수정 화면 업데이트 1. 기존 닉네임 수정 기능 유지 @@ -178,7 +226,7 @@ - `Member.java` - 3개 필드 추가, `signUp()` 오버로드, `updateMemberProfile()` 확장 ### Application Layer -- `SignUpUseCase.java` - SignUpCommand에 3개 필드 추가 +- `SignUpUseCase.java` - SignUpCommand에 연애 상태 필드 추가 - `GetMemberUseCase.java` - MemberResponseDto에 3개 필드 추가 - `UpdateMemberUseCase.java` - Command/ResponseDto에 3개 필드 추가 - `SignUpService.java` - 새 signUp 메서드 호출 @@ -191,7 +239,7 @@ - `MemberMapper.java` - 양방향 매핑 확장 - `MemberPersistenceAdapter.java` - RepositoryDto에 3개 필드 추가 - `MemberRepositoryCustomImpl.java` - QueryDSL select 확장 -- `SignUpController.java` - RequestDto에 3개 필드 추가 +- `SignUpController.java` - RequestDto에 `relationshipStatus` 추가 - `MemberController.java` - UpdateMemberRequestDto에 3개 필드 추가 ### Enum diff --git a/docs/API-CHANGES-PARTNER-LOVETYPE-INFERENCE-PERSISTENCE.md b/docs/API-CHANGES-PARTNER-LOVETYPE-INFERENCE-PERSISTENCE.md new file mode 100644 index 00000000..d2cb91fc --- /dev/null +++ b/docs/API-CHANGES-PARTNER-LOVETYPE-INFERENCE-PERSISTENCE.md @@ -0,0 +1,105 @@ +# 상대방 애착유형 내부 추론 영속화 변경 사항 + +## 개요 + +사용자가 상대방 애착유형(`partnerLoveTypeCategory`)을 직접 입력하지 않았거나 `UNKNOWN`으로 남겨둔 경우, +1단계 상황 수집이 완료된 뒤 생성되는 **2단계 첫 분석 메시지 직후**에 챗봇이 상대방 애착유형을 내부적으로 추론하고 `member.partnerLoveTypeCategory`에 저장합니다. + +이번 변경의 목적은 다음과 같습니다. + +1. 1단계에서 수집된 갈등 상황 정보를 바탕으로 더 이른 시점에 상대방 애착유형을 확정하기 +2. 2단계 분석 메시지와 동일한 맥락을 사용해 추론 결과의 일관성을 높이기 +3. 3단계 이후 프롬프트에서 확정된 상대방 애착유형을 재사용해 상담 품질을 높이기 + +--- + +## 동작 시점 + +추론은 다음 조건을 모두 만족할 때 실행됩니다. + +- 현재 완료된 단계가 `1단계`의 마지막 세부 단계임 +- 1단계 완료 후 시스템이 `2단계`의 첫 분석 메시지를 생성함 +- `partnerLoveTypeCategory`가 `null` 또는 `UNKNOWN`임 + +즉, **2단계 종료 시점이 아니라 2단계 첫 분석 메시지가 생성된 직후**에 실행됩니다. + +--- + +## 추론 규칙 + +추론은 별도 내부 프롬프트(`GlobalConstants.PARTNER_LOVE_TYPE_INFERENCE_PROMPT`)를 사용하며, +입력 컨텍스트에는 아래 내용이 포함됩니다. + +- 사용자 메타데이터 +- 이전 단계 요약 메타데이터 +- 2단계 레벨의 대화 메시지들 + - 여기에는 방금 생성된 챗봇의 2단계 첫 분석 메시지가 포함됩니다. + +반환값은 아래 4개 enum 중 하나만 허용됩니다. + +- `STABLE_TYPE` +- `ANXIETY_TYPE` +- `AVOIDANCE_TYPE` +- `CONFUSION_TYPE` + +`UNKNOWN`은 추론 결과로 허용하지 않습니다. + +응답 형식은 JSON 객체로 고정됩니다. + +```json +{ + "partnerLoveTypeCategory": "STABLE_TYPE" +} +``` + +--- + +## 저장 규칙 + +- 저장 대상 필드: `member.partnerLoveTypeCategory` +- 저장 조건: + - 현재 DB 값이 `null` 또는 `UNKNOWN`일 때만 반영 + - 이미 사용자가 직접 입력한 확정값이 있으면 덮어쓰지 않음 +- 저장 직전에는 `Member`를 다시 조회해 최신 DB 상태를 확인합니다. + +### 실패 처리 + +아래 경우에는 저장하지 않고 상담 흐름만 계속 진행합니다. + +- JSON 파싱 실패 +- enum 값 검증 실패 +- `UNKNOWN` 반환 +- 저장 직전 재조회 결과 이미 확정값이 존재함 +- 영속화 과정 예외 발생 + +--- + +## 외부 API 영향 + +외부 요청/응답 스펙 변경은 없습니다. + +다만 `GET /members`의 `partnerLoveTypeCategory`는 다음과 같은 값이 들어올 수 있습니다. + +- 사용자가 직접 입력한 값 +- 사용자가 `UNKNOWN`으로 설정한 값 +- 내부 2단계 분석 직후 추론되어 저장된 값 + +즉, 클라이언트는 `partnerLoveTypeCategory`가 이전보다 더 이른 시점에 확정될 수 있음을 고려해야 합니다. + +--- + +## 테스트 + +검증한 항목: + +- `AUXILIARY_EXTRACTION` 시나리오로 JSON 추론 호출 +- `UNKNOWN` 및 비정상 enum 값 거부 +- 1단계 완료 후 2단계 첫 분석 메시지 직후 추론 및 저장 +- 이미 확정된 상대방 애착유형이 있으면 추론/저장 생략 +- 추론 실패 시 저장 없이 다음 단계 진행 +- 2단계 종료 시점에는 더 이상 추론하지 않음 + +관련 테스트: + +- `src/test/java/makeus/cmc/malmo/application/service/chat/ChatProcessorTest.java` +- `src/test/java/makeus/cmc/malmo/application/service/chat/ChatMessageServiceTest.java` diff --git a/docs/API-DEPRECATION-COUPLE-LINKING.md b/docs/API-DEPRECATION-COUPLE-LINKING.md new file mode 100644 index 00000000..3d282d6c --- /dev/null +++ b/docs/API-DEPRECATION-COUPLE-LINKING.md @@ -0,0 +1,59 @@ +# API 변경 사항 - 커플 연동 API Deprecated + +## 개요 + +커플 초대 코드 기반 연동 기능은 제거 예정입니다. +앞으로는 사용자가 커플 및 상대 정보를 직접 입력하는 방식으로 전환합니다. + +이번 변경에서는 기존 API를 즉시 삭제하지 않고 `Deprecated` 상태로 전환하여 클라이언트가 점진적으로 마이그레이션할 수 있도록 합니다. + +--- + +## Deprecated 대상 API + +### 1. `POST /couples` + +- 상태: Deprecated +- 기존 역할: 초대 코드로 커플 연동 +- 변경 방향: 더 이상 신규 연동 플로우의 기준 API로 사용하지 않습니다. + +### 2. `GET /members/invite-code` + +- 상태: Deprecated +- 기존 역할: 현재 사용자의 커플 초대 코드 조회 +- 변경 방향: 초대 코드 기반 연결 흐름이 제거되므로 신규 클라이언트에서는 사용하지 않습니다. + +### 3. `DELETE /couples` + +- 상태: Deprecated +- 기존 역할: 연결된 커플 해제 +- 변경 방향: 커플 연동 모델 축소에 따라 제거 예정입니다. + +### 4. `PATCH /members/start-love-date` + +- 상태: Deprecated +- 기존 역할: 연동된 커플의 연애 시작일 수정 +- 변경 방향: 연애/커플 정보는 사용자 입력 기반 프로필 관리 방식으로 전환합니다. + +--- + +## 클라이언트 대응 가이드 + +- 신규 앱/웹 플로우에서는 위 4개 API를 호출하지 않아야 합니다. +- 커플 관련 정보는 사용자 입력 기반 프로필/온보딩 필드를 사용해 관리해야 합니다. +- Swagger 문서에서 위 API들은 `deprecated`로 표시됩니다. + +--- + +## 서버 반영 내용 + +- 컨트롤러 메서드에 `@Deprecated` 적용 +- OpenAPI `deprecated = true` 적용 +- 관련 Swagger 응답 스키마 설명에 deprecated 표기 추가 + +--- + +## 비고 + +현재 단계에서는 하위 호환성을 위해 엔드포인트 자체는 유지합니다. +실제 제거 시점에는 별도 릴리즈 노트와 함께 삭제 일정을 안내해야 합니다. diff --git a/docs/CHAT-PROMPT-PERSONALITY-TYPE-LOVETYPE-EXAMPLE.md b/docs/CHAT-PROMPT-PERSONALITY-TYPE-LOVETYPE-EXAMPLE.md new file mode 100644 index 00000000..6f02de51 --- /dev/null +++ b/docs/CHAT-PROMPT-PERSONALITY-TYPE-LOVETYPE-EXAMPLE.md @@ -0,0 +1,138 @@ +# 채팅 프롬프트 예시 - personalityType + 애착유형 메타데이터 주입 + +## 목적 + +이 문서는 실제 채팅 요청 시 모델에 전달되는 메타데이터가 어떤 형태인지 예시를 보여줍니다. + +주의: + +- 아래 예시는 `ChatPromptBuilder`가 만드는 메시지 중 첫 번째 `system` 메시지의 예시입니다. +- 실제 요청에는 이 메타데이터 뒤에 기존 대화 이력, 단계별 요약, 현재 사용자 메시지가 순서대로 붙습니다. + +--- + +## 예시 1. 사용자/상대방 모두 조합 데이터가 있는 경우 + +가정: + +- 사용자 personalityType: `ISTJ` +- 사용자 애착유형: `STABLE_TYPE` +- 상대방 personalityType: `ENFP` +- 상대방 애착유형: `ANXIETY_TYPE` +- 두 조합 모두 `love_type_personality_type_prompt` 테이블에 row 존재 + +실제로 모델에 들어가는 첫 system 메시지 예시: + +```text +[사용자 메타데이터] +- 사용자 이름: 다은 +- 사용자 연애 상태: IN_RELATIONSHIP +- 사용자 MBTI: ISTJ +- 상대방 MBTI: ENFP +- 사용자 애착 유형: 안정형 +- 애인 애착 유형: 불안형 +- 사용자 성향 프롬프트: +ISTJ 안정형 + +# 애착 결합 행동 패턴 + +약속과 책임을 최우선하며 일관된 행동으로... + +- 상대방 성향 프롬프트: +ENFP 불안형 + +# 애착 결합 행동 패턴 + +감정적 연결이 흔들린다고 느끼면... +``` + +--- + +## 예시 2. 상대방 애착유형이 UNKNOWN인 경우 + +가정: + +- 사용자 personalityType: `ISTJ` +- 사용자 애착유형: `STABLE_TYPE` +- 상대방 personalityType: `ENFP` +- 상대방 애착유형: `UNKNOWN` + +```text +[사용자 메타데이터] +- 사용자 이름: 다은 +- 사용자 연애 상태: IN_RELATIONSHIP +- 사용자 MBTI: ISTJ +- 상대방 MBTI: ENFP +- 사용자 애착 유형: 안정형 +- 애인 애착 유형: 모르겠어요 +- 사용자 성향 프롬프트: +ISTJ 안정형 + +# 애착 결합 행동 패턴 + +약속과 책임을 최우선하며... + +- 상대방 성향 프롬프트: +UNKNOWN, 사용자와의 대화로부터 유추할 것 +``` + +핵심: + +- `otherPersonalityType`가 있으므로 상대방 성향 프롬프트 항목은 생성됩니다. +- `partnerLoveTypeCategory == UNKNOWN` 이므로 DB 조회 없이 폴백 문구가 들어갑니다. + +--- + +## 예시 3. 사용자 조합 row가 아직 없는 경우 + +가정: + +- 사용자 personalityType: `INFP` +- 사용자 애착유형: `CONFUSION_TYPE` +- `love_type_personality_type_prompt`에 `(INFP, CONFUSION_TYPE)` row 없음 + +```text +[사용자 메타데이터] +- 사용자 이름: 다은 +- 사용자 연애 상태: SEEING_SOMEONE +- 사용자 MBTI: INFP +- 상대방 MBTI: 알 수 없음 +- 사용자 애착 유형: 혼란형 +- 애인 애착 유형: 알 수 없음 +- 사용자 성향 프롬프트: +UNKNOWN, 사용자와의 대화로부터 유추할 것 +``` + +핵심: + +- 사용자 프로필 값은 있어도 row가 없으면 채팅을 실패시키지 않습니다. +- 애플리케이션은 `warn` 로그를 남기고 폴백 문구를 삽입합니다. + +--- + +## OpenAI 요청 메시지 배열 예시 + +실제 요청 직전 메시지 배열은 대략 아래 순서가 됩니다. + +```json +[ + { + "role": "system", + "content": "[사용자 메타데이터] ..." + }, + { + "role": "system", + "content": "[사용자의 갈등 내용] ..." + }, + { + "role": "assistant", + "content": "이전 상담 응답" + }, + { + "role": "user", + "content": "제가 먼저 연락해야 할지 모르겠어요." + } +] +``` + +즉, 이번 기능은 별도 API 필드를 추가하는 방식이 아니라, 모델에 전달되는 첫 `system` 메시지의 메타데이터 내용을 더 풍부하게 만드는 방식으로 동작합니다. diff --git a/sqls/MM-188-release-migration.sql b/sqls/MM-188-release-migration.sql new file mode 100644 index 00000000..be20bf02 --- /dev/null +++ b/sqls/MM-188-release-migration.sql @@ -0,0 +1,82 @@ +-- MM-188 release migration +-- Target DB: MySQL 8.x +-- +-- Applies the schema required by: +-- 1. Direct partner love type persistence on member_entity +-- 2. MBTI + love type prompt enrichment +-- 3. MBTI + love type detailed result lookup +-- +-- NOTE: +-- - Run this once before deploying the application version that contains MM-188. +-- - This file intentionally replaces the split MM-180/MM-181 migration contents for release deployment. +-- - Production uses spring.jpa.hibernate.ddl-auto=none, so both tables below must exist before traffic reaches +-- /love-types/result or chat prompt enrichment paths. + +ALTER TABLE member_entity + ADD COLUMN partner_love_type_category VARCHAR(255) NULL + COMMENT 'Partner love type category: STABLE_TYPE, ANXIETY_TYPE, AVOIDANCE_TYPE, CONFUSION_TYPE, UNKNOWN'; + +CREATE TABLE love_type_personality_type_prompt ( + personality_type VARCHAR(4) NOT NULL COMMENT 'MBTI in 4-letter uppercase format', + lovetype VARCHAR(255) NOT NULL COMMENT 'Love type category: STABLE_TYPE, ANXIETY_TYPE, AVOIDANCE_TYPE, CONFUSION_TYPE', + prompts TEXT NULL COMMENT 'Prompt content to inject into chat metadata', + PRIMARY KEY (personality_type, lovetype) +) COMMENT='Personality type and love type specific prompt snippets for chat metadata'; + +CREATE TABLE love_type_personality_type_feature ( + personality_type VARCHAR(4) NOT NULL COMMENT 'MBTI in 4-letter uppercase format', + lovetype VARCHAR(255) NOT NULL COMMENT 'Love type category: STABLE_TYPE, ANXIETY_TYPE, AVOIDANCE_TYPE, CONFUSION_TYPE', + summary TEXT NULL, + keyword1 VARCHAR(255) NULL, + keyword2 VARCHAR(255) NULL, + keyword3 VARCHAR(255) NULL, + strength1 VARCHAR(255) NULL, + strength2 VARCHAR(255) NULL, + strength3 VARCHAR(255) NULL, + weakness VARCHAR(255) NULL, + strength_desc1 TEXT NULL, + strength_desc2 TEXT NULL, + strength_desc3 TEXT NULL, + weakness_desc TEXT NULL, + pattern_title1 VARCHAR(255) NULL, + pattern_title2 VARCHAR(255) NULL, + pattern_title3 VARCHAR(255) NULL, + pattern_title4 VARCHAR(255) NULL, + pattern1 TEXT NULL, + pattern2 TEXT NULL, + pattern3 TEXT NULL, + pattern4 TEXT NULL, + lovetype_feature_title1 VARCHAR(255) NULL, + lovetype_feature_title2 VARCHAR(255) NULL, + lovetype_feature_title3 VARCHAR(255) NULL, + lovetype_feature_title4 VARCHAR(255) NULL, + lovetype_feature1 TEXT NULL, + lovetype_feature2 TEXT NULL, + lovetype_feature3 TEXT NULL, + lovetype_feature4 TEXT NULL, + dating_guide1 TEXT NULL, + dating_guide2 TEXT NULL, + dating_guide3 TEXT NULL, + best_personality_type1 VARCHAR(4) NULL, + best_desc1 TEXT NULL, + best_personality_type2 VARCHAR(4) NULL, + best_desc2 TEXT NULL, + worst_personality_type1 VARCHAR(4) NULL, + worst_desc1 TEXT NULL, + worst_personality_type2 VARCHAR(4) NULL, + worst_desc2 TEXT NULL, + PRIMARY KEY (personality_type, lovetype) +) COMMENT='Detailed result content for MBTI and love type combinations'; + +-- Required production seed data: +-- Insert rows for all supported personality_type/lovetype combinations before enabling the related features. +-- The repository currently contains only test sample content, so real production copy/data should be loaded from +-- the product-approved source of truth. +-- +-- Minimum expected coverage: +-- - love_type_personality_type_prompt: rows used by ChatPromptBuilder for user/partner prompt enrichment. +-- - love_type_personality_type_feature: rows served by GET /love-types/result. +-- +-- Verification queries: +-- SELECT COUNT(*) AS prompt_count FROM love_type_personality_type_prompt; +-- SELECT COUNT(*) AS feature_count FROM love_type_personality_type_feature; diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/exception/ErrorCode.java b/src/main/java/makeus/cmc/malmo/adaptor/in/exception/ErrorCode.java index 50cd2382..3691390e 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/exception/ErrorCode.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/exception/ErrorCode.java @@ -26,6 +26,9 @@ public enum ErrorCode { NO_SUCH_BOOKMARK(HttpStatus.BAD_REQUEST, 40014, "북마크가 존재하지 않습니다."), BOOKMARK_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, 40015, "이미 북마크된 메시지입니다."), NO_SUCH_MESSAGE(HttpStatus.BAD_REQUEST, 40016, "메시지가 존재하지 않습니다."), + PARTNER_PROFILE_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, 40017, "이미 상대 프로필이 등록되어 있습니다."), + NO_SUCH_PARTNER_PROFILE(HttpStatus.BAD_REQUEST, 40018, "등록된 상대 프로필이 존재하지 않습니다."), + NO_SUCH_LOVE_TYPE_PERSONALITY_TYPE_RESULT(HttpStatus.BAD_REQUEST, 40019, "해당 MBTI와 애착 유형 결과가 존재하지 않습니다."), // 401 Unauthorized UNAUTHORIZED(HttpStatus.UNAUTHORIZED, 40100, "인증되지 않은 사용자입니다."), diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/exception/GlobalExceptionHandler.java b/src/main/java/makeus/cmc/malmo/adaptor/in/exception/GlobalExceptionHandler.java index 149dc51d..5a55397e 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/exception/GlobalExceptionHandler.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package makeus.cmc.malmo.adaptor.in.exception; import io.sentry.Sentry; +import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import makeus.cmc.malmo.adaptor.out.exception.OidcIdTokenException; import makeus.cmc.malmo.adaptor.out.exception.RestApiException; @@ -9,6 +10,7 @@ import org.hibernate.TypeMismatchException; import org.springframework.http.ResponseEntity; import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -168,12 +170,35 @@ public ResponseEntity handleMessageNotFoundException(MessageNotFo return ErrorResponse.of(ErrorCode.NO_SUCH_MESSAGE); } + @ExceptionHandler({PartnerProfileAlreadyExistsException.class}) + public ResponseEntity handlePartnerProfileAlreadyExistsException(PartnerProfileAlreadyExistsException e) { + log.info("[GlobalExceptionHandler: handlePartnerProfileAlreadyExistsException 호출] {}", e.getMessage()); + return ErrorResponse.of(ErrorCode.PARTNER_PROFILE_ALREADY_EXISTS); + } + + @ExceptionHandler({PartnerProfileNotFoundException.class}) + public ResponseEntity handlePartnerProfileNotFoundException(PartnerProfileNotFoundException e) { + log.info("[GlobalExceptionHandler: handlePartnerProfileNotFoundException 호출] {}", e.getMessage()); + return ErrorResponse.of(ErrorCode.NO_SUCH_PARTNER_PROFILE); + } + + @ExceptionHandler({LoveTypePersonalityTypeFeatureNotFoundException.class}) + public ResponseEntity handleLoveTypePersonalityTypeFeatureNotFoundException(LoveTypePersonalityTypeFeatureNotFoundException e) { + log.warn("[GlobalExceptionHandler: handleLoveTypePersonalityTypeFeatureNotFoundException 호출] {}", e.getMessage()); + return ErrorResponse.of(ErrorCode.NO_SUCH_LOVE_TYPE_PERSONALITY_TYPE_RESULT); + } + /** * ---------- 공통 예외 처리 핸들러 ---------- */ - @ExceptionHandler({NoHandlerFoundException.class, TypeMismatchException.class}) + @ExceptionHandler({ + NoHandlerFoundException.class, + TypeMismatchException.class, + MissingServletRequestParameterException.class, + ConstraintViolationException.class + }) public ResponseEntity handleBadRequestException(Exception e) { log.warn("[CommonExceptionHandler: handleBadRequestException 호출] {}", e.getMessage()); return ErrorResponse.of(ErrorCode.BAD_REQUEST); @@ -204,4 +229,4 @@ public ResponseEntity handleMethodArgumentNotValidException(Metho log.warn("[CommonExceptionHandler: handleMethodArgumentNotValidException 호출] {}", e.getMessage()); return ErrorResponse.of(ErrorCode.BAD_REQUEST); } -} \ No newline at end of file +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/CoupleController.java b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/CoupleController.java index 57224d62..40feb8a0 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/CoupleController.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/CoupleController.java @@ -20,7 +20,7 @@ import org.springframework.security.core.userdetails.User; import org.springframework.web.bind.annotation.*; -@Tag(name = "커플 관리 API", description = "커플 연결 및 관리 관련 API") +@Tag(name = "커플 관리 API", description = "커플 연결 및 관리 관련 API (Deprecated)") @RestController @RequestMapping("/couples") @RequiredArgsConstructor @@ -31,7 +31,8 @@ public class CoupleController { @Operation( summary = "커플 연결", - description = "커플 초대코드를 사용하여 커플을 연결합니다. JWT 토큰이 필요합니다.", + description = "[Deprecated] 커플 초대코드를 사용하여 커플을 연결합니다. 커플 연동 기능은 제거 예정이며, 앞으로는 사용자가 커플 정보를 직접 입력하는 방식을 사용합니다. JWT 토큰이 필요합니다.", + deprecated = true, security = @SecurityRequirement(name = "Bearer Authentication") ) @ApiResponse( @@ -42,6 +43,7 @@ public class CoupleController { @ApiCommonResponses.RequireAuth @ApiCommonResponses.CoupleCode @PostMapping + @Deprecated public BaseResponse linkCouple( @AuthenticationPrincipal User user, @Valid @RequestBody CoupleLinkRequestDto requestDto @@ -55,7 +57,8 @@ public BaseResponse linkCouple( @Operation( summary = "커플 연결 끊기", - description = "연결된 커플을 끊습니다. JWT 토큰이 필요합니다.", + description = "[Deprecated] 연결된 커플을 끊습니다. 커플 연동 기능은 제거 예정이며, 앞으로는 사용자가 커플 정보를 직접 입력하는 방식을 사용합니다. JWT 토큰이 필요합니다.", + deprecated = true, security = @SecurityRequirement(name = "Bearer Authentication") ) @ApiResponse( @@ -66,6 +69,7 @@ public BaseResponse linkCouple( @ApiCommonResponses.RequireAuth @ApiCommonResponses.OnlyCouple @DeleteMapping + @Deprecated public BaseResponse unlinkCouple( @AuthenticationPrincipal User user ) { @@ -80,6 +84,8 @@ public BaseResponse unlinkCouple( @Data + @Deprecated + @Schema(description = "[Deprecated] 커플 연결 요청 DTO") public static class CoupleLinkRequestDto { @NotBlank(message = "초대코드는 필수 입력값입니다.") @Size(min = 6, max = 8, message = "커플 코드는 6 ~ 8자리여야 합니다.") diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/LoveTypeController.java b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/LoveTypeController.java index 3a69c68a..91f0a85d 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/LoveTypeController.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/LoveTypeController.java @@ -10,6 +10,7 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,14 +19,20 @@ import makeus.cmc.malmo.adaptor.in.web.dto.BaseListResponse; import makeus.cmc.malmo.adaptor.in.web.dto.BaseResponse; import makeus.cmc.malmo.application.port.in.CalculateQuestionResultUseCase; +import makeus.cmc.malmo.application.port.in.GetLoveTypePersonalityTypeResultUseCase; import makeus.cmc.malmo.application.port.in.GetLoveTypeQuestionResultUseCase; import makeus.cmc.malmo.application.port.in.GetLoveTypeQuestionsUseCase; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import org.hibernate.TypeMismatchException; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Locale; @Tag(name = "애착유형 검사 API", description = "애착유형 검사 결과 등록 API") @Slf4j +@Validated @RestController @RequestMapping("/love-types") @RequiredArgsConstructor @@ -34,6 +41,7 @@ public class LoveTypeController { private final GetLoveTypeQuestionsUseCase getLoveTypeQuestionsUseCase; private final CalculateQuestionResultUseCase calculateQuestionResultUseCase; private final GetLoveTypeQuestionResultUseCase getLoveTypeQuestionResultUseCase; + private final GetLoveTypePersonalityTypeResultUseCase getLoveTypePersonalityTypeResultUseCase; @Operation( summary = "애착 유형 검사 질문 조회", @@ -124,6 +132,48 @@ public BaseResponse get return BaseResponse.success(getLoveTypeQuestionResultUseCase.getResult(command)); } + @Operation( + summary = "MBTI + 애착 유형 상세 결과 조회", + description = "MBTI와 애착 유형 조합에 해당하는 상세 결과를 조회합니다." + ) + @ApiResponse( + responseCode = "200", + description = "애착 유형 상세 결과 조회 성공", + content = @Content(schema = @Schema(implementation = SwaggerResponses.LoveTypePersonalityTypeResultSuccessResponse.class)) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = SwaggerErrorResponse.class)) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 또는 존재하지 않는 MBTI/애착 유형 결과", + content = @Content(schema = @Schema(implementation = SwaggerErrorResponse.class)) + ) + }) + @GetMapping("/result") + public BaseResponse getLoveTypePersonalityTypeResult( + @RequestParam("personalityType") + @Pattern(regexp = "^[a-zA-Z]{4}$", message = "MBTI는 영문 4자리여야 합니다.") + String personalityType, + @RequestParam("lovetype") + @Pattern( + regexp = "(?i)^(STABLE_TYPE|ANXIETY_TYPE|AVOIDANCE_TYPE|CONFUSION_TYPE)$", + message = "유효한 애착 유형이 아닙니다." + ) + String loveType + ) { + GetLoveTypePersonalityTypeResultUseCase.GetLoveTypePersonalityTypeResultCommand command = + GetLoveTypePersonalityTypeResultUseCase.GetLoveTypePersonalityTypeResultCommand.builder() + .personalityType(normalizePersonalityType(personalityType)) + .loveTypeCategory(normalizeLoveTypeCategory(loveType)) + .build(); + + return BaseResponse.success(getLoveTypePersonalityTypeResultUseCase.getResult(command)); + } + @Data public static class RegisterLoveTypeRequestDto { @Valid @@ -138,4 +188,16 @@ public static class LoveTypeTestResult { @Max(5) @Min(1) private Integer score; } + + private static String normalizePersonalityType(String personalityType) { + return personalityType == null ? null : personalityType.toUpperCase(Locale.ROOT); + } + + private static LoveTypeCategory normalizeLoveTypeCategory(String loveType) { + try { + return LoveTypeCategory.valueOf(loveType.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new TypeMismatchException("유효한 애착 유형이 아닙니다."); + } + } } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/MemberController.java b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/MemberController.java index 92f36274..4d6c4830 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/MemberController.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/MemberController.java @@ -16,6 +16,8 @@ import makeus.cmc.malmo.adaptor.in.web.dto.BaseListResponse; import makeus.cmc.malmo.adaptor.in.web.dto.BaseResponse; import makeus.cmc.malmo.application.port.in.member.*; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; import makeus.cmc.malmo.domain.value.type.RelationshipStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.User; @@ -35,6 +37,8 @@ public class MemberController { private final GetPartnerUseCase getPartnerUseCase; private final GetInviteCodeUseCase getInviteCodeUseCase; private final UpdateMemberUseCase updateMemberUseCase; + private final CreatePartnerProfileUseCase createPartnerProfileUseCase; + private final UpdatePartnerProfileUseCase updatePartnerProfileUseCase; private final UpdateTermsAgreementUseCase updateTermsAgreementUseCase; private final UpdateMemberLoveTypeUseCase updateMemberLoveTypeUseCase; private final UpdateStartLoveDateUseCase updateStartLoveDateUseCase; @@ -62,8 +66,9 @@ public BaseResponse getMemberInfo( } @Operation( - summary = "커플 상대 정보 조회", - description = "현재 로그인된 멤버의 파트너 정보를 조회합니다. JWT 토큰이 필요합니다.", + summary = "상대 프로필 조회", + description = "[Deprecated] 현재 로그인된 멤버가 직접 입력한 상대 프로필을 조회합니다. 신규 클라이언트는 GET /members 응답의 partner 필드를 사용하세요. JWT 토큰이 필요합니다.", + deprecated = true, security = @SecurityRequirement(name = "Bearer Authentication") ) @ApiResponse( @@ -71,9 +76,9 @@ public BaseResponse getMemberInfo( description = "파트너 멤버 정보 조회 성공", content = @Content(schema = @Schema(implementation = SwaggerResponses.PartnerMemberInfoSuccessResponse.class)) ) - @ApiCommonResponses.OnlyCouple @ApiCommonResponses.RequireAuth @GetMapping("/partner") + @Deprecated public BaseResponse getPartnerMemberInfo( @AuthenticationPrincipal User user ) { @@ -103,12 +108,64 @@ public BaseResponse updateMember( .memberId(Long.valueOf(user.getUsername())) .nickname(requestDto.getNickname()) .relationshipStatus(requestDto.getRelationshipStatus()) - .personalityType(requestDto.getPersonalityType()) - .otherPersonalityType(requestDto.getOtherPersonalityType()) + .personalityType(normalizePersonalityType(requestDto.getPersonalityType())) + .loveTypeCategory(requestDto.getLoveTypeCategory()) .build(); return BaseResponse.success(updateMemberUseCase.updateMember(command)); } + @Operation( + summary = "상대 프로필 최초 등록", + description = "현재 로그인된 사용자가 상대방 MBTI와 애착 유형을 직접 입력합니다. JWT 토큰이 필요합니다.", + security = @SecurityRequirement(name = "Bearer Authentication") + ) + @ApiResponse( + responseCode = "200", + description = "상대 프로필 등록 성공", + content = @Content(schema = @Schema(implementation = SwaggerResponses.PartnerProfileSuccessResponse.class)) + ) + @ApiCommonResponses.RequireAuth + @PostMapping("/partners") + public BaseResponse createPartnerProfile( + @AuthenticationPrincipal User user, + @RequestBody @Valid CreatePartnerProfileRequestDto requestDto + ) { + CreatePartnerProfileUseCase.CreatePartnerProfileCommand command = + CreatePartnerProfileUseCase.CreatePartnerProfileCommand.builder() + .memberId(Long.valueOf(user.getUsername())) + .personalityType(normalizePersonalityType(requestDto.getPersonalityType())) + .loveTypeCategory(requestDto.getLoveTypeCategory()) + .build(); + + return BaseResponse.success(createPartnerProfileUseCase.createPartnerProfile(command)); + } + + @Operation( + summary = "상대 프로필 수정", + description = "현재 로그인된 사용자가 직접 입력한 상대 프로필을 수정합니다. JWT 토큰이 필요합니다.", + security = @SecurityRequirement(name = "Bearer Authentication") + ) + @ApiResponse( + responseCode = "200", + description = "상대 프로필 수정 성공", + content = @Content(schema = @Schema(implementation = SwaggerResponses.PartnerProfileSuccessResponse.class)) + ) + @ApiCommonResponses.RequireAuth + @PatchMapping("/partners") + public BaseResponse updatePartnerProfile( + @AuthenticationPrincipal User user, + @RequestBody @Valid UpdatePartnerProfileRequestDto requestDto + ) { + UpdatePartnerProfileUseCase.UpdatePartnerProfileCommand command = + UpdatePartnerProfileUseCase.UpdatePartnerProfileCommand.builder() + .memberId(Long.valueOf(user.getUsername())) + .personalityType(normalizePersonalityType(requestDto.getPersonalityType())) + .loveTypeCategory(requestDto.getLoveTypeCategory()) + .build(); + + return BaseResponse.success(updatePartnerProfileUseCase.updatePartnerProfile(command)); + } + @Operation( summary = "사용자 약관 동의 수정", description = "현재 로그인된 사용자의 약관 동의 정보를 수정합니다. JWT 토큰이 필요합니다.", @@ -142,7 +199,8 @@ public BaseResponse> upda @Operation( summary = "사용자 초대 코드 조회", - description = "현재 로그인된 사용자의 초대 코드를 조회합니다. JWT 토큰이 필요합니다.", + description = "[Deprecated] 현재 로그인된 사용자의 초대 코드를 조회합니다. 커플 연동 기능은 제거 예정이며, 앞으로는 사용자가 커플 정보를 직접 입력하는 방식을 사용합니다. JWT 토큰이 필요합니다.", + deprecated = true, security = @SecurityRequirement(name = "Bearer Authentication") ) @ApiResponse( @@ -152,6 +210,7 @@ public BaseResponse> upda ) @ApiCommonResponses.RequireAuth @GetMapping("/invite-code") + @Deprecated public BaseResponse getMemberInviteCode( @AuthenticationPrincipal User user ) { @@ -221,7 +280,8 @@ public BaseResponse registerLoveType( @Operation( summary = "연애 시작일 변경", - description = "커플로 연동된 사용자의 연애 시작일을 변경합니다. 커플이 아닌 사용자는 사용할 수 없습니다. JWT 토큰이 필요합니다.", + description = "[Deprecated] 커플로 연동된 사용자의 연애 시작일을 변경합니다. 커플 연동 기능은 제거 예정이며, 앞으로는 사용자가 커플 정보를 직접 입력하는 방식을 사용합니다. 커플이 아닌 사용자는 사용할 수 없습니다. JWT 토큰이 필요합니다.", + deprecated = true, security = @SecurityRequirement(name = "Bearer Authentication") ) @ApiResponse( @@ -232,6 +292,7 @@ public BaseResponse registerLoveType( @ApiCommonResponses.RequireAuth @ApiCommonResponses.OnlyCouple @PatchMapping("/start-love-date") + @Deprecated public BaseResponse updateStartLoveDate( @AuthenticationPrincipal User user, @Valid @RequestBody UpdateStartLoveDateRequestDto requestDto @@ -249,10 +310,29 @@ public static class UpdateMemberRequestDto { @Size(min = 1, max = 10, message = "닉네임은 1자 이상 10자 이하여야 합니다.") @Pattern(regexp = "^[가-힣a-zA-Z0-9]+$", message = "닉네임은 한글, 영문, 숫자만 사용 가능합니다.") private String nickname; - + private RelationshipStatus relationshipStatus; + + @Pattern(regexp = "^[a-zA-Z]{4}$", message = "MBTI는 영문 4자리여야 합니다.") private String personalityType; - private String otherPersonalityType; + + private LoveTypeCategory loveTypeCategory; + } + + @Data + public static class CreatePartnerProfileRequestDto { + @NotBlank(message = "상대 MBTI는 필수 입력값입니다.") + @Pattern(regexp = "^[a-zA-Z]{4}$", message = "MBTI는 영문 4자리여야 합니다.") + private String personalityType; + + private PartnerLoveTypeCategory loveTypeCategory; + } + + @Data + public static class UpdatePartnerProfileRequestDto { + @Pattern(regexp = "^[a-zA-Z]{4}$", message = "MBTI는 영문 4자리여야 합니다.") + private String personalityType; + private PartnerLoveTypeCategory loveTypeCategory; } @Data @@ -261,6 +341,8 @@ public static class UpdateMemberTermsRequestDto { } @Data + @Deprecated + @Schema(description = "[Deprecated] 연애 시작일 변경 요청 DTO") public static class UpdateStartLoveDateRequestDto { @NotNull(message = "시작일은 필수 입력값입니다.") @PastOrPresent(message = "시작일은 오늘 또는 과거 날짜여야 합니다.") @@ -290,4 +372,8 @@ public static class LoveTypeTestResult { private Integer score; } + private static String normalizePersonalityType(String personalityType) { + return personalityType == null ? null : personalityType.toUpperCase(); + } + } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/QuestionController.java b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/QuestionController.java index ca5f1a89..ee0e7d56 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/QuestionController.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/QuestionController.java @@ -20,7 +20,7 @@ import org.springframework.security.core.userdetails.User; import org.springframework.web.bind.annotation.*; -@Tag(name = "오늘의 질문 API", description = "커플 오늘의 질문 API") +@Tag(name = "오늘의 질문 API", description = "오늘의 질문 API (Deprecated)") @Slf4j @RestController @RequestMapping("/questions") @@ -33,7 +33,8 @@ public class QuestionController { @Operation( summary = "오늘의 질문 조회", - description = "커플 오늘의 질문을 조회합니다. JWT 토큰이 필요합니다.", + description = "[Deprecated] 오늘의 질문 기능은 제거 예정입니다. JWT 토큰이 필요합니다.", + deprecated = true, security = @SecurityRequirement(name = "Bearer Authentication") ) @ApiResponse( @@ -43,6 +44,7 @@ public class QuestionController { ) @ApiCommonResponses.RequireAuth @GetMapping("/today") + @Deprecated public BaseResponse getTodayQuestion( @AuthenticationPrincipal User user ) { @@ -55,7 +57,8 @@ public BaseResponse getTodayQuestion( @Operation( summary = "과거 질문 조회", - description = "커플 오늘의 질문을 조회합니다. JWT 토큰이 필요합니다.", + description = "[Deprecated] 오늘의 질문 기능은 제거 예정입니다. JWT 토큰이 필요합니다.", + deprecated = true, security = @SecurityRequirement(name = "Bearer Authentication") ) @ApiResponse( @@ -66,6 +69,7 @@ public BaseResponse getTodayQuestion( @ApiCommonResponses.OnlyCouple @ApiCommonResponses.RequireAuth @GetMapping("/{level}") + @Deprecated public BaseResponse getQuestion( @AuthenticationPrincipal User user, @PathVariable int level) { @@ -79,7 +83,8 @@ public BaseResponse getQuestion( @Operation( summary = "질문 답변 조회", - description = "커플 질문 답변을 조회합니다. JWT 토큰이 필요합니다.", + description = "[Deprecated] 오늘의 질문 기능은 제거 예정입니다. JWT 토큰이 필요합니다.", + deprecated = true, security = @SecurityRequirement(name = "Bearer Authentication") ) @ApiResponse( @@ -90,6 +95,7 @@ public BaseResponse getQuestion( @ApiCommonResponses.OnlyOwner @ApiCommonResponses.RequireAuth @GetMapping("/{coupleQuestionId}/answers") + @Deprecated public BaseResponse getAnswers( @AuthenticationPrincipal User user, @PathVariable Long coupleQuestionId) { @@ -103,7 +109,8 @@ public BaseResponse getAnswers( @Operation( summary = "오늘의 질문 답변 등록", - description = "커플 오늘의 질문에 답변을 등록합니다. JWT 토큰이 필요합니다.", + description = "[Deprecated] 오늘의 질문 기능은 제거 예정입니다. JWT 토큰이 필요합니다.", + deprecated = true, security = @SecurityRequirement(name = "Bearer Authentication") ) @ApiResponse( @@ -113,6 +120,7 @@ public BaseResponse getAnswers( ) @ApiCommonResponses.RequireAuth @PostMapping("/today/answers") + @Deprecated public BaseResponse postAnswer( @AuthenticationPrincipal User user, @Valid @RequestBody AnswerRequestDto requestDto @@ -127,7 +135,8 @@ public BaseResponse postAnswer( @Operation( summary = "오늘의 질문 답변 수정", - description = "커플 오늘의 질문에 답변을 수정합니다. JWT 토큰이 필요합니다.", + description = "[Deprecated] 오늘의 질문 기능은 제거 예정입니다. JWT 토큰이 필요합니다.", + deprecated = true, security = @SecurityRequirement(name = "Bearer Authentication") ) @ApiResponse( @@ -137,6 +146,7 @@ public BaseResponse postAnswer( ) @ApiCommonResponses.RequireAuth @PatchMapping("/today/answers") + @Deprecated public BaseResponse updateAnswer( @AuthenticationPrincipal User user, @Valid @RequestBody AnswerRequestDto requestDto diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/SignUpController.java b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/SignUpController.java index 71b38407..00d273f5 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/SignUpController.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/SignUpController.java @@ -32,7 +32,7 @@ public class SignUpController { @Operation( summary = "회원가입", - description = "인증된 사용자의 추가 정보를 입력받아 회원가입을 완료합니다. 연애 시작일은 커플 연동 시 자동으로 설정됩니다. JWT 토큰이 필요합니다.", + description = "인증된 사용자의 추가 정보와 연애 상태를 입력받아 회원가입을 완료합니다. MBTI 관련 정보는 회원가입 이후 프로필 수정에서 관리합니다. JWT 토큰이 필요합니다.", security = @SecurityRequirement(name = "Bearer Authentication") ) @ApiResponse( @@ -60,8 +60,6 @@ public BaseResponse signUp( .nickname(requestDto.getNickname()) .loveTypeId(requestDto.getLoveTypeId()) .relationshipStatus(requestDto.getRelationshipStatus()) - .personalityType(requestDto.getPersonalityType()) - .otherPersonalityType(requestDto.getOtherPersonalityType()) .build(); signUpUseCase.signUp(command); @@ -82,10 +80,6 @@ public static class SignUpRequestDto { private Long loveTypeId; private RelationshipStatus relationshipStatus; - - private String personalityType; - - private String otherPersonalityType; } @Data diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java b/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java index 92d0a7ee..d8309323 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java @@ -83,6 +83,11 @@ public static class MemberInfoSuccessResponse extends BaseSwaggerResponse { } + @Getter + @Schema(description = "상대 프로필 등록/수정 성공 응답") + public static class PartnerProfileSuccessResponse extends BaseSwaggerResponse { + } + @Getter @Schema(description = "멤버 정보 수정 성공 응답") public static class UpdateMemberSuccessResponse extends BaseSwaggerResponse { @@ -109,18 +114,21 @@ public static class ReadMemberNotificationSuccessResponse extends BaseSwaggerRes } @Getter - @Schema(description = "멤버 초대 코드 성공 응답") + @Deprecated + @Schema(description = "[Deprecated] 멤버 초대 코드 성공 응답") public static class GetInviteCodeSuccessResponse extends BaseSwaggerResponse { } // 커플 관련 응답 @Getter - @Schema(description = "커플 연결 성공 응답") + @Deprecated + @Schema(description = "[Deprecated] 커플 연결 성공 응답") public static class CoupleLinkSuccessResponse extends BaseSwaggerResponse { } @Getter - @Schema(description = "커플 연결 끊기 성공 응답") + @Deprecated + @Schema(description = "[Deprecated] 커플 연결 끊기 성공 응답") public static class CoupleUnlinkSuccessResponse extends BaseSwaggerResponse { } @@ -135,13 +143,19 @@ public static class LoveTypeQuestionSuccessResponse extends BaseSwaggerResponse< public static class LoveTypeQuestionCalculateSuccessResponse extends BaseSwaggerResponse { } + @Getter + @Schema(description = "MBTI + 애착유형 상세 결과 조회 성공 응답") + public static class LoveTypePersonalityTypeResultSuccessResponse extends BaseSwaggerResponse { + } + @Getter @Schema(description = "애착유형 등록 성공 응답") public static class RegisterLoveTypeSuccessResponse extends BaseSwaggerResponse { } @Getter - @Schema(description = "연애 시작일 갱신 성공 응답") + @Deprecated + @Schema(description = "[Deprecated] 연애 시작일 갱신 성공 응답") public static class UpdateStartLoveDateSuccessResponse extends BaseSwaggerResponse { } @@ -274,33 +288,41 @@ public static class MemberData { @Schema(description = "연애 상태", example = "IN_RELATIONSHIP") private RelationshipStatus relationshipStatus; - @Schema(description = "성격 유형", example = "INTJ") + @Schema(description = "내 MBTI", example = "INTJ") private String personalityType; - @Schema(description = "상대방 성격 유형", example = "ENFP") + @Schema(description = "상대방 MBTI", example = "INTJ") private String otherPersonalityType; + + @Schema(description = "상대방 애착 유형", example = "UNKNOWN") + private PartnerLoveTypeCategory partnerLoveTypeCategory; } @Getter - @Schema(description = "파트너 멤버 정보 응답 데이터") + @Deprecated + @Schema(description = "[Deprecated] 상대 프로필 조회 응답 데이터") public static class PartnerMemberData { - @Schema(description = "멤버 상태", example = "ALIVE") - private MemberState memberState; + @Schema(description = "상대방 MBTI", example = "INTJ") + private String personalityType; - @Schema(description = "애착 유형", example = "STABLE_TYPE") - private LoveTypeCategory loveTypeCategory; + @Schema(description = "상대방 애착 유형", example = "UNKNOWN") + private PartnerLoveTypeCategory loveTypeCategory; - @Schema(description = "회피 비율", example = "0.3") - private float avoidanceRate; + @Schema(description = "애착 유형 설명", example = "모르겠어요") + private String description; + } - @Schema(description = "불안 비율", example = "0.2") - private float anxietyRate; + @Getter + @Schema(description = "상대 프로필 응답 데이터") + public static class PartnerProfileData { + @Schema(description = "상대방 MBTI", example = "INTJ") + private String personalityType; - @Schema(description = "닉네임", example = "김영희") - private String nickname; + @Schema(description = "상대방 애착 유형", example = "UNKNOWN") + private PartnerLoveTypeCategory loveTypeCategory; - @Schema(description = "디데이 변경 이력 여부", example = "false") - private Boolean isStartLoveDateUpdated; + @Schema(description = "애착 유형 설명", example = "모르겠어요") + private String description; } @Getter @@ -309,8 +331,14 @@ public static class UpdateMemberData { @Schema(description = "닉네임", example = "홍길동") private String nickname; - @Schema(description = "이메일", example = "test@example.com") - private String email; + @Schema(description = "연애 상태", example = "IN_RELATIONSHIP") + private RelationshipStatus relationshipStatus; + + @Schema(description = "내 MBTI", example = "INTJ") + private String personalityType; + + @Schema(description = "내 애착 유형", example = "STABLE_TYPE") + private LoveTypeCategory loveTypeCategory; } @Data @@ -348,14 +376,16 @@ public static class TermsData { } @Getter - @Schema(description = "커플 연결 응답 데이터") + @Deprecated + @Schema(description = "[Deprecated] 커플 연결 응답 데이터") public static class CoupleLinkData { @Schema(description = "생성된 커플 ID", example = "1") private Long coupleId; } @Getter - @Schema(description = "커플 연결 끊기 응답 데이터") + @Deprecated + @Schema(description = "[Deprecated] 커플 연결 끊기 응답 데이터") public static class CoupleUnlinkData { @Schema(description = "해제된 커플 ID", example = "1") private Long coupleId; @@ -387,7 +417,65 @@ public static class LoveTypeQuestionCalculationData { } @Getter - @Schema(description = "연애 시작일 갱신 응답 데이터") + @Schema(description = "MBTI + 애착유형 상세 결과 응답 데이터") + public static class LoveTypePersonalityTypeResultData { + @Schema(description = "personalityType", example = "INTJ") + private String personalityType; + + @Schema(description = "애착 유형", example = "STABLE_TYPE") + private LoveTypeCategory loveTypeCategory; + + @Schema(description = "요약", example = "풍부한 상상력과 사랑으로, 함께하는 일상을 즐겁게 만들어 가는 유형") + private String summary; + + @Schema(description = "키워드 목록") + private List keywords; + + @Schema(description = "강점 목록") + private List strengths; + + @Schema(description = "약점 목록") + private List weaknesses; + + @Schema(description = "관계 패턴 목록") + private List patterns; + + @Schema(description = "애착유형 특징 목록") + private List loveTypeFeatures; + + @Schema(description = "연애 가이드 목록") + private List datingGuides; + + @Schema(description = "잘 맞는 MBTI 목록") + private List bestMatches; + + @Schema(description = "부딪히기 쉬운 MBTI 목록") + private List worstMatches; + } + + @Getter + @Schema(description = "제목 + 설명 텍스트 블록") + public static class LoveTypeTextBlockData { + @Schema(description = "제목", example = "Ne") + private String title; + + @Schema(description = "설명", example = "흩어진 정보 속에서 하나의 핵심 맥락과 미래를 읽어내요") + private String description; + } + + @Getter + @Schema(description = "MBTI + 설명 블록") + public static class LoveTypePersonalityTypeBlockData { + @Schema(description = "personalityType", example = "INTJ") + private String personalityType; + + @Schema(description = "설명", example = "속마음을 깊이 이해해주며 안정적인 감정을 공유하는 궁합") + private String description; + } + + @Getter + @Deprecated + @Schema(description = "[Deprecated] 연애 시작일 갱신 응답 데이터") public static class UpdateStartLoveDateData { @Schema(description = "변경된 연애 시작일", example = "2023-01-15") private LocalDate startLoveDate; @@ -534,7 +622,8 @@ public static class TermsDetailsResponseData { } @Getter - @Schema(description = "초대 코드 응답 데이터") + @Deprecated + @Schema(description = "[Deprecated] 초대 코드 응답 데이터") public static class InviteCodeResponseData { private String coupleCode; } @@ -625,4 +714,4 @@ public static class GetChatRoomListResponse { @Schema(description = "채팅방 생성 시간", example = "2025-07-20T10:15:30") private LocalDateTime createdAt; } -} \ No newline at end of file +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/AbstractOpenAiCompatibleApiClient.java b/src/main/java/makeus/cmc/malmo/adaptor/out/AbstractOpenAiCompatibleApiClient.java new file mode 100644 index 00000000..fc7dfa99 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/AbstractOpenAiCompatibleApiClient.java @@ -0,0 +1,182 @@ +package makeus.cmc.malmo.adaptor.out; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import makeus.cmc.malmo.application.port.out.chat.LlmReasoningScenario; +import makeus.cmc.malmo.application.port.out.chat.RequestChatApiPort; +import makeus.cmc.malmo.config.properties.ReasoningEffortProperties; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +@Slf4j +public abstract class AbstractOpenAiCompatibleApiClient implements RequestChatApiPort { + + protected final WebClient webClient; + protected final ObjectMapper objectMapper; + + protected AbstractOpenAiCompatibleApiClient(WebClient webClient, ObjectMapper objectMapper) { + this.webClient = webClient; + this.objectMapper = objectMapper; + } + + protected abstract String getProviderName(); + + protected abstract String getApiKey(); + + protected abstract String getModel(); + + protected abstract ReasoningEffortProperties getReasoningEffortProperties(); + + public final String currentProviderName() { + return getProviderName(); + } + + public final String currentModel() { + return getModel(); + } + + public final String currentDefaultReasoningEffort() { + ReasoningEffortProperties properties = getReasoningEffortProperties(); + return properties != null ? properties.getDefault() : null; + } + + public final Map currentScenarioReasoningEfforts() { + ReasoningEffortProperties properties = getReasoningEffortProperties(); + if (properties == null || properties.getScenarios() == null) { + return Map.of(); + } + return Map.copyOf(new LinkedHashMap<>(properties.getScenarios())); + } + + public final String currentReasoningEffort(LlmReasoningScenario scenario) { + return resolveReasoningEffort(scenario); + } + + @Override + public Mono requestStreamResponse(List> messages, + LlmReasoningScenario scenario, + Consumer onData) { + Map body = createBody(messages, scenario, true, false); + + return webClient.post() + .uri("/chat/completions") + .header("Authorization", "Bearer " + getApiKey()) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .onStatus(HttpStatusCode::isError, response -> + response.bodyToMono(String.class) + .flatMap(errorBody -> { + log.error("{} API error response: {}", getProviderName(), errorBody); + return Mono.error(new RuntimeException(getProviderName() + " API error: " + errorBody)); + }) + ) + .bodyToFlux(String.class) + .filter(line -> !line.isBlank()) + .takeWhile(data -> !data.equals("[DONE]") && !data.equals("data: [DONE]")) + .map(data -> { + try { + return extractStreamContent(data); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to parse stream content", e); + } + }) + .filter(content -> !content.isEmpty()) + .doOnNext(onData) + .collect(Collectors.joining("")) + .doOnError(throwable -> log.error("Error during {} stream processing", getProviderName(), throwable)); + } + + @Override + public CompletableFuture requestResponse(List> messages, + LlmReasoningScenario scenario) { + return sendRequest(createBody(messages, scenario, false, false)) + .thenApply(this::extractContent); + } + + @Override + public CompletableFuture requestJsonResponse(List> messages, + LlmReasoningScenario scenario) { + return sendRequest(createBody(messages, scenario, false, true)) + .thenApply(this::extractContent); + } + + private CompletableFuture sendRequest(Map body) { + return webClient.post() + .uri("/chat/completions") + .header("Authorization", "Bearer " + getApiKey()) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .bodyToMono(String.class) + .toFuture(); + } + + private String extractContent(String data) { + try { + JsonNode node = objectMapper.readTree(data); + return node.path("choices").get(0).path("message").path("content").asText(); + } catch (JsonProcessingException e) { + log.error("Error processing {} API response", getProviderName(), e); + throw new RuntimeException(e); + } + } + + private String extractStreamContent(String data) throws JsonProcessingException { + String normalized = data.startsWith("data: ") ? data.substring(6) : data; + JsonNode node = objectMapper.readTree(normalized); + return node + .path("choices").get(0) + .path("delta") + .path("content") + .asText(); + } + + protected final Map createBody(List> messages, + LlmReasoningScenario scenario, + boolean stream, + boolean jsonResponse) { + Map body = new java.util.LinkedHashMap<>(); + body.put("model", getModel()); + + String reasoningEffort = resolveReasoningEffort(scenario); + if (StringUtils.hasText(reasoningEffort)) { + body.put("reasoning_effort", reasoningEffort); + } + + if (jsonResponse) { + body.put("response_format", Map.of("type", "json_object")); + } + + body.put("messages", messages); + body.put("stream", stream); + return body; + } + + private String resolveReasoningEffort(LlmReasoningScenario scenario) { + ReasoningEffortProperties properties = getReasoningEffortProperties(); + if (properties == null) { + return null; + } + + String scenarioEffort = properties.getScenarioEffort(scenario); + if (StringUtils.hasText(scenarioEffort)) { + return scenarioEffort; + } + + String defaultEffort = properties.getDefault(); + return StringUtils.hasText(defaultEffort) ? defaultEffort : null; + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/GeminiApiClient.java b/src/main/java/makeus/cmc/malmo/adaptor/out/GeminiApiClient.java new file mode 100644 index 00000000..48ca0dda --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/GeminiApiClient.java @@ -0,0 +1,83 @@ +package makeus.cmc.malmo.adaptor.out; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import makeus.cmc.malmo.application.port.out.CheckLlmHealth; +import makeus.cmc.malmo.config.properties.GeminiApiProperties; +import makeus.cmc.malmo.config.properties.ReasoningEffortProperties; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +//@Primary +@Component +public class GeminiApiClient extends AbstractOpenAiCompatibleApiClient implements CheckLlmHealth { + + private final GeminiApiProperties geminiApiProperties; + private final RestTemplate restTemplate; + + public GeminiApiClient( + @Qualifier("geminiWebClient") WebClient webClient, + ObjectMapper objectMapper, + GeminiApiProperties geminiApiProperties, + RestTemplate restTemplate + ) { + super(webClient, objectMapper); + this.geminiApiProperties = geminiApiProperties; + this.restTemplate = restTemplate; + } + + @Override + protected String getProviderName() { + return "Gemini"; + } + + @Override + protected String getApiKey() { + return geminiApiProperties.getKey(); + } + + @Override + protected String getModel() { + return geminiApiProperties.getModel(); + } + + @Override + protected ReasoningEffortProperties getReasoningEffortProperties() { + return geminiApiProperties.getReasoningEffort(); + } + + @Override + public boolean checkHealth() { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(geminiApiProperties.getKey()); + + ResponseEntity response = restTemplate.exchange( + geminiApiProperties.getBaseUrl() + "/models/" + geminiApiProperties.getModel(), + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + + boolean isUp = response.getStatusCode().is2xxSuccessful(); + if (isUp) { + log.info("Gemini API is UP for model={}", geminiApiProperties.getModel()); + } else { + log.warn("Gemini API check returned status={} for model={}", + response.getStatusCode(), geminiApiProperties.getModel()); + } + return isUp; + } catch (Exception e) { + log.error("Gemini HealthCheck failed", e); + return false; + } + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/OpenAiApiClient.java b/src/main/java/makeus/cmc/malmo/adaptor/out/OpenAiApiClient.java index 487d46b3..44731872 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/OpenAiApiClient.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/OpenAiApiClient.java @@ -1,177 +1,90 @@ package makeus.cmc.malmo.adaptor.out; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import makeus.cmc.malmo.adaptor.out.exception.OpenAiRequestException; -import makeus.cmc.malmo.application.port.out.CheckOpenAIHealth; -import makeus.cmc.malmo.application.port.out.chat.RequestChatApiPort; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatusCode; -import org.springframework.http.MediaType; +import makeus.cmc.malmo.application.port.out.CheckLlmHealth; +import makeus.cmc.malmo.config.properties.OpenAiApiProperties; +import makeus.cmc.malmo.config.properties.ReasoningEffortProperties; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.Disposable; -import reactor.core.publisher.Mono; -import java.util.List; import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.function.Consumer; -import java.util.stream.Collectors; - -import static makeus.cmc.malmo.util.GlobalConstants.OPENAI_STATUS_URL; @Slf4j +@Primary @Component -@RequiredArgsConstructor -public class OpenAiApiClient implements RequestChatApiPort, CheckOpenAIHealth { - - public static final String GPT_VERSION = "gpt-4.1"; - public static final double GPT_TEMPERATURE = 0.5; - - @Value("${openai.api.key}") - private String openAiApiKey; - - private final WebClient webClient; - private final ObjectMapper objectMapper; +public class OpenAiApiClient extends AbstractOpenAiCompatibleApiClient implements CheckLlmHealth { + private final OpenAiApiProperties openAiApiProperties; private final RestTemplate restTemplate; - @Override - public Mono requestStreamResponse(List> messages, Consumer onData) { - Map body = createStreamBody(messages); - - // WebClient로 스트리밍 데이터를 Flux 형태로 요청 - return webClient.post() - .uri("/chat/completions") - .header("Authorization", "Bearer " + openAiApiKey) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(body) - .retrieve() - .onStatus(HttpStatusCode::isError, response -> - response.bodyToMono(String.class) - .flatMap(errorBody -> { - log.error("OpenAI API error response: {}", errorBody); - return Mono.error(new RuntimeException("OpenAI API error: " + errorBody)); - }) - ) - .bodyToFlux(String.class) - .filter(line -> !line.isBlank()) // 비어있는 줄 필터링은 유지 - .takeWhile(data -> !data.equals("[DONE]")) - .map(data -> { - try { - return extractStreamContent(data); - } catch (JsonProcessingException e) { - throw new RuntimeException("Failed to parse stream content", e); - } - }) - .filter(content -> !content.isEmpty()) - .doOnNext(onData) - .collect(Collectors.joining("")) - .doOnError(throwable -> log.error("Error during OpenAI stream processing", throwable)); + public OpenAiApiClient( + @Qualifier("openAiWebClient") WebClient webClient, + ObjectMapper objectMapper, + OpenAiApiProperties openAiApiProperties, + RestTemplate restTemplate + ) { + super(webClient, objectMapper); + this.openAiApiProperties = openAiApiProperties; + this.restTemplate = restTemplate; } @Override - public CompletableFuture requestResponse(List> messages) { - Map body = createBody(messages); - return sendRequest(body) - .thenApply(this::extractContent); // 응답이 오면 extractContent 실행 + protected String getProviderName() { + return "OpenAI"; } @Override - public CompletableFuture requestJsonResponse(List> messages) { - Map body = createBodyForJsonResponse(messages); - return sendRequest(body) - .thenApply(this::extractContent); // 응답이 오면 extractContent 실행 - } - - private CompletableFuture sendRequest(Map body) { - return webClient.post() - .uri("/chat/completions") - .header("Authorization", "Bearer " + openAiApiKey) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(body) - .retrieve() - .bodyToMono(String.class) // 응답 바디를 Mono으로 받음 - .toFuture(); // Mono를 CompletableFuture로 변환 - } - - private String extractContent(String data) { - try { - JsonNode node = objectMapper.readTree(data); - return node.path("choices").get(0).path("message").path("content").asText(); - } catch (JsonProcessingException e) { - log.error("Error processing OpenAI API response", e); - throw new RuntimeException(e); // 예외를 던져 CompletableFuture가 exceptionally로 처리하게 함 - } + protected String getApiKey() { + return openAiApiProperties.getKey(); } - - private String extractStreamContent(String data) throws JsonProcessingException { - JsonNode node = new ObjectMapper().readTree(data); - return node - .path("choices").get(0) - .path("delta") - .path("content") - .asText(); - } - - private Map createStreamBody(List> messages) { - return Map.of( - "model", GPT_VERSION, - "messages", messages, - "temperature", GPT_TEMPERATURE, - "stream", true - ); - } - - private Map createBody(List> messages) { - return Map.of( - "model", GPT_VERSION, - "messages", messages, - "temperature", GPT_TEMPERATURE, - "stream", false - ); + @Override + protected String getModel() { + return openAiApiProperties.getModel(); } - private Map createBodyForJsonResponse(List> messages) { - return Map.of( - "model", GPT_VERSION, - "response_format", Map.of("type", "json_object"), - "messages", messages, - "temperature", GPT_TEMPERATURE, - "stream", false - ); + @Override + protected ReasoningEffortProperties getReasoningEffortProperties() { + return openAiApiProperties.getReasoningEffort(); } @Override public boolean checkHealth() { try { - Map response = restTemplate.getForObject(OPENAI_STATUS_URL, Map.class); - if (response == null) { + ResponseEntity response = restTemplate.exchange( + openAiApiProperties.getStatusUrl(), + HttpMethod.GET, + HttpEntity.EMPTY, + Map.class + ); + + Map body = response.getBody(); + if (body == null) { log.warn("OpenAI HealthCheck: Empty response"); return false; } - Map status = (Map) response.get("status"); + Map status = (Map) body.get("status"); String indicator = (String) status.get("indicator"); String description = (String) status.get("description"); if ("none".equalsIgnoreCase(indicator)) { log.info("OpenAI API is UP: {}", description); return true; - } else { - log.warn(" OpenAI API Issue: {} ({})", description, indicator); - return false; } + + log.warn("OpenAI API Issue: {} ({})", description, indicator); + return false; } catch (Exception e) { log.error("OpenAI HealthCheck failed", e); return false; } } } - diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/LoveTypePersonalityTypeFeaturePersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/LoveTypePersonalityTypeFeaturePersistenceAdapter.java new file mode 100644 index 00000000..7ff43d3a --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/LoveTypePersonalityTypeFeaturePersistenceAdapter.java @@ -0,0 +1,29 @@ +package makeus.cmc.malmo.adaptor.out.persistence.adapter; + +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.adaptor.out.persistence.mapper.LoveTypePersonalityTypeFeatureMapper; +import makeus.cmc.malmo.adaptor.out.persistence.repository.LoveTypePersonalityTypeFeatureRepository; +import makeus.cmc.malmo.application.port.out.LoadLoveTypePersonalityTypeFeaturePort; +import makeus.cmc.malmo.domain.model.love_type.LoveTypePersonalityTypeFeature; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class LoveTypePersonalityTypeFeaturePersistenceAdapter implements LoadLoveTypePersonalityTypeFeaturePort { + + private final LoveTypePersonalityTypeFeatureRepository loveTypePersonalityTypeFeatureRepository; + private final LoveTypePersonalityTypeFeatureMapper loveTypePersonalityTypeFeatureMapper; + + @Override + public Optional loadByPersonalityTypeAndLoveTypeCategory( + String personalityType, + LoveTypeCategory loveTypeCategory + ) { + return loveTypePersonalityTypeFeatureRepository + .findByPersonalityTypeIgnoreCaseAndLoveTypeCategory(personalityType, loveTypeCategory) + .map(loveTypePersonalityTypeFeatureMapper::toDomain); + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/LoveTypePersonalityTypePromptPersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/LoveTypePersonalityTypePromptPersistenceAdapter.java new file mode 100644 index 00000000..e74b1a5b --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/LoveTypePersonalityTypePromptPersistenceAdapter.java @@ -0,0 +1,29 @@ +package makeus.cmc.malmo.adaptor.out.persistence.adapter; + +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.adaptor.out.persistence.mapper.LoveTypePersonalityTypePromptMapper; +import makeus.cmc.malmo.adaptor.out.persistence.repository.LoveTypePersonalityTypePromptRepository; +import makeus.cmc.malmo.application.port.out.LoadLoveTypePersonalityTypePromptPort; +import makeus.cmc.malmo.domain.model.love_type.LoveTypePersonalityTypePrompt; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class LoveTypePersonalityTypePromptPersistenceAdapter implements LoadLoveTypePersonalityTypePromptPort { + + private final LoveTypePersonalityTypePromptRepository loveTypePersonalityTypePromptRepository; + private final LoveTypePersonalityTypePromptMapper loveTypePersonalityTypePromptMapper; + + @Override + public Optional loadByPersonalityTypeAndLoveTypeCategory( + String personalityType, + LoveTypeCategory loveTypeCategory + ) { + return loveTypePersonalityTypePromptRepository + .findByPersonalityTypeIgnoreCaseAndLoveTypeCategory(personalityType, loveTypeCategory) + .map(loveTypePersonalityTypePromptMapper::toDomain); + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/MemberPersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/MemberPersistenceAdapter.java index 72af5421..6e415871 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/MemberPersistenceAdapter.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/MemberPersistenceAdapter.java @@ -19,6 +19,7 @@ import makeus.cmc.malmo.domain.value.id.InviteCodeValue; import makeus.cmc.malmo.domain.value.id.MemberId; import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; import makeus.cmc.malmo.domain.value.type.Provider; import makeus.cmc.malmo.domain.value.type.RelationshipStatus; import org.springframework.stereotype.Component; @@ -106,6 +107,7 @@ public static class MemberResponseRepositoryDto { private RelationshipStatus relationshipStatus; private String personalityType; private String otherPersonalityType; + private PartnerLoveTypeCategory partnerLoveTypeCategory; public MemberQueryHelper.MemberInfoDto toDto(int totalChatRoomCount, int totalCoupleQuestionCount) { return MemberQueryHelper.MemberInfoDto.builder() @@ -122,6 +124,7 @@ public MemberQueryHelper.MemberInfoDto toDto(int totalChatRoomCount, int totalCo .relationshipStatus(relationshipStatus) .personalityType(personalityType) .otherPersonalityType(otherPersonalityType) + .partnerLoveTypeCategory(partnerLoveTypeCategory) .build(); } } @@ -129,21 +132,14 @@ public MemberQueryHelper.MemberInfoDto toDto(int totalChatRoomCount, int totalCo @Data @AllArgsConstructor public static class PartnerMemberRepositoryDto { - private String memberState; - private LoveTypeCategory loveTypeCategory; - private float avoidanceRate; - private float anxietyRate; - private String nickname; - private Boolean isStartLoveDateUpdated; + private String personalityType; + private PartnerLoveTypeCategory loveTypeCategory; public MemberQueryHelper.PartnerMemberDto toDto() { return MemberQueryHelper.PartnerMemberDto.builder() - .memberState(memberState) + .personalityType(personalityType) .loveTypeCategory(loveTypeCategory) - .avoidanceRate(avoidanceRate) - .anxietyRate(anxietyRate) - .nickname(nickname) - .isStartLoveDateUpdated(isStartLoveDateUpdated) + .description(loveTypeCategory == null ? null : loveTypeCategory.getDescription()) .build(); } } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/LoveTypePersonalityTypeFeatureEntity.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/LoveTypePersonalityTypeFeatureEntity.java new file mode 100644 index 00000000..4685d2ae --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/LoveTypePersonalityTypeFeatureEntity.java @@ -0,0 +1,152 @@ +package makeus.cmc.malmo.adaptor.out.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.LoveTypePersonalityTypeFeatureEntityId; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; + +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@IdClass(LoveTypePersonalityTypeFeatureEntityId.class) +@Table(name = "love_type_personality_type_feature") +public class LoveTypePersonalityTypeFeatureEntity { + + @Id + @Column(name = "personality_type") + private String personalityType; + + @Id + @Column(name = "lovetype") + @Enumerated(EnumType.STRING) + private LoveTypeCategory loveTypeCategory; + + @Column(name = "summary") + private String summary; + + @Column(name = "keyword1") + private String keyword1; + + @Column(name = "keyword2") + private String keyword2; + + @Column(name = "keyword3") + private String keyword3; + + @Column(name = "strength1") + private String strength1; + + @Column(name = "strength2") + private String strength2; + + @Column(name = "strength3") + private String strength3; + + @Column(name = "weakness") + private String weakness; + + @Column(name = "strength_desc1") + private String strengthDesc1; + + @Column(name = "strength_desc2") + private String strengthDesc2; + + @Column(name = "strength_desc3") + private String strengthDesc3; + + @Column(name = "weakness_desc") + private String weaknessDesc; + + @Column(name = "pattern_title1") + private String patternTitle1; + + @Column(name = "pattern_title2") + private String patternTitle2; + + @Column(name = "pattern_title3") + private String patternTitle3; + + @Column(name = "pattern_title4") + private String patternTitle4; + + @Column(name = "pattern1") + private String pattern1; + + @Column(name = "pattern2") + private String pattern2; + + @Column(name = "pattern3") + private String pattern3; + + @Column(name = "pattern4") + private String pattern4; + + @Column(name = "lovetype_feature_title1") + private String loveTypeFeatureTitle1; + + @Column(name = "lovetype_feature_title2") + private String loveTypeFeatureTitle2; + + @Column(name = "lovetype_feature_title3") + private String loveTypeFeatureTitle3; + + @Column(name = "lovetype_feature_title4") + private String loveTypeFeatureTitle4; + + @Column(name = "lovetype_feature1") + private String loveTypeFeature1; + + @Column(name = "lovetype_feature2") + private String loveTypeFeature2; + + @Column(name = "lovetype_feature3") + private String loveTypeFeature3; + + @Column(name = "lovetype_feature4") + private String loveTypeFeature4; + + @Column(name = "dating_guide1") + private String datingGuide1; + + @Column(name = "dating_guide2") + private String datingGuide2; + + @Column(name = "dating_guide3") + private String datingGuide3; + + @Column(name = "best_personality_type1") + private String bestPersonalityType1; + + @Column(name = "best_desc1") + private String bestDesc1; + + @Column(name = "best_personality_type2") + private String bestPersonalityType2; + + @Column(name = "best_desc2") + private String bestDesc2; + + @Column(name = "worst_personality_type1") + private String worstPersonalityType1; + + @Column(name = "worst_desc1") + private String worstDesc1; + + @Column(name = "worst_personality_type2") + private String worstPersonalityType2; + + @Column(name = "worst_desc2") + private String worstDesc2; +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/LoveTypePersonalityTypePromptEntity.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/LoveTypePersonalityTypePromptEntity.java new file mode 100644 index 00000000..fb6ce95b --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/LoveTypePersonalityTypePromptEntity.java @@ -0,0 +1,38 @@ +package makeus.cmc.malmo.adaptor.out.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.LoveTypePersonalityTypePromptEntityId; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; + +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@IdClass(LoveTypePersonalityTypePromptEntityId.class) +@Table(name = "love_type_personality_type_prompt") +public class LoveTypePersonalityTypePromptEntity { + + @Id + @Column(name = "personality_type") + private String personalityType; + + @Id + @Column(name = "lovetype") + @Enumerated(EnumType.STRING) + private LoveTypeCategory loveTypeCategory; + + @Column(name = "prompts", columnDefinition = "TEXT") + private String prompts; +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/member/MemberEntity.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/member/MemberEntity.java index a4154877..c0ab1297 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/member/MemberEntity.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/member/MemberEntity.java @@ -13,6 +13,7 @@ import makeus.cmc.malmo.domain.value.type.EmailForwardingStatus; import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; import makeus.cmc.malmo.domain.value.type.MemberRole; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; import makeus.cmc.malmo.domain.value.type.Provider; import makeus.cmc.malmo.domain.value.type.RelationshipStatus; @@ -74,7 +75,12 @@ public class MemberEntity extends BaseTimeEntity { @Enumerated(value = EnumType.STRING) private RelationshipStatus relationshipStatus; + @Column(name = "personality_type") private String personalityType; + @Column(name = "other_personality_type") private String otherPersonalityType; + + @Enumerated(value = EnumType.STRING) + private PartnerLoveTypeCategory partnerLoveTypeCategory; } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/value/LoveTypePersonalityTypeFeatureEntityId.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/value/LoveTypePersonalityTypeFeatureEntityId.java new file mode 100644 index 00000000..d0f4b543 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/value/LoveTypePersonalityTypeFeatureEntityId.java @@ -0,0 +1,16 @@ +package makeus.cmc.malmo.adaptor.out.persistence.entity.value; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; + +import java.io.Serializable; + +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class LoveTypePersonalityTypeFeatureEntityId implements Serializable { + private String personalityType; + private LoveTypeCategory loveTypeCategory; +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/value/LoveTypePersonalityTypePromptEntityId.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/value/LoveTypePersonalityTypePromptEntityId.java new file mode 100644 index 00000000..e7d60cbc --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/value/LoveTypePersonalityTypePromptEntityId.java @@ -0,0 +1,16 @@ +package makeus.cmc.malmo.adaptor.out.persistence.entity.value; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; + +import java.io.Serializable; + +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class LoveTypePersonalityTypePromptEntityId implements Serializable { + private String personalityType; + private LoveTypeCategory loveTypeCategory; +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/LoveTypePersonalityTypeFeatureMapper.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/LoveTypePersonalityTypeFeatureMapper.java new file mode 100644 index 00000000..5c2b9a22 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/LoveTypePersonalityTypeFeatureMapper.java @@ -0,0 +1,59 @@ +package makeus.cmc.malmo.adaptor.out.persistence.mapper; + +import makeus.cmc.malmo.adaptor.out.persistence.entity.LoveTypePersonalityTypeFeatureEntity; +import makeus.cmc.malmo.domain.model.love_type.LoveTypePersonalityTypeFeature; +import org.springframework.stereotype.Component; + +@Component +public class LoveTypePersonalityTypeFeatureMapper { + + public LoveTypePersonalityTypeFeature toDomain(LoveTypePersonalityTypeFeatureEntity entity) { + if (entity == null) { + return null; + } + + return LoveTypePersonalityTypeFeature.from( + entity.getPersonalityType(), + entity.getLoveTypeCategory(), + entity.getSummary(), + entity.getKeyword1(), + entity.getKeyword2(), + entity.getKeyword3(), + entity.getStrength1(), + entity.getStrength2(), + entity.getStrength3(), + entity.getWeakness(), + entity.getStrengthDesc1(), + entity.getStrengthDesc2(), + entity.getStrengthDesc3(), + entity.getWeaknessDesc(), + entity.getPatternTitle1(), + entity.getPatternTitle2(), + entity.getPatternTitle3(), + entity.getPatternTitle4(), + entity.getPattern1(), + entity.getPattern2(), + entity.getPattern3(), + entity.getPattern4(), + entity.getLoveTypeFeatureTitle1(), + entity.getLoveTypeFeatureTitle2(), + entity.getLoveTypeFeatureTitle3(), + entity.getLoveTypeFeatureTitle4(), + entity.getLoveTypeFeature1(), + entity.getLoveTypeFeature2(), + entity.getLoveTypeFeature3(), + entity.getLoveTypeFeature4(), + entity.getDatingGuide1(), + entity.getDatingGuide2(), + entity.getDatingGuide3(), + entity.getBestPersonalityType1(), + entity.getBestDesc1(), + entity.getBestPersonalityType2(), + entity.getBestDesc2(), + entity.getWorstPersonalityType1(), + entity.getWorstDesc1(), + entity.getWorstPersonalityType2(), + entity.getWorstDesc2() + ); + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/LoveTypePersonalityTypePromptMapper.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/LoveTypePersonalityTypePromptMapper.java new file mode 100644 index 00000000..cca3d908 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/LoveTypePersonalityTypePromptMapper.java @@ -0,0 +1,21 @@ +package makeus.cmc.malmo.adaptor.out.persistence.mapper; + +import makeus.cmc.malmo.adaptor.out.persistence.entity.LoveTypePersonalityTypePromptEntity; +import makeus.cmc.malmo.domain.model.love_type.LoveTypePersonalityTypePrompt; +import org.springframework.stereotype.Component; + +@Component +public class LoveTypePersonalityTypePromptMapper { + + public LoveTypePersonalityTypePrompt toDomain(LoveTypePersonalityTypePromptEntity entity) { + if (entity == null) { + return null; + } + + return LoveTypePersonalityTypePrompt.from( + entity.getPersonalityType(), + entity.getLoveTypeCategory(), + entity.getPrompts() + ); + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/MemberMapper.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/MemberMapper.java index 30ac893e..4225dadb 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/MemberMapper.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/MemberMapper.java @@ -36,6 +36,7 @@ public Member toDomain(MemberEntity entity) { entity.getRelationshipStatus(), entity.getPersonalityType(), entity.getOtherPersonalityType(), + entity.getPartnerLoveTypeCategory(), entity.getCreatedAt(), entity.getModifiedAt(), entity.getDeletedAt() @@ -67,9 +68,10 @@ public MemberEntity toEntity(Member domain) { .relationshipStatus(domain.getRelationshipStatus()) .personalityType(domain.getPersonalityType()) .otherPersonalityType(domain.getOtherPersonalityType()) + .partnerLoveTypeCategory(domain.getPartnerLoveTypeCategory()) .createdAt(domain.getCreatedAt()) .modifiedAt(domain.getModifiedAt()) .deletedAt(domain.getDeletedAt()) .build(); } -} \ No newline at end of file +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/LoveTypePersonalityTypeFeatureRepository.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/LoveTypePersonalityTypeFeatureRepository.java new file mode 100644 index 00000000..6200464e --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/LoveTypePersonalityTypeFeatureRepository.java @@ -0,0 +1,16 @@ +package makeus.cmc.malmo.adaptor.out.persistence.repository; + +import makeus.cmc.malmo.adaptor.out.persistence.entity.LoveTypePersonalityTypeFeatureEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.LoveTypePersonalityTypeFeatureEntityId; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface LoveTypePersonalityTypeFeatureRepository + extends JpaRepository { + Optional findByPersonalityTypeIgnoreCaseAndLoveTypeCategory( + String personalityType, + LoveTypeCategory loveTypeCategory + ); +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/LoveTypePersonalityTypePromptRepository.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/LoveTypePersonalityTypePromptRepository.java new file mode 100644 index 00000000..c4f58ae6 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/LoveTypePersonalityTypePromptRepository.java @@ -0,0 +1,16 @@ +package makeus.cmc.malmo.adaptor.out.persistence.repository; + +import makeus.cmc.malmo.adaptor.out.persistence.entity.LoveTypePersonalityTypePromptEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.LoveTypePersonalityTypePromptEntityId; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface LoveTypePersonalityTypePromptRepository + extends JpaRepository { + Optional findByPersonalityTypeIgnoreCaseAndLoveTypeCategory( + String personalityType, + LoveTypeCategory loveTypeCategory + ); +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/member/MemberRepositoryCustomImpl.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/member/MemberRepositoryCustomImpl.java index ca2d630f..ad74aadc 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/member/MemberRepositoryCustomImpl.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/member/MemberRepositoryCustomImpl.java @@ -1,12 +1,10 @@ package makeus.cmc.malmo.adaptor.out.persistence.repository.member; import com.querydsl.core.types.Projections; -import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import makeus.cmc.malmo.adaptor.out.persistence.adapter.MemberPersistenceAdapter; -import makeus.cmc.malmo.adaptor.out.persistence.entity.member.QMemberEntity; import makeus.cmc.malmo.adaptor.out.persistence.entity.value.InviteCodeEntityValue; import makeus.cmc.malmo.application.port.out.chat.LoadChatRoomMetadataPort; import makeus.cmc.malmo.domain.value.state.CoupleState; @@ -37,7 +35,8 @@ public Optional findMember memberEntity.email, memberEntity.relationshipStatus, memberEntity.personalityType, - memberEntity.otherPersonalityType + memberEntity.otherPersonalityType, + memberEntity.partnerLoveTypeCategory )) .from(memberEntity) .leftJoin(coupleEntity) @@ -51,61 +50,14 @@ public Optional findMember @Override public Optional findPartnerMember(Long memberId) { - QMemberEntity partnerMemberEntity = new QMemberEntity("partnerMemberEntity"); - MemberPersistenceAdapter.PartnerMemberRepositoryDto dto = queryFactory .select(Projections.constructor(MemberPersistenceAdapter.PartnerMemberRepositoryDto.class, - coupleEntity.coupleState.stringValue(), - // 파트너가 실제 멤버면 memberEntity 정보, 아니면 스냅샷 정보 사용 - new CaseBuilder() - .when(partnerMemberEntity.coupleEntityId.value.eq(coupleEntity.id)) - .then(partnerMemberEntity.loveTypeCategory) - .otherwise( - new CaseBuilder() - .when(coupleEntity.firstMemberId.value.eq(memberId)) - .then(coupleEntity.secondMemberSnapshot.loveTypeCategory) - .otherwise(coupleEntity.firstMemberSnapshot.loveTypeCategory) - ), - new CaseBuilder() - .when(partnerMemberEntity.coupleEntityId.value.eq(coupleEntity.id)) - .then(partnerMemberEntity.avoidanceRate) - .otherwise( - new CaseBuilder() - .when(coupleEntity.firstMemberId.value.eq(memberId)) - .then(coupleEntity.secondMemberSnapshot.avoidanceRate) - .otherwise(coupleEntity.firstMemberSnapshot.avoidanceRate) - ), - new CaseBuilder() - .when(partnerMemberEntity.coupleEntityId.value.eq(coupleEntity.id)) - .then(partnerMemberEntity.anxietyRate) - .otherwise( - new CaseBuilder() - .when(coupleEntity.firstMemberId.value.eq(memberId)) - .then(coupleEntity.secondMemberSnapshot.anxietyRate) - .otherwise(coupleEntity.firstMemberSnapshot.anxietyRate) - ), - new CaseBuilder() - .when(partnerMemberEntity.coupleEntityId.value.eq(coupleEntity.id)) - .then(partnerMemberEntity.nickname) - .otherwise( - new CaseBuilder() - .when(coupleEntity.firstMemberId.value.eq(memberId)) - .then(coupleEntity.secondMemberSnapshot.nickname) - .otherwise(coupleEntity.firstMemberSnapshot.nickname) - ), - coupleEntity.isStartLoveDateUpdated + memberEntity.otherPersonalityType, + memberEntity.partnerLoveTypeCategory )) .from(memberEntity) - .join(coupleEntity).on(memberEntity.coupleEntityId.value.eq(coupleEntity.id)) - .leftJoin(partnerMemberEntity).on( - partnerMemberEntity.id.eq( - new CaseBuilder() - .when(coupleEntity.firstMemberId.value.eq(memberId)) - .then(coupleEntity.secondMemberId.value) - .otherwise(coupleEntity.firstMemberId.value) - ) - ) - .where(memberEntity.id.eq(memberId)) + .where(memberEntity.id.eq(memberId) + .and(memberEntity.otherPersonalityType.isNotNull())) .fetchOne(); return Optional.ofNullable(dto); @@ -143,32 +95,13 @@ public Optional findInviteCodeByMemberId(Long memberId) { @Override public Optional loadChatRoomMetadata(Long memberId) { - QMemberEntity partnerMemberEntity = new QMemberEntity("partnerMemberEntity"); - LoadChatRoomMetadataPort.ChatRoomMetadataDto dto = queryFactory .select(Projections.constructor( LoadChatRoomMetadataPort.ChatRoomMetadataDto.class, memberEntity.loveTypeCategory, - new CaseBuilder() - .when(partnerMemberEntity.coupleEntityId.value.eq(coupleEntity.id)) - .then(partnerMemberEntity.loveTypeCategory) - .otherwise( - new CaseBuilder() - .when(coupleEntity.firstMemberId.value.eq(memberId)) - .then(coupleEntity.secondMemberSnapshot.loveTypeCategory) - .otherwise(coupleEntity.firstMemberSnapshot.loveTypeCategory) - ) + memberEntity.partnerLoveTypeCategory )) .from(memberEntity) - .leftJoin(coupleEntity).on(memberEntity.coupleEntityId.value.eq(coupleEntity.id)) - .leftJoin(partnerMemberEntity).on( - partnerMemberEntity.id.eq( - new CaseBuilder() - .when(coupleEntity.firstMemberId.value.eq(memberId)) - .then(coupleEntity.secondMemberId.value) - .otherwise(coupleEntity.firstMemberId.value) - ) - ) .where(memberEntity.id.eq(memberId)) .fetchOne(); diff --git a/src/main/java/makeus/cmc/malmo/application/exception/LoveTypePersonalityTypeFeatureNotFoundException.java b/src/main/java/makeus/cmc/malmo/application/exception/LoveTypePersonalityTypeFeatureNotFoundException.java new file mode 100644 index 00000000..a26475ed --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/exception/LoveTypePersonalityTypeFeatureNotFoundException.java @@ -0,0 +1,4 @@ +package makeus.cmc.malmo.application.exception; + +public class LoveTypePersonalityTypeFeatureNotFoundException extends RuntimeException { +} diff --git a/src/main/java/makeus/cmc/malmo/application/exception/PartnerProfileAlreadyExistsException.java b/src/main/java/makeus/cmc/malmo/application/exception/PartnerProfileAlreadyExistsException.java new file mode 100644 index 00000000..3bbfc820 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/exception/PartnerProfileAlreadyExistsException.java @@ -0,0 +1,7 @@ +package makeus.cmc.malmo.application.exception; + +public class PartnerProfileAlreadyExistsException extends IllegalArgumentException { + public PartnerProfileAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/exception/PartnerProfileNotFoundException.java b/src/main/java/makeus/cmc/malmo/application/exception/PartnerProfileNotFoundException.java new file mode 100644 index 00000000..e7cb011f --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/exception/PartnerProfileNotFoundException.java @@ -0,0 +1,7 @@ +package makeus.cmc.malmo.application.exception; + +public class PartnerProfileNotFoundException extends IllegalArgumentException { + public PartnerProfileNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/helper/love_type/LoveTypePersonalityTypePromptQueryHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/love_type/LoveTypePersonalityTypePromptQueryHelper.java new file mode 100644 index 00000000..ab367fc8 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/helper/love_type/LoveTypePersonalityTypePromptQueryHelper.java @@ -0,0 +1,24 @@ +package makeus.cmc.malmo.application.helper.love_type; + +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.application.port.out.LoadLoveTypePersonalityTypePromptPort; +import makeus.cmc.malmo.domain.model.love_type.LoveTypePersonalityTypePrompt; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class LoveTypePersonalityTypePromptQueryHelper { + + private final LoadLoveTypePersonalityTypePromptPort loadLoveTypePersonalityTypePromptPort; + + public Optional findByPersonalityTypeAndLoveTypeCategory( + String personalityType, + LoveTypeCategory loveTypeCategory + ) { + return loadLoveTypePersonalityTypePromptPort + .loadByPersonalityTypeAndLoveTypeCategory(personalityType, loveTypeCategory); + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/helper/member/MemberQueryHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/member/MemberQueryHelper.java index 80f3090c..a87f0168 100644 --- a/src/main/java/makeus/cmc/malmo/application/helper/member/MemberQueryHelper.java +++ b/src/main/java/makeus/cmc/malmo/application/helper/member/MemberQueryHelper.java @@ -7,11 +7,13 @@ import makeus.cmc.malmo.application.exception.MemberNotFoundException; import makeus.cmc.malmo.application.exception.NotCoupleMemberException; import makeus.cmc.malmo.application.exception.NotValidCoupleCodeException; +import makeus.cmc.malmo.application.exception.PartnerProfileNotFoundException; import makeus.cmc.malmo.application.port.out.member.*; import makeus.cmc.malmo.domain.model.member.Member; import makeus.cmc.malmo.domain.value.id.InviteCodeValue; import makeus.cmc.malmo.domain.value.id.MemberId; import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; import makeus.cmc.malmo.domain.value.type.Provider; import makeus.cmc.malmo.domain.value.type.RelationshipStatus; import org.springframework.stereotype.Component; @@ -49,7 +51,7 @@ public MemberInfoDto getMemberInfoOrThrow(MemberId memberId) { public PartnerMemberDto getPartnerInfoOrThrow(MemberId memberId) { return loadPartnerPort.loadPartnerByMemberId(memberId) - .orElseThrow(MemberNotFoundException::new); + .orElseThrow(() -> new PartnerProfileNotFoundException("등록된 상대 프로필이 없습니다.")); } public Optional getMemberByProviderId(Provider provider, String providerId) { @@ -104,17 +106,15 @@ public static class MemberInfoDto { private RelationshipStatus relationshipStatus; private String personalityType; private String otherPersonalityType; + private PartnerLoveTypeCategory partnerLoveTypeCategory; } @Data @Builder public static class PartnerMemberDto { - private String memberState; - private LoveTypeCategory loveTypeCategory; - private float avoidanceRate; - private float anxietyRate; - private String nickname; - private Boolean isStartLoveDateUpdated; + private String personalityType; + private PartnerLoveTypeCategory loveTypeCategory; + private String description; } } diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/GetLoveTypePersonalityTypeResultUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/GetLoveTypePersonalityTypeResultUseCase.java new file mode 100644 index 00000000..efab5a15 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/in/GetLoveTypePersonalityTypeResultUseCase.java @@ -0,0 +1,49 @@ +package makeus.cmc.malmo.application.port.in; + +import lombok.Builder; +import lombok.Data; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; + +import java.util.List; + +public interface GetLoveTypePersonalityTypeResultUseCase { + + LoveTypePersonalityTypeResultResponse getResult(GetLoveTypePersonalityTypeResultCommand command); + + @Data + @Builder + class GetLoveTypePersonalityTypeResultCommand { + private String personalityType; + private LoveTypeCategory loveTypeCategory; + } + + @Data + @Builder + class LoveTypePersonalityTypeResultResponse { + private String personalityType; + private LoveTypeCategory loveTypeCategory; + private String summary; + private List keywords; + private List strengths; + private List weaknesses; + private List patterns; + private List loveTypeFeatures; + private List datingGuides; + private List bestMatches; + private List worstMatches; + } + + @Data + @Builder + class TitleDescriptionItem { + private String title; + private String description; + } + + @Data + @Builder + class PersonalityTypeDescriptionItem { + private String personalityType; + private String description; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/member/CreatePartnerProfileUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/member/CreatePartnerProfileUseCase.java new file mode 100644 index 00000000..6266c561 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/in/member/CreatePartnerProfileUseCase.java @@ -0,0 +1,26 @@ +package makeus.cmc.malmo.application.port.in.member; + +import lombok.Builder; +import lombok.Data; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; + +public interface CreatePartnerProfileUseCase { + + PartnerProfileResponseDto createPartnerProfile(CreatePartnerProfileCommand command); + + @Data + @Builder + class CreatePartnerProfileCommand { + private Long memberId; + private String personalityType; + private PartnerLoveTypeCategory loveTypeCategory; + } + + @Data + @Builder + class PartnerProfileResponseDto { + private String personalityType; + private PartnerLoveTypeCategory loveTypeCategory; + private String description; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/member/GetMemberUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/member/GetMemberUseCase.java index 2c70c681..df099ec5 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/in/member/GetMemberUseCase.java +++ b/src/main/java/makeus/cmc/malmo/application/port/in/member/GetMemberUseCase.java @@ -4,6 +4,7 @@ import lombok.Data; import makeus.cmc.malmo.domain.value.state.MemberState; import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; import makeus.cmc.malmo.domain.value.type.Provider; import makeus.cmc.malmo.domain.value.type.RelationshipStatus; @@ -39,5 +40,6 @@ class MemberResponseDto { private RelationshipStatus relationshipStatus; private String personalityType; private String otherPersonalityType; + private PartnerLoveTypeCategory partnerLoveTypeCategory; } } diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/member/GetPartnerUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/member/GetPartnerUseCase.java index ae2c7776..09d83848 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/in/member/GetPartnerUseCase.java +++ b/src/main/java/makeus/cmc/malmo/application/port/in/member/GetPartnerUseCase.java @@ -2,8 +2,7 @@ import lombok.Builder; import lombok.Data; -import makeus.cmc.malmo.domain.value.state.MemberState; -import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; public interface GetPartnerUseCase { @@ -18,11 +17,8 @@ class PartnerInfoCommand { @Data @Builder class PartnerMemberResponseDto { - private MemberState memberState; - private LoveTypeCategory loveTypeCategory; - private float avoidanceRate; - private float anxietyRate; - private String nickname; - private Boolean isStartLoveDateUpdated; + private String personalityType; + private PartnerLoveTypeCategory loveTypeCategory; + private String description; } } diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/member/SignUpUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/member/SignUpUseCase.java index 9b926adf..77172304 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/in/member/SignUpUseCase.java +++ b/src/main/java/makeus/cmc/malmo/application/port/in/member/SignUpUseCase.java @@ -18,8 +18,6 @@ class SignUpCommand { private String nickname; private Long loveTypeId; private RelationshipStatus relationshipStatus; - private String personalityType; - private String otherPersonalityType; } @Data @@ -28,4 +26,4 @@ class TermsCommand { private Long termsId; private Boolean isAgreed; } -} \ No newline at end of file +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/member/UpdateMemberUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/member/UpdateMemberUseCase.java index 93910217..8444aa43 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/in/member/UpdateMemberUseCase.java +++ b/src/main/java/makeus/cmc/malmo/application/port/in/member/UpdateMemberUseCase.java @@ -2,6 +2,7 @@ import lombok.Builder; import lombok.Data; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; import makeus.cmc.malmo.domain.value.type.RelationshipStatus; public interface UpdateMemberUseCase { @@ -15,7 +16,7 @@ class UpdateMemberCommand { private String nickname; private RelationshipStatus relationshipStatus; private String personalityType; - private String otherPersonalityType; + private LoveTypeCategory loveTypeCategory; } @Data @@ -24,6 +25,6 @@ class UpdateMemberResponseDto { private String nickname; private RelationshipStatus relationshipStatus; private String personalityType; - private String otherPersonalityType; + private LoveTypeCategory loveTypeCategory; } } diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/member/UpdatePartnerProfileUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/member/UpdatePartnerProfileUseCase.java new file mode 100644 index 00000000..d562b0d3 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/in/member/UpdatePartnerProfileUseCase.java @@ -0,0 +1,18 @@ +package makeus.cmc.malmo.application.port.in.member; + +import lombok.Builder; +import lombok.Data; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; + +public interface UpdatePartnerProfileUseCase { + + CreatePartnerProfileUseCase.PartnerProfileResponseDto updatePartnerProfile(UpdatePartnerProfileCommand command); + + @Data + @Builder + class UpdatePartnerProfileCommand { + private Long memberId; + private String personalityType; + private PartnerLoveTypeCategory loveTypeCategory; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/CheckOpenAIHealth.java b/src/main/java/makeus/cmc/malmo/application/port/out/CheckLlmHealth.java similarity index 67% rename from src/main/java/makeus/cmc/malmo/application/port/out/CheckOpenAIHealth.java rename to src/main/java/makeus/cmc/malmo/application/port/out/CheckLlmHealth.java index d4c14420..6155aebf 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/out/CheckOpenAIHealth.java +++ b/src/main/java/makeus/cmc/malmo/application/port/out/CheckLlmHealth.java @@ -1,5 +1,5 @@ package makeus.cmc.malmo.application.port.out; -public interface CheckOpenAIHealth { +public interface CheckLlmHealth { boolean checkHealth(); } diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/LoadLoveTypePersonalityTypeFeaturePort.java b/src/main/java/makeus/cmc/malmo/application/port/out/LoadLoveTypePersonalityTypeFeaturePort.java new file mode 100644 index 00000000..559b5def --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/out/LoadLoveTypePersonalityTypeFeaturePort.java @@ -0,0 +1,13 @@ +package makeus.cmc.malmo.application.port.out; + +import makeus.cmc.malmo.domain.model.love_type.LoveTypePersonalityTypeFeature; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; + +import java.util.Optional; + +public interface LoadLoveTypePersonalityTypeFeaturePort { + Optional loadByPersonalityTypeAndLoveTypeCategory( + String personalityType, + LoveTypeCategory loveTypeCategory + ); +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/LoadLoveTypePersonalityTypePromptPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/LoadLoveTypePersonalityTypePromptPort.java new file mode 100644 index 00000000..5f0036e4 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/out/LoadLoveTypePersonalityTypePromptPort.java @@ -0,0 +1,13 @@ +package makeus.cmc.malmo.application.port.out; + +import makeus.cmc.malmo.domain.model.love_type.LoveTypePersonalityTypePrompt; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; + +import java.util.Optional; + +public interface LoadLoveTypePersonalityTypePromptPort { + Optional loadByPersonalityTypeAndLoveTypeCategory( + String personalityType, + LoveTypeCategory loveTypeCategory + ); +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LlmReasoningScenario.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LlmReasoningScenario.java new file mode 100644 index 00000000..8ff9da1f --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LlmReasoningScenario.java @@ -0,0 +1,9 @@ +package makeus.cmc.malmo.application.port.out.chat; + +public enum LlmReasoningScenario { + STRUCTURED_CHAT, + FREE_CONVERSATION, + VALIDATION, + SUMMARY, + AUXILIARY_EXTRACTION +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadChatRoomMetadataPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadChatRoomMetadataPort.java index 87dc7dcb..e697e85c 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadChatRoomMetadataPort.java +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadChatRoomMetadataPort.java @@ -2,6 +2,7 @@ import makeus.cmc.malmo.domain.value.id.MemberId; import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; import java.util.Optional; @@ -9,5 +10,5 @@ public interface LoadChatRoomMetadataPort { Optional loadChatRoomMetadata(MemberId memberId); - record ChatRoomMetadataDto(LoveTypeCategory memberLoveType, LoveTypeCategory partnerLoveType) {} + record ChatRoomMetadataDto(LoveTypeCategory memberLoveType, PartnerLoveTypeCategory partnerLoveType) {} } diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/RequestChatApiPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/RequestChatApiPort.java index 697cd3f5..02a3005f 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/out/chat/RequestChatApiPort.java +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/RequestChatApiPort.java @@ -8,9 +8,13 @@ import java.util.function.Consumer; public interface RequestChatApiPort { - Mono requestStreamResponse(List> messages, Consumer onData); + Mono requestStreamResponse(List> messages, + LlmReasoningScenario scenario, + Consumer onData); - CompletableFuture requestResponse(List> messages); + CompletableFuture requestResponse(List> messages, + LlmReasoningScenario scenario); - CompletableFuture requestJsonResponse(List> messages); + CompletableFuture requestJsonResponse(List> messages, + LlmReasoningScenario scenario); } diff --git a/src/main/java/makeus/cmc/malmo/application/service/LoveTypePersonalityTypeFeatureService.java b/src/main/java/makeus/cmc/malmo/application/service/LoveTypePersonalityTypeFeatureService.java new file mode 100644 index 00000000..dfa5c9d5 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/service/LoveTypePersonalityTypeFeatureService.java @@ -0,0 +1,107 @@ +package makeus.cmc.malmo.application.service; + +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.application.exception.LoveTypePersonalityTypeFeatureNotFoundException; +import makeus.cmc.malmo.application.port.in.GetLoveTypePersonalityTypeResultUseCase; +import makeus.cmc.malmo.application.port.out.LoadLoveTypePersonalityTypeFeaturePort; +import makeus.cmc.malmo.domain.model.love_type.LoveTypePersonalityTypeFeature; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +public class LoveTypePersonalityTypeFeatureService implements GetLoveTypePersonalityTypeResultUseCase { + + private final LoadLoveTypePersonalityTypeFeaturePort loadLoveTypePersonalityTypeFeaturePort; + + @Override + public LoveTypePersonalityTypeResultResponse getResult(GetLoveTypePersonalityTypeResultCommand command) { + LoveTypePersonalityTypeFeature feature = loadLoveTypePersonalityTypeFeaturePort + .loadByPersonalityTypeAndLoveTypeCategory(command.getPersonalityType(), command.getLoveTypeCategory()) + .orElseThrow(LoveTypePersonalityTypeFeatureNotFoundException::new); + + return LoveTypePersonalityTypeResultResponse.builder() + .personalityType(feature.getPersonalityType()) + .loveTypeCategory(feature.getLoveTypeCategory()) + .summary(feature.getSummary()) + .keywords(buildStringList(feature.getKeyword1(), feature.getKeyword2(), feature.getKeyword3())) + .strengths(buildTitleDescriptionItems( + feature.getStrength1(), feature.getStrengthDesc1(), + feature.getStrength2(), feature.getStrengthDesc2(), + feature.getStrength3(), feature.getStrengthDesc3() + )) + .weaknesses(buildTitleDescriptionItems(feature.getWeakness(), feature.getWeaknessDesc())) + .patterns(buildTitleDescriptionItems( + feature.getPatternTitle1(), feature.getPattern1(), + feature.getPatternTitle2(), feature.getPattern2(), + feature.getPatternTitle3(), feature.getPattern3(), + feature.getPatternTitle4(), feature.getPattern4() + )) + .loveTypeFeatures(buildTitleDescriptionItems( + feature.getLoveTypeFeatureTitle1(), feature.getLoveTypeFeature1(), + feature.getLoveTypeFeatureTitle2(), feature.getLoveTypeFeature2(), + feature.getLoveTypeFeatureTitle3(), feature.getLoveTypeFeature3(), + feature.getLoveTypeFeatureTitle4(), feature.getLoveTypeFeature4() + )) + .datingGuides(buildDatingGuideItems( + feature.getDatingGuide1(), + feature.getDatingGuide2(), + feature.getDatingGuide3() + )) + .bestMatches(buildPersonalityTypeDescriptionItems( + feature.getBestPersonalityType1(), feature.getBestDesc1(), + feature.getBestPersonalityType2(), feature.getBestDesc2() + )) + .worstMatches(buildPersonalityTypeDescriptionItems( + feature.getWorstPersonalityType1(), feature.getWorstDesc1(), + feature.getWorstPersonalityType2(), feature.getWorstDesc2() + )) + .build(); + } + + private List buildStringList(String... values) { + return Stream.of(values) + .filter(StringUtils::hasText) + .toList(); + } + + private List buildDatingGuideItems(String... values) { + return Stream.of(values) + .filter(StringUtils::hasText) + .map(value -> { + String[] parts = value.split(":", 2); + return TitleDescriptionItem.builder() + .title(parts[0].trim()) + .description(parts.length > 1 ? parts[1].trim() : null) + .build(); + }) + .toList(); + } + + private List buildTitleDescriptionItems(String... values) { + return Stream.iterate(0, index -> index < values.length, index -> index + 2) + .map(index -> TitleDescriptionItem.builder() + .title(normalizeBlank(values[index])) + .description(normalizeBlank(values[index + 1])) + .build()) + .filter(item -> StringUtils.hasText(item.getTitle()) || StringUtils.hasText(item.getDescription())) + .toList(); + } + + private List buildPersonalityTypeDescriptionItems(String... values) { + return Stream.iterate(0, index -> index < values.length, index -> index + 2) + .map(index -> PersonalityTypeDescriptionItem.builder() + .personalityType(normalizeBlank(values[index])) + .description(normalizeBlank(values[index + 1])) + .build()) + .filter(item -> StringUtils.hasText(item.getPersonalityType()) || StringUtils.hasText(item.getDescription())) + .toList(); + } + + private String normalizeBlank(String value) { + return StringUtils.hasText(value) ? value : null; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/service/OutboxService.java b/src/main/java/makeus/cmc/malmo/application/service/OutboxService.java index 93894a30..9c1beba9 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/OutboxService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/OutboxService.java @@ -7,7 +7,7 @@ import makeus.cmc.malmo.application.port.in.MarkOutboxUseCase; import makeus.cmc.malmo.application.port.in.PublishStreamMessageUseCase; import makeus.cmc.malmo.application.port.in.RetryPublishingUseCase; -import makeus.cmc.malmo.application.port.out.CheckOpenAIHealth; +import makeus.cmc.malmo.application.port.out.CheckLlmHealth; import makeus.cmc.malmo.application.port.out.LoadOutboxPort; import makeus.cmc.malmo.application.port.out.SaveOutboxPort; import makeus.cmc.malmo.application.port.out.chat.LoadPendingMessagePort; @@ -36,7 +36,7 @@ public class OutboxService implements PublishStreamMessageUseCase, RetryPublishi private final LoadPendingMessagePort loadPendingMessagePort; private final LoadOutboxPort loadOutboxPort; private final SaveOutboxPort saveOutboxPort; - private final CheckOpenAIHealth checkOpenAIHealth; + private final CheckLlmHealth checkLlmHealth; @Override @Transactional @@ -114,7 +114,7 @@ public void retryPublishing() { @Override @Transactional public void retryFailedMessages() { - boolean isUp = checkOpenAIHealth.checkHealth(); + boolean isUp = checkLlmHealth.checkHealth(); if (!isUp) return; List failedOutboxList = loadOutboxPort.findByState(OutboxState.FAILED); diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java index 5b63430a..47abf61b 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java @@ -8,9 +8,11 @@ import makeus.cmc.malmo.application.helper.chat_room.PromptQueryHelper; import makeus.cmc.malmo.application.helper.chat_room.DetailedPromptQueryHelper; import makeus.cmc.malmo.application.helper.chat_room.MemberChatRoomMetadataCommandHelper; +import makeus.cmc.malmo.application.helper.member.MemberCommandHelper; import makeus.cmc.malmo.application.helper.member.MemberMemoryCommandHelper; import makeus.cmc.malmo.application.helper.member.MemberQueryHelper; import makeus.cmc.malmo.application.helper.question.CoupleQuestionQueryHelper; +import makeus.cmc.malmo.application.port.out.chat.LlmReasoningScenario; import makeus.cmc.malmo.application.port.in.chat.ProcessMessageUseCase; import makeus.cmc.malmo.application.port.in.chat.SufficiencyCheckResult; import makeus.cmc.malmo.domain.model.chat.ChatMessage; @@ -29,7 +31,9 @@ import makeus.cmc.malmo.domain.value.id.CoupleId; import makeus.cmc.malmo.domain.value.id.CoupleQuestionId; import makeus.cmc.malmo.domain.value.id.MemberId; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; import makeus.cmc.malmo.util.ChatMessageSplitter; +import makeus.cmc.malmo.util.GlobalConstants; import org.springframework.stereotype.Service; import java.util.ArrayList; @@ -59,6 +63,7 @@ public class ChatMessageService implements ProcessMessageUseCase { private final CoupleQuestionQueryHelper coupleQuestionQueryHelper; + private final MemberCommandHelper memberCommandHelper; private final MemberMemoryCommandHelper memberMemoryCommandHelper; private final makeus.cmc.malmo.application.helper.outbox.OutboxHelper outboxHelper; @@ -104,16 +109,7 @@ public CompletableFuture processStreamChatMessage(ProcessMessageCommand co } // 마지막 충분성 조건인 경우 - // 1단계 종료 시 제목 생성 요청 - if (command.getPromptLevel() == 1) { - requestTitleGenerationAsync(chatRoom); - } - - // 다음 단계 오프닝 생성 성공 후 단계 전이 - return requestNextStageOpening(member, chatRoom, command) - .thenRun(() -> - chatRoomCommandHelper.upgradeChatRoomLevel(chatRoom.getId(), command.getPromptLevel() + 1, 1) - ); + return handleCompletedStage(member, chatRoom, command); }); } @@ -177,7 +173,7 @@ private CompletableFuture processFreeConversation(Member member, ChatRoom DetailedPrompt detailedPrompt = detailedPromptQueryHelper.getGuidelinePromptWithFallback( command.getPromptLevel(), command.getDetailedLevel()); - return chatProcessor.streamChat(messages, systemPrompt, prompt, detailedPrompt, + return chatProcessor.streamChat(messages, LlmReasoningScenario.FREE_CONVERSATION, systemPrompt, prompt, detailedPrompt, chunk -> chatSseSender.sendResponseChunk(MemberId.of(member.getId()), chunk), fullAnswer -> saveAiMessage(MemberId.of(member.getId()), ChatRoomId.of(chatRoom.getId()), command.getPromptLevel(), command.getDetailedLevel(), fullAnswer), @@ -212,7 +208,7 @@ private CompletableFuture requestResponseToMeetCondition(Member member, Ch DetailedPrompt detailedPrompt = detailedPromptQueryHelper.getGuidelinePromptWithFallback( command.getPromptLevel(), command.getDetailedLevel()); - return chatProcessor.streamChat(messages, systemPrompt, prompt, detailedPrompt, + return chatProcessor.streamChat(messages, LlmReasoningScenario.STRUCTURED_CHAT, systemPrompt, prompt, detailedPrompt, chunk -> chatSseSender.sendResponseChunk(MemberId.of(member.getId()), chunk), fullAnswer -> saveAiMessage(MemberId.of(member.getId()), ChatRoomId.of(chatRoom.getId()), command.getPromptLevel(), command.getDetailedLevel(), fullAnswer), @@ -250,7 +246,7 @@ private CompletableFuture requestNextDetailedPromptOpening(ChatRoom chatRo DetailedPrompt nextDetailedPrompt = detailedPromptQueryHelper.getGuidelinePromptWithFallback( command.getPromptLevel(), command.getDetailedLevel() + 1); - return chatProcessor.streamChat(messages, systemPrompt, prompt, nextDetailedPrompt, + return chatProcessor.streamChat(messages, LlmReasoningScenario.STRUCTURED_CHAT, systemPrompt, prompt, nextDetailedPrompt, chunk -> chatSseSender.sendResponseChunk(memberId, chunk), fullAnswer -> saveAiMessage(memberId, ChatRoomId.of(chatRoom.getId()), command.getPromptLevel(), command.getDetailedLevel() + 1, fullAnswer), @@ -272,7 +268,7 @@ private CompletableFuture requestNextStageOpening(Member member, ChatRoom // DetailedPrompt도 fallback 로직 적용 DetailedPrompt nextDetailedPrompt = detailedPromptQueryHelper.getGuidelinePromptWithFallback(nextLevel, 1); - return chatProcessor.streamChat(messages, systemPrompt, nextPrompt, nextDetailedPrompt, + return chatProcessor.streamChat(messages, LlmReasoningScenario.STRUCTURED_CHAT, systemPrompt, nextPrompt, nextDetailedPrompt, chunk -> chatSseSender.sendResponseChunk(MemberId.of(member.getId()), chunk), fullAnswer -> saveAiMessage(MemberId.of(member.getId()), ChatRoomId.of(chatRoom.getId()), nextLevel, 1, fullAnswer), @@ -280,6 +276,61 @@ private CompletableFuture requestNextStageOpening(Member member, ChatRoom ).toFuture(); } + private CompletableFuture handleCompletedStage( + Member member, + ChatRoom chatRoom, + ProcessMessageCommand command + ) { + if (command.getPromptLevel() == 1) { + requestTitleGenerationAsync(chatRoom); + } + + return requestNextStageOpening(member, chatRoom, command) + .thenCompose(ignored -> inferAndPersistPartnerLoveTypeIfNeeded(member, chatRoom, command)) + .thenRun(() -> + chatRoomCommandHelper.upgradeChatRoomLevel(chatRoom.getId(), command.getPromptLevel() + 1, 1) + ); + } + + private CompletableFuture inferAndPersistPartnerLoveTypeIfNeeded( + Member member, + ChatRoom chatRoom, + ProcessMessageCommand command + ) { + if (!shouldInferPartnerLoveType(member, command)) { + return CompletableFuture.completedFuture(null); + } + + List> messages = chatPromptBuilder.createForPartnerLoveTypeInference( + member, chatRoom, command.getPromptLevel() + 1); + + return chatProcessor.requestPartnerLoveTypeCategoryInference( + messages, + GlobalConstants.PARTNER_LOVE_TYPE_INFERENCE_PROMPT + ) + .thenAccept(inferredType -> persistPartnerLoveTypeIfStillUnknown(command.getMemberId(), inferredType)) + .exceptionally(throwable -> { + log.warn("Failed to infer partner love type for memberId={}, chatRoomId={}", + command.getMemberId(), chatRoom.getId(), throwable); + return null; + }); + } + + private boolean shouldInferPartnerLoveType(Member member, ProcessMessageCommand command) { + return command.getPromptLevel() == 1 + && (member.getPartnerLoveTypeCategory() == null + || member.getPartnerLoveTypeCategory() == PartnerLoveTypeCategory.UNKNOWN); + } + + private void persistPartnerLoveTypeIfStillUnknown(Long memberId, PartnerLoveTypeCategory inferredType) { + Member refreshedMember = memberQueryHelper.getMemberByIdOrThrow(MemberId.of(memberId)); + boolean updated = refreshedMember.updatePartnerLoveTypeCategoryIfUnknown(inferredType); + if (!updated) { + return; + } + memberCommandHelper.saveMember(refreshedMember); + } + /** * 비동기 제목 생성 요청 * Redis Stream을 통해 제목 생성 워커에 전달 diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatProcessor.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatProcessor.java index 5305c95a..1ba3e2c4 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatProcessor.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatProcessor.java @@ -1,13 +1,16 @@ package makeus.cmc.malmo.application.service.chat; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import makeus.cmc.malmo.application.port.out.chat.LlmReasoningScenario; import makeus.cmc.malmo.application.port.out.chat.RequestChatApiPort; import makeus.cmc.malmo.application.port.in.chat.SufficiencyCheckResult; import makeus.cmc.malmo.domain.model.chat.DetailedPrompt; import makeus.cmc.malmo.domain.model.chat.Prompt; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; import makeus.cmc.malmo.domain.value.type.SenderType; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; @@ -27,6 +30,7 @@ public class ChatProcessor { private final ObjectMapper objectMapper; public Mono streamChat(List> messages, + LlmReasoningScenario scenario, Prompt systemPrompt, Prompt prompt, DetailedPrompt detailedPrompt, @@ -40,7 +44,7 @@ public Mono streamChat(List> messages, log.info("Starting streamChat with messages: {}", messages); - return requestChatApiPort.requestStreamResponse(messages, onChunk) // onChunk 콜백만 넘김 + return requestChatApiPort.requestStreamResponse(messages, scenario, onChunk) .flatMap(fullAnswer -> { // 스트림이 성공적으로 완료되고 전체 응답(fullAnswer)이 오면 onComplete 로직 실행 onComplete.accept(fullAnswer); @@ -60,7 +64,7 @@ public CompletableFuture requestSummaryAsync(List> m messages.add(createMessageMap(SenderType.SYSTEM, prompt.getContent())); messages.add(createMessageMap(SenderType.SYSTEM, "[현재 단계 지시] " + summaryPrompt.getContent())); - return requestChatApiPort.requestResponse(messages); + return requestChatApiPort.requestResponse(messages, LlmReasoningScenario.SUMMARY); } @@ -73,7 +77,33 @@ public CompletableFuture requestMetaData(String question, createMessageMap(SenderType.USER, "[답변] " + memberAnswer) ); - return requestChatApiPort.requestResponse(messages); + return requestChatApiPort.requestResponse(messages, LlmReasoningScenario.AUXILIARY_EXTRACTION); + } + + public CompletableFuture requestPartnerLoveTypeCategoryInference( + List> messages, + String inferencePrompt + ) { + messages.add(createMessageMap(SenderType.SYSTEM, inferencePrompt)); + + return requestChatApiPort.requestJsonResponse(messages, LlmReasoningScenario.AUXILIARY_EXTRACTION) + .thenApply(jsonResponse -> { + try { + JsonNode node = objectMapper.readTree(jsonResponse); + String rawValue = node.path("partnerLoveTypeCategory").asText(null); + if (rawValue == null || rawValue.isBlank()) { + throw new IllegalArgumentException("partnerLoveTypeCategory is required"); + } + PartnerLoveTypeCategory partnerLoveTypeCategory = PartnerLoveTypeCategory.valueOf(rawValue); + if (partnerLoveTypeCategory == PartnerLoveTypeCategory.UNKNOWN) { + throw new IllegalArgumentException("UNKNOWN is not allowed for inferred partner love type"); + } + return partnerLoveTypeCategory; + } catch (JsonProcessingException e) { + log.error("Failed to parse partner love type inference JSON: {}", jsonResponse, e); + throw new RuntimeException("Failed to parse partner love type inference JSON", e); + } + }); } public CompletableFuture requestSufficiencyCheck(List> messages, @@ -82,7 +112,7 @@ public CompletableFuture requestSufficiencyCheck(List { try { log.info("Received sufficiency check JSON: {}", jsonResponse); @@ -97,7 +127,7 @@ public CompletableFuture requestSufficiencyCheck(List requestDetailedSummary(List> messages, DetailedPrompt summaryPrompt) { messages.add(createMessageMap(SenderType.SYSTEM, summaryPrompt.getContent())); - return requestChatApiPort.requestResponse(messages); + return requestChatApiPort.requestResponse(messages, LlmReasoningScenario.SUMMARY); } /** @@ -109,7 +139,7 @@ public CompletableFuture requestDetailedSummary(List public CompletableFuture requestConversationSummary(List> messages, Prompt summaryPrompt) { messages.add(createMessageMap(SenderType.SYSTEM, summaryPrompt.getContent())); - return requestChatApiPort.requestResponse(messages); + return requestChatApiPort.requestResponse(messages, LlmReasoningScenario.SUMMARY); } /** @@ -123,7 +153,7 @@ public CompletableFuture requestTitleGeneration(List List> promptMessages = new ArrayList<>(messages); promptMessages.add(createMessageMap(SenderType.SYSTEM, titlePrompt.getContent())); - return requestChatApiPort.requestResponse(promptMessages) + return requestChatApiPort.requestResponse(promptMessages, LlmReasoningScenario.AUXILIARY_EXTRACTION) .thenApply(title -> { // 제목 길이 제한 (최대 50자) String trimmedTitle = title.trim(); diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilder.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilder.java index 5947a37d..e571cb24 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilder.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilder.java @@ -1,8 +1,10 @@ package makeus.cmc.malmo.application.service.chat; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import makeus.cmc.malmo.application.helper.chat_room.ChatRoomQueryHelper; import makeus.cmc.malmo.application.helper.chat_room.MemberChatRoomMetadataQueryHelper; +import makeus.cmc.malmo.application.helper.love_type.LoveTypePersonalityTypePromptQueryHelper; import makeus.cmc.malmo.application.port.out.chat.LoadChatRoomMetadataPort; import makeus.cmc.malmo.domain.model.chat.ChatMessage; import makeus.cmc.malmo.domain.model.chat.ChatRoom; @@ -12,19 +14,26 @@ import makeus.cmc.malmo.domain.model.member.MemberMemory; import makeus.cmc.malmo.domain.value.id.ChatRoomId; import makeus.cmc.malmo.domain.value.id.MemberId; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; import makeus.cmc.malmo.domain.value.type.SenderType; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.List; import java.util.Map; +@Slf4j @Service @RequiredArgsConstructor public class ChatPromptBuilder { + private static final String UNKNOWN_INFERENCE_PROMPT = "UNKNOWN, 사용자와의 대화로부터 유추할 것"; + private final ChatRoomQueryHelper chatRoomQueryHelper; private final MemberChatRoomMetadataQueryHelper memberChatRoomMetadataQueryHelper; + private final LoveTypePersonalityTypePromptQueryHelper loveTypePersonalityTypePromptQueryHelper; public List> createForProcessUserMessage(Member member, ChatRoom chatRoom, String userMessage) { List> messages = new ArrayList<>(); @@ -94,13 +103,60 @@ private String getMetaDataContent(Member member) { String memberLoveTypeTitle = chatRoomMetadataDto.memberLoveType() != null ? chatRoomMetadataDto.memberLoveType().getTitle() : "알 수 없음"; metadataBuilder.append("- 사용자 애착 유형: ").append(memberLoveTypeTitle).append("\n"); - String partnerLoveType = chatRoomMetadataDto.partnerLoveType() != null ? chatRoomMetadataDto.partnerLoveType().getTitle() : "알 수 없음"; + String partnerLoveType = chatRoomMetadataDto.partnerLoveType() != null ? chatRoomMetadataDto.partnerLoveType().getDescription() : "알 수 없음"; metadataBuilder.append("- 애인 애착 유형: ").append(partnerLoveType).append("\n"); + metadataBuilder.append("- 사용자 성향 프롬프트:\n") + .append(resolveMemberPrompt(member)) + .append("\n"); + + if (StringUtils.hasText(member.getOtherPersonalityType())) { + metadataBuilder.append("- 상대방 성향 프롬프트:\n") + .append(resolvePartnerPrompt(member)) + .append("\n"); + } + metadataBuilder.append(memberMemoryList); return metadataBuilder.toString(); } + private String resolveMemberPrompt(Member member) { + if (!StringUtils.hasText(member.getPersonalityType()) || member.getLoveTypeCategory() == null) { + return UNKNOWN_INFERENCE_PROMPT; + } + + return loveTypePersonalityTypePromptQueryHelper + .findByPersonalityTypeAndLoveTypeCategory(member.getPersonalityType(), member.getLoveTypeCategory()) + .map(prompt -> prompt.getPrompts()) + .filter(StringUtils::hasText) + .orElseGet(() -> { + log.warn("Missing love_type_personality_type_prompt row for member. personalityType={}, loveType={}", + member.getPersonalityType(), member.getLoveTypeCategory()); + return UNKNOWN_INFERENCE_PROMPT; + }); + } + + private String resolvePartnerPrompt(Member member) { + if (!StringUtils.hasText(member.getOtherPersonalityType())) { + return UNKNOWN_INFERENCE_PROMPT; + } + + if (member.getPartnerLoveTypeCategory() == null || member.getPartnerLoveTypeCategory() == PartnerLoveTypeCategory.UNKNOWN) { + return UNKNOWN_INFERENCE_PROMPT; + } + + LoveTypeCategory partnerLoveTypeCategory = LoveTypeCategory.valueOf(member.getPartnerLoveTypeCategory().name()); + return loveTypePersonalityTypePromptQueryHelper + .findByPersonalityTypeAndLoveTypeCategory(member.getOtherPersonalityType(), partnerLoveTypeCategory) + .map(prompt -> prompt.getPrompts()) + .filter(StringUtils::hasText) + .orElseGet(() -> { + log.warn("Missing love_type_personality_type_prompt row for partner. memberId={}, otherPersonalityType={}, partnerLoveType={}", + member.getId(), member.getOtherPersonalityType(), partnerLoveTypeCategory); + return UNKNOWN_INFERENCE_PROMPT; + }); + } + public String getMemberMemoriesByMemberId(MemberId memberId) { StringBuilder sb = new StringBuilder(); List memoryList = chatRoomQueryHelper.getMemberMemoriesByMemberId(memberId); @@ -178,6 +234,27 @@ public List> createForNextStage(Member member, ChatRoom chat return messages; } + public List> createForPartnerLoveTypeInference(Member member, ChatRoom chatRoom, int targetLevel) { + List> messages = new ArrayList<>(); + ChatRoomId chatRoomId = ChatRoomId.of(chatRoom.getId()); + + String metaDataContent = getMetaDataContent(member); + messages.add(createMessageMap(SenderType.SYSTEM, metaDataContent)); + + List metadataList = memberChatRoomMetadataQueryHelper.getMemberChatRoomMetadata(chatRoomId); + if (!metadataList.isEmpty()) { + String metadataContent = getMemberChatRoomMetadataContent(metadataList); + messages.add(createMessageMap(SenderType.SYSTEM, metadataContent)); + } + + List stageMessages = chatRoomQueryHelper.getChatRoomLevelMessages(chatRoomId, targetLevel); + for (ChatMessage chatMessage : stageMessages) { + messages.add(createMessageMap(chatMessage.getSenderType(), chatMessage.getContent())); + } + + return messages; + } + private String getMemberChatRoomMetadataContent(List metadataList) { if (metadataList == null || metadataList.isEmpty()) { return ""; diff --git a/src/main/java/makeus/cmc/malmo/application/service/member/MemberCommandService.java b/src/main/java/makeus/cmc/malmo/application/service/member/MemberCommandService.java index b0ad5ca8..20ba3694 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/member/MemberCommandService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/member/MemberCommandService.java @@ -10,14 +10,19 @@ import makeus.cmc.malmo.application.helper.member.MemberQueryHelper; import makeus.cmc.malmo.application.helper.member.OauthTokenHelper; import makeus.cmc.malmo.application.helper.notification.MemberNotificationCommandHelper; +import makeus.cmc.malmo.application.port.in.member.CreatePartnerProfileUseCase; import makeus.cmc.malmo.application.port.in.member.DeleteMemberUseCase; +import makeus.cmc.malmo.application.port.in.member.UpdatePartnerProfileUseCase; import makeus.cmc.malmo.application.port.in.member.UpdateMemberUseCase; import makeus.cmc.malmo.application.port.in.member.UpdateStartLoveDateUseCase; +import makeus.cmc.malmo.application.exception.PartnerProfileAlreadyExistsException; +import makeus.cmc.malmo.application.exception.PartnerProfileNotFoundException; import makeus.cmc.malmo.application.port.out.sse.SendSseEventPort; import makeus.cmc.malmo.application.port.out.sse.ValidateSsePort; import makeus.cmc.malmo.domain.model.couple.Couple; import makeus.cmc.malmo.domain.model.member.Member; import makeus.cmc.malmo.domain.value.id.MemberId; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; import makeus.cmc.malmo.domain.value.type.Provider; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,7 +33,12 @@ @Service @RequiredArgsConstructor -public class MemberCommandService implements UpdateMemberUseCase, UpdateStartLoveDateUseCase, DeleteMemberUseCase { +public class MemberCommandService implements + UpdateMemberUseCase, + CreatePartnerProfileUseCase, + UpdatePartnerProfileUseCase, + UpdateStartLoveDateUseCase, + DeleteMemberUseCase { private final CoupleQueryHelper coupleQueryHelper; private final CoupleCommandHelper coupleCommandHelper; @@ -52,7 +62,7 @@ public UpdateMemberResponseDto updateMember(UpdateMemberCommand command) { command.getNickname(), command.getRelationshipStatus(), command.getPersonalityType(), - command.getOtherPersonalityType() + command.getLoveTypeCategory() ); Member savedMember = memberCommandHelper.saveMember(member); @@ -61,10 +71,40 @@ public UpdateMemberResponseDto updateMember(UpdateMemberCommand command) { .nickname(savedMember.getNickname()) .relationshipStatus(savedMember.getRelationshipStatus()) .personalityType(savedMember.getPersonalityType()) - .otherPersonalityType(savedMember.getOtherPersonalityType()) + .loveTypeCategory(savedMember.getLoveTypeCategory()) .build(); } + @Override + @CheckValidMember + @Transactional + public PartnerProfileResponseDto createPartnerProfile(CreatePartnerProfileCommand command) { + Member member = memberQueryHelper.getMemberByIdOrThrow(MemberId.of(command.getMemberId())); + if (member.hasPartnerProfile()) { + throw new PartnerProfileAlreadyExistsException("이미 상대 프로필이 등록되어 있습니다."); + } + + member.createPartnerProfile(command.getPersonalityType(), command.getLoveTypeCategory()); + Member savedMember = memberCommandHelper.saveMember(member); + + return toPartnerProfileResponse(savedMember); + } + + @Override + @CheckValidMember + @Transactional + public PartnerProfileResponseDto updatePartnerProfile(UpdatePartnerProfileCommand command) { + Member member = memberQueryHelper.getMemberByIdOrThrow(MemberId.of(command.getMemberId())); + if (!member.hasPartnerProfile()) { + throw new PartnerProfileNotFoundException("등록된 상대 프로필이 없습니다."); + } + + member.updatePartnerProfile(command.getPersonalityType(), command.getLoveTypeCategory()); + Member savedMember = memberCommandHelper.saveMember(member); + + return toPartnerProfileResponse(savedMember); + } + @Override @CheckCoupleMember @Transactional @@ -131,4 +171,15 @@ public void coupleUnlink(Member member, Couple couple) { memberNotificationCommandHelper.createAndSaveCoupleDisconnectedNotification(partnerId); } } + + private PartnerProfileResponseDto toPartnerProfileResponse(Member savedMember) { + return PartnerProfileResponseDto.builder() + .personalityType(savedMember.getOtherPersonalityType()) + .loveTypeCategory(savedMember.getPartnerLoveTypeCategory()) + .description(savedMember.getPartnerLoveTypeCategory() == null + ? null + : savedMember.getPartnerLoveTypeCategory().getDescription()) + .build(); + } + } diff --git a/src/main/java/makeus/cmc/malmo/application/service/member/MemberInfoService.java b/src/main/java/makeus/cmc/malmo/application/service/member/MemberInfoService.java index b59e5765..8df6a00c 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/member/MemberInfoService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/member/MemberInfoService.java @@ -1,7 +1,6 @@ package makeus.cmc.malmo.application.service.member; import lombok.RequiredArgsConstructor; -import makeus.cmc.malmo.adaptor.in.aop.CheckCoupleMember; import makeus.cmc.malmo.adaptor.in.aop.CheckValidMember; import makeus.cmc.malmo.application.helper.member.MemberQueryHelper; import makeus.cmc.malmo.application.port.in.member.GetMemberUseCase; @@ -35,21 +34,19 @@ public MemberResponseDto getMemberInfo(MemberInfoCommand command) { .relationshipStatus(member.getRelationshipStatus()) .personalityType(member.getPersonalityType()) .otherPersonalityType(member.getOtherPersonalityType()) + .partnerLoveTypeCategory(member.getPartnerLoveTypeCategory()) .build(); } @Override - @CheckCoupleMember + @CheckValidMember public PartnerMemberResponseDto getPartnerInfo(PartnerInfoCommand command) { MemberQueryHelper.PartnerMemberDto partner = memberQueryHelper.getPartnerInfoOrThrow(MemberId.of(command.getUserId())); return PartnerMemberResponseDto.builder() - .memberState(MemberState.valueOf(partner.getMemberState())) + .personalityType(partner.getPersonalityType()) .loveTypeCategory(partner.getLoveTypeCategory()) - .avoidanceRate(partner.getAvoidanceRate()) - .anxietyRate(partner.getAnxietyRate()) - .nickname(partner.getNickname()) - .isStartLoveDateUpdated(partner.getIsStartLoveDateUpdated()) + .description(partner.getDescription()) .build(); } } diff --git a/src/main/java/makeus/cmc/malmo/application/service/member/SignUpService.java b/src/main/java/makeus/cmc/malmo/application/service/member/SignUpService.java index eb3751c5..54e573e1 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/member/SignUpService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/member/SignUpService.java @@ -35,9 +35,7 @@ public void signUp(SignUpUseCase.SignUpCommand command) { Member member = memberQueryHelper.getMemberByIdOrThrow(MemberId.of(command.getMemberId())); member.signUp( command.getNickname(), - command.getRelationshipStatus(), - command.getPersonalityType(), - command.getOtherPersonalityType() + command.getRelationshipStatus() ); // 회원가입 전 애착 유형 검사를 진행했던 사용자인 경우 해당 정보를 가져와 덮어쓰기 diff --git a/src/main/java/makeus/cmc/malmo/config/LlmStartupLogger.java b/src/main/java/makeus/cmc/malmo/config/LlmStartupLogger.java new file mode 100644 index 00000000..b2479d59 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/config/LlmStartupLogger.java @@ -0,0 +1,33 @@ +package makeus.cmc.malmo.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import makeus.cmc.malmo.adaptor.out.AbstractOpenAiCompatibleApiClient; +import makeus.cmc.malmo.application.port.out.chat.RequestChatApiPort; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LlmStartupLogger { + + private final RequestChatApiPort requestChatApiPort; + + @EventListener(ApplicationReadyEvent.class) + public void logActiveLlmConfiguration() { + if (requestChatApiPort instanceof AbstractOpenAiCompatibleApiClient llmClient) { + log.info( + "Active LLM configuration: provider={}, model={}, defaultReasoningEffort={}, scenarioReasoningEfforts={}", + llmClient.currentProviderName(), + llmClient.currentModel(), + llmClient.currentDefaultReasoningEffort(), + llmClient.currentScenarioReasoningEfforts() + ); + return; + } + + log.info("Active LLM configuration: clientClass={}", requestChatApiPort.getClass().getName()); + } +} diff --git a/src/main/java/makeus/cmc/malmo/config/MainConfiguration.java b/src/main/java/makeus/cmc/malmo/config/MainConfiguration.java index 28dc5f43..55c64c08 100644 --- a/src/main/java/makeus/cmc/malmo/config/MainConfiguration.java +++ b/src/main/java/makeus/cmc/malmo/config/MainConfiguration.java @@ -3,15 +3,19 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import makeus.cmc.malmo.config.properties.GeminiApiProperties; +import makeus.cmc.malmo.config.properties.OpenAiApiProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; -import static makeus.cmc.malmo.util.GlobalConstants.OPENAI_CHAT_URL; - - @Configuration +@EnableConfigurationProperties({ + OpenAiApiProperties.class, + GeminiApiProperties.class +}) public class MainConfiguration { @Bean @@ -23,9 +27,16 @@ public ObjectMapper objectMapper() { } @Bean - public WebClient webClient() { + public WebClient openAiWebClient(OpenAiApiProperties openAiApiProperties) { + return WebClient.builder() + .baseUrl(openAiApiProperties.getBaseUrl()) + .build(); + } + + @Bean + public WebClient geminiWebClient(GeminiApiProperties geminiApiProperties) { return WebClient.builder() - .baseUrl(OPENAI_CHAT_URL) + .baseUrl(geminiApiProperties.getBaseUrl()) .build(); } diff --git a/src/main/java/makeus/cmc/malmo/config/properties/GeminiApiProperties.java b/src/main/java/makeus/cmc/malmo/config/properties/GeminiApiProperties.java new file mode 100644 index 00000000..061c8f24 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/config/properties/GeminiApiProperties.java @@ -0,0 +1,44 @@ +package makeus.cmc.malmo.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "gemini.api") +public class GeminiApiProperties { + + private String key; + private String model; + private String baseUrl; + private ReasoningEffortProperties reasoningEffort = new ReasoningEffortProperties(); + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public ReasoningEffortProperties getReasoningEffort() { + return reasoningEffort; + } + + public void setReasoningEffort(ReasoningEffortProperties reasoningEffort) { + this.reasoningEffort = reasoningEffort; + } +} diff --git a/src/main/java/makeus/cmc/malmo/config/properties/OpenAiApiProperties.java b/src/main/java/makeus/cmc/malmo/config/properties/OpenAiApiProperties.java new file mode 100644 index 00000000..03c4627e --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/config/properties/OpenAiApiProperties.java @@ -0,0 +1,53 @@ +package makeus.cmc.malmo.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "openai.api") +public class OpenAiApiProperties { + + private String key; + private String model; + private String baseUrl; + private String statusUrl; + private ReasoningEffortProperties reasoningEffort = new ReasoningEffortProperties(); + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getStatusUrl() { + return statusUrl; + } + + public void setStatusUrl(String statusUrl) { + this.statusUrl = statusUrl; + } + + public ReasoningEffortProperties getReasoningEffort() { + return reasoningEffort; + } + + public void setReasoningEffort(ReasoningEffortProperties reasoningEffort) { + this.reasoningEffort = reasoningEffort; + } +} diff --git a/src/main/java/makeus/cmc/malmo/config/properties/ReasoningEffortProperties.java b/src/main/java/makeus/cmc/malmo/config/properties/ReasoningEffortProperties.java new file mode 100644 index 00000000..7b6a723c --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/config/properties/ReasoningEffortProperties.java @@ -0,0 +1,36 @@ +package makeus.cmc.malmo.config.properties; + +import makeus.cmc.malmo.application.port.out.chat.LlmReasoningScenario; + +import java.util.EnumMap; +import java.util.Map; + +public class ReasoningEffortProperties { + + private String defaultEffort; + private Map scenarios = new EnumMap<>(LlmReasoningScenario.class); + + public String getDefault() { + return defaultEffort; + } + + public void setDefault(String defaultEffort) { + this.defaultEffort = defaultEffort; + } + + public Map getScenarios() { + return scenarios; + } + + public void setScenarios(Map scenarios) { + if (scenarios == null || scenarios.isEmpty()) { + this.scenarios = new EnumMap<>(LlmReasoningScenario.class); + return; + } + this.scenarios = new EnumMap<>(scenarios); + } + + public String getScenarioEffort(LlmReasoningScenario scenario) { + return scenarios.get(scenario); + } +} diff --git a/src/main/java/makeus/cmc/malmo/domain/model/love_type/LoveTypePersonalityTypeFeature.java b/src/main/java/makeus/cmc/malmo/domain/model/love_type/LoveTypePersonalityTypeFeature.java new file mode 100644 index 00000000..d7c11ab6 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/domain/model/love_type/LoveTypePersonalityTypeFeature.java @@ -0,0 +1,144 @@ +package makeus.cmc.malmo.domain.model.love_type; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class LoveTypePersonalityTypeFeature { + private String personalityType; + private LoveTypeCategory loveTypeCategory; + private String summary; + private String keyword1; + private String keyword2; + private String keyword3; + private String strength1; + private String strength2; + private String strength3; + private String weakness; + private String strengthDesc1; + private String strengthDesc2; + private String strengthDesc3; + private String weaknessDesc; + private String patternTitle1; + private String patternTitle2; + private String patternTitle3; + private String patternTitle4; + private String pattern1; + private String pattern2; + private String pattern3; + private String pattern4; + private String loveTypeFeatureTitle1; + private String loveTypeFeatureTitle2; + private String loveTypeFeatureTitle3; + private String loveTypeFeatureTitle4; + private String loveTypeFeature1; + private String loveTypeFeature2; + private String loveTypeFeature3; + private String loveTypeFeature4; + private String datingGuide1; + private String datingGuide2; + private String datingGuide3; + private String bestPersonalityType1; + private String bestDesc1; + private String bestPersonalityType2; + private String bestDesc2; + private String worstPersonalityType1; + private String worstDesc1; + private String worstPersonalityType2; + private String worstDesc2; + + public static LoveTypePersonalityTypeFeature from( + String personalityType, + LoveTypeCategory loveTypeCategory, + String summary, + String keyword1, + String keyword2, + String keyword3, + String strength1, + String strength2, + String strength3, + String weakness, + String strengthDesc1, + String strengthDesc2, + String strengthDesc3, + String weaknessDesc, + String patternTitle1, + String patternTitle2, + String patternTitle3, + String patternTitle4, + String pattern1, + String pattern2, + String pattern3, + String pattern4, + String loveTypeFeatureTitle1, + String loveTypeFeatureTitle2, + String loveTypeFeatureTitle3, + String loveTypeFeatureTitle4, + String loveTypeFeature1, + String loveTypeFeature2, + String loveTypeFeature3, + String loveTypeFeature4, + String datingGuide1, + String datingGuide2, + String datingGuide3, + String bestPersonalityType1, + String bestDesc1, + String bestPersonalityType2, + String bestDesc2, + String worstPersonalityType1, + String worstDesc1, + String worstPersonalityType2, + String worstDesc2 + ) { + return LoveTypePersonalityTypeFeature.builder() + .personalityType(normalizePersonalityType(personalityType)) + .loveTypeCategory(loveTypeCategory) + .summary(summary) + .keyword1(keyword1) + .keyword2(keyword2) + .keyword3(keyword3) + .strength1(strength1) + .strength2(strength2) + .strength3(strength3) + .weakness(weakness) + .strengthDesc1(strengthDesc1) + .strengthDesc2(strengthDesc2) + .strengthDesc3(strengthDesc3) + .weaknessDesc(weaknessDesc) + .patternTitle1(patternTitle1) + .patternTitle2(patternTitle2) + .patternTitle3(patternTitle3) + .patternTitle4(patternTitle4) + .pattern1(pattern1) + .pattern2(pattern2) + .pattern3(pattern3) + .pattern4(pattern4) + .loveTypeFeatureTitle1(loveTypeFeatureTitle1) + .loveTypeFeatureTitle2(loveTypeFeatureTitle2) + .loveTypeFeatureTitle3(loveTypeFeatureTitle3) + .loveTypeFeatureTitle4(loveTypeFeatureTitle4) + .loveTypeFeature1(loveTypeFeature1) + .loveTypeFeature2(loveTypeFeature2) + .loveTypeFeature3(loveTypeFeature3) + .loveTypeFeature4(loveTypeFeature4) + .datingGuide1(datingGuide1) + .datingGuide2(datingGuide2) + .datingGuide3(datingGuide3) + .bestPersonalityType1(normalizePersonalityType(bestPersonalityType1)) + .bestDesc1(bestDesc1) + .bestPersonalityType2(normalizePersonalityType(bestPersonalityType2)) + .bestDesc2(bestDesc2) + .worstPersonalityType1(normalizePersonalityType(worstPersonalityType1)) + .worstDesc1(worstDesc1) + .worstPersonalityType2(normalizePersonalityType(worstPersonalityType2)) + .worstDesc2(worstDesc2) + .build(); + } + + private static String normalizePersonalityType(String personalityType) { + return personalityType == null ? null : personalityType.toUpperCase(); + } +} diff --git a/src/main/java/makeus/cmc/malmo/domain/model/love_type/LoveTypePersonalityTypePrompt.java b/src/main/java/makeus/cmc/malmo/domain/model/love_type/LoveTypePersonalityTypePrompt.java new file mode 100644 index 00000000..9cdd6658 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/domain/model/love_type/LoveTypePersonalityTypePrompt.java @@ -0,0 +1,30 @@ +package makeus.cmc.malmo.domain.model.love_type; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class LoveTypePersonalityTypePrompt { + private String personalityType; + private LoveTypeCategory loveTypeCategory; + private String prompts; + + public static LoveTypePersonalityTypePrompt from( + String personalityType, + LoveTypeCategory loveTypeCategory, + String prompts + ) { + return LoveTypePersonalityTypePrompt.builder() + .personalityType(normalizePersonalityType(personalityType)) + .loveTypeCategory(loveTypeCategory) + .prompts(prompts) + .build(); + } + + private static String normalizePersonalityType(String personalityType) { + return personalityType == null ? null : personalityType.toUpperCase(); + } +} diff --git a/src/main/java/makeus/cmc/malmo/domain/model/member/Member.java b/src/main/java/makeus/cmc/malmo/domain/model/member/Member.java index ce2d2fc0..ea4a7292 100644 --- a/src/main/java/makeus/cmc/malmo/domain/model/member/Member.java +++ b/src/main/java/makeus/cmc/malmo/domain/model/member/Member.java @@ -9,6 +9,7 @@ import makeus.cmc.malmo.domain.value.type.EmailForwardingStatus; import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; import makeus.cmc.malmo.domain.value.type.MemberRole; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; import makeus.cmc.malmo.domain.value.type.Provider; import makeus.cmc.malmo.domain.value.type.RelationshipStatus; @@ -47,6 +48,7 @@ public class Member { private RelationshipStatus relationshipStatus; private String personalityType; private String otherPersonalityType; + private PartnerLoveTypeCategory partnerLoveTypeCategory; // BaseTimeEntity fields private LocalDateTime createdAt; @@ -89,6 +91,7 @@ public static Member from( RelationshipStatus relationshipStatus, String personalityType, String otherPersonalityType, + PartnerLoveTypeCategory partnerLoveTypeCategory, LocalDateTime createdAt, LocalDateTime modifiedAt, LocalDateTime deletedAt @@ -113,8 +116,9 @@ public static Member from( .oauthToken(oauthToken) .coupleId(coupleId) .relationshipStatus(relationshipStatus) - .personalityType(personalityType) - .otherPersonalityType(otherPersonalityType) + .personalityType(normalizePersonalityType(personalityType)) + .otherPersonalityType(normalizePersonalityType(otherPersonalityType)) + .partnerLoveTypeCategory(partnerLoveTypeCategory) .createdAt(createdAt) .modifiedAt(modifiedAt) .deletedAt(deletedAt) @@ -133,24 +137,13 @@ public void signUp(String nickname, LocalDate startLoveDate) { this.memberState = MemberState.ALIVE; } - /** - * V2 회원가입 - startLoveDate 없이 회원가입 - * 커플 연동 후 별도로 연애 시작일을 설정합니다. - */ - public void signUp(String nickname) { - this.nickname = nickname; - this.memberState = MemberState.ALIVE; - } - - public void signUp(String nickname, RelationshipStatus relationshipStatus, String personalityType, String otherPersonalityType) { + public void signUp(String nickname, RelationshipStatus relationshipStatus) { this.nickname = nickname; this.relationshipStatus = relationshipStatus; - this.personalityType = personalityType; - this.otherPersonalityType = otherPersonalityType; this.memberState = MemberState.ALIVE; } - public void updateMemberProfile(String nickname, RelationshipStatus relationshipStatus, String personalityType, String otherPersonalityType) { + public void updateMemberProfile(String nickname, RelationshipStatus relationshipStatus, String personalityType, LoveTypeCategory loveTypeCategory) { if (nickname != null) { this.nickname = nickname; } @@ -158,11 +151,40 @@ public void updateMemberProfile(String nickname, RelationshipStatus relationship this.relationshipStatus = relationshipStatus; } if (personalityType != null) { - this.personalityType = personalityType; + this.personalityType = normalizePersonalityType(personalityType); + } + if (loveTypeCategory != null) { + this.loveTypeCategory = loveTypeCategory; } + } + + public boolean hasPartnerProfile() { + return this.otherPersonalityType != null; + } + + public void createPartnerProfile(String otherPersonalityType, PartnerLoveTypeCategory partnerLoveTypeCategory) { + this.otherPersonalityType = normalizePersonalityType(otherPersonalityType); + this.partnerLoveTypeCategory = partnerLoveTypeCategory; + } + + public void updatePartnerProfile(String otherPersonalityType, PartnerLoveTypeCategory partnerLoveTypeCategory) { if (otherPersonalityType != null) { - this.otherPersonalityType = otherPersonalityType; + this.otherPersonalityType = normalizePersonalityType(otherPersonalityType); + } + if (partnerLoveTypeCategory != null) { + this.partnerLoveTypeCategory = partnerLoveTypeCategory; + } + } + + public boolean updatePartnerLoveTypeCategoryIfUnknown(PartnerLoveTypeCategory partnerLoveTypeCategory) { + if (partnerLoveTypeCategory == null) { + return false; + } + if (this.partnerLoveTypeCategory != null && this.partnerLoveTypeCategory != PartnerLoveTypeCategory.UNKNOWN) { + return false; } + this.partnerLoveTypeCategory = partnerLoveTypeCategory; + return true; } public void updateLoveType(LoveTypeCategory loveTypeCategory, float avoidanceRate, float anxietyRate) { @@ -222,4 +244,8 @@ public void linkCouple(CoupleId coupleId) { public void unlinkCouple() { this.coupleId = null; } -} \ No newline at end of file + + private static String normalizePersonalityType(String personalityType) { + return personalityType == null ? null : personalityType.toUpperCase(); + } +} diff --git a/src/main/java/makeus/cmc/malmo/domain/value/type/PartnerLoveTypeCategory.java b/src/main/java/makeus/cmc/malmo/domain/value/type/PartnerLoveTypeCategory.java new file mode 100644 index 00000000..91bb8f6f --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/domain/value/type/PartnerLoveTypeCategory.java @@ -0,0 +1,24 @@ +package makeus.cmc.malmo.domain.value.type; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PartnerLoveTypeCategory { + STABLE_TYPE("안정형"), + ANXIETY_TYPE("불안형"), + AVOIDANCE_TYPE("회피형"), + CONFUSION_TYPE("혼란형"), + UNKNOWN("모르겠어요"); + + private final String description; + + public static PartnerLoveTypeCategory fromLoveTypeCategory(LoveTypeCategory loveTypeCategory) { + if (loveTypeCategory == null) { + return UNKNOWN; + } + + return valueOf(loveTypeCategory.name()); + } +} diff --git a/src/main/java/makeus/cmc/malmo/util/GlobalConstants.java b/src/main/java/makeus/cmc/malmo/util/GlobalConstants.java index f59f6b71..6583afce 100644 --- a/src/main/java/makeus/cmc/malmo/util/GlobalConstants.java +++ b/src/main/java/makeus/cmc/malmo/util/GlobalConstants.java @@ -16,12 +16,26 @@ public class GlobalConstants { public static final String INIT_CHAT_MESSAGE_SECOND_BREAKUP = "오늘은 어떤 고민 때문에 나를 찾아왔어? 이별 전후로 마음에 남아 있는 상황을 이야기해 주면 내가 같이 고민해볼게!"; - public static final String OPENAI_CHAT_URL = "https://api.openai.com/v1"; - - public static final String OPENAI_STATUS_URL = "https://status.openai.com/api/v2/status.json"; - public static final String ATTACHMENT_TYPE_PROMPT_MESSAGE = "잠깐! 애착유형 테스트를 하면, 더 정확한 상담이 가능해! 그대로 진행하면 바로 상담해줄게"; + + public static final String PARTNER_LOVE_TYPE_INFERENCE_PROMPT = """ + 너는 연애 상담 대화의 2단계 분석 내용을 바탕으로 상대방의 애착유형을 추론하는 분류기야. + 지금 주어지는 컨텍스트는 2단계 상담 맥락, 2단계 대화 내용, 그리고 챗봇이 수행한 2단계 분석을 포함한다. + 반드시 이 2단계 분석과 동일한 맥락만 사용해서 상대방의 애착유형을 추론해라. + + [분류 규칙] + - partnerLoveTypeCategory는 반드시 다음 4개 중 하나만 선택한다. + STABLE_TYPE + ANXIETY_TYPE + AVOIDANCE_TYPE + CONFUSION_TYPE + - UNKNOWN은 절대 반환하지 않는다. + - 설명, 이유, 추가 텍스트 없이 JSON 객체만 반환한다. + + [응답 형식] + {"partnerLoveTypeCategory":"STABLE_TYPE"} + """; // 커플 복구 관련 상수 public static final int COUPLE_RECOVERY_LIMIT_DAYS = 30; diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 7e392f56..555e68d4 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -63,7 +63,32 @@ swagger: openai: api: - key: ${OPENAI_API_KEY} + key: ${OPENAI_API_KEY:} + model: gpt-5.4-mini + base-url: https://api.openai.com/v1 + status-url: https://status.openai.com/api/v2/status.json + reasoning-effort: + default: medium + scenarios: + structured-chat: low + free-conversation: low + validation: none + summary: none + auxiliary-extraction: none + +gemini: + api: + key: ${GEMINI_API_KEY:} + model: gemini-3-flash-preview + base-url: https://generativelanguage.googleapis.com/v1beta/openai + reasoning-effort: + default: high + scenarios: + structured-chat: medium + free-conversation: low + validation: low + summary: low + auxiliary-extraction: low security: server: @@ -93,4 +118,4 @@ sentry: logging: minimum-event-level: ERROR send-default-pii: true - max-request-body-size: medium \ No newline at end of file + max-request-body-size: medium diff --git a/src/main/resources/application-qa.yml b/src/main/resources/application-qa.yml index 81fa90a9..a6b5d91f 100644 --- a/src/main/resources/application-qa.yml +++ b/src/main/resources/application-qa.yml @@ -63,7 +63,32 @@ swagger: openai: api: - key: ${OPENAI_API_KEY} + key: ${OPENAI_API_KEY:} + model: gpt-5.4-mini + base-url: https://api.openai.com/v1 + status-url: https://status.openai.com/api/v2/status.json + reasoning-effort: + default: medium + scenarios: + structured-chat: low + free-conversation: low + validation: none + summary: none + auxiliary-extraction: none + +gemini: + api: + key: ${GEMINI_API_KEY:} + model: gemini-3-flash-preview + base-url: https://generativelanguage.googleapis.com/v1beta/openai + reasoning-effort: + default: high + scenarios: + structured-chat: medium + free-conversation: low + validation: low + summary: low + auxiliary-extraction: low security: server: @@ -93,4 +118,4 @@ sentry: logging: minimum-event-level: ERROR send-default-pii: true - max-request-body-size: medium \ No newline at end of file + max-request-body-size: medium diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 575bf9bb..27277b7d 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -73,6 +73,31 @@ swagger: openai: api: key: sk-test-openai-api-key-for-testing-only + model: gpt-5.4-mini + base-url: https://api.openai.com/v1 + status-url: https://status.openai.com/api/v2/status.json + reasoning-effort: + default: medium + scenarios: + structured-chat: low + free-conversation: low + validation: none + summary: none + auxiliary-extraction: none + +gemini: + api: + key: test-gemini-api-key-for-testing-only + model: gemini-3-flash-preview + base-url: https://generativelanguage.googleapis.com/v1beta/openai + reasoning-effort: + default: high + scenarios: + structured-chat: medium + free-conversation: low + validation: low + summary: low + auxiliary-extraction: low security: server: diff --git a/src/test/java/makeus/cmc/malmo/adaptor/out/AbstractOpenAiCompatibleApiClientTest.java b/src/test/java/makeus/cmc/malmo/adaptor/out/AbstractOpenAiCompatibleApiClientTest.java new file mode 100644 index 00000000..de52c3a5 --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/adaptor/out/AbstractOpenAiCompatibleApiClientTest.java @@ -0,0 +1,85 @@ +package makeus.cmc.malmo.adaptor.out; + +import com.fasterxml.jackson.databind.ObjectMapper; +import makeus.cmc.malmo.application.port.out.chat.LlmReasoningScenario; +import makeus.cmc.malmo.config.properties.ReasoningEffortProperties; +import org.junit.jupiter.api.Test; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class AbstractOpenAiCompatibleApiClientTest { + + @Test + void createBody_usesScenarioSpecificReasoningEffort_whenPresent() { + TestClient client = new TestClient(reasoningEffortProperties("medium", Map.of( + LlmReasoningScenario.VALIDATION, "none" + ))); + + Map body = client.exposeCreateBody(LlmReasoningScenario.VALIDATION); + + assertThat(body.get("reasoning_effort")).isEqualTo("none"); + } + + @Test + void createBody_fallsBackToDefaultReasoningEffort_whenScenarioOverrideMissing() { + TestClient client = new TestClient(reasoningEffortProperties("medium", Map.of())); + + Map body = client.exposeCreateBody(LlmReasoningScenario.STRUCTURED_CHAT); + + assertThat(body.get("reasoning_effort")).isEqualTo("medium"); + } + + @Test + void createBody_omitsReasoningEffort_whenNoValuesConfigured() { + TestClient client = new TestClient(reasoningEffortProperties(null, Map.of())); + + Map body = client.exposeCreateBody(LlmReasoningScenario.SUMMARY); + + assertThat(body).doesNotContainKey("reasoning_effort"); + } + + private static ReasoningEffortProperties reasoningEffortProperties(String defaultEffort, + Map scenarios) { + ReasoningEffortProperties properties = new ReasoningEffortProperties(); + properties.setDefault(defaultEffort); + properties.setScenarios(scenarios); + return properties; + } + + private static final class TestClient extends AbstractOpenAiCompatibleApiClient { + private final ReasoningEffortProperties reasoningEffortProperties; + + private TestClient(ReasoningEffortProperties reasoningEffortProperties) { + super(WebClient.builder().baseUrl("https://example.com").build(), new ObjectMapper()); + this.reasoningEffortProperties = reasoningEffortProperties; + } + + private Map exposeCreateBody(LlmReasoningScenario scenario) { + return createBody(List.of(Map.of("role", "user", "content", "hello")), scenario, false, false); + } + + @Override + protected String getProviderName() { + return "Test"; + } + + @Override + protected String getApiKey() { + return "test-key"; + } + + @Override + protected String getModel() { + return "test-model"; + } + + @Override + protected ReasoningEffortProperties getReasoningEffortProperties() { + return reasoningEffortProperties; + } + } +} diff --git a/src/test/java/makeus/cmc/malmo/application/service/chat/ChatMessageServiceTest.java b/src/test/java/makeus/cmc/malmo/application/service/chat/ChatMessageServiceTest.java new file mode 100644 index 00000000..c2e0c627 --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/application/service/chat/ChatMessageServiceTest.java @@ -0,0 +1,352 @@ +package makeus.cmc.malmo.application.service.chat; + +import makeus.cmc.malmo.application.helper.chat_room.ChatRoomCommandHelper; +import makeus.cmc.malmo.application.helper.chat_room.ChatRoomQueryHelper; +import makeus.cmc.malmo.application.helper.chat_room.DetailedPromptQueryHelper; +import makeus.cmc.malmo.application.helper.chat_room.MemberChatRoomMetadataCommandHelper; +import makeus.cmc.malmo.application.helper.chat_room.PromptQueryHelper; +import makeus.cmc.malmo.application.helper.member.MemberCommandHelper; +import makeus.cmc.malmo.application.helper.member.MemberMemoryCommandHelper; +import makeus.cmc.malmo.application.helper.member.MemberQueryHelper; +import makeus.cmc.malmo.application.helper.outbox.OutboxHelper; +import makeus.cmc.malmo.application.helper.question.CoupleQuestionQueryHelper; +import makeus.cmc.malmo.application.port.in.chat.ProcessMessageUseCase; +import makeus.cmc.malmo.application.port.in.chat.SufficiencyCheckResult; +import makeus.cmc.malmo.domain.model.chat.ChatRoom; +import makeus.cmc.malmo.domain.model.chat.DetailedPrompt; +import makeus.cmc.malmo.domain.model.chat.MemberChatRoomMetadata; +import makeus.cmc.malmo.domain.model.chat.Prompt; +import makeus.cmc.malmo.domain.model.member.Member; +import makeus.cmc.malmo.domain.service.ChatRoomDomainService; +import makeus.cmc.malmo.domain.value.id.InviteCodeValue; +import makeus.cmc.malmo.domain.value.id.MemberId; +import makeus.cmc.malmo.domain.value.state.ChatRoomState; +import makeus.cmc.malmo.domain.value.state.MemberState; +import makeus.cmc.malmo.domain.value.type.EmailForwardingStatus; +import makeus.cmc.malmo.domain.value.type.MemberRole; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; +import makeus.cmc.malmo.domain.value.type.Provider; +import makeus.cmc.malmo.domain.value.type.RelationshipStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatMessageService 테스트") +class ChatMessageServiceTest { + + @Mock + private MemberQueryHelper memberQueryHelper; + + @Mock + private ChatRoomQueryHelper chatRoomQueryHelper; + + @Mock + private PromptQueryHelper promptQueryHelper; + + @Mock + private DetailedPromptQueryHelper detailedPromptQueryHelper; + + @Mock + private MemberChatRoomMetadataCommandHelper memberChatRoomMetadataCommandHelper; + + @Mock + private ChatPromptBuilder chatPromptBuilder; + + @Mock + private ChatProcessor chatProcessor; + + @Mock + private ChatSseSender chatSseSender; + + @Mock + private ChatRoomCommandHelper chatRoomCommandHelper; + + @Mock + private ChatRoomDomainService chatRoomDomainService; + + @Mock + private CoupleQuestionQueryHelper coupleQuestionQueryHelper; + + @Mock + private MemberMemoryCommandHelper memberMemoryCommandHelper; + + @Mock + private OutboxHelper outboxHelper; + + @Mock + private MemberCommandHelper memberCommandHelper; + + @InjectMocks + private ChatMessageService chatMessageService; + + @Test + @DisplayName("1단계 완료 후 2단계 첫 분석 메시지 생성 직후 partnerLoveTypeCategory를 추론해 저장한다") + void processStreamChatMessage_infersAndPersistsPartnerLoveTypeAfterStage2Opening() { + Member stage2Member = createMember(1L, null); + Member refreshedMember = createMember(1L, null); + Member savedMember = createMember(1L, PartnerLoveTypeCategory.CONFUSION_TYPE); + ChatRoom chatRoom = createChatRoom(10L, 1, 2); + DetailedPrompt lastDetailedPrompt = DetailedPrompt.create(1, 2, "guideline", false, false, "상황 요약", true, true); + Prompt systemPrompt = Prompt.from(1L, 0, "system", true, false, false, false, false, false, false, null, null, null); + Prompt nextPrompt = Prompt.from(2L, 2, "next", false, false, false, false, true, false, false, null, null, null); + DetailedPrompt nextDetailedPrompt = DetailedPrompt.create(2, 1, "next-detailed", false, false, "분석", true, true); + ProcessMessageUseCase.ProcessMessageCommand command = ProcessMessageUseCase.ProcessMessageCommand.builder() + .memberId(1L) + .chatRoomId(10L) + .nowMessage("메시지") + .promptLevel(1) + .detailedLevel(2) + .build(); + SufficiencyCheckResult result = SufficiencyCheckResult.builder() + .completed(true) + .summary("1단계 상황 요약") + .build(); + + stubCompletedFlow(stage2Member, refreshedMember, chatRoom, lastDetailedPrompt, systemPrompt, nextPrompt, nextDetailedPrompt, result, savedMember); + + when(chatProcessor.requestPartnerLoveTypeCategoryInference(anyList(), anyString())) + .thenReturn(CompletableFuture.completedFuture(PartnerLoveTypeCategory.CONFUSION_TYPE)); + + chatMessageService.processStreamChatMessage(command).join(); + + ArgumentCaptor savedMemberCaptor = ArgumentCaptor.forClass(Member.class); + verify(memberCommandHelper).saveMember(savedMemberCaptor.capture()); + assertThat(savedMemberCaptor.getValue().getPartnerLoveTypeCategory()).isEqualTo(PartnerLoveTypeCategory.CONFUSION_TYPE); + + verify(chatPromptBuilder).createForPartnerLoveTypeInference(stage2Member, chatRoom, 2); + verify(chatPromptBuilder).createForNextStage(stage2Member, chatRoom, 2); + verify(chatRoomCommandHelper).upgradeChatRoomLevel(10L, 2, 1); + } + + @Test + @DisplayName("이미 partnerLoveTypeCategory가 확정되어 있으면 추론과 저장을 생략한다") + void processStreamChatMessage_skipsInferenceWhenPartnerLoveTypeAlreadyKnown() { + Member stage2Member = createMember(1L, PartnerLoveTypeCategory.ANXIETY_TYPE); + ChatRoom chatRoom = createChatRoom(10L, 1, 2); + DetailedPrompt lastDetailedPrompt = DetailedPrompt.create(1, 2, "guideline", false, false, "상황 요약", true, true); + Prompt systemPrompt = Prompt.from(1L, 0, "system", true, false, false, false, false, false, false, null, null, null); + Prompt nextPrompt = Prompt.from(2L, 2, "next", false, false, false, false, true, false, false, null, null, null); + DetailedPrompt nextDetailedPrompt = DetailedPrompt.create(2, 1, "next-detailed", false, false, "분석", true, true); + ProcessMessageUseCase.ProcessMessageCommand command = ProcessMessageUseCase.ProcessMessageCommand.builder() + .memberId(1L) + .chatRoomId(10L) + .nowMessage("메시지") + .promptLevel(1) + .detailedLevel(2) + .build(); + SufficiencyCheckResult result = SufficiencyCheckResult.builder() + .completed(true) + .summary("1단계 상황 요약") + .build(); + + when(memberQueryHelper.getMemberByIdOrThrow(MemberId.of(1L))).thenReturn(stage2Member); + when(chatRoomQueryHelper.getChatRoomByIdOrThrow(any())).thenReturn(chatRoom); + when(chatProcessor.requestSufficiencyCheck(anyList(), any())).thenReturn(CompletableFuture.completedFuture(result)); + when(chatPromptBuilder.createForSufficiencyCheck(stage2Member, chatRoom, 1, 2)).thenReturn(List.of()); + when(detailedPromptQueryHelper.getValidationPrompt(1, 2)).thenReturn(java.util.Optional.of(lastDetailedPrompt)); + when(detailedPromptQueryHelper.getGuidelinePrompt(1, 2)).thenReturn(java.util.Optional.of(lastDetailedPrompt)); + when(promptQueryHelper.getSystemPrompt()).thenReturn(systemPrompt); + when(promptQueryHelper.getGuidelinePromptWithFallback(2)).thenReturn(nextPrompt); + when(detailedPromptQueryHelper.getGuidelinePromptWithFallback(2, 1)).thenReturn(nextDetailedPrompt); + when(chatPromptBuilder.createForNextStage(stage2Member, chatRoom, 2)).thenReturn(List.of()); + stubStreamChatCompletion(); + + chatMessageService.processStreamChatMessage(command).join(); + + verify(chatProcessor, never()).requestPartnerLoveTypeCategoryInference(anyList(), anyString()); + verify(memberCommandHelper, never()).saveMember(any()); + verify(chatPromptBuilder).createForNextStage(stage2Member, chatRoom, 2); + } + + @Test + @DisplayName("2단계 첫 분석 메시지 이후 추론이 실패해도 저장하지 않고 흐름은 계속 진행한다") + void processStreamChatMessage_continuesWhenInferenceFailsAfterStage2Opening() { + Member stage2Member = createMember(1L, PartnerLoveTypeCategory.UNKNOWN); + Member refreshedMember = createMember(1L, PartnerLoveTypeCategory.UNKNOWN); + ChatRoom chatRoom = createChatRoom(10L, 1, 2); + DetailedPrompt lastDetailedPrompt = DetailedPrompt.create(1, 2, "guideline", false, false, "상황 요약", true, true); + Prompt systemPrompt = Prompt.from(1L, 0, "system", true, false, false, false, false, false, false, null, null, null); + Prompt nextPrompt = Prompt.from(2L, 2, "next", false, false, false, false, true, false, false, null, null, null); + DetailedPrompt nextDetailedPrompt = DetailedPrompt.create(2, 1, "next-detailed", false, false, "분석", true, true); + ProcessMessageUseCase.ProcessMessageCommand command = ProcessMessageUseCase.ProcessMessageCommand.builder() + .memberId(1L) + .chatRoomId(10L) + .nowMessage("메시지") + .promptLevel(1) + .detailedLevel(2) + .build(); + SufficiencyCheckResult result = SufficiencyCheckResult.builder() + .completed(true) + .summary("1단계 상황 요약") + .build(); + + when(memberQueryHelper.getMemberByIdOrThrow(MemberId.of(1L))).thenReturn(stage2Member, refreshedMember); + when(chatRoomQueryHelper.getChatRoomByIdOrThrow(any())).thenReturn(chatRoom); + when(chatProcessor.requestSufficiencyCheck(anyList(), any())).thenReturn(CompletableFuture.completedFuture(result)); + when(chatPromptBuilder.createForSufficiencyCheck(stage2Member, chatRoom, 1, 2)).thenReturn(List.of()); + when(detailedPromptQueryHelper.getValidationPrompt(1, 2)).thenReturn(java.util.Optional.of(lastDetailedPrompt)); + when(detailedPromptQueryHelper.getGuidelinePrompt(1, 2)).thenReturn(java.util.Optional.of(lastDetailedPrompt)); + when(chatPromptBuilder.createForPartnerLoveTypeInference(stage2Member, chatRoom, 2)) + .thenReturn(List.of(Map.of("role", "system", "content", "context"))); + when(chatProcessor.requestPartnerLoveTypeCategoryInference(anyList(), anyString())) + .thenReturn(CompletableFuture.failedFuture(new IllegalArgumentException("invalid"))); + when(promptQueryHelper.getSystemPrompt()).thenReturn(systemPrompt); + when(promptQueryHelper.getGuidelinePromptWithFallback(2)).thenReturn(nextPrompt); + when(detailedPromptQueryHelper.getGuidelinePromptWithFallback(2, 1)).thenReturn(nextDetailedPrompt); + when(chatPromptBuilder.createForNextStage(stage2Member, chatRoom, 2)).thenReturn(List.of()); + stubStreamChatCompletion(); + + chatMessageService.processStreamChatMessage(command).join(); + + verify(memberCommandHelper, never()).saveMember(any()); + verify(chatPromptBuilder).createForNextStage(stage2Member, chatRoom, 2); + verify(chatRoomCommandHelper).upgradeChatRoomLevel(10L, 2, 1); + } + + @Test + @DisplayName("2단계 종료 시점에는 더 이상 추론하지 않는다") + void processStreamChatMessage_skipsInferenceAtEndOfStage2() { + Member stage2Member = createMember(1L, null); + ChatRoom chatRoom = createChatRoom(10L, 2, 2); + DetailedPrompt lastDetailedPrompt = DetailedPrompt.create(2, 2, "guideline", false, false, "분석", true, true); + ProcessMessageUseCase.ProcessMessageCommand command = ProcessMessageUseCase.ProcessMessageCommand.builder() + .memberId(1L) + .chatRoomId(10L) + .nowMessage("메시지") + .promptLevel(2) + .detailedLevel(2) + .build(); + SufficiencyCheckResult result = SufficiencyCheckResult.builder() + .completed(true) + .summary("2단계 분석") + .build(); + + when(memberQueryHelper.getMemberByIdOrThrow(MemberId.of(1L))).thenReturn(stage2Member); + when(chatRoomQueryHelper.getChatRoomByIdOrThrow(any())).thenReturn(chatRoom); + when(chatProcessor.requestSufficiencyCheck(anyList(), any())).thenReturn(CompletableFuture.completedFuture(result)); + when(chatPromptBuilder.createForSufficiencyCheck(stage2Member, chatRoom, 2, 2)).thenReturn(List.of()); + when(detailedPromptQueryHelper.getValidationPrompt(2, 2)).thenReturn(java.util.Optional.of(lastDetailedPrompt)); + when(detailedPromptQueryHelper.getGuidelinePrompt(2, 2)).thenReturn(java.util.Optional.of(lastDetailedPrompt)); + when(promptQueryHelper.getSystemPrompt()).thenReturn(Prompt.from(1L, 0, "system", true, false, false, false, false, false, false, null, null, null)); + when(promptQueryHelper.getGuidelinePromptWithFallback(3)).thenReturn(Prompt.from(2L, 3, "stage3", false, false, false, false, true, false, false, null, null, null)); + when(detailedPromptQueryHelper.getGuidelinePromptWithFallback(3, 1)).thenReturn(DetailedPrompt.create(3, 1, "next", false, false, "다음", true, true)); + when(chatPromptBuilder.createForNextStage(stage2Member, chatRoom, 3)).thenReturn(List.of()); + stubStreamChatCompletion(); + + chatMessageService.processStreamChatMessage(command).join(); + + verify(chatProcessor, never()).requestPartnerLoveTypeCategoryInference(anyList(), anyString()); + verify(chatRoomCommandHelper).upgradeChatRoomLevel(10L, 3, 1); + } + + private void stubCompletedFlow( + Member stage2Member, + Member refreshedMember, + ChatRoom chatRoom, + DetailedPrompt lastDetailedPrompt, + Prompt systemPrompt, + Prompt nextPrompt, + DetailedPrompt nextDetailedPrompt, + SufficiencyCheckResult result, + Member nextStageMember + ) { + when(memberQueryHelper.getMemberByIdOrThrow(MemberId.of(1L))) + .thenReturn(stage2Member, refreshedMember); + when(chatRoomQueryHelper.getChatRoomByIdOrThrow(any())).thenReturn(chatRoom); + when(chatProcessor.requestSufficiencyCheck(anyList(), any())).thenReturn(CompletableFuture.completedFuture(result)); + when(chatPromptBuilder.createForSufficiencyCheck(stage2Member, chatRoom, 1, 2)).thenReturn(List.of()); + when(detailedPromptQueryHelper.getValidationPrompt(1, 2)).thenReturn(java.util.Optional.of(lastDetailedPrompt)); + when(detailedPromptQueryHelper.getGuidelinePrompt(1, 2)).thenReturn(java.util.Optional.of(lastDetailedPrompt)); + when(chatPromptBuilder.createForPartnerLoveTypeInference(stage2Member, chatRoom, 2)) + .thenReturn(List.of(Map.of("role", "system", "content", "context"))); + when(memberCommandHelper.saveMember(any())).thenReturn(nextStageMember); + when(promptQueryHelper.getSystemPrompt()).thenReturn(systemPrompt); + when(promptQueryHelper.getGuidelinePromptWithFallback(2)).thenReturn(nextPrompt); + when(detailedPromptQueryHelper.getGuidelinePromptWithFallback(2, 1)).thenReturn(nextDetailedPrompt); + when(chatPromptBuilder.createForNextStage(stage2Member, chatRoom, 2)).thenReturn(List.of()); + stubStreamChatCompletion(); + } + + private void stubStreamChatCompletion() { + doAnswer(invocation -> { + Consumer onComplete = invocation.getArgument(6); + onComplete.accept("다음 단계 응답"); + return Mono.empty(); + }).when(chatProcessor).streamChat(anyList(), any(), any(), any(), any(), any(), any(), any()); + } + + private Member createMember(Long id, PartnerLoveTypeCategory partnerLoveTypeCategory) { + LocalDateTime now = LocalDateTime.now(); + return Member.from( + id, + Provider.KAKAO, + "provider-id", + MemberRole.MEMBER, + MemberState.ALIVE, + false, + null, + null, + null, + 0.0f, + 0.0f, + "tak", + "tak@example.com", + EmailForwardingStatus.ENABLED, + InviteCodeValue.of("ABCD1234"), + null, + null, + null, + RelationshipStatus.IN_RELATIONSHIP, + "INTJ", + "ENFP", + partnerLoveTypeCategory, + now, + now, + null + ); + } + + private ChatRoom createChatRoom(Long id, int level, int detailedLevel) { + LocalDateTime now = LocalDateTime.now(); + return ChatRoom.from( + id, + MemberId.of(1L), + ChatRoomState.ALIVE, + level, + detailedLevel, + now, + null, + null, + null, + null, + null, + null, + now, + now, + null + ); + } +} diff --git a/src/test/java/makeus/cmc/malmo/application/service/chat/ChatProcessorTest.java b/src/test/java/makeus/cmc/malmo/application/service/chat/ChatProcessorTest.java new file mode 100644 index 00000000..62b5c670 --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/application/service/chat/ChatProcessorTest.java @@ -0,0 +1,163 @@ +package makeus.cmc.malmo.application.service.chat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import makeus.cmc.malmo.application.port.in.chat.SufficiencyCheckResult; +import makeus.cmc.malmo.application.port.out.chat.LlmReasoningScenario; +import makeus.cmc.malmo.application.port.out.chat.RequestChatApiPort; +import makeus.cmc.malmo.domain.model.chat.DetailedPrompt; +import makeus.cmc.malmo.domain.model.chat.Prompt; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ChatProcessorTest { + + @Mock + private RequestChatApiPort requestChatApiPort; + + private ChatProcessor chatProcessor; + + @BeforeEach + void setUp() { + chatProcessor = new ChatProcessor(requestChatApiPort, new ObjectMapper()); + } + + @Test + void streamChat_usesProvidedScenario() { + when(requestChatApiPort.requestStreamResponse(anyList(), eq(LlmReasoningScenario.STRUCTURED_CHAT), any())) + .thenReturn(Mono.just("full answer")); + + List> messages = new ArrayList<>(); + chatProcessor.streamChat( + messages, + LlmReasoningScenario.STRUCTURED_CHAT, + Prompt.from(1L, 1, "system", false, false, false, false, false, false, false, null, null, null), + Prompt.from(2L, 1, "guideline", false, false, true, false, false, false, false, null, null, null), + DetailedPrompt.create(1, 1, "detail", false, false, null, false, true), + chunk -> { }, + full -> { }, + error -> { } + ) + .block(); + + verify(requestChatApiPort).requestStreamResponse(anyList(), eq(LlmReasoningScenario.STRUCTURED_CHAT), any()); + } + + @Test + void requestMetaData_usesAuxiliaryExtractionScenario() { + when(requestChatApiPort.requestResponse(anyList(), eq(LlmReasoningScenario.AUXILIARY_EXTRACTION))) + .thenReturn(CompletableFuture.completedFuture("memo")); + + String result = chatProcessor.requestMetaData("question", "answer", + Prompt.from(1L, 999, "metadata", false, false, false, false, false, true, false, null, null, null)) + .join(); + + assertThat(result).isEqualTo("memo"); + verify(requestChatApiPort).requestResponse(anyList(), eq(LlmReasoningScenario.AUXILIARY_EXTRACTION)); + } + + @Test + void requestSufficiencyCheck_usesValidationScenario() { + when(requestChatApiPort.requestJsonResponse(anyList(), eq(LlmReasoningScenario.VALIDATION))) + .thenReturn(CompletableFuture.completedFuture(""" + {"completed":true,"summary":"ok","advice":null} + """)); + + SufficiencyCheckResult result = chatProcessor.requestSufficiencyCheck( + new ArrayList<>(), + DetailedPrompt.create(1, 1, "validation", true, false, null, false, false) + ).join(); + + assertThat(result.isCompleted()).isTrue(); + assertThat(result.getSummary()).isEqualTo("ok"); + verify(requestChatApiPort).requestJsonResponse(anyList(), eq(LlmReasoningScenario.VALIDATION)); + } + + @Test + void requestConversationSummary_usesSummaryScenario() { + when(requestChatApiPort.requestResponse(anyList(), eq(LlmReasoningScenario.SUMMARY))) + .thenReturn(CompletableFuture.completedFuture("summary")); + + String result = chatProcessor.requestConversationSummary( + new ArrayList<>(), + Prompt.from(1L, 4, "summary prompt", false, true, false, false, false, false, false, null, null, null) + ).join(); + + assertThat(result).isEqualTo("summary"); + verify(requestChatApiPort).requestResponse(anyList(), eq(LlmReasoningScenario.SUMMARY)); + } + + @Test + void requestTitleGeneration_usesAuxiliaryExtractionScenario() { + when(requestChatApiPort.requestResponse(anyList(), eq(LlmReasoningScenario.AUXILIARY_EXTRACTION))) + .thenReturn(CompletableFuture.completedFuture("title")); + + String result = chatProcessor.requestTitleGeneration( + new ArrayList<>(), + Prompt.from(1L, 999, "title", false, false, false, false, false, false, true, null, null, null) + ).join(); + + assertThat(result).isEqualTo("title"); + verify(requestChatApiPort).requestResponse(anyList(), eq(LlmReasoningScenario.AUXILIARY_EXTRACTION)); + } + + @Test + void requestPartnerLoveTypeCategoryInference_usesAuxiliaryExtractionScenario() { + when(requestChatApiPort.requestJsonResponse(anyList(), eq(LlmReasoningScenario.AUXILIARY_EXTRACTION))) + .thenReturn(CompletableFuture.completedFuture(""" + {"partnerLoveTypeCategory":"AVOIDANCE_TYPE"} + """)); + + PartnerLoveTypeCategory result = chatProcessor.requestPartnerLoveTypeCategoryInference( + new ArrayList<>(), + "prompt" + ).join(); + + assertThat(result).isEqualTo(PartnerLoveTypeCategory.AVOIDANCE_TYPE); + verify(requestChatApiPort).requestJsonResponse(anyList(), eq(LlmReasoningScenario.AUXILIARY_EXTRACTION)); + } + + @Test + void requestPartnerLoveTypeCategoryInference_rejectsUnknown() { + when(requestChatApiPort.requestJsonResponse(anyList(), eq(LlmReasoningScenario.AUXILIARY_EXTRACTION))) + .thenReturn(CompletableFuture.completedFuture(""" + {"partnerLoveTypeCategory":"UNKNOWN"} + """)); + + assertThatThrownBy(() -> chatProcessor.requestPartnerLoveTypeCategoryInference( + new ArrayList<>(), + "prompt" + ).join()).hasRootCauseInstanceOf(IllegalArgumentException.class); + } + + @Test + void requestPartnerLoveTypeCategoryInference_rejectsInvalidValue() { + when(requestChatApiPort.requestJsonResponse(anyList(), eq(LlmReasoningScenario.AUXILIARY_EXTRACTION))) + .thenReturn(CompletableFuture.completedFuture(""" + {"partnerLoveTypeCategory":"NOT_A_TYPE"} + """)); + + assertThatThrownBy(() -> chatProcessor.requestPartnerLoveTypeCategoryInference( + new ArrayList<>(), + "prompt" + ).join()).hasRootCauseInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilderTest.java b/src/test/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilderTest.java new file mode 100644 index 00000000..f7cb97a2 --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilderTest.java @@ -0,0 +1,199 @@ +package makeus.cmc.malmo.application.service.chat; + +import makeus.cmc.malmo.application.helper.chat_room.ChatRoomQueryHelper; +import makeus.cmc.malmo.application.helper.chat_room.MemberChatRoomMetadataQueryHelper; +import makeus.cmc.malmo.application.helper.love_type.LoveTypePersonalityTypePromptQueryHelper; +import makeus.cmc.malmo.application.port.out.chat.LoadChatRoomMetadataPort; +import makeus.cmc.malmo.domain.model.chat.ChatRoom; +import makeus.cmc.malmo.domain.model.love_type.LoveTypePersonalityTypePrompt; +import makeus.cmc.malmo.domain.model.member.Member; +import makeus.cmc.malmo.domain.value.id.InviteCodeValue; +import makeus.cmc.malmo.domain.value.type.EmailForwardingStatus; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import makeus.cmc.malmo.domain.value.type.MemberRole; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; +import makeus.cmc.malmo.domain.value.type.Provider; +import makeus.cmc.malmo.domain.value.type.RelationshipStatus; +import makeus.cmc.malmo.domain.value.state.MemberState; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatPromptBuilder 테스트") +class ChatPromptBuilderTest { + + private static final String UNKNOWN_INFERENCE_PROMPT = "UNKNOWN, 사용자와의 대화로부터 유추할 것"; + + @Mock + private ChatRoomQueryHelper chatRoomQueryHelper; + + @Mock + private MemberChatRoomMetadataQueryHelper memberChatRoomMetadataQueryHelper; + + @Mock + private LoveTypePersonalityTypePromptQueryHelper loveTypePersonalityTypePromptQueryHelper; + + @InjectMocks + private ChatPromptBuilder chatPromptBuilder; + + @Test + @DisplayName("사용자와 상대방 조합 프롬프트를 메타데이터에 삽입한다") + void createForProcessUserMessage_includesUserAndPartnerPrompts() { + // given + Member member = createMember("ISTJ", LoveTypeCategory.STABLE_TYPE, "ENFP", PartnerLoveTypeCategory.ANXIETY_TYPE); + ChatRoom chatRoom = createChatRoom(1L); + + stubCommon(chatRoom, LoveTypeCategory.STABLE_TYPE, PartnerLoveTypeCategory.ANXIETY_TYPE); + when(loveTypePersonalityTypePromptQueryHelper.findByPersonalityTypeAndLoveTypeCategory("ISTJ", LoveTypeCategory.STABLE_TYPE)) + .thenReturn(Optional.of(LoveTypePersonalityTypePrompt.from("ISTJ", LoveTypeCategory.STABLE_TYPE, "ISTJ 안정형 프롬프트"))); + when(loveTypePersonalityTypePromptQueryHelper.findByPersonalityTypeAndLoveTypeCategory("ENFP", LoveTypeCategory.ANXIETY_TYPE)) + .thenReturn(Optional.of(LoveTypePersonalityTypePrompt.from("ENFP", LoveTypeCategory.ANXIETY_TYPE, "ENFP 불안형 프롬프트"))); + + // when + List> messages = chatPromptBuilder.createForProcessUserMessage(member, chatRoom, "사용자 메시지"); + + // then + assertThat(messages).hasSize(2); + String metadata = messages.get(0).get("content"); + assertThat(metadata).contains("- 사용자 성향 프롬프트:"); + assertThat(metadata).contains("ISTJ 안정형 프롬프트"); + assertThat(metadata).contains("- 상대방 성향 프롬프트:"); + assertThat(metadata).contains("ENFP 불안형 프롬프트"); + } + + @Test + @DisplayName("사용자 정보가 없거나 상대방 애착유형이 UNKNOWN이면 폴백 문구를 삽입한다") + void createForProcessUserMessage_usesFallbackForUnknownCases() { + // given + Member member = createMember(null, null, "ENFP", PartnerLoveTypeCategory.UNKNOWN); + ChatRoom chatRoom = createChatRoom(2L); + + stubCommon(chatRoom, null, PartnerLoveTypeCategory.UNKNOWN); + + // when + List> messages = chatPromptBuilder.createForProcessUserMessage(member, chatRoom, "사용자 메시지"); + + // then + String metadata = messages.get(0).get("content"); + assertThat(metadata).contains("- 사용자 성향 프롬프트:\n" + UNKNOWN_INFERENCE_PROMPT); + assertThat(metadata).contains("- 상대방 성향 프롬프트:\n" + UNKNOWN_INFERENCE_PROMPT); + } + + @Test + @DisplayName("상대방 프로필이 없으면 상대방 성향 프롬프트 항목을 추가하지 않는다") + void createForProcessUserMessage_omitsPartnerPromptWithoutPartnerProfile() { + // given + Member member = createMember("ISTJ", LoveTypeCategory.STABLE_TYPE, null, null); + ChatRoom chatRoom = createChatRoom(3L); + + stubCommon(chatRoom, LoveTypeCategory.STABLE_TYPE, null); + when(loveTypePersonalityTypePromptQueryHelper.findByPersonalityTypeAndLoveTypeCategory("ISTJ", LoveTypeCategory.STABLE_TYPE)) + .thenReturn(Optional.of(LoveTypePersonalityTypePrompt.from("ISTJ", LoveTypeCategory.STABLE_TYPE, "ISTJ 안정형 프롬프트"))); + + // when + List> messages = chatPromptBuilder.createForProcessUserMessage(member, chatRoom, "사용자 메시지"); + + // then + String metadata = messages.get(0).get("content"); + assertThat(metadata).contains("- 사용자 성향 프롬프트:\nISTJ 안정형 프롬프트"); + assertThat(metadata).doesNotContain("- 상대방 성향 프롬프트:"); + } + + @Test + @DisplayName("조합 row가 없더라도 예외 없이 폴백 문구를 삽입한다") + void createForProcessUserMessage_fallsBackWhenPromptRowMissing() { + // given + Member member = createMember("ISTJ", LoveTypeCategory.STABLE_TYPE, "ENFP", PartnerLoveTypeCategory.ANXIETY_TYPE); + ChatRoom chatRoom = createChatRoom(4L); + + stubCommon(chatRoom, LoveTypeCategory.STABLE_TYPE, PartnerLoveTypeCategory.ANXIETY_TYPE); + when(loveTypePersonalityTypePromptQueryHelper.findByPersonalityTypeAndLoveTypeCategory(any(), any())) + .thenReturn(Optional.empty()); + + // when + List> messages = chatPromptBuilder.createForProcessUserMessage(member, chatRoom, "사용자 메시지"); + + // then + String metadata = messages.get(0).get("content"); + assertThat(metadata).contains("- 사용자 성향 프롬프트:\n" + UNKNOWN_INFERENCE_PROMPT); + assertThat(metadata).contains("- 상대방 성향 프롬프트:\n" + UNKNOWN_INFERENCE_PROMPT); + } + + private void stubCommon(ChatRoom chatRoom, LoveTypeCategory userLoveType, PartnerLoveTypeCategory partnerLoveType) { + when(chatRoomQueryHelper.getMemberMemoriesByMemberId(any())).thenReturn(List.of()); + when(chatRoomQueryHelper.getChatRoomMetadata(any())) + .thenReturn(new LoadChatRoomMetadataPort.ChatRoomMetadataDto(userLoveType, partnerLoveType)); + when(memberChatRoomMetadataQueryHelper.getMemberChatRoomMetadata(any())).thenReturn(List.of()); + when(chatRoomQueryHelper.getChatRoomLevelMessages(any(), any(Integer.class))).thenReturn(List.of()); + } + + private Member createMember( + String personalityType, + LoveTypeCategory loveTypeCategory, + String otherPersonalityType, + PartnerLoveTypeCategory partnerLoveTypeCategory + ) { + LocalDateTime now = LocalDateTime.now(); + return Member.from( + 1L, + Provider.KAKAO, + "provider-id", + MemberRole.MEMBER, + MemberState.ALIVE, + false, + null, + null, + loveTypeCategory, + 1.0f, + 1.0f, + "tak", + "tak@example.com", + EmailForwardingStatus.ENABLED, + InviteCodeValue.of("ABCD1234"), + null, + null, + null, + RelationshipStatus.IN_RELATIONSHIP, + personalityType, + otherPersonalityType, + partnerLoveTypeCategory, + now, + now, + null + ); + } + + private ChatRoom createChatRoom(Long id) { + LocalDateTime now = LocalDateTime.now(); + return ChatRoom.from( + id, + makeus.cmc.malmo.domain.value.id.MemberId.of(1L), + makeus.cmc.malmo.domain.value.state.ChatRoomState.ALIVE, + 1, + 1, + now, + null, + null, + null, + null, + null, + null, + now, + now, + null + ); + } +} diff --git a/src/test/java/makeus/cmc/malmo/config/LlmClientSelectionTest.java b/src/test/java/makeus/cmc/malmo/config/LlmClientSelectionTest.java new file mode 100644 index 00000000..e365d818 --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/config/LlmClientSelectionTest.java @@ -0,0 +1,26 @@ +package makeus.cmc.malmo.config; + +import makeus.cmc.malmo.adaptor.out.OpenAiApiClient; +import makeus.cmc.malmo.application.port.out.CheckLlmHealth; +import makeus.cmc.malmo.application.port.out.chat.RequestChatApiPort; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(properties = "spring.task.scheduling.enabled=false") +class LlmClientSelectionTest { + + @Autowired + private RequestChatApiPort requestChatApiPort; + + @Autowired + private CheckLlmHealth checkLlmHealth; + + @Test + void primaryLlmClient_isOpenAi() { + assertThat(requestChatApiPort).isInstanceOf(OpenAiApiClient.class); + assertThat(checkLlmHealth).isInstanceOf(OpenAiApiClient.class); + } +} diff --git a/src/test/java/makeus/cmc/malmo/config/LlmConfigurationPropertiesTest.java b/src/test/java/makeus/cmc/malmo/config/LlmConfigurationPropertiesTest.java new file mode 100644 index 00000000..0e606a87 --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/config/LlmConfigurationPropertiesTest.java @@ -0,0 +1,48 @@ +package makeus.cmc.malmo.config; + +import makeus.cmc.malmo.application.port.out.chat.LlmReasoningScenario; +import makeus.cmc.malmo.config.properties.GeminiApiProperties; +import makeus.cmc.malmo.config.properties.OpenAiApiProperties; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(properties = "spring.task.scheduling.enabled=false") +class LlmConfigurationPropertiesTest { + + @Autowired + private OpenAiApiProperties openAiApiProperties; + + @Autowired + private GeminiApiProperties geminiApiProperties; + + @Test + void openAiProperties_areBoundFromConfiguration() { + assertThat(openAiApiProperties.getKey()).isEqualTo("sk-test-openai-api-key-for-testing-only"); + assertThat(openAiApiProperties.getModel()).isEqualTo("gpt-5.4-mini"); + assertThat(openAiApiProperties.getBaseUrl()).isEqualTo("https://api.openai.com/v1"); + assertThat(openAiApiProperties.getStatusUrl()).isEqualTo("https://status.openai.com/api/v2/status.json"); + assertThat(openAiApiProperties.getReasoningEffort().getDefault()).isEqualTo("medium"); + assertThat(openAiApiProperties.getReasoningEffort().getScenarioEffort(LlmReasoningScenario.STRUCTURED_CHAT)).isEqualTo("low"); + assertThat(openAiApiProperties.getReasoningEffort().getScenarioEffort(LlmReasoningScenario.FREE_CONVERSATION)).isEqualTo("low"); + assertThat(openAiApiProperties.getReasoningEffort().getScenarioEffort(LlmReasoningScenario.VALIDATION)).isEqualTo("none"); + assertThat(openAiApiProperties.getReasoningEffort().getScenarioEffort(LlmReasoningScenario.SUMMARY)).isEqualTo("none"); + assertThat(openAiApiProperties.getReasoningEffort().getScenarioEffort(LlmReasoningScenario.AUXILIARY_EXTRACTION)).isEqualTo("none"); + } + + @Test + void geminiProperties_areBoundFromConfiguration() { + assertThat(geminiApiProperties.getKey()).isEqualTo("test-gemini-api-key-for-testing-only"); + assertThat(geminiApiProperties.getModel()).isEqualTo("gemini-3-flash-preview"); + assertThat(geminiApiProperties.getBaseUrl()) + .isEqualTo("https://generativelanguage.googleapis.com/v1beta/openai"); + assertThat(geminiApiProperties.getReasoningEffort().getDefault()).isEqualTo("high"); + assertThat(geminiApiProperties.getReasoningEffort().getScenarioEffort(LlmReasoningScenario.STRUCTURED_CHAT)).isEqualTo("medium"); + assertThat(geminiApiProperties.getReasoningEffort().getScenarioEffort(LlmReasoningScenario.FREE_CONVERSATION)).isEqualTo("low"); + assertThat(geminiApiProperties.getReasoningEffort().getScenarioEffort(LlmReasoningScenario.VALIDATION)).isEqualTo("low"); + assertThat(geminiApiProperties.getReasoningEffort().getScenarioEffort(LlmReasoningScenario.SUMMARY)).isEqualTo("low"); + assertThat(geminiApiProperties.getReasoningEffort().getScenarioEffort(LlmReasoningScenario.AUXILIARY_EXTRACTION)).isEqualTo("low"); + } +} diff --git a/src/test/java/makeus/cmc/malmo/integration_test/CoupleIntegrationTest.java b/src/test/java/makeus/cmc/malmo/integration_test/CoupleIntegrationTest.java index 80bb2edb..c9ced722 100644 --- a/src/test/java/makeus/cmc/malmo/integration_test/CoupleIntegrationTest.java +++ b/src/test/java/makeus/cmc/malmo/integration_test/CoupleIntegrationTest.java @@ -519,9 +519,9 @@ class CoupleUnLinkFeature { mockMvc.perform(get("/members/partner") .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("message").value(NOT_COUPLE_MEMBER.getMessage())) - .andExpect(jsonPath("code").value(NOT_COUPLE_MEMBER.getCode())); + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("message").value(NO_SUCH_PARTNER_PROFILE.getMessage())) + .andExpect(jsonPath("code").value(NO_SUCH_PARTNER_PROFILE.getCode())); } @Test @@ -538,4 +538,4 @@ class CoupleUnLinkFeature { } -} \ No newline at end of file +} diff --git a/src/test/java/makeus/cmc/malmo/integration_test/LoveTypePersonalityTypePromptPersistenceAdapterTest.java b/src/test/java/makeus/cmc/malmo/integration_test/LoveTypePersonalityTypePromptPersistenceAdapterTest.java new file mode 100644 index 00000000..5be96601 --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/integration_test/LoveTypePersonalityTypePromptPersistenceAdapterTest.java @@ -0,0 +1,50 @@ +package makeus.cmc.malmo.integration_test; + +import jakarta.persistence.EntityManager; +import makeus.cmc.malmo.adaptor.out.persistence.entity.LoveTypePersonalityTypePromptEntity; +import makeus.cmc.malmo.application.port.out.LoadLoveTypePersonalityTypePromptPort; +import makeus.cmc.malmo.domain.model.love_type.LoveTypePersonalityTypePrompt; +import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Transactional +@DisplayName("LoveTypePersonalityTypePromptPersistenceAdapter 테스트") +class LoveTypePersonalityTypePromptPersistenceAdapterTest { + + @Autowired + private EntityManager em; + + @Autowired + private LoadLoveTypePersonalityTypePromptPort loadLoveTypePersonalityTypePromptPort; + + @Test + @DisplayName("personalityType 대소문자와 복합키 기준으로 프롬프트를 조회한다") + void loadByPersonalityTypeAndLoveTypeCategory_findsPromptIgnoringPersonalityTypeCase() { + // given + em.persist(LoveTypePersonalityTypePromptEntity.builder() + .personalityType("ISTJ") + .loveTypeCategory(LoveTypeCategory.STABLE_TYPE) + .prompts("ISTJ 안정형 프롬프트") + .build()); + em.flush(); + em.clear(); + + // when + LoveTypePersonalityTypePrompt prompt = loadLoveTypePersonalityTypePromptPort + .loadByPersonalityTypeAndLoveTypeCategory("istj", LoveTypeCategory.STABLE_TYPE) + .orElse(null); + + // then + assertThat(prompt).isNotNull(); + assertThat(prompt.getPersonalityType()).isEqualTo("ISTJ"); + assertThat(prompt.getLoveTypeCategory()).isEqualTo(LoveTypeCategory.STABLE_TYPE); + assertThat(prompt.getPrompts()).isEqualTo("ISTJ 안정형 프롬프트"); + } +} diff --git a/src/test/java/makeus/cmc/malmo/integration_test/LoveTypeQuestionTest.java b/src/test/java/makeus/cmc/malmo/integration_test/LoveTypeQuestionTest.java index 7da77952..703a452a 100644 --- a/src/test/java/makeus/cmc/malmo/integration_test/LoveTypeQuestionTest.java +++ b/src/test/java/makeus/cmc/malmo/integration_test/LoveTypeQuestionTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.jayway.jsonpath.JsonPath; import jakarta.persistence.EntityManager; +import makeus.cmc.malmo.adaptor.out.persistence.entity.LoveTypePersonalityTypeFeatureEntity; import makeus.cmc.malmo.adaptor.out.persistence.entity.TempLoveTypeEntity; import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; import makeus.cmc.malmo.integration_test.dto_factory.LoveTypeQuestionRequestDtoFactory; @@ -248,4 +249,120 @@ class GetLoveTypeResultTest { } } + + @Nested + @DisplayName("MBTI + 애착 유형 상세 결과 조회 테스트") + class GetLoveTypePersonalityTypeResultTest { + @Test + @DisplayName("MBTI와 애착 유형 상세 결과 조회 성공 - 소문자 쿼리와 빈 항목 제외") + void personalityType과_애착유형_상세_결과_조회_성공() throws Exception { + // given + em.persist(LoveTypePersonalityTypeFeatureEntity.builder() + .personalityType("ENFP") + .loveTypeCategory(LoveTypeCategory.STABLE_TYPE) + .summary("풍부한 상상력과 사랑으로, 함께하는 일상을 즐겁게 만들어 가는 유형") + .keyword1("열정적") + .keyword2("자유로움") + .keyword3("") + .strength1("Ne") + .strengthDesc1("흩어진 정보 속에서 하나의 핵심 맥락과 미래를 읽어내요") + .strength2("") + .strengthDesc2("") + .strength3(null) + .strengthDesc3(null) + .weakness("Si") + .weaknessDesc("반복되는 루틴에 답답함을 느껴요") + .patternTitle1("나다운 기준을 지켜요") + .pattern1("여러 가능성 속에서 무엇이 나에게 의미 있는지 먼저 생각해요") + .patternTitle2("") + .pattern2("") + .patternTitle3(null) + .pattern3(null) + .patternTitle4(null) + .pattern4(null) + .loveTypeFeatureTitle1("미래의 가능성을 자주 상상해요") + .loveTypeFeature1("연인과 함께할 미래의 가능성을 상상하며 창의적인 질문으로 관계를 만들어요") + .loveTypeFeatureTitle2("") + .loveTypeFeature2("") + .loveTypeFeatureTitle3(null) + .loveTypeFeature3(null) + .loveTypeFeatureTitle4(null) + .loveTypeFeature4(null) + .datingGuide1("감정을 정리해 표현해요 : 솔직하게 말하면 관계가 깊어져요") + .datingGuide2("") + .datingGuide3(null) + .bestPersonalityType1("infj") + .bestDesc1("속마음을 깊이 이해해주며 안정적인 감정을 공유하는 궁합") + .bestPersonalityType2("") + .bestDesc2("") + .worstPersonalityType1("istp") + .worstDesc1("자유로운 감정선과 솔직한 피드백이 부딪히는 궁합") + .worstPersonalityType2(null) + .worstDesc2(null) + .build()); + em.flush(); + em.clear(); + + // when & then + mockMvc.perform(get("/love-types/result") + .param("personalityType", "enfp") + .param("lovetype", "stable_type") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("data.personalityType").value("ENFP")) + .andExpect(jsonPath("data.loveTypeCategory").value(LoveTypeCategory.STABLE_TYPE.name())) + .andExpect(jsonPath("data.summary").value("풍부한 상상력과 사랑으로, 함께하는 일상을 즐겁게 만들어 가는 유형")) + .andExpect(jsonPath("data.keywords.length()").value(2)) + .andExpect(jsonPath("data.keywords[0]").value("열정적")) + .andExpect(jsonPath("data.strengths.length()").value(1)) + .andExpect(jsonPath("data.strengths[0].title").value("Ne")) + .andExpect(jsonPath("data.weaknesses.length()").value(1)) + .andExpect(jsonPath("data.weaknesses[0].title").value("Si")) + .andExpect(jsonPath("data.patterns.length()").value(1)) + .andExpect(jsonPath("data.loveTypeFeatures.length()").value(1)) + .andExpect(jsonPath("data.datingGuides.length()").value(1)) + .andExpect(jsonPath("data.datingGuides[0].title").value("감정을 정리해 표현해요")) + .andExpect(jsonPath("data.datingGuides[0].description").value("솔직하게 말하면 관계가 깊어져요")) + .andExpect(jsonPath("data.bestMatches.length()").value(1)) + .andExpect(jsonPath("data.bestMatches[0].personalityType").value("INFJ")) + .andExpect(jsonPath("data.worstMatches.length()").value(1)) + .andExpect(jsonPath("data.worstMatches[0].personalityType").value("ISTP")); + } + + @Test + @DisplayName("MBTI와 애착 유형 상세 결과 조회 실패 - MBTI 형식 오류") + void personalityType과_애착유형_상세_결과_조회_실패_personalityType형식오류() throws Exception { + mockMvc.perform(get("/love-types/result") + .param("personalityType", "ENF") + .param("lovetype", "STABLE_TYPE") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("message").value(BAD_REQUEST.getMessage())) + .andExpect(jsonPath("code").value(BAD_REQUEST.getCode())); + } + + @Test + @DisplayName("MBTI와 애착 유형 상세 결과 조회 실패 - 애착 유형 값 오류") + void personalityType과_애착유형_상세_결과_조회_실패_애착유형값오류() throws Exception { + mockMvc.perform(get("/love-types/result") + .param("personalityType", "ENFP") + .param("lovetype", "WRONG_TYPE") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("message").value(BAD_REQUEST.getMessage())) + .andExpect(jsonPath("code").value(BAD_REQUEST.getCode())); + } + + @Test + @DisplayName("MBTI와 애착 유형 상세 결과 조회 실패 - 매칭되는 결과 없음") + void personalityType과_애착유형_상세_결과_조회_실패_결과없음() throws Exception { + mockMvc.perform(get("/love-types/result") + .param("personalityType", "ENFP") + .param("lovetype", "STABLE_TYPE") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("message").value(NO_SUCH_LOVE_TYPE_PERSONALITY_TYPE_RESULT.getMessage())) + .andExpect(jsonPath("code").value(NO_SUCH_LOVE_TYPE_PERSONALITY_TYPE_RESULT.getCode())); + } + } } diff --git a/src/test/java/makeus/cmc/malmo/integration_test/MemberIntegrationTest.java b/src/test/java/makeus/cmc/malmo/integration_test/MemberIntegrationTest.java index 8961b611..84f0d5d4 100644 --- a/src/test/java/makeus/cmc/malmo/integration_test/MemberIntegrationTest.java +++ b/src/test/java/makeus/cmc/malmo/integration_test/MemberIntegrationTest.java @@ -23,7 +23,9 @@ import makeus.cmc.malmo.domain.value.state.*; import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; import makeus.cmc.malmo.domain.value.type.MemberRole; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; import makeus.cmc.malmo.domain.value.type.Provider; +import makeus.cmc.malmo.domain.value.type.RelationshipStatus; import makeus.cmc.malmo.domain.value.type.TermsType; import makeus.cmc.malmo.integration_test.dto_factory.CoupleRequestDtoFactory; import makeus.cmc.malmo.integration_test.dto_factory.LoveTypeQuestionRequestDtoFactory; @@ -826,16 +828,14 @@ public static class MemberResponseDto { String relationshipStatus; String personalityType; String otherPersonalityType; + String partnerLoveTypeCategory; } @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) public static class PartnerResponseDto { - private MemberState memberState; - private LoveTypeCategory loveTypeCategory; - private float avoidanceRate; - private float anxietyRate; - private String nickname; - private Boolean isStartLoveDateUpdated; + private String personalityType; + private String loveTypeCategory; + private String description; } void assertMemberInfo(MemberResponseDto memberResponse, MemberEntity member, LocalDate startLoveDate, int coupleQuestionCount, int totalChatRoomCount) { @@ -857,6 +857,10 @@ void assertMemberInfo(MemberResponseDto memberResponse, MemberEntity member, Loc Assertions.assertThat(memberResponse.anxietyRate).isEqualTo(member.getAnxietyRate()); Assertions.assertThat(memberResponse.nickname).isEqualTo(member.getNickname()); Assertions.assertThat(memberResponse.email).isEqualTo(member.getEmail()); + Assertions.assertThat(memberResponse.personalityType).isEqualTo(member.getPersonalityType()); + Assertions.assertThat(memberResponse.otherPersonalityType).isEqualTo(member.getOtherPersonalityType()); + Assertions.assertThat(memberResponse.partnerLoveTypeCategory) + .isEqualTo(member.getPartnerLoveTypeCategory() == null ? null : member.getPartnerLoveTypeCategory().name()); } @Test @@ -983,18 +987,22 @@ void assertMemberInfo(MemberResponseDto memberResponse, MemberEntity member, Loc } @Test - @DisplayName("파트너 멤버 정보 조회 성공") + @DisplayName("상대 프로필 등록 및 조회 성공") void 파트너_멤버_정보_조회_성공() throws Exception { // given - MemberEntity partner = createAndSavePartner(); + Map requestDto = Map.of( + "personalityType", "enfp", + "loveTypeCategory", "CONFUSION_TYPE" + ); - mockMvc.perform(post("/couples") + mockMvc.perform(post("/members/partners") .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString( - CoupleRequestDtoFactory.createCoupleLinkRequestDto(partner.getInviteCodeEntityValue().getValue()) - ))) - .andExpect(status().isOk()); + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.personalityType").value("ENFP")) + .andExpect(jsonPath("$.data.loveTypeCategory").value("CONFUSION_TYPE")) + .andExpect(jsonPath("$.data.description").value("혼란형")); // when MvcResult mvcResult = mockMvc.perform(get("/members/partner") @@ -1010,41 +1018,34 @@ void assertMemberInfo(MemberResponseDto memberResponse, MemberEntity member, Loc new TypeReference<>() {} ); - // 파트너 멤버 정보가 정상적으로 조회되었는지 검증 + // 상대 프로필이 정상적으로 조회되는지 검증 PartnerResponseDto partnerDto = responseDto.data; - Assertions.assertThat(partnerDto.memberState).isEqualTo(partner.getMemberState()); - Assertions.assertThat(partnerDto.loveTypeCategory).isEqualTo(partner.getLoveTypeCategory()); - Assertions.assertThat(partnerDto.avoidanceRate).isEqualTo(partner.getAvoidanceRate()); - Assertions.assertThat(partnerDto.anxietyRate).isEqualTo(partner.getAnxietyRate()); - Assertions.assertThat(partnerDto.nickname).isEqualTo(partner.getNickname()); - // 새로 생성된 커플이므로 isStartLoveDateUpdated는 false여야 함 - Assertions.assertThat(partnerDto.isStartLoveDateUpdated).isFalse(); + Assertions.assertThat(partnerDto.personalityType).isEqualTo("ENFP"); + Assertions.assertThat(partnerDto.loveTypeCategory).isEqualTo("CONFUSION_TYPE"); + Assertions.assertThat(partnerDto.description).isEqualTo("혼란형"); } @Test - @DisplayName("디데이 변경 후 파트너 정보 조회 시 isStartLoveDateUpdated가 true인지 확인") - void 디데이_변경_후_파트너_정보_조회_시_isStartLoveDateUpdated_확인() throws Exception { + @DisplayName("상대 프로필 수정 성공") + void 상대_프로필_수정_성공() throws Exception { // given - MemberEntity partner = createAndSavePartner(); - - // 커플 연결 - mockMvc.perform(post("/couples") + mockMvc.perform(post("/members/partners") .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString( - CoupleRequestDtoFactory.createCoupleLinkRequestDto(partner.getInviteCodeEntityValue().getValue()) - ))) + .content(objectMapper.writeValueAsString(Map.of("personalityType", "enfp")))) .andExpect(status().isOk()); - // 디데이 변경 - LocalDate newDday = LocalDate.of(2025, 1, 1); - mockMvc.perform(patch("/members/start-love-date") + // loveTypeCategory를 null로 전달하면 기존 값(null) 유지 + mockMvc.perform(patch("/members/partners") .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString( - MemberRequestDtoFactory.createUpdateStartLoveDateRequestDto(newDday) - ))) - .andExpect(status().isOk()); + .content(""" + {"personalityType":"intj","loveTypeCategory":null} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.personalityType").value("INTJ")) + .andExpect(jsonPath("$.data.loveTypeCategory").doesNotExist()) + .andExpect(jsonPath("$.data.description").doesNotExist()); // when MvcResult mvcResult = mockMvc.perform(get("/members/partner") @@ -1060,50 +1061,111 @@ void assertMemberInfo(MemberResponseDto memberResponse, MemberEntity member, Loc new TypeReference<>() {} ); - // 디데이 변경 후 isStartLoveDateUpdated가 true인지 확인 PartnerResponseDto partnerDto = responseDto.data; - Assertions.assertThat(partnerDto.isStartLoveDateUpdated).isTrue(); + Assertions.assertThat(partnerDto.personalityType).isEqualTo("INTJ"); + Assertions.assertThat(partnerDto.loveTypeCategory).isNull(); + Assertions.assertThat(partnerDto.description).isNull(); + } + + @Test + @DisplayName("상대 프로필 수정 성공 - personalityType만 전달 시 loveTypeCategory 유지") + void 상대_프로필_수정_성공_personalityType만_업데이트() throws Exception { + // given - personalityType + loveTypeCategory 모두 등록 + mockMvc.perform(post("/members/partners") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"personalityType":"enfp","loveTypeCategory":"CONFUSION_TYPE"} + """)) + .andExpect(status().isOk()); + + // when - personalityType만 수정 (loveTypeCategory 미포함) + mockMvc.perform(patch("/members/partners") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"personalityType":"intj"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.personalityType").value("INTJ")) + .andExpect(jsonPath("$.data.loveTypeCategory").value("CONFUSION_TYPE")); + } + + @Test + @DisplayName("상대 프로필 수정 성공 - loveTypeCategory만 전달 시 personalityType 유지") + void 상대_프로필_수정_성공_loveTypeCategory만_업데이트() throws Exception { + // given - personalityType + loveTypeCategory 모두 등록 + mockMvc.perform(post("/members/partners") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"personalityType":"enfp","loveTypeCategory":"CONFUSION_TYPE"} + """)) + .andExpect(status().isOk()); + + // when - loveTypeCategory만 수정 (personalityType 미포함) + mockMvc.perform(patch("/members/partners") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"loveTypeCategory":"STABLE_TYPE"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.personalityType").value("ENFP")) + .andExpect(jsonPath("$.data.loveTypeCategory").value("STABLE_TYPE")); } @Test - @DisplayName("파트너 멤버 정보 조회 실패 - 커플이 아닌 경우") + @DisplayName("상대 프로필 조회 실패 - 미등록 상태") void 파트너_멤버_정보_조회_실패_커플이_아닌_경우() throws Exception { // when & then mockMvc.perform(get("/members/partner") .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("message").value(NOT_COUPLE_MEMBER.getMessage())) - .andExpect(jsonPath("code").value(NOT_COUPLE_MEMBER.getCode())); + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("message").value(NO_SUCH_PARTNER_PROFILE.getMessage())) + .andExpect(jsonPath("code").value(NO_SUCH_PARTNER_PROFILE.getCode())); } @Test - @DisplayName("파트너 멤버 정보 조회 실패 - 탈퇴한 멤버인 경우") + @DisplayName("상대 프로필 중복 등록 실패") void 파트너_멤버_정보_조회_실패_탈퇴한_멤버인_경우() throws Exception { // given - MemberEntity partner = createAndSavePartner(); - - mockMvc.perform(post("/couples") + mockMvc.perform(post("/members/partners") .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString( - CoupleRequestDtoFactory.createCoupleLinkRequestDto(partner.getInviteCodeEntityValue().getValue()) - ))) + .content(objectMapper.writeValueAsString(Map.of("personalityType", "ENFP")))) .andExpect(status().isOk()); - // 탈퇴 처리 - mockMvc.perform(delete("/members") + mockMvc.perform(post("/members/partners") .header("Authorization", "Bearer " + accessToken) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("personalityType", "INTJ")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("message").value(PARTNER_PROFILE_ALREADY_EXISTS.getMessage())) + .andExpect(jsonPath("code").value(PARTNER_PROFILE_ALREADY_EXISTS.getCode())); + } - // when & then - mockMvc.perform(get("/members/partner") + @Test + @DisplayName("멤버 정보 수정 시 MBTI와 애착 유형 반영") + void 멤버_정보_수정_시_MBTI와_애착유형_반영() throws Exception { + mockMvc.perform(patch("/members") .header("Authorization", "Bearer " + accessToken) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("message").value(NOT_COUPLE_MEMBER.getMessage())) - .andExpect(jsonPath("code").value(NOT_COUPLE_MEMBER.getCode())); + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"relationshipStatus":"IN_RELATIONSHIP","personalityType":"intj","loveTypeCategory":"STABLE_TYPE"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.relationshipStatus").value(RelationshipStatus.IN_RELATIONSHIP.name())) + .andExpect(jsonPath("$.data.personalityType").value("INTJ")) + .andExpect(jsonPath("$.data.loveTypeCategory").value(LoveTypeCategory.STABLE_TYPE.name())); + + em.flush(); + em.clear(); + + MemberEntity updatedMember = em.find(MemberEntity.class, member.getId()); + Assertions.assertThat(updatedMember.getPersonalityType()).isEqualTo("INTJ"); + Assertions.assertThat(updatedMember.getLoveTypeCategory()).isEqualTo(LoveTypeCategory.STABLE_TYPE); } @Test @@ -1534,6 +1596,150 @@ private MemberEntity createAndSavePartner() { return partner; } + @Nested + @DisplayName("상대 프로필 등록/수정 API 테스트 (POST/PATCH /members/partners)") + class PartnerProfileTest { + + @Test + @DisplayName("MBTI만 전송하면 loveTypeCategory는 null로 저장된다") + void createPartnerProfile_mbtiOnly_loveTypeCategoryIsNull() throws Exception { + // given + Map requestDto = Map.of("personalityType", "INFP"); + + // when & then + mockMvc.perform(post("/members/partners") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.personalityType").value("INFP")) + .andExpect(jsonPath("$.data.loveTypeCategory").doesNotExist()); + + em.flush(); + em.clear(); + + MemberEntity savedMember = em.find(MemberEntity.class, member.getId()); + assertThat(savedMember.getOtherPersonalityType()).isEqualTo("INFP"); + assertThat(savedMember.getPartnerLoveTypeCategory()).isNull(); + } + + @Test + @DisplayName("MBTI와 loveTypeCategory 모두 전송하면 두 필드 모두 저장된다") + void createPartnerProfile_bothFields_allSaved() throws Exception { + // given + Map requestDto = Map.of( + "personalityType", "INFP", + "loveTypeCategory", "STABLE_TYPE" + ); + + // when & then + mockMvc.perform(post("/members/partners") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.personalityType").value("INFP")) + .andExpect(jsonPath("$.data.loveTypeCategory").value("STABLE_TYPE")); + + em.flush(); + em.clear(); + + MemberEntity savedMember = em.find(MemberEntity.class, member.getId()); + assertThat(savedMember.getOtherPersonalityType()).isEqualTo("INFP"); + assertThat(savedMember.getPartnerLoveTypeCategory()).isEqualTo(PartnerLoveTypeCategory.STABLE_TYPE); + } + + @Test + @DisplayName("UNKNOWN을 명시적으로 전송하면 UNKNOWN으로 저장된다") + void createPartnerProfile_unknownExplicit_savedAsUnknown() throws Exception { + // given + Map requestDto = Map.of( + "personalityType", "INFP", + "loveTypeCategory", "UNKNOWN" + ); + + // when & then + mockMvc.perform(post("/members/partners") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.loveTypeCategory").value("UNKNOWN")); + + em.flush(); + em.clear(); + + MemberEntity savedMember = em.find(MemberEntity.class, member.getId()); + assertThat(savedMember.getPartnerLoveTypeCategory()).isEqualTo(PartnerLoveTypeCategory.UNKNOWN); + } + + @Test + @DisplayName("MBTI 없이 loveTypeCategory만 전송하면 400 오류가 발생한다") + void createPartnerProfile_missingMbti_returns400() throws Exception { + // given + Map requestDto = Map.of("loveTypeCategory", "STABLE_TYPE"); + + // when & then + mockMvc.perform(post("/members/partners") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("MBTI만 등록한 후 PATCH로 loveTypeCategory를 설정할 수 있다") + void updatePartnerProfile_addLoveTypeAfterMbtiOnly() throws Exception { + // given - MBTI만 먼저 등록 + mockMvc.perform(post("/members/partners") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("personalityType", "INFP")))) + .andExpect(status().isOk()); + + // when - loveTypeCategory만 PATCH + mockMvc.perform(patch("/members/partners") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("loveTypeCategory", "STABLE_TYPE")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.personalityType").value("INFP")) + .andExpect(jsonPath("$.data.loveTypeCategory").value("STABLE_TYPE")); + + em.flush(); + em.clear(); + + MemberEntity savedMember = em.find(MemberEntity.class, member.getId()); + assertThat(savedMember.getOtherPersonalityType()).isEqualTo("INFP"); + assertThat(savedMember.getPartnerLoveTypeCategory()).isEqualTo(PartnerLoveTypeCategory.STABLE_TYPE); + } + + @Test + @DisplayName("PATCH에서 loveTypeCategory를 생략하면 기존 값이 유지된다") + void updatePartnerProfile_omitLoveType_preservesExistingValue() throws Exception { + // given - MBTI + loveTypeCategory 모두 등록 + mockMvc.perform(post("/members/partners") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "personalityType", "INFP", + "loveTypeCategory", "STABLE_TYPE" + )))) + .andExpect(status().isOk()); + + // when - personalityType만 PATCH (loveTypeCategory 생략) + mockMvc.perform(patch("/members/partners") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("personalityType", "INTJ")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.personalityType").value("INTJ")) + .andExpect(jsonPath("$.data.loveTypeCategory").value("STABLE_TYPE")); + } + } + private CoupleQuestionEntity createAndSaveCoupleQuestion(QuestionEntity questionEntity, Integer coupleId) { CoupleQuestionEntity coupleQuestion = CoupleQuestionEntity.builder() .question(questionEntity) @@ -1560,4 +1766,4 @@ private ChatRoomEntity createAndSaveChatRoom() { return chatRoom; } -} \ No newline at end of file +} diff --git a/src/test/java/makeus/cmc/malmo/mapper/MemberMapperTest.java b/src/test/java/makeus/cmc/malmo/mapper/MemberMapperTest.java index 07c32db3..ebe9a0f5 100644 --- a/src/test/java/makeus/cmc/malmo/mapper/MemberMapperTest.java +++ b/src/test/java/makeus/cmc/malmo/mapper/MemberMapperTest.java @@ -10,6 +10,7 @@ import makeus.cmc.malmo.domain.value.state.MemberState; import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; import makeus.cmc.malmo.domain.value.type.MemberRole; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; import makeus.cmc.malmo.domain.value.type.Provider; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -84,6 +85,9 @@ private void assertMember(Member domain, MemberEntity entity) { assertThat(domain.getStartLoveDate()).isEqualTo(entity.getStartLoveDate()); assertThat(domain.getOauthToken()).isEqualTo(entity.getOauthToken()); assertThat(domain.getCoupleId().getValue()).isEqualTo(entity.getCoupleEntityId().getValue()); + assertThat(domain.getPersonalityType()).isEqualTo(entity.getPersonalityType()); + assertThat(domain.getOtherPersonalityType()).isEqualTo(entity.getOtherPersonalityType()); + assertThat(domain.getPartnerLoveTypeCategory()).isEqualTo(entity.getPartnerLoveTypeCategory()); assertThat(domain.getCreatedAt()).isEqualTo(entity.getCreatedAt()); assertThat(domain.getModifiedAt()).isEqualTo(entity.getModifiedAt()); assertThat(domain.getDeletedAt()).isEqualTo(entity.getDeletedAt()); @@ -107,6 +111,9 @@ private void assertMemberEntity(MemberEntity entity, Member domain) { assertThat(entity.getStartLoveDate()).isEqualTo(domain.getStartLoveDate()); assertThat(entity.getOauthToken()).isEqualTo(domain.getOauthToken()); assertThat(entity.getCoupleEntityId().getValue()).isEqualTo(domain.getCoupleId().getValue()); + assertThat(entity.getPersonalityType()).isEqualTo(domain.getPersonalityType()); + assertThat(entity.getOtherPersonalityType()).isEqualTo(domain.getOtherPersonalityType()); + assertThat(entity.getPartnerLoveTypeCategory()).isEqualTo(domain.getPartnerLoveTypeCategory()); assertThat(entity.getCreatedAt()).isEqualTo(domain.getCreatedAt()); assertThat(entity.getModifiedAt()).isEqualTo(domain.getModifiedAt()); assertThat(entity.getDeletedAt()).isEqualTo(domain.getDeletedAt()); @@ -132,6 +139,9 @@ private MemberEntity createCompleteEntity() { .startLoveDate(LocalDate.now()) .oauthToken("oauth_token") .coupleEntityId(CoupleEntityId.of(100L)) + .personalityType("INTJ") + .otherPersonalityType("ENFP") + .partnerLoveTypeCategory(PartnerLoveTypeCategory.UNKNOWN) .createdAt(now) .modifiedAt(now) .deletedAt(null) @@ -160,8 +170,9 @@ private Member createCompleteMember() { "oauth_token", CoupleId.of(100L), null, // relationshipStatus - null, // personalityType - null, // otherPersonalityType + "INTJ", + "ENFP", + PartnerLoveTypeCategory.UNKNOWN, now, now, null diff --git a/src/test/java/makeus/cmc/malmo/service/AppleNotificationServiceTest.java b/src/test/java/makeus/cmc/malmo/service/AppleNotificationServiceTest.java index aeaac1a3..1960e569 100644 --- a/src/test/java/makeus/cmc/malmo/service/AppleNotificationServiceTest.java +++ b/src/test/java/makeus/cmc/malmo/service/AppleNotificationServiceTest.java @@ -12,6 +12,7 @@ import makeus.cmc.malmo.domain.value.state.MemberState; import makeus.cmc.malmo.domain.value.type.EmailForwardingStatus; import makeus.cmc.malmo.domain.value.type.MemberRole; +import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory; import makeus.cmc.malmo.domain.value.type.Provider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -92,6 +93,7 @@ private Member createTestMember(String providerId, String oauthToken) { null, // relationshipStatus null, // personalityType null, // otherPersonalityType + null, // partnerLoveTypeCategory null, null, null @@ -347,4 +349,3 @@ class TokenInvalidationTest { } } } -