Skip to content
Merged
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
@@ -1,5 +1,8 @@
package com.server.running_handai.domain.member.controller;

import com.server.running_handai.domain.member.dto.MemberUpdateRequestDto;
import com.server.running_handai.domain.member.dto.MemberUpdateResponseDto;
import com.server.running_handai.global.oauth.CustomOAuth2User;
import com.server.running_handai.global.response.CommonResponse;
import com.server.running_handai.global.response.ResponseCode;
import com.server.running_handai.domain.member.dto.TokenRequestDto;
Expand All @@ -9,10 +12,17 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@Slf4j
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/members")
Expand All @@ -21,15 +31,67 @@ public class MemberController {
private final MemberService memberService;

@Operation(summary = "토큰 재발급",
description = "리프래쉬 토큰을 통해 만료된 액세스 토큰을 재발급합니다. 인증에 사용된 리프래시 토큰 역시 액세스 토큰과 함께 재발급됩니다. 로그인은 /oauth2/authorization/{provider}로 요청해주세요.")
description = "리프래쉬 토큰을 통해 만료된 액세스 토큰을 재발급합니다. 인증에 사용된 리프래시 토큰 역시 액세스 토큰과 함께 재발급됩니다. " +
"로그인은 /oauth2/authorization/{provider}로 요청해주세요.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공"),
@ApiResponse(responseCode = "401", description = "실패 (유효하지 않은 토큰)"),
@ApiResponse(responseCode = "404", description = "실패 (찾을 수 없는 리프래시 토큰)")
@ApiResponse(responseCode = "200", description = "성공 - SUCCESS"),
@ApiResponse(responseCode = "401", description = "실패 (유효하지 않은 토큰) - INVALID_REFRESH_TOKEN"),
@ApiResponse(responseCode = "404", description = "실패 (찾을 수 없는 리프래시 토큰) - REFRESH_TOKEN_NOT_FOUND")
})
@PostMapping("/oauth/token")
public ResponseEntity<CommonResponse<TokenResponseDto>> createToken(@RequestBody TokenRequestDto tokenRequestDto) {
TokenResponseDto tokenResponseDto = memberService.createToken(tokenRequestDto);
return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS, tokenResponseDto));
}

@Operation(summary = "닉네임 중복 여부 조회",
description = "사용자가 수정하려는 닉네임이 중복이 아닌 경우 true, 중복인 경우 false를 응답합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공 - SUCCESS"),
@ApiResponse(responseCode = "401", description = "토큰 인증 필요 - UNAUTHORIZED_ACCESS"),
@ApiResponse(responseCode = "404", description = "실패 (찾을 수 없는 사용자) - MEMBER_NOT_FOUND"),
@ApiResponse(responseCode = "400", description =
"실패 (유효성 검증):<br>" +
"• 글자수가 2글자 미만, 10글자 초과 - INVALID_NICKNAME_LENGTH<br>" +
"• 한글, 영문, 숫자 외의 문자가 존재 - INVALID_NICKNAME_FORMAT<br>" +
"• 현재 사용 중인 닉네임과 동일 - SAME_AS_CURRENT_NICKNAME<br>" +
"• 공백이나 Null 값으로 호출 - INVALID_INPUT_VALUE"
),
})
@GetMapping("/nickname")
public ResponseEntity<CommonResponse<Boolean>> checkNicknameDuplicate(
@NotBlank @RequestParam("value") String nickname,
@AuthenticationPrincipal CustomOAuth2User customOAuth2User
) {
Long memberId = customOAuth2User.getMember().getId();
log.info("[닉네임 중복 여부 조회] memberId: {} nickname: {}", memberId, nickname);
Boolean result = memberService.checkNicknameDuplicate(memberId, nickname);
return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS, result));
}

