diff --git a/apps/user-service/src/main/java/site/icebang/domain/log/mapper/ExecutionLogMapper.java b/apps/user-service/src/main/java/site/icebang/domain/log/mapper/ExecutionLogMapper.java new file mode 100644 index 00000000..772c47e2 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/log/mapper/ExecutionLogMapper.java @@ -0,0 +1,13 @@ +package site.icebang.domain.log.mapper; + +import java.util.List; + +import org.apache.ibatis.annotations.Mapper; + +import site.icebang.domain.workflow.dto.ExecutionLogDto; +import site.icebang.domain.workflow.dto.log.WorkflowLogQueryCriteria; + +@Mapper +public interface ExecutionLogMapper { + List selectLogsByCriteria(WorkflowLogQueryCriteria criteria); +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/log/service/ExecutionLogService.java b/apps/user-service/src/main/java/site/icebang/domain/log/service/ExecutionLogService.java new file mode 100644 index 00000000..7cd9a820 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/log/service/ExecutionLogService.java @@ -0,0 +1,21 @@ +package site.icebang.domain.log.service; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import site.icebang.domain.log.mapper.ExecutionLogMapper; +import site.icebang.domain.workflow.dto.ExecutionLogDto; +import site.icebang.domain.workflow.dto.log.WorkflowLogQueryCriteria; + +@Service +@RequiredArgsConstructor +public class ExecutionLogService { + private final ExecutionLogMapper executionLogMapper; + + public List getRawLogs(WorkflowLogQueryCriteria criteria) { + return executionLogMapper.selectLogsByCriteria(criteria); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowHistoryController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowHistoryController.java index 07d4f20e..0f8535cf 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowHistoryController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowHistoryController.java @@ -1,14 +1,20 @@ package site.icebang.domain.workflow.controller; +import java.util.List; + import org.springframework.web.bind.annotation.*; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import site.icebang.common.dto.ApiResponse; import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; +import site.icebang.domain.log.service.ExecutionLogService; import site.icebang.domain.workflow.dto.WorkflowHistoryDTO; import site.icebang.domain.workflow.dto.WorkflowRunDetailResponse; +import site.icebang.domain.workflow.dto.log.ExecutionLogSimpleDto; +import site.icebang.domain.workflow.dto.log.WorkflowLogQueryCriteria; import site.icebang.domain.workflow.service.WorkflowHistoryService; @RestController @@ -16,6 +22,7 @@ @RequiredArgsConstructor public class WorkflowHistoryController { private final WorkflowHistoryService workflowHistoryService; + private final ExecutionLogService executionLogService; @GetMapping("") public ApiResponse> getWorkflowHistoryList( @@ -35,4 +42,11 @@ public ApiResponse getWorkflowRunDetail(@PathVariable WorkflowRunDetailResponse response = workflowHistoryService.getWorkflowRunDetail(runId); return ApiResponse.success(response); } + + @GetMapping("/logs") + public ApiResponse> getTaskExecutionLog( + @Valid @ModelAttribute WorkflowLogQueryCriteria requestDto) { + return ApiResponse.success( + ExecutionLogSimpleDto.from(executionLogService.getRawLogs(requestDto))); + } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java index 7c8595a3..cbe6b2f7 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java @@ -21,4 +21,6 @@ public class ExecutionLogDto { private String logMessage; private Instant executedAt; private Integer durationMs; + private String traceId; + private String errorCode; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/ExecutionLogSimpleDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/ExecutionLogSimpleDto.java new file mode 100644 index 00000000..152de8e4 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/ExecutionLogSimpleDto.java @@ -0,0 +1,34 @@ +package site.icebang.domain.workflow.dto.log; + +import java.time.Instant; +import java.util.List; +import java.util.stream.Collectors; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import site.icebang.domain.workflow.dto.ExecutionLogDto; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExecutionLogSimpleDto { + private String logLevel; + private String logMessage; + private Instant executedAt; + + public static ExecutionLogSimpleDto from(ExecutionLogDto executionLogDto) { + return ExecutionLogSimpleDto.builder() + .logLevel(executionLogDto.getLogLevel()) + .logMessage(executionLogDto.getLogMessage()) + .executedAt(executionLogDto.getExecutedAt()) + .build(); + } + + public static List from(List executionLogList) { + return executionLogList.stream().map(ExecutionLogSimpleDto::from).collect(Collectors.toList()); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/ExecutionType.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/ExecutionType.java new file mode 100644 index 00000000..e7dbd659 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/ExecutionType.java @@ -0,0 +1,7 @@ +package site.icebang.domain.workflow.dto.log; + +public enum ExecutionType { + WORKFLOW, + JOB, + TASK +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/TaskExecutionMessagesDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/TaskExecutionMessagesDto.java new file mode 100644 index 00000000..4f51f07d --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/TaskExecutionMessagesDto.java @@ -0,0 +1,15 @@ +package site.icebang.domain.workflow.dto.log; + +import java.util.List; +import java.util.stream.Collectors; + +import site.icebang.domain.workflow.dto.ExecutionLogDto; + +public record TaskExecutionMessagesDto(List messages) { + public static TaskExecutionMessagesDto from(List executionLogList) { + List messages = + executionLogList.stream().map(ExecutionLogDto::getLogMessage).collect(Collectors.toList()); + + return new TaskExecutionMessagesDto(messages); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/WorkflowLogQueryCriteria.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/WorkflowLogQueryCriteria.java new file mode 100644 index 00000000..f2c2ed06 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/WorkflowLogQueryCriteria.java @@ -0,0 +1,17 @@ +package site.icebang.domain.workflow.dto.log; + +import java.math.BigInteger; + +import jakarta.validation.constraints.Pattern; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class WorkflowLogQueryCriteria { + private final String traceId; + private final BigInteger sourceId; + + @Pattern(regexp = "^(WORKFLOW|JOB|TASK)$", message = "실행 타입은 WORKFLOW, JOB, TASK 중 하나여야 합니다") + private final String executionType; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java index 5741e77b..1c3a0796 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java @@ -3,6 +3,8 @@ import java.time.Instant; import java.util.UUID; +import org.slf4j.MDC; + import lombok.Getter; import lombok.NoArgsConstructor; @@ -20,7 +22,8 @@ public class WorkflowRun { private WorkflowRun(Long workflowId) { this.workflowId = workflowId; - this.traceId = UUID.randomUUID().toString(); // 고유 추적 ID 생성 + // MDC에서 현재 요청의 traceId를 가져오거나, 없으면 새로 생성 + this.traceId = MDC.get("traceId") != null ? MDC.get("traceId") : UUID.randomUUID().toString(); this.status = "RUNNING"; this.startedAt = Instant.now(); this.createdAt = this.startedAt; diff --git a/apps/user-service/src/main/resources/mybatis/mapper/ExecutionLogMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/ExecutionLogMapper.xml new file mode 100644 index 00000000..4c1ff830 --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/ExecutionLogMapper.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/data/06-insert-execution-log-h2.sql b/apps/user-service/src/main/resources/sql/data/06-insert-execution-log-h2.sql new file mode 100644 index 00000000..8dac68c8 --- /dev/null +++ b/apps/user-service/src/main/resources/sql/data/06-insert-execution-log-h2.sql @@ -0,0 +1,38 @@ +-- execution_log 테스트 데이터 (H2용) +INSERT INTO execution_log (execution_type, source_id, log_level, executed_at, log_message, trace_id, run_id, status, duration_ms, error_code) VALUES +('WORKFLOW', 1, 'INFO', '2025-09-26 12:42:02.000', '========== 워크플로우 실행 시작: WorkflowId=1 ==========', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('WORKFLOW', 1, 'INFO', '2025-09-26 12:42:02.000', '총 2개의 Job을 순차적으로 실행합니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('JOB', 1, 'INFO', '2025-09-26 12:42:02.000', '---------- Job 실행 시작: JobId=1, JobRunId=1 ----------', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('JOB', 1, 'INFO', '2025-09-26 12:42:02.000', 'Job (JobRunId=1) 내 총 7개의 Task를 순차 실행합니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 1, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시작: TaskId=1, Name=키워드 검색 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 1, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시도 #1: TaskId=1, TaskRunId=1', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 1, 'ERROR', '2025-09-26 12:42:02.000', 'Task 최종 실패: TaskRunId=1, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 2, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시작: TaskId=2, Name=상품 검색 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 2, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시도 #1: TaskId=2, TaskRunId=2', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 2, 'ERROR', '2025-09-26 12:42:02.000', 'Task 최종 실패: TaskRunId=2, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 3, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시작: TaskId=3, Name=상품 매칭 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 3, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시도 #1: TaskId=3, TaskRunId=3', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 3, 'ERROR', '2025-09-26 12:42:02.000', 'Task 최종 실패: TaskRunId=3, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 4, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시작: TaskId=4, Name=상품 유사도 분석 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 4, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시도 #1: TaskId=4, TaskRunId=4', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 4, 'ERROR', '2025-09-26 12:42:02.000', 'Task 최종 실패: TaskRunId=4, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 5, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시작: TaskId=5, Name=상품 정보 크롤링 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 5, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시도 #1: TaskId=5, TaskRunId=5', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 5, 'ERROR', '2025-09-26 12:42:02.000', 'Task 최종 실패: TaskRunId=5, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 6, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시작: TaskId=6, Name=S3 업로드 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 6, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시도 #1: TaskId=6, TaskRunId=6', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 6, 'ERROR', '2025-09-26 12:42:02.000', 'Task 최종 실패: TaskRunId=6, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 7, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시작: TaskId=7, Name=상품 선택 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 7, 'INFO', '2025-09-26 12:42:02.000', 'Task 실행 시도 #1: TaskId=7, TaskRunId=7', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 7, 'ERROR', '2025-09-26 12:42:03.000', 'Task 최종 실패: TaskRunId=7, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('JOB', 1, 'ERROR', '2025-09-26 12:42:03.000', 'Job 실행 실패: JobRunId=1', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('JOB', 2, 'INFO', '2025-09-26 12:42:03.000', '---------- Job 실행 시작: JobId=2, JobRunId=2 ----------', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('JOB', 2, 'INFO', '2025-09-26 12:42:03.000', 'Job (JobRunId=2) 내 총 2개의 Task를 순차 실행합니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 8, 'INFO', '2025-09-26 12:42:03.000', 'Task 실행 시작: TaskId=8, Name=블로그 RAG 생성 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 8, 'INFO', '2025-09-26 12:42:03.000', 'Task 실행 시도 #1: TaskId=8, TaskRunId=8', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 8, 'ERROR', '2025-09-26 12:42:03.000', 'Task 최종 실패: TaskRunId=8, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 9, 'INFO', '2025-09-26 12:42:03.000', 'Task 실행 시작: TaskId=9, Name=블로그 발행 태스크', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 9, 'INFO', '2025-09-26 12:42:03.000', 'Task 실행 시도 #1: TaskId=9, TaskRunId=9', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('TASK', 9, 'ERROR', '2025-09-26 12:42:03.000', 'Task 최종 실패: TaskRunId=9, Message=FastApiAdapter 호출에 실패했습니다.', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('JOB', 2, 'ERROR', '2025-09-26 12:42:03.000', 'Job 실행 실패: JobRunId=2', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL), +('WORKFLOW', 1, 'INFO', '2025-09-26 12:42:03.000', '========== 워크플로우 실행 실패 : WorkflowRunId=1 ==========', '68d60b8a2f4cd59a880cf71f189b4ca5', NULL, NULL, NULL, NULL); \ No newline at end of file diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/ExecutionLogApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/ExecutionLogApiIntegrationTest.java new file mode 100644 index 00000000..39203494 --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/ExecutionLogApiIntegrationTest.java @@ -0,0 +1,201 @@ +package site.icebang.integration.tests.workflow; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; + +import site.icebang.integration.setup.support.IntegrationTestSupport; + +@Sql( + value = { + "classpath:sql/data/01-insert-internal-users.sql", + "classpath:sql/data/06-insert-execution-log-h2.sql" + }, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Transactional +public class ExecutionLogApiIntegrationTest extends IntegrationTestSupport { + + @Test + @DisplayName("실행 로그 조회 성공 - 전체 조회") + @WithUserDetails("admin@icebang.site") + void getTaskExecutionLog_success() throws Exception { + // when & then + mockMvc + .perform( + get(getApiUrlForDocs("/v0/workflow-runs/logs")) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(36)) + // 첫 번째 로그 검증 + .andExpect(jsonPath("$.data[0].logLevel").exists()) + .andExpect(jsonPath("$.data[0].logMessage").exists()) + .andExpect(jsonPath("$.data[0].executedAt").exists()) + .andDo( + document( + "execution-log-all", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Workflow History") + .summary("실행 로그 전체 조회") + .description("워크플로우 실행 로그를 상세 정보와 함께 조회합니다") + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.ARRAY).description("실행 로그 목록"), + fieldWithPath("data[].logLevel") + .type(JsonFieldType.STRING) + .description("로그 레벨 (INFO, ERROR, WARN, DEBUG)"), + fieldWithPath("data[].logMessage") + .type(JsonFieldType.STRING) + .description("로그 메시지"), + fieldWithPath("data[].executedAt") + .type(JsonFieldType.STRING) + .description("실행 시간 (UTC ISO-8601)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("실행 로그 조회 성공 - traceId 필터링") + @WithUserDetails("admin@icebang.site") + void getTaskExecutionLog_withTraceId_success() throws Exception { + // when & then + mockMvc + .perform( + get(getApiUrlForDocs("/v0/workflow-runs/logs")) + .param("traceId", "68d60b8a2f4cd59a880cf71f189b4ca5") + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(36)) + .andDo( + document( + "execution-log-by-trace-id", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Workflow History") + .summary("실행 로그 조회 - traceId 필터") + .description("특정 traceId로 워크플로우 실행 로그를 필터링하여 조회합니다") + .queryParameters( + parameterWithName("traceId").description("추적 ID").optional()) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.ARRAY).description("실행 로그 목록"), + fieldWithPath("data[].logLevel") + .type(JsonFieldType.STRING) + .description("로그 레벨 (INFO, ERROR, WARN, DEBUG)"), + fieldWithPath("data[].logMessage") + .type(JsonFieldType.STRING) + .description("로그 메시지"), + fieldWithPath("data[].executedAt") + .type(JsonFieldType.STRING) + .description("실행 시간 (UTC ISO-8601)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("실행 로그 조회 성공 - executionType 필터링") + @WithUserDetails("admin@icebang.site") + void getTaskExecutionLog_withExecutionType_success() throws Exception { + // when & then + mockMvc + .perform( + get(getApiUrlForDocs("/v0/workflow-runs/logs")) + .param("executionType", "TASK") + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(27)) // TASK 타입 로그만 + .andDo( + document( + "execution-log-by-execution-type", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Workflow History") + .summary("실행 로그 조회 - executionType 필터") + .description("특정 executionType으로 워크플로우 실행 로그를 필터링하여 조회합니다") + .queryParameters( + parameterWithName("executionType") + .description("실행 타입 (WORKFLOW, JOB, TASK)") + .optional()) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.ARRAY).description("실행 로그 목록"), + fieldWithPath("data[].logLevel") + .type(JsonFieldType.STRING) + .description("로그 레벨 (INFO, ERROR, WARN, DEBUG)"), + fieldWithPath("data[].logMessage") + .type(JsonFieldType.STRING) + .description("로그 메시지"), + fieldWithPath("data[].executedAt") + .type(JsonFieldType.STRING) + .description("실행 시간 (UTC ISO-8601)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("실행 로그 조회 실패 - 잘못된 executionType") + @WithUserDetails("admin@icebang.site") + void getTaskExecutionLog_withInvalidExecutionType_fail() throws Exception { + // when & then + mockMvc + .perform( + get("/v0/workflow-runs/logs") + .param("executionType", "INVALID_TYPE") + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } +}