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
47 changes: 47 additions & 0 deletions src/main/java/com/back/domain/user/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.back.domain.user.controller;

import com.back.domain.user.dto.UserRegisterRequest;
import com.back.domain.user.dto.UserResponse;
import com.back.domain.user.service.UserService;
import com.back.global.common.dto.RsData;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final UserService userService;

@PostMapping("/register")
@Operation(
summary = "회원가입",
description = "신규 사용자를 등록합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "201", description = "회원가입 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청 / 비밀번호 정책 위반"),
@ApiResponse(responseCode = "409", description = "중복된 아이디/이메일/닉네임"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
public ResponseEntity<RsData<UserResponse>> register(
@Valid @RequestBody UserRegisterRequest request
) {
UserResponse response = userService.register(request);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(RsData.success(
"회원가입이 성공적으로 완료되었습니다. 이메일 인증을 완료해주세요.",
response
));
}
}
19 changes: 19 additions & 0 deletions src/main/java/com/back/domain/user/dto/UserRegisterRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.back.domain.user.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

/**
* 사용자 회원 가입 요청을 나타내는 DTO
*
* @param username 사용자의 로그인 id
* @param email 사용자의 이메일 주소
* @param password 사용자의 비밀번호
* @param nickname 사용자의 별명
*/
public record UserRegisterRequest(
@NotBlank String username,
@NotBlank @Email String email,
@NotBlank String password,
@NotBlank String nickname
) {}
41 changes: 41 additions & 0 deletions src/main/java/com/back/domain/user/dto/UserResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.back.domain.user.dto;

import com.back.domain.user.entity.Role;
import com.back.domain.user.entity.User;
import com.back.domain.user.entity.UserProfile;
import com.back.domain.user.entity.UserStatus;

import java.time.LocalDateTime;

/**
* 사용자 응답을 나타내는 DTO
*
* @param userId 사용자의 고유 ID
* @param username 사용자의 로그인 id
* @param email 사용자의 이메일 주소
* @param nickname 사용자의 별명
* @param role 사용자의 역할 (예: USER, ADMIN)
* @param status 사용자의 상태 (예: ACTIVE, PENDING)
* @param createdAt 사용자가 생성된 날짜 및 시간
*/
public record UserResponse(
Long userId,
String username,
String email,
String nickname,
Role role,
UserStatus status,
LocalDateTime createdAt
) {
public static UserResponse from(User user, UserProfile profile) {
return new UserResponse(
user.getId(),
user.getUsername(),
user.getEmail(),
profile.getNickname(),
user.getRole(),
user.getUserStatus(),
user.getCreatedAt()
);
}
}
43 changes: 32 additions & 11 deletions src/main/java/com/back/domain/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ public class User extends BaseEntity {
@Enumerated(EnumType.STRING)
private UserStatus userStatus;

@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<UserProfile> userProfiles = new ArrayList<>();
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private UserProfile userProfile;

@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<UserToken> userTokens = new ArrayList<>();
Expand Down Expand Up @@ -81,21 +81,42 @@ public class User extends BaseEntity {
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<FileAttachment> fileAttachments = new ArrayList<>();

// -------------------- 생성자 --------------------
public User(String username, String email, String password, Role role, UserStatus userStatus) {
this.username = username;
this.email = email;
this.password = password;
this.role = role;
this.userStatus = userStatus;
}

// -------------------- 정적 팩토리 메서드 --------------------
// 일반 사용자 생성
public static User createUser(String username, String email, String password) {
return new User(username, email, password, Role.USER, UserStatus.PENDING);
}

// 관리자 사용자 생성
public static User createAdmin(String username, String email, String password) {
return new User(username, email, password, Role.ADMIN, UserStatus.ACTIVE);
}

// -------------------- 연관관계 메서드 --------------------
public void setUserProfile(UserProfile profile) {
this.userProfile = profile;
profile.setUser(this);
}

// -------------------- 헬퍼 메서드 --------------------
// 현재 사용자의 닉네임 조회
public String getNickname() {
return userProfiles.stream()
.findFirst()
.map(UserProfile::getNickname)
.filter(nickname -> nickname != null && !nickname.trim().isEmpty())
.orElse(this.username);
return userProfile != null && userProfile.getNickname() != null && !userProfile.getNickname().trim().isEmpty()
? userProfile.getNickname()
: this.username;
}

// 현재 사용자의 프로필 이미지 URL 조회
public String getProfileImageUrl() {
return userProfiles.stream()
.findFirst()
.map(UserProfile::getProfileImageUrl)
.orElse(null);
return userProfile != null ? userProfile.getProfileImageUrl() : null;
}
}
6 changes: 5 additions & 1 deletion src/main/java/com/back/domain/user/entity/UserProfile.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@

import com.back.global.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.LocalDateTime;

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserProfile extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.back.domain.user.repository;

import com.back.domain.user.entity.UserProfile;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserProfileRepository extends JpaRepository<UserProfile, Long> {
boolean existsByNickname(String nickname);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByUsername(String username);
boolean existsByEmail(String email);
Optional<User> findByUsername(String username);
}
93 changes: 93 additions & 0 deletions src/main/java/com/back/domain/user/service/UserService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.back.domain.user.service;

import com.back.domain.user.dto.UserRegisterRequest;
import com.back.domain.user.dto.UserResponse;
import com.back.domain.user.entity.User;
import com.back.domain.user.entity.UserProfile;
import com.back.domain.user.repository.UserProfileRepository;
import com.back.domain.user.repository.UserRepository;
import com.back.global.exception.CustomException;
import com.back.global.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class UserService {
private final UserRepository userRepository;
private final UserProfileRepository userProfileRepository;
private final PasswordEncoder passwordEncoder;

/**
* 회원가입 서비스
* 1. 중복 검사 (username, email, nickname)
* 2. 비밀번호 정책 검증
* 3. User + UserProfile 생성 및 연관관계 설정
* 4. 저장 후 UserResponse 변환
*/
public UserResponse register(UserRegisterRequest request) {

// 중복 검사 (username, email, nickname)
validateDuplicate(request);

// 비밀번호 정책 검증
validatePasswordPolicy(request.password());

// User 엔티티 생성 (기본 Role.USER, Status.PENDING)
User user = User.createUser(
request.username(),
request.email(),
passwordEncoder.encode(request.password())
);

// UserProfile 엔티티 생성
UserProfile profile = new UserProfile(
user,
request.nickname(),
null,
null,
null,
0
);

// 연관관계 설정
user.setUserProfile(profile);

// 저장 (cascade로 Profile도 함께 저장됨)
User saved = userRepository.save(user);

// UserResponse 변환 및 반환
return UserResponse.from(saved, profile);
}

/**
* 회원가입 시 중복 검증
* - username, email, nickname
*/
private void validateDuplicate(UserRegisterRequest request) {
if (userRepository.existsByUsername(request.username())) {
throw new CustomException(ErrorCode.USERNAME_DUPLICATED);
}
if (userRepository.existsByEmail(request.email())) {
throw new CustomException(ErrorCode.EMAIL_DUPLICATED);
}
if (userProfileRepository.existsByNickname(request.nickname())) {
throw new CustomException(ErrorCode.NICKNAME_DUPLICATED);
}
}

/**
* 비밀번호 정책 검증
* - 최소 8자 이상
* - 숫자 및 특수문자 반드시 포함
*/
private void validatePasswordPolicy(String password) {
String regex = "^(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$";
if (!password.matches(regex)) {
throw new CustomException(ErrorCode.INVALID_PASSWORD);
}
}
}
4 changes: 4 additions & 0 deletions src/main/java/com/back/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ public enum ErrorCode {

// ======================== 사용자 관련 ========================
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_001", "존재하지 않는 사용자입니다."),
USERNAME_DUPLICATED(HttpStatus.CONFLICT, "USER_002", "이미 사용 중인 아이디입니다."),
EMAIL_DUPLICATED(HttpStatus.CONFLICT, "USER_003", "이미 사용 중인 이메일입니다."),
NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "USER_004", "이미 사용 중인 닉네임입니다."),
INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "USER_005", "비밀번호는 최소 8자 이상, 숫자/특수문자를 포함해야 합니다."),

// ======================== 스터디룸 관련 ========================
ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "ROOM_001", "존재하지 않는 방입니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.back.global.exception;

import com.back.global.common.dto.RsData;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

Expand All @@ -17,4 +19,11 @@ public ResponseEntity<RsData<Void>> handleCustomException(
.status(errorCode.getStatus())
.body(RsData.fail(errorCode));
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<RsData<Void>> handleValidationException(MethodArgumentNotValidException ex) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(RsData.fail(ErrorCode.BAD_REQUEST));
}
}
Loading
Loading