@Operation(summary = "내 정보 수정",
description = "내 정보를 수정합니다. 현재는 닉네임 수정만 제공합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공 - SUCCESS"),
@ApiResponse(responseCode = "401", description = "토큰 인증 필요 - UNAUTHORIZED_ACCESS"),
@ApiResponse(responseCode = "404", description = "실패 (찾을 수 없는 사용자) - MEMBER_NOT_FOUND"),
@ApiResponse(responseCode = "400", description =
"실패 (유효성 검증):<br>" +
"• 글자수가 2글자 미만, 10글자 초과 - INVALID_NICKNAME_LENGTH<br>" +
"• 한글, 영문, 숫자 외의 문자가 존재 - INVALID_NICKNAME_FORMAT<br>" +
"• 현재 사용 중인 닉네임과 동일 - SAME_AS_CURRENT_NICKNAME<br>" +
"• 공백이나 Null 값으로 호출 - INVALID_INPUT_VALUE"
),
@ApiResponse(responseCode = "409", description = "실패 (중복된 닉네임) - DUPLICATE_NICKNAME"),
})
@PatchMapping("/me")
public ResponseEntity<CommonResponse<MemberUpdateResponseDto>> updateMemberInfo(
@RequestBody @Valid MemberUpdateRequestDto memberUpdateRequestDto,
@AuthenticationPrincipal CustomOAuth2User customOAuth2User
) {
Long memberId = customOAuth2User.getMember().getId();
log.info("[내 정보 수정] memberId: {}", memberId);
MemberUpdateResponseDto memberUpdateResponseDto = memberService.updateMemberInfo(memberId, memberUpdateRequestDto);
return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS, memberUpdateResponseDto));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.server.running_handai.domain.member.dto;

import jakarta.validation.constraints.NotBlank;

