diff --git a/docs/domain.md b/docs/domain.md index a4a1720..36958a0 100644 --- a/docs/domain.md +++ b/docs/domain.md @@ -68,6 +68,15 @@ WhyLog는 팀 회의를 녹음·전사하고 AI로 논의 주제와 결정 근 - GitHub 액세스 토큰은 AES로 암호화해 저장한다(`AESCryptoConverter`). - GitHub API가 401을 반환하면 저장된 토큰을 즉시 폐기한다(`clearGithubToken`) — 만료된 토큰을 계속 재사용하지 않는다. +### 회원 프로필·계정 (Member) + +- 마이페이지(`myInfo`)는 본인 전용이고, 멤버 프로필(`profile`)은 다른 사용자가 보는 공개 화면이다. +- 프로필 기본 정보(이미지, 이름, 이메일)는 공개하되, 활동 영역은 `PUBLIC`일 때만 보인다. `PRIVATE`이면 활동 영역 대신 비공개 안내를 보여준다. +- 가입 이메일은 변경하지 않는다. 이메일 인증 전(`UNVERIFIED`)에는 인증 화면 외 서비스 사용을 막는다. +- 회원 탈퇴는 최소 30일 동안 `INACTIVE`로 보관하고, 이후 처음 도래하는 자정에 개인정보를 제거해 `WITHDRAW`로 남긴다. 보관 중에는 같은 계정으로 로그인해 복구할 수 있다. +- 나간 사용자는 팀 멤버십만 비활성화된 사용자이고, 탈퇴 사용자는 계정 상태가 `INACTIVE` 또는 `WITHDRAW`인 사용자다. +- 탈퇴 요청 시 OWNER가 다른 활성 팀원이 있는 팀을 소유 중이면 막는다. OWNER 혼자 남은 팀만 있으면 탈퇴할 수 있고, 팀 삭제는 팀 도메인에서 별도로 정한다. + ### 커밋 · 레포지토리 (Git) - 커밋은 `(repository_id, hash)` 조합으로 유일해야 한다. diff --git a/server/.env.example b/server/.env.example index 6fc4cfa..2e31a82 100644 --- a/server/.env.example +++ b/server/.env.example @@ -22,6 +22,13 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000 JWT_SECRET=local-dev-jwt-secret-please-change-32chars JWT_ACCESS_TOKEN_EXPIRE_TIME=3600000 JWT_REFRESH_TOKEN_EXPIRE_TIME=1209600000 +# HTTPS 운영 환경에서는 true, 로컬 HTTP에서는 false +REFRESH_TOKEN_COOKIE_SECURE=false + +# 이메일 인증 발송(Gmail SMTP) +GMAIL_SMTP_USERNAME=your-sender@gmail.com +GMAIL_SMTP_APP_PASSWORD= +EMAIL_VERIFICATION_CODE_SECRET=local-dev-email-verification-secret-please-change-32chars # LiveKit 기능을 실제로 쓰지 않으면 기본값(application.yaml)으로 대체됨 LIVEKIT_URL=wss://example.livekit.invalid diff --git a/server/README.md b/server/README.md index 37e97b3..2070962 100644 --- a/server/README.md +++ b/server/README.md @@ -55,10 +55,21 @@ ```bash cp .env.example .env -# .env를 열어 값 채우기 (JWT_SECRET, GITHUB_TOKEN_ENCRYPTION_KEY, AWS_*, FAST_API_BASE_URL 등) -# 실제 기능(S3 업로드·GitHub 연동·LiveKit)을 안 쓸 거면 .env.example의 더미값 그대로 둬도 서버는 기동됩니다. +# .env를 열어 값 채우기 (JWT_SECRET, GMAIL_SMTP_*, EMAIL_VERIFICATION_CODE_SECRET 등) ``` +### 이메일 인증 발송 설정 + +가입 인증 메일은 Gmail SMTP로 발송하므로 실제 서버 실행 전에 아래 값을 채웁니다. + +- `GMAIL_SMTP_USERNAME`: 실제 발신에 사용할 Gmail 주소 +- `GMAIL_SMTP_APP_PASSWORD`: Google 계정 일반 비밀번호가 아니라 Gmail SMTP용 Google 앱 비밀번호입니다. +- `EMAIL_VERIFICATION_CODE_SECRET`: 인증 코드 HMAC에 사용할 32바이트 이상의 임의 문자열 + +Google 계정의 2단계 인증을 켠 뒤 앱 비밀번호를 발급해 `.env` 또는 배포 환경변수에만 저장합니다. 실제 값은 Git에 커밋하지 않습니다. + +- Google 앱 비밀번호 안내: + ### 1) Docker Compose로 실행 로컬에 MySQL/Redis를 직접 설치하지 않고 컨테이너로 띄웁니다. diff --git a/server/build.gradle b/server/build.gradle index a46001f..e9a78cb 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -55,6 +55,7 @@ dependencies { implementation 'org.flywaydb:flyway-mysql' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.boot:spring-boot-starter-aop' compileOnly 'org.projectlombok:lombok' diff --git a/server/scripts/check-env.sh b/server/scripts/check-env.sh index 28e5bfe..fc08216 100755 --- a/server/scripts/check-env.sh +++ b/server/scripts/check-env.sh @@ -12,7 +12,7 @@ set -a source .env set +a -# application.yaml에서 기본값(:) 없이 필수로 요구하는 값만 검사합니다. +# 서버 기동과 핵심 기능에 필요한 값을 검사합니다. # DEV_REDIS_PASSWORD는 필수지만 로컬 Redis에 비밀번호가 없으면 빈 값이 정상이라 제외합니다. REQUIRED_VARS=( DEV_DB_URL @@ -22,6 +22,9 @@ REQUIRED_VARS=( DEV_REDIS_PORT CORS_ALLOWED_ORIGINS JWT_SECRET + GMAIL_SMTP_USERNAME + GMAIL_SMTP_APP_PASSWORD + EMAIL_VERIFICATION_CODE_SECRET GITHUB_TOKEN_ENCRYPTION_KEY AWS_S3_BUCKET AWS_S3_ACCESS_KEY diff --git a/server/src/main/java/com/whylog/server/ServerApplication.java b/server/src/main/java/com/whylog/server/ServerApplication.java index 33cba2d..1183f38 100644 --- a/server/src/main/java/com/whylog/server/ServerApplication.java +++ b/server/src/main/java/com/whylog/server/ServerApplication.java @@ -3,13 +3,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @EnableJpaAuditing @SpringBootApplication public class ServerApplication { - public static void main(String[] args) { - SpringApplication.run(ServerApplication.class, args); - } - + public static void main(String[] args) { + SpringApplication.run(ServerApplication.class, args); + } } diff --git a/server/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java b/server/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java index 9f0dd8f..333c94a 100644 --- a/server/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java +++ b/server/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java @@ -1,17 +1,15 @@ package com.whylog.server.domain.decision.dto; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; -import java.util.List; - public class ApplicationResponse { - @Getter @NoArgsConstructor @AllArgsConstructor @@ -70,7 +68,6 @@ public static class DecisionTimelineItemDTO { @Schema(description = "타임라인 내용", example = "장애 이슈 제기") private String content; - } @Getter @@ -83,20 +80,25 @@ public static class DecisionContextItemDTO { @Schema(description = "타임라인 시간", example = "2026-03-24T12:28:00") private String time; - @Schema(description = "발화자 ID", example = "1", nullable = true) + @Schema( + description = "프로필 링크용 발화자 ID. 나간 사용자/탈퇴한 사용자는 null", + example = "1", + nullable = true) private Long memberId; @Schema(description = "발화자 이름", example = "김주뇽", nullable = true) private String memberName; - @Schema(description = "발화자 프로필 사진", example = "https://example.com/profile.jpg", nullable = true) + @Schema( + description = "발화자 프로필 사진", + example = "https://example.com/profile.jpg", + nullable = true) private String profileImage; @Schema(description = "대화 내용", example = "아니 우리 이거 버그난다니까?!?@??@") private String dialogueContent; } - @Getter @NoArgsConstructor @AllArgsConstructor @@ -109,7 +111,6 @@ public static class DecisionReasonItemDTO { @Schema(description = "근거 내용", example = "운영복잡 우려로 보류") private String title; - } @Getter @@ -216,5 +217,4 @@ public static class CommitConnectionResponseDTO { @Schema(description = "연결된 커밋 ID 목록", example = "[1, 2, 3]") private List commitIds; } - } diff --git a/server/src/main/java/com/whylog/server/domain/decision/repository/DecisionRepository.java b/server/src/main/java/com/whylog/server/domain/decision/repository/DecisionRepository.java index 5f6883c..13eaa49 100644 --- a/server/src/main/java/com/whylog/server/domain/decision/repository/DecisionRepository.java +++ b/server/src/main/java/com/whylog/server/domain/decision/repository/DecisionRepository.java @@ -1,6 +1,8 @@ package com.whylog.server.domain.decision.repository; import com.whylog.server.domain.decision.entity.Decision; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -12,13 +14,15 @@ public interface DecisionRepository extends JpaRepository { Optional findByMeetingId(Long meetingId); @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(""" + @Query( + """ UPDATE Decision d SET d.reliabilityScore = :reliabilityScore WHERE d.id = :decisionId """) - void updateReliabilityScore(@Param("decisionId") Long decisionId, - @Param("reliabilityScore") Integer reliabilityScore); + void updateReliabilityScore( + @Param("decisionId") Long decisionId, + @Param("reliabilityScore") Integer reliabilityScore); @Modifying @Query("DELETE FROM Application a WHERE a.decision.meeting.team.id = :teamId") @@ -35,4 +39,31 @@ void updateReliabilityScore(@Param("decisionId") Long decisionId, @Modifying @Query("DELETE FROM Decision d WHERE d.meeting.id = :meetingId") void deleteByMeetingId(@Param("meetingId") Long meetingId); + + @Query( + """ + SELECT new com.whylog.server.domain.decision.repository.DecisionRepository$RecentDecisionRow( + d.id, + t.id, + t.name, + m.name, + d.createdAt + ) + FROM Decision d + JOIN d.meeting m + JOIN m.team t + JOIN m.meetingMembers mm + JOIN TeamMember tm ON tm.team = t AND tm.member.id = :memberId AND tm.active = true + WHERE mm.member.id = :memberId + ORDER BY d.createdAt DESC, d.id DESC + """) + List findRecentDecisionRowsByMemberId( + @Param("memberId") Long memberId, org.springframework.data.domain.Pageable pageable); + + record RecentDecisionRow( + Long decisionId, + Long projectId, + String projectName, + String name, + LocalDateTime createdAt) {} } diff --git a/server/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java b/server/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java index a138b26..92e2baf 100644 --- a/server/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java +++ b/server/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java @@ -15,9 +15,9 @@ import com.whylog.server.domain.git.exception.GitErrorCode; import com.whylog.server.domain.git.repository.CommitConnectionRepository; import com.whylog.server.domain.git.repository.CommitRepository; -import com.whylog.server.domain.user.entity.Member; -import com.whylog.server.domain.user.service.MemberUseCase; +import com.whylog.server.domain.user.service.MemberDisplayResolver; import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import com.whylog.server.global.external.s3.S3Client; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -37,17 +37,25 @@ public class ApplicationQueryService { private final ApplicationCommitsRepository applicationCommitsRepository; private final CommitRepository commitRepository; private final CommitConnectionRepository commitConnectionRepository; - private final MemberUseCase memberUseCase; + private final MemberDisplayResolver memberDisplayResolver; + private final S3Client s3Client; // 적용사항 상세 조회에 필요한 제목, 타임라인, 원문 맥락, 결정근거를 조회 public ApplicationResponse.ApplicationDetailDTO getApplicationDetail(Long applicationId) { - Application application = applicationRepository.findById(applicationId) - .orElseThrow(ApplicationNotFoundException::new); + Application application = + applicationRepository + .findById(applicationId) + .orElseThrow(ApplicationNotFoundException::new); // 적용사항에 연결된 근거/타임라인 원본 엔티티를 각각 조회 - List applicationBases = applicationBaseRepository.findByApplicationId(applicationId); - List applicationTimelines = applicationTimelineRepository.findByApplicationId(applicationId); - Map membersById = findMembersById(applicationTimelines); + List applicationBases = + applicationBaseRepository.findByApplicationId(applicationId); + List applicationTimelines = + applicationTimelineRepository.findByApplicationId(applicationId); + Map displayMembersById = + memberDisplayResolver.resolveByTeamMemberIds( + application.getDecision().getMeeting().getTeam().getId(), + memberIds(applicationTimelines)); return ApplicationResponse.ApplicationDetailDTO.builder() .applicationId(application.getId()) @@ -55,7 +63,7 @@ public ApplicationResponse.ApplicationDetailDTO getApplicationDetail(Long applic .name(application.getName()) .decisionReasons(toDecisionReasonItems(applicationBases)) .decisionTimelines(toDecisionTimelineItems(applicationTimelines)) - .decisionContexts(toDecisionContextItems(applicationTimelines, membersById)) + .decisionContexts(toDecisionContextItems(applicationTimelines, displayMembersById)) .decisionReasonCount(applicationBases.size()) .build(); } @@ -63,22 +71,32 @@ public ApplicationResponse.ApplicationDetailDTO getApplicationDetail(Long applic // 적용사항에 연결된 커밋 목록을 조회 public ApplicationResponse.ConnectedCommitListDTO getConnectedCommits(Long applicationId) { // 적용사항 존재 여부검증 - applicationRepository.findById(applicationId) + applicationRepository + .findById(applicationId) .orElseThrow(ApplicationNotFoundException::new); // 적용사항에 사용자가 연결한 커밋 목록을 조회 - List commitConnections = commitConnectionRepository.findByApplicationId(applicationId); + List commitConnections = + commitConnectionRepository.findByApplicationId(applicationId); // 연결된 커밋 엔티티를 응답 DTO로 변환 - List commits = commitConnections.stream() - .map(commitConnection -> ApplicationResponse.ConnectedCommitDTO.builder() - .commitId(commitConnection.getCommit().getId()) - .repositoryName(commitConnection.getCommit().getRepository().getName()) - .commitHash(commitConnection.getCommit().getHash()) - .message(commitConnection.getCommit().getMessage()) - .committedDate(commitConnection.getCommit().getDateTime()) - .build()) - .toList(); + List commits = + commitConnections.stream() + .map( + commitConnection -> + ApplicationResponse.ConnectedCommitDTO.builder() + .commitId(commitConnection.getCommit().getId()) + .repositoryName( + commitConnection + .getCommit() + .getRepository() + .getName()) + .commitHash(commitConnection.getCommit().getHash()) + .message(commitConnection.getCommit().getMessage()) + .committedDate( + commitConnection.getCommit().getDateTime()) + .build()) + .toList(); return ApplicationResponse.ConnectedCommitListDTO.builder() .commitCount(commits.size()) @@ -89,19 +107,25 @@ public ApplicationResponse.ConnectedCommitListDTO getConnectedCommits(Long appli // 적용사항의 적용현황 요약 정보를 조회 public ApplicationResponse.ApplicationStatusDTO getApplicationStatus(Long applicationId) { // 적용사항 존재 여부 검증 - applicationRepository.findById(applicationId) + applicationRepository + .findById(applicationId) .orElseThrow(ApplicationNotFoundException::new); // 적용사항에 사용자가 연결한 커밋 목록을 조회 - List commitConnections = commitConnectionRepository.findByApplicationId(applicationId); + List commitConnections = + commitConnectionRepository.findByApplicationId(applicationId); // 연결된 커밋 목록을 적용현황 응답 형식으로 변환 - List commits = commitConnections.stream() - .map(commitConnection -> ApplicationResponse.ApplicationBaseItemDTO.builder() - .commitHash(commitConnection.getCommit().getHash()) - .commitMessage(commitConnection.getCommit().getMessage()) - .build()) - .toList(); + List commits = + commitConnections.stream() + .map( + commitConnection -> + ApplicationResponse.ApplicationBaseItemDTO.builder() + .commitHash(commitConnection.getCommit().getHash()) + .commitMessage( + commitConnection.getCommit().getMessage()) + .build()) + .toList(); return ApplicationResponse.ApplicationStatusDTO.builder() .commitCount(commits.size()) @@ -110,20 +134,26 @@ public ApplicationResponse.ApplicationStatusDTO getApplicationStatus(Long applic } // 적용사항에 추천된 커밋 목록을 조회 - public List getRecommendedCommits(Long applicationId) { + public List getRecommendedCommits( + Long applicationId) { // 적용사항 존재 여부 검증 - applicationRepository.findById(applicationId) + applicationRepository + .findById(applicationId) .orElseThrow(ApplicationNotFoundException::new); // 적용사항과 연결된 추천 커밋 원본 정보를 조회 - List applicationCommits = applicationCommitsRepository.findByApplicationId(applicationId); + List applicationCommits = + applicationCommitsRepository.findByApplicationId(applicationId); // 추천 원본이 들고 있는 commitId 목록으로 실제 커밋 정보를 조회 Map commitsById = findCommitsById(applicationCommits); // 추천 원본과 커밋 정보를 합쳐 응답 DTO로 변환 return applicationCommits.stream() - .filter(applicationCommit -> commitsById.containsKey(applicationCommit.getDecisionCommits().getCommitId())) + .filter( + applicationCommit -> + commitsById.containsKey( + applicationCommit.getDecisionCommits().getCommitId())) .map(applicationCommit -> toRecommendedCommitDTO(applicationCommit, commitsById)) .toList(); } @@ -131,17 +161,21 @@ public List getRecommendedCommits(Long // 추천 커밋 ID 목록에 해당하는 커밋 정보를 조회 private Map findCommitsById(List applicationCommits) { // 추천 커밋 원본에서 커밋 ID 목록을 추출 - List commitIds = applicationCommits.stream() - .map(applicationCommit -> applicationCommit.getDecisionCommits().getCommitId()) - .toList(); + List commitIds = + applicationCommits.stream() + .map( + applicationCommit -> + applicationCommit.getDecisionCommits().getCommitId()) + .toList(); if (commitIds.isEmpty()) { return Map.of(); } - //응답에 필요한 커밋 정보와 레포 이름을 함께 조회 - Map commitsById = commitRepository.findAllWithRepositoryByIdIn(commitIds).stream() - .collect(Collectors.toMap(Commit::getId, Function.identity())); + // 응답에 필요한 커밋 정보와 레포 이름을 함께 조회 + Map commitsById = + commitRepository.findAllWithRepositoryByIdIn(commitIds).stream() + .collect(Collectors.toMap(Commit::getId, Function.identity())); // 추천 원본이 존재하지 않는 커밋을 참조하는 경우 if (commitsById.size() != commitIds.size()) { @@ -152,8 +186,8 @@ private Map findCommitsById(List applicationCo } // 추천 커밋 연결 정보를 응답 DTO로 변환 - private ApplicationResponse.RecommendedCommitDTO toRecommendedCommitDTO(ApplicationCommits applicationCommit, - Map commitsById) { + private ApplicationResponse.RecommendedCommitDTO toRecommendedCommitDTO( + ApplicationCommits applicationCommit, Map commitsById) { Commit commit = commitsById.get(applicationCommit.getDecisionCommits().getCommitId()); return ApplicationResponse.RecommendedCommitDTO.builder() @@ -167,54 +201,78 @@ private ApplicationResponse.RecommendedCommitDTO toRecommendedCommitDTO(Applicat } // 연결 테이블을 따라 적용사항에 속한 결정근거 목록을 응답 DTO로 변환 - private List toDecisionReasonItems(List applicationBases) { + private List toDecisionReasonItems( + List applicationBases) { return applicationBases.stream() - .map(applicationBase -> ApplicationResponse.DecisionReasonItemDTO.builder() - .reasonId(String.valueOf(applicationBase.getDecisionBase().getId())) - .title(applicationBase.getDecisionBase().getContent()) - .build()) + .map( + applicationBase -> + ApplicationResponse.DecisionReasonItemDTO.builder() + .reasonId( + String.valueOf( + applicationBase.getDecisionBase().getId())) + .title(applicationBase.getDecisionBase().getContent()) + .build()) .toList(); } // 타임라인 요약 정보 - private List toDecisionTimelineItems(List applicationTimelines) { + private List toDecisionTimelineItems( + List applicationTimelines) { return applicationTimelines.stream() - .map(applicationTimeline -> ApplicationResponse.DecisionTimelineItemDTO.builder() - .time(applicationTimeline.getDecisionTimeline().getTimestamp()) - .step(applicationTimeline.getDecisionTimeline().getStep()) - .content(applicationTimeline.getDecisionTimeline().getContent()) - .build()) + .map( + applicationTimeline -> + ApplicationResponse.DecisionTimelineItemDTO.builder() + .time( + applicationTimeline + .getDecisionTimeline() + .getTimestamp()) + .step(applicationTimeline.getDecisionTimeline().getStep()) + .content( + applicationTimeline + .getDecisionTimeline() + .getContent()) + .build()) .toList(); } // 원문 맥락은 발화자 정보와 원문 발화를 함께 내려줌 - private List toDecisionContextItems(List applicationTimelines, - Map membersById) { + private List toDecisionContextItems( + List applicationTimelines, + Map displayMembersById) { return applicationTimelines.stream() - .map(applicationTimeline -> { - Long memberId = applicationTimeline.getDecisionTimeline().getMemberId(); - Member member = memberId != null ? membersById.get(memberId) : null; - - return ApplicationResponse.DecisionContextItemDTO.builder() - .time(applicationTimeline.getDecisionTimeline().getTimestamp()) - .memberId(memberId) - .memberName(member != null ? member.getName() : null) - .profileImage(memberUseCase.getProfileImageUrl(member) ) - .dialogueContent(applicationTimeline.getDecisionTimeline().getUtterance()) - .build(); - }) + .map( + applicationTimeline -> { + Long memberId = applicationTimeline.getDecisionTimeline().getMemberId(); + MemberDisplayResolver.DisplayMember displayMember = + memberId == null ? null : displayMembersById.get(memberId); + if (displayMember == null) { + displayMember = memberDisplayResolver.resolve(null, null); + } + + return ApplicationResponse.DecisionContextItemDTO.builder() + .time(applicationTimeline.getDecisionTimeline().getTimestamp()) + .memberId(displayMember.memberId()) + .memberName(displayMember.name()) + .profileImage(profileImageUrl(displayMember)) + .dialogueContent( + applicationTimeline + .getDecisionTimeline() + .getUtterance()) + .build(); + }) .toList(); } - // 타임라인에 포함된 발화자들을 한 번에 조회해 memberId 기준 맵으로 구성한다. - private Map findMembersById(List applicationTimelines) { - List memberIds = applicationTimelines.stream() + private String profileImageUrl(MemberDisplayResolver.DisplayMember displayMember) { + return displayMember == null ? null : s3Client.getFileUrl(displayMember.profileImageKey()); + } + + // 타임라인에 포함된 발화자 ID를 중복 없이 구성한다. + private List memberIds(List applicationTimelines) { + return applicationTimelines.stream() .map(applicationTimeline -> applicationTimeline.getDecisionTimeline().getMemberId()) .filter(memberId -> memberId != null) .distinct() .toList(); - - return memberUseCase.findMembersByIds(memberIds).stream() - .collect(Collectors.toMap(Member::getId, Function.identity())); } } diff --git a/server/src/main/java/com/whylog/server/domain/git/repository/RepositoryRepository.java b/server/src/main/java/com/whylog/server/domain/git/repository/RepositoryRepository.java index 8cbf8c5..6d628b8 100644 --- a/server/src/main/java/com/whylog/server/domain/git/repository/RepositoryRepository.java +++ b/server/src/main/java/com/whylog/server/domain/git/repository/RepositoryRepository.java @@ -1,16 +1,41 @@ package com.whylog.server.domain.git.repository; import com.whylog.server.domain.git.entity.Repository; +import java.time.LocalDateTime; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.List; public interface RepositoryRepository extends JpaRepository { // 팀의 레포지토리를 최근 동기화 시간 기준 내림차순으로 조회 - @Query("SELECT r FROM Repository r " + - "WHERE r.team.id = :teamId " + - "ORDER BY r.lastSyncedAt DESC NULLS LAST, r.id DESC") + @Query( + "SELECT r FROM Repository r " + + "WHERE r.team.id = :teamId " + + "ORDER BY r.lastSyncedAt DESC NULLS LAST, r.id DESC") List findByTeamId(@Param("teamId") Long teamId); + + @Query( + """ + SELECT new com.whylog.server.domain.git.repository.RepositoryRepository$ProfileRepositoryStatsRow( + t.id, + count(distinct r.id), + count(c.id), + max(r.lastSyncedAt) + ) + FROM Team t + LEFT JOIN t.repositories r + LEFT JOIN r.commits c + WHERE t.id IN :teamIds + GROUP BY t.id + """) + List findProfileRepositoryStatsRows( + @Param("teamIds") List teamIds); + + record ProfileRepositoryStatsRow( + Long projectId, + Long repositoryCount, + Long commitCount, + LocalDateTime latestLastSyncedAt) {} } diff --git a/server/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java b/server/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java index 160b3ec..5d1967e 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java @@ -4,12 +4,12 @@ import com.whylog.server.domain.meeting.dto.MeetingResponse; import com.whylog.server.domain.meeting.enums.MeetingStatus; import com.whylog.server.domain.meeting.exception.MeetingErrorCode; -import com.whylog.server.domain.meeting.service.MeetingCommandService; import com.whylog.server.domain.meeting.service.MeetingAnalysisService; +import com.whylog.server.domain.meeting.service.MeetingCommandService; import com.whylog.server.domain.meeting.service.MeetingQueryService; import com.whylog.server.domain.meeting.service.MeetingRtcService; import com.whylog.server.domain.team.exception.TeamErrorCode; -import com.whylog.server.domain.user.exception.MemberErrorStatus; +import com.whylog.server.domain.user.exception.MemberErrorCode; import com.whylog.server.global.apiPayload.ApiResponse; import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExample; import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExamples; @@ -19,6 +19,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -29,7 +30,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.List; @RestController @RequestMapping("/api") @@ -43,22 +43,25 @@ public class MeetingController { private final MeetingAnalysisService meetingAnalysisService; @GetMapping("/teams/{teamId}/meetings") - @Operation(summary = "회의 목록 조회 API", description = """ - + @Operation( + summary = "회의 목록 조회 API", + description = + """ + 특정 팀의 회의 목록을 조회하는 API입니다. status: ONGOING/COMPLETED - status는 필수가 아니며, 기본값은 COMPLETED 입니다. - + elapse : 경과시간 - 시:분:초 형태 - 완료된 회의라면 null로 반환함( 표시할 필요 없으니까 ) - + 페이징 없습니다. - + """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST") }) public ApiResponse> getMeetings( @Parameter(hidden = true) @CurrentMember Long memberId, @@ -68,117 +71,130 @@ public ApiResponse> getMeetings( return ApiResponse.onSuccess(meetingQueryService.getMeetings(teamId, status)); } -// @PostMapping("/meetings/{meetingId}/join") -// @Operation(summary = "회의 입장 API", description = "특정 회의에 입장하는 API입니다.") -// public ApiResponse joinMeeting( -// @PathVariable Long meetingId) { -// return ApiResponse.onSuccess(null); -// } + // @PostMapping("/meetings/{meetingId}/join") + // @Operation(summary = "회의 입장 API", description = "특정 회의에 입장하는 API입니다.") + // public ApiResponse joinMeeting( + // @PathVariable Long meetingId) { + // return ApiResponse.onSuccess(null); + // } @PostMapping("/teams/{teamId}/meetings") - @Operation(summary = "회의 생성 API", description = """ - + @Operation( + summary = "회의 생성 API", + description = + """ + 새로운 회의를 생성하는 API입니다. 생성하면 실시긴 회의방이 하나 생성됩니다. 해당 API는 방 생성만 담당합니다. 회의 참여를 위해서는 웹소켓 연결이 필요합니다. 웹소켓은 JWT 인증, 참여자 상태, WebRTC 시그널링 처리만 담당합니다. 실제 실시간 음성 전달은 WebRTC/SFU 경로를 사용합니다. - + """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_PARAMETER_REQUIRED"), - @ApiErrorCodeExample(value = MemberErrorStatus.class, name = "MEMBER_NOT_FOUND"), - @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NOT_FOUND") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_PARAMETER_REQUIRED"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_NOT_FOUND"), + @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NOT_FOUND") }) public ApiResponse createMeeting( @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long teamId, @Valid @RequestBody MeetingRequest.MeetingCreateDTO request) { - MeetingResponse.MeetingCreateResponseDTO result = meetingCommandService.makeMeetingRoom(memberId, teamId, request); + MeetingResponse.MeetingCreateResponseDTO result = + meetingCommandService.makeMeetingRoom(memberId, teamId, request); return ApiResponse.onSuccess(result); } @GetMapping("/meetings/{meetingId}/rtc-token") - @Operation(summary = "회의 SFU 접속 토큰 발급 API", description = """ + @Operation( + summary = "회의 SFU 접속 토큰 발급 API", + description = + """ 현재 로그인한 사용자가 해당 회의의 LiveKit SFU room에 접속할 수 있도록 join token을 발급합니다. 프론트는 이 토큰과 serverUrl을 사용해 WebRTC 음성 연결을 수립합니다. """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), - @ApiErrorCodeExample(value = MemberErrorStatus.class, name = "MEMBER_NOT_FOUND") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_NOT_FOUND") }) public ApiResponse issueRtcToken( - @Parameter(hidden = true) @CurrentMember Long memberId, - @PathVariable Long meetingId - ) { + @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long meetingId) { return ApiResponse.onSuccess(meetingRtcService.issueRtcToken(memberId, meetingId)); } @PatchMapping("/meetings/{meetingId}/end") - @Operation(summary = "회의 종료 API", description = """ + @Operation( + summary = "회의 종료 API", + description = + """ 진행 중인 회의를 종료하는 API입니다. 회의 종료 시 실시간 회의 참여자들에게 종료를 알리는 웹소켓 메시지를 전송합니다. 종료 이후의 음성 파일 처리, STT, 회의 분석은 비동기 후처리 파이프라인에서 수행합니다. """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_INVALID_MEMBER"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_ALREADY_ENDED") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_INVALID_MEMBER"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_ALREADY_ENDED") }) public ApiResponse endMeeting( - @Parameter(hidden= true) @CurrentMember Long memberId, - @PathVariable Long meetingId) { + @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long meetingId) { return ApiResponse.onSuccess(meetingCommandService.endMeeting(memberId, meetingId)); } @PostMapping("/meetings/{meetingId}/analysis-test") - @Operation(summary = "회의 분석 저장 테스트 API", description = """ + @Operation( + summary = "회의 분석 저장 테스트 API", + description = + """ FastAPI 상태 조회 응답 JSON을 그대로 전달해 결정사항/적용사항 저장 로직만 테스트하는 API입니다. 외부 분석 호출 없이 저장 흐름만 검증할 때 사용합니다. """) public ApiResponse analyzeTestResponse( @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long meetingId, - @RequestBody MeetingRequest.MeetingAnalysisTestDTO request - ) { + @RequestBody MeetingRequest.MeetingAnalysisTestDTO request) { meetingAnalysisService.persistTestMeetingAnalysis(memberId, meetingId, request); return ApiResponse.onSuccess(null); } @DeleteMapping("/meetings/{meetingId}") - @Operation(summary = "회의 삭제 API", description = """ + @Operation( + summary = "회의 삭제 API", + description = + """ 특정 회의를 삭제하는 API입니다. 회의 삭제 시 회의 참여자, 대화 기록, 결정사항, 분석 데이터가 함께 삭제됩니다. """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_OWNER") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_OWNER") }) public ApiResponse deleteMeeting( - @Parameter(hidden = true) @CurrentMember Long memberId, - @PathVariable Long meetingId - ) { + @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long meetingId) { return ApiResponse.onSuccess(meetingCommandService.deleteMeeting(memberId, meetingId)); } @GetMapping("/meetings/{meetingId}") - @Operation(summary = "회의 기본 정보 조회 API", description = """ + @Operation( + summary = "회의 기본 정보 조회 API", + description = + """ 특정 회의의 기본 정보를 조회하는 API입니다. 회의명, 날짜, 기간, 참여자 정보를 제공합니다. 본 API에서 분석결과, 대화기록은 제공하지 않습니다. """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_PARAMETER_REQUIRED"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_PARAMETER_REQUIRED"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND") }) public ApiResponse getMeetingDetail( @PathVariable Long meetingId) { @@ -188,30 +204,32 @@ public ApiResponse getMeetingDetail( @GetMapping("/meetings/{meetingId}/history") @Operation(summary = "회의 대화 기록 조회 API", description = "특정 회의의 대화 기록을 조회하는 API입니다.") @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_END"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_UNNORMAL_END") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_END"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_UNNORMAL_END") }) - public ApiResponse getHistory( - @PathVariable Long meetingId) { + public ApiResponse getHistory(@PathVariable Long meetingId) { return ApiResponse.onSuccess(meetingQueryService.getDialogueHistory(meetingId)); } @GetMapping("/meetings/{meetingId}/analysis") - @Operation(summary = "회의 분석 결과 조회 API", description = """ - + @Operation( + summary = "회의 분석 결과 조회 API", + description = + """ + 회의 분석 결과를 조회하는 API입니다.
회의가 존재하지 않으면 MEETING_NOT_FOUND(404)를 반환하고
회의가 존재하지만 아직 분석 중이거나 분석 결과를 만들지 못 헀을 경우에는
200응답으로 is_analyzed=false인 응답을 반환합니다.
- + """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND") }) public ApiResponse getAnalysisResult( @PathVariable Long meetingId) { @@ -219,7 +237,10 @@ public ApiResponse getAnalysisResult( } @GetMapping("/meetings/{meetingId}/audio") - @Operation(summary = "오디오 리플레이 API", description = """ + @Operation( + summary = "오디오 리플레이 API", + description = + """ 회의 녹음본을 브라우저에서 바로 재생할 수 있는 정보를 조회하는 API입니다. 응답의 `audioUrl`은 10분짜리 presigned URL입니다. @@ -228,20 +249,19 @@ public ApiResponse getAnalysisResult( `audioDuration`은 초 단위 재생 시간이며, 녹음본이 없거나 길이를 확인할 수 없으면 `null`입니다. """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_AUDIO_NOT_READY") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_AUDIO_NOT_READY") }) - public ApiResponse getAudio( - @PathVariable Long meetingId) { + public ApiResponse getAudio(@PathVariable Long meetingId) { return ApiResponse.onSuccess(meetingQueryService.getMeetingAudio(meetingId)); } -// @GetMapping("/meetings/{meetingId}/applications") -// @Operation(summary = "적용사항 목록 조회 API", description = "특정 회의의 적용사항 목록을 조회하는 API입니다.") -// public ApiResponse> getApplications( -// @PathVariable Long meetingId) { -// return ApiResponse.onSuccess(null); -// } + // @GetMapping("/meetings/{meetingId}/applications") + // @Operation(summary = "적용사항 목록 조회 API", description = "특정 회의의 적용사항 목록을 조회하는 API입니다.") + // public ApiResponse> getApplications( + // @PathVariable Long meetingId) { + // return ApiResponse.onSuccess(null); + // } } diff --git a/server/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java b/server/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java index 8a78924..180aff0 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java @@ -2,14 +2,13 @@ import com.whylog.server.domain.meeting.entity.MeetingAnalysis; import com.whylog.server.domain.meeting.enums.MeetingStatus; -import com.whylog.server.domain.meeting.socket.MeetingParticipant; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; -import java.util.List; public class MeetingResponse { @@ -31,7 +30,6 @@ public static class MeetingListDTO { @Schema(description = "경과시간 (시:분:초)", example = "00:00:00") private String elapse; - } @Getter @@ -158,8 +156,7 @@ public static class MeetingDetailDTO { @Schema( description = "녹음 파일의 재생 시간(초). 녹음본이 아직 없거나 길이를 확인할 수 없으면 null 입니다.", example = "120", - nullable = true - ) + nullable = true) private Integer audioDuration; } @@ -183,15 +180,19 @@ public static class HistoryListDTO { @Schema(description = "참여자 정보") public static class ParticipantDTO { - @Schema(description = "참여자 ID", example = "1") + @Schema( + description = "프로필 링크용 참여자 ID. 나간 사용자/탈퇴한 사용자는 null", + example = "1", + nullable = true) private Long memberId; @Schema(description = "참여자명", example = "김준용") private String name; - @Schema(description = "참여자 프로필 사진 URL", example = "https://example.com/profile/user-1.jpg") + @Schema( + description = "참여자 프로필 사진 URL", + example = "https://example.com/profile/user-1.jpg") private String profileImage; - } @Getter @@ -201,17 +202,27 @@ public static class ParticipantDTO { @Schema(description = "개별 대화 기록") public static class DialogueDTO { - @Schema(description = "발화자 id", example = "1") + @Schema( + description = "프로필 링크용 발화자 id. 나간 사용자/탈퇴한 사용자는 null", + example = "1", + nullable = true) private Long memberId; + @Schema(description = "발화자 표시 이름", example = "김준용", nullable = true) + private String name; + + @Schema( + description = "발화자 프로필 사진 URL", + example = "https://example.com/profile/user-1.jpg", + nullable = true) + private String profileImage; + @Schema(description = "대화 내용", example = "아니 우리 이거 버그난다니까?!?@??@") private String content; @Schema(description = "말한 시간", example = "00:22") private String timestamp; - } - } @Getter @@ -239,23 +250,32 @@ public static class AnalysisResultDTO { @Schema(description = "회의 재생 시간(초)", example = "43", nullable = true) private Integer audioDuration; - @Schema(description = "논의 주제 목록", example = "[\"서버 저장 상태 확인\", \"배포 DB 환경\"]", nullable = true) + @Schema( + description = "논의 주제 목록", + example = "[\"서버 저장 상태 확인\", \"배포 DB 환경\"]", + nullable = true) private List topics; - @Schema(description = "핵심 맥락 목록", example = "[\"서버에 데이터가 저장되지 않은 상태로 추정됨\"]", nullable = true) + @Schema( + description = "핵심 맥락 목록", + example = "[\"서버에 데이터가 저장되지 않은 상태로 추정됨\"]", + nullable = true) private List coreContext; - @Schema(description = "적용사항 제목 목록", example = "[\"서버 저장 절차 정리\", \"배포 DB 점검\"]", nullable = true) + @Schema( + description = "적용사항 제목 목록", + example = "[\"서버 저장 절차 정리\", \"배포 DB 점검\"]", + nullable = true) private List applicationTitles; - @Schema(description = "적용사항 사유 목록", example = "[\"저장 실패 원인을 추적하기 위해\", \"배포 환경 차이를 확인하기 위해\"]", nullable = true) + @Schema( + description = "적용사항 사유 목록", + example = "[\"저장 실패 원인을 추적하기 위해\", \"배포 환경 차이를 확인하기 위해\"]", + nullable = true) private List applicationReasons; public static AnalysisResultDTO createFalse(Long meetingId) { - return AnalysisResultDTO.builder() - .meetingId(meetingId) - .isAnalyzed(false) - .build(); + return AnalysisResultDTO.builder().meetingId(meetingId).isAnalyzed(false).build(); } public static AnalysisResultDTO create(MeetingAnalysis ma, Integer audioDuration) { @@ -272,7 +292,6 @@ public static AnalysisResultDTO create(MeetingAnalysis ma, Integer audioDuration .applicationReasons(ma.getApplicationReasons()) .build(); } - } @Getter @@ -284,20 +303,21 @@ public static class AudioDTO { @Schema(description = "회의 ID", example = "1") private Long meetingId; - @Schema(description = "실제 재생 가능한 오디오 저장 키(S3 object key). 예: recordings/meeting-1-audio.mp4", example = "recordings/meeting-1-audio.mp4") + @Schema( + description = + "실제 재생 가능한 오디오 저장 키(S3 object key). 예: recordings/meeting-1-audio.mp4", + example = "recordings/meeting-1-audio.mp4") private String audioKey; @Schema( description = "10분짜리 presigned URL. 브라우저는 이 URL로 오디오를 직접 받아 재생합니다.", - example = "https://example.com/presigned-audio-url" - ) + example = "https://example.com/presigned-audio-url") private String audioUrl; @Schema( description = "녹음 파일의 재생 시간(초). 아직 파일이 없거나 길이를 알 수 없으면 null 입니다.", example = "120", - nullable = true - ) + nullable = true) private Integer audioDuration; } @@ -320,9 +340,9 @@ public static class ApplicationDTO { @AllArgsConstructor @Builder @Schema(description = "미팅 내 참여자 정보") - public static class MeetingParticipantInfo{ + public static class MeetingParticipantInfo { - @Schema(description = "멤버 id", example = "1") + @Schema(description = "프로필 링크용 멤버 id. 나간 사용자/탈퇴한 사용자는 null", example = "1", nullable = true) private Long memberId; @Schema(description = "유저이름", example = "아무개") @@ -330,7 +350,5 @@ public static class MeetingParticipantInfo{ @Schema(description = "프로필이미지", example = "https://example.com/profile/user-1.jpg") private String profileImage; - } - } diff --git a/server/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java b/server/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java index 9eeefa0..d29b0e7 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java @@ -1,18 +1,18 @@ package com.whylog.server.domain.meeting.repository; import com.whylog.server.domain.meeting.entity.Meeting; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.List; -import java.util.Optional; -import java.time.LocalDateTime; - public interface MeetingRepository extends JpaRepository { - @Query(""" + @Query( + """ SELECT m FROM Meeting m LEFT JOIN FETCH m.meetingAnalysis @@ -21,16 +21,19 @@ public interface MeetingRepository extends JpaRepository { """) List findWithAnalysis(@Param("teamId") Long teamId); - @Query(""" + @Query( + """ SELECT DISTINCT m FROM Meeting m + JOIN FETCH m.team LEFT JOIN FETCH m.meetingMembers mm LEFT JOIN FETCH mm.member WHERE m.id = :meetingId """) Optional findWithMembers(@Param("meetingId") Long meetingId); - @Query(""" + @Query( + """ SELECT m.id FROM Meeting m WHERE m.team.id = :teamId @@ -38,34 +41,86 @@ public interface MeetingRepository extends JpaRepository { List findIdsByTeamId(@Param("teamId") Long teamId); @Modifying - @Query(""" + @Query( + """ DELETE FROM Meeting m WHERE m.team.id = :teamId """) void deleteByTeamId(@Param("teamId") Long teamId); - @Query(""" + @Query( + """ SELECT m FROM Meeting m + JOIN FETCH m.team LEFT JOIN FETCH m.meetingAnalysis ma WHERE m.id = :meetingId """) Optional findByMeetingId(@Param("meetingId") Long meetingId); @Modifying - @Query(""" + @Query( + """ UPDATE Meeting m SET m.endDateTime = :endedAt WHERE m.endDateTime IS NULL """) int markAllOngoingMeetingsAsEnded(@Param("endedAt") LocalDateTime endedAt); - @Query(""" - SELECT m FROM Meeting m + @Query( + """ + SELECT DISTINCT m FROM Meeting m + JOIN FETCH m.team LEFT JOIN FETCH m.dialogues d - LEFT JOIN FETCH m.meetingMembers mm - JOIN FETCH d.member dm + LEFT JOIN FETCH d.member dm WHERE m.id = :meetingId """) Optional findWithDialogue(@Param("meetingId") Long meetingId); + @Query( + """ + SELECT new com.whylog.server.domain.meeting.repository.MeetingRepository$ProfileMeetingStatsRow( + m.team.id, + count(m.id), + cast(coalesce(sum(timestampdiff(SECOND, m.startDateTime, m.endDateTime)), 0) as long) + ) + FROM Meeting m + JOIN m.meetingMembers mm + WHERE m.team.id IN :teamIds + AND mm.member.id = :memberId + AND m.endDateTime IS NOT NULL + GROUP BY m.team.id + """) + List findMemberCompletedMeetingStats( + @Param("memberId") Long memberId, @Param("teamIds") List teamIds); + + @Query( + """ + SELECT new com.whylog.server.domain.meeting.repository.MeetingRepository$RecentMeetingRow( + m.id, + t.id, + t.name, + m.name, + m.endDateTime, + cast(timestampdiff(SECOND, m.startDateTime, m.endDateTime) as long) + ) + FROM Meeting m + JOIN m.team t + JOIN m.meetingMembers mm + JOIN TeamMember tm ON tm.team = t AND tm.member.id = :memberId AND tm.active = true + WHERE mm.member.id = :memberId + AND m.endDateTime IS NOT NULL + ORDER BY m.endDateTime DESC, m.id DESC + """) + List findRecentCompletedMeetingRowsByMemberId( + @Param("memberId") Long memberId, org.springframework.data.domain.Pageable pageable); + + record ProfileMeetingStatsRow(Long projectId, Long meetingCount, Long durationSeconds) {} + + record RecentMeetingRow( + Long meetingId, + Long projectId, + String projectName, + String name, + LocalDateTime endedAt, + Long durationSeconds) {} } diff --git a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java index cca9c13..cd714ee 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java @@ -9,24 +9,28 @@ import com.whylog.server.domain.decision.entity.DecisionBase; import com.whylog.server.domain.decision.entity.DecisionTimeline; import com.whylog.server.domain.decision.repository.ApplicationBaseRepository; -import com.whylog.server.domain.decision.repository.ApplicationTimelineRepository; import com.whylog.server.domain.decision.repository.ApplicationRepository; +import com.whylog.server.domain.decision.repository.ApplicationTimelineRepository; import com.whylog.server.domain.decision.repository.DecisionBaseRepository; -import com.whylog.server.domain.decision.repository.DecisionTimelineRepository; import com.whylog.server.domain.decision.repository.DecisionRepository; +import com.whylog.server.domain.decision.repository.DecisionTimelineRepository; import com.whylog.server.domain.decision.service.DecisionCommitMatchService; import com.whylog.server.domain.meeting.dto.MeetingResponse; import com.whylog.server.domain.meeting.entity.Dialogue; import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingAnalysis; import com.whylog.server.domain.meeting.entity.MeetingMember; -import com.whylog.server.domain.meeting.exception.MeetingInvalidMemberException; import com.whylog.server.domain.meeting.exception.MeetingAudioNotReadyException; +import com.whylog.server.domain.meeting.exception.MeetingInvalidMemberException; +import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; import com.whylog.server.domain.meeting.repository.MeetingAnalysisRepository; import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; +import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.user.entity.Member; -import com.whylog.server.global.external.fast.client.FastApiTranscribeClient; +import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; +import com.whylog.server.global.apiPayload.exception.GeneralException; import com.whylog.server.global.external.fast.client.FastApiMeetingAnalysisClient; +import com.whylog.server.global.external.fast.client.FastApiTranscribeClient; import com.whylog.server.global.external.fast.dto.FastApiResponse; import com.whylog.server.global.external.fast.dto.request.ApplicationEmbeddingsRequest; import com.whylog.server.global.external.fast.dto.response.ApplicationEmbeddingsResponse; @@ -34,16 +38,14 @@ import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunResponse; import com.whylog.server.global.external.fast.exception.FastApiErrorCode; import com.whylog.server.global.external.fast.exception.FastApiException; -import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; -import com.whylog.server.global.apiPayload.exception.GeneralException; import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Map; import java.util.List; -import java.util.function.Function; +import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -64,7 +66,7 @@ public class MeetingAnalysisService { private static final Duration RUN_POLL_WAIT_INTERVAL = Duration.ofSeconds(3); private static final int MAX_RUN_POLL_ATTEMPTS = 120; - private final MeetingUseCase meetingUseCase; + private final MeetingRepository meetingRepository; private final MeetingAudioReplayService meetingAudioReplayService; private final MeetingAudioFileService meetingAudioFileService; private final FastApiTranscribeClient fastApiTranscribeClient; @@ -84,7 +86,7 @@ public class MeetingAnalysisService { // 회의 종료 후 오디오 분석 전체 흐름을 시작한다. ( 외부 클래스에서 호출 ) public void analyzeMeetingAudio(Long meetingId) { - Meeting meeting = meetingUseCase.findMeetingWithMembersById(meetingId); + Meeting meeting = findMeetingWithMembers(meetingId); MeetingResponse.AudioDTO audioResponse = resolveAudioWithRetry(meeting); String runId = createTranscribeApplicationRun(meeting, audioResponse); @@ -93,14 +95,15 @@ public void analyzeMeetingAudio(Long meetingId) { } // FastAPI 응답 JSON을 받아 저장 로직을 테스트한다. - public void persistTestMeetingAnalysis(Long memberId, - Long meetingId, - com.whylog.server.domain.meeting.dto.MeetingRequest.MeetingAnalysisTestDTO request) { + public void persistTestMeetingAnalysis( + Long memberId, + Long meetingId, + com.whylog.server.domain.meeting.dto.MeetingRequest.MeetingAnalysisTestDTO request) { if (!meetingMemberRepository.existsByMemberIdAndMeetingId(memberId, meetingId)) { throw new MeetingInvalidMemberException(); } - Meeting meeting = meetingUseCase.findMeetingWithMembersById(meetingId); + Meeting meeting = findMeetingWithMembers(meetingId); TranscribeApplicationRunResponse response = request.getResult(); if (response == null) { throw new FastApiException(FastApiErrorCode.FAST_API_RESPONSE_EMPTY); @@ -129,22 +132,23 @@ private MeetingResponse.AudioDTO resolveAudioWithRetry(Meeting meeting) { } // FastAPI 전사용 비동기 실행(run)을 생성하고 runId를 반환한다. - private String createTranscribeApplicationRun(Meeting meeting, MeetingResponse.AudioDTO audioResponse) { + private String createTranscribeApplicationRun( + Meeting meeting, MeetingResponse.AudioDTO audioResponse) { String audioKey = audioResponse.getAudioKey(); String audioUrl = audioResponse.getAudioUrl(); String audioFilename = meetingAudioFileService.extractFileName(audioKey); String contentType = meetingAudioFileService.resolveResponseContentType(audioKey); String liveMessagesJson = meetingLiveMessageBundleService.buildLiveMessagesJson(meeting); - FastApiResponse createResponse = fastApiTranscribeClient.createTranscribeApplicationRun( - buildAudioResource(audioUrl), - audioFilename, - contentType, - null, - String.valueOf(meeting.getId()), - null, - liveMessagesJson - ); + FastApiResponse createResponse = + fastApiTranscribeClient.createTranscribeApplicationRun( + buildAudioResource(audioUrl), + audioFilename, + contentType, + null, + String.valueOf(meeting.getId()), + null, + liveMessagesJson); String runId = requireResult(createResponse).runId(); log.info("회의 오디오 분석 run 생성 완료: meetingId={}, runId={}", meeting.getId(), runId); @@ -157,7 +161,8 @@ private TranscribeApplicationRunResponse pollTranscribeApplicationRun(String run for (int attempt = 1; attempt <= MAX_RUN_POLL_ATTEMPTS; attempt++) { try { - FastApiResponse response = fastApiTranscribeClient.getTranscribeApplicationRun(runId); + FastApiResponse response = + fastApiTranscribeClient.getTranscribeApplicationRun(runId); TranscribeApplicationRunResponse runResponse = requireResult(response); if (isFailed(runResponse)) { @@ -192,7 +197,8 @@ private boolean isCompleted(TranscribeApplicationRunResponse response) { // run이 실패 상태인지 확인한다. private boolean isFailed(TranscribeApplicationRunResponse response) { - return "failed".equalsIgnoreCase(response.status()) || "failed".equalsIgnoreCase(response.phase()); + return "failed".equalsIgnoreCase(response.status()) + || "failed".equalsIgnoreCase(response.phase()); } // FastAPI run not found가 일시적 상태인지 판별한다. @@ -200,11 +206,13 @@ private boolean isTransientRunNotFound(FastApiException exception) { Throwable cause = exception.getCause(); while (cause != null) { if (cause instanceof HttpClientErrorException httpClientErrorException - && httpClientErrorException.getStatusCode().value() == HttpStatus.NOT_FOUND.value()) { + && httpClientErrorException.getStatusCode().value() + == HttpStatus.NOT_FOUND.value()) { return true; } if (cause instanceof RestClientResponseException restClientResponseException - && restClientResponseException.getStatusCode().value() == HttpStatus.NOT_FOUND.value()) { + && restClientResponseException.getStatusCode().value() + == HttpStatus.NOT_FOUND.value()) { return true; } cause = cause.getCause(); @@ -213,87 +221,121 @@ private boolean isTransientRunNotFound(FastApiException exception) { } // FastAPI 분석 결과를 회의 도메인 엔티티와 대화 목록으로 저장한다. - private void persistMeetingAnalysis(Meeting meeting, TranscribeApplicationRunResponse response) { - TranscribeApplicationRunResponse.TranscribeApplicationRunResult runResult = response.result(); + private void persistMeetingAnalysis( + Meeting meeting, TranscribeApplicationRunResponse response) { + TranscribeApplicationRunResponse.TranscribeApplicationRunResult runResult = + response.result(); if (runResult == null) { throw new FastApiException(FastApiErrorCode.FAST_API_RESPONSE_EMPTY); } List transcriptSegments = Optional.ofNullable(runResult.transcriptSegments()).orElseGet(List::of); - TranscribeApplicationRunResponse.AnalysisResultResponse analysisResult = runResult.analysisResult(); + TranscribeApplicationRunResponse.AnalysisResultResponse analysisResult = + runResult.analysisResult(); TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis = analysisResult != null ? analysisResult.overallAnalysis() : null; List applications = - analysisResult != null && analysisResult.applications() != null ? analysisResult.applications() : List.of(); - MeetingAnalysis.MeetingAnalysisPayload payload = buildMeetingAnalysisPayload(overallAnalysis); - - SavedApplications savedApplications = transactionTemplate.execute(status -> { - Meeting managedMeeting = meetingUseCase.findMeetingWithMembersById(meeting.getId()); - MeetingAnalysis meetingAnalysis = meetingAnalysisRepository.findByMeetingId(managedMeeting.getId()) - .map(existingMeetingAnalysis -> { - existingMeetingAnalysis.updateAnalysis(payload); - return existingMeetingAnalysis; - }) - .orElseGet(() -> MeetingAnalysis.create(managedMeeting, payload)); - meetingAnalysisRepository.save(meetingAnalysis); - managedMeeting.attachMeetingAnalysis(meetingAnalysis); - - List dialogues = buildDialogues(managedMeeting, transcriptSegments); - managedMeeting.getDialogues().clear(); - managedMeeting.getDialogues().addAll(dialogues); - - Decision decision = createDecisionIfAbsent(managedMeeting); - return replaceApplications(managedMeeting.getId(), decision, applications); - }); + analysisResult != null && analysisResult.applications() != null + ? analysisResult.applications() + : List.of(); + MeetingAnalysis.MeetingAnalysisPayload payload = + buildMeetingAnalysisPayload(overallAnalysis); + + SavedApplications savedApplications = + transactionTemplate.execute( + status -> { + Meeting managedMeeting = findMeetingWithMembers(meeting.getId()); + MeetingAnalysis meetingAnalysis = + meetingAnalysisRepository + .findByMeetingId(managedMeeting.getId()) + .map( + existingMeetingAnalysis -> { + existingMeetingAnalysis.updateAnalysis( + payload); + return existingMeetingAnalysis; + }) + .orElseGet( + () -> + MeetingAnalysis.create( + managedMeeting, payload)); + meetingAnalysisRepository.save(meetingAnalysis); + managedMeeting.attachMeetingAnalysis(meetingAnalysis); + + List dialogues = + buildDialogues(managedMeeting, transcriptSegments); + managedMeeting.getDialogues().clear(); + managedMeeting.getDialogues().addAll(dialogues); + + Decision decision = createDecisionIfAbsent(managedMeeting); + return replaceApplications( + managedMeeting.getId(), decision, applications); + }); if (savedApplications == null) { savedApplications = SavedApplications.empty(null); } - log.info("회의 오디오 분석 저장 완료: meetingId={}, transcriptSegmentCount={}", meeting.getId(), transcriptSegments.size()); + log.info( + "회의 오디오 분석 저장 완료: meetingId={}, transcriptSegmentCount={}", + meeting.getId(), + transcriptSegments.size()); sendApplicationEmbeddingsSafely(meeting, response, savedApplications); matchApplicationCommitsSafely(meeting, savedApplications); - } // Decision이 없을 때 새로 생성한다. private Decision createDecisionIfAbsent(Meeting meeting) { - return decisionRepository.findByMeetingId(meeting.getId()) - .orElseGet(() -> { - Decision decision = decisionRepository.save(Decision.create(meeting, true)); - log.info("결정사항 저장 완료: meetingId={}, decisionId={}", meeting.getId(), decision.getId()); - return decision; - }); + return decisionRepository + .findByMeetingId(meeting.getId()) + .orElseGet( + () -> { + Decision decision = + decisionRepository.save(Decision.create(meeting, true)); + log.info( + "결정사항 저장 완료: meetingId={}, decisionId={}", + meeting.getId(), + decision.getId()); + return decision; + }); } // 분석 결과의 적용사항 제목 목록을 저장한다. - private SavedApplications replaceApplications(Long meetingId, - Decision decision, - List applications) { + private SavedApplications replaceApplications( + Long meetingId, + Decision decision, + List applications) { applicationBaseRepository.deleteByMeetingId(meetingId); applicationTimelineRepository.deleteByMeetingId(meetingId); decisionBaseRepository.deleteByMeetingId(meetingId); decisionTimelineRepository.deleteByMeetingId(meetingId); applicationRepository.deleteByMeetingId(meetingId); - List validApplications = applications.stream() - .filter(application -> application != null - && application.applicationTitle() != null - && !application.applicationTitle().isBlank()) - .toList(); - - List newApplications = validApplications.stream() - .map(application -> Application.create( - decision, - application.applicationTitle().trim() - )) - .toList(); + List validApplications = + applications.stream() + .filter( + application -> + application != null + && application.applicationTitle() != null + && !application.applicationTitle().isBlank()) + .toList(); + + List newApplications = + validApplications.stream() + .map( + application -> + Application.create( + decision, application.applicationTitle().trim())) + .toList(); if (!newApplications.isEmpty()) { - List savedApplications = applicationRepository.saveAllAndFlush(newApplications); + List savedApplications = + applicationRepository.saveAllAndFlush(newApplications); persistApplicationDetails(savedApplications, validApplications); - log.info("적용사항 저장 완료: meetingId={}, decisionId={}, applicationCount={}", - meetingId, decision.getId(), newApplications.size()); + log.info( + "적용사항 저장 완료: meetingId={}, decisionId={}, applicationCount={}", + meetingId, + decision.getId(), + newApplications.size()); return new SavedApplications(decision.getId(), savedApplications, validApplications); } @@ -301,11 +343,13 @@ private SavedApplications replaceApplications(Long meetingId, } // 저장된 적용사항 엔티티에 reason/timeline 세부 정보를 순서대로 연결 저장한다. - private void persistApplicationDetails(List applications, - List applicationResponses) { + private void persistApplicationDetails( + List applications, + List applicationResponses) { for (int index = 0; index < applications.size(); index++) { Application application = applications.get(index); - TranscribeApplicationRunResponse.ApplicationResponse response = applicationResponses.get(index); + TranscribeApplicationRunResponse.ApplicationResponse response = + applicationResponses.get(index); persistApplicationReasons(application, response.applicationReasons()); persistApplicationTimelines(application, response.timeline()); } @@ -318,141 +362,178 @@ private void persistApplicationReasons(Application application, List rea return; } - List decisionBases = validReasons.stream() - .map(reason -> DecisionBase.create(application.getDecision(), reason.trim())) - .toList(); - List savedDecisionBases = decisionBaseRepository.saveAllAndFlush(decisionBases); - - List applicationBases = savedDecisionBases.stream() - .map(decisionBase -> ApplicationBase.create(application, decisionBase)) - .toList(); + List decisionBases = + validReasons.stream() + .map( + reason -> + DecisionBase.create( + application.getDecision(), reason.trim())) + .toList(); + List savedDecisionBases = + decisionBaseRepository.saveAllAndFlush(decisionBases); + + List applicationBases = + savedDecisionBases.stream() + .map(decisionBase -> ApplicationBase.create(application, decisionBase)) + .toList(); applicationBaseRepository.saveAllAndFlush(applicationBases); } // 적용사항 timeline 목록을 DecisionTimeline/ApplicationTimeline으로 분리 저장한다. - private void persistApplicationTimelines(Application application, - List timelines) { + private void persistApplicationTimelines( + Application application, + List timelines) { if (timelines == null || timelines.isEmpty()) { return; } - List decisionTimelines = timelines.stream() - .filter(timeline -> timeline != null) - .map(timeline -> DecisionTimeline.create( - application.getDecision(), - timeline.timestamp(), - timeline.step(), - timeline.content(), - timeline.memberId(), - timeline.utterance() - )) - .toList(); + List decisionTimelines = + timelines.stream() + .filter(timeline -> timeline != null) + .map( + timeline -> + DecisionTimeline.create( + application.getDecision(), + timeline.timestamp(), + timeline.step(), + timeline.content(), + timeline.memberId(), + timeline.utterance())) + .toList(); if (decisionTimelines.isEmpty()) { return; } - List savedDecisionTimelines = decisionTimelineRepository.saveAllAndFlush(decisionTimelines); - List applicationTimelines = savedDecisionTimelines.stream() - .map(decisionTimeline -> ApplicationTimeline.create(application, decisionTimeline)) - .toList(); + List savedDecisionTimelines = + decisionTimelineRepository.saveAllAndFlush(decisionTimelines); + List applicationTimelines = + savedDecisionTimelines.stream() + .map( + decisionTimeline -> + ApplicationTimeline.create(application, decisionTimeline)) + .toList(); applicationTimelineRepository.saveAllAndFlush(applicationTimelines); } // 저장된 적용사항을 FastAPI 임베딩 요청으로 전달한다. - private void sendApplicationEmbeddingsSafely(Meeting meeting, - TranscribeApplicationRunResponse runResponse, - SavedApplications savedApplications) { + private void sendApplicationEmbeddingsSafely( + Meeting meeting, + TranscribeApplicationRunResponse runResponse, + SavedApplications savedApplications) { if (savedApplications.savedApplications().isEmpty()) { log.info("저장된 적용사항이 없어 임베딩 호출을 생략한다: meetingId={}", meeting.getId()); return; } try { - ApplicationEmbeddingsRequest request = buildEmbeddingsRequest(meeting, runResponse, savedApplications); - FastApiResponse response = fastApiMeetingAnalysisClient.createApplicationEmbeddings(request); - Integer totalDocuments = response != null && response.result() != null ? response.result().totalDocuments() : null; - log.info("적용사항 임베딩 저장 완료: meetingId={}, totalDocuments={}", meeting.getId(), totalDocuments); + ApplicationEmbeddingsRequest request = + buildEmbeddingsRequest(meeting, runResponse, savedApplications); + FastApiResponse response = + fastApiMeetingAnalysisClient.createApplicationEmbeddings(request); + Integer totalDocuments = + response != null && response.result() != null + ? response.result().totalDocuments() + : null; + log.info( + "적용사항 임베딩 저장 완료: meetingId={}, totalDocuments={}", + meeting.getId(), + totalDocuments); } catch (Exception exception) { log.error("적용사항 임베딩 호출 실패: meetingId={}", meeting.getId(), exception); } } // 회의 분석 저장 후 적용사항-커밋 추천 매칭을 자동 실행한다. - private void matchApplicationCommitsSafely(Meeting meeting, SavedApplications savedApplications) { - if (savedApplications.decisionId() == null || savedApplications.savedApplications().isEmpty()) { + private void matchApplicationCommitsSafely( + Meeting meeting, SavedApplications savedApplications) { + if (savedApplications.decisionId() == null + || savedApplications.savedApplications().isEmpty()) { log.info("저장된 적용사항이 없어 커밋 추천 매칭을 생략한다: meetingId={}", meeting.getId()); return; } try { decisionCommitMatchService.matchApplicationCommits(savedApplications.decisionId()); - log.info("적용사항-커밋 추천 매칭 완료: meetingId={}, decisionId={}", - meeting.getId(), savedApplications.decisionId()); + log.info( + "적용사항-커밋 추천 매칭 완료: meetingId={}, decisionId={}", + meeting.getId(), + savedApplications.decisionId()); } catch (GeneralException exception) { ErrorReasonDTO reason = exception.getErrorReason(); - log.error("적용사항-커밋 추천 매칭 실패: meetingId={}, decisionId={}, errorCode={}, message={}", - meeting.getId(), savedApplications.decisionId(), reason.getCode(), reason.getMessage(), exception); + log.error( + "적용사항-커밋 추천 매칭 실패: meetingId={}, decisionId={}, errorCode={}, message={}", + meeting.getId(), + savedApplications.decisionId(), + reason.getCode(), + reason.getMessage(), + exception); } catch (Exception exception) { - log.error("적용사항-커밋 추천 매칭 실패: meetingId={}, decisionId={}", - meeting.getId(), savedApplications.decisionId(), exception); + log.error( + "적용사항-커밋 추천 매칭 실패: meetingId={}, decisionId={}", + meeting.getId(), + savedApplications.decisionId(), + exception); } } // FastAPI 임베딩 요청을 생성한다. - private ApplicationEmbeddingsRequest buildEmbeddingsRequest(Meeting meeting, - TranscribeApplicationRunResponse runResponse, - SavedApplications savedApplications) { - TranscribeApplicationRunResponse.TranscribeApplicationRunResult runResult = runResponse.result(); + private ApplicationEmbeddingsRequest buildEmbeddingsRequest( + Meeting meeting, + TranscribeApplicationRunResponse runResponse, + SavedApplications savedApplications) { + TranscribeApplicationRunResponse.TranscribeApplicationRunResult runResult = + runResponse.result(); TranscribeApplicationRunResponse.AnalysisResultResponse analysisResult = runResult != null ? runResult.analysisResult() : null; TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis = analysisResult != null ? analysisResult.overallAnalysis() : null; List otherMentions = - analysisResult != null && analysisResult.otherMentions() != null ? analysisResult.otherMentions() : List.of(); + analysisResult != null && analysisResult.otherMentions() != null + ? analysisResult.otherMentions() + : List.of(); - List applicationPayloads = new ArrayList<>(); + List applicationPayloads = + new ArrayList<>(); List applications = savedApplications.savedApplications(); - List sourceApplications = savedApplications.sourceApplications(); + List sourceApplications = + savedApplications.sourceApplications(); for (int index = 0; index < applications.size(); index++) { Application savedApplication = applications.get(index); - TranscribeApplicationRunResponse.ApplicationResponse sourceApplication = sourceApplications.get(index); - applicationPayloads.add(new ApplicationEmbeddingsRequest.ApplicationPayload( - savedApplication.getId(), - sourceApplication.applicationTitle(), - sourceApplication.applicationReasons(), - mapTimelinePayloads(sourceApplication.timeline()) - )); + TranscribeApplicationRunResponse.ApplicationResponse sourceApplication = + sourceApplications.get(index); + applicationPayloads.add( + new ApplicationEmbeddingsRequest.ApplicationPayload( + savedApplication.getId(), + sourceApplication.applicationTitle(), + sourceApplication.applicationReasons(), + mapTimelinePayloads(sourceApplication.timeline()))); } return new ApplicationEmbeddingsRequest( String.valueOf(meeting.getId()), null, new ApplicationEmbeddingsRequest.AnalysisResultPayload( - overallAnalysis, - applicationPayloads, - otherMentions - ) - ); + overallAnalysis, applicationPayloads, otherMentions)); } // FastAPI 응답 타임라인을 임베딩 요청 payload로 변환한다. private List mapTimelinePayloads( - List timelines - ) { + List timelines) { if (timelines == null || timelines.isEmpty()) { return List.of(); } return timelines.stream() .filter(timeline -> timeline != null) - .map(timeline -> new ApplicationEmbeddingsRequest.TimelinePayload( - timeline.timestamp(), - timeline.step(), - timeline.memberId(), - timeline.content(), - timeline.utterance() - )) + .map( + timeline -> + new ApplicationEmbeddingsRequest.TimelinePayload( + timeline.timestamp(), + timeline.step(), + timeline.memberId(), + timeline.content(), + timeline.utterance())) .toList(); } @@ -462,33 +543,30 @@ private List safeStrings(List values) { return List.of(); } - return values.stream() - .filter(value -> value != null && !value.isBlank()) - .toList(); + return values.stream().filter(value -> value != null && !value.isBlank()).toList(); } - // OverallAnalysis를 MeetingAnalysis 저장용 payload로 변환한다. private MeetingAnalysis.MeetingAnalysisPayload buildMeetingAnalysisPayload( - TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis - ) { + TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis) { if (overallAnalysis == null) { return new MeetingAnalysis.MeetingAnalysisPayload( - null, - null, - null, - null, - null, - null, - null, - null - ); + null, null, null, null, null, null, null, null); } String analysisContent = serializeOverallAnalysis(overallAnalysis); - String meetingTitle = overallAnalysis.meetingInfo() != null ? overallAnalysis.meetingInfo().title() : null; - String meetingPurpose = overallAnalysis.meetingInfo() != null ? overallAnalysis.meetingInfo().purpose() : null; - String meetingDuration = overallAnalysis.meetingInfo() != null ? overallAnalysis.meetingInfo().duration() : null; + String meetingTitle = + overallAnalysis.meetingInfo() != null + ? overallAnalysis.meetingInfo().title() + : null; + String meetingPurpose = + overallAnalysis.meetingInfo() != null + ? overallAnalysis.meetingInfo().purpose() + : null; + String meetingDuration = + overallAnalysis.meetingInfo() != null + ? overallAnalysis.meetingInfo().duration() + : null; List topics = overallAnalysis.topics(); List coreContext = overallAnalysis.coreContext(); List applicationTitles = overallAnalysis.applicationTitles(); @@ -502,12 +580,12 @@ private MeetingAnalysis.MeetingAnalysisPayload buildMeetingAnalysisPayload( topics, coreContext, applicationTitles, - applicationReasons - ); + applicationReasons); } // OverallAnalysis를 원본 JSON 문자열로 직렬화한다. - private String serializeOverallAnalysis(TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis) { + private String serializeOverallAnalysis( + TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis) { if (overallAnalysis == null) { return null; } @@ -521,15 +599,17 @@ private String serializeOverallAnalysis(TranscribeApplicationRunResponse.Overall } // 전사 세그먼트를 회의 참여자와 매칭해 Dialogue 목록으로 변환한다. - private List buildDialogues(Meeting meeting, - List transcriptSegments) { + private List buildDialogues( + Meeting meeting, + List transcriptSegments) { if (transcriptSegments == null || transcriptSegments.isEmpty()) { return List.of(); } - Map membersById = meeting.getMeetingMembers().stream() - .map(MeetingMember::getMember) - .collect(Collectors.toMap(Member::getId, Function.identity())); + Map membersById = + meeting.getMeetingMembers().stream() + .map(MeetingMember::getMember) + .collect(Collectors.toMap(Member::getId, Function.identity())); if (membersById.isEmpty()) { return List.of(); @@ -537,7 +617,8 @@ private List buildDialogues(Meeting meeting, List dialogues = new ArrayList<>(); for (int index = 0; index < transcriptSegments.size(); index++) { - TranscribeApplicationRunResponse.TranscriptSegmentResponse segment = transcriptSegments.get(index); + TranscribeApplicationRunResponse.TranscriptSegmentResponse segment = + transcriptSegments.get(index); if (segment == null || segment.text() == null || segment.text().isBlank()) { continue; } @@ -547,7 +628,8 @@ private List buildDialogues(Meeting meeting, continue; } - LocalDateTime speechDateTime = resolveSpeechDateTime(meeting.getStartDateTime(), segment.startTime(), index); + LocalDateTime speechDateTime = + resolveSpeechDateTime(meeting.getStartDateTime(), segment.startTime(), index); dialogues.add(Dialogue.create(meeting, member, segment.text().trim(), speechDateTime)); } @@ -555,7 +637,8 @@ private List buildDialogues(Meeting meeting, } // 전사 시작 시각 offset을 회의 시작 시각 기준 LocalDateTime으로 변환한다. - private LocalDateTime resolveSpeechDateTime(LocalDateTime meetingStartDateTime, String offset, int fallbackIndex) { + private LocalDateTime resolveSpeechDateTime( + LocalDateTime meetingStartDateTime, String offset, int fallbackIndex) { if (meetingStartDateTime == null) { return LocalDateTime.now(); } @@ -591,9 +674,10 @@ private Duration parseDuration(String offset) { } } - private record SavedApplications(Long decisionId, - List savedApplications, - List sourceApplications) { + private record SavedApplications( + Long decisionId, + List savedApplications, + List sourceApplications) { private static SavedApplications empty(Long decisionId) { return new SavedApplications(decisionId, List.of(), List.of()); @@ -617,6 +701,12 @@ private UrlResource buildAudioResource(String audioUrl) { } } + private Meeting findMeetingWithMembers(Long meetingId) { + return meetingRepository + .findWithMembers(meetingId) + .orElseThrow(MeetingNotFoundException::new); + } + // 폴링/재시도 사이에 잠시 대기한다. private void sleep(Duration duration) { try { diff --git a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java index 12bbd8e..e9b21ad 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java @@ -6,55 +6,66 @@ import com.whylog.server.domain.meeting.entity.MeetingAnalysis; import com.whylog.server.domain.meeting.entity.MeetingMember; import com.whylog.server.domain.meeting.enums.MeetingStatus; +import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; +import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.user.entity.Member; -import com.whylog.server.domain.user.service.MemberUseCase; +import com.whylog.server.domain.user.service.MemberDisplayResolver; import com.whylog.server.global.apiPayload.exception.ParameterRequiredException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - +import com.whylog.server.global.external.s3.S3Client; import java.time.Duration; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class MeetingQueryService { - private final MeetingUseCase meetingUseCase; + private final MeetingRepository meetingRepository; private final MeetingAudioReplayService meetingAudioReplayService; - private final MemberUseCase memberUseCase; + private final MemberDisplayResolver memberDisplayResolver; + private final S3Client s3Client; // 미팅 목록 조회 - @Transactional(readOnly = true) - public List getMeetings(Long teamId, MeetingStatus status){ + public List getMeetings(Long teamId, MeetingStatus status) { // 기본값: 진행완료 MeetingStatus targetStatus = status != null ? status : MeetingStatus.COMPLETED; - List meetings = meetingUseCase.findMeetingByTeamId(teamId); + List meetings = meetingRepository.findWithAnalysis(teamId); return meetings.stream() - .filter( m -> checkMeetingStatus(m, targetStatus)) // 상태 일치 체크 - .map(m -> MeetingResponse.MeetingListDTO.builder() - .meetingId(m.getId()) - .name(m.getName()) - .status(m.getStatus()) - .elapse( !m.isOngoing() ? m.getElapse() : null ) // 진행완료일 경우 null로 반환 - .build() - ).toList(); - + .filter(m -> checkMeetingStatus(m, targetStatus)) // 상태 일치 체크 + .map( + m -> + MeetingResponse.MeetingListDTO.builder() + .meetingId(m.getId()) + .name(m.getName()) + .status(m.getStatus()) + .elapse( + !m.isOngoing() + ? m.getElapse() + : null) // 진행완료일 경우 null로 반환 + .build()) + .toList(); } // 회의 기본 정보 조회 - @Transactional(readOnly = true) - public MeetingResponse.MeetingDetailDTO getMeetingDefaultInfo(Long meetingId){ + public MeetingResponse.MeetingDetailDTO getMeetingDefaultInfo(Long meetingId) { - if (meetingId == null) { // null check + if (meetingId == null) { throw new ParameterRequiredException(); } - Meeting meeting = meetingUseCase.findMeetingById(meetingId); + Meeting meeting = findMeetingWithMembers(meetingId); + List participants = participants(meeting); + + Map displayMembers = + memberDisplayResolver.resolveByTeam(meeting.getTeam().getId(), participants); return MeetingResponse.MeetingDetailDTO.builder() .meetingId(meeting.getId()) @@ -62,85 +73,134 @@ public MeetingResponse.MeetingDetailDTO getMeetingDefaultInfo(Long meetingId){ .startDateTime(meeting.getStartDateTime()) .endDateTime(meeting.getEndDateTime()) .duration(meeting.getDuration()) - .memberCount( meetingUseCase.getMeetingMemberCount(meeting) ) - .members( memberToParticipantsInfo(meetingUseCase.getParticipantsInfo(meeting)) ) - .audioDuration( meetingAudioReplayService.resolveAudioDurationIfAvailable(meeting) ) + .memberCount(participants.size()) + .members(memberToParticipantsInfo(participants, displayMembers)) + .audioDuration(meetingAudioReplayService.resolveAudioDurationIfAvailable(meeting)) .build(); } - private List memberToParticipantsInfo(List members){ + private List memberToParticipantsInfo( + List members, Map displayMembers) { return members.stream() - .map(member -> MeetingResponse.MeetingParticipantInfo.builder() - .memberId(member.getId()) - .name(member.getName()) - .profileImage(memberUseCase.getProfileImageUrl(member)) - .build() - ).toList(); + .map( + member -> { + MemberDisplayResolver.DisplayMember displayMember = + displayMember(member, displayMembers); + return MeetingResponse.MeetingParticipantInfo.builder() + .memberId(displayMember.memberId()) + .name(displayMember.name()) + .profileImage(profileImageUrl(displayMember)) + .build(); + }) + .toList(); + } + + private List participants(Meeting meeting) { + return meeting.getMeetingMembers().stream().map(MeetingMember::getMember).toList(); } - private boolean checkMeetingStatus(Meeting meeting, MeetingStatus status){ + private boolean checkMeetingStatus(Meeting meeting, MeetingStatus status) { return meeting.getStatus() == status; } - @Transactional(readOnly = true) public MeetingResponse.AudioDTO getMeetingAudio(Long meetingId) { - Meeting meeting = meetingUseCase.findMeetingById(meetingId); + Meeting meeting = findMeeting(meetingId); return meetingAudioReplayService.buildAudioResponse(meeting); } - @Transactional(readOnly = true) public MeetingResponse.AnalysisResultDTO getAnalysis(Long meetingId) { - Meeting meeting = meetingUseCase.findWithAnalysisByMeetingId(meetingId); + Meeting meeting = findMeetingWithAnalysis(meetingId); MeetingAnalysis meetingAnalysis = meeting.getMeetingAnalysis(); - if(meetingAnalysis == null) // null이면 isAnalyzed = false인 응답 반환 + if (meetingAnalysis == null) { return MeetingResponse.AnalysisResultDTO.createFalse(meetingId); + } - Integer audioDuration = meetingAudioReplayService.resolveAudioDurationIfAvailable(meetingAnalysis.getMeeting()); + Integer audioDuration = + meetingAudioReplayService.resolveAudioDurationIfAvailable( + meetingAnalysis.getMeeting()); return MeetingResponse.AnalysisResultDTO.create(meetingAnalysis, audioDuration); } - @Transactional(readOnly = true) public MeetingResponse.HistoryListDTO getDialogueHistory(Long meetingId) { - // 회의 정보 조회 -> 대화 정보, 참여자 같이 조회 - Meeting meeting = meetingUseCase.findWithDialogue(meetingId); + Meeting meeting = findMeetingWithDialogue(meetingId); + Meeting meetingWithMembers = findMeetingWithMembers(meetingId); List dialogues = meeting.getDialogues(); - List members = meeting.getMeetingMembers().stream() - .map(MeetingMember::getMember) - .toList(); - - // dto 생성 및 반환 - return createHistoryListDto(meeting, dialogues, members); + List members = + meetingWithMembers.getMeetingMembers().stream() + .map(MeetingMember::getMember) + .toList(); + Map displayMembers = + memberDisplayResolver.resolveByTeam(meeting.getTeam().getId(), members); + + return createHistoryListDto(meeting, dialogues, members, displayMembers); } - private MeetingResponse.HistoryListDTO createHistoryListDto(Meeting meeting, List dialogues, List members) { + private MeetingResponse.HistoryListDTO createHistoryListDto( + Meeting meeting, + List dialogues, + List members, + Map displayMembers) { return MeetingResponse.HistoryListDTO.builder() - .participants(createParticipantDtos(members)) - .dialogues(createDialogueDtos(meeting, dialogues)) + .participants(createParticipantDtos(members, displayMembers)) + .dialogues(createDialogueDtos(meeting, dialogues, displayMembers)) .build(); } - private List createParticipantDtos(List members) { - return members.stream().map(member -> MeetingResponse.HistoryListDTO.ParticipantDTO.builder() - .memberId(member.getId()) - .name(member.getName()) - .profileImage(memberUseCase.getProfileImageUrl(member)) - .build() - ).toList(); + private List createParticipantDtos( + List members, Map displayMembers) { + return members.stream() + .map( + member -> { + MemberDisplayResolver.DisplayMember displayMember = + displayMember(member, displayMembers); + return MeetingResponse.HistoryListDTO.ParticipantDTO.builder() + .memberId(displayMember.memberId()) + .name(displayMember.name()) + .profileImage(profileImageUrl(displayMember)) + .build(); + }) + .toList(); } - private List createDialogueDtos(Meeting meeting, List dialogues) { + private List createDialogueDtos( + Meeting meeting, + List dialogues, + Map displayMembers) { LocalDateTime startDateTime = meeting.getStartDateTime(); - return dialogues.stream().map(dialogue -> MeetingResponse.HistoryListDTO.DialogueDTO.builder() - .memberId(dialogue.getMember().getId()) - .content(dialogue.getContent()) - .timestamp(formatElapsed(startDateTime, dialogue.getSpeechDateTime())) - .build() - ).toList(); + return dialogues.stream() + .map( + dialogue -> { + MemberDisplayResolver.DisplayMember displayMember = + displayMember(dialogue.getMember(), displayMembers); + return MeetingResponse.HistoryListDTO.DialogueDTO.builder() + .memberId(displayMember.memberId()) + .name(displayMember.name()) + .profileImage(profileImageUrl(displayMember)) + .content(dialogue.getContent()) + .timestamp( + formatElapsed( + startDateTime, dialogue.getSpeechDateTime())) + .build(); + }) + .toList(); + } + + private MemberDisplayResolver.DisplayMember displayMember( + Member member, Map displayMembers) { + if (member == null || member.getId() == null) { + return memberDisplayResolver.resolve(member, null); + } + return displayMembers.getOrDefault( + member.getId(), memberDisplayResolver.resolve(member, null)); + } + + private String profileImageUrl(MemberDisplayResolver.DisplayMember displayMember) { + return displayMember == null ? null : s3Client.getFileUrl(displayMember.profileImageKey()); } private String formatElapsed(LocalDateTime startDateTime, LocalDateTime speechDateTime) { @@ -159,4 +219,25 @@ private String formatElapsed(LocalDateTime startDateTime, LocalDateTime speechDa return String.format("%02d:%02d", minutes, seconds); } + private Meeting findMeeting(Long meetingId) { + return meetingRepository.findById(meetingId).orElseThrow(MeetingNotFoundException::new); + } + + private Meeting findMeetingWithMembers(Long meetingId) { + return meetingRepository + .findWithMembers(meetingId) + .orElseThrow(MeetingNotFoundException::new); + } + + private Meeting findMeetingWithAnalysis(Long meetingId) { + return meetingRepository + .findByMeetingId(meetingId) + .orElseThrow(MeetingNotFoundException::new); + } + + private Meeting findMeetingWithDialogue(Long meetingId) { + return meetingRepository + .findWithDialogue(meetingId) + .orElseThrow(MeetingNotFoundException::new); + } } diff --git a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java deleted file mode 100644 index 6b9a383..0000000 --- a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.whylog.server.domain.meeting.service; - -import com.whylog.server.domain.meeting.entity.Meeting; -import com.whylog.server.domain.meeting.entity.MeetingMember; -import com.whylog.server.domain.meeting.exception.MeetingErrorCode; -import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; -import com.whylog.server.domain.meeting.repository.MeetingRepository; -import com.whylog.server.domain.user.entity.Member; -import com.whylog.server.global.apiPayload.exception.GeneralException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class MeetingUseCase{ - - private final MeetingRepository meetingRepository; - - public Meeting findMeetingById(Long id) { - return meetingRepository.findById(id) - .orElseThrow(MeetingNotFoundException::new); - } - - public Meeting findMeetingWithMembersById(Long id) { - return meetingRepository.findWithMembers(id) - .orElseThrow(MeetingNotFoundException::new); - } - - public List findMeetingByTeamId(Long teamId) { - return meetingRepository.findWithAnalysis(teamId); - } - - // 회의 참여자 수 - public int getMeetingMemberCount(Meeting meeting) { - return meeting.getMeetingMembers().size(); - } - - // 회의의 참여자 정보 - public List getParticipantsInfo(Meeting meeting) { - return meeting.getMeetingMembers().stream() - .map(MeetingMember::getMember) - .toList(); - } - - public Meeting findWithAnalysisByMeetingId(Long meetingId) { - return meetingRepository.findByMeetingId(meetingId) - .orElseThrow(MeetingNotFoundException::new); - } - - public Meeting findWithDialogue(Long meetingId) { - return meetingRepository.findByMeetingId(meetingId) - .orElseThrow(MeetingNotFoundException::new); - } - - -} diff --git a/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java b/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java index 3ff1cff..54d1406 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java @@ -2,10 +2,13 @@ import com.whylog.server.domain.user.entity.Member; import com.whylog.server.domain.user.service.MemberUseCase; -import com.whylog.server.domain.meeting.socket.MeetingSocketRoomService; import com.whylog.server.global.auth.jwt.provider.JwtTokenProvider; import com.whylog.server.global.auth.jwt.provider.JwtValidationType; import jakarta.servlet.http.HttpServletResponse; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; @@ -17,11 +20,6 @@ import org.springframework.web.socket.server.HandshakeInterceptor; import org.springframework.web.util.UriComponentsBuilder; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; -import java.util.Map; -import java.util.Optional; - // 웹소켓 핸드셰이크 시 쿼리 파라미터의 회의 ID와 JWT를 검증하고 세션 속성에 담습니다. @Component @RequiredArgsConstructor @@ -41,9 +39,9 @@ public boolean beforeHandshake( ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, - Map attributes - ) { - MultiValueMap queryParams = UriComponentsBuilder.fromUri(request.getURI()).build().getQueryParams(); + Map attributes) { + MultiValueMap queryParams = + UriComponentsBuilder.fromUri(request.getURI()).build().getQueryParams(); Long meetingId = parseMeetingId(queryParams.getFirst("meetingId")); String token = decode(queryParams.getFirst("accessToken")); @@ -61,6 +59,10 @@ public boolean beforeHandshake( Long memberId = jwtTokenProvider.getMemberIdFromJwt(token); Member member = memberUseCase.findMemberById(memberId); + if (!member.getAccountStatus().canUseNormalService()) { + setStatus(response, HttpServletResponse.SC_UNAUTHORIZED); + return false; + } attributes.put(MEETING_ID_ATTRIBUTE, meetingId); attributes.put(MEMBER_ID_ATTRIBUTE, memberId); @@ -74,9 +76,7 @@ public void afterHandshake( ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, - Exception exception - ) { - } + Exception exception) {} // 문자열 meetingId를 Long 타입으로 안전하게 변환합니다. private Long parseMeetingId(String value) { diff --git a/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java b/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java index d9e82da..c7ce549 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java @@ -7,20 +7,22 @@ import com.whylog.server.domain.meeting.socket.repository.MeetingRoomRepository; import com.whylog.server.domain.meeting.socket.repository.MeetingSocketRoomRepository; import com.whylog.server.global.util.json.JsonConverter; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketMessage; - import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.concurrent.Executor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketMessage; // 회의별 참가자 세션 저장소 역할을 하며 텍스트/오디오 메시지 전달을 담당합니다. @Service +@Slf4j public class MeetingSocketRoomService { private final MeetingRepository meetingRepository; @@ -30,8 +32,7 @@ public class MeetingSocketRoomService { public MeetingSocketRoomService( MeetingRepository meetingRepository, MeetingSocketRoomRepository meetingSocketRoomRepository, - @Qualifier("meetingSocketDispatchExecutor") Executor meetingSocketDispatchExecutor - ) { + @Qualifier("meetingSocketDispatchExecutor") Executor meetingSocketDispatchExecutor) { this.meetingRepository = meetingRepository; this.meetingSocketRoomRepository = meetingSocketRoomRepository; this.meetingSocketDispatchExecutor = meetingSocketDispatchExecutor; @@ -74,6 +75,10 @@ public MeetingParticipant leave(Long meetingId, String sessionId) { } // 특정 멤버가 회의방에 이미 연결되어 있는지 확인합니다. + public void disconnectMemberSessions(Long memberId) { + dispatch(() -> disconnectMemberSessionsInternal(memberId)); + } + public boolean existsParticipant(Long meetingId, Long memberId) { MeetingRoomRepository room = getRoom(meetingId); if (room == null) { @@ -115,12 +120,9 @@ public void sendToMember(Long meetingId, Long targetMemberId, String payload) { public void broadcastMeetingEnded(Long meetingId, LocalDateTime endedAt) { broadcastText( meetingId, - JsonConverter.toJson(new MeetingEndedMessage( - MeetingMessageType.MEETING_ENDED, - meetingId, - endedAt - )) - ); + JsonConverter.toJson( + new MeetingEndedMessage( + MeetingMessageType.MEETING_ENDED, meetingId, endedAt))); } // 회의방이 이미 있으면 반환하고, 없으면 새로 생성해서 반환합니다. @@ -134,10 +136,35 @@ private MeetingRoomRepository getRoom(Long meetingId) { } private void dispatch(Runnable task) { + meetingSocketDispatchExecutor.execute(task); + } + + private void disconnectMemberSessionsInternal(Long memberId) { + for (Long meetingId : meetingSocketRoomRepository.findAllMeetingIds()) { + MeetingRoomRepository room = getRoom(meetingId); + if (room == null) { + continue; + } + for (MeetingParticipant participant : room.participantsByMemberId(memberId)) { + closeSession(participant); + } + } + } + + private void closeSession(MeetingParticipant participant) { + if (!participant.socketSession().isOpen()) { + return; + } try { - meetingSocketDispatchExecutor.execute(task); - } catch (RuntimeException exception) { - task.run(); + participant.socketSession().close(CloseStatus.POLICY_VIOLATION); + } catch (Exception exception) { + log.warn( + "Failed to close withdrawn member websocket session: meetingId={}, memberId={}, sessionId={}", + participant.meetingId(), + participant.memberId(), + participant.sessionId(), + exception); + leave(participant.meetingId(), participant.sessionId()); } } @@ -147,11 +174,13 @@ private void sendToMemberInternal(Long meetingId, Long targetMemberId, String pa return; } - room.participantsByMemberId(targetMemberId).forEach(participant -> { - if (!sendMessage(participant, new TextMessage(payload))) { - leave(meetingId, participant.sessionId()); - } - }); + room.participantsByMemberId(targetMemberId) + .forEach( + participant -> { + if (!sendMessage(participant, new TextMessage(payload))) { + leave(meetingId, participant.sessionId()); + } + }); } // 회의방 참가자 전체를 순회하면서 메시지를 보내고 끊어진 세션은 정리합니다. @@ -187,7 +216,8 @@ private boolean sendMessage(MeetingParticipant participant, WebSocketMessage } // 전송 중 끊어진 세션들을 회의방에서 제거합니다. - private void cleanupDisconnectedParticipants(Long meetingId, List disconnectedParticipants) { + private void cleanupDisconnectedParticipants( + Long meetingId, List disconnectedParticipants) { disconnectedParticipants.forEach(participant -> leave(meetingId, participant.sessionId())); } } diff --git a/server/src/main/java/com/whylog/server/domain/team/controller/TeamController.java b/server/src/main/java/com/whylog/server/domain/team/controller/TeamController.java index 28d7bfd..b88774f 100644 --- a/server/src/main/java/com/whylog/server/domain/team/controller/TeamController.java +++ b/server/src/main/java/com/whylog/server/domain/team/controller/TeamController.java @@ -6,27 +6,33 @@ import com.whylog.server.domain.team.exception.TeamErrorCode; import com.whylog.server.domain.team.service.TeamCommandService; import com.whylog.server.domain.team.service.TeamQueryService; -import com.whylog.server.domain.user.exception.MemberErrorStatus; +import com.whylog.server.domain.user.exception.MemberErrorCode; import com.whylog.server.global.apiPayload.ApiResponse; import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExample; import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExamples; import com.whylog.server.global.apiPayload.code.status.ErrorStatus; import com.whylog.server.global.auth.annotation.CurrentMember; import com.whylog.server.global.external.s3.S3ErrorCode; +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.Encoding; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import java.util.List; - @RestController @RequestMapping("/api/teams") @RequiredArgsConstructor @@ -39,15 +45,15 @@ public class TeamController { @GetMapping("/{teamId}/decisions") @Operation( summary = "결정사항 목록 조회 API", - description = """ + description = + """ 특정 팀의 결정사항 목록을 조회하는 API입니다. 각 결정사항에는 해당 결정사항에 연결된 적용사항 목록도 함께 포함됩니다. 페이징 없습니다. - """ - ) + """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST") }) public ApiResponse> getDecisions( @PathVariable Long teamId) { @@ -56,32 +62,37 @@ public ApiResponse> getDecisions( } @PostMapping("/{teamId}/invitations") - @Operation(summary = "팀 초대 API", description = """ + @Operation( + summary = "팀 초대 API", + description = + """ 특정 사용자를 팀에 초대합니다. 팀 초대를 하면 상대는 즉시 팀원으로 추가됩니다. """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NOT_FOUND"), - @ApiErrorCodeExample(value = MemberErrorStatus.class, name = "MEMBER_NOT_FOUND"), - @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_MEMBER_ALREADY_EXISTS") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NOT_FOUND"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_NOT_FOUND"), + @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_MEMBER_ALREADY_EXISTS") }) public ApiResponse sendInvitation( - @PathVariable Long teamId, - @Valid @RequestBody TeamRequest.InvitationDTO request) { + @PathVariable Long teamId, @Valid @RequestBody TeamRequest.InvitationDTO request) { TeamResponse.InvitationResponseDTO result = teamCommandService.invite(teamId, request); return ApiResponse.onSuccess(result); } @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "팀 생성 API", description = """ - + @Operation( + summary = "팀 생성 API", + description = + """ + ## 설명 팀을 생성합니다. 해당 api를 호출한 사람은 팀에 참여합니다. - + ## API 호출 방법 - `multipart/form-data`로 요청합니다. + `multipart/form-data`로 요청합니다. `request` 파트는 JSON Blob으로 추가하고, `image` 파트는 선택값입니다. | Part name | Required | Value | @@ -98,53 +109,63 @@ public ApiResponse sendInvitation( ``` `Content-Type`은 직접 지정하지 않습니다. 브라우저가 boundary를 포함한 `multipart/form-data` 값을 자동으로 생성해야 합니다. - + """, - requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( - content = @Content( - mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, - schema = @Schema(implementation = TeamRequest.TeamCreateMultipartDTO.class), - encoding = @Encoding(name = "request", contentType = MediaType.APPLICATION_JSON_VALUE) - ) - ) - ) + requestBody = + @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = + @Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + schema = + @Schema( + implementation = + TeamRequest + .TeamCreateMultipartDTO + .class), + encoding = + @Encoding( + name = "request", + contentType = + MediaType + .APPLICATION_JSON_VALUE)))) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NAME_ALREADY_EXISTS"), - @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NAME_LENGTH"), - @ApiErrorCodeExample(value = MemberErrorStatus.class, name = "MEMBER_NOT_FOUND"), - @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_FILE_EMPTY"), - @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_FILE_NAME_EMPTY"), - @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_BUCKET_NOT_CONFIGURED"), - @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_UPLOAD_FAILED") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NAME_ALREADY_EXISTS"), + @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NAME_LENGTH"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_NOT_FOUND"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_FILE_EMPTY"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_FILE_NAME_EMPTY"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_BUCKET_NOT_CONFIGURED"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_UPLOAD_FAILED") }) public ApiResponse createTeam( @Parameter(hidden = true) @CurrentMember Long memberId, - @Parameter(hidden = true) @Valid @RequestPart("request") TeamRequest.TeamCreateDTO request, - @Parameter(hidden = true) @RequestPart(value = "image", required = false) MultipartFile image - ) { + @Parameter(hidden = true) @Valid @RequestPart("request") + TeamRequest.TeamCreateDTO request, + @Parameter(hidden = true) @RequestPart(value = "image", required = false) + MultipartFile image) { return ApiResponse.onSuccess(teamCommandService.createTeam(memberId, request, image)); } @DeleteMapping("/{teamId}") - @Operation(summary = "팀 제거 api", description = """ - + @Operation( + summary = "팀 제거 api", + description = + """ + 팀 제거 API입니다.
팀을 제거하면 관련된 데이터( 팀원, 회의, 저장소 ) 정보도 함께 제거됩니다. - + """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NOT_FOUND"), - @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NOT_OWNER") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NOT_FOUND"), + @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NOT_OWNER") }) public ApiResponse removeTeam( - @CurrentMember Long memberId, - @PathVariable Long teamId - ) { + @CurrentMember Long memberId, @PathVariable Long teamId) { return ApiResponse.onSuccess(teamCommandService.removeTeam(memberId, teamId)); } - } diff --git a/server/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java b/server/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java index 5706bb9..4aa2102 100644 --- a/server/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java +++ b/server/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java @@ -2,11 +2,13 @@ import com.whylog.server.domain.team.entity.TeamMember; import com.whylog.server.domain.team.entity.TeamMemberId; +import com.whylog.server.domain.team.enums.TeamRole; import java.util.List; import java.util.Optional; - -import com.whylog.server.domain.team.enums.TeamRole; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -14,7 +16,8 @@ public interface TeamMemberRepository extends JpaRepository findActiveTeamsByMemberId(@Param("memberId") Long memberId); - @Query(""" - SELECT tm - FROM TeamMember tm - JOIN FETCH tm.team - WHERE tm.member.id = :memberId - AND tm.team.id = :teamId - AND tm.role = :role -""") + @Query( + """ + SELECT tm + FROM TeamMember tm + JOIN FETCH tm.team + WHERE tm.member.id = :memberId + AND tm.team.id = :teamId + AND tm.role = :role + """) Optional findOwnerTeamMember( @Param("memberId") Long memberId, @Param("teamId") Long teamId, - @Param("role") TeamRole role - ); + @Param("role") TeamRole role); + + @Query( + """ + SELECT tm.team.id + FROM TeamMember tm + WHERE tm.member.id = :memberId + AND tm.role = :role + AND tm.active = true + ORDER BY tm.team.id ASC + """) + List findActiveOwnerTeamIdsByMemberId( + @Param("memberId") Long memberId, @Param("role") TeamRole role); + + @Query( + """ + SELECT tm.team.id + FROM TeamMember tm + WHERE tm.team.id IN :teamIds + AND tm.active = true + GROUP BY tm.team.id + HAVING count(tm) > 1 + """) + List findOwnedTeamIdsWithOtherActiveMembers(@Param("teamIds") List teamIds); + + @Modifying + @Query( + """ + UPDATE TeamMember tm + SET tm.active = false + WHERE tm.member.id = :memberId + AND tm.active = true + """) + int deactivateActiveMembershipsByMemberId(@Param("memberId") Long memberId); + + @Query( + """ + SELECT tm + FROM TeamMember tm + WHERE tm.team.id = :teamId + AND tm.member.id IN :memberIds + """) + List findByTeamIdAndMemberIdIn( + @Param("teamId") Long teamId, @Param("memberIds") List memberIds); + + @Query( + """ + SELECT count(tm) + FROM TeamMember tm + WHERE tm.member.id = :memberId + AND tm.active = true + """) + long countActiveProjectsByMemberId(@Param("memberId") Long memberId); + + @Query( + """ + SELECT new com.whylog.server.domain.team.repository.TeamMemberRepository$ActiveProjectRow( + t.id, + t.name, + t.image + ) + FROM TeamMember tm + JOIN tm.team t + WHERE tm.member.id = :memberId + AND tm.active = true + AND (:cursorId IS NULL OR t.id > :cursorId) + ORDER BY t.id ASC + """) + Slice findActiveProjectRowsByMemberId( + @Param("memberId") Long memberId, @Param("cursorId") Long cursorId, Pageable pageable); + record ActiveProjectRow(Long projectId, String name, String image) {} } diff --git a/server/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java b/server/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java index 1dfa166..c886b0b 100644 --- a/server/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java +++ b/server/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java @@ -2,17 +2,19 @@ import com.whylog.server.domain.decision.dto.DecisionResponse; import com.whylog.server.domain.team.entity.Team; +import jakarta.persistence.LockModeType; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.List; - public interface TeamRepository extends JpaRepository { Boolean existsByName(String teamName); - @Query(""" + @Query( + """ SELECT new com.whylog.server.domain.decision.dto.DecisionResponse$DecisionFlatRow( d.id, m.name, @@ -27,4 +29,13 @@ public interface TeamRepository extends JpaRepository { """) List findDecisionRows(@Param("teamId") Long teamId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + """ + SELECT t + FROM Team t + WHERE t.id IN :teamIds + ORDER BY t.id ASC + """) + List findAllByIdInForUpdate(@Param("teamIds") List teamIds); } diff --git a/server/src/main/java/com/whylog/server/domain/user/controller/AuthController.java b/server/src/main/java/com/whylog/server/domain/user/controller/AuthController.java index 45caa83..03f2ab6 100644 --- a/server/src/main/java/com/whylog/server/domain/user/controller/AuthController.java +++ b/server/src/main/java/com/whylog/server/domain/user/controller/AuthController.java @@ -3,24 +3,26 @@ import com.whylog.server.domain.user.dto.AccessTokenGenerateResponse; import com.whylog.server.domain.user.dto.AuthRequest; import com.whylog.server.domain.user.dto.AuthResponse; -import com.whylog.server.domain.user.exception.AuthErrorStatus; +import com.whylog.server.domain.user.exception.AuthErrorCode; +import com.whylog.server.domain.user.exception.AuthSuccessStatus; import com.whylog.server.domain.user.service.AuthenticationService; +import com.whylog.server.domain.user.service.EmailVerificationCommandService; import com.whylog.server.domain.user.service.LocalLoginService; -import com.whylog.server.domain.user.exception.AuthSuccessStatus; +import com.whylog.server.domain.user.service.WithdrawalRecoveryCommandService; +import com.whylog.server.global.apiPayload.ApiResponse; import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExample; import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExamples; import com.whylog.server.global.apiPayload.code.status.ErrorStatus; import com.whylog.server.global.auth.annotation.CurrentMember; +import com.whylog.server.global.auth.jwt.application.RefreshTokenCookieService; import com.whylog.server.global.auth.jwt.application.TokenService; -import com.whylog.server.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseCookie; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -32,15 +34,18 @@ public class AuthController { private static final String REFRESH_TOKEN = "refreshToken"; - private static final int COOKIE_MAX_AGE = 7 * 24 * 60 * 60; - private final LocalLoginService localLoginService; private final AuthenticationService authenticationService; private final TokenService tokenService; + private final RefreshTokenCookieService refreshTokenCookieService; + private final EmailVerificationCommandService emailVerificationCommandService; + private final WithdrawalRecoveryCommandService withdrawalRecoveryCommandService; @PostMapping("/signup") - @Operation(summary = "회원가입 API", - description = """ + @Operation( + summary = "회원가입 API", + description = + """ 요청 형식: - `email`: 필수값이며 빈 문자열일 수 없습니다. @@ -48,36 +53,28 @@ public class AuthController { 처리 방식: - 동일 이메일이 이미 존재하면 회원가입에 실패합니다. - - 회원 생성 직후 자동 로그인 처리되어 액세스 토큰과 리프레시 토큰이 함께 발급됩니다. - - 토큰 응답 방식: - - 액세스 토큰은 응답 바디로 반환됩니다. - - 리프레시 토큰은 응답 바디에도 존재하지만, 실제 클라이언트 사용은 `Set-Cookie` 헤더의 HttpOnly 쿠키(`refreshToken`) 기준입니다. - - refresh token 쿠키는 `HttpOnly`, `Path=/`, `SameSite=Lax` 속성으로 내려갑니다. - - TTL 정책: - - 액세스 토큰 TTL: 1시간 (`3600000ms`) - - 리프레시 토큰 JWT TTL: 14일 (`1209600000ms`) - - 리프레시 토큰 쿠키 Max-Age: 7일 - - 그래도... - - 쿠키로 처리하기 귀찮거나...번거로울 경우를 대비해 Refresh Token을 Response Body에도 담아 보냅니다...😎 + - 회원은 이메일 인증 전 상태로 생성되며 토큰은 발급하지 않습니다. + - 최초 인증 메일 발송이 실패해도 회원가입은 롤백하지 않습니다. + - 메일이 오지 않으면 같은 이메일 인증 코드 발급 API로 재발급할 수 있습니다. """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = AuthErrorStatus.class, name = "EMAIL_ALREADY_EXISTS") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = AuthErrorCode.class, name = "EMAIL_ALREADY_EXISTS"), + @ApiErrorCodeExample( + value = AuthErrorCode.class, + name = "EMAIL_VERIFICATION_DELIVERY_FAILED") }) - public ApiResponse signup( - @Valid @RequestBody AuthRequest.SignUpDTO request, - HttpServletResponse httpServletResponse - ) { - AuthResponse.LoginResponseDTO response = localLoginService.signUp(request); - writeRefreshTokenCookie(httpServletResponse, response.getRefreshToken()); - return ApiResponse.of(AuthSuccessStatus.SIGN_UP_SUCCESS, response.withoutRefreshToken()); + public ApiResponse signup( + @Valid @RequestBody AuthRequest.SignUpDTO request) { + AuthResponse.SignUpResponseDTO response = localLoginService.signUp(request); + return ApiResponse.of(AuthSuccessStatus.SIGN_UP_SUCCESS, response); } @PostMapping("/login") - @Operation(summary = "로그인 API", description = """ + @Operation( + summary = "로그인 API", + description = + """ 요청 형식: - `email`: 필수값이며 빈 문자열일 수 없습니다. @@ -92,30 +89,93 @@ public ApiResponse signup( - 액세스 토큰은 응답 바디로 반환됩니다. - 리프레시 토큰은 `Set-Cookie` 헤더를 통해 HttpOnly 쿠키(`refreshToken`)로 저장됩니다. - refresh token 쿠키는 브라우저가 이후 요청에 자동 포함하도록 설계되어 있습니다. + - 응답 바디에는 refresh token을 담지 않습니다. TTL 정책: - 액세스 토큰 TTL: 1시간 (`3600000ms`) - 리프레시 토큰 JWT TTL: 14일 (`1209600000ms`) - - 리프레시 토큰 쿠키 Max-Age: 7일 - - 그래도... - - 쿠키로 처리하기 귀찮거나...번거로울 경우를 대비해 Refresh Token을 Response Body에도 담아 보냅니다...😎 + - 리프레시 토큰 쿠키 Max-Age: 리프레시 토큰 JWT TTL과 동일 + + 탈퇴 유예 계정: + - 30일 유예 중인 계정은 `RECOVERY_REQUIRED` 상태와 복구 챌린지를 반환합니다. + - 이 경우 access token, refresh token, refresh cookie는 발급하지 않습니다. """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = AuthErrorStatus.class, name = "LOGIN_FAILED") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = AuthErrorCode.class, name = "LOGIN_FAILED") }) public ApiResponse login( @Valid @RequestBody AuthRequest.LoginDTO request, - HttpServletResponse httpServletResponse - ) { + HttpServletResponse httpServletResponse) { AuthResponse.LoginResponseDTO response = localLoginService.login(request); - writeRefreshTokenCookie(httpServletResponse, response.getRefreshToken()); + if (response.isAuthenticated()) { + refreshTokenCookieService.write(httpServletResponse, response.getRefreshToken()); + } return ApiResponse.of(AuthSuccessStatus.LOGIN_SUCCESS, response.withoutRefreshToken()); } + @PostMapping("/email-verifications") + @Operation(summary = "이메일 인증 코드 발급 API", description = "인증 전 회원에게 6자리 이메일 인증 코드를 발급하거나 재발급합니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample( + value = AuthErrorCode.class, + name = "EMAIL_VERIFICATION_DELIVERY_FAILED"), + @ApiErrorCodeExample( + value = AuthErrorCode.class, + name = "EMAIL_VERIFICATION_ISSUE_COOLDOWN") + }) + public ApiResponse issueEmailVerification( + @Valid @RequestBody AuthRequest.EmailVerificationIssueDTO request) { + emailVerificationCommandService.issue(request); + return ApiResponse.of(AuthSuccessStatus.EMAIL_VERIFICATION_ISSUE_SUCCESS, null); + } + + @PostMapping("/email-verifications/verify") + @Operation( + summary = "이메일 인증 코드 검증 API", + description = "6자리 이메일 인증 코드를 확인하고 인증 완료 후 로그인 응답을 반환합니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = AuthErrorCode.class, name = "EMAIL_VERIFICATION_CODE_INVALID"), + @ApiErrorCodeExample(value = AuthErrorCode.class, name = "EMAIL_VERIFICATION_CODE_EXPIRED") + }) + public ApiResponse verifyEmailVerification( + @Valid @RequestBody AuthRequest.EmailVerificationVerifyDTO request, + HttpServletResponse httpServletResponse) { + AuthResponse.LoginResponseDTO response = + authenticationService.generateLoginResponse( + emailVerificationCommandService.verify(request)); + refreshTokenCookieService.write(httpServletResponse, response.getRefreshToken()); + return ApiResponse.of( + AuthSuccessStatus.EMAIL_VERIFICATION_VERIFY_SUCCESS, + response.withoutRefreshToken()); + } + + @PostMapping("/withdrawal-recoveries/verify") + @Operation(summary = "탈퇴 유예 계정 복구 API", description = "탈퇴 유예 로그인에서 받은 일회성 챌린지를 확인하고 계정을 복구합니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample( + value = AuthErrorCode.class, + name = "WITHDRAWAL_RECOVERY_CHALLENGE_INVALID") + }) + public ApiResponse verifyWithdrawalRecovery( + @Valid @RequestBody AuthRequest.WithdrawalRecoveryVerifyDTO request, + HttpServletResponse httpServletResponse) { + AuthResponse.LoginResponseDTO response = + authenticationService.generateLoginResponse( + withdrawalRecoveryCommandService.verify(request)); + refreshTokenCookieService.write(httpServletResponse, response.getRefreshToken()); + return ApiResponse.of( + AuthSuccessStatus.WITHDRAWAL_RECOVERY_SUCCESS, response.withoutRefreshToken()); + } + @PostMapping("/refresh-token") - @Operation(summary = "액세스 토큰 재발급 API", description = """ + @Operation( + summary = "액세스 토큰 재발급 API", + description = + """ 요청 방식: - 요청 바디는 사용하지 않습니다. @@ -139,22 +199,23 @@ public ApiResponse login( - 기존 리프레시 토큰 JWT TTL: 14일 (`1209600000ms`) """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = AuthErrorStatus.class, name = "INVALID_REFRESH_TOKEN"), - @ApiErrorCodeExample(value = AuthErrorStatus.class, name = "REFRESH_TOKEN_EXPIRED"), - @ApiErrorCodeExample(value = AuthErrorStatus.class, name = "REFRESH_TOKEN_NOT_FOUND") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = AuthErrorCode.class, name = "INVALID_REFRESH_TOKEN"), + @ApiErrorCodeExample(value = AuthErrorCode.class, name = "REFRESH_TOKEN_EXPIRED"), + @ApiErrorCodeExample(value = AuthErrorCode.class, name = "REFRESH_TOKEN_NOT_FOUND") }) public ApiResponse refreshToken( - @CookieValue(REFRESH_TOKEN) String refreshToken - ) { + @CookieValue(REFRESH_TOKEN) String refreshToken) { return ApiResponse.of( AuthSuccessStatus.REFRESH_TOKEN_SUCCESS, - authenticationService.generateAccessTokenFromRefreshToken(refreshToken) - ); + authenticationService.generateAccessTokenFromRefreshToken(refreshToken)); } @PostMapping("/logout") - @Operation(summary = "로그아웃 API", description = """ + @Operation( + summary = "로그아웃 API", + description = + """ 요청 방식: - `Authorization: Bearer {access_token}` 헤더가 필요합니다. @@ -166,39 +227,18 @@ public ApiResponse refreshToken( - 응답 시 `refreshToken` 쿠키를 만료 처리합니다. 쿠키 처리 방식: - - `Set-Cookie: refreshToken=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax` + - `Set-Cookie: refreshToken=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax` 형식으로 만료합니다. + - 운영 환경에서는 설정에 따라 Secure 속성이 함께 붙을 수 있습니다. - 브라우저 기준으로 refresh token 쿠키가 제거됩니다. """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = AuthErrorStatus.class, name = "REFRESH_TOKEN_NOT_FOUND") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = AuthErrorCode.class, name = "REFRESH_TOKEN_NOT_FOUND") }) public ApiResponse logout( - @CurrentMember Long memberId, - HttpServletResponse httpServletResponse - ) { + @CurrentMember Long memberId, HttpServletResponse httpServletResponse) { tokenService.deleteRefreshToken(memberId); - expireRefreshTokenCookie(httpServletResponse); + refreshTokenCookieService.expire(httpServletResponse); return ApiResponse.of(AuthSuccessStatus.LOGOUT_SUCCESS, null); } - - private void writeRefreshTokenCookie(HttpServletResponse httpServletResponse, String refreshToken) { - ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN, refreshToken) - .maxAge(COOKIE_MAX_AGE) - .path("/") - .httpOnly(true) - .sameSite("Lax") - .build(); - httpServletResponse.addHeader("Set-Cookie", cookie.toString()); - } - - private void expireRefreshTokenCookie(HttpServletResponse httpServletResponse) { - ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN, "") - .maxAge(0) - .path("/") - .httpOnly(true) - .sameSite("Lax") - .build(); - httpServletResponse.addHeader("Set-Cookie", cookie.toString()); - } } diff --git a/server/src/main/java/com/whylog/server/domain/user/controller/MemberController.java b/server/src/main/java/com/whylog/server/domain/user/controller/MemberController.java index 042d2f9..9e68536 100644 --- a/server/src/main/java/com/whylog/server/domain/user/controller/MemberController.java +++ b/server/src/main/java/com/whylog/server/domain/user/controller/MemberController.java @@ -1,7 +1,8 @@ package com.whylog.server.domain.user.controller; +import com.whylog.server.domain.user.dto.MemberRequest; import com.whylog.server.domain.user.dto.MemberResponse; -import com.whylog.server.domain.user.exception.MemberErrorStatus; +import com.whylog.server.domain.user.exception.MemberErrorCode; import com.whylog.server.domain.user.service.MemberCommandService; import com.whylog.server.domain.user.service.MemberQueryService; import com.whylog.server.global.apiPayload.ApiResponse; @@ -9,16 +10,24 @@ import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExamples; import com.whylog.server.global.apiPayload.code.status.ErrorStatus; import com.whylog.server.global.auth.annotation.CurrentMember; +import com.whylog.server.global.auth.jwt.application.RefreshTokenCookieService; import com.whylog.server.global.external.s3.S3ErrorCode; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @@ -31,35 +40,158 @@ public class MemberController { private final MemberCommandService memberCommandService; private final MemberQueryService memberQueryService; + private final RefreshTokenCookieService refreshTokenCookieService; + + @GetMapping("/me/profile") + @Operation(summary = "마이페이지 조회 API", description = "현재 로그인한 멤버의 비공개 마이페이지 정보를 조회합니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_NOT_FOUND") + }) + public ApiResponse getMyInfo( + @Parameter(hidden = true) @CurrentMember Long memberId) { + return ApiResponse.onSuccess(memberQueryService.getMyInfo(memberId)); + } + + @GetMapping("/{memberId}/profile") + @Operation(summary = "멤버 공개 프로필 조회 API", description = "다른 사용자가 보는 멤버 공개 프로필을 조회합니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_NOT_FOUND") + }) + public ApiResponse getProfile( + @Parameter(hidden = true) @CurrentMember Long viewerId, @PathVariable Long memberId) { + return ApiResponse.onSuccess(memberQueryService.getProfile(viewerId, memberId)); + } + + @GetMapping("/{memberId}/projects") + @Operation( + summary = "멤버 참여 프로젝트 목록 조회 API", + description = + """ + + 멤버가 참여 중인 프로젝트를 4개씩 커서 기반으로 조회합니다. + 첫 요청은 cursor 없이 호출하고, 응답의 hasNext가 true이면 nextCursorId를 cursor로 다시 요청합니다. + 내 프로필은 공개범위와 무관하게 조회할 수 있고, 다른 멤버의 비공개 프로필은 MEMBER_NOT_FOUND로 응답합니다. + + """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_NOT_FOUND") + }) + public ApiResponse getParticipatingProjects( + @Parameter(hidden = true) @CurrentMember Long viewerId, + @PathVariable Long memberId, + @Parameter(description = "이전 조회의 마지막 프로젝트 ID (첫 요청 시 생략)") + @RequestParam(required = false) + Long cursor) { + return ApiResponse.onSuccess( + memberQueryService.getParticipatingProjects(viewerId, memberId, cursor)); + } @GetMapping("/teams") - @Operation(summary = "내 소속 팀 목록 조회 API", description = """ - + @Operation( + summary = "내 소속 팀 목록 조회 API", + description = + """ + ## 설명 현재 로그인한 멤버가 소속된 활성 팀 목록을 조회합니다. - + ## 응답 `team_id`, `name`, `team_image` 필드를 가진 배열을 반환합니다. - + """) - @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED") - }) + @ApiErrorCodeExamples({@ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED")}) public ApiResponse> getMyTeams( - @Parameter(hidden = true) @CurrentMember Long memberId - ) { + @Parameter(hidden = true) @CurrentMember Long memberId) { return ApiResponse.onSuccess(memberQueryService.getTeams(memberId)); } - @PostMapping(value = "/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "멤버 프로필 이미지 업로드 API", description = """ - + @PatchMapping("/me/name") + @Operation(summary = "멤버 이름 변경 API", description = "현재 로그인한 멤버의 표시 이름을 변경합니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_NOT_FOUND") + }) + public ApiResponse updateName( + @Parameter(hidden = true) @CurrentMember Long memberId, + @Valid @RequestBody MemberRequest.NameUpdateDTO request) { + return ApiResponse.onSuccess(memberCommandService.updateName(memberId, request)); + } + + @PatchMapping("/me/profile/visibility") + @Operation(summary = "멤버 프로필 공개범위 변경 API", description = "현재 로그인한 멤버의 프로필 공개범위를 변경합니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_NOT_FOUND") + }) + public ApiResponse updateProfileVisibility( + @Parameter(hidden = true) @CurrentMember Long memberId, + @Valid @RequestBody MemberRequest.ProfileVisibilityUpdateDTO request) { + return ApiResponse.onSuccess( + memberCommandService.updateProfileVisibility(memberId, request)); + } + + @DeleteMapping("/me/profile-image") + @Operation(summary = "멤버 프로필 이미지 제거 API", description = "현재 로그인한 멤버의 프로필 이미지를 제거합니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_NOT_FOUND") + }) + public ApiResponse removeProfileImage( + @Parameter(hidden = true) @CurrentMember Long memberId) { + return ApiResponse.onSuccess(memberCommandService.removeProfileImage(memberId)); + } + + @PostMapping("/me/password/verify") + @Operation(summary = "현재 비밀번호 검증 API", description = "마이페이지 민감 작업 전 현재 비밀번호를 검증합니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_PASSWORD_MISMATCH") + }) + public ApiResponse verifyCurrentPassword( + @Parameter(hidden = true) @CurrentMember Long memberId, + @Valid @RequestBody MemberRequest.CurrentPasswordVerifyDTO request) { + memberCommandService.verifyCurrentPassword(memberId, request); + return ApiResponse.onSuccess(null); + } + + @PatchMapping("/me/password") + @Operation( + summary = "멤버 비밀번호 변경 API", + description = "현재 비밀번호 확인 후 새 비밀번호로 변경하고 기존 리프레시 세션을 폐기합니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_NOT_FOUND"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_PASSWORD_MISMATCH") + }) + public ApiResponse changePassword( + @Parameter(hidden = true) @CurrentMember Long memberId, + @Valid @RequestBody MemberRequest.PasswordChangeDTO request, + HttpServletResponse httpServletResponse) { + MemberResponse.MemberUpdateResponseDTO response = + memberCommandService.changePassword(memberId, request); + refreshTokenCookieService.expire(httpServletResponse); + return ApiResponse.onSuccess(response); + } + + @PostMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "멤버 프로필 이미지 업로드 API", + description = + """ + ## 설명 현재 로그인한 멤버의 프로필 이미지를 업로드합니다. - + ## API 호출 방법 - `multipart/form-data`로 요청합니다. + `multipart/form-data`로 요청합니다. `image` 파트에 업로드할 이미지 파일을 추가합니다. | Part name | Required | Value | @@ -69,21 +201,41 @@ public ApiResponse> getMyTeams( ### Web FormData 예시 `Content-Type`은 직접 지정하지 않습니다. 브라우저가 boundary를 포함한 `multipart/form-data` 값을 자동으로 생성해야 합니다. - + """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = MemberErrorStatus.class, name = "MEMBER_NOT_FOUND"), - @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_FILE_EMPTY"), - @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_FILE_NAME_EMPTY"), - @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_BUCKET_NOT_CONFIGURED"), - @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_UPLOAD_FAILED") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_NOT_FOUND"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_PROFILE_IMAGE_INVALID"), + @ApiErrorCodeExample( + value = MemberErrorCode.class, + name = "MEMBER_PROFILE_IMAGE_TOO_LARGE"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_FILE_EMPTY"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_FILE_NAME_EMPTY"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_BUCKET_NOT_CONFIGURED"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_UPLOAD_FAILED") }) public ApiResponse uploadProfileImage( @Parameter(hidden = true) @CurrentMember Long memberId, - @RequestPart("image") MultipartFile image - ) { + @RequestPart("image") MultipartFile image) { return ApiResponse.onSuccess(memberCommandService.uploadProfileImage(memberId, image)); } + @PostMapping("/me/withdrawal") + @Operation(summary = "회원 탈퇴 요청 API", description = "현재 회원을 30일 탈퇴 유예 상태로 전환합니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_NOT_FOUND"), + @ApiErrorCodeExample( + value = MemberErrorCode.class, + name = "MEMBER_WITHDRAWAL_OWNER_HAS_MEMBERS"), + @ApiErrorCodeExample(value = MemberErrorCode.class, name = "MEMBER_WITHDRAWAL_NOT_ALLOWED") + }) + public ApiResponse requestWithdrawal( + @Parameter(hidden = true) @CurrentMember Long memberId, + HttpServletResponse httpServletResponse) { + memberCommandService.requestWithdrawal(memberId); + refreshTokenCookieService.expire(httpServletResponse); + return ApiResponse.onSuccess(null); + } } diff --git a/server/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java b/server/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java index 5d2f4fd..de1b4f9 100644 --- a/server/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java +++ b/server/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java @@ -3,8 +3,14 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; -import lombok.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; public class AuthRequest { @@ -16,17 +22,13 @@ public class AuthRequest { public static class SignUpDTO { @Schema(description = "이름", example = "아무개") - @NotBlank - private String name; + @NotBlank private String name; @Schema(description = "이메일", example = "user@example.com") - @NotBlank @Email - private String email; + @NotBlank @Email @Size(max = 50) private String email; @Schema(description = "비밀번호", example = "wtf12345") - @NotBlank - @Size(min = 8, max = 100) - private String password; + @NotBlank @Size(min = 8, max = 100) private String password; } @Getter @@ -37,11 +39,48 @@ public static class SignUpDTO { public static class LoginDTO { @Schema(description = "이메일", example = "user@example.com") - @NotBlank @Email - private String email; + @NotBlank @Email @Size(max = 50) private String email; @Schema(description = "비밀번호", example = "wtf12345") - @NotBlank - private String password; + @NotBlank private String password; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "이메일 인증 코드 발급 요청") + public static class EmailVerificationIssueDTO { + + @Schema(description = "이메일", example = "user@example.com") + @NotBlank @Email @Size(max = 50) private String email; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "이메일 인증 코드 확인 요청") + public static class EmailVerificationVerifyDTO { + + @Schema(description = "이메일", example = "user@example.com") + @NotBlank @Email @Size(max = 50) private String email; + + @Schema(description = "6자리 이메일 인증 코드", example = "123456") + @NotBlank @Pattern(regexp = "\\d{6}", message = "이메일 인증 코드는 6자리 숫자여야 합니다.") private String code; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "탈퇴 유예 계정 복구 요청") + public static class WithdrawalRecoveryVerifyDTO { + + @Schema(description = "멤버 ID", example = "1") + @NotNull private Long memberId; + + @Schema(description = "탈퇴 복구 챌린지") + @NotBlank private String challenge; } } diff --git a/server/src/main/java/com/whylog/server/domain/user/dto/AuthResponse.java b/server/src/main/java/com/whylog/server/domain/user/dto/AuthResponse.java index 7a59029..43559cc 100644 --- a/server/src/main/java/com/whylog/server/domain/user/dto/AuthResponse.java +++ b/server/src/main/java/com/whylog/server/domain/user/dto/AuthResponse.java @@ -1,6 +1,7 @@ package com.whylog.server.domain.user.dto; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -8,6 +9,12 @@ public class AuthResponse { + public enum LoginStatus { + AUTHENTICATED, + RECOVERY_REQUIRED, + EMAIL_VERIFICATION_REQUIRED + } + @Getter @NoArgsConstructor @AllArgsConstructor @@ -29,6 +36,9 @@ public static class SignUpResponseDTO { @Schema(description = "로그인 응답") public static class LoginResponseDTO { + @Schema(description = "로그인 처리 상태", example = "AUTHENTICATED") + private LoginStatus status; + @Schema(description = "액세스 토큰", example = "accessstokenenenen...") private String accessToken; @@ -44,14 +54,16 @@ public static class LoginResponseDTO { @Schema(description = "권한", example = "ROLE_USER") private String role; + @Schema(description = "탈퇴 복구 챌린지. RECOVERY_REQUIRED 상태에서만 내려갑니다.", nullable = true) + private String withdrawalRecoveryChallenge; + + @Schema(description = "탈퇴 유예 종료 시각. RECOVERY_REQUIRED 상태에서만 내려갑니다.", nullable = true) + private LocalDateTime purgeAt; + public static LoginResponseDTO of( - String accessToken, - String refreshToken, - Long memberId, - String email, - String role - ) { + String accessToken, String refreshToken, Long memberId, String email, String role) { return LoginResponseDTO.builder() + .status(LoginStatus.AUTHENTICATED) .accessToken(accessToken) .refreshToken(refreshToken) .memberId(memberId) @@ -60,13 +72,50 @@ public static LoginResponseDTO of( .build(); } + public static LoginResponseDTO emailVerificationRequired( + Long memberId, String email, String role) { + return LoginResponseDTO.builder() + .status(LoginStatus.EMAIL_VERIFICATION_REQUIRED) + .accessToken(null) + .refreshToken(null) + .memberId(memberId) + .email(email) + .role(role) + .build(); + } + + public static LoginResponseDTO recoveryRequired( + Long memberId, + String email, + String role, + String withdrawalRecoveryChallenge, + LocalDateTime purgeAt) { + return LoginResponseDTO.builder() + .status(LoginStatus.RECOVERY_REQUIRED) + .accessToken(null) + .refreshToken(null) + .memberId(memberId) + .email(email) + .role(role) + .withdrawalRecoveryChallenge(withdrawalRecoveryChallenge) + .purgeAt(purgeAt) + .build(); + } + + public boolean isAuthenticated() { + return status == LoginStatus.AUTHENTICATED; + } + public LoginResponseDTO withoutRefreshToken() { return LoginResponseDTO.builder() + .status(status) .accessToken(accessToken) - .refreshToken(refreshToken) + .refreshToken(null) .memberId(memberId) .email(email) .role(role) + .withdrawalRecoveryChallenge(withdrawalRecoveryChallenge) + .purgeAt(purgeAt) .build(); } } diff --git a/server/src/main/java/com/whylog/server/domain/user/dto/MemberRequest.java b/server/src/main/java/com/whylog/server/domain/user/dto/MemberRequest.java new file mode 100644 index 0000000..8f8ee9b --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/dto/MemberRequest.java @@ -0,0 +1,62 @@ +package com.whylog.server.domain.user.dto; + +import com.whylog.server.domain.user.enums.ProfileVisibility; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class MemberRequest { + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "멤버 이름 변경 요청") + public static class NameUpdateDTO { + + @Schema(description = "새 이름", example = "홍길동") + @NotBlank @Size(max = 50) private String name; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "현재 비밀번호 검증 요청") + public static class CurrentPasswordVerifyDTO { + + @Schema(description = "현재 비밀번호", example = "oldPassword123") + @NotBlank private String currentPassword; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "비밀번호 변경 요청") + public static class PasswordChangeDTO { + + @Schema(description = "현재 비밀번호", example = "oldPassword123") + @NotBlank private String currentPassword; + + @Schema(description = "새 비밀번호", example = "newPassword123") + @NotBlank @Size(min = 8, max = 100) private String newPassword; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "프로필 공개범위 변경 요청") + public static class ProfileVisibilityUpdateDTO { + + @Schema(description = "프로필 공개범위", example = "PUBLIC") + @NotNull private ProfileVisibility profileVisibility; + } +} diff --git a/server/src/main/java/com/whylog/server/domain/user/dto/MemberResponse.java b/server/src/main/java/com/whylog/server/domain/user/dto/MemberResponse.java index 2b8392c..6310afa 100644 --- a/server/src/main/java/com/whylog/server/domain/user/dto/MemberResponse.java +++ b/server/src/main/java/com/whylog/server/domain/user/dto/MemberResponse.java @@ -1,13 +1,288 @@ package com.whylog.server.domain.user.dto; +import com.whylog.server.domain.user.enums.AccountStatus; +import com.whylog.server.domain.user.enums.ProfileView; +import com.whylog.server.domain.user.enums.ProfileVisibility; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.data.domain.Slice; public class MemberResponse { + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "마이페이지 응답") + public static class MyInfoDTO { + + @Schema(description = "멤버 ID", example = "1") + private Long memberId; + + @Schema(description = "이름", example = "홍길동") + private String name; + + @Schema(description = "이메일", example = "member@example.com") + private String email; + + @Schema(description = "프로필 이미지 URL", example = "https://cdn.whylog.com/members/profile.png") + private String profileImage; + + @Schema(description = "참여 중인 프로젝트 수", example = "2") + private Long participatingProjectCount; + + @Schema(description = "계정 상태", example = "ACTIVE") + private AccountStatus accountStatus; + + @Schema(description = "프로필 공개범위", example = "PUBLIC") + private ProfileVisibility profileVisibility; + + @Schema(description = "최근 완료 회의 목록") + private List recentMeetings; + + @Schema(description = "최근 결정 목록") + private List recentDecisions; + } + + @Schema(description = "멤버 공개 프로필 응답") + public interface ProfileDTO { + + ProfileView getProfileView(); + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "전체 멤버 공개 프로필 응답") + public static class FullProfileDTO implements ProfileDTO { + + @Builder.Default + @Schema(description = "프로필 응답 뷰", example = "FULL") + private ProfileView profileView = ProfileView.FULL; + + @Schema(description = "멤버 ID", example = "1") + private Long memberId; + + @Schema(description = "이름", example = "홍길동") + private String name; + + @Schema(description = "이메일", example = "member@example.com") + private String email; + + @Schema(description = "프로필 이미지 URL", example = "https://cdn.whylog.com/members/profile.png") + private String profileImage; + + @Schema(description = "참여 중인 프로젝트 수", example = "2") + private Long participatingProjectCount; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "비공개 멤버 프로필 응답") + public static class PrivateProfileDTO implements ProfileDTO { + + @Builder.Default + @Schema(description = "프로필 응답 뷰", example = "PRIVATE") + private ProfileView profileView = ProfileView.PRIVATE; + + @Schema(description = "멤버 ID", example = "1") + private Long memberId; + + @Schema(description = "이름", example = "홍길동") + private String name; + + @Schema(description = "이메일", example = "member@example.com") + private String email; + + @Schema(description = "프로필 이미지 URL", example = "https://cdn.whylog.com/members/profile.png") + private String profileImage; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "탈퇴한 멤버 공개 프로필 응답") + public static class WithdrawnProfileDTO implements ProfileDTO { + + @Builder.Default + @Schema(description = "프로필 응답 뷰", example = "WITHDRAWN") + private ProfileView profileView = ProfileView.WITHDRAWN; + + @Schema(description = "멤버 ID", example = "1") + private Long memberId; + + @Schema(description = "표시 이름", example = "탈퇴한 사용자") + private String name; + + @Schema(description = "삭제된 이메일 자리값", example = "") + private String email; + + @Schema(description = "삭제된 프로필 이미지 URL", nullable = true) + private String profileImage; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "참여 중인 프로젝트 커서 페이지 응답") + public static class ParticipatingProjectListResponseDTO { + + @Schema(description = "참여 프로젝트 목록") + private List participatingProjects; + + @Schema(description = "현재 페이지의 프로젝트 개수", example = "4") + private Integer projectListSize; + + @Schema(description = "페이지 처음 여부", example = "true") + private Boolean isFirst; + + @Schema(description = "다음 페이지가 있는지 여부", example = "true") + private Boolean hasNext; + + @Schema(description = "다음 요청에 사용할 커서 프로젝트 ID", example = "11") + private Long nextCursorId; + + public static ParticipatingProjectListResponseDTO from( + Slice participatingProjectSlice, Long cursorId) { + List participatingProjects = + participatingProjectSlice.getContent(); + Long nextCursorId = + participatingProjectSlice.hasNext() && !participatingProjects.isEmpty() + ? participatingProjects + .get(participatingProjects.size() - 1) + .getProjectId() + : null; + + return new ParticipatingProjectListResponseDTO( + participatingProjects, + participatingProjects.size(), + cursorId == null, + participatingProjectSlice.hasNext(), + nextCursorId); + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "참여 중인 프로젝트 요약") + public static class ParticipatingProjectSummaryDTO { + + @Schema(description = "프로젝트 ID", example = "1") + private Long projectId; + + @Schema(description = "프로젝트명", example = "WhyLog") + private String name; + + @Schema( + description = "프로젝트 이미지 URL", + example = "https://cdn.whylog.com/teams/team-image.png") + private String image; + + @Schema(description = "멤버가 참여한 완료 회의 수", example = "12") + private Long memberCompletedMeetingCount; + + @Schema(description = "멤버가 참여한 완료 회의 누적 시간(초)", example = "3600") + private Long memberCompletedMeetingDurationSeconds; + + @Schema(description = "프로젝트 저장 커밋 수", example = "120") + private Long projectStoredCommitCount; + + @Schema(description = "프로젝트 저장소 중 가장 최근 동기화 시각") + private LocalDateTime lastSyncedAt; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "최근 완료 회의 요약") + public static class RecentMeetingDTO { + + @Schema(description = "회의 ID", example = "1") + private Long meetingId; + + @Schema(description = "프로젝트 ID", example = "1") + private Long projectId; + + @Schema(description = "프로젝트명", example = "WhyLog") + private String projectName; + + @Schema(description = "회의명", example = "스프린트 회의") + private String name; + + @Schema(description = "회의 종료 시각") + private LocalDateTime endedAt; + + @Schema(description = "회의 시간(초)", example = "1800") + private Long durationSeconds; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "멤버가 참여한 회의의 최근 결정 요약") + public static class RecentDecisionDTO { + + @Schema(description = "결정 ID", example = "1") + private Long decisionId; + + @Schema(description = "프로젝트 ID", example = "1") + private Long projectId; + + @Schema(description = "프로젝트명", example = "WhyLog") + private String projectName; + + @Schema(description = "결정의 기준 회의명", example = "스프린트 회의") + private String name; + + @Schema(description = "결정 생성 시각") + private LocalDateTime createdAt; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "멤버 계정 정보 변경 응답") + public static class MemberUpdateResponseDTO { + + @Schema(description = "멤버 ID", example = "1") + private Long memberId; + + @Schema(description = "이름", example = "홍길동") + private String name; + + @Schema(description = "이메일", example = "member@example.com") + private String email; + + @Schema(description = "프로필 이미지 URL", example = "https://cdn.whylog.com/members/profile.png") + private String profileImage; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "멤버 프로필 공개범위 변경 응답") + public static class ProfileVisibilityUpdateResponseDTO { + + @Schema(description = "프로필 공개범위", example = "PUBLIC") + private ProfileVisibility profileVisibility; + } + @Getter @NoArgsConstructor @AllArgsConstructor @@ -18,7 +293,10 @@ public static class ProfileImageUploadResponseDTO { @Schema(description = "멤버 ID", example = "1") private Long memberId; - @Schema(description = "프로필 이미지 URL", example = "https://server-images-437659978683-ap-northeast-2-an.s3.ap-northeast-2.amazonaws.com/member_profile/member_profile_image_2026-04-15-03-23-22-262.png") + @Schema( + description = "프로필 이미지 URL", + example = + "https://server-images-437659978683-ap-northeast-2-an.s3.ap-northeast-2.amazonaws.com/member_profile/member_profile_image_2026-04-15-03-23-22-262.png") private String profileImageUrl; } diff --git a/server/src/main/java/com/whylog/server/domain/user/entity/EmailVerificationCode.java b/server/src/main/java/com/whylog/server/domain/user/entity/EmailVerificationCode.java new file mode 100644 index 0000000..166013e --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/entity/EmailVerificationCode.java @@ -0,0 +1,83 @@ +package com.whylog.server.domain.user.entity; + +import com.whylog.server.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Duration; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "email_verification_code") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EmailVerificationCode extends BaseEntity { + + private static final Duration VALID_DURATION = Duration.ofMinutes(30); + private static final int MAX_FAILED_ATTEMPTS = 5; + + @Id + @Column(name = "member_id") + private Long memberId; + + @Column(name = "code_hmac", length = 64, nullable = false) + private String codeHmac; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "last_issued_at", nullable = false) + private LocalDateTime lastIssuedAt; + + @Column(name = "failed_attempts", nullable = false) + private int failedAttempts; + + @Builder(access = AccessLevel.PRIVATE) + private EmailVerificationCode( + Long memberId, + String codeHmac, + LocalDateTime expiresAt, + LocalDateTime lastIssuedAt, + int failedAttempts) { + this.memberId = memberId; + this.codeHmac = codeHmac; + this.expiresAt = expiresAt; + this.lastIssuedAt = lastIssuedAt; + this.failedAttempts = failedAttempts; + } + + public static EmailVerificationCode issue( + Long memberId, String codeHmac, LocalDateTime issuedAt) { + return EmailVerificationCode.builder() + .memberId(memberId) + .codeHmac(codeHmac) + .expiresAt(issuedAt.plus(VALID_DURATION)) + .lastIssuedAt(issuedAt) + .failedAttempts(0) + .build(); + } + + public void replace(String codeHmac, LocalDateTime issuedAt) { + this.codeHmac = codeHmac; + this.expiresAt = issuedAt.plus(VALID_DURATION); + this.lastIssuedAt = issuedAt; + this.failedAttempts = 0; + } + + public boolean isExpiredAt(LocalDateTime at) { + return !at.isBefore(expiresAt); + } + + public boolean isInvalidated() { + return failedAttempts >= MAX_FAILED_ATTEMPTS; + } + + public void recordFailure() { + this.failedAttempts += 1; + } +} diff --git a/server/src/main/java/com/whylog/server/domain/user/entity/Member.java b/server/src/main/java/com/whylog/server/domain/user/entity/Member.java index d527331..48d8931 100644 --- a/server/src/main/java/com/whylog/server/domain/user/entity/Member.java +++ b/server/src/main/java/com/whylog/server/domain/user/entity/Member.java @@ -1,10 +1,23 @@ package com.whylog.server.domain.user.entity; import com.whylog.server.domain.user.dto.AuthRequest; +import com.whylog.server.domain.user.enums.AccountStatus; +import com.whylog.server.domain.user.enums.ProfileVisibility; import com.whylog.server.domain.user.enums.Role; import com.whylog.server.global.entity.BaseEntity; import com.whylog.server.global.util.crypto.AESCryptoConverter; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Objects; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -37,17 +50,45 @@ public class Member extends BaseEntity { @Column(nullable = false, length = 20) private Role role; + @Enumerated(EnumType.STRING) + @Column(name = "account_status", nullable = false, length = 20) + private AccountStatus accountStatus; + + @Enumerated(EnumType.STRING) + @Column(name = "profile_visibility", nullable = false, length = 20) + private ProfileVisibility profileVisibility; + + @Column(name = "email_verified_at") + private LocalDateTime emailVerifiedAt; + + @Column(name = "purge_at") + private LocalDateTime purgeAt; + @Convert(converter = AESCryptoConverter.class) @Column(name = "github_access_token", length = 500, nullable = true) private String githubAccessToken; @Builder - private Member(String name, String email, String password, String profileImage, Role role) { + private Member( + String name, + String email, + String password, + String profileImage, + Role role, + AccountStatus accountStatus, + ProfileVisibility profileVisibility, + LocalDateTime emailVerifiedAt, + LocalDateTime purgeAt) { this.name = name; - this.email = email; + this.email = canonicalizeEmail(email); this.password = password; this.profileImage = profileImage; this.role = role; + this.accountStatus = accountStatus == null ? AccountStatus.UNVERIFIED : accountStatus; + this.profileVisibility = + profileVisibility == null ? ProfileVisibility.PUBLIC : profileVisibility; + this.emailVerifiedAt = emailVerifiedAt; + this.purgeAt = purgeAt; } public static Member create(AuthRequest.SignUpDTO dto, String password, Role role) { @@ -59,10 +100,83 @@ public static Member create(AuthRequest.SignUpDTO dto, String password, Role rol .build(); } + public static String canonicalizeEmail(String email) { + if (email == null) { + return null; + } + return email.trim().toLowerCase(Locale.ROOT); + } + + public void verifyEmail(LocalDateTime verifiedAt) { + this.accountStatus = AccountStatus.ACTIVE; + this.emailVerifiedAt = verifiedAt; + } + + public void requestWithdrawal(LocalDateTime requestedAt) { + if (this.accountStatus != AccountStatus.ACTIVE) { + throw new IllegalStateException("Only active members can request withdrawal."); + } + this.accountStatus = AccountStatus.INACTIVE; + this.purgeAt = firstMidnightAfterThirtyDays(requestedAt); + } + + private LocalDateTime firstMidnightAfterThirtyDays(LocalDateTime requestedAt) { + LocalDateTime exactExpiry = requestedAt.plusDays(30); + LocalDateTime midnight = exactExpiry.toLocalDate().atStartOfDay(); + return midnight.isBefore(exactExpiry) ? midnight.plusDays(1) : midnight; + } + + public void recoverWithdrawal() { + this.accountStatus = AccountStatus.ACTIVE; + this.purgeAt = null; + } + + public void purgeWithdrawal(String syntheticEmail, String encodedPassword) { + this.name = "탈퇴한 사용자"; + this.email = canonicalizeEmail(syntheticEmail); + this.password = encodedPassword; + this.profileImage = null; + this.githubAccessToken = null; + this.accountStatus = AccountStatus.WITHDRAW; + this.purgeAt = null; + } + + public boolean isWithdrawalGrace() { + return this.accountStatus == AccountStatus.INACTIVE && this.purgeAt != null; + } + + public boolean isWithdrawalGraceActive(LocalDateTime now) { + return isWithdrawalGrace() && this.purgeAt.isAfter(now); + } + + public boolean isWithdrawalGraceExpired(LocalDateTime now) { + return isWithdrawalGrace() && !this.purgeAt.isAfter(now); + } + + public boolean isWithdrawnForRead(LocalDateTime now) { + return this.accountStatus == AccountStatus.WITHDRAW || isWithdrawalGraceExpired(now); + } + + public void updateName(String name) { + this.name = name; + } + + public void updatePassword(String password) { + this.password = password; + } + public void updateProfileImage(String profileImage) { this.profileImage = profileImage; } + public void updateProfileVisibility(ProfileVisibility profileVisibility) { + this.profileVisibility = Objects.requireNonNull(profileVisibility); + } + + public void removeProfileImage() { + this.profileImage = null; + } + public void setGithubAccessToken(String token) { this.githubAccessToken = token; } diff --git a/server/src/main/java/com/whylog/server/domain/user/enums/AccountStatus.java b/server/src/main/java/com/whylog/server/domain/user/enums/AccountStatus.java new file mode 100644 index 0000000..fb96850 --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/enums/AccountStatus.java @@ -0,0 +1,12 @@ +package com.whylog.server.domain.user.enums; + +public enum AccountStatus { + UNVERIFIED, + ACTIVE, + INACTIVE, + WITHDRAW; + + public boolean canUseNormalService() { + return this == ACTIVE; + } +} diff --git a/server/src/main/java/com/whylog/server/domain/user/enums/ProfileView.java b/server/src/main/java/com/whylog/server/domain/user/enums/ProfileView.java new file mode 100644 index 0000000..ea0dbf7 --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/enums/ProfileView.java @@ -0,0 +1,7 @@ +package com.whylog.server.domain.user.enums; + +public enum ProfileView { + FULL, + PRIVATE, + WITHDRAWN +} diff --git a/server/src/main/java/com/whylog/server/domain/user/enums/ProfileVisibility.java b/server/src/main/java/com/whylog/server/domain/user/enums/ProfileVisibility.java new file mode 100644 index 0000000..d96030c --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/enums/ProfileVisibility.java @@ -0,0 +1,10 @@ +package com.whylog.server.domain.user.enums; + +public enum ProfileVisibility { + PUBLIC, + PRIVATE; + + public boolean isPublic() { + return this == PUBLIC; + } +} diff --git a/server/src/main/java/com/whylog/server/domain/user/exception/AuthErrorStatus.java b/server/src/main/java/com/whylog/server/domain/user/exception/AuthErrorCode.java similarity index 56% rename from server/src/main/java/com/whylog/server/domain/user/exception/AuthErrorStatus.java rename to server/src/main/java/com/whylog/server/domain/user/exception/AuthErrorCode.java index fc87bf9..879aa81 100644 --- a/server/src/main/java/com/whylog/server/domain/user/exception/AuthErrorStatus.java +++ b/server/src/main/java/com/whylog/server/domain/user/exception/AuthErrorCode.java @@ -8,13 +8,20 @@ @Getter @AllArgsConstructor -public enum AuthErrorStatus implements BaseErrorCode { - +public enum AuthErrorCode implements BaseErrorCode { EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "AUTH409_1", "이미 가입된 이메일입니다."), LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH401_1", "이메일 또는 비밀번호가 올바르지 않습니다."), INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "AUTH400_1", "유효하지 않은 리프레시 토큰입니다."), REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH401_2", "리프레시 토큰이 만료되었습니다."), - REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH404_1", "저장된 리프레시 토큰이 없습니다."); + REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH404_1", "저장된 리프레시 토큰이 없습니다."), + EMAIL_VERIFICATION_CODE_INVALID(HttpStatus.BAD_REQUEST, "AUTH400_2", "유효하지 않은 이메일 인증 코드입니다."), + EMAIL_VERIFICATION_ISSUE_COOLDOWN( + HttpStatus.TOO_MANY_REQUESTS, "AUTH429_1", "이메일 인증 코드는 60초 후 다시 요청할 수 있습니다."), + EMAIL_VERIFICATION_CODE_EXPIRED(HttpStatus.GONE, "AUTH410_1", "이메일 인증 코드가 만료되었습니다."), + EMAIL_VERIFICATION_DELIVERY_FAILED( + HttpStatus.BAD_GATEWAY, "AUTH502_1", "이메일 인증 코드 발송에 실패했습니다. 같은 API로 다시 요청해 주세요."), + WITHDRAWAL_RECOVERY_CHALLENGE_INVALID( + HttpStatus.BAD_REQUEST, "AUTH400_3", "탈퇴 복구 요청이 유효하지 않습니다."); private final HttpStatus httpStatus; private final String code; @@ -22,11 +29,7 @@ public enum AuthErrorStatus implements BaseErrorCode { @Override public ErrorReasonDTO getReason() { - return ErrorReasonDTO.builder() - .isSuccess(false) - .code(code) - .message(message) - .build(); + return ErrorReasonDTO.builder().isSuccess(false).code(code).message(message).build(); } @Override diff --git a/server/src/main/java/com/whylog/server/domain/user/exception/AuthSuccessStatus.java b/server/src/main/java/com/whylog/server/domain/user/exception/AuthSuccessStatus.java index eaf4931..f570529 100644 --- a/server/src/main/java/com/whylog/server/domain/user/exception/AuthSuccessStatus.java +++ b/server/src/main/java/com/whylog/server/domain/user/exception/AuthSuccessStatus.java @@ -9,11 +9,13 @@ @Getter @AllArgsConstructor public enum AuthSuccessStatus implements BaseCode { - SIGN_UP_SUCCESS(HttpStatus.OK, "AUTH200_1", "회원가입에 성공했습니다."), LOGIN_SUCCESS(HttpStatus.OK, "AUTH200_2", "로그인에 성공했습니다."), REFRESH_TOKEN_SUCCESS(HttpStatus.OK, "AUTH200_3", "액세스 토큰 재발급에 성공했습니다."), - LOGOUT_SUCCESS(HttpStatus.OK, "AUTH200_4", "로그아웃에 성공했습니다."); + LOGOUT_SUCCESS(HttpStatus.OK, "AUTH200_4", "로그아웃에 성공했습니다."), + EMAIL_VERIFICATION_ISSUE_SUCCESS(HttpStatus.OK, "AUTH200_5", "이메일 인증 코드 발급 요청에 성공했습니다."), + EMAIL_VERIFICATION_VERIFY_SUCCESS(HttpStatus.OK, "AUTH200_6", "이메일 인증에 성공했습니다."), + WITHDRAWAL_RECOVERY_SUCCESS(HttpStatus.OK, "AUTH200_7", "탈퇴 계정 복구에 성공했습니다."); private final HttpStatus httpStatus; private final String code; @@ -21,11 +23,7 @@ public enum AuthSuccessStatus implements BaseCode { @Override public ReasonDTO getReason() { - return ReasonDTO.builder() - .isSuccess(true) - .code(code) - .message(message) - .build(); + return ReasonDTO.builder().isSuccess(true).code(code).message(message).build(); } @Override diff --git a/server/src/main/java/com/whylog/server/domain/user/exception/MemberErrorCode.java b/server/src/main/java/com/whylog/server/domain/user/exception/MemberErrorCode.java new file mode 100644 index 0000000..0519a34 --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/exception/MemberErrorCode.java @@ -0,0 +1,40 @@ +package com.whylog.server.domain.user.exception; + +import com.whylog.server.global.apiPayload.code.BaseErrorCode; +import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum MemberErrorCode implements BaseErrorCode { + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_404", "찾을 수 없는 유저입니다."), + MEMBER_WITHDRAWAL_OWNER_HAS_MEMBERS( + HttpStatus.CONFLICT, "MEMBER_409", "다른 팀원이 남아 있는 팀의 소유권을 이전한 뒤 탈퇴해 주세요."), + MEMBER_WITHDRAWAL_NOT_ALLOWED(HttpStatus.CONFLICT, "MEMBER_409_2", "탈퇴를 요청할 수 없는 계정 상태입니다."), + MEMBER_PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "MEMBER_400", "현재 비밀번호가 올바르지 않습니다."), + MEMBER_PROFILE_IMAGE_INVALID( + HttpStatus.BAD_REQUEST, "MEMBER_400_2", "JPEG, PNG, WebP 이미지 파일만 업로드할 수 있습니다."), + MEMBER_PROFILE_IMAGE_TOO_LARGE( + HttpStatus.PAYLOAD_TOO_LARGE, "MEMBER_413", "프로필 이미지는 5MB 이하만 업로드할 수 있습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.builder().isSuccess(false).code(code).message(message).build(); + } + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/server/src/main/java/com/whylog/server/domain/user/exception/MemberErrorStatus.java b/server/src/main/java/com/whylog/server/domain/user/exception/MemberErrorStatus.java deleted file mode 100644 index 2ba1094..0000000 --- a/server/src/main/java/com/whylog/server/domain/user/exception/MemberErrorStatus.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.whylog.server.domain.user.exception; - -import com.whylog.server.global.apiPayload.code.BaseErrorCode; -import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@Getter -@RequiredArgsConstructor -public enum MemberErrorStatus implements BaseErrorCode { - - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_404", "찾을 수 없는 유저입니다.") - ; - - private final HttpStatus httpStatus; - private final String code; - private final String message; - - @Override - public ErrorReasonDTO getReason() { - return ErrorReasonDTO.builder() - .isSuccess(false) - .code(code) - .message(message) - .build(); - } - - @Override - public ErrorReasonDTO getReasonHttpStatus() { - return ErrorReasonDTO.builder() - .httpStatus(httpStatus) - .isSuccess(false) - .code(code) - .message(message) - .build(); - } - -} diff --git a/server/src/main/java/com/whylog/server/domain/user/exception/MemberNotFoundException.java b/server/src/main/java/com/whylog/server/domain/user/exception/MemberNotFoundException.java index 7ad6262..09653a1 100644 --- a/server/src/main/java/com/whylog/server/domain/user/exception/MemberNotFoundException.java +++ b/server/src/main/java/com/whylog/server/domain/user/exception/MemberNotFoundException.java @@ -4,10 +4,7 @@ public class MemberNotFoundException extends GeneralException { - public MemberNotFoundException() { - super(MemberErrorStatus.MEMBER_NOT_FOUND); - } - - - + public MemberNotFoundException() { + super(MemberErrorCode.MEMBER_NOT_FOUND); + } } diff --git a/server/src/main/java/com/whylog/server/domain/user/repository/EmailVerificationCodeRepository.java b/server/src/main/java/com/whylog/server/domain/user/repository/EmailVerificationCodeRepository.java new file mode 100644 index 0000000..9e0628f --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/repository/EmailVerificationCodeRepository.java @@ -0,0 +1,47 @@ +package com.whylog.server.domain.user.repository; + +import com.whylog.server.domain.user.entity.EmailVerificationCode; +import jakarta.persistence.LockModeType; +import java.time.LocalDateTime; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface EmailVerificationCodeRepository + extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select c from EmailVerificationCode c where c.memberId = :memberId") + Optional findByMemberIdForUpdate(@Param("memberId") Long memberId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + """ + delete from EmailVerificationCode c + where c.memberId = :memberId + and c.codeHmac = :codeHmac + """) + int deleteIfCodeMatches(@Param("memberId") Long memberId, @Param("codeHmac") String codeHmac); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + """ + update EmailVerificationCode c + set c.codeHmac = :previousCodeHmac, + c.expiresAt = :previousExpiresAt, + c.lastIssuedAt = :previousLastIssuedAt, + c.failedAttempts = :previousFailedAttempts + where c.memberId = :memberId + and c.codeHmac = :issuedCodeHmac + """) + int restorePreviousCodeIfIssuedCodeMatches( + @Param("memberId") Long memberId, + @Param("issuedCodeHmac") String issuedCodeHmac, + @Param("previousCodeHmac") String previousCodeHmac, + @Param("previousExpiresAt") LocalDateTime previousExpiresAt, + @Param("previousLastIssuedAt") LocalDateTime previousLastIssuedAt, + @Param("previousFailedAttempts") int previousFailedAttempts); +} diff --git a/server/src/main/java/com/whylog/server/domain/user/repository/MemberRepository.java b/server/src/main/java/com/whylog/server/domain/user/repository/MemberRepository.java index 1858cbd..c03c3de 100644 --- a/server/src/main/java/com/whylog/server/domain/user/repository/MemberRepository.java +++ b/server/src/main/java/com/whylog/server/domain/user/repository/MemberRepository.java @@ -1,12 +1,43 @@ package com.whylog.server.domain.user.repository; import com.whylog.server.domain.user.entity.Member; -import org.springframework.data.jpa.repository.JpaRepository; - +import com.whylog.server.domain.user.enums.AccountStatus; +import jakarta.persistence.LockModeType; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface MemberRepository extends JpaRepository { boolean existsByEmail(String email); + boolean existsByIdAndAccountStatus(Long memberId, AccountStatus accountStatus); + Optional findByEmail(String email); + + Optional findByIdAndAccountStatus(Long memberId, AccountStatus accountStatus); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select m from Member m where m.email = :email") + Optional findByEmailForUpdate(@Param("email") String email); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select m from Member m where m.id = :memberId") + Optional findByIdForUpdate(@Param("memberId") Long memberId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + """ + select m + from Member m + where m.accountStatus = :accountStatus + and m.purgeAt <= :purgeAt + order by m.id asc + """) + List findPurgeCandidatesForUpdate( + @Param("accountStatus") AccountStatus accountStatus, + @Param("purgeAt") LocalDateTime purgeAt); } diff --git a/server/src/main/java/com/whylog/server/domain/user/service/AuthenticationService.java b/server/src/main/java/com/whylog/server/domain/user/service/AuthenticationService.java index 4c28ed5..4dac800 100644 --- a/server/src/main/java/com/whylog/server/domain/user/service/AuthenticationService.java +++ b/server/src/main/java/com/whylog/server/domain/user/service/AuthenticationService.java @@ -2,78 +2,79 @@ import com.whylog.server.domain.user.dto.AccessTokenGenerateResponse; import com.whylog.server.domain.user.dto.AuthResponse; -import com.whylog.server.domain.user.exception.AuthErrorStatus; import com.whylog.server.domain.user.entity.Member; import com.whylog.server.domain.user.enums.Role; +import com.whylog.server.domain.user.exception.AuthErrorCode; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; import com.whylog.server.global.auth.jwt.application.TokenService; import com.whylog.server.global.auth.jwt.provider.JwtTokenProvider; import com.whylog.server.global.auth.jwt.provider.JwtValidationType; import com.whylog.server.global.auth.security.MemberAuthentication; -import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import java.util.Collection; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Collection; -import java.util.List; - @Service @RequiredArgsConstructor public class AuthenticationService { private final JwtTokenProvider jwtTokenProvider; private final TokenService tokenService; + private final MemberAccountStatusQueryService memberAccountStatusQueryService; @Transactional public AuthResponse.LoginResponseDTO generateLoginResponse(Member member) { Role role = member.getRole(); Collection authorities = List.of(role.toGrantedAuthority()); - UsernamePasswordAuthenticationToken authentication = createAuthentication(member.getId(), role, authorities); + UsernamePasswordAuthenticationToken authentication = + createAuthentication(member.getId(), role, authorities); String refreshToken = jwtTokenProvider.issueRefreshToken(authentication); tokenService.saveRefreshToken(member.getId(), refreshToken); String accessToken = jwtTokenProvider.issueAccessToken(authentication); return AuthResponse.LoginResponseDTO.of( - accessToken, - refreshToken, - member.getId(), - member.getEmail(), - role.getRoleName() - ); + accessToken, refreshToken, member.getId(), member.getEmail(), role.getRoleName()); } @Transactional(readOnly = true) public AccessTokenGenerateResponse generateAccessTokenFromRefreshToken(String refreshToken) { JwtValidationType validationType = jwtTokenProvider.validateToken(refreshToken); if (validationType != JwtValidationType.VALID_JWT) { - throw new ErrorHandler(switch (validationType) { - case EXPIRED_JWT_TOKEN -> AuthErrorStatus.REFRESH_TOKEN_EXPIRED; - case INVALID_JWT_TOKEN, INVALID_JWT_SIGNATURE, UNSUPPORTED_JWT_TOKEN, EMPTY_JWT -> - AuthErrorStatus.INVALID_REFRESH_TOKEN; - default -> AuthErrorStatus.INVALID_REFRESH_TOKEN; - }); + throw new ErrorHandler( + switch (validationType) { + case EXPIRED_JWT_TOKEN -> AuthErrorCode.REFRESH_TOKEN_EXPIRED; + case INVALID_JWT_TOKEN, + INVALID_JWT_SIGNATURE, + UNSUPPORTED_JWT_TOKEN, + EMPTY_JWT -> + AuthErrorCode.INVALID_REFRESH_TOKEN; + default -> AuthErrorCode.INVALID_REFRESH_TOKEN; + }); } Long memberId = jwtTokenProvider.getMemberIdFromJwt(refreshToken); Long storedMemberId = tokenService.findIdByRefreshToken(refreshToken); if (!memberId.equals(storedMemberId)) { - throw new ErrorHandler(AuthErrorStatus.INVALID_REFRESH_TOKEN); + throw new ErrorHandler(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + if (!memberAccountStatusQueryService.isActive(memberId)) { + throw new ErrorHandler(AuthErrorCode.INVALID_REFRESH_TOKEN); } Role role = jwtTokenProvider.getRoleFromJwt(refreshToken); Collection authorities = List.of(role.toGrantedAuthority()); - UsernamePasswordAuthenticationToken authentication = createAuthentication(memberId, role, authorities); + UsernamePasswordAuthenticationToken authentication = + createAuthentication(memberId, role, authorities); return AccessTokenGenerateResponse.from(jwtTokenProvider.issueAccessToken(authentication)); } private UsernamePasswordAuthenticationToken createAuthentication( - Long memberId, - Role role, - Collection authorities - ) { + Long memberId, Role role, Collection authorities) { return new MemberAuthentication(memberId, null, authorities); } } diff --git a/server/src/main/java/com/whylog/server/domain/user/service/EmailVerificationCodeCodec.java b/server/src/main/java/com/whylog/server/domain/user/service/EmailVerificationCodeCodec.java new file mode 100644 index 0000000..7506113 --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/service/EmailVerificationCodeCodec.java @@ -0,0 +1,67 @@ +package com.whylog.server.domain.user.service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Locale; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class EmailVerificationCodeCodec { + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final String DOMAIN = "whylog:email-verification-code:v1"; + private static final int CODE_BOUND = 1_000_000; + + private final byte[] secret; + private final SecureRandom secureRandom; + + @Autowired + public EmailVerificationCodeCodec(@Value("${EMAIL_VERIFICATION_CODE_SECRET}") String secret) { + this(secret, new SecureRandom()); + } + + EmailVerificationCodeCodec(String secret, SecureRandom secureRandom) { + this.secret = secret.getBytes(StandardCharsets.UTF_8); + if (this.secret.length < 32) { + throw new IllegalArgumentException( + "email verification HMAC secret must be at least 32 bytes"); + } + this.secureRandom = secureRandom; + } + + public String newCode() { + return String.format(Locale.ROOT, "%06d", secureRandom.nextInt(CODE_BOUND)); + } + + public String hmac(Long memberId, String code) { + String payload = DOMAIN + "|memberId=" + memberId + "|code=" + code; + byte[] result = hmac(payload); + StringBuilder builder = new StringBuilder(result.length * 2); + for (byte b : result) { + builder.append(String.format(Locale.ROOT, "%02x", b)); + } + return builder.toString(); + } + + public boolean matches(Long memberId, String code, String expectedHmac) { + String actualHmac = hmac(memberId, code); + return MessageDigest.isEqual( + actualHmac.getBytes(StandardCharsets.UTF_8), + expectedHmac.getBytes(StandardCharsets.UTF_8)); + } + + private byte[] hmac(String payload) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(secret, HMAC_ALGORITHM)); + return mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + } catch (Exception exception) { + throw new IllegalStateException("HMAC-SHA256 code generation failed", exception); + } + } +} diff --git a/server/src/main/java/com/whylog/server/domain/user/service/EmailVerificationCodeFailureService.java b/server/src/main/java/com/whylog/server/domain/user/service/EmailVerificationCodeFailureService.java new file mode 100644 index 0000000..18a482a --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/service/EmailVerificationCodeFailureService.java @@ -0,0 +1,38 @@ +package com.whylog.server.domain.user.service; + +import com.whylog.server.domain.user.repository.EmailVerificationCodeRepository; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class EmailVerificationCodeFailureService { + + private final EmailVerificationCodeRepository codeRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void removeUndeliveredCode(Long memberId, String codeHmac) { + codeRepository.deleteIfCodeMatches(memberId, codeHmac); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void restorePreviousCode( + Long memberId, String issuedCodeHmac, PreviousCode previousCode) { + codeRepository.restorePreviousCodeIfIssuedCodeMatches( + memberId, + issuedCodeHmac, + previousCode.codeHmac(), + previousCode.expiresAt(), + previousCode.lastIssuedAt(), + previousCode.failedAttempts()); + } + + public record PreviousCode( + String codeHmac, + LocalDateTime expiresAt, + LocalDateTime lastIssuedAt, + int failedAttempts) {} +} diff --git a/server/src/main/java/com/whylog/server/domain/user/service/EmailVerificationCommandService.java b/server/src/main/java/com/whylog/server/domain/user/service/EmailVerificationCommandService.java new file mode 100644 index 0000000..102f3c5 --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/service/EmailVerificationCommandService.java @@ -0,0 +1,157 @@ +package com.whylog.server.domain.user.service; + +import com.whylog.server.domain.user.dto.AuthRequest; +import com.whylog.server.domain.user.entity.EmailVerificationCode; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.enums.AccountStatus; +import com.whylog.server.domain.user.exception.AuthErrorCode; +import com.whylog.server.domain.user.repository.EmailVerificationCodeRepository; +import com.whylog.server.domain.user.repository.MemberRepository; +import com.whylog.server.domain.user.service.EmailVerificationCodeFailureService.PreviousCode; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import com.whylog.server.global.external.email.EmailVerificationEmailMessage; +import com.whylog.server.global.external.email.EmailVerificationEmailSender; +import com.whylog.server.global.external.email.EmailVerificationSendException; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Service +@Transactional +@RequiredArgsConstructor +public class EmailVerificationCommandService { + + private static final Duration ISSUE_COOLDOWN = Duration.ofSeconds(60); + + private final MemberRepository memberRepository; + private final EmailVerificationCodeRepository codeRepository; + private final EmailVerificationCodeCodec codeCodec; + private final EmailVerificationEmailSender emailSender; + private final EmailVerificationCodeFailureService codeFailureService; + private final Clock clock = Clock.systemDefaultZone(); + + public void issueForSignup(Member member) { + issueForMember( + member, now(), codeRepository.findByMemberIdForUpdate(member.getId()).orElse(null)); + } + + public void issue(AuthRequest.EmailVerificationIssueDTO request) { + String email = Member.canonicalizeEmail(request.getEmail()); + Member member = memberRepository.findByEmailForUpdate(email).orElse(null); + if (member == null || member.getAccountStatus() != AccountStatus.UNVERIFIED) { + return; + } + + LocalDateTime now = now(); + EmailVerificationCode currentCode = + codeRepository.findByMemberIdForUpdate(member.getId()).orElse(null); + if (currentCode != null + && currentCode.getLastIssuedAt().plus(ISSUE_COOLDOWN).isAfter(now)) { + throw new ErrorHandler(AuthErrorCode.EMAIL_VERIFICATION_ISSUE_COOLDOWN); + } + + issueForMember(member, now, currentCode); + } + + @Transactional(noRollbackFor = ErrorHandler.class) + public Member verify(AuthRequest.EmailVerificationVerifyDTO request) { + String email = Member.canonicalizeEmail(request.getEmail()); + Member member = memberRepository.findByEmailForUpdate(email).orElse(null); + if (member == null) { + throw new ErrorHandler(AuthErrorCode.EMAIL_VERIFICATION_CODE_INVALID); + } + if (member.getAccountStatus() != AccountStatus.UNVERIFIED) { + throw new ErrorHandler(AuthErrorCode.EMAIL_VERIFICATION_CODE_INVALID); + } + + EmailVerificationCode verificationCode = + codeRepository.findByMemberIdForUpdate(member.getId()).orElse(null); + if (verificationCode == null || verificationCode.isInvalidated()) { + throw new ErrorHandler(AuthErrorCode.EMAIL_VERIFICATION_CODE_INVALID); + } + if (verificationCode.isExpiredAt(now())) { + throw new ErrorHandler(AuthErrorCode.EMAIL_VERIFICATION_CODE_EXPIRED); + } + + if (!codeCodec.matches(member.getId(), request.getCode(), verificationCode.getCodeHmac())) { + verificationCode.recordFailure(); + throw new ErrorHandler(AuthErrorCode.EMAIL_VERIFICATION_CODE_INVALID); + } + + member.verifyEmail(now()); + codeRepository.delete(verificationCode); + return member; + } + + private void issueForMember( + Member member, LocalDateTime now, EmailVerificationCode currentCode) { + String rawCode = codeCodec.newCode(); + String codeHmac = codeCodec.hmac(member.getId(), rawCode); + PreviousCode previousCode = snapshot(currentCode); + if (currentCode == null) { + codeRepository.save(EmailVerificationCode.issue(member.getId(), codeHmac, now)); + } else { + currentCode.replace(codeHmac, now); + } + scheduleAfterCommit( + () -> sendCode(member.getId(), member.getEmail(), rawCode, codeHmac, previousCode)); + } + + private void sendCode( + Long memberId, + String email, + String rawCode, + String codeHmac, + PreviousCode previousCode) { + try { + emailSender.send(new EmailVerificationEmailMessage(email, rawCode)); + } catch (EmailVerificationSendException exception) { + restoreCodeAfterDeliveryFailure(memberId, codeHmac, previousCode); + throw new ErrorHandler(AuthErrorCode.EMAIL_VERIFICATION_DELIVERY_FAILED); + } + } + + private PreviousCode snapshot(EmailVerificationCode currentCode) { + if (currentCode == null) { + return null; + } + return new PreviousCode( + currentCode.getCodeHmac(), + currentCode.getExpiresAt(), + currentCode.getLastIssuedAt(), + currentCode.getFailedAttempts()); + } + + private void restoreCodeAfterDeliveryFailure( + Long memberId, String codeHmac, PreviousCode previousCode) { + if (previousCode == null) { + codeFailureService.removeUndeliveredCode(memberId, codeHmac); + return; + } + codeFailureService.restorePreviousCode(memberId, codeHmac, previousCode); + } + + private void scheduleAfterCommit(Runnable task) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); + return; + } + + task.run(); + } + + private LocalDateTime now() { + return LocalDateTime.now(clock); + } +} diff --git a/server/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java b/server/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java index 9b03571..4d1ad20 100644 --- a/server/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java +++ b/server/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java @@ -2,11 +2,13 @@ import com.whylog.server.domain.user.dto.AuthRequest; import com.whylog.server.domain.user.dto.AuthResponse; -import com.whylog.server.domain.user.exception.AuthErrorStatus; import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.enums.AccountStatus; import com.whylog.server.domain.user.enums.Role; +import com.whylog.server.domain.user.exception.AuthErrorCode; import com.whylog.server.domain.user.repository.MemberRepository; import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -19,26 +21,55 @@ public class LocalLoginService { private final MemberRepository memberRepository; private final AuthenticationService authenticationService; private final PasswordEncoder passwordEncoder; + private final EmailVerificationCommandService emailVerificationCommandService; + private final WithdrawalRecoveryChallengeService withdrawalRecoveryChallengeService; @Transactional - public AuthResponse.LoginResponseDTO signUp(AuthRequest.SignUpDTO request) { - if (memberRepository.existsByEmail(request.getEmail())) { - throw new ErrorHandler(AuthErrorStatus.EMAIL_ALREADY_EXISTS); + public AuthResponse.SignUpResponseDTO signUp(AuthRequest.SignUpDTO request) { + String email = Member.canonicalizeEmail(request.getEmail()); + if (memberRepository.existsByEmail(email)) { + throw new ErrorHandler(AuthErrorCode.EMAIL_ALREADY_EXISTS); } String encodedPassword = passwordEncoder.encode(request.getPassword()); Member member = memberRepository.save(Member.create(request, encodedPassword, Role.USER)); + emailVerificationCommandService.issueForSignup(member); - return authenticationService.generateLoginResponse(member); + return AuthResponse.SignUpResponseDTO.builder() + .memberId(member.getId()) + .email(member.getEmail()) + .build(); } @Transactional public AuthResponse.LoginResponseDTO login(AuthRequest.LoginDTO request) { - Member member = memberRepository.findByEmail(request.getEmail()) - .orElseThrow(() -> new ErrorHandler(AuthErrorStatus.LOGIN_FAILED)); + String email = Member.canonicalizeEmail(request.getEmail()); + Member member = + memberRepository + .findByEmail(email) + .orElseThrow(() -> new ErrorHandler(AuthErrorCode.LOGIN_FAILED)); if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { - throw new ErrorHandler(AuthErrorStatus.LOGIN_FAILED); + throw new ErrorHandler(AuthErrorCode.LOGIN_FAILED); + } + + if (member.isWithdrawalGraceActive(LocalDateTime.now())) { + String challenge = withdrawalRecoveryChallengeService.issue(member.getId()); + return AuthResponse.LoginResponseDTO.recoveryRequired( + member.getId(), + member.getEmail(), + member.getRole().getRoleName(), + challenge, + member.getPurgeAt()); + } + + if (member.getAccountStatus() == AccountStatus.UNVERIFIED) { + return AuthResponse.LoginResponseDTO.emailVerificationRequired( + member.getId(), member.getEmail(), member.getRole().getRoleName()); + } + + if (!member.getAccountStatus().canUseNormalService()) { + throw new ErrorHandler(AuthErrorCode.LOGIN_FAILED); } return authenticationService.generateLoginResponse(member); diff --git a/server/src/main/java/com/whylog/server/domain/user/service/MemberAccountStatusQueryService.java b/server/src/main/java/com/whylog/server/domain/user/service/MemberAccountStatusQueryService.java new file mode 100644 index 0000000..96ff226 --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/service/MemberAccountStatusQueryService.java @@ -0,0 +1,19 @@ +package com.whylog.server.domain.user.service; + +import com.whylog.server.domain.user.enums.AccountStatus; +import com.whylog.server.domain.user.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberAccountStatusQueryService { + + private final MemberRepository memberRepository; + + public boolean isActive(Long memberId) { + return memberRepository.existsByIdAndAccountStatus(memberId, AccountStatus.ACTIVE); + } +} diff --git a/server/src/main/java/com/whylog/server/domain/user/service/MemberCommandService.java b/server/src/main/java/com/whylog/server/domain/user/service/MemberCommandService.java index 75bb068..f5af76f 100644 --- a/server/src/main/java/com/whylog/server/domain/user/service/MemberCommandService.java +++ b/server/src/main/java/com/whylog/server/domain/user/service/MemberCommandService.java @@ -1,31 +1,270 @@ package com.whylog.server.domain.user.service; +import com.whylog.server.domain.meeting.socket.MeetingSocketRoomService; +import com.whylog.server.domain.team.enums.TeamRole; +import com.whylog.server.domain.team.repository.TeamMemberRepository; +import com.whylog.server.domain.team.repository.TeamRepository; +import com.whylog.server.domain.user.dto.MemberRequest; import com.whylog.server.domain.user.dto.MemberResponse; import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.exception.MemberErrorCode; +import com.whylog.server.domain.user.repository.MemberRepository; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import com.whylog.server.global.auth.jwt.application.TokenService; import com.whylog.server.global.external.s3.ImageType; import com.whylog.server.global.external.s3.S3Client; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor +@Slf4j +@Transactional public class MemberCommandService { - private final MemberUseCase memberUseCase; + private static final long MAX_PROFILE_IMAGE_BYTES = 5L * 1024 * 1024; + private static final Set ALLOWED_PROFILE_IMAGE_TYPES = + Set.of("image/jpeg", "image/png", "image/webp"); + + private final MemberRepository memberRepository; + private final TeamMemberRepository teamMemberRepository; + private final TeamRepository teamRepository; + private final TokenService tokenService; + private final MeetingSocketRoomService meetingSocketRoomService; private final S3Client s3Client; + private final PasswordEncoder passwordEncoder; + private final TransactionTemplate transactionTemplate; + + public MemberResponse.MemberUpdateResponseDTO updateName( + Long memberId, MemberRequest.NameUpdateDTO request) { + Member member = findActiveMember(memberId); + member.updateName(request.getName()); + return updateResponse(member); + } - @Transactional - public MemberResponse.ProfileImageUploadResponseDTO uploadProfileImage(Long memberId, MultipartFile image) { + public MemberResponse.ProfileVisibilityUpdateResponseDTO updateProfileVisibility( + Long memberId, MemberRequest.ProfileVisibilityUpdateDTO request) { + Member member = findActiveMember(memberId); + member.updateProfileVisibility(request.getProfileVisibility()); + return MemberResponse.ProfileVisibilityUpdateResponseDTO.builder() + .profileVisibility(member.getProfileVisibility()) + .build(); + } - Member member = memberUseCase.findMemberById(memberId); + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public MemberResponse.ProfileImageUploadResponseDTO uploadProfileImage( + Long memberId, MultipartFile image) { + validateProfileImage(image); String imageKey = s3Client.uploadFile(image, ImageType.MEMBER_PROFILE); - member.updateProfileImage(imageKey); + try { + return transactionTemplate.execute(status -> replaceProfileImage(memberId, imageKey)); + } catch (RuntimeException exception) { + deleteUploadedImageAfterFailure(imageKey, exception); + throw exception; + } + } + + public MemberResponse.MemberUpdateResponseDTO removeProfileImage(Long memberId) { + Member member = findActiveMember(memberId); + String previousImage = member.getProfileImage(); + member.removeProfileImage(); + scheduleAfterCommit(() -> deleteOldProfileImageBestEffort(previousImage)); + return updateResponse(member); + } + + @Transactional(readOnly = true) + public void verifyCurrentPassword( + Long memberId, MemberRequest.CurrentPasswordVerifyDTO request) { + Member member = findActiveMemberForCredentialCheck(memberId); + if (!passwordEncoder.matches(request.getCurrentPassword(), member.getPassword())) { + throw new ErrorHandler(MemberErrorCode.MEMBER_PASSWORD_MISMATCH); + } + } + public MemberResponse.MemberUpdateResponseDTO changePassword( + Long memberId, MemberRequest.PasswordChangeDTO request) { + Member member = findActiveMember(memberId); + if (!passwordEncoder.matches(request.getCurrentPassword(), member.getPassword())) { + throw new ErrorHandler(MemberErrorCode.MEMBER_PASSWORD_MISMATCH); + } + member.updatePassword(passwordEncoder.encode(request.getNewPassword())); + tokenService.deleteRefreshTokenIfExists(memberId); + return updateResponse(member); + } + + private MemberResponse.ProfileImageUploadResponseDTO replaceProfileImage( + Long memberId, String imageKey) { + Member member = findActiveMember(memberId); + String previousImage = member.getProfileImage(); + member.updateProfileImage(imageKey); + scheduleAfterCommit(() -> deleteOldProfileImageBestEffort(previousImage)); return MemberResponse.ProfileImageUploadResponseDTO.builder() .memberId(member.getId()) .profileImageUrl(s3Client.getFileUrl(imageKey)) .build(); } + + private void deleteOldProfileImageBestEffort(String imageKey) { + if (imageKey == null || imageKey.isBlank()) { + return; + } + try { + s3Client.deleteFile(imageKey); + } catch (RuntimeException exception) { + log.warn("Failed to delete old member profile image: {}", imageKey, exception); + } + } + + private void deleteUploadedImageAfterFailure(String imageKey, RuntimeException cause) { + try { + s3Client.deleteFile(imageKey); + } catch (RuntimeException cleanupFailure) { + cause.addSuppressed(cleanupFailure); + } + } + + private void validateProfileImage(MultipartFile image) { + if (image == null || image.isEmpty()) { + return; + } + if (image.getSize() > MAX_PROFILE_IMAGE_BYTES) { + throw new ErrorHandler(MemberErrorCode.MEMBER_PROFILE_IMAGE_TOO_LARGE); + } + String contentType = image.getContentType(); + if (!ALLOWED_PROFILE_IMAGE_TYPES.contains(contentType) + || !hasExpectedImageSignature(image, contentType)) { + throw new ErrorHandler(MemberErrorCode.MEMBER_PROFILE_IMAGE_INVALID); + } + } + + private boolean hasExpectedImageSignature(MultipartFile image, String contentType) { + try (InputStream inputStream = image.getInputStream()) { + byte[] header = inputStream.readNBytes(12); + return switch (contentType) { + case "image/jpeg" -> isJpeg(header); + case "image/png" -> isPng(header); + case "image/webp" -> isWebp(header); + default -> false; + }; + } catch (IOException exception) { + return false; + } + } + + private boolean isJpeg(byte[] header) { + return header.length >= 3 + && (header[0] & 0xff) == 0xff + && (header[1] & 0xff) == 0xd8 + && (header[2] & 0xff) == 0xff; + } + + private boolean isPng(byte[] header) { + int[] signature = {0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}; + if (header.length < signature.length) { + return false; + } + for (int index = 0; index < signature.length; index++) { + if ((header[index] & 0xff) != signature[index]) { + return false; + } + } + return true; + } + + private boolean isWebp(byte[] header) { + return header.length >= 12 + && header[0] == 'R' + && header[1] == 'I' + && header[2] == 'F' + && header[3] == 'F' + && header[8] == 'W' + && header[9] == 'E' + && header[10] == 'B' + && header[11] == 'P'; + } + + private Member findActiveMember(Long memberId) { + Member member = + memberRepository + .findByIdForUpdate(memberId) + .orElseThrow(() -> new ErrorHandler(MemberErrorCode.MEMBER_NOT_FOUND)); + if (!member.getAccountStatus().canUseNormalService()) { + throw new ErrorHandler(MemberErrorCode.MEMBER_NOT_FOUND); + } + return member; + } + + private Member findActiveMemberForCredentialCheck(Long memberId) { + Member member = + memberRepository + .findById(memberId) + .orElseThrow( + () -> new ErrorHandler(MemberErrorCode.MEMBER_PASSWORD_MISMATCH)); + if (!member.getAccountStatus().canUseNormalService()) { + throw new ErrorHandler(MemberErrorCode.MEMBER_PASSWORD_MISMATCH); + } + return member; + } + + private MemberResponse.MemberUpdateResponseDTO updateResponse(Member member) { + return MemberResponse.MemberUpdateResponseDTO.builder() + .memberId(member.getId()) + .name(member.getName()) + .email(member.getEmail()) + .profileImage(s3Client.getFileUrl(member.getProfileImage())) + .build(); + } + + public void requestWithdrawal(Long memberId) { + List ownedTeamIds = + teamMemberRepository.findActiveOwnerTeamIdsByMemberId(memberId, TeamRole.OWNER); + if (!ownedTeamIds.isEmpty()) { + teamRepository.findAllByIdInForUpdate(ownedTeamIds); + } + + Member member = + memberRepository + .findByIdForUpdate(memberId) + .orElseThrow(() -> new ErrorHandler(MemberErrorCode.MEMBER_NOT_FOUND)); + if (!member.getAccountStatus().canUseNormalService()) { + throw new ErrorHandler(MemberErrorCode.MEMBER_WITHDRAWAL_NOT_ALLOWED); + } + if (!ownedTeamIds.isEmpty() + && !teamMemberRepository + .findOwnedTeamIdsWithOtherActiveMembers(ownedTeamIds) + .isEmpty()) { + throw new ErrorHandler(MemberErrorCode.MEMBER_WITHDRAWAL_OWNER_HAS_MEMBERS); + } + member.requestWithdrawal(LocalDateTime.now()); + tokenService.deleteRefreshTokenIfExists(memberId); + scheduleAfterCommit(() -> meetingSocketRoomService.disconnectMemberSessions(memberId)); + } + + private void scheduleAfterCommit(Runnable task) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); + return; + } + + task.run(); + } } diff --git a/server/src/main/java/com/whylog/server/domain/user/service/MemberDisplayResolver.java b/server/src/main/java/com/whylog/server/domain/user/service/MemberDisplayResolver.java new file mode 100644 index 0000000..1fe44fc --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/service/MemberDisplayResolver.java @@ -0,0 +1,82 @@ +package com.whylog.server.domain.user.service; + +import com.whylog.server.domain.team.entity.TeamMember; +import com.whylog.server.domain.team.repository.TeamMemberRepository; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.repository.MemberRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberDisplayResolver { + + private static final String LEFT_MEMBER_NAME = "나간 사용자"; + private static final String WITHDRAWN_MEMBER_NAME = "탈퇴한 사용자"; + + private final TeamMemberRepository teamMemberRepository; + private final MemberRepository memberRepository; + + public Map resolveByTeamMemberIds(Long teamId, List memberIds) { + List distinctMemberIds = + memberIds.stream().filter(id -> id != null).distinct().toList(); + if (distinctMemberIds.isEmpty()) { + return Map.of(); + } + return resolveByTeam(teamId, memberRepository.findAllById(distinctMemberIds)); + } + + public Map resolveByTeam(Long teamId, List members) { + List memberIds = + members.stream() + .filter(member -> member != null && member.getId() != null) + .map(Member::getId) + .distinct() + .toList(); + if (memberIds.isEmpty()) { + return Map.of(); + } + + Map membershipsByMemberId = + teamMemberRepository.findByTeamIdAndMemberIdIn(teamId, memberIds).stream() + .collect( + Collectors.toMap( + teamMember -> teamMember.getMember().getId(), + Function.identity())); + + return members.stream() + .filter(member -> member != null && member.getId() != null) + .collect( + Collectors.toMap( + Member::getId, + member -> + resolve( + member, + membershipsByMemberId.get(member.getId())))); + } + + public DisplayMember resolve(Member member, TeamMember teamMember) { + if (member == null) { + return DisplayMember.textOnly(null); + } + if (member.isWithdrawnForRead(LocalDateTime.now())) { + return DisplayMember.textOnly(WITHDRAWN_MEMBER_NAME); + } + if (teamMember != null && Boolean.FALSE.equals(teamMember.getActive())) { + return DisplayMember.textOnly(LEFT_MEMBER_NAME); + } + return new DisplayMember(member.getId(), member.getName(), member.getProfileImage()); + } + + public record DisplayMember(Long memberId, String name, String profileImageKey) { + + private static DisplayMember textOnly(String name) { + return new DisplayMember(null, name, null); + } + } +} diff --git a/server/src/main/java/com/whylog/server/domain/user/service/MemberProfileAccessPolicy.java b/server/src/main/java/com/whylog/server/domain/user/service/MemberProfileAccessPolicy.java new file mode 100644 index 0000000..8b62d9f --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/service/MemberProfileAccessPolicy.java @@ -0,0 +1,13 @@ +package com.whylog.server.domain.user.service; + +import com.whylog.server.domain.user.entity.Member; +import org.springframework.stereotype.Component; + +@Component +public class MemberProfileAccessPolicy { + + public boolean canViewActivity(Long viewerId, Member profileMember) { + return viewerId.equals(profileMember.getId()) + || profileMember.getProfileVisibility().isPublic(); + } +} diff --git a/server/src/main/java/com/whylog/server/domain/user/service/MemberQueryService.java b/server/src/main/java/com/whylog/server/domain/user/service/MemberQueryService.java index aa65bd0..a9e52b9 100644 --- a/server/src/main/java/com/whylog/server/domain/user/service/MemberQueryService.java +++ b/server/src/main/java/com/whylog/server/domain/user/service/MemberQueryService.java @@ -1,32 +1,263 @@ package com.whylog.server.domain.user.service; +import com.whylog.server.domain.decision.repository.DecisionRepository; +import com.whylog.server.domain.git.repository.RepositoryRepository; +import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.team.entity.Team; import com.whylog.server.domain.team.repository.TeamMemberRepository; import com.whylog.server.domain.user.dto.MemberResponse; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.enums.AccountStatus; +import com.whylog.server.domain.user.exception.MemberErrorCode; +import com.whylog.server.domain.user.repository.MemberRepository; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; import com.whylog.server.global.external.s3.S3Client; +import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class MemberQueryService { + private static final int PARTICIPATING_PROJECT_PAGE_SIZE = 4; + + private final MemberRepository memberRepository; private final TeamMemberRepository teamMemberRepository; + private final MeetingRepository meetingRepository; + private final DecisionRepository decisionRepository; + private final RepositoryRepository repositoryRepository; + private final MemberProfileAccessPolicy memberProfileAccessPolicy; private final S3Client s3Client; - @Transactional(readOnly = true) public List getTeams(Long memberId) { return teamMemberRepository.findActiveTeamsByMemberId(memberId).stream() - .map(teamMember -> { - Team team = teamMember.getTeam(); - return MemberResponse.TeamListResponseDTO.builder() - .teamId(team.getId()) - .name(team.getName()) - .teamImage(s3Client.getFileUrl(team.getImage())) - .build(); - }) + .map( + teamMember -> { + Team team = teamMember.getTeam(); + return MemberResponse.TeamListResponseDTO.builder() + .teamId(team.getId()) + .name(team.getName()) + .teamImage(s3Client.getFileUrl(team.getImage())) + .build(); + }) .toList(); } + + public MemberResponse.MyInfoDTO getMyInfo(Long memberId) { + Member member = findActiveMember(memberId); + + return MemberResponse.MyInfoDTO.builder() + .memberId(member.getId()) + .name(member.getName()) + .email(member.getEmail()) + .profileImage(s3Client.getFileUrl(member.getProfileImage())) + .participatingProjectCount(participatingProjectCount(memberId)) + .accountStatus(member.getAccountStatus()) + .profileVisibility(member.getProfileVisibility()) + .recentMeetings(recentMeetings(memberId)) + .recentDecisions(recentDecisions(memberId)) + .build(); + } + + public MemberResponse.ProfileDTO getProfile(Long viewerId, Long memberId) { + Member member = findProfileMember(viewerId, memberId, false); + LocalDateTime now = LocalDateTime.now(); + if (member.isWithdrawnForRead(now)) { + return withdrawnProfile(member); + } + + if (!memberProfileAccessPolicy.canViewActivity(viewerId, member)) { + return privateProfile(member); + } + + return MemberResponse.FullProfileDTO.builder() + .memberId(member.getId()) + .name(member.getName()) + .email(member.getEmail()) + .profileImage(s3Client.getFileUrl(member.getProfileImage())) + .participatingProjectCount(participatingProjectCount(memberId)) + .build(); + } + + public MemberResponse.ParticipatingProjectListResponseDTO getParticipatingProjects( + Long viewerId, Long memberId, Long cursorId) { + Member member = findProfileMember(viewerId, memberId, true); + return MemberResponse.ParticipatingProjectListResponseDTO.from( + participatingProjects(member.getId(), cursorId), cursorId); + } + + private MemberResponse.PrivateProfileDTO privateProfile(Member member) { + return MemberResponse.PrivateProfileDTO.builder() + .memberId(member.getId()) + .name(member.getName()) + .email(member.getEmail()) + .profileImage(s3Client.getFileUrl(member.getProfileImage())) + .build(); + } + + private MemberResponse.WithdrawnProfileDTO withdrawnProfile(Member member) { + return MemberResponse.WithdrawnProfileDTO.builder() + .memberId(member.getId()) + .name("탈퇴한 사용자") + .email("") + .profileImage(null) + .build(); + } + + private Member findActiveMember(Long memberId) { + return memberRepository + .findByIdAndAccountStatus(memberId, AccountStatus.ACTIVE) + .orElseThrow(() -> new ErrorHandler(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + private Member findVisibleProfileMember(Long memberId) { + Member member = + memberRepository + .findById(memberId) + .orElseThrow(() -> new ErrorHandler(MemberErrorCode.MEMBER_NOT_FOUND)); + LocalDateTime now = LocalDateTime.now(); + if (member.getAccountStatus() == AccountStatus.ACTIVE + || member.isWithdrawalGraceActive(now) + || member.isWithdrawnForRead(now)) { + return member; + } + throw new ErrorHandler(MemberErrorCode.MEMBER_NOT_FOUND); + } + + private Member findProfileMember(Long viewerId, Long memberId, boolean fullProfileRequired) { + findActiveMember(viewerId); + Member member = findVisibleProfileMember(memberId); + if (fullProfileRequired + && (member.isWithdrawnForRead(LocalDateTime.now()) + || !memberProfileAccessPolicy.canViewActivity(viewerId, member))) { + throw new ErrorHandler(MemberErrorCode.MEMBER_NOT_FOUND); + } + return member; + } + + private Long participatingProjectCount(Long memberId) { + return teamMemberRepository.countActiveProjectsByMemberId(memberId); + } + + private Slice participatingProjects( + Long memberId, Long cursorId) { + Slice projectRows = + teamMemberRepository.findActiveProjectRowsByMemberId( + memberId, cursorId, participatingProjectPage()); + List projectIds = + projectRows.stream().map(TeamMemberRepository.ActiveProjectRow::projectId).toList(); + if (projectIds.isEmpty()) { + return projectRows.map(project -> participatingProject(project, Map.of(), Map.of())); + } + + Map memberCompletedMeetingStats = + meetingRepository.findMemberCompletedMeetingStats(memberId, projectIds).stream() + .collect( + Collectors.toMap( + MeetingRepository.ProfileMeetingStatsRow::projectId, + Function.identity())); + Map repositoryStats = + repositoryRepository.findProfileRepositoryStatsRows(projectIds).stream() + .collect( + Collectors.toMap( + RepositoryRepository.ProfileRepositoryStatsRow::projectId, + Function.identity())); + + return projectRows.map( + project -> + participatingProject( + project, memberCompletedMeetingStats, repositoryStats)); + } + + private MemberResponse.ParticipatingProjectSummaryDTO participatingProject( + TeamMemberRepository.ActiveProjectRow project, + Map memberCompletedMeetingStats, + Map repositoryStats) { + MeetingRepository.ProfileMeetingStatsRow memberMeetingStats = + memberCompletedMeetingStats.get(project.projectId()); + RepositoryRepository.ProfileRepositoryStatsRow projectRepositoryStats = + repositoryStats.get(project.projectId()); + + return MemberResponse.ParticipatingProjectSummaryDTO.builder() + .projectId(project.projectId()) + .name(project.name()) + .image(s3Client.getFileUrl(project.image())) + .memberCompletedMeetingCount(meetingCount(memberMeetingStats)) + .memberCompletedMeetingDurationSeconds(durationSeconds(memberMeetingStats)) + .projectStoredCommitCount(commitCount(projectRepositoryStats)) + .lastSyncedAt(lastSyncedAt(projectRepositoryStats)) + .build(); + } + + private List recentMeetings(Long memberId) { + return meetingRepository + .findRecentCompletedMeetingRowsByMemberId(memberId, recentMeetingPage()) + .stream() + .map( + row -> + MemberResponse.RecentMeetingDTO.builder() + .meetingId(row.meetingId()) + .projectId(row.projectId()) + .projectName(row.projectName()) + .name(row.name()) + .endedAt(row.endedAt()) + .durationSeconds(row.durationSeconds()) + .build()) + .toList(); + } + + private List recentDecisions(Long memberId) { + return decisionRepository + .findRecentDecisionRowsByMemberId( + memberId, + PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "createdAt", "id"))) + .stream() + .map( + row -> + MemberResponse.RecentDecisionDTO.builder() + .decisionId(row.decisionId()) + .projectId(row.projectId()) + .projectName(row.projectName()) + .name(row.name()) + .createdAt(row.createdAt()) + .build()) + .toList(); + } + + private Long meetingCount(MeetingRepository.ProfileMeetingStatsRow row) { + return row == null ? 0L : row.meetingCount(); + } + + private PageRequest recentMeetingPage() { + return PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "endDateTime", "id")); + } + + private PageRequest participatingProjectPage() { + return PageRequest.of(0, PARTICIPATING_PROJECT_PAGE_SIZE); + } + + private Long durationSeconds(MeetingRepository.ProfileMeetingStatsRow row) { + return row == null ? 0L : row.durationSeconds(); + } + + private Long commitCount(RepositoryRepository.ProfileRepositoryStatsRow row) { + return row == null ? 0L : row.commitCount(); + } + + private LocalDateTime lastSyncedAt(RepositoryRepository.ProfileRepositoryStatsRow row) { + if (row == null || row.repositoryCount() == 0) { + return null; + } + return row.latestLastSyncedAt(); + } } diff --git a/server/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java b/server/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java index d920e67..d346bde 100644 --- a/server/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java +++ b/server/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java @@ -3,41 +3,23 @@ import com.whylog.server.domain.user.entity.Member; import com.whylog.server.domain.user.exception.MemberNotFoundException; import com.whylog.server.domain.user.repository.MemberRepository; -import java.util.List; - -import com.whylog.server.global.external.s3.S3Client; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class MemberUseCase { private final MemberRepository memberRepository; - private final S3Client s3Client; // id로 member 조회 - public Member findMemberById(Long id){ - return memberRepository.findById(id) - .orElseThrow(MemberNotFoundException::new); + public Member findMemberById(Long id) { + return memberRepository.findById(id).orElseThrow(MemberNotFoundException::new); } public Member findMemberByEmail(String email) { - return memberRepository.findByEmail(email) - .orElseThrow(MemberNotFoundException::new); - } - - public List findMembersByIds(List memberIds) { - return memberRepository.findAllById(memberIds); - } - - public String getProfileImageUrl(Member member) { - - if(member == null) - return null; - - // s3Client.getFileUrl에서 null 검사 해줘서 바로 리턴해줘도 됨 - return s3Client.getFileUrl(member.getProfileImage()); + return memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); } - } diff --git a/server/src/main/java/com/whylog/server/domain/user/service/WithdrawalPurgeService.java b/server/src/main/java/com/whylog/server/domain/user/service/WithdrawalPurgeService.java new file mode 100644 index 0000000..504831a --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/service/WithdrawalPurgeService.java @@ -0,0 +1,89 @@ +package com.whylog.server.domain.user.service; + +import com.whylog.server.domain.team.repository.TeamMemberRepository; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.enums.AccountStatus; +import com.whylog.server.domain.user.repository.MemberRepository; +import com.whylog.server.global.external.s3.S3Client; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class WithdrawalPurgeService { + + private final MemberRepository memberRepository; + private final TeamMemberRepository teamMemberRepository; + private final S3Client s3Client; + private final PasswordEncoder passwordEncoder; + private final SecureRandom secureRandom = new SecureRandom(); + + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + public void purgeExpiredWithdrawalMembers() { + purgeExpiredWithdrawalMembers(LocalDateTime.now()); + } + + public int purgeExpiredWithdrawalMembers(LocalDateTime now) { + List members = + memberRepository.findPurgeCandidatesForUpdate(AccountStatus.INACTIVE, now); + return (int) members.stream().filter(member -> purge(member, now)).count(); + } + + private boolean purge(Member member, LocalDateTime now) { + if (!member.isWithdrawalGraceExpired(now)) { + return false; + } + + String imageKey = member.getProfileImage(); + String syntheticEmail = "withdrawn-" + member.getId() + "@whylog.invalid"; + String encodedPassword = passwordEncoder.encode(randomSecret()); + member.purgeWithdrawal(syntheticEmail, encodedPassword); + teamMemberRepository.deactivateActiveMembershipsByMemberId(member.getId()); + scheduleAfterCommit(() -> deleteProfileImageBestEffort(imageKey)); + return true; + } + + private void deleteProfileImageBestEffort(String imageKey) { + if (imageKey == null || imageKey.isBlank()) { + return; + } + try { + s3Client.deleteFile(imageKey); + } catch (RuntimeException exception) { + log.warn("Failed to delete purged member profile image: {}", imageKey, exception); + } + } + + private void scheduleAfterCommit(Runnable task) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); + return; + } + + task.run(); + } + + private String randomSecret() { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/server/src/main/java/com/whylog/server/domain/user/service/WithdrawalRecoveryChallengeService.java b/server/src/main/java/com/whylog/server/domain/user/service/WithdrawalRecoveryChallengeService.java new file mode 100644 index 0000000..647584f --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/service/WithdrawalRecoveryChallengeService.java @@ -0,0 +1,52 @@ +package com.whylog.server.domain.user.service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.HexFormat; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class WithdrawalRecoveryChallengeService { + + private static final Duration TTL = Duration.ofMinutes(10); + private static final String KEY_PREFIX = "withdrawal-recovery:"; + + private final StringRedisTemplate redisTemplate; + private final SecureRandom secureRandom = new SecureRandom(); + + public String issue(Long memberId) { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + String rawChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + redisTemplate.opsForValue().set(key(memberId, rawChallenge), memberId.toString(), TTL); + return rawChallenge; + } + + public boolean consume(Long memberId, String rawChallenge) { + String storedMemberId = + redisTemplate.opsForValue().getAndDelete(key(memberId, rawChallenge)); + return memberId.toString().equals(storedMemberId); + } + + private String key(Long memberId, String rawChallenge) { + return KEY_PREFIX + memberId + ":" + sha256Hex(rawChallenge); + } + + private String sha256Hex(String rawChallenge) { + try { + byte[] digest = + MessageDigest.getInstance("SHA-256") + .digest(rawChallenge.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 algorithm is unavailable", exception); + } + } +} diff --git a/server/src/main/java/com/whylog/server/domain/user/service/WithdrawalRecoveryCommandService.java b/server/src/main/java/com/whylog/server/domain/user/service/WithdrawalRecoveryCommandService.java new file mode 100644 index 0000000..29efda4 --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/user/service/WithdrawalRecoveryCommandService.java @@ -0,0 +1,42 @@ +package com.whylog.server.domain.user.service; + +import com.whylog.server.domain.user.dto.AuthRequest; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.exception.AuthErrorCode; +import com.whylog.server.domain.user.repository.MemberRepository; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class WithdrawalRecoveryCommandService { + + private final MemberRepository memberRepository; + private final WithdrawalRecoveryChallengeService withdrawalRecoveryChallengeService; + + public Member verify(AuthRequest.WithdrawalRecoveryVerifyDTO request) { + Long memberId = request.getMemberId(); + if (memberId == null + || !withdrawalRecoveryChallengeService.consume(memberId, request.getChallenge())) { + throw new ErrorHandler(AuthErrorCode.WITHDRAWAL_RECOVERY_CHALLENGE_INVALID); + } + + Member member = + memberRepository + .findByIdForUpdate(memberId) + .orElseThrow( + () -> + new ErrorHandler( + AuthErrorCode + .WITHDRAWAL_RECOVERY_CHALLENGE_INVALID)); + if (!member.isWithdrawalGraceActive(LocalDateTime.now())) { + throw new ErrorHandler(AuthErrorCode.WITHDRAWAL_RECOVERY_CHALLENGE_INVALID); + } + member.recoverWithdrawal(); + return member; + } +} diff --git a/server/src/main/java/com/whylog/server/global/auth/jwt/application/RefreshTokenCookieService.java b/server/src/main/java/com/whylog/server/global/auth/jwt/application/RefreshTokenCookieService.java new file mode 100644 index 0000000..4fe3153 --- /dev/null +++ b/server/src/main/java/com/whylog/server/global/auth/jwt/application/RefreshTokenCookieService.java @@ -0,0 +1,47 @@ +package com.whylog.server.global.auth.jwt.application; + +import jakarta.servlet.http.HttpServletResponse; +import java.time.Duration; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; + +@Service +public class RefreshTokenCookieService { + + private static final String REFRESH_TOKEN = "refreshToken"; + + private final Duration maxAge; + private final boolean secure; + + public RefreshTokenCookieService( + @Value("${jwt.refresh-token-expire-time}") long refreshTokenExpireTime, + @Value("${REFRESH_TOKEN_COOKIE_SECURE:true}") boolean secure) { + this.maxAge = Duration.ofMillis(refreshTokenExpireTime); + this.secure = secure; + } + + public void write(HttpServletResponse httpServletResponse, String refreshToken) { + ResponseCookie cookie = + ResponseCookie.from(REFRESH_TOKEN, refreshToken) + .maxAge(maxAge) + .path("/") + .httpOnly(true) + .secure(secure) + .sameSite("Lax") + .build(); + httpServletResponse.addHeader("Set-Cookie", cookie.toString()); + } + + public void expire(HttpServletResponse httpServletResponse) { + ResponseCookie cookie = + ResponseCookie.from(REFRESH_TOKEN, "") + .maxAge(Duration.ZERO) + .path("/") + .httpOnly(true) + .secure(secure) + .sameSite("Lax") + .build(); + httpServletResponse.addHeader("Set-Cookie", cookie.toString()); + } +} diff --git a/server/src/main/java/com/whylog/server/global/auth/jwt/application/TokenService.java b/server/src/main/java/com/whylog/server/global/auth/jwt/application/TokenService.java index 586a564..4c62253 100644 --- a/server/src/main/java/com/whylog/server/global/auth/jwt/application/TokenService.java +++ b/server/src/main/java/com/whylog/server/global/auth/jwt/application/TokenService.java @@ -1,9 +1,9 @@ package com.whylog.server.global.auth.jwt.application; -import com.whylog.server.domain.user.exception.AuthErrorStatus; +import com.whylog.server.domain.user.exception.AuthErrorCode; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; import com.whylog.server.global.auth.jwt.dao.TokenRepository; import com.whylog.server.global.auth.redis.Token; -import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,15 +21,23 @@ public void saveRefreshToken(Long memberId, String refreshToken) { @Transactional(readOnly = true) public Long findIdByRefreshToken(String refreshToken) { - return tokenRepository.findByRefreshToken(refreshToken) + return tokenRepository + .findByRefreshToken(refreshToken) .map(Token::getId) - .orElseThrow(() -> new ErrorHandler(AuthErrorStatus.REFRESH_TOKEN_NOT_FOUND)); + .orElseThrow(() -> new ErrorHandler(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND)); } @Transactional public void deleteRefreshToken(Long memberId) { - Token token = tokenRepository.findById(memberId) - .orElseThrow(() -> new ErrorHandler(AuthErrorStatus.REFRESH_TOKEN_NOT_FOUND)); + Token token = + tokenRepository + .findById(memberId) + .orElseThrow(() -> new ErrorHandler(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND)); tokenRepository.delete(token); } + + @Transactional + public void deleteRefreshTokenIfExists(Long memberId) { + tokenRepository.findById(memberId).ifPresent(tokenRepository::delete); + } } diff --git a/server/src/main/java/com/whylog/server/global/auth/jwt/filter/JwtAuthenticationFilter.java b/server/src/main/java/com/whylog/server/global/auth/jwt/filter/JwtAuthenticationFilter.java index 857aef5..3e9083d 100644 --- a/server/src/main/java/com/whylog/server/global/auth/jwt/filter/JwtAuthenticationFilter.java +++ b/server/src/main/java/com/whylog/server/global/auth/jwt/filter/JwtAuthenticationFilter.java @@ -1,13 +1,18 @@ package com.whylog.server.global.auth.jwt.filter; import com.whylog.server.domain.user.enums.Role; +import com.whylog.server.domain.user.service.MemberAccountStatusQueryService; import com.whylog.server.global.auth.jwt.provider.JwtTokenProvider; import com.whylog.server.global.auth.jwt.provider.JwtValidationType; import com.whylog.server.global.auth.security.MemberAuthentication; +import com.whylog.server.global.auth.security.PublicAuthPaths; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collection; +import java.util.List; import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -18,22 +23,24 @@ import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; -import java.io.IOException; -import java.util.Collection; -import java.util.List; - @Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; + private final MemberAccountStatusQueryService memberAccountStatusQueryService; + + @Override + protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { + return isOptions(request) || isPublicAuthPost(request); + } @Override protected void doFilterInternal( @NonNull HttpServletRequest request, @NonNull HttpServletResponse response, - @NonNull FilterChain filterChain - ) throws ServletException, IOException { + @NonNull FilterChain filterChain) + throws ServletException, IOException { String token = getJwtFromRequest(request); if (!StringUtils.hasText(token)) { filterChain.doFilter(request, response); @@ -45,26 +52,51 @@ protected void doFilterInternal( response.setStatus( validationType == JwtValidationType.EXPIRED_JWT_TOKEN ? HttpServletResponse.SC_UNAUTHORIZED - : HttpServletResponse.SC_BAD_REQUEST - ); + : HttpServletResponse.SC_BAD_REQUEST); return; } - setAuthentication(token, request); + Long memberId = jwtTokenProvider.getMemberIdFromJwt(token); + if (!memberAccountStatusQueryService.isActive(memberId)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + setAuthentication(token, memberId, request); filterChain.doFilter(request, response); } - private void setAuthentication(String token, HttpServletRequest request) { - Long memberId = jwtTokenProvider.getMemberIdFromJwt(token); + private void setAuthentication(String token, Long memberId, HttpServletRequest request) { Role role = jwtTokenProvider.getRoleFromJwt(token); Collection authorities = List.of(role.toGrantedAuthority()); - UsernamePasswordAuthenticationToken authentication = new MemberAuthentication(memberId, null, authorities); + UsernamePasswordAuthenticationToken authentication = + new MemberAuthentication(memberId, null, authorities); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } + private boolean isOptions(HttpServletRequest request) { + return "OPTIONS".equalsIgnoreCase(request.getMethod()); + } + + private boolean isPublicAuthPost(HttpServletRequest request) { + if (!"POST".equalsIgnoreCase(request.getMethod())) { + return false; + } + return PublicAuthPaths.contains(getRequestPath(request)); + } + + private String getRequestPath(HttpServletRequest request) { + String contextPath = request.getContextPath(); + String requestUri = request.getRequestURI(); + if (StringUtils.hasText(contextPath) && requestUri.startsWith(contextPath)) { + return requestUri.substring(contextPath.length()); + } + return requestUri; + } + private String getJwtFromRequest(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { diff --git a/server/src/main/java/com/whylog/server/global/auth/security/PublicAuthPaths.java b/server/src/main/java/com/whylog/server/global/auth/security/PublicAuthPaths.java new file mode 100644 index 0000000..7575f08 --- /dev/null +++ b/server/src/main/java/com/whylog/server/global/auth/security/PublicAuthPaths.java @@ -0,0 +1,25 @@ +package com.whylog.server.global.auth.security; + +import java.util.Set; + +public final class PublicAuthPaths { + + private static final Set PATHS = + Set.of( + "/api/auth/signup", + "/api/auth/login", + "/api/auth/refresh-token", + "/api/auth/email-verifications", + "/api/auth/email-verifications/verify", + "/api/auth/withdrawal-recoveries/verify"); + + private PublicAuthPaths() {} + + public static boolean contains(String path) { + return PATHS.contains(path); + } + + public static String[] asArray() { + return PATHS.toArray(String[]::new); + } +} diff --git a/server/src/main/java/com/whylog/server/global/config/SecurityConfig.java b/server/src/main/java/com/whylog/server/global/config/SecurityConfig.java index 3ac0c7e..fc69cd8 100644 --- a/server/src/main/java/com/whylog/server/global/config/SecurityConfig.java +++ b/server/src/main/java/com/whylog/server/global/config/SecurityConfig.java @@ -3,6 +3,7 @@ import com.whylog.server.global.auth.jwt.filter.JwtAuthenticationFilter; import com.whylog.server.global.auth.security.CustomAccessDeniedHandler; import com.whylog.server.global.auth.security.CustomJwtAuthenticationEntryPoint; +import com.whylog.server.global.auth.security.PublicAuthPaths; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -30,25 +31,31 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .exceptionHandling(exception -> exception - .authenticationEntryPoint(customJwtAuthenticationEntryPoint) - .accessDeniedHandler(customAccessDeniedHandler)); + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling( + exception -> + exception + .authenticationEntryPoint(customJwtAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler)); - http.authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers( - "/api/auth/signup", - "/api/auth/login", - "/api/auth/refresh-token", - "/ws/**", - "/v3/api-docs/**", - "/swagger-ui/**", - "/swagger-ui.html", - "/error" - ).permitAll() - .anyRequest().authenticated()) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + http.authorizeHttpRequests( + auth -> + auth.requestMatchers(HttpMethod.OPTIONS, "/**") + .permitAll() + .requestMatchers(PublicAuthPaths.asArray()) + .permitAll() + .requestMatchers( + "/ws/**", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/error") + .permitAll() + .anyRequest() + .authenticated()) + .addFilterBefore( + jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/server/src/main/java/com/whylog/server/global/external/email/EmailVerificationEmailMessage.java b/server/src/main/java/com/whylog/server/global/external/email/EmailVerificationEmailMessage.java new file mode 100644 index 0000000..5a0ecc5 --- /dev/null +++ b/server/src/main/java/com/whylog/server/global/external/email/EmailVerificationEmailMessage.java @@ -0,0 +1,11 @@ +package com.whylog.server.global.external.email; + +public record EmailVerificationEmailMessage(String recipientEmail, String code) { + + @Override + public String toString() { + return "EmailVerificationEmailMessage[recipientEmail=" + + recipientEmail + + ", code=[redacted]]"; + } +} diff --git a/server/src/main/java/com/whylog/server/global/external/email/EmailVerificationEmailSender.java b/server/src/main/java/com/whylog/server/global/external/email/EmailVerificationEmailSender.java new file mode 100644 index 0000000..798e15c --- /dev/null +++ b/server/src/main/java/com/whylog/server/global/external/email/EmailVerificationEmailSender.java @@ -0,0 +1,6 @@ +package com.whylog.server.global.external.email; + +public interface EmailVerificationEmailSender { + + void send(EmailVerificationEmailMessage message); +} diff --git a/server/src/main/java/com/whylog/server/global/external/email/EmailVerificationSendException.java b/server/src/main/java/com/whylog/server/global/external/email/EmailVerificationSendException.java new file mode 100644 index 0000000..5b396d5 --- /dev/null +++ b/server/src/main/java/com/whylog/server/global/external/email/EmailVerificationSendException.java @@ -0,0 +1,12 @@ +package com.whylog.server.global.external.email; + +public class EmailVerificationSendException extends RuntimeException { + + public EmailVerificationSendException(String message) { + super(message); + } + + public EmailVerificationSendException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/server/src/main/java/com/whylog/server/global/external/email/GmailSmtpEmailVerificationEmailSender.java b/server/src/main/java/com/whylog/server/global/external/email/GmailSmtpEmailVerificationEmailSender.java new file mode 100644 index 0000000..835b0cb --- /dev/null +++ b/server/src/main/java/com/whylog/server/global/external/email/GmailSmtpEmailVerificationEmailSender.java @@ -0,0 +1,73 @@ +package com.whylog.server.global.external.email; + +import java.util.Properties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.MailException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +public class GmailSmtpEmailVerificationEmailSender implements EmailVerificationEmailSender { + + private static final String HOST = "smtp.gmail.com"; + private static final int PORT = 587; + private static final String SUBJECT = "WhyLog 이메일 인증"; + private static final String ENCODING = "UTF-8"; + private static final String CONNECT_TIMEOUT_MILLIS = "3000"; + private static final String TIMEOUT_MILLIS = "5000"; + private static final String WRITE_TIMEOUT_MILLIS = "5000"; + + private final JavaMailSenderImpl mailSender; + private final String fromEmail; + + public GmailSmtpEmailVerificationEmailSender( + @Value("${GMAIL_SMTP_USERNAME}") String username, + @Value("${GMAIL_SMTP_APP_PASSWORD}") String appPassword) { + if (!StringUtils.hasText(username) || !StringUtils.hasText(appPassword)) { + throw new IllegalStateException("Gmail SMTP 이메일 발송 설정이 비어 있습니다."); + } + this.fromEmail = username; + this.mailSender = mailSender(username, appPassword); + } + + @Override + public void send(EmailVerificationEmailMessage message) { + try { + mailSender.send(mailMessage(message)); + } catch (MailException exception) { + throw new EmailVerificationSendException("이메일 인증 코드 발송에 실패했습니다.", exception); + } + } + + private JavaMailSenderImpl mailSender(String username, String appPassword) { + JavaMailSenderImpl sender = new JavaMailSenderImpl(); + sender.setHost(HOST); + sender.setPort(PORT); + sender.setUsername(username); + sender.setPassword(appPassword); + sender.setDefaultEncoding(ENCODING); + sender.setJavaMailProperties(mailProperties()); + return sender; + } + + private Properties mailProperties() { + Properties properties = new Properties(); + properties.put("mail.smtp.auth", "true"); + properties.put("mail.smtp.starttls.enable", "true"); + properties.put("mail.smtp.connectiontimeout", CONNECT_TIMEOUT_MILLIS); + properties.put("mail.smtp.timeout", TIMEOUT_MILLIS); + properties.put("mail.smtp.writetimeout", WRITE_TIMEOUT_MILLIS); + return properties; + } + + private SimpleMailMessage mailMessage(EmailVerificationEmailMessage message) { + SimpleMailMessage mailMessage = new SimpleMailMessage(); + mailMessage.setFrom(fromEmail); + mailMessage.setTo(message.recipientEmail()); + mailMessage.setSubject(SUBJECT); + mailMessage.setText("WhyLog 이메일 인증 코드: " + message.code()); + return mailMessage; + } +} diff --git a/server/src/main/resources/db/migration/V2__add_member_account_status.sql b/server/src/main/resources/db/migration/V2__add_member_account_status.sql new file mode 100644 index 0000000..d47db82 --- /dev/null +++ b/server/src/main/resources/db/migration/V2__add_member_account_status.sql @@ -0,0 +1,8 @@ +alter table member + add column account_status enum ('UNVERIFIED','ACTIVE','INACTIVE','WITHDRAW') not null default 'ACTIVE', + add column email_verified_at datetime(6) null; + +update member +set email_verified_at = coalesce(updated_at, created_at, current_timestamp(6)) +where account_status = 'ACTIVE' + and email_verified_at is null; diff --git a/server/src/main/resources/db/migration/V3__add_email_verification_core.sql b/server/src/main/resources/db/migration/V3__add_email_verification_core.sql new file mode 100644 index 0000000..ae23519 --- /dev/null +++ b/server/src/main/resources/db/migration/V3__add_email_verification_core.sql @@ -0,0 +1,15 @@ +create table email_verification_code ( + member_id bigint not null, + created_at datetime(6), + updated_at datetime(6), + code_hmac varchar(64) not null, + expires_at datetime(6) not null, + last_issued_at datetime(6) not null, + failed_attempts integer not null, + primary key (member_id) +) engine=InnoDB; + +alter table email_verification_code + add constraint fk_email_verification_code_member + foreign key (member_id) references member (member_id) + on delete cascade; diff --git a/server/src/main/resources/db/migration/V4__add_withdrawal_lifecycle.sql b/server/src/main/resources/db/migration/V4__add_withdrawal_lifecycle.sql new file mode 100644 index 0000000..3d780ff --- /dev/null +++ b/server/src/main/resources/db/migration/V4__add_withdrawal_lifecycle.sql @@ -0,0 +1,6 @@ +alter table member + add column purge_at datetime(6) null; + + +create index idx_member_account_status_purge_at + on member (account_status, purge_at, member_id); diff --git a/server/src/main/resources/db/migration/V5__add_member_profile_visibility.sql b/server/src/main/resources/db/migration/V5__add_member_profile_visibility.sql new file mode 100644 index 0000000..3c628b2 --- /dev/null +++ b/server/src/main/resources/db/migration/V5__add_member_profile_visibility.sql @@ -0,0 +1,2 @@ +alter table member + add column profile_visibility enum ('PUBLIC','PRIVATE') not null default 'PUBLIC'; diff --git a/server/src/main/resources/db/migration/V6__add_member_profile_query_indexes.sql b/server/src/main/resources/db/migration/V6__add_member_profile_query_indexes.sql new file mode 100644 index 0000000..272fc43 --- /dev/null +++ b/server/src/main/resources/db/migration/V6__add_member_profile_query_indexes.sql @@ -0,0 +1,5 @@ +create index idx_team_member_member_active_team + on team_member (member_id, is_active, team_id); + +create index idx_meeting_member_member_meeting + on meeting_member (member_id, meeting_id);