-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat[ 마이페이지 기능 #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Feat[ 마이페이지 기능 #12
Changes from 5 commits
dd9851a
558b89a
9457191
cf3fd82
9e4b0ef
aaabe66
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.WhoIsRoom.WhoIs_Server.domain.club.repository; | ||
|
|
||
| import com.WhoIsRoom.WhoIs_Server.domain.club.model.Club; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface ClubRepository extends JpaRepository<Club, Long> { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.WhoIsRoom.WhoIs_Server.domain.member.repository; | ||
|
|
||
| import com.WhoIsRoom.WhoIs_Server.domain.member.model.Member; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import org.springframework.data.jpa.repository.Modifying; | ||
| import org.springframework.data.jpa.repository.Query; | ||
| import org.springframework.data.repository.query.Param; | ||
|
|
||
| import java.util.Collection; | ||
| import java.util.List; | ||
|
|
||
| public interface MemberRepository extends JpaRepository<Member, Long> { | ||
| List<Member> findByUserId(Long userId); | ||
| // 현재 유저가 속한 clubId 목록만 빠르게 가져오기 | ||
| @Query("select m.club.id from Member m where m.user.id = :userId") | ||
| List<Long> findClubIdsByUserId(@Param("userId") Long userId); | ||
|
|
||
| @Modifying(clearAutomatically = true, flushAutomatically = true) | ||
| @Query("delete from Member m where m.user.id = :userId and m.club.id in :clubIds") | ||
| void deleteByUserIdAndClubIdIn(@Param("userId") Long userId, @Param("clubIds") Collection<Long> clubIds); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.WhoIsRoom.WhoIs_Server.domain.user.dto.request; | ||
|
|
||
| import lombok.*; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @Builder | ||
| @AllArgsConstructor(access = AccessLevel.PRIVATE) | ||
| public class MyPageUpdateRequest { | ||
| String nickName; | ||
| List<Long> clubList; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.WhoIsRoom.WhoIs_Server.domain.user.dto.response; | ||
|
|
||
| import com.WhoIsRoom.WhoIs_Server.domain.club.model.Club; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| @Builder | ||
| public class ClubResponse { | ||
| private Long id; | ||
| private String name; | ||
|
|
||
| public static ClubResponse from(Club club) { | ||
| return ClubResponse.builder() | ||
| .id(club.getId()) | ||
| .name(club.getName()) | ||
| .build(); | ||
| } | ||
|
|
||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package com.WhoIsRoom.WhoIs_Server.domain.user.dto.response; | ||
|
|
||
| import com.WhoIsRoom.WhoIs_Server.domain.member.model.Member; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Getter | ||
| @Builder | ||
| public class MyPageResponse { | ||
| private String nickName; | ||
| private List<ClubResponse> clubList; | ||
|
|
||
| public static MyPageResponse from(String nickname, List<Member> memberList) { | ||
|
|
||
| List<ClubResponse> clubList = memberList.stream() | ||
| .map(Member::getClub) | ||
| .distinct() | ||
| .map(ClubResponse::from) | ||
| .toList(); | ||
|
|
||
| return MyPageResponse.builder() | ||
| .nickName(nickname) | ||
| .clubList(clubList) | ||
| .build(); | ||
| } | ||
|
Comment on lines
15
to
27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainClub 엔티티의 equals/hashCode 구현 확인 필요 Line 19에서 다음 스크립트로 🏁 Script executed: #!/bin/bash
# Description: Check if Club entity implements equals/hashCode
# Search for Club class definition and equals/hashCode methods
ast-grep --pattern $'class Club {
$$$
}'
echo "---"
echo "Checking for equals/hashCode methods in Club:"
rg -A 5 "public boolean equals|public int hashCode" --type java -g "**/Club.java"Length of output: 49 #!/bin/bash Club 엔티티 equals()/hashCode() 구현 여부 재확인Club.java 파일 경로 확인club_file=$(rg -l "class Club\b" -t java) 클래스 선언부 출력echo "--- 클래스 선언부 ---" equals()/hashCode() 메서드 구현 여부 확인echo "--- equals()/hashCode() 메서드 ---" 🏁 Script executed: #!/bin/bash
# Club.java 파일 경로 찾기
club_file=$(find src -type f -name 'Club.java' | head -n 1)
if [ -z "$club_file" ]; then
echo "Club.java 파일을 찾을 수 없습니다."
exit 0
fi
echo "Club.java 경로: $club_file"
# 클래스 선언부 출력
echo "--- 클래스 선언부 ---"
rg -n "class Club\b" "$club_file"
# equals()/hashCode() 메서드 구현 여부 확인
echo "--- equals()/hashCode() 구현 확인 ---"
rg -nP "^(public|protected|private)\s+(boolean\s+equals|int\s+hashCode)\s*\(" -C2 "$club_file"Length of output: 174 Club 엔티티가 BaseEntity로부터 equals()/hashCode()를 상속받는지 확인하기 위해 BaseEntity.java를 검사해주세요. 아래 스크립트를 실행해주세요. 🏁 Script executed: #!/bin/bash
# BaseEntity.java 파일 경로 찾기
base_file=$(find src -type f -name 'BaseEntity.java' | head -n 1)
if [ -z "$base_file" ]; then
echo "BaseEntity.java 파일을 찾을 수 없습니다."
exit 0
fi
echo "BaseEntity.java 경로: $base_file"
# 클래스 선언부
echo "--- 클래스 선언부 ---"
rg -n "class BaseEntity\b" "$base_file"
# Lombok @EqualsAndHashCode 어노테이션 확인
echo "--- Lombok EqualsAndHashCode 어노테이션 확인 ---"
rg -n "@EqualsAndHashCode" "$base_file"
# equals()/hashCode() 메서드 구현 여부
echo "--- equals()/hashCode() 구현 여부 확인 ---"
rg -nP "^(public|protected|private)\s+(boolean\s+equals|int\s+hashCode)\s*\(" -C2 "$base_file"Length of output: 229 Club 엔티티에 equals()/hashCode() 오버라이드 필요 🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,13 @@ | |
| import com.WhoIsRoom.WhoIs_Server.domain.auth.dto.request.MailRequest; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.auth.dto.request.PasswordRequest; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.auth.service.MailService; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.club.model.Club; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.club.repository.ClubRepository; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.member.model.Member; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.member.repository.MemberRepository; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.user.dto.request.MyPageUpdateRequest; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.user.dto.request.SignupRequest; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.user.dto.response.MyPageResponse; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.user.model.Role; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.user.model.User; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.user.repository.UserRepository; | ||
|
|
@@ -15,13 +21,21 @@ | |
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.LinkedHashSet; | ||
| import java.util.List; | ||
| import java.util.Objects; | ||
| import java.util.Set; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class UserService { | ||
| private final UserRepository userRepository; | ||
| private final PasswordEncoder passwordEncoder; | ||
| private final MailService mailService; | ||
| private final MemberRepository memberRepository; | ||
| private final ClubRepository clubRepository; | ||
|
|
||
| @Transactional | ||
| public void signUp(SignupRequest request) { | ||
|
|
@@ -62,4 +76,89 @@ public void updateMyPassword(Long userId, PasswordRequest request) { | |
| } | ||
| user.setPassword(passwordEncoder.encode(request.getNewPassword())); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public MyPageResponse getMyPage(Long userId) { | ||
| User user = userRepository.findById(userId) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); | ||
|
|
||
| List<Member> memberList = memberRepository.findByUserId(userId); | ||
| return MyPageResponse.from(user.getNickName(), memberList); | ||
| } | ||
|
|
||
| @Transactional | ||
| public MyPageResponse updateMyPage(Long userId, MyPageUpdateRequest request) { | ||
|
|
||
| User user = userRepository.findById(userId) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); | ||
|
|
||
| updateUserNickName(user, request.getNickName()); | ||
|
|
||
| updateUserClubs(user, request.getClubList()); | ||
|
|
||
| List<Member> updatedMemberList = memberRepository.findByUserId(userId); | ||
| return MyPageResponse.from(user.getNickName(), updatedMemberList); | ||
| } | ||
|
|
||
| private void updateUserNickName(User user, String newNickName) { | ||
|
|
||
| // 변경 사항이 없으면 아무것도 하지 않음 (최적화) | ||
| if (user.getNickName().equals(newNickName)) { | ||
| return; | ||
| } | ||
|
|
||
| // 닉네임 중복 검사 (자기 자신은 제외되므로 안전함) | ||
| if (userRepository.existsByNickName(newNickName)) { | ||
| throw new BusinessException(ErrorCode.USER_DUPLICATE_NICKNAME); | ||
| } | ||
|
|
||
| user.setNickName(newNickName); | ||
| } | ||
|
Comment on lines
+103
to
+116
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major 닉네임 업데이트 로직에 null 안전성 검증이 필요합니다. Line 106에서 컨트롤러나 DTO 레벨에서 // MyPageUpdateRequest에서
@NotBlank(message = "닉네임은 필수입니다")
private String nickName;또는 서비스 메서드에서 명시적 null 체크: private void updateUserNickName(User user, String newNickName) {
+ if (newNickName == null || newNickName.trim().isEmpty()) {
+ throw new BusinessException(ErrorCode.INVALID_INPUT);
+ }
// 변경 사항이 없으면 아무것도 하지 않음 (최적화)
if (user.getNickName().equals(newNickName)) {
return;
} |
||
|
|
||
| private void updateUserClubs(User user, List<Long> newClubIdList) { | ||
|
|
||
| // null이면 빈 리스트로 간주 => 모두 탈퇴 처리 | ||
| Set<Long> requested = newClubIdList == null ? Set.of() | ||
| : newClubIdList.stream() | ||
| .filter(Objects::nonNull) | ||
| .collect(Collectors.toCollection(LinkedHashSet::new)); // 순서 유지 필요시 | ||
|
|
||
| Long userId = user.getId(); | ||
|
|
||
| // 현재 가입된 clubId 목록 | ||
| Set<Long> current = new LinkedHashSet<>(memberRepository.findClubIdsByUserId(userId)); | ||
|
|
||
| // 계산: 추가/삭제 집합 | ||
| Set<Long> toAdd = new LinkedHashSet<>(requested); | ||
| toAdd.removeAll(current); | ||
|
|
||
| Set<Long> toRemove = new LinkedHashSet<>(current); | ||
| toRemove.removeAll(requested); | ||
|
|
||
| // 삭제 먼저 (없으면 no-op) | ||
| if (!toRemove.isEmpty()) { | ||
| memberRepository.deleteByUserIdAndClubIdIn(userId, toRemove); | ||
| } | ||
|
|
||
| // 추가할 Club의 존재성 검증 | ||
| if (!toAdd.isEmpty()) { | ||
| List<Club> clubs = clubRepository.findAllById(toAdd); | ||
|
|
||
| if (clubs.size() != toAdd.size()) { | ||
| // 어떤 ID는 존재X | ||
| throw new BusinessException(ErrorCode.CLUB_NOT_FOUND); | ||
| } | ||
|
|
||
| // Member 엔티티 생성 | ||
| List<Member> newMembers = clubs.stream() | ||
| .map(club -> Member.builder() | ||
| .user(user) | ||
| .club(club) | ||
| .build()) | ||
| .toList(); | ||
|
|
||
| // 저장 (유니크 제약 (user_id, club_id) 있어도 toAdd는 중복이 아님) | ||
| memberRepository.saveAll(newMembers); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,6 @@ | ||||||||||||||||||
| package com.WhoIsRoom.WhoIs_Server.global.common.resolver; | ||||||||||||||||||
|
|
||||||||||||||||||
| import com.WhoIsRoom.WhoIs_Server.domain.auth.model.UserPrincipal; | ||||||||||||||||||
| import com.WhoIsRoom.WhoIs_Server.domain.user.repository.UserRepository; | ||||||||||||||||||
| import com.WhoIsRoom.WhoIs_Server.global.common.exception.BusinessException; | ||||||||||||||||||
| import com.WhoIsRoom.WhoIs_Server.global.common.response.ErrorCode; | ||||||||||||||||||
|
|
@@ -29,9 +30,7 @@ public Object resolveArgument(MethodParameter parameter, | |||||||||||||||||
| NativeWebRequest webRequest, | ||||||||||||||||||
| WebDataBinderFactory binderFactory) throws Exception { | ||||||||||||||||||
|
|
||||||||||||||||||
| String email = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); | ||||||||||||||||||
| return userRepository.findByEmail(email) | ||||||||||||||||||
| .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)) | ||||||||||||||||||
| .getId(); | ||||||||||||||||||
| UserPrincipal principal = (UserPrincipal) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); | ||||||||||||||||||
| return principal.getUserId(); | ||||||||||||||||||
|
Comment on lines
+33
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 인증 객체와 타입 검증이 누락되었습니다.
다음과 같이 안전하게 처리하는 것을 권장합니다: - UserPrincipal principal = (UserPrincipal) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
- return principal.getUserId();
+ var authentication = SecurityContextHolder.getContext().getAuthentication();
+ if (authentication == null || !(authentication.getPrincipal() instanceof UserPrincipal)) {
+ throw new BusinessException(ErrorCode.UNAUTHORIZED);
+ }
+ UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
+ return principal.getUserId();📝 Committable suggestion
Suggested change
|
||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
비밀번호 찾기 엔드포인트의 보안 흐름을 재검토해주세요.
현재 구현에서 몇 가지 보안 우려사항이 있습니다:
Rate limiting 부재: 공격자가 무작위 이메일로 반복 요청해서 계정 존재 여부를 확인(account enumeration)하거나 메일 폭탄을 날릴 수 있습니다.
인증 없이 즉시 비밀번호 변경: 일반적인 비밀번호 재설정 플로우는 (1) 이메일로 재설정 링크 발송 → (2) 링크 클릭 후 본인 인증 → (3) 새 비밀번호 설정 순서인데, 현재는 바로 임시 비밀번호를 생성해서 DB를 업데이트하고 있네요. 만약 공격자가 다른 사람의 이메일 주소를 알고 있다면, 실제 사용자의 비밀번호를 강제로 변경시킬 수 있습니다.
개선 방안:
/email/validation플로우를 먼저 거치도록 강제🤖 Prompt for AI Agents