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

import com.mr.domain.user.exception.InstrumentErrorStatus;
import com.mr.global.apipayload.exception.GeneralException;
import com.mr.global.entity.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@Table(name = "instrument")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Instrument extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "instrument_id")
private Long instrumentId;

@Column(name = "code", nullable = false, unique = true, length = 20)
private String code;

@Column(name = "name", nullable = false, length = 50)
private String name;

@Column(name = "is_active", nullable = false)
private boolean active;

@Builder(access = AccessLevel.PRIVATE)
private Instrument(String code, String name, boolean active) {
validateCode(code);
validateName(name);
this.code = code;
this.name = name;
this.active = active;
}

public static Instrument create(String code, String name) {
return Instrument.builder()
.code(code)
.name(name)
.active(true)
.build();
}

private static void validateCode(String code) {
if (code == null) {
throw new GeneralException(InstrumentErrorStatus.CODE_REQUIRED);
}
}

private static void validateName(String name) {
if (name == null) {
throw new GeneralException(InstrumentErrorStatus.NAME_REQUIRED);
}
}
}
73 changes: 73 additions & 0 deletions src/main/java/com/mr/domain/user/entity/Student.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.mr.domain.user.entity;

import com.mr.domain.user.entity.enums.TheoryLevel;
import com.mr.domain.user.exception.StudentErrorStatus;
import com.mr.global.apipayload.exception.GeneralException;
import com.mr.global.entity.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@Table(name = "student")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Student extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "student_id")
private Long studentId;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false, unique = true)
private User user;

@Enumerated(EnumType.STRING)
@Column(name = "theory_level", nullable = false, length = 20)
private TheoryLevel theoryLevel;

@Builder(access = AccessLevel.PRIVATE)
private Student(User user, TheoryLevel theoryLevel) {
validateUser(user);
validateTheoryLevel(theoryLevel);
this.user = user;
this.theoryLevel = theoryLevel;
}

public static Student create(User user, TheoryLevel theoryLevel) {
return Student.builder()
.user(user)
.theoryLevel(theoryLevel)
.build();
}
Comment thread
ownue marked this conversation as resolved.

public void updateTheoryLevel(TheoryLevel theoryLevel) {
validateTheoryLevel(theoryLevel);
this.theoryLevel = theoryLevel;
}

private static void validateUser(User user) {
if (user == null) {
throw new GeneralException(StudentErrorStatus.USER_REQUIRED);
}
}

private static void validateTheoryLevel(TheoryLevel theoryLevel) {
if (theoryLevel == null) {
throw new GeneralException(StudentErrorStatus.THEORY_LEVEL_REQUIRED);
}
}
}
93 changes: 93 additions & 0 deletions src/main/java/com/mr/domain/user/entity/StudentInstrument.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.mr.domain.user.entity;

import com.mr.domain.user.exception.StudentInstrumentErrorStatus;
import com.mr.global.apipayload.exception.GeneralException;
import com.mr.global.entity.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@Table(
name = "student_instrument",
uniqueConstraints = {
@UniqueConstraint(name = "uk_student_instrument_student_id_instrument_id",
columnNames = {"student_id", "instrument_id"})
}
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StudentInstrument extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "student_instrument_id")
private Long studentInstrumentId;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "student_id", nullable = false)
private Student student;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "instrument_id", nullable = false)
private Instrument instrument;

@Column(name = "is_primary", nullable = false)
private boolean primary;

@Builder(access = AccessLevel.PRIVATE)
private StudentInstrument(Student student, Instrument instrument, boolean primary) {
validateStudent(student);
validateInstrument(instrument);
this.student = student;
this.instrument = instrument;
this.primary = primary;
}

public static StudentInstrument createPrimary(Student student, Instrument instrument) {
return StudentInstrument.builder()
.student(student)
.instrument(instrument)
.primary(true)
.build();
}

public static StudentInstrument createSecondary(Student student, Instrument instrument) {
return StudentInstrument.builder()
.student(student)
.instrument(instrument)
.primary(false)
.build();
}

public void markAsPrimary() {
this.primary = true;
}

public void unmarkAsPrimary() {
this.primary = false;
}

private static void validateStudent(Student student) {
if (student == null) {
throw new GeneralException(StudentInstrumentErrorStatus.STUDENT_REQUIRED);
}
}

private static void validateInstrument(Instrument instrument) {
if (instrument == null) {
throw new GeneralException(StudentInstrumentErrorStatus.INSTRUMENT_REQUIRED);
}
}
}
75 changes: 75 additions & 0 deletions src/main/java/com/mr/domain/user/entity/User.java

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

