diff --git a/src/main/java/com/cokerthon/Team3_Backend/domain/auth/exception/code/AuthErrorCode.java b/src/main/java/com/cokerthon/Team3_Backend/domain/auth/exception/code/AuthErrorCode.java index 196af80..7ccfcc1 100644 --- a/src/main/java/com/cokerthon/Team3_Backend/domain/auth/exception/code/AuthErrorCode.java +++ b/src/main/java/com/cokerthon/Team3_Backend/domain/auth/exception/code/AuthErrorCode.java @@ -15,6 +15,8 @@ public enum AuthErrorCode implements BaseErrorCode { NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH-002", "사용자를 찾을 수 없습니다."), DUPLICATE_NICKNAME(HttpStatus.CONFLICT, "AUTH-003", "이미 존재하는 닉네임입니다."), DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "AUTH-004", "이미 존재하는 아이디입니다."), + PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "AUTH-005", "새 비밀번호가 일치하지 않습니다."), + CURRENT_PASSWORD_REQUIRED(HttpStatus.BAD_REQUEST, "AUTH-006", "비밀번호 변경 시 현재 비밀번호가 필요합니다.") ; diff --git a/src/main/java/com/cokerthon/Team3_Backend/domain/user/controller/UserController.java b/src/main/java/com/cokerthon/Team3_Backend/domain/user/controller/UserController.java new file mode 100644 index 0000000..695943a --- /dev/null +++ b/src/main/java/com/cokerthon/Team3_Backend/domain/user/controller/UserController.java @@ -0,0 +1,64 @@ +package com.cokerthon.Team3_Backend.domain.user.controller; + +import com.cokerthon.Team3_Backend.domain.user.dto.request.UserUpdateRequest; +import com.cokerthon.Team3_Backend.domain.user.dto.response.UserProfileResponse; +import com.cokerthon.Team3_Backend.domain.user.dto.response.UserUpdateResponse; +import com.cokerthon.Team3_Backend.domain.user.entity.User; +import com.cokerthon.Team3_Backend.domain.user.service.UserService; +import com.cokerthon.Team3_Backend.global.apiPayload.ApiResponse; +import com.cokerthon.Team3_Backend.global.apiPayload.code.GeneralSuccessCode; +import com.cokerthon.Team3_Backend.global.security.CurrentUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/users") +@Tag(name = "User API", description = "유저 관련 API (프로필 조회, 수정, 아이디/닉네임 중복 확인)") +public class UserController { + + private final UserService userService; + + @PatchMapping("/me") + @Operation(summary = "개인정보 수정 API", description = "로그인한 사용자의 닉네임과 비밀번호를 수정합니다. 변경하지 않는 필드만 포함해 요청을 보냅니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "현재 비밀번호 불일치"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 존재하는 닉네임") + }) + public ApiResponse updateProfile( + @RequestBody @Valid UserUpdateRequest request, + @CurrentUser User user + ) { + UserUpdateResponse response = userService.updateUser(user, request); + return ApiResponse.onSuccess(GeneralSuccessCode.OK, response); + } + + @GetMapping("/me") + @Operation(summary = "개인정보 조회 API", description = "마이페이지 개인정보 수정 페이지에서 로그인한 사용자의 아이디와 닉네임을 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공") + }) + public ApiResponse getProfile(@CurrentUser User user) { + UserProfileResponse response = userService.getProfile(user); + return ApiResponse.onSuccess(GeneralSuccessCode.OK, response); + } + + @GetMapping("/check-nickname") + @Operation(summary = "닉네임 중복 확인 API", description = "닉네임 중복 여부를 확인합니다. (true: 중복, false: 사용가능)") + public ApiResponse checkNickname(@RequestParam String nickname) { + boolean isDuplicate = userService.checkNicknameDuplicate(nickname); + return ApiResponse.onSuccess(GeneralSuccessCode.OK, isDuplicate); + } + + @GetMapping("/check-login-id") + @Operation(summary = "아이디 중복 확인 API", description = "아이디 중복 여부를 확인합니다. (true: 중복, false: 사용가능)") + public ApiResponse checkLoginId(@RequestParam String loginId) { + boolean isDuplicate = userService.checkLoginIdDuplicate(loginId); + return ApiResponse.onSuccess(GeneralSuccessCode.OK, isDuplicate); + } +} \ No newline at end of file diff --git a/src/main/java/com/cokerthon/Team3_Backend/domain/user/dto/request/UserUpdateRequest.java b/src/main/java/com/cokerthon/Team3_Backend/domain/user/dto/request/UserUpdateRequest.java new file mode 100644 index 0000000..555384a --- /dev/null +++ b/src/main/java/com/cokerthon/Team3_Backend/domain/user/dto/request/UserUpdateRequest.java @@ -0,0 +1,19 @@ +package com.cokerthon.Team3_Backend.domain.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record UserUpdateRequest( + + @Schema(description = "변경할 닉네임 (변경하지 않을 경우 기존 닉네임으로 요청)", example = "달빛산책자") + String nickname, + + @Schema(description = "현재 비밀번호", example = "password") + String currentPassword, + + @Schema(description = "새 비밀번호", example = "newPassword") + String newPassword, + + @Schema(description = "새 비밀번호 확인", example = "newPassword") + String confirmNewPassword +) { +} diff --git a/src/main/java/com/cokerthon/Team3_Backend/domain/user/dto/response/UserProfileResponse.java b/src/main/java/com/cokerthon/Team3_Backend/domain/user/dto/response/UserProfileResponse.java new file mode 100644 index 0000000..2edd75b --- /dev/null +++ b/src/main/java/com/cokerthon/Team3_Backend/domain/user/dto/response/UserProfileResponse.java @@ -0,0 +1,20 @@ +package com.cokerthon.Team3_Backend.domain.user.dto.response; + +import com.cokerthon.Team3_Backend.domain.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; + +public record UserProfileResponse( + + @Schema(description = "로그인 ID", example = "user123") + String loginId, + + @Schema(description = "닉네임", example = "달빛 산책자") + String nickname +) { + public static UserProfileResponse from(User user) { + return new UserProfileResponse( + user.getLoginId(), + user.getNickname() + ); + } +} diff --git a/src/main/java/com/cokerthon/Team3_Backend/domain/user/dto/response/UserUpdateResponse.java b/src/main/java/com/cokerthon/Team3_Backend/domain/user/dto/response/UserUpdateResponse.java new file mode 100644 index 0000000..1adc17a --- /dev/null +++ b/src/main/java/com/cokerthon/Team3_Backend/domain/user/dto/response/UserUpdateResponse.java @@ -0,0 +1,24 @@ +package com.cokerthon.Team3_Backend.domain.user.dto.response; + +import com.cokerthon.Team3_Backend.domain.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; + +public record UserUpdateResponse( + + @Schema(description = "유저 ID", example = "1") + Long id, + + @Schema(description = "로그인 ID", example = "user123") + String loginId, + + @Schema(description = "닉네임", example = "달빛 산책자") + String nickname +) { + public static UserUpdateResponse from(User user) { + return new UserUpdateResponse( + user.getId(), + user.getLoginId(), + user.getNickname() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/cokerthon/Team3_Backend/domain/user/entity/User.java b/src/main/java/com/cokerthon/Team3_Backend/domain/user/entity/User.java index f9d2f02..fc2e3e4 100644 --- a/src/main/java/com/cokerthon/Team3_Backend/domain/user/entity/User.java +++ b/src/main/java/com/cokerthon/Team3_Backend/domain/user/entity/User.java @@ -33,4 +33,12 @@ public class User extends BaseEntity { /** 닉네임 */ @Column(nullable = false, length = 256, unique = true) private String nickname; + + public void updateNickname(String nickname) { + this.nickname = nickname; + } + + public void updatePassword(String password) { + this.password = password; + } } diff --git a/src/main/java/com/cokerthon/Team3_Backend/domain/user/repository/UserRepository.java b/src/main/java/com/cokerthon/Team3_Backend/domain/user/repository/UserRepository.java index 883bc2d..b33c496 100644 --- a/src/main/java/com/cokerthon/Team3_Backend/domain/user/repository/UserRepository.java +++ b/src/main/java/com/cokerthon/Team3_Backend/domain/user/repository/UserRepository.java @@ -9,4 +9,9 @@ public interface UserRepository extends JpaRepository { Optional findByLoginId(String loginId); - Optional findByNickname(String nickname); } + Optional findByNickname(String nickname); + + boolean existsByNickname(String nickname); + + boolean existsByLoginId(String loginId); +} diff --git a/src/main/java/com/cokerthon/Team3_Backend/domain/user/service/UserService.java b/src/main/java/com/cokerthon/Team3_Backend/domain/user/service/UserService.java new file mode 100644 index 0000000..66ec70b --- /dev/null +++ b/src/main/java/com/cokerthon/Team3_Backend/domain/user/service/UserService.java @@ -0,0 +1,76 @@ +package com.cokerthon.Team3_Backend.domain.user.service; + +import com.cokerthon.Team3_Backend.domain.auth.exception.code.AuthErrorCode; +import com.cokerthon.Team3_Backend.domain.auth.exception.AuthException; +import com.cokerthon.Team3_Backend.domain.user.dto.request.UserUpdateRequest; +import com.cokerthon.Team3_Backend.domain.user.dto.response.UserProfileResponse; +import com.cokerthon.Team3_Backend.domain.user.dto.response.UserUpdateResponse; +import com.cokerthon.Team3_Backend.domain.user.entity.User; +import com.cokerthon.Team3_Backend.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public UserUpdateResponse updateUser(User user, UserUpdateRequest request) { + + User managedUser = userRepository.findById(user.getId()) + .orElseThrow(() -> new AuthException(AuthErrorCode.NOT_FOUND)); + + // 닉네임 변경 + if (request.nickname() != null && !request.nickname().trim().isEmpty() + && !request.nickname().trim().equals(managedUser.getNickname())) { + // 닉네임 중복 체크 + if (userRepository.findByNickname(request.nickname().trim()).isPresent()) { + throw new AuthException(AuthErrorCode.DUPLICATE_NICKNAME); + } + managedUser.updateNickname(request.nickname().trim()); + } + + // 비밀번호 변경 + if (request.newPassword() != null && !request.newPassword().isEmpty()) { + // 현재 비밀번호 확인 (비밀번호 변경 시 필수) + if (request.currentPassword() == null || request.currentPassword().isEmpty()) { + throw new AuthException(AuthErrorCode.CURRENT_PASSWORD_REQUIRED); + } + if (!passwordEncoder.matches(request.currentPassword(), managedUser.getPassword())) { + throw new AuthException(AuthErrorCode.UNAUTHORIZED); + } + + // 새 비밀번호와 확인 비밀번호 일치 확인 + if (!request.newPassword().equals(request.confirmNewPassword())) { + throw new AuthException(AuthErrorCode.PASSWORD_MISMATCH); + } + + String encodedPassword = passwordEncoder.encode(request.newPassword()); + managedUser.updatePassword(encodedPassword); + } + + return UserUpdateResponse.from(managedUser); + } + + public UserProfileResponse getProfile(User user) { + // DB에서 최신 정보 조회 + User managedUser = userRepository.findById(user.getId()) + .orElseThrow(() -> new AuthException(AuthErrorCode.NOT_FOUND)); + return UserProfileResponse.from(managedUser); + } + + public boolean checkNicknameDuplicate(String nickname) { + return userRepository.existsByNickname(nickname); + } + + public boolean checkLoginIdDuplicate(String loginId) { + return userRepository.existsByLoginId(loginId); + } + +} \ No newline at end of file diff --git a/src/main/java/com/cokerthon/Team3_Backend/global/config/SecurityConfig.java b/src/main/java/com/cokerthon/Team3_Backend/global/config/SecurityConfig.java index 4b8ac5e..3c74732 100644 --- a/src/main/java/com/cokerthon/Team3_Backend/global/config/SecurityConfig.java +++ b/src/main/java/com/cokerthon/Team3_Backend/global/config/SecurityConfig.java @@ -33,6 +33,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/auth/signup").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/api/users/check-nickname", "/api/users/check-login-id").permitAll() //.anyRequest().permitAll() .anyRequest().authenticated() )