diff --git a/sequence_member/src/main/java/sequence/sequence_member/global/config/AsyncConfig.java b/sequence_member/src/main/java/sequence/sequence_member/global/config/AsyncConfig.java index d56d2a1..2ee4783 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/global/config/AsyncConfig.java +++ b/sequence_member/src/main/java/sequence/sequence_member/global/config/AsyncConfig.java @@ -1,9 +1,26 @@ package sequence.sequence_member.global.config; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; @Configuration @EnableAsync public class AsyncConfig { + + @Bean(name = "emailTaskExecutor") + public Executor emailTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(5); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("Email-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } } \ No newline at end of file diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/service/FindPasswordService.java b/sequence_member/src/main/java/sequence/sequence_member/member/service/FindPasswordService.java index 1760842..d88fe75 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/service/FindPasswordService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/service/FindPasswordService.java @@ -76,12 +76,160 @@ private void sendPasswordResetEmail(String email, String temporaryPassword) { helper.setTo(email); helper.setSubject("[Sequence] 임시 비밀번호 발급 안내"); - String content = "" - + "

임시 비밀번호 발급 안내

" - + "

안녕하세요. 임시 비밀번호가 발급되었습니다.

" - + "

아래의 임시 비밀번호로 로그인 후, 보안을 위해 마이페이지에서 비밀번호를 변경해주세요.

" - + "

임시 비밀번호: " + temporaryPassword + "

" - + ""; + String content = "" + + "" + + "" + + "" + + "" + + "Sequence 임시비밀번호 발송" + + "" + + "" + + "" + + + // 외부 컨테이너 + "" + + "" + + "" + + "" + + "
" + + + // 메인 컨테이너 (반응형) + "" + + + // 헤더 섹션 + "" + + "" + + "" + + + // 컨텐츠 섹션 + "" + + "" + + "" + + + // 구분선 + "" + + "" + + "" + + + // 푸터 섹션 + "" + + "" + + "" + + + "
" + + "

Sequence

" + + "

🔑 임시 비밀번호 발급

" + + "

계정 복구를 위한 임시 비밀번호를 발급했습니다

" + + "
" + + + // 안내 메시지 + "" + + "" + + "" + + "" + + "
" + + "

" + + "안녕하세요," + + "

" + + "

" + + "요청하신 임시 비밀번호가 발급되었습니다. 아래의 임시 비밀번호로 로그인 후, 보안을 위해 마이페이지에서 비밀번호를 변경해주세요." + + "

" + + "
" + + + // 임시 비밀번호 섹션 + "" + + "" + + "" + + "" + + "
" + + + // 비밀번호 라벨 + "" + + "" + + "" + + "" + + "
" + + "

임시 비밀번호

" + + "
" + + + // 비밀번호 표시 + "" + + "" + + "" + + "" + + "
" + + "
" + + temporaryPassword + + "
" + + "
" + + + "
" + + + // 보안 안내 섹션 + "" + + "" + + "" + + "" + + "
" + + "

보안 알림 및 이용 안내

" + + + // 안내사항 목록 + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
" + + "

• 로그인 즉시 마이페이지에서 새로운 비밀번호로 변경하세요

" + + "
" + + "

• 비밀번호를 타인과 공유하지 마세요

" + + "
" + + "

• Sequence 로그인 페이지에 접속합니다

" + + "
" + + "

• 이메일과 임시 비밀번호를 입력합니다

" + + "
" + + "

• 로그인 후 마이페이지에서 비밀번호 변경을 완료합니다

" + + "
" + + + "
" + + + "
" + + "
" + + "
" + + "

Sequence

" + + "

이 메시지는 발신 전용입니다.

" + + "

Sequence © 2025

" + + "
" + + "
" + + "" + + ""; helper.setText(content, true); mailSender.send(message); @@ -89,4 +237,5 @@ private void sendPasswordResetEmail(String email, String temporaryPassword) { throw new RuntimeException("이메일 발송 중 오류가 발생했습니다.",e); } } + } diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectInviteEmailService.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectInviteEmailService.java new file mode 100644 index 0000000..10e51d9 --- /dev/null +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectInviteEmailService.java @@ -0,0 +1,402 @@ +package sequence.sequence_member.project.service; + + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import sequence.sequence_member.member.entity.MemberEntity; +import sequence.sequence_member.project.entity.Project; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ProjectInviteEmailService { + private final JavaMailSender mailSender; + + @Value("${NAVER_MAIL_USERNAME:dev_mj_@naver.com}") + private String fromEmail; + + // 프로젝트 초대 이메일 발송 - 비동기 + @Async + public void sendInviteEmail(Project project, MemberEntity invitedMember) { + try { + log.info("이메일 발송 시작 - 수신자: {}, 프로젝트: {}", + invitedMember.getEmail(), project.getProjectName()); + + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + // 이메일 기본 설정 + helper.setFrom(fromEmail); + helper.setTo(invitedMember.getEmail()); + helper.setSubject(String.format("[%s] 프로젝트 초대장이 도착했습니다!", project.getProjectName())); + + // HTML 이메일 내용 작성 + String htmlContent = createInvitationHtmlContent(project, invitedMember); + helper.setText(htmlContent, true); + + // 이메일 발송 + mailSender.send(message); + log.info("프로젝트 초대 이메일 발송 완료 - 수신자: {}, 프로젝트: {}", + invitedMember.getEmail(), project.getProjectName()); + + } catch (MessagingException e) { + log.error("이메일 발송 실패 - 수신자: {}, 프로젝트: {}, 오류: {}", + invitedMember.getEmail(), project.getProjectName(), e.getMessage(), e); + } catch (Exception e) { + log.error("예상치 못한 이메일 발송 오류 - 수신자: {}, 프로젝트: {}, 오류: {}", + invitedMember.getEmail(), project.getProjectName(), e.getMessage(), e); + } + } + + // 프로젝트 수정 알림 이메일 발송 + @Async + public void sendProjectUpdateEmail(Project project, List members, String updateDetails) { + if (project == null) { + log.error("프로젝트 정보가 없습니다."); + return; + } + + if (members == null || members.isEmpty()) { + log.warn("알림을 받을 멤버가 없습니다 - 프로젝트: {}", project.getProjectName()); + return; + } + + for (MemberEntity member : members) { + try { + if (member == null || member.getEmail() == null || member.getEmail().trim().isEmpty()) { + log.warn("유효하지 않은 멤버 정보 - 프로젝트: {}", project.getProjectName()); + continue; + } + + log.info("프로젝트 수정 알림 이메일 발송 시작 - 수신자: {}, 프로젝트: {}", + member.getEmail(), project.getProjectName()); + + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(fromEmail); + helper.setTo(member.getEmail()); + helper.setSubject(String.format("[%s] 프로젝트 정보가 변경되었습니다", + project.getProjectName())); + + String htmlContent = createUpdateHtmlContent(project, member, updateDetails); + helper.setText(htmlContent, true); + + mailSender.send(message); + log.info("프로젝트 수정 알림 이메일 발송 완료 - 수신자: {}, 프로젝트: {}", + member.getEmail(), project.getProjectName()); + + } catch (MessagingException e) { + log.error("프로젝트 수정 알림 이메일 발송 실패 - 수신자: {}, 프로젝트: {}, 오류: {}", + member.getEmail(), project.getProjectName(), e.getMessage(), e); + } catch (Exception e) { + log.error("프로젝트 수정 알림 예상치 못한 오류 - 수신자: {}, 프로젝트: {}, 오류: {}", + member.getEmail(), project.getProjectName(), e.getMessage(), e); + } + } + } + + // 프로젝트 초대 이메일 HTML 템플릿 (반응형) + private String createInvitationHtmlContent(Project project, MemberEntity invitedMember) { + // Null 체크 및 기본값 설정 + String nickname = invitedMember.getNickname() != null ? invitedMember.getNickname() : "회원"; + String writerNickname = project.getWriter() != null && project.getWriter().getNickname() != null + ? project.getWriter().getNickname() : "관리자"; + String title = project.getTitle() != null ? project.getTitle() : "프로젝트"; + String projectName = project.getProjectName() != null ? project.getProjectName() : "프로젝트"; + String category = project.getCategory() != null ? project.getCategory().toString() : "미정"; + int personnel = project.getPersonnel(); + String startDate = project.getStartDate() != null ? project.getStartDate().toString() : "미정"; + String endDate = project.getEndDate() != null ? project.getEndDate().toString() : "미정"; + + return "" + + "" + + "" + + "" + + "" + + "Sequence 프로젝트 초대" + + "" + + "" + + "" + + + // 외부 컨테이너 + "" + + "" + + "" + + "" + + "
" + + + // 메인 컨테이너 (반응형) + "" + + + // 헤더 섹션 + "" + + "" + + "" + + + // 컨텐츠 섹션 + "" + + "" + + "" + + + // 구분선 + "" + + "" + + "" + + + // 푸터 섹션 + "" + + "" + + "" + + + "
" + + "

Sequence

" + + "

📋 프로젝트 초대 알림

" + + "

새로운 프로젝트 참여 요청이 있습니다

" + + "
" + + + // 인사말 + "" + + "" + + "" + + "" + + "
" + + "

" + + "안녕하세요 " + nickname + "님," + + "

" + + "
" + + + // 초대 메시지 + "" + + "" + + "" + + "" + + "
" + + "

" + + "" + writerNickname + "님이 회원님을 다음 프로젝트에 초대했습니다." + + "

" + + "
" + + + // 프로젝트 정보 섹션 (반응형 테이블) + "" + + "" + + "" + + "" + + "
" + + + // 프로젝트 제목 + "" + + "" + + "" + + "" + + "
" + + "

" + + "📋 " + title + + "

" + + "
" + + + // 프로젝트 상세 정보 (모바일에서 세로 정렬) + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
" + + "

" + + "프로젝트명: " + projectName + + "

" + + "
" + + "

" + + "카테고리: " + category + + "

" + + "
" + + "

" + + "모집인원: " + personnel + "명" + + "

" + + "
" + + "

" + + "프로젝트 기간: " + startDate + " ~ " + endDate + + "

" + + "
" + + + "
" + + + // 안내 섹션 + "" + + "" + + "" + + "" + + "
" + + "

💡 안내사항

" + + "

• 프로젝트 참여 초대 안내

" + + "

• 참여 의사를 결정하시려면 로그인 후 확인해주세요

" + + "

• 추가 문의사항은 프로젝트 관리자에게 연락해주세요

" + + "
" + + + "
" + + "
" + + "
" + + "

Sequence

" + + "

이 메시지는 발신 전용입니다.

" + + "

Sequence © 2025

" + + "
" + + "
" + + "" + + ""; + } + + // 프로젝트 수정 알림 이메일 HTML 템플릿 (반응형) + private String createUpdateHtmlContent(Project project, MemberEntity member, String updateDetails) { + String nickname = member.getNickname() != null ? member.getNickname() : "회원"; + String projectName = project.getProjectName() != null ? project.getProjectName() : "프로젝트"; + String details = updateDetails != null && !updateDetails.isEmpty() ? updateDetails : "프로젝트 정보가 변경되었습니다."; + + return "" + + "" + + "" + + "" + + "" + + "Sequence 프로젝트 정보 변경" + + "" + + "" + + "" + + + // 외부 컨테이너 + "" + + "" + + "" + + "" + + "
" + + + // 메인 컨테이너 (반응형) + "" + + + // 헤더 섹션 + "" + + "" + + "" + + + // 컨텐츠 섹션 + "" + + "" + + "" + + + // 구분선 + "" + + "" + + "" + + + // 푸터 섹션 + "" + + "" + + "" + + + "
" + + "

Sequence

" + + "

📝 프로젝트 정보 업데이트

" + + "

참여 중인 프로젝트의 정보가 변경되었습니다

" + + "
" + + + // 인사말 + "" + + "" + + "" + + "" + + "
" + + "

" + + "안녕하세요 " + nickname + "님," + + "

" + + "
" + + + // 업데이트 메시지 + "" + + "" + + "" + + "" + + "
" + + "

" + + "참여 중인 " + projectName + " 프로젝트의 정보가 변경되었습니다." + + "

" + + "
" + + + // 변경 내용 섹션 + "" + + "" + + "" + + "" + + "
" + + "

변경 내용

" + + "
" + + details + + "
" + + "
" + + + // 안내 섹션 + "" + + "" + + "" + + "" + + "
" + + "

💡 안내사항

" + + "

• 프로젝트 정보 변경 알림

" + + "

• 상세 내용은 로그인하여 프로젝트 페이지에서 확인 가능합니다

" + + "

• 변경사항에 대한 문의는 프로젝트 관리자에게 연락해주세요

" + + "
" + + + "
" + + "
" + + "
" + + "

Sequence

" + + "

이 메시지는 발신 전용입니다.

" + + "

Sequence © 2025

" + + "
" + + "
" + + "" + + ""; + } +} diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectMemberService.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectMemberService.java index 7149cfd..453dc6c 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectMemberService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectMemberService.java @@ -20,11 +20,13 @@ @Slf4j @Service @RequiredArgsConstructor +@Slf4j public class ProjectMemberService { private final ProjectMemberRepository projectMemberRepository; private final MemberRepository memberRepository; private final ProjectInvitedMemberRepository projectInvitedMemberRepository; + private final ProjectInviteEmailService projectInviteEmailService; // 프로젝트 초대 멤버를 저장하는 함수 @Transactional @@ -40,9 +42,14 @@ public void saveProjectInvitedMember(ProjectInputDTO projectInputDTO, MemberEnti // 초대된 멤버들을 저장하는 함수 public void saveProjectInvitedMemberEntities(Project project, List invitedMembers){ + for(MemberEntity member : invitedMembers){ ProjectInvitedMember entity = ProjectInvitedMember.fromProjectAndMember(project,member); projectInvitedMemberRepository.save(entity); + + // 초대 이메일 발송 + projectInviteEmailService.sendInviteEmail(project, member); + log.info("프로젝트 초대 이메일 발송 - 프로젝트: {}, 멤버: {}", project.getProjectName(), member.getNickname()); } } @@ -70,4 +77,30 @@ public List getProjectMemberOutputDTOS(Project project) return projectMemberOutputDTOS; } + // 프로젝트 수정 시 멤버들에게 알림 이메일 발송 + @Transactional(readOnly = true) + public void notifyProjectUpdate(Project project, String updateDetails) { + try { + List projectMembers = project.getMembers(); + + if (projectMembers != null && !projectMembers.isEmpty()) { + // MemberEntity 리스트로 변환 + List memberList = projectMembers.stream() + .map(ProjectMember::getMember) + .filter(member -> member != null) + .toList(); + + // 리스트로 전달 (현재 메서드 시그니처에 맞게) + projectInviteEmailService.sendProjectUpdateEmail(project, memberList, updateDetails); + + log.info("프로젝트 수정 알림 발송 완료 - 프로젝트: {}, 대상자: {}명", + project.getProjectName(), memberList.size()); + } + + } catch (Exception e) { + log.error("프로젝트 수정 알림 실패 - 프로젝트: {}, 오류: {}", + project.getProjectName(), e.getMessage()); + } + } + } diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectService.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectService.java index 64daf08..67a4cde 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectService.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -64,6 +65,12 @@ public ProjectOutputDTO updateProject(Long projectId, CustomUserDetails customUs throw new AuthException("작성자만 수정할 수 있습니다."); } + // 수정 전 원본 정보 저장 + String originalTitle = project.getTitle(); + String originalProjectName = project.getProjectName(); + Category originalCategory = project.getCategory(); + int originalPersonnel = project.getPersonnel(); + // Project Entity에 ProjectInputDTO의 정보를 업데이트 project.updateProject(projectUpdateDTO); @@ -106,6 +113,17 @@ public ProjectOutputDTO updateProject(Long projectId, CustomUserDetails customUs projectMemberService.saveProjectInvitedMemberEntities(project, invitedMembers); + // 원본 정보 사용해서 변경사항 비교 + String updateDetails = buildUpdateDetailsWithOriginal( + originalTitle, originalProjectName, originalCategory, originalPersonnel, + projectUpdateDTO + ); + + // 멤버들에게 알림 이메일 발송 + if (!updateDetails.isEmpty()) { + projectMemberService.notifyProjectUpdate(project, updateDetails); + } + return projectGetService.getProject(projectId,request, customUserDetails); } @@ -227,4 +245,31 @@ public List getAllProjects(){ } + // 변경사항 요약 헬퍼 메서드 + private String buildUpdateDetailsWithOriginal(String originalTitle, String originalProjectName, + Category originalCategory, int originalPersonnel, + ProjectUpdateDTO request) { + List changes = new ArrayList<>(); + + if (!Objects.equals(originalTitle, request.getTitle())) { + changes.add("프로젝트 제목 변경"); + } + if (!Objects.equals(originalProjectName, request.getProjectName())) { + changes.add("프로젝트명 변경"); + } + if (!Objects.equals(originalCategory, request.getCategory())) { + changes.add("카테고리 변경"); + } + if (originalPersonnel != request.getPersonnel()) { + changes.add("모집인원 변경"); + } + if (request.getDeletedMembersNicknames() != null && !request.getDeletedMembersNicknames().isEmpty()) { + changes.add("멤버 제외"); + } + if (request.getInvitedMembersNicknames() != null && !request.getInvitedMembersNicknames().isEmpty()) { + changes.add("새 멤버 초대"); + } + + return String.join(", ", changes); + } }