Skip to content

Commit f3b5889

Browse files
authored
feat(user): 유저 가입 경로 추가 (#291)
* feat(user): 사용자 테이블과 엔티티에 가입 경로 필드 추가 * feat(user): 회원가입 완료 API를 위한 요청 바디와 커맨드 정의 * feat(user): 회원가입 완료 API 구현 * test(user): 관련해서 테스트 수정 * refactor(user): 컨벤션에 맞게 성공 응답을 명시적으로 전달
1 parent 9fdf2e8 commit f3b5889

10 files changed

Lines changed: 245 additions & 3 deletions

File tree

src/main/java/org/devkor/apu/saerok_server/domain/user/api/UserController.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
import jakarta.annotation.security.PermitAll;
1010
import lombok.RequiredArgsConstructor;
1111
import org.devkor.apu.saerok_server.domain.user.api.dto.request.ProfileImagePresignRequest;
12+
import org.devkor.apu.saerok_server.domain.user.api.dto.request.SignupCompleteRequest;
1213
import org.devkor.apu.saerok_server.domain.user.api.dto.request.UpdateUserProfileRequest;
1314
import org.devkor.apu.saerok_server.domain.user.api.dto.response.ProfileImagePresignResponse;
15+
import org.devkor.apu.saerok_server.domain.user.api.dto.response.SignupCompleteResponse;
1416
import org.devkor.apu.saerok_server.domain.user.api.dto.response.UpdateUserProfileResponse;
1517
import org.devkor.apu.saerok_server.domain.user.api.response.CheckNicknameResponse;
1618
import org.devkor.apu.saerok_server.domain.user.api.response.UserInfoResponse;
@@ -109,6 +111,38 @@ public UpdateUserProfileResponse updateUserProfile(
109111
);
110112
}
111113

