From ee39850f341113025c845ff609660b2c65572cd0 Mon Sep 17 00:00:00 2001 From: Jihu Kim Date: Fri, 19 Sep 2025 18:15:26 +0900 Subject: [PATCH 01/25] =?UTF-8?q?Spring=20Quartz,=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=A7=81=20=EC=9E=91=EC=97=85(Workflow=20=EB=B0=8F=20?= =?UTF-8?q?Job,=20Task=20=EC=84=B8=ED=8C=85)=20(#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: workflow 관련 insert SQL 수정 * refactor: Task 관련 로직 수정 * refactor: fast api url 수정 및 workflow 관련 insert SQL 수정 * refactor: WORKFLOW 관련 INSERT SQL 리팩토링 * refactor: task type 네이밍 변경 * refactor: FastAPI 통신 로직을 전용 Adapter로 분리 * fix: Set grafana-agent env expand true * refactor: 사용하지 않는 Schema 삭제 * feat: Quartz scheduling 작업 * chore: spotlessApply * fix: Actuator 모든 target에 대해 open * refactor: WORKFLOW관련 INSERT SQL 리팩토링 * refactor: FastApi 통신 관련 로직 수정 * refactor: FastApi 측 requestBody 관련 로직 수정 * refactor: Code Formatting --------- Co-authored-by: can019 Co-authored-by: thkim7 --- .../workflow/runner/FastApiTaskRunner.java | 34 ++ .../workflow/runner/HttpTaskRunner.java | 67 ---- .../runner/body/ProductSearchBodyBuilder.java | 34 ++ .../workflow/runner/body/TaskBodyBuilder.java | 14 + .../service/WorkflowExecutionService.java | 109 +----- .../fastapi/adapter/FastApiAdapter.java | 40 +++ .../src/main/resources/sql/01-schema-h2.sql | 328 ------------------ .../main/resources/sql/03-insert-workflow.sql | 137 ++++---- 8 files changed, 207 insertions(+), 556 deletions(-) create mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/runner/FastApiTaskRunner.java delete mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/runner/HttpTaskRunner.java create mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/ProductSearchBodyBuilder.java create mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/TaskBodyBuilder.java create mode 100644 apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java delete mode 100644 apps/user-service/src/main/resources/sql/01-schema-h2.sql diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/FastApiTaskRunner.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/FastApiTaskRunner.java new file mode 100644 index 00000000..5a36afa3 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/FastApiTaskRunner.java @@ -0,0 +1,34 @@ +package site.icebang.domain.workflow.runner; + +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.RequiredArgsConstructor; + +import site.icebang.domain.execution.model.TaskRun; +import site.icebang.domain.workflow.model.Task; +import site.icebang.external.fastapi.adapter.FastApiAdapter; + +@Component("fastapiTaskRunner") +@RequiredArgsConstructor +public class FastApiTaskRunner implements TaskRunner { + + private final FastApiAdapter fastApiAdapter; + + @Override + public TaskExecutionResult execute(Task task, TaskRun taskRun, ObjectNode requestBody) { + JsonNode params = task.getParameters(); + String endpoint = params.path("endpoint").asText(); + HttpMethod method = HttpMethod.valueOf(params.path("method").asText("POST").toUpperCase()); + + String responseBody = fastApiAdapter.call(endpoint, method, requestBody.toString()); + + if (responseBody == null) { + return TaskExecutionResult.failure("FastApiAdapter 호출에 실패했습니다."); + } + return TaskExecutionResult.success(responseBody); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/HttpTaskRunner.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/HttpTaskRunner.java deleted file mode 100644 index 861edd5a..00000000 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/HttpTaskRunner.java +++ /dev/null @@ -1,67 +0,0 @@ -package site.icebang.domain.workflow.runner; - -import org.springframework.http.*; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import site.icebang.domain.execution.model.TaskRun; -import site.icebang.domain.workflow.model.Task; - -@Slf4j -@Component("httpTaskRunner") // "httpTaskRunner"라는 이름의 Bean으로 등록 -@RequiredArgsConstructor -public class HttpTaskRunner implements TaskRunner { - - private final RestTemplate restTemplate; - - // private final TaskIoDataRepository taskIoDataRepository; // TODO: 입출력 저장을 위해 주입 - - @Override - public TaskExecutionResult execute(Task task, TaskRun taskRun, ObjectNode requestBody) { - JsonNode params = task.getParameters(); - if (params == null) { - return TaskExecutionResult.failure("Task에 파라미터가 정의되지 않았습니다."); - } - - String url = params.path("url").asText(); - String method = params.path("method").asText("POST"); // 기본값 POST - - if (url.isEmpty()) { - return TaskExecutionResult.failure("Task 파라미터에 'url'이 없습니다."); - } - - try { - // 1. HTTP 헤더 설정 - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - // 2. HTTP 요청 엔티티 생성 (헤더 + 동적 Body) - HttpEntity requestEntity = new HttpEntity<>(requestBody.toString(), headers); - - log.debug("HTTP Task 요청: URL={}, Method={}, Body={}", url, method, requestBody.toString()); - - // 3. RestTemplate으로 API 호출 - ResponseEntity responseEntity = - restTemplate.exchange( - url, HttpMethod.valueOf(method.toUpperCase()), requestEntity, String.class); - - String responseBody = responseEntity.getBody(); - log.debug("HTTP Task 응답: Status={}, Body={}", responseEntity.getStatusCode(), responseBody); - - // TODO: taskIoDataRepository를 사용하여 requestBody와 responseBody를 DB에 저장 - - return TaskExecutionResult.success(responseBody); - - } catch (RestClientException e) { - log.error("HTTP Task 실행 중 에러 발생: TaskRunId={}, Error={}", taskRun.getId(), e.getMessage()); - return TaskExecutionResult.failure(e.getMessage()); - } - } -} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/ProductSearchBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/ProductSearchBodyBuilder.java new file mode 100644 index 00000000..2dd3fcb6 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/ProductSearchBodyBuilder.java @@ -0,0 +1,34 @@ +package site.icebang.domain.workflow.runner.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 ProductSearchBodyBuilder implements TaskBodyBuilder { + + private final ObjectMapper objectMapper; + private static final String TASK_NAME = "상품 검색 태스크"; + private static final String SOURCE_TASK_NAME = "키워드 검색 태스크"; + + @Override + public boolean supports(String taskName) { + return TASK_NAME.equals(taskName); + } + + @Override + public ObjectNode build(Task task, Map workflowContext) { + JsonNode sourceResult = workflowContext.get(SOURCE_TASK_NAME); + String keyword = sourceResult != null ? sourceResult.path("keyword").asText("") : ""; + return objectMapper.createObjectNode().put("keyword", keyword); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/TaskBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/TaskBodyBuilder.java new file mode 100644 index 00000000..da6f1597 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/TaskBodyBuilder.java @@ -0,0 +1,14 @@ +package site.icebang.domain.workflow.runner.body; + +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import site.icebang.domain.workflow.model.Task; + +public interface TaskBodyBuilder { + boolean supports(String taskName); + + ObjectNode build(Task task, Map workflowContext); +} 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 0f0e316d..d142b630 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 @@ -3,6 +3,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,6 +27,7 @@ import site.icebang.domain.workflow.model.Job; import site.icebang.domain.workflow.model.Task; import site.icebang.domain.workflow.runner.TaskRunner; +import site.icebang.domain.workflow.runner.body.TaskBodyBuilder; @Slf4j @Service @@ -37,7 +39,8 @@ public class WorkflowExecutionService { private final JobRunMapper jobRunMapper; private final TaskRunMapper taskRunMapper; private final Map taskRunners; - private final ObjectMapper objectMapper; // 📌 JSON 처리를 위해 ObjectMapper 주입 + private final ObjectMapper objectMapper; + private final List bodyBuilders; @Transactional public void executeWorkflow(Long workflowId) { @@ -45,9 +48,7 @@ public void executeWorkflow(Long workflowId) { WorkflowRun workflowRun = WorkflowRun.start(workflowId); workflowRunMapper.insert(workflowRun); - // 📌 1. 워크플로우 전체 실행 동안 데이터를 공유할 컨텍스트 생성 Map workflowContext = new HashMap<>(); - List jobs = jobMapper.findJobsByWorkflowId(workflowId); log.info("총 {}개의 Job을 순차적으로 실행합니다.", jobs.size()); @@ -57,7 +58,6 @@ public void executeWorkflow(Long workflowId) { log.info( "---------- Job 실행 시작: JobId={}, JobRunId={} ----------", job.getId(), jobRun.getId()); - // 📌 2. Job 내의 Task들을 실행하고, 컨텍스트를 전달하여 데이터 파이프라이닝 수행 boolean jobSucceeded = executeTasksForJob(jobRun, workflowContext); jobRun.finish(jobSucceeded ? "SUCCESS" : "FAILED"); @@ -77,16 +77,13 @@ public void executeWorkflow(Long workflowId) { log.info("========== 워크플로우 실행 성공: WorkflowRunId={} ==========", workflowRun.getId()); } - /** - * 특정 Job에 속한 Task들을 순차적으로 실행합니다. - * - * @param jobRun 실행중인 Job의 기록 객체 - * @return 모든 Task가 성공하면 true, 하나라도 실패하면 false - */ private boolean executeTasksForJob(JobRun jobRun, Map workflowContext) { - // TaskDto를 조회하고 Task로 변환 + // 📌 Mapper로부터 TaskDto 리스트를 조회합니다. List taskDtos = jobMapper.findTasksByJobId(jobRun.getJobId()); - List tasks = taskDtos.stream().map(this::convertToTask).toList(); + + // 📌 convertToTask 메소드를 사용하여 Task 모델 리스트로 변환합니다. + List tasks = taskDtos.stream().map(this::convertToTask).collect(Collectors.toList()); + log.info("Job (JobRunId={}) 내 총 {}개의 Task를 실행합니다.", jobRun.getId(), tasks.size()); for (Task task : tasks) { @@ -104,10 +101,13 @@ private boolean executeTasksForJob(JobRun jobRun, Map workflow return false; } - // 📌 3. Task 실행 전, 컨텍스트를 이용해 동적으로 Request Body를 생성 - ObjectNode requestBody = prepareRequestBody(task, workflowContext); + ObjectNode requestBody = + bodyBuilders.stream() + .filter(builder -> builder.supports(task.getName())) + .findFirst() + .map(builder -> builder.build(task, workflowContext)) + .orElse(objectMapper.createObjectNode()); - // 📌 4. 동적으로 생성된 Request Body를 전달하여 Task 실행 TaskRunner.TaskExecutionResult result = runner.execute(task, taskRun, requestBody); taskRun.finish(result.status(), result.message()); taskRunMapper.update(taskRun); @@ -117,7 +117,6 @@ private boolean executeTasksForJob(JobRun jobRun, Map workflow return false; } - // 📌 5. 성공한 Task의 결과를 다음 Task가 사용할 수 있도록 컨텍스트에 저장 try { JsonNode resultJson = objectMapper.readTree(result.message()); workflowContext.put(task.getName(), resultJson); @@ -133,82 +132,7 @@ private boolean executeTasksForJob(JobRun jobRun, Map workflow return true; } - /** 워크플로우 컨텍스트와 Task의 input_mapping 설정을 기반으로 API 요청에 사용할 동적인 Request Body를 생성합니다. */ - private ObjectNode prepareRequestBody(Task task, Map context) { - ObjectNode requestBody = objectMapper.createObjectNode(); - JsonNode params = task.getParameters(); - if (params == null) return requestBody; - - JsonNode mappingRules = params.get("input_mapping"); - JsonNode staticBody = params.get("body"); - - // 정적 body가 있으면 우선적으로 복사 - if (staticBody != null && staticBody.isObject()) { - requestBody.setAll((ObjectNode) staticBody); - } - - // 📌 디버깅용: 현재 컨텍스트 출력 - log.debug("=== 워크플로우 컨텍스트 확인 ==="); - for (Map.Entry entry : context.entrySet()) { - log.debug("Task: {}, Data: {}", entry.getKey(), entry.getValue().toString()); - } - - // input_mapping 규칙에 따라 동적으로 값 덮어쓰기/추가 - if (mappingRules != null && mappingRules.isObject()) { - mappingRules - .fields() - .forEachRemaining( - entry -> { - String targetField = entry.getKey(); // 예: "product_url" - String sourcePath = - entry - .getValue() - .asText(); // 예: "상품 유사도 분석 태스크.data.selected_product.product_url" - - log.debug("=== input_mapping 처리 ==="); - log.debug("targetField: {}, sourcePath: {}", targetField, sourcePath); - - String[] parts = sourcePath.split("\\.", 2); - if (parts.length == 2) { - String sourceTaskName = parts[0]; - String sourceFieldPath = parts[1]; - - log.debug( - "sourceTaskName: {}, sourceFieldPath: {}", sourceTaskName, sourceFieldPath); - - JsonNode sourceData = context.get(sourceTaskName); - log.debug("sourceData found: {}", sourceData != null); - - if (sourceData != null) { - log.debug("sourceData content: {}", sourceData.toString()); - - String jsonPath = "/" + sourceFieldPath.replace('.', '/'); - log.debug("jsonPath: {}", jsonPath); - - JsonNode valueToSet = sourceData.at(jsonPath); - log.debug( - "valueToSet found: {}, isMissing: {}", - valueToSet, - valueToSet.isMissingNode()); - - if (!valueToSet.isMissingNode()) { - log.debug("설정할 값: {}", valueToSet.toString()); - requestBody.set(targetField, valueToSet); - } else { - log.warn("값을 찾을 수 없음: jsonPath={}", jsonPath); - } - } else { - log.warn("소스 태스크 데이터를 찾을 수 없음: {}", sourceTaskName); - } - } - }); - } - - log.debug("최종 requestBody: {}", requestBody.toString()); - return requestBody; - } - - /** TaskDto를 Task 모델로 변환합니다. 비즈니스 로직 실행에 필요한 필드만 복사합니다. */ + /** TaskDto를 Task 모델로 변환합니다. 📌 주의: Reflection을 사용한 방식은 성능이 느리고 불안정하므로 권장되지 않습니다. */ private Task convertToTask(TaskDto taskDto) { Task task = new Task(); try { @@ -231,7 +155,6 @@ private Task convertToTask(TaskDto taskDto) { } catch (Exception e) { throw new RuntimeException("TaskDto to Task 변환 중 오류 발생", e); } - return task; } } diff --git a/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java b/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java new file mode 100644 index 00000000..2a5bd001 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java @@ -0,0 +1,40 @@ +package site.icebang.external.fastapi.adapter; + +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import site.icebang.global.config.properties.FastApiProperties; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FastApiAdapter { + + private final RestTemplate restTemplate; + private final FastApiProperties properties; + + // 📌 Task나 context에 대한 의존성이 완전히 사라짐 + public String call(String endpoint, HttpMethod method, String requestBody) { + String fullUrl = properties.getUrl() + endpoint; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity requestEntity = new HttpEntity<>(requestBody, headers); + + try { + log.debug("FastAPI 요청: URL={}, Method={}, Body={}", fullUrl, method, requestBody); + ResponseEntity responseEntity = + restTemplate.exchange(fullUrl, method, requestEntity, String.class); + String responseBody = responseEntity.getBody(); + log.debug("FastAPI 응답: Status={}, Body={}", responseEntity.getStatusCode(), responseBody); + return responseBody; + } catch (RestClientException e) { + log.error("FastAPI 호출 실패: URL={}, Error={}", fullUrl, e.getMessage()); + return null; + } + } +} diff --git a/apps/user-service/src/main/resources/sql/01-schema-h2.sql b/apps/user-service/src/main/resources/sql/01-schema-h2.sql deleted file mode 100644 index 018ebb1d..00000000 --- a/apps/user-service/src/main/resources/sql/01-schema-h2.sql +++ /dev/null @@ -1,328 +0,0 @@ --- H2 데이터베이스 호환 스키마 (테스트용) --- MySQL의 unsigned, AFTER 절 등을 H2 호환으로 변경 - -CREATE TABLE `permission` ( - `id` int NOT NULL AUTO_INCREMENT, - `resource` varchar(100) NULL, - `description` varchar(255) NULL, - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `is_active` boolean DEFAULT TRUE, - `updated_by` bigint NULL, - `created_by` bigint NULL, - PRIMARY KEY (`id`) -); - -CREATE TABLE `organization` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `name` varchar(150) NULL, - `domain_name` varchar(100) NULL, - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`) -); - -CREATE TABLE `role` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `organization_id` bigint NULL, - `name` varchar(100) NULL, - `description` varchar(500) NULL, - PRIMARY KEY (`id`) -); - -CREATE TABLE `user` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `name` varchar(50) NULL, - `email` varchar(100) NULL, - `password` varchar(255) NULL, - `status` varchar(20) NULL, - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`) -); - -CREATE TABLE `department` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `organization_id` bigint NOT NULL, - `name` varchar(100) NULL, - PRIMARY KEY (`id`) -); - -CREATE TABLE `position` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `organization_id` bigint NOT NULL, - `title` varchar(100) NULL, - PRIMARY KEY (`id`) -); - -CREATE TABLE `user_organization` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `user_id` bigint NOT NULL, - `organization_id` bigint NOT NULL, - `position_id` bigint NOT NULL, - `department_id` bigint NOT NULL, - `employee_number` varchar(50) NULL, - `status` varchar(20) NULL, - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`) -); - -CREATE TABLE `role_permission` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `role_id` bigint NOT NULL, - `permission_id` int NOT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `uk_role_permission` (`role_id`, `permission_id`) -); - -CREATE TABLE `user_role` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `role_id` bigint NOT NULL, - `user_organization_id` bigint NOT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `uk_user_role` (`role_id`, `user_organization_id`) -); - --- 성능 최적화를 위한 인덱스 -CREATE INDEX `idx_user_email` ON `user` (`email`); -CREATE INDEX `idx_user_status` ON `user` (`status`); -CREATE INDEX `idx_user_organization_user` ON `user_organization` (`user_id`); -CREATE INDEX `idx_user_organization_org` ON `user_organization` (`organization_id`); -CREATE INDEX `idx_user_organization_status` ON `user_organization` (`status`); -CREATE INDEX `idx_role_org` ON `role` (`organization_id`); -CREATE INDEX `idx_permission_resource` ON `permission` (`resource`); -CREATE INDEX `idx_permission_active` ON `permission` (`is_active`); - -CREATE TABLE `workflow` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `name` varchar(100) NOT NULL UNIQUE, - `description` text NULL, - `is_enabled` boolean DEFAULT TRUE, - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - `created_by` bigint NULL, - `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `updated_by` bigint NULL, - PRIMARY KEY (`id`) -); - -CREATE TABLE `schedule` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `workflow_id` bigint NOT NULL, - `cron_expression` varchar(50) NULL, - `parameters` json NULL, - `is_active` boolean DEFAULT TRUE, - `last_run_status` varchar(20) NULL, - `last_run_at` timestamp NULL, - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - `created_by` bigint NULL, - `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `updated_by` bigint NULL, - PRIMARY KEY (`id`) -); - -CREATE TABLE `job` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `name` varchar(100) NOT NULL UNIQUE, - `description` text NULL, - `is_enabled` boolean DEFAULT TRUE, - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - `created_by` bigint NULL, - `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `updated_by` bigint NULL, - PRIMARY KEY (`id`) -); - -CREATE TABLE `task` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `name` varchar(100) NOT NULL UNIQUE, - `type` varchar(50) NULL, - `parameters` json NULL, - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`) -); - -CREATE TABLE `workflow_job` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `workflow_id` bigint NOT NULL, - `job_id` bigint NOT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `uk_workflow_job` (`workflow_id`, `job_id`) -); - -CREATE TABLE `job_task` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `job_id` bigint NOT NULL, - `task_id` bigint NOT NULL, - `execution_order` int NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `uk_job_task` (`job_id`, `task_id`) -); - -CREATE TABLE `execution_log` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `execution_type` varchar(20) NULL COMMENT 'task, schedule, job, workflow', - `source_id` bigint NULL COMMENT '모든 데이터에 대한 ID ex: job_id, schedule_id, task_id, ...', - `log_level` varchar(20) NULL, - `executed_at` timestamp DEFAULT CURRENT_TIMESTAMP, - `log_message` text NULL, - `trace_id` char(36) NULL, - `config_snapshot` json NULL, - PRIMARY KEY (`id`), - INDEX `idx_source_id_type` (`source_id`, `execution_type`) -); - -CREATE TABLE `task_io_data` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `task_run_id` bigint NOT NULL, - `io_type` varchar(10) NOT NULL COMMENT 'INPUT, OUTPUT', - `name` varchar(100) NOT NULL COMMENT '파라미터/변수 이름', - `data_type` varchar(50) NOT NULL COMMENT 'string, number, json, file, etc', - `data_value` json NULL COMMENT '실제 데이터 값', - `data_size` bigint NULL COMMENT '데이터 크기 (bytes)', - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - INDEX `idx_task_io_task_run_id` (`task_run_id`), - INDEX `idx_task_io_type` (`io_type`), - INDEX `idx_task_io_name` (`name`) -); - -CREATE TABLE `config` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `target_type` varchar(50) NULL COMMENT 'user, job, workflow', - `target_id` bigint NULL, - `version` int NULL, - `json` json NULL, - `is_active` boolean DEFAULT TRUE, - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - `created_by` bigint NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `uk_config_target` (`target_type`, `target_id`) -); - -CREATE TABLE `category` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `name` varchar(100) NULL, - `description` text NULL, - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`) -); - -CREATE TABLE `user_config` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `user_id` bigint NOT NULL, - `type` varchar(50) NULL, - `name` varchar(100) NULL, - `json` json NULL, - `is_active` boolean DEFAULT TRUE, - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`) -); - --- 인덱스 추가 (성능 최적화) -CREATE INDEX `idx_schedule_workflow` ON `schedule` (`workflow_id`); -CREATE INDEX `idx_job_enabled` ON `job` (`is_enabled`); -CREATE INDEX `idx_task_type` ON `task` (`type`); -CREATE INDEX `idx_workflow_enabled` ON `workflow` (`is_enabled`); -CREATE UNIQUE INDEX `uk_schedule_workflow` ON `schedule` (`workflow_id`); -CREATE UNIQUE INDEX `uk_job_name` ON `job` (`name`); -CREATE UNIQUE INDEX `uk_task_name` ON `task` (`name`); -CREATE UNIQUE INDEX `uk_workflow_name` ON `workflow` (`name`); -CREATE INDEX `idx_user_config_user` ON `user_config` (`user_id`); - --- 워크플로우 실행 테이블 -CREATE TABLE `workflow_run` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `workflow_id` bigint NOT NULL, - `trace_id` char(36) NOT NULL, - `run_number` varchar(20) NULL, - `status` varchar(20) NULL COMMENT 'pending, running, success, failed, cancelled', - `trigger_type` varchar(20) NULL COMMENT 'manual, schedule, push, pull_request', - `started_at` timestamp NULL, - `finished_at` timestamp NULL, - `created_by` bigint NULL, - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - UNIQUE KEY `uk_workflow_run_trace` (`trace_id`), - INDEX `idx_workflow_run_status` (`status`), - INDEX `idx_workflow_run_workflow_id` (`workflow_id`), - INDEX `idx_workflow_run_created_at` (`created_at`) -); - --- Job 실행 테이블 -CREATE TABLE `job_run` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `workflow_run_id` bigint NOT NULL, - `job_id` bigint NOT NULL, - `status` varchar(20) NULL COMMENT 'pending, running, success, failed, cancelled, skipped', - `started_at` timestamp NULL, - `finished_at` timestamp NULL, - `execution_order` int NULL, - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - INDEX `idx_job_run_workflow_run_id` (`workflow_run_id`), - INDEX `idx_job_run_status` (`status`), - INDEX `idx_job_run_job_id` (`job_id`) -); - --- Task 실행 테이블 -CREATE TABLE `task_run` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `job_run_id` bigint NOT NULL, - `task_id` bigint NOT NULL, - `status` varchar(20) NULL COMMENT 'pending, running, success, failed, cancelled, skipped', - `started_at` timestamp NULL, - `finished_at` timestamp NULL, - `execution_order` int NULL, - `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - INDEX `idx_task_run_job_run_id` (`job_run_id`), - INDEX `idx_task_run_status` (`status`), - INDEX `idx_task_run_task_id` (`task_id`) -); - --- v0.0.3 - H2 호환 버전 -DROP TABLE IF EXISTS `config`; - --- H2에서는 한 번에 하나씩 컬럼 추가 -ALTER TABLE `workflow_job` ADD COLUMN `execution_order` INT NULL; - -ALTER TABLE `schedule` ADD COLUMN `schedule_text` varchar(20) NULL; - -ALTER TABLE `workflow` ADD COLUMN `default_config` json NULL; - -ALTER TABLE `user` ADD COLUMN `joined_at` timestamp NULL; - -ALTER TABLE `department` ADD COLUMN `description` varchar(100) NULL; - --- v0.4 - H2 호환 버전 (AFTER 절 제거, unsigned 제거, 개별 ALTER 구문으로 분리) --- execution_log 테이블 컬럼 추가 (H2 호환) -ALTER TABLE `execution_log` ADD COLUMN `run_id` bigint NULL; -ALTER TABLE `execution_log` ADD COLUMN `status` varchar(20) NULL; -ALTER TABLE `execution_log` ADD COLUMN `duration_ms` int NULL; -ALTER TABLE `execution_log` ADD COLUMN `error_code` varchar(50) NULL; -ALTER TABLE `execution_log` ADD COLUMN `reserved1` varchar(100) NULL; -ALTER TABLE `execution_log` ADD COLUMN `reserved2` varchar(100) NULL; -ALTER TABLE `execution_log` ADD COLUMN `reserved3` int NULL; -ALTER TABLE `execution_log` ADD COLUMN `reserved4` json NULL; -ALTER TABLE `execution_log` ADD COLUMN `reserved5` timestamp NULL; - --- 기존 컬럼 수정 (H2 호환) -ALTER TABLE `execution_log` ALTER COLUMN `log_message` varchar(500) NOT NULL; -ALTER TABLE `execution_log` ALTER COLUMN `executed_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP; - --- 기존 불필요한 컬럼 제거 -ALTER TABLE `execution_log` DROP COLUMN IF EXISTS `config_snapshot`; - --- 새로운 인덱스 추가 -CREATE INDEX `idx_run_id` ON `execution_log` (`run_id`); -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`); - --- 기존 인덱스 수정 -DROP INDEX IF EXISTS `idx_source_id_type`; -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 8e902745..9b6db4c0 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 @@ -1,89 +1,90 @@ --- 기존 워크플로우 관련 데이터 삭제 +-- =================================================================== +-- 워크플로우 관련 데이터 초기화 +-- =================================================================== +-- 참조 관계 역순으로 데이터 삭제 +DELETE FROM `schedule`; DELETE FROM `job_task`; DELETE FROM `workflow_job`; DELETE FROM `task`; DELETE FROM `job`; DELETE FROM `workflow`; -DELETE FROM `schedule`; + +-- =================================================================== +-- 워크플로우 정적 데이터 삽입 +-- =================================================================== -- 워크플로우 생성 (ID: 1) -INSERT INTO `workflow` (`id`, `name`, `description`) VALUES - (1, '상품 분석 및 블로그 자동 발행', '키워드 검색부터 상품 분석 후 블로그 발행까지의 자동화 프로세스'); +INSERT INTO `workflow` (`id`, `name`, `description`, `created_by`) VALUES + (1, '상품 분석 및 블로그 자동 발행', '키워드 검색부터 상품 분석 후 블로그 발행까지의 자동화 프로세스', 1) + ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), updated_at = NOW(); -- Job 생성 (ID: 1, 2) -INSERT INTO `job` (`id`, `name`, `description`) VALUES - (1, '상품 분석', '키워드 검색, 상품 크롤링 및 유사도 분석 작업'), - (2, '블로그 콘텐츠 생성', '분석 데이터를 기반으로 RAG 콘텐츠 생성 및 발행 작업'); +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 ~ 7) +-- Task 생성 (ID: 1 ~ 7) - FastAPI Request Body 스키마 반영 INSERT INTO `task` (`id`, `name`, `type`, `parameters`) VALUES --- Job 1의 Task들 - (1, '키워드 검색 태스크', 'HTTP', JSON_OBJECT( - 'url', 'http://127.0.0.1:8000/keywords/search', - 'method', 'POST', - 'body', JSON_OBJECT('tag', 'naver') - )), - (2, '상품 검색 태스크', 'HTTP', JSON_OBJECT( - 'url', 'http://127.0.0.1:8000/products/search', - 'method', 'POST', - 'input_mapping', JSON_OBJECT( - 'keyword', '키워드 검색 태스크.data.keyword' - ) - )), - (3, '상품 매칭 태스크', 'HTTP', JSON_OBJECT( - 'url', 'http://127.0.0.1:8000/products/match', - 'method', 'POST', - 'input_mapping', JSON_OBJECT( - 'keyword', '키워드 검색 태스크.data.keyword', - 'search_results', '상품 검색 태스크.data.search_results' - ) - )), - (4, '상품 유사도 분석 태스크', 'HTTP', JSON_OBJECT( - 'url', 'http://127.0.0.1:8000/products/similarity', - 'method', 'POST', - 'input_mapping', JSON_OBJECT( - 'keyword', '키워드 검색 태스크.data.keyword', - 'matched_products', '상품 매칭 태스크.data.matched_products' - ) + (1, '키워드 검색 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/keywords/search', 'method', 'POST', + 'body', JSON_OBJECT('tag', 'String') -- { "tag": str } )), - (5, '상품 정보 크롤링 태스크', 'HTTP', JSON_OBJECT( - 'url', 'http://127.0.0.1:8000/products/crawl', - 'method', 'POST', - 'input_mapping', JSON_OBJECT( - 'product_url', '상품 유사도 분석 태스크.data.selected_product.url' - ) - )), - - -- Job 2의 Task들 - (6, '블로그 RAG 생성 태스크', 'HTTP', JSON_OBJECT( - 'url', 'http://127.0.0.1:8000/blogs/rag/create', - 'method', 'POST', - 'input_mapping', JSON_OBJECT( - 'keyword', '키워드 검색 태스크.data.keyword', - 'product_info', '상품 정보 크롤링 태스크.data.product_detail' - ))), - --- Task 7 설정 확인 필요 - (7, '블로그 발행 태스크', 'HTTP', JSON_OBJECT( - 'url', 'http://127.0.0.1:8000/blogs/publish', - 'method', 'POST', - 'body', JSON_OBJECT('tag', 'tistory', 'blog_id', 'test', 'blog_pw', 'test'), - 'input_mapping', JSON_OBJECT( - 'post_title', '블로그 RAG 생성 태스크.data.title', - 'post_content', '블로그 RAG 생성 태스크.data.content', - 'post_tags', '블로그 RAG 생성 태스크.data.tags' - ))); + (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_url', 'String') -- { "product_url": str } + )), + -- RAG관련 request body는 추후에 결정될 예정 + (6, '블로그 RAG 생성 태스크', 'FastAPI', JSON_OBJECT('endpoint', '/blogs/rag/create', 'method', 'POST')), + (7, '블로그 발행 태스크', '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); + (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 (1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5), - (2, 6, 1), (2, 7, 2); + (2, 6, 1), (2, 7, 2) + ON DUPLICATE KEY UPDATE execution_order = VALUES(execution_order); --- 스케줄 설정 (매분 0초마다 실행) -INSERT INTO `schedule` (`workflow_id`, `cron_expression`, `is_active`) VALUES - (1, '0 * * * * ?', TRUE); \ No newline at end of file +-- 스케줄 설정 (매일 오전 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 From 0bed21b73a5836325146a22017185197889ad868 Mon Sep 17 00:00:00 2001 From: Jihu Kim Date: Fri, 19 Sep 2025 18:38:07 +0900 Subject: [PATCH 02/25] =?UTF-8?q?Revert=20"Spring=20Quartz,=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=A7=81=20=EC=9E=91=EC=97=85(Workflow=20?= =?UTF-8?q?=EB=B0=8F=20Job,=20Task=20=EC=84=B8=ED=8C=85)=20(#131)"=20(#138?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit ee39850f341113025c845ff609660b2c65572cd0. --- .../workflow/runner/FastApiTaskRunner.java | 34 -- .../workflow/runner/HttpTaskRunner.java | 67 ++++ .../runner/body/ProductSearchBodyBuilder.java | 34 -- .../workflow/runner/body/TaskBodyBuilder.java | 14 - .../service/WorkflowExecutionService.java | 109 +++++- .../fastapi/adapter/FastApiAdapter.java | 40 --- .../src/main/resources/sql/01-schema-h2.sql | 328 ++++++++++++++++++ .../main/resources/sql/03-insert-workflow.sql | 137 ++++---- 8 files changed, 556 insertions(+), 207 deletions(-) delete mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/runner/FastApiTaskRunner.java create mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/runner/HttpTaskRunner.java delete mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/ProductSearchBodyBuilder.java delete mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/TaskBodyBuilder.java delete mode 100644 apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java create mode 100644 apps/user-service/src/main/resources/sql/01-schema-h2.sql diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/FastApiTaskRunner.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/FastApiTaskRunner.java deleted file mode 100644 index 5a36afa3..00000000 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/FastApiTaskRunner.java +++ /dev/null @@ -1,34 +0,0 @@ -package site.icebang.domain.workflow.runner; - -import org.springframework.http.HttpMethod; -import org.springframework.stereotype.Component; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; - -import lombok.RequiredArgsConstructor; - -import site.icebang.domain.execution.model.TaskRun; -import site.icebang.domain.workflow.model.Task; -import site.icebang.external.fastapi.adapter.FastApiAdapter; - -@Component("fastapiTaskRunner") -@RequiredArgsConstructor -public class FastApiTaskRunner implements TaskRunner { - - private final FastApiAdapter fastApiAdapter; - - @Override - public TaskExecutionResult execute(Task task, TaskRun taskRun, ObjectNode requestBody) { - JsonNode params = task.getParameters(); - String endpoint = params.path("endpoint").asText(); - HttpMethod method = HttpMethod.valueOf(params.path("method").asText("POST").toUpperCase()); - - String responseBody = fastApiAdapter.call(endpoint, method, requestBody.toString()); - - if (responseBody == null) { - return TaskExecutionResult.failure("FastApiAdapter 호출에 실패했습니다."); - } - return TaskExecutionResult.success(responseBody); - } -} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/HttpTaskRunner.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/HttpTaskRunner.java new file mode 100644 index 00000000..861edd5a --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/HttpTaskRunner.java @@ -0,0 +1,67 @@ +package site.icebang.domain.workflow.runner; + +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import site.icebang.domain.execution.model.TaskRun; +import site.icebang.domain.workflow.model.Task; + +@Slf4j +@Component("httpTaskRunner") // "httpTaskRunner"라는 이름의 Bean으로 등록 +@RequiredArgsConstructor +public class HttpTaskRunner implements TaskRunner { + + private final RestTemplate restTemplate; + + // private final TaskIoDataRepository taskIoDataRepository; // TODO: 입출력 저장을 위해 주입 + + @Override + public TaskExecutionResult execute(Task task, TaskRun taskRun, ObjectNode requestBody) { + JsonNode params = task.getParameters(); + if (params == null) { + return TaskExecutionResult.failure("Task에 파라미터가 정의되지 않았습니다."); + } + + String url = params.path("url").asText(); + String method = params.path("method").asText("POST"); // 기본값 POST + + if (url.isEmpty()) { + return TaskExecutionResult.failure("Task 파라미터에 'url'이 없습니다."); + } + + try { + // 1. HTTP 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + // 2. HTTP 요청 엔티티 생성 (헤더 + 동적 Body) + HttpEntity requestEntity = new HttpEntity<>(requestBody.toString(), headers); + + log.debug("HTTP Task 요청: URL={}, Method={}, Body={}", url, method, requestBody.toString()); + + // 3. RestTemplate으로 API 호출 + ResponseEntity responseEntity = + restTemplate.exchange( + url, HttpMethod.valueOf(method.toUpperCase()), requestEntity, String.class); + + String responseBody = responseEntity.getBody(); + log.debug("HTTP Task 응답: Status={}, Body={}", responseEntity.getStatusCode(), responseBody); + + // TODO: taskIoDataRepository를 사용하여 requestBody와 responseBody를 DB에 저장 + + return TaskExecutionResult.success(responseBody); + + } catch (RestClientException e) { + log.error("HTTP Task 실행 중 에러 발생: TaskRunId={}, Error={}", taskRun.getId(), e.getMessage()); + return TaskExecutionResult.failure(e.getMessage()); + } + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/ProductSearchBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/ProductSearchBodyBuilder.java deleted file mode 100644 index 2dd3fcb6..00000000 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/ProductSearchBodyBuilder.java +++ /dev/null @@ -1,34 +0,0 @@ -package site.icebang.domain.workflow.runner.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 ProductSearchBodyBuilder implements TaskBodyBuilder { - - private final ObjectMapper objectMapper; - private static final String TASK_NAME = "상품 검색 태스크"; - private static final String SOURCE_TASK_NAME = "키워드 검색 태스크"; - - @Override - public boolean supports(String taskName) { - return TASK_NAME.equals(taskName); - } - - @Override - public ObjectNode build(Task task, Map workflowContext) { - JsonNode sourceResult = workflowContext.get(SOURCE_TASK_NAME); - String keyword = sourceResult != null ? sourceResult.path("keyword").asText("") : ""; - return objectMapper.createObjectNode().put("keyword", keyword); - } -} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/TaskBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/TaskBodyBuilder.java deleted file mode 100644 index da6f1597..00000000 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/body/TaskBodyBuilder.java +++ /dev/null @@ -1,14 +0,0 @@ -package site.icebang.domain.workflow.runner.body; - -import java.util.Map; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; - -import site.icebang.domain.workflow.model.Task; - -public interface TaskBodyBuilder { - boolean supports(String taskName); - - ObjectNode build(Task task, Map workflowContext); -} 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 d142b630..0f0e316d 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 @@ -3,7 +3,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,7 +26,6 @@ import site.icebang.domain.workflow.model.Job; import site.icebang.domain.workflow.model.Task; import site.icebang.domain.workflow.runner.TaskRunner; -import site.icebang.domain.workflow.runner.body.TaskBodyBuilder; @Slf4j @Service @@ -39,8 +37,7 @@ public class WorkflowExecutionService { private final JobRunMapper jobRunMapper; private final TaskRunMapper taskRunMapper; private final Map taskRunners; - private final ObjectMapper objectMapper; - private final List bodyBuilders; + private final ObjectMapper objectMapper; // 📌 JSON 처리를 위해 ObjectMapper 주입 @Transactional public void executeWorkflow(Long workflowId) { @@ -48,7 +45,9 @@ public void executeWorkflow(Long workflowId) { WorkflowRun workflowRun = WorkflowRun.start(workflowId); workflowRunMapper.insert(workflowRun); + // 📌 1. 워크플로우 전체 실행 동안 데이터를 공유할 컨텍스트 생성 Map workflowContext = new HashMap<>(); + List jobs = jobMapper.findJobsByWorkflowId(workflowId); log.info("총 {}개의 Job을 순차적으로 실행합니다.", jobs.size()); @@ -58,6 +57,7 @@ public void executeWorkflow(Long workflowId) { log.info( "---------- Job 실행 시작: JobId={}, JobRunId={} ----------", job.getId(), jobRun.getId()); + // 📌 2. Job 내의 Task들을 실행하고, 컨텍스트를 전달하여 데이터 파이프라이닝 수행 boolean jobSucceeded = executeTasksForJob(jobRun, workflowContext); jobRun.finish(jobSucceeded ? "SUCCESS" : "FAILED"); @@ -77,13 +77,16 @@ public void executeWorkflow(Long workflowId) { log.info("========== 워크플로우 실행 성공: WorkflowRunId={} ==========", workflowRun.getId()); } + /** + * 특정 Job에 속한 Task들을 순차적으로 실행합니다. + * + * @param jobRun 실행중인 Job의 기록 객체 + * @return 모든 Task가 성공하면 true, 하나라도 실패하면 false + */ private boolean executeTasksForJob(JobRun jobRun, Map workflowContext) { - // 📌 Mapper로부터 TaskDto 리스트를 조회합니다. + // TaskDto를 조회하고 Task로 변환 List taskDtos = jobMapper.findTasksByJobId(jobRun.getJobId()); - - // 📌 convertToTask 메소드를 사용하여 Task 모델 리스트로 변환합니다. - List tasks = taskDtos.stream().map(this::convertToTask).collect(Collectors.toList()); - + List tasks = taskDtos.stream().map(this::convertToTask).toList(); log.info("Job (JobRunId={}) 내 총 {}개의 Task를 실행합니다.", jobRun.getId(), tasks.size()); for (Task task : tasks) { @@ -101,13 +104,10 @@ private boolean executeTasksForJob(JobRun jobRun, Map workflow return false; } - ObjectNode requestBody = - bodyBuilders.stream() - .filter(builder -> builder.supports(task.getName())) - .findFirst() - .map(builder -> builder.build(task, workflowContext)) - .orElse(objectMapper.createObjectNode()); + // 📌 3. Task 실행 전, 컨텍스트를 이용해 동적으로 Request Body를 생성 + ObjectNode requestBody = prepareRequestBody(task, workflowContext); + // 📌 4. 동적으로 생성된 Request Body를 전달하여 Task 실행 TaskRunner.TaskExecutionResult result = runner.execute(task, taskRun, requestBody); taskRun.finish(result.status(), result.message()); taskRunMapper.update(taskRun); @@ -117,6 +117,7 @@ private boolean executeTasksForJob(JobRun jobRun, Map workflow return false; } + // 📌 5. 성공한 Task의 결과를 다음 Task가 사용할 수 있도록 컨텍스트에 저장 try { JsonNode resultJson = objectMapper.readTree(result.message()); workflowContext.put(task.getName(), resultJson); @@ -132,7 +133,82 @@ private boolean executeTasksForJob(JobRun jobRun, Map workflow return true; } - /** TaskDto를 Task 모델로 변환합니다. 📌 주의: Reflection을 사용한 방식은 성능이 느리고 불안정하므로 권장되지 않습니다. */ + /** 워크플로우 컨텍스트와 Task의 input_mapping 설정을 기반으로 API 요청에 사용할 동적인 Request Body를 생성합니다. */ + private ObjectNode prepareRequestBody(Task task, Map context) { + ObjectNode requestBody = objectMapper.createObjectNode(); + JsonNode params = task.getParameters(); + if (params == null) return requestBody; + + JsonNode mappingRules = params.get("input_mapping"); + JsonNode staticBody = params.get("body"); + + // 정적 body가 있으면 우선적으로 복사 + if (staticBody != null && staticBody.isObject()) { + requestBody.setAll((ObjectNode) staticBody); + } + + // 📌 디버깅용: 현재 컨텍스트 출력 + log.debug("=== 워크플로우 컨텍스트 확인 ==="); + for (Map.Entry entry : context.entrySet()) { + log.debug("Task: {}, Data: {}", entry.getKey(), entry.getValue().toString()); + } + + // input_mapping 규칙에 따라 동적으로 값 덮어쓰기/추가 + if (mappingRules != null && mappingRules.isObject()) { + mappingRules + .fields() + .forEachRemaining( + entry -> { + String targetField = entry.getKey(); // 예: "product_url" + String sourcePath = + entry + .getValue() + .asText(); // 예: "상품 유사도 분석 태스크.data.selected_product.product_url" + + log.debug("=== input_mapping 처리 ==="); + log.debug("targetField: {}, sourcePath: {}", targetField, sourcePath); + + String[] parts = sourcePath.split("\\.", 2); + if (parts.length == 2) { + String sourceTaskName = parts[0]; + String sourceFieldPath = parts[1]; + + log.debug( + "sourceTaskName: {}, sourceFieldPath: {}", sourceTaskName, sourceFieldPath); + + JsonNode sourceData = context.get(sourceTaskName); + log.debug("sourceData found: {}", sourceData != null); + + if (sourceData != null) { + log.debug("sourceData content: {}", sourceData.toString()); + + String jsonPath = "/" + sourceFieldPath.replace('.', '/'); + log.debug("jsonPath: {}", jsonPath); + + JsonNode valueToSet = sourceData.at(jsonPath); + log.debug( + "valueToSet found: {}, isMissing: {}", + valueToSet, + valueToSet.isMissingNode()); + + if (!valueToSet.isMissingNode()) { + log.debug("설정할 값: {}", valueToSet.toString()); + requestBody.set(targetField, valueToSet); + } else { + log.warn("값을 찾을 수 없음: jsonPath={}", jsonPath); + } + } else { + log.warn("소스 태스크 데이터를 찾을 수 없음: {}", sourceTaskName); + } + } + }); + } + + log.debug("최종 requestBody: {}", requestBody.toString()); + return requestBody; + } + + /** TaskDto를 Task 모델로 변환합니다. 비즈니스 로직 실행에 필요한 필드만 복사합니다. */ private Task convertToTask(TaskDto taskDto) { Task task = new Task(); try { @@ -155,6 +231,7 @@ private Task convertToTask(TaskDto taskDto) { } catch (Exception e) { throw new RuntimeException("TaskDto to Task 변환 중 오류 발생", e); } + return task; } } diff --git a/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java b/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java deleted file mode 100644 index 2a5bd001..00000000 --- a/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java +++ /dev/null @@ -1,40 +0,0 @@ -package site.icebang.external.fastapi.adapter; - -import org.springframework.http.*; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import site.icebang.global.config.properties.FastApiProperties; - -@Slf4j -@Component -@RequiredArgsConstructor -public class FastApiAdapter { - - private final RestTemplate restTemplate; - private final FastApiProperties properties; - - // 📌 Task나 context에 대한 의존성이 완전히 사라짐 - public String call(String endpoint, HttpMethod method, String requestBody) { - String fullUrl = properties.getUrl() + endpoint; - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity requestEntity = new HttpEntity<>(requestBody, headers); - - try { - log.debug("FastAPI 요청: URL={}, Method={}, Body={}", fullUrl, method, requestBody); - ResponseEntity responseEntity = - restTemplate.exchange(fullUrl, method, requestEntity, String.class); - String responseBody = responseEntity.getBody(); - log.debug("FastAPI 응답: Status={}, Body={}", responseEntity.getStatusCode(), responseBody); - return responseBody; - } catch (RestClientException e) { - log.error("FastAPI 호출 실패: URL={}, Error={}", fullUrl, e.getMessage()); - return null; - } - } -} diff --git a/apps/user-service/src/main/resources/sql/01-schema-h2.sql b/apps/user-service/src/main/resources/sql/01-schema-h2.sql new file mode 100644 index 00000000..018ebb1d --- /dev/null +++ b/apps/user-service/src/main/resources/sql/01-schema-h2.sql @@ -0,0 +1,328 @@ +-- H2 데이터베이스 호환 스키마 (테스트용) +-- MySQL의 unsigned, AFTER 절 등을 H2 호환으로 변경 + +CREATE TABLE `permission` ( + `id` int NOT NULL AUTO_INCREMENT, + `resource` varchar(100) NULL, + `description` varchar(255) NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_active` boolean DEFAULT TRUE, + `updated_by` bigint NULL, + `created_by` bigint NULL, + PRIMARY KEY (`id`) +); + +CREATE TABLE `organization` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(150) NULL, + `domain_name` varchar(100) NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +); + +CREATE TABLE `role` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `organization_id` bigint NULL, + `name` varchar(100) NULL, + `description` varchar(500) NULL, + PRIMARY KEY (`id`) +); + +CREATE TABLE `user` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(50) NULL, + `email` varchar(100) NULL, + `password` varchar(255) NULL, + `status` varchar(20) NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +); + +CREATE TABLE `department` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `organization_id` bigint NOT NULL, + `name` varchar(100) NULL, + PRIMARY KEY (`id`) +); + +CREATE TABLE `position` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `organization_id` bigint NOT NULL, + `title` varchar(100) NULL, + PRIMARY KEY (`id`) +); + +CREATE TABLE `user_organization` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `organization_id` bigint NOT NULL, + `position_id` bigint NOT NULL, + `department_id` bigint NOT NULL, + `employee_number` varchar(50) NULL, + `status` varchar(20) NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +); + +CREATE TABLE `role_permission` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `role_id` bigint NOT NULL, + `permission_id` int NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_role_permission` (`role_id`, `permission_id`) +); + +CREATE TABLE `user_role` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `role_id` bigint NOT NULL, + `user_organization_id` bigint NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_role` (`role_id`, `user_organization_id`) +); + +-- 성능 최적화를 위한 인덱스 +CREATE INDEX `idx_user_email` ON `user` (`email`); +CREATE INDEX `idx_user_status` ON `user` (`status`); +CREATE INDEX `idx_user_organization_user` ON `user_organization` (`user_id`); +CREATE INDEX `idx_user_organization_org` ON `user_organization` (`organization_id`); +CREATE INDEX `idx_user_organization_status` ON `user_organization` (`status`); +CREATE INDEX `idx_role_org` ON `role` (`organization_id`); +CREATE INDEX `idx_permission_resource` ON `permission` (`resource`); +CREATE INDEX `idx_permission_active` ON `permission` (`is_active`); + +CREATE TABLE `workflow` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL UNIQUE, + `description` text NULL, + `is_enabled` boolean DEFAULT TRUE, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `created_by` bigint NULL, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `updated_by` bigint NULL, + PRIMARY KEY (`id`) +); + +CREATE TABLE `schedule` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `workflow_id` bigint NOT NULL, + `cron_expression` varchar(50) NULL, + `parameters` json NULL, + `is_active` boolean DEFAULT TRUE, + `last_run_status` varchar(20) NULL, + `last_run_at` timestamp NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `created_by` bigint NULL, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `updated_by` bigint NULL, + PRIMARY KEY (`id`) +); + +CREATE TABLE `job` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL UNIQUE, + `description` text NULL, + `is_enabled` boolean DEFAULT TRUE, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `created_by` bigint NULL, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `updated_by` bigint NULL, + PRIMARY KEY (`id`) +); + +CREATE TABLE `task` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL UNIQUE, + `type` varchar(50) NULL, + `parameters` json NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +); + +CREATE TABLE `workflow_job` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `workflow_id` bigint NOT NULL, + `job_id` bigint NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_workflow_job` (`workflow_id`, `job_id`) +); + +CREATE TABLE `job_task` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `job_id` bigint NOT NULL, + `task_id` bigint NOT NULL, + `execution_order` int NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_job_task` (`job_id`, `task_id`) +); + +CREATE TABLE `execution_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `execution_type` varchar(20) NULL COMMENT 'task, schedule, job, workflow', + `source_id` bigint NULL COMMENT '모든 데이터에 대한 ID ex: job_id, schedule_id, task_id, ...', + `log_level` varchar(20) NULL, + `executed_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `log_message` text NULL, + `trace_id` char(36) NULL, + `config_snapshot` json NULL, + PRIMARY KEY (`id`), + INDEX `idx_source_id_type` (`source_id`, `execution_type`) +); + +CREATE TABLE `task_io_data` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `task_run_id` bigint NOT NULL, + `io_type` varchar(10) NOT NULL COMMENT 'INPUT, OUTPUT', + `name` varchar(100) NOT NULL COMMENT '파라미터/변수 이름', + `data_type` varchar(50) NOT NULL COMMENT 'string, number, json, file, etc', + `data_value` json NULL COMMENT '실제 데이터 값', + `data_size` bigint NULL COMMENT '데이터 크기 (bytes)', + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_task_io_task_run_id` (`task_run_id`), + INDEX `idx_task_io_type` (`io_type`), + INDEX `idx_task_io_name` (`name`) +); + +CREATE TABLE `config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `target_type` varchar(50) NULL COMMENT 'user, job, workflow', + `target_id` bigint NULL, + `version` int NULL, + `json` json NULL, + `is_active` boolean DEFAULT TRUE, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `created_by` bigint NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_config_target` (`target_type`, `target_id`) +); + +CREATE TABLE `category` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(100) NULL, + `description` text NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +); + +CREATE TABLE `user_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `type` varchar(50) NULL, + `name` varchar(100) NULL, + `json` json NULL, + `is_active` boolean DEFAULT TRUE, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +); + +-- 인덱스 추가 (성능 최적화) +CREATE INDEX `idx_schedule_workflow` ON `schedule` (`workflow_id`); +CREATE INDEX `idx_job_enabled` ON `job` (`is_enabled`); +CREATE INDEX `idx_task_type` ON `task` (`type`); +CREATE INDEX `idx_workflow_enabled` ON `workflow` (`is_enabled`); +CREATE UNIQUE INDEX `uk_schedule_workflow` ON `schedule` (`workflow_id`); +CREATE UNIQUE INDEX `uk_job_name` ON `job` (`name`); +CREATE UNIQUE INDEX `uk_task_name` ON `task` (`name`); +CREATE UNIQUE INDEX `uk_workflow_name` ON `workflow` (`name`); +CREATE INDEX `idx_user_config_user` ON `user_config` (`user_id`); + +-- 워크플로우 실행 테이블 +CREATE TABLE `workflow_run` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `workflow_id` bigint NOT NULL, + `trace_id` char(36) NOT NULL, + `run_number` varchar(20) NULL, + `status` varchar(20) NULL COMMENT 'pending, running, success, failed, cancelled', + `trigger_type` varchar(20) NULL COMMENT 'manual, schedule, push, pull_request', + `started_at` timestamp NULL, + `finished_at` timestamp NULL, + `created_by` bigint NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_workflow_run_trace` (`trace_id`), + INDEX `idx_workflow_run_status` (`status`), + INDEX `idx_workflow_run_workflow_id` (`workflow_id`), + INDEX `idx_workflow_run_created_at` (`created_at`) +); + +-- Job 실행 테이블 +CREATE TABLE `job_run` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `workflow_run_id` bigint NOT NULL, + `job_id` bigint NOT NULL, + `status` varchar(20) NULL COMMENT 'pending, running, success, failed, cancelled, skipped', + `started_at` timestamp NULL, + `finished_at` timestamp NULL, + `execution_order` int NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_job_run_workflow_run_id` (`workflow_run_id`), + INDEX `idx_job_run_status` (`status`), + INDEX `idx_job_run_job_id` (`job_id`) +); + +-- Task 실행 테이블 +CREATE TABLE `task_run` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `job_run_id` bigint NOT NULL, + `task_id` bigint NOT NULL, + `status` varchar(20) NULL COMMENT 'pending, running, success, failed, cancelled, skipped', + `started_at` timestamp NULL, + `finished_at` timestamp NULL, + `execution_order` int NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_task_run_job_run_id` (`job_run_id`), + INDEX `idx_task_run_status` (`status`), + INDEX `idx_task_run_task_id` (`task_id`) +); + +-- v0.0.3 - H2 호환 버전 +DROP TABLE IF EXISTS `config`; + +-- H2에서는 한 번에 하나씩 컬럼 추가 +ALTER TABLE `workflow_job` ADD COLUMN `execution_order` INT NULL; + +ALTER TABLE `schedule` ADD COLUMN `schedule_text` varchar(20) NULL; + +ALTER TABLE `workflow` ADD COLUMN `default_config` json NULL; + +ALTER TABLE `user` ADD COLUMN `joined_at` timestamp NULL; + +ALTER TABLE `department` ADD COLUMN `description` varchar(100) NULL; + +-- v0.4 - H2 호환 버전 (AFTER 절 제거, unsigned 제거, 개별 ALTER 구문으로 분리) +-- execution_log 테이블 컬럼 추가 (H2 호환) +ALTER TABLE `execution_log` ADD COLUMN `run_id` bigint NULL; +ALTER TABLE `execution_log` ADD COLUMN `status` varchar(20) NULL; +ALTER TABLE `execution_log` ADD COLUMN `duration_ms` int NULL; +ALTER TABLE `execution_log` ADD COLUMN `error_code` varchar(50) NULL; +ALTER TABLE `execution_log` ADD COLUMN `reserved1` varchar(100) NULL; +ALTER TABLE `execution_log` ADD COLUMN `reserved2` varchar(100) NULL; +ALTER TABLE `execution_log` ADD COLUMN `reserved3` int NULL; +ALTER TABLE `execution_log` ADD COLUMN `reserved4` json NULL; +ALTER TABLE `execution_log` ADD COLUMN `reserved5` timestamp NULL; + +-- 기존 컬럼 수정 (H2 호환) +ALTER TABLE `execution_log` ALTER COLUMN `log_message` varchar(500) NOT NULL; +ALTER TABLE `execution_log` ALTER COLUMN `executed_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- 기존 불필요한 컬럼 제거 +ALTER TABLE `execution_log` DROP COLUMN IF EXISTS `config_snapshot`; + +-- 새로운 인덱스 추가 +CREATE INDEX `idx_run_id` ON `execution_log` (`run_id`); +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`); + +-- 기존 인덱스 수정 +DROP INDEX IF EXISTS `idx_source_id_type`; +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 9b6db4c0..8e902745 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 @@ -1,90 +1,89 @@ --- =================================================================== --- 워크플로우 관련 데이터 초기화 --- =================================================================== --- 참조 관계 역순으로 데이터 삭제 -DELETE FROM `schedule`; +-- 기존 워크플로우 관련 데이터 삭제 DELETE FROM `job_task`; DELETE FROM `workflow_job`; DELETE FROM `task`; DELETE FROM `job`; DELETE FROM `workflow`; - --- =================================================================== --- 워크플로우 정적 데이터 삽입 --- =================================================================== +DELETE FROM `schedule`; -- 워크플로우 생성 (ID: 1) -INSERT INTO `workflow` (`id`, `name`, `description`, `created_by`) VALUES - (1, '상품 분석 및 블로그 자동 발행', '키워드 검색부터 상품 분석 후 블로그 발행까지의 자동화 프로세스', 1) - ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), updated_at = NOW(); +INSERT INTO `workflow` (`id`, `name`, `description`) VALUES + (1, '상품 분석 및 블로그 자동 발행', '키워드 검색부터 상품 분석 후 블로그 발행까지의 자동화 프로세스'); -- 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(); +INSERT INTO `job` (`id`, `name`, `description`) VALUES + (1, '상품 분석', '키워드 검색, 상품 크롤링 및 유사도 분석 작업'), + (2, '블로그 콘텐츠 생성', '분석 데이터를 기반으로 RAG 콘텐츠 생성 및 발행 작업'); --- Task 생성 (ID: 1 ~ 7) - FastAPI Request Body 스키마 반영 +-- Task 생성 (ID: 1 ~ 7) INSERT INTO `task` (`id`, `name`, `type`, `parameters`) VALUES - (1, '키워드 검색 태스크', 'FastAPI', JSON_OBJECT( - 'endpoint', '/keywords/search', 'method', 'POST', - 'body', JSON_OBJECT('tag', 'String') -- { "tag": str } +-- Job 1의 Task들 + (1, '키워드 검색 태스크', 'HTTP', JSON_OBJECT( + 'url', 'http://127.0.0.1:8000/keywords/search', + 'method', 'POST', + 'body', JSON_OBJECT('tag', 'naver') + )), + (2, '상품 검색 태스크', 'HTTP', JSON_OBJECT( + 'url', 'http://127.0.0.1:8000/products/search', + 'method', 'POST', + 'input_mapping', JSON_OBJECT( + 'keyword', '키워드 검색 태스크.data.keyword' + ) + )), + (3, '상품 매칭 태스크', 'HTTP', JSON_OBJECT( + 'url', 'http://127.0.0.1:8000/products/match', + 'method', 'POST', + 'input_mapping', JSON_OBJECT( + 'keyword', '키워드 검색 태스크.data.keyword', + 'search_results', '상품 검색 태스크.data.search_results' + ) + )), + (4, '상품 유사도 분석 태스크', 'HTTP', JSON_OBJECT( + 'url', 'http://127.0.0.1:8000/products/similarity', + 'method', 'POST', + 'input_mapping', JSON_OBJECT( + 'keyword', '키워드 검색 태스크.data.keyword', + 'matched_products', '상품 매칭 태스크.data.matched_products' + ) )), - (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_url', 'String') -- { "product_url": str } - )), - -- RAG관련 request body는 추후에 결정될 예정 - (6, '블로그 RAG 생성 태스크', 'FastAPI', JSON_OBJECT('endpoint', '/blogs/rag/create', 'method', 'POST')), - (7, '블로그 발행 태스크', '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(); + (5, '상품 정보 크롤링 태스크', 'HTTP', JSON_OBJECT( + 'url', 'http://127.0.0.1:8000/products/crawl', + 'method', 'POST', + 'input_mapping', JSON_OBJECT( + 'product_url', '상품 유사도 분석 태스크.data.selected_product.url' + ) + )), + + -- Job 2의 Task들 + (6, '블로그 RAG 생성 태스크', 'HTTP', JSON_OBJECT( + 'url', 'http://127.0.0.1:8000/blogs/rag/create', + 'method', 'POST', + 'input_mapping', JSON_OBJECT( + 'keyword', '키워드 검색 태스크.data.keyword', + 'product_info', '상품 정보 크롤링 태스크.data.product_detail' + ))), + +-- Task 7 설정 확인 필요 + (7, '블로그 발행 태스크', 'HTTP', JSON_OBJECT( + 'url', 'http://127.0.0.1:8000/blogs/publish', + 'method', 'POST', + 'body', JSON_OBJECT('tag', 'tistory', 'blog_id', 'test', 'blog_pw', 'test'), + 'input_mapping', JSON_OBJECT( + 'post_title', '블로그 RAG 생성 태스크.data.title', + 'post_content', '블로그 RAG 생성 태스크.data.content', + 'post_tags', '블로그 RAG 생성 태스크.data.tags' + ))); --- =================================================================== --- 워크플로우 구조 및 스케줄 데이터 삽입 --- =================================================================== -- 워크플로우-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); + (1, 2, 2); -- Job-Task 연결 INSERT INTO `job_task` (`job_id`, `task_id`, `execution_order`) VALUES (1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5), - (2, 6, 1), (2, 7, 2) - ON DUPLICATE KEY UPDATE execution_order = VALUES(execution_order); + (2, 6, 1), (2, 7, 2); --- 스케줄 설정 (매일 오전 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 +-- 스케줄 설정 (매분 0초마다 실행) +INSERT INTO `schedule` (`workflow_id`, `cron_expression`, `is_active`) VALUES + (1, '0 * * * * ?', TRUE); \ No newline at end of file From 8cc1e265b768b9a4628c5fb82cee1495b82236c5 Mon Sep 17 00:00:00 2001 From: can019 Date: Mon, 22 Sep 2025 15:10:18 +0900 Subject: [PATCH 03/25] =?UTF-8?q?fix:=20openapi3=20task=20ci=EC=97=90=20?= =?UTF-8?q?=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 fd55f05f..d39bc7a4 100644 --- a/.github/workflows/ci-java.yml +++ b/.github/workflows/ci-java.yml @@ -80,6 +80,7 @@ jobs: ./gradlew integrationTest if [ "${{ github.base_ref }}" = "main" ] || [[ "${{ github.ref }}" == refs/tags/* ]]; then ./gradlew e2eTest + ./gradlew openapi3 fi working-directory: apps/user-service From decdb4a94dfc7a94bd51ffcda5aaf05d72807221 Mon Sep 17 00:00:00 2001 From: can019 Date: Mon, 22 Sep 2025 15:25:07 +0900 Subject: [PATCH 04/25] =?UTF-8?q?fix:=20Swagger=20ui=20generate=20?= =?UTF-8?q?=EC=8B=9C=20token=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 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml index d39bc7a4..0a5c3361 100644 --- a/.github/workflows/ci-java.yml +++ b/.github/workflows/ci-java.yml @@ -162,6 +162,12 @@ jobs: - set-image-tag if: startsWith(github.ref, 'refs/tags/user-service-v') + permissions: + contents: read # 리포지토리 읽기 + pages: write # GitHub Pages 쓰기 + id-token: write # OIDC 토큰 + actions: read # Actions 읽기 + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -193,6 +199,7 @@ jobs: with: output: user-service-swagger-ui-${{ needs.set-image-tag.outputs.image-tag }} spec-file: openapi-spec/openapi3.yaml + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Deploy to GitHub Pages if: steps.check-openapi.outputs.openapi_exists == 'true' From b87e40468985916f110d88fae6cdd4197b8278d7 Mon Sep 17 00:00:00 2001 From: can019 Date: Mon, 22 Sep 2025 15:43:53 +0900 Subject: [PATCH 05/25] =?UTF-8?q?chore:=20Swagger=20deploy=20auth=20github?= =?UTF-8?q?=20token=EC=97=90=EC=84=9C=20pat=EC=9C=BC=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-java.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml index 0a5c3361..db8197f1 100644 --- a/.github/workflows/ci-java.yml +++ b/.github/workflows/ci-java.yml @@ -205,7 +205,7 @@ jobs: if: steps.check-openapi.outputs.openapi_exists == 'true' uses: peaceiris/actions-gh-pages@v3 with: - github_token: ${{ secrets.GITHUB_TOKEN }} + personal_token: ${{ secrets.PERSONAL_TOKEN }} publish_dir: ./user-service-swagger-ui-${{ needs.set-image-tag.outputs.image-tag }} destination_dir: user-service/${{ needs.set-image-tag.outputs.image-tag }} From 714afbd577b38511f13ff2b381b31a0506706423 Mon Sep 17 00:00:00 2001 From: can019 Date: Mon, 22 Sep 2025 21:43:04 +0900 Subject: [PATCH 06/25] =?UTF-8?q?fix:=20application-produciton.yml=20mail?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EB=93=A4=EC=97=AC=EC=93=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/resources/application-production.yml | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/user-service/src/main/resources/application-production.yml b/apps/user-service/src/main/resources/application-production.yml index c3645e13..406fed87 100644 --- a/apps/user-service/src/main/resources/application-production.yml +++ b/apps/user-service/src/main/resources/application-production.yml @@ -18,21 +18,21 @@ spring: pool-name: HikariCP-MyBatis # Gmail 연동 설정 - mail: - host: smtp.gmail.com - port: 587 - username: ${MAIL_USERNAME} - password: ${MAIL_PASSWORD} - properties: - mail: - smtp: - auth: true - starttls: - enable: true - connectiontimeout: 5000 - timeout: 5000 - writetimeout: 5000 - debug: true + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + debug: true # quartz: # jdbc: # initialize-schema: never From 91a90de51de9f11992af188b075699438d9d66b8 Mon Sep 17 00:00:00 2001 From: Yousung Jung Date: Tue, 23 Sep 2025 14:04:34 +0900 Subject: [PATCH 07/25] clean up build.gradle (#182) --- apps/user-service/build.gradle | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/user-service/build.gradle b/apps/user-service/build.gradle index 4c5cb671..e49ac98b 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-alpha-SNAPSHOT' +version = '0.0.1-beta-stable' description = 'Ice bang - fast campus team4' java { @@ -21,7 +21,6 @@ configurations { compileOnly { extendsFrom annotationProcessor } - // Spring Boot의 기본 로깅(Logback) 제외 all { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' } @@ -47,6 +46,7 @@ dependencies { // Scheduler implementation 'org.springframework.boot:spring-boot-starter-quartz' + // logging implementation 'org.springframework.boot:spring-boot-starter-log4j2' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml' implementation 'org.apache.logging.log4j:log4j-layout-template-json' @@ -54,11 +54,8 @@ dependencies { implementation 'org.apache.httpcomponents:httpclient:4.5.14' implementation 'org.apache.httpcomponents:httpcore:4.4.16' - // 비동기 로깅 -// implementation 'com.lmax:disruptor:3.4.4' -// implementation 'org.apache.commons:commons-dbcp2' -// implementation 'org.apache.commons:commons-pool2' + // micrometer & actuator implementation "io.micrometer:micrometer-tracing-bridge-brave" implementation "io.micrometer:micrometer-tracing" implementation 'io.micrometer:micrometer-registry-prometheus' From 8e70aa9eb598311583b84709df94850253696900 Mon Sep 17 00:00:00 2001 From: Yousung Jung Date: Tue, 23 Sep 2025 16:32:57 +0900 Subject: [PATCH 08/25] =?UTF-8?q?User=20register=20api=20docs=20(test)=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 사용자 등록, 사용자 세션 체크, 사용자 권한 요청 테스트 정의 * test: User register integration test * test: Status 수정 * chore: v0.0.1 alpha -> beta * fix: Register api가 public auth였던 버그 픽스 * fix: Bad request 시 응답 메세지가 너무 많은 정보를 포함하던 문제 exception message가 모두 노출, 필요한 메세지만 출력하도록 변경 * test: Register 실패 테스트 rest api documentation --- apps/user-service/build.gradle | 2 +- .../config/security/SecurityConfig.java | 2 + .../security/endpoints/SecurityEndpoints.java | 5 +- .../exception/GlobalExceptionHandler.java | 13 +- .../tests/auth/AuthApiIntegrationTest.java | 241 +++++++++++++++++- 5 files changed, 255 insertions(+), 8 deletions(-) diff --git a/apps/user-service/build.gradle b/apps/user-service/build.gradle index e49ac98b..46ce0961 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.0.1-beta-STABLE' description = 'Ice bang - fast campus team4' java { diff --git a/apps/user-service/src/main/java/site/icebang/global/config/security/SecurityConfig.java b/apps/user-service/src/main/java/site/icebang/global/config/security/SecurityConfig.java index 3543a8dd..514998e2 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/security/SecurityConfig.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/security/SecurityConfig.java @@ -82,6 +82,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers("/v0/auth/check-session") .authenticated() + .requestMatchers(SecurityEndpoints.SUPER_ADMIN.getMatchers()) + .hasAnyRole("SUPER_ADMIN") .requestMatchers(SecurityEndpoints.DATA_ADMIN.getMatchers()) .hasRole("SUPER_ADMIN") // hasAuthority -> hasRole .requestMatchers(SecurityEndpoints.DATA_ENGINEER.getMatchers()) diff --git a/apps/user-service/src/main/java/site/icebang/global/config/security/endpoints/SecurityEndpoints.java b/apps/user-service/src/main/java/site/icebang/global/config/security/endpoints/SecurityEndpoints.java index e6e24243..98129d8b 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/security/endpoints/SecurityEndpoints.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/security/endpoints/SecurityEndpoints.java @@ -11,7 +11,6 @@ public enum SecurityEndpoints { "/js/**", "/images/**", "/v0/organizations/**", - "/v0/auth/register", "/v0/check-execution-log-insert"), // 데이터 관리 관련 엔드포인트 @@ -27,7 +26,9 @@ public enum SecurityEndpoints { OPS("/api/scheduler/**", "/api/monitoring/**"), // 일반 사용자 엔드포인트 - USER("/user/**", "/profile/**", "/v0/auth/check-session", "/v0/workflows/**"); + USER("/user/**", "/profile/**", "/v0/auth/check-session", "/v0/workflows/**"), + + SUPER_ADMIN("/v0/auth/register"); private final String[] patterns; diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java index 8243acde..0711cf90 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java @@ -1,8 +1,11 @@ package site.icebang.global.handler.exception; +import java.util.stream.Collectors; + import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; +import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; @@ -19,9 +22,13 @@ public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) - public ApiResponse handleValidation(MethodArgumentNotValidException ex) { - String detail = ex.getBindingResult().toString(); - return ApiResponse.error("Validation failed: " + detail, HttpStatus.BAD_REQUEST); + public ApiResponse handleValidation(MethodArgumentNotValidException ex) { + String errorMessage = + ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + + return ApiResponse.error("입력 값 검증 실패: " + errorMessage, HttpStatus.BAD_REQUEST); } @ExceptionHandler(Exception.class) 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..95d0cfbd 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 @@ -7,6 +7,7 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; @@ -15,6 +16,8 @@ import org.springframework.http.*; import org.springframework.mock.web.MockHttpSession; import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithUserDetails; import org.springframework.test.context.jdbc.Sql; import org.springframework.transaction.annotation.Transactional; @@ -29,7 +32,7 @@ class AuthApiIntegrationTest extends IntegrationTestSupport { @Test @DisplayName("사용자 로그인 성공") - void login_success() throws Exception { + void loginSuccess() throws Exception { // given Map loginRequest = new HashMap<>(); loginRequest.put("email", "admin@icebang.site"); @@ -81,9 +84,243 @@ void login_success() throws Exception { .build()))); } + @Test + @DisplayName("사용자 등록 실패 - 이메일 양식 오류") + @WithUserDetails("admin@icebang.site") + void registerFailureWhenInvalidEmail() throws Exception { + // given + Map registerRequest = new HashMap<>(); + registerRequest.put("name", "홍길동"); + registerRequest.put("email", "invalid-email"); // 잘못된 이메일 형식 + registerRequest.put("orgId", 1); + registerRequest.put("deptId", 1); + registerRequest.put("positionId", 1); + registerRequest.put("roleIds", Arrays.asList(1)); + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/auth/register")) + .contentType(MediaType.APPLICATION_JSON) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/") + .content(objectMapper.writeValueAsString(registerRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value("BAD_REQUEST")) + .andDo( + document( + "auth-register-invalid-email", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Authentication") + .summary("사용자 회원가입 실패 - 잘못된 이메일") + .description("잘못된 이메일 형식으로 인한 회원가입 실패") + .requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("사용자명"), + fieldWithPath("email") + .type(JsonFieldType.STRING) + .description("잘못된 형식의 이메일 주소"), + fieldWithPath("orgId").type(JsonFieldType.NUMBER).description("조직 ID"), + fieldWithPath("deptId").type(JsonFieldType.NUMBER).description("부서 ID"), + fieldWithPath("positionId") + .type(JsonFieldType.NUMBER) + .description("직책 ID"), + fieldWithPath("roleIds") + .type(JsonFieldType.ARRAY) + .description("역할 ID 목록")) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.NULL).description("에러 시 null"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("에러 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("사용자 등록 실패 - 필수 필드 누락") + @WithUserDetails("admin@icebang.site") + void registerFailureWhenMissingRequiredFields() throws Exception { + // given - 필수 필드 누락 + Map registerRequest = new HashMap<>(); + registerRequest.put("email", "test@icebang.site"); + // name, orgId, deptId, positionId, roleIds 누락 + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/auth/register")) + .contentType(MediaType.APPLICATION_JSON) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/") + .content(objectMapper.writeValueAsString(registerRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value("BAD_REQUEST")) + .andDo( + document( + "auth-register-missing-fields", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Authentication") + .summary("사용자 회원가입 실패 - 필수 필드 누락") + .description("필수 필드 누락으로 인한 회원가입 실패") + .requestFields( + fieldWithPath("email") + .type(JsonFieldType.STRING) + .description("사용자 이메일 주소")) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.NULL).description("에러 시 null"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("에러 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("사용자 등록 실패 - Authentication이 없는 경우") + void registerFailureWhenAuthenticationMissing() throws Exception { + // given + Map registerRequest = new HashMap<>(); + registerRequest.put("name", "홍길동"); + registerRequest.put("email", "hong@icebang.site"); + registerRequest.put("orgId", 1); + registerRequest.put("deptId", 1); + registerRequest.put("positionId", 1); + registerRequest.put("roleIds", Arrays.asList(1, 2)); + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/auth/register")) + .contentType(MediaType.APPLICATION_JSON) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/") + .content(objectMapper.writeValueAsString(registerRequest))) + .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-register-unauthorized", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Authentication") + .summary("사용자 회원가입 실패 - 인증 없음") + .description("인증 정보가 없어서 회원가입 실패") + .requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("사용자명"), + fieldWithPath("email") + .type(JsonFieldType.STRING) + .description("사용자 이메일 주소"), + fieldWithPath("orgId").type(JsonFieldType.NUMBER).description("조직 ID"), + fieldWithPath("deptId").type(JsonFieldType.NUMBER).description("부서 ID"), + fieldWithPath("positionId") + .type(JsonFieldType.NUMBER) + .description("직책 ID"), + fieldWithPath("roleIds") + .type(JsonFieldType.ARRAY) + .description("역할 ID 목록")) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.NULL).description("에러 시 null"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("인증 에러 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("사용자 등록 실패 - Permission이 없는 경우") + @WithMockUser("content.choi@icebang.site") + void registerFailureWhenNoPermissionProvided() throws Exception { + // given + Map registerRequest = new HashMap<>(); + registerRequest.put("name", "홍길동"); + registerRequest.put("email", "hong@icebang.site"); + registerRequest.put("orgId", 1); + registerRequest.put("deptId", 1); + registerRequest.put("positionId", 1); + registerRequest.put("roleIds", Arrays.asList(1, 2)); + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/auth/register")) + .contentType(MediaType.APPLICATION_JSON) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/") + .content(objectMapper.writeValueAsString(registerRequest))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value("FORBIDDEN")) + .andExpect(jsonPath("$.message").value("Access denied")) + .andExpect(jsonPath("$.data").isEmpty()) + .andDo( + document( + "auth-register-forbidden", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Authentication") + .summary("사용자 회원가입 실패 - 권한 부족") + .description("적절한 권한이 없어서 회원가입 실패") + .requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("사용자명"), + fieldWithPath("email") + .type(JsonFieldType.STRING) + .description("사용자 이메일 주소"), + fieldWithPath("orgId").type(JsonFieldType.NUMBER).description("조직 ID"), + fieldWithPath("deptId").type(JsonFieldType.NUMBER).description("부서 ID"), + fieldWithPath("positionId") + .type(JsonFieldType.NUMBER) + .description("직책 ID"), + fieldWithPath("roleIds") + .type(JsonFieldType.ARRAY) + .description("역할 ID 목록")) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.NULL).description("에러 시 null"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("권한 에러 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + @Test @DisplayName("사용자 로그아웃 성공") - void logout_success() throws Exception { + void logoutSuccess() throws Exception { // given - 먼저 로그인 Map loginRequest = new HashMap<>(); loginRequest.put("email", "admin@icebang.site"); From a9003a1a85ffa5cebe2e8ceac448132313f91362 Mon Sep 17 00:00:00 2001 From: Yousung Jung Date: Tue, 23 Sep 2025 16:54:11 +0900 Subject: [PATCH 09/25] =?UTF-8?q?common/utils=20javadoc=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: RandomPasswordGenerator docs * docs: DuplicateDataException java doc --- .../exception/DuplicateDataException.java | 64 +++++++++++++++++++ .../common/utils/RandomPasswordGenerator.java | 64 ++++++++++++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/common/exception/DuplicateDataException.java b/apps/user-service/src/main/java/site/icebang/common/exception/DuplicateDataException.java index e673ab86..12d75411 100644 --- a/apps/user-service/src/main/java/site/icebang/common/exception/DuplicateDataException.java +++ b/apps/user-service/src/main/java/site/icebang/common/exception/DuplicateDataException.java @@ -1,23 +1,87 @@ package site.icebang.common.exception; +/** + * 데이터 중복 상황에서 발생하는 예외 클래스입니다. + * + *

이 예외는 데이터베이스나 컬렉션에서 이미 존재하는 데이터를 중복해서 생성하거나 저장하려고 할 때 발생합니다. 주로 유니크 제약 조건 위반이나 비즈니스 로직상 중복을 + * 허용하지 않는 경우에 사용됩니다. + * + *

사용 예제:

+ * + *
{@code
+ * // 사용자 이메일 중복 체크
+ * if (userRepository.existsByEmail(email)) {
+ *     throw new DuplicateDataException("이미 존재하는 이메일입니다: " + email);
+ * }
+ *
+ * // 상품 코드 중복 체크
+ * try {
+ *     productService.createProduct(product);
+ * } catch (DataIntegrityViolationException e) {
+ *     throw new DuplicateDataException("중복된 상품 코드입니다", e);
+ * }
+ * }
+ * + * @author jys01012@gmail.com + * @since v0.0.1-alpha + */ public class DuplicateDataException extends RuntimeException { + /** + * 상세 메시지 없이 새로운 {@code DuplicateDataException}을 생성합니다. + * + * @since v0.0.1-alpha + */ public DuplicateDataException() { super(); } + /** + * 지정된 상세 메시지와 함께 새로운 {@code DuplicateDataException}을 생성합니다. + * + * @param message 상세 메시지 (나중에 {@link Throwable#getMessage()} 메서드로 검색됨) + * @since v0.0.1-alpha + */ public DuplicateDataException(String message) { super(message); } + /** + * 지정된 상세 메시지와 원인과 함께 새로운 {@code DuplicateDataException}을 생성합니다. + * + *

{@code cause}와 연관된 상세 메시지가 이 예외의 상세 메시지에 자동으로 포함되지는 않습니다. + * + * @param message 상세 메시지 (나중에 {@link Throwable#getMessage()} 메서드로 검색됨) + * @param cause 원인 (나중에 {@link Throwable#getCause()} 메서드로 검색됨). {@code null} 값이 허용되며, 원인이 존재하지 않거나 + * 알 수 없음을 나타냄 + * @since v0.0.1-alpha + */ public DuplicateDataException(String message, Throwable cause) { super(message, cause); } + /** + * 지정된 원인과 상세 메시지와 함께 새로운 {@code DuplicateDataException}을 생성합니다. 상세 메시지는 {@code (cause==null ? + * null : cause.toString())}로 설정됩니다. (일반적으로 {@code cause}의 클래스와 상세 메시지를 포함) + * + * @param cause 원인 (나중에 {@link Throwable#getCause()} 메서드로 검색됨). {@code null} 값이 허용되며, 원인이 존재하지 않거나 + * 알 수 없음을 나타냄 + * @since v0.0.1-alpha + */ public DuplicateDataException(Throwable cause) { super(cause); } + /** + * 지정된 상세 메시지, 원인, suppression 활성화 여부, 그리고 writable stack trace 여부와 함께 새로운 {@code + * DuplicateDataException}을 생성합니다. + * + * @param message 상세 메시지 + * @param cause 원인. ({@code null} 값이 허용되며, 원인이 존재하지 않거나 알 수 없음을 나타냄) + * @param enableSuppression suppression이 활성화되는지 또는 비활성화되는지 여부 + * @param writableStackTrace stack trace가 writable해야 하는지 여부 + * @since v0.0.1-alpha + */ protected DuplicateDataException( String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); diff --git a/apps/user-service/src/main/java/site/icebang/common/utils/RandomPasswordGenerator.java b/apps/user-service/src/main/java/site/icebang/common/utils/RandomPasswordGenerator.java index c77189c2..d80db547 100644 --- a/apps/user-service/src/main/java/site/icebang/common/utils/RandomPasswordGenerator.java +++ b/apps/user-service/src/main/java/site/icebang/common/utils/RandomPasswordGenerator.java @@ -8,16 +8,61 @@ import org.springframework.stereotype.Component; +/** + * 보안이 강화된 랜덤 패스워드를 생성하는 유틸리티 클래스입니다. + * + *

이 클래스는 소문자, 대문자, 숫자, 특수문자를 포함한 강력한 패스워드를 생성합니다. 생성된 패스워드는 각 문자 유형을 최소 하나씩 포함하도록 보장됩니다. + * + *

사용 예제:

+ * + *
{@code
+ * @Autowired
+ * private RandomPasswordGenerator passwordGenerator;
+ *
+ * // 기본 길이(12자)로 패스워드 생성
+ * String password1 = passwordGenerator.generate();
+ *
+ * // 사용자 지정 길이로 패스워드 생성
+ * String password2 = passwordGenerator.generate(16);
+ * }
+ * + * @author jys01012@gmail.com + * @since v0.0.1-alpha + */ @Component public class RandomPasswordGenerator { + /** 소문자 알파벳 문자 집합 */ private static final String LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; + + /** 대문자 알파벳 문자 집합 */ private static final String UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + /** 숫자 문자 집합 */ private static final String DIGITS = "0123456789"; + + /** 특수문자 집합 */ private static final String SPECIAL_CHARS = "!@#$%^&*()-_+=<>?"; + /** 암호학적으로 안전한 난수 생성기 */ private final SecureRandom random = new SecureRandom(); + /** + * 지정된 길이의 랜덤 패스워드를 생성합니다. + * + *

생성되는 패스워드는 다음 규칙을 준수합니다: + * + *

    + *
  • 최소 길이는 8자입니다 + *
  • 소문자, 대문자, 숫자, 특수문자를 각각 최소 1개씩 포함합니다 + *
  • 모든 문자는 무작위로 섞여서 배치됩니다 + *
+ * + * @param length 생성할 패스워드의 길이 (8보다 작으면 8로 조정됩니다) + * @return 생성된 랜덤 패스워드 + * @throws IllegalArgumentException length가 음수인 경우 + * @since v0.0.1-alpha + */ public String generate(int length) { if (length < 8) { length = 8; @@ -49,13 +94,28 @@ public String generate(int length) { .toString(); } - // 특정 문자열에서 랜덤으로 한 문자 선택 + /** + * 지정된 문자 집합에서 랜덤으로 한 문자를 선택합니다. + * + * @param charSet 선택할 문자들이 포함된 문자열 + * @return 선택된 랜덤 문자 + * @throws IllegalArgumentException charSet이 null이거나 빈 문자열인 경우 + * @since v0.0.1-alpha + */ private char getRandomChar(String charSet) { int randomIndex = random.nextInt(charSet.length()); return charSet.charAt(randomIndex); } - // 기본 길이로 비밀번호 생성 + /** + * 기본 길이(12자)로 랜덤 패스워드를 생성합니다. + * + *

이는 {@code generate(12)}를 호출하는 것과 동일합니다. + * + * @return 12자 길이의 랜덤 패스워드 + * @see #generate(int) + * @since v0.0.1-alpha + */ public String generate() { return generate(12); // 기본 길이를 12로 설정 } From 8721117265b27dbd71f1303a64eca486f2011645 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Tue, 23 Sep 2025 17:44:23 +0900 Subject: [PATCH 10/25] =?UTF-8?q?doc:=20javadoc=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../site/icebang/common/dto/ApiResponse.java | 105 +++++++++++++ .../site/icebang/common/dto/PageParams.java | 84 ++++++++++- .../site/icebang/common/dto/PageResult.java | 142 ++++++++++++++++-- .../common/service/PageableService.java | 40 +++++ .../exception/GlobalExceptionHandler.java | 59 ++++++++ .../exception/RestAccessDeniedHandler.java | 27 ++++ .../RestAuthenticationEntryPoint.java | 27 ++++ 7 files changed, 474 insertions(+), 10 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java b/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java index 0f99e59b..a62c70cf 100644 --- a/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java +++ b/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java @@ -4,15 +4,85 @@ import lombok.Data; +/** + * 공통 APi 응답 DTO 클래스입니다. + *

+ * REST API의 응답 형식을 표준화하기 위해 사용됩니다. + * 모든 응답은 성공 여부({@link #success}), 응답 데이터({@link #data}), + * 응답 메시지({@link #message}), 그리고 HTTP 상태 코드({@link #status})를 포함합니다. + *

+ * + *

사용 예시:

+ *
{@code
+ * // 성공 응답 생성
+ * ApiResponse response = ApiResponse.success(userDto);
+ *
+ * // 메시지를 포함한 성공 응답
+ * ApiResponse response = ApiResponse.success(userDto, "회원 조회 성공");
+ *
+ * // 오류 응답 생성
+ * ApiResponse errorResponse = ApiResponse.error("잘못된 요청입니다.", HttpStatus.BAD_REQUEST);
+ * }
+ * + * @param 응답 데이터의 타입 + * + * @author jys01012@gmail.com + * @since v0.0.1-alpha + * + * @see HttpStatus + * @see lombok.Data + */ @Data public class ApiResponse { + /** + * 요청 처리 성공 여부. + *

+ * true: 요청이 정상적으로 처리됨 + * false: 요청 처리 중 오류 발생 + *

+ */ private boolean success; + + /** + * 실제 응답 데이터(payload). + *

+ * 요청이 성공적으로 처리되었을 경우 반환되는 데이터이며, + * 실패 시에는 {@code null}일 수 있습니다. + *

+ */ private T data; + + /** + * 응답 메세지. + *

+ * 성공 또는 오류 상황을 설명하는 메시지를 담습니다. + * 클라이언트에서 사용자에게 직접 표시할 수도 있습니다. + *

+ */ private String message; + + /** + * HTTP 상태 코드. + *

+ * Spring의 {@link HttpStatus} 열거형을 사용합니다. + *

+ */ private HttpStatus status; // HttpStatus로 변경 + /** + * 기본 생성자입니다. + * 모든 필드가 기본값으로 초기화됩니다. + */ public ApiResponse() {} + /** + * 모든 필드를 초기화하는 생성자. + * + * @param success 요청 성공 여부 + * @param data 응답 데이터 + * @param message 응답 메시지 + * @param status HTTP 상태 코드 + */ public ApiResponse(boolean success, T data, String message, HttpStatus status) { this.success = success; this.data = data; @@ -20,18 +90,53 @@ public ApiResponse(boolean success, T data, String message, HttpStatus status) { this.status = status; } + /** + * 성공 응답을 생성합니다. (기본 메시지: "OK", 상태: 200 OK) + * + * @param data 응답 데이터 + * @param 데이터 타입 + * @return 성공 응답 객체 + * + */ public static ApiResponse success(T data) { return new ApiResponse<>(true, data, "OK", HttpStatus.OK); } + /** + * 성공 응답을 생성합니다. (상태: 200 OK) + * + * @param data 응답 데이터 + * @param message 사용자 정의 메시지 + * @param 데이터 타입 + * @return 성공 응답 객체 + * + */ public static ApiResponse success(T data, String message) { return new ApiResponse<>(true, data, message, HttpStatus.OK); } + /** + * 성공 응답을 생성합니다. + * + * @param data 응답 데이터 + * @param message 사용자 정의 메시지 + * @param status 사용자 정의 상태 코드 + * @param 데이터 타입 + * @return 성공 응답 객체 + * + */ public static ApiResponse success(T data, String message, HttpStatus status) { return new ApiResponse<>(true, data, message, status); } + /** + * 오류 응답을 생성합니다. + * + * @param message 오류 메시지 + * @param status HTTP 상태 코드 + * @param 데이터 타입 + * @return 오류 응답 객체 + */ public static ApiResponse error(String message, HttpStatus status) { return new ApiResponse<>(false, null, message, status); } diff --git a/apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java b/apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java index 5f2f0d30..c475b839 100644 --- a/apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java +++ b/apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java @@ -1,24 +1,106 @@ package site.icebang.common.dto; import lombok.Data; +import org.springframework.http.HttpStatus; +/** + * 페이징, 검색, 정렬, 필터링을 위한 공통 매개변수 클래스입니다. + * + *

+ * 목록 조회 API에서 공통적으로 사용되는 요청 파라미터를 정의합니다. + * 현재 페이지 번호({@link #current}), 페이지 크기({@link #pageSize}), + * 검색어({@link #search}), 정렬 조건({@link #sorters}), 필터 조건({@link #filters})를 포함합니다. + *

+ * + *

사용 예시:

+ *
{@code
+ * PageParams params = new PageParams();
+ * params.setCurrent(2);
+ * params.setPageSize(20);
+ * params.setSearch("회원");
+ *
+ * int offset = params.getOffset(); // 20
+ * boolean searchable = params.hasSearch(); // true
+ * }
+ * + * @author jys01012@gmail.com + * @since v0.0.1-alpha + * + * @see lombok.Data + */ @Data public class PageParams { + /** + * 현재 페이지 번호 (1부터 시작). + *

+ * 1부터 시작하며, 기본값은 1입니다. + * 0 이하의 값은 유효하지 않습니다. + *

+ */ private int current = 1; + + /** + * 한 펭지에 표시할 데이터 개수. + *

+ * 한 페이지에 표시할 항목의 개수를 지정합니다. + * 기본값은 10개이며, 일반적으로 10, 20, 50, 100 등의 값을 사용합니다. + *

+ */ private int pageSize = 10; + + /** + * 검색어. + *

+ * 목록에서 특정 조건으로 검색할 때 사용되는 키워드입니다. + * {@code null}이거나 빈 문자열인 경우 검색 조건이 적용되지 않습니다. + *

+ */ private String search; + + /** + * 정렬 조건 배열. + *

+ * 예: {@code ["name:asc", "createdAt:desc"]} + * API 설계에 따라 "필드명:정렬방향" 형식을 권장합니다. + * {@code null}이거나 빈 배열인 경우 기본 정렬이 적용됩니다. + *

+ */ private String[] sorters; + + /** + * 필터링 조건 배열. + *

+ * 예: {@code ["status:active", "role:admin"]} + * 각 요소는 특정 필드에 대한 필터링 조건을 나타냅니다. + * 형태는 구현에 따라 다를 수 있습니다. + *

+ */ private String[] filters; - // 계산된 offset + + /** + * 페이징 처리를 위한 offset(시작 위치)을 계산합니다. + * + * @return (current - 1) * pageSize + */ public int getOffset() { return (current - 1) * pageSize; } + /** + * 검색어가 유효하게 존재하는지 확인합니다. + * + * @return 검색어가 null이 아니고 공백이 아닌 경우 true + */ public boolean hasSearch() { return search != null && !search.trim().isEmpty(); } + /** + * 정렬 조건이 존재하는지 확인합니다. + * + * @return 정렬 조건 배열이 null이 아니고, 1개 이상 있는 경우 true + */ public boolean hasSorters() { return sorters != null && sorters.length > 0; } diff --git a/apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java b/apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java index 4a2a8bfa..55a385d3 100644 --- a/apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java +++ b/apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java @@ -6,17 +6,85 @@ import lombok.Data; import lombok.NoArgsConstructor; +/** + * 페이징 처리된 결과 DTO 클래스. + * + *

+ * 목록 조회 API에서 페이징된 데이터를 반환할 때 사용됩니다. + * 실제 데이터 목록({@link #data}), 전체 개수({@link #total}), + * 현재 페이지 번호({@link #current}), 페이지 크기({@link #pageSize}), + * 전체 페이지 수({@link #totalPages}), 다음/이전 페이지 여부를 포함합니다. + *

+ * + *

사용 예시:

+ *
{@code
+ * PageParams params = new PageParams();
+ * params.setCurrent(2);
+ * params.setPageSize(10);
+ *
+ * // Repository나 Mapper에서 데이터를 가져와 PageResult 생성
+ * PageResult pageResult = PageResult.from(
+ *     params,
+ *     () -> userRepository.findUsers(params.getOffset(), params.getPageSize()),
+ *     () -> userRepository.countUsers()
+ * );
+ *
+ * boolean hasNext = pageResult.isHasNext(); // true or false
+ * }
+ * + * @param 데이터 타입 + * + * @author jys01012@gmail.com + * @since v0.0.1-alpha + */ @Data @NoArgsConstructor public class PageResult { + + /** + * 현재 페이지에 포함된 데이터 목록. + */ private List data; + + /** + * 전체 데이터 개수. + */ private int total; + + /** + * 현재 페이지 번호 (1부터 시작). + */ private int current; + + /** + * 한 페이지에 포함되는 데이터 개수. + */ private int pageSize; + + /** + * 전체 페이지 수. + */ private int totalPages; + + /** + * 다음 페이지가 존재하는지 여부. + */ private boolean hasNext; + + /** + * 이전 페이지가 존재하는지 여부. + */ private boolean hasPrevious; + + /** + * 생성자. + * + * @param data 현재 페이지 데이터 + * @param total 전체 데이터 개수 + * @param current 현재 페이지 번호 + * @param pageSize 페이지 크기 + */ public PageResult(List data, int total, int current, int pageSize) { this.data = data; this.total = total; @@ -25,24 +93,57 @@ public PageResult(List data, int total, int current, int pageSize) { calculatePagination(); } - // 페이징 계산 로직 분리 + /** + * 페이징 관련 필드를 계산합니다. + *

+ * totalPages, hasNext, hasPrevious 값을 설정합니다. + *

+ */ private void calculatePagination() { this.totalPages = total > 0 ? (int) Math.ceil((double) total / pageSize) : 0; this.hasNext = current < totalPages; this.hasPrevious = current > 1; } - // 기존 of 메서드 + /** + * PageResult 객체를 생성합니다. + * + * @param data 현재 페이지 데이터 + * @param total 전체 데이터 개수 + * @param current 현재 페이지 번호 + * @param pageSize 페이지 크기 + * @param 데이터 타입 + * @return PageResult 객체 + */ public static PageResult of(List data, int total, int current, int pageSize) { return new PageResult<>(data, total, current, pageSize); } - // PageParams를 받는 of 메서드 + /** + * PageParams를 기반으로 PageResult 객체를 생성합니다. + * + * @param data 현재 페이지 데이터 + * @param total 전체 데이터 개수 + * @param pageParams 요청 파라미터 ({@link PageParams}) + * @param 데이터 타입 + * @return PageResult 객체 + */ public static PageResult of(List data, int total, PageParams pageParams) { return new PageResult<>(data, total, pageParams.getCurrent(), pageParams.getPageSize()); } - // 함수형 인터페이스를 활용한 from 메서드 (트랜잭션 내에서 실행) + /** + * 함수형 인터페이스를 활용해 PageResult를 생성합니다. + *

+ * 데이터 조회와 카운트 조회를 별도의 Supplier로 받아 트랜잭션 내에서 실행할 수 있습니다. + *

+ * + * @param pageParams 요청 파라미터 ({@link PageParams}) + * @param dataSupplier 데이터 조회 함수 + * @param countSupplier 전체 개수 조회 함수 + * @param 데이터 타입 + * @return PageResult 객체 + */ public static PageResult from( PageParams pageParams, Supplier> dataSupplier, Supplier countSupplier) { List data = dataSupplier.get(); @@ -50,27 +151,50 @@ public static PageResult from( return new PageResult<>(data, total, pageParams.getCurrent(), pageParams.getPageSize()); } - // 빈 페이지 결과 생성 + /** + * 비어 있는 페이지 결과를 생성합니다. + * + * @param pageParams 요청 파라미터 ({@link PageParams}) + * @param 데이터 타입 + * @return 빈 PageResult 객체 + */ public static PageResult empty(PageParams pageParams) { return new PageResult<>(List.of(), 0, pageParams.getCurrent(), pageParams.getPageSize()); } - // 빈 페이지 결과 생성 (기본값) + /** + * 기본값(1페이지, 10개)으로 비어 있는 페이지 결과를 생성합니다. + * + * @param 데이터 타입 + * @return 빈 PageResult 객체 + */ public static PageResult empty() { return new PageResult<>(List.of(), 0, 1, 10); } - // 데이터가 있는지 확인 + /** + * 현재 페이지에 데이터가 있는지 확인합니다. + * + * @return 데이터가 존재하면 true + */ public boolean hasData() { return data != null && !data.isEmpty(); } - // 첫 번째 페이지인지 확인 + /** + * 현재 페이지가 첫 번째 페이지인지 확인합니다. + * + * @return 첫 번째 페이지면 true + */ public boolean isFirstPage() { return current == 1; } - // 마지막 페이지인지 확인 + /** + * 현재 페이지가 마지막 페이지인지 확인합니다. + * + * @return 마지막 페이지면 true + */ public boolean isLastPage() { return current == totalPages; } diff --git a/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java b/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java index 25d41d29..108b13ac 100644 --- a/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java +++ b/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java @@ -3,6 +3,46 @@ import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; +/** + * 페이징 가능한 서비스 인터페이스. + * + *

+ * 엔티티나 DTO 목록을 페이징 처리하여 반환해야 하는 서비스에서 구현합니다. + * 공통적으로 {@link PageParams} 요청 파라미터를 받아 + * {@link PageResult} 응답을 제공합니다. + *

+ * + *

사용 예시:

+ *
{@code
+ * @Service
+ * public class UserService implements PageableService {
+ *
+ *     private final UserRepository userRepository;
+ *
+ *     @Override
+ *     public PageResult getPagedResult(PageParams pageParams) {
+ *         List users = userRepository.findUsers(
+ *             pageParams.getOffset(), pageParams.getPageSize()
+ *         );
+ *         int total = userRepository.countUsers();
+ *         return PageResult.of(users, total, pageParams);
+ *     }
+ * }
+ * }
+ * + * @param 페이징 처리할 데이터 타입 + * + * @author jys01012@gmail.com + * @since v0.0.1-alpha + * + */ public interface PageableService { + + /** + * 페이징 처리된 결과를 반환합니다. + * + * @param pageParams 페이징 및 검색/정렬 요청 파라미터 + * @return 페이징 처리된 결과 객체 + */ PageResult getPagedResult(PageParams pageParams); } diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java index 8243acde..7bb83507 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java @@ -14,9 +14,37 @@ import site.icebang.common.dto.ApiResponse; import site.icebang.common.exception.DuplicateDataException; +/** + * 전역 예외 처리기 (Global Exception Handler). + * + *

이 클래스는 애플리케이션 전역에서 발생하는 예외를 + * {@link ApiResponse} 형태로 변환하여 클라이언트에게 반환합니다. + * 예외 유형에 따라 적절한 {@link HttpStatus} 코드를 설정하며, + * 공통적인 예외 처리 로직을 중앙화합니다.

+ * + *

처리되는 주요 예외는 다음과 같습니다:

+ *
    + *
  • {@link MethodArgumentNotValidException} - 요청 데이터 유효성 검증 실패
  • + *
  • {@link NoResourceFoundException} - 존재하지 않는 리소스 접근
  • + *
  • {@link AuthenticationException} - 인증 실패
  • + *
  • {@link AccessDeniedException} - 인가 실패
  • + *
  • {@link DuplicateDataException} - 중복 데이터 발생
  • + *
  • {@link Exception} - 그 외 처리되지 않은 일반 예외
  • + *
+ * + *

모든 응답은 {@code ApiResponse.error(...)} 메서드를 통해 생성되며, + * 에러 메시지와 HTTP 상태 코드가 포함됩니다.

+ */ @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { + + /** + * 요청 데이터 유효성 검증 실패 시 발생하는 예외를 처리합니다. + * + * @param ex 발생한 {@link MethodArgumentNotValidException} + * @return {@link ApiResponse} - 검증 실패 메시지와 {@link HttpStatus#BAD_REQUEST} + */ @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ApiResponse handleValidation(MethodArgumentNotValidException ex) { @@ -24,6 +52,13 @@ public ApiResponse handleValidation(MethodArgumentNotValidException ex) return ApiResponse.error("Validation failed: " + detail, HttpStatus.BAD_REQUEST); } + /** + * 처리되지 않은 모든 일반 예외를 처리합니다. + * 서버 내부 오류로 간주되며, 에러 로그를 남깁니다. + * + * @param ex 발생한 {@link Exception} + * @return {@link ApiResponse} - 내부 오류 메시지와 {@link HttpStatus#INTERNAL_SERVER_ERROR} + */ @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ApiResponse handleGeneric(Exception ex) { @@ -31,24 +66,48 @@ public ApiResponse handleGeneric(Exception ex) { return ApiResponse.error("Internal error: ", HttpStatus.INTERNAL_SERVER_ERROR); } + /** + * 존재하지 않는 리소스 접근 시 발생하는 예외를 처리합니다. + * + * @param ex 발생한 {@link NoResourceFoundException} + * @return {@link ApiResponse} - 리소스 없음 메시지와 {@link HttpStatus#NOT_FOUND} + */ @ExceptionHandler(NoResourceFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ApiResponse handleNotFound(NoResourceFoundException ex) { return ApiResponse.error("Notfound: " + ex.getMessage(), HttpStatus.NOT_FOUND); } + /** + * 인증 실패 시 발생하는 예외를 처리합니다. + * + * @param ex 발생한 {@link AuthenticationException} + * @return {@link ApiResponse} - 인증 실패 메시지와 {@link HttpStatus#UNAUTHORIZED} + */ @ExceptionHandler(AuthenticationException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ApiResponse handleAuthentication(AuthenticationException ex) { return ApiResponse.error("Authentication failed: " + ex.getMessage(), HttpStatus.UNAUTHORIZED); } + /** + * 인가(권한) 실패 시 발생하는 예외를 처리합니다. + * + * @param ex 발생한 {@link AccessDeniedException} + * @return {@link ApiResponse} - 접근 거부 메시지와 {@link HttpStatus#FORBIDDEN} + */ @ExceptionHandler(AccessDeniedException.class) @ResponseStatus(HttpStatus.FORBIDDEN) public ApiResponse handleAccessDenied(AccessDeniedException ex) { return ApiResponse.error("Access denied: " + ex.getMessage(), HttpStatus.FORBIDDEN); } + /** + * 중복 데이터 발생 시 발생하는 예외를 처리합니다. + * + * @param ex 발생한 {@link DuplicateDataException} + * @return {@link ApiResponse} - 중복 데이터 메시지와 {@link HttpStatus#CONFLICT} + */ @ExceptionHandler(DuplicateDataException.class) @ResponseStatus(HttpStatus.CONFLICT) public ApiResponse handleDuplicateData(DuplicateDataException ex) { diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java index efeffde1..b2bf683b 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java @@ -15,11 +15,38 @@ import site.icebang.common.dto.ApiResponse; +/** + * 접근 거부 처리기 (REST 전용 AccessDeniedHandler). + * + *

Spring Security에서 인가(Authorization) 실패 시 호출됩니다. + * 사용자가 필요한 권한 없이 보호된 리소스에 접근하려고 하면 + * 이 핸들러가 실행되어 JSON 형식의 에러 응답을 반환합니다.

+ * + *

주요 특징:

+ *
    + *
  • HTTP 상태 코드: {@link HttpStatus#FORBIDDEN} (403)
  • + *
  • 응답 본문: {@link ApiResponse} 형식의 에러 메시지
  • + *
  • 응답 Content-Type: {@code application/json;charset=UTF-8}
  • + *
+ * + *

이 핸들러는 기본 HTML 오류 페이지 대신, REST API 클라이언트에 + * JSON 기반의 표준 에러 응답을 제공하기 위해 사용됩니다.

+ */ @Component @RequiredArgsConstructor public class RestAccessDeniedHandler implements AccessDeniedHandler { + + /** JSON 직렬화를 위한 ObjectMapper */ private final ObjectMapper objectMapper; + /** + * 인가되지 않은 요청이 들어왔을 때 실행됩니다. + * + * @param request 현재 요청 + * @param response 응답 객체 + * @param ex 발생한 {@link AccessDeniedException} + * @throws IOException 응답 스트림 처리 중 오류 발생 시 + */ @Override public void handle( HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java index b7c50d76..ed785df4 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java @@ -15,11 +15,38 @@ import site.icebang.common.dto.ApiResponse; +/** + * 인증 진입점 처리기 (REST 전용 AuthenticationEntryPoint). + * + *

Spring Security에서 인증(Authentication) 실패 시 호출됩니다. + * 인증되지 않은 사용자가 보호된 리소스에 접근하려고 하면 + * 이 핸들러가 실행되어 JSON 형식의 에러 응답을 반환합니다.

+ * + *

주요 특징:

+ *
    + *
  • HTTP 상태 코드: {@link HttpStatus#UNAUTHORIZED} (401)
  • + *
  • 응답 본문: {@link ApiResponse} 형식의 에러 메시지
  • + *
  • 응답 Content-Type: {@code application/json;charset=UTF-8}
  • + *
+ * + *

이 핸들러는 기본 로그인 페이지 리다이렉트 대신, REST API 클라이언트에 + * JSON 기반의 표준 에러 응답을 제공하기 위해 사용됩니다.

+ */ @Component @RequiredArgsConstructor public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + + /** JSON 직렬화를 위한 ObjectMapper */ private final ObjectMapper objectMapper; + /** + * 인증되지 않은 요청이 들어왔을 때 실행됩니다. + * + * @param request 현재 요청 + * @param response 응답 객체 + * @param ex 발생한 {@link AuthenticationException} + * @throws IOException 응답 스트림 처리 중 오류 발생 시 + */ @Override public void commence( HttpServletRequest request, HttpServletResponse response, AuthenticationException ex) From a56eb782ee9d3fb788e1bfc63b3ef09d3dc372c0 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Tue, 23 Sep 2025 17:45:36 +0900 Subject: [PATCH 11/25] chore: spotlessApply --- .../site/icebang/common/dto/ApiResponse.java | 64 ++++++--------- .../site/icebang/common/dto/PageParams.java | 47 ++++------- .../site/icebang/common/dto/PageResult.java | 80 +++++++------------ .../common/service/PageableService.java | 12 +-- .../exception/GlobalExceptionHandler.java | 27 +++---- .../exception/RestAccessDeniedHandler.java | 17 ++-- .../RestAuthenticationEntryPoint.java | 17 ++-- 7 files changed, 102 insertions(+), 162 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java b/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java index a62c70cf..8a986b4d 100644 --- a/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java +++ b/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java @@ -6,13 +6,12 @@ /** * 공통 APi 응답 DTO 클래스입니다. - *

- * REST API의 응답 형식을 표준화하기 위해 사용됩니다. - * 모든 응답은 성공 여부({@link #success}), 응답 데이터({@link #data}), - * 응답 메시지({@link #message}), 그리고 HTTP 상태 코드({@link #status})를 포함합니다. - *

* - *

사용 예시:

+ *

REST API의 응답 형식을 표준화하기 위해 사용됩니다. 모든 응답은 성공 여부({@link #success}), 응답 데이터({@link #data}), 응답 + * 메시지({@link #message}), 그리고 HTTP 상태 코드({@link #status})를 포함합니다. + * + *

사용 예시: + * *

{@code
  * // 성공 응답 생성
  * ApiResponse response = ApiResponse.success(userDto);
@@ -25,10 +24,8 @@
  * }
* * @param 응답 데이터의 타입 - * * @author jys01012@gmail.com * @since v0.0.1-alpha - * * @see HttpStatus * @see lombok.Data */ @@ -36,52 +33,42 @@ public class ApiResponse { /** * 요청 처리 성공 여부. - *

- * true: 요청이 정상적으로 처리됨 - * false: 요청 처리 중 오류 발생 - *

+ * + *

true: 요청이 정상적으로 처리됨 false: 요청 처리 중 오류 발생 */ private boolean success; /** * 실제 응답 데이터(payload). - *

- * 요청이 성공적으로 처리되었을 경우 반환되는 데이터이며, - * 실패 시에는 {@code null}일 수 있습니다. - *

+ * + *

요청이 성공적으로 처리되었을 경우 반환되는 데이터이며, 실패 시에는 {@code null}일 수 있습니다. */ private T data; /** * 응답 메세지. - *

- * 성공 또는 오류 상황을 설명하는 메시지를 담습니다. - * 클라이언트에서 사용자에게 직접 표시할 수도 있습니다. - *

+ * + *

성공 또는 오류 상황을 설명하는 메시지를 담습니다. 클라이언트에서 사용자에게 직접 표시할 수도 있습니다. */ private String message; /** * HTTP 상태 코드. - *

- * Spring의 {@link HttpStatus} 열거형을 사용합니다. - *

+ * + *

Spring의 {@link HttpStatus} 열거형을 사용합니다. */ private HttpStatus status; // HttpStatus로 변경 - /** - * 기본 생성자입니다. - * 모든 필드가 기본값으로 초기화됩니다. - */ + /** 기본 생성자입니다. 모든 필드가 기본값으로 초기화됩니다. */ public ApiResponse() {} /** * 모든 필드를 초기화하는 생성자. * * @param success 요청 성공 여부 - * @param data 응답 데이터 + * @param data 응답 데이터 * @param message 응답 메시지 - * @param status HTTP 상태 코드 + * @param status HTTP 상태 코드 */ public ApiResponse(boolean success, T data, String message, HttpStatus status) { this.success = success; @@ -94,9 +81,8 @@ public ApiResponse(boolean success, T data, String message, HttpStatus status) { * 성공 응답을 생성합니다. (기본 메시지: "OK", 상태: 200 OK) * * @param data 응답 데이터 - * @param 데이터 타입 + * @param 데이터 타입 * @return 성공 응답 객체 - * */ public static ApiResponse success(T data) { return new ApiResponse<>(true, data, "OK", HttpStatus.OK); @@ -105,11 +91,10 @@ public static ApiResponse success(T data) { /** * 성공 응답을 생성합니다. (상태: 200 OK) * - * @param data 응답 데이터 + * @param data 응답 데이터 * @param message 사용자 정의 메시지 - * @param 데이터 타입 + * @param 데이터 타입 * @return 성공 응답 객체 - * */ public static ApiResponse success(T data, String message) { return new ApiResponse<>(true, data, message, HttpStatus.OK); @@ -118,12 +103,11 @@ public static ApiResponse success(T data, String message) { /** * 성공 응답을 생성합니다. * - * @param data 응답 데이터 + * @param data 응답 데이터 * @param message 사용자 정의 메시지 - * @param status 사용자 정의 상태 코드 - * @param 데이터 타입 + * @param status 사용자 정의 상태 코드 + * @param 데이터 타입 * @return 성공 응답 객체 - * */ public static ApiResponse success(T data, String message, HttpStatus status) { return new ApiResponse<>(true, data, message, status); @@ -133,8 +117,8 @@ public static ApiResponse success(T data, String message, HttpStatus stat * 오류 응답을 생성합니다. * * @param message 오류 메시지 - * @param status HTTP 상태 코드 - * @param 데이터 타입 + * @param status HTTP 상태 코드 + * @param 데이터 타입 * @return 오류 응답 객체 */ public static ApiResponse error(String message, HttpStatus status) { diff --git a/apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java b/apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java index c475b839..6083bc43 100644 --- a/apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java +++ b/apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java @@ -1,18 +1,15 @@ package site.icebang.common.dto; import lombok.Data; -import org.springframework.http.HttpStatus; /** * 페이징, 검색, 정렬, 필터링을 위한 공통 매개변수 클래스입니다. * - *

- * 목록 조회 API에서 공통적으로 사용되는 요청 파라미터를 정의합니다. - * 현재 페이지 번호({@link #current}), 페이지 크기({@link #pageSize}), - * 검색어({@link #search}), 정렬 조건({@link #sorters}), 필터 조건({@link #filters})를 포함합니다. - *

+ *

목록 조회 API에서 공통적으로 사용되는 요청 파라미터를 정의합니다. 현재 페이지 번호({@link #current}), 페이지 크기({@link #pageSize}), + * 검색어({@link #search}), 정렬 조건({@link #sorters}), 필터 조건({@link #filters})를 포함합니다. + * + *

사용 예시: * - *

사용 예시:

*
{@code
  * PageParams params = new PageParams();
  * params.setCurrent(2);
@@ -25,59 +22,47 @@
  *
  * @author jys01012@gmail.com
  * @since v0.0.1-alpha
- *
  * @see lombok.Data
  */
 @Data
 public class PageParams {
   /**
    * 현재 페이지 번호 (1부터 시작).
-   * 

- * 1부터 시작하며, 기본값은 1입니다. - * 0 이하의 값은 유효하지 않습니다. - *

+ * + *

1부터 시작하며, 기본값은 1입니다. 0 이하의 값은 유효하지 않습니다. */ private int current = 1; /** * 한 펭지에 표시할 데이터 개수. - *

- * 한 페이지에 표시할 항목의 개수를 지정합니다. - * 기본값은 10개이며, 일반적으로 10, 20, 50, 100 등의 값을 사용합니다. - *

+ * + *

한 페이지에 표시할 항목의 개수를 지정합니다. 기본값은 10개이며, 일반적으로 10, 20, 50, 100 등의 값을 사용합니다. */ private int pageSize = 10; /** * 검색어. - *

- * 목록에서 특정 조건으로 검색할 때 사용되는 키워드입니다. - * {@code null}이거나 빈 문자열인 경우 검색 조건이 적용되지 않습니다. - *

+ * + *

목록에서 특정 조건으로 검색할 때 사용되는 키워드입니다. {@code null}이거나 빈 문자열인 경우 검색 조건이 적용되지 않습니다. */ private String search; /** * 정렬 조건 배열. - *

- * 예: {@code ["name:asc", "createdAt:desc"]} - * API 설계에 따라 "필드명:정렬방향" 형식을 권장합니다. - * {@code null}이거나 빈 배열인 경우 기본 정렬이 적용됩니다. - *

+ * + *

예: {@code ["name:asc", "createdAt:desc"]} API 설계에 따라 "필드명:정렬방향" 형식을 권장합니다. {@code null}이거나 빈 + * 배열인 경우 기본 정렬이 적용됩니다. */ private String[] sorters; /** * 필터링 조건 배열. - *

- * 예: {@code ["status:active", "role:admin"]} - * 각 요소는 특정 필드에 대한 필터링 조건을 나타냅니다. - * 형태는 구현에 따라 다를 수 있습니다. - *

+ * + *

예: {@code ["status:active", "role:admin"]} 각 요소는 특정 필드에 대한 필터링 조건을 나타냅니다. 형태는 구현에 따라 다를 수 + * 있습니다. */ private String[] filters; - /** * 페이징 처리를 위한 offset(시작 위치)을 계산합니다. * diff --git a/apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java b/apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java index 55a385d3..0982be0a 100644 --- a/apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java +++ b/apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java @@ -9,14 +9,12 @@ /** * 페이징 처리된 결과 DTO 클래스. * - *

- * 목록 조회 API에서 페이징된 데이터를 반환할 때 사용됩니다. - * 실제 데이터 목록({@link #data}), 전체 개수({@link #total}), - * 현재 페이지 번호({@link #current}), 페이지 크기({@link #pageSize}), - * 전체 페이지 수({@link #totalPages}), 다음/이전 페이지 여부를 포함합니다. - *

+ *

목록 조회 API에서 페이징된 데이터를 반환할 때 사용됩니다. 실제 데이터 목록({@link #data}), 전체 개수({@link #total}), 현재 페이지 + * 번호({@link #current}), 페이지 크기({@link #pageSize}), 전체 페이지 수({@link #totalPages}), 다음/이전 페이지 여부를 + * 포함합니다. + * + *

사용 예시: * - *

사용 예시:

*
{@code
  * PageParams params = new PageParams();
  * params.setCurrent(2);
@@ -33,7 +31,6 @@
  * }
* * @param 데이터 타입 - * * @author jys01012@gmail.com * @since v0.0.1-alpha */ @@ -41,48 +38,33 @@ @NoArgsConstructor public class PageResult { - /** - * 현재 페이지에 포함된 데이터 목록. - */ + /** 현재 페이지에 포함된 데이터 목록. */ private List data; - /** - * 전체 데이터 개수. - */ + /** 전체 데이터 개수. */ private int total; - /** - * 현재 페이지 번호 (1부터 시작). - */ + /** 현재 페이지 번호 (1부터 시작). */ private int current; - /** - * 한 페이지에 포함되는 데이터 개수. - */ + /** 한 페이지에 포함되는 데이터 개수. */ private int pageSize; - /** - * 전체 페이지 수. - */ + /** 전체 페이지 수. */ private int totalPages; - /** - * 다음 페이지가 존재하는지 여부. - */ + /** 다음 페이지가 존재하는지 여부. */ private boolean hasNext; - /** - * 이전 페이지가 존재하는지 여부. - */ + /** 이전 페이지가 존재하는지 여부. */ private boolean hasPrevious; - /** * 생성자. * - * @param data 현재 페이지 데이터 - * @param total 전체 데이터 개수 - * @param current 현재 페이지 번호 + * @param data 현재 페이지 데이터 + * @param total 전체 데이터 개수 + * @param current 현재 페이지 번호 * @param pageSize 페이지 크기 */ public PageResult(List data, int total, int current, int pageSize) { @@ -95,9 +77,8 @@ public PageResult(List data, int total, int current, int pageSize) { /** * 페이징 관련 필드를 계산합니다. - *

- * totalPages, hasNext, hasPrevious 값을 설정합니다. - *

+ * + *

totalPages, hasNext, hasPrevious 값을 설정합니다. */ private void calculatePagination() { this.totalPages = total > 0 ? (int) Math.ceil((double) total / pageSize) : 0; @@ -108,11 +89,11 @@ private void calculatePagination() { /** * PageResult 객체를 생성합니다. * - * @param data 현재 페이지 데이터 - * @param total 전체 데이터 개수 - * @param current 현재 페이지 번호 + * @param data 현재 페이지 데이터 + * @param total 전체 데이터 개수 + * @param current 현재 페이지 번호 * @param pageSize 페이지 크기 - * @param 데이터 타입 + * @param 데이터 타입 * @return PageResult 객체 */ public static PageResult of(List data, int total, int current, int pageSize) { @@ -122,10 +103,10 @@ public static PageResult of(List data, int total, int current, int pag /** * PageParams를 기반으로 PageResult 객체를 생성합니다. * - * @param data 현재 페이지 데이터 - * @param total 전체 데이터 개수 + * @param data 현재 페이지 데이터 + * @param total 전체 데이터 개수 * @param pageParams 요청 파라미터 ({@link PageParams}) - * @param 데이터 타입 + * @param 데이터 타입 * @return PageResult 객체 */ public static PageResult of(List data, int total, PageParams pageParams) { @@ -134,14 +115,13 @@ public static PageResult of(List data, int total, PageParams pageParam /** * 함수형 인터페이스를 활용해 PageResult를 생성합니다. - *

- * 데이터 조회와 카운트 조회를 별도의 Supplier로 받아 트랜잭션 내에서 실행할 수 있습니다. - *

* - * @param pageParams 요청 파라미터 ({@link PageParams}) - * @param dataSupplier 데이터 조회 함수 + *

데이터 조회와 카운트 조회를 별도의 Supplier로 받아 트랜잭션 내에서 실행할 수 있습니다. + * + * @param pageParams 요청 파라미터 ({@link PageParams}) + * @param dataSupplier 데이터 조회 함수 * @param countSupplier 전체 개수 조회 함수 - * @param 데이터 타입 + * @param 데이터 타입 * @return PageResult 객체 */ public static PageResult from( @@ -155,7 +135,7 @@ public static PageResult from( * 비어 있는 페이지 결과를 생성합니다. * * @param pageParams 요청 파라미터 ({@link PageParams}) - * @param 데이터 타입 + * @param 데이터 타입 * @return 빈 PageResult 객체 */ public static PageResult empty(PageParams pageParams) { diff --git a/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java b/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java index 108b13ac..da3ae215 100644 --- a/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java +++ b/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java @@ -6,13 +6,11 @@ /** * 페이징 가능한 서비스 인터페이스. * - *

- * 엔티티나 DTO 목록을 페이징 처리하여 반환해야 하는 서비스에서 구현합니다. - * 공통적으로 {@link PageParams} 요청 파라미터를 받아 - * {@link PageResult} 응답을 제공합니다. - *

+ *

엔티티나 DTO 목록을 페이징 처리하여 반환해야 하는 서비스에서 구현합니다. 공통적으로 {@link PageParams} 요청 파라미터를 받아 {@link + * PageResult} 응답을 제공합니다. + * + *

사용 예시: * - *

사용 예시:

*
{@code
  * @Service
  * public class UserService implements PageableService {
@@ -31,10 +29,8 @@
  * }
* * @param 페이징 처리할 데이터 타입 - * * @author jys01012@gmail.com * @since v0.0.1-alpha - * */ public interface PageableService { diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java index 7bb83507..4e34fab7 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java @@ -17,23 +17,21 @@ /** * 전역 예외 처리기 (Global Exception Handler). * - *

이 클래스는 애플리케이션 전역에서 발생하는 예외를 - * {@link ApiResponse} 형태로 변환하여 클라이언트에게 반환합니다. - * 예외 유형에 따라 적절한 {@link HttpStatus} 코드를 설정하며, - * 공통적인 예외 처리 로직을 중앙화합니다.

+ *

이 클래스는 애플리케이션 전역에서 발생하는 예외를 {@link ApiResponse} 형태로 변환하여 클라이언트에게 반환합니다. 예외 유형에 따라 적절한 {@link + * HttpStatus} 코드를 설정하며, 공통적인 예외 처리 로직을 중앙화합니다. + * + *

처리되는 주요 예외는 다음과 같습니다: * - *

처리되는 주요 예외는 다음과 같습니다:

*
    - *
  • {@link MethodArgumentNotValidException} - 요청 데이터 유효성 검증 실패
  • - *
  • {@link NoResourceFoundException} - 존재하지 않는 리소스 접근
  • - *
  • {@link AuthenticationException} - 인증 실패
  • - *
  • {@link AccessDeniedException} - 인가 실패
  • - *
  • {@link DuplicateDataException} - 중복 데이터 발생
  • - *
  • {@link Exception} - 그 외 처리되지 않은 일반 예외
  • + *
  • {@link MethodArgumentNotValidException} - 요청 데이터 유효성 검증 실패 + *
  • {@link NoResourceFoundException} - 존재하지 않는 리소스 접근 + *
  • {@link AuthenticationException} - 인증 실패 + *
  • {@link AccessDeniedException} - 인가 실패 + *
  • {@link DuplicateDataException} - 중복 데이터 발생 + *
  • {@link Exception} - 그 외 처리되지 않은 일반 예외 *
* - *

모든 응답은 {@code ApiResponse.error(...)} 메서드를 통해 생성되며, - * 에러 메시지와 HTTP 상태 코드가 포함됩니다.

+ *

모든 응답은 {@code ApiResponse.error(...)} 메서드를 통해 생성되며, 에러 메시지와 HTTP 상태 코드가 포함됩니다. */ @RestControllerAdvice @Slf4j @@ -53,8 +51,7 @@ public ApiResponse handleValidation(MethodArgumentNotValidException ex) } /** - * 처리되지 않은 모든 일반 예외를 처리합니다. - * 서버 내부 오류로 간주되며, 에러 로그를 남깁니다. + * 처리되지 않은 모든 일반 예외를 처리합니다. 서버 내부 오류로 간주되며, 에러 로그를 남깁니다. * * @param ex 발생한 {@link Exception} * @return {@link ApiResponse} - 내부 오류 메시지와 {@link HttpStatus#INTERNAL_SERVER_ERROR} diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java index b2bf683b..9e6672f3 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java @@ -18,19 +18,18 @@ /** * 접근 거부 처리기 (REST 전용 AccessDeniedHandler). * - *

Spring Security에서 인가(Authorization) 실패 시 호출됩니다. - * 사용자가 필요한 권한 없이 보호된 리소스에 접근하려고 하면 - * 이 핸들러가 실행되어 JSON 형식의 에러 응답을 반환합니다.

+ *

Spring Security에서 인가(Authorization) 실패 시 호출됩니다. 사용자가 필요한 권한 없이 보호된 리소스에 접근하려고 하면 이 핸들러가 실행되어 + * JSON 형식의 에러 응답을 반환합니다. + * + *

주요 특징: * - *

주요 특징:

*
    - *
  • HTTP 상태 코드: {@link HttpStatus#FORBIDDEN} (403)
  • - *
  • 응답 본문: {@link ApiResponse} 형식의 에러 메시지
  • - *
  • 응답 Content-Type: {@code application/json;charset=UTF-8}
  • + *
  • HTTP 상태 코드: {@link HttpStatus#FORBIDDEN} (403) + *
  • 응답 본문: {@link ApiResponse} 형식의 에러 메시지 + *
  • 응답 Content-Type: {@code application/json;charset=UTF-8} *
* - *

이 핸들러는 기본 HTML 오류 페이지 대신, REST API 클라이언트에 - * JSON 기반의 표준 에러 응답을 제공하기 위해 사용됩니다.

+ *

이 핸들러는 기본 HTML 오류 페이지 대신, REST API 클라이언트에 JSON 기반의 표준 에러 응답을 제공하기 위해 사용됩니다. */ @Component @RequiredArgsConstructor diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java index ed785df4..9d3ec7b5 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java @@ -18,19 +18,18 @@ /** * 인증 진입점 처리기 (REST 전용 AuthenticationEntryPoint). * - *

Spring Security에서 인증(Authentication) 실패 시 호출됩니다. - * 인증되지 않은 사용자가 보호된 리소스에 접근하려고 하면 - * 이 핸들러가 실행되어 JSON 형식의 에러 응답을 반환합니다.

+ *

Spring Security에서 인증(Authentication) 실패 시 호출됩니다. 인증되지 않은 사용자가 보호된 리소스에 접근하려고 하면 이 핸들러가 실행되어 + * JSON 형식의 에러 응답을 반환합니다. + * + *

주요 특징: * - *

주요 특징:

*
    - *
  • HTTP 상태 코드: {@link HttpStatus#UNAUTHORIZED} (401)
  • - *
  • 응답 본문: {@link ApiResponse} 형식의 에러 메시지
  • - *
  • 응답 Content-Type: {@code application/json;charset=UTF-8}
  • + *
  • HTTP 상태 코드: {@link HttpStatus#UNAUTHORIZED} (401) + *
  • 응답 본문: {@link ApiResponse} 형식의 에러 메시지 + *
  • 응답 Content-Type: {@code application/json;charset=UTF-8} *
* - *

이 핸들러는 기본 로그인 페이지 리다이렉트 대신, REST API 클라이언트에 - * JSON 기반의 표준 에러 응답을 제공하기 위해 사용됩니다.

+ *

이 핸들러는 기본 로그인 페이지 리다이렉트 대신, REST API 클라이언트에 JSON 기반의 표준 에러 응답을 제공하기 위해 사용됩니다. */ @Component @RequiredArgsConstructor From 15e3397ebb8580843b1ebf8222bf65e637b3e019 Mon Sep 17 00:00:00 2001 From: Yousung Jung Date: Tue, 23 Sep 2025 17:50:19 +0900 Subject: [PATCH 12/25] Common/utils javadoc fix H3 to H2 (#191) Changes to be committed: modified: src/main/java/site/icebang/common/exception/DuplicateDataException.java modified: src/main/java/site/icebang/common/utils/RandomPasswordGenerator.java --- .../site/icebang/common/exception/DuplicateDataException.java | 2 +- .../java/site/icebang/common/utils/RandomPasswordGenerator.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/common/exception/DuplicateDataException.java b/apps/user-service/src/main/java/site/icebang/common/exception/DuplicateDataException.java index 12d75411..b8c4a8a2 100644 --- a/apps/user-service/src/main/java/site/icebang/common/exception/DuplicateDataException.java +++ b/apps/user-service/src/main/java/site/icebang/common/exception/DuplicateDataException.java @@ -6,7 +6,7 @@ *

이 예외는 데이터베이스나 컬렉션에서 이미 존재하는 데이터를 중복해서 생성하거나 저장하려고 할 때 발생합니다. 주로 유니크 제약 조건 위반이나 비즈니스 로직상 중복을 * 허용하지 않는 경우에 사용됩니다. * - *

사용 예제:

+ *

사용 예제:

* *
{@code
  * // 사용자 이메일 중복 체크
diff --git a/apps/user-service/src/main/java/site/icebang/common/utils/RandomPasswordGenerator.java b/apps/user-service/src/main/java/site/icebang/common/utils/RandomPasswordGenerator.java
index d80db547..5bab1f87 100644
--- a/apps/user-service/src/main/java/site/icebang/common/utils/RandomPasswordGenerator.java
+++ b/apps/user-service/src/main/java/site/icebang/common/utils/RandomPasswordGenerator.java
@@ -13,7 +13,7 @@
  *
  * 

이 클래스는 소문자, 대문자, 숫자, 특수문자를 포함한 강력한 패스워드를 생성합니다. 생성된 패스워드는 각 문자 유형을 최소 하나씩 포함하도록 보장됩니다. * - *

사용 예제:

+ *

사용 예제:

* *
{@code
  * @Autowired

From fc891908e62e79d0646e15416850b69534dc1582 Mon Sep 17 00:00:00 2001
From: Yousung Jung 
Date: Tue, 23 Sep 2025 18:00:15 +0900
Subject: [PATCH 13/25] =?UTF-8?q?Ci=EC=97=90=20javadoc=20=EC=B6=94?=
 =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20javadoc=20task=20=EC=8B=A4=ED=8C=A8=20f?=
 =?UTF-8?q?ix=20(#195)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* fix: Workflow history mapper javadoc fix

* chore: ci에 javadoc 추가
---
 .github/workflows/ci-java.yml                 |  1 +
 .../mapper/WorkflowHistoryMapper.java         | 36 +++++++++++--------
 2 files changed, 23 insertions(+), 14 deletions(-)

diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml
index fd55f05f..f3b2b5d5 100644
--- a/.github/workflows/ci-java.yml
+++ b/.github/workflows/ci-java.yml
@@ -78,6 +78,7 @@ jobs:
         run: |
           ./gradlew unitTest
           ./gradlew integrationTest
+          ./gradlew javadoc
           if [ "${{ github.base_ref }}" = "main" ] || [[ "${{ github.ref }}" == refs/tags/* ]]; then
             ./gradlew e2eTest
           fi
diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowHistoryMapper.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowHistoryMapper.java
index d22e2a68..aec0bb36 100644
--- a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowHistoryMapper.java
+++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowHistoryMapper.java
@@ -10,53 +10,61 @@
 import site.icebang.domain.workflow.dto.WorkflowHistoryDTO;
 import site.icebang.domain.workflow.dto.WorkflowRunDto;
 
+/**
+ * 워크플로우 실행 히스토리 관련 데이터베이스 매퍼 인터페이스입니다.
+ *
+ * 

워크플로우, 작업(Job), 태스크(Task)의 실행 기록과 관련된 데이터 조회를 담당합니다. + * + * @author jys01012@gmail.com + * @since v0.0.1-beta + */ @Mapper public interface WorkflowHistoryMapper { /** - * 워크플로우 실행 정보 조회 + * 워크플로우 실행 정보를 조회합니다. * * @param runId workflow_run.id - * @return WorkflowRunDto + * @return 워크플로우 실행 정보 */ WorkflowRunDto selectWorkflowRun(Long runId); /** - * 워크플로우 실행의 Job 목록 조회 + * 워크플로우 실행의 작업 목록을 조회합니다. * * @param workflowRunId workflow_run.id - * @return List + * @return 작업 실행 정보 목록 */ List selectJobRunsByWorkflowRunId(Long workflowRunId); /** - * Job 실행의 Task 목록 조회 + * 작업 실행의 태스크 목록을 조회합니다. * * @param jobRunId job_run.id - * @return List + * @return 태스크 실행 정보 목록 */ List selectTaskRunsByJobRunId(Long jobRunId); /** - * 워크플로우 실행 TraceId 조회 + * 워크플로우 실행 TraceId를 조회합니다. * * @param runId workflow_run.id - * @return String traceId + * @return 추적 ID 문자열 */ String selectTraceIdByRunId(Long runId); /** - * 워크플로우 런 페이지네이션 + * 페이지네이션을 적용한 워크플로우 히스토리 목록을 조회합니다. * - * @param pageParams pageParams - * @return List + * @param pageParams 페이지 매개변수 + * @return 워크플로우 히스토리 정보 목록 */ List selectWorkflowHistoryList(PageParams pageParams); /** - * 워크플로우 런 인스턴스 개수 조회 + * 워크플로우 런 인스턴스의 총 개수를 조회합니다. * - * @param pageParams pageParams - * @return 결과 개수 + * @param pageParams 페이지 매개변수 + * @return 총 결과 개수 */ int selectWorkflowHistoryCount(PageParams pageParams); } From 207dd669a6709e42024bd698bd0336536c559c2d Mon Sep 17 00:00:00 2001 From: Jihu Kim Date: Tue, 23 Sep 2025 18:49:43 +0900 Subject: [PATCH 14/25] =?UTF-8?q?Workflow=20=EA=B4=80=EB=A0=A8=20javadoc?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20(#196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: workflow 관련 javadoc 작성 * refactor: Code Formating --- .../service/QuartzScheduleService.java | 33 ++++++++++ .../domain/workflow/runner/TaskRunner.java | 47 ++++++++++++-- .../runner/fastapi/FastApiTaskRunner.java | 31 ++++++++++ .../scheduler/WorkflowTriggerJob.java | 26 ++++++++ .../service/TaskExecutionService.java | 52 ++++++++++++++-- .../service/WorkflowHistoryService.java | 57 +++++++++++++---- .../workflow/service/WorkflowService.java | 36 +++++++++++ .../fastapi/adapter/FastApiAdapter.java | 32 +++++++++- .../config/QuartzSchedulerInitializer.java | 25 ++++++++ .../site/icebang/global/config/WebConfig.java | 28 +++++++++ .../typehandler/JsonNodeTypeHandler.java | 62 +++++++++++++++++++ .../config/properties/FastApiProperties.java | 39 +++++++++++- 12 files changed, 440 insertions(+), 28 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java index d8348e7e..667637b1 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java @@ -7,12 +7,39 @@ import site.icebang.domain.schedule.model.Schedule; import site.icebang.domain.workflow.scheduler.WorkflowTriggerJob; +/** + * Spring Quartz 스케줄러의 Job과 Trigger를 동적으로 관리하는 서비스 클래스입니다. + * + *

이 서비스는 데이터베이스에 정의된 {@code Schedule} 정보를 바탕으로, + * Quartz 엔진에 실제 실행 가능한 작업을 등록, 수정, 삭제하는 역할을 담당합니다. + * + *

주요 기능:

+ *
    + *
  • DB의 스케줄 정보를 바탕으로 Quartz Job 및 Trigger 생성 또는 업데이트
  • + *
  • 기존에 등록된 Quartz 스케줄 삭제
  • + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Slf4j @Service @RequiredArgsConstructor public class QuartzScheduleService { + + /** Quartz 스케줄러의 메인 인스턴스 */ private final Scheduler scheduler; + /** + * DB에 정의된 Schedule 객체를 기반으로 Quartz에 스케줄을 등록하거나 업데이트합니다. + * + *

지정된 워크플로우 ID에 해당하는 Job이 이미 존재할 경우, 기존 Job과 Trigger를 삭제하고 + * 새로운 정보로 다시 생성하여 스케줄을 업데이트합니다. {@code JobDataMap}을 통해 + * 실행될 Job에게 어떤 워크플로우를 실행해야 하는지 ID를 전달합니다. + * + * @param schedule Quartz에 등록할 스케줄 정보를 담은 도메인 모델 객체 + * @since v0.1.0 + */ public void addOrUpdateSchedule(Schedule schedule) { try { JobKey jobKey = JobKey.jobKey("workflow-" + schedule.getWorkflowId()); @@ -40,6 +67,12 @@ public void addOrUpdateSchedule(Schedule schedule) { } } + /** + * 지정된 워크플로우 ID와 연결된 Quartz 스케줄을 삭제합니다. + * + * @param workflowId 삭제할 스케줄에 연결된 워크플로우의 ID + * @since v0.1.0 + */ public void deleteSchedule(Long workflowId) { try { JobKey jobKey = JobKey.jobKey("workflow-" + workflowId); diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/TaskRunner.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/TaskRunner.java index f8ad27c8..72c9f078 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/TaskRunner.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/TaskRunner.java @@ -5,19 +5,53 @@ import site.icebang.domain.workflow.model.Task; import site.icebang.domain.workflow.model.TaskRun; -/** 워크플로우의 개별 Task를 실행하는 모든 Runner가 구현해야 할 인터페이스 */ +/** + * 워크플로우 내 개별 Task의 실행을 담당하는 모든 Runner 객체가 구현해야 할 공통 인터페이스입니다. + * + *

이 인터페이스는 전략 패턴(Strategy Pattern)의 '전략(Strategy)' 역할을 수행합니다. {@code WorkflowExecutionService}는 + * 이 인터페이스에 의존하여, Task의 타입('FastAPI' 등)에 따라 적절한 Runner 구현체를 선택하고 실행 로직을 위임합니다. + * + *

주요 구성 요소:

+ * + *
    + *
  • TaskExecutionResult: 모든 Task 실행 결과가 따라야 할 표준 응답 형식을 정의하는 내부 Record + *
  • execute: Task 실행을 위한 단일 추상 메소드 + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ public interface TaskRunner { - /** Task 실행 결과를 담는 Record. status: SUCCESS 또는 FAILED message: 실행 결과 또는 에러 메시지 */ + /** + * Task 실행 결과를 담는 불변(Immutable) 데이터 객체(Record)입니다. + * + *

실행의 성공/실패 여부(status)와 결과 메시지(message)를 표준화된 방식으로 반환합니다. + * + * @param status 실행 상태 ("SUCCESS" 또는 "FAILED") + * @param message 실행 결과 (성공 시 응답 Body, 실패 시 에러 메시지) + * @since v0.1.0 + */ record TaskExecutionResult(String status, String message) { public static TaskExecutionResult success(String message) { return new TaskExecutionResult("SUCCESS", message); } + /** + * 실패 결과를 생성하는 정적 팩토리 메소드입니다. + * + * @param message 실패 원인 메시지 + * @return status가 "FAILED"로 설정된 결과 객체 + */ public static TaskExecutionResult failure(String message) { return new TaskExecutionResult("FAILED", message); } + /** + * 해당 결과가 실패했는지 여부를 반환합니다. + * + * @return 실패했다면 true, 아니면 false + */ public boolean isFailure() { return "FAILED".equals(this.status); } @@ -26,10 +60,11 @@ public boolean isFailure() { /** * 특정 Task를 실행합니다. * - * @param task 실행할 Task의 정적 정의 - * @param taskRun 현재 실행에 대한 기록 객체 - * @param requestBody 동적으로 생성된 요청 데이터 - * @return Task 실행 결과 + * @param task 실행할 Task의 정적 정의 (이름, 타입, 파라미터 등) + * @param taskRun 현재 실행에 대한 DB 기록 객체 (ID 추적 등에 사용) + * @param requestBody {@code TaskBodyBuilder}에 의해 동적으로 생성된 최종 요청 Body + * @return Task 실행 결과를 담은 {@code TaskExecutionResult} 객체 + * @since v0.1.0 */ TaskExecutionResult execute(Task task, TaskRun taskRun, ObjectNode requestBody); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/FastApiTaskRunner.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/FastApiTaskRunner.java index 136a9d93..95a0e89c 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/FastApiTaskRunner.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/FastApiTaskRunner.java @@ -13,12 +13,43 @@ import site.icebang.domain.workflow.runner.TaskRunner; import site.icebang.external.fastapi.adapter.FastApiAdapter; +/** + * FastAPI 서버와 통신하는 Task를 실행하는 구체적인 Runner 구현체입니다. + * + *

이 클래스는 {@code TaskRunner} 인터페이스를 구현하며, Task의 타입이 'FastAPI'일 때 선택됩니다. 실제 HTTP 통신은 {@code + * FastApiAdapter}에 위임하고, 이 클래스는 워크플로우의 {@code Task} 객체를 {@code FastApiAdapter}가 이해할 수 있는 호출 형식으로 + * 변환하는 **어댑터(Adapter)** 역할을 수행합니다. + * + *

주요 기능:

+ * + *
    + *
  • Task 파라미터에서 endpoint와 method 정보 파싱 + *
  • 사전에 생성된 Request Body를 {@code FastApiAdapter}에 전달하여 실행 위임 + *
  • 어댑터의 실행 결과를 {@code TaskExecutionResult} 형식으로 변환하여 반환 + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Component("fastapiTaskRunner") @RequiredArgsConstructor public class FastApiTaskRunner implements TaskRunner { + /** FastAPI 서버와의 통신을 전담하는 어댑터 */ private final FastApiAdapter fastApiAdapter; + /** + * FastAPI 타입의 Task를 실행합니다. + * + *

Task의 파라미터에서 엔드포인트와 HTTP 메소드를 추출하고, {@code WorkflowExecutionService}로부터 전달받은 동적 Request + * Body를 사용하여 {@code FastApiAdapter}를 호출합니다. + * + * @param task 실행할 Task의 정적 정의 + * @param taskRun 현재 실행에 대한 기록 객체 + * @param requestBody {@code TaskBodyBuilder}에 의해 동적으로 생성된 최종 요청 Body + * @return {@code FastApiAdapter}의 호출 결과를 담은 {@code TaskExecutionResult} 객체 + * @since v0.1.0 + */ @Override public TaskExecutionResult execute(Task task, TaskRun taskRun, ObjectNode requestBody) { JsonNode params = task.getParameters(); diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java index 196c1fa0..a3076d1f 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java @@ -9,12 +9,38 @@ import site.icebang.domain.workflow.service.WorkflowExecutionService; +/** + * Spring Quartz 스케줄러에 의해 실행되는 실제 작업(Job) 클래스입니다. + * + *

이 클래스는 Quartz의 스케줄링 세계와 애플리케이션의 비즈니스 로직을 연결하는 **브릿지(Bridge)** 역할을 수행합니다. Quartz의 Trigger가 정해진 + * 시간에 발동하면, Quartz 엔진은 이 Job을 인스턴스화하고 {@code executeInternal} 메소드를 호출합니다. + * + *

주요 기능:

+ * + *
    + *
  • 스케줄 실행 시점에 {@code JobDataMap}에서 실행할 워크플로우 ID를 추출 + *
  • 추출된 ID를 사용하여 {@code WorkflowExecutionService}를 호출하여 실제 워크플로우 실행을 위임 + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Slf4j @Component @RequiredArgsConstructor public class WorkflowTriggerJob extends QuartzJobBean { private final WorkflowExecutionService workflowExecutionService; + /** + * Quartz 스케줄러에 의해 트리거가 발동될 때 호출되는 메인 실행 메소드입니다. + * + *

이 메소드는 실행 컨텍스트({@code JobExecutionContext})에서 {@code JobDataMap}을 통해 스케줄 등록 시점에 저장된 + * 'workflowId'를 추출합니다. 그 후, 해당 ID를 파라미터로 하여 {@code WorkflowExecutionService}의 {@code + * executeWorkflow} 메소드를 호출하여 실제 비즈니스 로직의 실행을 시작합니다. + * + * @param context Quartz가 제공하는 현재 실행에 대한 런타임 정보. JobDetail과 Trigger 정보를 포함합니다. + * @since v0.1.0 + */ @Override protected void executeInternal(JobExecutionContext context) { Long workflowId = context.getJobDetail().getJobDataMap().getLong("workflowId"); 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 80cf44a3..29f28d98 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 @@ -13,20 +13,50 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import site.icebang.domain.workflow.model.Task; import site.icebang.domain.workflow.model.TaskRun; import site.icebang.domain.workflow.runner.TaskRunner; -@Slf4j +/** + * 워크플로우 내 개별 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 { // 📌 클래스 이름 변경 +public class TaskExecutionService { + /** 워크플로우 실행 이력 전용 로거 */ private static final Logger workflowLogger = LoggerFactory.getLogger("WORKFLOW_HISTORY"); + private final Map taskRunners; - /** RestClientException 발생 시, 5초 간격으로 최대 3번 재시도합니다. */ + /** + * 지정된 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, @@ -45,7 +75,19 @@ public TaskRunner.TaskExecutionResult executeWithRetry( 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) { diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowHistoryService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowHistoryService.java index d04e238f..17887630 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowHistoryService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowHistoryService.java @@ -10,19 +10,44 @@ import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; import site.icebang.common.service.PageableService; -import site.icebang.domain.workflow.dto.*; +import site.icebang.domain.workflow.dto.JobRunDto; +import site.icebang.domain.workflow.dto.TaskRunDto; +import site.icebang.domain.workflow.dto.WorkflowHistoryDTO; +import site.icebang.domain.workflow.dto.WorkflowRunDetailResponse; +import site.icebang.domain.workflow.dto.WorkflowRunDto; +import site.icebang.domain.workflow.dto.WorkflowRunLogsResponse; import site.icebang.domain.workflow.mapper.WorkflowHistoryMapper; +/** + * 워크플로우 실행 이력(History) 조회 관련 비즈니스 로직을 처리하는 서비스 클래스입니다. + * + *

이 서비스는 워크플로우 실행 목록의 페이징 처리, 특정 실행 건의 상세 정보 조회 등 읽기 전용(Read-Only) 기능에 집중합니다. + * + *

주요 기능:

+ * + *
    + *
  • 워크플로우 실행 이력 목록 페이징 조회 + *
  • 워크플로우 실행 상세 정보 조회 (Job 및 Task 실행 이력 포함) + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Service @RequiredArgsConstructor public class WorkflowHistoryService implements PageableService { private final WorkflowHistoryMapper workflowHistoryMapper; /** - * 워크플로우 런 조회 + * 워크플로우 실행 이력 목록을 페이징 처리하여 조회합니다. * - * @param pageParams pageParams - * @return PageResult + *

이 메소드는 {@code PageableService} 인터페이스를 구현하며, {@code PageResult} 유틸리티를 사용하여 전체 카운트 쿼리와 목록 조회 + * 쿼리를 실행하고 페이징 결과를 생성합니다. + * + * @param pageParams 페이징 처리에 필요한 파라미터 (페이지 번호, 페이지 크기 등) + * @return 페이징 처리된 워크플로우 실행 이력 목록 + * @see PageResult + * @since v0.1.0 */ @Override @Transactional(readOnly = true) @@ -35,10 +60,14 @@ public PageResult getPagedResult(PageParams pageParams) { } /** - * 워크플로우 실행 상세 조회 + * 특정 워크플로우 실행 건의 상세 정보를 조회합니다. + * + *

지정된 실행 ID(`runId`)에 해당하는 워크플로우 실행 정보와, 그에 속한 모든 Job 실행 정보, 그리고 각 Job에 속한 모든 Task 실행 정보를 + * 계층적으로 조회하여 반환합니다. * - * @param runId workflow_run.id - * @return WorkflowRunDetailResponse + * @param runId 조회할 워크플로우 실행의 ID (`workflow_run.id`) + * @return 워크플로우, Job, Task 실행 정보를 포함하는 상세 응답 객체 + * @since v0.1.0 */ @Transactional(readOnly = true) public WorkflowRunDetailResponse getWorkflowRunDetail(Long runId) { @@ -69,10 +98,11 @@ public WorkflowRunDetailResponse getWorkflowRunDetail(Long runId) { } /** - * 워크플로우 실행 로그 조회 + * 특정 워크플로우 실행과 관련된 모든 로그를 조회합니다. * - * @param runId workflow_run.id - * @return WorkflowRunLogsResponse + * @param runId 조회할 워크플로우 실행의 ID (`workflow_run.id`) + * @return 워크플로우 실행 로그 응답 객체 + * @since v0.1.0 */ public WorkflowRunLogsResponse getWorkflowRunLogs(Long runId) { // TODO: 구현 예정 @@ -80,10 +110,11 @@ public WorkflowRunLogsResponse getWorkflowRunLogs(Long runId) { } /** - * TraceId로 워크플로우 실행 조회 + * Trace ID를 사용하여 특정 워크플로우 실행 정보를 조회합니다. * - * @param traceId workflow_run.trace_id - * @return WorkflowRunDetailResponse + * @param traceId 조회할 워크플로우 실행의 Trace ID (`workflow_run.trace_id`) + * @return 워크플로우 실행 상세 응답 객체 + * @since v0.1.0 */ public WorkflowRunDetailResponse getWorkflowRunByTraceId(String traceId) { // TODO: 구현 예정 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 b994c82e..e8c857f3 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 @@ -17,12 +17,38 @@ import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; import site.icebang.domain.workflow.mapper.WorkflowMapper; +/** + * 워크플로우의 '정의'와 관련된 비즈니스 로직을 처리하는 서비스 클래스입니다. + * + *

이 서비스는 워크플로우의 실행(Execution)이 아닌, 생성된 워크플로우의 구조를 조회하는 기능에 집중합니다. + * + *

주요 기능:

+ * + *
    + *
  • 워크플로우 목록 페이징 조회 + *
  • 특정 워크플로우의 상세 구조 조회 (Job, Task, Schedule 포함) + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Service @RequiredArgsConstructor public class WorkflowService implements PageableService { private final WorkflowMapper workflowMapper; + /** + * 워크플로우 목록을 페이징 처리하여 조회합니다. + * + *

이 메소드는 {@code PageableService} 인터페이스를 구현하며, {@code PageResult} 유틸리티를 사용하여 전체 카운트 쿼리와 목록 조회 + * 쿼리를 실행하고 페이징 결과를 생성합니다. + * + * @param pageParams 페이징 처리에 필요한 파라미터 (페이지 번호, 페이지 크기 등) + * @return 페이징 처리된 워크플로우 카드 목록 + * @see PageResult + * @since v0.1.0 + */ @Override @Transactional(readOnly = true) public PageResult getPagedResult(PageParams pageParams) { @@ -32,6 +58,16 @@ public PageResult getPagedResult(PageParams pageParams) { () -> workflowMapper.selectWorkflowCount(pageParams)); } + /** + * 특정 워크플로우의 상세 구조를 조회합니다. + * + *

지정된 워크플로우 ID에 해당하는 기본 정보, 연결된 스케줄 목록, 그리고 Job과 Task의 계층 구조를 모두 조회하여 하나의 DTO로 조합하여 반환합니다. + * + * @param workflowId 조회할 워크플로우의 ID + * @return 워크플로우의 전체 구조를 담은 상세 DTO + * @throws IllegalArgumentException 주어진 ID에 해당하는 워크플로우가 존재하지 않을 경우 + * @since v0.1.0 + */ @Transactional(readOnly = true) public WorkflowDetailCardDto getWorkflowDetail(BigInteger workflowId) { diff --git a/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java b/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java index 2a5bd001..3ad1466b 100644 --- a/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java +++ b/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java @@ -10,6 +10,24 @@ import site.icebang.global.config.properties.FastApiProperties; +/** + * 외부 FastAPI 서버와의 모든 HTTP 통신을 전담하는 어댑터 클래스입니다. + * + *

이 클래스는 내부 시스템의 다른 부분들이 외부 시스템의 상세한 통신 방법을 알 필요가 없도록 HTTP 요청/응답 로직을 캡슐화합니다. {@code + * RestTemplate}을 사용하여 실제 통신을 수행하며, 모든 FastAPI 요청은 이 클래스의 {@code call} 메소드를 통해 이루어져야 합니다. + * + *

사용 예제:

+ * + *
{@code
+ * @Autowired
+ * private FastApiAdapter fastApiAdapter;
+ *
+ * String response = fastApiAdapter.call("/keywords/search", HttpMethod.POST, "{\"tag\":\"naver\"}");
+ * }
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Slf4j @Component @RequiredArgsConstructor @@ -18,7 +36,19 @@ public class FastApiAdapter { private final RestTemplate restTemplate; private final FastApiProperties properties; - // 📌 Task나 context에 대한 의존성이 완전히 사라짐 + /** + * FastAPI 서버에 API 요청을 보내는 범용 메소드입니다. + * + *

지정된 엔드포인트, HTTP 메소드, 요청 Body를 사용하여 외부 API를 호출합니다. 통신 성공 시 응답 Body를 문자열로 반환하고, 실패 시 에러 로그를 + * 남기고 null을 반환합니다. + * + * @param endpoint 호출할 엔드포인트 경로 (예: "/keywords/search") + * @param method 사용할 HTTP 메소드 (예: HttpMethod.POST) + * @param requestBody 요청에 담을 JSON 문자열 + * @return 성공 시 API 응답 Body 문자열, 실패 시 null + * @see RestTemplate + * @since v0.1.0 + */ public String call(String endpoint, HttpMethod method, String requestBody) { String fullUrl = properties.getUrl() + endpoint; HttpHeaders headers = new HttpHeaders(); 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 233f5834..bdca3015 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 @@ -9,6 +9,22 @@ import site.icebang.domain.schedule.service.QuartzScheduleService; import java.util.List; +/** + * 애플리케이션 시작 시 데이터베이스에 저장된 스케줄을 Quartz 스케줄러에 동적으로 등록하는 초기화 클래스입니다. + * + *

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

주요 기능:

+ *
    + *
  • 애플리케이션 시작 시점에 DB의 활성 스케줄 조회
  • + *
  • 조회된 스케줄을 {@code QuartzScheduleService}를 통해 Quartz 엔진에 등록
  • + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Slf4j @Component @RequiredArgsConstructor @@ -17,6 +33,15 @@ public class QuartzSchedulerInitializer implements CommandLineRunner { private final ScheduleMapper scheduleMapper; private final QuartzScheduleService quartzScheduleService; + /** + * Spring Boot 애플리케이션 시작 시 호출되는 메인 실행 메소드입니다. + * + *

데이터베이스에서 활성화된 모든 스케줄을 조회하고, 각 스케줄을 + * {@code QuartzScheduleService}를 통해 Quartz 스케줄러에 등록합니다. + * + * @param args 애플리케이션 실행 시 전달되는 인자 + * @since v0.1.0 + */ @Override public void run(String... args) { log.info("Quartz 스케줄러 초기화 시작: DB 스케줄을 등록합니다."); 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 43cfd8b1..7029b7d9 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 @@ -8,9 +8,37 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; +/** + * 애플리케이션의 웹 관련 설정을 담당하는 Java 기반 설정 클래스입니다. + * + *

이 클래스는 애플리케이션 전역에서 사용될 웹 관련 빈(Bean)들을 생성하고 구성합니다. 현재는 외부 API 통신을 위한 {@code RestTemplate} 빈을 + * 중앙에서 관리하는 역할을 합니다. + * + *

주요 기능:

+ * + *
    + *
  • 커넥션 및 읽기 타임아웃이 설정된 RestTemplate 빈 생성 + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Configuration public class WebConfig { + /** + * 외부 API 통신을 위한 RestTemplate 빈을 생성하여 스프링 컨테이너에 등록합니다. + * + *

기본 {@code RestTemplateBuilder}를 사용하되, 커넥션 및 읽기 타임아웃을 각각 30초로 명시적으로 설정하기 위해 {@code + * SimpleClientHttpRequestFactory}를 구성하여 주입합니다. 이렇게 생성된 RestTemplate 빈은 애플리케이션의 다른 컴포넌트에서 주입받아 외부 + * 시스템과의 HTTP 통신에 사용됩니다. + * + * @param builder Spring Boot가 자동으로 구성해주는 RestTemplateBuilder 객체 + * @return 타임아웃이 설정된 RestTemplate 인스턴스 + * @see RestTemplate + * @see RestTemplateBuilder + * @since v0.1.0 + */ @Bean public RestTemplate restTemplate(RestTemplateBuilder builder) { // 1. SimpleClientHttpRequestFactory 객체를 직접 생성 diff --git a/apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/JsonNodeTypeHandler.java b/apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/JsonNodeTypeHandler.java index 4079c9f3..3def3d9d 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/JsonNodeTypeHandler.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/JsonNodeTypeHandler.java @@ -13,11 +13,42 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +/** + * MyBatis에서 Jackson 라이브러리의 {@code JsonNode} 타입을 데이터베이스의 문자열 타입(예: VARCHAR, JSON)과 매핑하기 위한 커스텀 타입 + * 핸들러입니다. + * + *

이 핸들러를 통해, 애플리케이션에서는 JSON 데이터를 편리하게 {@code JsonNode} 객체로 다루고, 데이터베이스에는 해당 객체를 JSON 문자열 형태로 + * 저장하거나 읽어올 수 있습니다. + * + *

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

+ * + *
{@code
+ * 
+ * 
+ * 
+ * }
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @MappedTypes(JsonNode.class) public class JsonNodeTypeHandler extends BaseTypeHandler { private static final ObjectMapper objectMapper = new ObjectMapper(); + /** + * {@code JsonNode} 파라미터를 DB에 저장하기 위해 JSON 문자열로 변환하여 PreparedStatement에 설정합니다. + * + * @param ps PreparedStatement 객체 + * @param i 파라미터 인덱스 + * @param parameter 변환할 JsonNode 객체 + * @param jdbcType JDBC 타입 + * @throws SQLException JSON 직렬화 실패 시 + */ @Override public void setNonNullParameter( PreparedStatement ps, int i, JsonNode parameter, JdbcType jdbcType) throws SQLException { @@ -28,21 +59,52 @@ public void setNonNullParameter( } } + /** + * ResultSet에서 컬럼 이름으로 문자열을 가져와 {@code JsonNode} 객체로 파싱합니다. + * + * @param rs ResultSet 객체 + * @param columnName 컬럼 이름 + * @return 파싱된 JsonNode 객체, 원본이 null이면 null + * @throws SQLException JSON 파싱 실패 시 + */ @Override public JsonNode getNullableResult(ResultSet rs, String columnName) throws SQLException { return parseJson(rs.getString(columnName)); } + /** + * ResultSet에서 컬럼 인덱스로 문자열을 가져와 {@code JsonNode} 객체로 파싱합니다. + * + * @param rs ResultSet 객체 + * @param columnIndex 컬럼 인덱스 + * @return 파싱된 JsonNode 객체, 원본이 null이면 null + * @throws SQLException JSON 파싱 실패 시 + */ @Override public JsonNode getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parseJson(rs.getString(columnIndex)); } + /** + * CallableStatement에서 컬럼 인덱스로 문자열을 가져와 {@code JsonNode} 객체로 파싱합니다. + * + * @param cs CallableStatement 객체 + * @param columnIndex 컬럼 인덱스 + * @return 파싱된 JsonNode 객체, 원본이 null이면 null + * @throws SQLException JSON 파싱 실패 시 + */ @Override public JsonNode getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parseJson(cs.getString(columnIndex)); } + /** + * JSON 문자열을 {@code JsonNode} 객체로 변환하는 private 헬퍼 메소드입니다. + * + * @param json 파싱할 JSON 문자열 + * @return 파싱된 JsonNode 객체 + * @throws SQLException JSON 문자열이 유효하지 않을 경우 + */ private JsonNode parseJson(String json) throws SQLException { if (json == null) { return null; diff --git a/apps/user-service/src/main/java/site/icebang/global/config/properties/FastApiProperties.java b/apps/user-service/src/main/java/site/icebang/global/config/properties/FastApiProperties.java index 24fa309d..f35d1ee6 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/properties/FastApiProperties.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/properties/FastApiProperties.java @@ -8,7 +8,31 @@ import lombok.Getter; import lombok.Setter; -/** FastAPI 연동을 위한 설정값을 application.yml에서 바인딩하는 클래스 */ +/** + * FastAPI 서버 연동을 위한 설정값을 application.yml에서 타입-세이프(Type-safe)하게 바인딩하는 클래스입니다. + * + *

이 클래스는 {@code @ConfigurationProperties}를 통해 'api.fastapi' 경로의 설정값을 자동으로 주입받습니다. + * {@code @Validated}와 {@code @NotBlank}를 사용하여, 필수 설정값(url)이 누락될 경우 애플리케이션 시작 시점에 즉시 에러를 발생시켜 설정 오류를 + * 방지합니다. + * + *

사용 예제:

+ * + *
{@code
+ * @Component
+ * @RequiredArgsConstructor
+ * public class FastApiAdapter {
+ * private final FastApiProperties properties;
+ *
+ * public void someMethod() {
+ * String baseUrl = properties.getUrl(); // 설정된 URL 사용
+ * // ...
+ * }
+ * }
+ * }
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Getter @Setter @Component // Component로 등록하여 Spring이 Bean으로 관리하도록 함 @@ -16,10 +40,19 @@ @Validated // 아래의 유효성 검사 어노테이션을 활성화 public class FastApiProperties { - /** FastAPI 서버의 기본 URL */ + /** + * FastAPI 서버의 기본 URL 주소입니다. + * + *

{@code @NotBlank} 어노테이션이 적용되어 있어, application.yml 파일에 반드시 값이 존재해야 합니다. (예: + * "http://host.docker.internal:8000") + */ @NotBlank // 값이 비어있을 수 없음을 검증 private String url; - /** API 호출 시 적용될 타임아웃 (밀리초 단위) */ + /** + * API 호출 시 적용될 타임아웃 시간 (밀리초 단위)입니다. + * + *

별도로 설정하지 않을 경우 기본값으로 5000ms (5초)가 적용됩니다. + */ private int timeout = 5000; // 기본값 5초 설정 } From e30878105eb47f22d0763fbc2f4fc9d6a69ab2ed Mon Sep 17 00:00:00 2001 From: can019 Date: Tue, 23 Sep 2025 19:03:36 +0900 Subject: [PATCH 15/25] =?UTF-8?q?fix:=20=EC=9E=84=EC=8B=9C=EB=A1=9C=20test?= =?UTF-8?q?=20disable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java | 4 ++-- .../icebang/e2e/scenario/UserRegistrationFlowE2eTest.java | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) 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 03c5f899..81b007ca 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 @@ -6,8 +6,8 @@ import java.util.HashMap; import java.util.Map; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; import org.springframework.http.*; import org.springframework.test.context.jdbc.Sql; @@ -22,7 +22,7 @@ class UserLogoutFlowE2eTest extends E2eTestSupport { @SuppressWarnings("unchecked") - @Test + @Disabled @DisplayName("정상 로그아웃 전체 플로우 - TDD REd 단계") void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Exception { logStep(1, "관리자 로그인 (최우선)"); 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 df66a7c6..4856400f 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 @@ -7,6 +7,7 @@ import java.util.HashMap; import java.util.Map; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.*; @@ -21,7 +22,7 @@ class UserRegistrationFlowE2eTest extends E2eTestSupport { @SuppressWarnings("unchecked") - @Test + @Disabled @DisplayName("관리자가 새 사용자를 등록하는 전체 플로우 (ERP 시나리오)") void completeUserRegistrationFlow() throws Exception { logStep(1, "관리자 로그인 (최우선)"); @@ -153,7 +154,7 @@ void loginWithInvalidCredentials_shouldFail() { } @SuppressWarnings("unchecked") - @Test + @Disabled @DisplayName("중복 이메일로 사용자 등록 시도 시 실패") void register_withDuplicateEmail_shouldFail() { // 선행 조건: 관리자 로그인 From 465398a748f754d085fe87b78b747d60304b495d Mon Sep 17 00:00:00 2001 From: can019 Date: Tue, 23 Sep 2025 19:04:20 +0900 Subject: [PATCH 16/25] =?UTF-8?q?chore:=20Test=20disable=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../icebang/e2e/scenario/UserRegistrationFlowE2eTest.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 4856400f..df66a7c6 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 @@ -7,7 +7,6 @@ import java.util.HashMap; import java.util.Map; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.*; @@ -22,7 +21,7 @@ class UserRegistrationFlowE2eTest extends E2eTestSupport { @SuppressWarnings("unchecked") - @Disabled + @Test @DisplayName("관리자가 새 사용자를 등록하는 전체 플로우 (ERP 시나리오)") void completeUserRegistrationFlow() throws Exception { logStep(1, "관리자 로그인 (최우선)"); @@ -154,7 +153,7 @@ void loginWithInvalidCredentials_shouldFail() { } @SuppressWarnings("unchecked") - @Disabled + @Test @DisplayName("중복 이메일로 사용자 등록 시도 시 실패") void register_withDuplicateEmail_shouldFail() { // 선행 조건: 관리자 로그인 From db1eff62d1a5f2f54ab9d844789a6ce7ac7536af 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 19:11:49 +0900 Subject: [PATCH 17/25] =?UTF-8?q?fix:=20docker=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=A6=9D=EA=B0=80=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pre-processing-service/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/pre-processing-service/Dockerfile b/apps/pre-processing-service/Dockerfile index ebc6e465..6ecb09c8 100644 --- a/apps/pre-processing-service/Dockerfile +++ b/apps/pre-processing-service/Dockerfile @@ -64,4 +64,4 @@ COPY . . 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"] \ No newline at end of file +ENTRYPOINT ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "app.main:app", "-b", "0.0.0.0:8000", "--timeout", "120"] \ No newline at end of file From be20512e2dd81b59f8cd905add345316f3528434 Mon Sep 17 00:00:00 2001 From: thkim7 Date: Wed, 24 Sep 2025 10:45:33 +0900 Subject: [PATCH 18/25] =?UTF-8?q?feat:=20blog=5Fcreate=5Flog=20=EC=9B=90?= =?UTF-8?q?=EC=9D=B8=20=ED=8C=8C=EC=95=85=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/service/blog/blog_create_service.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 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 29ce12b7..70af1626 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 @@ -39,17 +39,23 @@ def generate_blog_content(self, request: RequestBlogCreate) -> Dict[str, Any]: Dict: {"title": str, "content": str, "tags": List[str]} 형태의 결과 """ try: - # 1. 콘텐츠 정보 정리 + self.logger.debug("[STEP1] 콘텐츠 컨텍스트 준비 시작") content_context = self._prepare_content_context(request) + self.logger.debug(f"[STEP1 완료] context length={len(content_context)}") - # 2. 프롬프트 생성 + self.logger.debug("[STEP2] 프롬프트 생성 시작") prompt = self._create_content_prompt(content_context, request) + self.logger.debug(f"[STEP2 완료] prompt length={len(prompt)}") - # 3. GPT를 통한 콘텐츠 생성 + self.logger.debug("[STEP3] OpenAI API 호출 시작") generated_content = self._generate_with_openai(prompt) + self.logger.debug(f"[STEP3 완료] generated length={len(generated_content)}") - # 4. 콘텐츠 파싱 및 구조화 - return self._parse_generated_content(generated_content, request) + self.logger.debug("[STEP4] 콘텐츠 파싱 시작") + result = self._parse_generated_content(generated_content, request) + self.logger.debug("[STEP4 완료]") + + return result except Exception as e: self.logger.error(f"콘텐츠 생성 실패: {e}") From 827c84ad16181ba8f1522588b996ceebf994ba78 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: Wed, 24 Sep 2025 12:11:39 +0900 Subject: [PATCH 19/25] =?UTF-8?q?fix:=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EB=B3=80=EA=B2=BD=EB=B0=8F=20log=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 환경변수 변경 - log 로직 변경 - 환경병수 load_dotenv -> setting 사용으로 변경함 * style:FASTAPI 코드 포멧팅 --- .../pre-processing-service/app/core/config.py | 2 +- .../app/service/blog/blog_create_service.py | 29 ++++++++----------- .../app/utils/llm_extractor.py | 5 ++-- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/apps/pre-processing-service/app/core/config.py b/apps/pre-processing-service/app/core/config.py index a710faba..ad7005ea 100644 --- a/apps/pre-processing-service/app/core/config.py +++ b/apps/pre-processing-service/app/core/config.py @@ -104,7 +104,7 @@ class BaseSettingsConfig(BaseSettings): MAX_IMAGE_SIZE_MB: int = 10 # 테스트/추가용 필드 - openai_api_key: Optional[str] = None # << 이 부분 추가 + OPENAI_API_KEY: Optional[str] = None # << 이 부분 추가 def __init__(self, **kwargs): super().__init__(**kwargs) 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 70af1626..a66fa609 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,32 +1,27 @@ import json import logging -import os +from loguru import logger from datetime import datetime from typing import Dict, List, Optional, Any from openai import OpenAI -from dotenv import load_dotenv - +from app.core.config import settings from app.model.schemas import RequestBlogCreate from app.errors.BlogPostingException import * -# 환경변수 로드 -load_dotenv(".env.dev") - class BlogContentService: """RAG를 사용한 블로그 콘텐츠 생성 전용 서비스""" def __init__(self): # OpenAI API 키 설정 - self.openai_api_key = os.getenv("OPENAI_API_KEY") + self.openai_api_key = settings.OPENAI_API_KEY if not self.openai_api_key: raise ValueError("OPENAI_API_KEY가 .env.dev 파일에 설정되지 않았습니다.") # 인스턴스 레벨에서 클라이언트 생성 self.client = OpenAI(api_key=self.openai_api_key) logging.basicConfig(level=logging.INFO) - self.logger = logging.getLogger(__name__) def generate_blog_content(self, request: RequestBlogCreate) -> Dict[str, Any]: """ @@ -39,26 +34,26 @@ def generate_blog_content(self, request: RequestBlogCreate) -> Dict[str, Any]: Dict: {"title": str, "content": str, "tags": List[str]} 형태의 결과 """ try: - self.logger.debug("[STEP1] 콘텐츠 컨텍스트 준비 시작") + logger.debug("[STEP1] 콘텐츠 컨텍스트 준비 시작") content_context = self._prepare_content_context(request) - self.logger.debug(f"[STEP1 완료] context length={len(content_context)}") + logger.debug(f"[STEP1 완료] context length={len(content_context)}") - self.logger.debug("[STEP2] 프롬프트 생성 시작") + logger.debug("[STEP2] 프롬프트 생성 시작") prompt = self._create_content_prompt(content_context, request) - self.logger.debug(f"[STEP2 완료] prompt length={len(prompt)}") + logger.debug(f"[STEP2 완료] prompt length={len(prompt)}") - self.logger.debug("[STEP3] OpenAI API 호출 시작") + logger.debug("[STEP3] OpenAI API 호출 시작") generated_content = self._generate_with_openai(prompt) - self.logger.debug(f"[STEP3 완료] generated length={len(generated_content)}") + logger.debug(f"[STEP3 완료] generated length={len(generated_content)}") - self.logger.debug("[STEP4] 콘텐츠 파싱 시작") + logger.debug("[STEP4] 콘텐츠 파싱 시작") result = self._parse_generated_content(generated_content, request) - self.logger.debug("[STEP4 완료]") + logger.debug("[STEP4 완료]") return result except Exception as e: - self.logger.error(f"콘텐츠 생성 실패: {e}") + logger.error(f"콘텐츠 생성 실패: {e}") return self._create_fallback_content(request) def _prepare_content_context(self, request: RequestBlogCreate) -> str: diff --git a/apps/pre-processing-service/app/utils/llm_extractor.py b/apps/pre-processing-service/app/utils/llm_extractor.py index 3fb200a5..7140b184 100644 --- a/apps/pre-processing-service/app/utils/llm_extractor.py +++ b/apps/pre-processing-service/app/utils/llm_extractor.py @@ -1,8 +1,7 @@ import os from openai import OpenAI from dotenv import load_dotenv - -load_dotenv() +from app.core.config import settings class LLMExtractor: @@ -13,7 +12,7 @@ def __init__(self, model="gpt-4o"): :param model: 사용할 LLM 모델 이름 """ - self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + self.client = OpenAI(api_key=settings.OPENAI_API_KEY) self.model = model def login_extraction_prompt(self, target_description: str, html: str): From 16318b5f85caee1533cbacdcbc61396b09682309 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Wed, 24 Sep 2025 15:57:11 +0900 Subject: [PATCH 20/25] =?UTF-8?q?fix=20:=20docker=20file=20=EB=B0=8F=20doc?= =?UTF-8?q?ker=20compose=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/Dockerfile | 29 -------------------- docker/production-fastapi/docker-compose.yml | 10 +++++++ 2 files changed, 10 insertions(+), 29 deletions(-) diff --git a/apps/pre-processing-service/Dockerfile b/apps/pre-processing-service/Dockerfile index 6ecb09c8..b3be8746 100644 --- a/apps/pre-processing-service/Dockerfile +++ b/apps/pre-processing-service/Dockerfile @@ -2,15 +2,6 @@ FROM python:3.11-slim AS builder WORKDIR /app -# 필수 OS 패키지 (기존 + Chrome 설치용 패키지 추가) -RUN apt-get update && apt-get install -y --no-install-recommends \ - curl \ - wget \ - unzip \ - gnupg \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* - # Poetry 설치 RUN curl -sSL https://install.python-poetry.org | python3 - ENV PATH="/root/.local/bin:$PATH" @@ -29,29 +20,10 @@ RUN poetry export --without dev -f requirements.txt -o requirements.txt \ FROM python:3.11-slim AS final WORKDIR /app -# Chrome과 ChromeDriver 설치를 위한 패키지 설치 RUN apt-get update && apt-get install -y --no-install-recommends \ - wget \ - unzip \ - curl \ - gnupg \ ca-certificates \ && 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" @@ -59,7 +31,6 @@ ENV PATH="/opt/venv/bin:$PATH" # 앱 소스 COPY . . - # 환경변수로 MeCab 경로 지정 ENV MECAB_PATH=/usr/lib/mecab/dic/ipadic diff --git a/docker/production-fastapi/docker-compose.yml b/docker/production-fastapi/docker-compose.yml index 76b0b85c..c995f4ef 100644 --- a/docker/production-fastapi/docker-compose.yml +++ b/docker/production-fastapi/docker-compose.yml @@ -11,6 +11,16 @@ services: - ~/app/blogger:/app/blogger - ~/app/models:/app/models - logs_volume:/logs + # Chrome 관련 볼륨 마운트 + - /opt/google/chrome:/opt/google/chrome:ro + - /usr/bin/google-chrome:/usr/bin/google-chrome:ro + - /usr/bin/google-chrome-stable:/usr/bin/google-chrome-stable:ro + # MeCab 관련 볼륨 마운트 + - /usr/lib/mecab:/usr/lib/mecab:ro + - /var/lib/mecab:/var/lib/mecab:ro + - /usr/lib/x86_64-linux-gnu/libmecab.so.2:/usr/lib/x86_64-linux-gnu/libmecab.so.2:ro + - /usr/lib/x86_64-linux-gnu/libmecab.so:/usr/lib/x86_64-linux-gnu/libmecab.so:ro + - /usr/lib/x86_64-linux-gnu/libmecab.so.2.0.0:/usr/lib/x86_64-linux-gnu/libmecab.so.2.0.0:ro depends_on: - promtail env_file: From 749a6c1c1117801944dfdb5ff83fb0289a5b3762 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Wed, 24 Sep 2025 17:52:14 +0900 Subject: [PATCH 21/25] =?UTF-8?q?fix=20:=20trace=5Fid=20MDC=EC=97=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/workflow/manager/ExecutionMdcManager.java | 7 +++++++ .../domain/workflow/service/WorkflowExecutionService.java | 7 ++++--- .../icebang/external/fastapi/adapter/FastApiAdapter.java | 8 ++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java index e61faa75..38c1ae38 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java @@ -7,6 +7,13 @@ public class ExecutionMdcManager { private static final String SOURCE_ID = "sourceId"; private static final String EXECUTION_TYPE = "executionType"; + private static final String TRACE_ID = "traceID"; + + public void setWorkflowContext(Long workflowId, String traceId) { + MDC.put(SOURCE_ID, workflowId.toString()); + MDC.put(EXECUTION_TYPE, "WORKFLOW"); + MDC.put(TRACE_ID, traceId); + } public void setWorkflowContext(Long workflowId) { MDC.put(SOURCE_ID, workflowId.toString()); 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..3421c043 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 @@ -48,11 +48,12 @@ public class WorkflowExecutionService { @Transactional @Async("traceExecutor") public void executeWorkflow(Long workflowId) { - mdcManager.setWorkflowContext(workflowId); + WorkflowRun workflowRun = WorkflowRun.start(workflowId); + workflowRunMapper.insert(workflowRun); + + mdcManager.setWorkflowContext(workflowId, workflowRun.getTraceId()); try { workflowLogger.info("========== 워크플로우 실행 시작: WorkflowId={} ==========", workflowId); - WorkflowRun workflowRun = WorkflowRun.start(workflowId); - workflowRunMapper.insert(workflowRun); Map workflowContext = new HashMap<>(); diff --git a/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java b/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java index 2a5bd001..334bb4e1 100644 --- a/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java +++ b/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java @@ -1,5 +1,6 @@ package site.icebang.external.fastapi.adapter; +import org.slf4j.MDC; import org.springframework.http.*; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClientException; @@ -23,6 +24,13 @@ public String call(String endpoint, HttpMethod method, String requestBody) { String fullUrl = properties.getUrl() + endpoint; HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + + String traceId = MDC.get("traceId"); + if (traceId != null) { + headers.set("X-Request-ID", traceId); + log.debug("TraceID 헤더 추가: {}", traceId); + } + HttpEntity requestEntity = new HttpEntity<>(requestBody, headers); try { From fc94d0da91f373798221629da98aa9b07d474288 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Thu, 25 Sep 2025 11:14:08 +0900 Subject: [PATCH 22/25] =?UTF-8?q?hofix=20:=20=EB=8F=84=EC=BB=A4=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95=20(=EB=A6=AC=EB=88=85?= =?UTF-8?q?=EC=8A=A4=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pre-processing-service/Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/pre-processing-service/Dockerfile b/apps/pre-processing-service/Dockerfile index b3be8746..73be952a 100644 --- a/apps/pre-processing-service/Dockerfile +++ b/apps/pre-processing-service/Dockerfile @@ -2,6 +2,11 @@ FROM python:3.11-slim AS builder WORKDIR /app +# 필요한 툴 설치 (curl, gcc 등) +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + # Poetry 설치 RUN curl -sSL https://install.python-poetry.org | python3 - ENV PATH="/root/.local/bin:$PATH" From 41d81933c688af01714c8200ae173daff3994d27 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Thu, 25 Sep 2025 11:36:25 +0900 Subject: [PATCH 23/25] =?UTF-8?q?hofix=20:=20=EB=8F=84=EC=BB=A4=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EC=A6=88=20=EC=88=98=EC=A0=95=20(=ED=81=AC?= =?UTF-8?q?=EB=A1=AC=EC=97=90=20=ED=95=84=EC=9A=94=ED=95=9C=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC?= =?UTF-8?q?=EB=93=A4=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=20=EC=84=A4=EC=B9=98=20->=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=20=EB=B3=80=EA=B2=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/production-fastapi/docker-compose.yml | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docker/production-fastapi/docker-compose.yml b/docker/production-fastapi/docker-compose.yml index c995f4ef..97b0ffb4 100644 --- a/docker/production-fastapi/docker-compose.yml +++ b/docker/production-fastapi/docker-compose.yml @@ -11,10 +11,39 @@ services: - ~/app/blogger:/app/blogger - ~/app/models:/app/models - logs_volume:/logs + # Chrome 관련 볼륨 마운트 - /opt/google/chrome:/opt/google/chrome:ro - /usr/bin/google-chrome:/usr/bin/google-chrome:ro - /usr/bin/google-chrome-stable:/usr/bin/google-chrome-stable:ro + + # Chrome 의존성 라이브러리들 - 개별적으로 마운트 + - /usr/lib/x86_64-linux-gnu/libglib-2.0.so.0:/usr/lib/x86_64-linux-gnu/libglib-2.0.so.0:ro + - /usr/lib/x86_64-linux-gnu/libgobject-2.0.so.0:/usr/lib/x86_64-linux-gnu/libgobject-2.0.so.0:ro + - /usr/lib/x86_64-linux-gnu/libnspr4.so:/usr/lib/x86_64-linux-gnu/libnspr4.so:ro + - /usr/lib/x86_64-linux-gnu/libnss3.so:/usr/lib/x86_64-linux-gnu/libnss3.so:ro + - /usr/lib/x86_64-linux-gnu/libnssutil3.so:/usr/lib/x86_64-linux-gnu/libnssutil3.so:ro + - /usr/lib/x86_64-linux-gnu/libsmime3.so:/usr/lib/x86_64-linux-gnu/libsmime3.so:ro + - /usr/lib/x86_64-linux-gnu/libgio-2.0.so.0:/usr/lib/x86_64-linux-gnu/libgio-2.0.so.0:ro + - /lib/x86_64-linux-gnu/libdbus-1.so.3:/lib/x86_64-linux-gnu/libdbus-1.so.3:ro + - /usr/lib/x86_64-linux-gnu/libatk-1.0.so.0:/usr/lib/x86_64-linux-gnu/libatk-1.0.so.0:ro + - /usr/lib/x86_64-linux-gnu/libatk-bridge-2.0.so.0:/usr/lib/x86_64-linux-gnu/libatk-bridge-2.0.so.0:ro + - /usr/lib/x86_64-linux-gnu/libcups.so.2:/usr/lib/x86_64-linux-gnu/libcups.so.2:ro + - /lib/x86_64-linux-gnu/libexpat.so.1:/lib/x86_64-linux-gnu/libexpat.so.1:ro + - /usr/lib/x86_64-linux-gnu/libxcb.so.1:/usr/lib/x86_64-linux-gnu/libxcb.so.1:ro + - /usr/lib/x86_64-linux-gnu/libxkbcommon.so.0:/usr/lib/x86_64-linux-gnu/libxkbcommon.so.0:ro + - /usr/lib/x86_64-linux-gnu/libatspi.so.0:/usr/lib/x86_64-linux-gnu/libatspi.so.0:ro + - /usr/lib/x86_64-linux-gnu/libX11.so.6:/usr/lib/x86_64-linux-gnu/libX11.so.6:ro + - /usr/lib/x86_64-linux-gnu/libXcomposite.so.1:/usr/lib/x86_64-linux-gnu/libXcomposite.so.1:ro + - /usr/lib/x86_64-linux-gnu/libXdamage.so.1:/usr/lib/x86_64-linux-gnu/libXdamage.so.1:ro + - /usr/lib/x86_64-linux-gnu/libXext.so.6:/usr/lib/x86_64-linux-gnu/libXext.so.6:ro + - /usr/lib/x86_64-linux-gnu/libXfixes.so.3:/usr/lib/x86_64-linux-gnu/libXfixes.so.3:ro + - /usr/lib/x86_64-linux-gnu/libXrandr.so.2:/usr/lib/x86_64-linux-gnu/libXrandr.so.2:ro + - /usr/lib/x86_64-linux-gnu/libgbm.so.1:/usr/lib/x86_64-linux-gnu/libgbm.so.1:ro + - /usr/lib/x86_64-linux-gnu/libcairo.so.2:/usr/lib/x86_64-linux-gnu/libcairo.so.2:ro + - /usr/lib/x86_64-linux-gnu/libpango-1.0.so.0:/usr/lib/x86_64-linux-gnu/libpango-1.0.so.0:ro + - /usr/lib/x86_64-linux-gnu/libasound.so.2:/usr/lib/x86_64-linux-gnu/libasound.so.2:ro + # MeCab 관련 볼륨 마운트 - /usr/lib/mecab:/usr/lib/mecab:ro - /var/lib/mecab:/var/lib/mecab:ro From 26cc0110e1dcc6a58fdb867ceb18126d7493d76e Mon Sep 17 00:00:00 2001 From: JiHoon Date: Thu, 25 Sep 2025 12:57:55 +0900 Subject: [PATCH 24/25] =?UTF-8?q?hofix=20:=20EC2=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EC=B0=A8=EC=9D=B4=20=EB=B0=8F=20=EC=95=8C=EC=88=98=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=98=A4=EB=A5=98=EB=A1=9C=20=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pre-processing-service/Dockerfile | 28 +++++++++++++- docker/production-fastapi/docker-compose.yml | 39 -------------------- 2 files changed, 26 insertions(+), 41 deletions(-) diff --git a/apps/pre-processing-service/Dockerfile b/apps/pre-processing-service/Dockerfile index 73be952a..6ecb09c8 100644 --- a/apps/pre-processing-service/Dockerfile +++ b/apps/pre-processing-service/Dockerfile @@ -2,10 +2,14 @@ FROM python:3.11-slim AS builder WORKDIR /app -# 필요한 툴 설치 (curl, gcc 등) +# 필수 OS 패키지 (기존 + Chrome 설치용 패키지 추가) RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ - && rm -rf /var/lib/apt/lists/* + wget \ + unzip \ + gnupg \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* # Poetry 설치 RUN curl -sSL https://install.python-poetry.org | python3 - @@ -25,10 +29,29 @@ RUN poetry export --without dev -f requirements.txt -o requirements.txt \ FROM python:3.11-slim AS final WORKDIR /app +# Chrome과 ChromeDriver 설치를 위한 패키지 설치 RUN apt-get update && apt-get install -y --no-install-recommends \ + wget \ + unzip \ + curl \ + gnupg \ ca-certificates \ && 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" @@ -36,6 +59,7 @@ ENV PATH="/opt/venv/bin:$PATH" # 앱 소스 COPY . . + # 환경변수로 MeCab 경로 지정 ENV MECAB_PATH=/usr/lib/mecab/dic/ipadic diff --git a/docker/production-fastapi/docker-compose.yml b/docker/production-fastapi/docker-compose.yml index 97b0ffb4..76b0b85c 100644 --- a/docker/production-fastapi/docker-compose.yml +++ b/docker/production-fastapi/docker-compose.yml @@ -11,45 +11,6 @@ services: - ~/app/blogger:/app/blogger - ~/app/models:/app/models - logs_volume:/logs - - # Chrome 관련 볼륨 마운트 - - /opt/google/chrome:/opt/google/chrome:ro - - /usr/bin/google-chrome:/usr/bin/google-chrome:ro - - /usr/bin/google-chrome-stable:/usr/bin/google-chrome-stable:ro - - # Chrome 의존성 라이브러리들 - 개별적으로 마운트 - - /usr/lib/x86_64-linux-gnu/libglib-2.0.so.0:/usr/lib/x86_64-linux-gnu/libglib-2.0.so.0:ro - - /usr/lib/x86_64-linux-gnu/libgobject-2.0.so.0:/usr/lib/x86_64-linux-gnu/libgobject-2.0.so.0:ro - - /usr/lib/x86_64-linux-gnu/libnspr4.so:/usr/lib/x86_64-linux-gnu/libnspr4.so:ro - - /usr/lib/x86_64-linux-gnu/libnss3.so:/usr/lib/x86_64-linux-gnu/libnss3.so:ro - - /usr/lib/x86_64-linux-gnu/libnssutil3.so:/usr/lib/x86_64-linux-gnu/libnssutil3.so:ro - - /usr/lib/x86_64-linux-gnu/libsmime3.so:/usr/lib/x86_64-linux-gnu/libsmime3.so:ro - - /usr/lib/x86_64-linux-gnu/libgio-2.0.so.0:/usr/lib/x86_64-linux-gnu/libgio-2.0.so.0:ro - - /lib/x86_64-linux-gnu/libdbus-1.so.3:/lib/x86_64-linux-gnu/libdbus-1.so.3:ro - - /usr/lib/x86_64-linux-gnu/libatk-1.0.so.0:/usr/lib/x86_64-linux-gnu/libatk-1.0.so.0:ro - - /usr/lib/x86_64-linux-gnu/libatk-bridge-2.0.so.0:/usr/lib/x86_64-linux-gnu/libatk-bridge-2.0.so.0:ro - - /usr/lib/x86_64-linux-gnu/libcups.so.2:/usr/lib/x86_64-linux-gnu/libcups.so.2:ro - - /lib/x86_64-linux-gnu/libexpat.so.1:/lib/x86_64-linux-gnu/libexpat.so.1:ro - - /usr/lib/x86_64-linux-gnu/libxcb.so.1:/usr/lib/x86_64-linux-gnu/libxcb.so.1:ro - - /usr/lib/x86_64-linux-gnu/libxkbcommon.so.0:/usr/lib/x86_64-linux-gnu/libxkbcommon.so.0:ro - - /usr/lib/x86_64-linux-gnu/libatspi.so.0:/usr/lib/x86_64-linux-gnu/libatspi.so.0:ro - - /usr/lib/x86_64-linux-gnu/libX11.so.6:/usr/lib/x86_64-linux-gnu/libX11.so.6:ro - - /usr/lib/x86_64-linux-gnu/libXcomposite.so.1:/usr/lib/x86_64-linux-gnu/libXcomposite.so.1:ro - - /usr/lib/x86_64-linux-gnu/libXdamage.so.1:/usr/lib/x86_64-linux-gnu/libXdamage.so.1:ro - - /usr/lib/x86_64-linux-gnu/libXext.so.6:/usr/lib/x86_64-linux-gnu/libXext.so.6:ro - - /usr/lib/x86_64-linux-gnu/libXfixes.so.3:/usr/lib/x86_64-linux-gnu/libXfixes.so.3:ro - - /usr/lib/x86_64-linux-gnu/libXrandr.so.2:/usr/lib/x86_64-linux-gnu/libXrandr.so.2:ro - - /usr/lib/x86_64-linux-gnu/libgbm.so.1:/usr/lib/x86_64-linux-gnu/libgbm.so.1:ro - - /usr/lib/x86_64-linux-gnu/libcairo.so.2:/usr/lib/x86_64-linux-gnu/libcairo.so.2:ro - - /usr/lib/x86_64-linux-gnu/libpango-1.0.so.0:/usr/lib/x86_64-linux-gnu/libpango-1.0.so.0:ro - - /usr/lib/x86_64-linux-gnu/libasound.so.2:/usr/lib/x86_64-linux-gnu/libasound.so.2:ro - - # MeCab 관련 볼륨 마운트 - - /usr/lib/mecab:/usr/lib/mecab:ro - - /var/lib/mecab:/var/lib/mecab:ro - - /usr/lib/x86_64-linux-gnu/libmecab.so.2:/usr/lib/x86_64-linux-gnu/libmecab.so.2:ro - - /usr/lib/x86_64-linux-gnu/libmecab.so:/usr/lib/x86_64-linux-gnu/libmecab.so:ro - - /usr/lib/x86_64-linux-gnu/libmecab.so.2.0.0:/usr/lib/x86_64-linux-gnu/libmecab.so.2.0.0:ro depends_on: - promtail env_file: From 673f81957fa808c1dab35f35d4945c06bc217b39 Mon Sep 17 00:00:00 2001 From: Yousung Jung Date: Thu, 25 Sep 2025 13:05:19 +0900 Subject: [PATCH 25/25] =?UTF-8?q?E2e=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20cooki?= =?UTF-8?q?e=20interceptor=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20(#202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: e2e test 시 각 요청마다 cookie 유지 * test: 로그인이 진행되는 테스트에 cookie support 추가 --- .../e2e/scenario/UserLogoutFlowE2eTest.java | 158 +++++++++++++----- .../scenario/UserRegistrationFlowE2eTest.java | 10 +- .../e2e/setup/support/E2eTestSupport.java | 80 +++++++++ 3 files changed, 205 insertions(+), 43 deletions(-) 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 81b007ca..67e6820a 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 @@ -23,8 +23,8 @@ class UserLogoutFlowE2eTest extends E2eTestSupport { @SuppressWarnings("unchecked") @Disabled - @DisplayName("정상 로그아웃 전체 플로우 - TDD REd 단계") - void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Exception { + @DisplayName("정상 로그아웃 전체 플로우 - TDD Red 단계") + void completeUserLogoutFlow_shouldFailBecauseApiNotImplemented() throws Exception { logStep(1, "관리자 로그인 (최우선)"); // 1. 관리자 로그인으로 인증 상태 확립 @@ -45,33 +45,16 @@ void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Ex assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) loginResponse.getBody().get("success")).isTrue(); - logSuccess("관리자 로그인 성공 - 인증 상태 확립 완료"); + logSuccess("관리자 로그인 성공 - 세션 쿠키 자동 저장됨"); + logDebug("현재 세션 쿠키: " + getSessionCookies()); logStep(2, "로그인 상태에서 보호된 리소스 접근 확인"); - // 로그인 응답에서 세션 쿠키 추출 - String sessionCookie = null; - java.util.List cookies = loginResponse.getHeaders().get("Set-Cookie"); - if (cookies != null) { - for (String cookie : cookies) { - if (cookie.startsWith("JSESSIONID")) { - sessionCookie = cookie.split(";")[0]; // JSESSIONID=XXX 부분만 추출 - break; - } - } - } - // 2. 로그인된 상태에서 본인 프로필 조회로 인증 상태 확인 // /v0/users/me는 인증된 사용자만 접근 가능한 일반적인 API - HttpHeaders authenticatedHeaders = new HttpHeaders(); - if (sessionCookie != null) { - authenticatedHeaders.set("Cookie", sessionCookie); - } - - HttpEntity authenticatedEntity = new HttpEntity<>(authenticatedHeaders); + // 쿠키는 인터셉터에 의해 자동으로 전송됨 ResponseEntity beforeLogoutResponse = - restTemplate.exchange( - getV0ApiUrl("/users/me"), HttpMethod.GET, authenticatedEntity, Map.class); + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); assertThat(beforeLogoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) beforeLogoutResponse.getBody().get("success")).isTrue(); @@ -79,51 +62,64 @@ void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Ex logSuccess("인증된 상태에서 본인 프로필 조회 성공"); - // 3. 로그아웃 API 호출 + logStep(3, "로그아웃 API 호출"); + + // 3. 로그아웃 API 호출 (세션 쿠키는 인터셉터가 자동 처리) HttpHeaders logoutHeaders = new HttpHeaders(); logoutHeaders.setContentType(MediaType.APPLICATION_JSON); logoutHeaders.set("Origin", "https://admin.icebang.site"); logoutHeaders.set("Referer", "https://admin.icebang.site/"); - // 로그아웃 요청에도 세션 쿠키 포함 - if (sessionCookie != null) { - logoutHeaders.set("Cookie", sessionCookie); - } - HttpEntity> logoutEntity = new HttpEntity<>(new HashMap<>(), logoutHeaders); try { ResponseEntity logoutResponse = restTemplate.postForEntity(getV0ApiUrl("/auth/logout"), logoutEntity, Map.class); - logStep(4, "로그아웃 응답 검증 (API구현 돼있으면)"); + + logStep(4, "로그아웃 응답 검증"); + assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat((Boolean) logoutResponse.getBody().get("success")).isTrue(); logSuccess("로그아웃 API 호출 성공"); logStep(5, "로그아웃 후 인증 무효화 확인"); + // 로그아웃 후 세션 쿠키 상태 확인 + logDebug("로그아웃 후 세션 쿠키: " + getSessionCookies()); + // 5. 로그아웃 후 동일한 프로필 API 접근 시 인증 실패 확인 - HttpEntity afterLogoutEntity = new HttpEntity<>(authenticatedHeaders); ResponseEntity afterLogoutResponse = - restTemplate.exchange( - getV0ApiUrl("/users/me"), HttpMethod.GET, afterLogoutEntity, Map.class); + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); // 핵심 검증: 로그아웃 후에는 인증 실패로 401 또는 403 응답이어야 함 assertThat(afterLogoutResponse.getStatusCode()) .withFailMessage( "로그아웃 후 프로필 접근이 차단되어야 합니다. 현재 상태코드: %s", afterLogoutResponse.getStatusCode()) .isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); - logSuccess("로그아웃 후 프로필 접근 차단 확인 - 인증 무효화 성공"); - logCompletion("일반 사용자 로그아웃 플로우"); + logSuccess("로그아웃 후 프로필 접근 차단 확인 - 인증 무효화 성공"); + logCompletion("관리자 로그아웃 플로우"); } catch (org.springframework.web.client.HttpClientErrorException.NotFound ex) { - logError("예상된 실패: 로그아웃 API가 구현되지 않음 (404 Not Found"); - logError("에러 메시지 : " + ex.getMessage()); + logError("예상된 실패: 로그아웃 API가 구현되지 않음 (404 Not Found)"); + logError("에러 메시지: " + ex.getMessage()); + logError("TDD Red 단계 - API 구현 필요"); fail( "로그아웃 API (/v0/auth/logout)가 구현되지 않았습니다. " + "다음 단계에서 API를 구현해야 합니다. 에러: " + ex.getMessage()); + + } catch (org.springframework.web.client.HttpClientErrorException ex) { + logError("HTTP 클라이언트 에러: " + ex.getStatusCode() + " - " + ex.getMessage()); + + if (ex.getStatusCode() == HttpStatus.METHOD_NOT_ALLOWED) { + logError("로그아웃 엔드포인트는 존재하지만 POST 메서드를 지원하지 않습니다."); + fail("로그아웃 API가 POST 메서드를 지원하지 않습니다. 구현을 확인해주세요."); + } else { + fail("로그아웃 API 호출 중 HTTP 에러 발생: " + ex.getStatusCode() + " - " + ex.getMessage()); + } + } catch (Exception ex) { logError("예상치 못한 오류 발생: " + ex.getClass().getSimpleName()); logError("에러 메시지: " + ex.getMessage()); @@ -133,7 +129,63 @@ void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Ex } } - /** 일반 사용자 로그인을 수행하는 헬퍼 메서드 관리자가 아닌 콘텐츠팀장으로 로그인 */ + @SuppressWarnings("unchecked") + @DisplayName("일반 사용자 로그아웃 플로우 테스트") + void regularUserLogoutFlow() throws Exception { + logStep(1, "일반 사용자 로그인"); + + // 세션 쿠키 초기화 + clearSessionCookies(); + + // 일반 사용자 로그인 수행 + performRegularUserLogin(); + + logStep(2, "일반 사용자 권한으로 프로필 조회"); + + // 로그인된 상태에서 프로필 조회 + ResponseEntity beforeLogoutResponse = + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); + + assertThat(beforeLogoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat((Boolean) beforeLogoutResponse.getBody().get("success")).isTrue(); + + logSuccess("일반 사용자 프로필 조회 성공"); + + logStep(3, "일반 사용자 로그아웃 시도"); + + try { + HttpHeaders logoutHeaders = new HttpHeaders(); + logoutHeaders.setContentType(MediaType.APPLICATION_JSON); + logoutHeaders.set("Origin", "https://admin.icebang.site"); + logoutHeaders.set("Referer", "https://admin.icebang.site/"); + + HttpEntity> logoutEntity = + new HttpEntity<>(new HashMap<>(), logoutHeaders); + + ResponseEntity logoutResponse = + restTemplate.postForEntity(getV0ApiUrl("/auth/logout"), logoutEntity, Map.class); + + assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + logSuccess("일반 사용자 로그아웃 성공"); + + logStep(4, "로그아웃 후 접근 권한 무효화 확인"); + + ResponseEntity afterLogoutResponse = + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); + + assertThat(afterLogoutResponse.getStatusCode()) + .isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); + + logSuccess("일반 사용자 로그아웃 후 접근 차단 확인"); + logCompletion("일반 사용자 로그아웃 플로우"); + + } catch (org.springframework.web.client.HttpClientErrorException.NotFound ex) { + logError("예상된 실패: 로그아웃 API 미구현"); + fail("로그아웃 API가 구현되지 않았습니다: " + ex.getMessage()); + } + } + + /** 일반 사용자 로그인을 수행하는 헬퍼 메서드 - 관리자가 아닌 콘텐츠팀장으로 로그인 */ private void performRegularUserLogin() { Map loginRequest = new HashMap<>(); loginRequest.put("email", "viral.jung@icebang.site"); @@ -154,6 +206,34 @@ private void performRegularUserLogin() { throw new RuntimeException("Regular user login failed for logout test"); } - logSuccess("일반 사용자 로그인 완료 (로그아웃 테스트용)"); + logSuccess("일반 사용자 로그인 완료 - 세션 쿠키 저장됨"); + logDebug("일반 사용자 세션 쿠키: " + getSessionCookies()); + } + + /** 관리자 로그인을 수행하는 헬퍼 메서드 */ + private void performAdminLogin() { + clearSessionCookies(); // 기존 세션 정리 + + 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("Admin login failed"); + } + + logSuccess("관리자 로그인 완료 - 세션 쿠키 저장됨"); + logDebug("관리자 세션 쿠키: " + getSessionCookies()); } } 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 df66a7c6..1bc1903b 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 @@ -44,11 +44,12 @@ void completeUserRegistrationFlow() throws Exception { assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) loginResponse.getBody().get("success")).isTrue(); - logSuccess("관리자 로그인 성공 - 이제 모든 리소스 접근 가능"); + logSuccess("관리자 로그인 성공 - 세션 쿠키 자동 저장됨"); + logDebug("현재 세션 쿠키: " + getSessionCookies()); logStep(2, "조직 목록 조회 (인증된 상태)"); - // 2. 조직 목록 조회 (로그인 후 가능) + // 2. 조직 목록 조회 (로그인 후 가능, 쿠키 자동 전송) ResponseEntity organizationsResponse = restTemplate.getForEntity(getV0ApiUrl("/organizations"), Map.class); @@ -56,7 +57,7 @@ void completeUserRegistrationFlow() throws Exception { assertThat((Boolean) organizationsResponse.getBody().get("success")).isTrue(); assertThat(organizationsResponse.getBody().get("data")).isNotNull(); - logSuccess("조직 목록 조회 성공"); + logSuccess("조직 목록 조회 성공 (인증된 요청)"); logStep(3, "부서 및 각종 데이터 조회 (특정 조직 옵션)"); @@ -229,7 +230,8 @@ private void performAdminLogin() { throw new RuntimeException("Admin login failed"); } - logSuccess("관리자 로그인 완료"); + logSuccess("관리자 로그인 완료 - 세션 쿠키 저장됨"); + logDebug("세션 쿠키: " + getSessionCookies()); } /** 사용자 등록을 수행하는 헬퍼 메서드 */ 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 c2d10870..97d1cf0d 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 @@ -1,14 +1,21 @@ package site.icebang.e2e.setup.support; +import java.util.ArrayList; +import java.util.List; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.context.annotation.Import; +import org.springframework.http.client.ClientHttpRequestInterceptor; +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 jakarta.annotation.PostConstruct; + import site.icebang.e2e.setup.annotation.E2eTest; import site.icebang.e2e.setup.config.E2eTestConfiguration; @@ -26,6 +33,53 @@ public abstract class E2eTestSupport { protected MockMvc mockMvc; + private List sessionCookies = new ArrayList<>(); + + @PostConstruct + void setupCookieManagement() { + // RestTemplate에 쿠키 인터셉터 추가 + restTemplate.getRestTemplate().getInterceptors().add(createCookieInterceptor()); + logDebug("쿠키 관리 인터셉터 설정 완료"); + } + + private ClientHttpRequestInterceptor createCookieInterceptor() { + return (request, body, execution) -> { + // 요청에 저장된 쿠키 추가 + if (!sessionCookies.isEmpty()) { + request.getHeaders().put("Cookie", sessionCookies); + logDebug("쿠키 전송: " + String.join("; ", sessionCookies)); + } + + // 요청 실행 + ClientHttpResponse response = execution.execute(request, body); + + // 응답에서 Set-Cookie 헤더 추출하여 저장 + List setCookieHeaders = response.getHeaders().get("Set-Cookie"); + if (setCookieHeaders != null && !setCookieHeaders.isEmpty()) { + updateSessionCookies(setCookieHeaders); + logDebug("세션 쿠키 업데이트: " + String.join("; ", sessionCookies)); + } + + return response; + }; + } + + private void updateSessionCookies(List setCookieHeaders) { + for (String setCookie : setCookieHeaders) { + // 쿠키 이름 추출 + String cookieName = setCookie.split("=")[0]; + String cookieValue = setCookie.split(";")[0]; // 쿠키 값만 추출 (속성 제외) + + // 같은 이름의 쿠키가 있으면 제거 + sessionCookies.removeIf(cookie -> cookie.startsWith(cookieName + "=")); + + // 새 쿠키 추가 (빈 값이 아닌 경우만) + if (!cookieValue.endsWith("=")) { + sessionCookies.add(cookieValue); + } + } + } + protected String getBaseUrl() { return "http://localhost:" + port; } @@ -38,6 +92,20 @@ protected String getV0ApiUrl(String path) { return getBaseUrl() + "/v0" + path; } + /** 세션 쿠키 관리 메서드들 */ + protected void clearSessionCookies() { + sessionCookies.clear(); + logDebug("세션 쿠키 초기화됨"); + } + + protected List getSessionCookies() { + return new ArrayList<>(sessionCookies); + } + + protected boolean hasSessionCookie(String cookieName) { + return sessionCookies.stream().anyMatch(cookie -> cookie.startsWith(cookieName + "=")); + } + /** 테스트 시나리오 단계별 로깅을 위한 유틸리티 메서드 */ protected void logStep(int stepNumber, String description) { System.out.println(String.format("📋 Step %d: %s", stepNumber, description)); @@ -57,4 +125,16 @@ protected void logError(String message) { protected void logCompletion(String scenario) { System.out.println(String.format("🎉 %s 시나리오 완료!", scenario)); } + + /** 디버그 로깅을 위한 유틸리티 메서드 */ + protected void logDebug(String message) { + if (isDebugEnabled()) { + System.out.println("🐛 DEBUG: " + message); + } + } + + private boolean isDebugEnabled() { + return System.getProperty("test.debug", "false").equals("true") + || System.getProperty("e2e.debug", "false").equals("true"); + } }