diff --git a/build.gradle b/build.gradle index 02b3789e..0ffbf1f5 100644 --- a/build.gradle +++ b/build.gradle @@ -91,6 +91,9 @@ dependencies { // WebSocket + STOMP 통신용 implementation 'org.springframework.boot:spring-boot-starter-websocket' + + // 이메일 전송 의존성 + implementation 'org.springframework.boot:spring-boot-starter-mail' } tasks.named('test') { diff --git a/src/main/java/grep/neogul_coder/domain/admin/controller/dto/response/AdminStudyResponse.java b/src/main/java/grep/neogul_coder/domain/admin/controller/dto/response/AdminStudyResponse.java index 87b9a821..35e7d2ba 100644 --- a/src/main/java/grep/neogul_coder/domain/admin/controller/dto/response/AdminStudyResponse.java +++ b/src/main/java/grep/neogul_coder/domain/admin/controller/dto/response/AdminStudyResponse.java @@ -22,7 +22,7 @@ public class AdminStudyResponse { private Category category; @Schema(description = "스터디 종료 여부", example = "false") - private boolean isFinished; + private boolean finished; @Schema(description = "활성화 여부", example = "true") private boolean activated; @@ -33,7 +33,7 @@ private AdminStudyResponse(Long id, String name, Category category, boolean isFi this.id = id; this.name = name; this.category = category; - this.isFinished = isFinished; + this.finished = isFinished; this.activated = activated; } @@ -42,7 +42,7 @@ public static AdminStudyResponse from(Study study) { .id(study.getId()) .name(study.getName()) .category(study.getCategory()) - .isFinished(study.getEndDate().toLocalDate().isBefore(LocalDate.now())) + .isFinished(study.isFinished()) .activated(study.getActivated()) .build(); } diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java index a2bdabfc..0df3d39f 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java @@ -1,6 +1,9 @@ package grep.neogul_coder.domain.groupchat.controller.dto.requset; +import grep.neogul_coder.domain.groupchat.entity.GroupChatMessage; +import grep.neogul_coder.domain.groupchat.entity.GroupChatRoom; import io.swagger.v3.oas.annotations.Hidden; +import java.time.LocalDateTime; import lombok.Getter; @Hidden @@ -18,15 +21,12 @@ public GroupChatMessageRequestDto(Long roomId, Long senderId, String message) { this.message = message; } - public void setRoomId(Long roomId) { - this.roomId = roomId; - } - - public void setSenderId(Long senderId) { - this.senderId = senderId; - } - - public void setMessage(String message) { - this.message = message; + public GroupChatMessage toEntity(GroupChatRoom room, Long senderId) { + return new GroupChatMessage( + room, + senderId, + this.message, + LocalDateTime.now() + ); } } diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java index bf5f1f1c..53fa2e13 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java @@ -15,11 +15,4 @@ public class GroupChatSwaggerRequest { @Schema(description = "보낼 메시지", example = "안녕하세요!") private String message; - - - public void setSenderId(Long senderId) { this.senderId = senderId; } - - public void setRoomId(Long roomId) { this.roomId = roomId; } - - public void setMessage(String message) { this.message = message; } } diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java index 35e8a843..48c687cc 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java @@ -1,5 +1,7 @@ package grep.neogul_coder.domain.groupchat.controller.dto.response; +import grep.neogul_coder.domain.groupchat.entity.GroupChatMessage; +import grep.neogul_coder.domain.users.entity.User; import io.swagger.v3.oas.annotations.Hidden; import java.time.LocalDateTime; import lombok.Getter; @@ -16,10 +18,7 @@ public class GroupChatMessageResponseDto { private String message; // 메시지 내용 private LocalDateTime sentAt; // 보낸 시간 - public GroupChatMessageResponseDto() { - } - - public GroupChatMessageResponseDto(Long id, Long roomId, Long senderId, + private GroupChatMessageResponseDto(Long id, Long roomId, Long senderId, String senderNickname, String profileImageUrl, String message, LocalDateTime sentAt) { this.id = id; @@ -31,31 +30,15 @@ public GroupChatMessageResponseDto(Long id, Long roomId, Long senderId, this.sentAt = sentAt; } - public void setId(Long id) { - this.id = id; - } - - public void setRoomId(Long roomId) { - this.roomId = roomId; - } - - public void setSenderId(Long senderId) { - this.senderId = senderId; - } - - public void setSenderNickname(String senderNickname) { - this.senderNickname = senderNickname; - } - - public void setProfileImageUrl(String profileImageUrl) { - this.profileImageUrl = profileImageUrl; - } - - public void setMessage(String message) { - this.message = message; - } - - public void setSentAt(LocalDateTime sentAt) { - this.sentAt = sentAt; + public static GroupChatMessageResponseDto from(GroupChatMessage message, User sender) { + return new GroupChatMessageResponseDto( + message.getMessageId(), + message.getGroupChatRoom().getRoomId(), + sender.getId(), + sender.getNickname(), + sender.getProfileImageUrl(), + message.getMessage(), + message.getSentAt() + ); } } diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java b/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java index 10ba941e..5f9e141c 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java @@ -27,23 +27,14 @@ public class GroupChatMessage extends BaseEntity { private LocalDateTime sentAt; - public void setMessageId(Long messageId) { - this.messageId = messageId; - } - - public void setGroupChatRoom(GroupChatRoom groupChatRoom) { - this.groupChatRoom = groupChatRoom; - } - - public void setUserId(Long userId) { + public GroupChatMessage(GroupChatRoom room, Long userId, String message, LocalDateTime sentAt) { + this.groupChatRoom = room; this.userId = userId; - } - - public void setMessage(String message) { this.message = message; + this.sentAt = sentAt; } - public void setSentAt(LocalDateTime sentAt) { - this.sentAt = sentAt; + protected GroupChatMessage() { + } } diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java b/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java index c5437d47..515c3cb4 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java @@ -9,11 +9,10 @@ public interface GroupChatMessageRepository extends JpaRepository { - // 채팅방(roomId)에 속한 메시지를 전송 시간 내림차순으로 페이징 조회 @Query("SELECT m FROM GroupChatMessage m " + "WHERE m.groupChatRoom.roomId = :roomId " + "ORDER BY m.sentAt ASC") Page findMessagesByRoomIdAsc(@Param("roomId") Long roomId, Pageable pageable); -} +} \ No newline at end of file diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java b/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java index ddce78be..381c5afd 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java @@ -10,6 +10,7 @@ import grep.neogul_coder.domain.users.entity.User; import grep.neogul_coder.domain.users.repository.UserRepository; import grep.neogul_coder.global.response.PageResponse; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -17,6 +18,7 @@ import org.springframework.stereotype.Service; import java.time.LocalDateTime; +@RequiredArgsConstructor @Service public class GroupChatService { @@ -25,17 +27,6 @@ public class GroupChatService { private final UserRepository userRepository; private final StudyMemberRepository studyMemberRepository; - // 생성자 주입을 통한 의존성 주입 - public GroupChatService(GroupChatMessageRepository messageRepository, - GroupChatRoomRepository roomRepository, - UserRepository userRepository, - StudyMemberRepository studyMemberRepository) { - this.messageRepository = messageRepository; - this.roomRepository = roomRepository; - this.userRepository = userRepository; - this.studyMemberRepository = studyMemberRepository; - } - public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto requestDto) { // 채팅방 존재 여부 확인 GroupChatRoom room = roomRepository.findById(requestDto.getRoomId()) @@ -51,26 +42,14 @@ public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto reques throw new IllegalArgumentException("해당 스터디에 참가한 사용자만 채팅할 수 있습니다."); } - // 메시지 생성 및 저장 - GroupChatMessage message = new GroupChatMessage(); - message.setGroupChatRoom(room); // 엔티티에 있는 필드명 맞게 사용 - message.setUserId(sender.getId()); // 연관관계 없이 userId만 저장 - message.setMessage(requestDto.getMessage()); - message.setSentAt(LocalDateTime.now()); + // 메시지 생성 + GroupChatMessage message = requestDto.toEntity(room, sender.getId()); // 메시지 저장 messageRepository.save(message); - // 저장된 메시지를 dto로 변환 - return new GroupChatMessageResponseDto( - message.getMessageId(), - message.getGroupChatRoom().getRoomId(), // ← roomId 필드가 필요하면 여기서 꺼내야 함 - sender.getId(), - sender.getNickname(), - sender.getProfileImageUrl(), - message.getMessage(), - message.getSentAt() - ); + // 응답 dto 생성 + return GroupChatMessageResponseDto.from(message, sender); } // 과거 채팅 메시지 페이징 조회 (무한 스크롤용) @@ -86,15 +65,7 @@ public PageResponse getMessages(Long roomId, int pa User sender = userRepository.findById(message.getUserId()) .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")); - return new GroupChatMessageResponseDto( - message.getMessageId(), - message.getGroupChatRoom().getRoomId(), - sender.getId(), - sender.getNickname(), - sender.getProfileImageUrl(), - message.getMessage(), - message.getSentAt() - ); + return GroupChatMessageResponseDto.from(message, sender); }); // PageResponse로 감싸서 반환 diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostService.java b/src/main/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostService.java index af49d8b8..a73cd268 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostService.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostService.java @@ -15,7 +15,7 @@ import grep.neogul_coder.domain.study.Study; import grep.neogul_coder.domain.study.repository.StudyRepository; import grep.neogul_coder.domain.studyapplication.StudyApplication; -import grep.neogul_coder.domain.studyapplication.repository.StudyApplicationRepository; +import grep.neogul_coder.domain.studyapplication.repository.ApplicationRepository; import grep.neogul_coder.global.exception.business.BusinessException; import grep.neogul_coder.global.exception.business.NotFoundException; import lombok.RequiredArgsConstructor; @@ -40,7 +40,7 @@ public class RecruitmentPostService { private final RecruitmentPostRepository postRepository; private final RecruitmentPostQueryRepository postQueryRepository; - private final StudyApplicationRepository studyApplicationRepository; + private final ApplicationRepository applicationRepository; private final RecruitmentPostCommentQueryRepository commentQueryRepository; public RecruitmentPostInfo get(long recruitmentPostId) { @@ -49,7 +49,7 @@ public RecruitmentPostInfo get(long recruitmentPostId) { RecruitmentPostWithStudyInfo postInfo = postQueryRepository.findPostWithStudyInfo(post.getId()); List comments = findCommentsWithWriterInfo(post); - List applications = studyApplicationRepository.findByRecruitmentPostId(post.getId()); + List applications = applicationRepository.findByRecruitmentPostId(post.getId()); return new RecruitmentPostInfo(postInfo, comments, applications.size()); } diff --git a/src/main/java/grep/neogul_coder/domain/study/Study.java b/src/main/java/grep/neogul_coder/domain/study/Study.java index fb69e252..414ce0b3 100644 --- a/src/main/java/grep/neogul_coder/domain/study/Study.java +++ b/src/main/java/grep/neogul_coder/domain/study/Study.java @@ -43,6 +43,8 @@ public class Study extends BaseEntity { private boolean extended; + private boolean finished; + protected Study() {} @Builder @@ -60,6 +62,7 @@ private Study(Long originStudyId, String name, Category category, int capacity, this.introduction = introduction; this.imageUrl = imageUrl; this.extended = false; + this.finished = false; } public void update(String name, Category category, int capacity, StudyType studyType, @@ -86,6 +89,10 @@ public long calculateRemainSlots(long currentCount) { return this.capacity - currentCount; } + public void increaseMemberCount() { + currentCount++; + } + public void decreaseMemberCount() { currentCount--; } @@ -97,4 +104,8 @@ public boolean alreadyExtended() { public void extend() { this.extended = true; } + + public void finish() { + this.finished = true; + } } diff --git a/src/main/java/grep/neogul_coder/domain/study/StudyMember.java b/src/main/java/grep/neogul_coder/domain/study/StudyMember.java index 66e92859..38260637 100644 --- a/src/main/java/grep/neogul_coder/domain/study/StudyMember.java +++ b/src/main/java/grep/neogul_coder/domain/study/StudyMember.java @@ -36,6 +36,14 @@ public StudyMember(Study study, Long userId, StudyMemberRole role) { this.participated = false; } + public static StudyMember createMember(Study study, Long userId) { + return StudyMember.builder() + .study(study) + .userId(userId) + .role(StudyMemberRole.MEMBER) + .build(); + } + public void delete() { this.activated = false; } diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyItemResponse.java b/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyItemResponse.java index 26184c49..b899e602 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyItemResponse.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyItemResponse.java @@ -1,14 +1,11 @@ package grep.neogul_coder.domain.study.controller.dto.response; import com.querydsl.core.annotations.QueryProjection; -import grep.neogul_coder.domain.study.Study; import grep.neogul_coder.domain.study.enums.Category; import grep.neogul_coder.domain.study.enums.StudyType; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; import lombok.Getter; -import java.time.LocalDate; import java.time.LocalDateTime; @Getter @@ -48,13 +45,11 @@ public class StudyItemResponse { private StudyType studyType; @Schema(description = "종료 여부", example = "false") - public boolean isFinished() { - return endDate.toLocalDate().isBefore(LocalDate.now()); - } + private boolean finished; @QueryProjection public StudyItemResponse(Long studyId, String name, String leaderNickname, int capacity, int currentCount, LocalDateTime startDate, - LocalDateTime endDate, String imageUrl, String introduction, Category category, StudyType studyType) { + LocalDateTime endDate, String imageUrl, String introduction, Category category, StudyType studyType, boolean finished) { this.studyId = studyId; this.name = name; this.leaderNickname = leaderNickname; @@ -66,5 +61,6 @@ public StudyItemResponse(Long studyId, String name, String leaderNickname, int c this.introduction = introduction; this.category = category; this.studyType = studyType; + this.finished = finished; } } diff --git a/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java b/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java index 239a7fe8..f00bc2f6 100644 --- a/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java +++ b/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java @@ -2,6 +2,7 @@ import grep.neogul_coder.domain.study.Study; import grep.neogul_coder.domain.study.StudyMember; +import grep.neogul_coder.domain.study.enums.StudyMemberRole; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -37,4 +38,6 @@ public interface StudyMemberRepository extends JpaRepository LocalDateTime findCreatedDateByStudyIdAndUserId(@Param("studyId") Long studyId, @Param("userId") Long userId); boolean existsByStudyIdAndUserId(Long studyId, Long id); + + boolean existsByStudyIdAndUserIdAndRole(Long studyId, Long userId, StudyMemberRole role); } diff --git a/src/main/java/grep/neogul_coder/domain/study/repository/StudyQueryRepository.java b/src/main/java/grep/neogul_coder/domain/study/repository/StudyQueryRepository.java index e3894009..865fc19b 100644 --- a/src/main/java/grep/neogul_coder/domain/study/repository/StudyQueryRepository.java +++ b/src/main/java/grep/neogul_coder/domain/study/repository/StudyQueryRepository.java @@ -42,7 +42,8 @@ public Page findMyStudiesPaging(Pageable pageable, Long userI study.imageUrl, study.introduction, study.category, - study.studyType + study.studyType, + study.finished )) .from(studyMember) .join(user).on(user.id.eq(studyMember.userId)) @@ -78,7 +79,8 @@ public List findMyStudies(Long userId) { study.imageUrl, study.introduction, study.category, - study.studyType + study.studyType, + study.finished )) .from(studyMember) .join(user).on(user.id.eq(studyMember.userId)) diff --git a/src/main/java/grep/neogul_coder/domain/study/repository/StudyRepository.java b/src/main/java/grep/neogul_coder/domain/study/repository/StudyRepository.java index 7dc60f71..c259e093 100644 --- a/src/main/java/grep/neogul_coder/domain/study/repository/StudyRepository.java +++ b/src/main/java/grep/neogul_coder/domain/study/repository/StudyRepository.java @@ -6,8 +6,9 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.Optional; +import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; public interface StudyRepository extends JpaRepository { List findByIdIn(List studyIds); @@ -19,4 +20,10 @@ public interface StudyRepository extends JpaRepository { @Modifying(clearAutomatically = true) @Query("update Study s set s.activated = false where s.id = :studyId") void deactivateByStudyId(@Param("studyId") Long studyId); + + @Query("select s from Study s where s.endDate >= :endDateStart and s.endDate < :endDateEnd and s.finished = false and s.activated = true") + List findStudiesEndingIn7Days(@Param("endDateStart") LocalDateTime endDateStart, @Param("endDateEnd") LocalDateTime endDateEnd); + + @Query("select s from Study s where s.endDate < :now and s.finished = false and s.activated = true") + List findStudiesToBeFinished(@Param("now") LocalDateTime now); } diff --git a/src/main/java/grep/neogul_coder/domain/study/scheduler/StudyScheduler.java b/src/main/java/grep/neogul_coder/domain/study/scheduler/StudyScheduler.java new file mode 100644 index 00000000..c814a455 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/study/scheduler/StudyScheduler.java @@ -0,0 +1,23 @@ +package grep.neogul_coder.domain.study.scheduler; + +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.service.StudySchedulerService; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class StudyScheduler { + + private final StudySchedulerService studySchedulerService; + + @Scheduled(cron = "0 0 0 * * *") + public void processEndingStudies() { + List studiesEndingIn7Days = studySchedulerService.findStudiesEndingIn7Days(); + + studySchedulerService.finalizeStudies(); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/study/service/StudyManagementService.java b/src/main/java/grep/neogul_coder/domain/study/service/StudyManagementService.java index 209dd301..e97a4c23 100644 --- a/src/main/java/grep/neogul_coder/domain/study/service/StudyManagementService.java +++ b/src/main/java/grep/neogul_coder/domain/study/service/StudyManagementService.java @@ -19,7 +19,8 @@ import java.util.List; import java.util.Random; -import static grep.neogul_coder.domain.study.enums.StudyMemberRole.*; +import static grep.neogul_coder.domain.study.enums.StudyMemberRole.LEADER; +import static grep.neogul_coder.domain.study.enums.StudyMemberRole.MEMBER; import static grep.neogul_coder.domain.study.exception.code.StudyErrorCode.*; @Transactional(readOnly = true) diff --git a/src/main/java/grep/neogul_coder/domain/study/service/StudySchedulerService.java b/src/main/java/grep/neogul_coder/domain/study/service/StudySchedulerService.java new file mode 100644 index 00000000..6128638d --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/study/service/StudySchedulerService.java @@ -0,0 +1,37 @@ +package grep.neogul_coder.domain.study.service; + +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.repository.StudyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class StudySchedulerService { + + private final StudyRepository studyRepository; + + public List findStudiesEndingIn7Days() { + LocalDate targetEndDate = LocalDate.now().plusDays(7); + LocalDateTime endDateStart = targetEndDate.atStartOfDay(); + LocalDateTime endDateEnd = targetEndDate.plusDays(1).atStartOfDay(); + + return studyRepository.findStudiesEndingIn7Days(endDateStart, endDateEnd); + } + + @Transactional + public void finalizeStudies() { + LocalDateTime now = LocalDateTime.now(); + List studiesToBeFinished = studyRepository.findStudiesToBeFinished(now); + + for (Study study : studiesToBeFinished) { + study.finish(); + } + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studyapplication/StudyApplication.java b/src/main/java/grep/neogul_coder/domain/studyapplication/StudyApplication.java index 3c9dea1e..c74abb86 100644 --- a/src/main/java/grep/neogul_coder/domain/studyapplication/StudyApplication.java +++ b/src/main/java/grep/neogul_coder/domain/studyapplication/StudyApplication.java @@ -3,13 +3,15 @@ import grep.neogul_coder.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.Builder; +import lombok.Getter; +@Getter @Entity public class StudyApplication extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long studyApplicationId; + private Long id; @Column(nullable = false) private Long recruitmentPostId; @@ -35,4 +37,12 @@ private StudyApplication(Long recruitmentPostId, String applicationReason, Appli } protected StudyApplication() {} + + public void approve() { + this.status = ApplicationStatus.APPROVED; + } + + public void reject() { + this.status = ApplicationStatus.REJECTED; + } } diff --git a/src/main/java/grep/neogul_coder/domain/studyapplication/controller/ApplicationController.java b/src/main/java/grep/neogul_coder/domain/studyapplication/controller/ApplicationController.java index af2b0279..2f82ef1a 100644 --- a/src/main/java/grep/neogul_coder/domain/studyapplication/controller/ApplicationController.java +++ b/src/main/java/grep/neogul_coder/domain/studyapplication/controller/ApplicationController.java @@ -2,33 +2,48 @@ import grep.neogul_coder.domain.studyapplication.controller.dto.request.ApplicationCreateRequest; import grep.neogul_coder.domain.studyapplication.controller.dto.response.MyApplicationResponse; +import grep.neogul_coder.domain.studyapplication.service.ApplicationService; +import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; -@RequestMapping("/api/applications") +@RequestMapping("/api/recruitment-posts") +@RequiredArgsConstructor @RestController public class ApplicationController implements ApplicationSpecification { - @GetMapping("/me") - public ApiResponse> getMyStudyApplications() { - return ApiResponse.success(List.of(new MyApplicationResponse())); + private final ApplicationService applicationService; + + @GetMapping("/{recruitment-post-id}/applications") + public ApiResponse> getMyStudyApplications(@PathVariable("recruitment-post-id") Long recruitmentPostId, + @AuthenticationPrincipal Principal userDetails) { + return ApiResponse.success(applicationService.getMyStudyApplications(userDetails.getUserId())); } - @PostMapping - public ApiResponse createApplication(@RequestBody @Valid ApplicationCreateRequest request) { - return ApiResponse.noContent(); + @PostMapping("/{recruitment-post-id}/applications") + public ApiResponse createApplication(@PathVariable("recruitment-post-id") Long recruitmentPostId, + @RequestBody @Valid ApplicationCreateRequest request, + @AuthenticationPrincipal Principal userDetails) { + Long id = applicationService.createApplication(recruitmentPostId, request, userDetails.getUserId()); + return ApiResponse.success(id); } - @PostMapping("/{applicationId}/approve") - public ApiResponse approveApplication(@PathVariable("applicationId") Long applicationId) { + @PostMapping("/applications/{applicationId}/approve") + public ApiResponse approveApplication(@PathVariable("applicationId") Long applicationId, + @AuthenticationPrincipal Principal userDetails) { + applicationService.approveApplication(applicationId, userDetails.getUserId()); return ApiResponse.noContent(); } - @PostMapping("/{applicationId}/reject") - public ApiResponse rejectApplication(@PathVariable("applicationId") Long applicationId) { + @PostMapping("/applications/{applicationId}/reject") + public ApiResponse rejectApplication(@PathVariable("applicationId") Long applicationId, + @AuthenticationPrincipal Principal userDetails) { + applicationService.rejectApplication(applicationId, userDetails.getUserId()); return ApiResponse.noContent(); } } diff --git a/src/main/java/grep/neogul_coder/domain/studyapplication/controller/ApplicationSpecification.java b/src/main/java/grep/neogul_coder/domain/studyapplication/controller/ApplicationSpecification.java index 57d9f4c8..fbc6a9fb 100644 --- a/src/main/java/grep/neogul_coder/domain/studyapplication/controller/ApplicationSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/studyapplication/controller/ApplicationSpecification.java @@ -2,6 +2,7 @@ import grep.neogul_coder.domain.studyapplication.controller.dto.request.ApplicationCreateRequest; import grep.neogul_coder.domain.studyapplication.controller.dto.response.MyApplicationResponse; +import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -12,14 +13,14 @@ public interface ApplicationSpecification { @Operation(summary = "내 스터디 신청 목록 조회", description = "내가 신청한 스터디의 목록을 조회합니다.") - ApiResponse> getMyStudyApplications(); + ApiResponse> getMyStudyApplications(Long recruitmentPostId, Principal userDetails); @Operation(summary = "스터디 신청 생성", description = "스터디를 신청합니다.") - ApiResponse createApplication(ApplicationCreateRequest request); + ApiResponse createApplication(Long recruitmentPostId, ApplicationCreateRequest request, Principal userDetails); @Operation(summary = "스터디 신청 승인", description = "스터디장이 스터디 신청을 승인합니다.") - ApiResponse approveApplication(Long applicationId); + ApiResponse approveApplication(Long applicationId, Principal userDetails); @Operation(summary = "스터디 신청 거절", description = "스터디장이 스터디 신청을 거절합니다.") - ApiResponse rejectApplication(Long applicationId); + ApiResponse rejectApplication(Long applicationId, Principal userDetails); } diff --git a/src/main/java/grep/neogul_coder/domain/studyapplication/controller/dto/request/ApplicationCreateRequest.java b/src/main/java/grep/neogul_coder/domain/studyapplication/controller/dto/request/ApplicationCreateRequest.java index fb634caa..15f28be0 100644 --- a/src/main/java/grep/neogul_coder/domain/studyapplication/controller/dto/request/ApplicationCreateRequest.java +++ b/src/main/java/grep/neogul_coder/domain/studyapplication/controller/dto/request/ApplicationCreateRequest.java @@ -1,7 +1,10 @@ package grep.neogul_coder.domain.studyapplication.controller.dto.request; +import grep.neogul_coder.domain.studyapplication.ApplicationStatus; +import grep.neogul_coder.domain.studyapplication.StudyApplication; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import lombok.Builder; import lombok.Getter; @Getter @@ -10,4 +13,22 @@ public class ApplicationCreateRequest { @NotBlank @Schema(description = "스터디 신청 지원 동기", example = "자바를 더 공부하고싶어 지원하였습니다.") private String applicationReason; + + private ApplicationCreateRequest() { + } + + @Builder + private ApplicationCreateRequest(String applicationReason) { + this.applicationReason = applicationReason; + } + + public StudyApplication toEntity(Long recruitmentPostId, Long userId) { + return StudyApplication.builder() + .recruitmentPostId(recruitmentPostId) + .userId(userId) + .applicationReason(this.applicationReason) + .isRead(false) + .status(ApplicationStatus.APPLYING) + .build(); + } } diff --git a/src/main/java/grep/neogul_coder/domain/studyapplication/controller/dto/response/MyApplicationResponse.java b/src/main/java/grep/neogul_coder/domain/studyapplication/controller/dto/response/MyApplicationResponse.java index 2b7d62b1..a4f388e8 100644 --- a/src/main/java/grep/neogul_coder/domain/studyapplication/controller/dto/response/MyApplicationResponse.java +++ b/src/main/java/grep/neogul_coder/domain/studyapplication/controller/dto/response/MyApplicationResponse.java @@ -1,16 +1,20 @@ package grep.neogul_coder.domain.studyapplication.controller.dto.response; +import com.querydsl.core.annotations.QueryProjection; import grep.neogul_coder.domain.study.enums.Category; import grep.neogul_coder.domain.study.enums.StudyType; import grep.neogul_coder.domain.studyapplication.ApplicationStatus; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; -import java.time.LocalDate; +import java.time.LocalDateTime; @Getter public class MyApplicationResponse { + @Schema(description = "신청 번호", example = "1") + private Long applicationId; + @Schema(description = "스터디 이름", example = "자바 스터디") private String name; @@ -24,7 +28,7 @@ public class MyApplicationResponse { private int currentCount; @Schema(description = "시작일", example = "2025-07-15") - private LocalDate startDate; + private LocalDateTime startDate; @Schema(description = "대표 이미지", example = "http://localhost:8083/image.jpg") private String imageUrl; @@ -43,4 +47,21 @@ public class MyApplicationResponse { @Schema(description = "신청 상태", example = "PENDING") private ApplicationStatus status; + + @QueryProjection + public MyApplicationResponse(Long applicationId, String name, String leaderNickname, int capacity, int currentCount, LocalDateTime startDate, + String imageUrl,String introduction, Category category, StudyType studyType, boolean isRead, ApplicationStatus status) { + this.applicationId = applicationId; + this.name = name; + this.leaderNickname = leaderNickname; + this.capacity = capacity; + this.currentCount = currentCount; + this.startDate = startDate; + this.imageUrl = imageUrl; + this.introduction = introduction; + this.category = category; + this.studyType = studyType; + this.isRead = isRead; + this.status = status; + } } diff --git a/src/main/java/grep/neogul_coder/domain/studyapplication/exception/code/ApplicationErrorCode.java b/src/main/java/grep/neogul_coder/domain/studyapplication/exception/code/ApplicationErrorCode.java new file mode 100644 index 00000000..5e5f268e --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studyapplication/exception/code/ApplicationErrorCode.java @@ -0,0 +1,27 @@ +package grep.neogul_coder.domain.studyapplication.exception.code; + +import grep.neogul_coder.global.response.code.ErrorCode; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ApplicationErrorCode implements ErrorCode { + APPLICATION_NOT_FOUND("SA001",HttpStatus.NOT_FOUND,"신청서를 찾을 수 없습니다."), + + ALREADY_APPLICATION("SA002", HttpStatus.BAD_REQUEST, "이미 지원한 모집글입니다."), + APPLICATION_NOT_APPLYING("SA003", HttpStatus.BAD_REQUEST, "신청 상태가 APPLYING이 아닙니다."), + + LEADER_CANNOT_APPLY("SA004", HttpStatus.BAD_REQUEST, "스터디장은 스터디를 신청할 수 없습니다."), + LEADER_ONLY_APPROVED("SA005", HttpStatus.BAD_REQUEST, "스터디장만 승인이 가능합니다."), + LEADER_ONLY_REJECTED("SA006", HttpStatus.BAD_REQUEST, "스터디장만 거절이 가능합니다."); + + private final String code; + private final HttpStatus status; + private final String message; + + ApplicationErrorCode(String code, HttpStatus status, String message) { + this.code = code; + this.status = status; + this.message = message; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studyapplication/repository/ApplicationQueryRepository.java b/src/main/java/grep/neogul_coder/domain/studyapplication/repository/ApplicationQueryRepository.java new file mode 100644 index 00000000..a35640e2 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studyapplication/repository/ApplicationQueryRepository.java @@ -0,0 +1,51 @@ +package grep.neogul_coder.domain.studyapplication.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import grep.neogul_coder.domain.studyapplication.controller.dto.response.MyApplicationResponse; +import grep.neogul_coder.domain.studyapplication.controller.dto.response.QMyApplicationResponse; +import jakarta.persistence.EntityManager; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static grep.neogul_coder.domain.recruitment.post.QRecruitmentPost.recruitmentPost; +import static grep.neogul_coder.domain.study.QStudy.study; +import static grep.neogul_coder.domain.study.QStudyMember.studyMember; +import static grep.neogul_coder.domain.study.enums.StudyMemberRole.LEADER; +import static grep.neogul_coder.domain.studyapplication.QStudyApplication.studyApplication; +import static grep.neogul_coder.domain.users.entity.QUser.user; + +@Repository +public class ApplicationQueryRepository { + + private final JPAQueryFactory queryFactory; + + public ApplicationQueryRepository(EntityManager em) { + this.queryFactory = new JPAQueryFactory(em); + } + + public List findMyApplications(Long userId) { + return queryFactory + .select(new QMyApplicationResponse( + studyApplication.id, + study.name, + user.nickname, + study.capacity, + study.currentCount, + study.startDate, + study.imageUrl, + study.introduction, + study.category, + study.studyType, + studyApplication.isRead, + studyApplication.status + )) + .from(studyApplication) + .join(recruitmentPost).on(recruitmentPost.id.eq(studyApplication.recruitmentPostId)) + .join(study).on(study.id.eq(recruitmentPost.studyId)) + .join(studyMember).on(studyMember.study.id.eq(study.id), studyMember.role.eq(LEADER)) + .join(user).on(user.id.eq(studyMember.userId)) + .where(studyApplication.userId.eq(userId)) + .fetch(); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studyapplication/repository/StudyApplicationRepository.java b/src/main/java/grep/neogul_coder/domain/studyapplication/repository/ApplicationRepository.java similarity index 51% rename from src/main/java/grep/neogul_coder/domain/studyapplication/repository/StudyApplicationRepository.java rename to src/main/java/grep/neogul_coder/domain/studyapplication/repository/ApplicationRepository.java index 25c6de3f..ad214d2c 100644 --- a/src/main/java/grep/neogul_coder/domain/studyapplication/repository/StudyApplicationRepository.java +++ b/src/main/java/grep/neogul_coder/domain/studyapplication/repository/ApplicationRepository.java @@ -4,7 +4,12 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; -public interface StudyApplicationRepository extends JpaRepository { +public interface ApplicationRepository extends JpaRepository { List findByRecruitmentPostId(Long recruitmentPostId); + + boolean existsByRecruitmentPostIdAndUserId(Long recruitmentPostId, Long userId); + + Optional findByIdAndActivatedTrue(Long applicationId); } diff --git a/src/main/java/grep/neogul_coder/domain/studyapplication/service/ApplicationService.java b/src/main/java/grep/neogul_coder/domain/studyapplication/service/ApplicationService.java new file mode 100644 index 00000000..bfae0aaf --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studyapplication/service/ApplicationService.java @@ -0,0 +1,135 @@ +package grep.neogul_coder.domain.studyapplication.service; + +import grep.neogul_coder.domain.recruitment.post.RecruitmentPost; +import grep.neogul_coder.domain.recruitment.post.repository.RecruitmentPostRepository; +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.StudyMember; +import grep.neogul_coder.domain.study.enums.StudyMemberRole; +import grep.neogul_coder.domain.study.repository.StudyMemberRepository; +import grep.neogul_coder.domain.study.repository.StudyRepository; +import grep.neogul_coder.domain.studyapplication.ApplicationStatus; +import grep.neogul_coder.domain.studyapplication.StudyApplication; +import grep.neogul_coder.domain.studyapplication.controller.dto.request.ApplicationCreateRequest; +import grep.neogul_coder.domain.studyapplication.controller.dto.response.MyApplicationResponse; +import grep.neogul_coder.domain.studyapplication.repository.ApplicationQueryRepository; +import grep.neogul_coder.domain.studyapplication.repository.ApplicationRepository; +import grep.neogul_coder.global.exception.business.BusinessException; +import grep.neogul_coder.global.exception.business.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static grep.neogul_coder.domain.recruitment.RecruitmentErrorCode.NOT_FOUND; +import static grep.neogul_coder.domain.study.exception.code.StudyErrorCode.STUDY_NOT_FOUND; +import static grep.neogul_coder.domain.studyapplication.exception.code.ApplicationErrorCode.*; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class ApplicationService { + + private final ApplicationRepository applicationRepository; + private final ApplicationQueryRepository applicationQueryRepository; + private final RecruitmentPostRepository recruitmentPostRepository; + private final StudyMemberRepository studyMemberRepository; + private final StudyRepository studyRepository; + + public List getMyStudyApplications(Long userId) { + return applicationQueryRepository.findMyApplications(userId); + } + + @Transactional + public Long createApplication(Long recruitmentPostId, ApplicationCreateRequest request, Long userId) { + RecruitmentPost recruitmentPost = findValidRecruimentPost(recruitmentPostId); + + validateNotLeaderApply(recruitmentPost, userId); + validateNotAlreadyApplied(recruitmentPostId, userId); + + StudyApplication application = request.toEntity(recruitmentPostId, userId); + applicationRepository.save(application); + + return application.getId(); + } + + @Transactional + public void approveApplication(Long applicationId, Long userId) { + StudyApplication application = findValidApplication(applicationId); + RecruitmentPost post = findValidRecruimentPost(application.getRecruitmentPostId()); + Study study = findValidStudy(post); + + validateOnlyLeaderCanApprove(study, userId); + validateStatusIsApplying(application); + + application.approve(); + + StudyMember studyMember = StudyMember.createMember(study, application.getUserId()); + studyMemberRepository.save(studyMember); + study.increaseMemberCount(); + } + + @Transactional + public void rejectApplication(Long applicationId, Long userId) { + StudyApplication application = findValidApplication(applicationId); + RecruitmentPost post = findValidRecruimentPost(application.getRecruitmentPostId()); + Study study = findValidStudy(post); + + validateOnlyLeaderCanReject(study, userId); + validateStatusIsApplying(application); + + application.reject(); + } + + private Study findValidStudy(RecruitmentPost post) { + Study study = studyRepository.findByIdAndActivatedTrue(post.getStudyId()) + .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); + return study; + } + + private StudyApplication findValidApplication(Long applicationId) { + StudyApplication application = applicationRepository.findById(applicationId) + .orElseThrow(() -> new NotFoundException(APPLICATION_NOT_FOUND)); + return application; + } + + private RecruitmentPost findValidRecruimentPost(Long recruitmentPostId) { + RecruitmentPost post = recruitmentPostRepository.findByIdAndActivatedTrue(recruitmentPostId) + .orElseThrow(() -> new NotFoundException(NOT_FOUND)); + return post; + } + + private void validateNotLeaderApply(RecruitmentPost recruitmentPost, Long userId) { + boolean isLeader = studyMemberRepository.existsByStudyIdAndUserIdAndRole(recruitmentPost.getStudyId(), userId, StudyMemberRole.LEADER); + if (isLeader) { + throw new BusinessException(LEADER_CANNOT_APPLY); + } + } + + private void validateNotAlreadyApplied(Long recruitmentPostId, Long userId) { + boolean alreadyApplied = applicationRepository.existsByRecruitmentPostIdAndUserId(recruitmentPostId, userId); + if (alreadyApplied) { + throw new BusinessException(ALREADY_APPLICATION); + } + } + + private static void validateStatusIsApplying(StudyApplication application) { + if (application.getStatus() != ApplicationStatus.APPLYING) { + throw new BusinessException(APPLICATION_NOT_APPLYING); + } + } + + private void validateOnlyLeaderCanApprove(Study study, Long userId) { + boolean isLeader = studyMemberRepository.existsByStudyIdAndUserIdAndRole(study.getId(), userId, StudyMemberRole.LEADER); + if (!isLeader) { + throw new BusinessException(LEADER_ONLY_APPROVED); + } + } + + private void validateOnlyLeaderCanReject(Study study, Long userId) { + boolean isLeader = studyMemberRepository.existsByStudyIdAndUserIdAndRole(study.getId(), userId, StudyMemberRole.LEADER); + if (!isLeader) { + throw new BusinessException(LEADER_ONLY_REJECTED); + } + } +} diff --git a/src/main/java/grep/neogul_coder/domain/timevote/controller/TimeVoteController.java b/src/main/java/grep/neogul_coder/domain/timevote/controller/TimeVoteController.java index 8bde0482..93d5f837 100644 --- a/src/main/java/grep/neogul_coder/domain/timevote/controller/TimeVoteController.java +++ b/src/main/java/grep/neogul_coder/domain/timevote/controller/TimeVoteController.java @@ -8,7 +8,6 @@ import grep.neogul_coder.domain.timevote.dto.response.TimeVoteResponse; import grep.neogul_coder.domain.timevote.dto.response.TimeVoteStatListResponse; import grep.neogul_coder.domain.timevote.dto.response.TimeVoteSubmissionStatusResponse; -import grep.neogul_coder.domain.timevote.service.TimeVotePeriodService; import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; import jakarta.validation.Valid; @@ -30,8 +29,6 @@ @RequestMapping("/api/studies/{studyId}/time-vote") public class TimeVoteController implements TimeVoteSpecification { - private final TimeVotePeriodService timeVotePeriodService; - @PostMapping("/periods") public ApiResponse createPeriod( @PathVariable("studyId") Long studyId, diff --git a/src/main/java/grep/neogul_coder/domain/users/controller/UserController.java b/src/main/java/grep/neogul_coder/domain/users/controller/UserController.java index 63ddb2c4..0a5fd2c1 100644 --- a/src/main/java/grep/neogul_coder/domain/users/controller/UserController.java +++ b/src/main/java/grep/neogul_coder/domain/users/controller/UserController.java @@ -4,25 +4,36 @@ import grep.neogul_coder.domain.users.controller.dto.request.SignUpRequest; import grep.neogul_coder.domain.users.controller.dto.request.UpdatePasswordRequest; import grep.neogul_coder.domain.users.controller.dto.response.UserResponse; +import grep.neogul_coder.domain.users.service.EmailVerificationService; import grep.neogul_coder.domain.users.service.UserService; import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; import jakarta.validation.Valid; +import java.io.IOException; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; - @RestController @RequiredArgsConstructor @RequestMapping("/api/users") public class UserController implements UserSpecification { private final UserService usersService; + private final EmailVerificationService verificationService; @GetMapping("/me") public ApiResponse get(@AuthenticationPrincipal Principal principal) { @@ -38,9 +49,9 @@ public ApiResponse get(@PathVariable("userid") Long userId) { @PutMapping(value = "/update/profile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse updateProfile( - @AuthenticationPrincipal Principal principal, - @RequestPart("nickname") String nickname, - @RequestPart(value = "profileImage", required = false) MultipartFile profileImage + @AuthenticationPrincipal Principal principal, + @RequestPart("nickname") String nickname, + @RequestPart(value = "profileImage", required = false) MultipartFile profileImage ) throws IOException { usersService.updateProfile(principal.getUserId(), nickname, profileImage); return ApiResponse.noContent(); @@ -48,14 +59,15 @@ public ApiResponse updateProfile( @PutMapping("/update/password") public ApiResponse updatePassword(@AuthenticationPrincipal Principal principal, - @Valid @RequestBody UpdatePasswordRequest request) { - usersService.updatePassword(principal.getUserId(), request.getPassword(), request.getNewPassword(), request.getNewPasswordCheck()); + @Valid @RequestBody UpdatePasswordRequest request) { + usersService.updatePassword(principal.getUserId(), request.getPassword(), + request.getNewPassword(), request.getNewPasswordCheck()); return ApiResponse.noContent(); } @DeleteMapping("/delete/me") public ApiResponse delete(@AuthenticationPrincipal Principal principal, - @RequestBody @Valid PasswordRequest request) { + @RequestBody @Valid PasswordRequest request) { usersService.deleteUser(principal.getUserId(), request.getPassword()); return ApiResponse.noContent(); } @@ -67,4 +79,21 @@ public ApiResponse signUp(@Valid @RequestBody SignUpRequest request) { return ApiResponse.noContent(); } + @PostMapping("/mail/send") + public ApiResponse sendCode(@RequestParam String email) { + verificationService.sendVerificationEmail(email); + return ApiResponse.noContent(); + } + + @PostMapping("/mail/verify") + public ApiResponse verifyCode( + @RequestParam String email, + @RequestParam String code + ) { + boolean result = verificationService.verifyCode(email, code); + return result ? + ApiResponse.noContent() : + ApiResponse.badRequest(); + } + } \ No newline at end of file diff --git a/src/main/java/grep/neogul_coder/domain/users/controller/UserSpecification.java b/src/main/java/grep/neogul_coder/domain/users/controller/UserSpecification.java index a9ea9c9e..e68c9425 100644 --- a/src/main/java/grep/neogul_coder/domain/users/controller/UserSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/users/controller/UserSpecification.java @@ -7,10 +7,12 @@ import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.tags.Tag; import java.io.IOException; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; @@ -36,4 +38,19 @@ ApiResponse updatePassword(@AuthenticationPrincipal Principal principal, @Operation(summary = "회원 상태 삭제로 변경", description = "회원 상태를 삭제로 변경합니다.") ApiResponse delete(@AuthenticationPrincipal Principal principal, @RequestBody PasswordRequest request); + + @Operation(summary = "이메일 인증 코드 발송", description = "입력한 이메일 주소로 인증 코드를 발송합니다.") + ApiResponse sendCode( + @Parameter(description = "인증 코드를 보낼 이메일 주소", required = true, example = "user@example.com") + @RequestParam String email + ); + + @Operation(summary = "이메일 인증 코드 검증", description = "사용자가 입력한 인증 코드가 올바른지 검증합니다.") + ApiResponse verifyCode( + @Parameter(description = "인증 요청한 이메일 주소", required = true, example = "user@example.com") + @RequestParam String email, + + @Parameter(description = "사용자가 입력한 인증 코드", required = true, example = "123456") + @RequestParam String code + ); } \ No newline at end of file diff --git a/src/main/java/grep/neogul_coder/domain/users/controller/dto/response/UserResponse.java b/src/main/java/grep/neogul_coder/domain/users/controller/dto/response/UserResponse.java index 38fa5de5..4ce853d4 100644 --- a/src/main/java/grep/neogul_coder/domain/users/controller/dto/response/UserResponse.java +++ b/src/main/java/grep/neogul_coder/domain/users/controller/dto/response/UserResponse.java @@ -20,24 +20,29 @@ public class UserResponse { @Schema(description = "회원 프로필 이미지", example = "profileImageUrl") private String profileImageUrl; + @Schema(description = "회원 OAuth 정보", example = "Google") + private String oauth; + @Schema(description = "Role") private Role role; @Builder - private UserResponse(Long id, String email, String nickname,String profileImageUrl, Role role) { + private UserResponse(Long id, String email, String nickname,String profileImageUrl, String oauth ,Role role) { this.id = id; this.email = email; this.nickname = nickname; this.profileImageUrl = profileImageUrl; + this.oauth = oauth; this.role = role; } - public static UserResponse toUserResponse(Long id, String email, String nickname,String profileImageUrl, Role role){ + public static UserResponse toUserResponse(Long id, String email, String nickname,String profileImageUrl, String oauth, Role role){ return UserResponse.builder() .id(id) .email(email) .nickname(nickname) .profileImageUrl(profileImageUrl) + .oauth(oauth) .role(role) .build(); } diff --git a/src/main/java/grep/neogul_coder/domain/users/entity/User.java b/src/main/java/grep/neogul_coder/domain/users/entity/User.java index d46f8321..49c19829 100644 --- a/src/main/java/grep/neogul_coder/domain/users/entity/User.java +++ b/src/main/java/grep/neogul_coder/domain/users/entity/User.java @@ -2,7 +2,13 @@ import grep.neogul_coder.global.auth.code.Role; import grep.neogul_coder.global.entity.BaseEntity; -import jakarta.persistence.*; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; import jakarta.validation.constraints.Email; import lombok.Builder; import lombok.Getter; @@ -34,11 +40,12 @@ public class User extends BaseEntity { public static User UserInit(String email, String password, String nickname) { return User.builder() - .email(email) - .password(password) - .nickname(nickname) - .role(Role.ROLE_USER) - .build(); + .email(email) + .password(password) + .nickname(nickname) + .role(Role.ROLE_USER) + .activated(true) + .build(); } public void updateProfile(String nickname, String profileImageUrl) { @@ -58,7 +65,7 @@ public void delete() { @Builder private User(Long id, String oauthId, String oauthProvider, String email, String password, - String nickname, String profileImageUrl, Role role) { + String nickname, String profileImageUrl, Boolean activated, Role role) { this.id = id; this.oauthId = oauthId; this.oauthProvider = oauthProvider; @@ -66,6 +73,7 @@ private User(Long id, String oauthId, String oauthProvider, String email, String this.password = password; this.nickname = nickname; this.profileImageUrl = profileImageUrl; + this.activated = activated; this.role = role; } diff --git a/src/main/java/grep/neogul_coder/domain/users/exception/NotVerifiedEmailException.java b/src/main/java/grep/neogul_coder/domain/users/exception/NotVerifiedEmailException.java new file mode 100644 index 00000000..1c9d725d --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/users/exception/NotVerifiedEmailException.java @@ -0,0 +1,11 @@ +package grep.neogul_coder.domain.users.exception; + +import grep.neogul_coder.global.exception.business.BusinessException; +import grep.neogul_coder.global.response.code.ErrorCode; + +public class NotVerifiedEmailException extends BusinessException { + + public NotVerifiedEmailException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/users/exception/UnActivatedUserException.java b/src/main/java/grep/neogul_coder/domain/users/exception/UnActivatedUserException.java new file mode 100644 index 00000000..62114177 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/users/exception/UnActivatedUserException.java @@ -0,0 +1,11 @@ +package grep.neogul_coder.domain.users.exception; + +import grep.neogul_coder.global.exception.business.BusinessException; +import grep.neogul_coder.global.response.code.ErrorCode; + +public class UnActivatedUserException extends BusinessException { + + public UnActivatedUserException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/users/exception/advice/UserAdvice.java b/src/main/java/grep/neogul_coder/domain/users/exception/advice/UserAdvice.java index f100594e..d9692afb 100644 --- a/src/main/java/grep/neogul_coder/domain/users/exception/advice/UserAdvice.java +++ b/src/main/java/grep/neogul_coder/domain/users/exception/advice/UserAdvice.java @@ -2,6 +2,7 @@ import grep.neogul_coder.domain.users.exception.EmailDuplicationException; import grep.neogul_coder.domain.users.exception.NicknameDuplicatedException; +import grep.neogul_coder.domain.users.exception.NotVerifiedEmailException; import grep.neogul_coder.domain.users.exception.PasswordNotMatchException; import grep.neogul_coder.domain.users.exception.PasswordUncheckException; import grep.neogul_coder.domain.users.exception.UserNotFoundException; @@ -50,4 +51,11 @@ public ResponseEntity> nicknameDuplicationException(NicknameDu .body(ApiResponse.errorWithoutData(UserErrorCode.IS_DUPLICATED_NICKNAME)); } + @ ExceptionHandler(NotVerifiedEmailException.class) + public ResponseEntity> notVerifiedEmailException(NotVerifiedEmailException ex) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.errorWithoutData(UserErrorCode.NOT_VERIFIED_EMAIL)); + } + } \ No newline at end of file diff --git a/src/main/java/grep/neogul_coder/domain/users/exception/code/UserErrorCode.java b/src/main/java/grep/neogul_coder/domain/users/exception/code/UserErrorCode.java index 9958e581..5a2899ae 100644 --- a/src/main/java/grep/neogul_coder/domain/users/exception/code/UserErrorCode.java +++ b/src/main/java/grep/neogul_coder/domain/users/exception/code/UserErrorCode.java @@ -11,7 +11,9 @@ public enum UserErrorCode implements ErrorCode { PASSWORD_MISMATCH("U002", HttpStatus.BAD_REQUEST, "비밀번호를 다시 확인해주세요."), PASSWORD_UNCHECKED("U003", HttpStatus.BAD_REQUEST, "비밀번호와 비밀번호 확인이 다릅니다"), IS_DUPLICATED_MALI("U004", HttpStatus.BAD_REQUEST,"이미 존재하는 이메일입니다."), - IS_DUPLICATED_NICKNAME("U005", HttpStatus.BAD_REQUEST,"이미 존재하는 닉네임입니다."); + IS_DUPLICATED_NICKNAME("U005", HttpStatus.BAD_REQUEST,"이미 존재하는 닉네임입니다."), + UNACTIVATED_USER("U006", HttpStatus.BAD_REQUEST,"탈퇴된 회원입니다."), + NOT_VERIFIED_EMAIL("U007", HttpStatus.BAD_REQUEST, "메일 인증이 되지 않은 이메일입니다."); private final String code; diff --git a/src/main/java/grep/neogul_coder/domain/users/service/EmailVerificationService.java b/src/main/java/grep/neogul_coder/domain/users/service/EmailVerificationService.java new file mode 100644 index 00000000..309a69d5 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/users/service/EmailVerificationService.java @@ -0,0 +1,74 @@ +package grep.neogul_coder.domain.users.service; + +import java.time.Duration; +import java.util.Random; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmailVerificationService { + + private final JavaMailSender mailSender; + private final RedisTemplate redisTemplate; + + private static final long CODE_TTL_SECONDS = 300; + + public void sendVerificationEmail(String email) { + String code = generateRandomCode(); + + sendEmail(email,code); + + redisTemplate.opsForValue().set(getRedisKey(email), code, Duration.ofSeconds(CODE_TTL_SECONDS)); + } + + public boolean verifyCode(String email, String inputCode) { + String redisKey = getRedisKey(email); + Object storedCode = redisTemplate.opsForValue().get(getRedisKey(email)); + + + if (isValidCode(storedCode,inputCode)) { + redisTemplate.delete(redisKey); + + redisTemplate.opsForValue().set(getVerifiedKey(email), "true", Duration.ofMinutes(10)); + return true; + } + return false; + } + + public boolean isNotEmailVerified(String email) { + Object value = redisTemplate.opsForValue().get(getVerifiedKey(email)); + return !"true".equals(value); + } + + public void clearVerifiedStatus(String email) { + redisTemplate.delete(getVerifiedKey(email)); + } + + private void sendEmail(String to, String code) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); + message.setSubject("[wibby] 이메일 인증 코드"); + message.setText("인증 코드: " + code + "\n5분 안에 입력해주세요."); + mailSender.send(message); + } + + private String generateRandomCode() { + return String.format("%06d", new Random().nextInt(1000000)); + } + + private String getRedisKey(String email) { + return "email_verification:" + email; + } + + private String getVerifiedKey(String email) { + return "email_verified:" + email; + } + + private boolean isValidCode(Object storedCode, String inputCode) { + return storedCode != null && storedCode.equals(inputCode); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/users/service/UserService.java b/src/main/java/grep/neogul_coder/domain/users/service/UserService.java index 644e0b77..794bddcd 100644 --- a/src/main/java/grep/neogul_coder/domain/users/service/UserService.java +++ b/src/main/java/grep/neogul_coder/domain/users/service/UserService.java @@ -13,6 +13,7 @@ import grep.neogul_coder.domain.users.entity.User; import grep.neogul_coder.domain.users.exception.EmailDuplicationException; import grep.neogul_coder.domain.users.exception.NicknameDuplicatedException; +import grep.neogul_coder.domain.users.exception.NotVerifiedEmailException; import grep.neogul_coder.domain.users.exception.PasswordNotMatchException; import grep.neogul_coder.domain.users.exception.UserNotFoundException; import grep.neogul_coder.domain.users.exception.code.UserErrorCode; @@ -22,6 +23,7 @@ import grep.neogul_coder.global.utils.upload.uploader.GcpFileUploader; import grep.neogul_coder.global.utils.upload.uploader.LocalFileUploader; import jakarta.transaction.Transactional; +import jakarta.validation.constraints.NotBlank; import java.io.IOException; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; @@ -43,6 +45,7 @@ public class UserService { private final LinkService linkService; private final StudyManagementService studyManagementService; private final BuddyEnergyService buddyEnergyService; + private final EmailVerificationService verificationService; @Autowired(required = false) private GcpFileUploader gcpFileUploader; @@ -68,6 +71,10 @@ public void signUp(SignUpRequest request) { duplicationCheck(request.getEmail(), request.getNickname()); + if (verificationService.isNotEmailVerified(request.getEmail())) { + throw new NotVerifiedEmailException(UserErrorCode.NOT_VERIFIED_EMAIL); + } + if (isNotMatchPasswordCheck(request.getPassword(), request.getPasswordCheck())) { throw new PasswordNotMatchException(UserErrorCode.PASSWORD_MISMATCH); } @@ -77,15 +84,9 @@ public void signUp(SignUpRequest request) { User.UserInit(request.getEmail(), encodedPassword, request.getNickname())); User user = findUser(request.getEmail()); + initializeUserData(user.getId()); - prTemplateRepository.save( - PrTemplate.PrTemplateInit(user.getId(), null, null)); - - linkRepository.save(Link.LinkInit(user.getId(), null, null)); - linkRepository.save(Link.LinkInit(user.getId(), null, null)); - - // 회원가입 시 버디 에너지 +50 생성 - buddyEnergyService.createDefaultEnergy(user.getId()); + verificationService.clearVerifiedStatus(request.getEmail()); } @Transactional @@ -93,7 +94,9 @@ public void updateProfile(Long userId, String nickname, MultipartFile profileIma throws IOException { User user = findUser(userId); - isDuplicateNickname(nickname); + if(isDuplicateNickname(nickname)){ + throw new NicknameDuplicatedException(UserErrorCode.IS_DUPLICATED_NICKNAME); + } String uploadedImageUrl; if (isProfileImgExists(profileImage)) { @@ -147,6 +150,13 @@ public void deleteUser(Long userId) { user.delete(); } + public void initializeUserData(Long userId) { + prTemplateRepository.save(PrTemplate.PrTemplateInit(userId, null, null)); + linkRepository.save(Link.LinkInit(userId, null, null)); + linkRepository.save(Link.LinkInit(userId, null, null)); + buddyEnergyService.createDefaultEnergy(userId); + } + public UserResponse getUserResponse(Long userId) { User user = get(userId); return UserResponse.toUserResponse( @@ -154,6 +164,7 @@ public UserResponse getUserResponse(Long userId) { user.getEmail(), user.getNickname(), user.getProfileImageUrl(), + user.getOauthProvider(), user.getRole()); } @@ -211,7 +222,6 @@ private boolean isProductionEnvironment() { private boolean isProfileImgExists(MultipartFile profileImage) { return profileImage != null && !profileImage.isEmpty(); } - } diff --git a/src/main/java/grep/neogul_coder/global/auth/entity/RefreshToken.java b/src/main/java/grep/neogul_coder/global/auth/entity/RefreshToken.java index 600e5090..0d36c8d0 100644 --- a/src/main/java/grep/neogul_coder/global/auth/entity/RefreshToken.java +++ b/src/main/java/grep/neogul_coder/global/auth/entity/RefreshToken.java @@ -12,9 +12,6 @@ public class RefreshToken { private String token = UUID.randomUUID().toString(); private Long ttl = 3600 * 24 * 7L; - public RefreshToken() { - } - public RefreshToken(String atId){ this.atId = atId; } diff --git a/src/main/java/grep/neogul_coder/global/auth/service/AuthService.java b/src/main/java/grep/neogul_coder/global/auth/service/AuthService.java index 332917bc..92189273 100644 --- a/src/main/java/grep/neogul_coder/global/auth/service/AuthService.java +++ b/src/main/java/grep/neogul_coder/global/auth/service/AuthService.java @@ -1,6 +1,14 @@ package grep.neogul_coder.global.auth.service; +import grep.neogul_coder.domain.buddy.service.BuddyEnergyService; +import grep.neogul_coder.domain.prtemplate.entity.Link; +import grep.neogul_coder.domain.prtemplate.entity.PrTemplate; +import grep.neogul_coder.domain.prtemplate.repository.LinkRepository; +import grep.neogul_coder.domain.prtemplate.repository.PrTemplateRepository; import grep.neogul_coder.domain.users.entity.User; +import grep.neogul_coder.domain.users.exception.UnActivatedUserException; +import grep.neogul_coder.domain.users.exception.UserNotFoundException; +import grep.neogul_coder.domain.users.exception.code.UserErrorCode; import grep.neogul_coder.domain.users.repository.UserRepository; import grep.neogul_coder.global.auth.code.Role; import grep.neogul_coder.global.auth.entity.RefreshToken; @@ -12,6 +20,7 @@ import grep.neogul_coder.global.auth.repository.UserBlackListRepository; import grep.neogul_coder.global.exception.GoogleUserLoginException; import grep.neogul_coder.global.response.code.CommonCode; +import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -23,8 +32,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.UUID; - @Service @RequiredArgsConstructor @Slf4j @@ -36,6 +43,9 @@ public class AuthService { private final RefreshTokenService refreshTokenService; private final UserBlackListRepository userBlackListRepository; private final UserRepository usersRepository; + private final LinkRepository linkRepository; + private final PrTemplateRepository prTemplateRepository; + private final BuddyEnergyService buddyEnergyService; public TokenDto signin(LoginRequest loginRequest) { @@ -43,15 +53,19 @@ public TokenDto signin(LoginRequest loginRequest) { throw new GoogleUserLoginException(CommonCode.SECURITY_INCIDENT); } + if (isUnactivatedUser(loginRequest.getEmail())) { + throw new UnActivatedUserException(UserErrorCode.UNACTIVATED_USER); + } + UsernamePasswordAuthenticationToken authenticationToken = - new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), - loginRequest.getPassword()); + new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), + loginRequest.getPassword()); Authentication authentication = authenticationManagerBuilder.getObject() - .authenticate(authenticationToken); + .authenticate(authenticationToken); SecurityContextHolder.getContext().setAuthentication(authentication); String roles = String.join(",", authentication.getAuthorities().stream().map( - GrantedAuthority::getAuthority).toList()); + GrantedAuthority::getAuthority).toList()); return processTokenSignin(authentication.getName(), roles); } @@ -63,40 +77,52 @@ public TokenDto processTokenSignin(String email, String roles) { RefreshToken refreshToken = refreshTokenService.saveWithAtId(accessToken.getJti()); return TokenDto.builder() - .atId(accessToken.getJti()) - .accessToken(accessToken.getToken()) - .refreshToken(refreshToken.getToken()) - .grantType("Bearer") - .refreshExpiresIn(jwtTokenProvider.getRefreshTokenExpiration()) - .expiresIn(jwtTokenProvider.getAccessTokenExpiration()) - .build(); + .atId(accessToken.getJti()) + .accessToken(accessToken.getToken()) + .refreshToken(refreshToken.getToken()) + .grantType("Bearer") + .refreshExpiresIn(jwtTokenProvider.getRefreshTokenExpiration()) + .expiresIn(jwtTokenProvider.getAccessTokenExpiration()) + .build(); } @Transactional public TokenDto processOAuthSignin(OAuth2UserInfo userInfo, String roles) { String email = userInfo.getEmail(); - String dummyPassword = UUID.randomUUID().toString(); - User user = usersRepository.findByEmail(email) - .orElseGet(() -> { - User newUser = User.builder() - .email(email) - .nickname(userInfo.getName()) - .oauthProvider(userInfo.getProvider()) - .oauthId(userInfo.getProviderId()) - .password(dummyPassword) - .role(Role.ROLE_USER) - .build(); - return usersRepository.save(newUser); - }); - - return processTokenSignin(user.getEmail(), user.getRole().name()); + return usersRepository.findByEmail(email) + .map(user -> processTokenSignin(user.getEmail(), user.getRole().name())) + .orElseGet(() -> { + User newUser = User.builder() + .email(email) + .nickname(userInfo.getName()) + .oauthProvider(userInfo.getProvider()) + .oauthId(userInfo.getProviderId()) + .password(dummyPassword) + .role(Role.ROLE_USER) + .activated(true) + .build(); + + User savedUser = usersRepository.save(newUser); + prTemplateRepository.save(PrTemplate.PrTemplateInit(savedUser.getId(), null, null)); + linkRepository.save(Link.LinkInit(savedUser.getId(), null, null)); + linkRepository.save(Link.LinkInit(savedUser.getId(), null, null)); + buddyEnergyService.createDefaultEnergy(savedUser.getId()); + + return processTokenSignin(savedUser.getEmail(), savedUser.getRole().name()); + }); + } + + private boolean isUnactivatedUser(String email) { + return !usersRepository.findByEmail(email) + .orElseThrow(() -> new UserNotFoundException(UserErrorCode.USER_NOT_FOUND)) + .getActivated(); } private boolean isGoogleUser(String email) { User user = usersRepository.findByEmail(email) - .orElseThrow(() -> new UsernameNotFoundException("해당 이메일의 유저가 없습니다.")); + .orElseThrow(() -> new UsernameNotFoundException("해당 이메일의 유저가 없습니다.")); return "Google".equals(user.getOauthProvider()); } diff --git a/src/main/java/grep/neogul_coder/global/config/MailConfig.java b/src/main/java/grep/neogul_coder/global/config/MailConfig.java new file mode 100644 index 00000000..4644022c --- /dev/null +++ b/src/main/java/grep/neogul_coder/global/config/MailConfig.java @@ -0,0 +1,45 @@ +package grep.neogul_coder.global.config; + +import java.util.Properties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +@Configuration +public class MailConfig { + + @Value("${spring.mail.username}") + private String email; + + @Value("${spring.mail.password}") + private String password; + + @Value("${spring.mail.host}") + private String host; + + @Bean + public JavaMailSender mailSender() { + + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(host); + mailSender.setPort(587); + mailSender.setUsername(email); + mailSender.setPassword(password); + + Properties javaMailProperties = new Properties(); + javaMailProperties.put("mail.transport.protocol", "smtp"); + javaMailProperties.put("mail.smtp.auth", "true"); + javaMailProperties.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory"); + javaMailProperties.put("mail.smtp.starttls.enable", "true"); + javaMailProperties.put("mail.debug", "false"); + javaMailProperties.put("mail.smtp.ssl.trust", "smtp.gmail.com"); + javaMailProperties.put("mail.smtp.ssl.protocols", "TLSv1.2"); + + mailSender.setJavaMailProperties(javaMailProperties); + + return mailSender; + } + +} diff --git a/src/main/java/grep/neogul_coder/global/config/security/SecurityConfig.java b/src/main/java/grep/neogul_coder/global/config/security/SecurityConfig.java index f9768a60..b7e57fee 100644 --- a/src/main/java/grep/neogul_coder/global/config/security/SecurityConfig.java +++ b/src/main/java/grep/neogul_coder/global/config/security/SecurityConfig.java @@ -88,7 +88,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, ClientRegistra "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/webjars/**", "/favicon.ico", - "/error").permitAll() + "/error" + ).permitAll() + .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 diff --git a/src/main/java/grep/neogul_coder/global/response/ApiResponse.java b/src/main/java/grep/neogul_coder/global/response/ApiResponse.java index 41d6c731..4656675a 100644 --- a/src/main/java/grep/neogul_coder/global/response/ApiResponse.java +++ b/src/main/java/grep/neogul_coder/global/response/ApiResponse.java @@ -25,6 +25,10 @@ public static ApiResponse success(T data) { return ApiResponse.of(CommonCode.OK.getCode(), CommonCode.OK.getMessage(), data); } + public static ApiResponse success(String message) { + return ApiResponse.of(CommonCode.OK.getCode(), message, null); + } + public static ApiResponse successWithCode(T data){ return ApiResponse.of(CommonCode.OK.getCode(), CommonCode.OK.getMessage(), data); } diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 98c35ea1..4cb8d97e 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -4,11 +4,11 @@ INSERT INTO member (email, password, nickname, profile_image_url, role, oauth_id INSERT INTO member (email, password, nickname, profile_image_url, role, oauth_id, oauth_provider) VALUES ('rryu@yuhanhoesa.com', '$2a$10$WZzvlwlN6FVtQvGUXw2CIeNQvT5fPfA4qN99NisD2GOyCeuC4W0t2','NPy2EpXd$6', 'https://placekitten.com/842/456', 'ROLE_USER', NULL, NULL); INSERT INTO member (email, password, nickname, profile_image_url, role, oauth_id, oauth_provider) VALUES ('yeonghoseo@nate.com', '$2a$10$WZzvlwlN6FVtQvGUXw2CIeNQvT5fPfA4qN99NisD2GOyCeuC4W0t2', '$c3VDMSkF)','https://placekitten.com/812/623', 'ROLE_ADMIN', NULL, NULL); -INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated) VALUES (NULL, '자바 스터디', 'IT', 10, 1, 'ONLINE', NULL, '2025-08-01', '2025-12-31', '자바 스터디에 오신 것을 환영합니다.', 'https://example.com/image.jpg', FALSE, TRUE); -INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated) VALUES (NULL, '파이썬 스터디', 'IT', 8, 1, 'OFFLINE', '대구', '2025-09-01', '2026-01-31', '파이썬 기초부터 심화까지 학습합니다.', 'https://example.com/python.jpg', FALSE, TRUE); -INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated) VALUES (NULL, '디자인 스터디', 'DESIGN', 6, 1, 'HYBRID', '서울', '2025-07-15', '2025-10-15', 'UI/UX 디자인 실습 중심 스터디입니다.', 'https://example.com/design.jpg', FALSE, TRUE); -INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated) VALUES (NULL, '7급 공무원 스터디', 'EXAM', 12, 1, 'ONLINE', NULL, '2025-08-10', '2025-12-20', '7급 공무원 대비 스터디입니다.', 'https://example.com/exam.jpg', FALSE, TRUE); -INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated) VALUES (NULL, '토익 스터디', 'LANGUAGE', 9, 1, 'OFFLINE', '광주', '2025-09-05', '2026-02-28', '토익 스터디입니다.', 'https://example.com/datascience.jpg', FALSE, TRUE); +INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (NULL, '자바 스터디', 'IT', 10, 1, 'ONLINE', NULL, '2025-08-01', '2025-12-31', '자바 스터디에 오신 것을 환영합니다.', 'https://example.com/image.jpg', FALSE, TRUE, FALSE); +INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (NULL, '파이썬 스터디', 'IT', 8, 1, 'OFFLINE', '대구', '2025-09-01', '2026-01-31', '파이썬 기초부터 심화까지 학습합니다.', 'https://example.com/python.jpg', FALSE, TRUE, FALSE); +INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (NULL, '디자인 스터디', 'DESIGN', 6, 1, 'HYBRID', '서울', '2025-07-15', '2025-10-15', 'UI/UX 디자인 실습 중심 스터디입니다.', 'https://example.com/design.jpg', FALSE, TRUE, FALSE); +INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (NULL, '7급 공무원 스터디', 'EXAM', 12, 1, 'ONLINE', NULL, '2025-08-10', '2025-12-20', '7급 공무원 대비 스터디입니다.', 'https://example.com/exam.jpg', FALSE, TRUE, FALSE); +INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (NULL, '토익 스터디', 'LANGUAGE', 9, 1, 'OFFLINE', '광주', '2025-09-05', '2026-02-28', '토익 스터디입니다.', 'https://example.com/datascience.jpg', FALSE, TRUE, FALSE); INSERT INTO study_post (study_id, user_id, title, category, content) VALUES (5, 3, '자바 스터디 1주차 공지.', 'NOTICE', '1주차 스터디 내용은 가위바위보 게임 만들기 입니다. 모두 각자 만드시고 설명 하는 시간을 가지겠습니다.'); INSERT INTO study_post (study_id, user_id, title, category, content) VALUES (4, 4, '익명 클래스 자료 공유', 'FREE', '동물 이라는 인터페이스가 있을때 구현체는 강아지, 고양이 등이 있습니다. 구현을 하면 여러 구현 클래스가 필요합니다 이를 줄이기 위해 익명클래스를 사용할 수 있습니다.'); @@ -16,11 +16,11 @@ INSERT INTO study_post (study_id, user_id, title, category, content) VALUES (4, INSERT INTO study_post (study_id, user_id, title, category, content) VALUES (2, 2, '개발 유튜브 공유', 'FREE', '재미니의 개발실무 ( 토스 ); 개발바닥'); INSERT INTO study_post (study_id, user_id, title, category, content) VALUES (5, 5, '점심 메뉴 추천', 'FREE', '오늘 점심 뭐먹을지 추천 받습니다!'); -INSERT INTO comment (post_id, user_id, content) VALUES (5, 4, '확인 했습니다!'); -INSERT INTO comment (post_id, user_id, content) VALUES (5, 4, '좋은 정보 감사합니다!'); -INSERT INTO comment (post_id, user_id, content) VALUES (3, 2, '관련된 블로그 공유 드립니다!'); -INSERT INTO comment (post_id, user_id, content) VALUES (2, 5, '정보 감사합니다!'); -INSERT INTO comment (post_id, user_id, content) VALUES (4, 1, '제육 돈까스'); +-- INSERT INTO comment (post_id, user_id, content) VALUES (5, 4, '확인 했습니다!'); +-- INSERT INTO comment (post_id, user_id, content) VALUES (5, 4, '좋은 정보 감사합니다!'); +-- INSERT INTO comment (post_id, user_id, content) VALUES (3, 2, '관련된 블로그 공유 드립니다!'); +-- INSERT INTO comment (post_id, user_id, content) VALUES (2, 5, '정보 감사합니다!'); +-- INSERT INTO comment (post_id, user_id, content) VALUES (4, 1, '제육 돈까스'); INSERT INTO study_member (study_id, user_id, role, participated) VALUES (1, 3, 'LEADER', FALSE); INSERT INTO study_member (study_id, user_id, role, participated) VALUES (1, 4, 'MEMBER', FALSE); diff --git a/src/main/resources/static/Chat-Test.html b/src/main/resources/static/Chat-Test.html new file mode 100644 index 00000000..edb6eaf0 --- /dev/null +++ b/src/main/resources/static/Chat-Test.html @@ -0,0 +1,86 @@ + + + + + 그룹 채팅 로컬 테스트 + + + + +

그룹 채팅 테스트 (로컬 서버)

+ +
+ + + + + + +
+ +
+ +
+ + +
+ +

채팅 로그

+
    + + + + diff --git a/src/test/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostServiceTest.java b/src/test/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostServiceTest.java index 35584190..e2b4827c 100644 --- a/src/test/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostServiceTest.java +++ b/src/test/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostServiceTest.java @@ -19,7 +19,7 @@ import grep.neogul_coder.domain.study.repository.StudyMemberRepository; import grep.neogul_coder.domain.study.repository.StudyRepository; import grep.neogul_coder.domain.studyapplication.StudyApplication; -import grep.neogul_coder.domain.studyapplication.repository.StudyApplicationRepository; +import grep.neogul_coder.domain.studyapplication.repository.ApplicationRepository; import grep.neogul_coder.domain.users.entity.User; import grep.neogul_coder.domain.users.repository.UserRepository; import grep.neogul_coder.global.exception.business.BusinessException; @@ -62,7 +62,7 @@ class RecruitmentPostServiceTest extends IntegrationTestSupport { private RecruitmentPostCommentRepository commentRepository; @Autowired - private StudyApplicationRepository studyApplicationRepository; + private ApplicationRepository applicationRepository; private long userId; private long recruitmentPostId; @@ -99,7 +99,7 @@ void get() { StudyApplication application1 = createStudyApplication(post.getId(), user1.getId(), "신청 사유"); StudyApplication application2 = createStudyApplication(post.getId(), user2.getId(), "신청 사유2"); - studyApplicationRepository.saveAll(List.of(application1, application2)); + applicationRepository.saveAll(List.of(application1, application2)); //when RecruitmentPostInfo response = recruitmentPostService.get(post.getId()); diff --git a/src/test/java/grep/neogul_coder/domain/studyapplication/service/ApplicationServiceTest.java b/src/test/java/grep/neogul_coder/domain/studyapplication/service/ApplicationServiceTest.java new file mode 100644 index 00000000..f7a183b0 --- /dev/null +++ b/src/test/java/grep/neogul_coder/domain/studyapplication/service/ApplicationServiceTest.java @@ -0,0 +1,196 @@ +package grep.neogul_coder.domain.studyapplication.service; + +import grep.neogul_coder.domain.IntegrationTestSupport; +import grep.neogul_coder.domain.recruitment.post.RecruitmentPost; +import grep.neogul_coder.domain.recruitment.post.repository.RecruitmentPostRepository; +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.StudyMember; +import grep.neogul_coder.domain.study.repository.StudyMemberRepository; +import grep.neogul_coder.domain.study.repository.StudyRepository; +import grep.neogul_coder.domain.studyapplication.StudyApplication; +import grep.neogul_coder.domain.studyapplication.controller.dto.request.ApplicationCreateRequest; +import grep.neogul_coder.domain.studyapplication.repository.ApplicationRepository; +import grep.neogul_coder.domain.users.entity.User; +import grep.neogul_coder.domain.users.repository.UserRepository; +import grep.neogul_coder.global.exception.business.BusinessException; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; +import java.util.List; + +import static grep.neogul_coder.domain.study.enums.StudyMemberRole.LEADER; +import static grep.neogul_coder.domain.study.enums.StudyMemberRole.MEMBER; +import static grep.neogul_coder.domain.studyapplication.ApplicationStatus.*; +import static grep.neogul_coder.domain.studyapplication.exception.code.ApplicationErrorCode.APPLICATION_NOT_APPLYING; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ApplicationServiceTest extends IntegrationTestSupport { + + @Autowired + private EntityManager em; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudyRepository studyRepository; + + @Autowired + private StudyMemberRepository studyMemberRepository; + + @Autowired + private RecruitmentPostRepository recruitmentPostRepository; + + @Autowired + private ApplicationService applicationService; + + @Autowired + private ApplicationRepository applicationRepository; + + private Long userId1; + private Long userId2; + private Long studyId; + private Long recruitmentPostId; + + @BeforeEach + void init() { + User user1 = createUser("test1"); + User user2 = createUser("test2"); + userRepository.saveAll(List.of(user1, user2)); + userId1 = user1.getId(); + userId2 = user2.getId(); + + Study study = createStudy("스터디", LocalDateTime.parse("2025-07-25T20:20:20"), LocalDateTime.parse("2025-07-28T20:20:20")); + studyRepository.save(study); + studyId = study.getId(); + + StudyMember studyLeader = createStudyLeader(study, userId1); + StudyMember studyMember = createStudyMember(study, userId2); + studyMemberRepository.saveAll(List.of(studyLeader, studyMember)); + + RecruitmentPost recruitmentPost = createRecruitmentPost(studyId, userId1, "제목", "내용", 1); + recruitmentPostRepository.save(recruitmentPost); + recruitmentPostId = recruitmentPost.getId(); + } + + @DisplayName("신청서를 생성합니다.") + @Test + void createApplication() { + // given + ApplicationCreateRequest request = ApplicationCreateRequest.builder() + .applicationReason("자바를 더 공부하고 싶어 지원합니다.") + .build(); + + // when + Long id = applicationService.createApplication(recruitmentPostId, request, userId2); + em.flush(); + em.clear(); + + // then + StudyApplication application = applicationRepository.findByIdAndActivatedTrue(id).orElseThrow(); + assertThat(application.getApplicationReason()).isEqualTo("자바를 더 공부하고 싶어 지원합니다."); + } + + @DisplayName("스터디장이 신청서를 승인합니다.") + @Test + void approveApplication() { + // given + StudyApplication application = createApplication(recruitmentPostId, userId2); + applicationRepository.save(application); + Long id = application.getId(); + + // when + applicationService.approveApplication(id, userId1); + em.flush(); + em.clear(); + + // then + assertThat(application.getStatus()).isEqualTo(APPROVED); + } + + @DisplayName("스터디장이 신청서를 거절합니다.") + @Test + void rejectApplication() { + // given + StudyApplication application = createApplication(recruitmentPostId, userId2); + applicationRepository.save(application); + Long id = application.getId(); + + // when + applicationService.rejectApplication(id, userId1); + em.flush(); + em.clear(); + + // then + assertThat(application.getStatus()).isEqualTo(REJECTED); + } + + @DisplayName("스터디장이 이미 승인이나 거절한 경우 신청서 거절 시 예외가 발생합니다.") + @Test + void rejectApplicationFail() { + // given + StudyApplication application = createApplication(recruitmentPostId, userId2); + applicationRepository.save(application); + Long id = application.getId(); + applicationService.rejectApplication(id, userId1); + + // when then + assertThatThrownBy(() -> + applicationService.rejectApplication(id, userId1)) + .isInstanceOf(BusinessException.class).hasMessage(APPLICATION_NOT_APPLYING.getMessage()); + } + + private static User createUser(String nickname) { + return User.builder() + .nickname(nickname) + .build(); + } + + private static Study createStudy(String name, LocalDateTime startDate, LocalDateTime endDate) { + return Study.builder() + .name(name) + .startDate(startDate) + .endDate(endDate) + .build(); + } + + private StudyMember createStudyLeader(Study study, Long userId) { + return StudyMember.builder() + .study(study) + .userId(userId) + .role(LEADER) + .build(); + } + + private StudyMember createStudyMember(Study study, Long userId) { + return StudyMember.builder() + .study(study) + .userId(userId) + .role(MEMBER) + .build(); + } + + private RecruitmentPost createRecruitmentPost(Long studyId ,Long userId, String subject, String content, int count) { + return RecruitmentPost.builder() + .subject(subject) + .content(content) + .recruitmentCount(count) + .studyId(studyId) + .userId(userId) + .build(); + } + + private StudyApplication createApplication(Long recruitmentPostId, Long userId) { + return StudyApplication.builder() + .recruitmentPostId(recruitmentPostId) + .userId(userId) + .applicationReason("자바를 더 공부하고 싶어 지원합니다.") + .status(APPLYING) + .build(); + } +} \ No newline at end of file