114+
@PostMapping("/signup-complete")
115+
@PreAuthorize("isAuthenticated()")
116+
@Operation(
117+
summary = "회원가입 완료",
118+
security = @SecurityRequirement(name = "bearerAuth"),
119+
description = """
120+
회원가입을 완료하고 회원가입 경로를 기록합니다.
121+
122+
처리 내용:
123+
- 닉네임 설정 및 유효성 검증
124+
- 회원가입 경로 기록 (INSTAGRAM, OTHER_SNS, FRIEND, COMMUNITY, ETC)
125+
- 회원가입 상태를 COMPLETED로 변경
126+
127+
주의사항:
128+
- 회원가입이 이미 완료된 사용자는 호출할 수 없습니다
129+
""",
130+
responses = {
131+
@ApiResponse(responseCode = "200", description = "회원가입 완료 성공",
132+
content = @Content(schema = @Schema(implementation = SignupCompleteResponse.class))),
133+
@ApiResponse(responseCode = "400", description = "회원가입 완료 실패 - 닉네임 정책 위반, 중복 완료 등", content = @Content),
134+
@ApiResponse(responseCode = "401", description = "사용자 인증 실패", content = @Content),
135+
}
136+
)
137+
public SignupCompleteResponse signupComplete(
138+
@AuthenticationPrincipal UserPrincipal userPrincipal,
139+
@RequestBody SignupCompleteRequest request
140+
) {
141+
return userCommandService.signupComplete(
142+
userWebMapper.toSignupCompleteCommand(request, userPrincipal.getId())
143+
);
144+
}
145+
112146
@PostMapping("/me/profile-image/presign")
113147
@PreAuthorize("isAuthenticated()")
114148
@Operation(
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package org.devkor.apu.saerok_server.domain.user.api.dto.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
5+
@Schema(description = "회원가입 완료 요청 DTO")
6+
public record SignupCompleteRequest(
7+
@Schema(description = "사용자 닉네임", example = "새록이")
8+
String nickname,
9+
10+
@Schema(description = "회원가입 경로", example = "INSTAGRAM", allowableValues = {"INSTAGRAM", "OTHER_SNS", "FRIEND", "COMMUNITY", "ETC"})
11+
String signupSource
12+
) {
13+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.devkor.apu.saerok_server.domain.user.api.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType;
5+
6+
@Schema(description = "회원가입 완료 응답 DTO")
7+
public record SignupCompleteResponse(
8+
@Schema(description = "사용자 ID", example = "42")
9+
Long userId,
10+
11+
@Schema(description = "회원가입 상태", example = "COMPLETED")
12+
SignupStatusType signupStatus,
13+
14+
@Schema(description = "성공 여부", example = "true")
15+
boolean success
16+
) {
17+
}

src/main/java/org/devkor/apu/saerok_server/domain/user/application/UserCommandService.java

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
import org.devkor.apu.saerok_server.domain.auth.infra.SocialRevoker;
55
import org.devkor.apu.saerok_server.domain.user.api.dto.response.ProfileImagePresignResponse;
66
import org.devkor.apu.saerok_server.domain.auth.core.repository.SocialAuthRepository;
7+
import org.devkor.apu.saerok_server.domain.user.api.dto.response.SignupCompleteResponse;
78
import org.devkor.apu.saerok_server.domain.user.api.dto.response.UpdateUserProfileResponse;
9+
import org.devkor.apu.saerok_server.domain.user.application.dto.SignupCompleteCommand;
810
import org.devkor.apu.saerok_server.domain.user.application.dto.UpdateUserProfileCommand;
911
import org.devkor.apu.saerok_server.domain.user.application.helper.UserHardDeleteHelper;
12+
import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType;
1013
import org.devkor.apu.saerok_server.domain.user.core.entity.User;
1114
import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository;
1215
import org.devkor.apu.saerok_server.domain.user.core.service.UserProfileImageUrlService;
@@ -53,8 +56,6 @@ public UpdateUserProfileResponse updateUserProfile(UpdateUserProfileCommand comm
5356
throw new BadRequestException("프로필 사진 변경 시, profileImageContentType과 profileImageObjectKey 둘 다 있어야 합니다");
5457
}
5558

56-
userSignupStatusService.tryCompleteSignup(user);
57-
5859
return new UpdateUserProfileResponse(
5960
user.getNickname(),
6061
user.getEmail(),
@@ -63,6 +64,34 @@ public UpdateUserProfileResponse updateUserProfile(UpdateUserProfileCommand comm
6364
);
6465
}
6566

67+
public SignupCompleteResponse signupComplete(SignupCompleteCommand command) {
68+
User user = userRepository.findById(command.userId())
69+
.orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요"));
70+
71+
// 1) 회원가입 상태 검증 (중복 완료 방지)
72+
if (user.getSignupStatus() == SignupStatusType.COMPLETED) {
73+
throw new BadRequestException("이미 회원가입이 완료된 사용자입니다");
74+
}
75+
76+
// 2) 닉네임 설정
77+
try {
78+
userProfileUpdateService.changeNickname(user, command.nickname());
79+
} catch (IllegalArgumentException e) {
80+
throw new BadRequestException("닉네임 정책을 만족하지 않습니다: " + e.getMessage());
81+
}
82+
83+
// 3) 회원가입 경로 설정
84+
if (command.signupSource() == null) {
85+
throw new BadRequestException("회원가입 경로는 필수입니다");
86+
}
87+
user.setSignupSource(command.signupSource());
88+
89+
// 4) 회원가입 완료 상태로 변경
90+
userSignupStatusService.tryCompleteSignup(user);
91+
92+
return new SignupCompleteResponse(user.getId(), user.getSignupStatus(), true);
93+
}
94+
6695
public ProfileImagePresignResponse generateProfileImagePresignUrl(Long userId, String contentType) {
6796
userRepository.findById(userId).orElseThrow(() -> new NotFoundException("해당 사용자가 존재하지 않습니다."));
6897
if (contentType == null || contentType.isEmpty()) {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package org.devkor.apu.saerok_server.domain.user.application.dto;
2+
3+
import org.devkor.apu.saerok_server.domain.user.core.entity.SignupSourceType;
4+
5+
public record SignupCompleteCommand(
6+
Long userId,
7+
String nickname,
8+
SignupSourceType signupSource
9+
) {
10+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package org.devkor.apu.saerok_server.domain.user.core.entity;
2+
3+
public enum SignupSourceType {
4+
INSTAGRAM,
5+
OTHER_SNS,
6+
FRIEND,
7+
COMMUNITY,
8+
ETC
9+
}

src/main/java/org/devkor/apu/saerok_server/domain/user/core/entity/User.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ public class User extends SoftDeletableAuditable {
4040
@Setter
4141
private SignupStatusType signupStatus;
4242

43+
@Setter
44+
@Enumerated(EnumType.STRING)
45+
@Column(name = "signup_source", nullable = true)
46+
private SignupSourceType signupSource;
47+
4348
@Column(name = "joined_at")
4449
private OffsetDateTime joinedAt;
4550

src/main/java/org/devkor/apu/saerok_server/domain/user/mapper/UserWebMapper.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package org.devkor.apu.saerok_server.domain.user.mapper;
22

3+
import org.devkor.apu.saerok_server.domain.user.api.dto.request.SignupCompleteRequest;
34
import org.devkor.apu.saerok_server.domain.user.api.dto.request.UpdateUserProfileRequest;
5+
import org.devkor.apu.saerok_server.domain.user.application.dto.SignupCompleteCommand;
46
import org.devkor.apu.saerok_server.domain.user.application.dto.UpdateUserProfileCommand;
57
import org.mapstruct.Mapper;
68
import org.mapstruct.MappingConstants;
@@ -11,4 +13,6 @@
1113
public interface UserWebMapper {
1214

1315
UpdateUserProfileCommand toUpdateUserProfileCommand(UpdateUserProfileRequest request, Long userId);
16+
17+
SignupCompleteCommand toSignupCompleteCommand(SignupCompleteRequest request, Long userId);
1418
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- 회원가입 경로 추적을 위한 컬럼 추가
2+
ALTER TABLE users
3+
ADD COLUMN signup_source VARCHAR(30);

src/test/java/org/devkor/apu/saerok_server/domain/user/application/UserCommandServiceTest.java

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
import org.devkor.apu.saerok_server.domain.auth.core.repository.SocialAuthRepository;
66
import org.devkor.apu.saerok_server.domain.auth.infra.SocialRevoker;
77
import org.devkor.apu.saerok_server.domain.user.api.dto.response.ProfileImagePresignResponse;
8+
import org.devkor.apu.saerok_server.domain.user.api.dto.response.SignupCompleteResponse;
89
import org.devkor.apu.saerok_server.domain.user.api.dto.response.UpdateUserProfileResponse;
10+
import org.devkor.apu.saerok_server.domain.user.application.dto.SignupCompleteCommand;
911
import org.devkor.apu.saerok_server.domain.user.application.dto.UpdateUserProfileCommand;
1012
import org.devkor.apu.saerok_server.domain.user.application.helper.UserHardDeleteHelper;
13+
import org.devkor.apu.saerok_server.domain.user.core.entity.SignupSourceType;
1114
import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType;
1215
import org.devkor.apu.saerok_server.domain.user.core.entity.User;
1316
import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository;
@@ -101,7 +104,6 @@ void nicknameOnly() {
101104

102105
verify(userRepository).findById(42L);
103106
verify(userProfileUpdateService).changeNickname(user, "newNick");
104-
verify(userSignupStatusService).tryCompleteSignup(user);
105107
verify(userProfileImageUrlService).getProfileImageUrlFor(user);
106108
verify(userProfileImageUrlService).getProfileThumbnailImageUrlFor(user);
107109
verifyNoMoreInteractions(userProfileUpdateService);
@@ -296,4 +298,120 @@ void userNotFound() {
296298
verifyNoInteractions(userHardDeleteHelper, kakaoRevoker, appleRevoker);
297299
}
298300
}
301+
302+
@Nested
303+
class SignupComplete {
304+
305+
@Test
306+
@DisplayName("정상 플로우: 닉네임 설정, 회원가입 경로 설정, 상태 COMPLETED로 변경")
307+
void ok() {
308+
long userId = 42L;
309+
User user = new User();
310+
ReflectionTestUtils.setField(user, "id", userId);
311+
user.setSignupStatus(SignupStatusType.PROFILE_REQUIRED);
312+
ReflectionTestUtils.setField(user, "email", "test@example.com");
313+
314+
given(userRepository.findById(userId)).willReturn(Optional.of(user));
315+
doAnswer(invocation -> {
316+
User u = invocation.getArgument(0);
317+
String newNick = invocation.getArgument(1);
318+
u.setNickname(newNick);
319+
return null;
320+
}).when(userProfileUpdateService).changeNickname(same(user), eq("새록이"));
321+
doAnswer(invocation -> {
322+
User u = invocation.getArgument(0);
323+
u.setSignupStatus(SignupStatusType.COMPLETED);
324+
return null;
325+
}).when(userSignupStatusService).tryCompleteSignup(same(user));
326+
327+
SignupCompleteCommand cmd = new SignupCompleteCommand(userId, "새록이", SignupSourceType.INSTAGRAM);
328+
329+
SignupCompleteResponse response = sut.signupComplete(cmd);
330+
331+
assertThat(response.success()).isTrue();
332+
assertThat(response.userId()).isEqualTo(userId);
333+
assertThat(response.signupStatus()).isEqualTo(SignupStatusType.COMPLETED);
334+
verify(userRepository).findById(userId);
335+
verify(userProfileUpdateService).changeNickname(user, "새록이");
336+
verify(userSignupStatusService).tryCompleteSignup(user);
337+
assertThat(user.getNickname()).isEqualTo("새록이");
338+
assertThat(user.getSignupSource()).isEqualTo(SignupSourceType.INSTAGRAM);
339+
}
340+
341+
@Test
342+
@DisplayName("이미 회원가입 완료된 사용자는 BadRequestException")
343+
void alreadyCompleted_throwsBadRequest() {
344+
long userId = 42L;
345+
User user = new User();
346+
ReflectionTestUtils.setField(user, "id", userId);
347+
user.setSignupStatus(SignupStatusType.COMPLETED);
348+
349+
given(userRepository.findById(userId)).willReturn(Optional.of(user));
350+
351+
SignupCompleteCommand cmd = new SignupCompleteCommand(userId, "새록이", SignupSourceType.INSTAGRAM);
352+
353+
assertThatThrownBy(() -> sut.signupComplete(cmd))
354+
.isInstanceOf(BadRequestException.class)
355+
.hasMessageContaining("이미 회원가입이 완료된 사용자입니다");
356+
357+
verifyNoInteractions(userProfileUpdateService, userSignupStatusService);
358+
}
359+
360+
@Test
361+
@DisplayName("닉네임 정책 위반 시 BadRequestException")
362+
void invalidNickname_throwsBadRequest() {
363+
long userId = 42L;
364+
User user = new User();
365+
ReflectionTestUtils.setField(user, "id", userId);
366+
user.setSignupStatus(SignupStatusType.PROFILE_REQUIRED);
367+
368+
given(userRepository.findById(userId)).willReturn(Optional.of(user));
369+
doThrow(new IllegalArgumentException("닉네임 정책 위반"))
370+
.when(userProfileUpdateService).changeNickname(user, "badnick");
371+
372+
SignupCompleteCommand cmd = new SignupCompleteCommand(userId, "badnick", SignupSourceType.INSTAGRAM);
373+
374+
assertThatThrownBy(() -> sut.signupComplete(cmd))
375+
.isInstanceOf(BadRequestException.class)
376+
.hasMessageContaining("닉네임 정책을 만족하지 않습니다");
377+
}
378+
379+
@Test
380+
@DisplayName("회원가입 경로 null이면 BadRequestException")
381+
void nullSignupSource_throwsBadRequest() {
382+
long userId = 42L;
383+
User user = new User();
384+
ReflectionTestUtils.setField(user, "id", userId);
385+
user.setSignupStatus(SignupStatusType.PROFILE_REQUIRED);
386+
387+
given(userRepository.findById(userId)).willReturn(Optional.of(user));
388+
doAnswer(invocation -> {
389+
User u = invocation.getArgument(0);
390+
String newNick = invocation.getArgument(1);
391+
u.setNickname(newNick);
392+
return null;
393+
}).when(userProfileUpdateService).changeNickname(same(user), eq("새록이"));
394+
395+
SignupCompleteCommand cmd = new SignupCompleteCommand(userId, "새록이", null);
396+
397+
assertThatThrownBy(() -> sut.signupComplete(cmd))
398+
.isInstanceOf(BadRequestException.class)
399+
.hasMessageContaining("회원가입 경로는 필수입니다");
400+
401+
verifyNoInteractions(userSignupStatusService);
402+
}
403+
404+
@Test
405+
@DisplayName("사용자 없음 시 404")
406+
void userNotFound() {
407+
given(userRepository.findById(999L)).willReturn(Optional.empty());
408+
409+
SignupCompleteCommand cmd = new SignupCompleteCommand(999L, "새록이", SignupSourceType.INSTAGRAM);
410+
411+
assertThatThrownBy(() -> sut.signupComplete(cmd))
412+
.isInstanceOf(NotFoundException.class);
413+
414+
verifyNoInteractions(userProfileUpdateService, userSignupStatusService);
415+
}
416+
}
299417
}

0 commit comments

Comments
 (0)