public record MemberUpdateRequestDto (
@NotBlank
String nickname
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.server.running_handai.domain.member.dto;

public record MemberUpdateResponseDto (
Long memberId,
String nickname
) {
public static MemberUpdateResponseDto from(Long memberId, String nickname) {
return new MemberUpdateResponseDto(memberId, nickname);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ public Member(String providerId, String email, String nickname, Provider provide
this.role = role;
}

// ==== 연관관계 편의 메서드 ==== //
public void updateRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}

public void updateNickname(String nickname) { this.nickname = nickname; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
/**
* Provider Id로 사용자를 조회합니다.
*/
Optional<Member> findByProviderId(String providerId);
boolean existsByNickname(String nickname);

/**
* 리프래시 토큰으로 사용자를 조회합니다.
*/
Optional<Member> findByRefreshToken(String refreshToken);

/**
* 닉네임 중복 여부를 확인합니다.
*/
boolean existsByNickname(String nickname);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.server.running_handai.domain.member.service;

import com.server.running_handai.domain.member.dto.MemberUpdateRequestDto;
import com.server.running_handai.domain.member.dto.MemberUpdateResponseDto;
import com.server.running_handai.global.jwt.JwtProvider;
import com.server.running_handai.global.oauth.userInfo.OAuth2UserInfo;
import com.server.running_handai.global.response.ResponseCode;
Expand All @@ -10,7 +12,7 @@
import com.server.running_handai.domain.member.entity.Role;
import com.server.running_handai.domain.member.repository.MemberRepository;
import io.jsonwebtoken.ExpiredJwtException;
import jakarta.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand All @@ -24,7 +26,9 @@ public class MemberService {
private final MemberRepository memberRepository;
private final JwtProvider jwtProvider;

public static final int NICKNAME_NUMBER = 10;
public static final int NICKNAME_MAX_LENGTH = 10;
public static final int NICKNAME_MIN_LENGTH = 2;
private static final String NICKNAME_PATTERN = "^[ㄱ-ㅎㅏ-ㅣ가-힣a-zA-Z0-9]{2,10}$";

/**
* OAuth2 사용자 정보를 기반으로 회원을 생성하거나 기존 회원을 조회합니다.
Expand Down Expand Up @@ -130,7 +134,7 @@ private String generateRandomNickname() {
String animal = animals.get(random.nextInt(animals.size()));

int usedLength = adjective.length() + animal.length();
int remainLength = NICKNAME_NUMBER - usedLength;
int remainLength = NICKNAME_MAX_LENGTH - usedLength;

if (remainLength > 0) {
// 이미 선택된 형용사, 동물의 자리수를 확인하여, 남은 수를 숫자에 사용 (최소 1자리, 최대 remainLength)
Expand All @@ -153,4 +157,74 @@ private String generateRandomNickname() {

return nickname;
}

/**
* 닉네임 중복 여부를 조회합니다.
* 닉네임 유효성 검증도 함께 수행합니다.
*
* @param memberId 사용자 Id
* @param nickname 검증할 닉네임
* @return 중복이지 않으면 true, 중복이면 false.
*/
public Boolean checkNicknameDuplicate(Long memberId, String nickname) {
Member member = memberRepository.findById(memberId).orElseThrow(() -> new BusinessException(ResponseCode.MEMBER_NOT_FOUND));

// 중복 여부 조회 시 문자 앞, 뒤 공백과 영문 대, 소문자는 무시 (프론트 측에서 처리해서 보내줌)
String newNickname = nickname.trim().toLowerCase();
String currentNickname = member.getNickname().trim().toLowerCase();

return isNicknameValid(newNickname, currentNickname);
}

/**
* 내 정보를 수정합니다.
* 닉네임 유효성 검증도 함께 수행합니다.
*
* @param memberId 사용자 Id
* @param memberUpdateRequestDto 수정하고 싶은 내 정보 Dto
* @return 수정된 내 정보 Dto (MemberUpdateResponseDto)
*/
@Transactional
public MemberUpdateResponseDto updateMemberInfo(Long memberId, MemberUpdateRequestDto memberUpdateRequestDto) {
Member member = memberRepository.findById(memberId).orElseThrow(() -> new BusinessException(ResponseCode.MEMBER_NOT_FOUND));

// 중복 여부 조회 시 문자 앞, 뒤 공백과 영문 대, 소문자는 무시 (프론트 측에서 처리해서 보내줌)
String newNickname = memberUpdateRequestDto.nickname().trim().toLowerCase();
String currentNickname = member.getNickname().trim().toLowerCase();

if (isNicknameValid(newNickname, currentNickname)) {
member.updateNickname(newNickname);
} else {
throw new BusinessException(ResponseCode.DUPLICATE_NICKNAME);
}

return MemberUpdateResponseDto.from(member.getId(), member.getNickname());
}

/**
* 닉네임 유효성을 검증합니다.
* 테스트를 위해 가시성을 완화했습니다. (private -> package-private)
*
* @param newNickname 검증할 닉네임
* @param currentNickname 사용자의 현재 닉네임
* @return 사용 가능하면 true, 사용 불가하면 false.
*/
boolean isNicknameValid(String newNickname, String currentNickname) {
// 이미 자신이 사용 중인 닉네임이어서는 안됨
if (currentNickname.equals(newNickname)) {
throw new BusinessException(ResponseCode.SAME_AS_CURRENT_NICKNAME);
}

// 닉네임 글자수는 2글자부터 최대 10글자까지
if (newNickname.length() < NICKNAME_MIN_LENGTH || newNickname.length() > NICKNAME_MAX_LENGTH) {
throw new BusinessException(ResponseCode.INVALID_NICKNAME_LENGTH);
}

// 닉네임은 한글, 숫자, 영문만 입력할 수 있음
if (!newNickname.matches(NICKNAME_PATTERN)) {
throw new BusinessException(ResponseCode.INVALID_NICKNAME_FORMAT);
}

return !memberRepository.existsByNickname(newNickname);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ public enum ResponseCode {
INVALID_REVIEW_STARS(BAD_REQUEST, "별점은 0.5점 단위여야합니다."),
EMPTY_REVIEW_CONTENTS(BAD_REQUEST, "리뷰 내용은 비워둘 수 없습니다"),
BAD_REQUEST_STATE_PARAMETER(BAD_REQUEST, "로그인 요청 시 유효한 state 값이 필요합니다."),
INVALID_NICKNAME_LENGTH(BAD_REQUEST, "닉네임은 2글자부터 10글자까지 입력할 수 있습니다."),
INVALID_NICKNAME_FORMAT(BAD_REQUEST, "닉네임은 영문, 한글, 숫자만 입력할 수 있습니다."),
SAME_AS_CURRENT_NICKNAME(BAD_REQUEST, "현재 사용 중인 닉네임과 동일합니다."),

// UNAUTHORIZED (401)
INVALID_ACCESS_TOKEN(UNAUTHORIZED, "유효하지 않은 액세스 토큰입니다."),
Expand All @@ -46,14 +49,17 @@ public enum ResponseCode {
BOOKMARK_NOT_FOUND(NOT_FOUND, "찾을 수 없는 북마크입니다."),
REVIEW_NOT_FOUND(NOT_FOUND, "찾을 수 없는 리뷰입니다."),

// CONFLICT (409)
DUPLICATE_NICKNAME(CONFLICT, "이미 사용 중인 닉네임입니다."),

/** 시스템 및 공통 예외용 에러 코드 */
// BAD_REQUEST (400)
ILLEGAL_ARGUMENT(BAD_REQUEST, "잘못된 인자 값입니다."),
METHOD_ARGUMENT_NOT_VALID(BAD_REQUEST, "유효하지 않은 인자 값입니다."),
HTTP_MESSAGE_NOT_READABLE(BAD_REQUEST, "잘못된 요청 형식입니다."),
MISSING_SERVLET_REQUEST_PARAMETER(BAD_REQUEST, "필수 요청 매개변수가 누락되었습니다."),
ARGUMENT_TYPE_MISMATCH(BAD_REQUEST, "요청 매개변수의 타입이 올바르지 않습니다."),
OPENAI_RESPONSE_INVALID(BAD_REQUEST, "OPEN AI 응답값이 유효하지 않습니다."),
INVALID_INPUT_VALUE(BAD_REQUEST, "유효하지 않은 입력 값입니다."),

// NOT_FOUND (404)
RESOURCE_NOT_FOUND(NOT_FOUND, "존재하지 않는 리소스입니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.server.running_handai.global.response.CommonResponse;
import com.server.running_handai.global.response.ResponseCode;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
Expand All @@ -27,10 +28,11 @@ public ResponseEntity<CommonResponse<?>> handleCustomException(BusinessException
/**
* BAD_REQUEST (400)
* IllegalArgumentException: 사용자가 값을 잘못 입력한 경우
* MethodArgumentNotValidException: 전달된 값이 유효하지 않은 경우
* MethodArgumentNotValidException: 전달된 값이 유효하지 않은 경우 (@Valid)
* HttpMessageNotReadableException: 잘못된 형식으로 요청할 경우
* MissingServletRequestParameterException: 필수 요청 매개변수가 누락된 경우
* MethodArgumentTypeMismatchException: 요청 매개변수의 타입 변환을 실패한 경우
* ConstraintViolationException: 전달된 값이 유효하지 않은 경우 (@Validated)
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<CommonResponse<?>> handleIllegalArgumentException(IllegalArgumentException e) {
Expand All @@ -40,7 +42,7 @@ public ResponseEntity<CommonResponse<?>> handleIllegalArgumentException(IllegalA
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<CommonResponse<?>> handleMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
return getErrorResponse(e, ResponseCode.METHOD_ARGUMENT_NOT_VALID);
return getErrorResponse(e, ResponseCode.INVALID_INPUT_VALUE);
}

@ExceptionHandler(HttpMessageNotReadableException.class)
Expand All @@ -61,6 +63,12 @@ public ResponseEntity<CommonResponse<?>> handleMethodArgumentTypeMismatchExcepti
return getErrorResponse(e, ResponseCode.ARGUMENT_TYPE_MISMATCH);
}

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<CommonResponse<?>> handleConstraintViolationException(
ConstraintViolationException e) {
return getErrorResponse(e, ResponseCode.INVALID_INPUT_VALUE);
}

/**
* METHOD_NOT_ALLOWED (405)
* HttpRequestMethodNotSupportedException: 잘못된 Http Method를 가지고 요청할 경우
Expand Down
Loading