Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/deploy-java.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jobs:
echo "GRAFANA_CLOUD_PROMETHEUS_URL=${{ secrets.GRAFANA_CLOUD_PROMETHEUS_URL }}" >> .env.prod
echo "GRAFANA_CLOUD_PROMETHEUS_USER=${{ secrets.GRAFANA_CLOUD_PROMETHEUS_USER }}" >> .env.prod
echo "GRAFANA_CLOUD_API_KEY=${{ secrets.GRAFANA_CLOUD_API_KEY }}" >> .env.prod
echo "MAIL_USERNAME=${{ secrets.MAIL_USERNAME }}" >> .env.prod
echo "MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}" >> .env.prod

- name: Set repo lowercase
run: echo "REPO_LC=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
Expand Down
6 changes: 6 additions & 0 deletions apps/pre-processing-service/app/model/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,12 @@ class S3UploadData(BaseModel):
uploaded_at: str = Field(
..., title="업로드 완료 시간", description="S3 업로드 완료 시간"
)
# 🆕 임시: 콘텐츠 생성용 단일 상품만 추가 (나중에 삭제 예정)
selected_product_for_content: Optional[Dict] = Field(
None,
title="콘텐츠 생성용 선택 상품",
description="임시: 블로그 콘텐츠 생성을 위해 선택된 단일 상품 정보",
)


# 최종 응답 모델
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Dict, Optional
# app/service/blog/blog_publish_service.py
from typing import Dict
from app.errors.CustomException import CustomException
from app.model.schemas import RequestBlogPublish
from app.service.blog.blog_service_factory import BlogServiceFactory
Expand All @@ -10,28 +11,23 @@ class BlogPublishService:
def __init__(self):
self.factory = BlogServiceFactory()

def publish_content(
self,
request: RequestBlogPublish,
) -> Dict:
def publish_content(self, request: RequestBlogPublish) -> Dict:
"""
생성된 블로그 콘텐츠를 배포합니다.

Args:
request: 블로그 발행 요청 데이터
blog_id: 블로그 아이디
blog_password: 블로그 비밀번호
request: RequestBlogPublish 객체
"""
try:
# 팩토리를 통해 적절한 서비스 생성
# 블로그 서비스 생성 (네이버, 티스토리, 블로거 등)
blog_service = self.factory.create_service(
request.tag,
blog_id=request.blog_id,
blog_password=request.blog_pw,
blog_name=request.blog_name,
)

