diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/user/api/UserController.java b/src/main/java/org/devkor/apu/saerok_server/domain/user/api/UserController.java index e4d259f5..df931a60 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/user/api/UserController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/user/api/UserController.java @@ -9,8 +9,10 @@ import jakarta.annotation.security.PermitAll; import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.user.api.dto.request.ProfileImagePresignRequest; +import org.devkor.apu.saerok_server.domain.user.api.dto.request.SignupCompleteRequest; import org.devkor.apu.saerok_server.domain.user.api.dto.request.UpdateUserProfileRequest; import org.devkor.apu.saerok_server.domain.user.api.dto.response.ProfileImagePresignResponse; +import org.devkor.apu.saerok_server.domain.user.api.dto.response.SignupCompleteResponse; import org.devkor.apu.saerok_server.domain.user.api.dto.response.UpdateUserProfileResponse; import org.devkor.apu.saerok_server.domain.user.api.response.CheckNicknameResponse; import org.devkor.apu.saerok_server.domain.user.api.response.UserInfoResponse; @@ -109,6 +111,38 @@ public UpdateUserProfileResponse updateUserProfile( ); } + @PostMapping("/signup-complete") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "회원가입 완료", + security = @SecurityRequirement(name = "bearerAuth"), + description = """ + 회원가입을 완료하고 회원가입 경로를 기록합니다. + + 처리 내용: + - 닉네임 설정 및 유효성 검증 + - 회원가입 경로 기록 (INSTAGRAM, OTHER_SNS, FRIEND, COMMUNITY, ETC) + - 회원가입 상태를 COMPLETED로 변경 + + 주의사항: + - 회원가입이 이미 완료된 사용자는 호출할 수 없습니다 + """, + responses = { + @ApiResponse(responseCode = "200", description = "회원가입 완료 성공", + content = @Content(schema = @Schema(implementation = SignupCompleteResponse.class))), + @ApiResponse(responseCode = "400", description = "회원가입 완료 실패 - 닉네임 정책 위반, 중복 완료 등", content = @Content), + @ApiResponse(responseCode = "401", description = "사용자 인증 실패", content = @Content), + } + ) + public SignupCompleteResponse signupComplete( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestBody SignupCompleteRequest request + ) { + return userCommandService.signupComplete( + userWebMapper.toSignupCompleteCommand(request, userPrincipal.getId()) + ); + } + @PostMapping("/me/profile-image/presign") @PreAuthorize("isAuthenticated()") @Operation( diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/user/api/dto/request/SignupCompleteRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/user/api/dto/request/SignupCompleteRequest.java new file mode 100644 index 00000000..585853e5 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/user/api/dto/request/SignupCompleteRequest.java @@ -0,0 +1,13 @@ +package org.devkor.apu.saerok_server.domain.user.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "회원가입 완료 요청 DTO") +public record SignupCompleteRequest( + @Schema(description = "사용자 닉네임", example = "새록이") + String nickname, + + @Schema(description = "회원가입 경로", example = "INSTAGRAM", allowableValues = {"INSTAGRAM", "OTHER_SNS", "FRIEND", "COMMUNITY", "ETC"}) + String signupSource +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/user/api/dto/response/SignupCompleteResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/user/api/dto/response/SignupCompleteResponse.java new file mode 100644 index 00000000..16bb4118 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/user/api/dto/response/SignupCompleteResponse.java @@ -0,0 +1,17 @@ +package org.devkor.apu.saerok_server.domain.user.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType; + +@Schema(description = "회원가입 완료 응답 DTO") +public record SignupCompleteResponse( + @Schema(description = "사용자 ID", example = "42") + Long userId, + + @Schema(description = "회원가입 상태", example = "COMPLETED") + SignupStatusType signupStatus, + + @Schema(description = "성공 여부", example = "true") + boolean success +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/user/application/UserCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/user/application/UserCommandService.java index b071999a..46d83a38 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/user/application/UserCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/user/application/UserCommandService.java @@ -4,9 +4,12 @@ import org.devkor.apu.saerok_server.domain.auth.infra.SocialRevoker; import org.devkor.apu.saerok_server.domain.user.api.dto.response.ProfileImagePresignResponse; import org.devkor.apu.saerok_server.domain.auth.core.repository.SocialAuthRepository; +import org.devkor.apu.saerok_server.domain.user.api.dto.response.SignupCompleteResponse; import org.devkor.apu.saerok_server.domain.user.api.dto.response.UpdateUserProfileResponse; +import org.devkor.apu.saerok_server.domain.user.application.dto.SignupCompleteCommand; import org.devkor.apu.saerok_server.domain.user.application.dto.UpdateUserProfileCommand; import org.devkor.apu.saerok_server.domain.user.application.helper.UserHardDeleteHelper; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; import org.devkor.apu.saerok_server.domain.user.core.service.UserProfileImageUrlService; @@ -53,8 +56,6 @@ public UpdateUserProfileResponse updateUserProfile(UpdateUserProfileCommand comm throw new BadRequestException("프로필 사진 변경 시, profileImageContentType과 profileImageObjectKey 둘 다 있어야 합니다"); } - userSignupStatusService.tryCompleteSignup(user); - return new UpdateUserProfileResponse( user.getNickname(), user.getEmail(), @@ -63,6 +64,34 @@ public UpdateUserProfileResponse updateUserProfile(UpdateUserProfileCommand comm ); } + public SignupCompleteResponse signupComplete(SignupCompleteCommand command) { + User user = userRepository.findById(command.userId()) + .orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요")); + + // 1) 회원가입 상태 검증 (중복 완료 방지) + if (user.getSignupStatus() == SignupStatusType.COMPLETED) { + throw new BadRequestException("이미 회원가입이 완료된 사용자입니다"); + } + + // 2) 닉네임 설정 + try { + userProfileUpdateService.changeNickname(user, command.nickname()); + } catch (IllegalArgumentException e) { + throw new BadRequestException("닉네임 정책을 만족하지 않습니다: " + e.getMessage()); + } + + // 3) 회원가입 경로 설정 + if (command.signupSource() == null) { + throw new BadRequestException("회원가입 경로는 필수입니다"); + } + user.setSignupSource(command.signupSource()); + + // 4) 회원가입 완료 상태로 변경 + userSignupStatusService.tryCompleteSignup(user); + + return new SignupCompleteResponse(user.getId(), user.getSignupStatus(), true); + } + public ProfileImagePresignResponse generateProfileImagePresignUrl(Long userId, String contentType) { userRepository.findById(userId).orElseThrow(() -> new NotFoundException("해당 사용자가 존재하지 않습니다.")); if (contentType == null || contentType.isEmpty()) { diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/user/application/dto/SignupCompleteCommand.java b/src/main/java/org/devkor/apu/saerok_server/domain/user/application/dto/SignupCompleteCommand.java new file mode 100644 index 00000000..7273a211 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/user/application/dto/SignupCompleteCommand.java @@ -0,0 +1,10 @@ +package org.devkor.apu.saerok_server.domain.user.application.dto; + +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupSourceType; + +public record SignupCompleteCommand( + Long userId, + String nickname, + SignupSourceType signupSource +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/user/core/entity/SignupSourceType.java b/src/main/java/org/devkor/apu/saerok_server/domain/user/core/entity/SignupSourceType.java new file mode 100644 index 00000000..6696cf81 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/user/core/entity/SignupSourceType.java @@ -0,0 +1,9 @@ +package org.devkor.apu.saerok_server.domain.user.core.entity; + +public enum SignupSourceType { + INSTAGRAM, + OTHER_SNS, + FRIEND, + COMMUNITY, + ETC +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/user/core/entity/User.java b/src/main/java/org/devkor/apu/saerok_server/domain/user/core/entity/User.java index 59e9382d..a7f2778d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/user/core/entity/User.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/user/core/entity/User.java @@ -40,6 +40,11 @@ public class User extends SoftDeletableAuditable { @Setter private SignupStatusType signupStatus; + @Setter + @Enumerated(EnumType.STRING) + @Column(name = "signup_source", nullable = true) + private SignupSourceType signupSource; + @Column(name = "joined_at") private OffsetDateTime joinedAt; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/user/mapper/UserWebMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/user/mapper/UserWebMapper.java index c26f28e4..82d61eea 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/user/mapper/UserWebMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/user/mapper/UserWebMapper.java @@ -1,6 +1,8 @@ package org.devkor.apu.saerok_server.domain.user.mapper; +import org.devkor.apu.saerok_server.domain.user.api.dto.request.SignupCompleteRequest; import org.devkor.apu.saerok_server.domain.user.api.dto.request.UpdateUserProfileRequest; +import org.devkor.apu.saerok_server.domain.user.application.dto.SignupCompleteCommand; import org.devkor.apu.saerok_server.domain.user.application.dto.UpdateUserProfileCommand; import org.mapstruct.Mapper; import org.mapstruct.MappingConstants; @@ -11,4 +13,6 @@ public interface UserWebMapper { UpdateUserProfileCommand toUpdateUserProfileCommand(UpdateUserProfileRequest request, Long userId); + + SignupCompleteCommand toSignupCompleteCommand(SignupCompleteRequest request, Long userId); } diff --git a/src/main/resources/db/migration/V86__add_signup_source_to_users.sql b/src/main/resources/db/migration/V86__add_signup_source_to_users.sql new file mode 100644 index 00000000..e705f266 --- /dev/null +++ b/src/main/resources/db/migration/V86__add_signup_source_to_users.sql @@ -0,0 +1,3 @@ +-- 회원가입 경로 추적을 위한 컬럼 추가 +ALTER TABLE users + ADD COLUMN signup_source VARCHAR(30); diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/user/application/UserCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/user/application/UserCommandServiceTest.java index d25ddead..86d19d6d 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/user/application/UserCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/user/application/UserCommandServiceTest.java @@ -5,9 +5,12 @@ import org.devkor.apu.saerok_server.domain.auth.core.repository.SocialAuthRepository; import org.devkor.apu.saerok_server.domain.auth.infra.SocialRevoker; import org.devkor.apu.saerok_server.domain.user.api.dto.response.ProfileImagePresignResponse; +import org.devkor.apu.saerok_server.domain.user.api.dto.response.SignupCompleteResponse; import org.devkor.apu.saerok_server.domain.user.api.dto.response.UpdateUserProfileResponse; +import org.devkor.apu.saerok_server.domain.user.application.dto.SignupCompleteCommand; import org.devkor.apu.saerok_server.domain.user.application.dto.UpdateUserProfileCommand; import org.devkor.apu.saerok_server.domain.user.application.helper.UserHardDeleteHelper; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupSourceType; import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; @@ -101,7 +104,6 @@ void nicknameOnly() { verify(userRepository).findById(42L); verify(userProfileUpdateService).changeNickname(user, "newNick"); - verify(userSignupStatusService).tryCompleteSignup(user); verify(userProfileImageUrlService).getProfileImageUrlFor(user); verify(userProfileImageUrlService).getProfileThumbnailImageUrlFor(user); verifyNoMoreInteractions(userProfileUpdateService); @@ -296,4 +298,120 @@ void userNotFound() { verifyNoInteractions(userHardDeleteHelper, kakaoRevoker, appleRevoker); } } + + @Nested + class SignupComplete { + + @Test + @DisplayName("정상 플로우: 닉네임 설정, 회원가입 경로 설정, 상태 COMPLETED로 변경") + void ok() { + long userId = 42L; + User user = new User(); + ReflectionTestUtils.setField(user, "id", userId); + user.setSignupStatus(SignupStatusType.PROFILE_REQUIRED); + ReflectionTestUtils.setField(user, "email", "test@example.com"); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + doAnswer(invocation -> { + User u = invocation.getArgument(0); + String newNick = invocation.getArgument(1); + u.setNickname(newNick); + return null; + }).when(userProfileUpdateService).changeNickname(same(user), eq("새록이")); + doAnswer(invocation -> { + User u = invocation.getArgument(0); + u.setSignupStatus(SignupStatusType.COMPLETED); + return null; + }).when(userSignupStatusService).tryCompleteSignup(same(user)); + + SignupCompleteCommand cmd = new SignupCompleteCommand(userId, "새록이", SignupSourceType.INSTAGRAM); + + SignupCompleteResponse response = sut.signupComplete(cmd); + + assertThat(response.success()).isTrue(); + assertThat(response.userId()).isEqualTo(userId); + assertThat(response.signupStatus()).isEqualTo(SignupStatusType.COMPLETED); + verify(userRepository).findById(userId); + verify(userProfileUpdateService).changeNickname(user, "새록이"); + verify(userSignupStatusService).tryCompleteSignup(user); + assertThat(user.getNickname()).isEqualTo("새록이"); + assertThat(user.getSignupSource()).isEqualTo(SignupSourceType.INSTAGRAM); + } + + @Test + @DisplayName("이미 회원가입 완료된 사용자는 BadRequestException") + void alreadyCompleted_throwsBadRequest() { + long userId = 42L; + User user = new User(); + ReflectionTestUtils.setField(user, "id", userId); + user.setSignupStatus(SignupStatusType.COMPLETED); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + SignupCompleteCommand cmd = new SignupCompleteCommand(userId, "새록이", SignupSourceType.INSTAGRAM); + + assertThatThrownBy(() -> sut.signupComplete(cmd)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("이미 회원가입이 완료된 사용자입니다"); + + verifyNoInteractions(userProfileUpdateService, userSignupStatusService); + } + + @Test + @DisplayName("닉네임 정책 위반 시 BadRequestException") + void invalidNickname_throwsBadRequest() { + long userId = 42L; + User user = new User(); + ReflectionTestUtils.setField(user, "id", userId); + user.setSignupStatus(SignupStatusType.PROFILE_REQUIRED); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + doThrow(new IllegalArgumentException("닉네임 정책 위반")) + .when(userProfileUpdateService).changeNickname(user, "badnick"); + + SignupCompleteCommand cmd = new SignupCompleteCommand(userId, "badnick", SignupSourceType.INSTAGRAM); + + assertThatThrownBy(() -> sut.signupComplete(cmd)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("닉네임 정책을 만족하지 않습니다"); + } + + @Test + @DisplayName("회원가입 경로 null이면 BadRequestException") + void nullSignupSource_throwsBadRequest() { + long userId = 42L; + User user = new User(); + ReflectionTestUtils.setField(user, "id", userId); + user.setSignupStatus(SignupStatusType.PROFILE_REQUIRED); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + doAnswer(invocation -> { + User u = invocation.getArgument(0); + String newNick = invocation.getArgument(1); + u.setNickname(newNick); + return null; + }).when(userProfileUpdateService).changeNickname(same(user), eq("새록이")); + + SignupCompleteCommand cmd = new SignupCompleteCommand(userId, "새록이", null); + + assertThatThrownBy(() -> sut.signupComplete(cmd)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("회원가입 경로는 필수입니다"); + + verifyNoInteractions(userSignupStatusService); + } + + @Test + @DisplayName("사용자 없음 시 404") + void userNotFound() { + given(userRepository.findById(999L)).willReturn(Optional.empty()); + + SignupCompleteCommand cmd = new SignupCompleteCommand(999L, "새록이", SignupSourceType.INSTAGRAM); + + assertThatThrownBy(() -> sut.signupComplete(cmd)) + .isInstanceOf(NotFoundException.class); + + verifyNoInteractions(userProfileUpdateService, userSignupStatusService); + } + } }