diff --git a/.github/workflows/PROJECT-COMMON-PROJECT-BACKLOG-MANAGER.yaml b/.github/workflows/PROJECT-COMMON-PROJECT-BACKLOG-MANAGER.yaml new file mode 100644 index 0000000..be38dd0 --- /dev/null +++ b/.github/workflows/PROJECT-COMMON-PROJECT-BACKLOG-MANAGER.yaml @@ -0,0 +1,526 @@ +# =================================================================== +# GitHub Projects Backlog 자동 관리 워크플로우 +# =================================================================== +# +# 이 워크플로우는 GitHub Issue와 Projects를 자동으로 동기화합니다. +# +# 작동 방식: +# 1. Issue 생성 시 자동으로 프로젝트에 추가 (PR은 옵션) +# 2. Issue Label 변경 시 Projects Status 자동 동기화 +# 3. Issue 닫기 시 Projects Status를 "작업 완료"로 자동 변경 (PR은 옵션) +# +# 지원 기능: +# - Issue 생성 → 프로젝트 자동 추가 (기본 Status: "작업 전") +# - PR 생성 → 프로젝트 자동 추가 (옵션, 기본값: 비활성화) +# - Label 변경 → Projects Status 실시간 동기화 (Issue만 지원) +# • 작업 전 → 작업 전 +# • 작업 중 → 작업 중 +# • 확인 대기 → 확인 대기 +# • 피드백 → 피드백 +# • 작업 완료 → 작업 완료 +# • 취소 → 취소 +# - Issue 닫기 → "작업 완료" Status 자동 설정 +# - PR 닫기 → "작업 완료" Status 자동 설정 (옵션, 기본값: 비활성화) +# - 여러 Status Label 동시 존재 시 우선순위 정책 적용 +# +# Label 우선순위: +# 작업 완료 > 취소 > 피드백 > 확인 대기 > 작업 중 > 작업 전 +# +# 필수 설정: +# - Organization Secret: _GITHUB_PAT_TOKEN (프로젝트 추가용) +# - Organization Secret: UPDATE_PROJECT_V2_PAT (Status 업데이트용, Classic PAT 필요) +# • 필요 권한: repo (전체), project (read:project, write:project) +# • Fine-grained token은 GraphQL API 미지원 +# +# 환경변수 설정: +# - ENABLE_PR_AUTO_ADD: PR 생성 시 프로젝트 자동 추가 (기본값: false) +# - ENABLE_PR_AUTO_CLOSE: PR 닫기 시 작업 완료 처리 (기본값: false) +# +# 사용 예시: +# - Issue만 자동화: 기본 설정 사용 (변경 불필요) +# - PR도 자동화: env에서 ENABLE_PR_AUTO_ADD와 ENABLE_PR_AUTO_CLOSE를 true로 변경 +# +# =================================================================== + +name: PROJECT-BACKLOG-MANAGER + +on: + issues: + types: [opened, labeled, unlabeled, closed] + pull_request: + types: [opened, closed] + +# =================================================================== +# 설정 변수 +# =================================================================== +env: + PROJECT_URL: https://github.com/orgs/MapSee-Lab/projects/1 + STATUS_FIELD: Status + # PR 자동화 옵션 (기본값: false - Issue만 처리) + ENABLE_PR_AUTO_ADD: false # PR 생성 시 프로젝트 자동 추가 + ENABLE_PR_AUTO_CLOSE: false # PR 닫기 시 작업 완료 처리 + +permissions: + issues: write + pull-requests: write + contents: read + +jobs: + # =================================================================== + # Job 1: Issue/PR 생성 시 프로젝트에 자동 추가 + # =================================================================== + add-to-project: + name: 프로젝트에 Issue/PR 추가 + if: | + github.event.action == 'opened' && + ( + github.event_name == 'issues' || + (github.event_name == 'pull_request' && env.ENABLE_PR_AUTO_ADD == 'true') + ) + runs-on: ubuntu-latest + steps: + - name: 프로젝트에 Issue/PR 추가 + uses: actions/add-to-project@v0.5.0 + with: + project-url: ${{ env.PROJECT_URL }} + github-token: ${{ secrets._GITHUB_PAT_TOKEN }} + + - name: 추가 완료 로그 + run: | + if [ "${{ github.event_name }}" == "issues" ]; then + echo "✅ Issue가 프로젝트에 추가되었습니다." + echo " • 번호: #${{ github.event.issue.number }}" + else + echo "✅ PR이 프로젝트에 추가되었습니다. (ENABLE_PR_AUTO_ADD: true)" + echo " • 번호: #${{ github.event.pull_request.number }}" + fi + echo " • 프로젝트: ${{ env.PROJECT_URL }}" + + # =================================================================== + # Job 2: Label 변경 시 Projects Status 동기화 + # =================================================================== + sync-label-to-status: + name: Label을 Projects Status로 동기화 + if: | + github.event_name == 'issues' && + (github.event.action == 'labeled' || github.event.action == 'unlabeled') + runs-on: ubuntu-latest + steps: + - name: 현재 Issue의 모든 Label 조회 + id: get-labels + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('📋 Label 조회 시작'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + const issue = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + const labels = issue.data.labels.map(label => label.name); + console.log(`📌 Issue #${context.issue.number}의 현재 Labels:`); + console.log(` ${labels.join(', ') || '(없음)'}`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + return labels; + + - name: Status로 매핑할 Label 결정 + id: determine-status + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('🎯 Status 결정 시작'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + const labels = ${{ steps.get-labels.outputs.result }}; + + // Status Label 우선순위 (높음 → 낮음) + const statusPriority = [ + '작업 완료', + '취소', + '피드백', + '확인 대기', + '작업 중', + '작업 전' + ]; + + // 현재 Label 중 Status Label 찾기 + let targetStatus = ''; + for (const status of statusPriority) { + if (labels.includes(status)) { + targetStatus = status; + console.log(`✅ Status Label 발견: "${targetStatus}"`); + break; + } + } + + if (!targetStatus) { + console.log('⚠️ Status Label이 없습니다. Status 업데이트 건너뜀'); + } else { + console.log(`🎯 Projects Status로 설정할 값: "${targetStatus}"`); + } + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + return targetStatus; + + - name: Projects Status 업데이트 + if: steps.determine-status.outputs.result != '' + uses: actions/github-script@v7 + env: + TARGET_STATUS: ${{ steps.determine-status.outputs.result }} + PAT_TOKEN: ${{ secrets.UPDATE_PROJECT_V2_PAT }} + with: + github-token: ${{ secrets.UPDATE_PROJECT_V2_PAT }} + script: | + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('🔄 Projects Status 업데이트 시작'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + const targetStatus = process.env.TARGET_STATUS; + const issueNodeId = context.payload.issue.node_id; + const projectUrl = '${{ env.PROJECT_URL }}'; + + console.log(`📌 Issue Node ID: ${issueNodeId}`); + console.log(`📌 목표 Status: "${targetStatus}"`); + console.log(`📌 프로젝트 URL: ${projectUrl}`); + + try { + // 1. 프로젝트 번호 추출 + const projectMatch = projectUrl.match(/\/projects\/(\d+)/); + if (!projectMatch) { + throw new Error('프로젝트 URL에서 번호를 추출할 수 없습니다.'); + } + const projectNumber = parseInt(projectMatch[1]); + console.log(`📊 프로젝트 번호: ${projectNumber}`); + + // 2. 조직 프로젝트 정보 조회 + const orgLogin = context.repo.owner; + console.log(`🏢 조직명: ${orgLogin}`); + + const projectQuery = ` + query($orgLogin: String!, $projectNumber: Int!) { + organization(login: $orgLogin) { + projectV2(number: $projectNumber) { + id + fields(first: 20) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + } + } + } + } + } + } + } + `; + + const projectData = await github.graphql(projectQuery, { + orgLogin, + projectNumber + }); + + const project = projectData.organization.projectV2; + const projectId = project.id; + console.log(`✅ 프로젝트 ID: ${projectId}`); + + // 3. Status 필드 및 옵션 ID 찾기 + const statusField = project.fields.nodes.find( + field => field.name === '${{ env.STATUS_FIELD }}' + ); + + if (!statusField) { + throw new Error('Status 필드를 찾을 수 없습니다.'); + } + + const fieldId = statusField.id; + console.log(`✅ Status 필드 ID: ${fieldId}`); + + const statusOption = statusField.options.find( + option => option.name === targetStatus + ); + + if (!statusOption) { + throw new Error(`"${targetStatus}" 옵션을 찾을 수 없습니다.`); + } + + const optionId = statusOption.id; + console.log(`✅ "${targetStatus}" 옵션 ID: ${optionId}`); + + // 4. Issue의 프로젝트 아이템 ID 조회 + const itemQuery = ` + query($issueId: ID!) { + node(id: $issueId) { + ... on Issue { + projectItems(first: 10) { + nodes { + id + project { + id + } + } + } + } + } + } + `; + + const itemData = await github.graphql(itemQuery, { + issueId: issueNodeId + }); + + const projectItem = itemData.node.projectItems.nodes.find( + item => item.project.id === projectId + ); + + if (!projectItem) { + console.log('⚠️ 이 Issue가 프로젝트에 추가되지 않았습니다.'); + console.log(' 프로젝트 추가 후 Label을 다시 변경해주세요.'); + return; + } + + const itemId = projectItem.id; + console.log(`✅ 프로젝트 아이템 ID: ${itemId}`); + + // 5. Status 업데이트 뮤테이션 실행 + const updateMutation = ` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue( + input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + } + ) { + projectV2Item { + id + } + } + } + `; + + await github.graphql(updateMutation, { + projectId, + itemId, + fieldId, + optionId + }); + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('🎉 Projects Status 업데이트 완료!'); + console.log(` • Issue: #${context.issue.number}`); + console.log(` • Status: "${targetStatus}"`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + } catch (error) { + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error('❌ Status 업데이트 실패'); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error(error); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + throw error; + } + + # =================================================================== + # Job 3: Issue/PR 닫기 시 "작업 완료" Status로 변경 + # =================================================================== + move-closed-to-done: + name: 닫힌 Issue/PR을 작업 완료로 이동 + if: | + github.event.action == 'closed' && + ( + github.event_name == 'issues' || + (github.event_name == 'pull_request' && env.ENABLE_PR_AUTO_CLOSE == 'true') + ) + runs-on: ubuntu-latest + steps: + - name: Projects Status를 "작업 완료"로 업데이트 + uses: actions/github-script@v7 + env: + DONE_STATUS: 작업 완료 + PAT_TOKEN: ${{ secrets.UPDATE_PROJECT_V2_PAT }} + with: + github-token: ${{ secrets.UPDATE_PROJECT_V2_PAT }} + script: | + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('🏁 Issue/PR 닫기 감지 - 작업 완료 처리'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + const targetStatus = process.env.DONE_STATUS; + const itemNodeId = context.payload.issue?.node_id || context.payload.pull_request?.node_id; + const itemNumber = context.payload.issue?.number || context.payload.pull_request?.number; + const itemType = context.payload.issue ? 'Issue' : 'Pull Request'; + const projectUrl = '${{ env.PROJECT_URL }}'; + + console.log(`📌 ${itemType} #${itemNumber}`); + console.log(`📌 Node ID: ${itemNodeId}`); + console.log(`📌 목표 Status: "${targetStatus}"`); + + try { + // 1. 프로젝트 번호 추출 + const projectMatch = projectUrl.match(/\/projects\/(\d+)/); + if (!projectMatch) { + throw new Error('프로젝트 URL에서 번호를 추출할 수 없습니다.'); + } + const projectNumber = parseInt(projectMatch[1]); + + // 2. 조직 프로젝트 정보 조회 + const orgLogin = context.repo.owner; + + const projectQuery = ` + query($orgLogin: String!, $projectNumber: Int!) { + organization(login: $orgLogin) { + projectV2(number: $projectNumber) { + id + fields(first: 20) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + } + } + } + } + } + } + } + `; + + const projectData = await github.graphql(projectQuery, { + orgLogin, + projectNumber + }); + + const project = projectData.organization.projectV2; + const projectId = project.id; + + // 3. Status 필드 및 "작업 완료" 옵션 ID 찾기 + const statusField = project.fields.nodes.find( + field => field.name === '${{ env.STATUS_FIELD }}' + ); + + if (!statusField) { + throw new Error('Status 필드를 찾을 수 없습니다.'); + } + + const fieldId = statusField.id; + + const doneOption = statusField.options.find( + option => option.name === targetStatus + ); + + if (!doneOption) { + throw new Error(`"${targetStatus}" 옵션을 찾을 수 없습니다.`); + } + + const optionId = doneOption.id; + console.log(`✅ "${targetStatus}" 옵션 ID: ${optionId}`); + + // 4. Issue/PR의 프로젝트 아이템 ID 조회 + const itemQuery = context.payload.issue + ? ` + query($itemId: ID!) { + node(id: $itemId) { + ... on Issue { + projectItems(first: 10) { + nodes { + id + project { + id + } + } + } + } + } + } + ` + : ` + query($itemId: ID!) { + node(id: $itemId) { + ... on PullRequest { + projectItems(first: 10) { + nodes { + id + project { + id + } + } + } + } + } + } + `; + + const itemData = await github.graphql(itemQuery, { + itemId: itemNodeId + }); + + const projectItem = itemData.node.projectItems.nodes.find( + item => item.project.id === projectId + ); + + if (!projectItem) { + console.log(`⚠️ 이 ${itemType}가 프로젝트에 추가되지 않았습니다.`); + console.log(' 자동 완료 처리를 건너뜁니다.'); + return; + } + + const itemId = projectItem.id; + console.log(`✅ 프로젝트 아이템 ID: ${itemId}`); + + // 5. Status 업데이트 뮤테이션 실행 + const updateMutation = ` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue( + input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + } + ) { + projectV2Item { + id + } + } + } + `; + + await github.graphql(updateMutation, { + projectId, + itemId, + fieldId, + optionId + }); + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('🎉 작업 완료 처리 성공!'); + console.log(` • ${itemType}: #${itemNumber}`); + console.log(` • Status: "${targetStatus}"`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + } catch (error) { + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error('❌ 작업 완료 처리 실패'); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error(error); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + // 실패해도 워크플로우는 성공으로 처리 (GitHub 기본 자동화가 있을 수 있음) + console.log('⚠️ GitHub Projects 기본 자동화를 확인하세요.'); + } diff --git a/CHANGELOG.json b/CHANGELOG.json index 843d024..79d1e39 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,11 +1,42 @@ { "metadata": { - "lastUpdated": "2026-01-19T04:22:34Z", - "currentVersion": "0.1.13", + "lastUpdated": "2026-01-19T12:59:01Z", + "currentVersion": "0.1.16", "projectType": "spring", - "totalReleases": 4 + "totalReleases": 5 }, "releases": [ + { + "version": "0.1.16", + "project_type": "spring", + "date": "2026-01-19", + "pr_number": 11, + "raw_summary": "## Summary by CodeRabbit\n\n## 릴리스 노트\n\n* **New Features**\n * Firebase 기반 인증 시스템으로 로그인 방식 변경\n * 장소 북마크에 폴더, 메모, 별점, 방문 기록 기능 추가\n * 인기 키워드 및 트렌딩 키워드 시스템 구현\n\n* **Bug Fixes**\n * Firebase 토큰 검증 오류 처리 개선\n\n* **Chores**\n * 관심사 관련 기능 제거 및 온보딩 프로세스 단순화\n * 프로젝트 버전 업데이트 (v0.1.13 → v0.1.16)", + "parsed_changes": { + "new_features": { + "title": "New Features", + "items": [ + "Firebase 기반 인증 시스템으로 로그인 방식 변경", + "장소 북마크에 폴더, 메모, 별점, 방문 기록 기능 추가", + "인기 키워드 및 트렌딩 키워드 시스템 구현" + ] + }, + "bug_fixes": { + "title": "Bug Fixes", + "items": [ + "Firebase 토큰 검증 오류 처리 개선" + ] + }, + "chores": { + "title": "Chores", + "items": [ + "관심사 관련 기능 제거 및 온보딩 프로세스 단순화", + "프로젝트 버전 업데이트 (v0.1.13 → v0.1.16)" + ] + } + }, + "parse_method": "markdown" + }, { "version": "0.1.13", "project_type": "spring", diff --git a/CHANGELOG.md b/CHANGELOG.md index ebe234f..8845541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,25 @@ # Changelog -**현재 버전:** 0.1.13 -**마지막 업데이트:** 2026-01-19T04:22:34Z +**현재 버전:** 0.1.16 +**마지막 업데이트:** 2026-01-19T12:59:01Z + +--- + +## [0.1.16] - 2026-01-19 + +**PR:** #11 + +**New Features** +- Firebase 기반 인증 시스템으로 로그인 방식 변경 +- 장소 북마크에 폴더, 메모, 별점, 방문 기록 기능 추가 +- 인기 키워드 및 트렌딩 키워드 시스템 구현 + +**Bug Fixes** +- Firebase 토큰 검증 오류 처리 개선 + +**Chores** +- 관심사 관련 기능 제거 및 온보딩 프로세스 단순화 +- 프로젝트 버전 업데이트 (v0.1.13 → v0.1.16) --- diff --git a/MS-AI/src/main/java/kr/suhsaechan/mapsy/ai/dto/AiCallbackRequest.java b/MS-AI/src/main/java/kr/suhsaechan/mapsy/ai/dto/AiCallbackRequest.java index b091350..63b7f19 100644 --- a/MS-AI/src/main/java/kr/suhsaechan/mapsy/ai/dto/AiCallbackRequest.java +++ b/MS-AI/src/main/java/kr/suhsaechan/mapsy/ai/dto/AiCallbackRequest.java @@ -91,9 +91,26 @@ public static class PlaceInfo { @NotNull(message = "address는 필수입니다.") private String address; + @Schema(description = "위도", example = "37.563476", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "latitude는 필수입니다.") + private Double latitude; + + @Schema(description = "경도", example = "126.983920", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "longitude는 필수입니다.") + private Double longitude; + @Schema(description = "국가 코드 (ISO 3166-1 alpha-2)", example = "KR") private String country; + @Schema(description = "카테고리", example = "한식 음식점") + private String category; + + @Schema(description = "전화번호", example = "02-776-5348") + private String phone; + + @Schema(description = "영업시간", example = "매일 10:30 - 21:30") + private String openingHours; + @Schema(description = "장소 설명", example = "칼국수와 만두로 유명한 맛집") private String description; @@ -102,5 +119,8 @@ public static class PlaceInfo { @Schema(description = "언어 코드 (ISO 639-1)", example = "ko", allowableValues = {"ko", "en", "ja", "zh"}) private String language = "ko"; + + @Schema(description = "키워드 목록", example = "[\"#명동맛집\", \"#칼국수\", \"#만두\"]") + private List keywords; } } diff --git a/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/dto/FirebaseUserInfo.java b/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/dto/FirebaseUserInfo.java new file mode 100644 index 0000000..13f1cfb --- /dev/null +++ b/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/dto/FirebaseUserInfo.java @@ -0,0 +1,30 @@ +package kr.suhsaechan.mapsy.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Schema(description = "Firebase 토큰에서 추출한 사용자 정보") +public class FirebaseUserInfo { + + @Schema(description = "Firebase UID", example = "cXcpyzYSX....") + private String uid; + + @Schema(description = "이메일", example = "user@example.com") + private String email; + + @Schema(description = "Firebase displayName (사용 안 함)", example = "엘리페어") + private String name; + + @Schema(description = "프로필 이미지 URL", example = "https://lh3.googleusercontent.com/a/...") + private String profileImageUrl; + + @Schema(description = "로그인 제공자", example = "google.com") + private String signInProvider; +} diff --git a/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/dto/SignInRequest.java b/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/dto/SignInRequest.java index 1129f5a..d78ddc0 100644 --- a/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/dto/SignInRequest.java +++ b/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/dto/SignInRequest.java @@ -1,9 +1,7 @@ package kr.suhsaechan.mapsy.auth.dto; import kr.suhsaechan.mapsy.common.constant.DeviceType; -import kr.suhsaechan.mapsy.common.constant.SocialPlatform; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import lombok.*; @@ -15,20 +13,10 @@ @NoArgsConstructor public class SignInRequest { - @Schema(description = "로그인 플랫폼 (KAKAO, GOOGLE 등)", example = "KAKAO") - private SocialPlatform socialPlatform; - - @NotBlank(message = "이메일은 필수입니다.") - @Email(message = "올바른 이메일 형식이 아닙니다.") - @Schema(description = "소셜 로그인 후 반환된 이메일", example = "user@example.com") - private String email; - - @NotBlank(message = "이름은 필수입니다.") - @Schema(description = "소셜 로그인 후 반환된 닉네임", example = "홍길동") - private String name; - - @Schema(description = "소셜 로그인 후 반환된 프로필 URL", example = "https://example.com/profile.jpg") - private String profileUrl; + @NotBlank(message = "Firebase ID Token은 필수입니다.") + @Schema(description = "Firebase ID Token (클라이언트에서 Firebase 인증 후 전달)", + example = "eyJhbGciOiJSUzI1NiIsImtpZCI6...") + private String firebaseIdToken; @Schema(description = "FCM 푸시 알림 토큰 (선택)", example = "dXQzM2k1N2RkZjM0OGE3YjczZGY5...") private String fcmToken; diff --git a/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/service/AuthService.java b/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/service/AuthService.java index 6cd551d..cec49d3 100644 --- a/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/service/AuthService.java +++ b/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/service/AuthService.java @@ -1,8 +1,10 @@ package kr.suhsaechan.mapsy.auth.service; +import com.google.firebase.auth.FirebaseToken; import kr.suhsaechan.mapsy.auth.dto.AuthRequest; import kr.suhsaechan.mapsy.auth.dto.AuthResponse; import kr.suhsaechan.mapsy.auth.dto.CustomUserDetails; +import kr.suhsaechan.mapsy.auth.dto.FirebaseUserInfo; import kr.suhsaechan.mapsy.auth.dto.ReissueRequest; import kr.suhsaechan.mapsy.auth.dto.ReissueResponse; import kr.suhsaechan.mapsy.auth.dto.SignInRequest; @@ -14,14 +16,12 @@ import kr.suhsaechan.mapsy.member.constant.OnboardingStep; import kr.suhsaechan.mapsy.member.entity.FcmToken; import kr.suhsaechan.mapsy.member.entity.Member; -import kr.suhsaechan.mapsy.member.entity.MemberInterest; import kr.suhsaechan.mapsy.member.repository.FcmTokenRepository; -import kr.suhsaechan.mapsy.member.repository.MemberInterestRepository; import kr.suhsaechan.mapsy.member.repository.MemberRepository; import kr.suhsaechan.mapsy.member.service.MemberService; +import kr.suhsaechan.mapsy.member.service.NicknameService; import io.jsonwebtoken.ExpiredJwtException; import java.time.LocalDateTime; -import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -43,37 +43,48 @@ public class AuthService { private final MemberService memberService; private final JwtUtil jwtUtil; private final RedisTemplate redisTemplate; - private final MemberInterestRepository memberInterestRepository; private final FcmTokenRepository fcmTokenRepository; + private final FirebaseTokenService firebaseTokenService; + private final NicknameService nicknameService; /** - * 로그인 로직 클라이언트로부터 플랫폼, 닉네임, 프로필url, 이메일을 입력받아 JWT를 발급합니다. + * Firebase OAuth 로그인 로직 + * 클라이언트로부터 Firebase ID Token을 받아 검증 후 JWT를 발급합니다. */ @Transactional public SignInResponse signIn(SignInRequest request) { - // FCM 토큰 입력값 검증 + // 1단계: FCM 토큰 요청 검증 validateFcmTokenRequest(request); - // 요청 값으로부터 사용자 정보 획득 - String email = request.getEmail(); - String name = request.getName(); + // 2단계: Firebase ID Token 검증 + FirebaseToken decodedToken = firebaseTokenService.verifyIdToken(request.getFirebaseIdToken()); - //회원 조회 + // 3단계: 토큰에서 사용자 정보 추출 + FirebaseUserInfo userInfo = firebaseTokenService.extractUserInfo(decodedToken); + String email = userInfo.getEmail(); + String profileImageUrl = userInfo.getProfileImageUrl(); + + // 4단계: 회원 조회 또는 신규 생성 Member member = memberRepository.findByEmail(email) .orElseGet(() -> { - // 신규 회원 생성 시 기본값 자동 설정 + // 신규 회원 생성 시 랜덤 닉네임 자동 생성 + String randomNickname = nicknameService.generateUniqueNickname(); + Member newMember = Member.builder() .email(email) - .name("name") + .name(randomNickname) // 자동 생성된 랜덤 닉네임 + .profileImageUrl(profileImageUrl) // Firebase 프로필 이미지 .build(); + memberRepository.save(newMember); - log.debug("신규 회원 가입: {}", email); + log.info("신규 회원 가입 - email={}, nickname={}", email, randomNickname); return newMember; }); + // 5단계: 첫 로그인 여부 확인 boolean isFirstLogin = member.getOnboardingStatus() == MemberOnboardingStatus.NOT_STARTED; - //온보딩 상태 갱신 (NOT_STARTED → IN_PROGRESS) + // 6단계: 온보딩 상태 갱신 if (isFirstLogin) { member.setOnboardingStatus(MemberOnboardingStatus.IN_PROGRESS); memberRepository.save(member); @@ -82,33 +93,32 @@ public SignInResponse signIn(SignInRequest request) { log.debug("기존 회원 로그인: {}", email); } - // FCM 토큰 저장/업데이트 + // 7단계: FCM 토큰 저장/업데이트 saveFcmToken(member, request); - // JWT 토큰 생성 + // 8단계: JWT 토큰 생성 CustomUserDetails customUserDetails = new CustomUserDetails(member); String accessToken = jwtUtil.createAccessToken(customUserDetails); String refreshToken = jwtUtil.createRefreshToken(customUserDetails); log.debug("로그인 성공: email={}, accessToken={}, refreshToken={}", email, accessToken, refreshToken); - // RefreshToken -> Redis 저장 (키: "RT:{memberId}") + // 9단계: RefreshToken -> Redis 저장 (키: "RT:{memberId}") redisTemplate.opsForValue().set( REFRESH_KEY_PREFIX + customUserDetails.getMemberId(), refreshToken, jwtUtil.getRefreshExpirationTime(), TimeUnit.MILLISECONDS); - //온보딩 필요 여부 확인 + // 10단계: 온보딩 필요 여부 확인 boolean requiresOnboarding = (member.getOnboardingStatus() != MemberOnboardingStatus.COMPLETED); - // 온보딩 단계 계산 및 저장 - // COMPLETED 상태면 계산하지 않고 캐시된 값 사용 + // 11단계: 온보딩 단계 계산 및 저장 String onboardingStep; if (member.getOnboardingStatus() == MemberOnboardingStatus.COMPLETED) { // COMPLETED 상태면 캐시된 값 사용 (없으면 COMPLETED 반환) - onboardingStep = member.getOnboardingStep() != null - ? member.getOnboardingStep().name() + onboardingStep = member.getOnboardingStep() != null + ? member.getOnboardingStep().name() : OnboardingStep.COMPLETED.name(); } else { // IN_PROGRESS 또는 NOT_STARTED 상태면 계산 후 저장 @@ -116,7 +126,7 @@ public SignInResponse signIn(SignInRequest request) { onboardingStep = step.name(); } - //응답 생성 + // 12단계: 응답 생성 return SignInResponse.builder() .accessToken(accessToken) .refreshToken(refreshToken) @@ -230,10 +240,6 @@ public void withdrawMember(UUID memberId, String accessToken) { // 탈퇴 처리 (email, name에 타임스탬프 자동 추가) String timestamp = member.withdraw(memberId.toString()); - // 회원 관심사 소프트삭제 - List memberInterests = memberInterestRepository.findByMemberId(memberId); - memberInterests.forEach(interest -> interest.softDelete(memberId.toString())); - // FCM 토큰 삭제 (하드삭제 - 소프트삭제 시 FK 제약조건 위반 방지) fcmTokenRepository.deleteByMember(member); log.info("[Auth] FCM 토큰 삭제 완료 - memberId={}", memberId); diff --git a/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/service/FirebaseTokenService.java b/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/service/FirebaseTokenService.java new file mode 100644 index 0000000..477cc23 --- /dev/null +++ b/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/service/FirebaseTokenService.java @@ -0,0 +1,79 @@ +package kr.suhsaechan.mapsy.auth.service; + +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.FirebaseToken; +import kr.suhsaechan.mapsy.auth.dto.FirebaseUserInfo; +import kr.suhsaechan.mapsy.common.exception.CustomException; +import kr.suhsaechan.mapsy.common.exception.constant.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@Slf4j +@RequiredArgsConstructor +public class FirebaseTokenService { + + /** + * Firebase ID Token 검증 + * + * @param idToken 클라이언트가 제공한 Firebase ID Token + * @return 검증된 FirebaseToken 객체 + * @throws CustomException 토큰 검증 실패 시 + */ + public FirebaseToken verifyIdToken(String idToken) { + try { + FirebaseAuth firebaseAuth = FirebaseAuth.getInstance(); + FirebaseToken decodedToken = firebaseAuth.verifyIdToken(idToken); + + log.info("Firebase 토큰 검증 성공 - uid={}, email={}", + decodedToken.getUid(), decodedToken.getEmail()); + + return decodedToken; + } catch (FirebaseAuthException e) { + log.error("Firebase 토큰 검증 실패 - error={}", e.getMessage()); + + // 에러 코드에 따른 처리 + String authErrorCode = e.getAuthErrorCode().name(); + + if ("EXPIRED_ID_TOKEN".equals(authErrorCode)) { + throw new CustomException(ErrorCode.FIREBASE_TOKEN_EXPIRED); + } else if ("INVALID_ID_TOKEN".equals(authErrorCode) || "INVALID_ARGUMENT".equals(authErrorCode)) { + throw new CustomException(ErrorCode.FIREBASE_TOKEN_INVALID); + } else { + throw new CustomException(ErrorCode.FIREBASE_TOKEN_VERIFICATION_FAILED); + } + } + } + + /** + * FirebaseToken에서 사용자 정보 추출 + * + * @param decodedToken 검증된 FirebaseToken + * @return UserInfo DTO + */ + public FirebaseUserInfo extractUserInfo(FirebaseToken decodedToken) { + String email = decodedToken.getEmail(); + String name = decodedToken.getName(); // Firebase에서 제공하는 displayName + String profileImageUrl = decodedToken.getPicture(); + + // sign_in_provider 추출 (google.com, kakao.com 등) + Map claims = decodedToken.getClaims(); + Map firebase = (Map) claims.get("firebase"); + String signInProvider = firebase != null ? (String) firebase.get("sign_in_provider") : null; + + log.debug("Firebase 사용자 정보 추출 - email={}, name={}, provider={}", + email, name, signInProvider); + + return FirebaseUserInfo.builder() + .email(email) + .name(name) + .profileImageUrl(profileImageUrl) + .signInProvider(signInProvider) + .uid(decodedToken.getUid()) + .build(); + } +} diff --git a/MS-Common/build.gradle b/MS-Common/build.gradle index a8e7013..16486a5 100644 --- a/MS-Common/build.gradle +++ b/MS-Common/build.gradle @@ -69,4 +69,7 @@ dependencies { // Firebase Admin SDK api 'com.google.firebase:firebase-admin:9.3.0' + + // Suh Random Engine (닉네임 생성) + api 'me.suhsaechan:suh-random-engine:1.1.0' } diff --git a/MS-Common/src/main/java/kr/suhsaechan/mapsy/common/exception/constant/ErrorCode.java b/MS-Common/src/main/java/kr/suhsaechan/mapsy/common/exception/constant/ErrorCode.java index 915609b..94fdff9 100644 --- a/MS-Common/src/main/java/kr/suhsaechan/mapsy/common/exception/constant/ErrorCode.java +++ b/MS-Common/src/main/java/kr/suhsaechan/mapsy/common/exception/constant/ErrorCode.java @@ -48,6 +48,13 @@ public enum ErrorCode { TOKEN_BLACKLISTED(HttpStatus.UNAUTHORIZED, "블랙리스트 처리된 토큰입니다."), + // Firebase 토큰 검증 관련 + FIREBASE_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "Firebase 토큰이 만료되었습니다."), + + FIREBASE_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "유효하지 않은 Firebase 토큰입니다."), + + FIREBASE_TOKEN_VERIFICATION_FAILED(HttpStatus.UNAUTHORIZED, "Firebase 토큰 검증에 실패했습니다."), + // Member MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다."), @@ -105,7 +112,9 @@ public enum ErrorCode { PLACE_ALREADY_SAVED(HttpStatus.BAD_REQUEST, "이미 저장된 장소입니다."), - CANNOT_DELETE_SAVED_PLACE(HttpStatus.BAD_REQUEST, "임시 저장된 장소만 삭제할 수 있습니다."); + CANNOT_DELETE_SAVED_PLACE(HttpStatus.BAD_REQUEST, "임시 저장된 장소만 삭제할 수 있습니다."), + + INVALID_RATING(HttpStatus.BAD_REQUEST, "별점은 1-5 사이의 값이어야 합니다."); private final HttpStatus status; private final String message; diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/constant/InterestCategory.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/constant/InterestCategory.java deleted file mode 100644 index 3566107..0000000 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/constant/InterestCategory.java +++ /dev/null @@ -1,43 +0,0 @@ -package kr.suhsaechan.mapsy.member.constant; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonValue; -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.util.Arrays; - -@Getter -@AllArgsConstructor -public enum InterestCategory { - - FOOD("맛집/푸드"), - CAFE_DESSERT("카페/디저트"), - LOCAL_MARKET("로컬시장/골목"), - NATURE_OUTDOOR("자연/아웃도어"), - URBAN_PHOTOSPOTS("도시산책/포토스팟"), - CULTURE_ART("문화/예술"), - HISTORY_ARCHITECTURE("역사/건축/종교"), - EXPERIENCE_CLASS("체험/클래스"), - SHOPPING_FASHION("쇼핑/패션"), - NIGHTLIFE("나이트라이프/음주"), - WELLNESS("웰니스/휴식"), - FAMILY_KIDS("가족/아이동반"), - KPOP_CULTURE("K-POP·K-컬처"), - DRIVE_SUBURBS("드라이브/근교"); - - private final String displayName; - - @JsonValue - public String toValue() { - return this.name(); - } - - @JsonCreator - public static InterestCategory fromValue(String value) { - return Arrays.stream(values()) - .filter(category -> category.name().equals(value)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Invalid InterestCategory: " + value)); - } -} diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/constant/OnboardingStep.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/constant/OnboardingStep.java index 7227e32..45f566f 100644 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/constant/OnboardingStep.java +++ b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/constant/OnboardingStep.java @@ -7,10 +7,8 @@ @AllArgsConstructor public enum OnboardingStep { TERMS("서비스 이용약관 및 개인정보처리방침 동의"), - NAME("이름 설정"), BIRTH_DATE("생년월일 설정"), GENDER("성별 설정"), - INTERESTS("관심사 설정"), COMPLETED("온보딩 완료"); private final String description; diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/InterestDto.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/InterestDto.java deleted file mode 100644 index 9bc48cc..0000000 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/InterestDto.java +++ /dev/null @@ -1,14 +0,0 @@ -package kr.suhsaechan.mapsy.member.dto; - -import java.util.UUID; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.Setter; - -@Setter -@Getter -@AllArgsConstructor -public class InterestDto { - private UUID id; - private String name; -} \ No newline at end of file diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/ProfileUpdateRequest.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/ProfileUpdateRequest.java index 250d060..2332302 100644 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/ProfileUpdateRequest.java +++ b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/ProfileUpdateRequest.java @@ -2,8 +2,6 @@ import kr.suhsaechan.mapsy.member.constant.MemberGender; import java.time.LocalDate; -import java.util.List; -import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -18,6 +16,5 @@ public class ProfileUpdateRequest { private String name; private MemberGender gender; // MALE, FEMALE, NONE private LocalDate birthDate; - private List interestIds; // 관심사 목록 } diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/interest/response/GetAllInterestsResponse.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/interest/response/GetAllInterestsResponse.java deleted file mode 100644 index 1d58511..0000000 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/interest/response/GetAllInterestsResponse.java +++ /dev/null @@ -1,87 +0,0 @@ -package kr.suhsaechan.mapsy.member.dto.interest.response; - -import kr.suhsaechan.mapsy.member.constant.InterestCategory; -import kr.suhsaechan.mapsy.member.entity.Interest; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.stream.Collectors; - -/** - * 전체 관심사 목록 조회 응답 DTO - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "전체 관심사 목록 조회 응답") -public class GetAllInterestsResponse { - - @Schema(description = "카테고리별 그룹핑된 관심사 목록") - private List categories; - - /** - * 카테고리 그룹 - */ - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @Schema(description = "카테고리 그룹") - public static class CategoryGroup { - - @Schema(description = "카테고리 코드", example = "FOOD") - private String category; - - @Schema(description = "카테고리 표시 이름", example = "맛집/푸드") - private String displayName; - - @Schema(description = "관심사 목록") - private List interests; - } - - /** - * 관심사 항목 - */ - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @Schema(description = "관심사 항목") - public static class InterestItem { - - @Schema(description = "관심사 ID", example = "550e8400-e29b-41d4-a716-446655440000") - private UUID id; - - @Schema(description = "관심사 이름", example = "한식") - private String name; - } - - /** - * Entity List를 Response로 변환 - */ - public static GetAllInterestsResponse from(Map> groupedInterests) { - List categoryGroups = groupedInterests.entrySet().stream() - .map(entry -> CategoryGroup.builder() - .category(entry.getKey().name()) - .displayName(entry.getKey().getDisplayName()) - .interests(entry.getValue().stream() - .map(interest -> InterestItem.builder() - .id(interest.getId()) - .name(interest.getName()) - .build()) - .toList()) - .build()) - .toList(); - - return GetAllInterestsResponse.builder() - .categories(categoryGroups) - .build(); - } -} diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/interest/response/GetInterestByIdResponse.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/interest/response/GetInterestByIdResponse.java deleted file mode 100644 index 4400ced..0000000 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/interest/response/GetInterestByIdResponse.java +++ /dev/null @@ -1,45 +0,0 @@ -package kr.suhsaechan.mapsy.member.dto.interest.response; - -import kr.suhsaechan.mapsy.member.entity.Interest; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.UUID; - -/** - * 관심사 상세 조회 응답 DTO - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "관심사 상세 조회 응답") -public class GetInterestByIdResponse { - - @Schema(description = "관심사 ID", example = "550e8400-e29b-41d4-a716-446655440000") - private UUID id; - - @Schema(description = "카테고리 코드", example = "FOOD") - private String category; - - @Schema(description = "카테고리 표시 이름", example = "맛집/푸드") - private String categoryDisplayName; - - @Schema(description = "관심사 이름", example = "한식") - private String name; - - /** - * Entity를 Response로 변환 - */ - public static GetInterestByIdResponse from(Interest interest) { - return GetInterestByIdResponse.builder() - .id(interest.getId()) - .category(interest.getCategory().name()) - .categoryDisplayName(interest.getCategory().getDisplayName()) - .name(interest.getName()) - .build(); - } -} diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/interest/response/GetInterestsByCategoryResponse.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/interest/response/GetInterestsByCategoryResponse.java deleted file mode 100644 index cf2a948..0000000 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/interest/response/GetInterestsByCategoryResponse.java +++ /dev/null @@ -1,66 +0,0 @@ -package kr.suhsaechan.mapsy.member.dto.interest.response; - -import kr.suhsaechan.mapsy.member.entity.Interest; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; -import java.util.UUID; - -/** - * 특정 카테고리 관심사 조회 응답 DTO - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "특정 카테고리 관심사 조회 응답") -public class GetInterestsByCategoryResponse { - - @Schema(description = "관심사 목록") - private List interests; - - /** - * 관심사 항목 - */ - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @Schema(description = "관심사 항목") - public static class InterestItem { - - @Schema(description = "관심사 ID", example = "550e8400-e29b-41d4-a716-446655440000") - private UUID id; - - @Schema(description = "카테고리 코드", example = "FOOD") - private String category; - - @Schema(description = "카테고리 표시 이름", example = "맛집/푸드") - private String categoryDisplayName; - - @Schema(description = "관심사 이름", example = "한식") - private String name; - } - - /** - * Entity List를 Response로 변환 - */ - public static GetInterestsByCategoryResponse from(List interests) { - List interestItems = interests.stream() - .map(interest -> InterestItem.builder() - .id(interest.getId()) - .category(interest.getCategory().name()) - .categoryDisplayName(interest.getCategory().getDisplayName()) - .name(interest.getName()) - .build()) - .toList(); - - return GetInterestsByCategoryResponse.builder() - .interests(interestItems) - .build(); - } -} diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/onboarding/request/UpdateInterestsRequest.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/onboarding/request/UpdateInterestsRequest.java deleted file mode 100644 index 4ff051d..0000000 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/onboarding/request/UpdateInterestsRequest.java +++ /dev/null @@ -1,31 +0,0 @@ -package kr.suhsaechan.mapsy.member.dto.onboarding.request; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.util.List; -import java.util.UUID; - -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class UpdateInterestsRequest { - @Schema(hidden = true) - @JsonIgnore - private UUID memberId; - - @NotNull(message = "관심사 목록은 필수입니다.") - @Size(min = 3, message = "관심사는 최소 3개 이상 선택해야 합니다.") - @Schema(description = "관심사 ID 목록", example = "[\"id1\", \"id2\", \"id3\"]", required = true) - private List interestIds; -} - diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/onboarding/request/UpdateNameRequest.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/onboarding/request/UpdateNameRequest.java deleted file mode 100644 index 5837b8e..0000000 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/dto/onboarding/request/UpdateNameRequest.java +++ /dev/null @@ -1,29 +0,0 @@ -package kr.suhsaechan.mapsy.member.dto.onboarding.request; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.util.UUID; - -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class UpdateNameRequest { - @Schema(hidden = true) - @JsonIgnore - private UUID memberId; - - @NotBlank(message = "이름은 필수입니다.") - @Schema(description = "이름", example = "홍길동", required = true) - private String name; -} - diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/entity/Interest.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/entity/Interest.java deleted file mode 100644 index 5d4972b..0000000 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/entity/Interest.java +++ /dev/null @@ -1,29 +0,0 @@ -package kr.suhsaechan.mapsy.member.entity; - -import kr.suhsaechan.mapsy.common.entity.BaseEntity; -import kr.suhsaechan.mapsy.member.constant.InterestCategory; -import jakarta.persistence.*; -import lombok.*; - -import java.util.UUID; - -@Entity -@Builder -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Interest extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.UUID) - @Column(updatable = false, nullable = false) - private UUID id; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private InterestCategory category; - - @Column(length = 100, nullable = false) - private String name; - -} diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/entity/MemberInterest.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/entity/MemberInterest.java deleted file mode 100644 index 1109c89..0000000 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/entity/MemberInterest.java +++ /dev/null @@ -1,30 +0,0 @@ -package kr.suhsaechan.mapsy.member.entity; - -import kr.suhsaechan.mapsy.common.entity.SoftDeletableBaseEntity; -import jakarta.persistence.*; -import lombok.*; - -import java.util.UUID; - -@Entity -@Builder -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class MemberInterest extends SoftDeletableBaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.UUID) - @Column(updatable = false, nullable = false) - private UUID id; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - private Member member; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - private Interest interest; - - public UUID getInterestId() { - return this.interest != null ? this.interest.getId() : null; // interest가 null이 아닐 경우 interest의 ID 반환 - } -} diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/repository/InterestRepository.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/repository/InterestRepository.java deleted file mode 100644 index e2484f0..0000000 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/repository/InterestRepository.java +++ /dev/null @@ -1,34 +0,0 @@ -package kr.suhsaechan.mapsy.member.repository; - -import kr.suhsaechan.mapsy.member.constant.InterestCategory; -import kr.suhsaechan.mapsy.member.entity.Interest; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - -import java.util.List; -import java.util.UUID; - -public interface InterestRepository extends JpaRepository { - - /** - * 카테고리별 관심사 조회 - */ - List findByCategory(InterestCategory category); - - /** - * 전체 관심사 조회 (카테고리 및 이름 순서로 정렬) - */ - @Query("SELECT i FROM Interest i ORDER BY i.category ASC, i.name ASC") - List findAllOrderByCategoryAndName(); - - /** - * 카테고리와 이름으로 존재 여부 확인 - */ - boolean existsByCategoryAndName(InterestCategory category, String name); - - /** - * 이름으로 관심사 조회 - */ - Optional findByName(String name); -} diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/repository/MemberInterestRepository.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/repository/MemberInterestRepository.java deleted file mode 100644 index 8c4447d..0000000 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/repository/MemberInterestRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package kr.suhsaechan.mapsy.member.repository; - -import kr.suhsaechan.mapsy.member.entity.MemberInterest; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; -import java.util.UUID; - -public interface MemberInterestRepository extends JpaRepository { - - List findByMemberId(UUID memberId); - - void deleteByMemberId(UUID memberId); -} - diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/service/InterestService.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/service/InterestService.java deleted file mode 100644 index e729de7..0000000 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/service/InterestService.java +++ /dev/null @@ -1,88 +0,0 @@ -package kr.suhsaechan.mapsy.member.service; - -import kr.suhsaechan.mapsy.common.exception.CustomException; -import kr.suhsaechan.mapsy.common.exception.ErrorCodeBuilder; -import kr.suhsaechan.mapsy.common.exception.constant.ErrorMessageTemplate.BusinessStatus; -import kr.suhsaechan.mapsy.common.exception.constant.ErrorMessageTemplate.Subject; -import kr.suhsaechan.mapsy.member.constant.InterestCategory; -import kr.suhsaechan.mapsy.member.dto.interest.response.GetAllInterestsResponse; -import kr.suhsaechan.mapsy.member.dto.interest.response.GetInterestByIdResponse; -import kr.suhsaechan.mapsy.member.dto.interest.response.GetInterestsByCategoryResponse; -import kr.suhsaechan.mapsy.member.entity.Interest; -import kr.suhsaechan.mapsy.member.repository.InterestRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.stream.Collectors; - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class InterestService { - - private final InterestRepository interestRepository; - - /** - * 전체 관심사 목록 조회 (대분류별 그룹핑) - * Redis 캐싱 적용 (TTL: 1시간) - */ - @Cacheable(value = "interests", key = "'all'") - public GetAllInterestsResponse getAllInterestsGroupedByCategory() { - log.info("Fetching all interests grouped by category"); - - List interests = interestRepository.findAllOrderByCategoryAndName(); - - // 카테고리별 그룹핑 - Map> groupedByCategory = interests.stream() - .collect(Collectors.groupingBy(Interest::getCategory)); - - // Response 변환 - return GetAllInterestsResponse.from(groupedByCategory); - } - - /** - * 특정 카테고리 관심사 조회 - */ - @Cacheable(value = "interests", key = "#category.name()") - public GetInterestsByCategoryResponse getInterestsByCategory(InterestCategory category) { - log.info("Fetching interests by category: {}", category); - - List interests = interestRepository.findByCategory(category); - - return GetInterestsByCategoryResponse.from(interests); - } - - /** - * 관심사 ID로 조회 - */ - public GetInterestByIdResponse getInterestById(UUID interestId) { - Interest interest = interestRepository.findById(interestId) - .orElseThrow(() -> new CustomException( - ErrorCodeBuilder.businessStatus(Subject.INTEREST, BusinessStatus.NOT_FOUND, HttpStatus.NOT_FOUND) - )); - - return GetInterestByIdResponse.from(interest); - } - - /** - * 관심사 name으로 조회 - */ - @Cacheable(value = "interests", key = "'name:' + #interestName") - public GetInterestByIdResponse getInterestByName(String interestName) { - Interest interest = interestRepository.findByName(interestName) - .orElseThrow(() -> new CustomException( - ErrorCodeBuilder.businessStatus(Subject.INTEREST, BusinessStatus.NOT_FOUND, HttpStatus.NOT_FOUND) - )); - - return GetInterestByIdResponse.from(interest); - } -} diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/service/MemberService.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/service/MemberService.java index 4a587e6..8d9bf55 100644 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/service/MemberService.java +++ b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/service/MemberService.java @@ -9,25 +9,16 @@ import kr.suhsaechan.mapsy.member.constant.MemberOnboardingStatus; import kr.suhsaechan.mapsy.member.constant.OnboardingStep; import kr.suhsaechan.mapsy.member.dto.CheckNameResponse; -import kr.suhsaechan.mapsy.member.dto.InterestDto; import kr.suhsaechan.mapsy.member.dto.MemberDto; import kr.suhsaechan.mapsy.member.dto.ProfileUpdateRequest; import kr.suhsaechan.mapsy.member.dto.UpdateServiceAgreementTermsRequest; import kr.suhsaechan.mapsy.member.dto.UpdateServiceAgreementTermsResponse; import kr.suhsaechan.mapsy.member.dto.onboarding.request.UpdateBirthDateRequest; import kr.suhsaechan.mapsy.member.dto.onboarding.request.UpdateGenderRequest; -import kr.suhsaechan.mapsy.member.dto.onboarding.request.UpdateInterestsRequest; -import kr.suhsaechan.mapsy.member.dto.onboarding.request.UpdateNameRequest; import kr.suhsaechan.mapsy.member.dto.onboarding.response.OnboardingResponse; -import kr.suhsaechan.mapsy.member.entity.Interest; import kr.suhsaechan.mapsy.member.entity.Member; -import kr.suhsaechan.mapsy.member.entity.MemberInterest; -import kr.suhsaechan.mapsy.member.repository.InterestRepository; -import kr.suhsaechan.mapsy.member.repository.MemberInterestRepository; import kr.suhsaechan.mapsy.member.repository.MemberRepository; import java.time.LocalDate; -import java.util.HashSet; -import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -45,8 +36,6 @@ public class MemberService { private final MemberRepository memberRepository; - private final MemberInterestRepository memberInterestRepository; - private final InterestRepository interestRepository; // 만 14세 이상만 가입 가능 LocalDate today = LocalDate.now(); @@ -93,11 +82,6 @@ public OnboardingStep calculateOnboardingStep(Member member) { return OnboardingStep.TERMS; } - // 이름 체크 (기본값이거나 빈 문자열인 경우) - if (member.getName() == null || member.getName().trim().isEmpty() || member.getName().equals("name")) { - return OnboardingStep.NAME; - } - // 생년월일 체크 if (member.getBirthDate() == null) { return OnboardingStep.BIRTH_DATE; @@ -108,12 +92,6 @@ public OnboardingStep calculateOnboardingStep(Member member) { return OnboardingStep.GENDER; } - // 관심사 체크 - List interests = memberInterestRepository.findByMemberId(member.getId()); - if (interests == null || interests.isEmpty()) { - return OnboardingStep.INTERESTS; - } - // 모든 단계 완료 return OnboardingStep.COMPLETED; } @@ -185,56 +163,6 @@ public UpdateServiceAgreementTermsResponse agreeTerms(UpdateServiceAgreementTerm .build(); } - /** - * 이름 업데이트 - * - * @param request 이름 업데이트 요청 - * @return 온보딩 응답 (현재 단계, 상태, 회원 정보) - */ - @Transactional - public OnboardingResponse updateName(UpdateNameRequest request) { - UUID memberId = request.getMemberId(); - - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); - - if(member.getOnboardingStep() != OnboardingStep.NAME && member.getOnboardingStep() != OnboardingStep.COMPLETED) { - log.warn("[Onboarding] 현재 온보딩 단계가 닉네임 설정이 아님 - memberId={}, currentStep={}", - memberId, member.getOnboardingStep()); - throw new CustomException(ErrorCode.INVALID_ONBOARDING_STEP); - } - - if (request.getName().length() < 2 || request.getName().length() > 50) { - log.warn("[Onboarding] 닉네임은 2자 이상 50자 이하 - memberName={}", request.getName()); - throw new CustomException(ErrorCode.INVALID_NAME_LENGTH); - } - - boolean isDuplicateName = memberRepository.existsByNameAndIdNot(request.getName(), memberId); - if (isDuplicateName) { - log.warn("[Onboarding] 이미 사용 중인 닉네임 - memberName={}", request.getName()); - throw new CustomException(ErrorCode.NAME_ALREADY_EXISTS); - } - - member.setName(request.getName()); - - // 온보딩 상태를 IN_PROGRESS로 변경 - if (member.getOnboardingStatus() == MemberOnboardingStatus.NOT_STARTED) { - member.setOnboardingStatus(MemberOnboardingStatus.IN_PROGRESS); - } - - // 온보딩 단계 계산 및 저장 - OnboardingStep currentStep = calculateAndSaveOnboardingStep(member); - - log.info("[Onboarding] 이름 업데이트 완료 - memberId={}, name={}, currentStep={}", - memberId, request.getName(), currentStep); - - return OnboardingResponse.builder() - .currentStep(currentStep) - .onboardingStatus(member.getOnboardingStatus().name()) - .member(MemberDto.entityToDto(member)) - .build(); - } - /** * 생년월일 업데이트 * @@ -331,81 +259,6 @@ public OnboardingResponse updateGender(UpdateGenderRequest request) { .build(); } - /** - * 관심사 업데이트 (전체 교체) - * - * @param request 관심사 업데이트 요청 - * @return 온보딩 응답 (현재 단계, 상태, 회원 정보) - */ - @Transactional - public OnboardingResponse updateInterests(UpdateInterestsRequest request) { - UUID memberId = request.getMemberId(); - - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); - - if(member.getOnboardingStep() != OnboardingStep.INTERESTS && member.getOnboardingStep() != OnboardingStep.COMPLETED) { - log.warn("[Onboarding] 현재 온보딩 단계가 관심사 설정이 아님 - memberId={}, currentStep={}", - memberId, member.getOnboardingStep()); - throw new CustomException(ErrorCode.INVALID_ONBOARDING_STEP); - } - - List interestIds = request.getInterestIds(); - Set uniqueInterestIds = new HashSet<>(interestIds); - if (uniqueInterestIds.size() != interestIds.size()) { - log.warn("[Onboarding] 중복된 관심사 ID 포함 - memberId={}, interestIds={}", - memberId, interestIds); - throw new CustomException(ErrorCode.DUPLICATE_INTEREST_IDS); - } - - // 관심사 ID 유효성 검증 - List interests = interestRepository.findAllById(request.getInterestIds()); - if (interests.size() != request.getInterestIds().size()) { - log.warn("[Onboarding] 유효하지 않은 관심사 ID 포함 - memberId={}", memberId); - throw new CustomException(ErrorCode.INTEREST_NOT_FOUND); - } - - // 관심사 최소 3개 이상 선택 검증 - if (request.getInterestIds().size() < 3) { - log.warn("[Onboarding] 최소 3개 이상의 관심사 선택 필요 - memberId={}, selectedCount={}", - memberId, request.getInterestIds().size()); - throw new CustomException(ErrorCode.INSUFFICIENT_INTEREST_SELECTION); - } - - // 기존 관심사 삭제 - memberInterestRepository.deleteByMemberId(memberId); - - // 새 관심사 추가 - List memberInterests = request.getInterestIds().stream() - .map(interestId -> MemberInterest.builder() - .member(member) - .interest(interests.stream() - .filter(i -> i.getId().equals(interestId)) - .findFirst() - .orElseThrow(() -> new CustomException(ErrorCode.INTEREST_NOT_FOUND))) - .build()) - .collect(Collectors.toList()); - - memberInterestRepository.saveAll(memberInterests); - - // 온보딩 상태를 IN_PROGRESS로 변경 - if (member.getOnboardingStatus() == MemberOnboardingStatus.NOT_STARTED) { - member.setOnboardingStatus(MemberOnboardingStatus.IN_PROGRESS); - } - - // 온보딩 단계 계산 및 저장 - OnboardingStep currentStep = calculateAndSaveOnboardingStep(member); - - log.info("[Onboarding] 관심사 업데이트 완료 - memberId={}, interestCount={}, currentStep={}", - memberId, memberInterests.size(), currentStep); - - return OnboardingResponse.builder() - .currentStep(currentStep) - .onboardingStatus(member.getOnboardingStatus().name()) - .member(MemberDto.entityToDto(member)) - .build(); - } - @Transactional public MemberDto updateProfile(UUID memberId, ProfileUpdateRequest request) { // 회원 존재 여부 확인 @@ -452,16 +305,8 @@ public MemberDto updateProfile(UUID memberId, ProfileUpdateRequest request) { member.setGender(request.getGender()); member.setBirthDate(request.getBirthDate()); - updateInterests( - UpdateInterestsRequest.builder() - .memberId(memberId) - .interestIds(request.getInterestIds()) - .build() - ); - - // updateInterests가 이미 member를 저장하므로, 최신 상태를 다시 조회하여 반환 - member = memberRepository.findById(memberId) - .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + // 변경사항 저장 + memberRepository.save(member); log.info("[Onboarding] 프로필 업데이트 완료 - memberId={}, name={}, gender={}, birthDate={}", memberId, member.getName(), member.getGender(), member.getBirthDate()); @@ -527,42 +372,6 @@ public MemberDto getMemberByEmail(String email) { return MemberDto.entityToDto(entity); } - /** - * 회원 ID로 관심사 조회 - * - * @param memberId 회원 ID - * @return 회원 관심사 목록 - */ - public List getInterestsByMemberId(UUID memberId) { - // 멤버가 존재하는지 확인 - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); - - // 탈퇴한 회원인지 확인 - if (member.isDeleted()) { - log.warn("[Member] 탈퇴한 회원의 관심사 조회 시도 - memberId={}", memberId); - throw new CustomException(ErrorCode.MEMBER_ALREADY_WITHDRAWN); - } - - if(member.getOnboardingStep() != OnboardingStep.COMPLETED) { - log.warn("[Onboarding] 현재 온보딩 단계가 완료되지 않았음 - memberId={}, currentStep={}", - memberId, member.getOnboardingStep()); - throw new CustomException(ErrorCode.INVALID_ONBOARDING_STEP); - } - - // 멤버의 관심사 리스트 반환 - List memberInterests = memberInterestRepository.findByMemberId(memberId); - - // 관심사 ID를 통해 관심사 리스트 반환 - List interests = memberInterests.stream() - .map(memberInterest -> new InterestDto( - memberInterest.getInterest().getId(), - memberInterest.getInterest().getName())) - .collect(Collectors.toList()); - - return interests; - } - /** * 닉네임 사용 가능 여부 확인 * diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/service/NicknameService.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/service/NicknameService.java new file mode 100644 index 0000000..f6ca660 --- /dev/null +++ b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/service/NicknameService.java @@ -0,0 +1,79 @@ +package kr.suhsaechan.mapsy.member.service; + +import kr.suhsaechan.mapsy.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.suhsaechan.suhrandomengine.core.SuhRandomKit; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +public class NicknameService { + + private final MemberRepository memberRepository; + + /** + * Spring Bean으로 SuhRandomKit 초기화 + */ + @Bean + public SuhRandomKit suhRandomKit() { + return SuhRandomKit.builder() + .locale("ko") // 한국어 닉네임 + .numberLength(4) // 숫자 접미사 4자리 + .uuidLength(4) // UUID 접미사 4자리 + .build(); + } + + /** + * 중복되지 않는 랜덤 닉네임 생성 + * + * @return 생성된 닉네임 + */ + public String generateUniqueNickname() { + SuhRandomKit nicknameGenerator = suhRandomKit(); + String nickname; + int attempts = 0; + int maxAttempts = 100; // 최대 시도 횟수 + + do { + // 기본 닉네임 생성 (예: "멋진고양이") + nickname = nicknameGenerator.simpleNickname(); + + attempts++; + + // 중복 체크 + if (!memberRepository.existsByName(nickname)) { + log.info("랜덤 닉네임 생성 성공 - nickname={}, attempts={}", nickname, attempts); + return nickname; + } + + // 중복이면 숫자 접미사 추가 (예: "멋진고양이-1234") + if (attempts > 10) { + nickname = nicknameGenerator.nicknameWithNumber(); + + if (!memberRepository.existsByName(nickname)) { + log.info("숫자 접미사 닉네임 생성 성공 - nickname={}, attempts={}", nickname, attempts); + return nickname; + } + } + + // 그래도 중복이면 UUID 접미사 추가 (예: "멋진고양이-abcd") + if (attempts > 50) { + nickname = nicknameGenerator.nicknameWithUuid(); + + if (!memberRepository.existsByName(nickname)) { + log.info("UUID 접미사 닉네임 생성 성공 - nickname={}, attempts={}", nickname, attempts); + return nickname; + } + } + + } while (attempts < maxAttempts); + + // 최대 시도 횟수 초과 시 UUID 접미사 강제 적용 + nickname = nicknameGenerator.nicknameWithUuid(); + log.warn("최대 시도 횟수 초과, UUID 닉네임 강제 생성 - nickname={}", nickname); + return nickname; + } +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Keyword.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Keyword.java new file mode 100644 index 0000000..61c5918 --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Keyword.java @@ -0,0 +1,92 @@ +package kr.suhsaechan.mapsy.place.entity; + +import kr.suhsaechan.mapsy.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Keyword 엔티티 + * - Instagram 게시물이나 장소 설명에서 추출된 키워드/해시태그 관리 + * - 트렌드 점수를 통해 인기 키워드 분석 가능 + * - Place와 다대다 관계 (PlaceKeyword 중간 테이블) + */ +@Entity +@Table(name = "keywords") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Keyword extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", updatable = false, nullable = false) + private UUID id; + + /** + * 키워드 문자열 + * - 해시태그 형태 (#강남카페) 또는 일반 텍스트 (강남카페) + * - unique 제약조건으로 중복 방지 + */ + @Column(unique = true, nullable = false, length = 100) + private String keyword; + + /** + * 키워드 사용 횟수 + * - 장소에 연결될 때마다 증가 + * - 트렌드 점수 계산에 사용 + */ + @Column(nullable = false) + @Builder.Default + private Integer count = 1; + + /** + * 트렌드 점수 + * - 급상승률 기반 계산: (현재 count - 이전 count) / 이전 count + * - 배치 작업으로 주기적 업데이트 (예: 매시간) + * - 높을수록 최근 급상승 중인 키워드 + */ + @Column(precision = 10, scale = 2) + @Builder.Default + private BigDecimal trendScore = BigDecimal.ZERO; + + /** + * 이 키워드가 연결된 장소 목록 + * - PlaceKeyword 중간 테이블을 통한 다대다 관계 + */ + @OneToMany(mappedBy = "keyword") + @Builder.Default + private List placeKeywords = new ArrayList<>(); + + /** + * 키워드 사용 횟수 증가 + * - 장소에 키워드 연결 시 호출 + */ + public void incrementCount() { + this.count++; + } + + /** + * 트렌드 점수 업데이트 + * - 배치 작업에서 호출 + * + * @param newTrendScore 새로운 트렌드 점수 + */ + public void updateTrendScore(BigDecimal newTrendScore) { + this.trendScore = newTrendScore; + } +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/MemberPlace.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/MemberPlace.java index 9d561b9..ebbb34a 100644 --- a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/MemberPlace.java +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/MemberPlace.java @@ -75,6 +75,48 @@ public class MemberPlace extends SoftDeletableBaseEntity { */ private LocalDateTime savedAt; + /** + * 북마크 폴더명 + * - 사용자가 장소를 분류하기 위한 폴더 + * - 예: "가고 싶은 곳", "가본 곳", "즐겨찾기", "맛집" 등 + * - 기본값: "default" + */ + @Column(length = 50) + @Builder.Default + private String folder = "default"; + + /** + * 사용자 메모 + * - 장소에 대한 개인적인 메모 (최대 1000자) + */ + @Column(columnDefinition = "TEXT") + private String memo; + + /** + * 별점 (1-5) + * - 사용자가 매긴 개인적인 평점 + * - null 가능 (평가하지 않은 경우) + */ + @Column + private Integer rating; + + /** + * 방문 여부 + * - true: 방문함, false: 방문하지 않음 + * - 기본값: false + */ + @Column(nullable = false) + @Builder.Default + private Boolean visited = false; + + /** + * 방문 일시 + * - 사용자가 실제로 방문한 시간 + * - visited = true일 때만 의미 있음 + */ + @Column + private LocalDateTime visitedAt; + /** * 임시 저장 상태에서 저장 상태로 변경 * - savedStatus: TEMPORARY -> SAVED @@ -91,4 +133,53 @@ public void markAsSaved() { this.savedStatus = PlaceSavedStatus.SAVED; this.savedAt = LocalDateTime.now(); } + + /** + * 폴더 변경 + * + * @param newFolder 새 폴더명 + */ + public void updateFolder(String newFolder) { + this.folder = newFolder; + } + + /** + * 메모 수정 + * + * @param newMemo 새 메모 + */ + public void updateMemo(String newMemo) { + this.memo = newMemo; + } + + /** + * 별점 수정 + * + * @param newRating 새 별점 (1-5) + * @throws CustomException 별점이 1-5 범위를 벗어난 경우 + */ + public void updateRating(Integer newRating) { + if (newRating != null && (newRating < 1 || newRating > 5)) { + throw new CustomException(ErrorCode.INVALID_RATING); + } + this.rating = newRating; + } + + /** + * 방문 여부 및 일시 기록 + * + * @param visitedAt 방문 일시 (null이면 현재 시간) + */ + public void markAsVisited(LocalDateTime visitedAt) { + this.visited = true; + this.visitedAt = visitedAt != null ? visitedAt : LocalDateTime.now(); + } + + /** + * 방문 취소 + */ + public void unmarkVisited() { + this.visited = false; + this.visitedAt = null; + } } diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Place.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Place.java index a87ed74..7551922 100644 --- a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Place.java +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Place.java @@ -1,7 +1,13 @@ package kr.suhsaechan.mapsy.place.entity; import kr.suhsaechan.mapsy.common.entity.SoftDeletableBaseEntity; -import jakarta.persistence.*; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMin; import lombok.AccessLevel; @@ -12,6 +18,7 @@ import lombok.Setter; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -58,6 +65,9 @@ public class Place extends SoftDeletableBaseEntity { @Column(length = 50) private String phone; + @Column(length = 500) + private String openingHours; //영업시간 + @Column(columnDefinition = "TEXT") private String description; //요약 설명 @@ -82,4 +92,33 @@ public class Place extends SoftDeletableBaseEntity { @JdbcTypeCode(SqlTypes.ARRAY) private List photoUrls; //사진 URL 배열 (최대 10개) + /** + * 이 장소와 연결된 키워드 목록 + * - PlaceKeyword 중간 테이블을 통한 다대다 관계 + */ + @OneToMany(mappedBy = "place", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List placeKeywords = new ArrayList<>(); + + /** + * 키워드 추가 + * - PlaceKeyword 연결 생성 및 양방향 관계 설정 + * + * @param keyword 추가할 키워드 + */ + public void addKeyword(Keyword keyword) { + PlaceKeyword placeKeyword = PlaceKeyword.of(this, keyword); + this.placeKeywords.add(placeKeyword); + keyword.getPlaceKeywords().add(placeKeyword); + } + + /** + * 키워드 제거 + * + * @param keyword 제거할 키워드 + */ + public void removeKeyword(Keyword keyword) { + this.placeKeywords.removeIf(pk -> pk.getKeyword().equals(keyword)); + keyword.getPlaceKeywords().removeIf(pk -> pk.getPlace().equals(this)); + } } diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/PlaceKeyword.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/PlaceKeyword.java new file mode 100644 index 0000000..976e3d7 --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/PlaceKeyword.java @@ -0,0 +1,94 @@ +package kr.suhsaechan.mapsy.place.entity; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.util.Objects; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * PlaceKeyword 엔티티 + * - Place와 Keyword 간의 M:N 관계를 표현하는 중간 테이블 + * - 복합키(Composite Key) 사용 + */ +@Entity +@Table(name = "place_keywords") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class PlaceKeyword { + + @EmbeddedId + private PlaceKeywordId id; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("placeId") + @JoinColumn(name = "place_id") + private Place place; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("keywordId") + @JoinColumn(name = "keyword_id") + private Keyword keyword; + + /** + * PlaceKeyword 생성 편의 메서드 + * + * @param place 장소 + * @param keyword 키워드 + * @return PlaceKeyword 엔티티 + */ + public static PlaceKeyword of(Place place, Keyword keyword) { + PlaceKeywordId id = new PlaceKeywordId(place.getId(), keyword.getId()); + return PlaceKeyword.builder() + .id(id) + .place(place) + .keyword(keyword) + .build(); + } + + /** + * PlaceKeyword 복합키 + * - placeId, keywordId 조합으로 유일성 보장 + */ + @Embeddable + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + public static class PlaceKeywordId implements Serializable { + + private UUID placeId; + private UUID keywordId; + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PlaceKeywordId that = (PlaceKeywordId) o; + return Objects.equals(placeId, that.placeId) + && Objects.equals(keywordId, that.keywordId); + } + + @Override + public int hashCode() { + return Objects.hash(placeId, keywordId); + } + } +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/KeywordRepository.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/KeywordRepository.java new file mode 100644 index 0000000..c4be011 --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/KeywordRepository.java @@ -0,0 +1,96 @@ +package kr.suhsaechan.mapsy.place.repository; + +import kr.suhsaechan.mapsy.place.entity.Keyword; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Keyword 엔티티에 대한 Repository + */ +@Repository +public interface KeywordRepository extends JpaRepository { + + /** + * 키워드 문자열로 조회 + * + * @param keyword 키워드 문자열 + * @return Optional + */ + Optional findByKeyword(String keyword); + + /** + * 트렌드 점수 기준 상위 키워드 조회 + * - 트렌드 점수가 높은 순서대로 정렬 + * + * @param pageable 페이징 정보 + * @return Page + */ + @Query(""" + SELECT k FROM Keyword k + ORDER BY k.trendScore DESC, k.count DESC + """) + Page findTopByTrendScore(Pageable pageable); + + /** + * 사용 횟수 기준 상위 키워드 조회 + * - count가 높은 순서대로 정렬 + * + * @param pageable 페이징 정보 + * @return Page + */ + Page findAllByOrderByCountDesc(Pageable pageable); + + /** + * 키워드 검색 (자동완성용) + * - 키워드가 특정 문자열로 시작하는 경우 + * - 사용 횟수가 높은 순서대로 정렬 + * + * @param prefix 키워드 시작 문자열 + * @param pageable 페이징 정보 + * @return List + */ + @Query(""" + SELECT k FROM Keyword k + WHERE k.keyword LIKE :prefix% + ORDER BY k.count DESC + """) + List findByKeywordStartingWithOrderByCountDesc( + @Param("prefix") String prefix, + Pageable pageable + ); + + /** + * 키워드 포함 검색 (자동완성용) + * - 키워드에 특정 문자열이 포함된 경우 + * - 사용 횟수가 높은 순서대로 정렬 + * + * @param term 검색어 + * @param pageable 페이징 정보 + * @return List + */ + @Query(""" + SELECT k FROM Keyword k + WHERE k.keyword LIKE %:term% + ORDER BY k.count DESC + """) + List findByKeywordContainingOrderByCountDesc( + @Param("term") String term, + Pageable pageable + ); + + /** + * 특정 키워드 목록 조회 (배치 조회) + * + * @param keywords 키워드 문자열 목록 + * @return List + */ + List findByKeywordIn(List keywords); +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/PlaceKeywordRepository.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/PlaceKeywordRepository.java new file mode 100644 index 0000000..be3ad95 --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/PlaceKeywordRepository.java @@ -0,0 +1,100 @@ +package kr.suhsaechan.mapsy.place.repository; + +import kr.suhsaechan.mapsy.place.entity.Keyword; +import kr.suhsaechan.mapsy.place.entity.Place; +import kr.suhsaechan.mapsy.place.entity.PlaceKeyword; +import kr.suhsaechan.mapsy.place.entity.PlaceKeyword.PlaceKeywordId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * PlaceKeyword 엔티티에 대한 Repository + */ +@Repository +public interface PlaceKeywordRepository extends JpaRepository { + + /** + * 특정 장소의 모든 키워드 조회 + * + * @param place 장소 + * @return List + */ + List findByPlace(Place place); + + /** + * 특정 키워드가 연결된 모든 장소 조회 + * + * @param keyword 키워드 + * @return List + */ + List findByKeyword(Keyword keyword); + + /** + * 특정 키워드가 연결된 장소 수 조회 + * - 키워드의 인기도 계산에 사용 + * + * @param keyword 키워드 + * @return 장소 수 + */ + long countByKeyword(Keyword keyword); + + /** + * 특정 장소와 키워드의 연결 존재 여부 확인 + * + * @param place 장소 + * @param keyword 키워드 + * @return 존재 여부 + */ + boolean existsByPlaceAndKeyword(Place place, Keyword keyword); + + /** + * 특정 키워드로 장소 검색 (Place 직접 조회) + * - N+1 문제 방지를 위한 fetch join + * + * @param keyword 키워드 + * @return List + */ + @Query(""" + SELECT pk.place FROM PlaceKeyword pk + JOIN FETCH pk.place p + WHERE pk.keyword = :keyword + AND p.isDeleted = false + ORDER BY p.createdAt DESC + """) + List findPlacesByKeyword(@Param("keyword") Keyword keyword); + + /** + * 특정 키워드 목록으로 장소 검색 (여러 키워드 OR 조건) + * + * @param keywords 키워드 목록 + * @return List + */ + @Query(""" + SELECT DISTINCT pk.place FROM PlaceKeyword pk + JOIN FETCH pk.place p + WHERE pk.keyword IN :keywords + AND p.isDeleted = false + ORDER BY p.createdAt DESC + """) + List findPlacesByKeywords(@Param("keywords") List keywords); + + /** + * 특정 장소의 키워드 모두 삭제 + * - 장소 삭제 시 자동 호출됨 (CascadeType.ALL) + * + * @param place 장소 + */ + void deleteByPlace(Place place); + + /** + * 특정 키워드와 연결된 모든 PlaceKeyword 삭제 + * - 키워드 삭제 시 자동 호출됨 (CascadeType.ALL) + * + * @param keyword 키워드 + */ + void deleteByKeyword(Keyword keyword); +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/KeywordService.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/KeywordService.java new file mode 100644 index 0000000..4acd2aa --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/KeywordService.java @@ -0,0 +1,250 @@ +package kr.suhsaechan.mapsy.place.service; + +import kr.suhsaechan.mapsy.place.entity.Keyword; +import kr.suhsaechan.mapsy.place.entity.Place; +import kr.suhsaechan.mapsy.place.entity.PlaceKeyword; +import kr.suhsaechan.mapsy.place.repository.KeywordRepository; +import kr.suhsaechan.mapsy.place.repository.PlaceKeywordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +/** + * Keyword 비즈니스 로직 서비스 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class KeywordService { + + private final KeywordRepository keywordRepository; + private final PlaceKeywordRepository placeKeywordRepository; + + /** + * 키워드 생성 또는 조회 + * - 이미 존재하는 키워드면 조회 후 사용 횟수 증가 + * - 존재하지 않으면 새로 생성 + * + * @param keywordText 키워드 문자열 + * @return Keyword 엔티티 + */ + @Transactional + public Keyword createOrGet(String keywordText) { + log.info("Creating or getting keyword: {}", keywordText); + + // 기존 키워드 조회 + return keywordRepository.findByKeyword(keywordText) + .map(existingKeyword -> { + // 기존 키워드 사용 횟수 증가 + existingKeyword.incrementCount(); + log.info("Incremented count for keyword: {}, new count: {}", + keywordText, existingKeyword.getCount()); + return existingKeyword; + }) + .orElseGet(() -> { + // 새 키워드 생성 + Keyword newKeyword = Keyword.builder() + .keyword(keywordText) + .build(); + Keyword saved = keywordRepository.save(newKeyword); + log.info("Created new keyword: {}", keywordText); + return saved; + }); + } + + /** + * 장소에 키워드 목록 연결 + * - 기존 연결된 키워드는 건너뛰기 + * - 새 키워드는 생성 후 연결 + * + * @param place 장소 + * @param keywords 키워드 문자열 목록 + */ + @Transactional + public void linkKeywordsToPlace(Place place, List keywords) { + if (keywords == null || keywords.isEmpty()) { + log.warn("No keywords to link to place: placeId={}", place.getId()); + return; + } + + log.info("Linking {} keywords to place: placeId={}, placeName={}", + keywords.size(), place.getId(), place.getName()); + + for (String keywordText : keywords) { + // 해시태그 정규화 (# 제거, 소문자 변환) + String normalized = normalizeKeyword(keywordText); + + // 키워드 생성 또는 조회 + Keyword keyword = createOrGet(normalized); + + // 이미 연결되어 있는지 확인 + boolean alreadyLinked = placeKeywordRepository.existsByPlaceAndKeyword(place, keyword); + + if (!alreadyLinked) { + // PlaceKeyword 생성 및 연결 + PlaceKeyword placeKeyword = PlaceKeyword.of(place, keyword); + placeKeywordRepository.save(placeKeyword); + log.info("Linked keyword '{}' to place '{}'", normalized, place.getName()); + } else { + log.debug("Keyword '{}' already linked to place '{}'", normalized, place.getName()); + } + } + + log.info("Successfully linked all keywords to place: placeId={}", place.getId()); + } + + /** + * 키워드 정규화 + * - # 제거 + * - 앞뒤 공백 제거 + * - 소문자 변환 + * + * @param keyword 원본 키워드 + * @return 정규화된 키워드 + */ + private String normalizeKeyword(String keyword) { + if (keyword == null) { + return ""; + } + + // # 제거, trim, 소문자 변환 + return keyword.replace("#", "").trim().toLowerCase(); + } + + /** + * 트렌드 키워드 조회 + * - 트렌드 점수 기준 내림차순 정렬 + * + * @param pageable 페이징 정보 + * @return Page + */ + @Transactional(readOnly = true) + public Page getTrendingKeywords(Pageable pageable) { + log.info("Fetching trending keywords: page={}, size={}", + pageable.getPageNumber(), pageable.getPageSize()); + + return keywordRepository.findTopByTrendScore(pageable); + } + + /** + * 인기 키워드 조회 (사용 횟수 기준) + * - count 기준 내림차순 정렬 + * + * @param pageable 페이징 정보 + * @return Page + */ + @Transactional(readOnly = true) + public Page getPopularKeywords(Pageable pageable) { + log.info("Fetching popular keywords: page={}, size={}", + pageable.getPageNumber(), pageable.getPageSize()); + + return keywordRepository.findAllByOrderByCountDesc(pageable); + } + + /** + * 키워드 자동완성 검색 + * - 키워드가 prefix로 시작하는 경우 + * + * @param prefix 검색어 시작 문자열 + * @param pageable 페이징 정보 + * @return List + */ + @Transactional(readOnly = true) + public List searchKeywords(String prefix, Pageable pageable) { + String normalized = normalizeKeyword(prefix); + + log.info("Searching keywords with prefix: {}", normalized); + + return keywordRepository.findByKeywordStartingWithOrderByCountDesc(normalized, pageable); + } + + /** + * 키워드로 장소 검색 + * - 특정 키워드가 연결된 모든 장소 조회 + * + * @param keywordText 키워드 문자열 + * @return List + */ + @Transactional(readOnly = true) + public List findPlacesByKeyword(String keywordText) { + String normalized = normalizeKeyword(keywordText); + + log.info("Finding places by keyword: {}", normalized); + + // 키워드 조회 + return keywordRepository.findByKeyword(normalized) + .map(keyword -> { + List places = placeKeywordRepository.findPlacesByKeyword(keyword); + log.info("Found {} places for keyword '{}'", places.size(), normalized); + return places; + }) + .orElseGet(() -> { + log.warn("Keyword not found: {}", normalized); + return new ArrayList<>(); + }); + } + + /** + * 여러 키워드로 장소 검색 (OR 조건) + * - 키워드 목록 중 하나라도 포함된 장소 조회 + * + * @param keywordTexts 키워드 문자열 목록 + * @return List + */ + @Transactional(readOnly = true) + public List findPlacesByKeywords(List keywordTexts) { + if (keywordTexts == null || keywordTexts.isEmpty()) { + log.warn("No keywords provided for search"); + return new ArrayList<>(); + } + + log.info("Finding places by {} keywords", keywordTexts.size()); + + // 키워드 정규화 + List normalized = keywordTexts.stream() + .map(this::normalizeKeyword) + .toList(); + + // 키워드 엔티티 조회 + List keywords = keywordRepository.findByKeywordIn(normalized); + + if (keywords.isEmpty()) { + log.warn("No keywords found in database"); + return new ArrayList<>(); + } + + // 키워드로 장소 검색 + List places = placeKeywordRepository.findPlacesByKeywords(keywords); + log.info("Found {} places for {} keywords", places.size(), keywords.size()); + + return places; + } + + /** + * 장소의 키워드 목록 조회 + * + * @param place 장소 + * @return List + */ + @Transactional(readOnly = true) + public List getKeywordsByPlace(Place place) { + log.info("Fetching keywords for place: placeId={}, placeName={}", + place.getId(), place.getName()); + + List placeKeywords = placeKeywordRepository.findByPlace(place); + + List keywords = placeKeywords.stream() + .map(PlaceKeyword::getKeyword) + .toList(); + + log.info("Found {} keywords for place '{}'", keywords.size(), place.getName()); + + return keywords; + } +} diff --git a/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/repository/ContentPlaceRepository.java b/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/repository/ContentPlaceRepository.java index 399bf43..eae6b90 100644 --- a/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/repository/ContentPlaceRepository.java +++ b/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/repository/ContentPlaceRepository.java @@ -1,5 +1,7 @@ package kr.suhsaechan.mapsy.sns.repository; +import kr.suhsaechan.mapsy.place.entity.Place; +import kr.suhsaechan.mapsy.sns.entity.Content; import kr.suhsaechan.mapsy.sns.entity.ContentPlace; import java.util.List; import java.util.UUID; @@ -23,4 +25,7 @@ public interface ContentPlaceRepository extends JpaRepository findByContentIdWithPlace(@Param("contentId") UUID contentId); + + // Content와 Place 조합으로 중복 체크 + boolean existsByContentAndPlace(Content content, Place place); } diff --git a/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/service/AiCallbackService.java b/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/service/AiCallbackService.java index 5662ccf..ff77a0e 100644 --- a/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/service/AiCallbackService.java +++ b/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/service/AiCallbackService.java @@ -13,6 +13,7 @@ import kr.suhsaechan.mapsy.place.repository.MemberPlaceRepository; import kr.suhsaechan.mapsy.place.repository.PlacePlatformReferenceRepository; import kr.suhsaechan.mapsy.place.repository.PlaceRepository; +import kr.suhsaechan.mapsy.place.service.KeywordService; import kr.suhsaechan.mapsy.sns.entity.Content; import kr.suhsaechan.mapsy.sns.entity.ContentMember; import kr.suhsaechan.mapsy.sns.entity.ContentPlace; @@ -24,6 +25,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -45,6 +47,7 @@ public class AiCallbackService { private final PlacePlatformReferenceRepository placePlatformReferenceRepository; private final MemberPlaceRepository memberPlaceRepository; private final FcmService fcmService; + private final KeywordService keywordService; /** * AI 서버로부터 받은 Callback 처리 @@ -128,24 +131,53 @@ private void processAiServerSuccessCallback(Content content, AiCallbackRequest r contentRepository.save(content); - // TODO: AI 서버에서 받은 Place 정보로 직접 Place 생성하도록 수정 필요 - // 현재는 Google Places API 호출이 제거되어 Place 저장 로직이 임시로 비활성화됨 + // AI 서버에서 받은 Place 정보로 Place 생성 및 Content 연결 + int placeCount = 0; if (request.getPlaces() != null && !request.getPlaces().isEmpty()) { - List places = request.getPlaces(); - log.info("Received {} places for contentId={} (update mode: {}). Place saving temporarily disabled.", - places.size(), content.getId(), isContentAlreadyCompleted); - - // TODO: AiCallbackRequest.PlaceInfo에서 직접 Place 생성하도록 구현 필요 - // - 위도/경도 정보가 AI 서버 응답에 포함되어야 함 - // - Place 생성 로직 재구현 필요 - log.warn("Place saving is temporarily disabled. Will be implemented later with AI server response data."); + List placeInfos = request.getPlaces(); + log.info("Received {} places for contentId={} (update mode: {}). Starting Place creation.", + placeInfos.size(), content.getId(), isContentAlreadyCompleted); + + // Place 생성 및 ContentPlace 연결 + List savedPlaces = new ArrayList<>(); + for (AiCallbackRequest.PlaceInfo placeInfo : placeInfos) { + try { + // 좌표 검증 + if (placeInfo.getLatitude() == null || placeInfo.getLongitude() == null) { + log.error("Missing coordinates for place: {}. Skipping.", placeInfo.getName()); + continue; + } + + // Place 생성 또는 조회 + Place place = createOrGetPlaceFromAiData(placeInfo); + savedPlaces.add(place); + + // ContentPlace 연결 (중복 체크) + createContentPlace(content, place); + + // 키워드 연결 + if (placeInfo.getKeywords() != null && !placeInfo.getKeywords().isEmpty()) { + keywordService.linkKeywordsToPlace(place, placeInfo.getKeywords()); + log.debug("Linked {} keywords to place: {}", placeInfo.getKeywords().size(), place.getName()); + } + + log.debug("Successfully processed place: {} (id={})", place.getName(), place.getId()); + } catch (Exception e) { + log.error("Failed to process place: {}. Error: {}", placeInfo.getName(), e.getMessage(), e); + // 개별 장소 처리 실패 시 계속 진행 (다른 장소는 저장) + } + } + + placeCount = savedPlaces.size(); + log.info("Successfully saved {} out of {} places for contentId={}", + placeCount, placeInfos.size(), content.getId()); } else { // Place 데이터가 없는 경우 경고 로그 log.warn("No places found in callback for contentId={}", content.getId()); } // AI 분석 완료 후 모든 요청 회원에게 알림 전송 - sendContentCompleteNotification(content, request); + sendContentCompleteNotification(content, request, placeCount); } /** @@ -234,83 +266,127 @@ private void processFailedCallback(Content content, AiCallbackRequest request) { contentRepository.save(content); } - // TODO: Google Places API 제거로 인해 임시로 주석 처리 - // 나중에 AI 서버 응답 데이터(AiCallbackRequest.PlaceInfo)로 직접 Place 생성하도록 재구현 필요 - /* - private Place createOrUpdatePlace(GooglePlaceSearchDto.PlaceDetail googlePlace) { + /** + * AI 서버 응답 데이터로부터 Place 생성 또는 조회 + *