# 공통 인터페이스로 포스팅 실행
# 콘텐츠 포스팅
response_data = blog_service.post_content(
title=request.post_title,
content=request.post_content,
Expand All @@ -40,16 +36,20 @@ def publish_content(

if not response_data:
raise CustomException(
500, f"{request.tag} 블로그 포스팅에 실패했습니다.", "POSTING_FAIL"
detail=f"{request.tag} 블로그 포스팅에 실패했습니다.",
status_code=500,
code="POSTING_FAIL",
)

return response_data

except CustomException:
# 이미 처리된 예외는 그대로 전달
# 이미 CustomException이면 그대로 전달
raise
except Exception as e:
# 예상치 못한 예외 처리
raise CustomException(
500, f"블로그 포스팅 중 오류가 발생했습니다: {str(e)}", "ERROR"
detail=f"블로그 포스팅 중 오류가 발생했습니다: {str(e)}",
status_code=500,
code="ERROR",
)
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def create_service(
if platform.lower() == "tistory_blog":
if not blog_name:
raise CustomException(
200,
400,
"티스토리 블로그가 존재하지않습니다.",
"NOT_FOUND_BLOG",
)
Expand Down
95 changes: 94 additions & 1 deletion apps/pre-processing-service/app/service/s3_upload_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,16 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict:
if product_index < len(crawled_products):
await asyncio.sleep(1)

# 🆕 임시: 콘텐츠 생성용 단일 상품 선택 로직
selected_product_for_content = self._select_single_product_for_content(
crawled_products, upload_results
)

logger.success(
f"S3 업로드 서비스 완료: 총 성공 이미지 {total_success_images}개, 총 실패 이미지 {total_fail_images}개"
)

# 간소화된 응답 데이터 구성
# 기존 응답 데이터 구성
data = {
"upload_results": upload_results,
"summary": {
Expand All @@ -115,6 +120,8 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict:
"total_fail_images": total_fail_images,
},
"uploaded_at": time.strftime("%Y-%m-%d %H:%M:%S"),
# 🆕 임시: 콘텐츠 생성용 단일 상품만 추가 (나중에 삭제 예정)
"selected_product_for_content": selected_product_for_content,
}

message = f"S3 업로드 완료: {total_success_images}개 이미지 업로드 성공, 상품 데이터 JSON 파일 포함"
Expand All @@ -123,3 +130,89 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict:
except Exception as e:
logger.error(f"S3 업로드 서비스 전체 오류: {e}")
raise InvalidItemDataException()

def _select_single_product_for_content(
self, crawled_products: List[Dict], upload_results: List[Dict]
) -> Dict:
"""
🆕 임시: 콘텐츠 생성을 위한 단일 상품 선택 로직
우선순위: 1) S3 업로드 성공한 상품 중 이미지 개수가 많은 것
2) 없다면 크롤링 성공한 첫 번째 상품
"""
try:
# 1순위: S3 업로드 성공하고 이미지가 있는 상품들
successful_uploads = [
result
for result in upload_results
if result.get("status") == "completed"
and result.get("success_count", 0) > 0
]

if successful_uploads:
# 이미지 개수가 가장 많은 상품 선택
best_upload = max(
successful_uploads, key=lambda x: x.get("success_count", 0)
)
selected_index = best_upload["product_index"]

# 원본 크롤링 데이터에서 해당 상품 찾기
for product_info in crawled_products:
if product_info.get("index") == selected_index:
logger.info(
f"콘텐츠 생성용 상품 선택: index={selected_index}, "
f"title='{product_info.get('product_detail', {}).get('title', 'Unknown')[:30]}', "
f"images={best_upload.get('success_count', 0)}개"
)
return {
"selection_reason": "s3_upload_success_with_most_images",
"product_info": product_info,
"s3_upload_info": best_upload,
}

# 2순위: 크롤링 성공한 첫 번째 상품 (S3 업로드 실패해도)
for product_info in crawled_products:
if product_info.get("status") == "success" and product_info.get(
"product_detail"
):

# 해당 상품의 S3 업로드 정보 찾기
upload_info = None
for result in upload_results:
if result.get("product_index") == product_info.get("index"):
upload_info = result
break

logger.info(
f"콘텐츠 생성용 상품 선택 (fallback): index={product_info.get('index')}, "
f"title='{product_info.get('product_detail', {}).get('title', 'Unknown')[:30]}'"
)
return {
"selection_reason": "first_crawl_success",
"product_info": product_info,
"s3_upload_info": upload_info,
}

# 3순위: 아무거나 (모든 상품이 실패한 경우)
if crawled_products:
logger.warning("모든 상품이 크롤링 실패 - 첫 번째 상품으로 fallback")
return {
"selection_reason": "fallback_first_product",
"product_info": crawled_products[0],
"s3_upload_info": upload_results[0] if upload_results else None,
}

logger.error("선택할 상품이 없습니다")
return {
"selection_reason": "no_products_available",
"product_info": None,
"s3_upload_info": None,
}

except Exception as e:
logger.error(f"단일 상품 선택 오류: {e}")
return {
"selection_reason": "selection_error",
"product_info": crawled_products[0] if crawled_products else None,
"s3_upload_info": upload_results[0] if upload_results else None,
"error": str(e),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package site.icebang.domain.email.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import lombok.RequiredArgsConstructor;

import site.icebang.domain.email.dto.EmailRequest;
import site.icebang.domain.email.service.EmailService;

@RestController
@RequestMapping("/api/v1/email")
@RequiredArgsConstructor
public class EmailTestController {
private final EmailService emailService;

@GetMapping("/test")
@PreAuthorize("permitAll()")
public ResponseEntity<String> sendTestEmail(@RequestParam String to) {
try {
EmailRequest emailRequest =
EmailRequest.builder()
.to(to)
.subject("IceBang 실제 테스트 이메일")
.body("안녕하세요!\n\nIceBang에서 보내는 실제 Gmail 테스트 이메일입니다.\n\n성공적으로 연동되었습니다!")
.isHtml(false)
.build();

emailService.send(emailRequest);
return ResponseEntity.ok("실제 Gmail 테스트 이메일 전송 완료! 받은편지함을 확인하세요!");

} catch (Exception e) {
return ResponseEntity.badRequest().body("이메일 전송 실패: " + e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,92 @@
// package site.icebang.domain.email.service;
//
// import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
// import org.springframework.stereotype.Service;
//
// import lombok.RequiredArgsConstructor;
//
// import site.icebang.domain.email.dto.EmailRequest;
//
// @Service
// @RequiredArgsConstructor
package site.icebang.domain.email.service;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import site.icebang.domain.email.dto.EmailRequest;

@Slf4j
@Service
@Profile({"production"})
@RequiredArgsConstructor
// @ConditionalOnMissingBean(EmailService.class)
// public class EmailServiceImpl implements EmailService {
// @Override
// public void send(EmailRequest emailRequest) {}
// }
public class EmailServiceImpl implements EmailService {

private final JavaMailSender mailSender;

@Value("${spring.mail.username}")
private String defaultSender;

@Override
public void send(EmailRequest request) {
try {
if (request.isHtml()) {
sendHtmlEmail(request);
} else {
sendSimpleEmail(request);
}
} catch (Exception e) {
log.error("❌ 이메일 전송 실패 - To: {}", request.getTo(), e);
throw new RuntimeException("이메일 전송 실패: " + e.getMessage(), e);
}
}

private void sendSimpleEmail(EmailRequest request) {
try {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(request.getTo());
message.setSubject(request.getSubject());
message.setText(request.getBody());
message.setFrom(defaultSender);

// CC 설정
if (request.getCc() != null && !request.getCc().isEmpty()) {
message.setCc(request.getCc().toArray(new String[0]));
}

// BCC 설정
if (request.getBcc() != null && !request.getBcc().isEmpty()) {
message.setBcc(request.getBcc().toArray(new String[0]));
}

mailSender.send(message);
log.info("✅ 실제 Gmail 전송 성공! To: {}, Subject: {}", request.getTo(), request.getSubject());

} catch (Exception e) {
log.error("❌ Gmail 텍스트 전송 실패", e);
throw e;
}
}

private void sendHtmlEmail(EmailRequest request) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

helper.setTo(request.getTo());
helper.setSubject(request.getSubject());
helper.setText(request.getBody(), true); // HTML 모드
helper.setFrom(defaultSender);

// CC 설정
if (request.getCc() != null && !request.getCc().isEmpty()) {
helper.setCc(request.getCc().toArray(new String[0]));
}

// BCC 설정
if (request.getBcc() != null && !request.getBcc().isEmpty()) {
helper.setBcc(request.getBcc().toArray(new String[0]));
}

mailSender.send(message);
log.info("✅ 실제 Gmail HTML 전송 성공! To: {}, Subject: {}", request.getTo(), request.getSubject());
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
package site.icebang.domain.email.service;

import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;

import lombok.extern.slf4j.Slf4j;

import site.icebang.domain.email.dto.EmailRequest;

@Service
// @Profile({"test-unit", "test-e2e", "test-integration", "local", "develop", "production"})
@Profile({"develop", "test-e2e", "test-integration", "test-unit"})
@Slf4j
public class MockEmailService implements EmailService {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import site.icebang.common.dto.PageResult;
import site.icebang.domain.workflow.dto.WorkflowCardDto;
import site.icebang.domain.workflow.service.WorkflowExecutionService;
import site.icebang.domain.workflow.service.WorkflowHistoryService;
import site.icebang.domain.workflow.service.WorkflowService;

@RestController
Expand All @@ -18,6 +19,7 @@
public class WorkflowController {
private final WorkflowService workflowService;
private final WorkflowExecutionService workflowExecutionService;
private final WorkflowHistoryService workflowHistoryService;

@GetMapping("")
public ApiResponse<PageResult<WorkflowCardDto>> getWorkflowList(
Expand Down
Loading
Loading