From c589bf27829d582b9daf6d4e2d4790ae08decf0e Mon Sep 17 00:00:00 2001 From: SJ-PARKs Date: Thu, 14 Aug 2025 23:16:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[FEAT]=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=ED=99=95=EC=9D=B8=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=A4=91=EB=B3=B5=20=EB=B0=A9=EC=A7=80=20#106?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserProfileController.java | 25 ++++++++++++++++++ .../server/domain/user/entity/User.java | 2 ++ .../user/repository/UserRepository.java | 3 +++ .../UserService/UserProfileService.java | 26 ++++++++++++++++++- .../global/api/code/status/ErrorStatus.java | 1 + 5 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java b/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java index f9a41ae..1882b38 100644 --- a/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java +++ b/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java @@ -15,6 +15,9 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -51,6 +54,28 @@ public ResponseEntity> getMyProfile( return ResponseEntity.ok(ApiResponse.onSuccess(profile)); } + @Operation( + summary = "닉네임 중복 확인", + description = "입력한 닉네임이 사용 가능한지 확인합니다. (본인 닉네임은 중복으로 간주하지 않음)" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "사용 가능 여부 반환"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "형식 오류"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 실패") + }) + @GetMapping("/username/check") + public ResponseEntity> checkUsername( + @AuthenticationPrincipal JwtUserPrincipal principal, + @Parameter(description = "확인할 닉네임", example = "감자도리") + @RequestParam @NotBlank(message = "username은 필수입니다.") + @Size(min = 2, max = 20, message = "닉네임은 2~20자여야 합니다.") + @Pattern(regexp = "^[\\p{L}0-9 _-]{2,20}$", message = "닉네임은 한글/영문/숫자/공백/[_-]만 허용합니다.") + String username + ) { + boolean available = userProfileService.isUsernameAvailable(username, principal.userId()); + return ResponseEntity.ok(ApiResponse.onSuccess(available)); + } + @Operation(summary = "닉네임 변경", description = "재인증 토큰 검증 후 닉네임을 변경합니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "변경 성공"), diff --git a/src/main/java/com/indayvidual/server/domain/user/entity/User.java b/src/main/java/com/indayvidual/server/domain/user/entity/User.java index 863267f..1838013 100644 --- a/src/main/java/com/indayvidual/server/domain/user/entity/User.java +++ b/src/main/java/com/indayvidual/server/domain/user/entity/User.java @@ -42,6 +42,8 @@ public class User extends BaseEntity { private String email; private String password; // (소셜 로그인 시 null) + + @Column(unique = true) private String username; private String phone_number; diff --git a/src/main/java/com/indayvidual/server/domain/user/repository/UserRepository.java b/src/main/java/com/indayvidual/server/domain/user/repository/UserRepository.java index 4616b2f..743405a 100644 --- a/src/main/java/com/indayvidual/server/domain/user/repository/UserRepository.java +++ b/src/main/java/com/indayvidual/server/domain/user/repository/UserRepository.java @@ -14,4 +14,7 @@ public interface UserRepository extends JpaRepository { boolean existsByEmail(String email); Optional findByEmailAndStatus(String email, Status status); + + boolean existsByUsernameIgnoreCase(String username); + boolean existsByUsernameIgnoreCaseAndIdNot(String username, Long id); } \ No newline at end of file diff --git a/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java b/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java index 525e6b6..fa8f935 100644 --- a/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java +++ b/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java @@ -54,7 +54,31 @@ public UserResponseDTO.Profile getMyProfile(Long userId) { public void updateUsername(Long userId, String newUsername) { User user = userRepository.findById(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.MYPAGE_USER_NOT_FOUND)); - user.changeUsername(newUsername); + String target = newUsername == null ? "" : newUsername.trim(); + if (target.isEmpty()) throw new GeneralException(ErrorStatus._BAD_REQUEST); + // 동일 닉네임(대소문자/공백 무시)인 경우 변경 스킵 + if (user.getUsername() != null && user.getUsername().trim().equalsIgnoreCase(target)) { + return; + } + // 타 유저가 사용 중이면 차단 + boolean exists = userRepository.existsByUsernameIgnoreCaseAndIdNot(target, userId); + if (exists) throw new GeneralException(ErrorStatus.MYPAGE_USERNAME_DUPLICATED); + user.changeUsername(target); + } + + @Transactional(readOnly = true) + public boolean isUsernameAvailable(String candidate, Long currentUserId) { + String target = candidate == null ? "" : candidate.trim(); + if (target.isEmpty()) return false; + if (currentUserId != null) { + // 본인 닉네임은 중복으로 간주하지 않음 + boolean sameAsMine = userRepository.findById(currentUserId) + .map(u -> u.getUsername() != null && u.getUsername().trim().equalsIgnoreCase(target)) + .orElse(false); + if (sameAsMine) return true; + return !userRepository.existsByUsernameIgnoreCaseAndIdNot(target, currentUserId); + } + return !userRepository.existsByUsernameIgnoreCase(target); } public void updatePassword(Long userId, String currentPassword, String newPassword) { diff --git a/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java b/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java index cb3b5d2..c0dc8e2 100644 --- a/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java +++ b/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java @@ -77,6 +77,7 @@ public enum ErrorStatus implements BaseErrorCode { MYPAGE_IMAGE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "MYPAGE4130", "이미지 용량이 허용 범위를 초과했습니다."), MYPAGE_IMAGE_UPLOAD_FAILED(HttpStatus.BAD_GATEWAY, "MYPAGE5020", "이미지 업로드에 실패했습니다."), MYPAGE_PASSWORD_NOT_SUPPORTED(HttpStatus.CONFLICT, "MYPAGE4091", "비밀번호를 변경할 수 없는 계정입니다."), + MYPAGE_USERNAME_DUPLICATED(HttpStatus.CONFLICT, "MYPAGE4092", "이미 사용 중인 닉네임입니다."), // 계정 관련 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER4041", "사용자를 찾을 수 없습니다."), From c637dd1843fbc0784b98edcf037cdedf84f0c958 Mon Sep 17 00:00:00 2001 From: SJ-PARKs Date: Fri, 15 Aug 2025 01:19:08 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[FEAT]=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=EC=8B=9D=20\p{L}=20=E2=86=92=20=ED=95=9C?= =?UTF-8?q?=EA=B8=80=20=EC=9C=A0=EB=8B=88=EC=BD=94=EB=93=9C=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20#106?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/indayvidual/server/common/Patterns.java | 10 ++++++++++ .../domain/user/controller/UserProfileController.java | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/indayvidual/server/common/Patterns.java diff --git a/src/main/java/com/indayvidual/server/common/Patterns.java b/src/main/java/com/indayvidual/server/common/Patterns.java new file mode 100644 index 0000000..221d6bc --- /dev/null +++ b/src/main/java/com/indayvidual/server/common/Patterns.java @@ -0,0 +1,10 @@ +package com.indayvidual.server.common; + + +public final class Patterns { + private Patterns() {} + + // JS/Java 공통 호환 패턴: 한글/영문/숫자/공백/_/- + public static final String USERNAME_JS_COMPAT = + "^[A-Za-z0-9 _\\-\\uAC00-\\uD7A3\\u1100-\\u11FF\\u3130-\\u318F]{2,20}$"; +} diff --git a/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java b/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java index 1882b38..0fb4071 100644 --- a/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java +++ b/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java @@ -1,5 +1,6 @@ package com.indayvidual.server.domain.user.controller; +import com.indayvidual.server.common.Patterns; import com.indayvidual.server.domain.user.dto.request.DeleteAccountRequest; import com.indayvidual.server.domain.user.dto.request.UserRequestDTO; import com.indayvidual.server.domain.user.dto.response.SimpleMessageResponse; @@ -69,7 +70,7 @@ public ResponseEntity> checkUsername( @Parameter(description = "확인할 닉네임", example = "감자도리") @RequestParam @NotBlank(message = "username은 필수입니다.") @Size(min = 2, max = 20, message = "닉네임은 2~20자여야 합니다.") - @Pattern(regexp = "^[\\p{L}0-9 _-]{2,20}$", message = "닉네임은 한글/영문/숫자/공백/[_-]만 허용합니다.") + @Pattern(regexp = Patterns.USERNAME_JS_COMPAT, message = "닉네임은 한글/영문/숫자/공백/[_-]만 허용합니다.") String username ) { boolean available = userProfileService.isUsernameAvailable(username, principal.userId()); From e8606d7ea3c33b18991559dd94bdd74c403e32b8 Mon Sep 17 00:00:00 2001 From: SJ-PARKs Date: Fri, 15 Aug 2025 01:35:36 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[FEAT]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=EB=8B=89=EB=84=A4=EC=9E=84=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EA=B2=80=EC=82=AC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=ED=91=9C=EC=A4=80=ED=99=94=20#106?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/user/controller/AuthController.java | 6 ++++++ .../server/domain/user/dto/request/SignupRequestDTO.java | 3 +++ .../user/service/UserService/UserAuthServiceImpl.java | 6 ++++++ .../server/global/api/code/status/ErrorStatus.java | 1 + 4 files changed, 16 insertions(+) diff --git a/src/main/java/com/indayvidual/server/domain/user/controller/AuthController.java b/src/main/java/com/indayvidual/server/domain/user/controller/AuthController.java index 7326aa3..3384cf9 100644 --- a/src/main/java/com/indayvidual/server/domain/user/controller/AuthController.java +++ b/src/main/java/com/indayvidual/server/domain/user/controller/AuthController.java @@ -11,6 +11,7 @@ import com.indayvidual.server.global.api.response.ApiResponse; import com.indayvidual.server.global.exception.GeneralException; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import com.indayvidual.server.global.api.code.status.ErrorStatus; import jakarta.validation.Valid; @@ -34,6 +35,11 @@ public class AuthController { summary = "회원가입", description = "이메일과 비밀번호, 이름, 전화번호를 입력하여 회원가입을 진행합니다. 이미 가입된 이메일인 경우 실패합니다." ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "가입 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 검증 실패"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이메일/닉네임 중복") + }) @PostMapping("/signup") public ResponseEntity> signup(@RequestBody @Valid SignupRequestDTO request) { SignupResponseDTO response = userAuthService.signupWithEmail(request); diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/request/SignupRequestDTO.java b/src/main/java/com/indayvidual/server/domain/user/dto/request/SignupRequestDTO.java index d516de3..30bc7ff 100644 --- a/src/main/java/com/indayvidual/server/domain/user/dto/request/SignupRequestDTO.java +++ b/src/main/java/com/indayvidual/server/domain/user/dto/request/SignupRequestDTO.java @@ -28,6 +28,9 @@ public class SignupRequestDTO { @Schema(description = "사용자 이름", example = "박성준", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "이름은 필수입니다.") + @Size(min = 2, max = 20, message = "닉네임은 2~20자여야 합니다.") + @Pattern(regexp = com.indayvidual.server.common.Patterns.USERNAME_JS_COMPAT, + message = "닉네임은 한글/영문/숫자/공백/[_-]만 허용합니다.") private String username; @Schema(description = "전화번호(선택)", example = "010-1234-5678") diff --git a/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserAuthServiceImpl.java b/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserAuthServiceImpl.java index 73af704..6d311bf 100644 --- a/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserAuthServiceImpl.java +++ b/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserAuthServiceImpl.java @@ -39,9 +39,15 @@ public class UserAuthServiceImpl implements UserAuthService { @Override public SignupResponseDTO signupWithEmail(SignupRequestDTO request) { + // 1) 이메일 중복 if (userRepository.existsByEmail(request.getEmail())) { throw new GeneralException(ErrorStatus.AUTH_EMAIL_DUPLICATED); } + // 2) 닉네임 중복 (대소문자 무시) + if (userRepository.existsByUsernameIgnoreCase(request.getUsername())) { + throw new GeneralException(ErrorStatus.AUTH_USERNAME_DUPLICATED); + } + String encodedPassword = passwordEncoder.encode(request.getPassword()); User user = User.builder() diff --git a/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java b/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java index c0dc8e2..db63737 100644 --- a/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java +++ b/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java @@ -53,6 +53,7 @@ public enum ErrorStatus implements BaseErrorCode { AUTH_USER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH4015", "존재하지 않는 사용자입니다."), AUTH_OAUTH_INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH4016", "소셜 액세스 토큰이 유효하지 않습니다."), AUTH_OAUTH_PROVIDER_ERROR(HttpStatus.BAD_GATEWAY, "AUTH5021", "소셜 인증 제공자와의 통신에 실패했습니다."), + AUTH_USERNAME_DUPLICATED(HttpStatus.CONFLICT, "AUTH4092", "이미 사용 중인 닉네임입니다."), // ===== EMAIL VERIFICATION ===== EMAIL_RESEND_COOLDOWN(HttpStatus.TOO_MANY_REQUESTS, "EMAIL4290", "요청이 너무 잦습니다. 잠시 후 다시 시도해 주세요."),