From d2c933697748e48439129c62e37362907d1d8409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B2=BD=EB=AF=BC?= <153978154+kakusiA@users.noreply.github.com> Date: Tue, 23 Sep 2025 18:30:44 +0900 Subject: [PATCH 01/40] =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20default=5Fconfig=20=EC=82=AC=EC=9A=A9=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EA=B0=9C=EB=B0=9C=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :fix - 스케줄 스키마 유니크 제약조건 제거 - workflow insert문 default_config 로직에 맞게 수정 * refactor: 워크 플로우 하드코딩 제거 1. workflow 테이블에서 default_config값 가져오기 2. 각 task_id에맞게 config값이 setting에 들어가도록 수정 3. 각각의 taskBuilder에 하드코딩된값 변경 * fix: 코드 머지후 다시 코드 작성 * style: springBoot 코드 포맷팅 * fix:v0.0.5 Alter 스키마 삭제 --- .../icebang/domain/workflow/dto/TaskDto.java | 4 +-- .../icebang/domain/workflow/model/Task.java | 3 ++ .../fastapi/body/BlogPublishBodyBuilder.java | 9 +++--- .../body/KeywordSearchBodyBuilder.java | 3 +- .../service/WorkflowExecutionService.java | 30 ++++++++++++++----- .../src/main/resources/sql/01-schema.sql | 2 ++ .../main/resources/sql/03-insert-workflow.sql | 2 +- 7 files changed, 38 insertions(+), 15 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskDto.java index 569e93dc..fa83fe7d 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskDto.java @@ -11,9 +11,9 @@ public class TaskDto { private Long id; private String name; private String type; + private Integer executionOrder; + private JsonNode settings; private JsonNode parameters; private LocalDateTime createdAt; private LocalDateTime updatedAt; - - private Integer executionOrder; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java index 713e460f..2c917100 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java @@ -24,6 +24,8 @@ public class Task { /** Task 실행에 필요한 파라미터 (JSON) 예: {"url": "http://...", "method": "POST", "body": {...}} */ private JsonNode parameters; + private JsonNode settings; + private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -32,6 +34,7 @@ public Task(TaskDto taskDto) { this.id = taskDto.getId(); this.name = taskDto.getName(); this.type = taskDto.getType(); + this.settings = taskDto.getSettings(); this.parameters = taskDto.getParameters(); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogPublishBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogPublishBodyBuilder.java index d0967857..ed148061 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogPublishBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogPublishBodyBuilder.java @@ -49,10 +49,11 @@ public ObjectNode build(Task task, Map workflowContext) { .filter(node -> !node.isMissingNode()) .ifPresent(tagsNode -> body.set("post_tags", tagsNode)); }); - - body.put("tag", "Blogger"); - body.put("blog_id", ""); - body.put("blog_pw", ""); + String blog_name = task.getSettings().path("blog_name").asText(""); + body.put("tag", task.getSettings().get("tag").asText()); + body.put("blog_name", blog_name); + body.put("blog_id", task.getSettings().get("blog_id").asText()); + body.put("blog_pw", task.getSettings().get("blog_pw").asText()); return body; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/KeywordSearchBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/KeywordSearchBodyBuilder.java index 17add786..597ab0b7 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/KeywordSearchBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/KeywordSearchBodyBuilder.java @@ -27,6 +27,7 @@ public boolean supports(String taskName) { @Override public ObjectNode build(Task task, Map workflowContext) { // 이 Task는 항상 정적인 Body를 가집니다. - return objectMapper.createObjectNode().put("tag", "naver"); + String tag = task.getSettings().get("tag").asText(); + return objectMapper.createObjectNode().put("tag", tag); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java index a27807ec..c434cb6f 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java @@ -1,5 +1,6 @@ package site.icebang.domain.workflow.service; +import java.math.BigInteger; import java.util.Comparator; import java.util.HashMap; import java.util.List; @@ -11,6 +12,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -19,11 +22,9 @@ import site.icebang.domain.workflow.dto.JobDto; import site.icebang.domain.workflow.dto.TaskDto; +import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; import site.icebang.domain.workflow.manager.ExecutionMdcManager; -import site.icebang.domain.workflow.mapper.JobMapper; -import site.icebang.domain.workflow.mapper.JobRunMapper; -import site.icebang.domain.workflow.mapper.TaskRunMapper; -import site.icebang.domain.workflow.mapper.WorkflowRunMapper; +import site.icebang.domain.workflow.mapper.*; import site.icebang.domain.workflow.model.Job; import site.icebang.domain.workflow.model.JobRun; import site.icebang.domain.workflow.model.Task; @@ -44,6 +45,7 @@ public class WorkflowExecutionService { private final List bodyBuilders; private final ExecutionMdcManager mdcManager; private final TaskExecutionService taskExecutionService; + private final WorkflowMapper workflowMapper; @Transactional @Async("traceExecutor") @@ -55,7 +57,9 @@ public void executeWorkflow(Long workflowId) { workflowRunMapper.insert(workflowRun); Map workflowContext = new HashMap<>(); - + WorkflowDetailCardDto settings = + workflowMapper.selectWorkflowDetailById(BigInteger.valueOf(workflowId)); + JsonNode setting = objectMapper.readTree(settings.getDefaultConfig()); // 📌 Mapper로부터 JobDto 리스트를 조회합니다. List jobDtos = jobMapper.findJobsByWorkflowId(workflowId); // 📌 JobDto를 execution_order 기준으로 정렬합니다. @@ -78,7 +82,7 @@ public void executeWorkflow(Long workflowId) { workflowLogger.info( "---------- Job 실행 시작: JobId={}, JobRunId={} ----------", job.getId(), jobRun.getId()); - boolean jobSucceeded = executeTasksForJob(jobRun, workflowContext); + boolean jobSucceeded = executeTasksForJob(jobRun, workflowContext, setting); jobRun.finish(jobSucceeded ? "SUCCESS" : "FAILED"); jobRunMapper.update(jobRun); @@ -96,13 +100,25 @@ public void executeWorkflow(Long workflowId) { "========== 워크플로우 실행 {} : WorkflowRunId={} ==========", hasAnyJobFailed ? "실패" : "성공", workflowRun.getId()); + } catch (JsonMappingException e) { + throw new RuntimeException(e); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); } finally { mdcManager.clearExecutionContext(); } } - private boolean executeTasksForJob(JobRun jobRun, Map workflowContext) { + private boolean executeTasksForJob( + JobRun jobRun, Map workflowContext, JsonNode setting) { List taskDtos = jobMapper.findTasksByJobId(jobRun.getJobId()); + for (TaskDto taskDto : taskDtos) { + String taskId = taskDto.getId().toString(); + JsonNode settingForTask = setting.get(taskId); + if (settingForTask != null) { + taskDto.setSettings(settingForTask); + } + } taskDtos.sort( Comparator.comparing( TaskDto::getExecutionOrder, Comparator.nullsLast(Comparator.naturalOrder())) diff --git a/apps/user-service/src/main/resources/sql/01-schema.sql b/apps/user-service/src/main/resources/sql/01-schema.sql index 31242c33..35d42e59 100644 --- a/apps/user-service/src/main/resources/sql/01-schema.sql +++ b/apps/user-service/src/main/resources/sql/01-schema.sql @@ -333,3 +333,5 @@ CREATE INDEX idx_log_level_status ON execution_log(log_level, status); CREATE INDEX idx_error_code ON execution_log(error_code); CREATE INDEX idx_duration ON execution_log(duration_ms); CREATE INDEX idx_execution_type_source ON execution_log(execution_type, source_id); + + diff --git a/apps/user-service/src/main/resources/sql/03-insert-workflow.sql b/apps/user-service/src/main/resources/sql/03-insert-workflow.sql index 0660b31f..9238b8a2 100644 --- a/apps/user-service/src/main/resources/sql/03-insert-workflow.sql +++ b/apps/user-service/src/main/resources/sql/03-insert-workflow.sql @@ -16,7 +16,7 @@ DELETE FROM `workflow`; -- 워크플로우 생성 (ID: 1) INSERT INTO `workflow` (`id`, `name`, `description`, `created_by`, `default_config`) VALUES (1, '상품 분석 및 블로그 자동 발행', '키워드 검색부터 상품 분석 후 블로그 발행까지의 자동화 프로세스', 1, - JSON_OBJECT('keyword_search',json_object('tag','naver'),'blog_publish',json_object('tag','naver_blog','blog_id', 'wtecho331', 'blog_pw', 'testpass'))) + JSON_OBJECT('1',json_object('tag','naver'),'8',json_object('tag','naver_blog','blog_id', 'wtecho331', 'blog_pw', 'testpass'))) ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), From b09558fd86a55fb1c5d982ca6332ee3270427f4d Mon Sep 17 00:00:00 2001 From: bwnfo3 <142577603+bwnfo3@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:07:48 +0900 Subject: [PATCH 02/40] =?UTF-8?q?check-session,=20permissons=20api?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20(#200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/auth/AuthApiIntegrationTest.java | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java index 4fe3b00d..b6e0e237 100644 --- a/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java @@ -2,6 +2,7 @@ import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; import static org.springframework.restdocs.payload.PayloadDocumentation.*; @@ -138,4 +139,239 @@ void logout_success() throws Exception { .description("HTTP 상태")) .build()))); } + + @Test + @DisplayName("세션 확인 - 인증된 사용자") + void checkSession_authenticated_success() throws Exception { + // given - 먼저 로그인하여 세션 생성 + Map loginRequest = new HashMap<>(); + loginRequest.put("email", "admin@icebang.site"); + loginRequest.put("password", "qwer1234!A"); + + MockHttpSession session = new MockHttpSession(); + + // 로그인 먼저 수행 + mockMvc + .perform( + post(getApiUrlForDocs("/v0/auth/login")) + .contentType(MediaType.APPLICATION_JSON) + .session(session) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()); + + // when & then - 세션 확인 수행 + mockMvc + .perform( + get(getApiUrlForDocs("/v0/auth/check-session")) + .session(session) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data").value(true)) + .andDo( + document( + "auth-check-session-authenticated", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Authentication") + .summary("세션 확인 - 인증된 상태") + .description("현재 사용자의 인증 세션이 유효한지 확인합니다 (인증된 경우)") + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data") + .type(JsonFieldType.BOOLEAN) + .description("세션 유효 여부 (인증된 경우 true)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("세션 확인 - 미인증 사용자") + void checkSession_unauthenticated_returns_unauthorized() throws Exception { + // given - 세션 없이 요청 (미인증 상태) + + // when & then - 세션 확인 수행 + mockMvc + .perform( + get(getApiUrlForDocs("/v0/auth/check-session")) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value("UNAUTHORIZED")) + .andExpect(jsonPath("$.message").value("Authentication required")) + .andExpect(jsonPath("$.data").isEmpty()) + .andDo( + document( + "auth-check-session-unauthenticated", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Authentication") + .summary("세션 확인 - 미인증 상태") + .description("인증되지 않은 상태에서 세션 확인 시 401 Unauthorized를 반환합니다") + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부 (미인증 시 false)"), + fieldWithPath("data") + .type(JsonFieldType.NULL) // BOOLEAN -> NULL로 변경 + .description("응답 데이터 (미인증 시 null)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지 (Authentication required)"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태 (UNAUTHORIZED)")) + .build()))); + } + + @Test + @DisplayName("권한 정보 조회 - 인증된 사용자") + void getPermissions_authenticated_success() throws Exception { + // given - 먼저 로그인하여 세션 생성 + Map loginRequest = new HashMap<>(); + loginRequest.put("email", "admin@icebang.site"); + loginRequest.put("password", "qwer1234!A"); + + MockHttpSession session = new MockHttpSession(); + + // 로그인 먼저 수행 + mockMvc + .perform( + post(getApiUrlForDocs("/v0/auth/login")) + .contentType(MediaType.APPLICATION_JSON) + .session(session) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()); + + // when & then - 권한 정보 조회 수행 + mockMvc + .perform( + get(getApiUrlForDocs("/v0/auth/permissions")) + .session(session) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data.email").value("admin@icebang.site")) + .andDo( + document( + "auth-get-permissions-authenticated", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Authentication") + .summary("권한 정보 조회 - 인증된 상태") + .description("현재 인증된 사용자의 상세 정보와 권한을 조회합니다") + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data") + .type(JsonFieldType.OBJECT) + .description("사용자 인증 정보"), + fieldWithPath("data.id") + .type(JsonFieldType.NUMBER) + .description("사용자 고유 ID"), + fieldWithPath("data.email") + .type(JsonFieldType.STRING) + .description("사용자 이메일 주소"), + fieldWithPath("data.password") + .type(JsonFieldType.STRING) + .description("사용자 비밀번호"), + fieldWithPath("data.status") + .type(JsonFieldType.STRING) + .description("사용자 계정 상태"), + fieldWithPath("data.roles") + .type(JsonFieldType.ARRAY) + .description("사용자 권한 목록"), + fieldWithPath("data.enabled") + .type(JsonFieldType.BOOLEAN) + .description("계정 활성화 여부"), + fieldWithPath("data.username") + .type(JsonFieldType.STRING) + .description("사용자명 (이메일과 동일)"), + fieldWithPath("data.authorities") + .type(JsonFieldType.ARRAY) + .description("Spring Security 권한 목록"), + fieldWithPath("data.authorities[].authority") + .type(JsonFieldType.STRING) + .description("개별 권한"), + fieldWithPath("data.credentialsNonExpired") + .type(JsonFieldType.BOOLEAN) + .description("자격증명 만료 여부"), + fieldWithPath("data.accountNonExpired") + .type(JsonFieldType.BOOLEAN) + .description("계정 만료 여부"), + fieldWithPath("data.accountNonLocked") + .type(JsonFieldType.BOOLEAN) + .description("계정 잠금 여부"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("권한 정보 조회 - 미인증 사용자") + void getPermissions_unauthenticated_returns_unauthorized() throws Exception { + // given - 세션 없이 요청 (미인증 상태) + + // when & then - 권한 정보 조회 수행 (401 Unauthorized 응답 예상) + mockMvc + .perform( + get(getApiUrlForDocs("/v0/auth/permissions")) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value("UNAUTHORIZED")) + .andExpect(jsonPath("$.message").value("Authentication required")) + .andExpect(jsonPath("$.data").isEmpty()) + .andDo( + document( + "auth-get-permissions-unauthenticated", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Authentication") + .summary("권한 정보 조회 - 미인증 상태") + .description("인증되지 않은 상태에서 권한 정보 조회 시 401 Unauthorized를 반환합니다") + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부 (미인증 시 false)"), + fieldWithPath("data") + .type(JsonFieldType.NULL) + .description("응답 데이터 (미인증 시 null)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지 (Authentication required)"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태 (UNAUTHORIZED)")) + .build()))); + } } From 0fd54d66959a0eb63bceabcf160b83b06fd78f3b Mon Sep 17 00:00:00 2001 From: thkim7 Date: Thu, 25 Sep 2025 15:34:10 +0900 Subject: [PATCH 03/40] =?UTF-8?q?feat:=20S3=20=ED=95=98=EA=B3=A0=20RDB=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=201.=20body=20builder=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=202.=20s3=5Fupload=20+=20=EC=9E=84=EC=8B=9C=201=EA=B0=9C=20?= =?UTF-8?q?=EB=BD=91=EA=B8=B0=20->=20rag=5Fcreate=20=EB=A5=BC=20s3=5Fuploa?= =?UTF-8?q?d=20+=20rdb=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=82=BD=EC=9E=85?= =?UTF-8?q?=20->=20=EC=9E=84=EC=8B=9C=201=EA=B0=9C=20=EB=BD=91=EA=B8=B0=20?= =?UTF-8?q?->=20rag=5Fcreate=EB=A1=9C=20=EB=B3=80=EA=B2=BD=203.=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=9D=BC=20schemas=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/api/endpoints/product.py | 17 ++ .../app/model/schemas.py | 28 ++- .../app/service/product_selection_service.py | 191 ++++++++++++++++ .../app/service/s3_upload_service.py | 204 +++++++++--------- .../fastapi/body/BlogRagBodyBuilder.java | 66 +++--- .../body/ProductSelectBodyBuilder.java | 40 ++++ .../fastapi/body/S3UploadBodyBuilder.java | 62 +++--- 7 files changed, 434 insertions(+), 174 deletions(-) create mode 100644 apps/pre-processing-service/app/service/product_selection_service.py create mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java diff --git a/apps/pre-processing-service/app/api/endpoints/product.py b/apps/pre-processing-service/app/api/endpoints/product.py index 2812ef79..0b9e888f 100644 --- a/apps/pre-processing-service/app/api/endpoints/product.py +++ b/apps/pre-processing-service/app/api/endpoints/product.py @@ -10,6 +10,7 @@ from ...service.search_service import SearchService from ...service.match_service import MatchService from ...service.similarity_service import SimilarityService +from ...service.product_selection_service import ProductSelectionService # from ...service.similarity_service import SimilarityService @@ -121,3 +122,19 @@ async def s3_upload(request: RequestS3Upload): raise HTTPException(status_code=e.status_code, detail=e.detail) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/select", response_model=ResponseProductSelect, summary="콘텐츠용 상품 선택") +def select_product(request: RequestProductSelect): # async 제거 + """ + S3 업로드 완료 후 콘텐츠 생성을 위한 최적 상품을 선택합니다. + """ + try: + selection_service = ProductSelectionService() + response_data = selection_service.select_product_for_content(request) # await 제거 + + if not response_data: + raise CustomException(500, "상품 선택에 실패했습니다.", "PRODUCT_SELECTION_FAILED") + + return response_data + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/apps/pre-processing-service/app/model/schemas.py b/apps/pre-processing-service/app/model/schemas.py index dd49cf44..a555dfe5 100644 --- a/apps/pre-processing-service/app/model/schemas.py +++ b/apps/pre-processing-service/app/model/schemas.py @@ -161,7 +161,7 @@ class ResponseSadaguCrawl(ResponseBase[SadaguCrawlData]): pass -# ============== S3 이미지 업로드 ============== +# ============== S3 업로드 ============== class RequestS3Upload(RequestBase): @@ -227,12 +227,6 @@ class S3UploadData(BaseModel): uploaded_at: str = Field( ..., title="업로드 완료 시간", description="S3 업로드 완료 시간" ) - # 🆕 임시: 콘텐츠 생성용 단일 상품만 추가 (나중에 삭제 예정) - selected_product_for_content: Optional[Dict] = Field( - None, - title="콘텐츠 생성용 선택 상품", - description="임시: 블로그 콘텐츠 생성을 위해 선택된 단일 상품 정보", - ) # 최종 응답 모델 @@ -241,6 +235,26 @@ class ResponseS3Upload(ResponseBase[S3UploadData]): pass +# ============== 상품 선택 (새로 추가) ============== + +class RequestProductSelect(RequestBase): + task_run_id: int = Field(..., title="Task Run ID", description="상품을 선택할 task_run_id") + selection_criteria: Optional[str] = Field( + None, title="선택 기준", description="특별한 선택 기준 (기본: 이미지 개수 우선)" + ) + + +# 응답 데이터 모델 +class ProductSelectData(BaseModel): + task_run_id: int = Field(..., title="Task Run ID") + selected_product: Dict = Field(..., title="선택된 상품", description="콘텐츠 생성용으로 선택된 상품") + total_available_products: int = Field(..., title="전체 상품 수", description="선택 가능했던 전체 상품 개수") + + +# 최종 응답 모델 +class ResponseProductSelect(ResponseBase[ProductSelectData]): + """상품 선택 API 응답""" + pass # ============== 블로그 콘텐츠 생성 ============== diff --git a/apps/pre-processing-service/app/service/product_selection_service.py b/apps/pre-processing-service/app/service/product_selection_service.py new file mode 100644 index 00000000..96093707 --- /dev/null +++ b/apps/pre-processing-service/app/service/product_selection_service.py @@ -0,0 +1,191 @@ +import json +from typing import List, Dict +from loguru import logger +from app.model.schemas import RequestProductSelect +from app.utils.response import Response +from app.db.mariadb_manager import MariadbManager + + +class ProductSelectionService: + """콘텐츠 생성용 단일 상품 선택 서비스""" + + def __init__(self): + self.db_manager = MariadbManager() + + def select_product_for_content(self, request: RequestProductSelect) -> dict: + """ + S3 업로드와 DB 저장 결과를 바탕으로 콘텐츠 생성용 단일 상품을 선택 + """ + try: + task_run_id = request.task_run_id + logger.info(f"콘텐츠용 상품 선택 시작: task_run_id={task_run_id}") + + # 1. DB에서 해당 task_run_id의 모든 상품 조회 + db_products = self._fetch_products_from_db(task_run_id) + + if not db_products: + logger.warning(f"DB에서 상품을 찾을 수 없음: task_run_id={task_run_id}") + return Response.error("상품 데이터를 찾을 수 없습니다.", "PRODUCTS_NOT_FOUND") + + # 2. 최적 상품 선택 + selected_product = self._select_best_product(db_products) + + logger.success( + f"콘텐츠용 상품 선택 완료: name={selected_product['name']}, " + f"selection_reason={selected_product['selection_reason']}" + ) + + data = { + "task_run_id": task_run_id, + "selected_product": selected_product, + "total_available_products": len(db_products), + } + + return Response.ok(data, f"콘텐츠용 상품 선택 완료: {selected_product['name']}") + + except Exception as e: + logger.error(f"콘텐츠용 상품 선택 오류: {e}") + raise + + def _fetch_products_from_db(self, task_run_id: int) -> List[Dict]: + """DB에서 task_run_id에 해당하는 모든 상품 조회""" + try: + sql = """ + SELECT id, \ + name, \ + data_value, \ + created_at + FROM task_io_data + WHERE task_run_id = %s + AND io_type = 'OUTPUT' + AND data_type = 'JSON' + ORDER BY name \ + """ + + with self.db_manager.get_cursor() as cursor: + cursor.execute(sql, (task_run_id,)) + rows = cursor.fetchall() + + products = [] + for row in rows: + try: + # MariaDB에서 반환되는 row는 튜플 형태 + id, name, data_value_str, created_at = row + + # JSON 데이터 파싱 + data_value = json.loads(data_value_str) + + products.append({ + "id": id, + "name": name, + "data_value": data_value, + "created_at": created_at + }) + except json.JSONDecodeError as e: + logger.warning(f"JSON 파싱 실패: name={name}, error={e}") + continue + except Exception as e: + logger.warning(f"Row 처리 실패: {row}, error={e}") + continue + + logger.info(f"DB에서 {len(products)}개 상품 조회 완료") + return products + + except Exception as e: + logger.error(f"DB 상품 조회 오류: {e}") + return [] + + def _select_best_product(self, db_products: List[Dict]) -> Dict: + """ + 상품 선택 로직: + 1순위: S3 이미지 업로드가 성공하고 이미지가 많은 상품 + 2순위: 크롤링 성공한 첫 번째 상품 + 3순위: 첫 번째 상품 (fallback) + """ + try: + successful_products = [] + + # 1순위: S3 업로드 성공하고 이미지가 있는 상품들 + for product in db_products: + data_value = product.get("data_value", {}) + product_detail = data_value.get("product_detail", {}) + product_images = product_detail.get("product_images", []) + + # 크롤링 성공하고 이미지가 있는 상품 + if (data_value.get("status") == "success" and + product_detail and len(product_images) > 0): + successful_products.append({ + "product": product, + "image_count": len(product_images), + "title": product_detail.get("title", "Unknown") + }) + + if successful_products: + # 이미지 개수가 가장 많은 상품 선택 + best_product = max(successful_products, key=lambda x: x["image_count"]) + + logger.info( + f"1순위 선택: name={best_product['product']['name']}, " + f"images={best_product['image_count']}개" + ) + + return { + "selection_reason": "s3_upload_success_with_most_images", + "name": best_product["product"]["name"], + "product_info": best_product["product"]["data_value"], + "image_count": best_product["image_count"], + "title": best_product["title"] + } + + # 2순위: 크롤링 성공한 첫 번째 상품 (이미지 없어도) + for product in db_products: + data_value = product.get("data_value", {}) + if (data_value.get("status") == "success" and + data_value.get("product_detail")): + product_detail = data_value.get("product_detail", {}) + logger.info(f"2순위 선택: name={product['name']}") + + return { + "selection_reason": "first_crawl_success", + "name": product["name"], + "product_info": data_value, + "image_count": len(product_detail.get("product_images", [])), + "title": product_detail.get("title", "Unknown") + } + + # 3순위: 첫 번째 상품 (fallback) + if db_products: + first_product = db_products[0] + data_value = first_product.get("data_value", {}) + product_detail = data_value.get("product_detail", {}) + + logger.warning(f"3순위 fallback 선택: name={first_product['name']}") + + return { + "selection_reason": "fallback_first_product", + "name": first_product["name"], + "product_info": data_value, + "image_count": len(product_detail.get("product_images", [])), + "title": product_detail.get("title", "Unknown") + } + + # 모든 경우 실패 + logger.error("선택할 상품이 없습니다") + return { + "selection_reason": "no_products_available", + "name": None, + "product_info": None, + "image_count": 0, + "title": "Unknown" + } + + except Exception as e: + logger.error(f"상품 선택 로직 오류: {e}") + return { + "selection_reason": "selection_error", + "name": db_products[0]["name"] if db_products else None, + "product_info": db_products[0]["data_value"] if db_products else None, + "image_count": 0, + "title": "Unknown", + "error": str(e) + } \ No newline at end of file diff --git a/apps/pre-processing-service/app/service/s3_upload_service.py b/apps/pre-processing-service/app/service/s3_upload_service.py index 48c84d35..7e52152c 100644 --- a/apps/pre-processing-service/app/service/s3_upload_service.py +++ b/apps/pre-processing-service/app/service/s3_upload_service.py @@ -1,41 +1,59 @@ import time +import json import asyncio import aiohttp +import ssl, certifi from typing import List, Dict +from datetime import datetime from loguru import logger from app.errors.CustomException import InvalidItemDataException from app.model.schemas import RequestS3Upload from app.utils.s3_upload_util import S3UploadUtil from app.utils.response import Response +from app.db.mariadb_manager import MariadbManager class S3UploadService: - """6단계: 크롤링된 상품 이미지들과 데이터를 S3에 업로드하는 서비스""" + """6단계: 크롤링된 상품 이미지들과 데이터를 S3에 업로드하고 DB에 저장하는 서비스""" def __init__(self): self.s3_util = S3UploadUtil() + self.db_manager = MariadbManager() async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: """ - 크롤링된 상품들의 이미지와 데이터를 S3에 업로드하는 비즈니스 로직 (6단계) + 크롤링된 상품들의 이미지와 데이터를 S3에 업로드하고 DB에 저장하는 비즈니스 로직 (6단계) """ - keyword = request.keyword # 키워드 추가 + keyword = request.keyword crawled_products = request.crawled_products - base_folder = ( - request.base_folder or "product" - ) # 🔸 기본값 변경: product-images → product + base_folder = request.base_folder or "product" + + # task_run_id는 자바 워크플로우에서 전달받음 + task_run_id = getattr(request, 'task_run_id', None) + if not task_run_id: + # 임시: task_run_id가 없으면 생성 + task_run_id = int(time.time() * 1000) + logger.warning(f"task_run_id가 없어서 임시로 생성: {task_run_id}") + else: + logger.info(f"자바 워크플로우에서 전달받은 task_run_id: {task_run_id}") logger.info( - f"S3 업로드 서비스 시작: keyword='{keyword}', {len(crawled_products)}개 상품" + f"S3 업로드 + DB 저장 서비스 시작: keyword='{keyword}', " + f"{len(crawled_products)}개 상품, task_run_id={task_run_id}" ) upload_results = [] total_success_images = 0 total_fail_images = 0 + db_save_results = [] try: # HTTP 세션을 사용한 이미지 다운로드 - async with aiohttp.ClientSession() as session: + + ssl_context = ssl.create_default_context(cafile=certifi.where()) + connector = aiohttp.TCPConnector(ssl=ssl_context) + + async with aiohttp.ClientSession(connector=connector) as session: # 각 상품별로 순차 업로드 for product_info in crawled_products: @@ -43,7 +61,7 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: product_detail = product_info.get("product_detail") logger.info( - f"상품 {product_index}/{len(crawled_products)} S3 업로드 시작" + f"상품 {product_index}/{len(crawled_products)} S3 업로드 + DB 저장 시작" ) # 크롤링 실패한 상품은 스킵 @@ -62,30 +80,43 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: "fail_count": 0, } ) + db_save_results.append({ + "product_index": product_index, + "db_status": "skipped", + "error": "크롤링 실패" + }) continue try: - # 상품 이미지 + 데이터 업로드 (키워드 전달 추가!) - # 🔸 전체 크롤링 데이터를 전달 (product_detail이 아닌 product_info 전체) + # 1. 상품 이미지 + 데이터 S3 업로드 upload_result = await self.s3_util.upload_single_product_images( session, product_info, product_index, keyword, - base_folder, # product_detail → product_info + base_folder, ) upload_results.append(upload_result) total_success_images += upload_result["success_count"] total_fail_images += upload_result["fail_count"] + # 2. DB에 상품 데이터 저장 + db_result = self._save_product_to_db( + task_run_id, + keyword, + product_index, + product_info + ) + db_save_results.append(db_result) + logger.success( - f"상품 {product_index} S3 업로드 완료: 성공 {upload_result['success_count']}개, " - f"실패 {upload_result['fail_count']}개" + f"상품 {product_index} S3 업로드 + DB 저장 완료: " + f"이미지 성공 {upload_result['success_count']}개, DB {db_result['db_status']}" ) except Exception as e: - logger.error(f"상품 {product_index} S3 업로드 오류: {e}") + logger.error(f"상품 {product_index} S3 업로드/DB 저장 오류: {e}") upload_results.append( { "product_index": product_index, @@ -97,122 +128,93 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: "fail_count": 0, } ) + db_save_results.append({ + "product_index": product_index, + "db_status": "error", + "error": str(e) + }) # 상품간 간격 (서버 부하 방지) 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}개" + f"S3 업로드 + DB 저장 서비스 완료: 총 성공 이미지 {total_success_images}개, " + f"총 실패 이미지 {total_fail_images}개" ) - # 기존 응답 데이터 구성 + # 응답 데이터 구성 data = { "upload_results": upload_results, + "db_save_results": db_save_results, + "task_run_id": task_run_id, "summary": { "total_products": len(crawled_products), "total_success_images": total_success_images, "total_fail_images": total_fail_images, + "db_success_count": len([r for r in db_save_results if r.get("db_status") == "success"]), + "db_fail_count": len([r for r in db_save_results if r.get("db_status") == "error"]), }, "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 파일 포함" + message = f"S3 업로드 + DB 저장 완료: {total_success_images}개 이미지 성공, {len([r for r in db_save_results if r.get('db_status') == 'success'])}개 상품 DB 저장 성공" return Response.ok(data, message) except Exception as e: - logger.error(f"S3 업로드 서비스 전체 오류: {e}") + logger.error(f"S3 업로드 + DB 저장 서비스 전체 오류: {e}") raise InvalidItemDataException() - def _select_single_product_for_content( - self, crawled_products: List[Dict], upload_results: List[Dict] + def _save_product_to_db( + self, + task_run_id: int, + keyword: str, + product_index: int, + product_info: Dict ) -> Dict: """ - 🆕 임시: 콘텐츠 생성을 위한 단일 상품 선택 로직 - 우선순위: 1) S3 업로드 성공한 상품 중 이미지 개수가 많은 것 - 2) 없다면 크롤링 성공한 첫 번째 상품 + 상품 데이터를 TASK_IO_DATA 테이블에 저장 (MariaDB) """ 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 + # 상품명 생성 (산리오_01 형식) + product_name = f"{keyword}_{product_index:02d}" + + # data_value에 저장할 JSON 데이터 (전체 product_info) + data_value_json = json.dumps(product_info, ensure_ascii=False) + + # 현재 시간 + created_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # MariaDB에 저장 + with self.db_manager.get_cursor() as cursor: + sql = """ + INSERT INTO task_io_data + (task_run_id, io_type, name, data_type, data_value, created_at) + VALUES (%s, %s, %s, %s, %s, %s) \ + """ + + cursor.execute(sql, ( + task_run_id, + "OUTPUT", + product_name, + "JSON", + data_value_json, + created_at + )) + + logger.success(f"상품 {product_index} DB 저장 성공: name={product_name}") - 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, + "product_index": product_index, + "product_name": product_name, + "db_status": "success", + "task_run_id": task_run_id, } except Exception as e: - logger.error(f"단일 상품 선택 오류: {e}") + logger.error(f"상품 {product_index} DB 저장 오류: {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), - } + "product_index": product_index, + "db_status": "error", + "error": str(e) + } \ No newline at end of file diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogRagBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogRagBodyBuilder.java index 419a23a4..87c838a5 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogRagBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogRagBodyBuilder.java @@ -17,39 +17,33 @@ @RequiredArgsConstructor public class BlogRagBodyBuilder implements TaskBodyBuilder { - private final ObjectMapper objectMapper; - private static final String TASK_NAME = "블로그 RAG 생성 태스크"; - private static final String KEYWORD_SOURCE_TASK = "키워드 검색 태스크"; - private static final String S3_UPLOAD_SOURCE_TASK = "S3 업로드 태스크"; // 변경: 크롤링 → S3 업로드 - - @Override - public boolean supports(String taskName) { - return TASK_NAME.equals(taskName); - } - - @Override - public ObjectNode build(Task task, Map workflowContext) { - ObjectNode body = objectMapper.createObjectNode(); - - // 키워드 정보 가져오기 - Optional.ofNullable(workflowContext.get(KEYWORD_SOURCE_TASK)) - .map(node -> node.path("data").path("keyword")) - .ifPresent(keywordNode -> body.set("keyword", keywordNode)); - - // S3 업로드에서 선택된 상품 정보 가져오기 (변경된 부분) - Optional.ofNullable(workflowContext.get(S3_UPLOAD_SOURCE_TASK)) - .map( - node -> - node.path("data") - .path("selected_product_for_content") - .path("product_info") - .path("product_detail")) - .ifPresent(productNode -> body.set("product_info", productNode)); - - // 기본 콘텐츠 설정 - body.put("content_type", "review_blog"); - body.put("target_length", 1000); - - return body; - } -} + private final ObjectMapper objectMapper; + private static final String TASK_NAME = "블로그 RAG 생성 태스크"; + private static final String KEYWORD_SOURCE_TASK = "키워드 검색 태스크"; + private static final String PRODUCT_SELECT_SOURCE_TASK = "상품 선택 태스크"; // 변경: S3 업로드 → 상품 선택 + + @Override + public boolean supports(String taskName) { + return TASK_NAME.equals(taskName); + } + + @Override + public ObjectNode build(Task task, Map workflowContext) { + ObjectNode body = objectMapper.createObjectNode(); + + // 키워드 정보 가져오기 + Optional.ofNullable(workflowContext.get(KEYWORD_SOURCE_TASK)) + .map(node -> node.path("data").path("keyword")) + .ifPresent(keywordNode -> body.set("keyword", keywordNode)); + + Optional.ofNullable(workflowContext.get(PRODUCT_SELECT_SOURCE_TASK)) + .map(node -> node.path("data").path("selected_product")) + .ifPresent(productNode -> body.set("product_info", productNode)); + + // 기본 콘텐츠 설정 + body.put("content_type", "review_blog"); + body.put("target_length", 1000); + + return body; + } +} \ No newline at end of file diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java new file mode 100644 index 00000000..1d2ef5bf --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java @@ -0,0 +1,40 @@ +package site.icebang.domain.workflow.runner.fastapi.body; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.RequiredArgsConstructor; + +import site.icebang.domain.workflow.model.Task; + +@Component +@RequiredArgsConstructor +public class ProductSelectBodyBuilder implements TaskBodyBuilder { + + private final ObjectMapper objectMapper; + private static final String TASK_NAME = "상품 선택 태스크"; + + @Override + public boolean supports(String taskName) { + return TASK_NAME.equals(taskName); + } + + @Override + public ObjectNode build(Task task, Map workflowContext) { + ObjectNode body = objectMapper.createObjectNode(); + + // task_run_id는 현재 실행 중인 task의 run_id를 사용 + // 실제 구현에서는 Task 객체나 워크플로우 컨텍스트에서 가져와야 할 수 있습니다. + body.put("task_run_id", task.getId()); // Task 객체에서 ID를 가져오는 방식으로 가정 + + // 기본 선택 기준 설정 (이미지 개수 우선) + body.put("selection_criteria", "image_count_priority"); + + return body; + } +} \ No newline at end of file diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/S3UploadBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/S3UploadBodyBuilder.java index bd0f823e..7b927dff 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/S3UploadBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/S3UploadBodyBuilder.java @@ -17,33 +17,35 @@ @RequiredArgsConstructor public class S3UploadBodyBuilder implements TaskBodyBuilder { - private final ObjectMapper objectMapper; - private static final String TASK_NAME = "S3 업로드 태스크"; - private static final String KEYWORD_SOURCE_TASK = "키워드 검색 태스크"; - private static final String CRAWL_SOURCE_TASK = "상품 정보 크롤링 태스크"; - - @Override - public boolean supports(String taskName) { - return TASK_NAME.equals(taskName); - } - - @Override - public ObjectNode build(Task task, Map workflowContext) { - ObjectNode body = objectMapper.createObjectNode(); - - // 키워드 정보 가져오기 - Optional.ofNullable(workflowContext.get(KEYWORD_SOURCE_TASK)) - .map(node -> node.path("data").path("keyword")) - .ifPresent(keywordNode -> body.set("keyword", keywordNode)); - - // 크롤링된 상품 데이터 가져오기 - Optional.ofNullable(workflowContext.get(CRAWL_SOURCE_TASK)) - .map(node -> node.path("data").path("crawled_products")) - .ifPresent(crawledProductsNode -> body.set("crawled_products", crawledProductsNode)); - - // 기본 폴더 설정 - body.put("base_folder", "product"); - - return body; - } -} + private final ObjectMapper objectMapper; + private static final String TASK_NAME = "S3 업로드 태스크"; + private static final String KEYWORD_SOURCE_TASK = "키워드 검색 태스크"; + private static final String CRAWL_SOURCE_TASK = "상품 정보 크롤링 태스크"; + + @Override + public boolean supports(String taskName) { + return TASK_NAME.equals(taskName); + } + + @Override + public ObjectNode build(Task task, Map workflowContext) { + ObjectNode body = objectMapper.createObjectNode(); + + // 키워드 정보 가져오기 (폴더명 생성용 - 스키마 주석 참조) + Optional.ofNullable(workflowContext.get(KEYWORD_SOURCE_TASK)) + .map(node -> node.path("data").path("keyword")) + .filter(node -> !node.isMissingNode() && !node.asText().trim().isEmpty()) + .ifPresent(keywordNode -> body.set("keyword", keywordNode)); + + // 크롤링된 상품 데이터 가져오기 + Optional.ofNullable(workflowContext.get(CRAWL_SOURCE_TASK)) + .map(node -> node.path("data").path("crawled_products")) + .filter(node -> !node.isMissingNode()) + .ifPresent(crawledProductsNode -> body.set("crawled_products", crawledProductsNode)); + + // 기본 폴더 설정 (스키마의 기본값과 일치) + body.put("base_folder", "product"); + + return body; + } +} \ No newline at end of file From 9076e8a9d0081c34147f491a8d8b8b79e4cb8d69 Mon Sep 17 00:00:00 2001 From: thkim7 Date: Thu, 25 Sep 2025 15:36:04 +0900 Subject: [PATCH 04/40] chore: poetry run black . & spotlessApply --- .../app/api/endpoints/product.py | 15 ++-- .../app/model/schemas.py | 16 ++++- .../app/service/product_selection_service.py | 58 +++++++++------ .../app/service/s3_upload_service.py | 72 ++++++++++--------- .../fastapi/body/BlogRagBodyBuilder.java | 60 ++++++++-------- .../body/ProductSelectBodyBuilder.java | 34 ++++----- .../fastapi/body/S3UploadBodyBuilder.java | 64 ++++++++--------- 7 files changed, 177 insertions(+), 142 deletions(-) diff --git a/apps/pre-processing-service/app/api/endpoints/product.py b/apps/pre-processing-service/app/api/endpoints/product.py index 0b9e888f..f5a91272 100644 --- a/apps/pre-processing-service/app/api/endpoints/product.py +++ b/apps/pre-processing-service/app/api/endpoints/product.py @@ -123,18 +123,25 @@ async def s3_upload(request: RequestS3Upload): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -@router.post("/select", response_model=ResponseProductSelect, summary="콘텐츠용 상품 선택") + +@router.post( + "/select", response_model=ResponseProductSelect, summary="콘텐츠용 상품 선택" +) def select_product(request: RequestProductSelect): # async 제거 """ S3 업로드 완료 후 콘텐츠 생성을 위한 최적 상품을 선택합니다. """ try: selection_service = ProductSelectionService() - response_data = selection_service.select_product_for_content(request) # await 제거 + response_data = selection_service.select_product_for_content( + request + ) # await 제거 if not response_data: - raise CustomException(500, "상품 선택에 실패했습니다.", "PRODUCT_SELECTION_FAILED") + raise CustomException( + 500, "상품 선택에 실패했습니다.", "PRODUCT_SELECTION_FAILED" + ) return response_data except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file + raise HTTPException(status_code=500, detail=str(e)) diff --git a/apps/pre-processing-service/app/model/schemas.py b/apps/pre-processing-service/app/model/schemas.py index a555dfe5..7487927b 100644 --- a/apps/pre-processing-service/app/model/schemas.py +++ b/apps/pre-processing-service/app/model/schemas.py @@ -235,10 +235,14 @@ class ResponseS3Upload(ResponseBase[S3UploadData]): pass + # ============== 상품 선택 (새로 추가) ============== + class RequestProductSelect(RequestBase): - task_run_id: int = Field(..., title="Task Run ID", description="상품을 선택할 task_run_id") + task_run_id: int = Field( + ..., title="Task Run ID", description="상품을 선택할 task_run_id" + ) selection_criteria: Optional[str] = Field( None, title="선택 기준", description="특별한 선택 기준 (기본: 이미지 개수 우선)" ) @@ -247,15 +251,21 @@ class RequestProductSelect(RequestBase): # 응답 데이터 모델 class ProductSelectData(BaseModel): task_run_id: int = Field(..., title="Task Run ID") - selected_product: Dict = Field(..., title="선택된 상품", description="콘텐츠 생성용으로 선택된 상품") - total_available_products: int = Field(..., title="전체 상품 수", description="선택 가능했던 전체 상품 개수") + selected_product: Dict = Field( + ..., title="선택된 상품", description="콘텐츠 생성용으로 선택된 상품" + ) + total_available_products: int = Field( + ..., title="전체 상품 수", description="선택 가능했던 전체 상품 개수" + ) # 최종 응답 모델 class ResponseProductSelect(ResponseBase[ProductSelectData]): """상품 선택 API 응답""" + pass + # ============== 블로그 콘텐츠 생성 ============== diff --git a/apps/pre-processing-service/app/service/product_selection_service.py b/apps/pre-processing-service/app/service/product_selection_service.py index 96093707..723bd940 100644 --- a/apps/pre-processing-service/app/service/product_selection_service.py +++ b/apps/pre-processing-service/app/service/product_selection_service.py @@ -25,7 +25,9 @@ def select_product_for_content(self, request: RequestProductSelect) -> dict: if not db_products: logger.warning(f"DB에서 상품을 찾을 수 없음: task_run_id={task_run_id}") - return Response.error("상품 데이터를 찾을 수 없습니다.", "PRODUCTS_NOT_FOUND") + return Response.error( + "상품 데이터를 찾을 수 없습니다.", "PRODUCTS_NOT_FOUND" + ) # 2. 최적 상품 선택 selected_product = self._select_best_product(db_products) @@ -41,7 +43,9 @@ def select_product_for_content(self, request: RequestProductSelect) -> dict: "total_available_products": len(db_products), } - return Response.ok(data, f"콘텐츠용 상품 선택 완료: {selected_product['name']}") + return Response.ok( + data, f"콘텐츠용 상품 선택 완료: {selected_product['name']}" + ) except Exception as e: logger.error(f"콘텐츠용 상품 선택 오류: {e}") @@ -75,12 +79,14 @@ def _fetch_products_from_db(self, task_run_id: int) -> List[Dict]: # JSON 데이터 파싱 data_value = json.loads(data_value_str) - products.append({ - "id": id, - "name": name, - "data_value": data_value, - "created_at": created_at - }) + products.append( + { + "id": id, + "name": name, + "data_value": data_value, + "created_at": created_at, + } + ) except json.JSONDecodeError as e: logger.warning(f"JSON 파싱 실패: name={name}, error={e}") continue @@ -112,13 +118,18 @@ def _select_best_product(self, db_products: List[Dict]) -> Dict: product_images = product_detail.get("product_images", []) # 크롤링 성공하고 이미지가 있는 상품 - if (data_value.get("status") == "success" and - product_detail and len(product_images) > 0): - successful_products.append({ - "product": product, - "image_count": len(product_images), - "title": product_detail.get("title", "Unknown") - }) + if ( + data_value.get("status") == "success" + and product_detail + and len(product_images) > 0 + ): + successful_products.append( + { + "product": product, + "image_count": len(product_images), + "title": product_detail.get("title", "Unknown"), + } + ) if successful_products: # 이미지 개수가 가장 많은 상품 선택 @@ -134,14 +145,15 @@ def _select_best_product(self, db_products: List[Dict]) -> Dict: "name": best_product["product"]["name"], "product_info": best_product["product"]["data_value"], "image_count": best_product["image_count"], - "title": best_product["title"] + "title": best_product["title"], } # 2순위: 크롤링 성공한 첫 번째 상품 (이미지 없어도) for product in db_products: data_value = product.get("data_value", {}) - if (data_value.get("status") == "success" and - data_value.get("product_detail")): + if data_value.get("status") == "success" and data_value.get( + "product_detail" + ): product_detail = data_value.get("product_detail", {}) logger.info(f"2순위 선택: name={product['name']}") @@ -150,7 +162,7 @@ def _select_best_product(self, db_products: List[Dict]) -> Dict: "name": product["name"], "product_info": data_value, "image_count": len(product_detail.get("product_images", [])), - "title": product_detail.get("title", "Unknown") + "title": product_detail.get("title", "Unknown"), } # 3순위: 첫 번째 상품 (fallback) @@ -166,7 +178,7 @@ def _select_best_product(self, db_products: List[Dict]) -> Dict: "name": first_product["name"], "product_info": data_value, "image_count": len(product_detail.get("product_images", [])), - "title": product_detail.get("title", "Unknown") + "title": product_detail.get("title", "Unknown"), } # 모든 경우 실패 @@ -176,7 +188,7 @@ def _select_best_product(self, db_products: List[Dict]) -> Dict: "name": None, "product_info": None, "image_count": 0, - "title": "Unknown" + "title": "Unknown", } except Exception as e: @@ -187,5 +199,5 @@ def _select_best_product(self, db_products: List[Dict]) -> Dict: "product_info": db_products[0]["data_value"] if db_products else None, "image_count": 0, "title": "Unknown", - "error": str(e) - } \ No newline at end of file + "error": str(e), + } diff --git a/apps/pre-processing-service/app/service/s3_upload_service.py b/apps/pre-processing-service/app/service/s3_upload_service.py index 7e52152c..c804a201 100644 --- a/apps/pre-processing-service/app/service/s3_upload_service.py +++ b/apps/pre-processing-service/app/service/s3_upload_service.py @@ -29,7 +29,7 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: base_folder = request.base_folder or "product" # task_run_id는 자바 워크플로우에서 전달받음 - task_run_id = getattr(request, 'task_run_id', None) + task_run_id = getattr(request, "task_run_id", None) if not task_run_id: # 임시: task_run_id가 없으면 생성 task_run_id = int(time.time() * 1000) @@ -80,11 +80,13 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: "fail_count": 0, } ) - db_save_results.append({ - "product_index": product_index, - "db_status": "skipped", - "error": "크롤링 실패" - }) + db_save_results.append( + { + "product_index": product_index, + "db_status": "skipped", + "error": "크롤링 실패", + } + ) continue try: @@ -103,10 +105,7 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: # 2. DB에 상품 데이터 저장 db_result = self._save_product_to_db( - task_run_id, - keyword, - product_index, - product_info + task_run_id, keyword, product_index, product_info ) db_save_results.append(db_result) @@ -116,7 +115,9 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: ) except Exception as e: - logger.error(f"상품 {product_index} S3 업로드/DB 저장 오류: {e}") + logger.error( + f"상품 {product_index} S3 업로드/DB 저장 오류: {e}" + ) upload_results.append( { "product_index": product_index, @@ -128,11 +129,13 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: "fail_count": 0, } ) - db_save_results.append({ - "product_index": product_index, - "db_status": "error", - "error": str(e) - }) + db_save_results.append( + { + "product_index": product_index, + "db_status": "error", + "error": str(e), + } + ) # 상품간 간격 (서버 부하 방지) if product_index < len(crawled_products): @@ -152,8 +155,12 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: "total_products": len(crawled_products), "total_success_images": total_success_images, "total_fail_images": total_fail_images, - "db_success_count": len([r for r in db_save_results if r.get("db_status") == "success"]), - "db_fail_count": len([r for r in db_save_results if r.get("db_status") == "error"]), + "db_success_count": len( + [r for r in db_save_results if r.get("db_status") == "success"] + ), + "db_fail_count": len( + [r for r in db_save_results if r.get("db_status") == "error"] + ), }, "uploaded_at": time.strftime("%Y-%m-%d %H:%M:%S"), } @@ -166,11 +173,7 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: raise InvalidItemDataException() def _save_product_to_db( - self, - task_run_id: int, - keyword: str, - product_index: int, - product_info: Dict + self, task_run_id: int, keyword: str, product_index: int, product_info: Dict ) -> Dict: """ 상품 데이터를 TASK_IO_DATA 테이블에 저장 (MariaDB) @@ -193,14 +196,17 @@ def _save_product_to_db( VALUES (%s, %s, %s, %s, %s, %s) \ """ - cursor.execute(sql, ( - task_run_id, - "OUTPUT", - product_name, - "JSON", - data_value_json, - created_at - )) + cursor.execute( + sql, + ( + task_run_id, + "OUTPUT", + product_name, + "JSON", + data_value_json, + created_at, + ), + ) logger.success(f"상품 {product_index} DB 저장 성공: name={product_name}") @@ -216,5 +222,5 @@ def _save_product_to_db( return { "product_index": product_index, "db_status": "error", - "error": str(e) - } \ No newline at end of file + "error": str(e), + } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogRagBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogRagBodyBuilder.java index 87c838a5..8a8008ed 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogRagBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogRagBodyBuilder.java @@ -17,33 +17,33 @@ @RequiredArgsConstructor public class BlogRagBodyBuilder implements TaskBodyBuilder { - private final ObjectMapper objectMapper; - private static final String TASK_NAME = "블로그 RAG 생성 태스크"; - private static final String KEYWORD_SOURCE_TASK = "키워드 검색 태스크"; - private static final String PRODUCT_SELECT_SOURCE_TASK = "상품 선택 태스크"; // 변경: S3 업로드 → 상품 선택 - - @Override - public boolean supports(String taskName) { - return TASK_NAME.equals(taskName); - } - - @Override - public ObjectNode build(Task task, Map workflowContext) { - ObjectNode body = objectMapper.createObjectNode(); - - // 키워드 정보 가져오기 - Optional.ofNullable(workflowContext.get(KEYWORD_SOURCE_TASK)) - .map(node -> node.path("data").path("keyword")) - .ifPresent(keywordNode -> body.set("keyword", keywordNode)); - - Optional.ofNullable(workflowContext.get(PRODUCT_SELECT_SOURCE_TASK)) - .map(node -> node.path("data").path("selected_product")) - .ifPresent(productNode -> body.set("product_info", productNode)); - - // 기본 콘텐츠 설정 - body.put("content_type", "review_blog"); - body.put("target_length", 1000); - - return body; - } -} \ No newline at end of file + private final ObjectMapper objectMapper; + private static final String TASK_NAME = "블로그 RAG 생성 태스크"; + private static final String KEYWORD_SOURCE_TASK = "키워드 검색 태스크"; + private static final String PRODUCT_SELECT_SOURCE_TASK = "상품 선택 태스크"; // 변경: S3 업로드 → 상품 선택 + + @Override + public boolean supports(String taskName) { + return TASK_NAME.equals(taskName); + } + + @Override + public ObjectNode build(Task task, Map workflowContext) { + ObjectNode body = objectMapper.createObjectNode(); + + // 키워드 정보 가져오기 + Optional.ofNullable(workflowContext.get(KEYWORD_SOURCE_TASK)) + .map(node -> node.path("data").path("keyword")) + .ifPresent(keywordNode -> body.set("keyword", keywordNode)); + + Optional.ofNullable(workflowContext.get(PRODUCT_SELECT_SOURCE_TASK)) + .map(node -> node.path("data").path("selected_product")) + .ifPresent(productNode -> body.set("product_info", productNode)); + + // 기본 콘텐츠 설정 + body.put("content_type", "review_blog"); + body.put("target_length", 1000); + + return body; + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java index 1d2ef5bf..17934012 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java @@ -16,25 +16,25 @@ @RequiredArgsConstructor public class ProductSelectBodyBuilder implements TaskBodyBuilder { - private final ObjectMapper objectMapper; - private static final String TASK_NAME = "상품 선택 태스크"; + private final ObjectMapper objectMapper; + private static final String TASK_NAME = "상품 선택 태스크"; - @Override - public boolean supports(String taskName) { - return TASK_NAME.equals(taskName); - } + @Override + public boolean supports(String taskName) { + return TASK_NAME.equals(taskName); + } - @Override - public ObjectNode build(Task task, Map workflowContext) { - ObjectNode body = objectMapper.createObjectNode(); + @Override + public ObjectNode build(Task task, Map workflowContext) { + ObjectNode body = objectMapper.createObjectNode(); - // task_run_id는 현재 실행 중인 task의 run_id를 사용 - // 실제 구현에서는 Task 객체나 워크플로우 컨텍스트에서 가져와야 할 수 있습니다. - body.put("task_run_id", task.getId()); // Task 객체에서 ID를 가져오는 방식으로 가정 + // task_run_id는 현재 실행 중인 task의 run_id를 사용 + // 실제 구현에서는 Task 객체나 워크플로우 컨텍스트에서 가져와야 할 수 있습니다. + body.put("task_run_id", task.getId()); // Task 객체에서 ID를 가져오는 방식으로 가정 - // 기본 선택 기준 설정 (이미지 개수 우선) - body.put("selection_criteria", "image_count_priority"); + // 기본 선택 기준 설정 (이미지 개수 우선) + body.put("selection_criteria", "image_count_priority"); - return body; - } -} \ No newline at end of file + return body; + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/S3UploadBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/S3UploadBodyBuilder.java index 7b927dff..7548452a 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/S3UploadBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/S3UploadBodyBuilder.java @@ -17,35 +17,35 @@ @RequiredArgsConstructor public class S3UploadBodyBuilder implements TaskBodyBuilder { - private final ObjectMapper objectMapper; - private static final String TASK_NAME = "S3 업로드 태스크"; - private static final String KEYWORD_SOURCE_TASK = "키워드 검색 태스크"; - private static final String CRAWL_SOURCE_TASK = "상품 정보 크롤링 태스크"; - - @Override - public boolean supports(String taskName) { - return TASK_NAME.equals(taskName); - } - - @Override - public ObjectNode build(Task task, Map workflowContext) { - ObjectNode body = objectMapper.createObjectNode(); - - // 키워드 정보 가져오기 (폴더명 생성용 - 스키마 주석 참조) - Optional.ofNullable(workflowContext.get(KEYWORD_SOURCE_TASK)) - .map(node -> node.path("data").path("keyword")) - .filter(node -> !node.isMissingNode() && !node.asText().trim().isEmpty()) - .ifPresent(keywordNode -> body.set("keyword", keywordNode)); - - // 크롤링된 상품 데이터 가져오기 - Optional.ofNullable(workflowContext.get(CRAWL_SOURCE_TASK)) - .map(node -> node.path("data").path("crawled_products")) - .filter(node -> !node.isMissingNode()) - .ifPresent(crawledProductsNode -> body.set("crawled_products", crawledProductsNode)); - - // 기본 폴더 설정 (스키마의 기본값과 일치) - body.put("base_folder", "product"); - - return body; - } -} \ No newline at end of file + private final ObjectMapper objectMapper; + private static final String TASK_NAME = "S3 업로드 태스크"; + private static final String KEYWORD_SOURCE_TASK = "키워드 검색 태스크"; + private static final String CRAWL_SOURCE_TASK = "상품 정보 크롤링 태스크"; + + @Override + public boolean supports(String taskName) { + return TASK_NAME.equals(taskName); + } + + @Override + public ObjectNode build(Task task, Map workflowContext) { + ObjectNode body = objectMapper.createObjectNode(); + + // 키워드 정보 가져오기 (폴더명 생성용 - 스키마 주석 참조) + Optional.ofNullable(workflowContext.get(KEYWORD_SOURCE_TASK)) + .map(node -> node.path("data").path("keyword")) + .filter(node -> !node.isMissingNode() && !node.asText().trim().isEmpty()) + .ifPresent(keywordNode -> body.set("keyword", keywordNode)); + + // 크롤링된 상품 데이터 가져오기 + Optional.ofNullable(workflowContext.get(CRAWL_SOURCE_TASK)) + .map(node -> node.path("data").path("crawled_products")) + .filter(node -> !node.isMissingNode()) + .ifPresent(crawledProductsNode -> body.set("crawled_products", crawledProductsNode)); + + // 기본 폴더 설정 (스키마의 기본값과 일치) + body.put("base_folder", "product"); + + return body; + } +} From ac8c7846965ce0dda2d592fecb2b9b31b3f70ce0 Mon Sep 17 00:00:00 2001 From: thkim7 Date: Thu, 25 Sep 2025 16:04:36 +0900 Subject: [PATCH 05/40] =?UTF-8?q?refactor:=20=ED=81=AC=EB=A1=A4=EB=A7=81?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=88=9C=EC=B0=A8=EC=A0=81=20?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=EB=A7=81=EC=97=90=EC=84=9C=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=ED=81=AC=EB=A1=A4=EB=A7=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/service/crawl_service.py | 150 ++++++++++-------- .../app/service/crawlers/detail_crawler.py | 77 +++++---- 2 files changed, 132 insertions(+), 95 deletions(-) diff --git a/apps/pre-processing-service/app/service/crawl_service.py b/apps/pre-processing-service/app/service/crawl_service.py index e8785f64..978226b9 100644 --- a/apps/pre-processing-service/app/service/crawl_service.py +++ b/apps/pre-processing-service/app/service/crawl_service.py @@ -11,10 +11,10 @@ class CrawlService: def __init__(self): pass - async def crawl_product_detail(self, request: RequestSadaguCrawl) -> dict: + async def crawl_product_detail(self, request: RequestSadaguCrawl, max_concurrent: int = 5) -> dict: """ 선택된 상품들의 상세 정보를 크롤링하는 비즈니스 로직입니다. (5단계) - 여러 상품 URL을 입력받아 순차적으로 상세 정보를 크롤링하여 딕셔너리로 반환합니다. + 여러 상품 URL을 입력받아 비동기로 상세 정보를 크롤링하여 딕셔너리로 반환합니다. """ product_urls = [str(url) for url in request.product_urls] @@ -25,70 +25,40 @@ async def crawl_product_detail(self, request: RequestSadaguCrawl) -> dict: fail_count = 0 try: - # 각 상품을 순차적으로 크롤링 (안정성 확보) + # 세마포어로 동시 실행 수 제한 + semaphore = asyncio.Semaphore(max_concurrent) + + # 모든 크롤링 태스크를 동시에 실행 + tasks = [] for i, product_url in enumerate(product_urls, 1): - logger.info(f"상품 {i}/{len(product_urls)} 크롤링 시작: {product_url}") - - crawler = DetailCrawler(use_selenium=True) - - try: - # 상세 정보 크롤링 실행 - product_detail = await crawler.crawl_detail(product_url) - - if product_detail: - product_title = product_detail.get("title", "Unknown")[:50] - logger.success( - f"상품 {i} 크롤링 성공: title='{product_title}', price={product_detail.get('price', 0)}" - ) - - # 성공한 상품 추가 - crawled_products.append( - { - "index": i, - "url": product_url, - "product_detail": product_detail, - "status": "success", - "crawled_at": time.strftime("%Y-%m-%d %H:%M:%S"), - } - ) + task = self._crawl_single_with_semaphore(semaphore, i, product_url, len(product_urls)) + tasks.append(task) + + # 모든 태스크 동시 실행 및 결과 수집 + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 결과 정리 + for result in results: + if isinstance(result, Exception): + logger.error(f"크롤링 태스크 오류: {result}") + crawled_products.append({ + "index": len(crawled_products) + 1, + "url": "unknown", + "product_detail": None, + "status": "failed", + "error": str(result), + "crawled_at": time.strftime("%Y-%m-%d %H:%M:%S"), + }) + fail_count += 1 + else: + crawled_products.append(result) + if result["status"] == "success": success_count += 1 else: - logger.error(f"상품 {i} 크롤링 실패: 상세 정보 없음") - crawled_products.append( - { - "index": i, - "url": product_url, - "product_detail": None, - "status": "failed", - "error": "상세 정보 없음", - "crawled_at": time.strftime("%Y-%m-%d %H:%M:%S"), - } - ) fail_count += 1 - except Exception as e: - logger.error( - f"상품 {i} 크롤링 오류: url={product_url}, error='{e}'" - ) - crawled_products.append( - { - "index": i, - "url": product_url, - "product_detail": None, - "status": "failed", - "error": str(e), - "crawled_at": time.strftime("%Y-%m-%d %H:%M:%S"), - } - ) - fail_count += 1 - - finally: - # 각 크롤러 개별 정리 - await crawler.close() - - # 상품간 간격 (서버 부하 방지) - if i < len(product_urls): - await asyncio.sleep(1) + # 인덱스 순으로 정렬 + crawled_products.sort(key=lambda x: x["index"]) logger.success( f"전체 크롤링 완료: 총 {len(product_urls)}개, 성공 {success_count}개, 실패 {fail_count}개" @@ -111,10 +81,62 @@ async def crawl_product_detail(self, request: RequestSadaguCrawl) -> dict: logger.error(f"배치 크롤링 서비스 오류: error='{e}'") raise InvalidItemDataException() - # 기존 단일 크롤링 메서드도 유지 (하위 호환성) + async def _crawl_single_with_semaphore(self, semaphore: asyncio.Semaphore, index: int, product_url: str, + total_count: int) -> dict: + """ + 세마포어를 사용한 단일 상품 크롤링 + """ + async with semaphore: + logger.info(f"상품 {index}/{total_count} 크롤링 시작: {product_url}") + + crawler = DetailCrawler(use_selenium=True) + + try: + # 상세 정보 크롤링 실행 + product_detail = await crawler.crawl_detail(product_url) + + if product_detail: + product_title = product_detail.get("title", "Unknown")[:50] + logger.success( + f"상품 {index} 크롤링 성공: title='{product_title}', price={product_detail.get('price', 0)}" + ) + + return { + "index": index, + "url": product_url, + "product_detail": product_detail, + "status": "success", + "crawled_at": time.strftime("%Y-%m-%d %H:%M:%S"), + } + else: + logger.error(f"상품 {index} 크롤링 실패: 상세 정보 없음") + return { + "index": index, + "url": product_url, + "product_detail": None, + "status": "failed", + "error": "상세 정보 없음", + "crawled_at": time.strftime("%Y-%m-%d %H:%M:%S"), + } + + except Exception as e: + logger.error(f"상품 {index} 크롤링 오류: url={product_url}, error='{e}'") + return { + "index": index, + "url": product_url, + "product_detail": None, + "status": "failed", + "error": str(e), + "crawled_at": time.strftime("%Y-%m-%d %H:%M:%S"), + } + + finally: + # 각 크롤러 개별 정리 + await crawler.close() + async def crawl_single_product_detail(self, product_url: str) -> dict: """ - 단일 상품 크롤링 (하위 호환성용) + 단일 상품 크롤링 """ crawler = DetailCrawler(use_selenium=True) @@ -142,4 +164,4 @@ async def crawl_single_product_detail(self, product_url: str) -> dict: logger.error(f"단일 크롤링 오류: url={product_url}, error='{e}'") raise InvalidItemDataException() finally: - await crawler.close() + await crawler.close() \ No newline at end of file diff --git a/apps/pre-processing-service/app/service/crawlers/detail_crawler.py b/apps/pre-processing-service/app/service/crawlers/detail_crawler.py index f01ed53a..097f7e0d 100644 --- a/apps/pre-processing-service/app/service/crawlers/detail_crawler.py +++ b/apps/pre-processing-service/app/service/crawlers/detail_crawler.py @@ -1,5 +1,6 @@ import time import re +import asyncio from bs4 import BeautifulSoup from .search_crawler import SearchCrawler from loguru import logger @@ -13,28 +14,17 @@ async def crawl_detail(self, product_url: str) -> dict: try: logger.info(f"상품 상세 크롤링 시작: url='{product_url}'") - # HTML 가져오기 + # HTML 가져오기 (Selenium 부분을 별도 스레드에서 실행) soup = ( await self._get_soup_selenium(product_url) if self.use_selenium else await self._get_soup_httpx(product_url) ) - # 기본 정보 추출 - title = self._extract_title(soup) - price = self._extract_price(soup) - rating = self._extract_rating(soup) - options = self._extract_options(soup) - material_info = self._extract_material_info(soup) - - # 이미지 정보 추출 (항상 실행) - logger.info("이미지 정보 추출 중...") - page_images = self._extract_images(soup) - option_images = [ - opt["image_url"] for opt in options if opt.get("image_url") - ] - # 중복 제거 후 합치기 - all_images = list(set(page_images + option_images)) + # 기본 정보 추출 (CPU 집약적 작업을 별도 스레드에서 실행) + extraction_tasks = await asyncio.to_thread(self._extract_all_data, soup, product_url) + + title, price, rating, options, material_info, all_images = extraction_tasks product_data = { "url": product_url, @@ -58,20 +48,25 @@ async def crawl_detail(self, product_url: str) -> dict: raise Exception(f"크롤링 실패: {str(e)}") async def _get_soup_selenium(self, product_url: str) -> BeautifulSoup: - """Selenium으로 HTML 가져오기""" - try: - logger.debug(f"Selenium HTML 로딩 시작: url='{product_url}'") - self.driver.get(product_url) - self.wait.until( - lambda driver: driver.execute_script("return document.readyState") - == "complete" - ) - time.sleep(2) - logger.debug("Selenium HTML 로딩 완료") - return BeautifulSoup(self.driver.page_source, "html.parser") - except Exception as e: - logger.error(f"Selenium HTML 로딩 실패: url='{product_url}', error='{e}'") - raise Exception(f"Selenium HTML 로딩 실패: {e}") + """Selenium으로 HTML 가져오기 (별도 스레드에서 실행)""" + + def _selenium_sync(url): + try: + logger.debug(f"Selenium HTML 로딩 시작: url='{url}'") + self.driver.get(url) + self.wait.until( + lambda driver: driver.execute_script("return document.readyState") + == "complete" + ) + time.sleep(2) + logger.debug("Selenium HTML 로딩 완료") + return BeautifulSoup(self.driver.page_source, "html.parser") + except Exception as e: + logger.error(f"Selenium HTML 로딩 실패: url='{url}', error='{e}'") + raise Exception(f"Selenium HTML 로딩 실패: {e}") + + # Selenium 동기 코드를 별도 스레드에서 실행 + return await asyncio.to_thread(_selenium_sync, product_url) async def _get_soup_httpx(self, product_url: str) -> BeautifulSoup: """httpx로 HTML 가져오기""" @@ -85,6 +80,26 @@ async def _get_soup_httpx(self, product_url: str) -> BeautifulSoup: logger.error(f"httpx HTML 요청 실패: url='{product_url}', error='{e}'") raise Exception(f"HTTP 요청 실패: {e}") + def _extract_all_data(self, soup: BeautifulSoup, product_url: str) -> tuple: + """모든 데이터 추출을 한 번에 처리 (동기 함수)""" + # 기본 정보 추출 + title = self._extract_title(soup) + price = self._extract_price(soup) + rating = self._extract_rating(soup) + options = self._extract_options(soup) + material_info = self._extract_material_info(soup) + + # 이미지 정보 추출 + logger.info("이미지 정보 추출 중...") + page_images = self._extract_images(soup) + option_images = [ + opt["image_url"] for opt in options if opt.get("image_url") + ] + # 중복 제거 후 합치기 + all_images = list(set(page_images + option_images)) + + return title, price, rating, options, material_info, all_images + def _extract_title(self, soup: BeautifulSoup) -> str: title_element = soup.find("h1", {"id": "kakaotitle"}) title = title_element.get_text(strip=True) if title_element else "제목 없음" @@ -184,4 +199,4 @@ def _extract_images(self, soup: BeautifulSoup) -> list[str]: src = self.base_url + src images.append(src) logger.info(f"총 {len(images)}개 이미지 URL 추출 완료") - return images + return images \ No newline at end of file From 426b4cfd7254f151a5f2d9cdfa52cbce45dabca0 Mon Sep 17 00:00:00 2001 From: thkim7 Date: Thu, 25 Sep 2025 17:39:07 +0900 Subject: [PATCH 06/40] =?UTF-8?q?refactor:=20RDB=EC=99=80=20selection=20ta?= =?UTF-8?q?sk=20=EC=8B=A4=ED=96=89=EC=8B=9C=20task=5Frun=5Fid=EA=B0=80=20m?= =?UTF-8?q?ismatch=20=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/model/schemas.py | 1 + .../app/service/crawl_service.py | 2 + .../app/service/product_selection_service.py | 52 ++-- .../app/service/s3_upload_service.py | 274 +++++++++++------- .../body/ProductSelectBodyBuilder.java | 5 - .../service/WorkflowExecutionService.java | 14 + .../main/resources/sql/03-insert-workflow.sql | 23 +- 7 files changed, 219 insertions(+), 152 deletions(-) diff --git a/apps/pre-processing-service/app/model/schemas.py b/apps/pre-processing-service/app/model/schemas.py index 7487927b..4001b705 100644 --- a/apps/pre-processing-service/app/model/schemas.py +++ b/apps/pre-processing-service/app/model/schemas.py @@ -165,6 +165,7 @@ class ResponseSadaguCrawl(ResponseBase[SadaguCrawlData]): class RequestS3Upload(RequestBase): + task_run_id: int = Field(..., title="Task Run ID", description="워크플로우 실행 ID") keyword: str = Field( ..., title="검색 키워드", description="폴더명 생성용 키워드" ) # 추가 diff --git a/apps/pre-processing-service/app/service/crawl_service.py b/apps/pre-processing-service/app/service/crawl_service.py index 978226b9..768dd3dc 100644 --- a/apps/pre-processing-service/app/service/crawl_service.py +++ b/apps/pre-processing-service/app/service/crawl_service.py @@ -5,6 +5,8 @@ from app.model.schemas import RequestSadaguCrawl from loguru import logger from app.utils.response import Response +import os +os.environ["TOKENIZERS_PARALLELISM"] = "false" class CrawlService: diff --git a/apps/pre-processing-service/app/service/product_selection_service.py b/apps/pre-processing-service/app/service/product_selection_service.py index 723bd940..41b1c8dc 100644 --- a/apps/pre-processing-service/app/service/product_selection_service.py +++ b/apps/pre-processing-service/app/service/product_selection_service.py @@ -1,7 +1,7 @@ import json from typing import List, Dict from loguru import logger -from app.model.schemas import RequestProductSelect +from app.model.schemas import RequestProductSelect, ProductSelectData from app.utils.response import Response from app.db.mariadb_manager import MariadbManager @@ -25,8 +25,15 @@ def select_product_for_content(self, request: RequestProductSelect) -> dict: if not db_products: logger.warning(f"DB에서 상품을 찾을 수 없음: task_run_id={task_run_id}") + # Pydantic Generic Response 구조에 맞춰 data에 항상 객체를 넣음 + data = ProductSelectData( + task_run_id=task_run_id, + selected_product={}, # 상품 없음 + total_available_products=0, + ) return Response.error( - "상품 데이터를 찾을 수 없습니다.", "PRODUCTS_NOT_FOUND" + message="상품 데이터를 찾을 수 없습니다.", + data=data.dict(), ) # 2. 최적 상품 선택 @@ -37,14 +44,16 @@ def select_product_for_content(self, request: RequestProductSelect) -> dict: f"selection_reason={selected_product['selection_reason']}" ) - data = { - "task_run_id": task_run_id, - "selected_product": selected_product, - "total_available_products": len(db_products), - } + # 응답용 데이터 구성 + data = ProductSelectData( + task_run_id=task_run_id, + selected_product=selected_product, + total_available_products=len(db_products), + ) return Response.ok( - data, f"콘텐츠용 상품 선택 완료: {selected_product['name']}" + data=data.dict(), + message=f"콘텐츠용 상품 선택 완료: {selected_product['name']}" ) except Exception as e: @@ -63,7 +72,7 @@ def _fetch_products_from_db(self, task_run_id: int) -> List[Dict]: WHERE task_run_id = %s AND io_type = 'OUTPUT' AND data_type = 'JSON' - ORDER BY name \ + ORDER BY name """ with self.db_manager.get_cursor() as cursor: @@ -73,12 +82,8 @@ def _fetch_products_from_db(self, task_run_id: int) -> List[Dict]: products = [] for row in rows: try: - # MariaDB에서 반환되는 row는 튜플 형태 id, name, data_value_str, created_at = row - - # JSON 데이터 파싱 data_value = json.loads(data_value_str) - products.append( { "id": id, @@ -111,18 +116,13 @@ def _select_best_product(self, db_products: List[Dict]) -> Dict: try: successful_products = [] - # 1순위: S3 업로드 성공하고 이미지가 있는 상품들 + # 1순위: S3 업로드 성공하고 이미지가 있는 상품 for product in db_products: data_value = product.get("data_value", {}) product_detail = data_value.get("product_detail", {}) product_images = product_detail.get("product_images", []) - # 크롤링 성공하고 이미지가 있는 상품 - if ( - data_value.get("status") == "success" - and product_detail - and len(product_images) > 0 - ): + if data_value.get("status") == "success" and product_detail and len(product_images) > 0: successful_products.append( { "product": product, @@ -132,14 +132,11 @@ def _select_best_product(self, db_products: List[Dict]) -> Dict: ) if successful_products: - # 이미지 개수가 가장 많은 상품 선택 best_product = max(successful_products, key=lambda x: x["image_count"]) - logger.info( f"1순위 선택: name={best_product['product']['name']}, " f"images={best_product['image_count']}개" ) - return { "selection_reason": "s3_upload_success_with_most_images", "name": best_product["product"]["name"], @@ -148,15 +145,12 @@ def _select_best_product(self, db_products: List[Dict]) -> Dict: "title": best_product["title"], } - # 2순위: 크롤링 성공한 첫 번째 상품 (이미지 없어도) + # 2순위: 크롤링 성공한 첫 번째 상품 for product in db_products: data_value = product.get("data_value", {}) - if data_value.get("status") == "success" and data_value.get( - "product_detail" - ): + if data_value.get("status") == "success" and data_value.get("product_detail"): product_detail = data_value.get("product_detail", {}) logger.info(f"2순위 선택: name={product['name']}") - return { "selection_reason": "first_crawl_success", "name": product["name"], @@ -170,9 +164,7 @@ def _select_best_product(self, db_products: List[Dict]) -> Dict: first_product = db_products[0] data_value = first_product.get("data_value", {}) product_detail = data_value.get("product_detail", {}) - logger.warning(f"3순위 fallback 선택: name={first_product['name']}") - return { "selection_reason": "fallback_first_product", "name": first_product["name"], diff --git a/apps/pre-processing-service/app/service/s3_upload_service.py b/apps/pre-processing-service/app/service/s3_upload_service.py index c804a201..fd422f42 100644 --- a/apps/pre-processing-service/app/service/s3_upload_service.py +++ b/apps/pre-processing-service/app/service/s3_upload_service.py @@ -20,7 +20,7 @@ def __init__(self): self.s3_util = S3UploadUtil() self.db_manager = MariadbManager() - async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: + async def upload_crawled_products_to_s3(self, request: RequestS3Upload, max_concurrent: int = 5) -> dict: """ 크롤링된 상품들의 이미지와 데이터를 S3에 업로드하고 DB에 저장하는 비즈니스 로직 (6단계) """ @@ -31,11 +31,24 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: # task_run_id는 자바 워크플로우에서 전달받음 task_run_id = getattr(request, "task_run_id", None) if not task_run_id: - # 임시: task_run_id가 없으면 생성 - task_run_id = int(time.time() * 1000) - logger.warning(f"task_run_id가 없어서 임시로 생성: {task_run_id}") - else: - logger.info(f"자바 워크플로우에서 전달받은 task_run_id: {task_run_id}") + # 자바에서 TaskRun을 만들었으므로 없으면 에러 + logger.error("task_run_id가 없어서 파이썬에서 실행 불가") + return Response.error( + data={ + "upload_results": [], + "db_save_results": [], + "task_run_id": None, + "summary": { + "total_products": 0, + "total_success_images": 0, + "total_fail_images": 0, + "db_success_count": 0, + "db_fail_count": 0, + }, + "uploaded_at": time.strftime("%Y-%m-%d %H:%M:%S"), + }, + message="task_run_id is required from Java workflow" + ) logger.info( f"S3 업로드 + DB 저장 서비스 시작: keyword='{keyword}', " @@ -49,131 +62,174 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: try: # HTTP 세션을 사용한 이미지 다운로드 - ssl_context = ssl.create_default_context(cafile=certifi.where()) connector = aiohttp.TCPConnector(ssl=ssl_context) async with aiohttp.ClientSession(connector=connector) as session: + # 세마포어로 동시 실행 수 제한 + semaphore = asyncio.Semaphore(max_concurrent) - # 각 상품별로 순차 업로드 + # 모든 업로드 태스크를 동시에 실행 + tasks = [] for product_info in crawled_products: - product_index = product_info.get("index", 0) - product_detail = product_info.get("product_detail") - - logger.info( - f"상품 {product_index}/{len(crawled_products)} S3 업로드 + DB 저장 시작" + task = self._upload_single_product_with_semaphore( + semaphore, session, product_info, keyword, base_folder, task_run_id ) + tasks.append(task) - # 크롤링 실패한 상품은 스킵 - if not product_detail or product_info.get("status") != "success": - logger.warning( - f"상품 {product_index}: 크롤링 실패로 인한 업로드 스킵" - ) - upload_results.append( - { - "product_index": product_index, - "product_title": "Unknown", - "status": "skipped", - "folder_s3_url": None, - "uploaded_images": [], - "success_count": 0, - "fail_count": 0, - } - ) - db_save_results.append( - { - "product_index": product_index, - "db_status": "skipped", - "error": "크롤링 실패", - } - ) - continue - - try: - # 1. 상품 이미지 + 데이터 S3 업로드 - upload_result = await self.s3_util.upload_single_product_images( - session, - product_info, - product_index, - keyword, - base_folder, - ) + # 모든 태스크 동시 실행 및 결과 수집 + results = await asyncio.gather(*tasks, return_exceptions=True) + # 결과 정리 + for result in results: + if isinstance(result, Exception): + logger.error(f"업로드 태스크 오류: {result}") + upload_results.append({ + "product_index": len(upload_results) + 1, + "product_title": "Unknown", + "status": "error", + "folder_s3_url": None, + "uploaded_images": [], + "success_count": 0, + "fail_count": 0, + }) + db_save_results.append({ + "product_index": len(db_save_results) + 1, + "db_status": "error", + "error": str(result), + }) + else: + upload_result, db_result = result upload_results.append(upload_result) + db_save_results.append(db_result) + total_success_images += upload_result["success_count"] total_fail_images += upload_result["fail_count"] - # 2. DB에 상품 데이터 저장 - db_result = self._save_product_to_db( - task_run_id, keyword, product_index, product_info - ) - db_save_results.append(db_result) - - logger.success( - f"상품 {product_index} S3 업로드 + DB 저장 완료: " - f"이미지 성공 {upload_result['success_count']}개, DB {db_result['db_status']}" - ) - - except Exception as e: - logger.error( - f"상품 {product_index} S3 업로드/DB 저장 오류: {e}" - ) - upload_results.append( - { - "product_index": product_index, - "product_title": product_detail.get("title", "Unknown"), - "status": "error", - "folder_s3_url": None, - "uploaded_images": [], - "success_count": 0, - "fail_count": 0, - } - ) - db_save_results.append( - { - "product_index": product_index, - "db_status": "error", - "error": str(e), - } - ) - - # 상품간 간격 (서버 부하 방지) - if product_index < len(crawled_products): - await asyncio.sleep(1) + # 인덱스 순으로 정렬 + upload_results.sort(key=lambda x: x["product_index"]) + db_save_results.sort(key=lambda x: x["product_index"]) logger.success( f"S3 업로드 + DB 저장 서비스 완료: 총 성공 이미지 {total_success_images}개, " f"총 실패 이미지 {total_fail_images}개" ) - # 응답 데이터 구성 - data = { - "upload_results": upload_results, - "db_save_results": db_save_results, - "task_run_id": task_run_id, - "summary": { - "total_products": len(crawled_products), - "total_success_images": total_success_images, - "total_fail_images": total_fail_images, - "db_success_count": len( - [r for r in db_save_results if r.get("db_status") == "success"] - ), - "db_fail_count": len( - [r for r in db_save_results if r.get("db_status") == "error"] - ), + # Response.ok 사용하여 올바른 스키마로 응답 + return Response.ok( + data={ + "upload_results": upload_results, + "db_save_results": db_save_results, + "task_run_id": task_run_id, + "summary": { + "total_products": len(crawled_products), + "total_success_images": total_success_images, + "total_fail_images": total_fail_images, + "db_success_count": len( + [r for r in db_save_results if r.get("db_status") == "success"] + ), + "db_fail_count": len( + [r for r in db_save_results if r.get("db_status") == "error"] + ), + }, + "uploaded_at": time.strftime("%Y-%m-%d %H:%M:%S"), }, - "uploaded_at": time.strftime("%Y-%m-%d %H:%M:%S"), - } - - message = f"S3 업로드 + DB 저장 완료: {total_success_images}개 이미지 성공, {len([r for r in db_save_results if r.get('db_status') == 'success'])}개 상품 DB 저장 성공" - return Response.ok(data, message) + message=f"S3 업로드 + DB 저장 완료: 총 성공 이미지 {total_success_images}개, 총 실패 이미지 {total_fail_images}개" + ) except Exception as e: logger.error(f"S3 업로드 + DB 저장 서비스 전체 오류: {e}") - raise InvalidItemDataException() + # Response.error 사용하여 에러도 올바른 스키마로 응답 + return Response.error( + data={ + "upload_results": [], + "db_save_results": [], + "task_run_id": task_run_id, + "summary": { + "total_products": 0, + "total_success_images": 0, + "total_fail_images": 0, + "db_success_count": 0, + "db_fail_count": 0, + }, + "uploaded_at": time.strftime("%Y-%m-%d %H:%M:%S"), + }, + message=f"S3 업로드 서비스 오류: {str(e)}" + ) + + async def _upload_single_product_with_semaphore( + self, + semaphore: asyncio.Semaphore, + session: aiohttp.ClientSession, + product_info: Dict, + keyword: str, + base_folder: str, + task_run_id: int + ) -> tuple: + """세마포어를 사용한 단일 상품 업로드 + DB 저장""" + async with semaphore: + product_index = product_info.get("index", 0) + product_detail = product_info.get("product_detail") + + logger.info(f"상품 {product_index} S3 업로드 + DB 저장 시작") + + # 크롤링 실패한 상품은 스킵 + if not product_detail or product_info.get("status") != "success": + logger.warning(f"상품 {product_index}: 크롤링 실패로 인한 업로드 스킵") + upload_result = { + "product_index": product_index, + "product_title": "Unknown", + "status": "skipped", + "folder_s3_url": None, + "uploaded_images": [], + "success_count": 0, + "fail_count": 0, + } + db_result = { + "product_index": product_index, + "db_status": "skipped", + "error": "크롤링 실패", + } + return upload_result, db_result + + try: + # S3 업로드와 DB 저장을 동시에 실행 + upload_task = self.s3_util.upload_single_product_images( + session, product_info, product_index, keyword, base_folder + ) + db_task = asyncio.to_thread( + self._save_product_to_db, task_run_id, keyword, product_index, product_info + ) + + upload_result, db_result = await asyncio.gather(upload_task, db_task) + + logger.success( + f"상품 {product_index} S3 업로드 + DB 저장 완료: " + f"이미지 성공 {upload_result['success_count']}개, DB {db_result['db_status']}" + ) + + return upload_result, db_result + + except Exception as e: + logger.error(f"상품 {product_index} S3 업로드/DB 저장 오류: {e}") + upload_result = { + "product_index": product_index, + "product_title": product_detail.get("title", "Unknown"), + "status": "error", + "folder_s3_url": None, + "uploaded_images": [], + "success_count": 0, + "fail_count": 0, + } + db_result = { + "product_index": product_index, + "db_status": "error", + "error": str(e), + } + return upload_result, db_result def _save_product_to_db( - self, task_run_id: int, keyword: str, product_index: int, product_info: Dict + self, task_run_id: int, keyword: str, product_index: int, product_info: Dict ) -> Dict: """ 상품 데이터를 TASK_IO_DATA 테이블에 저장 (MariaDB) @@ -192,8 +248,8 @@ def _save_product_to_db( with self.db_manager.get_cursor() as cursor: sql = """ INSERT INTO task_io_data - (task_run_id, io_type, name, data_type, data_value, created_at) - VALUES (%s, %s, %s, %s, %s, %s) \ + (task_run_id, io_type, name, data_type, data_value, created_at) + VALUES (%s, %s, %s, %s, %s, %s) """ cursor.execute( @@ -223,4 +279,4 @@ def _save_product_to_db( "product_index": product_index, "db_status": "error", "error": str(e), - } + } \ No newline at end of file diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java index 17934012..a8a885ed 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java @@ -28,11 +28,6 @@ public boolean supports(String taskName) { public ObjectNode build(Task task, Map workflowContext) { ObjectNode body = objectMapper.createObjectNode(); - // task_run_id는 현재 실행 중인 task의 run_id를 사용 - // 실제 구현에서는 Task 객체나 워크플로우 컨텍스트에서 가져와야 할 수 있습니다. - body.put("task_run_id", task.getId()); // Task 객체에서 ID를 가져오는 방식으로 가정 - - // 기본 선택 기준 설정 (이미지 개수 우선) body.put("selection_criteria", "image_count_priority"); return body; diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java index 3fa71524..e2b663ab 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java @@ -128,6 +128,7 @@ private boolean executeTasksForJob( workflowLogger.info( "Job (JobRunId={}) 내 총 {}개의 Task를 순차 실행합니다.", jobRun.getId(), taskDtos.size()); boolean hasAnyTaskFailed = false; + Long s3UploadTaskRunId = null; // S3 업로드 태스크의 task_run_id 저장용 for (TaskDto taskDto : taskDtos) { try { @@ -146,6 +147,19 @@ private boolean executeTasksForJob( .map(builder -> builder.build(task, workflowContext)) .orElse(objectMapper.createObjectNode()); + if ("S3 업로드 태스크".equals(task.getName())) { + requestBody.put("task_run_id", taskRun.getId()); + s3UploadTaskRunId = taskRun.getId(); // S3 업로드의 task_run_id 저장 + } else if ("상품 선택 태스크".equals(task.getName())) { + // S3 업로드에서 사용한 task_run_id를 사용 + if (s3UploadTaskRunId != null) { + requestBody.put("task_run_id", s3UploadTaskRunId); + } else { + workflowLogger.error("S3 업로드 태스크가 먼저 실행되지 않아 task_run_id를 찾을 수 없습니다."); + // 또는 이전 Job에서 S3 업로드를 찾는 로직 추가 가능 + } + } + TaskRunner.TaskExecutionResult result = taskExecutionService.executeWithRetry(task, taskRun, requestBody); taskRun.finish(result.status(), result.message()); diff --git a/apps/user-service/src/main/resources/sql/03-insert-workflow.sql b/apps/user-service/src/main/resources/sql/03-insert-workflow.sql index 9238b8a2..379140b5 100644 --- a/apps/user-service/src/main/resources/sql/03-insert-workflow.sql +++ b/apps/user-service/src/main/resources/sql/03-insert-workflow.sql @@ -16,7 +16,7 @@ DELETE FROM `workflow`; -- 워크플로우 생성 (ID: 1) INSERT INTO `workflow` (`id`, `name`, `description`, `created_by`, `default_config`) VALUES (1, '상품 분석 및 블로그 자동 발행', '키워드 검색부터 상품 분석 후 블로그 발행까지의 자동화 프로세스', 1, - JSON_OBJECT('1',json_object('tag','naver'),'8',json_object('tag','naver_blog','blog_id', 'wtecho331', 'blog_pw', 'testpass'))) + JSON_OBJECT('1',json_object('tag','naver'),'9',json_object('tag','blogger','blog_id', '', 'blog_pw', ''))) ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), @@ -27,7 +27,7 @@ INSERT INTO `job` (`id`, `name`, `description`, `created_by`) VALUES (2, '블로그 콘텐츠 생성', '분석 데이터를 기반으로 RAG 콘텐츠 생성 및 발행 작업', 1) ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), updated_at = NOW(); --- Task 생성 (ID: 1 ~ 7) - FastAPI Request Body 스키마 반영 +-- Task 생성 (ID: 1 ~ 9) INSERT INTO `task` (`id`, `name`, `type`, `parameters`) VALUES (1, '키워드 검색 태스크', 'FastAPI', JSON_OBJECT( 'endpoint', '/keywords/search', 'method', 'POST', @@ -56,7 +56,6 @@ INSERT INTO `task` (`id`, `name`, `type`, `parameters`) VALUES 'endpoint', '/products/crawl', 'method', 'POST', 'body', JSON_OBJECT('product_urls', 'List') -- { "product_urls": List[str] } 수정됨 )), - -- 🆕 S3 업로드 태스크 추가 (6, 'S3 업로드 태스크', 'FastAPI', JSON_OBJECT( 'endpoint', '/products/s3-upload', 'method', 'POST', 'body', JSON_OBJECT( -- { keyword: str, crawled_products: List, base_folder: str } @@ -65,9 +64,16 @@ INSERT INTO `task` (`id`, `name`, `type`, `parameters`) VALUES 'base_folder', 'String' ) )), + (7, '상품 선택 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/products/select', 'method', 'POST', + 'body', JSON_OBJECT( -- { task_run_id: int, selection_criteria: str } + 'task_run_id', 'Integer', + 'selection_criteria', 'String' + ) + )), -- RAG관련 request body는 추후에 결정될 예정 - (7, '블로그 RAG 생성 태스크', 'FastAPI', JSON_OBJECT('endpoint', '/blogs/rag/create', 'method', 'POST')), - (8, '블로그 발행 태스크', 'FastAPI', JSON_OBJECT( + (8, '블로그 RAG 생성 태스크', 'FastAPI', JSON_OBJECT('endpoint', '/blogs/rag/create', 'method', 'POST')), + (9, '블로그 발행 태스크', 'FastAPI', JSON_OBJECT( 'endpoint', '/blogs/publish', 'method', 'POST', 'body', JSON_OBJECT( -- { tag: str, blog_id: str, ... } 'tag', 'String', @@ -92,9 +98,10 @@ INSERT INTO `workflow_job` (`workflow_id`, `job_id`, `execution_order`) VALUES -- Job-Task 연결 INSERT INTO `job_task` (`job_id`, `task_id`, `execution_order`) VALUES - -- Job 1: 상품 분석 (키워드검색 → 상품검색 → 매칭 → 유사도 → 크롤링 → S3업로드) - (1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5), (1, 6, 6), - (2, 7, 1), (2, 8, 2) + -- Job 1: 상품 분석 (키워드검색 → 상품검색 → 매칭 → 유사도 → 크롤링 → S3업로드 → 상품선택) + (1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5), (1, 6, 6), (1, 7, 7), + -- Job 2: 블로그 콘텐츠 생성 (RAG생성 → 발행) + (2, 8, 1), (2, 9, 2) ON DUPLICATE KEY UPDATE execution_order = VALUES(execution_order); -- 스케줄 설정 (매일 오전 8시) From f9845f3ddcc47c2be81525c7390df20bea0fdb3a Mon Sep 17 00:00:00 2001 From: thkim7 Date: Thu, 25 Sep 2025 17:39:39 +0900 Subject: [PATCH 07/40] chore: poetry run black . --- .../app/service/crawl_service.py | 42 ++++++--- .../app/service/crawlers/detail_crawler.py | 12 +-- .../app/service/product_selection_service.py | 12 ++- .../app/service/s3_upload_service.py | 85 ++++++++++++------- 4 files changed, 97 insertions(+), 54 deletions(-) diff --git a/apps/pre-processing-service/app/service/crawl_service.py b/apps/pre-processing-service/app/service/crawl_service.py index 768dd3dc..3d1183eb 100644 --- a/apps/pre-processing-service/app/service/crawl_service.py +++ b/apps/pre-processing-service/app/service/crawl_service.py @@ -6,6 +6,7 @@ from loguru import logger from app.utils.response import Response import os + os.environ["TOKENIZERS_PARALLELISM"] = "false" @@ -13,7 +14,9 @@ class CrawlService: def __init__(self): pass - async def crawl_product_detail(self, request: RequestSadaguCrawl, max_concurrent: int = 5) -> dict: + async def crawl_product_detail( + self, request: RequestSadaguCrawl, max_concurrent: int = 5 + ) -> dict: """ 선택된 상품들의 상세 정보를 크롤링하는 비즈니스 로직입니다. (5단계) 여러 상품 URL을 입력받아 비동기로 상세 정보를 크롤링하여 딕셔너리로 반환합니다. @@ -33,7 +36,9 @@ async def crawl_product_detail(self, request: RequestSadaguCrawl, max_concurrent # 모든 크롤링 태스크를 동시에 실행 tasks = [] for i, product_url in enumerate(product_urls, 1): - task = self._crawl_single_with_semaphore(semaphore, i, product_url, len(product_urls)) + task = self._crawl_single_with_semaphore( + semaphore, i, product_url, len(product_urls) + ) tasks.append(task) # 모든 태스크 동시 실행 및 결과 수집 @@ -43,14 +48,16 @@ async def crawl_product_detail(self, request: RequestSadaguCrawl, max_concurrent for result in results: if isinstance(result, Exception): logger.error(f"크롤링 태스크 오류: {result}") - crawled_products.append({ - "index": len(crawled_products) + 1, - "url": "unknown", - "product_detail": None, - "status": "failed", - "error": str(result), - "crawled_at": time.strftime("%Y-%m-%d %H:%M:%S"), - }) + crawled_products.append( + { + "index": len(crawled_products) + 1, + "url": "unknown", + "product_detail": None, + "status": "failed", + "error": str(result), + "crawled_at": time.strftime("%Y-%m-%d %H:%M:%S"), + } + ) fail_count += 1 else: crawled_products.append(result) @@ -83,8 +90,13 @@ async def crawl_product_detail(self, request: RequestSadaguCrawl, max_concurrent logger.error(f"배치 크롤링 서비스 오류: error='{e}'") raise InvalidItemDataException() - async def _crawl_single_with_semaphore(self, semaphore: asyncio.Semaphore, index: int, product_url: str, - total_count: int) -> dict: + async def _crawl_single_with_semaphore( + self, + semaphore: asyncio.Semaphore, + index: int, + product_url: str, + total_count: int, + ) -> dict: """ 세마포어를 사용한 단일 상품 크롤링 """ @@ -122,7 +134,9 @@ async def _crawl_single_with_semaphore(self, semaphore: asyncio.Semaphore, index } except Exception as e: - logger.error(f"상품 {index} 크롤링 오류: url={product_url}, error='{e}'") + logger.error( + f"상품 {index} 크롤링 오류: url={product_url}, error='{e}'" + ) return { "index": index, "url": product_url, @@ -166,4 +180,4 @@ async def crawl_single_product_detail(self, product_url: str) -> dict: logger.error(f"단일 크롤링 오류: url={product_url}, error='{e}'") raise InvalidItemDataException() finally: - await crawler.close() \ No newline at end of file + await crawler.close() diff --git a/apps/pre-processing-service/app/service/crawlers/detail_crawler.py b/apps/pre-processing-service/app/service/crawlers/detail_crawler.py index 097f7e0d..38c6d56c 100644 --- a/apps/pre-processing-service/app/service/crawlers/detail_crawler.py +++ b/apps/pre-processing-service/app/service/crawlers/detail_crawler.py @@ -22,7 +22,9 @@ async def crawl_detail(self, product_url: str) -> dict: ) # 기본 정보 추출 (CPU 집약적 작업을 별도 스레드에서 실행) - extraction_tasks = await asyncio.to_thread(self._extract_all_data, soup, product_url) + extraction_tasks = await asyncio.to_thread( + self._extract_all_data, soup, product_url + ) title, price, rating, options, material_info, all_images = extraction_tasks @@ -56,7 +58,7 @@ def _selenium_sync(url): self.driver.get(url) self.wait.until( lambda driver: driver.execute_script("return document.readyState") - == "complete" + == "complete" ) time.sleep(2) logger.debug("Selenium HTML 로딩 완료") @@ -92,9 +94,7 @@ def _extract_all_data(self, soup: BeautifulSoup, product_url: str) -> tuple: # 이미지 정보 추출 logger.info("이미지 정보 추출 중...") page_images = self._extract_images(soup) - option_images = [ - opt["image_url"] for opt in options if opt.get("image_url") - ] + option_images = [opt["image_url"] for opt in options if opt.get("image_url")] # 중복 제거 후 합치기 all_images = list(set(page_images + option_images)) @@ -199,4 +199,4 @@ def _extract_images(self, soup: BeautifulSoup) -> list[str]: src = self.base_url + src images.append(src) logger.info(f"총 {len(images)}개 이미지 URL 추출 완료") - return images \ No newline at end of file + return images diff --git a/apps/pre-processing-service/app/service/product_selection_service.py b/apps/pre-processing-service/app/service/product_selection_service.py index 41b1c8dc..590bf15e 100644 --- a/apps/pre-processing-service/app/service/product_selection_service.py +++ b/apps/pre-processing-service/app/service/product_selection_service.py @@ -53,7 +53,7 @@ def select_product_for_content(self, request: RequestProductSelect) -> dict: return Response.ok( data=data.dict(), - message=f"콘텐츠용 상품 선택 완료: {selected_product['name']}" + message=f"콘텐츠용 상품 선택 완료: {selected_product['name']}", ) except Exception as e: @@ -122,7 +122,11 @@ def _select_best_product(self, db_products: List[Dict]) -> Dict: product_detail = data_value.get("product_detail", {}) product_images = product_detail.get("product_images", []) - if data_value.get("status") == "success" and product_detail and len(product_images) > 0: + if ( + data_value.get("status") == "success" + and product_detail + and len(product_images) > 0 + ): successful_products.append( { "product": product, @@ -148,7 +152,9 @@ def _select_best_product(self, db_products: List[Dict]) -> Dict: # 2순위: 크롤링 성공한 첫 번째 상품 for product in db_products: data_value = product.get("data_value", {}) - if data_value.get("status") == "success" and data_value.get("product_detail"): + if data_value.get("status") == "success" and data_value.get( + "product_detail" + ): product_detail = data_value.get("product_detail", {}) logger.info(f"2순위 선택: name={product['name']}") return { diff --git a/apps/pre-processing-service/app/service/s3_upload_service.py b/apps/pre-processing-service/app/service/s3_upload_service.py index fd422f42..725db0ec 100644 --- a/apps/pre-processing-service/app/service/s3_upload_service.py +++ b/apps/pre-processing-service/app/service/s3_upload_service.py @@ -20,7 +20,9 @@ def __init__(self): self.s3_util = S3UploadUtil() self.db_manager = MariadbManager() - async def upload_crawled_products_to_s3(self, request: RequestS3Upload, max_concurrent: int = 5) -> dict: + async def upload_crawled_products_to_s3( + self, request: RequestS3Upload, max_concurrent: int = 5 + ) -> dict: """ 크롤링된 상품들의 이미지와 데이터를 S3에 업로드하고 DB에 저장하는 비즈니스 로직 (6단계) """ @@ -47,7 +49,7 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload, max_conc }, "uploaded_at": time.strftime("%Y-%m-%d %H:%M:%S"), }, - message="task_run_id is required from Java workflow" + message="task_run_id is required from Java workflow", ) logger.info( @@ -73,7 +75,12 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload, max_conc tasks = [] for product_info in crawled_products: task = self._upload_single_product_with_semaphore( - semaphore, session, product_info, keyword, base_folder, task_run_id + semaphore, + session, + product_info, + keyword, + base_folder, + task_run_id, ) tasks.append(task) @@ -84,20 +91,24 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload, max_conc for result in results: if isinstance(result, Exception): logger.error(f"업로드 태스크 오류: {result}") - upload_results.append({ - "product_index": len(upload_results) + 1, - "product_title": "Unknown", - "status": "error", - "folder_s3_url": None, - "uploaded_images": [], - "success_count": 0, - "fail_count": 0, - }) - db_save_results.append({ - "product_index": len(db_save_results) + 1, - "db_status": "error", - "error": str(result), - }) + upload_results.append( + { + "product_index": len(upload_results) + 1, + "product_title": "Unknown", + "status": "error", + "folder_s3_url": None, + "uploaded_images": [], + "success_count": 0, + "fail_count": 0, + } + ) + db_save_results.append( + { + "product_index": len(db_save_results) + 1, + "db_status": "error", + "error": str(result), + } + ) else: upload_result, db_result = result upload_results.append(upload_result) @@ -126,15 +137,23 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload, max_conc "total_success_images": total_success_images, "total_fail_images": total_fail_images, "db_success_count": len( - [r for r in db_save_results if r.get("db_status") == "success"] + [ + r + for r in db_save_results + if r.get("db_status") == "success" + ] ), "db_fail_count": len( - [r for r in db_save_results if r.get("db_status") == "error"] + [ + r + for r in db_save_results + if r.get("db_status") == "error" + ] ), }, "uploaded_at": time.strftime("%Y-%m-%d %H:%M:%S"), }, - message=f"S3 업로드 + DB 저장 완료: 총 성공 이미지 {total_success_images}개, 총 실패 이미지 {total_fail_images}개" + message=f"S3 업로드 + DB 저장 완료: 총 성공 이미지 {total_success_images}개, 총 실패 이미지 {total_fail_images}개", ) except Exception as e: @@ -154,17 +173,17 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload, max_conc }, "uploaded_at": time.strftime("%Y-%m-%d %H:%M:%S"), }, - message=f"S3 업로드 서비스 오류: {str(e)}" + message=f"S3 업로드 서비스 오류: {str(e)}", ) async def _upload_single_product_with_semaphore( - self, - semaphore: asyncio.Semaphore, - session: aiohttp.ClientSession, - product_info: Dict, - keyword: str, - base_folder: str, - task_run_id: int + self, + semaphore: asyncio.Semaphore, + session: aiohttp.ClientSession, + product_info: Dict, + keyword: str, + base_folder: str, + task_run_id: int, ) -> tuple: """세마포어를 사용한 단일 상품 업로드 + DB 저장""" async with semaphore: @@ -198,7 +217,11 @@ async def _upload_single_product_with_semaphore( session, product_info, product_index, keyword, base_folder ) db_task = asyncio.to_thread( - self._save_product_to_db, task_run_id, keyword, product_index, product_info + self._save_product_to_db, + task_run_id, + keyword, + product_index, + product_info, ) upload_result, db_result = await asyncio.gather(upload_task, db_task) @@ -229,7 +252,7 @@ async def _upload_single_product_with_semaphore( return upload_result, db_result def _save_product_to_db( - self, task_run_id: int, keyword: str, product_index: int, product_info: Dict + self, task_run_id: int, keyword: str, product_index: int, product_info: Dict ) -> Dict: """ 상품 데이터를 TASK_IO_DATA 테이블에 저장 (MariaDB) @@ -279,4 +302,4 @@ def _save_product_to_db( "product_index": product_index, "db_status": "error", "error": str(e), - } \ No newline at end of file + } From d561ab356dbd16049587befb340a0ab9825232c3 Mon Sep 17 00:00:00 2001 From: thkim7 Date: Thu, 25 Sep 2025 17:39:56 +0900 Subject: [PATCH 08/40] chore: spotlessApply --- .../service/WorkflowExecutionService.java | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java index e2b663ab..afc4f555 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java @@ -128,7 +128,7 @@ private boolean executeTasksForJob( workflowLogger.info( "Job (JobRunId={}) 내 총 {}개의 Task를 순차 실행합니다.", jobRun.getId(), taskDtos.size()); boolean hasAnyTaskFailed = false; - Long s3UploadTaskRunId = null; // S3 업로드 태스크의 task_run_id 저장용 + Long s3UploadTaskRunId = null; // S3 업로드 태스크의 task_run_id 저장용 for (TaskDto taskDto : taskDtos) { try { @@ -147,18 +147,18 @@ private boolean executeTasksForJob( .map(builder -> builder.build(task, workflowContext)) .orElse(objectMapper.createObjectNode()); - if ("S3 업로드 태스크".equals(task.getName())) { - requestBody.put("task_run_id", taskRun.getId()); - s3UploadTaskRunId = taskRun.getId(); // S3 업로드의 task_run_id 저장 - } else if ("상품 선택 태스크".equals(task.getName())) { - // S3 업로드에서 사용한 task_run_id를 사용 - if (s3UploadTaskRunId != null) { - requestBody.put("task_run_id", s3UploadTaskRunId); - } else { - workflowLogger.error("S3 업로드 태스크가 먼저 실행되지 않아 task_run_id를 찾을 수 없습니다."); - // 또는 이전 Job에서 S3 업로드를 찾는 로직 추가 가능 - } + if ("S3 업로드 태스크".equals(task.getName())) { + requestBody.put("task_run_id", taskRun.getId()); + s3UploadTaskRunId = taskRun.getId(); // S3 업로드의 task_run_id 저장 + } else if ("상품 선택 태스크".equals(task.getName())) { + // S3 업로드에서 사용한 task_run_id를 사용 + if (s3UploadTaskRunId != null) { + requestBody.put("task_run_id", s3UploadTaskRunId); + } else { + workflowLogger.error("S3 업로드 태스크가 먼저 실행되지 않아 task_run_id를 찾을 수 없습니다."); + // 또는 이전 Job에서 S3 업로드를 찾는 로직 추가 가능 } + } TaskRunner.TaskExecutionResult result = taskExecutionService.executeWithRetry(task, taskRun, requestBody); From f3210d3d7d235415f7999c034611f2f58105512d Mon Sep 17 00:00:00 2001 From: Jihu Kim Date: Thu, 25 Sep 2025 18:54:37 +0900 Subject: [PATCH 09/40] =?UTF-8?q?Workflow=20=EC=88=98=EB=8F=99=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EB=B0=8F=20Retry=20=EB=A1=9C=EC=A7=81=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?(#209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: integration log4j2 설정파일 분리 * refactor: RetryConfig 구성 * feat: Workflow 수동 실행 api 테스트 코드 작성 * feat: task 실패 시 재시도 동작 테스트 코드 작성 * refactor: ApplicationListener로 변경 * refactor: Code Formatting * refactor: javadoc 수정 --- .../service/TaskExecutionService.java | 102 ++++++------------ .../config/QuartzSchedulerInitializer.java | 18 ++-- .../global/config/retry/RetryConfig.java | 28 +++++ .../application-test-integration.yml | 2 +- .../resources/log4j2-test-integration.yml | 77 +++++++++++++ .../TaskExecutionServiceIntegrationTest.java | 60 +++++++++++ .../WorkflowRunApiIntegrationTest.java | 64 +++++++++++ 7 files changed, 272 insertions(+), 79 deletions(-) create mode 100644 apps/user-service/src/main/java/site/icebang/global/config/retry/RetryConfig.java create mode 100644 apps/user-service/src/main/resources/log4j2-test-integration.yml create mode 100644 apps/user-service/src/test/java/site/icebang/integration/tests/workflow/TaskExecutionServiceIntegrationTest.java create mode 100644 apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/TaskExecutionService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/TaskExecutionService.java index 29f28d98..62b72fed 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/TaskExecutionService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/TaskExecutionService.java @@ -4,11 +4,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.retry.annotation.Backoff; -import org.springframework.retry.annotation.Recover; -import org.springframework.retry.annotation.Retryable; +import org.springframework.retry.support.RetryTemplate; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestClientException; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -18,80 +15,45 @@ import site.icebang.domain.workflow.model.TaskRun; import site.icebang.domain.workflow.runner.TaskRunner; -/** - * 워크플로우 내 개별 Task의 실행과 재시도 정책을 전담하는 서비스입니다. - * - *

이 클래스는 {@code WorkflowExecutionService}로부터 Task 실행 책임을 위임받습니다. Spring AOP의 '자기 - * 호출(Self-invocation)' 문제를 회피하고, 재시도 로직을 비즈니스 흐름과 분리하기 위해 별도의 서비스로 구현되었습니다. - * - *

주요 기능:

- * - *
    - *
  • {@code @Retryable} 어노테이션을 통한 선언적 재시도 처리 - *
  • {@code @Recover} 어노테이션을 이용한 최종 실패 시 복구 로직 수행 - *
  • Task 타입에 맞는 적절한 {@code TaskRunner} 선택 및 실행 - *
- * - * @author jihu0210@naver.com - * @since v0.1.0 - */ @Service @RequiredArgsConstructor public class TaskExecutionService { - /** 워크플로우 실행 이력 전용 로거 */ private static final Logger workflowLogger = LoggerFactory.getLogger("WORKFLOW_HISTORY"); - private final Map taskRunners; + private final RetryTemplate taskExecutionRetryTemplate; // 📌 RetryTemplate 주입 - /** - * 지정된 Task를 재시도 정책을 적용하여 실행합니다. - * - *

HTTP 통신 오류 등 {@code RestClientException} 발생 시, 5초의 고정된 간격({@code Backoff})으로 최대 3회({@code - * maxAttempts})까지 실행을 재시도합니다. 지원하지 않는 Task 타입의 경우 재시도 없이 즉시 {@code IllegalArgumentException}을 - * 발생시킵니다. - * - * @param task 실행할 Task의 도메인 모델 - * @param taskRun 현재 실행에 대한 기록 객체 - * @param requestBody 동적으로 생성된 요청 Body - * @return Task 실행 결과 - * @throws IllegalArgumentException 지원하지 않는 Task 타입일 경우 - * @since v0.1.0 - */ - @Retryable( - value = {RestClientException.class}, - maxAttempts = 3, - backoff = @Backoff(delay = 5000)) + // 📌 @Retryable, @Recover 어노테이션 제거 public TaskRunner.TaskExecutionResult executeWithRetry( Task task, TaskRun taskRun, ObjectNode requestBody) { - workflowLogger.info("Task 실행 시도: TaskId={}, TaskRunId={}", task.getId(), taskRun.getId()); - - String runnerBeanName = task.getType().toLowerCase() + "TaskRunner"; - TaskRunner runner = taskRunners.get(runnerBeanName); - - if (runner == null) { - throw new IllegalArgumentException("지원하지 않는 Task 타입: " + task.getType()); - } - - return runner.execute(task, taskRun, requestBody); - } - /** - * {@code @Retryable} 재시도가 모두 실패했을 때 호출되는 복구 메소드입니다. - * - *

이 메소드는 {@code executeWithRetry} 메소드와 동일한 파라미터 시그니처를 가지며, 발생한 예외를 첫 번째 파라미터로 추가로 받습니다. 최종 실패 - * 상태를 기록하고 실패 결과를 반환하는 역할을 합니다. - * - * @param e 재시도를 유발한 마지막 예외 객체 - * @param task 실패한 Task의 도메인 모델 - * @param taskRun 실패한 실행의 기록 객체 - * @param requestBody 실패 당시 사용된 요청 Body - * @return 최종 실패를 나타내는 Task 실행 결과 - * @since v0.1.0 - */ - @Recover - public TaskRunner.TaskExecutionResult recover( - RestClientException e, Task task, TaskRun taskRun, ObjectNode requestBody) { - workflowLogger.error("최종 Task 실행 실패 (모든 재시도 소진): TaskRunId={}", taskRun.getId(), e); - return TaskRunner.TaskExecutionResult.failure("최대 재시도 횟수 초과: " + e.getMessage()); + // RetryTemplate을 사용하여 실행 로직을 감쌉니다. + return taskExecutionRetryTemplate.execute( + // 1. 재시도할 로직 (RetryCallback) + context -> { + // 📌 이 블록은 재시도할 때마다 실행되므로, 로그가 누락되지 않습니다. + workflowLogger.info( + "Task 실행 시도 #{}: TaskId={}, TaskRunId={}", + context.getRetryCount() + 1, + task.getId(), + taskRun.getId()); + + String runnerBeanName = task.getType().toLowerCase() + "TaskRunner"; + TaskRunner runner = taskRunners.get(runnerBeanName); + + if (runner == null) { + throw new IllegalArgumentException("지원하지 않는 Task 타입: " + task.getType()); + } + + // 이 부분에서 RestClientException 발생 시 재시도됩니다. + return runner.execute(task, taskRun, requestBody); + }, + // 2. 모든 재시도가 실패했을 때 실행될 로직 (RecoveryCallback) + context -> { + Throwable lastThrowable = context.getLastThrowable(); + workflowLogger.error( + "최종 Task 실행 실패 (모든 재시도 소진): TaskRunId={}", taskRun.getId(), lastThrowable); + return TaskRunner.TaskExecutionResult.failure( + "최대 재시도 횟수 초과: " + lastThrowable.getMessage()); + }); } } diff --git a/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java b/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java index bdca3015..9ebd150f 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java @@ -3,6 +3,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.CommandLineRunner; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.stereotype.Component; import site.icebang.domain.schedule.model.Schedule; import site.icebang.domain.schedule.mapper.ScheduleMapper; @@ -12,13 +14,13 @@ /** * 애플리케이션 시작 시 데이터베이스에 저장된 스케줄을 Quartz 스케줄러에 동적으로 등록하는 초기화 클래스입니다. * - *

이 클래스는 {@code CommandLineRunner}를 구현하여, Spring Boot 애플리케이션이 완전히 - * 로드된 후 단 한 번 실행됩니다. 데이터베이스의 {@code schedule} 테이블을 'Source of Truth'로 삼아, - * 활성화된 모든 스케줄을 읽어와 Quartz 엔진에 동기화하는 매우 중요한 역할을 수행합니다. + *

이 클래스는 {@code ApplicationListener}를 구현하여, Spring의 ApplicationContext가 + * 완전히 초기화되고 모든 Bean이 준비되었을 때 단 한 번 실행됩니다. 데이터베이스의 {@code schedule} 테이블을 + * 'Source of Truth'로 삼아, 활성화된 모든 스케줄을 읽어와 Quartz 엔진에 동기화하는 매우 중요한 역할을 수행합니다. * *

주요 기능:

*
    - *
  • 애플리케이션 시작 시점에 DB의 활성 스케줄 조회
  • + *
  • 애플리케이션 컨텍스트 초기화 완료 시점에 DB의 활성 스케줄 조회
  • *
  • 조회된 스케줄을 {@code QuartzScheduleService}를 통해 Quartz 엔진에 등록
  • *
* @@ -28,22 +30,22 @@ @Slf4j @Component @RequiredArgsConstructor -public class QuartzSchedulerInitializer implements CommandLineRunner { +public class QuartzSchedulerInitializer implements ApplicationListener { private final ScheduleMapper scheduleMapper; private final QuartzScheduleService quartzScheduleService; /** - * Spring Boot 애플리케이션 시작 시 호출되는 메인 실행 메소드입니다. + * Spring ApplicationContext가 완전히 새로고침(초기화)될 때 호출되는 이벤트 핸들러 메소드입니다. * *

데이터베이스에서 활성화된 모든 스케줄을 조회하고, 각 스케줄을 * {@code QuartzScheduleService}를 통해 Quartz 스케줄러에 등록합니다. * - * @param args 애플리케이션 실행 시 전달되는 인자 + * @param event 발생한 ContextRefreshedEvent 객체 * @since v0.1.0 */ @Override - public void run(String... args) { + public void onApplicationEvent(ContextRefreshedEvent event) { log.info("Quartz 스케줄러 초기화 시작: DB 스케줄을 등록합니다."); try { List activeSchedules = scheduleMapper.findAllActive(); diff --git a/apps/user-service/src/main/java/site/icebang/global/config/retry/RetryConfig.java b/apps/user-service/src/main/java/site/icebang/global/config/retry/RetryConfig.java new file mode 100644 index 00000000..98cda2bc --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/global/config/retry/RetryConfig.java @@ -0,0 +1,28 @@ +package site.icebang.global.config.retry; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.backoff.FixedBackOffPolicy; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.RetryTemplate; + +@Configuration +public class RetryConfig { + + @Bean + public RetryTemplate taskExecutionRetryTemplate() { + RetryTemplate retryTemplate = new RetryTemplate(); + + // 1. 재시도 정책 설정: 최대 3번 시도 + SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(); + retryPolicy.setMaxAttempts(3); + retryTemplate.setRetryPolicy(retryPolicy); + + // 2. 재시도 간격 설정: 5초 고정 간격 + FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy(); + backOffPolicy.setBackOffPeriod(5000L); // 5000ms = 5초 + retryTemplate.setBackOffPolicy(backOffPolicy); + + return retryTemplate; + } +} diff --git a/apps/user-service/src/main/resources/application-test-integration.yml b/apps/user-service/src/main/resources/application-test-integration.yml index 526cf151..6eccdace 100644 --- a/apps/user-service/src/main/resources/application-test-integration.yml +++ b/apps/user-service/src/main/resources/application-test-integration.yml @@ -39,4 +39,4 @@ mybatis: map-underscore-to-camel-case: true logging: - config: classpath:log4j2-test-unit.yml \ No newline at end of file + config: classpath:log4j2-test-integration.yml \ No newline at end of file diff --git a/apps/user-service/src/main/resources/log4j2-test-integration.yml b/apps/user-service/src/main/resources/log4j2-test-integration.yml new file mode 100644 index 00000000..e28b7e24 --- /dev/null +++ b/apps/user-service/src/main/resources/log4j2-test-integration.yml @@ -0,0 +1,77 @@ +Configuration: + name: test + + properties: + property: + - name: "log-path" + value: "./logs" + - name: "charset-UTF-8" + value: "UTF-8" + # 통일된 콘솔 패턴 - 모든 로그에 RequestId 포함 + - name: "console-layout-pattern" + value: "%highlight{[%-5level]} [%X{id}] %d{MM-dd HH:mm:ss} [%t] %n %msg%n%n" + + # [Appenders] 로그 기록방식 정의 + Appenders: + # 통일된 콘솔 출력 + Console: + name: console-appender + target: SYSTEM_OUT + PatternLayout: + pattern: ${console-layout-pattern} + + # [Loggers] 로그 출력 범위를 정의 + Loggers: + # [Loggers - Root] 모든 로그를 기록하는 최상위 로그를 정의 + Root: + level: OFF + AppenderRef: + - ref: console-appender + + # [Loggers - Loggers] 특정 패키지나 클래스에 대한 로그를 정의 + Logger: + # 1. Spring Framework 로그 + - name: org.springframework + additivity: "false" + level: INFO + AppenderRef: + - ref: console-appender + + # 2. 애플리케이션 로그 + - name: site.icebang + additivity: "false" + level: INFO + AppenderRef: + - ref: console-appender + + # 3. HikariCP 로그 비활성화 + - name: com.zaxxer.hikari + level: OFF + + # 4. Spring Security 로그 - 인증/인가 추적에 중요 + - name: org.springframework.security + level: INFO + additivity: "false" + AppenderRef: + - ref: console-appender + + # 5. 웹 요청 로그 - 요청 처리 과정 추적 + - name: org.springframework.web + level: INFO + additivity: "false" + AppenderRef: + - ref: console-appender + + # 6. 트랜잭션 로그 - DB 작업 추적 + - name: org.springframework.transaction + level: INFO + additivity: "false" + AppenderRef: + - ref: console-appender + + # 7. WORKFLOW_HISTORY 로그 - 워크플로우 기록 + - name: "WORKFLOW_HISTORY" + level: "INFO" + AppenderRef: + - ref: "console-appender" + additivity: "false" \ No newline at end of file diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/TaskExecutionServiceIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/TaskExecutionServiceIntegrationTest.java new file mode 100644 index 00000000..8308fe0d --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/TaskExecutionServiceIntegrationTest.java @@ -0,0 +1,60 @@ +package site.icebang.integration.tests.workflow; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.client.RestClientException; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import site.icebang.domain.workflow.model.Task; +import site.icebang.domain.workflow.model.TaskRun; +import site.icebang.domain.workflow.runner.TaskRunner; +import site.icebang.domain.workflow.runner.fastapi.FastApiTaskRunner; +import site.icebang.domain.workflow.service.TaskExecutionService; +import site.icebang.integration.setup.support.IntegrationTestSupport; + +/** + * TaskExecutionService의 재시도 로직에 대한 통합 테스트 클래스입니다. 실제 Spring 컨텍스트를 로드하여 RetryTemplate 기반의 재시도 기능이 정상 + * 동작하는지 검증합니다. + */ +public class TaskExecutionServiceIntegrationTest extends IntegrationTestSupport { + + @Autowired private TaskExecutionService taskExecutionService; + + @MockitoBean(name = "fastapiTaskRunner") + private FastApiTaskRunner mockFastApiTaskRunner; + + @Test + @DisplayName("Task 실행이 3번 모두 실패하면, 재시도 로그가 3번 기록되고 최종 FAILED 결과를 반환해야 한다") + void executeWithRetry_shouldLogRetries_andFail_afterAllRetries() { + // given + Task testTask = new Task(1L, "테스트 태스크", "FastAPI", null, null, null, null); + TaskRun testTaskRun = new TaskRun(); + ObjectNode testRequestBody = new ObjectMapper().createObjectNode(); + + // Mock Runner가 호출될 때마다 예외를 던지도록 설정 + when(mockFastApiTaskRunner.execute(any(Task.class), any(TaskRun.class), any(ObjectNode.class))) + .thenThrow(new RestClientException("Connection failed")); + + // when + // RetryTemplate이 적용된 실제 서비스를 호출 + TaskRunner.TaskExecutionResult finalResult = + taskExecutionService.executeWithRetry(testTask, testTaskRun, testRequestBody); + + // then + // 1. Runner의 execute 메소드가 RetryTemplate 정책에 따라 3번 호출되었는지 검증 + verify(mockFastApiTaskRunner, times(3)) + .execute(any(Task.class), any(TaskRun.class), any(ObjectNode.class)); + + // 2. RecoveryCallback이 반환한 최종 결과가 FAILED인지 검증 + assertThat(finalResult.isFailure()).isTrue(); + assertThat(finalResult.message()).contains("최대 재시도 횟수 초과"); + } +} diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java new file mode 100644 index 00000000..2daa4db1 --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java @@ -0,0 +1,64 @@ +package site.icebang.integration.tests.workflow; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; + +import site.icebang.domain.workflow.service.WorkflowExecutionService; +import site.icebang.integration.setup.support.IntegrationTestSupport; + +@Sql( + value = {"classpath:sql/01-insert-internal-users.sql", "classpath:sql/03-insert-workflow.sql"}, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Transactional +public class WorkflowRunApiIntegrationTest extends IntegrationTestSupport { + + @MockitoBean private WorkflowExecutionService mockWorkflowExecutionService; + + @Test + @DisplayName("워크플로우 수동 실행 API 호출 성공") + @WithUserDetails("admin@icebang.site") + void runWorkflow_success() throws Exception { + // given + Long workflowId = 1L; + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/workflows/{workflowId}/run"), workflowId) + .contentType(MediaType.APPLICATION_JSON) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isAccepted()) // 📌 1. 즉시 202 Accepted 응답을 받는지 확인 + .andDo( + document( + "workflow-run", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Workflow Execution") + .summary("워크플로우 수동 실행") + .description( + "지정된 ID의 워크플로우를 즉시 비동기적으로 실행합니다. " + + "성공 시 202 Accepted를 반환하며, 실제 실행은 백그라운드에서 진행됩니다.") + .build()))); + + // 📌 2. 비동기 호출된 executeWorkflow 메소드가 1초 이내에 1번 실행되었는지 검증 + verify(mockWorkflowExecutionService, timeout(1000).times(1)).executeWorkflow(workflowId); + } +} From 773b61f2af2e4870b7fbf3cde52f84c6a7235c72 Mon Sep 17 00:00:00 2001 From: bwnfo3 <142577603+bwnfo3@users.noreply.github.com> Date: Thu, 25 Sep 2025 22:24:23 +0900 Subject: [PATCH 10/40] =?UTF-8?q?Workflow=20=EC=83=9D=EC=84=B1=20api=20(#2?= =?UTF-8?q?11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: WorkflowCreateDto 초안 * feat: Workflow생성 관련 메서드, sql 추가 * feat: Workflow생성 임시 post api * feat: WorkflowCreateE2eTest 초안 * feat: WorkflowController 워크플로우 생성 api * feat: description @Null 제거 * feat: 워크플로우 생성시 job,task 생성 메서드 추가 * feat: 워크플로우 생성시 job,task 생성 메서드 추가 * feat: 워크플로우 생성시 job,task 생성 메서드 구현중 * feat: 워크플로우 생성 e2eTest 작성중 * chore: spotlessApply * feat: job,task 생성 없이 워크플로우 생성으로만 수정 --- .../controller/WorkflowController.java | 22 ++ .../workflow/dto/WorkflowCreateDto.java | 112 +++++++++ .../workflow/mapper/WorkflowMapper.java | 11 + .../workflow/service/WorkflowService.java | 72 ++++++ .../mybatis/mapper/WorkflowMapper.xml | 57 +++++ .../scenario/WorkflowCreateFlowE2eTest.java | 219 ++++++++++++++++++ 6 files changed, 493 insertions(+) create mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java create mode 100644 apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java index fd42ea13..c98ece1f 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java @@ -2,15 +2,20 @@ import java.math.BigInteger; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import site.icebang.common.dto.ApiResponse; import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; +import site.icebang.domain.auth.model.AuthCredential; import site.icebang.domain.workflow.dto.WorkflowCardDto; +import site.icebang.domain.workflow.dto.WorkflowCreateDto; import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; import site.icebang.domain.workflow.service.WorkflowExecutionService; import site.icebang.domain.workflow.service.WorkflowService; @@ -29,6 +34,23 @@ public ApiResponse> getWorkflowList( return ApiResponse.success(result); } + @PostMapping("") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createWorkflow( + @Valid @RequestBody WorkflowCreateDto workflowCreateDto, + @AuthenticationPrincipal AuthCredential authCredential) { + // 인증 체크 + if (authCredential == null) { + throw new IllegalArgumentException("로그인이 필요합니다"); + } + + // AuthCredential에서 userId 추출 + BigInteger userId = authCredential.getId(); + + workflowService.createWorkflow(workflowCreateDto, userId); + return ApiResponse.success(null); + } + @PostMapping("/{workflowId}/run") public ResponseEntity runWorkflow(@PathVariable Long workflowId) { // HTTP 요청/응답 스레드를 블로킹하지 않도록 비동기 실행 diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java new file mode 100644 index 00000000..bcd0cc56 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java @@ -0,0 +1,112 @@ +package site.icebang.domain.workflow.dto; + +import java.math.BigInteger; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 워크플로우 생성 요청 DTO + * + *

프론트엔드에서 워크플로우 생성 시 필요한 모든 정보를 담는 DTO - 기본 정보: 이름, 설명 - 플랫폼 설정: 검색 플랫폼, 포스팅 플랫폼 - 계정 설정: 포스팅 계정 + * 정보 (JSON 형태로 저장) + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class WorkflowCreateDto { + @Null private BigInteger id; + + @NotBlank(message = "워크플로우 이름은 필수입니다") + @Size(max = 100, message = "워크플로우 이름은 100자를 초과할 수 없습니다") + @Pattern( + regexp = "^[가-힣a-zA-Z0-9\\s_-]+$", + message = "워크플로우 이름은 한글, 영문, 숫자, 공백, 언더스코어, 하이픈만 사용 가능합니다") + private String name; + + @Size(max = 500, message = "설명은 500자를 초과할 수 없습니다") + private String description; + + @Pattern(regexp = "^(naver|naver_store)?$", message = "검색 플랫폼은 'naver' 또는 'naver_store'만 가능합니다") + @JsonProperty("search_platform") + private String searchPlatform; + + @Pattern( + regexp = "^(naver_blog|tstory_blog|blogger)?$", + message = "포스팅 플랫폼은 'naver_blog', 'tstory_blog', 'blogger' 중 하나여야 합니다") + @JsonProperty("posting_platform") + private String postingPlatform; + + @Size(max = 100, message = "포스팅 계정 ID는 100자를 초과할 수 없습니다") + @JsonProperty("posting_account_id") + private String postingAccountId; + + @Size(max = 200, message = "포스팅 계정 비밀번호는 200자를 초과할 수 없습니다") + @JsonProperty("posting_account_password") + private String postingAccountPassword; + + @Size(max = 100, message = "블로그 이름은 100자를 초과할 수 없습니다") + @JsonProperty("blog_name") + private String blogName; + + @Builder.Default + @JsonProperty("is_enabled") + private Boolean isEnabled = true; + + // JSON 변환용 필드 (MyBatis에서 사용) + private String defaultConfigJson; + + public String genertateDefaultConfigJson() { + StringBuilder jsonBuilder = new StringBuilder(); + jsonBuilder.append("{"); + + // 크롤링 플랫폼 설정 (키: "1") + if (searchPlatform != null && !searchPlatform.isBlank()) { + jsonBuilder.append("\"1\": {\"tag\": \"").append(searchPlatform).append("\"}"); + } + + // 포스팅 설정 (키: "8") + if (hasPostingConfig()) { + if (jsonBuilder.length() > 1) { + jsonBuilder.append(", "); + } + jsonBuilder + .append("\"8\": {") + .append("\"tag\": \"") + .append(postingPlatform) + .append("\", ") + .append("\"blog_id\": \"") + .append(postingAccountId) + .append("\", ") + .append("\"blog_pw\": \"") + .append(postingAccountPassword) + .append("\""); + + // tstory_blog인 경우 blog_name 추가 + if ("tstory_blog".equals(postingPlatform) && blogName != null && !blogName.isBlank()) { + jsonBuilder.append(", \"blog_name\": \"").append(blogName).append("\""); + } + + jsonBuilder.append("}"); + } + + jsonBuilder.append("}"); + return jsonBuilder.toString(); + } + + // 포스팅 설정 완성도 체크 (상태 확인 유틸) + public boolean hasPostingConfig() { + return postingPlatform != null + && !postingPlatform.isBlank() + && postingAccountId != null + && !postingAccountId.isBlank() + && postingAccountPassword != null + && !postingAccountPassword.isBlank(); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java index 82381737..417dfd1d 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java @@ -13,6 +13,17 @@ public interface WorkflowMapper { int selectWorkflowCount(PageParams pageParams); + int insertWorkflow(Map params); // insert workflow + + // Job 생성 관련 메서드 + void insertJobs(Map params); // 여러 Job을 동적으로 생성 + + void insertWorkflowJobs(Map params); // Workflow-Job 연결 + + void insertJobTasks(Map params); // Job-Task 연결 + + boolean existsByName(String name); + WorkflowCardDto selectWorkflowById(BigInteger id); WorkflowDetailCardDto selectWorkflowDetailById(BigInteger workflowId); diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java index e8c857f3..06a9ee5c 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java @@ -1,6 +1,7 @@ package site.icebang.domain.workflow.service; import java.math.BigInteger; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -8,12 +9,14 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; import site.icebang.common.service.PageableService; import site.icebang.domain.workflow.dto.ScheduleDto; import site.icebang.domain.workflow.dto.WorkflowCardDto; +import site.icebang.domain.workflow.dto.WorkflowCreateDto; import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; import site.icebang.domain.workflow.mapper.WorkflowMapper; @@ -32,6 +35,7 @@ * @author jihu0210@naver.com * @since v0.1.0 */ +@Slf4j @Service @RequiredArgsConstructor public class WorkflowService implements PageableService { @@ -86,4 +90,72 @@ public WorkflowDetailCardDto getWorkflowDetail(BigInteger workflowId) { return workflow; } + + /** 워크플로우 생성 */ + @Transactional + public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { + // 1. 기본 검증 + validateBasicInput(dto, createdBy); + + // 2. 비즈니스 검증 + validateBusinessRules(dto); + + // 3. 중복체크 + if (workflowMapper.existsByName(dto.getName())) { + throw new IllegalArgumentException("이미 존재하는 워크플로우 이름입니다 : " + dto.getName()); + } + + // 4. 워크플로우 생성 + try { + // JSON 설정 생성 + String defaultConfigJson = dto.genertateDefaultConfigJson(); + dto.setDefaultConfigJson(defaultConfigJson); + + // DB 삽입 파라미터 구성 + Map params = new HashMap<>(); + params.put("dto", dto); + params.put("createdBy", createdBy); + + int result = workflowMapper.insertWorkflow(params); + if (result != 1) { + throw new RuntimeException("워크플로우 생성에 실패했습니다"); + } + + log.info("워크플로우 생성 완료: {} (생성자: {})", dto.getName(), createdBy); + + } catch (Exception e) { + log.error("워크플로우 생성 실패: {}", dto.getName(), e); + throw new RuntimeException("워크플로우 생성 중 오류가 발생했습니다", e); + } + } + + /** 기본 입력값 검증 */ + private void validateBasicInput(WorkflowCreateDto dto, BigInteger createdBy) { + if (dto == null) { + throw new IllegalArgumentException("워크플로우 정보가 필요합니다"); + } + if (createdBy == null) { + throw new IllegalArgumentException("생성자 정보가 필요합니다"); + } + } + + /** 비즈니스 규칙 검증 */ + private void validateBusinessRules(WorkflowCreateDto dto) { + // 포스팅 플랫폼 선택 시 계정 정보 필수 검증 + String postingPlatform = dto.getPostingPlatform(); + if (postingPlatform != null && !postingPlatform.isBlank()) { + if (dto.getPostingAccountId() == null || dto.getPostingAccountId().isBlank()) { + throw new IllegalArgumentException("포스팅 플랫폼 선택 시 계정 ID는 필수입니다"); + } + if (dto.getPostingAccountPassword() == null || dto.getPostingAccountPassword().isBlank()) { + throw new IllegalArgumentException("포스팅 플랫폼 선택 시 계정 비밀번호는 필수입니다"); + } + // 티스토리 블로그 추가 검증 + if ("tstory_blog".equals(postingPlatform)) { + if (dto.getBlogName() == null || dto.getBlogName().isBlank()) { + throw new IllegalArgumentException("티스토리 블로그 선택 시 블로그 이름은 필수입니다"); + } + } + } + } } diff --git a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml index 63a9f6db..dda398a9 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml @@ -129,4 +129,61 @@ wj.execution_order, j.id, j.name, j.description, j.is_enabled ORDER BY wj.execution_order + + + INSERT INTO workflow ( + name, + description, + is_enabled, + created_by, + created_at, + default_config + ) VALUES ( + #{dto.name}, + #{dto.description}, + #{dto.isEnabled}, + #{createdBy}, + NOW(), + #{dto.defaultConfigJson} + ) + + + + + + + + + SELECT LAST_INSERT_ID() as id + + INSERT INTO job (name, description, created_by, created_at) VALUES + ('상품 분석', '키워드 검색, 상품 크롤링 및 유사도 분석 작업', #{createdBy}, NOW()), + ('블로그 콘텐츠 생성', '분석 데이터를 기반으로 RAG 콘텐츠 생성 및 발행 작업', #{createdBy}, NOW()) + + + + + INSERT INTO workflow_job (workflow_id, job_id, execution_order) VALUES + (#{workflowId}, #{job1Id}, 1), + (#{workflowId}, #{job2Id}, 2) + + + + + INSERT INTO job_task (job_id, task_id, execution_order) VALUES + + (#{job1Id}, 1, 1), + (#{job1Id}, 2, 2), + (#{job1Id}, 3, 3), + (#{job1Id}, 4, 4), + (#{job1Id}, 5, 5), + (#{job1Id}, 6, 6), + + (#{job2Id}, 7, 1), + (#{job2Id}, 8, 2) + \ No newline at end of file diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java new file mode 100644 index 00000000..115bec64 --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java @@ -0,0 +1,219 @@ +package site.icebang.e2e.scenario; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.*; +import org.springframework.test.context.jdbc.Sql; + +import site.icebang.e2e.setup.annotation.E2eTest; +import site.icebang.e2e.setup.support.E2eTestSupport; + +@Sql( + value = {"classpath:sql/00-truncate.sql", "classpath:sql/01-insert-internal-users.sql"}, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +@DisplayName("워크플로우 생성 플로우 E2E 테스트") +@E2eTest +class WorkflowCreateFlowE2eTest extends E2eTestSupport { + + @SuppressWarnings("unchecked") + @Test + @DisplayName("사용자가 새 워크플로우를 생성하는 전체 플로우") + void completeWorkflowCreateFlow() throws Exception { + logStep(1, "사용자 로그인"); + + // 1. 로그인 (세션에 userId 저장) + Map loginRequest = new HashMap<>(); + loginRequest.put("email", "admin@icebang.site"); + loginRequest.put("password", "qwer1234!A"); + + HttpHeaders loginHeaders = new HttpHeaders(); + loginHeaders.setContentType(MediaType.APPLICATION_JSON); + loginHeaders.set("Origin", "https://admin.icebang.site"); + loginHeaders.set("Referer", "https://admin.icebang.site/"); + + HttpEntity> loginEntity = new HttpEntity<>(loginRequest, loginHeaders); + + ResponseEntity loginResponse = + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), loginEntity, Map.class); + + assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat((Boolean) loginResponse.getBody().get("success")).isTrue(); + + logSuccess("사용자 로그인 성공 - 세션 쿠키 자동 저장됨"); + logDebug("현재 세션 쿠키: " + getSessionCookies()); + + logStep(2, "네이버 블로그 워크플로우 생성"); + + // 2. 네이버 블로그 워크플로우 생성 + Map naverBlogWorkflow = new HashMap<>(); + naverBlogWorkflow.put("name", "상품 분석 및 네이버 블로그 자동 발행"); + naverBlogWorkflow.put("description", "키워드 검색부터 상품 분석 후 네이버 블로그 발행까지의 자동화 프로세스"); + naverBlogWorkflow.put("search_platform", "naver"); + naverBlogWorkflow.put("posting_platform", "naver_blog"); + naverBlogWorkflow.put("posting_account_id", "test_naver_blog"); + naverBlogWorkflow.put("posting_account_password", "naver_password123"); + naverBlogWorkflow.put("is_enabled", true); + + HttpHeaders workflowHeaders = new HttpHeaders(); + workflowHeaders.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> naverEntity = + new HttpEntity<>(naverBlogWorkflow, workflowHeaders); + + ResponseEntity naverResponse = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), naverEntity, Map.class); + + assertThat(naverResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) naverResponse.getBody().get("success")).isTrue(); + + logSuccess("네이버 블로그 워크플로우 생성 성공"); + + logStep(3, "티스토리 블로그 워크플로우 생성 (블로그명 포함)"); + + // 3. 티스토리 블로그 워크플로우 생성 (블로그명 필수) + Map tstoryWorkflow = new HashMap<>(); + tstoryWorkflow.put("name", "티스토리 자동 발행 워크플로우"); + tstoryWorkflow.put("description", "티스토리 블로그 자동 포스팅"); + tstoryWorkflow.put("search_platform", "naver"); + tstoryWorkflow.put("posting_platform", "tstory_blog"); + tstoryWorkflow.put("posting_account_id", "test_tstory"); + tstoryWorkflow.put("posting_account_password", "tstory_password123"); + tstoryWorkflow.put("blog_name", "my-tech-blog"); // 티스토리는 블로그명 필수 + tstoryWorkflow.put("is_enabled", true); + + HttpEntity> tstoryEntity = + new HttpEntity<>(tstoryWorkflow, workflowHeaders); + + ResponseEntity tstoryResponse = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), tstoryEntity, Map.class); + + assertThat(tstoryResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) tstoryResponse.getBody().get("success")).isTrue(); + + logSuccess("티스토리 워크플로우 생성 성공"); + + logStep(4, "검색만 하는 워크플로우 생성 (포스팅 없음)"); + + // 4. 포스팅 없는 검색 전용 워크플로우 (추후 예정) + Map searchOnlyWorkflow = new HashMap<>(); + searchOnlyWorkflow.put("name", "검색 전용 워크플로우"); + searchOnlyWorkflow.put("description", "상품 검색 및 분석만 수행"); + searchOnlyWorkflow.put("search_platform", "naver"); + searchOnlyWorkflow.put("is_enabled", true); + // posting_platform, posting_account_id, posting_account_password는 선택사항 + + HttpEntity> searchOnlyEntity = + new HttpEntity<>(searchOnlyWorkflow, workflowHeaders); + + ResponseEntity searchOnlyResponse = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), searchOnlyEntity, Map.class); + + assertThat(searchOnlyResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) searchOnlyResponse.getBody().get("success")).isTrue(); + + logSuccess("검색 전용 워크플로우 생성 성공"); + + logCompletion("워크플로우 생성 플로우 완료"); + } + + @Test + @DisplayName("중복된 이름으로 워크플로우 생성 시도 시 실패") + void createWorkflow_withDuplicateName_shouldFail() { + // 선행 조건: 로그인 + performUserLogin(); + + logStep(1, "첫 번째 워크플로우 생성"); + + // 첫 번째 워크플로우 생성 + Map firstWorkflow = new HashMap<>(); + firstWorkflow.put("name", "중복테스트워크플로우"); + firstWorkflow.put("search_platform", "naver"); + firstWorkflow.put("is_enabled", true); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> firstEntity = new HttpEntity<>(firstWorkflow, headers); + + ResponseEntity firstResponse = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), firstEntity, Map.class); + + assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + logSuccess("첫 번째 워크플로우 생성 성공"); + + logStep(2, "동일한 이름으로 두 번째 워크플로우 생성 시도"); + + // 동일한 이름으로 다시 생성 시도 + Map duplicateWorkflow = new HashMap<>(); + duplicateWorkflow.put("name", "중복테스트워크플로우"); // 동일한 이름 + duplicateWorkflow.put("search_platform", "naver_store"); + duplicateWorkflow.put("is_enabled", true); + + HttpEntity> duplicateEntity = new HttpEntity<>(duplicateWorkflow, headers); + + ResponseEntity duplicateResponse = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), duplicateEntity, Map.class); + + // 중복 이름 처리 확인 (400 또는 409 예상) + assertThat(duplicateResponse.getStatusCode()) + .isIn(HttpStatus.BAD_REQUEST, HttpStatus.CONFLICT, HttpStatus.INTERNAL_SERVER_ERROR); + + logSuccess("중복 이름 워크플로우 생성 차단 확인"); + } + + @Test + @DisplayName("필수 필드 누락 시 워크플로우 생성 실패") + void createWorkflow_withMissingRequiredFields_shouldFail() { + // 선행 조건: 로그인 + performUserLogin(); + + logStep(1, "워크플로우 이름 없이 생성 시도"); + + // 이름 없는 요청 + Map noNameWorkflow = new HashMap<>(); + noNameWorkflow.put("search_platform", "naver"); + noNameWorkflow.put("is_enabled", true); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(noNameWorkflow, headers); + + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + assertThat(response.getStatusCode()) + .isIn(HttpStatus.BAD_REQUEST, HttpStatus.UNPROCESSABLE_ENTITY); + + logSuccess("필수 필드 검증 확인"); + } + + /** 사용자 로그인을 수행하는 헬퍼 메서드 */ + private void performUserLogin() { + Map loginRequest = new HashMap<>(); + loginRequest.put("email", "admin@icebang.site"); + loginRequest.put("password", "qwer1234!A"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Origin", "https://admin.icebang.site"); + headers.set("Referer", "https://admin.icebang.site/"); + + HttpEntity> entity = new HttpEntity<>(loginRequest, headers); + + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); + + if (response.getStatusCode() != HttpStatus.OK) { + logError("사용자 로그인 실패: " + response.getStatusCode()); + throw new RuntimeException("User login failed"); + } + + logSuccess("사용자 로그인 완료"); + } +} From f8b70269935e8e87eb62654ab2eb733c605d2f08 Mon Sep 17 00:00:00 2001 From: Yousung Jung Date: Thu, 25 Sep 2025 22:55:58 +0900 Subject: [PATCH 11/40] =?UTF-8?q?Timezone=20Instant(UTC)=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: UTC 기반으로 변경 * refactor: data, schema sql 분리 * fix: IntegrationTest fix * test: E2e, integration test UTC 검증 --- .../domain/schedule/model/Schedule.java | 8 +- .../icebang/domain/workflow/dto/JobDto.java | 6 +- .../domain/workflow/dto/ScheduleDto.java | 6 +- .../icebang/domain/workflow/dto/TaskDto.java | 6 +- .../domain/workflow/dto/WorkflowCardDto.java | 4 +- .../workflow/dto/WorkflowDetailCardDto.java | 4 +- .../workflow/dto/WorkflowHistoryDTO.java | 6 +- .../icebang/domain/workflow/model/Job.java | 6 +- .../icebang/domain/workflow/model/JobRun.java | 12 +- .../icebang/domain/workflow/model/Task.java | 6 +- .../domain/workflow/model/TaskRun.java | 14 +- .../domain/workflow/model/Workflow.java | 6 +- .../domain/workflow/model/WorkflowRun.java | 12 +- .../typehandler/InstantTypeHandler.java | 94 +++++++ .../main/resources/application-develop.yml | 18 +- .../main/resources/application-production.yml | 2 +- .../main/resources/application-test-e2e.yml | 12 +- .../application-test-integration.yml | 8 +- .../main/resources/application-test-unit.yml | 8 +- .../src/main/resources/application.yml | 4 + .../resources/mybatis/mapper/JobMapper.xml | 8 +- .../resources/mybatis/mapper/JobRunMapper.xml | 6 +- .../mybatis/mapper/ScheduleMapper.xml | 12 + .../resources/mybatis/mapper/TaskMapper.xml | 2 + .../mybatis/mapper/TaskRunMapper.xml | 13 + .../mybatis/mapper/WorkflowMapper.xml | 14 +- .../mybatis/mapper/WorkflowRunMapper.xml | 6 +- .../resources/sql/{ => data}/00-truncate.sql | 0 .../sql/data/01-insert-internal-users-h2.sql | 229 ++++++++++++++++ .../{ => data}/01-insert-internal-users.sql | 0 .../{ => data}/02-insert-external-users.sql | 0 .../sql/data/03-insert-workflow-h2.sql | 110 ++++++++ .../sql/{ => data}/03-insert-workflow.sql | 8 +- .../data/04-insert-workflow-history-h2.sql | 76 ++++++ .../{ => data}/04-insert-workflow-history.sql | 4 +- .../sql/data/05-fix-timezone-data-h2.sql | 33 +++ .../sql/data/05-fix-timezone-data.sql | 250 ++++++++++++++++++ .../resources/sql/{ => schema}/00-drop-h2.sql | 0 .../sql/{ => schema}/00-drop-maria.sql | 0 .../resources/sql/{ => schema}/01-schema.sql | 0 .../sql/{ => schema}/02-quartz-schema.sql | 0 .../sql/schema/03-schema-h2-timezone.sql | 51 ++++ .../sql/schema/03-schema-mariadb-timezone.sql | 49 ++++ .../e2e/scenario/UserLogoutFlowE2eTest.java | 5 +- .../scenario/UserRegistrationFlowE2eTest.java | 5 +- .../scenario/WorkflowCreateFlowE2eTest.java | 82 +++++- .../setup/config/E2eTestConfiguration.java | 2 +- .../tests/auth/AuthApiIntegrationTest.java | 2 +- .../OrganizationApiIntegrationTest.java | 4 +- .../WorkflowHistoryApiIntegrationTest.java | 75 +++++- .../WorkflowRunApiIntegrationTest.java | 5 +- 51 files changed, 1194 insertions(+), 99 deletions(-) create mode 100644 apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/InstantTypeHandler.java rename apps/user-service/src/main/resources/sql/{ => data}/00-truncate.sql (100%) create mode 100644 apps/user-service/src/main/resources/sql/data/01-insert-internal-users-h2.sql rename apps/user-service/src/main/resources/sql/{ => data}/01-insert-internal-users.sql (100%) rename apps/user-service/src/main/resources/sql/{ => data}/02-insert-external-users.sql (100%) create mode 100644 apps/user-service/src/main/resources/sql/data/03-insert-workflow-h2.sql rename apps/user-service/src/main/resources/sql/{ => data}/03-insert-workflow.sql (97%) create mode 100644 apps/user-service/src/main/resources/sql/data/04-insert-workflow-history-h2.sql rename apps/user-service/src/main/resources/sql/{ => data}/04-insert-workflow-history.sql (96%) create mode 100644 apps/user-service/src/main/resources/sql/data/05-fix-timezone-data-h2.sql create mode 100644 apps/user-service/src/main/resources/sql/data/05-fix-timezone-data.sql rename apps/user-service/src/main/resources/sql/{ => schema}/00-drop-h2.sql (100%) rename apps/user-service/src/main/resources/sql/{ => schema}/00-drop-maria.sql (100%) rename apps/user-service/src/main/resources/sql/{ => schema}/01-schema.sql (100%) rename apps/user-service/src/main/resources/sql/{ => schema}/02-quartz-schema.sql (100%) create mode 100644 apps/user-service/src/main/resources/sql/schema/03-schema-h2-timezone.sql create mode 100644 apps/user-service/src/main/resources/sql/schema/03-schema-mariadb-timezone.sql diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/model/Schedule.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/model/Schedule.java index c2218bd0..cce15a25 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/model/Schedule.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/model/Schedule.java @@ -1,6 +1,6 @@ package site.icebang.domain.schedule.model; -import java.time.LocalDateTime; +import java.time.Instant; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -22,10 +22,10 @@ public class Schedule { private String parameters; // JSON format private boolean isActive; private String lastRunStatus; - private LocalDateTime lastRunAt; - private LocalDateTime createdAt; + private Instant lastRunAt; + private Instant createdAt; private Long createdBy; - private LocalDateTime updatedAt; + private Instant updatedAt; private Long updatedBy; private String scheduleText; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobDto.java index 6dd40c5d..035d6d17 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobDto.java @@ -1,6 +1,6 @@ package site.icebang.domain.workflow.dto; -import java.time.LocalDateTime; +import java.time.Instant; import lombok.Data; @@ -10,9 +10,9 @@ public class JobDto { private String name; private String description; private Boolean isEnabled; - private LocalDateTime createdAt; + private Instant createdAt; private Long createdBy; - private LocalDateTime updatedAt; + private Instant updatedAt; private Long updatedBy; private Integer executionOrder; diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleDto.java index 397285cb..752bd619 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleDto.java @@ -1,6 +1,6 @@ package site.icebang.domain.workflow.dto; -import java.time.LocalDateTime; +import java.time.Instant; import lombok.Data; @@ -10,7 +10,7 @@ public class ScheduleDto { private String cronExpression; private Boolean isActive; private String lastRunStatus; - private LocalDateTime lastRunAt; + private Instant lastRunAt; private String scheduleText; - private LocalDateTime createdAt; + private Instant createdAt; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskDto.java index fa83fe7d..1047d141 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskDto.java @@ -1,6 +1,6 @@ package site.icebang.domain.workflow.dto; -import java.time.LocalDateTime; +import java.time.Instant; import com.fasterxml.jackson.databind.JsonNode; @@ -14,6 +14,6 @@ public class TaskDto { private Integer executionOrder; private JsonNode settings; private JsonNode parameters; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; + private Instant createdAt; + private Instant updatedAt; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCardDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCardDto.java index a39ce0c3..4d074930 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCardDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCardDto.java @@ -1,7 +1,7 @@ package site.icebang.domain.workflow.dto; import java.math.BigInteger; -import java.time.LocalDateTime; +import java.time.Instant; import lombok.Data; @@ -12,5 +12,5 @@ public class WorkflowCardDto { private String description; private boolean isEnabled; private String createdBy; - private LocalDateTime createdAt; + private Instant createdAt; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowDetailCardDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowDetailCardDto.java index a2ef46b8..175db6ac 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowDetailCardDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowDetailCardDto.java @@ -1,6 +1,6 @@ package site.icebang.domain.workflow.dto; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Map; @@ -9,7 +9,7 @@ @Data public class WorkflowDetailCardDto extends WorkflowCardDto { private String defaultConfig; - private LocalDateTime updatedAt; + private Instant updatedAt; private String updatedBy; private List schedules; private List> jobs; diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowHistoryDTO.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowHistoryDTO.java index 18a25b7e..9f5a9b8d 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowHistoryDTO.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowHistoryDTO.java @@ -1,7 +1,7 @@ package site.icebang.domain.workflow.dto; import java.math.BigInteger; -import java.time.LocalDateTime; +import java.time.Instant; import lombok.Data; @@ -11,8 +11,8 @@ public class WorkflowHistoryDTO { private BigInteger id; private BigInteger workflowId; private String traceId; - private LocalDateTime startedAt; - private LocalDateTime finishedAt; + private Instant startedAt; + private Instant finishedAt; private BigInteger createdBy; private String triggerType; private String runNumber; diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Job.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Job.java index f0d36d8b..c363f8de 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Job.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Job.java @@ -1,6 +1,6 @@ package site.icebang.domain.workflow.model; -import java.time.LocalDateTime; +import java.time.Instant; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -17,9 +17,9 @@ public class Job { private String name; private String description; private boolean isEnabled; - private LocalDateTime createdAt; + private Instant createdAt; private Long createdBy; - private LocalDateTime updatedAt; + private Instant updatedAt; private Long updatedBy; public Job(JobDto dto) { diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/JobRun.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/JobRun.java index 038890dc..eeaffd28 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/JobRun.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/JobRun.java @@ -1,6 +1,6 @@ package site.icebang.domain.workflow.model; -import java.time.LocalDateTime; +import java.time.Instant; import lombok.Getter; import lombok.NoArgsConstructor; @@ -13,15 +13,15 @@ public class JobRun { private Long workflowRunId; private Long jobId; private String status; // PENDING, RUNNING, SUCCESS, FAILED - private LocalDateTime startedAt; - private LocalDateTime finishedAt; - private LocalDateTime createdAt; + private Instant startedAt; + private Instant finishedAt; + private Instant createdAt; private JobRun(Long workflowRunId, Long jobId) { this.workflowRunId = workflowRunId; this.jobId = jobId; this.status = "RUNNING"; - this.startedAt = LocalDateTime.now(); + this.startedAt = Instant.now(); this.createdAt = this.startedAt; } @@ -33,6 +33,6 @@ public static JobRun start(Long workflowRunId, Long jobId) { /** Job 실행 완료 처리 */ public void finish(String status) { this.status = status; - this.finishedAt = LocalDateTime.now(); + this.finishedAt = Instant.now(); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java index 2c917100..04d577c1 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java @@ -1,6 +1,6 @@ package site.icebang.domain.workflow.model; -import java.time.LocalDateTime; +import java.time.Instant; import com.fasterxml.jackson.databind.JsonNode; @@ -26,9 +26,9 @@ public class Task { private JsonNode settings; - private LocalDateTime createdAt; + private Instant createdAt; - private LocalDateTime updatedAt; + private Instant updatedAt; public Task(TaskDto taskDto) { this.id = taskDto.getId(); diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/TaskRun.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/TaskRun.java index d49542f0..6d89a150 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/TaskRun.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/TaskRun.java @@ -1,6 +1,6 @@ package site.icebang.domain.workflow.model; -import java.time.LocalDateTime; +import java.time.Instant; import lombok.Getter; import lombok.NoArgsConstructor; @@ -15,16 +15,16 @@ public class TaskRun { private Integer executionOrder; private String status; // PENDING, RUNNING, SUCCESS, FAILED private String resultMessage; // 실행 결과 메시지 - private LocalDateTime startedAt; - private LocalDateTime finishedAt; - private LocalDateTime createdAt; + private Instant startedAt; + private Instant finishedAt; + private Instant createdAt; // 생성자나 정적 팩토리 메서드를 통해 객체 생성 로직을 관리 private TaskRun(Long jobRunId, Long taskId) { this.jobRunId = jobRunId; this.taskId = taskId; this.status = "PENDING"; - this.createdAt = LocalDateTime.now(); + this.createdAt = Instant.now(); } /** Task 실행 시작을 위한 정적 팩토리 메서드 */ @@ -32,7 +32,7 @@ public static TaskRun start(Long jobRunId, Long taskId, Integer executionOrder) TaskRun taskRun = new TaskRun(jobRunId, taskId); taskRun.executionOrder = executionOrder; taskRun.status = "RUNNING"; - taskRun.startedAt = LocalDateTime.now(); + taskRun.startedAt = Instant.now(); return taskRun; } @@ -40,6 +40,6 @@ public static TaskRun start(Long jobRunId, Long taskId, Integer executionOrder) public void finish(String status, String resultMessage) { this.status = status; this.resultMessage = resultMessage; - this.finishedAt = LocalDateTime.now(); + this.finishedAt = Instant.now(); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Workflow.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Workflow.java index 8b536003..695364aa 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Workflow.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Workflow.java @@ -1,6 +1,6 @@ package site.icebang.domain.workflow.model; -import java.time.LocalDateTime; +import java.time.Instant; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -16,9 +16,9 @@ public class Workflow { private String name; private String description; private boolean isEnabled; - private LocalDateTime createdAt; + private Instant createdAt; private Long createdBy; - private LocalDateTime updatedAt; + private Instant updatedAt; private Long updatedBy; /** 워크플로우별 기본 설정값 (JSON) */ diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java index 011f7ee5..5741e77b 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java @@ -1,6 +1,6 @@ package site.icebang.domain.workflow.model; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.UUID; import lombok.Getter; @@ -14,15 +14,15 @@ public class WorkflowRun { private Long workflowId; private String traceId; // 분산 추적을 위한 ID private String status; // PENDING, RUNNING, SUCCESS, FAILED - private LocalDateTime startedAt; - private LocalDateTime finishedAt; - private LocalDateTime createdAt; + private Instant startedAt; + private Instant finishedAt; + private Instant createdAt; private WorkflowRun(Long workflowId) { this.workflowId = workflowId; this.traceId = UUID.randomUUID().toString(); // 고유 추적 ID 생성 this.status = "RUNNING"; - this.startedAt = LocalDateTime.now(); + this.startedAt = Instant.now(); this.createdAt = this.startedAt; } @@ -34,6 +34,6 @@ public static WorkflowRun start(Long workflowId) { /** 워크플로우 실행 완료 처리 */ public void finish(String status) { this.status = status; - this.finishedAt = LocalDateTime.now(); + this.finishedAt = Instant.now(); } } diff --git a/apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/InstantTypeHandler.java b/apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/InstantTypeHandler.java new file mode 100644 index 00000000..4146c4af --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/InstantTypeHandler.java @@ -0,0 +1,94 @@ +package site.icebang.global.config.mybatis.typehandler; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; + +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedTypes; + +/** + * MyBatis에서 Java 8의 {@code Instant} 타입을 데이터베이스의 TIMESTAMP 타입과 매핑하기 위한 커스텀 타입 핸들러입니다. + * + *

이 핸들러를 통해 애플리케이션에서는 UTC 기준의 시간을 {@code Instant} 객체로 다루고, 데이터베이스에는 해당 객체를 TIMESTAMP 형태로 저장하거나 + * 읽어올 수 있습니다. + * + *

MyBatis XML 매퍼에서의 사용 예제:

+ * + *
{@code
+ * 
+ *     
+ * 
+ * }
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ +@MappedTypes(Instant.class) +public class InstantTypeHandler extends BaseTypeHandler { + + /** + * {@code Instant} 파라미터를 DB에 저장하기 위해 Timestamp로 변환하여 PreparedStatement에 설정합니다. + * + * @param ps PreparedStatement 객체 + * @param i 파라미터 인덱스 + * @param parameter 변환할 Instant 객체 + * @param jdbcType JDBC 타입 + * @throws SQLException 변환 실패 시 + */ + @Override + public void setNonNullParameter(PreparedStatement ps, int i, Instant parameter, JdbcType jdbcType) + throws SQLException { + ps.setTimestamp(i, Timestamp.from(parameter)); + } + + /** + * ResultSet에서 컬럼 이름으로 Timestamp를 가져와 {@code Instant} 객체로 변환합니다. + * + * @param rs ResultSet 객체 + * @param columnName 컬럼 이름 + * @return 변환된 Instant 객체, 원본이 null이면 null + * @throws SQLException 변환 실패 시 + */ + @Override + public Instant getNullableResult(ResultSet rs, String columnName) throws SQLException { + Timestamp timestamp = rs.getTimestamp(columnName); + return timestamp != null ? timestamp.toInstant() : null; + } + + /** + * ResultSet에서 컬럼 인덱스로 Timestamp를 가져와 {@code Instant} 객체로 변환합니다. + * + * @param rs ResultSet 객체 + * @param columnIndex 컬럼 인덱스 + * @return 변환된 Instant 객체, 원본이 null이면 null + * @throws SQLException 변환 실패 시 + */ + @Override + public Instant getNullableResult(ResultSet rs, int columnIndex) throws SQLException { + Timestamp timestamp = rs.getTimestamp(columnIndex); + return timestamp != null ? timestamp.toInstant() : null; + } + + /** + * CallableStatement에서 컬럼 인덱스로 Timestamp를 가져와 {@code Instant} 객체로 변환합니다. + * + * @param cs CallableStatement 객체 + * @param columnIndex 컬럼 인덱스 + * @return 변환된 Instant 객체, 원본이 null이면 null + * @throws SQLException 변환 실패 시 + */ + @Override + public Instant getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { + Timestamp timestamp = cs.getTimestamp(columnIndex); + return timestamp != null ? timestamp.toInstant() : null; + } +} diff --git a/apps/user-service/src/main/resources/application-develop.yml b/apps/user-service/src/main/resources/application-develop.yml index 9de00956..64e1a0be 100644 --- a/apps/user-service/src/main/resources/application-develop.yml +++ b/apps/user-service/src/main/resources/application-develop.yml @@ -28,7 +28,7 @@ spring: auto-startup: true # 📌 Quartz 전용 DataSource 설정을 여기에 추가 datasource: - url: jdbc:mariadb://localhost:3306/pre_process + url: jdbc:mariadb://localhost:3306/pre_process?serverTimezone=UTC username: mariadb password: qwer1234 driver-class-name: org.mariadb.jdbc.Driver @@ -44,14 +44,16 @@ spring: init: mode: always schema-locations: - - classpath:sql/00-drop-maria.sql - - classpath:sql/01-schema.sql - - classpath:sql/02-quartz-schema.sql + - classpath:sql/schema/00-drop-maria.sql + - classpath:sql/schema/01-schema.sql + - classpath:sql/schema/02-quartz-schema.sql + - classpath:sql/schema/03-schema-mariadb-timezone.sql data-locations: - - classpath:sql/00-truncate.sql - - classpath:sql/01-insert-internal-users.sql - - classpath:sql/02-insert-external-users.sql - - classpath:sql/03-insert-workflow.sql + - classpath:sql/data/00-truncate.sql + - classpath:sql/data/01-insert-internal-users.sql + - classpath:sql/data/02-insert-external-users.sql + - classpath:sql/data/03-insert-workflow.sql + - classpath:sql/data/05-fix-timezone-data.sql encoding: UTF-8 mybatis: diff --git a/apps/user-service/src/main/resources/application-production.yml b/apps/user-service/src/main/resources/application-production.yml index 406fed87..c53e00bb 100644 --- a/apps/user-service/src/main/resources/application-production.yml +++ b/apps/user-service/src/main/resources/application-production.yml @@ -4,7 +4,7 @@ spring: on-profile: production datasource: - url: jdbc:mariadb://${DB_HOST}:${DB_PORT}/${DB_NAME} + url: jdbc:mariadb://${DB_HOST}:${DB_PORT}/${DB_NAME}?serverTimezone=UTC username: ${DB_USER} password: ${DB_PASS} driver-class-name: org.mariadb.jdbc.Driver diff --git a/apps/user-service/src/main/resources/application-test-e2e.yml b/apps/user-service/src/main/resources/application-test-e2e.yml index 3a777909..14c572b1 100644 --- a/apps/user-service/src/main/resources/application-test-e2e.yml +++ b/apps/user-service/src/main/resources/application-test-e2e.yml @@ -7,8 +7,16 @@ spring: init: mode: always schema-locations: - - classpath:sql/00-drop-maria.sql - - classpath:sql/01-schema.sql + - classpath:sql/schema/00-drop-maria.sql + - classpath:sql/schema/01-schema.sql + - classpath:sql/schema/02-quartz-schema.sql + - classpath:sql/schema/03-schema-mariadb-timezone.sql + data-locations: + - classpath:sql/data/00-truncate.sql + - classpath:sql/data/01-insert-internal-users.sql + - classpath:sql/data/02-insert-external-users.sql + - classpath:sql/data/03-insert-workflow.sql + - classpath:sql/data/05-fix-timezone-data.sql encoding: UTF-8 mybatis: diff --git a/apps/user-service/src/main/resources/application-test-integration.yml b/apps/user-service/src/main/resources/application-test-integration.yml index 6eccdace..0bc7cbcc 100644 --- a/apps/user-service/src/main/resources/application-test-integration.yml +++ b/apps/user-service/src/main/resources/application-test-integration.yml @@ -10,7 +10,7 @@ spring: password: driver-class-name: org.h2.Driver hikari: - connection-init-sql: "SET MODE MariaDB; SET NON_KEYWORDS USER;" + connection-init-sql: "SET MODE MariaDB; SET NON_KEYWORDS USER; " connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 @@ -28,8 +28,10 @@ spring: init: mode: always schema-locations: - - classpath:sql/00-drop-h2.sql - - classpath:sql/01-schema.sql + - classpath:sql/schema/00-drop-h2.sql + - classpath:sql/schema/01-schema.sql + - classpath:sql/schema/02-quartz-schema.sql + - classpath:sql/schema/03-schema-h2-timezone.sql encoding: UTF-8 mybatis: diff --git a/apps/user-service/src/main/resources/application-test-unit.yml b/apps/user-service/src/main/resources/application-test-unit.yml index d9a8059b..1487e336 100644 --- a/apps/user-service/src/main/resources/application-test-unit.yml +++ b/apps/user-service/src/main/resources/application-test-unit.yml @@ -11,7 +11,7 @@ spring: password: driver-class-name: org.h2.Driver hikari: - connection-init-sql: "SET MODE MariaDB" + connection-init-sql: "SET MODE MariaDB " connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 @@ -29,8 +29,10 @@ spring: init: mode: always schema-locations: - - classpath:sql/00-drop-h2.sql - - classpath:sql/01-schema.sql + - classpath:sql/schema/00-drop-h2.sql + - classpath:sql/schema/01-schema.sql + - classpath:sql/schema/02-quartz-schema.sql + - classpath:sql/schema/03-schema-h2-timezone.sql encoding: UTF-8 mybatis: diff --git a/apps/user-service/src/main/resources/application.yml b/apps/user-service/src/main/resources/application.yml index fbda82f3..55fece16 100644 --- a/apps/user-service/src/main/resources/application.yml +++ b/apps/user-service/src/main/resources/application.yml @@ -7,6 +7,10 @@ spring: context: cache: maxSize: 1 + jackson: + time-zone: UTC + serialization: + write-dates-as-timestamps: false mybatis: # Mapper XML 파일 위치 diff --git a/apps/user-service/src/main/resources/mybatis/mapper/JobMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/JobMapper.xml index cd64ad2c..5b959db3 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/JobMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/JobMapper.xml @@ -8,8 +8,8 @@ - - + + @@ -18,8 +18,8 @@ - - + + diff --git a/apps/user-service/src/main/resources/mybatis/mapper/JobRunMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/JobRunMapper.xml index 3a0e17bd..2cc51d78 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/JobRunMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/JobRunMapper.xml @@ -8,9 +8,9 @@ - - - + + + diff --git a/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml index 2a5480e3..80d6ffae 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml @@ -3,6 +3,18 @@ + + + + + + + + + + + + diff --git a/apps/user-service/src/main/resources/mybatis/mapper/TaskRunMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/TaskRunMapper.xml index 8fb277e2..61ec3cf0 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/TaskRunMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/TaskRunMapper.xml @@ -2,6 +2,19 @@ + + + + + + + + + + + + + INSERT INTO task_run (job_run_id, task_id, execution_order, status, started_at, created_at) VALUES (#{jobRunId}, #{taskId}, #{executionOrder}, #{status}, #{startedAt}, #{createdAt}) diff --git a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml index dda398a9..ea5a0d01 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml @@ -47,9 +47,9 @@ - + - + @@ -58,9 +58,9 @@ - + - + @@ -143,7 +143,7 @@ #{dto.description}, #{dto.isEnabled}, #{createdBy}, - NOW(), + UTC_TIMESTAMP(), #{dto.defaultConfigJson} ) @@ -161,8 +161,8 @@ SELECT LAST_INSERT_ID() as id INSERT INTO job (name, description, created_by, created_at) VALUES - ('상품 분석', '키워드 검색, 상품 크롤링 및 유사도 분석 작업', #{createdBy}, NOW()), - ('블로그 콘텐츠 생성', '분석 데이터를 기반으로 RAG 콘텐츠 생성 및 발행 작업', #{createdBy}, NOW()) + ('상품 분석', '키워드 검색, 상품 크롤링 및 유사도 분석 작업', #{createdBy}, UTC_TIMESTAMP()), + ('블로그 콘텐츠 생성', '분석 데이터를 기반으로 RAG 콘텐츠 생성 및 발행 작업', #{createdBy}, UTC_TIMESTAMP()) diff --git a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowRunMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowRunMapper.xml index d032da56..8011fc6c 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowRunMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowRunMapper.xml @@ -8,9 +8,9 @@ - - - + + + diff --git a/apps/user-service/src/main/resources/sql/00-truncate.sql b/apps/user-service/src/main/resources/sql/data/00-truncate.sql similarity index 100% rename from apps/user-service/src/main/resources/sql/00-truncate.sql rename to apps/user-service/src/main/resources/sql/data/00-truncate.sql diff --git a/apps/user-service/src/main/resources/sql/data/01-insert-internal-users-h2.sql b/apps/user-service/src/main/resources/sql/data/01-insert-internal-users-h2.sql new file mode 100644 index 00000000..88108427 --- /dev/null +++ b/apps/user-service/src/main/resources/sql/data/01-insert-internal-users-h2.sql @@ -0,0 +1,229 @@ +-- icebang 내부 직원 전체 INSERT (H2 호환 버전) + +-- 1. icebang 조직 +INSERT INTO `organization` (`name`, `domain_name`) VALUES + ('icebang', 'icebang.site'); + +-- 2. icebang 부서들 (직접 ID로 참조) +INSERT INTO `department` (`organization_id`, `name`) VALUES + (1, 'AI개발팀'), + (1, '데이터팀'), + (1, '콘텐츠팀'), + (1, '마케팅팀'), + (1, '운영팀'), + (1, '기획팀'); + +-- 3. icebang 직책들 (직접 ID로 참조) +INSERT INTO `position` (`organization_id`, `title`) VALUES + (1, 'CEO'), + (1, 'CTO'), + (1, '팀장'), + (1, '시니어'), + (1, '주니어'), + (1, '인턴'); + +-- 4. 바이럴 콘텐츠 워크플로우 권한들 +INSERT INTO `permission` (`resource`, `description`) VALUES +-- 사용자 관리 +('users.create', '사용자 생성'), +('users.read', '사용자 조회'), +('users.read.own', '본인 정보 조회'), +('users.read.department', '부서 내 사용자 조회'), +('users.read.organization', '조직 전체 사용자 조회'), +('users.update', '사용자 정보 수정'), +('users.update.own', '본인 정보 수정'), +('users.delete', '사용자 삭제'), +('users.invite', '사용자 초대'), + +-- 조직 관리 +('organizations.read', '조직 조회'), +('organizations.settings', '조직 설정 관리'), + +-- 부서 관리 +('departments.read', '부서 조회'), +('departments.manage', '부서 관리'), + +-- 역할/권한 관리 +('roles.create', '역할 생성'), +('roles.read', '역할 조회'), +('roles.update', '역할 수정'), +('roles.assign', '역할 할당'), +('permissions.read', '권한 조회'), +('permissions.assign', '권한 할당'), + +-- 트렌드 키워드 관리 +('trends.read', '트렌드 키워드 조회'), +('trends.create', '트렌드 키워드 등록'), +('trends.update', '트렌드 키워드 수정'), +('trends.delete', '트렌드 키워드 삭제'), +('trends.analyze', '트렌드 분석'), + +-- 크롤링 관리 +('crawling.create', '크롤링 작업 생성'), +('crawling.read', '크롤링 결과 조회'), +('crawling.update', '크롤링 설정 수정'), +('crawling.delete', '크롤링 데이터 삭제'), +('crawling.execute', '크롤링 실행'), +('crawling.schedule', '크롤링 스케줄 관리'), + +-- 콘텐츠 생성 +('content.create', '콘텐츠 생성'), +('content.read', '콘텐츠 조회'), +('content.read.own', '본인 콘텐츠만 조회'), +('content.read.department', '부서 콘텐츠 조회'), +('content.read.all', '모든 콘텐츠 조회'), +('content.update', '콘텐츠 수정'), +('content.delete', '콘텐츠 삭제'), +('content.publish', '콘텐츠 발행'), +('content.approve', '콘텐츠 승인'), +('content.reject', '콘텐츠 거절'), + +-- AI 모델 관리 +('ai.models.read', 'AI 모델 조회'), +('ai.models.create', 'AI 모델 생성'), +('ai.models.update', 'AI 모델 수정'), +('ai.models.delete', 'AI 모델 삭제'), +('ai.models.train', 'AI 모델 학습'), +('ai.models.deploy', 'AI 모델 배포'), + +-- 워크플로우 관리 +('workflows.create', '워크플로우 생성'), +('workflows.read', '워크플로우 조회'), +('workflows.update', '워크플로우 수정'), +('workflows.delete', '워크플로우 삭제'), +('workflows.execute', '워크플로우 실행'), +('workflows.schedule', '워크플로우 스케줄링'), + +-- 캠페인 관리 +('campaigns.create', '캠페인 생성'), +('campaigns.read', '캠페인 조회'), +('campaigns.update', '캠페인 수정'), +('campaigns.delete', '캠페인 삭제'), +('campaigns.execute', '캠페인 실행'), + +-- 시스템 관리 +('system.health', '시스템 상태 조회'), +('system.logs', '시스템 로그 조회'), +('system.backup', '시스템 백업'), +('system.config', '시스템 설정 관리'); + +-- 5. icebang 역할들 +INSERT INTO `role` (`organization_id`, `name`, `description`) VALUES +-- 글로벌 관리자 역할 +(NULL, 'SUPER_ADMIN', '전체 시스템 관리자 - 모든 권한'), +(NULL, 'ORG_ADMIN', '조직 관리자 - 조직별 모든 권한'), + +-- icebang 전용 역할들 +(1, 'AI_ENGINEER', 'AI 개발자 - AI 모델 관리 및 워크플로우'), +(1, 'DATA_SCIENTIST', '데이터 과학자 - 트렌드 분석 및 데이터 관리'), +(1, 'CONTENT_MANAGER', '콘텐츠 매니저 - 콘텐츠 생성 및 관리'), +(1, 'MARKETING_SPECIALIST', '마케팅 전문가 - 캠페인 관리'), +(1, 'WORKFLOW_ADMIN', '워크플로우 관리자 - 워크플로우 전체 관리'), +(1, 'CRAWLER_OPERATOR', '크롤링 운영자 - 크롤링 작업 관리'), +(1, 'BASIC_USER', '기본 사용자 - 기본 조회 권한'); + +-- 6. icebang 직원들 +INSERT INTO `user` (`name`, `email`, `password`, `status`) VALUES +('김아이스', 'ice.kim@icebang.site', '$2a$10$encrypted_password_hash1', 'ACTIVE'), +('박방방', 'bang.park@icebang.site', '$2a$10$encrypted_password_hash2', 'ACTIVE'), +('이트렌드', 'trend.lee@icebang.site', '$2a$10$encrypted_password_hash3', 'ACTIVE'), +('정바이럴', 'viral.jung@icebang.site', '$2a$10$encrypted_password_hash4', 'ACTIVE'), +('최콘텐츠', 'content.choi@icebang.site', '$2a$10$encrypted_password_hash5', 'ACTIVE'), +('홍크롤러', 'crawler.hong@icebang.site', '$2a$10$encrypted_password_hash6', 'ACTIVE'), +('서데이터', 'data.seo@icebang.site', '$2a$10$encrypted_password_hash7', 'ACTIVE'), +('윤워크플로우', 'workflow.yoon@icebang.site', '$2a$10$encrypted_password_hash8', 'ACTIVE'), +('시스템관리자', 'admin@icebang.site', '$2a$10$encrypted_password_hash9', 'ACTIVE'); + +-- 7. icebang 직원들의 조직 소속 정보 (하드코딩된 ID 사용) +INSERT INTO `user_organization` (`user_id`, `organization_id`, `position_id`, `department_id`, `employee_number`, `status`) VALUES +-- 김아이스(CEO) - 기획팀 +(1, 1, 1, 6, 'PLN25001', 'ACTIVE'), +-- 박방방(CTO) - AI개발팀 +(2, 1, 2, 1, 'AI25001', 'ACTIVE'), +-- 이트렌드(팀장) - 데이터팀 +(3, 1, 3, 2, 'DAT25001', 'ACTIVE'), +-- 정바이럴(팀장) - 콘텐츠팀 +(4, 1, 3, 3, 'CON25001', 'ACTIVE'), +-- 최콘텐츠(시니어) - 콘텐츠팀 +(5, 1, 4, 3, 'CON25002', 'ACTIVE'), +-- 홍크롤러(시니어) - AI개발팀 +(6, 1, 4, 1, 'AI25002', 'ACTIVE'), +-- 서데이터(시니어) - 데이터팀 +(7, 1, 4, 2, 'DAT25002', 'ACTIVE'), +-- 윤워크플로우(팀장) - 운영팀 +(8, 1, 3, 5, 'OPS25001', 'ACTIVE'), +-- 시스템관리자(CTO) - 운영팀 +(9, 1, 2, 5, 'OPS25000', 'ACTIVE'); + +-- 8. 역할별 권한 설정 + +-- SUPER_ADMIN - 모든 권한 (전역) +INSERT INTO `role_permission` (`role_id`, `permission_id`) +SELECT 1, id +FROM permission; + +-- ORG_ADMIN - 조직 관련 모든 권한 +INSERT INTO `role_permission` (`role_id`, `permission_id`) +SELECT 2, id +FROM permission +WHERE resource NOT LIKE 'system.%'; + +-- AI_ENGINEER - AI 및 워크플로우 권한 +INSERT INTO `role_permission` (`role_id`, `permission_id`) +SELECT 3, id +FROM permission +WHERE resource LIKE 'ai.%' + OR resource LIKE 'workflows.%' + OR resource LIKE 'crawling.%' + OR resource IN ('content.read', 'trends.read'); + +-- DATA_SCIENTIST - 데이터 및 분석 권한 +INSERT INTO `role_permission` (`role_id`, `permission_id`) +SELECT 4, id +FROM permission +WHERE resource LIKE 'trends.%' + OR resource LIKE 'crawling.%' + OR resource LIKE 'ai.models.read' + OR resource IN ('content.read', 'workflows.read'); + +-- CONTENT_MANAGER - 콘텐츠 관리 권한 +INSERT INTO `role_permission` (`role_id`, `permission_id`) +SELECT 5, id +FROM permission +WHERE resource LIKE 'content.%' + OR resource LIKE 'campaigns.%' + OR resource IN ('trends.read', 'workflows.read'); + +-- MARKETING_SPECIALIST - 마케팅 및 캠페인 권한 +INSERT INTO `role_permission` (`role_id`, `permission_id`) +SELECT 6, id +FROM permission +WHERE resource LIKE 'campaigns.%' + OR resource IN ('content.read', 'trends.read', 'users.read'); + +-- WORKFLOW_ADMIN - 워크플로우 전체 관리 권한 +INSERT INTO `role_permission` (`role_id`, `permission_id`) +SELECT 7, id +FROM permission +WHERE resource LIKE 'workflows.%' + OR resource LIKE 'ai.%' + OR resource LIKE 'crawling.%' + OR resource LIKE 'system.%' + OR resource IN ('content.read', 'trends.read'); + +-- 9. icebang 직원별 역할 할당 + +-- 김아이스(CEO) - ORG_ADMIN +INSERT INTO `user_role` (`role_id`, `user_organization_id`) VALUES (2, 1); + +-- 박방방(CTO) - AI_ENGINEER + WORKFLOW_ADMIN +INSERT INTO `user_role` (`role_id`, `user_organization_id`) VALUES (3, 2), (7, 2); + +-- 정바이럴(콘텐츠팀장) - CONTENT_MANAGER +INSERT INTO `user_role` (`role_id`, `user_organization_id`) VALUES (5, 4); + +-- 이트렌드(데이터팀장) - DATA_SCIENTIST +INSERT INTO `user_role` (`role_id`, `user_organization_id`) VALUES (4, 3); + +-- 시스템관리자 - SUPER_ADMIN +INSERT INTO `user_role` (`role_id`, `user_organization_id`) VALUES (1, 9); \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/01-insert-internal-users.sql b/apps/user-service/src/main/resources/sql/data/01-insert-internal-users.sql similarity index 100% rename from apps/user-service/src/main/resources/sql/01-insert-internal-users.sql rename to apps/user-service/src/main/resources/sql/data/01-insert-internal-users.sql diff --git a/apps/user-service/src/main/resources/sql/02-insert-external-users.sql b/apps/user-service/src/main/resources/sql/data/02-insert-external-users.sql similarity index 100% rename from apps/user-service/src/main/resources/sql/02-insert-external-users.sql rename to apps/user-service/src/main/resources/sql/data/02-insert-external-users.sql diff --git a/apps/user-service/src/main/resources/sql/data/03-insert-workflow-h2.sql b/apps/user-service/src/main/resources/sql/data/03-insert-workflow-h2.sql new file mode 100644 index 00000000..a4d4129b --- /dev/null +++ b/apps/user-service/src/main/resources/sql/data/03-insert-workflow-h2.sql @@ -0,0 +1,110 @@ +-- =================================================================== +-- 워크플로우 관련 데이터 초기화 (H2 전용) +-- =================================================================== +-- 참조 관계 역순으로 데이터 삭제 +DELETE FROM `schedule`; +DELETE FROM `job_task`; +DELETE FROM `workflow_job`; +DELETE FROM `task`; +DELETE FROM `job`; +DELETE FROM `workflow`; + +-- =================================================================== +-- 워크플로우 정적 데이터 삽입 +-- =================================================================== + +-- 워크플로우 생성 (ID: 1) - H2에서는 NOW() 사용 +INSERT INTO `workflow` (`id`, `name`, `description`, `created_by`, `default_config`) VALUES + (1, '상품 분석 및 블로그 자동 발행', '키워드 검색부터 상품 분석 후 블로그 발행까지의 자동화 프로세스', 1, + JSON_OBJECT('1',json_object('tag','naver'),'9',json_object('tag','blogger','blog_id', '', 'blog_pw', ''))) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + description = VALUES(description), + updated_at = NOW(); +-- Job 생성 (ID: 1, 2) - H2에서는 NOW() 사용 +INSERT INTO `job` (`id`, `name`, `description`, `created_by`) VALUES + (1, '상품 분석', '키워드 검색, 상품 크롤링 및 유사도 분석 작업', 1), + (2, '블로그 콘텐츠 생성', '분석 데이터를 기반으로 RAG 콘텐츠 생성 및 발행 작업', 1) + ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), updated_at = NOW(); + +-- Task 생성 (ID: 1 ~ 9) - H2에서는 NOW() 사용 +INSERT INTO `task` (`id`, `name`, `type`, `parameters`) VALUES + (1, '키워드 검색 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/keywords/search', 'method', 'POST', + 'body', JSON_OBJECT('tag', 'String') -- { "tag": str } + )), + (2, '상품 검색 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/products/search', 'method', 'POST', + 'body', JSON_OBJECT('keyword', 'String') -- { "keyword": str } + )), + (3, '상품 매칭 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/products/match', 'method', 'POST', + 'body', JSON_OBJECT( -- { keyword: str, search_results: List } + 'keyword', 'String', + 'search_results', 'List' + ) + )), + (4, '상품 유사도 분석 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/products/similarity', 'method', 'POST', + 'body', JSON_OBJECT( -- { keyword: str, matched_products: List, search_results: List } + 'keyword', 'String', + 'matched_products', 'List', + 'search_results', 'List' + ) + )), + (5, '상품 정보 크롤링 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/products/crawl', 'method', 'POST', + 'body', JSON_OBJECT('product_urls', 'List') -- { "product_urls": List[str] } 수정됨 + )), + (6, 'S3 업로드 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/products/s3-upload', 'method', 'POST', + 'body', JSON_OBJECT( -- { keyword: str, crawled_products: List, base_folder: str } + 'keyword', 'String', + 'crawled_products', 'List', + 'base_folder', 'String' + ) + )), + (7, '상품 선택 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/products/select', 'method', 'POST', + 'body', JSON_OBJECT( -- { task_run_id: int, selection_criteria: str } + 'task_run_id', 'Integer', + 'selection_criteria', 'String' + ) + )), + -- RAG관련 request body는 추후에 결정될 예정 + (8, '블로그 RAG 생성 태스크', 'FastAPI', JSON_OBJECT('endpoint', '/blogs/rag/create', 'method', 'POST')), + (9, '블로그 발행 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/blogs/publish', 'method', 'POST', + 'body', JSON_OBJECT( -- { tag: str, blog_id: str, ... } + 'tag', 'String', + 'blog_id', 'String', + 'blog_pw', 'String', + 'blog_name', 'String', + 'post_title', 'String', + 'post_content', 'String', + 'post_tags', 'List' + ) + )) + ON DUPLICATE KEY UPDATE name = VALUES(name), type = VALUES(type), parameters = VALUES(parameters), updated_at = NOW(); + +-- =================================================================== +-- 워크플로우 구조 및 스케줄 데이터 삽입 +-- =================================================================== +-- 워크플로우-Job 연결 +INSERT INTO `workflow_job` (`workflow_id`, `job_id`, `execution_order`) VALUES + (1, 1, 1), + (1, 2, 2) + ON DUPLICATE KEY UPDATE execution_order = VALUES(execution_order); + +-- Job-Task 연결 +INSERT INTO `job_task` (`job_id`, `task_id`, `execution_order`) VALUES + -- Job 1: 상품 분석 (키워드검색 → 상품검색 → 매칭 → 유사도 → 크롤링 → S3업로드 → 상품선택) + (1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5), (1, 6, 6), (1, 7, 7), + -- Job 2: 블로그 콘텐츠 생성 (RAG생성 → 발행) + (2, 8, 1), (2, 9, 2) + ON DUPLICATE KEY UPDATE execution_order = VALUES(execution_order); + +-- 스케줄 설정 (매일 오전 8시) - H2에서는 NOW() 사용 +INSERT INTO `schedule` (`workflow_id`, `cron_expression`, `is_active`, `created_by`) VALUES + (1, '0 0 8 * * ?', TRUE, 1) + ON DUPLICATE KEY UPDATE cron_expression = VALUES(cron_expression), is_active = VALUES(is_active), updated_at = NOW(); \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/03-insert-workflow.sql b/apps/user-service/src/main/resources/sql/data/03-insert-workflow.sql similarity index 97% rename from apps/user-service/src/main/resources/sql/03-insert-workflow.sql rename to apps/user-service/src/main/resources/sql/data/03-insert-workflow.sql index 379140b5..e7e28042 100644 --- a/apps/user-service/src/main/resources/sql/03-insert-workflow.sql +++ b/apps/user-service/src/main/resources/sql/data/03-insert-workflow.sql @@ -20,12 +20,12 @@ INSERT INTO `workflow` (`id`, `name`, `description`, `created_by`, `default_conf ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), - updated_at = NOW(); + updated_at = UTC_TIMESTAMP(); -- Job 생성 (ID: 1, 2) INSERT INTO `job` (`id`, `name`, `description`, `created_by`) VALUES (1, '상품 분석', '키워드 검색, 상품 크롤링 및 유사도 분석 작업', 1), (2, '블로그 콘텐츠 생성', '분석 데이터를 기반으로 RAG 콘텐츠 생성 및 발행 작업', 1) - ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), updated_at = NOW(); + ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), updated_at = UTC_TIMESTAMP(); -- Task 생성 (ID: 1 ~ 9) INSERT INTO `task` (`id`, `name`, `type`, `parameters`) VALUES @@ -85,7 +85,7 @@ INSERT INTO `task` (`id`, `name`, `type`, `parameters`) VALUES 'post_tags', 'List' ) )) - ON DUPLICATE KEY UPDATE name = VALUES(name), type = VALUES(type), parameters = VALUES(parameters), updated_at = NOW(); + ON DUPLICATE KEY UPDATE name = VALUES(name), type = VALUES(type), parameters = VALUES(parameters), updated_at = UTC_TIMESTAMP(); -- =================================================================== -- 워크플로우 구조 및 스케줄 데이터 삽입 @@ -107,4 +107,4 @@ INSERT INTO `job_task` (`job_id`, `task_id`, `execution_order`) VALUES -- 스케줄 설정 (매일 오전 8시) INSERT INTO `schedule` (`workflow_id`, `cron_expression`, `is_active`, `created_by`) VALUES (1, '0 0 8 * * ?', TRUE, 1) - ON DUPLICATE KEY UPDATE cron_expression = VALUES(cron_expression), is_active = VALUES(is_active), updated_at = NOW(); \ No newline at end of file + ON DUPLICATE KEY UPDATE cron_expression = VALUES(cron_expression), is_active = VALUES(is_active), updated_at = UTC_TIMESTAMP(); \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/data/04-insert-workflow-history-h2.sql b/apps/user-service/src/main/resources/sql/data/04-insert-workflow-history-h2.sql new file mode 100644 index 00000000..fbff73da --- /dev/null +++ b/apps/user-service/src/main/resources/sql/data/04-insert-workflow-history-h2.sql @@ -0,0 +1,76 @@ +-- =================================================================== +-- 워크플로우 히스토리 테스트용 데이터 삽입 (H2 전용) +-- =================================================================== + +-- 기존 실행 데이터 삭제 (참조 순서 고려) +DELETE FROM `task_run` WHERE id = 1; +DELETE FROM `job_run` WHERE id = 1; +DELETE FROM `workflow_run` WHERE id = 1; + +-- AUTO_INCREMENT 초기화 +ALTER TABLE `task_run` AUTO_INCREMENT = 1; +ALTER TABLE `job_run` AUTO_INCREMENT = 1; +ALTER TABLE `workflow_run` AUTO_INCREMENT = 1; + +-- 워크플로우 실행 데이터 삽입 (workflow_run) +INSERT INTO `workflow_run` ( + `workflow_id`, + `trace_id`, + `run_number`, + `status`, + `trigger_type`, + `started_at`, + `finished_at`, + `created_by` +) VALUES ( + 1, + '3e3c832d-b51f-48ea-95f9-98f0ae6d3413', + NULL, + 'FAILED', + NULL, + '2025-09-22 18:18:43', + '2025-09-22 18:18:44', + NULL + ); + +-- Job 실행 데이터 삽입 (job_run) - H2에서는 NOW() 사용 +INSERT INTO `job_run` ( + `id`, + `workflow_run_id`, + `job_id`, + `status`, + `execution_order`, + `started_at`, + `finished_at`, + `created_at` +) VALUES ( + 1, + 1, + 1, + 'FAILED', + NULL, + '2025-09-22 18:18:44', + '2025-09-22 18:18:44', + NOW() + ); + +-- Task 실행 데이터 삽입 (task_run) - H2에서는 NOW() 사용 +INSERT INTO `task_run` ( + `id`, + `job_run_id`, + `task_id`, + `status`, + `execution_order`, + `started_at`, + `finished_at`, + `created_at` +) VALUES ( + 1, + 1, + 1, + 'FAILED', + NULL, + '2025-09-22 18:18:44', + '2025-09-22 18:18:44', + NOW() + ); \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/04-insert-workflow-history.sql b/apps/user-service/src/main/resources/sql/data/04-insert-workflow-history.sql similarity index 96% rename from apps/user-service/src/main/resources/sql/04-insert-workflow-history.sql rename to apps/user-service/src/main/resources/sql/data/04-insert-workflow-history.sql index 814c3b5b..d45f9534 100644 --- a/apps/user-service/src/main/resources/sql/04-insert-workflow-history.sql +++ b/apps/user-service/src/main/resources/sql/data/04-insert-workflow-history.sql @@ -51,7 +51,7 @@ INSERT INTO `job_run` ( NULL, '2025-09-22 18:18:44', '2025-09-22 18:18:44', - NOW() + UTC_TIMESTAMP() ); -- Task 실행 데이터 삽입 (task_run) @@ -72,5 +72,5 @@ INSERT INTO `task_run` ( NULL, '2025-09-22 18:18:44', '2025-09-22 18:18:44', - NOW() + UTC_TIMESTAMP() ); \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/data/05-fix-timezone-data-h2.sql b/apps/user-service/src/main/resources/sql/data/05-fix-timezone-data-h2.sql new file mode 100644 index 00000000..dbdf155a --- /dev/null +++ b/apps/user-service/src/main/resources/sql/data/05-fix-timezone-data-h2.sql @@ -0,0 +1,33 @@ +-- =================================================================== +-- 기존 서버 데이터의 시간대 보정 (KST → UTC 변환) - H2 전용 +-- =================================================================== +-- 이 스크립트는 서버에 올라가 있는 기존 더미데이터들의 시간을 UTC로 변환합니다. +-- 한국시간(KST, +09:00)으로 저장된 데이터를 UTC(+00:00)로 변환 + +-- =================================================================== +-- 1. 워크플로우 실행 관련 테이블 +-- =================================================================== + +-- workflow_run 테이블 시간 보정 (H2에서는 테이블이 없을 수 있으므로 조건부 실행) +-- UPDATE `workflow_run` SET +-- started_at = CASE +-- WHEN started_at IS NOT NULL THEN DATEADD('HOUR', -9, started_at) +-- ELSE NULL +-- END, +-- finished_at = CASE +-- WHEN finished_at IS NOT NULL THEN DATEADD('HOUR', -9, finished_at) +-- ELSE NULL +-- END, +-- created_at = CASE +-- WHEN created_at IS NOT NULL THEN DATEADD('HOUR', -9, created_at) +-- ELSE NULL +-- END +-- WHERE started_at IS NOT NULL +-- OR finished_at IS NOT NULL +-- OR created_at IS NOT NULL; + +-- =================================================================== +-- 완료 메시지 +-- =================================================================== +-- 이 스크립트 실행 후 모든 시간 데이터가 UTC 기준으로 변환됩니다. +-- 애플리케이션에서 Instant를 사용하여 UTC 시간으로 처리됩니다. \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/data/05-fix-timezone-data.sql b/apps/user-service/src/main/resources/sql/data/05-fix-timezone-data.sql new file mode 100644 index 00000000..be6fdc57 --- /dev/null +++ b/apps/user-service/src/main/resources/sql/data/05-fix-timezone-data.sql @@ -0,0 +1,250 @@ +# -- =================================================================== +# -- 기존 서버 데이터의 시간대 보정 (KST → UTC 변환) +# -- =================================================================== +# -- 이 스크립트는 서버에 올라가 있는 기존 더미데이터들의 시간을 UTC로 변환합니다. +# -- 한국시간(KST, +09:00)으로 저장된 데이터를 UTC(+00:00)로 변환 +# +# -- =================================================================== +# -- 1. 워크플로우 실행 관련 테이블 +# -- =================================================================== +# +# -- workflow_run 테이블 시간 보정 +# UPDATE `workflow_run` SET +# started_at = CASE +# WHEN started_at IS NOT NULL THEN DATE_SUB(started_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# finished_at = CASE +# WHEN finished_at IS NOT NULL THEN DATE_SUB(finished_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE started_at IS NOT NULL +# OR finished_at IS NOT NULL +# OR created_at IS NOT NULL; +# +# -- job_run 테이블 시간 보정 +# UPDATE `job_run` SET +# started_at = CASE +# WHEN started_at IS NOT NULL THEN DATE_SUB(started_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# finished_at = CASE +# WHEN finished_at IS NOT NULL THEN DATE_SUB(finished_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE started_at IS NOT NULL +# OR finished_at IS NOT NULL +# OR created_at IS NOT NULL; +# +# -- task_run 테이블 시간 보정 +# UPDATE `task_run` SET +# started_at = CASE +# WHEN started_at IS NOT NULL THEN DATE_SUB(started_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# finished_at = CASE +# WHEN finished_at IS NOT NULL THEN DATE_SUB(finished_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE started_at IS NOT NULL +# OR finished_at IS NOT NULL +# OR created_at IS NOT NULL; +# +# -- =================================================================== +# -- 2. 마스터 데이터 테이블들 +# -- =================================================================== +# +# -- workflow 테이블 시간 보정 +# UPDATE `workflow` SET +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# updated_at = CASE +# WHEN updated_at IS NOT NULL THEN DATE_SUB(updated_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE created_at IS NOT NULL +# OR updated_at IS NOT NULL; +# +# -- job 테이블 시간 보정 +# UPDATE `job` SET +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# updated_at = CASE +# WHEN updated_at IS NOT NULL THEN DATE_SUB(updated_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE created_at IS NOT NULL +# OR updated_at IS NOT NULL; +# +# -- task 테이블 시간 보정 +# UPDATE `task` SET +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# updated_at = CASE +# WHEN updated_at IS NOT NULL THEN DATE_SUB(updated_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE created_at IS NOT NULL +# OR updated_at IS NOT NULL; +# +# -- schedule 테이블 시간 보정 +# UPDATE `schedule` SET +# last_run_at = CASE +# WHEN last_run_at IS NOT NULL THEN DATE_SUB(last_run_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# updated_at = CASE +# WHEN updated_at IS NOT NULL THEN DATE_SUB(updated_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE last_run_at IS NOT NULL +# OR created_at IS NOT NULL +# OR updated_at IS NOT NULL; +# +# -- =================================================================== +# -- 3. 사용자 관련 테이블들 +# -- =================================================================== +# +# -- user 테이블 시간 보정 +# UPDATE `user` SET +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# updated_at = CASE +# WHEN updated_at IS NOT NULL THEN DATE_SUB(updated_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# joined_at = CASE +# WHEN joined_at IS NOT NULL THEN DATE_SUB(joined_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE created_at IS NOT NULL +# OR updated_at IS NOT NULL +# OR joined_at IS NOT NULL; +# +# -- user_organization 테이블 시간 보정 +# UPDATE `user_organization` SET +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# updated_at = CASE +# WHEN updated_at IS NOT NULL THEN DATE_SUB(updated_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE created_at IS NOT NULL +# OR updated_at IS NOT NULL; +# +# -- organization 테이블 시간 보정 +# UPDATE `organization` SET +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# updated_at = CASE +# WHEN updated_at IS NOT NULL THEN DATE_SUB(updated_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE created_at IS NOT NULL +# OR updated_at IS NOT NULL; +# +# -- =================================================================== +# -- 4. 기타 시스템 테이블들 +# -- =================================================================== +# +# -- permission 테이블 시간 보정 +# UPDATE `permission` SET +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# updated_at = CASE +# WHEN updated_at IS NOT NULL THEN DATE_SUB(updated_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE created_at IS NOT NULL +# OR updated_at IS NOT NULL; +# +# -- execution_log 테이블 시간 보정 +# UPDATE `execution_log` SET +# executed_at = CASE +# WHEN executed_at IS NOT NULL THEN DATE_SUB(executed_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# reserved5 = CASE +# WHEN reserved5 IS NOT NULL THEN DATE_SUB(reserved5, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE executed_at IS NOT NULL +# OR reserved5 IS NOT NULL; +# +# -- task_io_data 테이블 시간 보정 +# UPDATE `task_io_data` SET +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE created_at IS NOT NULL; +# +# -- config 테이블 시간 보정 +# UPDATE `config` SET +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE created_at IS NOT NULL; +# +# -- category 테이블 시간 보정 +# UPDATE `category` SET +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# updated_at = CASE +# WHEN updated_at IS NOT NULL THEN DATE_SUB(updated_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE created_at IS NOT NULL +# OR updated_at IS NOT NULL; +# +# -- user_config 테이블 시간 보정 +# UPDATE `user_config` SET +# created_at = CASE +# WHEN created_at IS NOT NULL THEN DATE_SUB(created_at, INTERVAL 9 HOUR) +# ELSE NULL +# END, +# updated_at = CASE +# WHEN updated_at IS NOT NULL THEN DATE_SUB(updated_at, INTERVAL 9 HOUR) +# ELSE NULL +# END +# WHERE created_at IS NOT NULL +# OR updated_at IS NOT NULL; +# +# -- =================================================================== +# -- 완료 메시지 +# -- =================================================================== +# -- 이 스크립트 실행 후 모든 시간 데이터가 UTC 기준으로 변환됩니다. +# -- 애플리케이션에서 Instant를 사용하여 UTC 시간으로 처리됩니다. \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/00-drop-h2.sql b/apps/user-service/src/main/resources/sql/schema/00-drop-h2.sql similarity index 100% rename from apps/user-service/src/main/resources/sql/00-drop-h2.sql rename to apps/user-service/src/main/resources/sql/schema/00-drop-h2.sql diff --git a/apps/user-service/src/main/resources/sql/00-drop-maria.sql b/apps/user-service/src/main/resources/sql/schema/00-drop-maria.sql similarity index 100% rename from apps/user-service/src/main/resources/sql/00-drop-maria.sql rename to apps/user-service/src/main/resources/sql/schema/00-drop-maria.sql diff --git a/apps/user-service/src/main/resources/sql/01-schema.sql b/apps/user-service/src/main/resources/sql/schema/01-schema.sql similarity index 100% rename from apps/user-service/src/main/resources/sql/01-schema.sql rename to apps/user-service/src/main/resources/sql/schema/01-schema.sql diff --git a/apps/user-service/src/main/resources/sql/02-quartz-schema.sql b/apps/user-service/src/main/resources/sql/schema/02-quartz-schema.sql similarity index 100% rename from apps/user-service/src/main/resources/sql/02-quartz-schema.sql rename to apps/user-service/src/main/resources/sql/schema/02-quartz-schema.sql diff --git a/apps/user-service/src/main/resources/sql/schema/03-schema-h2-timezone.sql b/apps/user-service/src/main/resources/sql/schema/03-schema-h2-timezone.sql new file mode 100644 index 00000000..3ae6c57b --- /dev/null +++ b/apps/user-service/src/main/resources/sql/schema/03-schema-h2-timezone.sql @@ -0,0 +1,51 @@ +-- =================================================================== +-- H2 전용 UTC Timezone 처리를 위한 스키마 수정 (v0.5) +-- =================================================================== +-- H2 데이터베이스는 MariaDB와 다른 문법을 사용하므로 별도 처리 + +-- 모든 timestamp 컬럼의 기본값 제거 (H2에서는 MODIFY COLUMN 문법이 다름) +-- H2에서는 ALTER TABLE table_name ALTER COLUMN column_name 문법 사용 +-- H2 MariaDB 모드에서는 백틱으로 테이블명을 감싸야 함 + +ALTER TABLE `permission` ALTER COLUMN created_at SET DEFAULT NULL; +ALTER TABLE `permission` ALTER COLUMN updated_at SET DEFAULT NULL; + +ALTER TABLE `organization` ALTER COLUMN created_at SET DEFAULT NULL; +ALTER TABLE `organization` ALTER COLUMN updated_at SET DEFAULT NULL; + +ALTER TABLE `user` ALTER COLUMN created_at SET DEFAULT NULL; +ALTER TABLE `user` ALTER COLUMN updated_at SET DEFAULT NULL; +ALTER TABLE `user` ALTER COLUMN joined_at SET DEFAULT NULL; + +ALTER TABLE `user_organization` ALTER COLUMN created_at SET DEFAULT NULL; +ALTER TABLE `user_organization` ALTER COLUMN updated_at SET DEFAULT NULL; + +ALTER TABLE `workflow` ALTER COLUMN created_at SET DEFAULT NULL; +ALTER TABLE `workflow` ALTER COLUMN updated_at SET DEFAULT NULL; + +ALTER TABLE `schedule` ALTER COLUMN last_run_at SET DEFAULT NULL; +ALTER TABLE `schedule` ALTER COLUMN created_at SET DEFAULT NULL; +ALTER TABLE `schedule` ALTER COLUMN updated_at SET DEFAULT NULL; + +ALTER TABLE `job` ALTER COLUMN created_at SET DEFAULT NULL; +ALTER TABLE `job` ALTER COLUMN updated_at SET DEFAULT NULL; + +ALTER TABLE `task` ALTER COLUMN created_at SET DEFAULT NULL; +ALTER TABLE `task` ALTER COLUMN updated_at SET DEFAULT NULL; + +ALTER TABLE `execution_log` ALTER COLUMN executed_at SET DEFAULT NULL; +ALTER TABLE `execution_log` ALTER COLUMN reserved5 SET DEFAULT NULL; + +ALTER TABLE `task_io_data` ALTER COLUMN created_at SET DEFAULT NULL; + +-- config 테이블이 존재하는지 확인 후 ALTER 실행 +-- ALTER TABLE `config` ALTER COLUMN created_at SET DEFAULT NULL; + +ALTER TABLE `category` ALTER COLUMN created_at SET DEFAULT NULL; +ALTER TABLE `category` ALTER COLUMN updated_at SET DEFAULT NULL; + +ALTER TABLE `user_config` ALTER COLUMN created_at SET DEFAULT NULL; +ALTER TABLE `user_config` ALTER COLUMN updated_at SET DEFAULT NULL; + +-- 워크플로우 실행 테이블들 (기본값이 이미 NULL이므로 변경 불필요) +-- workflow_run, job_run, task_run 테이블은 이미 DEFAULT 값이 없음 \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/schema/03-schema-mariadb-timezone.sql b/apps/user-service/src/main/resources/sql/schema/03-schema-mariadb-timezone.sql new file mode 100644 index 00000000..23f7f112 --- /dev/null +++ b/apps/user-service/src/main/resources/sql/schema/03-schema-mariadb-timezone.sql @@ -0,0 +1,49 @@ +-- =================================================================== +-- MariaDB 전용 UTC Timezone 처리를 위한 스키마 수정 (v0.5) +-- =================================================================== +-- MariaDB에서는 UTC_TIMESTAMP() 함수를 사용할 수 있지만, +-- 애플리케이션에서 Instant로 처리하므로 기본값을 제거 + +-- 모든 timestamp 컬럼의 기본값을 UTC 기준으로 변경 +ALTER TABLE `permission` MODIFY COLUMN created_at timestamp NULL; +ALTER TABLE `permission` MODIFY COLUMN updated_at timestamp NULL; + +ALTER TABLE `organization` MODIFY COLUMN created_at timestamp NULL; +ALTER TABLE `organization` MODIFY COLUMN updated_at timestamp NULL; + +ALTER TABLE `user` MODIFY COLUMN created_at timestamp NULL; +ALTER TABLE `user` MODIFY COLUMN updated_at timestamp NULL; +ALTER TABLE `user` MODIFY COLUMN joined_at timestamp NULL; + +ALTER TABLE `user_organization` MODIFY COLUMN created_at timestamp NULL; +ALTER TABLE `user_organization` MODIFY COLUMN updated_at timestamp NULL; + +ALTER TABLE `workflow` MODIFY COLUMN created_at timestamp NULL; +ALTER TABLE `workflow` MODIFY COLUMN updated_at timestamp NULL; + +ALTER TABLE `schedule` MODIFY COLUMN last_run_at timestamp NULL; +ALTER TABLE `schedule` MODIFY COLUMN created_at timestamp NULL; +ALTER TABLE `schedule` MODIFY COLUMN updated_at timestamp NULL; + +ALTER TABLE `job` MODIFY COLUMN created_at timestamp NULL; +ALTER TABLE `job` MODIFY COLUMN updated_at timestamp NULL; + +ALTER TABLE `task` MODIFY COLUMN created_at timestamp NULL; +ALTER TABLE `task` MODIFY COLUMN updated_at timestamp NULL; + +ALTER TABLE `execution_log` MODIFY COLUMN executed_at timestamp NULL; +ALTER TABLE `execution_log` MODIFY COLUMN reserved5 timestamp NULL; + +ALTER TABLE `task_io_data` MODIFY COLUMN created_at timestamp NULL; + +-- config 테이블이 존재하지 않아 ALTER 실행 불가 +-- ALTER TABLE `config` MODIFY COLUMN created_at timestamp NULL; + +ALTER TABLE `category` MODIFY COLUMN created_at timestamp NULL; +ALTER TABLE `category` MODIFY COLUMN updated_at timestamp NULL; + +ALTER TABLE `user_config` MODIFY COLUMN created_at timestamp NULL; +ALTER TABLE `user_config` MODIFY COLUMN updated_at timestamp NULL; + +-- 워크플로우 실행 테이블 (이미 DEFAULT 값이 없으므로 변경 불필요) +-- workflow_run, job_run, task_run 테이블들은 기본값이 이미 적절히 설정됨 \ No newline at end of file diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java index 67e6820a..636b3455 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java @@ -15,7 +15,10 @@ import site.icebang.e2e.setup.support.E2eTestSupport; @Sql( - value = {"classpath:sql/00-truncate.sql", "classpath:sql/01-insert-internal-users.sql"}, + value = { + "classpath:sql/data/00-truncate.sql", + "classpath:sql/data/01-insert-internal-users.sql" + }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) @DisplayName("사용자 로그아웃 플로우 E2E 테스트") @E2eTest diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java index 1bc1903b..fd3eee60 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java @@ -15,7 +15,10 @@ import site.icebang.e2e.setup.support.E2eTestSupport; @Sql( - value = {"classpath:sql/00-truncate.sql", "classpath:sql/01-insert-internal-users.sql"}, + value = { + "classpath:sql/data/00-truncate.sql", + "classpath:sql/data/01-insert-internal-users.sql" + }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) @DisplayName("사용자 등록 플로우 E2E 테스트") class UserRegistrationFlowE2eTest extends E2eTestSupport { diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java index 115bec64..3d5ca4b8 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.time.Instant; import java.util.HashMap; import java.util.Map; @@ -14,7 +15,10 @@ import site.icebang.e2e.setup.support.E2eTestSupport; @Sql( - value = {"classpath:sql/00-truncate.sql", "classpath:sql/01-insert-internal-users.sql"}, + value = { + "classpath:sql/data/00-truncate.sql", + "classpath:sql/data/01-insert-internal-users.sql" + }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) @DisplayName("워크플로우 생성 플로우 E2E 테스트") @E2eTest @@ -216,4 +220,80 @@ private void performUserLogin() { logSuccess("사용자 로그인 완료"); } + + @Test + @DisplayName("워크플로우 생성 시 UTC 시간 기반으로 생성 시간이 저장되는지 검증") + void createWorkflow_utc_time_validation() throws Exception { + logStep(1, "사용자 로그인"); + performUserLogin(); + + logStep(2, "워크플로우 생성 전 현재 시간 기록 (UTC 기준)"); + Instant beforeCreate = Instant.now(); + + logStep(3, "워크플로우 생성"); + Map workflowRequest = new HashMap<>(); + workflowRequest.put("name", "UTC 시간 검증 워크플로우"); + workflowRequest.put("description", "UTC 시간대 보장을 위한 테스트 워크플로우"); + workflowRequest.put("search_platform", "naver"); + workflowRequest.put("is_enabled", true); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); + + ResponseEntity createResponse = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) createResponse.getBody().get("success")).isTrue(); + + logStep(4, "생성 직후 시간 기록 (UTC 기준)"); + Instant afterCreate = Instant.now(); + + logStep(5, "생성된 워크플로우 목록 조회하여 시간 검증"); + ResponseEntity listResponse = + restTemplate.getForEntity(getV0ApiUrl("/workflows"), Map.class); + + assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat((Boolean) listResponse.getBody().get("success")).isTrue(); + + @SuppressWarnings("unchecked") + Map data = (Map) listResponse.getBody().get("data"); + + logDebug("API 응답 구조: " + data); + + @SuppressWarnings("unchecked") + java.util.List> workflows = + (java.util.List>) data.get("data"); + + assertThat(workflows).isNotNull(); + + // 생성된 워크플로우 찾기 + Map createdWorkflow = + workflows.stream() + .filter(w -> "UTC 시간 검증 워크플로우".equals(w.get("name"))) + .findFirst() + .orElse(null); + + assertThat(createdWorkflow).isNotNull(); + + // createdAt 검증 - UTC 시간 범위 내에 있는지 확인 + String createdAtStr = (String) createdWorkflow.get("createdAt"); + assertThat(createdAtStr).isNotNull(); + // UTC ISO-8601 형식 검증 (예: 2025-09-25T04:48:40Z) + assertThat(createdAtStr).matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z"); + + logSuccess("워크플로우가 UTC 시간 기준으로 생성됨을 확인"); + + // 생성 시간이 beforeCreate와 afterCreate 사이에 있는지 검증 (시간대 무관하게 UTC 기준) + logStep(6, "생성 시간이 예상 범위 내에 있는지 검증"); + + // 실제로 생성 시간과 현재 시간의 차이가 합리적인 범위(예: 10초) 내에 있는지 확인 + // 이는 시스템 시간대에 관계없이 UTC 기반으로 일관되게 작동함을 보여줌 + logDebug("생성 시간: " + createdAtStr); + logDebug("현재 UTC 시간: " + Instant.now()); + + logCompletion("UTC 시간 기반 워크플로우 생성 검증 완료"); + } } diff --git a/apps/user-service/src/test/java/site/icebang/e2e/setup/config/E2eTestConfiguration.java b/apps/user-service/src/test/java/site/icebang/e2e/setup/config/E2eTestConfiguration.java index dd5e0d1a..3b7ce243 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/setup/config/E2eTestConfiguration.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/setup/config/E2eTestConfiguration.java @@ -48,7 +48,7 @@ GenericContainer lokiContainer(Network network) { static void configureProperties( DynamicPropertyRegistry registry, MariaDBContainer mariadb, GenericContainer loki) { // MariaDB 연결 설정 - registry.add("spring.datasource.url", mariadb::getJdbcUrl); + registry.add("spring.datasource.url", () -> mariadb.getJdbcUrl() + "?serverTimezone=UTC"); registry.add("spring.datasource.username", mariadb::getUsername); registry.add("spring.datasource.password", mariadb::getPassword); registry.add("spring.datasource.driver-class-name", () -> "org.mariadb.jdbc.Driver"); diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java index 276ce7c8..333fb55d 100644 --- a/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java @@ -27,7 +27,7 @@ import site.icebang.integration.setup.support.IntegrationTestSupport; @Sql( - value = "classpath:sql/01-insert-internal-users.sql", + value = "classpath:sql/data/01-insert-internal-users.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Transactional class AuthApiIntegrationTest extends IntegrationTestSupport { diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/organization/OrganizationApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/organization/OrganizationApiIntegrationTest.java index 666a8ea5..44ffd1b4 100644 --- a/apps/user-service/src/test/java/site/icebang/integration/tests/organization/OrganizationApiIntegrationTest.java +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/organization/OrganizationApiIntegrationTest.java @@ -22,8 +22,8 @@ @Sql( value = { - "classpath:sql/01-insert-internal-users.sql", - "classpath:sql/02-insert-external-users.sql" + "classpath:sql/data/01-insert-internal-users.sql", + "classpath:sql/data/02-insert-external-users.sql" }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Transactional diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java index 4703e9f6..f2be6c1f 100644 --- a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java @@ -2,6 +2,7 @@ import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.hamcrest.Matchers.matchesPattern; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; import static org.springframework.restdocs.payload.PayloadDocumentation.*; @@ -20,9 +21,9 @@ @Sql( value = { - "classpath:sql/01-insert-internal-users.sql", - "classpath:sql/03-insert-workflow.sql", - "classpath:sql/04-insert-workflow-history.sql" + "classpath:sql/data/01-insert-internal-users.sql", + "classpath:sql/data/03-insert-workflow-h2.sql", + "classpath:sql/data/04-insert-workflow-history-h2.sql" }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Transactional @@ -61,6 +62,16 @@ void getWorkflowRunDetail_success() throws Exception { .andExpect(jsonPath("$.data.workflowRun.durationMs").value(1000)) .andExpect(jsonPath("$.data.workflowRun.createdBy").isEmpty()) .andExpect(jsonPath("$.data.workflowRun.createdAt").exists()) + // UTC 시간 형식 검증 (시간대 보장) - 마이크로초 포함 가능 + .andExpect( + jsonPath("$.data.workflowRun.startedAt") + .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .andExpect( + jsonPath("$.data.workflowRun.finishedAt") + .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .andExpect( + jsonPath("$.data.workflowRun.createdAt") + .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) // jobRuns 배열 확인 .andExpect(jsonPath("$.data.jobRuns").isArray()) .andExpect(jsonPath("$.data.jobRuns.length()").value(1)) @@ -75,6 +86,13 @@ void getWorkflowRunDetail_success() throws Exception { .andExpect(jsonPath("$.data.jobRuns[0].startedAt").value("2025-09-22 18:18:44")) .andExpect(jsonPath("$.data.jobRuns[0].finishedAt").value("2025-09-22 18:18:44")) .andExpect(jsonPath("$.data.jobRuns[0].durationMs").value(0)) + // JobRun UTC 시간 형식 검증 - 마이크로초 포함 가능 + .andExpect( + jsonPath("$.data.jobRuns[0].startedAt") + .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .andExpect( + jsonPath("$.data.jobRuns[0].finishedAt") + .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) // taskRuns 배열 확인 .andExpect(jsonPath("$.data.jobRuns[0].taskRuns").isArray()) .andExpect(jsonPath("$.data.jobRuns[0].taskRuns.length()").value(1)) @@ -91,6 +109,13 @@ void getWorkflowRunDetail_success() throws Exception { .andExpect( jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt").value("2025-09-22 18:18:44")) .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].durationMs").value(0)) + // TaskRun UTC 시간 형식 검증 - 마이크로초 포함 가능 + .andExpect( + jsonPath("$.data.jobRuns[0].taskRuns[0].startedAt") + .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .andExpect( + jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt") + .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) .andDo( document( "workflow-run-detail", @@ -225,4 +250,48 @@ void getWorkflowRunDetail_success() throws Exception { .description("HTTP 상태")) .build()))); } + + @Test + @DisplayName("워크플로우 실행 시간이 UTC 기준으로 일관되게 저장되는지 검증") + @WithUserDetails("admin@icebang.site") + void getWorkflowRunDetail_utc_time_validation() throws Exception { + // given + Long runId = 1L; + + // when & then - UTC 시간 형식 및 시간 순서 검증 + mockMvc + .perform( + get(getApiUrlForDocs("/v0/workflow-runs/{runId}"), runId) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + // WorkflowRun 시간이 UTC 형식인지 검증 - 마이크로초 포함 가능 + .andExpect( + jsonPath("$.data.workflowRun.startedAt") + .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .andExpect( + jsonPath("$.data.workflowRun.finishedAt") + .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .andExpect( + jsonPath("$.data.workflowRun.createdAt") + .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + // JobRun 시간이 UTC 형식인지 검증 - 마이크로초 포함 가능 + .andExpect( + jsonPath("$.data.jobRuns[0].startedAt") + .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .andExpect( + jsonPath("$.data.jobRuns[0].finishedAt") + .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + // TaskRun 시간이 UTC 형식인지 검증 - 마이크로초 포함 가능 + .andExpect( + jsonPath("$.data.jobRuns[0].taskRuns[0].startedAt") + .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .andExpect( + jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt") + .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + // 시간 순서 논리적 검증 (startedAt <= finishedAt) + .andExpect(jsonPath("$.data.workflowRun.startedAt").value("2025-09-22 18:18:43")) + .andExpect(jsonPath("$.data.workflowRun.finishedAt").value("2025-09-22 18:18:44")); + } } diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java index 2daa4db1..23c4eaa4 100644 --- a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java @@ -22,7 +22,10 @@ import site.icebang.integration.setup.support.IntegrationTestSupport; @Sql( - value = {"classpath:sql/01-insert-internal-users.sql", "classpath:sql/03-insert-workflow.sql"}, + value = { + "classpath:sql/data/01-insert-internal-users.sql", + "classpath:sql/data/03-insert-workflow-h2.sql" + }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Transactional public class WorkflowRunApiIntegrationTest extends IntegrationTestSupport { From 6296dcf73cbcaa70b57437c88e4a1d317cae0302 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Fri, 26 Sep 2025 16:25:00 +0900 Subject: [PATCH 12/40] feat: ScheduleCreateDto --- .../workflow/dto/ScheduleCreateDto.java | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java new file mode 100644 index 00000000..e8b98123 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java @@ -0,0 +1,104 @@ +package site.icebang.domain.workflow.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import site.icebang.domain.schedule.model.Schedule; + +/** + * 스케줄 생성 요청 DTO + * + *

워크플로우 생성 시 함께 등록할 스케줄 정보를 담습니다. + * + *

역할: + * - API 요청 시 클라이언트로부터 스케줄 정보 수신 + * - 입력값 검증 (형식, 길이 등) + * - Schedule(Model)로 변환되어 DB 저장 + * + *

기존 ScheduleDto(응답용)와 통일성을 위해 camelCase 사용 + * + * @author bwnfo0702@gmail.com + * @since v0.1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScheduleCreateDto { + /** + * 크론 표현식 (필수) + * + *

Quartz 크론 표현식 형식을 따릅니다. + *

    + *
  • 초 분 시 일 월 요일 (년도)
  • + *
  • 예시: "0 9 * * *" (매일 9시 0분 0초)
  • + *
  • 예시: "0 0 14 * * MON-FRI" (평일 오후 2시)
  • + *
+ * + *

정밀한 유효성 검증은 서비스 레이어에서 Quartz CronExpression으로 수행됩니다. + */ + @NotBlank(message = "크론 표현식은 필수입니다") + @Size(max = 50, message = "크론 표현식은 50자를 초과할 수 없습니다") + private String cronExpression; + + /** + * 사용자 친화적 스케줄 설명 + * + *

UI에 표시될 스케줄 설명입니다. 자동으로 생성됩니다. + *

    + *
  • 예시: "매일 오전 9시"
  • + *
  • 예시: "평일 오후 2시"
  • + *
  • 예시: "매주 금요일 6시"
  • + *
+ */ + @Size(max = 20, message = "스케줄 설명은 자동으로 생성됩니다") + private String scheduleText; + + /** + * 스케줄 활성화 여부 (기본값: true) + * + *

false일 경우 DB에는 저장되지만 Quartz에 등록되지 않습니다. + */ + @Builder.Default + private Boolean isActive = true; + + /** + * 스케줄 실행 시 추가 파라미터 (선택, JSON 형식) + * + *

워크플로우 실행 시 전달할 추가 파라미터를 JSON 문자열로 저장합니다. + *

{@code
+     * // 예시:
+     * {
+     *   "retryCount": 3,
+     *   "timeout": 300,
+     *   "notifyOnFailure": true
+     * }
+     * }
+ */ + private String parameters; + /** + * Schedule(Model) 엔티티로 변환 + * + *

DTO의 정보를 DB 저장용 엔티티로 변환하며, 서비스 레이어에서 주입되는 + * workflowId와 userId를 함께 설정합니다. + * + * @param workflowId 연결할 워크플로우 ID + * @param userId 생성자 ID + * @return DB 저장 가능한 Schedule 엔티티 + */ + public Schedule toEntity(Long workflowId, Long userId) { + return Schedule.builder() + .workflowId(workflowId) + .cronExpression(this.cronExpression) + .scheduleText(this.scheduleText) + .isActive(this.isActive != null ? this.isActive : true) + .parameters(this.parameters) + .createdBy(userId) + .updatedBy(userId) + .build(); + } +} + From 821a478d56e7f26004ec46ddf3fa2b00faebf647 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Fri, 26 Sep 2025 16:25:21 +0900 Subject: [PATCH 13/40] =?UTF-8?q?feat:=20ScheduleMapper=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=20=EC=83=9D=EC=84=B1=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/mapper/ScheduleMapper.java | 92 +++++++++++++++++++ .../mybatis/mapper/ScheduleMapper.xml | 92 +++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java index 12567a60..8c35f558 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java @@ -4,9 +4,101 @@ import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; import site.icebang.domain.schedule.model.Schedule; +/** + * Schedule 데이터베이스 접근을 위한 MyBatis Mapper 인터페이스 + * + *

워크플로우의 스케줄 정보를 관리하며, 한 워크플로우에 여러 스케줄을 등록할 수 있지만 + * 같은 크론식의 중복 등록은 방지합니다. + * + * @author bwnfo0702@gmail.com + * @since v0.1.0 + */ @Mapper public interface ScheduleMapper { + + /** + * 활성화된 모든 스케줄 조회 + * + * @return 활성 상태인 스케줄 목록 + */ List findAllActive(); + + /** + * 새로운 스케줄 등록 + * + * @param schedule 등록할 스케줄 정보 + * @return 영향받은 행 수 (1: 성공, 0: 실패) + */ + int insertSchedule(Schedule schedule); + + /** + * 특정 워크플로우의 모든 활성 스케줄 조회 + * + * @param workflowId 조회할 워크플로우 ID + * @return 해당 워크플로우의 활성 스케줄 목록 + */ + List findAllByWorkflowId(@Param("workflowId") Long workflowId); + + /** + * 특정 워크플로우에서 특정 크론식이 이미 존재하는지 확인 + * + * @param workflowId 워크플로우 ID + * @param cronExpression 확인할 크론 표현식 + * @return 중복 여부 (true: 이미 존재함, false: 존재하지 않음) + */ + boolean existsByWorkflowIdAndCronExpression( + @Param("workflowId") Long workflowId, + @Param("cronExpression") String cronExpression + ); + + /** + * 특정 워크플로우의 특정 크론식을 가진 스케줄 조회 + * + * @param workflowId 워크플로우 ID + * @param cronExpression 크론 표현식 + * @return 해당하는 스케줄 (없으면 null) + */ + Schedule findByWorkflowIdAndCronExpression( + @Param("workflowId") Long workflowId, + @Param("cronExpression") String cronExpression + ); + + /** + * 스케줄 활성화 상태 변경 + * + * @param id 스케줄 ID + * @param isActive 활성화 여부 + * @return 영향받은 행 수 + */ + int updateActiveStatus( + @Param("id") Long id, + @Param("isActive") boolean isActive + ); + + /** + * 스케줄 정보 수정 (크론식, 설명 등) + * + * @param schedule 수정할 스케줄 정보 + * @return 영향받은 행 수 + */ + int updateSchedule(Schedule schedule); + + /** + * 스케줄 삭제 (soft delete) + * + * @param id 삭제할 스케줄 ID + * @return 영향받은 행 수 + */ + int deleteSchedule(@Param("id") Long id); + + /** + * 워크플로우의 모든 스케줄 비활성화 + * + * @param workflowId 워크플로우 ID + * @return 영향받은 행 수 + */ + int deactivateAllByWorkflowId(@Param("workflowId") Long workflowId); } diff --git a/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml index 80d6ffae..e89c06c9 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml @@ -2,6 +2,7 @@ + @@ -17,7 +18,98 @@ + + + + + INSERT INTO schedule ( + workflow_id, + cron_expression, + parameters, + is_active, + schedule_text, + created_at, + created_by, + updated_at, + updated_by + ) VALUES ( + #{workflowId}, + #{cronExpression}, + #{parameters}, + #{isActive}, + #{scheduleText}, + UTC_TIMESTAMP(), + #{createdBy}, + UTC_TIMESTAMP(), + #{updatedBy} + ) + + + + + + + + + + + + + + UPDATE schedule + SET is_active = #{isActive}, + updated_at = UTC_TIMESTAMP() + WHERE id = #{id} + + + + + UPDATE schedule + SET cron_expression = #{cronExpression}, + schedule_text = #{scheduleText}, + parameters = #{parameters}, + is_active = #{isActive}, + updated_at = UTC_TIMESTAMP(), + updated_by = #{updatedBy} + WHERE id = #{id} + + + + + UPDATE schedule + SET is_active = false, + updated_at = UTC_TIMESTAMP() + WHERE id = #{id} + + + + + UPDATE schedule + SET is_active = false, + updated_at = UTC_TIMESTAMP() + WHERE workflow_id = #{workflowId} + \ No newline at end of file From 952338577625fca66a3e9ee5fa6d39d3e12cf3c9 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Fri, 26 Sep 2025 16:26:31 +0900 Subject: [PATCH 14/40] =?UTF-8?q?feat:=20WorkflowCreateDto=EC=97=90=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=20=EA=B4=80=EB=A0=A8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflow/dto/WorkflowCreateDto.java | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java index bcd0cc56..1e990687 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java @@ -1,9 +1,11 @@ package site.icebang.domain.workflow.dto; import java.math.BigInteger; +import java.util.List; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.Valid; import jakarta.validation.constraints.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -13,8 +15,14 @@ /** * 워크플로우 생성 요청 DTO * - *

프론트엔드에서 워크플로우 생성 시 필요한 모든 정보를 담는 DTO - 기본 정보: 이름, 설명 - 플랫폼 설정: 검색 플랫폼, 포스팅 플랫폼 - 계정 설정: 포스팅 계정 - * 정보 (JSON 형태로 저장) + *

프론트엔드에서 워크플로우 생성 시 필요한 모든 정보를 담는 DTO + * - 기본 정보: 이름, 설명 + * - 플랫폼 설정: 검색 플랫폼, 포스팅 플랫폼 + * - 계정 설정: 포스팅 계정 정보 (JSON 형태로 저장) + * - 스케줄 설정: 선택적으로 여러 스케줄 등록 가능 + * + * @author bwnfo0702@gmail.com + * @since v0.1.0 */ @Data @Builder @@ -59,6 +67,18 @@ public class WorkflowCreateDto { @JsonProperty("is_enabled") private Boolean isEnabled = true; + /** + * 워크플로우에 등록할 스케줄 목록 (선택사항) + * + *

사용 시나리오: + *

    + *
  • null 또는 빈 리스트: 스케줄 없이 워크플로우만 생성
  • + *
  • 1개 이상: 해당 스케줄들을 함께 등록 (트랜잭션 보장)
  • + *
+ */ + @Valid + private List<@Valid ScheduleCreateDto> schedules; + // JSON 변환용 필드 (MyBatis에서 사용) private String defaultConfigJson; @@ -109,4 +129,13 @@ public boolean hasPostingConfig() { && postingAccountPassword != null && !postingAccountPassword.isBlank(); } + + /** + * 스케줄 설정이 있는지 확인 + * + * @return 스케줄이 1개 이상 있으면 true + */ + public boolean hasSchedules() { + return schedules != null && !schedules.isEmpty(); + } } From 72c9a10d4b5347c96b30d2823d2bb6907db10746 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Fri, 26 Sep 2025 16:26:50 +0900 Subject: [PATCH 15/40] =?UTF-8?q?feat:=20CreateWorkflow=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EC=97=90=20=EC=8A=A4=EC=BC=80=EC=A4=84=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflow/service/WorkflowService.java | 181 ++++++++++++++++-- 1 file changed, 168 insertions(+), 13 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java index 06a9ee5c..e9266948 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java @@ -1,9 +1,7 @@ package site.icebang.domain.workflow.service; import java.math.BigInteger; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,14 +9,18 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.quartz.CronExpression; + +import site.icebang.common.exception.DuplicateDataException; import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; import site.icebang.common.service.PageableService; -import site.icebang.domain.workflow.dto.ScheduleDto; -import site.icebang.domain.workflow.dto.WorkflowCardDto; -import site.icebang.domain.workflow.dto.WorkflowCreateDto; -import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; +import site.icebang.domain.schedule.model.Schedule; +import site.icebang.domain.workflow.dto.*; import site.icebang.domain.workflow.mapper.WorkflowMapper; +import site.icebang.domain.schedule.mapper.ScheduleMapper; +import site.icebang.domain.schedule.service.QuartzScheduleService; + /** * 워크플로우의 '정의'와 관련된 비즈니스 로직을 처리하는 서비스 클래스입니다. @@ -41,6 +43,8 @@ public class WorkflowService implements PageableService { private final WorkflowMapper workflowMapper; + private final ScheduleMapper scheduleMapper; + private final QuartzScheduleService quartzScheduleService; /** * 워크플로우 목록을 페이징 처리하여 조회합니다. @@ -91,7 +95,17 @@ public WorkflowDetailCardDto getWorkflowDetail(BigInteger workflowId) { return workflow; } - /** 워크플로우 생성 */ + /** + * 워크플로우 생성 (스케줄 포함 가능) + * + *

워크플로우와 스케줄을 하나의 트랜잭션으로 처리하여 원자성을 보장합니다. + * 스케줄이 포함된 경우 DB 저장 후 즉시 Quartz 스케줄러에 등록합니다. + * + * @param dto 워크플로우 생성 정보 (스케줄 선택사항) + * @param createdBy 생성자 ID + * @throws IllegalArgumentException 검증 실패 시 + * @throws RuntimeException 생성 중 오류 발생 시 + */ @Transactional public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { // 1. 기본 검증 @@ -100,12 +114,18 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { // 2. 비즈니스 검증 validateBusinessRules(dto); - // 3. 중복체크 + // 3. 스케줄 검증 (있는 경우만) + if (dto.hasSchedules()) { + validateSchedules(dto.getSchedules()); + } + + // 4. 워크플로우 이름 중복 체크 if (workflowMapper.existsByName(dto.getName())) { - throw new IllegalArgumentException("이미 존재하는 워크플로우 이름입니다 : " + dto.getName()); + throw new IllegalArgumentException("이미 존재하는 워크플로우 이름입니다: " + dto.getName()); } - // 4. 워크플로우 생성 + // 5. 워크플로우 생성 + Long workflowId = null; try { // JSON 설정 생성 String defaultConfigJson = dto.genertateDefaultConfigJson(); @@ -121,12 +141,24 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { throw new RuntimeException("워크플로우 생성에 실패했습니다"); } - log.info("워크플로우 생성 완료: {} (생성자: {})", dto.getName(), createdBy); + // 생성된 workflow ID 추출 + Object generatedId = params.get("id"); + workflowId = (generatedId instanceof BigInteger) + ? ((BigInteger) generatedId).longValue() + : ((Number) generatedId).longValue(); + + log.info("워크플로우 생성 완료: {} (ID: {}, 생성자: {})", + dto.getName(), workflowId, createdBy); } catch (Exception e) { log.error("워크플로우 생성 실패: {}", dto.getName(), e); throw new RuntimeException("워크플로우 생성 중 오류가 발생했습니다", e); } + + // 6. 스케줄 등록 (있는 경우만) + if (dto.hasSchedules() && workflowId != null) { + registerSchedules(workflowId, dto.getSchedules(), createdBy.longValue()); + } } /** 기본 입력값 검증 */ @@ -139,7 +171,9 @@ private void validateBasicInput(WorkflowCreateDto dto, BigInteger createdBy) { } } - /** 비즈니스 규칙 검증 */ + /** + * 비즈니스 규칙 검증 + */ private void validateBusinessRules(WorkflowCreateDto dto) { // 포스팅 플랫폼 선택 시 계정 정보 필수 검증 String postingPlatform = dto.getPostingPlatform(); @@ -158,4 +192,125 @@ private void validateBusinessRules(WorkflowCreateDto dto) { } } } + + /** + * 스케줄 목록 검증 + * + *

크론 표현식 유효성 및 중복 검사를 수행합니다. + * + * @param schedules 검증할 스케줄 목록 + * @throws IllegalArgumentException 유효하지 않은 크론식 + * @throws DuplicateDataException 중복 크론식 발견 + */ + private void validateSchedules(List schedules) { + if (schedules == null || schedules.isEmpty()) { + return; + } + + // 중복 크론식 검사 (같은 요청 내에서) + Set cronExpressions = new HashSet<>(); + + for (ScheduleCreateDto schedule : schedules) { + String cron = schedule.getCronExpression(); + + // 1. 크론 표현식 유효성 검증 (Quartz 기준) + if (!isValidCronExpression(cron)) { + throw new IllegalArgumentException( + "유효하지 않은 크론 표현식입니다: " + cron); + } + + // 2. 중복 크론식 검사 + if (cronExpressions.contains(cron)) { + throw new DuplicateDataException( + "중복된 크론 표현식이 있습니다: " + cron); + } + cronExpressions.add(cron); + } + } + + /** + * Quartz 크론 표현식 유효성 검증 + * + * @param cronExpression 검증할 크론 표현식 + * @return 유효하면 true + */ + private boolean isValidCronExpression(String cronExpression) { + try { + new CronExpression(cronExpression); + return true; + } catch (Exception e) { + log.warn("유효하지 않은 크론 표현식: {}", cronExpression, e); + return false; + } + } + + /** + * 스케줄 목록 등록 (DB 저장 + Quartz 등록) + * + *

트랜잭션 내에서 DB 저장을 수행하고, Quartz 등록은 실패해도 + * 워크플로우는 유지되도록 예외를 로그로만 처리합니다. + * + * @param workflowId 워크플로우 ID + * @param scheduleCreateDtos 등록할 스케줄 목록 + * @param userId 생성자 ID + */ + private void registerSchedules( + Long workflowId, + List scheduleCreateDtos, + Long userId + ) { + if (scheduleCreateDtos == null || scheduleCreateDtos.isEmpty()) { + return; + } + + log.info("스케줄 등록 시작: Workflow ID {} - {}개", + workflowId, scheduleCreateDtos.size()); + + int successCount = 0; + int failCount = 0; + + for (ScheduleCreateDto dto : scheduleCreateDtos) { + try { + // 1. DTO → Model 변환 + Schedule schedule = dto.toEntity(workflowId, userId); + + // 2. DB 중복 체크 (같은 워크플로우 + 같은 크론식) + if (scheduleMapper.existsByWorkflowIdAndCronExpression( + workflowId, schedule.getCronExpression())) { + throw new DuplicateDataException( + "이미 동일한 크론식의 스케줄이 존재합니다: " + schedule.getCronExpression()); + } + + // 3. DB 저장 + int insertResult = scheduleMapper.insertSchedule(schedule); + if (insertResult != 1) { + log.error("스케줄 DB 저장 실패: Workflow ID {} - {}", + workflowId, schedule.getCronExpression()); + failCount++; + continue; + } + + // 4. Quartz 등록 (실시간 반영) + quartzScheduleService.addOrUpdateSchedule(schedule); + + log.info("스케줄 등록 완료: Workflow ID {} - {} ({})", + workflowId, schedule.getCronExpression(), schedule.getScheduleText()); + successCount++; + + } catch (DuplicateDataException e) { + log.warn("스케줄 중복으로 등록 건너뜀: Workflow ID {} - {}", + workflowId, dto.getCronExpression()); + failCount++; + // 중복은 경고만 하고 계속 진행 + } catch (Exception e) { + log.error("스케줄 등록 실패: Workflow ID {} - {}", + workflowId, dto.getCronExpression(), e); + failCount++; + // 스케줄 등록 실패해도 워크플로우는 유지 + } + } + + log.info("스케줄 등록 완료: Workflow ID {} - 성공 {}개, 실패 {}개", + workflowId, successCount, failCount); + } } From c7fc11c120699fb9d5990c6ec0f1dfb363eedc3e Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Fri, 26 Sep 2025 16:27:11 +0900 Subject: [PATCH 16/40] =?UTF-8?q?feat:=20WorkflowCreateFlowE2eTest?= =?UTF-8?q?=EC=97=90=20=EC=8A=A4=EC=BC=80=EC=A4=84=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenario/WorkflowCreateFlowE2eTest.java | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java index 3d5ca4b8..86920cc0 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java @@ -3,7 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import java.time.Instant; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.DisplayName; @@ -296,4 +298,281 @@ void createWorkflow_utc_time_validation() throws Exception { logCompletion("UTC 시간 기반 워크플로우 생성 검증 완료"); } + + @Test + @DisplayName("워크플로우 생성 시 단일 스케줄 등록 성공") + void createWorkflow_withSingleSchedule_success() { + performUserLogin(); + + logStep(1, "스케줄이 포함된 워크플로우 생성"); + + // 워크플로우 + 스케줄 요청 데이터 구성 + Map workflowRequest = new HashMap<>(); + workflowRequest.put("name", "매일 오전 9시 자동 실행 워크플로우"); + workflowRequest.put("description", "매일 오전 9시에 자동으로 실행되는 워크플로우"); + workflowRequest.put("search_platform", "naver"); + workflowRequest.put("posting_platform", "naver_blog"); + workflowRequest.put("posting_account_id", "test_account"); + workflowRequest.put("posting_account_password", "test_password"); + workflowRequest.put("is_enabled", true); + + // 스케줄 정보 추가 + List> schedules = new ArrayList<>(); + Map schedule = new HashMap<>(); + schedule.put("cronExpression", "0 0 9 * * ?"); // 매일 오전 9시 + schedule.put("scheduleText", "매일 오전 9시"); + schedule.put("isActive", true); + schedules.add(schedule); + + workflowRequest.put("schedules", schedules); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); + + logStep(2, "워크플로우 생성 요청 전송"); + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + logStep(3, "응답 검증"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) response.getBody().get("success")).isTrue(); + + logSuccess("스케줄이 포함된 워크플로우 생성 성공"); + logDebug("응답: " + response.getBody()); + + logCompletion("단일 스케줄 등록 테스트 완료"); + } + + @Test + @DisplayName("워크플로우 생성 시 다중 스케줄 등록 성공") + void createWorkflow_withMultipleSchedules_success() { + performUserLogin(); + + logStep(1, "다중 스케줄이 포함된 워크플로우 생성"); + + // 워크플로우 기본 정보 + Map workflowRequest = new HashMap<>(); + workflowRequest.put("name", "다중 스케줄 워크플로우"); + workflowRequest.put("description", "여러 시간대에 실행되는 워크플로우"); + workflowRequest.put("search_platform", "naver"); + workflowRequest.put("posting_platform", "naver_blog"); + workflowRequest.put("posting_account_id", "test_multi"); + workflowRequest.put("posting_account_password", "test_pass123"); + workflowRequest.put("is_enabled", true); + + // 다중 스케줄 정보 추가 + List> schedules = new ArrayList<>(); + + // 스케줄 1: 매일 오전 9시 + Map schedule1 = new HashMap<>(); + schedule1.put("cronExpression", "0 0 9 * * ?"); + schedule1.put("scheduleText", "매일 오전 9시"); + schedule1.put("isActive", true); + schedules.add(schedule1); + + // 스케줄 2: 매일 오후 6시 + Map schedule2 = new HashMap<>(); + schedule2.put("cronExpression", "0 0 18 * * ?"); + schedule2.put("scheduleText", "매일 오후 6시"); + schedule2.put("isActive", true); + schedules.add(schedule2); + + // 스케줄 3: 평일 오후 2시 + Map schedule3 = new HashMap<>(); + schedule3.put("cronExpression", "0 0 14 ? * MON-FRI"); + schedule3.put("scheduleText", "평일 오후 2시"); + schedule3.put("isActive", true); + schedules.add(schedule3); + + workflowRequest.put("schedules", schedules); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); + + logStep(2, "워크플로우 생성 요청 전송 (3개 스케줄 포함)"); + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + logStep(3, "응답 검증"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) response.getBody().get("success")).isTrue(); + + logSuccess("다중 스케줄이 포함된 워크플로우 생성 성공"); + logDebug("응답: " + response.getBody()); + + logCompletion("다중 스케줄 등록 테스트 완료"); + } + + @Test + @DisplayName("유효하지 않은 크론 표현식으로 스케줄 등록 시 실패") + void createWorkflow_withInvalidCronExpression_shouldFail() { + performUserLogin(); + + logStep(1, "잘못된 크론 표현식으로 워크플로우 생성 시도"); + + Map workflowRequest = new HashMap<>(); + workflowRequest.put("name", "잘못된 크론식 테스트"); + workflowRequest.put("search_platform", "naver"); + workflowRequest.put("is_enabled", true); + + // 잘못된 크론 표현식 + List> schedules = new ArrayList<>(); + Map schedule = new HashMap<>(); + schedule.put("cronExpression", "INVALID CRON"); // 잘못된 형식 + schedule.put("scheduleText", "잘못된 스케줄"); + schedule.put("isActive", true); + schedules.add(schedule); + + workflowRequest.put("schedules", schedules); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); + + logStep(2, "워크플로우 생성 요청 전송"); + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + logStep(3, "에러 응답 검증"); + assertThat(response.getStatusCode()) + .isIn(HttpStatus.BAD_REQUEST, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.INTERNAL_SERVER_ERROR); + + logSuccess("유효하지 않은 크론 표현식 검증 확인"); + logDebug("에러 응답: " + response.getBody()); + + logCompletion("크론 표현식 검증 테스트 완료"); + } + + @Test + @DisplayName("중복된 크론 표현식으로 스케줄 등록 시 실패") + void createWorkflow_withDuplicateCronExpression_shouldFail() { + performUserLogin(); + + logStep(1, "중복된 크론식을 가진 워크플로우 생성 시도"); + + Map workflowRequest = new HashMap<>(); + workflowRequest.put("name", "중복 크론식 테스트"); + workflowRequest.put("search_platform", "naver"); + workflowRequest.put("is_enabled", true); + + // 동일한 크론 표현식을 가진 스케줄 2개 + List> schedules = new ArrayList<>(); + + Map schedule1 = new HashMap<>(); + schedule1.put("cronExpression", "0 0 9 * * ?"); // 매일 오전 9시 + schedule1.put("scheduleText", "매일 오전 9시 - 첫번째"); + schedule1.put("isActive", true); + schedules.add(schedule1); + + Map schedule2 = new HashMap<>(); + schedule2.put("cronExpression", "0 0 9 * * ?"); // 동일한 크론식 + schedule2.put("scheduleText", "매일 오전 9시 - 두번째"); + schedule2.put("isActive", true); + schedules.add(schedule2); + + workflowRequest.put("schedules", schedules); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); + + logStep(2, "워크플로우 생성 요청 전송"); + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + logStep(3, "중복 크론식 에러 검증"); + assertThat(response.getStatusCode()) + .isIn(HttpStatus.BAD_REQUEST, HttpStatus.CONFLICT, HttpStatus.INTERNAL_SERVER_ERROR); + + logSuccess("중복 크론 표현식 검증 확인"); + logDebug("에러 응답: " + response.getBody()); + + logCompletion("중복 크론식 검증 테스트 완료"); + } + + @Test + @DisplayName("스케줄 없이 워크플로우 생성 후 정상 작동 확인") + void createWorkflow_withoutSchedule_success() { + performUserLogin(); + + logStep(1, "스케줄 없이 워크플로우 생성"); + + Map workflowRequest = new HashMap<>(); + workflowRequest.put("name", "스케줄 없는 워크플로우"); + workflowRequest.put("description", "수동 실행 전용 워크플로우"); + workflowRequest.put("search_platform", "naver"); + workflowRequest.put("posting_platform", "naver_blog"); + workflowRequest.put("posting_account_id", "manual_test"); + workflowRequest.put("posting_account_password", "manual_pass"); + workflowRequest.put("is_enabled", true); + // schedules 필드 없음 + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); + + logStep(2, "워크플로우 생성 요청 전송"); + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + logStep(3, "응답 검증"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) response.getBody().get("success")).isTrue(); + + logSuccess("스케줄 없는 워크플로우 생성 성공"); + logDebug("응답: " + response.getBody()); + + logCompletion("스케줄 선택사항 테스트 완료"); + } + + @Test + @DisplayName("비활성화 스케줄로 워크플로우 생성 시 Quartz 미등록 확인") + void createWorkflow_withInactiveSchedule_shouldNotRegisterToQuartz() { + performUserLogin(); + + logStep(1, "비활성화 스케줄로 워크플로우 생성"); + + Map workflowRequest = new HashMap<>(); + workflowRequest.put("name", "비활성화 스케줄 테스트"); + workflowRequest.put("description", "DB에는 저장되지만 Quartz에는 등록되지 않음"); + workflowRequest.put("search_platform", "naver"); + workflowRequest.put("is_enabled", true); + + // 비활성화 스케줄 + List> schedules = new ArrayList<>(); + Map schedule = new HashMap<>(); + schedule.put("cronExpression", "0 0 10 * * ?"); + schedule.put("scheduleText", "매일 오전 10시 (비활성)"); + schedule.put("isActive", false); // 비활성화 + schedules.add(schedule); + + workflowRequest.put("schedules", schedules); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); + + logStep(2, "워크플로우 생성 요청 전송"); + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + logStep(3, "응답 검증 - DB 저장은 성공하지만 Quartz 미등록"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) response.getBody().get("success")).isTrue(); + + logSuccess("비활성화 스케줄로 워크플로우 생성 성공"); + logDebug("응답: " + response.getBody()); + logDebug("비활성화 스케줄은 DB에 저장되지만 Quartz에는 등록되지 않음"); + + logCompletion("비활성화 스케줄 테스트 완료"); + } + } From 140698ffddad0eae60766ac3069d7cab72525946 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Fri, 26 Sep 2025 16:28:41 +0900 Subject: [PATCH 17/40] chore: spotlessApply --- .../schedule/mapper/ScheduleMapper.java | 18 +-- .../workflow/dto/ScheduleCreateDto.java | 147 +++++++++--------- .../workflow/dto/WorkflowCreateDto.java | 15 +- .../workflow/service/WorkflowService.java | 63 +++----- .../scenario/WorkflowCreateFlowE2eTest.java | 20 +-- 5 files changed, 120 insertions(+), 143 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java index 8c35f558..07ac19ea 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java @@ -3,15 +3,14 @@ import java.util.List; import org.apache.ibatis.annotations.Mapper; - import org.apache.ibatis.annotations.Param; + import site.icebang.domain.schedule.model.Schedule; /** * Schedule 데이터베이스 접근을 위한 MyBatis Mapper 인터페이스 * - *

워크플로우의 스케줄 정보를 관리하며, 한 워크플로우에 여러 스케줄을 등록할 수 있지만 - * 같은 크론식의 중복 등록은 방지합니다. + *

워크플로우의 스케줄 정보를 관리하며, 한 워크플로우에 여러 스케줄을 등록할 수 있지만 같은 크론식의 중복 등록은 방지합니다. * * @author bwnfo0702@gmail.com * @since v0.1.0 @@ -50,9 +49,7 @@ public interface ScheduleMapper { * @return 중복 여부 (true: 이미 존재함, false: 존재하지 않음) */ boolean existsByWorkflowIdAndCronExpression( - @Param("workflowId") Long workflowId, - @Param("cronExpression") String cronExpression - ); + @Param("workflowId") Long workflowId, @Param("cronExpression") String cronExpression); /** * 특정 워크플로우의 특정 크론식을 가진 스케줄 조회 @@ -62,9 +59,7 @@ boolean existsByWorkflowIdAndCronExpression( * @return 해당하는 스케줄 (없으면 null) */ Schedule findByWorkflowIdAndCronExpression( - @Param("workflowId") Long workflowId, - @Param("cronExpression") String cronExpression - ); + @Param("workflowId") Long workflowId, @Param("cronExpression") String cronExpression); /** * 스케줄 활성화 상태 변경 @@ -73,10 +68,7 @@ Schedule findByWorkflowIdAndCronExpression( * @param isActive 활성화 여부 * @return 영향받은 행 수 */ - int updateActiveStatus( - @Param("id") Long id, - @Param("isActive") boolean isActive - ); + int updateActiveStatus(@Param("id") Long id, @Param("isActive") boolean isActive); /** * 스케줄 정보 수정 (크론식, 설명 등) diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java index e8b98123..87fdcb5a 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java @@ -6,6 +6,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; + import site.icebang.domain.schedule.model.Schedule; /** @@ -13,10 +14,7 @@ * *

워크플로우 생성 시 함께 등록할 스케줄 정보를 담습니다. * - *

역할: - * - API 요청 시 클라이언트로부터 스케줄 정보 수신 - * - 입력값 검증 (형식, 길이 등) - * - Schedule(Model)로 변환되어 DB 저장 + *

역할: - API 요청 시 클라이언트로부터 스케줄 정보 수신 - 입력값 검증 (형식, 길이 등) - Schedule(Model)로 변환되어 DB 저장 * *

기존 ScheduleDto(응답용)와 통일성을 위해 camelCase 사용 * @@ -28,77 +26,78 @@ @NoArgsConstructor @AllArgsConstructor public class ScheduleCreateDto { - /** - * 크론 표현식 (필수) - * - *

Quartz 크론 표현식 형식을 따릅니다. - *

    - *
  • 초 분 시 일 월 요일 (년도)
  • - *
  • 예시: "0 9 * * *" (매일 9시 0분 0초)
  • - *
  • 예시: "0 0 14 * * MON-FRI" (평일 오후 2시)
  • - *
- * - *

정밀한 유효성 검증은 서비스 레이어에서 Quartz CronExpression으로 수행됩니다. - */ - @NotBlank(message = "크론 표현식은 필수입니다") - @Size(max = 50, message = "크론 표현식은 50자를 초과할 수 없습니다") - private String cronExpression; + /** + * 크론 표현식 (필수) + * + *

Quartz 크론 표현식 형식을 따릅니다. + * + *

    + *
  • 초 분 시 일 월 요일 (년도) + *
  • 예시: "0 9 * * *" (매일 9시 0분 0초) + *
  • 예시: "0 0 14 * * MON-FRI" (평일 오후 2시) + *
+ * + *

정밀한 유효성 검증은 서비스 레이어에서 Quartz CronExpression으로 수행됩니다. + */ + @NotBlank(message = "크론 표현식은 필수입니다") + @Size(max = 50, message = "크론 표현식은 50자를 초과할 수 없습니다") + private String cronExpression; - /** - * 사용자 친화적 스케줄 설명 - * - *

UI에 표시될 스케줄 설명입니다. 자동으로 생성됩니다. - *

    - *
  • 예시: "매일 오전 9시"
  • - *
  • 예시: "평일 오후 2시"
  • - *
  • 예시: "매주 금요일 6시"
  • - *
- */ - @Size(max = 20, message = "스케줄 설명은 자동으로 생성됩니다") - private String scheduleText; + /** + * 사용자 친화적 스케줄 설명 + * + *

UI에 표시될 스케줄 설명입니다. 자동으로 생성됩니다. + * + *

    + *
  • 예시: "매일 오전 9시" + *
  • 예시: "평일 오후 2시" + *
  • 예시: "매주 금요일 6시" + *
+ */ + @Size(max = 20, message = "스케줄 설명은 자동으로 생성됩니다") + private String scheduleText; - /** - * 스케줄 활성화 여부 (기본값: true) - * - *

false일 경우 DB에는 저장되지만 Quartz에 등록되지 않습니다. - */ - @Builder.Default - private Boolean isActive = true; + /** + * 스케줄 활성화 여부 (기본값: true) + * + *

false일 경우 DB에는 저장되지만 Quartz에 등록되지 않습니다. + */ + @Builder.Default private Boolean isActive = true; - /** - * 스케줄 실행 시 추가 파라미터 (선택, JSON 형식) - * - *

워크플로우 실행 시 전달할 추가 파라미터를 JSON 문자열로 저장합니다. - *

{@code
-     * // 예시:
-     * {
-     *   "retryCount": 3,
-     *   "timeout": 300,
-     *   "notifyOnFailure": true
-     * }
-     * }
- */ - private String parameters; - /** - * Schedule(Model) 엔티티로 변환 - * - *

DTO의 정보를 DB 저장용 엔티티로 변환하며, 서비스 레이어에서 주입되는 - * workflowId와 userId를 함께 설정합니다. - * - * @param workflowId 연결할 워크플로우 ID - * @param userId 생성자 ID - * @return DB 저장 가능한 Schedule 엔티티 - */ - public Schedule toEntity(Long workflowId, Long userId) { - return Schedule.builder() - .workflowId(workflowId) - .cronExpression(this.cronExpression) - .scheduleText(this.scheduleText) - .isActive(this.isActive != null ? this.isActive : true) - .parameters(this.parameters) - .createdBy(userId) - .updatedBy(userId) - .build(); - } -} + /** + * 스케줄 실행 시 추가 파라미터 (선택, JSON 형식) + * + *

워크플로우 실행 시 전달할 추가 파라미터를 JSON 문자열로 저장합니다. + * + *

{@code
+   * // 예시:
+   * {
+   *   "retryCount": 3,
+   *   "timeout": 300,
+   *   "notifyOnFailure": true
+   * }
+   * }
+ */ + private String parameters; + /** + * Schedule(Model) 엔티티로 변환 + * + *

DTO의 정보를 DB 저장용 엔티티로 변환하며, 서비스 레이어에서 주입되는 workflowId와 userId를 함께 설정합니다. + * + * @param workflowId 연결할 워크플로우 ID + * @param userId 생성자 ID + * @return DB 저장 가능한 Schedule 엔티티 + */ + public Schedule toEntity(Long workflowId, Long userId) { + return Schedule.builder() + .workflowId(workflowId) + .cronExpression(this.cronExpression) + .scheduleText(this.scheduleText) + .isActive(this.isActive != null ? this.isActive : true) + .parameters(this.parameters) + .createdBy(userId) + .updatedBy(userId) + .build(); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java index 1e990687..f14b2aeb 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java @@ -15,11 +15,8 @@ /** * 워크플로우 생성 요청 DTO * - *

프론트엔드에서 워크플로우 생성 시 필요한 모든 정보를 담는 DTO - * - 기본 정보: 이름, 설명 - * - 플랫폼 설정: 검색 플랫폼, 포스팅 플랫폼 - * - 계정 설정: 포스팅 계정 정보 (JSON 형태로 저장) - * - 스케줄 설정: 선택적으로 여러 스케줄 등록 가능 + *

프론트엔드에서 워크플로우 생성 시 필요한 모든 정보를 담는 DTO - 기본 정보: 이름, 설명 - 플랫폼 설정: 검색 플랫폼, 포스팅 플랫폼 - 계정 설정: 포스팅 계정 + * 정보 (JSON 형태로 저장) - 스케줄 설정: 선택적으로 여러 스케줄 등록 가능 * * @author bwnfo0702@gmail.com * @since v0.1.0 @@ -71,13 +68,13 @@ public class WorkflowCreateDto { * 워크플로우에 등록할 스케줄 목록 (선택사항) * *

사용 시나리오: + * *

    - *
  • null 또는 빈 리스트: 스케줄 없이 워크플로우만 생성
  • - *
  • 1개 이상: 해당 스케줄들을 함께 등록 (트랜잭션 보장)
  • + *
  • null 또는 빈 리스트: 스케줄 없이 워크플로우만 생성 + *
  • 1개 이상: 해당 스케줄들을 함께 등록 (트랜잭션 보장) *
*/ - @Valid - private List<@Valid ScheduleCreateDto> schedules; + @Valid private List<@Valid ScheduleCreateDto> schedules; // JSON 변환용 필드 (MyBatis에서 사용) private String defaultConfigJson; diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java index e9266948..4ed2c4a0 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java @@ -3,24 +3,22 @@ import java.math.BigInteger; import java.util.*; +import org.quartz.CronExpression; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.quartz.CronExpression; - -import site.icebang.common.exception.DuplicateDataException; import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; +import site.icebang.common.exception.DuplicateDataException; import site.icebang.common.service.PageableService; +import site.icebang.domain.schedule.mapper.ScheduleMapper; import site.icebang.domain.schedule.model.Schedule; +import site.icebang.domain.schedule.service.QuartzScheduleService; import site.icebang.domain.workflow.dto.*; import site.icebang.domain.workflow.mapper.WorkflowMapper; -import site.icebang.domain.schedule.mapper.ScheduleMapper; -import site.icebang.domain.schedule.service.QuartzScheduleService; - /** * 워크플로우의 '정의'와 관련된 비즈니스 로직을 처리하는 서비스 클래스입니다. @@ -98,8 +96,7 @@ public WorkflowDetailCardDto getWorkflowDetail(BigInteger workflowId) { /** * 워크플로우 생성 (스케줄 포함 가능) * - *

워크플로우와 스케줄을 하나의 트랜잭션으로 처리하여 원자성을 보장합니다. - * 스케줄이 포함된 경우 DB 저장 후 즉시 Quartz 스케줄러에 등록합니다. + *

워크플로우와 스케줄을 하나의 트랜잭션으로 처리하여 원자성을 보장합니다. 스케줄이 포함된 경우 DB 저장 후 즉시 Quartz 스케줄러에 등록합니다. * * @param dto 워크플로우 생성 정보 (스케줄 선택사항) * @param createdBy 생성자 ID @@ -143,12 +140,12 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { // 생성된 workflow ID 추출 Object generatedId = params.get("id"); - workflowId = (generatedId instanceof BigInteger) + workflowId = + (generatedId instanceof BigInteger) ? ((BigInteger) generatedId).longValue() : ((Number) generatedId).longValue(); - log.info("워크플로우 생성 완료: {} (ID: {}, 생성자: {})", - dto.getName(), workflowId, createdBy); + log.info("워크플로우 생성 완료: {} (ID: {}, 생성자: {})", dto.getName(), workflowId, createdBy); } catch (Exception e) { log.error("워크플로우 생성 실패: {}", dto.getName(), e); @@ -171,9 +168,7 @@ private void validateBasicInput(WorkflowCreateDto dto, BigInteger createdBy) { } } - /** - * 비즈니스 규칙 검증 - */ + /** 비즈니스 규칙 검증 */ private void validateBusinessRules(WorkflowCreateDto dto) { // 포스팅 플랫폼 선택 시 계정 정보 필수 검증 String postingPlatform = dto.getPostingPlatform(); @@ -215,14 +210,12 @@ private void validateSchedules(List schedules) { // 1. 크론 표현식 유효성 검증 (Quartz 기준) if (!isValidCronExpression(cron)) { - throw new IllegalArgumentException( - "유효하지 않은 크론 표현식입니다: " + cron); + throw new IllegalArgumentException("유효하지 않은 크론 표현식입니다: " + cron); } // 2. 중복 크론식 검사 if (cronExpressions.contains(cron)) { - throw new DuplicateDataException( - "중복된 크론 표현식이 있습니다: " + cron); + throw new DuplicateDataException("중복된 크론 표현식이 있습니다: " + cron); } cronExpressions.add(cron); } @@ -247,24 +240,19 @@ private boolean isValidCronExpression(String cronExpression) { /** * 스케줄 목록 등록 (DB 저장 + Quartz 등록) * - *

트랜잭션 내에서 DB 저장을 수행하고, Quartz 등록은 실패해도 - * 워크플로우는 유지되도록 예외를 로그로만 처리합니다. + *

트랜잭션 내에서 DB 저장을 수행하고, Quartz 등록은 실패해도 워크플로우는 유지되도록 예외를 로그로만 처리합니다. * * @param workflowId 워크플로우 ID * @param scheduleCreateDtos 등록할 스케줄 목록 * @param userId 생성자 ID */ private void registerSchedules( - Long workflowId, - List scheduleCreateDtos, - Long userId - ) { + Long workflowId, List scheduleCreateDtos, Long userId) { if (scheduleCreateDtos == null || scheduleCreateDtos.isEmpty()) { return; } - log.info("스케줄 등록 시작: Workflow ID {} - {}개", - workflowId, scheduleCreateDtos.size()); + log.info("스케줄 등록 시작: Workflow ID {} - {}개", workflowId, scheduleCreateDtos.size()); int successCount = 0; int failCount = 0; @@ -276,16 +264,15 @@ private void registerSchedules( // 2. DB 중복 체크 (같은 워크플로우 + 같은 크론식) if (scheduleMapper.existsByWorkflowIdAndCronExpression( - workflowId, schedule.getCronExpression())) { + workflowId, schedule.getCronExpression())) { throw new DuplicateDataException( - "이미 동일한 크론식의 스케줄이 존재합니다: " + schedule.getCronExpression()); + "이미 동일한 크론식의 스케줄이 존재합니다: " + schedule.getCronExpression()); } // 3. DB 저장 int insertResult = scheduleMapper.insertSchedule(schedule); if (insertResult != 1) { - log.error("스케줄 DB 저장 실패: Workflow ID {} - {}", - workflowId, schedule.getCronExpression()); + log.error("스케줄 DB 저장 실패: Workflow ID {} - {}", workflowId, schedule.getCronExpression()); failCount++; continue; } @@ -293,24 +280,24 @@ private void registerSchedules( // 4. Quartz 등록 (실시간 반영) quartzScheduleService.addOrUpdateSchedule(schedule); - log.info("스케줄 등록 완료: Workflow ID {} - {} ({})", - workflowId, schedule.getCronExpression(), schedule.getScheduleText()); + log.info( + "스케줄 등록 완료: Workflow ID {} - {} ({})", + workflowId, + schedule.getCronExpression(), + schedule.getScheduleText()); successCount++; } catch (DuplicateDataException e) { - log.warn("스케줄 중복으로 등록 건너뜀: Workflow ID {} - {}", - workflowId, dto.getCronExpression()); + log.warn("스케줄 중복으로 등록 건너뜀: Workflow ID {} - {}", workflowId, dto.getCronExpression()); failCount++; // 중복은 경고만 하고 계속 진행 } catch (Exception e) { - log.error("스케줄 등록 실패: Workflow ID {} - {}", - workflowId, dto.getCronExpression(), e); + log.error("스케줄 등록 실패: Workflow ID {} - {}", workflowId, dto.getCronExpression(), e); failCount++; // 스케줄 등록 실패해도 워크플로우는 유지 } } - log.info("스케줄 등록 완료: Workflow ID {} - 성공 {}개, 실패 {}개", - workflowId, successCount, failCount); + log.info("스케줄 등록 완료: Workflow ID {} - 성공 {}개, 실패 {}개", workflowId, successCount, failCount); } } diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java index 86920cc0..08088a8c 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java @@ -333,7 +333,7 @@ void createWorkflow_withSingleSchedule_success() { logStep(2, "워크플로우 생성 요청 전송"); ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); logStep(3, "응답 검증"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); @@ -395,7 +395,7 @@ void createWorkflow_withMultipleSchedules_success() { logStep(2, "워크플로우 생성 요청 전송 (3개 스케줄 포함)"); ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); logStep(3, "응답 검증"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); @@ -436,11 +436,14 @@ void createWorkflow_withInvalidCronExpression_shouldFail() { logStep(2, "워크플로우 생성 요청 전송"); ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); logStep(3, "에러 응답 검증"); assertThat(response.getStatusCode()) - .isIn(HttpStatus.BAD_REQUEST, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.INTERNAL_SERVER_ERROR); + .isIn( + HttpStatus.BAD_REQUEST, + HttpStatus.UNPROCESSABLE_ENTITY, + HttpStatus.INTERNAL_SERVER_ERROR); logSuccess("유효하지 않은 크론 표현식 검증 확인"); logDebug("에러 응답: " + response.getBody()); @@ -484,11 +487,11 @@ void createWorkflow_withDuplicateCronExpression_shouldFail() { logStep(2, "워크플로우 생성 요청 전송"); ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); logStep(3, "중복 크론식 에러 검증"); assertThat(response.getStatusCode()) - .isIn(HttpStatus.BAD_REQUEST, HttpStatus.CONFLICT, HttpStatus.INTERNAL_SERVER_ERROR); + .isIn(HttpStatus.BAD_REQUEST, HttpStatus.CONFLICT, HttpStatus.INTERNAL_SERVER_ERROR); logSuccess("중복 크론 표현식 검증 확인"); logDebug("에러 응답: " + response.getBody()); @@ -520,7 +523,7 @@ void createWorkflow_withoutSchedule_success() { logStep(2, "워크플로우 생성 요청 전송"); ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); logStep(3, "응답 검증"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); @@ -562,7 +565,7 @@ void createWorkflow_withInactiveSchedule_shouldNotRegisterToQuartz() { logStep(2, "워크플로우 생성 요청 전송"); ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); logStep(3, "응답 검증 - DB 저장은 성공하지만 Quartz 미등록"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); @@ -574,5 +577,4 @@ void createWorkflow_withInactiveSchedule_shouldNotRegisterToQuartz() { logCompletion("비활성화 스케줄 테스트 완료"); } - } From 9e4aa3185eabce358918fb37d1bb64b921f7cba1 Mon Sep 17 00:00:00 2001 From: Yousung Jung Date: Fri, 26 Sep 2025 20:03:44 +0900 Subject: [PATCH 18/40] =?UTF-8?q?Jackson=20Timezone=20=EC=A7=81=EB=A0=AC?= =?UTF-8?q?=ED=99=94=EA=B0=80=20=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: ObjectMapper timezone 설정 * fix: Workflow 관련 timezone이 찍히지 않는 문제 * fix: ObjectMapper UTC 설정 bean config로 변경 application.yml 설정이 무시됨 * fix: ExecutionLogDto instant로 변환 * test: DB가 UTC 기준으로 저장되어있다고 가정하도록 수정 * test: SQL 09:18 -> 18:18로 일치 * test: H2 DB UTC 고정 --- .../domain/workflow/dto/ExecutionLogDto.java | 4 +- .../domain/workflow/dto/JobRunDto.java | 5 +- .../domain/workflow/dto/TaskRunDto.java | 6 ++- .../domain/workflow/dto/WorkflowRunDto.java | 8 +-- .../site/icebang/global/config/WebConfig.java | 29 +++++++++++ .../src/main/resources/application.yml | 5 -- .../sql/schema/03-schema-h2-timezone.sql | 2 +- .../setup/config/E2eTestConfiguration.java | 5 -- .../e2e/setup/support/E2eTestSupport.java | 3 +- .../setup/config/RestDocsConfiguration.java | 7 --- .../WorkflowHistoryApiIntegrationTest.java | 52 ++++++++++--------- 11 files changed, 75 insertions(+), 51 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java index 5dbb5711..7c8595a3 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java @@ -1,5 +1,7 @@ package site.icebang.domain.workflow.dto; +import java.time.Instant; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -17,6 +19,6 @@ public class ExecutionLogDto { private String logLevel; // info, success, warning, error private String status; // running, success, failed, etc private String logMessage; - private String executedAt; + private Instant executedAt; private Integer durationMs; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobRunDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobRunDto.java index 618a6214..8ebe6c51 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobRunDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobRunDto.java @@ -1,5 +1,6 @@ package site.icebang.domain.workflow.dto; +import java.time.Instant; import java.util.List; import lombok.AllArgsConstructor; @@ -19,8 +20,8 @@ public class JobRunDto { private String jobDescription; private String status; private Integer executionOrder; - private String startedAt; - private String finishedAt; + private Instant startedAt; + private Instant finishedAt; private Integer durationMs; private List taskRuns; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskRunDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskRunDto.java index 9005c45a..b6bc9a3d 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskRunDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskRunDto.java @@ -1,5 +1,7 @@ package site.icebang.domain.workflow.dto; +import java.time.Instant; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -18,7 +20,7 @@ public class TaskRunDto { private String taskType; private String status; private Integer executionOrder; - private String startedAt; - private String finishedAt; + private Instant startedAt; + private Instant finishedAt; private Integer durationMs; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDto.java index 20b8ecd2..af2a3005 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDto.java @@ -1,5 +1,7 @@ package site.icebang.domain.workflow.dto; +import java.time.Instant; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -17,9 +19,9 @@ public class WorkflowRunDto { private String runNumber; private String status; private String triggerType; - private String startedAt; - private String finishedAt; + private Instant startedAt; + private Instant finishedAt; private Integer durationMs; private Long createdBy; - private String createdAt; + private Instant createdAt; } diff --git a/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java b/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java index 7029b7d9..9369f887 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java @@ -1,13 +1,19 @@ package site.icebang.global.config; import java.time.Duration; +import java.util.TimeZone; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + /** * 애플리케이션의 웹 관련 설정을 담당하는 Java 기반 설정 클래스입니다. * @@ -51,4 +57,27 @@ public RestTemplate restTemplate(RestTemplateBuilder builder) { // 3. 빌더에 직접 생성한 requestFactory를 설정 return builder.requestFactory(() -> requestFactory).build(); } + + /** + * Z 포함 UTC 형식으로 시간을 직렬화하는 ObjectMapper 빈을 생성합니다. + * + *

이 ObjectMapper는 애플리케이션 전역에서 사용되며, 다음과 같은 설정을 적용합니다: + * + *

    + *
  • JavaTimeModule 등록으로 Java 8 시간 API 지원 + *
  • timestamps 대신 ISO 8601 문자열 형식 사용 + *
  • UTC 타임존 설정으로 Z 포함 형식 보장 + *
+ * + * @return Z 포함 UTC 형식이 설정된 ObjectMapper 인스턴스 + * @since v0.0.1 + */ + @Bean + @Primary + public ObjectMapper objectMapper() { + return new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .setTimeZone(TimeZone.getTimeZone("UTC")); + } } diff --git a/apps/user-service/src/main/resources/application.yml b/apps/user-service/src/main/resources/application.yml index 55fece16..f6302bc7 100644 --- a/apps/user-service/src/main/resources/application.yml +++ b/apps/user-service/src/main/resources/application.yml @@ -7,11 +7,6 @@ spring: context: cache: maxSize: 1 - jackson: - time-zone: UTC - serialization: - write-dates-as-timestamps: false - mybatis: # Mapper XML 파일 위치 mapper-locations: classpath:mapper/**/*.xml diff --git a/apps/user-service/src/main/resources/sql/schema/03-schema-h2-timezone.sql b/apps/user-service/src/main/resources/sql/schema/03-schema-h2-timezone.sql index 3ae6c57b..018b4d18 100644 --- a/apps/user-service/src/main/resources/sql/schema/03-schema-h2-timezone.sql +++ b/apps/user-service/src/main/resources/sql/schema/03-schema-h2-timezone.sql @@ -6,7 +6,7 @@ -- 모든 timestamp 컬럼의 기본값 제거 (H2에서는 MODIFY COLUMN 문법이 다름) -- H2에서는 ALTER TABLE table_name ALTER COLUMN column_name 문법 사용 -- H2 MariaDB 모드에서는 백틱으로 테이블명을 감싸야 함 - +SET TIME ZONE 'UTC'; ALTER TABLE `permission` ALTER COLUMN created_at SET DEFAULT NULL; ALTER TABLE `permission` ALTER COLUMN updated_at SET DEFAULT NULL; diff --git a/apps/user-service/src/test/java/site/icebang/e2e/setup/config/E2eTestConfiguration.java b/apps/user-service/src/test/java/site/icebang/e2e/setup/config/E2eTestConfiguration.java index 3b7ce243..c7b18ce8 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/setup/config/E2eTestConfiguration.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/setup/config/E2eTestConfiguration.java @@ -9,15 +9,10 @@ import org.testcontainers.containers.MariaDBContainer; import org.testcontainers.containers.Network; import org.testcontainers.containers.wait.strategy.Wait; -import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; import org.testcontainers.utility.DockerImageName; @TestConfiguration(proxyBeanMethods = false) public class E2eTestConfiguration { - @Bean - public ObjectMapper objectMapper() { - return new ObjectMapper(); - } @Bean public Network testNetwork() { diff --git a/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java b/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java index 97d1cf0d..002cd307 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java @@ -12,7 +12,8 @@ import org.springframework.http.client.ClientHttpResponse; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.context.WebApplicationContext; -import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; + +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; diff --git a/apps/user-service/src/test/java/site/icebang/integration/setup/config/RestDocsConfiguration.java b/apps/user-service/src/test/java/site/icebang/integration/setup/config/RestDocsConfiguration.java index f60de9cc..16285140 100644 --- a/apps/user-service/src/test/java/site/icebang/integration/setup/config/RestDocsConfiguration.java +++ b/apps/user-service/src/test/java/site/icebang/integration/setup/config/RestDocsConfiguration.java @@ -6,8 +6,6 @@ import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; import org.springframework.restdocs.operation.preprocess.Preprocessors; -import com.fasterxml.jackson.databind.ObjectMapper; - @TestConfiguration public class RestDocsConfiguration { @@ -21,9 +19,4 @@ public RestDocumentationResultHandler restDocumentationResultHandler() { Preprocessors.removeHeaders("Content-Length", "Date", "Keep-Alive", "Connection"), Preprocessors.prettyPrint())); } - - @Bean - public ObjectMapper testObjectMapper() { - return new ObjectMapper(); - } } diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java index f2be6c1f..f83e0142 100644 --- a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java @@ -57,21 +57,21 @@ void getWorkflowRunDetail_success() throws Exception { .andExpect(jsonPath("$.data.workflowRun.runNumber").isEmpty()) .andExpect(jsonPath("$.data.workflowRun.status").value("FAILED")) .andExpect(jsonPath("$.data.workflowRun.triggerType").isEmpty()) - .andExpect(jsonPath("$.data.workflowRun.startedAt").value("2025-09-22 18:18:43")) - .andExpect(jsonPath("$.data.workflowRun.finishedAt").value("2025-09-22 18:18:44")) + .andExpect(jsonPath("$.data.workflowRun.startedAt").value("2025-09-22T18:18:43Z")) + .andExpect(jsonPath("$.data.workflowRun.finishedAt").value("2025-09-22T18:18:44Z")) .andExpect(jsonPath("$.data.workflowRun.durationMs").value(1000)) .andExpect(jsonPath("$.data.workflowRun.createdBy").isEmpty()) .andExpect(jsonPath("$.data.workflowRun.createdAt").exists()) // UTC 시간 형식 검증 (시간대 보장) - 마이크로초 포함 가능 .andExpect( jsonPath("$.data.workflowRun.startedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andExpect( jsonPath("$.data.workflowRun.finishedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andExpect( jsonPath("$.data.workflowRun.createdAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) // jobRuns 배열 확인 .andExpect(jsonPath("$.data.jobRuns").isArray()) .andExpect(jsonPath("$.data.jobRuns.length()").value(1)) @@ -83,16 +83,19 @@ void getWorkflowRunDetail_success() throws Exception { .andExpect(jsonPath("$.data.jobRuns[0].jobDescription").value("키워드 검색, 상품 크롤링 및 유사도 분석 작업")) .andExpect(jsonPath("$.data.jobRuns[0].status").value("FAILED")) .andExpect(jsonPath("$.data.jobRuns[0].executionOrder").isEmpty()) - .andExpect(jsonPath("$.data.jobRuns[0].startedAt").value("2025-09-22 18:18:44")) - .andExpect(jsonPath("$.data.jobRuns[0].finishedAt").value("2025-09-22 18:18:44")) + .andExpect(jsonPath("$.data.jobRuns[0].startedAt").value("2025-09-22T18:18:44Z")) + .andExpect(jsonPath("$.data.jobRuns[0].finishedAt").value("2025-09-22T18:18:44Z")) .andExpect(jsonPath("$.data.jobRuns[0].durationMs").value(0)) // JobRun UTC 시간 형식 검증 - 마이크로초 포함 가능 .andExpect( - jsonPath("$.data.jobRuns[0].startedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + jsonPath( + "$.data.jobRuns[0].startedAt", + matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) + // finishedAt 도 동일하게 .andExpect( - jsonPath("$.data.jobRuns[0].finishedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + jsonPath( + "$.data.jobRuns[0].finishedAt", + matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) // taskRuns 배열 확인 .andExpect(jsonPath("$.data.jobRuns[0].taskRuns").isArray()) .andExpect(jsonPath("$.data.jobRuns[0].taskRuns.length()").value(1)) @@ -105,17 +108,18 @@ void getWorkflowRunDetail_success() throws Exception { .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].taskType").value("FastAPI")) .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].status").value("FAILED")) .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].executionOrder").isEmpty()) - .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].startedAt").value("2025-09-22 18:18:44")) .andExpect( - jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt").value("2025-09-22 18:18:44")) + jsonPath("$.data.jobRuns[0].taskRuns[0].startedAt").value("2025-09-22T18:18:44Z")) + .andExpect( + jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt").value("2025-09-22T18:18:44Z")) .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].durationMs").value(0)) // TaskRun UTC 시간 형식 검증 - 마이크로초 포함 가능 .andExpect( jsonPath("$.data.jobRuns[0].taskRuns[0].startedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andExpect( jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andDo( document( "workflow-run-detail", @@ -269,29 +273,29 @@ void getWorkflowRunDetail_utc_time_validation() throws Exception { // WorkflowRun 시간이 UTC 형식인지 검증 - 마이크로초 포함 가능 .andExpect( jsonPath("$.data.workflowRun.startedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andExpect( jsonPath("$.data.workflowRun.finishedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andExpect( jsonPath("$.data.workflowRun.createdAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) // JobRun 시간이 UTC 형식인지 검증 - 마이크로초 포함 가능 .andExpect( jsonPath("$.data.jobRuns[0].startedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andExpect( jsonPath("$.data.jobRuns[0].finishedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) // TaskRun 시간이 UTC 형식인지 검증 - 마이크로초 포함 가능 .andExpect( jsonPath("$.data.jobRuns[0].taskRuns[0].startedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andExpect( jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) // 시간 순서 논리적 검증 (startedAt <= finishedAt) - .andExpect(jsonPath("$.data.workflowRun.startedAt").value("2025-09-22 18:18:43")) - .andExpect(jsonPath("$.data.workflowRun.finishedAt").value("2025-09-22 18:18:44")); + .andExpect(jsonPath("$.data.workflowRun.startedAt").value("2025-09-22T18:18:43Z")) + .andExpect(jsonPath("$.data.workflowRun.finishedAt").value("2025-09-22T18:18:44Z")); } } From bef571987e2182ab568241af4e5daabf5320948b Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 27 Sep 2025 01:05:58 +0900 Subject: [PATCH 19/40] feature : google-api ocr --- .../app/api/endpoints/ocr.py | 102 ++++++++++++ apps/pre-processing-service/app/api/router.py | 4 +- .../app/service/ocr/ChineseOCRTranslator.py | 155 ++++++++++++++++++ .../app/service/ocr/S3Service.py | 57 +++++++ 4 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 apps/pre-processing-service/app/api/endpoints/ocr.py create mode 100644 apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py create mode 100644 apps/pre-processing-service/app/service/ocr/S3Service.py diff --git a/apps/pre-processing-service/app/api/endpoints/ocr.py b/apps/pre-processing-service/app/api/endpoints/ocr.py new file mode 100644 index 00000000..7f59596e --- /dev/null +++ b/apps/pre-processing-service/app/api/endpoints/ocr.py @@ -0,0 +1,102 @@ +# app/api/endpoints/ocr.py +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import List, Optional +from loguru import logger + +from app.service.ocr.ChineseOCRTranslator import ChineseOCRTranslator +from app.service.ocr.S3Service import S3Service + +router = APIRouter() + + +# Request/Response 모델들 +class OCRProcessRequest(BaseModel): + keyword: str + + +class OCRResult(BaseModel): + s3_key: str + chinese_text: str + korean_text: str + success: bool + error: Optional[str] = None + + +class OCRProcessResponse(BaseModel): + keyword: str + total_objects: int + jpg_files_count: int + results: List[OCRResult] + + +@router.post("/process", response_model=OCRProcessResponse) +async def process_ocr_batch(request: OCRProcessRequest): + """ + 키워드로 S3 폴더 조회 → JPG 파일 필터링 → 중국어 OCR → 한국어 번역 (원스톱 처리) + """ + try: + logger.info(f"OCR 배치 처리 시작 - 키워드: {request.keyword}") + + # S3 서비스 및 OCR 서비스 초기화 + s3_service = S3Service(request.keyword) + ocr_service = ChineseOCRTranslator() + + # S3에서 모든 객체 가져오기 + all_objects = s3_service.get_folder_objects() + logger.info(f"총 {len(all_objects)}개 객체 발견") + + # JPG 파일만 필터링 + jpg_files = s3_service.get_jpg_files(all_objects) + logger.info(f"JPG 파일 {len(jpg_files)}개 필터링 완료") + + if not jpg_files: + logger.warning(f"키워드 '{request.keyword}'에 해당하는 JPG 파일이 없습니다.") + return OCRProcessResponse( + keyword=request.keyword, + total_objects=len(all_objects), + jpg_files_count=0, + results=[] + ) + + # 각 JPG 파일 OCR 처리 + results = [] + for jpg_file in jpg_files: + try: + logger.info(f"OCR 처리 중: {jpg_file}") + + # 이미지 데이터 가져오기 + image_data = s3_service.get_image_data(jpg_file) + + # OCR 처리 + result = ocr_service.process_image_from_bytes(image_data) + result["s3_key"] = jpg_file + + results.append(OCRResult(**result)) + logger.info(f"OCR 처리 완료: {jpg_file}") + + except Exception as e: + logger.error(f"OCR 처리 실패 ({jpg_file}): {e}") + results.append(OCRResult( + s3_key=jpg_file, + chinese_text="", + korean_text="", + success=False, + error=str(e) + )) + + logger.info(f"OCR 배치 처리 완료 - 총 {len(results)}개 파일 처리됨") + + return OCRProcessResponse( + keyword=request.keyword, + total_objects=len(all_objects), + jpg_files_count=len(jpg_files), + results=results + ) + + except Exception as e: + logger.error(f"OCR 배치 처리 실패 (키워드: {request.keyword}): {e}") + raise HTTPException( + status_code=500, + detail=f"OCR 처리 중 오류가 발생했습니다: {str(e)}" + ) \ No newline at end of file diff --git a/apps/pre-processing-service/app/api/router.py b/apps/pre-processing-service/app/api/router.py index c1a2fcb4..a1c3b9d2 100644 --- a/apps/pre-processing-service/app/api/router.py +++ b/apps/pre-processing-service/app/api/router.py @@ -1,6 +1,6 @@ # app/api/router.py from fastapi import APIRouter -from .endpoints import keywords, blog, product, test, sample +from .endpoints import keywords, blog, product, test, sample, ocr from ..core.config import settings api_router = APIRouter() @@ -19,6 +19,8 @@ api_router.include_router(sample.router, prefix="/v0", tags=["Sample"]) +api_router.include_router(ocr.router, prefix="/ocr", tags=["OCR"]) + @api_router.get("/ping") async def root(): diff --git a/apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py b/apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py new file mode 100644 index 00000000..cb234141 --- /dev/null +++ b/apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py @@ -0,0 +1,155 @@ +import os +import json +from google.cloud import vision +from google.oauth2 import service_account +from deep_translator import GoogleTranslator +from loguru import logger +import io + + +class ChineseOCRTranslator: + + def __init__(self): + self.translator = GoogleTranslator(source='zh-CN', target='ko') + + # Google Vision API 클라이언트 초기화 + self.vision_client = self._initialize_vision_client() + + def _initialize_vision_client(self): + """Google Vision API 클라이언트 초기화""" + try: + # # 방법 1: 환경변수에서 JSON 문자열로 인증정보 가져오기 + # creds_json = os.getenv('GOOGLE_APPLICATION_CREDENTIALS_JSON') + # if creds_json: + # logger.info("환경변수에서 Google 인증정보 로드") + # creds_dict = json.loads(creds_json) + # if "private_key" in creds_dict: + # while "\\n" in creds_dict["private_key"]: + # creds_dict["private_key"] = creds_dict["private_key"].replace("\\n", "\n") + # credentials = service_account.Credentials.from_service_account_info(creds_dict) + # return vision.ImageAnnotatorClient(credentials=credentials) + + # 방법 2: 파일 경로에서 인증정보 가져오기 + creds_file = os.getenv('GOOGLE_APPLICATION_CREDENTIALS') + if creds_file and os.path.exists(creds_file): + logger.info(f"파일에서 Google 인증정보 로드: {creds_file}") + return vision.ImageAnnotatorClient() + + # 방법 3: 기본 인증 (Cloud Shell, GCE 등) + logger.info("기본 인증으로 Google Vision 클라이언트 초기화") + return vision.ImageAnnotatorClient() + + except Exception as e: + logger.error(f"Google Vision 클라이언트 초기화 실패: {e}") + raise + + def _extract_chinese_text(self, image_path): + """이미지에서 중국어 텍스트 추출 (파일 경로)""" + try: + # 이미지 파일 읽기 + with io.open(image_path, 'rb') as image_file: + content = image_file.read() + + return self._extract_text_from_content(content) + + except Exception as e: + logger.error(f"Error during OCR: {e}") + raise + + def _extract_chinese_text_from_bytes(self, image_data: bytes): + """이미지에서 중국어 텍스트 추출 (바이트 데이터)""" + try: + return self._extract_text_from_content(image_data) + except Exception as e: + logger.error(f"Error during OCR from bytes: {e}") + raise + + def _extract_text_from_content(self, image_content: bytes): + """Google Vision API를 사용해 이미지에서 텍스트 추출""" + try: + # Vision API Image 객체 생성 + image = vision.Image(content=image_content) + + # 텍스트 감지 요청 + response = self.vision_client.text_detection(image=image) + + # 에러 체크 + if response.error.message: + raise Exception(f'Vision API Error: {response.error.message}') + + # 텍스트 추출 + texts = response.text_annotations + if texts: + # 첫 번째 요소가 전체 텍스트 + extracted_text = texts[0].description + logger.info(f"Vision API로 추출된 텍스트: {extracted_text}") + return extracted_text.strip() + else: + logger.warning("이미지에서 텍스트를 찾을 수 없습니다") + return "" + + except Exception as e: + logger.error(f"Vision API 텍스트 추출 오류: {e}") + raise + + def translate_to_korean(self, text): + """중국어 텍스트를 한국어로 번역""" + if not text: + return "" + + try: + result = self.translator.translate(text) + # None 체크 추가 + return result if result is not None else "" + except Exception as e: + logger.error(f"Error during translation: {e}") + return "" # 에러 시 빈 문자열 반환 + + def process_image(self, image_path): + """이미지에서 중국어 텍스트 추출 후 한국어로 번역""" + chinese_text = self._extract_chinese_text(image_path) + logger.info("추출된 중국어 텍스트: " + chinese_text) + + korean_text = self.translate_to_korean(chinese_text) + logger.info("번역된 한국어 텍스트: " + korean_text) + + return { + "chinese_text": chinese_text, + "korean_text": korean_text, + "success": True + } + + def process_image_from_bytes(self, image_data: bytes): + """이미지에서 중국어 텍스트 추출 후 한국어로 번역 (바이트 데이터)""" + chinese_text = self._extract_chinese_text_from_bytes(image_data) + logger.info("추출된 중국어 텍스트: " + chinese_text) + + korean_text = self.translate_to_korean(chinese_text) + # None 체크 추가 + korean_text = korean_text if korean_text is not None else "" + logger.info("번역된 한국어 텍스트: " + korean_text) + + return { + "chinese_text": chinese_text, + "korean_text": korean_text, + "success": True + } + +if __name__ == "__main__": + from S3Service import S3Service + + s3_service = S3Service(keyword="가디건") + ocr = ChineseOCRTranslator() + + object_keys = s3_service.get_folder_objects() + jpg_files = s3_service.get_jpg_files(object_keys) + print(f"JPG 파일 {len(jpg_files)}개 발견") + + results = {} + for key in jpg_files: + print(f"Processing {key}...") + image_data = s3_service.get_image_data(key) + result = ocr.process_image_from_bytes(image_data) + results[key] = result + print(result) + print("------") diff --git a/apps/pre-processing-service/app/service/ocr/S3Service.py b/apps/pre-processing-service/app/service/ocr/S3Service.py new file mode 100644 index 00000000..54176fb9 --- /dev/null +++ b/apps/pre-processing-service/app/service/ocr/S3Service.py @@ -0,0 +1,57 @@ +from datetime import datetime + +import boto3 +import os +from loguru import logger +from typing import List +from dotenv import load_dotenv + +load_dotenv() + +class S3Service: + + def __init__(self, keyword:str): + self.s3_client = boto3.client('s3', + aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"), + aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"), + region_name=os.getenv("AWS_REGION")) + self.bucket_name = os.getenv("S3_BUCKET_NAME") + self.date = datetime.now().strftime("%Y%m%d") + self.keyword = keyword + + def get_folder_objects(self): + """S3 버킷에서 특정 폴더 내의 모든 객체를 가져오는 메서드""" + + try: + response = self.s3_client.list_objects_v2( + Bucket=self.bucket_name, + Prefix=f"product/20250922_{self.keyword}_1" + ) + + objects = [] + if 'Contents' in response: + for obj in response['Contents']: + objects.append(obj['Key']) + + return objects + except Exception as e: + logger.error(f"S3 객체 조회 실패: {e}") + return [] + + def get_jpg_files(self, object_keys: List[str]) -> List[str]: + """객체 키 리스트에서 JPG 파일만 필터링""" + jpg_files = [] + for key in object_keys: + if key.lower().endswith(('.jpg', '.jpeg')): + jpg_files.append(key) + return jpg_files + + def get_image_data(self, key: str) -> bytes: + """S3에서 이미지 데이터 가져오기""" + try: + response = self.s3_client.get_object(Bucket=self.bucket_name, Key=key) + image_data = response['Body'].read() + return image_data + except Exception as e: + logger.error(f"S3 이미지 데이터 가져오기 실패 ({key}): {e}") + raise \ No newline at end of file From abb0631f9654b7be649859101c277fc357ad0f4d Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 27 Sep 2025 01:09:59 +0900 Subject: [PATCH 20/40] feature : easyocr --- .../app/service/ocr/ChineseOCRTranslator.py | 141 ++++++++---------- 1 file changed, 66 insertions(+), 75 deletions(-) diff --git a/apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py b/apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py index cb234141..7426f8da 100644 --- a/apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py +++ b/apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py @@ -1,9 +1,9 @@ import os -import json -from google.cloud import vision -from google.oauth2 import service_account +import easyocr from deep_translator import GoogleTranslator from loguru import logger +import numpy as np +from PIL import Image import io @@ -12,45 +12,38 @@ class ChineseOCRTranslator: def __init__(self): self.translator = GoogleTranslator(source='zh-CN', target='ko') - # Google Vision API 클라이언트 초기화 - self.vision_client = self._initialize_vision_client() + # EasyOCR 리더 초기화 (중국어 간체, 중국어 번체, 영어 지원) + self.ocr_reader = self._initialize_ocr_reader() - def _initialize_vision_client(self): - """Google Vision API 클라이언트 초기화""" + def _initialize_ocr_reader(self): + """EasyOCR 리더 초기화""" try: - # # 방법 1: 환경변수에서 JSON 문자열로 인증정보 가져오기 - # creds_json = os.getenv('GOOGLE_APPLICATION_CREDENTIALS_JSON') - # if creds_json: - # logger.info("환경변수에서 Google 인증정보 로드") - # creds_dict = json.loads(creds_json) - # if "private_key" in creds_dict: - # while "\\n" in creds_dict["private_key"]: - # creds_dict["private_key"] = creds_dict["private_key"].replace("\\n", "\n") - # credentials = service_account.Credentials.from_service_account_info(creds_dict) - # return vision.ImageAnnotatorClient(credentials=credentials) - - # 방법 2: 파일 경로에서 인증정보 가져오기 - creds_file = os.getenv('GOOGLE_APPLICATION_CREDENTIALS') - if creds_file and os.path.exists(creds_file): - logger.info(f"파일에서 Google 인증정보 로드: {creds_file}") - return vision.ImageAnnotatorClient() - - # 방법 3: 기본 인증 (Cloud Shell, GCE 등) - logger.info("기본 인증으로 Google Vision 클라이언트 초기화") - return vision.ImageAnnotatorClient() - + logger.info("EasyOCR 리더 초기화 중...") + # 중국어 간체('ch_sim'), 중국어 번체('ch_tra'), 영어('en') 지원 + reader = easyocr.Reader(['ch_sim', 'en'], gpu=False) + logger.info("EasyOCR 리더 초기화 완료") + return reader except Exception as e: - logger.error(f"Google Vision 클라이언트 초기화 실패: {e}") - raise + logger.warning(f"GPU 모드로 EasyOCR 초기화 실패, CPU 모드로 재시도: {e}") + try: + reader = easyocr.Reader(['ch_sim', 'ch_tra', 'en'], gpu=False) + logger.info("EasyOCR 리더 초기화 완료 (CPU 모드)") + return reader + except Exception as e: + logger.error(f"EasyOCR 리더 초기화 실패: {e}") + raise def _extract_chinese_text(self, image_path): """이미지에서 중국어 텍스트 추출 (파일 경로)""" try: - # 이미지 파일 읽기 - with io.open(image_path, 'rb') as image_file: - content = image_file.read() + # EasyOCR로 텍스트 추출 + results = self.ocr_reader.readtext(image_path) - return self._extract_text_from_content(content) + # 결과에서 텍스트만 추출하여 합치기 + extracted_text = self._process_ocr_results(results) + + logger.info(f"EasyOCR로 추출된 텍스트: {extracted_text}") + return extracted_text except Exception as e: logger.error(f"Error during OCR: {e}") @@ -59,38 +52,47 @@ def _extract_chinese_text(self, image_path): def _extract_chinese_text_from_bytes(self, image_data: bytes): """이미지에서 중국어 텍스트 추출 (바이트 데이터)""" try: - return self._extract_text_from_content(image_data) + # 바이트 데이터를 PIL Image로 변환 + image = Image.open(io.BytesIO(image_data)) + + # PIL Image를 numpy array로 변환 (EasyOCR이 요구하는 형식) + image_array = np.array(image) + + # EasyOCR로 텍스트 추출 + results = self.ocr_reader.readtext(image_array) + + # 결과에서 텍스트만 추출하여 합치기 + extracted_text = self._process_ocr_results(results) + + logger.info(f"EasyOCR로 추출된 텍스트: {extracted_text}") + return extracted_text + except Exception as e: logger.error(f"Error during OCR from bytes: {e}") raise - def _extract_text_from_content(self, image_content: bytes): - """Google Vision API를 사용해 이미지에서 텍스트 추출""" + def _process_ocr_results(self, results): + """EasyOCR 결과를 처리하여 텍스트 추출""" try: - # Vision API Image 객체 생성 - image = vision.Image(content=image_content) - - # 텍스트 감지 요청 - response = self.vision_client.text_detection(image=image) - - # 에러 체크 - if response.error.message: - raise Exception(f'Vision API Error: {response.error.message}') - - # 텍스트 추출 - texts = response.text_annotations - if texts: - # 첫 번째 요소가 전체 텍스트 - extracted_text = texts[0].description - logger.info(f"Vision API로 추출된 텍스트: {extracted_text}") - return extracted_text.strip() - else: + if not results: logger.warning("이미지에서 텍스트를 찾을 수 없습니다") return "" + # EasyOCR 결과는 [bbox, text, confidence] 형태의 리스트 + # confidence가 0.5 이상인 텍스트만 추출 + texts = [] + for (bbox, text, confidence) in results: + if confidence > 0.5: # 신뢰도 임계값 + texts.append(text) + logger.debug(f"추출된 텍스트: '{text}' (신뢰도: {confidence:.2f})") + + # 모든 텍스트를 줄바꿈으로 연결 + extracted_text = '\n'.join(texts) + return extracted_text.strip() + except Exception as e: - logger.error(f"Vision API 텍스트 추출 오류: {e}") - raise + logger.error(f"OCR 결과 처리 오류: {e}") + return "" def translate_to_korean(self, text): """중국어 텍스트를 한국어로 번역""" @@ -135,21 +137,10 @@ def process_image_from_bytes(self, image_data: bytes): "success": True } -if __name__ == "__main__": - from S3Service import S3Service - - s3_service = S3Service(keyword="가디건") - ocr = ChineseOCRTranslator() - - object_keys = s3_service.get_folder_objects() - jpg_files = s3_service.get_jpg_files(object_keys) - print(f"JPG 파일 {len(jpg_files)}개 발견") - - results = {} - for key in jpg_files: - print(f"Processing {key}...") - image_data = s3_service.get_image_data(key) - result = ocr.process_image_from_bytes(image_data) - results[key] = result - print(result) - print("------") + def set_confidence_threshold(self, threshold: float): + """신뢰도 임계값 설정 (0.0 ~ 1.0)""" + if 0.0 <= threshold <= 1.0: + self.confidence_threshold = threshold + logger.info(f"신뢰도 임계값을 {threshold}로 설정") + else: + logger.warning("신뢰도 임계값은 0.0과 1.0 사이의 값이어야 합니다") From d44956a7a447c58920942b15db9ea2eb41281a96 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 27 Sep 2025 02:42:01 +0900 Subject: [PATCH 21/40] =?UTF-8?q?test=20:=20=EC=99=B8=EB=B6=80=EC=A3=BC?= =?UTF-8?q?=EC=9E=85=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pre-processing-service/Dockerfile | 49 +++---- .../app/service/ocr/ChineseOCRTranslator.py | 124 ++++++++---------- 2 files changed, 78 insertions(+), 95 deletions(-) diff --git a/apps/pre-processing-service/Dockerfile b/apps/pre-processing-service/Dockerfile index 6ecb09c8..6e1d7ac0 100644 --- a/apps/pre-processing-service/Dockerfile +++ b/apps/pre-processing-service/Dockerfile @@ -2,13 +2,11 @@ FROM python:3.11-slim AS builder WORKDIR /app -# 필수 OS 패키지 (기존 + Chrome 설치용 패키지 추가) +# OS 패키지 설치 RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ - wget \ - unzip \ - gnupg \ ca-certificates \ + build-essential \ && rm -rf /var/lib/apt/lists/* # Poetry 설치 @@ -24,44 +22,39 @@ ENV PATH="/opt/venv/bin:$PATH" COPY pyproject.toml poetry.lock ./ RUN poetry export --without dev -f requirements.txt -o requirements.txt \ && pip install --no-cache-dir -r requirements.txt - # ---- runtime ---- FROM python:3.11-slim AS final WORKDIR /app -# Chrome과 ChromeDriver 설치를 위한 패키지 설치 +# OS 패키지 설치 RUN apt-get update && apt-get install -y --no-install-recommends \ wget \ unzip \ curl \ gnupg \ ca-certificates \ + libgl1-mesa-dri \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender1 \ + libgomp1 \ + libgthread-2.0-0 \ + ffmpeg \ && rm -rf /var/lib/apt/lists/* -# Chrome 설치 (블로그 방식 - 직접 .deb 파일 다운로드) -RUN wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \ - && apt-get update \ - && apt-get install -y ./google-chrome-stable_current_amd64.deb \ - && rm ./google-chrome-stable_current_amd64.deb \ - && rm -rf /var/lib/apt/lists/* - -# MeCab & 사전 설치 (형태소 분석 의존) -RUN apt-get update && apt-get install -y --no-install-recommends \ - mecab \ - libmecab-dev \ - mecab-ipadic-utf8 \ - && rm -rf /var/lib/apt/lists/* - -# /opt/venv 복사 +# 빌드된 가상환경 복사 COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" -# 앱 소스 +# 앱 소스 복사 COPY . . - -# 환경변수로 MeCab 경로 지정 -ENV MECAB_PATH=/usr/lib/mecab/dic/ipadic - -# (권장 대안) 코드에서 uvicorn import 안 하고 프로세스 매니저로 실행하려면: -ENTRYPOINT ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "app.main:app", "-b", "0.0.0.0:8000", "--timeout", "120"] \ No newline at end of file +# 실행 명령 +ENTRYPOINT ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "app.main:app", \ + "-b", "0.0.0.0:8000", \ + "--timeout", "240", \ + "--workers", "1", \ + "--max-requests", "50", \ + "--max-requests-jitter", "10", \ + "--preload"] \ No newline at end of file diff --git a/apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py b/apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py index 7426f8da..8608e301 100644 --- a/apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py +++ b/apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py @@ -1,9 +1,9 @@ import os -import easyocr +import json +from google.cloud import vision +from google.oauth2 import service_account from deep_translator import GoogleTranslator from loguru import logger -import numpy as np -from PIL import Image import io @@ -12,38 +12,45 @@ class ChineseOCRTranslator: def __init__(self): self.translator = GoogleTranslator(source='zh-CN', target='ko') - # EasyOCR 리더 초기화 (중국어 간체, 중국어 번체, 영어 지원) - self.ocr_reader = self._initialize_ocr_reader() + # Google Vision API 클라이언트 초기화 + self.vision_client = self._initialize_vision_client() - def _initialize_ocr_reader(self): - """EasyOCR 리더 초기화""" + def _initialize_vision_client(self): + """Google Vision API 클라이언트 초기화""" try: - logger.info("EasyOCR 리더 초기화 중...") - # 중국어 간체('ch_sim'), 중국어 번체('ch_tra'), 영어('en') 지원 - reader = easyocr.Reader(['ch_sim', 'en'], gpu=False) - logger.info("EasyOCR 리더 초기화 완료") - return reader + # # 방법 1: 환경변수에서 JSON 문자열로 인증정보 가져오기 + # creds_json = os.getenv('GOOGLE_APPLICATION_CREDENTIALS_JSON') + # if creds_json: + # logger.info("환경변수에서 Google 인증정보 로드") + # creds_dict = json.loads(creds_json) + # if "private_key" in creds_dict: + # while "\\n" in creds_dict["private_key"]: + # creds_dict["private_key"] = creds_dict["private_key"].replace("\\n", "\n") + # credentials = service_account.Credentials.from_service_account_info(creds_dict) + # return vision.ImageAnnotatorClient(credentials=credentials) + + # # 방법 2: 파일 경로에서 인증정보 가져오기 + # creds_file = os.getenv('GOOGLE_APPLICATION_CREDENTIALS') + # if creds_file and os.path.exists(creds_file): + # logger.info(f"파일에서 Google 인증정보 로드: {creds_file}") + # return vision.ImageAnnotatorClient() + + # 방법 3: 기본 인증 (Cloud Shell, GCE 등) + logger.info("기본 인증으로 Google Vision 클라이언트 초기화") + return vision.ImageAnnotatorClient() + except Exception as e: - logger.warning(f"GPU 모드로 EasyOCR 초기화 실패, CPU 모드로 재시도: {e}") - try: - reader = easyocr.Reader(['ch_sim', 'ch_tra', 'en'], gpu=False) - logger.info("EasyOCR 리더 초기화 완료 (CPU 모드)") - return reader - except Exception as e: - logger.error(f"EasyOCR 리더 초기화 실패: {e}") - raise + logger.error(f"Google Vision 클라이언트 초기화 실패: {e}") + raise def _extract_chinese_text(self, image_path): """이미지에서 중국어 텍스트 추출 (파일 경로)""" try: - # EasyOCR로 텍스트 추출 - results = self.ocr_reader.readtext(image_path) + # 이미지 파일 읽기 + with io.open(image_path, 'rb') as image_file: + content = image_file.read() - # 결과에서 텍스트만 추출하여 합치기 - extracted_text = self._process_ocr_results(results) - - logger.info(f"EasyOCR로 추출된 텍스트: {extracted_text}") - return extracted_text + return self._extract_text_from_content(content) except Exception as e: logger.error(f"Error during OCR: {e}") @@ -52,47 +59,38 @@ def _extract_chinese_text(self, image_path): def _extract_chinese_text_from_bytes(self, image_data: bytes): """이미지에서 중국어 텍스트 추출 (바이트 데이터)""" try: - # 바이트 데이터를 PIL Image로 변환 - image = Image.open(io.BytesIO(image_data)) - - # PIL Image를 numpy array로 변환 (EasyOCR이 요구하는 형식) - image_array = np.array(image) - - # EasyOCR로 텍스트 추출 - results = self.ocr_reader.readtext(image_array) - - # 결과에서 텍스트만 추출하여 합치기 - extracted_text = self._process_ocr_results(results) - - logger.info(f"EasyOCR로 추출된 텍스트: {extracted_text}") - return extracted_text - + return self._extract_text_from_content(image_data) except Exception as e: logger.error(f"Error during OCR from bytes: {e}") raise - def _process_ocr_results(self, results): - """EasyOCR 결과를 처리하여 텍스트 추출""" + def _extract_text_from_content(self, image_content: bytes): + """Google Vision API를 사용해 이미지에서 텍스트 추출""" try: - if not results: + # Vision API Image 객체 생성 + image = vision.Image(content=image_content) + + # 텍스트 감지 요청 + response = self.vision_client.text_detection(image=image) + + # 에러 체크 + if response.error.message: + raise Exception(f'Vision API Error: {response.error.message}') + + # 텍스트 추출 + texts = response.text_annotations + if texts: + # 첫 번째 요소가 전체 텍스트 + extracted_text = texts[0].description + logger.info(f"Vision API로 추출된 텍스트: {extracted_text}") + return extracted_text.strip() + else: logger.warning("이미지에서 텍스트를 찾을 수 없습니다") return "" - # EasyOCR 결과는 [bbox, text, confidence] 형태의 리스트 - # confidence가 0.5 이상인 텍스트만 추출 - texts = [] - for (bbox, text, confidence) in results: - if confidence > 0.5: # 신뢰도 임계값 - texts.append(text) - logger.debug(f"추출된 텍스트: '{text}' (신뢰도: {confidence:.2f})") - - # 모든 텍스트를 줄바꿈으로 연결 - extracted_text = '\n'.join(texts) - return extracted_text.strip() - except Exception as e: - logger.error(f"OCR 결과 처리 오류: {e}") - return "" + logger.error(f"Vision API 텍스트 추출 오류: {e}") + raise def translate_to_korean(self, text): """중국어 텍스트를 한국어로 번역""" @@ -136,11 +134,3 @@ def process_image_from_bytes(self, image_data: bytes): "korean_text": korean_text, "success": True } - - def set_confidence_threshold(self, threshold: float): - """신뢰도 임계값 설정 (0.0 ~ 1.0)""" - if 0.0 <= threshold <= 1.0: - self.confidence_threshold = threshold - logger.info(f"신뢰도 임계값을 {threshold}로 설정") - else: - logger.warning("신뢰도 임계값은 0.0과 1.0 사이의 값이어야 합니다") From 76ab50874bf331d88f03a5d8f0e0b45e0adf5864 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 27 Sep 2025 03:05:34 +0900 Subject: [PATCH 22/40] =?UTF-8?q?test=20:=20=EC=99=B8=EB=B6=80=EC=A3=BC?= =?UTF-8?q?=EC=9E=85=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pre-processing-service/poetry.lock | 363 +++++++++++++++------ apps/pre-processing-service/pyproject.toml | 4 + 2 files changed, 263 insertions(+), 104 deletions(-) diff --git a/apps/pre-processing-service/poetry.lock b/apps/pre-processing-service/poetry.lock index f02855bc..16e99d6b 100644 --- a/apps/pre-processing-service/poetry.lock +++ b/apps/pre-processing-service/poetry.lock @@ -150,14 +150,14 @@ files = [ [[package]] name = "anyio" -version = "4.10.0" +version = "4.11.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"}, - {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"}, + {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, + {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, ] [package.dependencies] @@ -166,7 +166,7 @@ sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -trio = ["trio (>=0.26.1)"] +trio = ["trio (>=0.31.0)"] [[package]] name = "asyncpg" @@ -323,18 +323,18 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "boto3" -version = "1.40.35" +version = "1.40.39" description = "The AWS SDK for Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "boto3-1.40.35-py3-none-any.whl", hash = "sha256:f4c1b01dd61e7733b453bca38b004ce030e26ee36e7a3d4a9e45a730b67bc38d"}, - {file = "boto3-1.40.35.tar.gz", hash = "sha256:d718df3591c829bcca4c498abb7b09d64d1eecc4e5a2b6cef14b476501211b8a"}, + {file = "boto3-1.40.39-py3-none-any.whl", hash = "sha256:e2cab5606269fe9f428981892aa592b7e0c087a038774475fa4cd6c8b5fe0a99"}, + {file = "boto3-1.40.39.tar.gz", hash = "sha256:27ca06d4d6f838b056b4935c9eceb92c8d125dbe0e895c5583bcf7130627dcd2"}, ] [package.dependencies] -botocore = ">=1.40.35,<1.41.0" +botocore = ">=1.40.39,<1.41.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.14.0,<0.15.0" @@ -343,14 +343,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.40.35" +version = "1.40.39" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "botocore-1.40.35-py3-none-any.whl", hash = "sha256:c545de2cbbce161f54ca589fbb677bae14cdbfac7d5f1a27f6a620cb057c26f4"}, - {file = "botocore-1.40.35.tar.gz", hash = "sha256:67e062752ff579c8cc25f30f9c3a84c72d692516a41a9ee1cf17735767ca78be"}, + {file = "botocore-1.40.39-py3-none-any.whl", hash = "sha256:144e0e887a9fc198c6772f660fc006028bd1a9ce5eea3caddd848db3e421bc79"}, + {file = "botocore-1.40.39.tar.gz", hash = "sha256:c6efc55cac341811ba90c693d20097db6e2ce903451d94496bccd3f672b1709d"}, ] [package.dependencies] @@ -650,6 +650,27 @@ docs = ["docutils"] pg = ["PyGreSQL (>=5)"] tests = ["pytest (>=7)", "ruff"] +[[package]] +name = "deep-translator" +version = "1.11.4" +description = "A flexible free and unlimited python tool to translate between different languages in a simple way using multiple translators" +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +files = [ + {file = "deep_translator-1.11.4-py3-none-any.whl", hash = "sha256:d635df037e23fa35d12fd42dab72a0b55c9dd19e6292009ee7207e3f30b9e60a"}, + {file = "deep_translator-1.11.4.tar.gz", hash = "sha256:801260c69231138707ea88a0955e484db7d40e210c9e0ae0f77372ffda5f4bf5"}, +] + +[package.dependencies] +beautifulsoup4 = ">=4.9.1,<5.0.0" +requests = ">=2.23.0,<3.0.0" + +[package.extras] +ai = ["openai (>=0.27.6,<0.28.0) ; python_full_version >= \"3.7.1\" and python_full_version < \"4.0.0\""] +docx = ["docx2txt (>=0.8,<0.9)"] +pdf = ["pypdf (>=3.3.0,<4.0.0)"] + [[package]] name = "distro" version = "1.9.0" @@ -698,14 +719,14 @@ files = [ [[package]] name = "flatbuffers" -version = "25.2.10" +version = "25.9.23" description = "The FlatBuffers serialization format for Python" optional = false python-versions = "*" groups = ["main"] files = [ - {file = "flatbuffers-25.2.10-py2.py3-none-any.whl", hash = "sha256:ebba5f4d5ea615af3f7fd70fc310636fbb2bbd1f566ac0a23d98dd412de50051"}, - {file = "flatbuffers-25.2.10.tar.gz", hash = "sha256:97e451377a41262f8d9bd4295cc836133415cc03d8cb966410a4af92eb00d26e"}, + {file = "flatbuffers-25.9.23-py2.py3-none-any.whl", hash = "sha256:255538574d6cb6d0a79a17ec8bc0d30985913b87513a01cce8bcdb6b4c44d0e2"}, + {file = "flatbuffers-25.9.23.tar.gz", hash = "sha256:676f9fa62750bb50cf531b42a0a2a118ad8f7f797a511eda12881c016f093b12"}, ] [[package]] @@ -892,9 +913,11 @@ files = [ [package.dependencies] google-auth = ">=2.14.1,<3.0.0" googleapis-common-protos = ">=1.56.2,<2.0.0" +grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} +grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} proto-plus = [ {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, - {version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""}, + {version = ">=1.22.3,<2.0.0"}, ] protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" requests = ">=2.18.0,<3.0.0" @@ -907,14 +930,14 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] [[package]] name = "google-api-python-client" -version = "2.182.0" +version = "2.183.0" description = "Google API Client Library for Python" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "google_api_python_client-2.182.0-py3-none-any.whl", hash = "sha256:a9b071036d41a17991d8fbf27bedb61f2888a39ae5696cb5a326bf999b2d5209"}, - {file = "google_api_python_client-2.182.0.tar.gz", hash = "sha256:cb2aa127e33c3a31e89a06f39cf9de982db90a98dee020911b21013afafad35f"}, + {file = "google_api_python_client-2.183.0-py3-none-any.whl", hash = "sha256:2005b6e86c27be1db1a43f43e047a0f8e004159f3cceddecb08cf1624bddba31"}, + {file = "google_api_python_client-2.183.0.tar.gz", hash = "sha256:abae37e04fecf719388e5c02f707ed9cdf952f10b217c79a3e76c636762e3ea9"}, ] [package.dependencies] @@ -986,6 +1009,27 @@ requests-oauthlib = ">=0.7.0" [package.extras] tool = ["click (>=6.0.0)"] +[[package]] +name = "google-cloud-vision" +version = "3.10.2" +description = "Google Cloud Vision API client library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_cloud_vision-3.10.2-py3-none-any.whl", hash = "sha256:42a17fbc2219b0a88e325e2c1df6664a8dafcbae66363fb37ebcb511b018fc87"}, + {file = "google_cloud_vision-3.10.2.tar.gz", hash = "sha256:649380faab8933440b632bf88072c0c382a08d49ab02bc0b4fba821882ae1765"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +proto-plus = [ + {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""}, +] +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + [[package]] name = "googleapis-common-protos" version = "1.70.0" @@ -1073,6 +1117,100 @@ files = [ docs = ["Sphinx", "furo"] test = ["objgraph", "psutil", "setuptools"] +[[package]] +name = "grpcio" +version = "1.75.1" +description = "HTTP/2-based RPC framework" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "grpcio-1.75.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:1712b5890b22547dd29f3215c5788d8fc759ce6dd0b85a6ba6e2731f2d04c088"}, + {file = "grpcio-1.75.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8d04e101bba4b55cea9954e4aa71c24153ba6182481b487ff376da28d4ba46cf"}, + {file = "grpcio-1.75.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:683cfc70be0c1383449097cba637317e4737a357cfc185d887fd984206380403"}, + {file = "grpcio-1.75.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:491444c081a54dcd5e6ada57314321ae526377f498d4aa09d975c3241c5b9e1c"}, + {file = "grpcio-1.75.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ce08d4e112d0d38487c2b631ec8723deac9bc404e9c7b1011426af50a79999e4"}, + {file = "grpcio-1.75.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5a2acda37fc926ccc4547977ac3e56b1df48fe200de968e8c8421f6e3093df6c"}, + {file = "grpcio-1.75.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:745c5fe6bf05df6a04bf2d11552c7d867a2690759e7ab6b05c318a772739bd75"}, + {file = "grpcio-1.75.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:259526a7159d39e2db40d566fe3e8f8e034d0fb2db5bf9c00e09aace655a4c2b"}, + {file = "grpcio-1.75.1-cp310-cp310-win32.whl", hash = "sha256:f4b29b9aabe33fed5df0a85e5f13b09ff25e2c05bd5946d25270a8bd5682dac9"}, + {file = "grpcio-1.75.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf2e760978dcce7ff7d465cbc7e276c3157eedc4c27aa6de7b594c7a295d3d61"}, + {file = "grpcio-1.75.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:573855ca2e58e35032aff30bfbd1ee103fbcf4472e4b28d4010757700918e326"}, + {file = "grpcio-1.75.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:6a4996a2c8accc37976dc142d5991adf60733e223e5c9a2219e157dc6a8fd3a2"}, + {file = "grpcio-1.75.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b1ea1bbe77ecbc1be00af2769f4ae4a88ce93be57a4f3eebd91087898ed749f9"}, + {file = "grpcio-1.75.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e5b425aee54cc5e3e3c58f00731e8a33f5567965d478d516d35ef99fd648ab68"}, + {file = "grpcio-1.75.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0049a7bf547dafaeeb1db17079ce79596c298bfe308fc084d023c8907a845b9a"}, + {file = "grpcio-1.75.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b8ea230c7f77c0a1a3208a04a1eda164633fb0767b4cefd65a01079b65e5b1f"}, + {file = "grpcio-1.75.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:36990d629c3c9fb41e546414e5af52d0a7af37ce7113d9682c46d7e2919e4cca"}, + {file = "grpcio-1.75.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b10ad908118d38c2453ade7ff790e5bce36580c3742919007a2a78e3a1e521ca"}, + {file = "grpcio-1.75.1-cp311-cp311-win32.whl", hash = "sha256:d6be2b5ee7bea656c954dcf6aa8093c6f0e6a3ef9945c99d99fcbfc88c5c0bfe"}, + {file = "grpcio-1.75.1-cp311-cp311-win_amd64.whl", hash = "sha256:61c692fb05956b17dd6d1ab480f7f10ad0536dba3bc8fd4e3c7263dc244ed772"}, + {file = "grpcio-1.75.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:7b888b33cd14085d86176b1628ad2fcbff94cfbbe7809465097aa0132e58b018"}, + {file = "grpcio-1.75.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8775036efe4ad2085975531d221535329f5dac99b6c2a854a995456098f99546"}, + {file = "grpcio-1.75.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb658f703468d7fbb5dcc4037c65391b7dc34f808ac46ed9136c24fc5eeb041d"}, + {file = "grpcio-1.75.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4b7177a1cdb3c51b02b0c0a256b0a72fdab719600a693e0e9037949efffb200b"}, + {file = "grpcio-1.75.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d4fa6ccc3ec2e68a04f7b883d354d7fea22a34c44ce535a2f0c0049cf626ddf"}, + {file = "grpcio-1.75.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d86880ecaeb5b2f0a8afa63824de93adb8ebe4e49d0e51442532f4e08add7d6"}, + {file = "grpcio-1.75.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a8041d2f9e8a742aeae96f4b047ee44e73619f4f9d24565e84d5446c623673b6"}, + {file = "grpcio-1.75.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3652516048bf4c314ce12be37423c79829f46efffb390ad64149a10c6071e8de"}, + {file = "grpcio-1.75.1-cp312-cp312-win32.whl", hash = "sha256:44b62345d8403975513af88da2f3d5cc76f73ca538ba46596f92a127c2aea945"}, + {file = "grpcio-1.75.1-cp312-cp312-win_amd64.whl", hash = "sha256:b1e191c5c465fa777d4cafbaacf0c01e0d5278022082c0abbd2ee1d6454ed94d"}, + {file = "grpcio-1.75.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:3bed22e750d91d53d9e31e0af35a7b0b51367e974e14a4ff229db5b207647884"}, + {file = "grpcio-1.75.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5b8f381eadcd6ecaa143a21e9e80a26424c76a0a9b3d546febe6648f3a36a5ac"}, + {file = "grpcio-1.75.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5bf4001d3293e3414d0cf99ff9b1139106e57c3a66dfff0c5f60b2a6286ec133"}, + {file = "grpcio-1.75.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f82ff474103e26351dacfe8d50214e7c9322960d8d07ba7fa1d05ff981c8b2d"}, + {file = "grpcio-1.75.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0ee119f4f88d9f75414217823d21d75bfe0e6ed40135b0cbbfc6376bc9f7757d"}, + {file = "grpcio-1.75.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:664eecc3abe6d916fa6cf8dd6b778e62fb264a70f3430a3180995bf2da935446"}, + {file = "grpcio-1.75.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c32193fa08b2fbebf08fe08e84f8a0aad32d87c3ad42999c65e9449871b1c66e"}, + {file = "grpcio-1.75.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5cebe13088b9254f6e615bcf1da9131d46cfa4e88039454aca9cb65f639bd3bc"}, + {file = "grpcio-1.75.1-cp313-cp313-win32.whl", hash = "sha256:4b4c678e7ed50f8ae8b8dbad15a865ee73ce12668b6aaf411bf3258b5bc3f970"}, + {file = "grpcio-1.75.1-cp313-cp313-win_amd64.whl", hash = "sha256:5573f51e3f296a1bcf71e7a690c092845fb223072120f4bdb7a5b48e111def66"}, + {file = "grpcio-1.75.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:c05da79068dd96723793bffc8d0e64c45f316248417515f28d22204d9dae51c7"}, + {file = "grpcio-1.75.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06373a94fd16ec287116a825161dca179a0402d0c60674ceeec8c9fba344fe66"}, + {file = "grpcio-1.75.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4484f4b7287bdaa7a5b3980f3c7224c3c622669405d20f69549f5fb956ad0421"}, + {file = "grpcio-1.75.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2720c239c1180eee69f7883c1d4c83fc1a495a2535b5fa322887c70bf02b16e8"}, + {file = "grpcio-1.75.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:07a554fa31c668cf0e7a188678ceeca3cb8fead29bbe455352e712ec33ca701c"}, + {file = "grpcio-1.75.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3e71a2105210366bfc398eef7f57a664df99194f3520edb88b9c3a7e46ee0d64"}, + {file = "grpcio-1.75.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8679aa8a5b67976776d3c6b0521e99d1c34db8a312a12bcfd78a7085cb9b604e"}, + {file = "grpcio-1.75.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aad1c774f4ebf0696a7f148a56d39a3432550612597331792528895258966dc0"}, + {file = "grpcio-1.75.1-cp314-cp314-win32.whl", hash = "sha256:62ce42d9994446b307649cb2a23335fa8e927f7ab2cbf5fcb844d6acb4d85f9c"}, + {file = "grpcio-1.75.1-cp314-cp314-win_amd64.whl", hash = "sha256:f86e92275710bea3000cb79feca1762dc0ad3b27830dd1a74e82ab321d4ee464"}, + {file = "grpcio-1.75.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:c09fba33327c3ac11b5c33dbdd8218eef8990d78f83b1656d628831812a8c0fb"}, + {file = "grpcio-1.75.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:7e21400b037be29545704889e72e586c238e346dcb2d08d8a7288d16c883a9ec"}, + {file = "grpcio-1.75.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c12121e509b9f8b0914d10054d24120237d19e870b1cd82acbb8a9b9ddd198a3"}, + {file = "grpcio-1.75.1-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:73577a93e692b3474b1bfe84285d098de36705dbd838bb4d6a056d326e4dc880"}, + {file = "grpcio-1.75.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e19e7dfa0d7ca7dea22be464339e18ac608fd75d88c56770c646cdabe54bc724"}, + {file = "grpcio-1.75.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4e1c28f51c1cf67eccdfc1065e8e866c9ed622f09773ca60947089c117f848a1"}, + {file = "grpcio-1.75.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:030a6164bc2ca726052778c0cf8e3249617a34e368354f9e6107c27ad4af8c28"}, + {file = "grpcio-1.75.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:67697efef5a98d46d5db7b1720fa4043536f8b8e5072a5d61cfca762f287e939"}, + {file = "grpcio-1.75.1-cp39-cp39-win32.whl", hash = "sha256:52015cf73eb5d76f6404e0ce0505a69b51fd1f35810b3a01233b34b10baafb41"}, + {file = "grpcio-1.75.1-cp39-cp39-win_amd64.whl", hash = "sha256:9fe51e4a1f896ea84ac750900eae34d9e9b896b5b1e4a30b02dc31ad29f36383"}, + {file = "grpcio-1.75.1.tar.gz", hash = "sha256:3e81d89ece99b9ace23a6916880baca613c03a799925afb2857887efa8b1b3d2"}, +] + +[package.dependencies] +typing-extensions = ">=4.12,<5.0" + +[package.extras] +protobuf = ["grpcio-tools (>=1.75.1)"] + +[[package]] +name = "grpcio-status" +version = "1.75.1" +description = "Status proto mapping for gRPC" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "grpcio_status-1.75.1-py3-none-any.whl", hash = "sha256:f681b301be26dcf7abf5c765d4a22e4098765e1a65cbdfa3efca384edf8e4e3c"}, + {file = "grpcio_status-1.75.1.tar.gz", hash = "sha256:8162afa21833a2085c91089cc395ad880fac1378a1d60233d976649ed724cbf8"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.5.5" +grpcio = ">=1.75.1" +protobuf = ">=6.31.1,<7.0.0" + [[package]] name = "gunicorn" version = "23.0.0" @@ -1193,14 +1331,14 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "huggingface-hub" -version = "0.35.0" +version = "0.35.1" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.8.0" groups = ["main"] files = [ - {file = "huggingface_hub-0.35.0-py3-none-any.whl", hash = "sha256:f2e2f693bca9a26530b1c0b9bcd4c1495644dad698e6a0060f90e22e772c31e9"}, - {file = "huggingface_hub-0.35.0.tar.gz", hash = "sha256:ccadd2a78eef75effff184ad89401413629fabc52cefd76f6bbacb9b1c0676ac"}, + {file = "huggingface_hub-0.35.1-py3-none-any.whl", hash = "sha256:2f0e2709c711e3040e31d3e0418341f7092910f1462dd00350c4e97af47280a8"}, + {file = "huggingface_hub-0.35.1.tar.gz", hash = "sha256:3585b88c5169c64b7e4214d0e88163d4a709de6d1a502e0cd0459e9ee2c9c572"}, ] [package.dependencies] @@ -1709,30 +1847,34 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] [[package]] name = "onnxruntime" -version = "1.22.1" +version = "1.23.0" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "onnxruntime-1.22.1-cp310-cp310-macosx_13_0_universal2.whl", hash = "sha256:80e7f51da1f5201c1379b8d6ef6170505cd800e40da216290f5e06be01aadf95"}, - {file = "onnxruntime-1.22.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89ddfdbbdaf7e3a59515dee657f6515601d55cb21a0f0f48c81aefc54ff1b73"}, - {file = "onnxruntime-1.22.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bddc75868bcf6f9ed76858a632f65f7b1846bdcefc6d637b1e359c2c68609964"}, - {file = "onnxruntime-1.22.1-cp310-cp310-win_amd64.whl", hash = "sha256:01e2f21b2793eb0c8642d2be3cee34cc7d96b85f45f6615e4e220424158877ce"}, - {file = "onnxruntime-1.22.1-cp311-cp311-macosx_13_0_universal2.whl", hash = "sha256:f4581bccb786da68725d8eac7c63a8f31a89116b8761ff8b4989dc58b61d49a0"}, - {file = "onnxruntime-1.22.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ae7526cf10f93454beb0f751e78e5cb7619e3b92f9fc3bd51aa6f3b7a8977e5"}, - {file = "onnxruntime-1.22.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f6effa1299ac549a05c784d50292e3378dbbf010346ded67400193b09ddc2f04"}, - {file = "onnxruntime-1.22.1-cp311-cp311-win_amd64.whl", hash = "sha256:f28a42bb322b4ca6d255531bb334a2b3e21f172e37c1741bd5e66bc4b7b61f03"}, - {file = "onnxruntime-1.22.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:a938d11c0dc811badf78e435daa3899d9af38abee950d87f3ab7430eb5b3cf5a"}, - {file = "onnxruntime-1.22.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984cea2a02fcc5dfea44ade9aca9fe0f7a8a2cd6f77c258fc4388238618f3928"}, - {file = "onnxruntime-1.22.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d39a530aff1ec8d02e365f35e503193991417788641b184f5b1e8c9a6d5ce8d"}, - {file = "onnxruntime-1.22.1-cp312-cp312-win_amd64.whl", hash = "sha256:6a64291d57ea966a245f749eb970f4fa05a64d26672e05a83fdb5db6b7d62f87"}, - {file = "onnxruntime-1.22.1-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:d29c7d87b6cbed8fecfd09dca471832384d12a69e1ab873e5effbb94adc3e966"}, - {file = "onnxruntime-1.22.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:460487d83b7056ba98f1f7bac80287224c31d8149b15712b0d6f5078fcc33d0f"}, - {file = "onnxruntime-1.22.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0c37070268ba4e02a1a9d28560cd00cd1e94f0d4f275cbef283854f861a65fa"}, - {file = "onnxruntime-1.22.1-cp313-cp313-win_amd64.whl", hash = "sha256:70980d729145a36a05f74b573435531f55ef9503bcda81fc6c3d6b9306199982"}, - {file = "onnxruntime-1.22.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a7980bbc4b7f446bac26c3785652fe8730ed02617d765399e89ac7d44e0f7d"}, - {file = "onnxruntime-1.22.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7e823624b015ea879d976cbef8bfaed2f7e2cc233d7506860a76dd37f8f381"}, + {file = "onnxruntime-1.23.0-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:009bf5ecad107a7f11af8214fcff19e844214887b38c6673bd63a25af2f6121f"}, + {file = "onnxruntime-1.23.0-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:9f875c93891200a946a3387d2c66c66668b9b60a1a053a83d4ee025d8b8892de"}, + {file = "onnxruntime-1.23.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c613fd9280e506d237f7701c1275b6ff30f517a523ced62d1def11a8cf5acf7c"}, + {file = "onnxruntime-1.23.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8984f38de1a2d57fead5c791c5a6e1921dadfe0bc9f5ea26a5acfcc78908e3e9"}, + {file = "onnxruntime-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:08efde1dd5c4881aaf49e79cd2f03d0cd977e8f657217e2796c343c06fefac51"}, + {file = "onnxruntime-1.23.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:ecf8c589d7d55bd645237442a97c9a2b4bd35bab35b20fc7f2bc81b70c062071"}, + {file = "onnxruntime-1.23.0-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:b703c42e6aee8d58d23b39ea856c4202173fcd4260e87fe08fc1d4e983d76f92"}, + {file = "onnxruntime-1.23.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8634c5f54774df1e4d1debfdf2ca8f3274fe4ffc816ff5f861c01c48468a2c4"}, + {file = "onnxruntime-1.23.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c681ab5ae4fce92d09f4a86ac088a18ea36f8739115b8abf55e557cb6729e97"}, + {file = "onnxruntime-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:a91e14627c08fbbde3c54fbce21e0903ce07a985f664f24d097cbfb01a930a69"}, + {file = "onnxruntime-1.23.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:5921f2e106f5faf2b32095b2ecdfae047e445c3bce063e439dadc75c212e7be7"}, + {file = "onnxruntime-1.23.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:053df2f9c6522b258055bce4b776aa9ea3adb4b28d2530ab07b204a3d4b04bf9"}, + {file = "onnxruntime-1.23.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:974e327ca3b6d43da404b9a45df1f61e2503667fde46843ee7ad1567a98f3f0b"}, + {file = "onnxruntime-1.23.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f67edb93678cab5cd77eda89b65bb1b58f3d4c0742058742cfad8b172cfa83"}, + {file = "onnxruntime-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:e100f3869da4c12b17a9b942934a96a542406f860eb8beb74a68342ea43aaa55"}, + {file = "onnxruntime-1.23.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:b6659f17326e64f2902cd31aa5efc1af41d0e0e3bd1357a75985e358412c35ca"}, + {file = "onnxruntime-1.23.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:9ef62369a0261aa15b1399addaaf17ed398e4e2128c8548fafcd73aac13820fd"}, + {file = "onnxruntime-1.23.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0edee45d4119f7a6f187dc1b63e177e3e6c76932446006fd4f3e81540f260dfa"}, + {file = "onnxruntime-1.23.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2dc1993aa91d665faf2b17772e4e29a2999821e110c0e3d17e2b1c00d0e7f48"}, + {file = "onnxruntime-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:e52c8603c4cc74746ece9966102e4fc6c2b355efc0102a9deb107f3ff86680af"}, + {file = "onnxruntime-1.23.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24ac2a8b2c6dd00a152a08a9cf1ba3f06b38915f6cb6cf1adbe714e16e5ff460"}, + {file = "onnxruntime-1.23.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ed85686e08cfb29ee96365b9a49e8a350aff7557c13d63d9f07ca3ad68975074"}, ] [package.dependencies] @@ -1745,14 +1887,14 @@ sympy = "*" [[package]] name = "openai" -version = "1.108.1" +version = "1.109.1" description = "The official Python library for the openai API" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "openai-1.108.1-py3-none-any.whl", hash = "sha256:952fc027e300b2ac23be92b064eac136a2bc58274cec16f5d2906c361340d59b"}, - {file = "openai-1.108.1.tar.gz", hash = "sha256:6648468c1aec4eacfa554001e933a9fa075f57bacfc27588c2e34456cee9fef9"}, + {file = "openai-1.109.1-py3-none-any.whl", hash = "sha256:6bcaf57086cf59159b8e27447e4e7dd019db5d29a438072fbd49c290c7e65315"}, + {file = "openai-1.109.1.tar.gz", hash = "sha256:d173ed8dbca665892a6db099b4a2dfac624f94d20a93f46eb0b56aae940ed869"}, ] [package.dependencies] @@ -2285,14 +2427,14 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pydantic-settings" -version = "2.10.1" +version = "2.11.0" description = "Settings management using Pydantic" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796"}, - {file = "pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee"}, + {file = "pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c"}, + {file = "pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180"}, ] [package.dependencies] @@ -2355,14 +2497,14 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pyperclip" -version = "1.10.0" +version = "1.11.0" description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" optional = false python-versions = "*" groups = ["main"] files = [ - {file = "pyperclip-1.10.0-py3-none-any.whl", hash = "sha256:596fbe55dc59263bff26e61d2afbe10223e2fccb5210c9c96a28d6887cfcc7ec"}, - {file = "pyperclip-1.10.0.tar.gz", hash = "sha256:180c8346b1186921c75dfd14d9048a6b5d46bfc499778811952c6dd6eb1ca6be"}, + {file = "pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273"}, + {file = "pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6"}, ] [[package]] @@ -2463,65 +2605,78 @@ dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "t [[package]] name = "pyyaml" -version = "6.0.2" +version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] [[package]] @@ -3526,4 +3681,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.14" -content-hash = "fe9799a3d3a101e05d75d5e193c6e9e4ef17a7581cb273f41101e12129f80a2f" +content-hash = "e9a01f0153fc072624975c8d42f585bb18b4122b9b762868712eaa5d21a66d13" diff --git a/apps/pre-processing-service/pyproject.toml b/apps/pre-processing-service/pyproject.toml index 8cb11c0f..2db7750e 100644 --- a/apps/pre-processing-service/pyproject.toml +++ b/apps/pre-processing-service/pyproject.toml @@ -39,6 +39,10 @@ aiohttp = "^3.12.15" prometheus-client = "^0.23.1" prometheus-fastapi-instrumentator = "^7.1.0" boto3 = "^1.40.35" +deep-translator = "^1.11.4" +google-cloud-vision = "^3.10.2" +google-auth = "^2.40.3" + [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] From a905f9deb1b2e1b38e0277ad79753f94aba7687a Mon Sep 17 00:00:00 2001 From: Yousung Jung Date: Sat, 27 Sep 2025 13:09:21 +0900 Subject: [PATCH 23/40] =?UTF-8?q?ExecutionLog=20API=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EB=B0=8F=20traceId=20=EC=9D=BC=EA=B4=80=EC=84=B1=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20(#215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Execution log api * fix: Workflow run 시 trace id가 달라지던 버그 MDC에서 trace id를 가져오도록 설정 없다면 uuid 재발급 --- .../domain/log/mapper/ExecutionLogMapper.java | 13 ++ .../log/service/ExecutionLogService.java | 21 ++ .../controller/WorkflowHistoryController.java | 14 ++ .../domain/workflow/dto/ExecutionLogDto.java | 2 + .../dto/log/ExecutionLogSimpleDto.java | 34 +++ .../workflow/dto/log/ExecutionType.java | 7 + .../dto/log/TaskExecutionMessagesDto.java | 15 ++ .../dto/log/WorkflowLogQueryCriteria.java | 17 ++ .../domain/workflow/model/WorkflowRun.java | 5 +- .../mybatis/mapper/ExecutionLogMapper.xml | 55 +++++ .../sql/data/06-insert-execution-log-h2.sql | 38 ++++ .../ExecutionLogApiIntegrationTest.java | 201 ++++++++++++++++++ 12 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 apps/user-service/src/main/java/site/icebang/domain/log/mapper/ExecutionLogMapper.java create mode 100644 apps/user-service/src/main/java/site/icebang/domain/log/service/ExecutionLogService.java create mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/ExecutionLogSimpleDto.java create mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/ExecutionType.java create mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/TaskExecutionMessagesDto.java create mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/WorkflowLogQueryCriteria.java create mode 100644 apps/user-service/src/main/resources/mybatis/mapper/ExecutionLogMapper.xml create mode 100644 apps/user-service/src/main/resources/sql/data/06-insert-execution-log-h2.sql create mode 100644 apps/user-service/src/test/java/site/icebang/integration/tests/workflow/ExecutionLogApiIntegrationTest.java diff --git a/apps/user-service/src/main/java/site/icebang/domain/log/mapper/ExecutionLogMapper.java b/apps/user-service/src/main/java/site/icebang/domain/log/mapper/ExecutionLogMapper.java new file mode 100644 index 00000000..772c47e2 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/log/mapper/ExecutionLogMapper.java @@ -0,0 +1,13 @@ +package site.icebang.domain.log.mapper; + +import java.util.List; + +import org.apache.ibatis.annotations.Mapper; + +import site.icebang.domain.workflow.dto.ExecutionLogDto; +import site.icebang.domain.workflow.dto.log.WorkflowLogQueryCriteria; + +@Mapper +public interface ExecutionLogMapper { + List selectLogsByCriteria(WorkflowLogQueryCriteria criteria); +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/log/service/ExecutionLogService.java b/apps/user-service/src/main/java/site/icebang/domain/log/service/ExecutionLogService.java new file mode 100644 index 00000000..7cd9a820 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/log/service/ExecutionLogService.java @@ -0,0 +1,21 @@ +package site.icebang.domain.log.service; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import site.icebang.domain.log.mapper.ExecutionLogMapper; +import site.icebang.domain.workflow.dto.ExecutionLogDto; +import site.icebang.domain.workflow.dto.log.WorkflowLogQueryCriteria; + +@Service +@RequiredArgsConstructor +public class ExecutionLogService { + private final ExecutionLogMapper executionLogMapper; + + public List getRawLogs(WorkflowLogQueryCriteria criteria) { + return executionLogMapper.selectLogsByCriteria(criteria); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowHistoryController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowHistoryController.java index 07d4f20e..0f8535cf 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowHistoryController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowHistoryController.java @@ -1,14 +1,20 @@ package site.icebang.domain.workflow.controller; +import java.util.List; + import org.springframework.web.bind.annotation.*; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import site.icebang.common.dto.ApiResponse; import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; +import site.icebang.domain.log.service.ExecutionLogService; import site.icebang.domain.workflow.dto.WorkflowHistoryDTO; import site.icebang.domain.workflow.dto.WorkflowRunDetailResponse; +import site.icebang.domain.workflow.dto.log.ExecutionLogSimpleDto; +import site.icebang.domain.workflow.dto.log.WorkflowLogQueryCriteria; import site.icebang.domain.workflow.service.WorkflowHistoryService; @RestController @@ -16,6 +22,7 @@ @RequiredArgsConstructor public class WorkflowHistoryController { private final WorkflowHistoryService workflowHistoryService; + private final ExecutionLogService executionLogService; @GetMapping("") public ApiResponse> getWorkflowHistoryList( @@ -35,4 +42,11 @@ public ApiResponse getWorkflowRunDetail(@PathVariable WorkflowRunDetailResponse response = workflowHistoryService.getWorkflowRunDetail(runId); return ApiResponse.success(response); } + + @GetMapping("/logs") + public ApiResponse> getTaskExecutionLog( + @Valid @ModelAttribute WorkflowLogQueryCriteria requestDto) { + return ApiResponse.success( + ExecutionLogSimpleDto.from(executionLogService.getRawLogs(requestDto))); + } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java index 7c8595a3..cbe6b2f7 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java @@ -21,4 +21,6 @@ public class ExecutionLogDto { private String logMessage; private Instant executedAt; private Integer durationMs; + private String traceId; + private String errorCode; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/ExecutionLogSimpleDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/ExecutionLogSimpleDto.java new file mode 100644 index 00000000..152de8e4 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/ExecutionLogSimpleDto.java @@ -0,0 +1,34 @@ +package site.icebang.domain.workflow.dto.log; + +import java.time.Instant; +import java.util.List; +import java.util.stream.Collectors; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import site.icebang.domain.workflow.dto.ExecutionLogDto; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExecutionLogSimpleDto { + private String logLevel; + private String logMessage; + private Instant executedAt; + + public static ExecutionLogSimpleDto from(ExecutionLogDto executionLogDto) { + return ExecutionLogSimpleDto.builder() + .logLevel(executionLogDto.getLogLevel()) + .logMessage(executionLogDto.getLogMessage()) + .executedAt(executionLogDto.getExecutedAt()) + .build(); + } + + public static List from(List executionLogList) { + return executionLogList.stream().map(ExecutionLogSimpleDto::from).collect(Collectors.toList()); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/ExecutionType.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/ExecutionType.java new file mode 100644 index 00000000..e7dbd659 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/ExecutionType.java @@ -0,0 +1,7 @@ +package site.icebang.domain.workflow.dto.log; + +public enum ExecutionType { + WORKFLOW, + JOB, + TASK +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/TaskExecutionMessagesDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/TaskExecutionMessagesDto.java new file mode 100644 index 00000000..4f51f07d --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/TaskExecutionMessagesDto.java @@ -0,0 +1,15 @@ +package site.icebang.domain.workflow.dto.log; + +import java.util.List; +import java.util.stream.Collectors; + +import site.icebang.domain.workflow.dto.ExecutionLogDto; + +public record TaskExecutionMessagesDto(List messages) { + public static TaskExecutionMessagesDto from(List executionLogList) { + List messages = + executionLogList.stream().map(ExecutionLogDto::getLogMessage).collect(Collectors.toList()); + + return new TaskExecutionMessagesDto(messages); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/WorkflowLogQueryCriteria.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/WorkflowLogQueryCriteria.java new file mode 100644 index 00000000..f2c2ed06 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/WorkflowLogQueryCriteria.java @@ -0,0 +1,17 @@ +package site.icebang.domain.workflow.dto.log; + +import java.math.BigInteger; + +import jakarta.validation.constraints.Pattern; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class WorkflowLogQueryCriteria { + private final String traceId; + private final BigInteger sourceId; + + @Pattern(regexp = "^(WORKFLOW|JOB|TASK)$", message = "실행 타입은 WORKFLOW, JOB, TASK 중 하나여야 합니다") + private final String executionType; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java index 5741e77b..1c3a0796 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java @@ -3,6 +3,8 @@ import java.time.Instant; import java.util.UUID; +import org.slf4j.MDC; + import lombok.Getter; import lombok.NoArgsConstructor; @@ -20,7 +22,8 @@ public class WorkflowRun { private WorkflowRun(Long workflowId) { this.workflowId = workflowId; - this.traceId = UUID.randomUUID().toString(); // 고유 추적 ID 생성 + // MDC에서 현재 요청의 traceId를 가져오거나, 없으면 새로 생성 + this.traceId = MDC.get("traceId") != null ? MDC.get("traceId") : UUID.randomUUID().toString(); this.status = "RUNNING"; this.startedAt = Instant.now(); this.createdAt = this.startedAt; diff --git a/apps/user-service/src/main/resources/mybatis/mapper/ExecutionLogMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/ExecutionLogMapper.xml new file mode 100644 index 00000000..4c1ff830 --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/ExecutionLogMapper.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/data/06-insert-execution-log-h2.sql b/apps/user-service/src/main/resources/sql/data/06-insert-execution-log-h2.sql new file mode 100644 index 00000000..8dac68c8 --- /dev/null +++ b/apps/user-service/src/main/resources/sql/data/06-insert-execution-log-h2.sql @@ -0,0 +1,38 @@ +-- execution_log 테스트 데이터 (H2용) +INSERT INTO execution_log (execution_type, source_id, log_level, executed_at, log_message, trace_id, run_id, status, duration_ms, error_code) VALUES +('WORKFLOW', 1, 'INFO', '2025-09-26 12:42:02.000', '========== 워크플로우 실행 시작: WorkflowId=1 ==========', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('WORKFLOW', 1, 'INFO', '2025-09-26 12:42:02.000', '총 2개의 Job을 순차적으로 실행합니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('JOB', 1, 'INFO', '2025-09-26 12:42:02.000', '---------- Job 실행 시작: JobId=1, JobRunId=1 ----------', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('JOB', 1, 'INFO', '2025-09-26 12:42:02.000', 'Job (JobRunId=1) 내 총 7개의 Task를 순차 실행합니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 1, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시작: TaskId=1, Name=키워드 검색 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 1, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시도 #1: TaskId=1, TaskRunId=1', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 1, 'ERROR', '2025-09-26 12:42:02.000', 'Task 최종 실패: TaskRunId=1, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 2, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시작: TaskId=2, Name=상품 검색 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 2, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시도 #1: TaskId=2, TaskRunId=2', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 2, 'ERROR', '2025-09-26 12:42:02.000', 'Task 최종 실패: TaskRunId=2, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 3, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시작: TaskId=3, Name=상품 매칭 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 3, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시도 #1: TaskId=3, TaskRunId=3', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 3, 'ERROR', '2025-09-26 12:42:02.000', 'Task 최종 실패: TaskRunId=3, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 4, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시작: TaskId=4, Name=상품 유사도 분석 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 4, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시도 #1: TaskId=4, TaskRunId=4', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 4, 'ERROR', '2025-09-26 12:42:02.000', 'Task 최종 실패: TaskRunId=4, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 5, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시작: TaskId=5, Name=상품 정보 크롤링 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 5, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시도 #1: TaskId=5, TaskRunId=5', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 5, 'ERROR', '2025-09-26 12:42:02.000', 'Task 최종 실패: TaskRunId=5, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 6, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시작: TaskId=6, Name=S3 업로드 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 6, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시도 #1: TaskId=6, TaskRunId=6', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 6, 'ERROR', '2025-09-26 12:42:02.000', 'Task 최종 실패: TaskRunId=6, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 7, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시작: TaskId=7, Name=상품 선택 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 7, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시도 #1: TaskId=7, TaskRunId=7', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 7, 'ERROR', '2025-09-26 12:42:03.000', 'Task 최종 실패: TaskRunId=7, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('JOB', 1, 'ERROR', '2025-09-26 12:42:03.000', 'Job 실행 실패: JobRunId=1', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('JOB', 2, 'INFO', '2025-09-26 12:42:03.000', '---------- Job 실행 시작: JobId=2, JobRunId=2 ----------', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('JOB', 2, 'INFO', '2025-09-26 12:42:03.000', 'Job (JobRunId=2) 내 총 2개의 Task를 순차 실행합니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 8, 'INFO', '2025-09-26 12:42:03.000', 'Task 실행 시작: TaskId=8, Name=블로그 RAG 생성 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 8, 'INFO', '2025-09-26 12:42:03.000', 'Task 실행 시도 #1: TaskId=8, TaskRunId=8', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 8, 'ERROR', '2025-09-26 12:42:03.000', 'Task 최종 실패: TaskRunId=8, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 9, 'INFO', '2025-09-26 12:42:03.000', 'Task 실행 시작: TaskId=9, Name=블로그 발행 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 9, 'INFO', '2025-09-26 12:42:03.000', 'Task 실행 시도 #1: TaskId=9, TaskRunId=9', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 9, 'ERROR', '2025-09-26 12:42:03.000', 'Task 최종 실패: TaskRunId=9, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('JOB', 2, 'ERROR', '2025-09-26 12:42:03.000', 'Job 실행 실패: JobRunId=2', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('WORKFLOW', 1, 'INFO', '2025-09-26 12:42:03.000', '========== 워크플로우 실행 실패 : WorkflowRunId=1 ==========', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL); \ No newline at end of file diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/ExecutionLogApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/ExecutionLogApiIntegrationTest.java new file mode 100644 index 00000000..39203494 --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/ExecutionLogApiIntegrationTest.java @@ -0,0 +1,201 @@ +package site.icebang.integration.tests.workflow; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; + +import site.icebang.integration.setup.support.IntegrationTestSupport; + +@Sql( + value = { + "classpath:sql/data/01-insert-internal-users.sql", + "classpath:sql/data/06-insert-execution-log-h2.sql" + }, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Transactional +public class ExecutionLogApiIntegrationTest extends IntegrationTestSupport { + + @Test + @DisplayName("실행 로그 조회 성공 - 전체 조회") + @WithUserDetails("admin@icebang.site") + void getTaskExecutionLog_success() throws Exception { + // when & then + mockMvc + .perform( + get(getApiUrlForDocs("/v0/workflow-runs/logs")) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(36)) + // 첫 번째 로그 검증 + .andExpect(jsonPath("$.data[0].logLevel").exists()) + .andExpect(jsonPath("$.data[0].logMessage").exists()) + .andExpect(jsonPath("$.data[0].executedAt").exists()) + .andDo( + document( + "execution-log-all", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Workflow History") + .summary("실행 로그 전체 조회") + .description("워크플로우 실행 로그를 상세 정보와 함께 조회합니다") + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.ARRAY).description("실행 로그 목록"), + fieldWithPath("data[].logLevel") + .type(JsonFieldType.STRING) + .description("로그 레벨 (INFO, ERROR, WARN, DEBUG)"), + fieldWithPath("data[].logMessage") + .type(JsonFieldType.STRING) + .description("로그 메시지"), + fieldWithPath("data[].executedAt") + .type(JsonFieldType.STRING) + .description("실행 시간 (UTC ISO-8601)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("실행 로그 조회 성공 - traceId 필터링") + @WithUserDetails("admin@icebang.site") + void getTaskExecutionLog_withTraceId_success() throws Exception { + // when & then + mockMvc + .perform( + get(getApiUrlForDocs("/v0/workflow-runs/logs")) + .param("traceId", "68d60b8a2f4cd59a880cf71f189b4ca5") + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(36)) + .andDo( + document( + "execution-log-by-trace-id", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Workflow History") + .summary("실행 로그 조회 - traceId 필터") + .description("특정 traceId로 워크플로우 실행 로그를 필터링하여 조회합니다") + .queryParameters( + parameterWithName("traceId").description("추적 ID").optional()) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.ARRAY).description("실행 로그 목록"), + fieldWithPath("data[].logLevel") + .type(JsonFieldType.STRING) + .description("로그 레벨 (INFO, ERROR, WARN, DEBUG)"), + fieldWithPath("data[].logMessage") + .type(JsonFieldType.STRING) + .description("로그 메시지"), + fieldWithPath("data[].executedAt") + .type(JsonFieldType.STRING) + .description("실행 시간 (UTC ISO-8601)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("실행 로그 조회 성공 - executionType 필터링") + @WithUserDetails("admin@icebang.site") + void getTaskExecutionLog_withExecutionType_success() throws Exception { + // when & then + mockMvc + .perform( + get(getApiUrlForDocs("/v0/workflow-runs/logs")) + .param("executionType", "TASK") + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(27)) // TASK 타입 로그만 + .andDo( + document( + "execution-log-by-execution-type", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Workflow History") + .summary("실행 로그 조회 - executionType 필터") + .description("특정 executionType으로 워크플로우 실행 로그를 필터링하여 조회합니다") + .queryParameters( + parameterWithName("executionType") + .description("실행 타입 (WORKFLOW, JOB, TASK)") + .optional()) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.ARRAY).description("실행 로그 목록"), + fieldWithPath("data[].logLevel") + .type(JsonFieldType.STRING) + .description("로그 레벨 (INFO, ERROR, WARN, DEBUG)"), + fieldWithPath("data[].logMessage") + .type(JsonFieldType.STRING) + .description("로그 메시지"), + fieldWithPath("data[].executedAt") + .type(JsonFieldType.STRING) + .description("실행 시간 (UTC ISO-8601)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("실행 로그 조회 실패 - 잘못된 executionType") + @WithUserDetails("admin@icebang.site") + void getTaskExecutionLog_withInvalidExecutionType_fail() throws Exception { + // when & then + mockMvc + .perform( + get("/v0/workflow-runs/logs") + .param("executionType", "INVALID_TYPE") + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } +} From 61150f98ee9c16499b560f464ddf6f297a77f4f4 Mon Sep 17 00:00:00 2001 From: Yousung Jung Date: Sat, 27 Sep 2025 16:32:37 +0900 Subject: [PATCH 24/40] =?UTF-8?q?User=20=EA=B4=80=EB=A0=A8=20api=20test=20?= =?UTF-8?q?=EB=B0=8F=20api=20document=20=EC=9E=91=EC=84=B1=20(#217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: User 개인 정보 조회 api * fix: Check email reqeust Mapper에서 user가 아닌 users로 table 이름을 사용했던 버그 `@NoArgsConstructor`를 통해 직렬화가 실패하던 버그 * test: User check email api --- .../domain/user/dto/CheckEmailRequest.java | 2 + .../domain/user/mapper/UserMapper.java | 2 +- .../tests/user/UserApiIntegrationTest.java | 227 ++++++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 apps/user-service/src/test/java/site/icebang/integration/tests/user/UserApiIntegrationTest.java diff --git a/apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailRequest.java b/apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailRequest.java index f3b2c2a1..fb4c9844 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailRequest.java +++ b/apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailRequest.java @@ -4,9 +4,11 @@ import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; @Data @AllArgsConstructor +@NoArgsConstructor public class CheckEmailRequest { @NotBlank(message = "이메일은 필수입니다") @Email(message = "올바른 이메일 형식이 아닙니다") diff --git a/apps/user-service/src/main/java/site/icebang/domain/user/mapper/UserMapper.java b/apps/user-service/src/main/java/site/icebang/domain/user/mapper/UserMapper.java index d2e14012..75df3852 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/user/mapper/UserMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/user/mapper/UserMapper.java @@ -6,6 +6,6 @@ @Mapper public interface UserMapper { - @Select("SELECT COUNT(1) > 0 FROM users WHERE email = #{email}") + @Select("SELECT COUNT(1) > 0 FROM user WHERE email = #{email}") boolean existsByEmail(@Param("email") String email); } diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/user/UserApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/user/UserApiIntegrationTest.java new file mode 100644 index 00000000..8b958437 --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/user/UserApiIntegrationTest.java @@ -0,0 +1,227 @@ +package site.icebang.integration.tests.user; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; + +import site.icebang.integration.setup.support.IntegrationTestSupport; + +@Sql( + value = { + "classpath:sql/data/01-insert-internal-users.sql", + "classpath:sql/data/02-insert-external-users.sql" + }, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Transactional +public class UserApiIntegrationTest extends IntegrationTestSupport { + + @Test + @DisplayName("유저 자신의 정보 조회 성공") + @WithUserDetails("admin@icebang.site") + void getUserProfile_success() throws Exception { + // when & then + mockMvc + .perform( + get(getApiUrlForDocs("/v0/users/me")) + .contentType(MediaType.APPLICATION_JSON) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.id").exists()) + .andExpect(jsonPath("$.data.email").value("admin@icebang.site")) + .andExpect(jsonPath("$.data.name").exists()) + .andExpect(jsonPath("$.data.roles").exists()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andDo( + document( + "user-profile", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("User") + .summary("사용자 프로필 조회") + .description("현재 로그인한 사용자의 프로필 정보를 조회합니다") + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("사용자 정보"), + fieldWithPath("data.id") + .type(JsonFieldType.NUMBER) + .description("사용자 ID"), + fieldWithPath("data.email") + .type(JsonFieldType.STRING) + .description("사용자 이메일"), + fieldWithPath("data.name") + .type(JsonFieldType.STRING) + .description("사용자 이름"), + fieldWithPath("data.roles") + .type(JsonFieldType.ARRAY) + .description("사용자 권한 목록"), + fieldWithPath("data.status") + .type(JsonFieldType.STRING) + .description("사용자 상태 (ACTIVE, INACTIVE)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("이메일 중복 검사 성공 - 사용 가능한 이메일") + @WithUserDetails("admin@icebang.site") + void checkEmailAvailable_success() throws Exception { + String requestBody = + """ + { + "email": "newuser@example.com" + } + """; + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/users/check-email")) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.message").value("사용 가능한 이메일입니다.")) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.available").value(true)) + .andDo( + document( + "check-email-available", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("User") + .summary("이메일 중복 검사") + .description("사용자 회원가입 전 이메일 중복 여부를 확인합니다") + .requestFields( + fieldWithPath("email") + .type(JsonFieldType.STRING) + .description("검사할 이메일 주소")) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.available") + .type(JsonFieldType.BOOLEAN) + .description("이메일 사용 가능 여부 (true: 사용 가능, false: 이미 사용 중)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("이메일 중복 검사 성공 - 이미 사용 중인 이메일") + @WithUserDetails("admin@icebang.site") + void checkEmailAvailable_alreadyExists() throws Exception { + String requestBody = + """ + { + "email": "admin@icebang.site" + } + """; + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/users/check-email")) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.message").value("이미 가입된 이메일입니다.")) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.available").value(false)) + .andDo( + document( + "check-email-unavailable", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("User") + .summary("이메일 중복 검사 - 사용 불가") + .description("이미 가입된 이메일에 대한 중복 검사 결과") + .requestFields( + fieldWithPath("email") + .type(JsonFieldType.STRING) + .description("검사할 이메일 주소")) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.available") + .type(JsonFieldType.BOOLEAN) + .description("이메일 사용 가능 여부 (true: 사용 가능, false: 이미 사용 중)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("이메일 중복 검사 실패 - 잘못된 이메일 형식") + @WithUserDetails("admin@icebang.site") + void checkEmailAvailable_invalidFormat() throws Exception { + String requestBody = """ + { + "email": "invalid-email" + } + """; + + // when & then + mockMvc + .perform( + post("/v0/users/check-email") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } +} From cfc639785b85268dfc4aee16ea84cf763b115272 Mon Sep 17 00:00:00 2001 From: Yousung Jung Date: Sat, 27 Sep 2025 16:33:27 +0900 Subject: [PATCH 25/40] =?UTF-8?q?Gradle=20=EC=BA=90=EC=8B=B1=EC=9D=84=20?= =?UTF-8?q?=ED=86=B5=ED=95=B4=20CI=20(Java)=20=EC=86=8D=EB=8F=84=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20(#218)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: ci-java에 gradle action 추가 * feat: gradle properties에서 build caching 활성화 - 각 Job 내에서 변경되지 않은 task는 UP-TO-DATE 처리 - Job 간 중복 컴파일은 그대로 두되, Job 내 불필요한 재작업 방지 - 안전하고 검증된 기본 최적화 * refactor: Test, document step 분리 * chore: 쓸모없는 dev container 삭제 * refactor: lint, test step 수행 조건 평가 후 실행 각 step이 자신에게 관게있는 파일 변경 시에만 실행 * chore: 변경점에 따라 step 실행 revert PR sync시에도 base와 변경점을 감지 - 소스 변경: 모든 테스트 영향 (컴파일 필요) - 테스트 변경: 빌드는 필요 - 의존성 복잡: Unit ↔ Integration 경계 모호 - 복잡성 증가 > 성능 향상 - 조건문 길어짐: 가독성 저하 - 멀티 모듈이라면 의미가 있지만 단일 모듈, 의미 없다고 판단 * refactor: Tag 발행 시 cache가 쓰이도록 변경 --- .github/workflows/ci-java.yml | 15 +++++++++++++-- apps/user-service/gradle.properties | 1 + .../site/icebang/TestUserServiceApplication.java | 12 ------------ .../site/icebang/TestcontainersConfiguration.java | 6 ------ 4 files changed, 14 insertions(+), 20 deletions(-) create mode 100644 apps/user-service/gradle.properties delete mode 100644 apps/user-service/src/test/java/site/icebang/TestUserServiceApplication.java delete mode 100644 apps/user-service/src/test/java/site/icebang/TestcontainersConfiguration.java diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml index e3349be5..0cae6a72 100644 --- a/.github/workflows/ci-java.yml +++ b/.github/workflows/ci-java.yml @@ -12,6 +12,7 @@ on: - release/** paths: - "apps/user-service/**" + - ".github/workflows/ci-java.yml" permissions: contents: read @@ -32,12 +33,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' - cache: 'gradle' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: ${{ github.event_name == 'pull_request' }} - name: Grant execute permission for Gradle wrapper run: chmod +x ./gradlew @@ -59,12 +65,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up JDK ${{ matrix.java-version }} uses: actions/setup-java@v4 with: java-version: '${{ matrix.java-version }}' distribution: 'temurin' - cache: 'gradle' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: ${{ github.event_name == 'pull_request' }} - name: Grant execute permission for Gradle wrapper run: chmod +x ./gradlew diff --git a/apps/user-service/gradle.properties b/apps/user-service/gradle.properties new file mode 100644 index 00000000..5f1ed7bb --- /dev/null +++ b/apps/user-service/gradle.properties @@ -0,0 +1 @@ +org.gradle.caching=true \ No newline at end of file diff --git a/apps/user-service/src/test/java/site/icebang/TestUserServiceApplication.java b/apps/user-service/src/test/java/site/icebang/TestUserServiceApplication.java deleted file mode 100644 index ba8c2403..00000000 --- a/apps/user-service/src/test/java/site/icebang/TestUserServiceApplication.java +++ /dev/null @@ -1,12 +0,0 @@ -package site.icebang; - -import org.springframework.boot.SpringApplication; - -public class TestUserServiceApplication { - - public static void main(String[] args) { - SpringApplication.from(UserServiceApplication::main) - .with(TestcontainersConfiguration.class) - .run(args); - } -} diff --git a/apps/user-service/src/test/java/site/icebang/TestcontainersConfiguration.java b/apps/user-service/src/test/java/site/icebang/TestcontainersConfiguration.java deleted file mode 100644 index b9eb7b76..00000000 --- a/apps/user-service/src/test/java/site/icebang/TestcontainersConfiguration.java +++ /dev/null @@ -1,6 +0,0 @@ -package site.icebang; - -import org.springframework.boot.test.context.TestConfiguration; - -@TestConfiguration(proxyBeanMethods = false) -class TestcontainersConfiguration {} From bb2353488e536f92b0c30bafaea0c1b46a554a3f Mon Sep 17 00:00:00 2001 From: can019 Date: Sat, 27 Sep 2025 16:35:04 +0900 Subject: [PATCH 26/40] =?UTF-8?q?chore:=20version=200.1.0-SNAPSHOT?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20build.gradle=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/user-service/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/user-service/build.gradle b/apps/user-service/build.gradle index 94750654..16905e8e 100644 --- a/apps/user-service/build.gradle +++ b/apps/user-service/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'site.icebang' -version = '0.0.1-beta-STABLE' +version = '0.1.0-SNAPSHOT' description = 'Ice bang - fast campus team4' java { From b4a62ff51b9acf83323a7bbd938fe0334ac20786 Mon Sep 17 00:00:00 2001 From: can019 Date: Sat, 27 Sep 2025 16:38:47 +0900 Subject: [PATCH 27/40] =?UTF-8?q?chore:=20Document=20artifcat=20step=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-java.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml index 0cae6a72..10165fce 100644 --- a/.github/workflows/ci-java.yml +++ b/.github/workflows/ci-java.yml @@ -89,13 +89,18 @@ jobs: run: | ./gradlew unitTest ./gradlew integrationTest - ./gradlew javadoc if [ "${{ github.base_ref }}" = "main" ] || [[ "${{ github.ref }}" == refs/tags/* ]]; then ./gradlew e2eTest - ./gradlew openapi3 fi working-directory: apps/user-service + - name: Generate document artifacts + run: | + ./gradlew javadoc + if [ "${{ github.base_ref }}" = "main" ] || [[ "${{ github.ref }}" == refs/tags/* ]]; then + ./gradlew openapi3 + fi + - name: Upload build artifacts if: matrix.java-version == '21' && startsWith(github.ref, 'refs/tags/') uses: actions/upload-artifact@v4 From 369e616ba335b56146ce164cd3476c266f054217 Mon Sep 17 00:00:00 2001 From: can019 Date: Sat, 27 Sep 2025 16:43:02 +0900 Subject: [PATCH 28/40] =?UTF-8?q?fix:=20Working=20directory=20document-jav?= =?UTF-8?q?a=20step=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-java.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml index 10165fce..773b9102 100644 --- a/.github/workflows/ci-java.yml +++ b/.github/workflows/ci-java.yml @@ -100,6 +100,7 @@ jobs: if [ "${{ github.base_ref }}" = "main" ] || [[ "${{ github.ref }}" == refs/tags/* ]]; then ./gradlew openapi3 fi + working-directory: apps/user-service - name: Upload build artifacts if: matrix.java-version == '21' && startsWith(github.ref, 'refs/tags/') From 8b524d7d0e7ca7380065de00b2d3ca234d447b64 Mon Sep 17 00:00:00 2001 From: thkim7 Date: Sat, 27 Sep 2025 17:25:04 +0900 Subject: [PATCH 29/40] =?UTF-8?q?feat:=20OCR=20=EC=B2=98=EB=A6=AC=EB=90=9C?= =?UTF-8?q?=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=B8=94=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=83=9D=EC=84=B1=EC=97=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(=EC=B4=88=EC=95=88)=20-=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pre-processing-service/app/model/schemas.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/pre-processing-service/app/model/schemas.py b/apps/pre-processing-service/app/model/schemas.py index 4001b705..b50077cd 100644 --- a/apps/pre-processing-service/app/model/schemas.py +++ b/apps/pre-processing-service/app/model/schemas.py @@ -186,6 +186,9 @@ class S3ImageInfo(BaseModel): ..., title="원본 URL", description="크롤링된 원본 이미지 URL" ) s3_url: str = Field(..., title="S3 URL", description="S3에서 접근 가능한 URL") + # 새로 추가: 파일 크기 정보 (이미지 선별용) + file_size_kb: Optional[float] = Field(None, title="파일 크기(KB)", description="이미지 파일 크기") + file_name: Optional[str] = Field(None, title="파일명", description="S3에 저장된 파일명") # 상품별 S3 업로드 결과 @@ -274,14 +277,14 @@ class RequestBlogCreate(RequestBase): keyword: Optional[str] = Field( None, title="키워드", description="콘텐츠 생성용 키워드" ) + translation_language: Optional[str] = Field( + None, title="번역한 언어", description="이미지에서 중국어를 한국어로 번역한 언어" + ) product_info: Optional[Dict] = Field( None, title="상품 정보", description="블로그 콘텐츠에 포함할 상품 정보" ) - content_type: Optional[str] = Field( - None, title="콘텐츠 타입", description="생성할 콘텐츠 유형" - ) - target_length: Optional[int] = Field( - None, title="목표 글자 수", description="생성할 콘텐츠의 목표 길이" + uploaded_images: Optional[List[Dict]] = Field( + None, title="업로드된 이미지", description="S3에 업로드된 이미지 목록 (크기 정보 포함)" ) From 11f3d49ef26cdcdaf0451fa1c83b16be395b509c Mon Sep 17 00:00:00 2001 From: thkim7 Date: Sat, 27 Sep 2025 17:25:10 +0900 Subject: [PATCH 30/40] =?UTF-8?q?feat:=20OCR=20=EC=B2=98=EB=A6=AC=EB=90=9C?= =?UTF-8?q?=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=B8=94=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=83=9D=EC=84=B1=EC=97=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(=EC=B4=88=EC=95=88)=20-=20=EC=9C=A0=ED=8B=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/utils/s3_upload_util.py | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/apps/pre-processing-service/app/utils/s3_upload_util.py b/apps/pre-processing-service/app/utils/s3_upload_util.py index 0aaa5ace..a47f2ae3 100644 --- a/apps/pre-processing-service/app/utils/s3_upload_util.py +++ b/apps/pre-processing-service/app/utils/s3_upload_util.py @@ -159,16 +159,15 @@ def get_s3_url(self, s3_key: str) -> str: return f"{self.base_url}/{s3_key}" async def upload_single_product_images( - self, - session: aiohttp.ClientSession, - product_info: Dict, # 🔸 이름 변경: product_data → product_info (전체 크롤링 데이터) - product_index: int, - keyword: str, # 키워드 파라미터 추가 - base_folder: str = "product", # 🔸 기본 폴더 변경: product-images → product + self, + session: aiohttp.ClientSession, + product_info: Dict, + product_index: int, + keyword: str, + base_folder: str = "product", ) -> Dict: """단일 상품의 모든 데이터(이미지 + JSON)를 S3에 업로드""" - # 🔸 전체 크롤링 데이터에서 필요한 정보 추출 product_detail = product_info.get("product_detail", {}) product_title = product_detail.get("title", "Unknown") product_images = product_detail.get("product_images", []) @@ -179,18 +178,15 @@ async def upload_single_product_images( f"상품 {product_index} 업로드 시작: {len(product_images)}개 이미지, keyword='{keyword}'" ) - # 키워드 기반 폴더명 한 번만 생성 folder_name = self.generate_product_folder_name(product_index, keyword) - fail_count = 0 folder_s3_url = f"{self.base_url}/{base_folder}/{folder_name}" - # 🆕 1. 먼저 상품 데이터 JSON 파일 업로드 + # 1. JSON 파일 업로드 try: - # 전체 크롤링 데이터를 JSON으로 저장 (S3 업로드 메타데이터 추가) product_data_with_meta = { - **product_info, # 전체 크롤링 데이터 (index, url, product_detail, status, crawled_at 포함) - "s3_upload_keyword": keyword, # 추가 메타데이터 + **product_info, + "s3_upload_keyword": keyword, "s3_uploaded_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } @@ -206,7 +202,7 @@ async def upload_single_product_images( except Exception as e: logger.error(f"상품 {product_index} JSON 업로드 오류: {e}") - # 2. 이미지 업로드 (기존 로직) + # 2. 이미지 업로드 if not product_images: logger.warning(f"상품 {product_index}: 업로드할 이미지가 없음") return { @@ -236,7 +232,10 @@ async def upload_single_product_images( fail_count += 1 continue - # S3 키 생성 (키워드 기반 폴더명 사용) + # 파일 크기 계산 (KB 단위) + file_size_kb = len(image_data) / 1024 + + # S3 키 생성 file_extension = self.get_file_extension(original_url) image_file_name = f"image_{img_idx:03d}{file_extension}" s3_key = self.generate_s3_key(base_folder, folder_name, image_file_name) @@ -246,15 +245,18 @@ async def upload_single_product_images( if self.upload_to_s3(image_data, s3_key, content_type): s3_url = self.get_s3_url(s3_key) + # 파일 크기 정보 추가 uploaded_images.append( { "index": img_idx, "original_url": original_url, "s3_url": s3_url, + "file_size_kb": round(file_size_kb, 2), + "file_name": image_file_name, } ) - logger.debug(f"상품 {product_index}, 이미지 {img_idx} 업로드 완료") + logger.debug(f"상품 {product_index}, 이미지 {img_idx} 업로드 완료 ({file_size_kb:.1f}KB)") else: fail_count += 1 @@ -273,8 +275,8 @@ async def upload_single_product_images( "product_index": product_index, "product_title": product_title, "status": "completed", - "folder_s3_url": folder_s3_url, # 🔸 폴더 전체를 가리킴 (이미지 + JSON 포함) - "json_s3_url": f"{folder_s3_url}/product_data.json", # 🆕 JSON 파일 직접 링크 + "folder_s3_url": folder_s3_url, + "json_s3_url": f"{folder_s3_url}/product_data.json", "uploaded_images": uploaded_images, "success_count": len(uploaded_images), "fail_count": fail_count, From 4f9bdc75c647d05d8dcd81b933c97d86589c329d Mon Sep 17 00:00:00 2001 From: thkim7 Date: Sat, 27 Sep 2025 17:25:15 +0900 Subject: [PATCH 31/40] =?UTF-8?q?feat:=20OCR=20=EC=B2=98=EB=A6=AC=EB=90=9C?= =?UTF-8?q?=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=B8=94=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=83=9D=EC=84=B1=EC=97=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(=EC=B4=88=EC=95=88)=20-=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/service/blog/blog_create_service.py | 293 +++++++++++++----- 1 file changed, 222 insertions(+), 71 deletions(-) diff --git a/apps/pre-processing-service/app/service/blog/blog_create_service.py b/apps/pre-processing-service/app/service/blog/blog_create_service.py index a66fa609..11d92285 100644 --- a/apps/pre-processing-service/app/service/blog/blog_create_service.py +++ b/apps/pre-processing-service/app/service/blog/blog_create_service.py @@ -1,5 +1,6 @@ -import json import logging +import os +import boto3 from loguru import logger from datetime import datetime from typing import Dict, List, Optional, Any @@ -19,19 +20,94 @@ def __init__(self): if not self.openai_api_key: raise ValueError("OPENAI_API_KEY가 .env.dev 파일에 설정되지 않았습니다.") - # 인스턴스 레벨에서 클라이언트 생성 self.client = OpenAI(api_key=self.openai_api_key) + + # S3 클라이언트 추가 + self.s3_client = boto3.client( + "s3", + aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"), + aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"), + region_name=os.getenv("AWS_REGION", "ap-northeast-2") + ) + self.bucket_name = os.getenv("S3_BUCKET_NAME", "icebang4-dev-bucket") + logging.basicConfig(level=logging.INFO) - def generate_blog_content(self, request: RequestBlogCreate) -> Dict[str, Any]: - """ - 요청 데이터를 기반으로 블로그 콘텐츠 생성 + def _fetch_images_from_s3(self, keyword: str, product_index: int = 1) -> List[Dict]: + """S3에서 해당 상품의 이미지 정보를 조회""" + try: + # 폴더 패턴: 20250922_키워드_1/ 형식으로 검색 + from datetime import datetime + date_str = datetime.now().strftime("%Y%m%d") + + # 키워드 정리 (S3UploadUtil과 동일한 방식) + safe_keyword = ( + keyword.replace("/", "-") + .replace("\\", "-") + .replace(" ", "_") + .replace(":", "-") + .replace("*", "-") + .replace("?", "-") + .replace('"', "-") + .replace("<", "-") + .replace(">", "-") + .replace("|", "-")[:20] + ) + + folder_prefix = f"product/{date_str}_{safe_keyword}_{product_index}/" + + logger.debug(f"S3에서 이미지 조회: {folder_prefix}") + + # S3에서 해당 폴더의 파일 목록 조회 + response = self.s3_client.list_objects_v2( + Bucket=self.bucket_name, + Prefix=folder_prefix + ) + + if 'Contents' not in response: + logger.warning(f"S3에서 이미지를 찾을 수 없음: {folder_prefix}") + return [] + + images = [] + base_url = f"https://{self.bucket_name}.s3.ap-northeast-2.amazonaws.com" + + # 이미지 파일만 필터링 (image_*.jpg 패턴) + for obj in response['Contents']: + key = obj['Key'] + file_name = key.split('/')[-1] # 마지막 부분이 파일명 + + # 이미지 파일인지 확인 + if file_name.startswith('image_') and file_name.endswith(('.jpg', '.jpeg', '.png')): + # 파일 크기 정보 (bytes -> KB) + file_size_kb = obj['Size'] / 1024 - Args: - request: RequestBlogCreate 객체 + # 인덱스 추출 (image_001.jpg -> 1) + try: + index = int(file_name.split('_')[1].split('.')[0]) + except: + index = len(images) + 1 - Returns: - Dict: {"title": str, "content": str, "tags": List[str]} 형태의 결과 + images.append({ + "index": index, + "s3_url": f"{base_url}/{key}", + "file_name": file_name, + "file_size_kb": round(file_size_kb, 2), + "original_url": "" # 원본 URL은 S3에서 조회 불가 + }) + + # 인덱스 순으로 정렬 + images.sort(key=lambda x: x['index']) + + logger.success(f"S3에서 이미지 {len(images)}개 조회 완료") + return images + + except Exception as e: + logger.error(f"S3 이미지 조회 실패: {e}") + return [] + + def generate_blog_content(self, request: RequestBlogCreate) -> Dict[str, Any]: + """ + 요청 데이터를 기반으로 블로그 콘텐츠 생성 (이미지 자동 배치 포함) """ try: logger.debug("[STEP1] 콘텐츠 컨텍스트 준비 시작") @@ -50,6 +126,22 @@ def generate_blog_content(self, request: RequestBlogCreate) -> Dict[str, Any]: result = self._parse_generated_content(generated_content, request) logger.debug("[STEP4 완료]") + # STEP5: S3에서 이미지 정보 조회 (새로 추가) + uploaded_images = request.uploaded_images + if not uploaded_images and request.keyword: + logger.debug("[STEP5-1] S3에서 이미지 정보 조회 시작") + uploaded_images = self._fetch_images_from_s3(request.keyword) + logger.debug(f"[STEP5-1 완료] 조회된 이미지: {len(uploaded_images)}개") + + # STEP6: 이미지 자동 배치 + if uploaded_images and len(uploaded_images) > 0: + logger.debug("[STEP6] 이미지 자동 배치 시작") + result['content'] = self._insert_images_to_content( + result['content'], + uploaded_images + ) + logger.debug("[STEP6 완료] 이미지 배치 완료") + return result except Exception as e: @@ -60,29 +152,29 @@ def _prepare_content_context(self, request: RequestBlogCreate) -> str: """요청 데이터를 콘텐츠 생성용 컨텍스트로 변환""" context_parts = [] - # 키워드 정보 추가 + # 키워드 정보 if request.keyword: context_parts.append(f"주요 키워드: {request.keyword}") - # 상품 정보 추가 + # 상품 정보 if request.product_info: context_parts.append("\n상품 정보:") - # 상품 기본 정보 if request.product_info.get("title"): context_parts.append(f"- 상품명: {request.product_info['title']}") if request.product_info.get("price"): - context_parts.append(f"- 가격: {request.product_info['price']:,}원") + try: + context_parts.append(f"- 가격: {int(request.product_info['price']):,}원") + except Exception: + context_parts.append(f"- 가격: {request.product_info.get('price')}") if request.product_info.get("rating"): context_parts.append(f"- 평점: {request.product_info['rating']}/5.0") - # 상품 상세 정보 if request.product_info.get("description"): context_parts.append(f"- 설명: {request.product_info['description']}") - # 상품 사양 (material_info 등) if request.product_info.get("material_info"): context_parts.append("- 주요 사양:") specs = request.product_info["material_info"] @@ -90,37 +182,135 @@ def _prepare_content_context(self, request: RequestBlogCreate) -> str: for key, value in specs.items(): context_parts.append(f" * {key}: {value}") - # 상품 옵션 if request.product_info.get("options"): options = request.product_info["options"] context_parts.append(f"- 구매 옵션 ({len(options)}개):") - for i, option in enumerate(options[:5], 1): # 최대 5개만 + for i, option in enumerate(options[:5], 1): if isinstance(option, dict): option_name = option.get("name", f"옵션 {i}") context_parts.append(f" {i}. {option_name}") else: context_parts.append(f" {i}. {option}") - # 구매 링크 - if request.product_info.get("url") or request.product_info.get( - "product_url" - ): - url = request.product_info.get("url") or request.product_info.get( - "product_url" - ) + if request.product_info.get("url") or request.product_info.get("product_url"): + url = request.product_info.get("url") or request.product_info.get("product_url") context_parts.append(f"- 구매 링크: {url}") + # 번역 텍스트 (translation_language) 추가 + if request.translation_language: + context_parts.append("\n이미지(OCR)에서 추출·번역된 텍스트:") + context_parts.append(request.translation_language.strip()) + return "\n".join(context_parts) if context_parts else "키워드 기반 콘텐츠 생성" + def _select_best_images(self, uploaded_images: List[Dict], target_count: int = 4) -> List[Dict]: + """크기 기반으로 최적의 이미지 4개 선별""" + if not uploaded_images: + return [] + + logger.debug(f"이미지 선별 시작: 전체 {len(uploaded_images)}개 -> 목표 {target_count}개") + + # 1단계: 너무 작은 이미지 제외 (20KB 이하는 아이콘, 로고 가능성) + filtered = [img for img in uploaded_images if img.get('file_size_kb', 0) > 20] + logger.debug(f"크기 필터링 후: {len(filtered)}개 이미지 남음") + + if len(filtered) == 0: + # 모든 이미지가 너무 작다면 원본에서 선택 + filtered = uploaded_images + + # 2단계: 크기순 정렬 (큰 이미지 = 메인 상품 사진일 가능성) + sorted_images = sorted(filtered, key=lambda x: x.get('file_size_kb', 0), reverse=True) + + # 3단계: 상위 이미지 선택하되, 너무 많으면 균등 분산 + if len(sorted_images) <= target_count: + selected = sorted_images + else: + # 상위 2개 (메인 이미지) + 나머지에서 균등분산으로 2개 + selected = sorted_images[:2] # 큰 이미지 2개 + + remaining = sorted_images[2:] + if len(remaining) >= 2: + step = len(remaining) // 2 + selected.extend([remaining[i * step] for i in range(2)]) + + result = selected[:target_count] + + logger.debug(f"최종 선택된 이미지: {len(result)}개") + for i, img in enumerate(result): + logger.debug(f" {i + 1}. {img.get('file_name', 'unknown')} ({img.get('file_size_kb', 0):.1f}KB)") + + return result + + def _insert_images_to_content(self, content: str, uploaded_images: List[Dict]) -> str: + """AI가 적절한 위치에 이미지 4개를 자동 배치""" + + # 1단계: 최적의 이미지 4개 선별 + selected_images = self._select_best_images(uploaded_images, target_count=4) + + if not selected_images: + logger.warning("선별된 이미지가 없어서 이미지 배치를 건너뜀") + return content + + logger.debug(f"이미지 배치 시작: {len(selected_images)}개 이미지") + + # 2단계: AI에게 이미지 배치 위치 물어보기 + image_placement_prompt = f""" +다음 HTML 콘텐츠에서 이미지 {len(selected_images)}개를 적절한 위치에 배치해주세요. + +콘텐츠: +{content} + +이미지 개수: {len(selected_images)}개 + +요구사항: +- 각 섹션(h2, h3 태그)마다 골고루 분산 배치 +- 너무 몰려있지 않게 적절한 간격 유지 +- 글의 흐름을 방해하지 않는 자연스러운 위치 +- [IMAGE_1], [IMAGE_2], [IMAGE_3], [IMAGE_4] 형식의 플레이스홀더로 표시 + +⚠️ 주의사항: +- 기존 HTML 구조와 내용은 그대로 유지 +- 오직 이미지 플레이스홀더만 적절한 위치에 삽입 +- 코드 블록(```)은 사용하지 말고 수정된 HTML만 반환 + +수정된 HTML을 반환해주세요. +""" + + try: + # 3단계: AI로 배치 위치 결정 + modified_content = self._generate_with_openai(image_placement_prompt) + + # 4단계: 플레이스홀더를 실제 img 태그로 교체 + for i, img in enumerate(selected_images): + img_tag = f''' +
+ 상품 이미지 {i + 1} +
''' + + placeholder = f"[IMAGE_{i + 1}]" + modified_content = modified_content.replace(placeholder, img_tag) + + # 5단계: 남은 플레이스홀더 제거 (혹시 AI가 더 많이 만들었을 경우) + import re + modified_content = re.sub(r'\[IMAGE_\d+\]', '', modified_content) + + logger.success(f"이미지 배치 완료: {len(selected_images)}개 이미지 삽입") + return modified_content + + except Exception as e: + logger.error(f"이미지 배치 중 오류: {e}, 원본 콘텐츠 반환") + return content + def _create_content_prompt(self, context: str, request: RequestBlogCreate) -> str: """콘텐츠 생성용 프롬프트 생성""" # 기본 키워드가 없으면 상품 제목에서 추출 main_keyword = request.keyword if ( - not main_keyword - and request.product_info - and request.product_info.get("title") + not main_keyword + and request.product_info + and request.product_info.get("title") ): main_keyword = request.product_info["title"] @@ -138,7 +328,7 @@ def _create_content_prompt(self, context: str, request: RequestBlogCreate) -> st 작성 요구사항: 1. SEO 친화적이고 클릭하고 싶은 매력적인 제목 2. 독자의 관심을 끄는 도입부 -3. 핵심 특징과 장점을 구체적으로 설명 +3. 핵심 특징과 장점을 구체적으로 설명 (h2, h3 태그로 구조화) 4. 실제 사용 시나리오나 활용 팁 5. 구매 결정에 도움이 되는 정보 @@ -147,6 +337,7 @@ def _create_content_prompt(self, context: str, request: RequestBlogCreate) -> st - 출력 시 ```나 ```html 같은 코드 블록 구문을 포함하지 마세요. - 오직 HTML 태그만 사용하여 구조화된 콘텐츠를 작성해주세요. (예:

,

,

,

    ,
  • 등) +- 이미지는 나중에 자동으로 삽입되므로 img 태그를 작성하지 마세요. """ return prompt @@ -170,11 +361,11 @@ def _generate_with_openai(self, prompt: str) -> str: return response.choices[0].message.content except Exception as e: - self.logger.error(f"OpenAI API 호출 실패: {e}") + logger.error(f"OpenAI API 호출 실패: {e}") raise def _parse_generated_content( - self, content: str, request: RequestBlogCreate + self, content: str, request: RequestBlogCreate ) -> Dict[str, Any]: """생성된 콘텐츠를 파싱하여 구조화""" @@ -303,44 +494,4 @@ def _create_fallback_content(self, request: RequestBlogCreate) -> Dict[str, Any] "title": title, "content": content, "tags": self._generate_tags(request), - } - - -# if __name__ == '__main__': -# # 테스트용 요청 데이터 -# test_request = RequestBlogCreate( -# keyword="아이폰 케이스", -# product_info={ -# "title": "아이폰 15 프로 투명 케이스", -# "price": 29900, -# "rating": 4.8, -# "description": "9H 강화 보호 기능을 제공하는 투명 케이스", -# "material_info": { -# "소재": "TPU + PC", -# "두께": "1.2mm", -# "색상": "투명", -# "호환성": "아이폰 15 Pro" -# }, -# "options": [ -# {"name": "투명"}, -# {"name": "반투명"}, -# {"name": "블랙"} -# ], -# "url": "https://example.com/iphone-case" -# } -# ) -# -# # 서비스 실행 -# service = BlogContentService() -# print("=== 블로그 콘텐츠 생성 테스트 ===") -# print(f"키워드: {test_request.keyword}") -# print(f"상품: {test_request.product_info['title']}") -# print("\n--- 생성 시작 ---") -# -# result = service.generate_blog_content(test_request) -# -# print(f"\n=== 생성 결과 ===") -# print(f"제목: {result['title']}") -# print(f"\n태그: {', '.join(result['tags'])}") -# print(f"\n내용:\n{result['content']}") -# print(f"\n글자수: {len(result['content'])}자") + } \ No newline at end of file From 6544dc8df2d6455c2a9e80dac427920f828f54d9 Mon Sep 17 00:00:00 2001 From: thkim7 Date: Sat, 27 Sep 2025 17:25:31 +0900 Subject: [PATCH 32/40] chore: poetry run black . --- .../app/model/schemas.py | 16 +++- .../app/service/blog/blog_create_service.py | 96 +++++++++++-------- .../app/utils/s3_upload_util.py | 16 ++-- 3 files changed, 79 insertions(+), 49 deletions(-) diff --git a/apps/pre-processing-service/app/model/schemas.py b/apps/pre-processing-service/app/model/schemas.py index b50077cd..4a49ca0e 100644 --- a/apps/pre-processing-service/app/model/schemas.py +++ b/apps/pre-processing-service/app/model/schemas.py @@ -187,8 +187,12 @@ class S3ImageInfo(BaseModel): ) s3_url: str = Field(..., title="S3 URL", description="S3에서 접근 가능한 URL") # 새로 추가: 파일 크기 정보 (이미지 선별용) - file_size_kb: Optional[float] = Field(None, title="파일 크기(KB)", description="이미지 파일 크기") - file_name: Optional[str] = Field(None, title="파일명", description="S3에 저장된 파일명") + file_size_kb: Optional[float] = Field( + None, title="파일 크기(KB)", description="이미지 파일 크기" + ) + file_name: Optional[str] = Field( + None, title="파일명", description="S3에 저장된 파일명" + ) # 상품별 S3 업로드 결과 @@ -278,13 +282,17 @@ class RequestBlogCreate(RequestBase): None, title="키워드", description="콘텐츠 생성용 키워드" ) translation_language: Optional[str] = Field( - None, title="번역한 언어", description="이미지에서 중국어를 한국어로 번역한 언어" + None, + title="번역한 언어", + description="이미지에서 중국어를 한국어로 번역한 언어", ) product_info: Optional[Dict] = Field( None, title="상품 정보", description="블로그 콘텐츠에 포함할 상품 정보" ) uploaded_images: Optional[List[Dict]] = Field( - None, title="업로드된 이미지", description="S3에 업로드된 이미지 목록 (크기 정보 포함)" + None, + title="업로드된 이미지", + description="S3에 업로드된 이미지 목록 (크기 정보 포함)", ) diff --git a/apps/pre-processing-service/app/service/blog/blog_create_service.py b/apps/pre-processing-service/app/service/blog/blog_create_service.py index 11d92285..fdc8b6a0 100644 --- a/apps/pre-processing-service/app/service/blog/blog_create_service.py +++ b/apps/pre-processing-service/app/service/blog/blog_create_service.py @@ -27,7 +27,7 @@ def __init__(self): "s3", aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"), aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"), - region_name=os.getenv("AWS_REGION", "ap-northeast-2") + region_name=os.getenv("AWS_REGION", "ap-northeast-2"), ) self.bucket_name = os.getenv("S3_BUCKET_NAME", "icebang4-dev-bucket") @@ -38,6 +38,7 @@ def _fetch_images_from_s3(self, keyword: str, product_index: int = 1) -> List[Di try: # 폴더 패턴: 20250922_키워드_1/ 형식으로 검색 from datetime import datetime + date_str = datetime.now().strftime("%Y%m%d") # 키워드 정리 (S3UploadUtil과 동일한 방식) @@ -60,11 +61,10 @@ def _fetch_images_from_s3(self, keyword: str, product_index: int = 1) -> List[Di # S3에서 해당 폴더의 파일 목록 조회 response = self.s3_client.list_objects_v2( - Bucket=self.bucket_name, - Prefix=folder_prefix + Bucket=self.bucket_name, Prefix=folder_prefix ) - if 'Contents' not in response: + if "Contents" not in response: logger.warning(f"S3에서 이미지를 찾을 수 없음: {folder_prefix}") return [] @@ -72,31 +72,35 @@ def _fetch_images_from_s3(self, keyword: str, product_index: int = 1) -> List[Di base_url = f"https://{self.bucket_name}.s3.ap-northeast-2.amazonaws.com" # 이미지 파일만 필터링 (image_*.jpg 패턴) - for obj in response['Contents']: - key = obj['Key'] - file_name = key.split('/')[-1] # 마지막 부분이 파일명 + for obj in response["Contents"]: + key = obj["Key"] + file_name = key.split("/")[-1] # 마지막 부분이 파일명 # 이미지 파일인지 확인 - if file_name.startswith('image_') and file_name.endswith(('.jpg', '.jpeg', '.png')): + if file_name.startswith("image_") and file_name.endswith( + (".jpg", ".jpeg", ".png") + ): # 파일 크기 정보 (bytes -> KB) - file_size_kb = obj['Size'] / 1024 + file_size_kb = obj["Size"] / 1024 # 인덱스 추출 (image_001.jpg -> 1) try: - index = int(file_name.split('_')[1].split('.')[0]) + index = int(file_name.split("_")[1].split(".")[0]) except: index = len(images) + 1 - images.append({ - "index": index, - "s3_url": f"{base_url}/{key}", - "file_name": file_name, - "file_size_kb": round(file_size_kb, 2), - "original_url": "" # 원본 URL은 S3에서 조회 불가 - }) + images.append( + { + "index": index, + "s3_url": f"{base_url}/{key}", + "file_name": file_name, + "file_size_kb": round(file_size_kb, 2), + "original_url": "", # 원본 URL은 S3에서 조회 불가 + } + ) # 인덱스 순으로 정렬 - images.sort(key=lambda x: x['index']) + images.sort(key=lambda x: x["index"]) logger.success(f"S3에서 이미지 {len(images)}개 조회 완료") return images @@ -136,9 +140,8 @@ def generate_blog_content(self, request: RequestBlogCreate) -> Dict[str, Any]: # STEP6: 이미지 자동 배치 if uploaded_images and len(uploaded_images) > 0: logger.debug("[STEP6] 이미지 자동 배치 시작") - result['content'] = self._insert_images_to_content( - result['content'], - uploaded_images + result["content"] = self._insert_images_to_content( + result["content"], uploaded_images ) logger.debug("[STEP6 완료] 이미지 배치 완료") @@ -165,7 +168,9 @@ def _prepare_content_context(self, request: RequestBlogCreate) -> str: if request.product_info.get("price"): try: - context_parts.append(f"- 가격: {int(request.product_info['price']):,}원") + context_parts.append( + f"- 가격: {int(request.product_info['price']):,}원" + ) except Exception: context_parts.append(f"- 가격: {request.product_info.get('price')}") @@ -192,8 +197,12 @@ def _prepare_content_context(self, request: RequestBlogCreate) -> str: else: context_parts.append(f" {i}. {option}") - if request.product_info.get("url") or request.product_info.get("product_url"): - url = request.product_info.get("url") or request.product_info.get("product_url") + if request.product_info.get("url") or request.product_info.get( + "product_url" + ): + url = request.product_info.get("url") or request.product_info.get( + "product_url" + ) context_parts.append(f"- 구매 링크: {url}") # 번역 텍스트 (translation_language) 추가 @@ -203,15 +212,19 @@ def _prepare_content_context(self, request: RequestBlogCreate) -> str: return "\n".join(context_parts) if context_parts else "키워드 기반 콘텐츠 생성" - def _select_best_images(self, uploaded_images: List[Dict], target_count: int = 4) -> List[Dict]: + def _select_best_images( + self, uploaded_images: List[Dict], target_count: int = 4 + ) -> List[Dict]: """크기 기반으로 최적의 이미지 4개 선별""" if not uploaded_images: return [] - logger.debug(f"이미지 선별 시작: 전체 {len(uploaded_images)}개 -> 목표 {target_count}개") + logger.debug( + f"이미지 선별 시작: 전체 {len(uploaded_images)}개 -> 목표 {target_count}개" + ) # 1단계: 너무 작은 이미지 제외 (20KB 이하는 아이콘, 로고 가능성) - filtered = [img for img in uploaded_images if img.get('file_size_kb', 0) > 20] + filtered = [img for img in uploaded_images if img.get("file_size_kb", 0) > 20] logger.debug(f"크기 필터링 후: {len(filtered)}개 이미지 남음") if len(filtered) == 0: @@ -219,7 +232,9 @@ def _select_best_images(self, uploaded_images: List[Dict], target_count: int = 4 filtered = uploaded_images # 2단계: 크기순 정렬 (큰 이미지 = 메인 상품 사진일 가능성) - sorted_images = sorted(filtered, key=lambda x: x.get('file_size_kb', 0), reverse=True) + sorted_images = sorted( + filtered, key=lambda x: x.get("file_size_kb", 0), reverse=True + ) # 3단계: 상위 이미지 선택하되, 너무 많으면 균등 분산 if len(sorted_images) <= target_count: @@ -237,11 +252,15 @@ def _select_best_images(self, uploaded_images: List[Dict], target_count: int = 4 logger.debug(f"최종 선택된 이미지: {len(result)}개") for i, img in enumerate(result): - logger.debug(f" {i + 1}. {img.get('file_name', 'unknown')} ({img.get('file_size_kb', 0):.1f}KB)") + logger.debug( + f" {i + 1}. {img.get('file_name', 'unknown')} ({img.get('file_size_kb', 0):.1f}KB)" + ) return result - def _insert_images_to_content(self, content: str, uploaded_images: List[Dict]) -> str: + def _insert_images_to_content( + self, content: str, uploaded_images: List[Dict] + ) -> str: """AI가 적절한 위치에 이미지 4개를 자동 배치""" # 1단계: 최적의 이미지 4개 선별 @@ -282,18 +301,19 @@ def _insert_images_to_content(self, content: str, uploaded_images: List[Dict]) - # 4단계: 플레이스홀더를 실제 img 태그로 교체 for i, img in enumerate(selected_images): - img_tag = f''' + img_tag = f"""
    상품 이미지 {i + 1} -
    ''' +""" placeholder = f"[IMAGE_{i + 1}]" modified_content = modified_content.replace(placeholder, img_tag) # 5단계: 남은 플레이스홀더 제거 (혹시 AI가 더 많이 만들었을 경우) import re - modified_content = re.sub(r'\[IMAGE_\d+\]', '', modified_content) + + modified_content = re.sub(r"\[IMAGE_\d+\]", "", modified_content) logger.success(f"이미지 배치 완료: {len(selected_images)}개 이미지 삽입") return modified_content @@ -308,9 +328,9 @@ def _create_content_prompt(self, context: str, request: RequestBlogCreate) -> st # 기본 키워드가 없으면 상품 제목에서 추출 main_keyword = request.keyword if ( - not main_keyword - and request.product_info - and request.product_info.get("title") + not main_keyword + and request.product_info + and request.product_info.get("title") ): main_keyword = request.product_info["title"] @@ -365,7 +385,7 @@ def _generate_with_openai(self, prompt: str) -> str: raise def _parse_generated_content( - self, content: str, request: RequestBlogCreate + self, content: str, request: RequestBlogCreate ) -> Dict[str, Any]: """생성된 콘텐츠를 파싱하여 구조화""" @@ -494,4 +514,4 @@ def _create_fallback_content(self, request: RequestBlogCreate) -> Dict[str, Any] "title": title, "content": content, "tags": self._generate_tags(request), - } \ No newline at end of file + } diff --git a/apps/pre-processing-service/app/utils/s3_upload_util.py b/apps/pre-processing-service/app/utils/s3_upload_util.py index a47f2ae3..374bfd8e 100644 --- a/apps/pre-processing-service/app/utils/s3_upload_util.py +++ b/apps/pre-processing-service/app/utils/s3_upload_util.py @@ -159,12 +159,12 @@ def get_s3_url(self, s3_key: str) -> str: return f"{self.base_url}/{s3_key}" async def upload_single_product_images( - self, - session: aiohttp.ClientSession, - product_info: Dict, - product_index: int, - keyword: str, - base_folder: str = "product", + self, + session: aiohttp.ClientSession, + product_info: Dict, + product_index: int, + keyword: str, + base_folder: str = "product", ) -> Dict: """단일 상품의 모든 데이터(이미지 + JSON)를 S3에 업로드""" @@ -256,7 +256,9 @@ async def upload_single_product_images( } ) - logger.debug(f"상품 {product_index}, 이미지 {img_idx} 업로드 완료 ({file_size_kb:.1f}KB)") + logger.debug( + f"상품 {product_index}, 이미지 {img_idx} 업로드 완료 ({file_size_kb:.1f}KB)" + ) else: fail_count += 1 From 081041638b9a99b2d917d5ec048c5032a68c861a Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 27 Sep 2025 17:31:40 +0900 Subject: [PATCH 33/40] =?UTF-8?q?feat=20:=20google-vision=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20OCR=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. S3에서 이미지 추출 (json 미포함, 로컬 저장X) 2. google-vision api로 중국어, 일본어, 영어 -> 한국어 변환 3. 개행 및 불 필요한 텍스트 전처리 4. 엔드포인트 추가 (blogs/ocr/extract) 5. Dockerfile 주석 ENTRYPORIN 경로 변경 --- apps/pre-processing-service/Dockerfile | 53 ++-- .../app/api/endpoints/blog.py | 17 ++ .../app/api/endpoints/ocr.py | 102 ------- apps/pre-processing-service/app/api/router.py | 4 +- .../pre-processing-service/app/core/config.py | 3 + .../app/model/schemas.py | 23 ++ .../app/service/ocr/ChineseOCRTranslator.py | 136 ---------- .../app/service/ocr/OCRTranslator.py | 254 ++++++++++++++++++ .../app/service/ocr/S3OCRProcessor.py | 127 +++++++++ .../app/service/ocr/S3Service.py | 28 +- 10 files changed, 472 insertions(+), 275 deletions(-) delete mode 100644 apps/pre-processing-service/app/api/endpoints/ocr.py delete mode 100644 apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py create mode 100644 apps/pre-processing-service/app/service/ocr/OCRTranslator.py create mode 100644 apps/pre-processing-service/app/service/ocr/S3OCRProcessor.py diff --git a/apps/pre-processing-service/Dockerfile b/apps/pre-processing-service/Dockerfile index 6e1d7ac0..07f71ce3 100644 --- a/apps/pre-processing-service/Dockerfile +++ b/apps/pre-processing-service/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.11-slim AS builder WORKDIR /app -# OS 패키지 설치 +# 필수 OS 패키지 설치 RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ ca-certificates \ @@ -18,7 +18,7 @@ RUN poetry self add "poetry-plugin-export>=1.7.0" RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" -# 의존성 해결 → requirements로 export → pip로 설치(= 반드시 /opt/venv에 설치됨) +# poetry → requirements로 export → pip로 설치 COPY pyproject.toml poetry.lock ./ RUN poetry export --without dev -f requirements.txt -o requirements.txt \ && pip install --no-cache-dir -r requirements.txt @@ -26,35 +26,46 @@ RUN poetry export --without dev -f requirements.txt -o requirements.txt \ FROM python:3.11-slim AS final WORKDIR /app -# OS 패키지 설치 +# Chrome과 ChromeDriver 설치를 위한 패키지 설치 (삭제 예정 - 마운트 방식) RUN apt-get update && apt-get install -y --no-install-recommends \ wget \ unzip \ curl \ gnupg \ ca-certificates \ - libgl1-mesa-dri \ - libglib2.0-0 \ - libsm6 \ - libxext6 \ - libxrender1 \ - libgomp1 \ - libgthread-2.0-0 \ - ffmpeg \ && rm -rf /var/lib/apt/lists/* -# 빌드된 가상환경 복사 +# Chrome 설치 (삭제 예정 - 마운트 방식) +RUN wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \ + && apt-get update \ + && apt-get install -y ./google-chrome-stable_current_amd64.deb \ + && rm ./google-chrome-stable_current_amd64.deb \ + && rm -rf /var/lib/apt/lists/* + +# MeCab & 사전 설치 (삭제 예정 - 마운트 방식) +RUN apt-get update && apt-get install -y --no-install-recommends \ + mecab \ + libmecab-dev \ + wget \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# 한국어 사전 수동 설치 (삭제 예정 - 마운트 방식) +RUN cd /tmp && \ + wget https://bitbucket.org/eunjeon/mecab-ko-dic/downloads/mecab-ko-dic-2.1.1-20180720.tar.gz && \ + tar -zxf mecab-ko-dic-2.1.1-20180720.tar.gz && \ + cd mecab-ko-dic-2.1.1-20180720 && \ + ./configure && \ + make && \ + make install && \ + cd / && rm -rf /tmp/mecab-ko-dic-* + +# /opt/venv 복사 COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" -# 앱 소스 복사 +# 앱 소스 COPY . . -# 실행 명령 -ENTRYPOINT ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "app.main:app", \ - "-b", "0.0.0.0:8000", \ - "--timeout", "240", \ - "--workers", "1", \ - "--max-requests", "50", \ - "--max-requests-jitter", "10", \ - "--preload"] \ No newline at end of file +# gunicorn으로 FastAPI 앱 실행 - 타임아웃 240초 설정 +ENTRYPOINT ["/opt/venv/bin/gunicorn", "-k", "uvicorn.workers.UvicornWorker", "app.main:app", "-b", "0.0.0.0:8000", "--timeout", "240"] \ No newline at end of file diff --git a/apps/pre-processing-service/app/api/endpoints/blog.py b/apps/pre-processing-service/app/api/endpoints/blog.py index d0d078e8..f7043f14 100644 --- a/apps/pre-processing-service/app/api/endpoints/blog.py +++ b/apps/pre-processing-service/app/api/endpoints/blog.py @@ -10,10 +10,27 @@ from app.utils.response import Response from app.service.blog.blog_create_service import BlogContentService from app.service.blog.blog_publish_service import BlogPublishService +from app.service.ocr.S3OCRProcessor import S3OCRProcessor router = APIRouter() +@router.post( + "/ocr/extract", + response_model=ResponseImageTextExtract, + summary="S3 이미지에서 텍스트 추출 및 번역", +) +async def ocr_extract(request: RequestImageTextExtract): + """ + S3 이미지에서 텍스트 추출 및 번역 + """ + processor = S3OCRProcessor(request.keyword) + + result = processor.process_images() + + return Response.ok(result) + + @router.post( "/rag/create", response_model=ResponseBlogCreate, diff --git a/apps/pre-processing-service/app/api/endpoints/ocr.py b/apps/pre-processing-service/app/api/endpoints/ocr.py deleted file mode 100644 index 7f59596e..00000000 --- a/apps/pre-processing-service/app/api/endpoints/ocr.py +++ /dev/null @@ -1,102 +0,0 @@ -# app/api/endpoints/ocr.py -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel -from typing import List, Optional -from loguru import logger - -from app.service.ocr.ChineseOCRTranslator import ChineseOCRTranslator -from app.service.ocr.S3Service import S3Service - -router = APIRouter() - - -# Request/Response 모델들 -class OCRProcessRequest(BaseModel): - keyword: str - - -class OCRResult(BaseModel): - s3_key: str - chinese_text: str - korean_text: str - success: bool - error: Optional[str] = None - - -class OCRProcessResponse(BaseModel): - keyword: str - total_objects: int - jpg_files_count: int - results: List[OCRResult] - - -@router.post("/process", response_model=OCRProcessResponse) -async def process_ocr_batch(request: OCRProcessRequest): - """ - 키워드로 S3 폴더 조회 → JPG 파일 필터링 → 중국어 OCR → 한국어 번역 (원스톱 처리) - """ - try: - logger.info(f"OCR 배치 처리 시작 - 키워드: {request.keyword}") - - # S3 서비스 및 OCR 서비스 초기화 - s3_service = S3Service(request.keyword) - ocr_service = ChineseOCRTranslator() - - # S3에서 모든 객체 가져오기 - all_objects = s3_service.get_folder_objects() - logger.info(f"총 {len(all_objects)}개 객체 발견") - - # JPG 파일만 필터링 - jpg_files = s3_service.get_jpg_files(all_objects) - logger.info(f"JPG 파일 {len(jpg_files)}개 필터링 완료") - - if not jpg_files: - logger.warning(f"키워드 '{request.keyword}'에 해당하는 JPG 파일이 없습니다.") - return OCRProcessResponse( - keyword=request.keyword, - total_objects=len(all_objects), - jpg_files_count=0, - results=[] - ) - - # 각 JPG 파일 OCR 처리 - results = [] - for jpg_file in jpg_files: - try: - logger.info(f"OCR 처리 중: {jpg_file}") - - # 이미지 데이터 가져오기 - image_data = s3_service.get_image_data(jpg_file) - - # OCR 처리 - result = ocr_service.process_image_from_bytes(image_data) - result["s3_key"] = jpg_file - - results.append(OCRResult(**result)) - logger.info(f"OCR 처리 완료: {jpg_file}") - - except Exception as e: - logger.error(f"OCR 처리 실패 ({jpg_file}): {e}") - results.append(OCRResult( - s3_key=jpg_file, - chinese_text="", - korean_text="", - success=False, - error=str(e) - )) - - logger.info(f"OCR 배치 처리 완료 - 총 {len(results)}개 파일 처리됨") - - return OCRProcessResponse( - keyword=request.keyword, - total_objects=len(all_objects), - jpg_files_count=len(jpg_files), - results=results - ) - - except Exception as e: - logger.error(f"OCR 배치 처리 실패 (키워드: {request.keyword}): {e}") - raise HTTPException( - status_code=500, - detail=f"OCR 처리 중 오류가 발생했습니다: {str(e)}" - ) \ No newline at end of file diff --git a/apps/pre-processing-service/app/api/router.py b/apps/pre-processing-service/app/api/router.py index a1c3b9d2..c1a2fcb4 100644 --- a/apps/pre-processing-service/app/api/router.py +++ b/apps/pre-processing-service/app/api/router.py @@ -1,6 +1,6 @@ # app/api/router.py from fastapi import APIRouter -from .endpoints import keywords, blog, product, test, sample, ocr +from .endpoints import keywords, blog, product, test, sample from ..core.config import settings api_router = APIRouter() @@ -19,8 +19,6 @@ api_router.include_router(sample.router, prefix="/v0", tags=["Sample"]) -api_router.include_router(ocr.router, prefix="/ocr", tags=["OCR"]) - @api_router.get("/ping") async def root(): diff --git a/apps/pre-processing-service/app/core/config.py b/apps/pre-processing-service/app/core/config.py index ad7005ea..8e6b70f2 100644 --- a/apps/pre-processing-service/app/core/config.py +++ b/apps/pre-processing-service/app/core/config.py @@ -106,6 +106,9 @@ class BaseSettingsConfig(BaseSettings): # 테스트/추가용 필드 OPENAI_API_KEY: Optional[str] = None # << 이 부분 추가 + # OCR 번역기 설정 + google_application_credentials: Optional[str] = None + def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/apps/pre-processing-service/app/model/schemas.py b/apps/pre-processing-service/app/model/schemas.py index 4001b705..01583ca8 100644 --- a/apps/pre-processing-service/app/model/schemas.py +++ b/apps/pre-processing-service/app/model/schemas.py @@ -301,6 +301,29 @@ class ResponseBlogCreate(ResponseBase[BlogCreateData]): pass +# ================== 이미지에서 텍스트 추출 및 번역 ================== +class RequestImageTextExtract(RequestBase): + keyword: Optional[str] = Field( + ..., title="키워드", description="텍스트 추출용 키워드" + ) + + +class ImageTextExtract(BaseModel): + keyword: Optional[str] = Field( + ..., title="키워드", description="텍스트 추출용 키워드" + ) + extraction_language: str = Field( + ..., title="추출된 텍스트", description="이미지에서 추출된 텍스트" + ) + translation_language: str = Field( + ..., title="번역된 텍스트", description="추출된 텍스트의 번역본" + ) + + +class ResponseImageTextExtract(ResponseBase[ImageTextExtract]): + pass + + # ============== 블로그 배포 ============== diff --git a/apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py b/apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py deleted file mode 100644 index 8608e301..00000000 --- a/apps/pre-processing-service/app/service/ocr/ChineseOCRTranslator.py +++ /dev/null @@ -1,136 +0,0 @@ -import os -import json -from google.cloud import vision -from google.oauth2 import service_account -from deep_translator import GoogleTranslator -from loguru import logger -import io - - -class ChineseOCRTranslator: - - def __init__(self): - self.translator = GoogleTranslator(source='zh-CN', target='ko') - - # Google Vision API 클라이언트 초기화 - self.vision_client = self._initialize_vision_client() - - def _initialize_vision_client(self): - """Google Vision API 클라이언트 초기화""" - try: - # # 방법 1: 환경변수에서 JSON 문자열로 인증정보 가져오기 - # creds_json = os.getenv('GOOGLE_APPLICATION_CREDENTIALS_JSON') - # if creds_json: - # logger.info("환경변수에서 Google 인증정보 로드") - # creds_dict = json.loads(creds_json) - # if "private_key" in creds_dict: - # while "\\n" in creds_dict["private_key"]: - # creds_dict["private_key"] = creds_dict["private_key"].replace("\\n", "\n") - # credentials = service_account.Credentials.from_service_account_info(creds_dict) - # return vision.ImageAnnotatorClient(credentials=credentials) - - # # 방법 2: 파일 경로에서 인증정보 가져오기 - # creds_file = os.getenv('GOOGLE_APPLICATION_CREDENTIALS') - # if creds_file and os.path.exists(creds_file): - # logger.info(f"파일에서 Google 인증정보 로드: {creds_file}") - # return vision.ImageAnnotatorClient() - - # 방법 3: 기본 인증 (Cloud Shell, GCE 등) - logger.info("기본 인증으로 Google Vision 클라이언트 초기화") - return vision.ImageAnnotatorClient() - - except Exception as e: - logger.error(f"Google Vision 클라이언트 초기화 실패: {e}") - raise - - def _extract_chinese_text(self, image_path): - """이미지에서 중국어 텍스트 추출 (파일 경로)""" - try: - # 이미지 파일 읽기 - with io.open(image_path, 'rb') as image_file: - content = image_file.read() - - return self._extract_text_from_content(content) - - except Exception as e: - logger.error(f"Error during OCR: {e}") - raise - - def _extract_chinese_text_from_bytes(self, image_data: bytes): - """이미지에서 중국어 텍스트 추출 (바이트 데이터)""" - try: - return self._extract_text_from_content(image_data) - except Exception as e: - logger.error(f"Error during OCR from bytes: {e}") - raise - - def _extract_text_from_content(self, image_content: bytes): - """Google Vision API를 사용해 이미지에서 텍스트 추출""" - try: - # Vision API Image 객체 생성 - image = vision.Image(content=image_content) - - # 텍스트 감지 요청 - response = self.vision_client.text_detection(image=image) - - # 에러 체크 - if response.error.message: - raise Exception(f'Vision API Error: {response.error.message}') - - # 텍스트 추출 - texts = response.text_annotations - if texts: - # 첫 번째 요소가 전체 텍스트 - extracted_text = texts[0].description - logger.info(f"Vision API로 추출된 텍스트: {extracted_text}") - return extracted_text.strip() - else: - logger.warning("이미지에서 텍스트를 찾을 수 없습니다") - return "" - - except Exception as e: - logger.error(f"Vision API 텍스트 추출 오류: {e}") - raise - - def translate_to_korean(self, text): - """중국어 텍스트를 한국어로 번역""" - if not text: - return "" - - try: - result = self.translator.translate(text) - # None 체크 추가 - return result if result is not None else "" - except Exception as e: - logger.error(f"Error during translation: {e}") - return "" # 에러 시 빈 문자열 반환 - - def process_image(self, image_path): - """이미지에서 중국어 텍스트 추출 후 한국어로 번역""" - chinese_text = self._extract_chinese_text(image_path) - logger.info("추출된 중국어 텍스트: " + chinese_text) - - korean_text = self.translate_to_korean(chinese_text) - logger.info("번역된 한국어 텍스트: " + korean_text) - - return { - "chinese_text": chinese_text, - "korean_text": korean_text, - "success": True - } - - def process_image_from_bytes(self, image_data: bytes): - """이미지에서 중국어 텍스트 추출 후 한국어로 번역 (바이트 데이터)""" - chinese_text = self._extract_chinese_text_from_bytes(image_data) - logger.info("추출된 중국어 텍스트: " + chinese_text) - - korean_text = self.translate_to_korean(chinese_text) - # None 체크 추가 - korean_text = korean_text if korean_text is not None else "" - logger.info("번역된 한국어 텍스트: " + korean_text) - - return { - "chinese_text": chinese_text, - "korean_text": korean_text, - "success": True - } diff --git a/apps/pre-processing-service/app/service/ocr/OCRTranslator.py b/apps/pre-processing-service/app/service/ocr/OCRTranslator.py new file mode 100644 index 00000000..3c639b82 --- /dev/null +++ b/apps/pre-processing-service/app/service/ocr/OCRTranslator.py @@ -0,0 +1,254 @@ +import os +import json +from google.cloud import vision +from google.oauth2 import service_account +from deep_translator import GoogleTranslator +from loguru import logger +import io + + +class OCRTranslator: + + def __init__(self): + """다국어 OCR 번역기 초기화 (중국어, 일본어, 영어 -> 한국어)""" + + self.source_languages = ["zh-CN", "ja", "en"] + self.target_language = "ko" + + # 각 언어별 번역기 초기화 + self.translators = {} + for lang in self.source_languages: + try: + self.translators[lang] = GoogleTranslator( + source=lang, target=self.target_language + ) + logger.info(f"{lang} -> {self.target_language} 번역기 초기화 완료") + except Exception as e: + logger.error(f"{lang} 번역기 초기화 실패: {e}") + + # Google Vision API 클라이언트 초기화 + self.vision_client = self._initialize_vision_client() + + def _initialize_vision_client(self): + """Google Vision API 클라이언트 초기화""" + try: + # # 환경변수에서 JSON 문자열로 인증정보 가져오기 + # creds_json = os.getenv('GOOGLE_APPLICATION_CREDENTIALS_JSON') + # if creds_json: + # logger.info("환경변수에서 Google 인증정보 로드") + # creds_dict = json.loads(creds_json) + # if "private_key" in creds_dict: + # while "\\n" in creds_dict["private_key"]: + # creds_dict["private_key"] = creds_dict["private_key"].replace("\\n", "\n") + # credentials = service_account.Credentials.from_service_account_info(creds_dict) + # return vision.ImageAnnotatorClient(credentials=credentials) + + # 파일 경로에서 인증정보 가져오기 + creds_file = os.getenv("GOOGLE_APPLICATION_CREDENTIALS") + if creds_file and os.path.exists(creds_file): + logger.info(f"파일에서 Google 인증정보 로드: {creds_file}") + return vision.ImageAnnotatorClient() + + # 기본 인증 (Cloud Shell, GCE 등) + logger.info("기본 인증으로 Google Vision 클라이언트 초기화") + return vision.ImageAnnotatorClient() + + except Exception as e: + logger.error(f"Google Vision 클라이언트 초기화 실패: {e}") + raise + + def _detect_language(self, text): + """텍스트에서 언어 감지""" + if not text: + return None + + # 각 언어별 특징 문자 카운트 + chinese_chars = sum(1 for char in text if "\u4e00" <= char <= "\u9fff") + hiragana_chars = sum(1 for char in text if "\u3040" <= char <= "\u309f") + katakana_chars = sum(1 for char in text if "\u30a0" <= char <= "\u30ff") + english_chars = sum(1 for char in text if char.isascii() and char.isalpha()) + + total_chars = len( + [ + char + for char in text + if char.isalpha() + or "\u4e00" <= char <= "\u9fff" + or "\u3040" <= char <= "\u30ff" + ] + ) + + if total_chars == 0: + return None + + # 일본어 특징 문자(히라가나/가타카나)가 있으면 일본어로 판단 + if (hiragana_chars + katakana_chars) > 0: + return "ja" + + # 영어 문자가 대부분이면 영어로 판단 + if english_chars / total_chars > 0.7: + return "en" + + # 중국어 문자가 있으면 중국어로 판단 + if chinese_chars > 0: + return "zh-CN" + + # 기본값은 중국어로 설정 + return "zh_CN" + + def _extract_text_from_content(self, image_content: bytes): + """Google Vision API를 사용해 이미지에서 텍스트 추출""" + try: + # Vision API Image 객체 생성 + image = vision.Image(content=image_content) + + # 텍스트 감지 요청 + response = self.vision_client.text_detection(image=image) + + if response.error.message: + raise Exception(f"Vision API Error: {response.error.message}") + + # 텍스트 추출 + texts = response.text_annotations + if texts: + # 첫 번째 요소가 전체 텍스트 + extracted_text = texts[0].description + logger.info(f"Vision API로 추출된 텍스트: {extracted_text}") + return extracted_text.strip() + else: + logger.warning("이미지에서 텍스트를 찾을 수 없습니다") + return "" + + except Exception as e: + logger.error(f"Vision API 텍스트 추출 오류: {e}") + raise + + def _extract_text_from_path(self, image_path): + """이미지에서 텍스트 추출 (파일 경로)""" + try: + # 이미지 파일 읽기 + with io.open(image_path, "rb") as image_file: + content = image_file.read() + + return self._extract_text_from_content(content) + + except Exception as e: + logger.error(f"Error during OCR: {e}") + raise + + def _extract_text_from_bytes(self, image_data: bytes): + """이미지에서 텍스트 추출 (바이트 데이터)""" + try: + return self._extract_text_from_content(image_data) + except Exception as e: + logger.error(f"Error during OCR from bytes: {e}") + raise + + def translate_text(self, text, source_lang=None): + """텍스트를 한국어로 번역""" + if not text: + return "", None + + # 언어가 지정되지 않은 경우 자동 감지 + if source_lang is None: + source_lang = self._detect_language(text) + + if source_lang is None or source_lang not in self.translators: + logger.warning(f"지원하지 않는 언어이거나 감지할 수 없음: {source_lang}") + return "", source_lang + + try: + result = self.translators[source_lang].translate(text) + return result if result is not None else "", source_lang + except Exception as e: + logger.error(f"번역 오류 ({source_lang}): {e}") + return "", source_lang + + def process_image(self, image_path, source_lang=None): + """이미지에서 텍스트 추출 후 한국어로 번역 (파일 경로)""" + try: + # 텍스트 추출 + extracted_text = self._extract_text_from_path(image_path) + logger.info(f"추출된 텍스트: {extracted_text}") + + # 번역 + translated_text, detected_lang = self.translate_text( + extracted_text, source_lang + ) + logger.info(f"번역된 텍스트 ({detected_lang}): {translated_text}") + + return { + "original_text": extracted_text, + "translated_text": translated_text, + "detected_language": detected_lang, + "success": True, + "error": None, + } + + except Exception as e: + logger.error(f"이미지 처리 오류: {e}") + return { + "original_text": "", + "translated_text": "", + "detected_language": None, + "success": False, + "error": str(e), + } + + def process_image_from_bytes(self, image_data: bytes, source_lang=None): + """이미지에서 텍스트 추출 후 한국어로 번역 (바이트 데이터)""" + try: + # 텍스트 추출 + extracted_text = self._extract_text_from_bytes(image_data) + logger.info(f"추출된 텍스트: {extracted_text}") + + # 번역 + translated_text, detected_lang = self.translate_text( + extracted_text, source_lang + ) + logger.info(f"번역된 텍스트 ({detected_lang}): {translated_text}") + + return { + "original_text": extracted_text, + "translated_text": translated_text, + "detected_language": detected_lang, + "success": True, + "error": None, + } + + except Exception as e: + logger.error(f"이미지 처리 오류: {e}") + return { + "original_text": "", + "translated_text": "", + "detected_language": None, + "success": False, + "error": str(e), + } + + def process_chinese_only(self, image_data): + """중국어 전용 처리""" + result = self.process_image_from_bytes(image_data, source_lang="zh-CN") + return { + "chinese_text": result["original_text"], + "korean_text": result["translated_text"], + "success": result["success"], + } + + def process_japanese_only(self, image_data): + """일본어 전용 처리""" + result = self.process_image_from_bytes(image_data, source_lang="ja") + return { + "japanese_text": result["original_text"], + "korean_text": result["translated_text"], + "success": result["success"], + } + + def process_english_only(self, image_data): + """영어 전용 처리""" + result = self.process_image_from_bytes(image_data, source_lang="en") + return { + "english_text": result["original_text"], + "korean_text": result["translated_text"], + "success": result["success"], + } diff --git a/apps/pre-processing-service/app/service/ocr/S3OCRProcessor.py b/apps/pre-processing-service/app/service/ocr/S3OCRProcessor.py new file mode 100644 index 00000000..4e97c47d --- /dev/null +++ b/apps/pre-processing-service/app/service/ocr/S3OCRProcessor.py @@ -0,0 +1,127 @@ +from datetime import datetime +import re +from typing import List, Dict, Any +from loguru import logger + +from app.service.ocr.OCRTranslator import OCRTranslator +from app.service.ocr.S3Service import S3Service + + +class S3OCRProcessor: + + def __init__(self, keyword: str): + """S3 OCR 처리기 초기화""" + self.keyword = keyword + self.s3_service = S3Service(keyword) + self.ocr_translator = OCRTranslator() + + def _preprocess_text(self, text: str) -> str: + """ + 텍스트 전처리 + - 개행 문자(\n) 제거 + - 영어 문자 제거 + """ + if not text: + return "" + + # 개행 문자를 공백으로 변경 + text = text.replace("\n", " ") + + # 영어 문자만 제거 (알파벳만, 숫자는 유지) + text = re.sub(r"[a-zA-Z]+", " ", text) + + # 특수문자 제거 + text = re.sub(r'[\'\/\\;:<>"|\-~•]+', " ", text) + + # 연속된 공백을 하나로 정리 + text = re.sub(r"\s+", " ", text) + + # 앞뒤 공백 제거 + return text.strip() + + def process_images(self) -> Dict[str, Any]: + """ + S3에서 이미지들을 가져와서 OCR 처리 후 지정된 형식으로 반환 + + Returns: + Dict: { + "keyword": "키워드", + "extraction_language": "전처리된 원본 언어 텍스트", + "translation_language": "전처리된 번역된 한국어 텍스트" + } + """ + try: + logger.info(f"키워드 '{self.keyword}' OCR 처리 시작") + + # S3에서 객체 목록 가져오기 + all_objects = self.s3_service.get_folder_objects() + + if not all_objects: + logger.warning("S3에서 객체를 찾을 수 없습니다") + return self._create_empty_response() + + # JPG 파일만 필터링 + jpg_files = self.s3_service.get_jpg_files(all_objects) + logger.info(f"총 {len(jpg_files)}개의 JPG 파일 발견") + + if not jpg_files: + logger.warning("JPG 파일을 찾을 수 없습니다") + return self._create_empty_response() + + # 모든 추출된 텍스트와 번역된 텍스트를 수집 + all_extracted_texts = [] + all_translated_texts = [] + + for jpg_file in jpg_files: + try: + logger.info(f"처리 중: {jpg_file}") + + # S3에서 이미지 데이터 가져오기 + image_data = self.s3_service.get_image_data(jpg_file) + + # OCR 및 번역 처리 + ocr_result = self.ocr_translator.process_image_from_bytes( + image_data + ) + + # 성공한 경우에만 텍스트 추가 + if ocr_result["success"] and ocr_result["original_text"]: + all_extracted_texts.append(ocr_result["original_text"]) + + if ocr_result["success"] and ocr_result["translated_text"]: + all_translated_texts.append(ocr_result["translated_text"]) + + except Exception as e: + logger.error(f"이미지 처리 실패 ({jpg_file}): {e}") + continue + + # 텍스트 전처리 적용 + raw_extracted = " ".join(all_extracted_texts) + raw_translated = " ".join(all_translated_texts) + + processed_extracted = self._preprocess_text(raw_extracted) + processed_translated = self._preprocess_text(raw_translated) + + # 최종 응답 생성 + response = { + "keyword": self.keyword, + "extraction_language": processed_extracted, + "translation_language": processed_translated, + } + + logger.info( + f"처리 완료: {len(all_extracted_texts)}개 텍스트 추출, {len(all_translated_texts)}개 번역" + ) + return response + + except Exception as e: + logger.error(f"OCR 처리 과정에서 오류 발생: {e}") + return self._create_empty_response() + + def _create_empty_response(self) -> Dict[str, Any]: + """빈 응답 생성""" + return { + "keyword": self.keyword, + "extraction_language": "", + "translation_language": "", + } diff --git a/apps/pre-processing-service/app/service/ocr/S3Service.py b/apps/pre-processing-service/app/service/ocr/S3Service.py index 54176fb9..1bf36e5f 100644 --- a/apps/pre-processing-service/app/service/ocr/S3Service.py +++ b/apps/pre-processing-service/app/service/ocr/S3Service.py @@ -8,13 +8,16 @@ load_dotenv() + class S3Service: - def __init__(self, keyword:str): - self.s3_client = boto3.client('s3', - aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"), - aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"), - region_name=os.getenv("AWS_REGION")) + def __init__(self, keyword: str): + self.s3_client = boto3.client( + "s3", + aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"), + aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"), + region_name=os.getenv("AWS_REGION"), + ) self.bucket_name = os.getenv("S3_BUCKET_NAME") self.date = datetime.now().strftime("%Y%m%d") self.keyword = keyword @@ -24,14 +27,13 @@ def get_folder_objects(self): try: response = self.s3_client.list_objects_v2( - Bucket=self.bucket_name, - Prefix=f"product/20250922_{self.keyword}_1" + Bucket=self.bucket_name, Prefix=f"product/20250922_{self.keyword}_1" ) objects = [] - if 'Contents' in response: - for obj in response['Contents']: - objects.append(obj['Key']) + if "Contents" in response: + for obj in response["Contents"]: + objects.append(obj["Key"]) return objects except Exception as e: @@ -42,7 +44,7 @@ def get_jpg_files(self, object_keys: List[str]) -> List[str]: """객체 키 리스트에서 JPG 파일만 필터링""" jpg_files = [] for key in object_keys: - if key.lower().endswith(('.jpg', '.jpeg')): + if key.lower().endswith((".jpg", ".jpeg")): jpg_files.append(key) return jpg_files @@ -50,8 +52,8 @@ def get_image_data(self, key: str) -> bytes: """S3에서 이미지 데이터 가져오기""" try: response = self.s3_client.get_object(Bucket=self.bucket_name, Key=key) - image_data = response['Body'].read() + image_data = response["Body"].read() return image_data except Exception as e: logger.error(f"S3 이미지 데이터 가져오기 실패 ({key}): {e}") - raise \ No newline at end of file + raise From ffb5238d5e54aa36f67342c0bc4a3b2c263ed93d Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 27 Sep 2025 17:35:02 +0900 Subject: [PATCH 34/40] =?UTF-8?q?chore=20:=20=5F=5Finit=5F=5F=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pre-processing-service/app/service/ocr/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 apps/pre-processing-service/app/service/ocr/__init__.py diff --git a/apps/pre-processing-service/app/service/ocr/__init__.py b/apps/pre-processing-service/app/service/ocr/__init__.py new file mode 100644 index 00000000..e69de29b From 553d280275755ab70672d3ba8d4d071f8bed6a25 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 27 Sep 2025 17:41:38 +0900 Subject: [PATCH 35/40] =?UTF-8?q?fix=20:=20S3=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pre-processing-service/app/service/ocr/S3Service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/pre-processing-service/app/service/ocr/S3Service.py b/apps/pre-processing-service/app/service/ocr/S3Service.py index 1bf36e5f..3ba66e35 100644 --- a/apps/pre-processing-service/app/service/ocr/S3Service.py +++ b/apps/pre-processing-service/app/service/ocr/S3Service.py @@ -27,7 +27,7 @@ def get_folder_objects(self): try: response = self.s3_client.list_objects_v2( - Bucket=self.bucket_name, Prefix=f"product/20250922_{self.keyword}_1" + Bucket=self.bucket_name, Prefix=f"product/{self.date}_{self.keyword}_1" ) objects = [] From 337910e840c761758416cfbadc1f00bbbfdfc92a Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 27 Sep 2025 17:48:39 +0900 Subject: [PATCH 36/40] =?UTF-8?q?fix=20:=20API=20key=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EC=A7=80=EC=A0=95=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EB=A7=88?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/production-fastapi/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/production-fastapi/docker-compose.yml b/docker/production-fastapi/docker-compose.yml index 76b0b85c..0f6e3a2b 100644 --- a/docker/production-fastapi/docker-compose.yml +++ b/docker/production-fastapi/docker-compose.yml @@ -11,6 +11,7 @@ services: - ~/app/blogger:/app/blogger - ~/app/models:/app/models - logs_volume:/logs + - ~/app/key:/app/key depends_on: - promtail env_file: From 13a809683628ff0a05f40f3b272a55f7726e642b Mon Sep 17 00:00:00 2001 From: Yousung Jung Date: Sat, 27 Sep 2025 20:52:15 +0900 Subject: [PATCH 37/40] =?UTF-8?q?Gradle=20action=20v4=EB=A1=9C=20upgrade?= =?UTF-8?q?=20(#220)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Gradle action v4로 upgrade * fix: Gradle 캐시 설정 후 spotlessApply * chore: Submission 병렬로 변경 * chore: main에서 tag 발행 시 캐시 저장으로 원복 --- .github/workflows/ci-java.yml | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml index 773b9102..f19d3b00 100644 --- a/.github/workflows/ci-java.yml +++ b/.github/workflows/ci-java.yml @@ -15,21 +15,47 @@ on: - ".github/workflows/ci-java.yml" permissions: - contents: read + contents: write # Dependency graph 생성용 packages: write security-events: write checks: write pull-requests: write pages: write # GitHub Pages 배포 id-token: write # GitHub Pages 배포 + actions: write jobs: + dependency-submission: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Generate and submit dependency graph + uses: gradle/actions/dependency-submission@v4 + with: + build-root-directory: apps/user-service + spotless-check: if: github.event.pull_request.draft == false name: Lint Check runs-on: ubuntu-latest steps: + - name: Debug cache settings + run: | + echo "Event name: ${{ github.event_name }}" + echo "Event type: ${{ github.event.action }}" + echo "Cache read-only condition: ${{ github.event_name == 'pull_request' }}" + echo "GitHub ref: ${{ github.ref }}" + - name: Checkout repository uses: actions/checkout@v4 @@ -44,6 +70,11 @@ jobs: uses: gradle/actions/setup-gradle@v3 with: cache-read-only: ${{ github.event_name == 'pull_request' }} + gradle-home-cache-cleanup: false + gradle-home-cache-includes: | + caches + notifications + wrapper - name: Grant execute permission for Gradle wrapper run: chmod +x ./gradlew @@ -73,7 +104,7 @@ jobs: distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v4 with: cache-read-only: ${{ github.event_name == 'pull_request' }} From 0a9fc5eed5177e2344a1b4be270f31e4293795a6 Mon Sep 17 00:00:00 2001 From: can019 Date: Sat, 27 Sep 2025 21:57:09 +0900 Subject: [PATCH 38/40] chore: fix lint --- .../icebang/domain/workflow/dto/WorkflowCreateDto.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java index b581a6b1..efee78bd 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java @@ -16,14 +16,10 @@ * 워크플로우 생성 요청 DTO * *

    프론트엔드에서 워크플로우 생성 시 필요한 모든 정보를 담는 DTO - 기본 정보: 이름, 설명 - 플랫폼 설정: 검색 플랫폼, 포스팅 플랫폼 - 계정 설정: 포스팅 계정 -<<<<<<< HEAD - * 정보 (JSON 형태로 저장) - 스케줄 설정: 선택적으로 여러 스케줄 등록 가능 + * <<<<<<< HEAD 정보 (JSON 형태로 저장) - 스케줄 설정: 선택적으로 여러 스케줄 등록 가능 * * @author bwnfo0702@gmail.com - * @since v0.1.0 -======= - * 정보 (JSON 형태로 저장) ->>>>>>> main + * @since v0.1.0 ======= 정보 (JSON 형태로 저장) >>>>>>> main */ @Data @Builder From e92bf721efd59c03016addc24a0ef198b8a2a732 Mon Sep 17 00:00:00 2001 From: can019 Date: Sat, 27 Sep 2025 21:59:37 +0900 Subject: [PATCH 39/40] chore: Fix javadoc merged --- .../site/icebang/domain/workflow/dto/WorkflowCreateDto.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java index efee78bd..f14b2aeb 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java @@ -16,10 +16,10 @@ * 워크플로우 생성 요청 DTO * *

    프론트엔드에서 워크플로우 생성 시 필요한 모든 정보를 담는 DTO - 기본 정보: 이름, 설명 - 플랫폼 설정: 검색 플랫폼, 포스팅 플랫폼 - 계정 설정: 포스팅 계정 - * <<<<<<< HEAD 정보 (JSON 형태로 저장) - 스케줄 설정: 선택적으로 여러 스케줄 등록 가능 + * 정보 (JSON 형태로 저장) - 스케줄 설정: 선택적으로 여러 스케줄 등록 가능 * * @author bwnfo0702@gmail.com - * @since v0.1.0 ======= 정보 (JSON 형태로 저장) >>>>>>> main + * @since v0.1.0 */ @Data @Builder From 346e4f40181cb81549ffc827a51cdd5115149e1e Mon Sep 17 00:00:00 2001 From: Yousung Jung Date: Sat, 27 Sep 2025 23:43:59 +0900 Subject: [PATCH 40/40] =?UTF-8?q?Spring=20=EC=84=A4=EC=A0=95=20docker=20im?= =?UTF-8?q?age=EC=97=90=EC=84=9C=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=20Log?= =?UTF-8?q?4j2=20=EC=9E=90=EB=8F=99=20=EA=B0=90=EC=8B=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#228)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: production 환경에서 log, application yml mount로 변경 외부 ec2에서 mount 되도록 설정 * refactor: Production jar에 필요한 resource만 export * feat: Production 환경에서 30초마다 log 설정 load --- .github/workflows/deploy-java.yml | 22 ++++++++++++++++++- apps/user-service/build.gradle | 7 ++++++ .../src/main/resources/log4j2-production.yml | 1 + docker/production/docker-compose.yml | 1 + 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-java.yml b/.github/workflows/deploy-java.yml index bb4483dd..facbdd1c 100644 --- a/.github/workflows/deploy-java.yml +++ b/.github/workflows/deploy-java.yml @@ -79,7 +79,7 @@ jobs: source: "docker/production/promtail-config.yml" target: "~/app" - - name: Copy promtail-config to EC2 + - name: Copy agent-config to EC2 uses: appleboy/scp-action@v0.1.7 with: host: ${{ secrets.SERVER_HOST }} @@ -89,6 +89,26 @@ jobs: target: "~/app" overwrite: true + - name: Copy application-production.yml to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.SERVER_HOST }} + username: ubuntu + key: ${{ secrets.SERVER_SSH_KEY }} + source: "apps/user-service/src/main/resources/application-production.yml" + target: "~/app/docker/production/config/application-production.yml" + overwrite: true + + - name: Copy log4j2-production.yml to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.SERVER_HOST }} + username: ubuntu + key: ${{ secrets.SERVER_SSH_KEY }} + source: "apps/user-service/src/main/resources/log4j2-production.yml" + target: "~/app/docker/production/config/log4j2-production.yml" + overwrite: true + - name: Deploy on EC2 uses: appleboy/ssh-action@v1.0.3 with: diff --git a/apps/user-service/build.gradle b/apps/user-service/build.gradle index 16905e8e..3660ab02 100644 --- a/apps/user-service/build.gradle +++ b/apps/user-service/build.gradle @@ -160,6 +160,13 @@ bootJar { from ("${asciidoctor.outputDir}/html5") { into 'static/docs' } + + // 프로덕션 JAR에서 불필요한 파일들 제외 + exclude 'application-test-*.yml' + exclude 'log4j2-test-*.yml' + exclude 'application-develop.yml' + exclude 'log4j2-develop.yml' + exclude 'sql/**' } spotless { diff --git a/apps/user-service/src/main/resources/log4j2-production.yml b/apps/user-service/src/main/resources/log4j2-production.yml index ae8e9aba..79d920fc 100644 --- a/apps/user-service/src/main/resources/log4j2-production.yml +++ b/apps/user-service/src/main/resources/log4j2-production.yml @@ -1,6 +1,7 @@ Configuration: status: INFO name: production + monitorInterval: 30 properties: property: diff --git a/docker/production/docker-compose.yml b/docker/production/docker-compose.yml index 6d9a3c10..35ecb706 100644 --- a/docker/production/docker-compose.yml +++ b/docker/production/docker-compose.yml @@ -32,6 +32,7 @@ services: - SPRING_PROFILES_ACTIVE=production volumes: - logs_volume:/logs + - ./config:/app/config:ro # Grafana Agent만으로 메트릭 수집 + 전송 grafana-agent: