Skip to content

Commit

Permalink
Api: ✨ 사용자 프로필 수정 API (#118)
Browse files Browse the repository at this point in the history
* fix: 배포 파이프라인 이미지 빌드 버전 추가

* feat: 사용자 인증 코드 발급 & 전화번호 변경 dto 정의

* fix: 사용자 계정 수정 dto 변경

* rename: username-and-phone -> profile 수정

* rename: username-and-profile 수정

* docs: 아이디 & 전화번호 수정 스웨거 작성

* feat: 아이디 & 전화번호 수정 컨트롤러 추가

* feat: 아이디 & 전화번호 수정 usecase 추가

* fix: 인증코드 cache key타입 추가

* fix: 인증코드 요청 유형 phone 타입 추가

* feat: 인증 코드 요청 dto 정적 팩토리 메서드 of 추가

* feat: user entity phone 수정 메서드 추가

* feat: 인증 코드 검증 dto 정적 팩토리 메서드 of 추가

* fix: 사용자 아이디 & 전화번호 변경 dto 불필요한 메서드 제거 (인증코드 검증 dto 치환 메서드)

* feat: 사용자 아이디 & 전화번호 수정 서비스 구현

* docs: 사용자 프로필 수정 dto의 code 필드 문서 수정

* docs: 사용자 프로필 수정 api 스웨거 인증번호 에러 응답 추가

* test: user profile update service test mock bean add

* feat: 전화번호, 아이디 중복 검사 메서드 추가 (본인 제외)

* feat: 사용자 전화번호, 아이디 중복 409 에러코드 추가

* fix: 아이디 & 전화번호 수정 서비스 유효성 검사 추가

* rename: 사용자 수정 서비스 테스트 -> 이름 수정 서비스 테스트

* fix: 나를 제외한 아이디, 전화번호 중복 검사 제거 -> 전체 중복 검사 확인 메서드 추가

* fix: 유효성 검사 시 호출 메서드 수정

* test: 사용자 아이디 & 전화번호 같은 경우 테스트

* test: 아이디 수정 요청 테스트

* test: 전화번호 변경 요청 테스트

* test: 아이디 혹은 전화번호 수정 실패 시 예외 테스트

* rename: 전화번호 예외 검사 불가 항목 주석 추가

* docs: 409 에러 스웨거 문서 추가
  • Loading branch information
psychology50 authored Jun 30, 2024
1 parent 66c6907 commit e9d2cc5
Show file tree
Hide file tree
Showing 14 changed files with 340 additions and 90 deletions.
Original file line number Diff line number Diff line change
@@ -1,101 +1,104 @@
package kr.co.pennyway.api.apis.auth.dto;

import java.time.LocalDateTime;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;

import java.time.LocalDateTime;

public class PhoneVerificationDto {
@Schema(title = "인증번호 요청 DTO", description = "전화번호로 인증번호 송신 요청을 위한 DTO")
public record PushCodeReq(
@Schema(description = "전화번호", example = "010-2629-4624")
@NotBlank(message = "전화번호는 필수입니다.")
@Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.")
String phone
) {
}
@Schema(title = "인증번호 요청 DTO", description = "전화번호로 인증번호 송신 요청을 위한 DTO")
public record PushCodeReq(
@Schema(description = "전화번호", example = "010-2629-4624")
@NotBlank(message = "전화번호는 필수입니다.")
@Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.")
String phone
) {
}

@Schema(title = "인증번호 발송 응답 DTO", description = "전화번호로 인증번호 송신 응답을 위한 DTO")
public record PushCodeRes(
@Schema(description = "수신자 번호")
String to,
@Schema(description = "발송 시간")
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
LocalDateTime sendAt,
@Schema(description = "만료 시간 (default: 3분)", example = "2021-08-01T00:00:00")
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
LocalDateTime expiresAt
) {
/**
* 인증번호 발송 응답 객체 생성
*
* @param to String : 수신자 번호
* @param sendAt LocalDateTime : 발송 시간
* @param expiresAt LocalDateTime : 만료 시간 (default: 5분)
*/
public static PushCodeRes of(String to, LocalDateTime sendAt, LocalDateTime expiresAt) {
return new PushCodeRes(to, sendAt, expiresAt);
}
}

@Schema(title = "인증번호 발송 응답 DTO", description = "전화번호로 인증번호 송신 응답을 위한 DTO")
public record PushCodeRes(
@Schema(description = "수신자 번호")
String to,
@Schema(description = "발송 시간")
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
LocalDateTime sendAt,
@Schema(description = "만료 시간 (default: 3분)", example = "2021-08-01T00:00:00")
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
LocalDateTime expiresAt
) {
/**
* 인증번호 발송 응답 객체 생성
*
* @param to String : 수신자 번호
* @param sendAt LocalDateTime : 발송 시간
* @param expiresAt LocalDateTime : 만료 시간 (default: 5분)
*/
public static PushCodeRes of(String to, LocalDateTime sendAt, LocalDateTime expiresAt) {
return new PushCodeRes(to, sendAt, expiresAt);
}
}
@Schema(title = "인증번호 검증 DTO", description = "전화번호로 인증번호 검증 요청을 위한 DTO")
public record VerifyCodeReq(
@Schema(description = "전화번호", example = "010-2629-4624")
@NotBlank(message = "전화번호는 필수입니다.")
@Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.")
String phone,
@Schema(description = "6자리 정수 인증번호", example = "123456")
@NotBlank(message = "인증번호는 필수입니다.")
@Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.")
String code
) {
public static VerifyCodeReq from(SignUpReq.Info request) {
return new VerifyCodeReq(request.phone(), request.code());
}

@Schema(title = "인증번호 검증 DTO", description = "전화번호로 인증번호 검증 요청을 위한 DTO")
public record VerifyCodeReq(
@Schema(description = "전화번호", example = "010-2629-4624")
@NotBlank(message = "전화번호는 필수입니다.")
@Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.")
String phone,
@Schema(description = "6자리 정수 인증번호", example = "123456")
@NotBlank(message = "인증번호는 필수입니다.")
@Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.")
String code
) {
public static VerifyCodeReq from(SignUpReq.Info request) {
return new VerifyCodeReq(request.phone(), request.code());
}
public static VerifyCodeReq from(SignUpReq.OauthInfo request) {
return new VerifyCodeReq(request.phone(), request.code());
}

public static VerifyCodeReq from(SignUpReq.OauthInfo request) {
return new VerifyCodeReq(request.phone(), request.code());
}
}
public static VerifyCodeReq of(String phone, String code) {
return new VerifyCodeReq(phone, code);
}
}

@Schema(title = "인증번호 검증 응답 DTO")
public record VerifyCodeRes(
@Schema(description = "코드 일치 여부 : 일치하지 않으면 예외이므로 성공하면 언제나 true", example = "true")
Boolean code,
@Schema(description = "oauth 사용자 여부. true면 sync, false면 회원가입으로 진행 (일반 회원가입 시 필수값)", example = "true")
@JsonInclude(JsonInclude.Include.NON_NULL)
Boolean oauth,
@Schema(description = "기존 계정 존재 여부. true면 sync, false면 회원가입 (oauth 회원가입 시 필수값)", example = "true")
@JsonInclude(JsonInclude.Include.NON_NULL)
Boolean existsUser,
@Schema(description = "기존 사용자 아이디", example = "pennyway")
@JsonInclude(JsonInclude.Include.NON_NULL)
String username
) {
/**
* 일반 회원가입 시 인증 코드 응답 객체 생성
*
* @param isOauthUser Boolean : oauth 사용자 여부. true면 sync, false면 회원가입으로 진행
*/
public static VerifyCodeRes valueOfGeneral(Boolean isValidCode, Boolean isOauthUser, String username) {
return new VerifyCodeRes(isValidCode, isOauthUser, null, username);
}
@Schema(title = "인증번호 검증 응답 DTO")
public record VerifyCodeRes(
@Schema(description = "코드 일치 여부 : 일치하지 않으면 예외이므로 성공하면 언제나 true", example = "true")
Boolean code,
@Schema(description = "oauth 사용자 여부. true면 sync, false면 회원가입으로 진행 (일반 회원가입 시 필수값)", example = "true")
@JsonInclude(JsonInclude.Include.NON_NULL)
Boolean oauth,
@Schema(description = "기존 계정 존재 여부. true면 sync, false면 회원가입 (oauth 회원가입 시 필수값)", example = "true")
@JsonInclude(JsonInclude.Include.NON_NULL)
Boolean existsUser,
@Schema(description = "기존 사용자 아이디", example = "pennyway")
@JsonInclude(JsonInclude.Include.NON_NULL)
String username
) {
/**
* 일반 회원가입 시 인증 코드 응답 객체 생성
*
* @param isOauthUser Boolean : oauth 사용자 여부. true면 sync, false면 회원가입으로 진행
*/
public static VerifyCodeRes valueOfGeneral(Boolean isValidCode, Boolean isOauthUser, String username) {
return new VerifyCodeRes(isValidCode, isOauthUser, null, username);
}

/**
* oauth 회원가입 시 인증 코드 응답 객체 생성
*
* @param existsUser Boolean : 기존 계정 존재 여부. true면 sync, false면 회원가입으로 진행
*/
public static VerifyCodeRes valueOfOauth(Boolean isValidCode, Boolean existsUser, String username) {
return new VerifyCodeRes(isValidCode, null, existsUser, username);
}
}
/**
* oauth 회원가입 시 인증 코드 응답 객체 생성
*
* @param existsUser Boolean : 기존 계정 존재 여부. true면 sync, false면 회원가입으로 진행
*/
public static VerifyCodeRes valueOfOauth(Boolean isValidCode, Boolean existsUser, String username) {
return new VerifyCodeRes(isValidCode, null, existsUser, username);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,41 @@ ResponseEntity<?> postPasswordVerification(@RequestBody @Validated UserProfileUp
})
ResponseEntity<?> patchPassword(@RequestBody @Validated UserProfileUpdateDto.PasswordReq request, @AuthenticationPrincipal SecurityUserDetails user);

@Operation(summary = "사용자 프로필 수정")
@ApiResponses({
@ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name = "검증 실패", value = """
{
"code": "4010",
"message": "인증번호가 일치하지 않습니다."
}
""")
})),
@ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name = "검증 실패 - 인증번호 만료", value = """
{
"code": "4042",
"message": "만료되었거나 등록되지 않은 휴대폰 정보입니다."
}
""")
})),
@ApiResponse(responseCode = "409", content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name = "검증 실패 - 이미 존재하는 아이디", value = """
{
"code": "4091",
"message": "이미 존재하는 아이디입니다."
}
"""),
@ExampleObject(name = "검증 실패 - 이미 존재하는 휴대폰 번호", value = """
{
"code": "4091",
"message": "이미 존재하는 휴대폰 번호입니다."
}
""")
}))
})
ResponseEntity<?> patchProfile(@RequestBody @Validated UserProfileUpdateDto.UsernameAndPhoneReq request, @AuthenticationPrincipal SecurityUserDetails user);

@Operation(summary = "사용자 알림 활성화")
@Parameter(name = "type", description = "알림 타입", examples = {
@ExampleObject(name = "가계부", value = "account_book"), @ExampleObject(name = "피드", value = "feed"), @ExampleObject(name = "채팅", value = "chat")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ public ResponseEntity<?> patchPassword(UserProfileUpdateDto.PasswordReq request,
return ResponseEntity.ok(SuccessResponse.noContent());
}

@Override
@PatchMapping("/profile")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<?> patchProfile(@RequestBody @Validated UserProfileUpdateDto.UsernameAndPhoneReq request, @AuthenticationPrincipal SecurityUserDetails user) {
userAccountUseCase.updateUsernameAndPhone(user.getUserId(), request);
return ResponseEntity.ok(SuccessResponse.noContent());
}

@Override
@PatchMapping("/notifications")
@PreAuthorize("isAuthenticated()")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,21 @@ public record ProfileImageReq(
String profileImageUrl
) {
}

@Schema(title = "사용자 아이디, 전화번호 변경 DTO")
public record UsernameAndPhoneReq(
@Schema(description = "변경할 아이디", example = "pennyway")
@NotBlank(message = "아이디를 입력해주세요")
@Pattern(regexp = "^[a-z-_.]{5,20}$", message = "5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.")
String username,
@Schema(description = "전화번호", example = "010-2629-4624")
@NotBlank(message = "전화번호는 필수입니다.")
@Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.")
String phone,
@Schema(description = "6자리 정수 인증번호. 만약 전화번호가 변경되지 않는다면, 6자리 정수 더미값 삽입", example = "123456")
@NotBlank(message = "인증번호는 필수입니다.")
@Pattern(regexp = "^[0-9]{6}$", message = "인증 코드는 6자리 숫자여야 합니다.")
String code
) {
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package kr.co.pennyway.api.apis.users.service;

import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto;
import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService;
import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType;
import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService;
import kr.co.pennyway.domain.domains.user.domain.NotifySetting;
import kr.co.pennyway.domain.domains.user.domain.User;
import kr.co.pennyway.domain.domains.user.exception.UserErrorCode;
Expand All @@ -21,6 +25,9 @@ public class UserProfileUpdateService {
private final UserService userService;
private final AwsS3Provider awsS3Provider;

private final PhoneVerificationService phoneVerificationService;
private final PhoneCodeService phoneCodeService;

@Transactional
public void updateName(Long userId, String newName) {
User user = readUserOrThrow(userId);
Expand Down Expand Up @@ -53,10 +60,34 @@ public void updateProfileImage(Long userId, String profileImageUrl) {
user.updateProfileImageUrl(awsS3Provider.getObjectPrefix() + originKey);
}

@Transactional
public void updateUsernameAndPhone(Long userId, String username, String phone, String code) {
User user = readUserOrThrow(userId);

if (!user.getUsername().equals(username)) {
if (userService.isExistUsername(username)) {
throw new UserErrorException(UserErrorCode.ALREADY_EXIST_USERNAME);
}

user.updateUsername(username);
}

if (!user.getPhone().equals(phone)) {
phoneVerificationService.isValidCode(PhoneVerificationDto.VerifyCodeReq.of(phone, code), PhoneCodeKeyType.PHONE);
phoneCodeService.delete(phone, PhoneCodeKeyType.PHONE);

if (userService.isExistPhone(phone)) {
throw new UserErrorException(UserErrorCode.ALREADY_EXIST_PHONE);
}

user.updatePhone(phone);
}
}

@Transactional
public void updateNotifySetting(Long userId, NotifySetting.NotifyType type, Boolean flag) {
User user = readUserOrThrow(userId);

user.getNotifySetting().updateNotifySetting(type, flag);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ public void updateProfileImage(Long userId, UserProfileUpdateDto.ProfileImageReq
userProfileUpdateService.updateProfileImage(userId, request.profileImageUrl());
}

public void updateUsernameAndPhone(Long userId, UserProfileUpdateDto.UsernameAndPhoneReq request) {
userProfileUpdateService.updateUsernameAndPhone(userId, request.username(), request.phone(), request.code());
}

public UserProfileUpdateDto.NotifySettingUpdateRes activateNotification(Long userId, NotifySetting.NotifyType type) {
userProfileUpdateService.updateNotifySetting(userId, type, Boolean.TRUE);
return UserProfileMapper.toNotifySettingUpdateRes(type, Boolean.TRUE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ public enum VerificationType {
GENERAL("general"),
OAUTH("oauth"),
USERNAME("username"),
PASSWORD("password");
PASSWORD("password"),
PHONE("phone");

private final String type;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package kr.co.pennyway.api.apis.users.usecase;
package kr.co.pennyway.api.apis.users.service;

import com.querydsl.jpa.impl.JPAQueryFactory;
import kr.co.pennyway.api.apis.users.service.UserProfileUpdateService;
import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService;
import kr.co.pennyway.api.config.ExternalApiDBTestConfig;
import kr.co.pennyway.api.config.fixture.UserFixture;
import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService;
import kr.co.pennyway.domain.config.JpaConfig;
import kr.co.pennyway.domain.domains.user.domain.User;
import kr.co.pennyway.domain.domains.user.exception.UserErrorCode;
Expand All @@ -28,7 +29,7 @@
@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create")
@ContextConfiguration(classes = {JpaConfig.class, UserProfileUpdateService.class, UserService.class})
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class UserProfileUpdateServiceTest extends ExternalApiDBTestConfig {
public class NameUpdateServiceTest extends ExternalApiDBTestConfig {
@Autowired
private UserService userService;

Expand All @@ -38,6 +39,12 @@ public class UserProfileUpdateServiceTest extends ExternalApiDBTestConfig {
@MockBean
private AwsS3Provider awsS3Provider;

@MockBean
private PhoneVerificationService phoneVerificationService;

@MockBean
private PhoneCodeService phoneCodeService;

@MockBean
private JPAQueryFactory queryFactory;

Expand Down
Loading

0 comments on commit e9d2cc5

Please sign in to comment.