Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -53,8 +56,6 @@ public UpdateUserProfileResponse updateUserProfile(UpdateUserProfileCommand comm
throw new BadRequestException("프로필 사진 변경 시, profileImageContentType과 profileImageObjectKey 둘 다 있어야 합니다");
}

userSignupStatusService.tryCompleteSignup(user);

return new UpdateUserProfileResponse(
user.getNickname(),
user.getEmail(),
Expand All @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.devkor.apu.saerok_server.domain.user.core.entity;

public enum SignupSourceType {
INSTAGRAM,
OTHER_SNS,
FRIEND,
COMMUNITY,
ETC
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,4 +13,6 @@
public interface UserWebMapper {

UpdateUserProfileCommand toUpdateUserProfileCommand(UpdateUserProfileRequest request, Long userId);

SignupCompleteCommand toSignupCompleteCommand(SignupCompleteRequest request, Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- 회원가입 경로 추적을 위한 컬럼 추가
ALTER TABLE users
ADD COLUMN signup_source VARCHAR(30);
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
}