diff --git a/src/main/java/com/haru/api/domain/meeting/service/MeetingCommandServiceImpl.java b/src/main/java/com/haru/api/domain/meeting/service/MeetingCommandServiceImpl.java index 8e6a8730..afb1528c 100644 --- a/src/main/java/com/haru/api/domain/meeting/service/MeetingCommandServiceImpl.java +++ b/src/main/java/com/haru/api/domain/meeting/service/MeetingCommandServiceImpl.java @@ -139,7 +139,8 @@ public void updateMeetingTitle(User user, Meeting meeting, MeetingRequestDTO.upd meeting.updateTitle(request.getTitle()); - markdownFileUploader.updateFileTitle(meeting.getProceedingPdfKeyName(), request.getTitle()); + markdownFileUploader.updateFileTitle(meeting.getProceedingPdfKeyName(), request.getTitle() + ".pdf"); + markdownFileUploader.updateFileTitle(meeting.getProceedingWordKeyName(), request.getTitle() + ".docx"); meetingRepository.save(meeting); } diff --git a/src/main/java/com/haru/api/domain/moodTracker/controller/MoodTrackerController.java b/src/main/java/com/haru/api/domain/moodTracker/controller/MoodTrackerController.java index faa5353a..ce951f98 100644 --- a/src/main/java/com/haru/api/domain/moodTracker/controller/MoodTrackerController.java +++ b/src/main/java/com/haru/api/domain/moodTracker/controller/MoodTrackerController.java @@ -255,7 +255,7 @@ public ApiResponse downloadLi @PostMapping("/{mood-tracker-hashed-Id}/report-test") @Operation( - summary = "분위기 트래커 설문 리포트 즉시 생성 테스트 API", + summary = "분위기 트래커 설문 리포트 즉시 생성 API", description = "# [v1.0 (2025-07-26)](https://www.notion.so/23f5da7802c58080b4a5e6d24b47d924) 해당 ID의 분위기 트래커 설문 리포트를 즉시 생성합니다." ) @Parameters({ @@ -274,7 +274,7 @@ public ApiResponse generateMoodTrackerReportTest ( @PostMapping("/{mood-tracker-hashed-Id}/report-file-thumbnail-test") @Operation( - summary = "분위기 트래커 설문 리포트, 파일, 썸네일 즉시 생성 테스트 API", + summary = "분위기 트래커 설문 리포트, 파일, 썸네일 즉시 생성 테스트 API + redis에서 제외하여 마감일시에 중복 생성 불가", description = "# [v1.0 (2025-08-14)](https://www.notion.so/24f5da7802c58019a1f7d9c8e882226e) 해당 ID의 분위기 트래커 설문 리포트를 즉시 생성합니다." ) @Parameters({ diff --git a/src/main/java/com/haru/api/domain/moodTracker/service/MoodTrackerCommandServiceImpl.java b/src/main/java/com/haru/api/domain/moodTracker/service/MoodTrackerCommandServiceImpl.java index ee51f0df..fc9b30af 100644 --- a/src/main/java/com/haru/api/domain/moodTracker/service/MoodTrackerCommandServiceImpl.java +++ b/src/main/java/com/haru/api/domain/moodTracker/service/MoodTrackerCommandServiceImpl.java @@ -19,6 +19,7 @@ import com.haru.api.global.apiPayload.code.status.ErrorStatus; import com.haru.api.global.apiPayload.exception.handler.*; import com.haru.api.global.util.HashIdUtil; +import com.haru.api.infra.redis.RedisReportConsumer; import com.haru.api.infra.redis.RedisReportProducer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -52,6 +53,7 @@ public class MoodTrackerCommandServiceImpl implements MoodTrackerCommandService private final MoodTrackerReportService moodTrackerReportService; private final RedisReportProducer redisReportProducer; + private final RedisReportConsumer redisReportConsumer; private final HashIdUtil hashIdUtil; @@ -318,6 +320,10 @@ public void generateReportTest( public void generateReportFileAndThumbnailTest( MoodTracker moodTracker ) { + // 중복 처리 제외 + redisReportConsumer.removeFromQueue(moodTracker.getId()); + + // 즉시 생성 moodTrackerReportService.generateAndUploadReportFileAndThumbnail(moodTracker.getId()); } } diff --git a/src/main/java/com/haru/api/domain/snsEvent/controller/SnsEventController.java b/src/main/java/com/haru/api/domain/snsEvent/controller/SnsEventController.java index aabe6115..b20a4484 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/controller/SnsEventController.java +++ b/src/main/java/com/haru/api/domain/snsEvent/controller/SnsEventController.java @@ -4,6 +4,7 @@ import com.haru.api.domain.snsEvent.dto.SnsEventResponseDTO; import com.haru.api.domain.snsEvent.entity.SnsEvent; import com.haru.api.domain.snsEvent.entity.enums.Format; +import com.haru.api.domain.snsEvent.entity.enums.InstagramRedirectType; import com.haru.api.domain.snsEvent.entity.enums.ListType; import com.haru.api.domain.snsEvent.service.SnsEventCommandService; import com.haru.api.domain.snsEvent.service.SnsEventQueryService; @@ -56,19 +57,20 @@ public ApiResponse instagramRedirectUri( } @Operation( - summary = "인스타그램 연동 API [v1.1 (2025-08-07)]", - description = "# [v1.1 (2025-08-07)](https://www.notion.so/API-21e5da7802c581cca23dff937ac3f155?p=23f5da7802c5803b98abe74d511c2cf4&pm=s)" + + summary = "인스타그램 연동 API [v1.1 (2025-08-21)]", + description = "# [v1.1 (2025-08-21)](https://www.notion.so/API-21e5da7802c581cca23dff937ac3f155?p=23f5da7802c5803b98abe74d511c2cf4&pm=s)" + " 인스타그램 로그인 후 인증 서버로부터 받은 code를 header에 넣어주시고, workspaceId를 Path Variable로 넣어주세요." ) @PostMapping("/{workspaceId}/link-instagram") public ApiResponse linkInstagramAccount( @RequestHeader("code") String code, @PathVariable String workspaceId, + @RequestParam InstagramRedirectType instagramRedirectType, @Parameter(hidden = true) @AuthWorkspace Workspace workspace ) { System.out.println("Received accessToken: " + code); return ApiResponse.onSuccess( - snsEventCommandService.getInstagramAccessTokenAndAccount(code, workspace) + snsEventCommandService.getInstagramAccessTokenAndAccount(code, workspace, instagramRedirectType) ); } diff --git a/src/main/java/com/haru/api/domain/snsEvent/entity/enums/InstagramRedirectType.java b/src/main/java/com/haru/api/domain/snsEvent/entity/enums/InstagramRedirectType.java new file mode 100644 index 00000000..21934023 --- /dev/null +++ b/src/main/java/com/haru/api/domain/snsEvent/entity/enums/InstagramRedirectType.java @@ -0,0 +1,5 @@ +package com.haru.api.domain.snsEvent.entity.enums; + +public enum InstagramRedirectType { + ONBOARDING, WORKSPACE +} diff --git a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandService.java b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandService.java index cda950e2..ced6aef3 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandService.java +++ b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandService.java @@ -4,6 +4,7 @@ import com.haru.api.domain.snsEvent.dto.SnsEventResponseDTO; import com.haru.api.domain.snsEvent.entity.SnsEvent; import com.haru.api.domain.snsEvent.entity.enums.Format; +import com.haru.api.domain.snsEvent.entity.enums.InstagramRedirectType; import com.haru.api.domain.snsEvent.entity.enums.ListType; import com.haru.api.domain.user.entity.User; import com.haru.api.domain.workspace.entity.Workspace; @@ -11,7 +12,7 @@ public interface SnsEventCommandService { SnsEventResponseDTO.CreateSnsEventResponse createSnsEvent(User user, Workspace workspace, SnsEventRequestDTO.CreateSnsRequest request); - SnsEventResponseDTO.LinkInstagramAccountResponse getInstagramAccessTokenAndAccount(String code, Workspace workspace); + SnsEventResponseDTO.LinkInstagramAccountResponse getInstagramAccessTokenAndAccount(String code, Workspace workspace, InstagramRedirectType instagramRedirectType); void updateSnsEventTitle(User user, SnsEvent snsEvent, SnsEventRequestDTO.UpdateSnsEventRequest request); diff --git a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandServiceImpl.java b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandServiceImpl.java index 81dedc0e..f0afaef0 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandServiceImpl.java +++ b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandServiceImpl.java @@ -8,6 +8,7 @@ import com.haru.api.domain.snsEvent.entity.SnsEvent; import com.haru.api.domain.snsEvent.entity.Winner; import com.haru.api.domain.snsEvent.entity.enums.Format; +import com.haru.api.domain.snsEvent.entity.enums.InstagramRedirectType; import com.haru.api.domain.snsEvent.entity.enums.ListType; import com.haru.api.domain.snsEvent.repository.ParticipantRepository; import com.haru.api.domain.snsEvent.repository.SnsEventRepository; @@ -170,14 +171,18 @@ public SnsEventResponseDTO.CreateSnsEventResponse createSnsEvent( @Transactional public SnsEventResponseDTO.LinkInstagramAccountResponse getInstagramAccessTokenAndAccount( String code, - Workspace workspace + Workspace workspace, + InstagramRedirectType instagramRedirectType ) { String shortLivedAccessToken; String longLivedAccessToken; Map userInfo; try { // 1. Access Token 요청 - shortLivedAccessToken = instagramOauth2RestTemplate.getShortLivedAccessTokenUrl(code); + shortLivedAccessToken = instagramOauth2RestTemplate.getShortLivedAccessTokenUrl( + code, + instagramRedirectType + ); // 2. 단기 토큰을 장기(Long-Lived) 토큰으로 교환 longLivedAccessToken = instagramOauth2RestTemplate.getLongLivedAccessToken(shortLivedAccessToken); // 3. 장기 토큰으로 사용자 계정 정보 요청 diff --git a/src/main/java/com/haru/api/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/haru/api/global/apiPayload/code/status/ErrorStatus.java index 9e239dcf..f512f785 100644 --- a/src/main/java/com/haru/api/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/haru/api/global/apiPayload/code/status/ErrorStatus.java @@ -69,7 +69,8 @@ public enum ErrorStatus implements BaseErrorCode { INVALID_CHOICE_FOR_QUESTION(HttpStatus.BAD_REQUEST, "MOODTRACKER4011", "분위기 트래커 질문에 유효하지 않은 선택지입니다."), // 메일 관련 에러 - MAIL_SEND_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "MAIL500", "이메일 전송에 실패했습니다."), + MAIL_SEND_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "MAIL500", "이메일 전송에 실패했습니다. (존재하지 않는 이메일일 수 있습니다)"), + MAIL_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "MAIL400", "이메일 형식이 잘못되었습니다."), // SNS 이벤트 관련 에러 SNS_EVENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "SNSEVENT4001", "SNS 이벤트가 존재하지 않습니다."), diff --git a/src/main/java/com/haru/api/infra/api/restTemplate/InstagramOauth2RestTemplate.java b/src/main/java/com/haru/api/infra/api/restTemplate/InstagramOauth2RestTemplate.java index aa3d784b..a0d4aa93 100644 --- a/src/main/java/com/haru/api/infra/api/restTemplate/InstagramOauth2RestTemplate.java +++ b/src/main/java/com/haru/api/infra/api/restTemplate/InstagramOauth2RestTemplate.java @@ -1,5 +1,6 @@ package com.haru.api.infra.api.restTemplate; +import com.haru.api.domain.snsEvent.entity.enums.InstagramRedirectType; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; @@ -21,12 +22,17 @@ public class InstagramOauth2RestTemplate { private String instagramClientId; @Value("${instagram.client.secret}") private String instagramClientSecret; - @Value("${instagram.redirect.uri}") - private String instagramRedirectUri; + @Value("${instagram.redirect.uri-onboarding}") + private String instagramRedirectUriOnboarding; + @Value("${instagram.redirect.uri-workspace}") + private String instagramRedirectUriWorkspace; private final RestTemplate restTemplate; - public String getShortLivedAccessTokenUrl(String code) { + public String getShortLivedAccessTokenUrl( + String code, + InstagramRedirectType instagramRedirectType + ) { // 1. Access Token 요청 String tokenUrl = "https://api.instagram.com/oauth/access_token"; RestTemplate restTemplate = new RestTemplate(); @@ -35,7 +41,13 @@ public String getShortLivedAccessTokenUrl(String code) { params.add("client_id", instagramClientId); // 인스타 앱의 클라이언트 ID params.add("client_secret", instagramClientSecret); // 인스타 앱의 클라이언트 시크릿 params.add("grant_type", "authorization_code"); - params.add("redirect_uri", instagramRedirectUri); // 인가코드와 동일한 redirect_uri + // 인가코드와 동일한 redirect_uri + if (instagramRedirectType == InstagramRedirectType.ONBOARDING) { + params.add("redirect_uri", instagramRedirectUriOnboarding); + } else if(instagramRedirectType == InstagramRedirectType.WORKSPACE) { + System.out.println("Using workspace redirect URI: " + instagramRedirectUriWorkspace); + params.add("redirect_uri", instagramRedirectUriWorkspace); + } params.add("code", code); HttpHeaders headers = new HttpHeaders(); diff --git a/src/main/java/com/haru/api/infra/mail/SmtpEmailSender.java b/src/main/java/com/haru/api/infra/mail/SmtpEmailSender.java index 4ebea824..7e391d16 100644 --- a/src/main/java/com/haru/api/infra/mail/SmtpEmailSender.java +++ b/src/main/java/com/haru/api/infra/mail/SmtpEmailSender.java @@ -3,6 +3,8 @@ import com.haru.api.global.apiPayload.code.status.ErrorStatus; import com.haru.api.infra.mail.handler.MailHandler; import jakarta.mail.MessagingException; +import jakarta.mail.internet.AddressException; +import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,6 +29,13 @@ public void send( String title, String content ) { + // 1. 이메일 형식 검증 + if (!isValidEmail(to)) { + log.warn("잘못된 이메일 형식 - {}", to); + throw new MailHandler(ErrorStatus.MAIL_INVALID_FORMAT); + } + + // 2. 메일 전송 시도 try { MimeMessage message = javaMailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8"); @@ -44,5 +53,16 @@ public void send( throw new MailHandler(ErrorStatus.MAIL_SEND_FAIL); } } + + // RFC 형식 검증 + private boolean isValidEmail(String email) { + try { + InternetAddress internetAddress = new InternetAddress(email); + internetAddress.validate(); // 형식이 틀리면 AddressException 발생 + return true; + } catch (AddressException e) { + return false; + } + } } diff --git a/src/main/java/com/haru/api/infra/redis/RedisReportConsumer.java b/src/main/java/com/haru/api/infra/redis/RedisReportConsumer.java index cb149aa1..30a5a61d 100644 --- a/src/main/java/com/haru/api/infra/redis/RedisReportConsumer.java +++ b/src/main/java/com/haru/api/infra/redis/RedisReportConsumer.java @@ -44,4 +44,18 @@ public void pollQueueEvery30Minutes() { } } } + + @Transactional + public void removeFromQueue(Long moodTrackerId) { + try { + Long removed = redisTemplate.opsForZSet().remove(QUEUE_KEY, moodTrackerId.toString()); + if (removed != null && removed > 0) { + log.info("즉시 생성 API 호출로 큐에서 제거됨: {}", moodTrackerId); + } else { + log.info("큐에 존재하지 않아 제거할 항목 없음: {}", moodTrackerId); + } + } catch (Exception e) { + log.error("큐 제거 실패: {}", moodTrackerId, e); + } + } } diff --git a/src/main/java/com/haru/api/infra/s3/AmazonS3Manager.java b/src/main/java/com/haru/api/infra/s3/AmazonS3Manager.java index ae161a07..0f4fe1b8 100644 --- a/src/main/java/com/haru/api/infra/s3/AmazonS3Manager.java +++ b/src/main/java/com/haru/api/infra/s3/AmazonS3Manager.java @@ -153,6 +153,7 @@ public byte[] downloadFile(String keyName) { } } + // newDisplayName에 확장자 포함되어있음 public void updateFileTitle(String keyName, String newDisplayName) { try { diff --git a/src/main/java/com/haru/api/infra/s3/MarkdownFileUploader.java b/src/main/java/com/haru/api/infra/s3/MarkdownFileUploader.java index 6c7e88d3..41900bc1 100644 --- a/src/main/java/com/haru/api/infra/s3/MarkdownFileUploader.java +++ b/src/main/java/com/haru/api/infra/s3/MarkdownFileUploader.java @@ -42,7 +42,7 @@ public String createOrUpdatePdf(String markdownText, String featurePath, String } // 3. 결정된 키로 PDF 파일을 S3에 업로드 - amazonS3Manager.uploadFileWithTitle(pdfKeyToUse, pdfBytes, "application/pdf", fileTitle); + amazonS3Manager.uploadFileWithTitle(pdfKeyToUse, pdfBytes, "application/pdf", fileTitle + ".pdf"); log.info("PDF 업로드/갱신 성공. Key: {}", pdfKeyToUse); // 4. 사용된 PDF의 key를 반환 @@ -65,7 +65,7 @@ public String createOrUpdateWord(String markdownText, String featurePath, String // 3. 결정된 키로 Word 파일을 S3에 업로드 String contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; - amazonS3Manager.uploadFileWithTitle(wordKeyToUse, wordBytes, contentType, fileTitle); + amazonS3Manager.uploadFileWithTitle(wordKeyToUse, wordBytes, contentType, fileTitle + ".docx"); log.info("Word 파일 업로드/갱신 성공. Key: {}", wordKeyToUse); // 4. 사용된 Word의 key를 반환 diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 5f8ed5ae..f100cf77 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -57,4 +57,5 @@ instagram: id: "dummy-client-id" secret: "dummy-client-secret" redirect: - uri: "dummy-redirect-uri" \ No newline at end of file + uri-onboarding: "dummy-redirect-uri" + uri-workspace: "dummy-redirect-uri" \ No newline at end of file