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..01ba584 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
@@ -25,6 +25,7 @@ public class ProjectMemberService {
private final ProjectMemberRepository projectMemberRepository;
private final MemberRepository memberRepository;
private final ProjectInvitedMemberRepository projectInvitedMemberRepository;
+ private final ProjectInviteEmailService projectInviteEmailService;
// 프로젝트 초대 멤버를 저장하는 함수
@Transactional
@@ -40,9 +41,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 +76,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);
+ }
}