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
10 changes: 10 additions & 0 deletions src/main/java/com/indayvidual/server/common/Patterns.java
Original file line number Diff line number Diff line change
@@ -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}$";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ApiResponse<SignupResponseDTO>> signup(@RequestBody @Valid SignupRequestDTO request) {
SignupResponseDTO response = userAuthService.signupWithEmail(request);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,6 +16,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;
Expand Down Expand Up @@ -51,6 +55,28 @@ public ResponseEntity<ApiResponse<UserResponseDTO.Profile>> 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<ApiResponse<Boolean>> checkUsername(
@AuthenticationPrincipal JwtUserPrincipal principal,
@Parameter(description = "확인할 닉네임", example = "감자도리")
@RequestParam @NotBlank(message = "username은 필수입니다.")
@Size(min = 2, max = 20, message = "닉네임은 2~20자여야 합니다.")
@Pattern(regexp = Patterns.USERNAME_JS_COMPAT, 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 = "변경 성공"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@ public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByEmail(String email);

Optional<User> findByEmailAndStatus(String email, Status status);

boolean existsByUsernameIgnoreCase(String username);
boolean existsByUsernameIgnoreCaseAndIdNot(String username, Long id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", "요청이 너무 잦습니다. 잠시 후 다시 시도해 주세요."),
Expand All @@ -77,6 +78,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", "사용자를 찾을 수 없습니다."),
Expand Down