diff --git a/src/main/java/com/example/spot/api/code/status/ErrorStatus.java b/src/main/java/com/example/spot/api/code/status/ErrorStatus.java index 6539787b..07b051d3 100644 --- a/src/main/java/com/example/spot/api/code/status/ErrorStatus.java +++ b/src/main/java/com/example/spot/api/code/status/ErrorStatus.java @@ -28,6 +28,7 @@ public enum ErrorStatus implements BaseErrorCode { _TERMS_NOT_AGREED(HttpStatus.FORBIDDEN, "COMMON4013", "이용 약관이 동의되지 않았습니다."), _MEMBER_EMAIL_EXIST(HttpStatus.BAD_REQUEST, "COMMON4014", "이미 가입 된 이메일입니다. 다른 로그인 방식을 이용해주세요."), _RSA_ERROR(HttpStatus.BAD_REQUEST, "COMMON4015", "RSA 에러가 발생했습니다."), + _NOT_ADMIN(HttpStatus.FORBIDDEN, "COMMON4016", "관리자 권한이 없습니다."), // 네이버 소셜 로그인 관련 에러 _NAVER_SIGN_IN_INTEGRATION_FAILED(HttpStatus.UNAUTHORIZED, "NAVER4001", "네이버 로그인 연동에 실패하였습니다."), @@ -63,8 +64,10 @@ public enum ErrorStatus implements BaseErrorCode { _STUDY_OWNER_NOT_FOUND(HttpStatus.NOT_FOUND, "STUDY4002", "스터디장을 찾을 수 없습니다."), _STUDY_ALREADY_APPLIED(HttpStatus.BAD_REQUEST, "STUDY4003", "이미 신청된 스터디입니다."), _STUDY_MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "STUDY4004", "스터디 회원을 찾을 수 없습니다."), + _STUDY_MEMBER_NOT_EXIST(HttpStatus.NOT_FOUND, "STUDY4008", "스터디 회원이 아닙니다."), _STUDY_NOT_APPROVED(HttpStatus.FORBIDDEN, "STUDY4005", "승인되지 않은 스터디입니다."), _STUDY_OWNER_CANNOT_WITHDRAW(HttpStatus.FORBIDDEN, "STUDY4006", "스터디장은 스터디를 탈퇴할 수 없습니다."), + _STUDY_OWNER_ONLY_CAN_WITHDRAW(HttpStatus.FORBIDDEN, "STUDY4007", "스터디장만 해당 API를 통해 스터디를 탈퇴할 수 있습니다."), _STUDY_NOT_RECRUITING(HttpStatus.BAD_REQUEST, "STUDY4007", "스터디 모집기한이 아닙니다."), _STUDY_APPLICANT_NOT_FOUND(HttpStatus.NOT_FOUND, "STUDY4009", "처리를 기다리는 스터디 신청을 찾을 수 없습니다."), _STUDY_APPLY_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "STUDY4010","스터디 신청이 이미 처리된 회원입니다."), @@ -77,6 +80,7 @@ public enum ErrorStatus implements BaseErrorCode { _ALREADY_STUDY_MEMBER(HttpStatus.BAD_REQUEST, "STUDY4017", "이미 스터디 멤버입니다."), _STUDY_OWNER_ONLY_CAN_TERMINATE(HttpStatus.BAD_REQUEST, "STUDY4018", "스터디장만 스터디를 종료할 수 있습니다."), _STUDY_ALREADY_TERMINATED(HttpStatus.BAD_REQUEST, "STUDY4019", "이미 종료된 스터디입니다."), + _OWNED_STUDY_EXISTS(HttpStatus.BAD_REQUEST, "STUDY4020", "운영중인 스터디가 존재합니다."), //스터디 게시글 관련 에러 _STUDY_POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST4001", "스터디 게시글을 찾을 수 없습니다."), diff --git a/src/main/java/com/example/spot/api/code/status/SuccessStatus.java b/src/main/java/com/example/spot/api/code/status/SuccessStatus.java index b606afd3..d294cea6 100644 --- a/src/main/java/com/example/spot/api/code/status/SuccessStatus.java +++ b/src/main/java/com/example/spot/api/code/status/SuccessStatus.java @@ -69,6 +69,7 @@ public enum SuccessStatus implements BaseCode { _STUDY_APPLICANT_UPDATED(HttpStatus.OK, "STUDY4011", "스터디 신청 처리 완료"), _STUDY_APPLY_COMPLETED(HttpStatus.OK, "STUDY4012", "스터디 신청 완료"), _HOT_KEYWORD_FOUND(HttpStatus.OK, "SEARCH2001", "인기 검색어 조회 완료"), + _STUDY_HOST_FOUND(HttpStatus.OK, "STUDY2013", "스터디 호스트 조회 완료"), //스터디 출석 퀴즈 관련 _STUDY_QUIZ_CREATED(HttpStatus.CREATED, "QUIZ2001", "스터디 퀴즈 생성 완료"), diff --git a/src/main/java/com/example/spot/config/WebSecurity.java b/src/main/java/com/example/spot/config/WebSecurity.java index 0c9956e2..5bae53a3 100644 --- a/src/main/java/com/example/spot/config/WebSecurity.java +++ b/src/main/java/com/example/spot/config/WebSecurity.java @@ -62,7 +62,7 @@ protected SecurityFilterChain configure(HttpSecurity http) throws Exception { .requestMatchers(new AntPathRequestMatcher("/spot/login/kakao", "GET")).permitAll() .requestMatchers(new AntPathRequestMatcher("/spot/members/sign-in/kakao", "GET")).permitAll() .requestMatchers(new AntPathRequestMatcher("/spot/members/sign-in/kakao/redirect", "GET")).permitAll() - .requestMatchers(new AntPathRequestMatcher("/spot/member/test", "POST")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/spot/members/test", "POST")).permitAll() .requestMatchers(new AntPathRequestMatcher("/spot/current-env", "GET")).permitAll() .requestMatchers(new AntPathRequestMatcher("/api-docs")).permitAll() .requestMatchers(new AntPathRequestMatcher("/v3/**", "GET")).permitAll() diff --git a/src/main/java/com/example/spot/domain/Member.java b/src/main/java/com/example/spot/domain/Member.java index 33480b8e..6db120ad 100644 --- a/src/main/java/com/example/spot/domain/Member.java +++ b/src/main/java/com/example/spot/domain/Member.java @@ -62,7 +62,7 @@ public class Member extends BaseEntity { private Carrier carrier; // 안 쓰면 지워도 될 것 같은데 사이드 이펙트 생길까봐 일단 놔둡니다..! - @Column(length = 15, unique = true) + @Column(length = 15) private String phone; @Column(nullable = false) @@ -75,6 +75,7 @@ public class Member extends BaseEntity { @Column(nullable = false) private String profileImage; + @Setter @Column private LocalDateTime inactive; @@ -95,12 +96,12 @@ public class Member extends BaseEntity { private Status status; //== 스터디 희망사유 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List studyReasonList = new ArrayList<>(); //== 알림 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List notificationList = new ArrayList<>(); @@ -110,106 +111,106 @@ public class Member extends BaseEntity { private List memberReportList = new ArrayList<>(); //== 회원이 선호하는 테마 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List memberThemeList = new ArrayList<>(); //== 회원의 출석 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List memberAttendanceList = new ArrayList<>(); //== 회원이 참여하는 스터디 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List memberStudyList = new ArrayList<>(); //== 회원이 찜한 스터디 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List preferredStudyList = new ArrayList<>(); //== 회원이 선호하는 지역 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List preferredRegionList = new ArrayList<>(); ////== 회원이 작성한 게시글 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List postList = new ArrayList<>(); ////== 회원이 좋아요한 게시글 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List likedPostList = new ArrayList<>(); ////== 회원이 선호하는 지역 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List postReportList = new ArrayList<>(); ////== 회원이 스크랩한 게시글 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List memberScrapList = new ArrayList<>(); ////== 회원이 작성한 게시글 댓글 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List postCommentList = new ArrayList<>(); ////== 회원이 좋아요한 게시글 댓글 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List likedCommentList = new ArrayList<>(); //== 회원이 작성한 스터디 게시글 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List studyPostList = new ArrayList<>(); //== 회원이 좋아요한 스터디 게시글 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List studyLikedPostList = new ArrayList<>(); //== 회원이 작성한 스터디 게시글 댓글 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List studyPostCommentList = new ArrayList<>(); //== 회원이 좋아요한 게시글 댓글 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List studyLikedCommentList = new ArrayList<>(); //== 회원이 생성한 투표 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List voteList = new ArrayList<>(); //== 회원이 투표한 항목 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List memberVoteList = new ArrayList<>(); //== 회원이 선호하는 지역 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List regions = new ArrayList<>(); //== 회원이 생성한 스터디 퀴즈 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List quizList = new ArrayList<>(); //== 회원이 생성한 스터디 일정 목록 ==// - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List scheduleList = new ArrayList<>(); - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List toDoLists = new ArrayList<>(); diff --git a/src/main/java/com/example/spot/domain/Region.java b/src/main/java/com/example/spot/domain/Region.java index 34168b2e..b746c51b 100644 --- a/src/main/java/com/example/spot/domain/Region.java +++ b/src/main/java/com/example/spot/domain/Region.java @@ -32,11 +32,11 @@ public class Region extends BaseEntity { private String neighborhood; @Builder.Default - @OneToMany(mappedBy = "region") + @OneToMany(mappedBy = "region", cascade = CascadeType.ALL) private List regionStudyList = new ArrayList<>(); @Builder.Default - @OneToMany(mappedBy = "region") + @OneToMany(mappedBy = "region", cascade = CascadeType.ALL) private List prefferedRegionList = new ArrayList<>(); diff --git a/src/main/java/com/example/spot/domain/mapping/MemberStudy.java b/src/main/java/com/example/spot/domain/mapping/MemberStudy.java index 11ed76b1..5358a246 100644 --- a/src/main/java/com/example/spot/domain/mapping/MemberStudy.java +++ b/src/main/java/com/example/spot/domain/mapping/MemberStudy.java @@ -27,11 +27,17 @@ public class MemberStudy extends BaseEntity { private ApplicationStatus status; @Column(nullable = false, columnDefinition = "BIT DEFAULT 0") + @Setter private Boolean isOwned; @Column(columnDefinition = "text") private String introduction; + // 해당 유저로 호스트를 위임하는 이유 + @Column(columnDefinition = "text") + @Setter + private String reason; + //== 회원 ==// @Setter @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/java/com/example/spot/domain/study/Study.java b/src/main/java/com/example/spot/domain/study/Study.java index cf5f61aa..36293b07 100644 --- a/src/main/java/com/example/spot/domain/study/Study.java +++ b/src/main/java/com/example/spot/domain/study/Study.java @@ -59,6 +59,9 @@ public class Study extends BaseEntity { @Enumerated(EnumType.STRING) private StudyState studyState; + @Column(length = 30) + private String performance; + @Column(nullable = false) private Boolean isOnline; @@ -74,7 +77,6 @@ public class Study extends BaseEntity { @Column(nullable = false) private String title; - @Setter @Column(nullable = false) @Enumerated(EnumType.STRING) private Status status; @@ -199,4 +201,10 @@ public void deleteVote(Vote vote) { public void addToDoList(ToDoList toDoList) { toDoLists.add(toDoList); } + + public void terminateStudy(String performance) { + this.studyState = StudyState.COMPLETED; + this.status = Status.OFF; + this.performance = performance; + } } diff --git a/src/main/java/com/example/spot/repository/MemberRepository.java b/src/main/java/com/example/spot/repository/MemberRepository.java index db835538..c1f8fea2 100644 --- a/src/main/java/com/example/spot/repository/MemberRepository.java +++ b/src/main/java/com/example/spot/repository/MemberRepository.java @@ -1,6 +1,9 @@ package com.example.spot.repository; import com.example.spot.domain.Member; + +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import com.example.spot.domain.enums.LoginType; @@ -23,4 +26,6 @@ public interface MemberRepository extends JpaRepository { boolean existsByEmailAndLoginType(String email, LoginType loginType); Optional findByEmailAndLoginType(String email, LoginType loginType); + + List findAllByInactiveBefore(LocalDateTime stdTime); } diff --git a/src/main/java/com/example/spot/repository/MemberStudyRepository.java b/src/main/java/com/example/spot/repository/MemberStudyRepository.java index 9c28aa61..898be448 100644 --- a/src/main/java/com/example/spot/repository/MemberStudyRepository.java +++ b/src/main/java/com/example/spot/repository/MemberStudyRepository.java @@ -1,5 +1,6 @@ package com.example.spot.repository; +import com.example.spot.domain.Member; import com.example.spot.domain.enums.ApplicationStatus; import com.example.spot.domain.mapping.MemberStudy; import org.springframework.data.domain.Pageable; @@ -30,4 +31,7 @@ public interface MemberStudyRepository extends JpaRepository boolean existsByMemberIdAndStudyIdAndStatus(Long memberId, Long studyId, ApplicationStatus applicationStatus); + Optional findByStudyIdAndIsOwned(Long studyId, boolean b); + + boolean existsByMemberIdAndIsOwned(Long memberId, boolean b); } diff --git a/src/main/java/com/example/spot/repository/RefreshTokenRepository.java b/src/main/java/com/example/spot/repository/RefreshTokenRepository.java index 5039aaed..8ffd3cc2 100644 --- a/src/main/java/com/example/spot/repository/RefreshTokenRepository.java +++ b/src/main/java/com/example/spot/repository/RefreshTokenRepository.java @@ -2,6 +2,8 @@ import com.example.spot.domain.auth.RefreshToken; import com.example.spot.domain.study.Option; + +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -13,4 +15,6 @@ public interface RefreshTokenRepository extends JpaRepository deletedMemberIds); } diff --git a/src/main/java/com/example/spot/scheduler/MemberRemovalScheduler.java b/src/main/java/com/example/spot/scheduler/MemberRemovalScheduler.java new file mode 100644 index 00000000..34512e50 --- /dev/null +++ b/src/main/java/com/example/spot/scheduler/MemberRemovalScheduler.java @@ -0,0 +1,28 @@ +package com.example.spot.scheduler; + +import com.example.spot.service.admin.AdminService; +import com.example.spot.web.dto.admin.AdminResponseDTO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Slf4j +@RequiredArgsConstructor +@Transactional +public class MemberRemovalScheduler { + + private final AdminService adminService; + + // 매일 오전 6시에 탈퇴 후 30일이 지난 회원을 삭제합니다. + @Scheduled(cron = "0 0 6 * * *") + public void deleteMembers() { + AdminResponseDTO.DeletedMemberListDTO deletedMemberListDTO = adminService.deleteInactiveMembers(); + log.info("Deleted Members: {}", deletedMemberListDTO.getDeletedMembers().size()); + deletedMemberListDTO.getDeletedMembers().forEach(member -> + log.info("Deleted Member: id={}, email={}", member.getMemberId(), member.getEmail()) + ); + } +} diff --git a/src/main/java/com/example/spot/security/utils/SecurityUtils.java b/src/main/java/com/example/spot/security/utils/SecurityUtils.java index 50e0db9c..4e861fd0 100644 --- a/src/main/java/com/example/spot/security/utils/SecurityUtils.java +++ b/src/main/java/com/example/spot/security/utils/SecurityUtils.java @@ -64,4 +64,13 @@ public static String getVerifiedTempUserEmail() { } return authentication.getName(); } + + /** + * 현재 인증된 사용자의 로그인 정보를 삭제합니다. + * 로그인 정보를 삭제하며 SecurityContext도 함께 삭제합니다. + */ + public static void deleteCurrentUser() { + SecurityContextHolder.getContext().setAuthentication(null); + SecurityContextHolder.clearContext(); + } } diff --git a/src/main/java/com/example/spot/service/admin/AdminService.java b/src/main/java/com/example/spot/service/admin/AdminService.java new file mode 100644 index 00000000..53efeffd --- /dev/null +++ b/src/main/java/com/example/spot/service/admin/AdminService.java @@ -0,0 +1,15 @@ +package com.example.spot.service.admin; + +import com.example.spot.web.dto.admin.AdminResponseDTO; + +public interface AdminService { + +/* ----------------------------- 회원 정보 관리 API ------------------------------------- */ + + boolean getIsAdmin(); + + AdminResponseDTO.DeletedMemberListDTO deleteInactiveMembers(); + +/* ----------------------------- 신고 내역 관리 API ------------------------------------- */ + +} diff --git a/src/main/java/com/example/spot/service/admin/AdminServiceImpl.java b/src/main/java/com/example/spot/service/admin/AdminServiceImpl.java new file mode 100644 index 00000000..c933594b --- /dev/null +++ b/src/main/java/com/example/spot/service/admin/AdminServiceImpl.java @@ -0,0 +1,57 @@ +package com.example.spot.service.admin; + +import com.example.spot.api.code.status.ErrorStatus; +import com.example.spot.api.exception.handler.MemberHandler; +import com.example.spot.domain.Member; +import com.example.spot.repository.MemberRepository; +import com.example.spot.repository.RefreshTokenRepository; +import com.example.spot.security.utils.SecurityUtils; +import com.example.spot.web.dto.admin.AdminResponseDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminServiceImpl implements AdminService { + + private final MemberRepository memberRepository; + private final RefreshTokenRepository refreshTokenRepository; + +/* ----------------------------- 회원 정보 관리 API ------------------------------------- */ + + @Override + public boolean getIsAdmin() { + Long memberId = SecurityUtils.getCurrentUserId(); + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND)); + return member.getIsAdmin(); + } + + @Override + public AdminResponseDTO.DeletedMemberListDTO deleteInactiveMembers() { + + // 삭제 기준일시 + LocalDateTime stdTime = LocalDateTime.now().minusDays(30); + + // 회원 삭제 + List deletedMembers = memberRepository.findAllByInactiveBefore(stdTime); + List deletedMemberIds = deletedMembers.stream().map(Member::getId).toList(); + AdminResponseDTO.DeletedMemberListDTO deletedMemberListDTO = AdminResponseDTO.DeletedMemberListDTO.toDTO(deletedMembers); + + // Token 정리 + refreshTokenRepository.deleteAllByMemberIdIn(deletedMemberIds); + + // 회원 정보 정리 + memberRepository.deleteAllByIdInBatch(deletedMemberIds); + + return deletedMemberListDTO; + } + +/* ----------------------------- 신고 내역 관리 API ------------------------------------- */ + +} diff --git a/src/main/java/com/example/spot/service/auth/AuthService.java b/src/main/java/com/example/spot/service/auth/AuthService.java index 57539b1e..6182153b 100644 --- a/src/main/java/com/example/spot/service/auth/AuthService.java +++ b/src/main/java/com/example/spot/service/auth/AuthService.java @@ -18,6 +18,8 @@ public interface AuthService { MemberResponseDTO.MemberInfoCreationDTO signUpAndPartialUpdate(String nickname, Boolean personalInfo, Boolean idInfo); + MemberResponseDTO.InactiveMemberDTO withdraw(); + void authorizeWithNaver(HttpServletRequest request, HttpServletResponse response); SocialLoginSignInDTO signInWithNaver(HttpServletRequest request, HttpServletResponse response, NaverCallback naverCallback) throws Exception; @@ -41,5 +43,4 @@ public interface AuthService { MemberResponseDTO.AvailabilityDTO checkLoginIdAvailability(String loginId); MemberResponseDTO.AvailabilityDTO checkEmailAvailability(String email); - } diff --git a/src/main/java/com/example/spot/service/auth/AuthServiceImpl.java b/src/main/java/com/example/spot/service/auth/AuthServiceImpl.java index c1993b3a..54e2c0de 100644 --- a/src/main/java/com/example/spot/service/auth/AuthServiceImpl.java +++ b/src/main/java/com/example/spot/service/auth/AuthServiceImpl.java @@ -6,6 +6,7 @@ import com.example.spot.api.exception.handler.MemberHandler; import com.example.spot.domain.Member; import com.example.spot.domain.auth.RsaKey; +import com.example.spot.repository.MemberStudyRepository; import com.example.spot.web.dto.rsa.Rsa; import com.example.spot.domain.auth.RefreshToken; import com.example.spot.domain.auth.VerificationCode; @@ -43,6 +44,8 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -55,10 +58,15 @@ @Transactional public class AuthServiceImpl implements AuthService{ + @PersistenceContext + private EntityManager entityManager; + private final JwtTokenProvider jwtTokenProvider; private final MemberRepository memberRepository; + private final MemberStudyRepository memberStudyRepository; private final RefreshTokenRepository refreshTokenRepository; private final VerificationCodeRepository verificationCodeRepository; + private final MailService mailService; private final NaverOAuthService naverOAuthService; @@ -135,6 +143,31 @@ public MemberResponseDTO.MemberInfoCreationDTO signUpAndPartialUpdate(String nic return MemberResponseDTO.MemberInfoCreationDTO.toDTO(member); } + @Override + public MemberResponseDTO.InactiveMemberDTO withdraw() { + + // Authorization + Long memberId = SecurityUtils.getCurrentUserId(); + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND)); + + // 운영중인 스터디가 있는 경우 탈퇴 불가 + if (memberStudyRepository.existsByMemberIdAndIsOwned(memberId, true)) { + throw new MemberHandler(ErrorStatus._OWNED_STUDY_EXISTS); + } + + // inactive 필드 활성화 + member.setInactive(LocalDateTime.now()); + + // SecurityContextHolder 정리 + SecurityUtils.deleteCurrentUser(); + + // Refresh Token 정리 + refreshTokenRepository.deleteAllByMemberId(memberId); + + return MemberResponseDTO.InactiveMemberDTO.toDTO(member); + } + /* ----------------------------- 네이버 소셜로그인 API ------------------------------------- */ /** @@ -191,13 +224,38 @@ public SocialLoginSignInDTO signInWithNaver(HttpServletRequest request, HttpServ private SocialLoginSignInDTO getSocialLoginSignInDTO(NaverMember.ResponseDTO responseDTO) { String email = responseDTO.getResponse().getEmail(); - if (memberRepository.existsByEmailAndLoginTypeNot(email, LoginType.NAVER)) - throw new GeneralException(ErrorStatus._MEMBER_EMAIL_EXIST); + // 다른 로그인 방식을 사용한 계정이 있는지 확인 + if (memberRepository.existsByEmailAndLoginTypeNot(email, LoginType.NAVER)) { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND)); + + // 탈퇴한(inactive) 회원이면 기존 정보 삭제 + if (member.getInactive() != null) { + refreshTokenRepository.deleteByMemberId(member.getId()); + memberRepository.deleteById(member.getId()); + entityManager.flush(); + } + else { + throw new MemberHandler(ErrorStatus._MEMBER_EMAIL_ALREADY_EXISTS); + } + } + // 네이버 로그인 계정이 있는지 확인 Boolean isSpotMember = Boolean.TRUE; - - // 가입되지 않은 회원이면 회원 정보 저장 - if (!memberRepository.existsByEmailAndLoginType(email, LoginType.NAVER)) { + if (memberRepository.existsByEmailAndLoginType(email, LoginType.NAVER)) { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND)); + + // 탈퇴한(inactive) 회원이면 기존 정보 삭제 후 회원 정보 저장 + if (member.getInactive() != null) { + refreshTokenRepository.deleteByMemberId(member.getId()); + memberRepository.deleteById(member.getId()); + entityManager.flush(); + isSpotMember = Boolean.FALSE; + signUpWithNaver(responseDTO); + } + } + else { isSpotMember = Boolean.FALSE; signUpWithNaver(responseDTO); } @@ -277,6 +335,11 @@ public MemberResponseDTO.MemberSignInDTO signIn(Long rsaId, MemberRequestDTO.Sig Member member = memberRepository.findByLoginId(signInDTO.getLoginId()) .orElseThrow(() -> new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND)); + // 탈퇴한 회원이면 로그인 불가 + if (member.getInactive() != null) { + throw new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND); + } + // 비밀번호 확인 String password = getDecryptedPassword(rsaId, signInDTO.getPassword()); if (!password.equals(member.getPassword())) { @@ -396,14 +459,24 @@ public TokenResponseDTO.TempTokenDTO verifyEmail(String code, String email) { @Override public MemberResponseDTO.MemberSignInDTO signUp(Long rsaId, MemberRequestDTO.SignUpDTO signUpDTO) throws Exception { - // 회원 생성 + // 이미 존재하는 회원인 경우 if (memberRepository.existsByEmail(signUpDTO.getEmail())) { - throw new MemberHandler(ErrorStatus._MEMBER_EMAIL_ALREADY_EXISTS); - } - if (memberRepository.existsByLoginId(signUpDTO.getLoginId())) { - throw new MemberHandler(ErrorStatus._MEMBER_LOGIN_ID_ALREADY_EXISTS); + + Member member = memberRepository.findByEmail(signUpDTO.getEmail()) + .orElseThrow(() -> new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND)); + + // 탈퇴한(inactive) 회원이면 기존 정보 삭제 후 회원 생성 + if (member.getInactive() != null) { + refreshTokenRepository.deleteByMemberId(member.getId()); + memberRepository.deleteById(member.getId()); + entityManager.flush(); + } + else { + throw new MemberHandler(ErrorStatus._MEMBER_EMAIL_ALREADY_EXISTS); + } } + // 회원 생성 String password = getDecryptedPassword(rsaId, signUpDTO.getPassword()); String pwCheck = getDecryptedPassword(rsaId, signUpDTO.getPwCheck()); if (!password.equals(pwCheck)) { @@ -488,6 +561,11 @@ public MemberResponseDTO.FindIdDTO findId() { Member member = memberRepository.findByEmail(email) .orElseThrow(() -> new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND)); + // 탈퇴한 회원이면 아이디 찾기 불가 + if (member.getInactive() != null) { + throw new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND); + } + return MemberResponseDTO.FindIdDTO.toDTO(member); } @@ -506,6 +584,11 @@ public MemberResponseDTO.FindPwDTO findPw(String loginId) { Member member = memberRepository.findByEmail(email) .orElseThrow(() -> new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND)); + // 탈퇴한 회원이면 아이디 찾기 불가 + if (member.getInactive() != null) { + throw new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND); + } + // 인증된 사용자의 아이디와 입력 아이디 일치 여부 확인 if (!member.getLoginId().equals(loginId)) { throw new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND); diff --git a/src/main/java/com/example/spot/service/member/MemberServiceImpl.java b/src/main/java/com/example/spot/service/member/MemberServiceImpl.java index 4f2f860e..d2239ac8 100644 --- a/src/main/java/com/example/spot/service/member/MemberServiceImpl.java +++ b/src/main/java/com/example/spot/service/member/MemberServiceImpl.java @@ -4,10 +4,7 @@ import com.example.spot.api.exception.GeneralException; import com.example.spot.api.exception.handler.MemberHandler; import com.example.spot.domain.StudyReason; -import com.example.spot.domain.enums.LoginType; -import com.example.spot.domain.enums.Reason; -import com.example.spot.domain.enums.Status; -import com.example.spot.domain.enums.ThemeType; +import com.example.spot.domain.enums.*; import com.example.spot.repository.StudyReasonRepository; import com.example.spot.security.utils.JwtTokenProvider; import com.example.spot.domain.Member; @@ -29,6 +26,8 @@ import com.example.spot.web.dto.member.kakao.KaKaoUser; import com.example.spot.web.dto.token.TokenResponseDTO.TokenDTO; import com.fasterxml.jackson.core.JsonProcessingException; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; @@ -63,6 +62,9 @@ @RequiredArgsConstructor public class MemberServiceImpl implements MemberService { + @PersistenceContext + private EntityManager entityManager; + // OAuth private final KaKaoOAuthService kaKaoOAuthService; @@ -273,31 +275,54 @@ public SocialLoginSignInDTO signUpByKAKAOForTest(String code) throws JsonProcess // 응답에서 사용자 정보를 파싱 KaKaoUser kaKaoUser = kaKaoOAuthService.getUserInfo(userInfoResponse); - if (memberRepository.existsByEmailAndLoginTypeNot(kaKaoUser.toMember().getEmail(), LoginType.KAKAO)) - throw new GeneralException(ErrorStatus._MEMBER_EMAIL_EXIST); + // 다른 로그인 방식을 사용한 계정이 있는지 확인 + if (memberRepository.existsByEmailAndLoginTypeNot(kaKaoUser.toMember().getEmail(), LoginType.KAKAO)) { + Member member = memberRepository.findByEmail(kaKaoUser.toMember().getEmail()) + .orElseThrow(() -> new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND)); + + // 탈퇴한(inactive) 회원이면 기존 정보 삭제 + if (member.getInactive() != null) { + refreshTokenRepository.deleteByMemberId(member.getId()); + memberRepository.deleteById(member.getId()); + entityManager.flush(); + } + else { + throw new GeneralException(ErrorStatus._MEMBER_EMAIL_EXIST); + } + } + - Boolean isSpotMember = false; // 사용자가 이미 존재하는지 확인 + Boolean isSpotMember = false; if (memberRepository.existsByEmail(kaKaoUser.toMember().getEmail())) { // 존재하는 경우, 사용자 정보를 가져옴 Member member = memberRepository.findByEmail(kaKaoUser.toMember().getEmail()) .orElseThrow(() -> new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND)); - isSpotMember = true; - - // JWT 토큰 생성 - TokenDTO token = jwtTokenProvider.createToken(member.getId()); - - saveRefreshToken(member, token); - - // 로그인 DTO 반환 - MemberSignInDTO memberSignInDto = MemberSignInDTO.builder() - .tokens(token) - .memberId(member.getId()) - .loginType(member.getLoginType()) - .email(member.getEmail()) - .build(); - return SocialLoginSignInDTO.toDTO(isSpotMember, memberSignInDto); + // 탈퇴한(inactive) 회원이면 기존 정보 삭제 + if (member.getInactive() != null) { + refreshTokenRepository.deleteByMemberId(member.getId()); + memberRepository.deleteById(member.getId()); + entityManager.flush(); + } + else { + // JWT 토큰 생성 + TokenDTO token = jwtTokenProvider.createToken(member.getId()); + + saveRefreshToken(member, token); + + isSpotMember = true; + + // 로그인 DTO 반환 + MemberSignInDTO memberSignInDto = MemberSignInDTO.builder() + .tokens(token) + .memberId(member.getId()) + .loginType(member.getLoginType()) + .email(member.getEmail()) + .build(); + + return SocialLoginSignInDTO.toDTO(isSpotMember, memberSignInDto); + } } // 존재하지 않는 경우, 새로운 회원 정보 저장 @@ -587,17 +612,18 @@ public MemberTestDTO testMember(MemberInfoListDTO memberInfoListDTO) { // 회원 생성 Member member = Member.builder() .name(memberInfoListDTO.getName()) - .carrier(memberInfoListDTO.getCarrier()) - .birth(memberInfoListDTO.getBirth()) .nickname(memberInfoListDTO.getNickname()) + .birth(memberInfoListDTO.getBirth()) + .gender(Gender.UNKNOWN) .email(memberInfoListDTO.getEmail()) - .password(UUID.randomUUID().toString()) + .carrier(memberInfoListDTO.getCarrier()) .phone(memberInfoListDTO.getPhone()) + .password(UUID.randomUUID().toString()) + .profileImage(memberInfoListDTO.getProfileImage()) .personalInfo(memberInfoListDTO.isPersonalInfo()) .idInfo(memberInfoListDTO.isIdInfo()) - .profileImage(memberInfoListDTO.getProfileImage()) - .status(Status.ON) .loginType(LoginType.NORMAL) + .status(Status.ON) .build(); // 회원 저장 diff --git a/src/main/java/com/example/spot/service/memberstudy/MemberStudyCommandService.java b/src/main/java/com/example/spot/service/memberstudy/MemberStudyCommandService.java index ac5607fb..28803106 100644 --- a/src/main/java/com/example/spot/service/memberstudy/MemberStudyCommandService.java +++ b/src/main/java/com/example/spot/service/memberstudy/MemberStudyCommandService.java @@ -14,8 +14,9 @@ public interface MemberStudyCommandService { StudyWithdrawalResponseDTO.WithdrawalDTO withdrawFromStudy(Long studyId); + StudyWithdrawalResponseDTO.WithdrawalDTO withdrawHostFromStudy(Long studyId, StudyHostWithdrawRequestDTO requestDTO); - StudyTerminationResponseDTO.TerminationDTO terminateStudy(Long studyId); + StudyTerminationResponseDTO.TerminationDTO terminateStudy(Long studyId, String performance); // 스터디 신청 수락 StudyApplyResponseDTO acceptAndRejectStudyApply(Long memberId, Long studyId, boolean isAccept); diff --git a/src/main/java/com/example/spot/service/memberstudy/MemberStudyCommandServiceImpl.java b/src/main/java/com/example/spot/service/memberstudy/MemberStudyCommandServiceImpl.java index 2a30badb..c70ee0d6 100644 --- a/src/main/java/com/example/spot/service/memberstudy/MemberStudyCommandServiceImpl.java +++ b/src/main/java/com/example/spot/service/memberstudy/MemberStudyCommandServiceImpl.java @@ -22,6 +22,7 @@ import com.example.spot.web.dto.memberstudy.request.toDo.ToDoListResponseDTO.ToDoListCreateResponseDTO; import com.example.spot.web.dto.memberstudy.request.toDo.ToDoListResponseDTO.ToDoListUpdateResponseDTO; import com.example.spot.web.dto.memberstudy.response.*; +import com.example.spot.web.dto.memberstudy.response.StudyWithdrawalResponseDTO.WithdrawalDTO; import com.example.spot.web.dto.study.response.StudyApplyResponseDTO; import java.time.LocalDate; @@ -100,12 +101,39 @@ public StudyWithdrawalResponseDTO.WithdrawalDTO withdrawFromStudy(Long studyId) return StudyWithdrawalResponseDTO.WithdrawalDTO.toDTO(member, study); } + @Override + public WithdrawalDTO withdrawHostFromStudy(Long studyId, StudyHostWithdrawRequestDTO requestDTO) { + // Authorization + Long hostId = SecurityUtils.getCurrentUserId(); + SecurityUtils.verifyUserId(hostId); + + MemberStudy memberStudy = memberStudyRepository.findByMemberIdAndStudyIdAndStatus(hostId, studyId, ApplicationStatus.APPROVED) + .orElseThrow(() -> new StudyHandler(ErrorStatus._STUDY_MEMBER_NOT_FOUND)); + + if (!memberStudy.getIsOwned()) { + throw new StudyHandler(ErrorStatus._STUDY_OWNER_ONLY_CAN_WITHDRAW); + } + + MemberStudy newHostStudy = memberStudyRepository.findByMemberIdAndStudyIdAndStatus(requestDTO.getNewHostId(), studyId, ApplicationStatus.APPROVED) + .orElseThrow(() -> new StudyHandler(ErrorStatus._STUDY_MEMBER_NOT_EXIST)); + + memberStudyRepository.delete(memberStudy); + + newHostStudy.setIsOwned(true); + newHostStudy.setReason(requestDTO.getReason()); + + memberStudyRepository.save(newHostStudy); + + return StudyWithdrawalResponseDTO.WithdrawalDTO.toDTO(newHostStudy.getMember(), newHostStudy.getStudy()); + } /** * 운영중인 스터디를 종료하는 메서드입니다. 스터디장만 호출 가능합니다. - * @param studyId 종료할 스터디의 아이디를 입력 받습니다. + * + * @param studyId 종료할 스터디의 아이디를 입력 받습니다. + * @param performance 종료할 스터디의 성과를 입력 받습니다. * @return 종료된 스터디의 아이디, 이름, 상태를 반환합니다. */ - public StudyTerminationResponseDTO.TerminationDTO terminateStudy(Long studyId) { + public StudyTerminationResponseDTO.TerminationDTO terminateStudy(Long studyId, String performance) { // Authorization Long memberId = SecurityUtils.getCurrentUserId(); @@ -126,7 +154,7 @@ public StudyTerminationResponseDTO.TerminationDTO terminateStudy(Long studyId) { throw new StudyHandler(ErrorStatus._STUDY_ALREADY_TERMINATED); } - study.setStatus(Status.OFF); + study.terminateStudy(performance); studyRepository.save(study); return StudyTerminationResponseDTO.TerminationDTO.toDTO(study); diff --git a/src/main/java/com/example/spot/service/memberstudy/MemberStudyQueryService.java b/src/main/java/com/example/spot/service/memberstudy/MemberStudyQueryService.java index 95c280b5..4cc90925 100644 --- a/src/main/java/com/example/spot/service/memberstudy/MemberStudyQueryService.java +++ b/src/main/java/com/example/spot/service/memberstudy/MemberStudyQueryService.java @@ -28,6 +28,9 @@ public interface MemberStudyQueryService { // 스터디 별 신청 회원 목록 조회하기 StudyMemberResponseDTO findStudyApplicants(Long studyId); + // 스터디 호스트 조회하기 + StudyMemberResDTO.StudyHostDTO getStudyHost(Long studyId); + // 스터디 신청 정보 가져오기 StudyMemberResponseDTO.StudyApplyMemberDTO findStudyApplication(Long studyId, Long memberId); @@ -63,4 +66,5 @@ public interface MemberStudyQueryService { // 스터디 원 투두 리스트 조회 ToDoListResponseDTO.ToDoListSearchResponseDTO getMemberToDoList(Long studyId, Long memberId, LocalDate date, PageRequest pageRequest); + } diff --git a/src/main/java/com/example/spot/service/memberstudy/MemberStudyQueryServiceImpl.java b/src/main/java/com/example/spot/service/memberstudy/MemberStudyQueryServiceImpl.java index 2bc6cf44..735eed8f 100644 --- a/src/main/java/com/example/spot/service/memberstudy/MemberStudyQueryServiceImpl.java +++ b/src/main/java/com/example/spot/service/memberstudy/MemberStudyQueryServiceImpl.java @@ -1,6 +1,7 @@ package com.example.spot.service.memberstudy; import com.example.spot.api.code.status.ErrorStatus; +import com.example.spot.api.exception.handler.MemberHandler; import com.example.spot.api.exception.handler.StudyHandler; import com.example.spot.domain.Member; import com.example.spot.domain.Quiz; @@ -178,6 +179,26 @@ public StudyMemberResponseDTO findStudyApplicants(Long studyId) { return new StudyMemberResponseDTO(memberDTOS); } + @Override + public StudyMemberResDTO.StudyHostDTO getStudyHost(Long studyId) { + + // Authorization + Long memberId = SecurityUtils.getCurrentUserId(); + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND)); + + // 스터디 호스트 찾기 + MemberStudy studyHost = memberStudyRepository.findByStudyIdAndIsOwned(studyId, true) + .orElseThrow(() -> new StudyHandler(ErrorStatus._STUDY_OWNER_NOT_FOUND)); + + // 로그인한 회원이 호스트인지 확인 + if (studyHost.getMember().getId().equals(memberId)) { + return StudyMemberResDTO.StudyHostDTO.toDTO(true, member); + } else { + return StudyMemberResDTO.StudyHostDTO.toDTO(false, studyHost.getMember()); + } + } + /** * 스터디 신청자의 정보를 조회합니다. * @param studyId 스터디 ID diff --git a/src/main/java/com/example/spot/web/controller/AdminController.java b/src/main/java/com/example/spot/web/controller/AdminController.java index 32eabac5..cc455861 100644 --- a/src/main/java/com/example/spot/web/controller/AdminController.java +++ b/src/main/java/com/example/spot/web/controller/AdminController.java @@ -1,14 +1,18 @@ package com.example.spot.web.controller; +import com.example.spot.api.ApiResponse; import com.example.spot.api.code.status.ErrorStatus; +import com.example.spot.api.code.status.SuccessStatus; import com.example.spot.api.exception.GeneralException; +import com.example.spot.service.admin.AdminService; +import com.example.spot.web.dto.admin.AdminResponseDTO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -17,8 +21,31 @@ @Tag(name = "관리자 - 개발 중", description = "관리자 관련 API") @RestController @RequestMapping("/spot/admin") +@RequiredArgsConstructor public class AdminController { + private final AdminService adminService; + +/* ----------------------------- 회원 정보 관리 API ------------------------------------- */ + + @Operation(summary = "[회원 정보 관리] 탈퇴 회원 영구 삭제 수동 실행 API", + description = """ + ## [회원 정보 관리] inactive 시점 이후로 30일이 지난 회원의 정보를 DB에서 영구적으로 삭제합니다. + * <탈퇴 회원 영구 삭제>는 스케줄러에 의해 오전 6시마다 실행되고 있습니다. + * 오류로 인하여 수동 삭제가 필요한 경우 해당 API를 호출해주시기 바랍니다. + """) + @DeleteMapping("/members/removal") + public ApiResponse deleteInactiveMembers() { + // 관리자 인증 + if (!adminService.getIsAdmin()) { + throw new GeneralException(ErrorStatus._NOT_ADMIN); + } + AdminResponseDTO.DeletedMemberListDTO deletedMemberListDTO = adminService.deleteInactiveMembers(); + return ApiResponse.onSuccess(SuccessStatus._MEMBER_DELETED, deletedMemberListDTO); + } + +/* ----------------------------- 신고 내역 관리 API ------------------------------------- */ + @Operation( summary = "[신고 내역 조회 - 개발중] 특정 스터디 신고 내역 조회", description = """ diff --git a/src/main/java/com/example/spot/web/controller/AuthController.java b/src/main/java/com/example/spot/web/controller/AuthController.java index ef894d96..45ea453c 100644 --- a/src/main/java/com/example/spot/web/controller/AuthController.java +++ b/src/main/java/com/example/spot/web/controller/AuthController.java @@ -65,6 +65,22 @@ public ApiResponse signUpAndPartialUpda return ApiResponse.onSuccess(SuccessStatus._MEMBER_UPDATED, memberInfoCreationDTO); } + @Tag(name = "회원 관리 API - 개발 완료", description = "회원 관리 API") + @Operation(summary = "[공통 회원 관리] 회원탈퇴 API", + description = """ + ## [공통 회원 관리] 로그인한 회원이 SPOT을 탈퇴할 때 사용되는 API입니다. + * Authorization 헤더에 액세스 토큰을 포함해야 합니다. + * 회원탈퇴 시 해당 회원의 inactive(LocalDateTime) 필드가 활성화 됩니다. + * 회원 정보 및 회원의 스터디 정보는 30일간 DB에 저장되며 30일이 지나면 자동으로 삭제됩니다. + * 30일 이후 정보 삭제 시 "로그인 이메일", "성명", "생년월일 정보", "진행중 스터디", \ + "모집중 스터디", "스터디 찜 정보", "게시글", "댓글", "사진", "관심사", "관심지역"이 삭제됩니다. + """) + @PatchMapping("/withdraw") + public ApiResponse withdraw() { + MemberResponseDTO.InactiveMemberDTO inactiveMemberDTO = authService.withdraw(); + return ApiResponse.onSuccess(SuccessStatus._MEMBER_DELETED, inactiveMemberDTO); + } + /* ----------------------------- 네이버 소셜로그인 API ------------------------------------- */ @Tag(name = "테스트 용 API", description = "테스트 용 API") @@ -259,7 +275,7 @@ public ApiResponse login( description = """ ## [로그아웃] 로그아웃 API입니다. 로그아웃을 진행합니다. - 로그아웃 시, 사용 하던 액세스 토큰과 리프레시 토큰은 더 이상 사용이 불가능합니다. + 로그아웃 시, 사용 하던 액세스 토큰과 리프레시 토큰은 더 이상 사용이 불가능합니다. 다시 서비스를 이용하기 위해서는 로그인을 다시 진행해야 합니다. """) @PostMapping("/logout") diff --git a/src/main/java/com/example/spot/web/controller/MemberController.java b/src/main/java/com/example/spot/web/controller/MemberController.java index 092b26a1..c6082ff8 100644 --- a/src/main/java/com/example/spot/web/controller/MemberController.java +++ b/src/main/java/com/example/spot/web/controller/MemberController.java @@ -67,7 +67,7 @@ public ApiResponse testMember( ## [회원 권한 부여] 해당하는 회원에게 관리자 권한을 부여합니다. 테스트를 위해 구현한 테스트 용 API입니다. 회원의 ID를 입력 받아 관리자 권한을 부여합니다. - 성공 여부와 회원 ID가 반환 됩니다. + 성공 여부와 회원 ID가 반환 됩니다. """) @PostMapping("/members/test/admin") public ApiResponse toAdmin(){ diff --git a/src/main/java/com/example/spot/web/controller/MemberStudyController.java b/src/main/java/com/example/spot/web/controller/MemberStudyController.java index e41ef971..65d3f740 100644 --- a/src/main/java/com/example/spot/web/controller/MemberStudyController.java +++ b/src/main/java/com/example/spot/web/controller/MemberStudyController.java @@ -16,6 +16,7 @@ import com.example.spot.web.dto.memberstudy.request.toDo.ToDoListResponseDTO.ToDoListUpdateResponseDTO; import com.example.spot.web.dto.memberstudy.response.*; import com.example.spot.web.dto.study.response.*; +import com.example.spot.web.dto.study.response.StudyMemberResponseDTO; import com.example.spot.web.dto.study.response.StudyMemberResponseDTO.StudyApplicantDTO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -54,14 +55,35 @@ public ApiResponse withdrawFromStudy(@ return ApiResponse.onSuccess(SuccessStatus._STUDY_MEMBER_DELETED, withdrawalDTO); } + @Tag(name = "진행중인 스터디") + @Operation(summary = "[진행중인 스터디] 스터디 호스트 탈퇴", + description = """ + ## [진행중인 스터디] 특정 스터디의 호스트가 해당 스터디에서 탈퇴합니다. + 탈퇴 시, 호스트 권한이 회수되며 스터디에서 제외됩니다. + 요청 시, 새로운 호스트의 아이디와 임명 사유를 입력해야 합니다. + """) + @DeleteMapping("/studies/{studyId}/hosts/withdrawal") + public ApiResponse withdrawHostFromStudy( + @PathVariable Long studyId, + @RequestBody StudyHostWithdrawRequestDTO requestDTO) { + StudyWithdrawalResponseDTO.WithdrawalDTO withdrawalDTO = + memberStudyCommandService.withdrawHostFromStudy(studyId, requestDTO); + return ApiResponse.onSuccess(SuccessStatus._STUDY_MEMBER_DELETED, withdrawalDTO); + } + + @Tag(name = "진행중인 스터디") @Operation(summary = "[진행중인 스터디] 스터디 끝내기", description = """ ## [진행중인 스터디] 마이페이지 > 진행중 > 진행중인 스터디의 메뉴 클릭, 로그인한 회원이 운영중인 스터디를 끝냅니다. - 로그인한 회원이 운영하는 특정 스터디에 대해 study status OFF로 전환합니다. + * 로그인한 회원이 운영하는 특정 스터디에 대해 study status OFF로 전환합니다. + * 스터디 성과를 입력받아 DB에 저장합니다. """) @PatchMapping("/studies/{studyId}/termination") - public ApiResponse terminateStudy(@PathVariable Long studyId) { - StudyTerminationResponseDTO.TerminationDTO terminationDTO = memberStudyCommandService.terminateStudy(studyId); + public ApiResponse terminateStudy( + @PathVariable @ExistStudy Long studyId, + @RequestParam @TextLength(min=1, max=30) String performance + ) { + StudyTerminationResponseDTO.TerminationDTO terminationDTO = memberStudyCommandService.terminateStudy(studyId, performance); return ApiResponse.onSuccess(SuccessStatus._STUDY_TERMINATED, terminationDTO); } @@ -157,6 +179,7 @@ public ApiResponse rejectApplicantForTest( /* ----------------------------- 스터디 상세 정보 관련 API ------------------------------------- */ + @Tag(name = "스터디 상세 정보") @Operation(summary = "[스터디 상세 정보] 스터디 최근 공지 1개 불러오기", description = """ ## [스터디 상세 정보] 내 스터디 > 스터디 클릭, 로그인한 회원이 참여하는 특정 스터디의 최근 공지 1개를 불러옵니다. @@ -192,6 +215,20 @@ public ApiResponse getStudyMembers( return ApiResponse.onSuccess(SuccessStatus._STUDY_MEMBER_FOUND, studyMemberResponseDTO); } + @Tag(name = "스터디 상세 정보") + @Operation(summary = "[스터디 상세 정보] 스터디 호스트 정보 불러오기", description = """ + ## [스터디 상세 정보] 로그인한 회원이 참여하는 특정 스터디의 호스트 정보를 조회합니다. + * isOwned : 로그인한 회원이 호스트인지 true or false로 반환 + * host : 호스트의 id와 nickname 반환 + """) + @GetMapping("/studies/{studyId}/host") + public ApiResponse getStudyHost( + @PathVariable @ExistStudy Long studyId) + { + StudyMemberResDTO.StudyHostDTO studyHostDTO = memberStudyQueryService.getStudyHost(studyId); + return ApiResponse.onSuccess(SuccessStatus._STUDY_HOST_FOUND, studyHostDTO); + } + /* ----------------------------- 스터디 일정 관련 API ------------------------------------- */ diff --git a/src/main/java/com/example/spot/web/dto/admin/AdminResponseDTO.java b/src/main/java/com/example/spot/web/dto/admin/AdminResponseDTO.java new file mode 100644 index 00000000..d1e5deb1 --- /dev/null +++ b/src/main/java/com/example/spot/web/dto/admin/AdminResponseDTO.java @@ -0,0 +1,47 @@ +package com.example.spot.web.dto.admin; + +import com.example.spot.domain.Member; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +public class AdminResponseDTO { + + @Getter + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + @Builder(access = AccessLevel.PRIVATE) + public static class DeletedMemberListDTO { + private final LocalDateTime deletedAt; + private final List deletedMembers; + + public static DeletedMemberListDTO toDTO(List members) { + return DeletedMemberListDTO.builder() + .deletedAt(LocalDateTime.now()) + .deletedMembers(members.stream() + .map(DeletedMemberDTO::toDTO) + .toList()) + .build(); + } + } + + @Getter + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + @Builder(access = AccessLevel.PRIVATE) + public static class DeletedMemberDTO { + + private final Long memberId; + private final String email; + + public static DeletedMemberDTO toDTO(Member member) { + return DeletedMemberDTO.builder() + .memberId(member.getId()) + .email(member.getEmail()) + .build(); + } + } +} diff --git a/src/main/java/com/example/spot/web/dto/member/MemberResponseDTO.java b/src/main/java/com/example/spot/web/dto/member/MemberResponseDTO.java index 2048de1e..0aa7bc02 100644 --- a/src/main/java/com/example/spot/web/dto/member/MemberResponseDTO.java +++ b/src/main/java/com/example/spot/web/dto/member/MemberResponseDTO.java @@ -183,5 +183,26 @@ public static ReportedMemberDTO toDTO(Member member) { .build(); } } + + @Getter + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + @Builder(access = AccessLevel.PRIVATE) + public static class InactiveMemberDTO { + + private final Long memberId; + private final String name; + private final String email; + private final LocalDateTime inactive; + + public static InactiveMemberDTO toDTO(Member member) { + return InactiveMemberDTO.builder() + .memberId(member.getId()) + .name(member.getName()) + .email(member.getEmail()) + .inactive(member.getInactive()) + .build(); + } + } + } diff --git a/src/main/java/com/example/spot/web/dto/memberstudy/request/StudyHostWithdrawRequestDTO.java b/src/main/java/com/example/spot/web/dto/memberstudy/request/StudyHostWithdrawRequestDTO.java new file mode 100644 index 00000000..cadb03ae --- /dev/null +++ b/src/main/java/com/example/spot/web/dto/memberstudy/request/StudyHostWithdrawRequestDTO.java @@ -0,0 +1,16 @@ +package com.example.spot.web.dto.memberstudy.request; + + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class StudyHostWithdrawRequestDTO { + + private Long newHostId; + private String reason; + +} diff --git a/src/main/java/com/example/spot/web/dto/memberstudy/response/StudyMemberResDTO.java b/src/main/java/com/example/spot/web/dto/memberstudy/response/StudyMemberResDTO.java new file mode 100644 index 00000000..10cdcd9f --- /dev/null +++ b/src/main/java/com/example/spot/web/dto/memberstudy/response/StudyMemberResDTO.java @@ -0,0 +1,39 @@ +package com.example.spot.web.dto.memberstudy.response; + +import com.example.spot.domain.Member; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +public class StudyMemberResDTO { + + @Getter + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + @Builder(access = AccessLevel.PRIVATE) + public static class StudyHostDTO { + private final Boolean isOwned; + private final HostInfoDTO host; + public static StudyHostDTO toDTO(Boolean isOwned, Member host) { + return StudyHostDTO.builder() + .isOwned(isOwned) + .host(HostInfoDTO.toDTO(host)) + .build(); + } + } + + @Getter + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + @Builder(access = AccessLevel.PRIVATE) + private static class HostInfoDTO { + private final Long memberId; + private final String nickname; + public static HostInfoDTO toDTO(Member host) { + return HostInfoDTO.builder() + .memberId(host.getId()) + .nickname(host.getNickname()) + .build(); + } + } +} diff --git a/src/test/java/com/example/spot/service/memberstudy/MemberStudyCommandServiceTest.java b/src/test/java/com/example/spot/service/memberstudy/MemberStudyCommandServiceTest.java index 1e7bdc42..859b6b8f 100644 --- a/src/test/java/com/example/spot/service/memberstudy/MemberStudyCommandServiceTest.java +++ b/src/test/java/com/example/spot/service/memberstudy/MemberStudyCommandServiceTest.java @@ -27,12 +27,10 @@ import com.example.spot.web.dto.memberstudy.response.StudyTerminationResponseDTO; import com.example.spot.web.dto.memberstudy.response.StudyWithdrawalResponseDTO; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.BDDMockito; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; @@ -218,7 +216,7 @@ void terminateStudy_Success() { .thenReturn(Optional.of(memberStudy)); // when - StudyTerminationResponseDTO.TerminationDTO result = memberStudyCommandService.terminateStudy(1L); + StudyTerminationResponseDTO.TerminationDTO result = memberStudyCommandService.terminateStudy(1L, "스터디 성과"); // then assertNotNull(result); @@ -254,7 +252,7 @@ void terminateStudy_NotStudyOwner_Fail() { .thenReturn(Optional.of(memberStudy)); // when & then - assertThrows(StudyHandler.class, () -> memberStudyCommandService.terminateStudy(1L)); + assertThrows(StudyHandler.class, () -> memberStudyCommandService.terminateStudy(1L, "스터디 성과")); } @Test @@ -284,7 +282,7 @@ void terminateStudy_AlreadyTerminated_Fail() { .thenReturn(Optional.of(memberStudy)); // when & then - assertThrows(StudyHandler.class, () -> memberStudyCommandService.terminateStudy(1L)); + assertThrows(StudyHandler.class, () -> memberStudyCommandService.terminateStudy(1L, "스터디 성과")); }