닉네임 유효성 검증 로직함수 만드시는게 좋을 것 같습니다!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예외처리도 해주세요

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateNickname() 호출 시 값을 바로 저장하지 않고 validateNickname()이라는 검증 함수를 먼저 거치도록 수정했습니다. 닉네임이 비어있거나 공백, 10자 초과 시 예외 던지도록 만들었습니다~

public void updateNickname(String nickname) {
    validateNickname(nickname);
    this.nickname = nickname;
}

private static void validateNickname(String nickname) {
    if (nickname == null || nickname.isBlank()) {
        throw new GeneralException(UserErrorStatus.NICKNAME_REQUIRED);
    }
    if (nickname.length() > NICKNAME_MAX_LENGTH) {
        throw new GeneralException(UserErrorStatus.NICKNAME_TOO_LONG);
    }
}

예외 타입은 IllegalArgumentException이 아니라 프로젝트에서 쓰고 있는 GeneralException과 UserErrorStatus를 사용했습니다. (이렇게 해야 GlobalExceptionHandler가 정확한 HTTP 상태코드와 메시지를 함께 응답해준대서 했어용...)
확인 부탁드립니다!!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IllegalArgumentException 대신 GeneralException 활용하신 것 좋습니다~!

다만 저희 대면 회의 때 정책으로 가능한 닉네임 양식 정해뒀었던 걸로 기억해요~! 지금은 null이나 빈 값을 방어하고, 최댓값을 방어하고 있는데, (1) 한글인 경우 자모음만 들어오는 것 방어 + (2) 한글/영어/숫자 외 문자 방어 + (3) 최소 글자수(2글자) 제한을 같이 검증하는 방안으로 수정해주실 수 있으신가요?!

회의록에서 긁어왔습니당...ㅎㅎ

  • 자음만 / 모음만 안 됨
  • 한글, 영어, 숫자만 가능 (특수문자 X, 공백 제외)
  • 2글자~10글자 사이 제한

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 반영했습니다!
min/max @Size에 추가했고 제가 빼먹은 회의록 규칙(자음/모음 단독 금지, 한글·영어·숫자만 허용, 2~10자)
정규식 하나로 같이 처리했습니다.

private static final Pattern NICKNAME_PATTERN =
        Pattern.compile("^[가-힣a-zA-Z0-9]{2,10}$");

한글 완성형(가-힣)만 허용해서 자음/모음 단독 입력은 유니코드 범위가 달라 자동으로 막히고
공백·특수문자도 같은 이유로 걸러집니다.
형식이 틀리면 UserErrorStatus.NICKNAME_INVALID_FORMAT로 통일된 메시지가 나갑니다.

Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.mr.domain.user.entity;