+ * 위도/경도 기반 중복 체크 후 기존 Place 업데이트 또는 신규 생성 + * + * @param placeInfo AI 서버 응답 장소 정보 + * @return 생성 또는 업데이트된 Place + */ + private Place createOrGetPlaceFromAiData(AiCallbackRequest.PlaceInfo placeInfo) { + BigDecimal latitude = BigDecimal.valueOf(placeInfo.getLatitude()); + BigDecimal longitude = BigDecimal.valueOf(placeInfo.getLongitude()); + // 이름+좌표로 중복 체크 Optional existing = placeRepository.findByNameAndLatitudeAndLongitude( - googlePlace.getName(), - googlePlace.getLatitude(), - googlePlace.getLongitude() + placeInfo.getName(), + latitude, + longitude ); if (existing.isPresent()) { // 기존 Place 업데이트 Place place = existing.get(); - place.setAddress(googlePlace.getAddress()); - place.setCountry(googlePlace.getCountry()); - place.setTypes(googlePlace.getTypes()); - place.setBusinessStatus(googlePlace.getBusinessStatus()); - place.setIconUrl(googlePlace.getIconUrl()); - place.setRating(googlePlace.getRating()); - place.setUserRatingsTotal(googlePlace.getUserRatingsTotal()); - place.setPhotoUrls(googlePlace.getPhotoUrls()); - log.debug("Updated existing place: id={}, name={}, rating={}", - place.getId(), place.getName(), place.getRating()); + updatePlaceFromAiData(place, placeInfo); + log.debug("Updated existing place: id={}, name={}, address={}", + place.getId(), place.getName(), place.getAddress()); return placeRepository.save(place); } else { // 새로 생성 Place newPlace = Place.builder() - .name(googlePlace.getName()) - .address(googlePlace.getAddress()) - .country(googlePlace.getCountry()) - .latitude(googlePlace.getLatitude()) - .longitude(googlePlace.getLongitude()) - .types(googlePlace.getTypes()) - .businessStatus(googlePlace.getBusinessStatus()) - .iconUrl(googlePlace.getIconUrl()) - .rating(googlePlace.getRating()) - .userRatingsTotal(googlePlace.getUserRatingsTotal()) - .photoUrls(googlePlace.getPhotoUrls()) + .name(placeInfo.getName()) + .address(placeInfo.getAddress()) + .latitude(latitude) + .longitude(longitude) + .country(placeInfo.getCountry()) .build(); + // 선택 필드 설정 + if (placeInfo.getCategory() != null) { + newPlace.setTypes(placeInfo.getCategory()); + } + if (placeInfo.getPhone() != null) { + newPlace.setPhone(placeInfo.getPhone()); + } + if (placeInfo.getOpeningHours() != null) { + newPlace.setOpeningHours(placeInfo.getOpeningHours()); + } + if (placeInfo.getDescription() != null) { + newPlace.setDescription(placeInfo.getDescription()); + } + Place savedPlace = placeRepository.save(newPlace); - log.debug("Created new place: id={}, name={}, rating={}", - savedPlace.getId(), savedPlace.getName(), savedPlace.getRating()); + log.debug("Created new place: id={}, name={}, lat={}, lng={}", + savedPlace.getId(), savedPlace.getName(), latitude, longitude); return savedPlace; } } - private void savePlacePlatformReference(Place place, String googlePlaceId) { - log.info("Saving PlacePlatformReference: placeId={}, googlePlaceId={}", place.getId(), googlePlaceId); - - Optional existing = - placePlatformReferenceRepository.findByPlaceAndPlacePlatform(place, PlacePlatform.GOOGLE); + /** + * AI 응답 데이터로 기존 Place 업데이트 + *

+ * null이 아닌 필드만 업데이트하여 기존 데이터 보존 + * + * @param place 업데이트할 Place + * @param placeInfo AI 응답 장소 정보 + */ + private void updatePlaceFromAiData(Place place, AiCallbackRequest.PlaceInfo placeInfo) { + if (placeInfo.getAddress() != null) { + place.setAddress(placeInfo.getAddress()); + } + if (placeInfo.getCountry() != null) { + place.setCountry(placeInfo.getCountry()); + } + if (placeInfo.getCategory() != null) { + place.setTypes(placeInfo.getCategory()); + } + if (placeInfo.getPhone() != null) { + place.setPhone(placeInfo.getPhone()); + } + if (placeInfo.getOpeningHours() != null) { + place.setOpeningHours(placeInfo.getOpeningHours()); + } + if (placeInfo.getDescription() != null) { + place.setDescription(placeInfo.getDescription()); + } + } - if (existing.isEmpty()) { - PlacePlatformReference ref = PlacePlatformReference.builder() - .place(place) - .placePlatform(PlacePlatform.GOOGLE) - .placePlatformId(googlePlaceId) - .build(); - placePlatformReferenceRepository.save(ref); - log.info("Successfully saved NEW PlacePlatformReference: refId={}, placeId={}, googlePlaceId={}", - ref.getId(), place.getId(), googlePlaceId); - } else { - log.info("PlacePlatformReference already exists (skipping save): refId={}, placeId={}, existingPlaceId={}, newPlaceId={}", - existing.get().getId(), place.getId(), existing.get().getPlacePlatformId(), googlePlaceId); + /** + * ContentPlace 연결 생성 (중복 체크 포함) + *

+ * Content와 Place 매핑. 이미 존재하면 스킵 + * + * @param content 대상 Content + * @param place 대상 Place + */ + private void createContentPlace(Content content, Place place) { + // 중복 체크 + boolean exists = contentPlaceRepository.existsByContentAndPlace(content, place); + if (exists) { + log.debug("ContentPlace already exists: contentId={}, placeId={}", content.getId(), place.getId()); + return; } + + // ContentPlace 엔티티 생성 + ContentPlace contentPlace = ContentPlace.builder() + .content(content) + .place(place) + .position(0) // AI 서버 응답에서는 순서 정보 없음 + .build(); + + // ContentPlace 저장 + contentPlaceRepository.save(contentPlace); + log.debug("Created ContentPlace: contentId={}, placeId={}", content.getId(), place.getId()); } - */ /** - * ContentPlace 연결 생성 + * ContentPlace 연결 생성 (순서 포함) *

* Content와 Place 매핑 및 순서 저장 * - * @param content 대상 Content - * @param place 대상 Place + * @param content 대상 Content + * @param place 대상 Place * @param position 순서 */ private void createContentPlace(Content content, Place place, int position) { @@ -332,11 +408,12 @@ private void createContentPlace(Content content, Place place, int position) { * 해당 Content를 요청한 모든 회원에게 FCM 알림 전송 * notified=false인 ContentMember만 대상으로 함 * - * @param content 완료된 Content - * @param request AI Callback 요청 + * @param content 완료된 Content + * @param request AI Callback 요청 + * @param placeCount 추출된 장소 개수 */ - private void sendContentCompleteNotification(Content content, AiCallbackRequest request) { - log.info("Sending content complete notifications for contentId={}", content.getId()); + private void sendContentCompleteNotification(Content content, AiCallbackRequest request, int placeCount) { + log.info("Sending content complete notifications for contentId={}, placeCount={}", content.getId(), placeCount); // 알림 미전송된 ContentMember 조회 (Member Fetch Join으로 N+1 방지) List unnotifiedMembers = contentMemberRepository.findUnnotifiedMembersWithMember(content.getId()); @@ -352,6 +429,7 @@ private void sendContentCompleteNotification(Content content, AiCallbackRequest Map notificationData = new HashMap<>(); notificationData.put("type", "CONTENT_COMPLETE"); notificationData.put("contentId", content.getId().toString()); + notificationData.put("placeCount", String.valueOf(placeCount)); if (content.getTitle() != null) { notificationData.put("title", content.getTitle()); @@ -360,6 +438,20 @@ private void sendContentCompleteNotification(Content content, AiCallbackRequest notificationData.put("thumbnailUrl", content.getThumbnailUrl()); } + // 알림 메시지 구성 + String notificationTitle = "콘텐츠 분석 완료"; + String notificationBody; + if (placeCount > 0) { + notificationBody = String.format("%d개의 장소가 발견되었습니다.", placeCount); + if (content.getTitle() != null) { + notificationBody = content.getTitle() + " - " + notificationBody; + } + } else { + notificationBody = content.getTitle() != null + ? content.getTitle() + " 분석이 완료되었습니다." + : "콘텐츠 분석이 완료되었습니다."; + } + // 각 회원에게 알림 전송 int successCount = 0; List succeededMembers = new ArrayList<>(); @@ -368,8 +460,8 @@ private void sendContentCompleteNotification(Content content, AiCallbackRequest // FCM 알림 전송 fcmService.sendNotificationToMember( contentMember.getMember().getId(), - "콘텐츠 분석 완료", - content.getTitle() != null ? content.getTitle() + " 분석이 완료되었습니다." : "콘텐츠 분석이 완료되었습니다.", + notificationTitle, + notificationBody, notificationData, content.getThumbnailUrl() // 썸네일 이미지 URL (null 가능) ); diff --git a/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/InterestController.java b/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/InterestController.java deleted file mode 100644 index c8223ad..0000000 --- a/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/InterestController.java +++ /dev/null @@ -1,53 +0,0 @@ -package kr.suhsaechan.mapsy.web.controller; - -import kr.suhsaechan.mapsy.member.constant.InterestCategory; -import kr.suhsaechan.mapsy.member.dto.interest.response.GetAllInterestsResponse; -import kr.suhsaechan.mapsy.member.dto.interest.response.GetInterestByIdResponse; -import kr.suhsaechan.mapsy.member.dto.interest.response.GetInterestsByCategoryResponse; -import kr.suhsaechan.mapsy.member.service.InterestService; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.UUID; - -@Slf4j -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/interests") -@Tag(name = "관심사 관리", description = "관심사 조회 API") -public class InterestController implements InterestControllerDocs { - - private final InterestService interestService; - - /** - * 전체 관심사 목록 조회 (대분류별 그룹핑) - */ - @GetMapping - public ResponseEntity getAllInterests() { - GetAllInterestsResponse result = interestService.getAllInterestsGroupedByCategory(); - return ResponseEntity.ok(result); - } - - /** - * 특정 카테고리 관심사 조회 - */ - @GetMapping("/categories/{category}") - public ResponseEntity getInterestsByCategory( - @PathVariable InterestCategory category) { - GetInterestsByCategoryResponse result = interestService.getInterestsByCategory(category); - return ResponseEntity.ok(result); - } - - /** - * 관심사 상세 조회 - */ - @GetMapping("/{interestId}") - public ResponseEntity getInterestById(@PathVariable UUID interestId) { - GetInterestByIdResponse result = interestService.getInterestById(interestId); - return ResponseEntity.ok(result); - } -} diff --git a/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/InterestControllerDocs.java b/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/InterestControllerDocs.java deleted file mode 100644 index b29758e..0000000 --- a/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/InterestControllerDocs.java +++ /dev/null @@ -1,40 +0,0 @@ -package kr.suhsaechan.mapsy.web.controller; - -import kr.suhsaechan.mapsy.common.constant.Author; -import kr.suhsaechan.mapsy.member.constant.InterestCategory; -import kr.suhsaechan.mapsy.member.dto.interest.response.GetAllInterestsResponse; -import kr.suhsaechan.mapsy.member.dto.interest.response.GetInterestByIdResponse; -import kr.suhsaechan.mapsy.member.dto.interest.response.GetInterestsByCategoryResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import kr.suhsaechan.suhapilog.annotation.ApiLog; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; - -import java.util.List; -import java.util.UUID; - -public interface InterestControllerDocs { - - @ApiLog(date = "2025.11.04", author = Author.SUHSAECHAN, issueNumber = 61, description = "전체 관심사 목록 조회 init") - @Operation(summary = "전체 관심사 목록 조회", description = "13개 대분류 카테고리별로 그룹핑된 전체 관심사 목록을 조회합니다. (Redis 캐싱 적용)") - ResponseEntity getAllInterests(); - - @ApiLog(date = "2025.11.04", author = Author.SUHSAECHAN, issueNumber = 61, description = "특정 카테고리 관심사 조회 init") - @Operation(summary = "특정 카테고리 관심사 조회", description = "특정 대분류 카테고리의 관심사 목록을 조회합니다.") - ResponseEntity getInterestsByCategory( - @Parameter(description = "관심사 카테고리 (FOOD, CAFE_DESSERT 등)", required = true) - @PathVariable InterestCategory category - ); - - @ApiLog(date = "2025.11.04", author = Author.SUHSAECHAN, issueNumber = 61, description = "관심사 상세 조회 init") - @Operation(summary = "관심사 상세 조회", description = "관심사 ID로 특정 관심사의 상세 정보를 조회합니다.") - ResponseEntity getInterestById( - @Parameter(description = "관심사 ID", required = true) - @PathVariable UUID interestId - ); -} diff --git a/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/MemberController.java b/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/MemberController.java index 0c747fd..30dc3e5 100644 --- a/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/MemberController.java +++ b/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/MemberController.java @@ -2,7 +2,6 @@ import kr.suhsaechan.mapsy.auth.dto.CustomUserDetails; import kr.suhsaechan.mapsy.member.dto.CheckNameResponse; -import kr.suhsaechan.mapsy.member.dto.InterestDto; import kr.suhsaechan.mapsy.member.dto.MemberDto; import kr.suhsaechan.mapsy.member.dto.ProfileUpdateRequest; import kr.suhsaechan.mapsy.member.dto.UpdateServiceAgreementTermsRequest; @@ -10,8 +9,6 @@ import kr.suhsaechan.mapsy.member.dto.onboarding.response.OnboardingResponse; import kr.suhsaechan.mapsy.member.dto.onboarding.request.UpdateBirthDateRequest; import kr.suhsaechan.mapsy.member.dto.onboarding.request.UpdateGenderRequest; -import kr.suhsaechan.mapsy.member.dto.onboarding.request.UpdateInterestsRequest; -import kr.suhsaechan.mapsy.member.dto.onboarding.request.UpdateNameRequest; import kr.suhsaechan.mapsy.member.service.MemberService; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -51,16 +48,6 @@ public ResponseEntity agreeMemberTerms( return ResponseEntity.ok(response); } - @PostMapping("/onboarding/name") - public ResponseEntity updateName( - @AuthenticationPrincipal CustomUserDetails userDetails, - @Valid @RequestBody UpdateNameRequest request - ) { - request.setMemberId(userDetails.getMemberId()); - OnboardingResponse response = memberService.updateName(request); - return ResponseEntity.ok(response); - } - @PostMapping("/onboarding/birth-date") public ResponseEntity updateBirthDate( @AuthenticationPrincipal CustomUserDetails userDetails, @@ -81,16 +68,6 @@ public ResponseEntity updateGender( return ResponseEntity.ok(response); } - @PostMapping("/onboarding/interests") - public ResponseEntity updateInterests( - @AuthenticationPrincipal CustomUserDetails userDetails, - @Valid @RequestBody UpdateInterestsRequest request - ) { - request.setMemberId(userDetails.getMemberId()); - OnboardingResponse response = memberService.updateInterests(request); - return ResponseEntity.ok(response); - } - @PostMapping("/profile") @Override public ResponseEntity updateProfile( @@ -128,13 +105,6 @@ public ResponseEntity getMemberByEmail(@PathVariable String email) { return ResponseEntity.ok(dto); } - @GetMapping("/{memberId}/interests") - @Override - public ResponseEntity> getInterestsByMemberId(@PathVariable UUID memberId) { - List interestDtos = memberService.getInterestsByMemberId(memberId); - return ResponseEntity.ok(interestDtos); - } - @GetMapping("/check-name") @Override public ResponseEntity checkName( diff --git a/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/MemberControllerDocs.java b/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/MemberControllerDocs.java index d664284..747c6e6 100644 --- a/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/MemberControllerDocs.java +++ b/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/MemberControllerDocs.java @@ -3,7 +3,6 @@ import kr.suhsaechan.mapsy.auth.dto.CustomUserDetails; import kr.suhsaechan.mapsy.common.constant.Author; import kr.suhsaechan.mapsy.member.dto.CheckNameResponse; -import kr.suhsaechan.mapsy.member.dto.InterestDto; import kr.suhsaechan.mapsy.member.dto.MemberDto; import kr.suhsaechan.mapsy.member.dto.ProfileUpdateRequest; import kr.suhsaechan.mapsy.member.dto.UpdateServiceAgreementTermsRequest; @@ -11,8 +10,6 @@ import kr.suhsaechan.mapsy.member.dto.onboarding.response.OnboardingResponse; import kr.suhsaechan.mapsy.member.dto.onboarding.request.UpdateBirthDateRequest; import kr.suhsaechan.mapsy.member.dto.onboarding.request.UpdateGenderRequest; -import kr.suhsaechan.mapsy.member.dto.onboarding.request.UpdateInterestsRequest; -import kr.suhsaechan.mapsy.member.dto.onboarding.request.UpdateNameRequest; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import kr.suhsaechan.suhapilog.annotation.ApiLog; @@ -67,7 +64,7 @@ public interface MemberControllerDocs { - **`isMarketingAgreed`**: 마케팅 수신 동의 여부 (선택) ## 반환값 (UpdateServiceAgreementTermsResponse) - - **`currentStep`**: 현재 온보딩 단계 (TERMS, NAME, BIRTH_DATE, GENDER, INTERESTS, COMPLETED) + - **`currentStep`**: 현재 온보딩 단계 (TERMS, BIRTH_DATE, GENDER, COMPLETED) - **`onboardingStatus`**: 온보딩 상태 (NOT_STARTED, IN_PROGRESS, COMPLETED) - **`member`**: 회원 정보 (디버깅용) @@ -84,29 +81,6 @@ ResponseEntity agreeMemberTerms( @AuthenticationPrincipal CustomUserDetails userDetails, @Valid UpdateServiceAgreementTermsRequest request); - @ApiLog(date = "2025.01.15", author = Author.SUHSAECHAN, issueNumber = 22, description = "온보딩 이름 설정 API 추가") - @Operation(summary = "이름 설정", description = """ - ## 인증(JWT): **필요** - - ## 요청 파라미터 (UpdateNameRequest) - - **`name`**: 이름 (필수, 2자 이상 50자 이하) - - ## 반환값 (OnboardingResponse) - - **`currentStep`**: 현재 온보딩 단계 - - **`onboardingStatus`**: 온보딩 상태 - - **`member`**: 회원 정보 (디버깅용) - - ## 특이사항 - - 온보딩 단계 중 이름 설정 단계를 완료합니다. - - ## 에러코드 - - **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다. - - **`INVALID_INPUT_VALUE`**: 유효하지 않은 입력값입니다. - """) - ResponseEntity updateName( - @AuthenticationPrincipal CustomUserDetails userDetails, - @Valid UpdateNameRequest request); - @ApiLog(date = "2025.01.15", author = Author.SUHSAECHAN, issueNumber = 22, description = "온보딩 생년월일 설정 API 추가") @Operation(summary = "생년월일 설정", description = """ ## 인증(JWT): **필요** @@ -153,31 +127,6 @@ ResponseEntity updateGender( @AuthenticationPrincipal CustomUserDetails userDetails, @Valid UpdateGenderRequest request); - @ApiLog(date = "2025.01.15", author = Author.SUHSAECHAN, issueNumber = 22, description = "온보딩 관심사 설정 API 추가") - @Operation(summary = "관심사 설정", description = """ - ## 인증(JWT): **필요** - - ## 요청 파라미터 (UpdateInterestsRequest) - - **`interestIds`**: 관심사 ID 목록 (필수, 최소 1개 이상) - - ## 반환값 (OnboardingResponse) - - **`currentStep`**: 현재 온보딩 단계 - - **`onboardingStatus`**: 온보딩 상태 - - **`member`**: 회원 정보 (디버깅용) - - ## 특이사항 - - 온보딩 단계 중 관심사 설정 단계를 완료합니다. - - 기존 관심사는 전체 삭제 후 새로 추가됩니다 (전체 교체). - - ## 에러코드 - - **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다. - - **`INVALID_INPUT_VALUE`**: 유효하지 않은 입력값입니다. - - **`INTEREST_NOT_FOUND`**: 유효하지 않은 관심사 ID가 포함되어 있습니다. - """) - ResponseEntity updateInterests( - @AuthenticationPrincipal CustomUserDetails userDetails, - @Valid UpdateInterestsRequest request); - @ApiLog(date = "2025.10.16", author = Author.SUHSAECHAN, issueNumber = 22, description = "회원 관리 API 문서화") @Operation(summary = "전체 회원 목록 조회", description = """ ## 인증(JWT): **불필요** @@ -262,8 +211,7 @@ ResponseEntity updateInterests( - **`name`**: 이름 (필수) - **`gender`**: 성별 (MALE, FEMALE, NONE) - **`birthDate`**: 생년월일 (LocalDate 형식) - - **`interestIds`**: 관심사 ID 목록 - + ## 반환값 (MemberDto) - **`memberId`**: 회원 ID - **`email`**: 회원 이메일 @@ -275,8 +223,7 @@ ResponseEntity updateInterests( ## 특이사항 - 회원 프로필 정보를 업데이트합니다. - 이름 중복 검사가 수행됩니다. - - 관심사도 함께 업데이트됩니다. - + ## 에러코드 - **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다. - **`NAME_ALREADY_EXISTS`**: 이미 사용 중인 이름입니다. @@ -286,26 +233,6 @@ ResponseEntity updateProfile( @AuthenticationPrincipal CustomUserDetails userDetails, @Valid ProfileUpdateRequest request); - @ApiLog(date = "2025.10.16", author = Author.SUHSAECHAN, issueNumber = 22, description = "회원 관리 API 문서화") - @Operation(summary = "회원 관심사 조회 (ID)", description = """ - ## 인증(JWT): **불필요** - - ## 요청 파라미터 - - **`memberId`**: 회원 ID (Path Variable) - - ## 반환값 (List) - - **`id`**: 관심사 ID - - **`name`**: 관심사 이름 - - ## 특이사항 - - 회원 ID로 해당 회원의 관심사 목록을 조회합니다. - - ## 에러코드 - - **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다. - - **`INVALID_INPUT_VALUE`**: 유효하지 않은 입력값입니다. - """) - ResponseEntity> getInterestsByMemberId(UUID memberId); - @ApiLog(date = "2025.11.23", author = Author.SUHSAECHAN, issueNumber = 106, description = "닉네임 중복 확인 API 추가") @Operation(summary = "닉네임 중복 확인", description = """ ## 인증(JWT): **불필요** diff --git a/MS-Web/src/main/resources/db/migration/V0.2.1__insert_interests.sql b/MS-Web/src/main/resources/db/migration/V0.2.1__insert_interests.sql deleted file mode 100644 index b3ab340..0000000 --- a/MS-Web/src/main/resources/db/migration/V0.2.1__insert_interests.sql +++ /dev/null @@ -1,245 +0,0 @@ --- ============================================ --- Mapsy Interest Data Migration --- Version: 0.2.1 --- Description: 14개 대분류, 123개 소분류 관심사 초기화 --- - 테이블이 없으면 아무 작업도 하지 않음 (JPA가 자동 생성) --- ============================================ - -DO -$$ - BEGIN - ------------------------------------------------------------------- - -- 0. interest 테이블 존재 확인 - ------------------------------------------------------------------- - IF EXISTS (SELECT 1 - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = 'interest') THEN - ----------------------------------------------------------------- - -- 1. UNIQUE 제약조건 추가 (ON CONFLICT를 위해 필수) - ----------------------------------------------------------------- - IF NOT EXISTS (SELECT 1 - FROM pg_constraint - WHERE conname = 'interest_category_name_unique') THEN - ALTER TABLE public.interest - ADD CONSTRAINT interest_category_name_unique UNIQUE (category, name); - END IF; - - ----------------------------------------------------------------- - -- 2. Check constraint 수정 (실제 사용하는 카테고리로 변경) - -- 기존 제약조건 삭제 후 재생성 - ----------------------------------------------------------------- - ALTER TABLE public.interest DROP CONSTRAINT IF EXISTS interest_category_check; - ALTER TABLE public.interest - ADD CONSTRAINT interest_category_check CHECK ( - category IN ( - 'FOOD', 'CAFE_DESSERT', 'LOCAL_MARKET', 'NATURE_OUTDOOR', - 'URBAN_PHOTOSPOTS', 'CULTURE_ART', 'HISTORY_ARCHITECTURE', - 'EXPERIENCE_CLASS', 'SHOPPING_FASHION', 'NIGHTLIFE', - 'WELLNESS', 'FAMILY_KIDS', 'KPOP_CULTURE', 'DRIVE_SUBURBS' - ) - ); - - ----------------------------------------------------------------- - -- 3. 관심사 데이터 INSERT - ----------------------------------------------------------------- - - -- 1. 맛집/푸드 (14개) - INSERT INTO public.interest (id, category, name, created_at, updated_at) - VALUES - (gen_random_uuid(), 'FOOD', '한식', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FOOD', '일식', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FOOD', '중식', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FOOD', '양식', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FOOD', '분식/간식', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FOOD', '고기구이', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FOOD', '해산물', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FOOD', '비건/플렉시', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FOOD', '브런치', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FOOD', '포장마차/야식', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FOOD', '파인다이닝/오마카세', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FOOD', '지역별 로컬맛집', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FOOD', '세계음식', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FOOD', '전통주점/민속주점', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (category, name) DO NOTHING; - - -- 2. 카페/디저트 (7개) - INSERT INTO public.interest (id, category, name, created_at, updated_at) - VALUES - (gen_random_uuid(), 'CAFE_DESSERT', '스페셜티 카페', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CAFE_DESSERT', '베이커리/빵집', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CAFE_DESSERT', '디저트 바', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CAFE_DESSERT', '뷰카페(루프탑/오션뷰)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CAFE_DESSERT', '레트로 카페', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CAFE_DESSERT', '감성 카페', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CAFE_DESSERT', '전통찻집/다원', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (category, name) DO NOTHING; - - -- 3. 로컬시장/골목 (7개) - INSERT INTO public.interest (id, category, name, created_at, updated_at) - VALUES - (gen_random_uuid(), 'LOCAL_MARKET', '재래시장', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'LOCAL_MARKET', '수산시장', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'LOCAL_MARKET', '야시장/도깨비시장', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'LOCAL_MARKET', '벼룩/플리마켓/공예마켓', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'LOCAL_MARKET', '노포거리', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'LOCAL_MARKET', '공방거리', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'LOCAL_MARKET', '대학교 상권/대학가', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (category, name) DO NOTHING; - - -- 4. 자연/아웃도어 (10개) - INSERT INTO public.interest (id, category, name, created_at, updated_at) - VALUES - (gen_random_uuid(), 'NATURE_OUTDOOR', '해변/수상 액티비티', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NATURE_OUTDOOR', '산/트레킹', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NATURE_OUTDOOR', '국립공원', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NATURE_OUTDOOR', '공원/유원지', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NATURE_OUTDOOR', '계절 명소 (벚꽃, 단풍 등)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NATURE_OUTDOOR', '캠핑/백패킹', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NATURE_OUTDOOR', '일출/일몰 스팟', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NATURE_OUTDOOR', '계곡/폭포', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NATURE_OUTDOOR', '수목원/식물원', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NATURE_OUTDOOR', '자전거길/하이킹코스', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (category, name) DO NOTHING; - - -- 5. 도시산책/포토스팟 (8개) - INSERT INTO public.interest (id, category, name, created_at, updated_at) - VALUES - (gen_random_uuid(), 'URBAN_PHOTOSPOTS', '골목 산책로', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'URBAN_PHOTOSPOTS', '벽화마을', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'URBAN_PHOTOSPOTS', '하천/보행로', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'URBAN_PHOTOSPOTS', '야경 스팟/전망대', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'URBAN_PHOTOSPOTS', '레트로 거리', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'URBAN_PHOTOSPOTS', '창고/산업뷰', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'URBAN_PHOTOSPOTS', '대학교 캠퍼스', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'URBAN_PHOTOSPOTS', '건축물 투어', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (category, name) DO NOTHING; - - -- 6. 문화/예술 (10개) - INSERT INTO public.interest (id, category, name, created_at, updated_at) - VALUES - (gen_random_uuid(), 'CULTURE_ART', '미술관/갤러리', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CULTURE_ART', '공연/연극/뮤지컬', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CULTURE_ART', '독립서점', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CULTURE_ART', '전시회/박람회', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CULTURE_ART', '페스티벌', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CULTURE_ART', '복합문화공간', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CULTURE_ART', '스트리트 아트', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CULTURE_ART', '클래식 공연', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CULTURE_ART', '독립영화관', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'CULTURE_ART', '라이브 공연장', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (category, name) DO NOTHING; - - -- 7. 역사/건축/종교 (9개) - INSERT INTO public.interest (id, category, name, created_at, updated_at) - VALUES - (gen_random_uuid(), 'HISTORY_ARCHITECTURE', '궁궐/고택', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'HISTORY_ARCHITECTURE', '성곽/산성', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'HISTORY_ARCHITECTURE', '근대건축', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'HISTORY_ARCHITECTURE', '한옥', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'HISTORY_ARCHITECTURE', '유적지/역사박물관', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'HISTORY_ARCHITECTURE', '사찰', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'HISTORY_ARCHITECTURE', '성당', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'HISTORY_ARCHITECTURE', '교회', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'HISTORY_ARCHITECTURE', '템플스테이', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (category, name) DO NOTHING; - - -- 8. 체험/클래스 (9개) - INSERT INTO public.interest (id, category, name, created_at, updated_at) - VALUES - (gen_random_uuid(), 'EXPERIENCE_CLASS', '도예/목공', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'EXPERIENCE_CLASS', '향수/비누', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'EXPERIENCE_CLASS', '쿠킹 클래스', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'EXPERIENCE_CLASS', '플라워/가드닝', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'EXPERIENCE_CLASS', '사진/영상 원데이', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'EXPERIENCE_CLASS', '한복 대여', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'EXPERIENCE_CLASS', '농장 체험', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'EXPERIENCE_CLASS', '스포츠 원데이 클래스', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'EXPERIENCE_CLASS', '실내 액티비티/게임', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (category, name) DO NOTHING; - - -- 9. 쇼핑/패션 (10개) - INSERT INTO public.interest (id, category, name, created_at, updated_at) - VALUES - (gen_random_uuid(), 'SHOPPING_FASHION', '편집숍', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'SHOPPING_FASHION', '로컬 브랜드', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'SHOPPING_FASHION', '빈티지', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'SHOPPING_FASHION', '아울렛/몰', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'SHOPPING_FASHION', '홈리빙/인테리어숍', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'SHOPPING_FASHION', '뷰티/드럭스토어', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'SHOPPING_FASHION', '백화점/면세점', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'SHOPPING_FASHION', '캐릭터/팬시샵', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'SHOPPING_FASHION', '플래그십 스토어', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'SHOPPING_FASHION', '팝업스토어/브랜드 행사', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (category, name) DO NOTHING; - - -- 10. 나이트라이프/음주 (10개) - INSERT INTO public.interest (id, category, name, created_at, updated_at) - VALUES - (gen_random_uuid(), 'NIGHTLIFE', '수제맥주', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NIGHTLIFE', '와인바', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NIGHTLIFE', '칵테일바', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NIGHTLIFE', '이자카야/사케바', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NIGHTLIFE', '루프탑 바', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NIGHTLIFE', '재즈클럽', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NIGHTLIFE', '힙합/EDM 클럽', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NIGHTLIFE', '위스키 바', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NIGHTLIFE', 'LP바', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'NIGHTLIFE', '라운지 바', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (category, name) DO NOTHING; - - -- 11. 웰니스/휴식 (6개) - INSERT INTO public.interest (id, category, name, created_at, updated_at) - VALUES - (gen_random_uuid(), 'WELLNESS', '스파/마사지', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'WELLNESS', '찜질방/사우나', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'WELLNESS', '온천', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'WELLNESS', '요가/명상', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'WELLNESS', '북카페/라운지', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'WELLNESS', '삼림욕/휴양림', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (category, name) DO NOTHING; - - -- 12. 가족/아이동반 (9개) - INSERT INTO public.interest (id, category, name, created_at, updated_at) - VALUES - (gen_random_uuid(), 'FAMILY_KIDS', '키즈카페', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FAMILY_KIDS', '키즈존 식당', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FAMILY_KIDS', '과학관/체험관', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FAMILY_KIDS', '아쿠아리움', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FAMILY_KIDS', '동물농장/체험', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FAMILY_KIDS', '테마파크/놀이공원', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FAMILY_KIDS', '어린이 도서관', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FAMILY_KIDS', '직업체험관', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'FAMILY_KIDS', '어린이 공연/전시', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (category, name) DO NOTHING; - - -- 13. K-POP·K-컬처 (9개) - INSERT INTO public.interest (id, category, name, created_at, updated_at) - VALUES - (gen_random_uuid(), 'KPOP_CULTURE', '엔터사 사옥', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'KPOP_CULTURE', '음악방송/사전녹화', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'KPOP_CULTURE', '굿즈샵', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'KPOP_CULTURE', 'K-pop 댄스 원데이', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'KPOP_CULTURE', '팬카페/포토존', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'KPOP_CULTURE', '드라마/영화 촬영지', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'KPOP_CULTURE', 'K-뷰티', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'KPOP_CULTURE', '미디어·스타 맛집', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'KPOP_CULTURE', '아이돌 생일/이벤트 카페', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (category, name) DO NOTHING; - - -- 14. 드라이브/근교 (5개) - INSERT INTO public.interest (id, category, name, created_at, updated_at) - VALUES - (gen_random_uuid(), 'DRIVE_SUBURBS', '해안도로', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'DRIVE_SUBURBS', '야경/전망대', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'DRIVE_SUBURBS', '오토캠핑/차박', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'DRIVE_SUBURBS', '야간 드라이브', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (gen_random_uuid(), 'DRIVE_SUBURBS', '자동차 극장', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (category, name) DO NOTHING; - - ELSE - RAISE NOTICE 'Table "public.interest" does not exist. Skipping migration. JPA will create the table automatically.'; - END IF; - - END -$$; diff --git a/MS-Web/src/main/resources/db/migration/V0.3.0__create_keywords.sql b/MS-Web/src/main/resources/db/migration/V0.3.0__create_keywords.sql new file mode 100644 index 0000000..3a75122 --- /dev/null +++ b/MS-Web/src/main/resources/db/migration/V0.3.0__create_keywords.sql @@ -0,0 +1,102 @@ +-- =================================================================== +-- Flyway Migration: V0.3.0 +-- Description: 키워드 시스템 추가 (keywords, place_keywords 테이블 생성) +-- Author: MapSee Team +-- Date: 2026-01-19 +-- =================================================================== + +-- 1. keywords 테이블 생성 +DO +$$ + BEGIN + -- keywords 테이블이 이미 존재하는지 확인 + IF NOT EXISTS (SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'keywords') THEN + + -- keywords 테이블 생성 + CREATE TABLE public.keywords + ( + id UUID NOT NULL, + keyword VARCHAR(100) NOT NULL, + count INTEGER NOT NULL DEFAULT 1, + trend_score NUMERIC(10, 2) NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_by VARCHAR(255), + updated_by VARCHAR(255), + + CONSTRAINT pk_keywords PRIMARY KEY (id), + CONSTRAINT uk_keywords_keyword UNIQUE (keyword) + ); + + -- 키워드 검색 성능 향상을 위한 인덱스 + CREATE INDEX idx_keywords_keyword ON public.keywords (keyword); + + -- 트렌드 점수 기반 정렬을 위한 인덱스 + CREATE INDEX idx_keywords_trend_score ON public.keywords (trend_score DESC); + + -- 사용 횟수 기반 정렬을 위한 인덱스 + CREATE INDEX idx_keywords_count ON public.keywords (count DESC); + + RAISE NOTICE 'Created keywords table with indexes'; + ELSE + RAISE NOTICE 'keywords table already exists. Skipping creation.'; + END IF; + END +$$; + +-- 2. place_keywords 중간 테이블 생성 +DO +$$ + BEGIN + -- place_keywords 테이블이 이미 존재하는지 확인 + IF NOT EXISTS (SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'place_keywords') THEN + + -- place 테이블 존재 확인 (부모 테이블) + IF EXISTS (SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'place') THEN + + -- keywords 테이블 존재 확인 (부모 테이블) + IF EXISTS (SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'keywords') THEN + + -- place_keywords 테이블 생성 + CREATE TABLE public.place_keywords + ( + place_id UUID NOT NULL, + keyword_id UUID NOT NULL, + + CONSTRAINT pk_place_keywords PRIMARY KEY (place_id, keyword_id), + CONSTRAINT fk_place_keywords_place FOREIGN KEY (place_id) + REFERENCES public.place (id) ON DELETE CASCADE, + CONSTRAINT fk_place_keywords_keyword FOREIGN KEY (keyword_id) + REFERENCES public.keywords (id) ON DELETE CASCADE + ); + + -- 키워드로 장소 검색 성능 향상을 위한 인덱스 + CREATE INDEX idx_place_keywords_keyword_id ON public.place_keywords (keyword_id); + + -- 장소로 키워드 검색 성능 향상을 위한 인덱스 + CREATE INDEX idx_place_keywords_place_id ON public.place_keywords (place_id); + + RAISE NOTICE 'Created place_keywords table with foreign keys and indexes'; + ELSE + RAISE NOTICE 'keywords table does not exist. Skipping place_keywords creation. JPA will create it.'; + END IF; + ELSE + RAISE NOTICE 'place table does not exist. Skipping place_keywords creation. JPA will create it.'; + END IF; + ELSE + RAISE NOTICE 'place_keywords table already exists. Skipping creation.'; + END IF; + END +$$; diff --git a/MS-Web/src/main/resources/db/migration/V0.3.1__alter_member_place.sql b/MS-Web/src/main/resources/db/migration/V0.3.1__alter_member_place.sql new file mode 100644 index 0000000..f57a2a7 --- /dev/null +++ b/MS-Web/src/main/resources/db/migration/V0.3.1__alter_member_place.sql @@ -0,0 +1,128 @@ +-- =================================================================== +-- Flyway Migration: V0.3.1 +-- Description: member_place 테이블에 북마크 기능 필드 추가 +-- Author: MapSee Team +-- Date: 2026-01-19 +-- =================================================================== + +-- member_place 테이블 확장 (북마크 기능 강화) +DO +$$ + BEGIN + -- member_place 테이블 존재 확인 + IF EXISTS (SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'member_place') THEN + + -- folder 컬럼 추가 + IF NOT EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'member_place' + AND column_name = 'folder') THEN + ALTER TABLE public.member_place + ADD COLUMN folder VARCHAR(50) DEFAULT 'default'; + RAISE NOTICE 'Added folder column to member_place'; + ELSE + RAISE NOTICE 'folder column already exists in member_place'; + END IF; + + -- memo 컬럼 추가 + IF NOT EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'member_place' + AND column_name = 'memo') THEN + ALTER TABLE public.member_place + ADD COLUMN memo TEXT; + RAISE NOTICE 'Added memo column to member_place'; + ELSE + RAISE NOTICE 'memo column already exists in member_place'; + END IF; + + -- rating 컬럼 추가 + IF NOT EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'member_place' + AND column_name = 'rating') THEN + ALTER TABLE public.member_place + ADD COLUMN rating INTEGER; + + -- 별점은 1-5 사이의 값만 허용 + ALTER TABLE public.member_place + ADD CONSTRAINT chk_member_place_rating CHECK (rating IS NULL OR (rating >= 1 AND rating <= 5)); + + RAISE NOTICE 'Added rating column to member_place with check constraint'; + ELSE + RAISE NOTICE 'rating column already exists in member_place'; + END IF; + + -- visited 컬럼 추가 + IF NOT EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'member_place' + AND column_name = 'visited') THEN + ALTER TABLE public.member_place + ADD COLUMN visited BOOLEAN NOT NULL DEFAULT FALSE; + RAISE NOTICE 'Added visited column to member_place'; + ELSE + RAISE NOTICE 'visited column already exists in member_place'; + END IF; + + -- visited_at 컬럼 추가 + IF NOT EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'member_place' + AND column_name = 'visited_at') THEN + ALTER TABLE public.member_place + ADD COLUMN visited_at TIMESTAMP WITH TIME ZONE; + RAISE NOTICE 'Added visited_at column to member_place'; + ELSE + RAISE NOTICE 'visited_at column already exists in member_place'; + END IF; + + -- 폴더 기반 검색을 위한 인덱스 추가 + IF NOT EXISTS (SELECT 1 + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'member_place' + AND indexname = 'idx_member_place_folder') THEN + CREATE INDEX idx_member_place_folder ON public.member_place (member_id, folder); + RAISE NOTICE 'Created index idx_member_place_folder'; + ELSE + RAISE NOTICE 'Index idx_member_place_folder already exists'; + END IF; + + -- 방문 여부 필터링을 위한 인덱스 추가 + IF NOT EXISTS (SELECT 1 + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'member_place' + AND indexname = 'idx_member_place_visited') THEN + CREATE INDEX idx_member_place_visited ON public.member_place (member_id, visited); + RAISE NOTICE 'Created index idx_member_place_visited'; + ELSE + RAISE NOTICE 'Index idx_member_place_visited already exists'; + END IF; + + -- 별점 기반 정렬을 위한 인덱스 추가 + IF NOT EXISTS (SELECT 1 + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'member_place' + AND indexname = 'idx_member_place_rating') THEN + CREATE INDEX idx_member_place_rating ON public.member_place (member_id, rating DESC NULLS LAST); + RAISE NOTICE 'Created index idx_member_place_rating'; + ELSE + RAISE NOTICE 'Index idx_member_place_rating already exists'; + END IF; + + ELSE + RAISE NOTICE 'member_place table does not exist. Skipping migration. JPA will create the table with new columns.'; + END IF; + END +$$; diff --git a/MS-Web/src/main/resources/db/migration/V0.3.2__drop_interests.sql b/MS-Web/src/main/resources/db/migration/V0.3.2__drop_interests.sql new file mode 100644 index 0000000..e121ce8 --- /dev/null +++ b/MS-Web/src/main/resources/db/migration/V0.3.2__drop_interests.sql @@ -0,0 +1,66 @@ +-- =================================================================== +-- Flyway Migration: V0.3.2 +-- Description: Interest 관련 테이블 제거 (MapSee에서 불필요) +-- Author: MapSee Team +-- Date: 2026-01-19 +-- =================================================================== + +-- 1. member_interest 중간 테이블 제거 +DO +$$ + BEGIN + IF EXISTS (SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'member_interest') THEN + DROP TABLE public.member_interest CASCADE; + RAISE NOTICE 'Dropped member_interest table'; + ELSE + RAISE NOTICE 'member_interest table does not exist. Skipping.'; + END IF; + END +$$; + +-- 2. interest 테이블 제거 +DO +$$ + BEGIN + IF EXISTS (SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'interest') THEN + DROP TABLE public.interest CASCADE; + RAISE NOTICE 'Dropped interest table'; + ELSE + RAISE NOTICE 'interest table does not exist. Skipping.'; + END IF; + END +$$; + +-- 3. member 테이블에서 interest 관련 컬럼 제거 (있다면) +DO +$$ + BEGIN + IF EXISTS (SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'member') THEN + + -- onboarding_interest_selected 컬럼 제거 (있다면) + IF EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'member' + AND column_name = 'onboarding_interest_selected') THEN + ALTER TABLE public.member + DROP COLUMN onboarding_interest_selected; + RAISE NOTICE 'Dropped onboarding_interest_selected column from member'; + ELSE + RAISE NOTICE 'onboarding_interest_selected column does not exist in member'; + END IF; + + ELSE + RAISE NOTICE 'member table does not exist. Skipping interest column cleanup.'; + END IF; + END +$$; diff --git a/MS-Web/src/main/resources/db/migration/V0.3.3__alter_place_add_opening_hours.sql b/MS-Web/src/main/resources/db/migration/V0.3.3__alter_place_add_opening_hours.sql new file mode 100644 index 0000000..61af5c0 --- /dev/null +++ b/MS-Web/src/main/resources/db/migration/V0.3.3__alter_place_add_opening_hours.sql @@ -0,0 +1,18 @@ +-- V0.3.3: Place 테이블에 opening_hours 컬럼 추가 +-- MapSee AI 서버 연동을 위한 영업시간 필드 추가 + +-- opening_hours 컬럼 추가 (존재하지 않는 경우에만) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'place' + AND column_name = 'opening_hours' + ) THEN + ALTER TABLE place + ADD COLUMN opening_hours VARCHAR(500); + + COMMENT ON COLUMN place.opening_hours IS 'AI 서버에서 추출한 영업시간 정보'; + END IF; +END $$; diff --git a/README.md b/README.md index 297fd76..eaf3a8c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ -## 최신 버전 : v0.1.8 (2026-01-18) +## 최신 버전 : v0.1.13 (2026-01-19) [전체 버전 기록 보기](CHANGELOG.md) diff --git a/build.gradle b/build.gradle index eace6b8..74db046 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ bootJar { allprojects { group = 'kr.suhsaechan.mapsy' - version = '0.1.13' + version = '0.1.16' repositories { mavenCentral() @@ -18,6 +18,11 @@ allprojects { maven { url "https://nexus.suhsaechan.kr/repository/maven-releases/" } + // Suh-Nexus (insecure) + maven { + url "http://suh-project.synology.me:9999/repository/maven-releases/" + allowInsecureProtocol = true + } } } diff --git a/version.yml b/version.yml index 5106076..969bea9 100644 --- a/version.yml +++ b/version.yml @@ -33,11 +33,11 @@ # - project_type은 최초 설정 후 변경하지 마세요 # - 버전은 항상 높은 버전으로 자동 동기화됩니다 # =================================================================== -version: "0.1.13" -version_code: 17 # app build number +version: "0.1.16" +version_code: 20 # app build number project_type: "spring" # spring, flutter, next, react, react-native, react-native-expo, node, python, basic metadata: - last_updated: "2026-01-19 01:19:20" + last_updated: "2026-01-19 08:42:51" last_updated_by: "Cassiiopeia" default_branch: "main" integrated_from: "SUH-DEVOPS-TEMPLATE"