import com.mr.domain.user.exception.UserErrorStatus;
import com.mr.global.apipayload.exception.GeneralException;
import com.mr.global.entity.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.Size;
import java.util.regex.Pattern;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseTimeEntity {

private static final int NICKNAME_MIN_LENGTH = 2;
private static final int NICKNAME_MAX_LENGTH = 10;
private static final Pattern NICKNAME_PATTERN =
Pattern.compile("^[가-힣a-zA-Z0-9]{" + NICKNAME_MIN_LENGTH + "," + NICKNAME_MAX_LENGTH + "}$");

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long userId;

@Size(
min = NICKNAME_MIN_LENGTH,
max = NICKNAME_MAX_LENGTH,
message = "닉네임은 2자 이상 10자 이하로 입력해야 합니다."
)
@Column(name = "nickname", unique = true, length = NICKNAME_MAX_LENGTH)
private String nickname;

@Column(name = "profile_img_url", nullable = false, length = 255)
private String profileImgUrl;

@Builder(access = AccessLevel.PRIVATE)
private User(String profileImgUrl) {
this.profileImgUrl = profileImgUrl;
}

public static User createFromOAuth(String profileImgUrl) {
return User.builder()
.profileImgUrl(profileImgUrl)
.build();
}
Comment on lines +46 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Builder에서 profileImgUrl만 먼저 받는 이유는 OAuth 가입 단계와 닉네임 설정 단계가 분리되어 있기 때문으로 이해했는데, 맞을까요? 그렇다면 OAuth 로그인은 완료했지만 닉네임 설정을 마치지 않은 사용자가 일반 API를 호출하지 못하도록 온보딩 완료 여부를 구분할 필요가 있어 보입니다! 현재 닉네임 설정이 유일한 필수 온보딩 단계라면 아래처럼 nickname을 기준으로 완료 여부를 판단할 수 있을 것 같습니다.

public boolean isOnboardingCompleted() {
    return nickname != null;
}

다만 실제 검사를 인증/인가 로직, 필터 또는 각 API의 공통 사용자 조회 로직 중 어디에서 처리할지는 전체 로그인 흐름과 연관된 부분이라, 해당 로직을 추후 구현할 예정인지 궁금합니다. 이 부분은 인증 쪽 담당하시는 예원님과 함께 정책을 정해보면 좋을 것 같습니다! 백엔드 전체 회의 때 안건으로 올려도 좋을 것 같고...!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀하신 대로 isOnboardingCompleted()는 추가했습니다.

public boolean isOnboardingCompleted() {
    return nickname != null;
}

다만 이걸 필터/인터셉터에서 전역으로 막을지 각 API에서 개별 검사할지는
인증 흐름 전체(JWT 필터, Spring Security 설정)와 얽혀 있어서 이 PR 범위에선 결정하지 않았습니다...!
스레드 파서 다른 팀원분들 의견 듣고 결정되면 수정해보겠습니다!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니당!


public boolean isOnboardingCompleted() {
return nickname != null;
}

public void updateNickname(String nickname) {
String trimmedNickname = nickname == null ? null : nickname.trim();
validateNickname(trimmedNickname);
this.nickname = trimmedNickname;
}

private static void validateNickname(String nickname) {
if (nickname == null || nickname.isBlank()) {
throw new GeneralException(UserErrorStatus.NICKNAME_REQUIRED);
}
if (!NICKNAME_PATTERN.matcher(nickname).matches()) {
throw new GeneralException(UserErrorStatus.NICKNAME_INVALID_FORMAT);
}
}
Comment on lines +61 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재는 updateNickname()에만 validateNickname()을 거치고 있는데, 유저 생성 시 닉네임을 함께 전달받을 때를 고려해 생성자 내부 또는 닉네임 할당 시 항상 validateNickname()을 거치도록 처리하는 것은 어떨지 의견 여쭙습니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인해보니 현재 User는 @Builder(access = PRIVATE)로 막혀 있어서
nickname을 바꿀 수 있는 경로가 updateNickname() 하나뿐입니다 (생성자/빌더 자체가 nickname을 안 받음).
그래서 최초 설정이든 이후 프로필 수정이든 둘 다 이 메서드를 거치게 되어 있어 지금 구조에선 검증이 빠지는 경로가 없습니다!
혹시 나중에 nickname을 직접 받는 생성 경로가 추가되면 그때도 반드시 validateNickname()을 거치도록 하겠습니다!

}
Comment thread
ownue marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.mr.domain.user.entity.enums;

public enum TheoryLevel {
BEGINNER,
INTERMEDIATE,
ADVANCED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.mr.domain.user.exception;

import com.mr.global.apipayload.code.BaseCode;
import org.springframework.http.HttpStatus;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum InstrumentErrorStatus implements BaseCode {

CODE_REQUIRED(HttpStatus.BAD_REQUEST, "INSTRUMENT_400_01", "code는 필수입니다."),
NAME_REQUIRED(HttpStatus.BAD_REQUEST, "INSTRUMENT_400_02", "name은 필수입니다.");

private final HttpStatus status;
private final String code;
private final String message;
}
18 changes: 18 additions & 0 deletions src/main/java/com/mr/domain/user/exception/StudentErrorStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.mr.domain.user.exception;

import com.mr.global.apipayload.code.BaseCode;
import org.springframework.http.HttpStatus;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum StudentErrorStatus implements BaseCode {

USER_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_400_01", "user는 필수입니다."),
THEORY_LEVEL_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_400_02", "theoryLevel은 필수입니다.");

private final HttpStatus status;
private final String code;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.mr.domain.user.exception;

import com.mr.global.apipayload.code.BaseCode;
import org.springframework.http.HttpStatus;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum StudentInstrumentErrorStatus implements BaseCode {

STUDENT_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_INSTRUMENT_400_01", "student는 필수입니다."),
INSTRUMENT_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_INSTRUMENT_400_02", "instrument는 필수입니다.");

private final HttpStatus status;
private final String code;
private final String message;
}
18 changes: 18 additions & 0 deletions src/main/java/com/mr/domain/user/exception/UserErrorStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.mr.domain.user.exception;

import com.mr.global.apipayload.code.BaseCode;
import org.springframework.http.HttpStatus;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum UserErrorStatus implements BaseCode {

NICKNAME_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_01", "닉네임은 필수입니다."),
NICKNAME_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "USER_400_02", "닉네임은 한글, 영어, 숫자 2~10자로 입력해야 합니다.");

private final HttpStatus status;
private final String code;
private final String message;
}
Loading